//! Admin API (milestone 7): user management and server settings. //! All routes require an admin session (via [`AdminUser`]). use std::sync::Arc; use api_types::{ AdminShare, AdminUser, CreateUser, Mode, OkResp, Root, RootInfo, Settings, UpdateUser, }; use axum::Json; use axum::extract::{Path as AxumPath, State}; use axum::http::StatusCode; use crate::api::common::AdminUser as AdminGuard; use crate::api::common::{ blocking, display_name, hash_password, validate_account_name, validate_password, }; use crate::api::shares; use crate::db::Db; use crate::error::{ApiError, AppState}; use crate::fs; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- fn root_info(state: &AppState, r: &crate::db::RootRow) -> RootInfo { RootInfo { id: r.id, name: display_name(state, &r.path), path: r.path.clone(), mode: r.mode, } } async fn user_info( db: &Db, state: &AppState, user: &crate::db::User, ) -> Result { let roots = db.user_roots(user.id).await?; Ok(AdminUser { id: user.id, name: user.name.clone(), is_admin: user.is_admin, active: user.active, roots: roots.iter().map(|r| root_info(state, r)).collect(), }) } /// Validate each requested root path (must exist, be a directory, and stay /// inside the server root). Returns the (path, mode) pairs. /// /// The mode needs no check: `Mode` only deserializes from "rw" or "ro", so a /// bad value is rejected by the `Json` extractor before this runs. async fn validate_roots(state: &AppState, roots: &[Root]) -> 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() }; let server_root = state.root.clone(); let (path2, label) = (path.clone(), path.clone()); // The message is built inside the closure so it carries the // `FsError`, not the join failure. blocking(move || { fs::resolve_root(&server_root, &path2).map_err(|e| { ApiError::new(StatusCode::BAD_REQUEST, format!("root path '{label}': {e}")) }) }) .await?; out.push((path, r.mode)); } Ok(out) } // --------------------------------------------------------------------------- // Handlers // --------------------------------------------------------------------------- /// GET /api/admin/users — list all users with their roots. pub async fn list_users( State(state): State>, _admin: AdminGuard, ) -> Result>, ApiError> { let out = state .db .all_users_with_roots() .await? .into_iter() .map(|(u, roots)| AdminUser { id: u.id, name: u.name, is_admin: u.is_admin, active: u.active, roots: roots.iter().map(|r| root_info(&state, r)).collect(), }) .collect(); Ok(Json(out)) } /// POST /api/admin/users — create a user. pub async fn create_user( State(state): State>, _admin: AdminGuard, Json(body): Json, ) -> Result, ApiError> { let name = body.name.trim().to_string(); validate_account_name(&name)?; validate_password(&body.password)?; if state.db.find_user_by_name(&name).await?.is_some() { return Err(ApiError::localized( StatusCode::CONFLICT, "a user with that name already exists", "err_user_exists", )); } let roots = validate_roots(&state, &body.roots).await?; let pass_hash = hash_password(&body.password).await?; let user = state .db .create_user(&name, &pass_hash, body.is_admin, &roots) .await?; Ok(Json(user_info(&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: AdminGuard, AxumPath(id): AxumPath, Json(body): Json, ) -> Result, ApiError> { let target = state.db.find_user_by_id(id).await?.ok_or_else(|| { ApiError::localized( StatusCode::NOT_FOUND, "user not found", "err_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::localized( StatusCode::BAD_REQUEST, "you cannot remove your own admin rights", "err_own_admin", )); } if body.active == Some(false) { return Err(ApiError::localized( StatusCode::BAD_REQUEST, "you cannot disable your own account", "err_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::localized( StatusCode::BAD_REQUEST, "cannot remove the last active admin", "err_last_admin", )); } let hash = match &body.password { Some(pw) => { validate_password(pw)?; Some(hash_password(pw).await?) } None => None, }; let pairs = match &body.roots { Some(roots) => Some(validate_roots(&state, roots).await?), None => None, }; state .db .update_user( id, hash.as_deref(), body.is_admin, body.active, pairs.as_deref(), ) .await?; crate::auth::forget_verified(); let updated = state.db.find_user_by_id(id).await?.ok_or_else(|| { ApiError::localized( StatusCode::NOT_FOUND, "user not found", "err_user_not_found", ) })?; Ok(Json(user_info(&state.db, &state, &updated).await?)) } /// DELETE /api/admin/users/{id} — delete a user (not yourself). pub async fn delete_user( State(state): State>, admin: AdminGuard, AxumPath(id): AxumPath, ) -> Result, ApiError> { if id == admin.user.id { return Err(ApiError::localized( StatusCode::BAD_REQUEST, "you cannot delete your own account", "err_own_delete", )); } let target = state.db.find_user_by_id(id).await?.ok_or_else(|| { ApiError::localized( StatusCode::NOT_FOUND, "user not found", "err_user_not_found", ) })?; if target.is_admin && target.active && state.db.count_admins().await? <= 1 { return Err(ApiError::localized( StatusCode::BAD_REQUEST, "cannot delete the last active admin", "err_last_admin_delete", )); } crate::auth::forget_verified(); if !state.db.delete_user(id).await? { return Err(ApiError::localized( StatusCode::NOT_FOUND, "user not found", "err_user_not_found", )); } Ok(Json(OkResp {})) } // --------------------------------------------------------------------------- // Shares // --------------------------------------------------------------------------- /// GET /api/admin/shares — every share on the server with its creator. /// /// Answers with the full share tokens, which the admin view offers as copy /// buttons. A token is access, so this route stays admin-only. pub async fn list_shares( State(state): State>, _admin: AdminGuard, ) -> Result>, ApiError> { let rows = state.db.all_shares_with_creators().await?; Ok(Json( rows.iter() .map(|r| AdminShare { share: shares::share_info(&r.share, &state), creator_id: r.share.creator_id, creator_name: r.creator_name.clone(), creator_active: r.creator_active, }) .collect(), )) } /// DELETE /api/admin/shares/{id} — revoke a share whoever created it. The /// user-facing `DELETE /api/shares/{id}` only touches the caller's own links. pub async fn delete_share( State(state): State>, _admin: AdminGuard, AxumPath(id): AxumPath, ) -> Result, ApiError> { if !state.db.admin_delete_share(id).await? { return Err(ApiError::localized( StatusCode::NOT_FOUND, "share not found", "err_share_not_found", )); } Ok(Json(OkResp {})) } /// GET /api/admin/settings pub async fn get_settings( State(state): State>, _admin: AdminGuard, ) -> Result, ApiError> { Ok(Json(Settings { allow_writable_shares: state.db.allow_writable_shares().await?, search_excludes: state.db.search_excludes().await?, })) } /// PUT /api/admin/settings pub async fn update_settings( State(state): State>, _admin: AdminGuard, Json(body): Json, ) -> Result, ApiError> { state .db .set_allow_writable_shares(body.allow_writable_shares) .await?; // Normalised so the search can compare plain strings. "." is dropped: // excluding the root would switch search off instead of narrowing it. let mut excludes: Vec = Vec::new(); for p in &body.search_excludes { let p = p.trim().replace('\\', "/"); let p = p.trim_matches('/'); if p.is_empty() || p == "." || excludes.iter().any(|e| e == p) { continue; } excludes.push(p.to_string()); } state.db.set_search_excludes(&excludes).await?; Ok(Json(Settings { allow_writable_shares: body.allow_writable_shares, search_excludes: excludes, })) }