//! Admin API (milestone 7): user management and server settings. //! All routes require an admin session (via [`AdminUser`]). use std::sync::Arc; use api_types::{ AdminPimLink, AdminShare, AdminUser, CreateRoom, CreateUser, Mode, OkResp, RoomInfo, RoomKind, Root, Settings, UpdateRoom, 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, hash_password, root_info, validate_account_name, validate_password, }; use crate::api::pim::{INBOX, principal_href}; use crate::api::shares; use crate::api::{pim_api, pim_schedule}; use crate::db::{PimKind, PimOp, PimPrincipal, RootRow, UserType}; use crate::error::{ApiError, AppState}; use crate::fs; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- fn user_not_found() -> ApiError { ApiError::localized( StatusCode::NOT_FOUND, "user not found", "err_user_not_found", ) } fn admin_user(state: &AppState, user: &crate::db::User, roots: &[RootRow]) -> AdminUser { 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| { let msg = ApiError::from(e).1; ApiError::new( StatusCode::BAD_REQUEST, format!("root path '{label}': {msg}"), ) }) }) .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)| admin_user(&state, &u, &roots)) .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)?; let taken = || { ApiError::localized( StatusCode::CONFLICT, "a user with that name already exists", "err_user_exists", ) }; // Rooms and resources share the name space. if state.db.name_taken(&name).await? { return Err(taken()); } let roots = validate_roots(&state, &body.roots).await?; let pass_hash = hash_password(&body.password).await?; let user = match state .db .create_user(&name, &pass_hash, body.is_admin, &roots) .await { Ok(u) => u, // A user or room of that name may have come in since the check. Err(_) if state.db.name_taken(&name).await? => return Err(taken()), Err(e) => return Err(e.into()), }; let roots = state.db.user_roots(user.id).await?; Ok(Json(admin_user(&state, &user, &roots))) } /// 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(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(user_not_found)?; let roots = state.db.user_roots(updated.id).await?; Ok(Json(admin_user(&state, &updated, &roots))) } /// 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(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(); let _lock = pim_schedule::LOCK.lock().await; let pid = state.db.principal_of(id).await?; let ops = match state.db.pim_principal_by_id(pid).await? { Some(p) => { let retracted = retract_all(&state, &p).await?; pim_schedule::forget(&state, &p, retracted).await? } None => Vec::new(), }; if !state.db.delete_user(id, &ops).await? { return Err(user_not_found()); } Ok(Json(OkResp {})) } // --------------------------------------------------------------------------- // Shares // --------------------------------------------------------------------------- /// The cancellations and declines for everything `p` owns. Hold the /// scheduling lock. async fn retract_all(state: &AppState, p: &PimPrincipal) -> Result, ApiError> { let dir = pim_schedule::Directory::load(state).await?; let ids: Vec = state .db .pim_collections(p.id, PimKind::Calendar) .await? .into_iter() .filter(|c| c.slug != INBOX) .map(|c| c.id) .collect(); pim_schedule::retract(state, &dir, p, &ids) .await? .map_err(|_| ApiError::new(StatusCode::CONFLICT, "the meetings cannot be cancelled")) } /// 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.delete_share(id, None).await? { return Err(ApiError::localized( StatusCode::NOT_FOUND, "share not found", "err_share_not_found", )); } Ok(Json(OkResp {})) } /// GET {ADMIN_PIM_LINKS} — every public calendar and address book feed. /// Like the share list, it carries the full tokens. pub async fn list_pim_links( State(state): State>, _admin: AdminGuard, ) -> Result>, ApiError> { let rows = state.db.pim_links_with_owner(None).await?; Ok(Json(rows.into_iter().map(pim_api::feed_entry).collect())) } /// DELETE {ADMIN_PIM_LINKS}/{id} — revoke a feed whoever made it. pub async fn delete_pim_link( State(state): State>, _admin: AdminGuard, AxumPath(id): AxumPath, ) -> Result, ApiError> { if !state.db.pim_delete_link(id, None).await? { return Err(ApiError::new(StatusCode::NOT_FOUND, "link not found")); } Ok(Json(OkResp {})) } // --------------------------------------------------------------------------- // Rooms and resources // --------------------------------------------------------------------------- fn room_info(p: &PimPrincipal) -> RoomInfo { RoomInfo { id: p.id, name: p.name.clone(), display_name: p.display().to_string(), kind: match p.kind { UserType::Resource => RoomKind::Resource, _ => RoomKind::Room, }, url: principal_href(&p.name), } } fn room_not_found() -> ApiError { ApiError::new(StatusCode::NOT_FOUND, "room not found") } fn room_display_name(v: &str) -> Result { let v = v.trim(); if v.is_empty() || v.chars().count() > 200 || v.chars().any(char::is_control) { return Err(ApiError::new( StatusCode::BAD_REQUEST, "invalid display name", )); } Ok(v.to_string()) } /// GET /api/admin/rooms pub async fn list_rooms( State(state): State>, _admin: AdminGuard, ) -> Result>, ApiError> { Ok(Json( state.db.rooms().await?.iter().map(room_info).collect(), )) } /// POST /api/admin/rooms — a room or resource with its booking calendar. pub async fn create_room( State(state): State>, _admin: AdminGuard, Json(body): Json, ) -> Result, ApiError> { let name = body.name.trim().to_string(); validate_account_name(&name)?; let display = room_display_name(body.display_name.as_deref().unwrap_or(&name))?; let kind = match body.kind { RoomKind::Room => UserType::Room, RoomKind::Resource => UserType::Resource, }; let room = state .db .create_room(&name, &display, kind) .await? .ok_or_else(|| ApiError::new(StatusCode::CONFLICT, "the name is taken"))?; Ok(Json(room_info(&room))) } /// PUT /api/admin/rooms/{id} — change the display name. The name stays: it /// is the scheduling address. pub async fn update_room( State(state): State>, _admin: AdminGuard, AxumPath(id): AxumPath, Json(body): Json, ) -> Result, ApiError> { let display = room_display_name(&body.display_name)?; if !state.db.set_room_display_name(id, &display).await? { return Err(room_not_found()); } let rooms = state.db.rooms().await?; let room = rooms .iter() .find(|r| r.id == id) .ok_or_else(room_not_found)?; Ok(Json(room_info(room))) } /// DELETE /api/admin/rooms/{id} — with its bookings. pub async fn delete_room( State(state): State>, _admin: AdminGuard, AxumPath(id): AxumPath, ) -> Result, ApiError> { let _lock = pim_schedule::LOCK.lock().await; let Some(room) = state .db .pim_principal_by_id(id) .await? .filter(|p| p.user_id.is_none()) else { return Err(room_not_found()); }; let retracted = retract_all(&state, &room).await?; let ops = pim_schedule::forget(&state, &room, retracted).await?; if !state.db.delete_room(id, &ops).await? { return Err(room_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, })) }