//! Admin API (milestone 7): user management and server settings. //! All routes require an admin session (via [`AdminUser`]). use std::sync::Arc; use axum::Json; use axum::extract::{Path as AxumPath, State}; use axum::http::StatusCode; use serde::Deserialize; use crate::api::common::AdminUser; use crate::auth; use crate::db::Db; use crate::error::{ApiError, AppState}; use crate::fs; // --------------------------------------------------------------------------- // Bodies // --------------------------------------------------------------------------- #[derive(Deserialize)] pub struct RootBody { /// Path relative to the server root; "." means the whole root. path: String, /// "rw" or "ro". #[serde(default = "default_rw")] mode: String, } fn default_rw() -> String { "rw".to_string() } #[derive(Deserialize)] pub struct CreateUserBody { name: String, password: String, #[serde(default)] is_admin: bool, #[serde(default)] roots: Vec, } #[derive(Deserialize)] pub struct UpdateUserBody { #[serde(default)] password: Option, #[serde(default)] is_admin: Option, #[serde(default)] active: Option, #[serde(default)] roots: Option>, } #[derive(Deserialize)] pub struct SettingsBody { allow_writable_shares: bool, } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /// Display name for a root path (folder name, or the server root's name for "."). fn display_name(state_root: &std::path::Path, rel: &str) -> String { let name = if rel == "." { state_root.file_name() } else { std::path::Path::new(rel) .file_name() .filter(|_| !std::path::Path::new(rel).as_os_str().is_empty()) }; name.map(|s| s.to_string_lossy().into_owned()) .unwrap_or_else(|| rel.to_string()) } fn root_json(state: &AppState, r: &crate::db::RootRow) -> serde_json::Value { serde_json::json!({ "id": r.id, "name": display_name(&state.root, &r.path), "path": r.path, "mode": r.mode, }) } async fn user_json(db: &Db, state: &AppState, user: &crate::db::User) -> serde_json::Value { let roots = db.user_roots(user.id).await; serde_json::json!({ "id": user.id, "name": user.name, "is_admin": user.is_admin, "active": user.active, "roots": roots.iter().map(|r| root_json(state, r)).collect::>(), }) } /// Validate each requested root path (must exist, be a directory, and stay /// inside the server root) and its mode. Returns the (path, mode) pairs. async fn validate_roots( state: &AppState, roots: &[RootBody], ) -> Result, ApiError> { let mut out = Vec::new(); for r in roots { let path = if r.path.trim().is_empty() { ".".to_string() } else { r.path.trim().to_string() }; if r.mode != "rw" && r.mode != "ro" { return Err(ApiError::new( StatusCode::BAD_REQUEST, "mode must be 'rw' or 'ro'", )); } let server_root = state.root.clone(); let path2 = path.clone(); tokio::task::spawn_blocking(move || fs::resolve_root(&server_root, &path2)) .await .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))? .map_err(|e| { ApiError::new(StatusCode::BAD_REQUEST, format!("root path '{path}': {e}")) })?; out.push((path, r.mode.clone())); } Ok(out) } fn validate_name(name: &str) -> Result<(), ApiError> { let n = name.trim(); if n.is_empty() || n.len() > 64 { return Err(ApiError::new( StatusCode::BAD_REQUEST, "name must be 1–64 characters", )); } Ok(()) } fn validate_password(pw: &str) -> Result<(), ApiError> { if pw.len() < 8 { return Err(ApiError::new( StatusCode::BAD_REQUEST, "password must be at least 8 characters", )); } Ok(()) } // --------------------------------------------------------------------------- // Handlers // --------------------------------------------------------------------------- /// GET /api/admin/users — list all users with their roots. pub async fn list_users( State(state): State>, _admin: AdminUser, ) -> Result, ApiError> { let users = state.db.all_users().await; let mut values = Vec::with_capacity(users.len()); for u in &users { values.push(user_json(&state.db, &state, u).await); } Ok(Json(serde_json::json!(values))) } /// POST /api/admin/users — create a user. pub async fn create_user( State(state): State>, _admin: AdminUser, Json(body): Json, ) -> Result, ApiError> { let name = body.name.trim().to_string(); validate_name(&name)?; validate_password(&body.password)?; if state.db.find_user_by_name(&name).await.is_some() { return Err(ApiError::new( StatusCode::CONFLICT, "a user with that name already exists", )); } let roots = validate_roots(&state, &body.roots).await?; let pass_hash = auth::hash_password(&body.password).map_err(|e| { ApiError::new( StatusCode::INTERNAL_SERVER_ERROR, format!("hashing failed: {e}"), ) })?; let user = state .db .create_user(&name, &pass_hash, body.is_admin, &roots) .await?; Ok(Json(user_json(&state.db, &state, &user).await)) } /// PUT /api/admin/users/{id} — update a user (password / is_admin / active / /// roots; all optional). pub async fn update_user( State(state): State>, admin: AdminUser, AxumPath(id): AxumPath, Json(body): Json, ) -> Result, ApiError> { let target = state .db .find_user_by_id(id) .await .ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "user not found"))?; // Lockout guards: an admin cannot demote, disable, or delete themselves. if id == admin.user.id { if body.is_admin == Some(false) { return Err(ApiError::new( StatusCode::BAD_REQUEST, "you cannot remove your own admin rights", )); } if body.active == Some(false) { return Err(ApiError::new( StatusCode::BAD_REQUEST, "you cannot disable your own account", )); } } // Never allow dropping to zero active admins. let demoting = id != admin.user.id && body.is_admin == Some(false) && target.is_admin; let disabling = id != admin.user.id && body.active == Some(false) && target.active && target.is_admin; if (demoting || disabling) && state.db.count_admins().await <= 1 { return Err(ApiError::new( StatusCode::BAD_REQUEST, "cannot remove the last active admin", )); } if let Some(pw) = &body.password { validate_password(pw)?; let hash = auth::hash_password(pw).map_err(|e| { ApiError::new( StatusCode::INTERNAL_SERVER_ERROR, format!("hashing failed: {e}"), ) })?; state.db.update_user_password(id, &hash).await?; } if let Some(is_admin) = body.is_admin { state.db.set_user_admin(id, is_admin).await?; } if let Some(active) = body.active { state.db.set_user_active(id, active).await?; } if let Some(roots) = &body.roots { let pairs = validate_roots(&state, roots).await?; state.db.set_user_roots(id, &pairs).await?; } let updated = state .db .find_user_by_id(id) .await .ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "user not found"))?; Ok(Json(user_json(&state.db, &state, &updated).await)) } /// DELETE /api/admin/users/{id} — delete a user (not yourself). pub async fn delete_user( State(state): State>, admin: AdminUser, AxumPath(id): AxumPath, ) -> Result, ApiError> { if id == admin.user.id { return Err(ApiError::new( StatusCode::BAD_REQUEST, "you cannot delete your own account", )); } let target = state .db .find_user_by_id(id) .await .ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "user not found"))?; if target.is_admin && target.active && state.db.count_admins().await <= 1 { return Err(ApiError::new( StatusCode::BAD_REQUEST, "cannot delete the last active admin", )); } if !state.db.delete_user(id).await { return Err(ApiError::new(StatusCode::NOT_FOUND, "user not found")); } Ok(Json(serde_json::json!({ "ok": true }))) } /// GET /api/admin/settings pub async fn get_settings( State(state): State>, _admin: AdminUser, ) -> Result, ApiError> { Ok(Json(serde_json::json!({ "allow_writable_shares": state.db.allow_writable_shares().await, }))) } /// PUT /api/admin/settings pub async fn update_settings( State(state): State>, _admin: AdminUser, Json(body): Json, ) -> Result, ApiError> { state .db .set_allow_writable_shares(body.allow_writable_shares) .await?; Ok(Json(serde_json::json!({ "allow_writable_shares": body.allow_writable_shares, }))) }