//! SSH key management API endpoints. use axum::extract::{Path, State}; use axum::http::{HeaderMap, StatusCode}; use axum::response::{IntoResponse, Response}; use axum::{Form, Json}; use serde::{Deserialize, Serialize}; use crate::auth::AuthUser; use crate::db::{self, SshKeyId}; use crate::error::{AppError, Result}; use crate::helpers::{hx_toast, is_htmx_request}; use crate::validation; use crate::AppState; #[derive(Debug, Deserialize)] pub struct AddKeyRequest { pub public_key: String, #[serde(default)] pub label: String, } #[derive(Debug, Serialize)] pub struct SshKeyResponse { pub id: SshKeyId, pub fingerprint: String, pub label: String, pub created_at: String, } /// GET /api/users/me/ssh-keys/list: HTMX partial for the SSH keys list. #[tracing::instrument(skip_all, name = "ssh_keys::list_keys_html")] pub(super) async fn list_keys_html( State(state): State, AuthUser(user): AuthUser, ) -> Result { let keys = db::ssh_keys::list_keys_by_user(&state.db, user.id).await?; let ssh_keys: Vec = keys.iter().map(SshKeyView::from).collect(); let html = crate::templates::SshKeysListTemplate { ssh_keys } .render() .unwrap_or_default(); Ok(axum::response::Html(html)) } /// GET /api/users/me/ssh-keys: list the authenticated user's SSH keys. #[tracing::instrument(skip_all, name = "ssh_keys::list_keys")] pub(super) async fn list_keys( State(state): State, AuthUser(user): AuthUser, ) -> Result { let keys = db::ssh_keys::list_keys_by_user(&state.db, user.id).await?; let data: Vec = keys .into_iter() .map(|k| SshKeyResponse { id: k.id, fingerprint: k.fingerprint, label: k.label, created_at: k.created_at.format("%b %d, %Y").to_string(), }) .collect(); Ok(Json(crate::types::ListResponse { data })) } /// POST /api/users/me/ssh-keys: add a new SSH key. #[tracing::instrument(skip_all, name = "ssh_keys::add_key")] pub(super) async fn add_key( State(state): State, headers: HeaderMap, AuthUser(user): AuthUser, Form(req): Form, ) -> Result { user.check_not_suspended()?; user.check_not_sandbox()?; // Validate and normalize the key let (normalized_key, fingerprint) = validation::validate_ssh_public_key(&req.public_key)?; validation::validate_ssh_key_label(&req.label)?; // Insert (unique constraint on user_id + fingerprint handles races) let key = db::ssh_keys::add_key(&state.db, user.id, &normalized_key, &fingerprint, &req.label) .await .map_err(|e| { // Check for unique constraint violation (duplicate fingerprint) if let AppError::Database(ref db_err) = e && db_err .to_string() .contains("ssh_keys_user_id_fingerprint_key") { return AppError::validation( "This SSH key is already registered to your account".to_string(), ); } e })?; // Trigger authorized_keys rebuild (best-effort, non-blocking) rebuild_authorized_keys(); if is_htmx_request(&headers) { // Re-render the SSH keys section via HTMX let keys = db::ssh_keys::list_keys_by_user(&state.db, user.id).await?; let ssh_keys: Vec = keys.iter().map(SshKeyView::from).collect(); let html = crate::templates::SshKeysListTemplate { ssh_keys } .render() .unwrap_or_default(); return Ok(( [("HX-Trigger", hx_toast("SSH key added", "success"))], axum::response::Html(html), ) .into_response()); } Ok(Json(SshKeyResponse { id: key.id, fingerprint: key.fingerprint, label: key.label, created_at: key.created_at.format("%b %d, %Y").to_string(), }) .into_response()) } /// DELETE /api/users/me/ssh-keys/{id}: remove an SSH key. #[tracing::instrument(skip_all, name = "ssh_keys::delete_key")] pub(super) async fn delete_key( State(state): State, headers: HeaderMap, AuthUser(user): AuthUser, Path(key_id): Path, ) -> Result { user.check_not_suspended()?; let deleted = db::ssh_keys::delete_key(&state.db, key_id, user.id).await?; if !deleted { return Err(AppError::NotFound); } // Trigger authorized_keys rebuild (best-effort, non-blocking) rebuild_authorized_keys(); if is_htmx_request(&headers) { let keys = db::ssh_keys::list_keys_by_user(&state.db, user.id).await?; let ssh_keys: Vec = keys.iter().map(SshKeyView::from).collect(); let html = crate::templates::SshKeysListTemplate { ssh_keys } .render() .unwrap_or_default(); return Ok(( [("HX-Trigger", hx_toast("SSH key removed", "success"))], axum::response::Html(html), ) .into_response()); } Ok(StatusCode::NO_CONTENT.into_response()) } /// Trigger authorized_keys rebuild via mnw-admin. /// /// Spawns the rebuild as a background process and does not wait for it. /// If sudo/mnw-admin is not available (e.g., dev environment), logs a warning. fn rebuild_authorized_keys() { std::thread::spawn(|| { let result = std::process::Command::new("sudo") .args(["-u", "git", "/opt/mnw/current/mnw-admin", "rebuild-keys"]) .output(); match result { Ok(output) if !output.status.success() => { let stderr = String::from_utf8_lossy(&output.stderr); tracing::warn!( status = %output.status, stderr = %stderr, "authorized_keys rebuild failed" ); } Err(e) => { tracing::debug!(error = %e, "authorized_keys rebuild skipped (mnw-admin not available)"); } _ => {} } }); } use askama::Template; /// View type for SSH key display in templates. #[derive(Clone)] pub struct SshKeyView { pub id: String, pub fingerprint: String, pub label: String, pub created_at: String, } impl From<&db::DbSshKey> for SshKeyView { fn from(k: &db::DbSshKey) -> Self { Self { id: k.id.to_string(), fingerprint: k.fingerprint.clone(), label: k.label.clone(), created_at: k.created_at.format("%b %d, %Y").to_string(), } } }