use axum::{ Router, extract::{DefaultBodyLimit, Json, Multipart, Path, Query, State}, http::StatusCode, response::{ IntoResponse, sse::{Event, KeepAlive, Sse}, }, routing::{delete, get, post, put}, }; use crate::application::dtos::drive_dto::DriveDto; use crate::application::dtos::grant_dto::{GrantDto, RoleDto, SubjectDto, SubjectTypeDto}; use crate::application::dtos::plugin_dto::{ PluginInfoDto, PluginLogEntryDto, PluginLogPageDto, PluginLogQueryDto, PluginRetentionDto, SetEnabledDto, }; use crate::application::dtos::settings_dto::{ AdminCreateUserDto, AdminResetPasswordDto, DashboardStatsDto, DriveKindUsageDto, ListSessionsQueryDto, ListUsersQueryDto, MigrationStateDto, SaveOidcSettingsDto, SaveStorageSettingsDto, SendSmtpTestDto, SmtpInfoDto, SmtpTestResultDto, StartMigrationDto, TestOidcConnectionDto, TestStorageConnectionDto, UpdateUserActiveDto, UpdateUserQuotaDto, UpdateUserRoleDto, }; use crate::application::dtos::user_dto::{FullUserDto, PublicUserDto}; use crate::application::ports::authorization_ports::AuthorizationEngine; use crate::application::ports::plugin_ports::{LogQuery, PluginManagementPort, PluginMgmtError}; // JobStoreProvider is used only by the storage-migration shims below, // but the compiler needs the trait in scope for method resolution on // the concrete `PgJobStoreProvider` that lives on `AppState`. use crate::common::di::AppState; use crate::domain::repositories::drive_repository::DriveRepository; use crate::domain::services::authorization::{Resource, Subject}; use crate::infrastructure::scheduler::{JobStoreProvider, PausedRunBrief}; use crate::interfaces::api::handlers::dedup_handler::{get_stats, recalculate_stats}; use crate::interfaces::api::handlers::search_handler::clear_search_cache; use crate::interfaces::errors::AppError; use crate::interfaces::middleware::auth::AuthUser; use std::sync::Arc; use uuid::Uuid; /// Response envelope for `GET /api/admin/users`. `users` is always /// `Vec` — same shape one row of `/me`'s embedded /// `full` block carries; the FE seeds `resolveUser` cache from /// `row.user` (kills the per-row `/api/users/{id}` fetch). See /// `docs/plan/userdto-refactor.md`. #[derive(serde::Serialize)] struct AdminUsersPageResponse { users: Vec, total: i64, limit: i64, offset: i64, } /// Admin API routes — all require admin role. pub fn admin_routes() -> Router> { use super::admin_external_mounts as ext_mounts; Router::new() // External file mounts .route( "/external-mounts", get(ext_mounts::list_external_mounts).post(ext_mounts::create_external_mount), ) .route( "/external-mounts/{id}", delete(ext_mounts::delete_external_mount), ) // OIDC settings .route("/settings/oidc", get(get_oidc_settings)) .route("/settings/oidc", put(save_oidc_settings)) .route("/settings/oidc/test", post(test_oidc_connection)) // Storage settings .route("/settings/storage", get(get_storage_settings)) .route("/settings/storage", put(save_storage_settings)) .route("/settings/storage/test", post(test_storage_connection)) // Storage migration — thin shims over the recoverable-run // engine (job_name = "backend_migration"). Retained under // /storage/migration/* until the admin UI is rewired to // /api/admin/jobs/backend_migration/*; both paths route to // the same underlying JobRegistry dispatch. The old /complete // endpoint is retired — a finished run is a Completed row, // there's nothing to acknowledge. .route("/storage/migration", get(get_migration_status)) .route("/storage/migration/start", post(start_migration)) .route("/storage/migration/pause", post(pause_migration)) .route("/storage/migration/resume", post(resume_migration)) // K3 (storage-key-rotation): per-entry rotate trigger. // Normalises every blob on the named entry to its head-pair // format (legacy → v1, plaintext ↔ encrypted, old-key → // new-key). No readonly mode; safe under normal traffic. .route( "/storage/entries/{name}/rotate", post(trigger_backend_rotate), ) // NOTE: /storage/migration/verify retired in slice 7 (see the // comment near where `verify_migration` used to live). Use // `POST /api/admin/jobs/blobs_consistency/trigger?storage=`. // Encryption key generation .route( "/settings/storage/generate-key", post(generate_encryption_key), ) // Dashboard / stats .route("/dashboard", get(get_dashboard_stats)) // User management .route("/users", get(list_users)) .route("/users", post(create_user)) .route("/users/{id}", get(get_user)) .route("/users/{id}", delete(delete_user)) // Session management (DPoP admin panel — see docs/plan/dpop.md // Gate 10). List is global cross-user with `?user_id=` narrow; // revoke sets `revoked=true` (row stays for audit). .route("/sessions", get(list_sessions)) .route("/sessions/{id}", delete(revoke_session)) .route("/users/{id}/role", put(update_user_role)) .route("/users/{id}/active", put(update_user_active)) .route("/users/{id}/quota", put(update_user_quota)) .route("/users/{id}/password", put(reset_user_password)) .route( "/users/{id}/promote-to-internal", post(admin_promote_external_to_internal), ) // Registration control .route("/settings/registration", put(set_registration_setting)) // Audio metadata .route("/audio/metadata/reextract", post(reextract_audio_metadata)) // Image/video capture metadata (Photos timeline backfill) .route("/photos/metadata/reextract", post(reextract_image_metadata)) // Plugin management .route("/plugins", get(list_plugins)) // Install caps the request body at 32 MiB (overriding the global // multi-GB upload limit) — a plugin bundle is small; the unpack also // enforces a 64 MiB decompressed ceiling. .route( "/plugins", post(install_plugin).layer(DefaultBodyLimit::max(32 * 1024 * 1024)), ) .route("/plugins/{id}/enabled", put(set_plugin_enabled)) .route("/plugins/{id}", delete(delete_plugin)) // Plugin logs + per-plugin retention .route("/plugins/{id}/logs", get(get_plugin_logs)) .route("/plugins/{id}/logs", delete(clear_plugin_logs)) .route("/plugins/{id}/logs/stream", get(stream_plugin_logs)) .route("/plugins/{id}/retention", get(get_plugin_retention)) .route("/plugins/{id}/retention", put(set_plugin_retention)) // Search — operator flush of the shared moka results cache // (AuthZ audit #14, 2026-07-16). `invalidate_all()` semantics // touch every tenant, so this is admin-only. Lived at // `/api/search/cache` pre-2026-07-17; the URL now declares // its admin intent up front. .route("/search/cache", delete(clear_search_cache)) // Dedup — global storage stats + integrity recalculation // (AuthZ audit #24 + #25, 2026-07-17). Both are operator-only // observability / maintenance surfaces (blob-count-level data // + verify_integrity sweep). Moved here from `/api/dedup/*` // so the URL declares admin intent and the middleware layer // enforces it — same pattern as `search/cache` above. The // any-authenticated sibling routes (`/check`, `/check-batch`, // `/blob/{hash}`) stay at `/api/dedup/*`. .route("/dedup/stats", get(get_stats)) .route("/dedup/recalculate", post(recalculate_stats)) // SMTP diagnostics .route("/smtp/info", get(get_smtp_info)) .route("/smtp/test", post(send_smtp_test)) // Test-only capture endpoint. The handler short-circuits to 404 // when `OXICLOUD_SMTP_MOCK` is off, so production deployments // can route the path freely without leaking inboxes. .route("/smtp/test/captured", get(get_captured_email)) // JobRegistry admin surface — production, always-on, // audit-logged. See `docs/plan/job-registry.md` §Cross-cutting. // Retired the `/internal/trigger-sweep|gc|grant-cleanup` shims // that used to sit here (Stage 2 of the job-registry rollout). // // `/jobs` + `/jobs/{name}/trigger` cover every registered // JobHandler (periodic + recoverable — recoverable ones slot // in through `RecoverableAdapter`). The `/cancel` + `/runs` // + `/runs/{id}` triplet is recoverable-only — hitting them // on a stateless job silently gets an empty list / no-op // cancel, since no rows in `jobs.recoverable_runs` match. // See `docs/plan/job-registry.md` Part 2. .route("/jobs", get(list_jobs)) .route("/jobs/{name}/trigger", post(trigger_job)) .route("/jobs/{name}/cancel", post(cancel_job)) .route("/jobs/{name}/pause", post(pause_job)) .route("/jobs/{name}/runs", get(list_job_runs)) .route("/jobs/{name}/runs/{id}", get(get_job_run)) .route( "/jobs/{name}/runs/{id}/findings", get(list_job_run_findings), ) // Retention cleanup — operator-triggered, not periodic. // See `purge_job_runs` docstring for the semantics. .route("/jobs/runs/purge", post(purge_job_runs)) // Drives — admin-wide view (distinct from `/api/drives` which // is filtered to the caller's role grants). .route("/drives", get(list_all_drives)) .route("/drives/{id}", delete(delete_drive_admin)) .route( "/drives/{id}/members", get(list_drive_members_admin).post(add_drive_member_admin), ) .route( "/drives/{id}/members/{kind}/{sid}", axum::routing::patch(update_drive_member_admin).delete(remove_drive_member_admin), ) } // Every route under `/api/admin/*` is gated by the // `require_admin` middleware layer wired at the router nest point // (`routes.rs::admin_router`). Handlers no longer need an inline // guard call — the caller is guaranteed to be admin by construction. // Callers that need the caller's id read it from the `AuthUser` // extractor (`middleware::auth::AuthUser`), populated by the outer // `auth_middleware`. /// GET /api/admin/settings/oidc — get OIDC settings for the admin panel #[utoipa::path( get, path = "/api/admin/settings/oidc", responses( (status = 200, description = "OIDC settings"), (status = 401, description = "Unauthorized"), (status = 403, description = "Admin required") ), security(("bearerAuth" = [])), tag = "admin" )] pub async fn get_oidc_settings( State(state): State>, ) -> Result { let svc = state .admin_settings_service .as_ref() .ok_or_else(|| AppError::internal_error("Admin settings service not available"))?; let settings = svc .get_oidc_settings() .await .map_err(|e| AppError::internal_error(format!("Failed to load settings: {}", e)))?; Ok(Json(settings)) } /// PUT /api/admin/settings/oidc — save OIDC settings + hot-reload #[utoipa::path( put, path = "/api/admin/settings/oidc", responses( (status = 200, description = "OIDC settings saved"), (status = 401, description = "Unauthorized"), (status = 403, description = "Admin required") ), security(("bearerAuth" = [])), tag = "admin" )] pub async fn save_oidc_settings( State(state): State>, auth_user: AuthUser, Json(dto): Json, ) -> Result { let user_id = auth_user.id; let svc = state .admin_settings_service .as_ref() .ok_or_else(|| AppError::internal_error("Admin settings service not available"))?; svc.save_oidc_settings(dto, user_id) .await .map_err(|e| AppError::internal_error(format!("Failed to save settings: {}", e)))?; Ok(( StatusCode::OK, Json(serde_json::json!({ "message": "OIDC settings saved and applied successfully" })), )) } /// POST /api/admin/settings/oidc/test — test OIDC discovery async fn test_oidc_connection( State(state): State>, Json(dto): Json, ) -> Result { let svc = state .admin_settings_service .as_ref() .ok_or_else(|| AppError::internal_error("Admin settings service not available"))?; let result = svc .test_oidc_connection(dto) .await .map_err(|e| AppError::internal_error(format!("Connection test failed: {}", e)))?; Ok(Json(result)) } // ───────────────────────────────────────────────────── // Storage settings handlers // ───────────────────────────────────────────────────── /// GET /api/admin/settings/storage — get storage backend settings #[utoipa::path( get, path = "/api/admin/settings/storage", responses( (status = 200, description = "Storage settings"), (status = 401, description = "Unauthorized"), (status = 403, description = "Admin required") ), security(("bearerAuth" = [])), tag = "admin" )] pub async fn get_storage_settings( State(state): State>, ) -> Result { let svc = state .storage_settings_service .as_ref() .ok_or_else(|| AppError::internal_error("Storage settings service not available"))?; let settings = svc .get_storage_settings() .await .map_err(|e| AppError::internal_error(format!("Failed to load storage settings: {}", e)))?; Ok(Json(settings)) } /// PUT /api/admin/settings/storage — save storage backend settings #[utoipa::path( put, path = "/api/admin/settings/storage", responses( (status = 200, description = "Storage settings saved"), (status = 401, description = "Unauthorized"), (status = 403, description = "Admin required") ), security(("bearerAuth" = [])), tag = "admin" )] pub async fn save_storage_settings( State(state): State>, auth_user: AuthUser, Json(dto): Json, ) -> Result { let user_id = auth_user.id; let svc = state .storage_settings_service .as_ref() .ok_or_else(|| AppError::internal_error("Storage settings service not available"))?; svc.save_storage_settings(dto, user_id) .await .map_err(|e| AppError::internal_error(format!("Failed to save storage settings: {}", e)))?; Ok(( StatusCode::OK, Json(serde_json::json!({ "message": "Storage settings saved successfully" })), )) } /// POST /api/admin/settings/storage/test — test storage backend connection async fn test_storage_connection( State(state): State>, Json(dto): Json, ) -> Result { let svc = state .storage_settings_service .as_ref() .ok_or_else(|| AppError::internal_error("Storage settings service not available"))?; let result = svc .test_storage_connection(dto) .await .map_err(|e| AppError::internal_error(format!("Storage connection test failed: {}", e)))?; Ok(Json(result)) } // ───────────────────────────────────────────────────── // Storage migration handlers // ───────────────────────────────────────────────────── /// GET /api/admin/storage/migration — current migration progress. /// /// Shim over the recoverable-run engine: reads the latest /// `backend_migration` run from `jobs.recoverable_runs` (via the /// `JobStoreProvider`) and projects it into the legacy /// `MigrationStateDto` shape the admin storage tab expects. When no /// run has ever been triggered the response is an empty "idle" DTO — /// same behaviour the old in-memory `MigrationState::default()` /// produced. #[utoipa::path( get, path = "/api/admin/storage/migration", responses( (status = 200, description = "Current migration status"), (status = 401, description = "Unauthorized"), (status = 403, description = "Admin required") ), security(("bearerAuth" = [])), tag = "admin" )] pub async fn get_migration_status( State(state): State>, ) -> Result { use crate::infrastructure::services::backend_migration_service::BACKEND_MIGRATION_JOB_NAME; let provider = state.core.job_store_provider.clone(); let latest = provider .list_runs(BACKEND_MIGRATION_JOB_NAME, 1) .await .map_err(AppError::from)? .into_iter() .next(); let Some(run) = latest else { return Ok(Json(idle_migration_dto())); }; // Failed blobs are stored as findings, kind = "migration_failed". // Pull up to a reasonable ceiling — the DTO ships the full list, // and the admin UI truncates its own display. let findings = provider .list_findings(run.id, 500, 0) .await .map_err(AppError::from)?; let failed_blobs: Vec = findings .into_iter() .filter(|f| f.kind == "migration_failed") .filter_map(|f| { f.detail .get("hash") .and_then(|v| v.as_str()) .map(|s| s.to_string()) }) .collect(); Ok(Json(run_to_migration_dto(&run, failed_blobs))) } /// POST /api/admin/storage/migration/start — begin background migration. /// /// Shim that forwards to `JobRegistry::trigger("backend_migration", /// ...)`. `run_or_resume` (the RecoverableAdapter's inner dispatch) /// resumes a Paused run or starts a fresh one — one endpoint covers /// both. Exclusivity is enforced at the DB layer (the partial unique /// index on `jobs.recoverable_runs`), so a second concurrent trigger /// is a no-op that returns the existing run. /// /// `StartMigrationDto.concurrency` is currently ignored — the /// recoverable copy loop runs sequentially. Kept in the DTO for /// wire-compat with the admin UI; will be honoured if a concurrency /// knob is added later. #[utoipa::path( post, path = "/api/admin/storage/migration/start", responses( (status = 200, description = "Migration started"), (status = 401, description = "Unauthorized"), (status = 403, description = "Admin required") ), security(("bearerAuth" = [])), tag = "admin" )] pub async fn start_migration( State(state): State>, Json(dto): Json, ) -> Result { // Validate at the HTTP layer (before spawning) so unknown / no-op // targets get a synchronous 400 response instead of burning a // failed run row. The handler's own checks are second-line // defence for the resume path where args aren't repeated. let entries = &state.core.config.storage_entries; // Snapshot the active-name for the guard. Read lock held only // for the clone. let active = state .core .active_backend_name .read() .unwrap_or_else(std::sync::PoisonError::into_inner) .clone(); if entries.iter().all(|e| e.name != dto.target_name) { let available = if entries.is_empty() { "(none)".to_string() } else { entries .iter() .map(|e| e.name.as_str()) .collect::>() .join(", ") }; return Err(AppError::bad_request(format!( "unknown target entry `{}`. Available: [{available}]", dto.target_name ))); } if dto.target_name == active { return Err(AppError::bad_request(format!( "target `{}` is the currently-active entry — pick a different entry to migrate to", dto.target_name ))); } trigger_backend_migration(state, Some(dto.target_name)).await } /// POST /api/admin/storage/migration/pause — pause a running migration. /// /// Shim over cooperative cancel: flips the run row's status to /// `CancelRequested`; the recoverable handler polls between batches /// and returns `Paused` at the next boundary. If nothing is running, /// returns 200 with `paused: false` — matches the "no-op is fine" /// contract of `/api/admin/jobs/{name}/cancel`. #[utoipa::path( post, path = "/api/admin/storage/migration/pause", responses( (status = 200, description = "Pause signalled (or no-op)"), (status = 401, description = "Unauthorized"), (status = 403, description = "Admin required") ), security(("bearerAuth" = [])), tag = "admin" )] pub async fn pause_migration( State(state): State>, ) -> Result { use crate::infrastructure::services::backend_migration_service::BACKEND_MIGRATION_JOB_NAME; tracing::info!( target: "audit", event = "backend_migration.pause_requested", "👮🏻‍♂️ Admin requested backend_migration pause" ); let flipped = state .core .job_store_provider .request_cancel(BACKEND_MIGRATION_JOB_NAME) .await .map_err(AppError::from)?; Ok(( StatusCode::OK, Json(serde_json::json!({ "paused": flipped.is_some(), "run_id": flipped, "message": if flipped.is_some() { "Pause requested — handler will yield at the next batch boundary" } else { "No running migration to pause" }, })), )) } /// POST /api/admin/storage/migration/resume — resume a paused migration. /// /// Same underlying trigger as `/start`: `run_or_resume` inspects the /// latest row and picks Fresh / Resume / AlreadyActive at dispatch /// time. Kept as a distinct endpoint for wire-compat. #[utoipa::path( post, path = "/api/admin/storage/migration/resume", responses( (status = 200, description = "Migration resumed (or already running)"), (status = 401, description = "Unauthorized"), (status = 403, description = "Admin required") ), security(("bearerAuth" = [])), tag = "admin" )] pub async fn resume_migration( State(state): State>, ) -> Result { // Resume path — no target_name in the body. The handler reads // it from `params.target_name` stamped on the original Fresh // open. Refuses gracefully via RunOutcome::Failed if there is // no Paused row to resume. trigger_backend_migration(state, None).await } // verify_migration endpoint retired (slice 7 of // docs/plan/storage-multi-entry.md). It was a sample-based sanity // check against the currently-effective target backend; superseded // by `POST /api/admin/jobs/blobs_consistency/trigger?storage=` // which does a full walk against ANY named entry (not just the // migration target), records structured findings per mismatch, and // integrates with the standard runs / cancel / findings admin // surface. Frontend "Verify integrity" button removed in the same // slice. /// Shared body for `start` / `resume` — both funnel through /// `run_or_resume` via `JobRegistry::trigger`. Detaches into a /// `tokio::spawn` so the HTTP response returns immediately — same /// rationale as `trigger_job` above (browser timeout mid-await would /// desync `current_run_start` from the actually-running task). The /// admin UI polls `GET /storage/migration` for progress; the trigger /// itself is fire-and-forget. async fn trigger_backend_migration( state: Arc, target_name: Option, ) -> Result { use crate::infrastructure::scheduler::JobRunArgs; use crate::infrastructure::services::backend_migration_service::BACKEND_MIGRATION_JOB_NAME; tracing::info!( target: "audit", event = "backend_migration.trigger_requested", target_name = target_name.as_deref().unwrap_or(""), "👮🏻‍♂️ Admin triggered backend_migration" ); let registry = state.core.job_registry.clone(); let args = JobRunArgs { storage: target_name, ..JobRunArgs::default() }; tokio::spawn(async move { registry.trigger(BACKEND_MIGRATION_JOB_NAME, &args).await; }); Ok(( StatusCode::ACCEPTED, Json(serde_json::json!({ "message": "Migration dispatched — poll GET /api/admin/storage/migration for status", "detached": true, })), ) .into_response()) } /// POST /api/admin/storage/entries/{name}/rotate — trigger the /// `backend_rotate` recoverable job on a specific entry. /// /// Normalises every blob on `` to the entry's head-pair /// format: legacy → v1, plaintext ↔ encrypted, old-key → new-key. /// See `docs/plan/storage-key-rotation.md` §"The rotation job". /// /// Unlike migration, rotation does NOT engage read-only mode — /// rewrites happen in place under normal traffic. Concurrent user /// writes coexist safely. /// /// The handler validates the entry name synchronously (400 on /// unknown entry); the actual walk detaches into a /// `tokio::spawn` so the HTTP call returns immediately. #[utoipa::path( post, path = "/api/admin/storage/entries/{name}/rotate", responses( (status = 202, description = "Rotation dispatched"), (status = 400, description = "Unknown entry"), (status = 401, description = "Unauthorized"), (status = 403, description = "Admin required") ), params( ("name" = String, Path, description = "Storage entry name to rotate") ), security(("bearerAuth" = [])), tag = "admin" )] pub async fn trigger_backend_rotate( State(state): State>, axum::extract::Path(name): axum::extract::Path, ) -> Result { use crate::infrastructure::scheduler::JobRunArgs; use crate::infrastructure::services::backend_migration_service::BACKEND_MIGRATION_JOB_NAME; use crate::infrastructure::services::backend_rotate_service::BACKEND_ROTATE_JOB_NAME; // Synchronous entry-existence check — a bad name would fail the // run anyway, but returning 400 here spares the operator an // audit-log round-trip. let entries = &state.core.config.storage_entries; if entries.iter().all(|e| e.name != name) { let available = if entries.is_empty() { "(none)".to_string() } else { entries .iter() .map(|e| e.name.as_str()) .collect::>() .join(", ") }; return Err(AppError::bad_request(format!( "unknown storage entry `{name}`. Available: [{available}]" ))); } // Refuse on non-active entry. `storage.blobs` describes what's on // the ACTIVE backend; walking it against a stale target produces a // `rotation_failed` finding per missing blob (pure noise) and can't // actually normalise anything the app reads. The right recipe for // "normalise a different backend" is: migrate to it (blobs land in // the head-pair's format on arrival — no rotation needed). let active = state .core .active_backend_name .read() .unwrap_or_else(std::sync::PoisonError::into_inner) .clone(); if name != active { return Err(AppError::bad_request(format!( "backend_rotate refuses non-active entry `{name}` — the DB blob registry \ describes the active entry (`{active}`), so walking it against a stale \ target produces spurious `rotation_failed` findings. Activate `{name}` \ first via `Migrate & activate`, then rotate." ))); } // Concurrency guard per plan: at most one encryption-touching // recoverable run at a time across the whole app. Rotation // rewrites blobs in place; migration copies + swaps; running // both simultaneously could interleave writes on the same // hash. Cheap check — `list_runs` limit 1 with the status // filter is an index scan. let provider = state.core.job_store_provider.clone(); for job_name in [BACKEND_ROTATE_JOB_NAME, BACKEND_MIGRATION_JOB_NAME] { let in_flight = provider .list_runs(job_name, 5) .await .map_err(AppError::from)? .into_iter() .any(|r| { matches!( r.status, crate::infrastructure::scheduler::RunStatus::Running | crate::infrastructure::scheduler::RunStatus::Paused | crate::infrastructure::scheduler::RunStatus::CancelRequested ) }); if in_flight { return Err(AppError::bad_request(format!( "cannot start backend_rotate on `{name}` — `{job_name}` is already Running / \ Paused / CancelRequested. Wait for it to finish (or cancel via \ `POST /api/admin/jobs/{job_name}/cancel`)." ))); } } tracing::info!( target: "audit", event = "backend_rotate.trigger_requested", target_name = %name, "👮🏻‍♂️ Admin triggered backend_rotate on `{name}`" ); let registry = state.core.job_registry.clone(); let args = JobRunArgs { storage: Some(name.clone()), ..JobRunArgs::default() }; tokio::spawn(async move { registry.trigger(BACKEND_ROTATE_JOB_NAME, &args).await; }); Ok(( StatusCode::ACCEPTED, Json(serde_json::json!({ "message": format!("Rotation dispatched on `{name}` — poll GET /api/admin/jobs/{BACKEND_ROTATE_JOB_NAME} for progress"), "detached": true, })), ) .into_response()) } /// Idle-state DTO — no run has been triggered yet. fn idle_migration_dto() -> MigrationStateDto { MigrationStateDto { status: "idle".to_string(), total_blobs: 0, migrated_blobs: 0, // `migrated_bytes` and `throughput_bytes_per_sec` are no // longer tracked — the recoverable engine bumps // `stats.scanned_count` (a blob-count aggregator), not a // bytes counter. The admin UI keeps these fields for // wire-compat; they read 0 / null. migrated_bytes: 0, failed_blobs: Vec::new(), started_at: None, completed_at: None, throughput_bytes_per_sec: None, } } /// Project a recoverable `RunSummary` into the admin UI's /// `MigrationStateDto`. Byte-counter fields are always 0 / None — /// see `idle_migration_dto`'s comment. fn run_to_migration_dto( run: &crate::infrastructure::scheduler::RunSummary, failed_blobs: Vec, ) -> MigrationStateDto { use crate::infrastructure::scheduler::RunStatus; // Fold CancelRequested into "paused" — from the admin UI's // point of view a cancel-in-flight is the "waiting for the // handler to yield" state. Same visual affordance as Paused. let status = match run.status { RunStatus::Running => "running", RunStatus::Paused => "paused", RunStatus::CancelRequested => "paused", RunStatus::Completed => "completed", RunStatus::Failed => "failed", // Cancelled is user-abandoned but terminal — same visual as // failed for the migration status endpoint (both mean "not // going to finish, look at findings/logs to know why"). RunStatus::Cancelled => "cancelled", } .to_string(); let (total_blobs, migrated_blobs) = match run.progress.as_ref() { Some(p) => (p.total, p.scanned), None => ( 0, run.stats .get("scanned_count") .and_then(|v| v.as_u64()) .unwrap_or(0), ), }; MigrationStateDto { status, total_blobs, migrated_blobs, migrated_bytes: 0, failed_blobs, started_at: Some(run.started_at.to_rfc3339()), completed_at: run.completed_at.map(|d| d.to_rfc3339()), throughput_bytes_per_sec: None, } } /// POST /api/admin/settings/storage/generate-key — generate a random AES-256 key. #[utoipa::path( post, path = "/api/admin/settings/storage/generate-key", responses( (status = 200, description = "Generated AES-256 key"), (status = 401, description = "Unauthorized"), (status = 403, description = "Admin required") ), security(("bearerAuth" = [])), tag = "admin" )] pub async fn generate_encryption_key() -> Result { let key = crate::infrastructure::services::encrypted_blob_backend::EncryptedBlobBackend::generate_key( ); let key_b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, key); // Fingerprint uses the same colon-hex rendering as the boot log, // the pair chain in admin/storage, and `oxicloud --fingerprint`. // Ed can cross-reference it against `.env` after pasting the key // in — if the fingerprints match, the key made it into the // config intact. let fingerprint = crate::common::config::fingerprint_from_base64_key(&key_b64).unwrap_or_else(|_| { // Should never happen — we JUST generated a 32-byte key // and base64-encoded it — but if the fingerprint helper // rejects, degrade gracefully rather than 500. "—".to_string() }); Ok(Json(serde_json::json!({ "key": key_b64, "fingerprint": fingerprint, "warning": "Store this key securely. If lost, encrypted data is IRRECOVERABLY LOST." }))) } // ============================================================================ // Dashboard / Stats // ============================================================================ /// GET /api/admin/dashboard — full dashboard statistics #[utoipa::path( get, path = "/api/admin/dashboard", responses( (status = 200, description = "Dashboard statistics"), (status = 401, description = "Unauthorized"), (status = 403, description = "Admin required") ), security(("bearerAuth" = [])), tag = "admin" )] pub async fn get_dashboard_stats( State(state): State>, ) -> Result { let auth = state .auth_service .as_ref() .ok_or_else(|| AppError::internal_error("Auth service not configured"))?; let auth_app = &auth.auth_application_service; // Get storage stats from repository (single efficient query) let db_pool = state .db_pool .as_ref() .ok_or_else(|| AppError::internal_error("Database not available"))?; // Use direct SQL for aggregated stats — more efficient than loading all users. // // Scope: internal users only (`is_external = false`). External // accounts (grant-only magic-link / OCM recipients) have no // storage envelope by construction (DB CHECK // `users_external_no_storage`) and cannot be admin // (`users_external_not_admin`), so they'd inflate `total_users` // and `active_users` with rows that don't represent operational // seats. The audit list (`/api/admin/users`) still shows every // account; only the dashboard totals filter externals out. let stats_row = sqlx::query( r#" SELECT COUNT(*)::INT8 as total_users, COUNT(*) FILTER (WHERE active = true)::INT8 as active_users, COUNT(*) FILTER (WHERE role::text = 'admin')::INT8 as admin_users, COUNT(*) FILTER (WHERE storage_quota_bytes > 0 AND storage_used_bytes > storage_quota_bytes * 0.8)::INT8 as users_over_80, COUNT(*) FILTER (WHERE storage_quota_bytes > 0 AND storage_used_bytes > storage_quota_bytes)::INT8 as users_over_quota FROM auth.users WHERE is_external = false "# ) .fetch_one(db_pool.as_ref()) .await .map_err(|e| AppError::internal_error(format!("Database query failed: {}", e)))?; // External account count — distinct query (not FILTERed into // `stats_row` above) because `stats_row` scopes to // `is_external = false` for the operational-seat counts. // Externals form their own population; the dashboard renders them // as a separate stat card in the "User accounts" section. let external_users: i64 = sqlx::query_scalar(r#"SELECT COUNT(*)::INT8 FROM auth.users WHERE is_external = true"#) .fetch_one(db_pool.as_ref()) .await .map_err(|e| AppError::internal_error(format!("External user count failed: {}", e)))?; // Live-activity counts — projection over auth.sessions, same // `ONLINE_WINDOW` (5 min) the Prometheus gauges use so the // dashboard number, admin-table green dot, and // `oxicloud_sessions_online` scrape all agree by construction. // Bound as `$1 = window_secs` via `make_interval(secs => $1)` // to keep the single-source-of-truth pattern (no SQL literal // for the window). Both queries hit the partial index // `idx_sessions_last_seen_at WHERE revoked = FALSE` so per-run // cost is ~μs even at tens of thousands of session rows. let online_window_secs: f64 = crate::application::dtos::session_dto::ONLINE_WINDOW.as_secs_f64(); let online_sessions: i64 = sqlx::query_scalar( r#" SELECT COUNT(*)::INT8 FROM auth.sessions WHERE revoked = FALSE AND last_seen_at > NOW() - make_interval(secs => $1) "#, ) .bind(online_window_secs) .fetch_one(db_pool.as_ref()) .await .map_err(|e| AppError::internal_error(format!("Online session count failed: {}", e)))?; let online_users: i64 = sqlx::query_scalar( r#" SELECT COUNT(DISTINCT user_id)::INT8 FROM auth.sessions WHERE revoked = FALSE AND last_seen_at > NOW() - make_interval(secs => $1) "#, ) .bind(online_window_secs) .fetch_one(db_pool.as_ref()) .await .map_err(|e| AppError::internal_error(format!("Online user count failed: {}", e)))?; use sqlx::Row; // Per-drive-kind quota panel: // // - **Personal** rolls up via the user envelope // (`auth.users.storage_quota_bytes`; `= 0` means unlimited), // because personal drives inherit their cap from the user per // `docs/plan/drive.md`. The "N unlimited" here counts USERS // with unlimited envelope, not drives. // - **Shared** uses `storage.drives.quota_bytes` directly // (`IS NULL` means unlimited). // // Both rows sum `used_bytes` — for personal that's // `auth.users.storage_used_bytes`, which is itself // `SUM(drives.used_bytes) WHERE kind='personal'` per the sweep // in `storage_usage_service.rs`. For shared it's the drive's own // `used_bytes`. Trashed files are excluded from both — see // `bug_trash_excluded_from_quota` for the known gap. let personal_row = sqlx::query( r#" SELECT COALESCE(SUM(storage_used_bytes)::INT8, 0) AS used_bytes, COALESCE(SUM(storage_quota_bytes) FILTER (WHERE storage_quota_bytes > 0)::INT8, 0) AS capped_quota_bytes, COUNT(*) FILTER (WHERE storage_quota_bytes = 0)::INT8 AS unlimited_count, COUNT(*) FILTER (WHERE storage_quota_bytes > 0)::INT8 AS capped_count FROM auth.users WHERE is_external = false "#, ) .fetch_one(db_pool.as_ref()) .await .map_err(|e| AppError::internal_error(format!("Personal-drive stats failed: {}", e)))?; let shared_row = sqlx::query( r#" SELECT COALESCE(SUM(used_bytes)::INT8, 0) AS used_bytes, COALESCE(SUM(quota_bytes) FILTER (WHERE quota_bytes IS NOT NULL)::INT8, 0) AS capped_quota_bytes, COUNT(*) FILTER (WHERE quota_bytes IS NULL)::INT8 AS unlimited_count, COUNT(*) FILTER (WHERE quota_bytes IS NOT NULL)::INT8 AS capped_count FROM storage.drives WHERE kind::text = 'shared' "#, ) .fetch_one(db_pool.as_ref()) .await .map_err(|e| AppError::internal_error(format!("Shared-drive stats failed: {}", e)))?; let build_row = |kind: &str, row: sqlx::postgres::PgRow| DriveKindUsageDto { kind: kind.to_string(), used_bytes: row.get("used_bytes"), // Only surface the cap when there's at least one capped drive // — else the FE would render "0 / 0 (NaN%)" for a kind that's // entirely unlimited. capped_quota_bytes: { let capped_count: i64 = row.get("capped_count"); if capped_count > 0 { Some(row.get("capped_quota_bytes")) } else { None } }, unlimited_count: row.get("unlimited_count"), capped_count: row.get("capped_count"), }; let drive_usage = vec![ build_row("personal", personal_row), build_row("shared", shared_row), ]; // Backend physical stats (post-dedup, post-encryption) — // rendered in the dashboard's "Backend Storage" card next to // the user-quota panel. Same source `StorageSettingsDto` uses; // cheap aggregate over `storage.blobs`. let dedup_stats = state.core.dedup_service.get_stats().await; let stats = DashboardStatsDto { server_version: env!("CARGO_PKG_VERSION").to_string(), auth_enabled: true, oidc_configured: auth_app.oidc_enabled(), quotas_enabled: true, // Feature flag could be checked here total_users: stats_row.get("total_users"), active_users: stats_row.get("active_users"), admin_users: stats_row.get("admin_users"), external_users, online_users, online_sessions, drive_usage, users_over_80_percent: stats_row.get("users_over_80"), users_over_quota: stats_row.get("users_over_quota"), total_bytes_stored: Some(dedup_stats.total_bytes_stored as i64), dedup_ratio: Some(dedup_stats.dedup_ratio), registration_enabled: { if let Some(svc) = state.admin_settings_service.as_ref() { svc.get_registration_enabled().await } else { true // default: enabled } }, }; Ok(Json(stats)) } // ============================================================================ // User Management // ============================================================================ /// GET /api/admin/users?limit=50&offset=0 — list all users /// /// Always returns `Vec` — the shape one row of the /// `/me` response's embedded `full` block carries. The former /// `?summary` toggle (flat `PublicUserDto` vs nested `FullUserDto`) /// has been retired: admin listing is low-volume and the FE always /// asked for the nested shape anyway, so the two-shape split served /// no caller and only invited jq-path bugs. See /// `docs/plan/userdto-refactor.md`. #[utoipa::path( get, path = "/api/admin/users", params( ("limit" = Option, Query, description = "Max users to return (default 100, max 500)"), ("offset" = Option, Query, description = "Pagination offset") ), responses( (status = 200, description = "List of users"), (status = 401, description = "Unauthorized"), (status = 403, description = "Admin required") ), security(("bearerAuth" = [])), tag = "admin" )] pub async fn list_users( State(state): State>, auth_user: AuthUser, Query(query): Query, ) -> Result { let auth = state .auth_service .as_ref() .ok_or_else(|| AppError::internal_error("Auth service not configured"))?; let limit = query.limit.unwrap_or(100).min(500); let offset = query.offset.unwrap_or(0); // Admin surface must show *every* account for audit — grant-only // magic-link / OCM recipients (is_external = true) included. The // internal-only variant is used by system address book / sharee // search, where surfacing externals would leak identities. See // `auth_application_service::list_users` doc for the split. let users = auth .auth_application_service .list_user_summaries_including_external_with_perms( state.authorization.as_ref(), auth_user.id, limit, offset, ) .await .map_err(AppError::from)?; let total = auth .auth_application_service .count_users_efficient() .await .unwrap_or(0); Ok(Json(AdminUsersPageResponse { users, total, limit, offset, })) } /// GET /api/admin/users/:id — get single user #[utoipa::path( get, path = "/api/admin/users/{id}", params(("id" = String, Path, description = "User UUID")), responses( (status = 200, description = "User details"), (status = 401, description = "Unauthorized"), (status = 403, description = "Admin required"), (status = 404, description = "User not found") ), security(("bearerAuth" = [])), tag = "admin" )] pub async fn get_user( State(state): State>, Path(id): Path, ) -> Result { let id = Uuid::parse_str(&id).map_err(|_| AppError::bad_request("Invalid UUID"))?; let auth = state .auth_service .as_ref() .ok_or_else(|| AppError::internal_error("Auth service not configured"))?; let user = auth .auth_application_service .get_user_admin(id) .await .map_err(|e| AppError::not_found(format!("User not found: {}", e)))?; Ok(Json(user)) } /// DELETE /api/admin/users/:id — delete a user #[utoipa::path( delete, path = "/api/admin/users/{id}", params(("id" = String, Path, description = "User UUID")), responses( (status = 200, description = "User deleted"), (status = 400, description = "Cannot delete own account"), (status = 401, description = "Unauthorized"), (status = 403, description = "Admin required") ), security(("bearerAuth" = [])), tag = "admin" )] pub async fn delete_user( State(state): State>, auth_user: AuthUser, Path(id): Path, ) -> Result { let admin_id = auth_user.id; let id = Uuid::parse_str(&id).map_err(|_| AppError::bad_request("Invalid UUID"))?; // Prevent self-deletion if admin_id == id { return Err(AppError::new( StatusCode::BAD_REQUEST, "Cannot delete your own account", "SelfDeletion", )); } let auth = state .auth_service .as_ref() .ok_or_else(|| AppError::internal_error("Auth service not configured"))?; auth.auth_application_service .delete_user_admin(id) .await .map_err(|e| AppError::internal_error(format!("Failed to delete user: {}", e)))?; Ok(( StatusCode::OK, Json(serde_json::json!({ "message": "User deleted successfully" })), )) } /// GET /api/admin/sessions?user_id=&include_revoked=&limit=&offset= — list sessions /// /// Global cross-user listing by default. `user_id` narrows to one /// user; omit for cross-user. `include_revoked=true` opts into /// showing revoked / expired rows for forensics (default hides). /// Response is `{sessions, limit, offset}` — no total count (would /// require a second scan; the panel paginates on presence of /// exactly `limit` rows returned). #[utoipa::path( get, path = "/api/admin/sessions", params( ("user_id" = Option, Query, description = "Narrow to one user (UUID); omit for cross-user"), ("include_revoked" = Option, Query, description = "Include revoked + expired rows (default false — active only)"), ("limit" = Option, Query, description = "Max rows to return (default 100, max 500)"), ("offset" = Option, Query, description = "Pagination offset") ), responses( (status = 200, description = "List of sessions"), (status = 401, description = "Unauthorized"), (status = 403, description = "Admin required") ), security(("bearerAuth" = [])), tag = "admin" )] pub async fn list_sessions( State(state): State>, auth_user: AuthUser, Query(query): Query, ) -> Result { let auth = state .auth_service .as_ref() .ok_or_else(|| AppError::internal_error("Auth service not configured"))?; let limit = query.limit.unwrap_or(100).min(500); let offset = query.offset.unwrap_or(0); let include_revoked = query.include_revoked.unwrap_or(false); let user_id_filter = match query.user_id.as_deref() { Some(s) => Some(Uuid::parse_str(s).map_err(|_| AppError::bad_request("Invalid user_id"))?), None => None, }; // Pass the caller's DPoP thumbprint through the SessionCaller // wrapper so the DTO can flag which row is the admin's own // current session (`is_current = true`). Rendered as a "this // is you" badge — prevents accidentally revoking the session // the click came from. `None` when the admin is unbound (rare // — legacy / migration-window sessions), in which case no row // highlights. let caller = crate::application::dtos::session_dto::SessionCaller { id: auth_user.id, dpop_jkt: auth_user.dpop_jkt.as_deref(), }; let sessions = auth .auth_application_service .admin_list_sessions_with_perms( state.authorization.as_ref(), caller, user_id_filter, include_revoked, limit, offset, ) .await .map_err(AppError::from)?; // Also publish the current access-token TTL so the admin panel can // render an honest "revoke takes effect within N seconds" warning // above the table. Revoking a session flips the DB row (breaks the // refresh path), but any in-flight JWT stays valid until its `exp` // — which is `access_token_expiry_secs` from now. Showing this // number keeps the UX honest instead of implying instant kill. let access_token_expiry_secs = state.core.config.auth.access_token_expiry_secs; Ok(Json(serde_json::json!({ "sessions": sessions, "limit": limit, "offset": offset, "access_token_expiry_secs": access_token_expiry_secs, }))) } /// DELETE /api/admin/sessions/:id — revoke a session #[utoipa::path( delete, path = "/api/admin/sessions/{id}", params(("id" = String, Path, description = "Session UUID")), responses( (status = 200, description = "Session revoked"), (status = 400, description = "Invalid UUID"), (status = 401, description = "Unauthorized"), (status = 403, description = "Admin required"), (status = 404, description = "Session not found") ), security(("bearerAuth" = [])), tag = "admin" )] pub async fn revoke_session( State(state): State>, auth_user: AuthUser, Path(id): Path, ) -> Result { let session_id = Uuid::parse_str(&id).map_err(|_| AppError::bad_request("Invalid UUID"))?; let auth = state .auth_service .as_ref() .ok_or_else(|| AppError::internal_error("Auth service not configured"))?; let caller = crate::application::dtos::session_dto::SessionCaller { id: auth_user.id, dpop_jkt: auth_user.dpop_jkt.as_deref(), }; auth.auth_application_service .admin_revoke_session_with_perms(state.authorization.as_ref(), caller, session_id) .await .map_err(AppError::from)?; Ok(( StatusCode::OK, Json(serde_json::json!({ "message": "Session revoked" })), )) } /// PUT /api/admin/users/:id/role — change user role #[utoipa::path( put, path = "/api/admin/users/{id}/role", params(("id" = String, Path, description = "User UUID")), responses( (status = 200, description = "Role updated"), (status = 400, description = "Cannot change own role"), (status = 401, description = "Unauthorized"), (status = 403, description = "Admin required") ), security(("bearerAuth" = [])), tag = "admin" )] pub async fn update_user_role( State(state): State>, auth_user: AuthUser, Path(id): Path, Json(dto): Json, ) -> Result { let admin_id = auth_user.id; let id = Uuid::parse_str(&id).map_err(|_| AppError::bad_request("Invalid UUID"))?; // Prevent changing own role if admin_id == id { return Err(AppError::new( StatusCode::BAD_REQUEST, "Cannot change your own role", "SelfRoleChange", )); } let auth = state .auth_service .as_ref() .ok_or_else(|| AppError::internal_error("Auth service not configured"))?; auth.auth_application_service .change_user_role(id, &dto.role) .await .map_err(|e| AppError::internal_error(format!("Failed to change role: {}", e)))?; Ok(( StatusCode::OK, Json(serde_json::json!({ "message": format!("User role updated to '{}'", dto.role) })), )) } /// PUT /api/admin/users/:id/active — activate/deactivate user #[utoipa::path( put, path = "/api/admin/users/{id}/active", params(("id" = String, Path, description = "User UUID")), responses( (status = 200, description = "User active status updated"), (status = 400, description = "Cannot deactivate own account"), (status = 401, description = "Unauthorized"), (status = 403, description = "Admin required") ), security(("bearerAuth" = [])), tag = "admin" )] pub async fn update_user_active( State(state): State>, auth_user: AuthUser, Path(id): Path, Json(dto): Json, ) -> Result { let admin_id = auth_user.id; let id = Uuid::parse_str(&id).map_err(|_| AppError::bad_request("Invalid UUID"))?; // Prevent deactivating yourself if admin_id == id && !dto.active { return Err(AppError::new( StatusCode::BAD_REQUEST, "Cannot deactivate your own account", "SelfDeactivation", )); } let auth = state .auth_service .as_ref() .ok_or_else(|| AppError::internal_error("Auth service not configured"))?; auth.auth_application_service .set_user_active(id, dto.active) .await .map_err(|e| AppError::internal_error(format!("Failed to update user status: {}", e)))?; let status = if dto.active { "activated" } else { "deactivated" }; Ok(( StatusCode::OK, Json(serde_json::json!({ "message": format!("User {}", status) })), )) } /// PUT /api/admin/users/:id/quota — update user storage quota #[utoipa::path( put, path = "/api/admin/users/{id}/quota", params(("id" = String, Path, description = "User UUID")), responses( (status = 200, description = "Quota updated"), (status = 401, description = "Unauthorized"), (status = 403, description = "Admin required") ), security(("bearerAuth" = [])), tag = "admin" )] pub async fn update_user_quota( State(state): State>, Path(id): Path, Json(dto): Json, ) -> Result { let id = Uuid::parse_str(&id).map_err(|_| AppError::bad_request("Invalid UUID"))?; let auth = state .auth_service .as_ref() .ok_or_else(|| AppError::internal_error("Auth service not configured"))?; auth.auth_application_service .update_user_quota(id, dto.quota_bytes) .await .map_err(|e| AppError::internal_error(format!("Failed to update quota: {}", e)))?; Ok(( StatusCode::OK, Json(serde_json::json!({ "message": "User quota updated", "quota_bytes": dto.quota_bytes, })), )) } // ============================================================================ // Admin User Creation & Password Reset // ============================================================================ /// POST /api/admin/users — create a new user (admin only) #[utoipa::path( post, path = "/api/admin/users", responses( (status = 201, description = "User created"), (status = 400, description = "Invalid user data"), (status = 401, description = "Unauthorized"), (status = 403, description = "Admin required") ), security(("bearerAuth" = [])), tag = "admin" )] pub async fn create_user( State(state): State>, Json(dto): Json, ) -> Result { let auth = state .auth_service .as_ref() .ok_or_else(|| AppError::internal_error("Auth service not configured"))?; let user = auth .auth_application_service .admin_create_user(dto) .await .map_err(|e| { AppError::new( StatusCode::BAD_REQUEST, format!("Failed to create user: {}", e), "CreateUserFailed", ) })?; Ok((StatusCode::CREATED, Json(user))) } /// PUT /api/admin/users/:id/password — reset a user's password (admin only) #[utoipa::path( put, path = "/api/admin/users/{id}/password", params(("id" = String, Path, description = "User UUID")), responses( (status = 200, description = "Password reset"), (status = 400, description = "Invalid password"), (status = 401, description = "Unauthorized"), (status = 403, description = "Admin required") ), security(("bearerAuth" = [])), tag = "admin" )] pub async fn reset_user_password( State(state): State>, Path(id): Path, Json(dto): Json, ) -> Result { let id = Uuid::parse_str(&id).map_err(|_| AppError::bad_request("Invalid UUID"))?; let auth = state .auth_service .as_ref() .ok_or_else(|| AppError::internal_error("Auth service not configured"))?; auth.auth_application_service .admin_reset_password(id, &dto.new_password) .await .map_err(|e| { AppError::new( StatusCode::BAD_REQUEST, format!("Failed to reset password: {}", e), "ResetPasswordFailed", ) })?; Ok(( StatusCode::OK, Json(serde_json::json!({ "message": "Password reset successfully" })), )) } /// POST /api/admin/users/{id}/promote-to-internal — flip an external /// (grant-only) account into a normal internal account, provisioning /// its personal drive on the way. The deployment MUST have magic-link /// login enabled (the admin doesn't set the user's password on their /// behalf, so the promoted user needs some way to log in). Refuses /// OIDC-linked users and users who are already internal. #[utoipa::path( post, path = "/api/admin/users/{id}/promote-to-internal", params(("id" = String, Path, description = "Target user id")), responses( (status = 200, description = "User promoted", body = PublicUserDto), (status = 400, description = "Magic-link login is disabled on this deployment"), (status = 401, description = "Unauthorized"), (status = 403, description = "Admin required (or target is OIDC-linked)"), (status = 404, description = "User not found"), (status = 409, description = "User is already internal"), ), security(("bearerAuth" = [])), tag = "admin" )] pub async fn admin_promote_external_to_internal( State(state): State>, auth_user: AuthUser, Path(id): Path, ) -> Result { let target_id = Uuid::parse_str(&id).map_err(|_| AppError::bad_request("Invalid UUID"))?; let auth = state .auth_service .as_ref() .ok_or_else(|| AppError::internal_error("Auth service not configured"))?; let dto = auth .auth_application_service .admin_promote_external_to_internal(auth_user.id, target_id) .await .map_err(AppError::from)?; Ok((StatusCode::OK, Json(dto))) } // ============================================================================ // Registration Control // ============================================================================ /// PUT /api/admin/settings/registration — enable/disable public registration #[utoipa::path( put, path = "/api/admin/settings/registration", responses( (status = 200, description = "Registration setting updated"), (status = 400, description = "Missing field"), (status = 401, description = "Unauthorized"), (status = 403, description = "Admin required") ), security(("bearerAuth" = [])), tag = "admin" )] pub async fn set_registration_setting( State(state): State>, auth_user: AuthUser, Json(body): Json, ) -> Result { let admin_id = auth_user.id; let enabled = body .get("registration_enabled") .and_then(|v| v.as_bool()) .ok_or_else(|| { AppError::new( StatusCode::BAD_REQUEST, "Missing boolean field 'registration_enabled'", "InvalidInput", ) })?; let svc = state .admin_settings_service .as_ref() .ok_or_else(|| AppError::internal_error("Admin settings service not available"))?; svc.set_registration_enabled(enabled, admin_id) .await .map_err(|e| AppError::internal_error(format!("Failed to save setting: {}", e)))?; Ok(( StatusCode::OK, Json(serde_json::json!({ "message": format!("Public registration {}", if enabled { "enabled" } else { "disabled" }), "registration_enabled": enabled, })), )) } async fn reextract_audio_metadata( State(state): State>, ) -> Result { let audio_service = state .applications .audio_metadata_service .as_ref() .ok_or_else(|| AppError::internal_error("Audio metadata service not available"))?; let result = audio_service .reextract_all_audio_metadata() .await .map_err(|e| { AppError::internal_error(format!("Failed to re-extract audio metadata: {}", e)) })?; Ok(Json(serde_json::json!({ "message": "Audio metadata extraction complete", "total": result.total, "processed": result.processed, "failed": result.failed, }))) } /// Backfill image/video capture dates (EXIF / container creation time) into /// `storage.file_metadata` for every existing media file, re-bucketing the /// Photos timeline by real capture date. Safe to re-run (idempotent upsert). async fn reextract_image_metadata( State(state): State>, ) -> Result { let result = state .applications .media_metadata_service .reextract_all_image_metadata() .await .map_err(|e| { AppError::internal_error(format!("Failed to re-extract capture metadata: {}", e)) })?; Ok(Json(serde_json::json!({ "message": "Image/video capture-metadata extraction complete", "total": result.total, "processed": result.processed, "failed": result.failed, }))) } // ───────────────────────────────────────────────────── // SMTP diagnostics // ───────────────────────────────────────────────────── // // The SMTP backend is configured exclusively via OXICLOUD_SMTP_* env // vars (see docs/config/env.md). The admin UI uses these two endpoints // purely for diagnostics: // - `get_smtp_info` shows the current runtime config (read-only — no // write endpoint exists; operators edit `.env` and restart). // - `send_smtp_test` sends a hardcoded confirmation mail to a // recipient supplied by the admin, returning the SMTP server's // response so the operator can correlate it with their relay logs. /// GET /api/admin/smtp/info — read-only view of the running SMTP config. #[utoipa::path( get, path = "/api/admin/smtp/info", responses( (status = 200, description = "Current SMTP settings", body = SmtpInfoDto), (status = 401, description = "Unauthorized"), (status = 403, description = "Admin required"), ), security(("bearerAuth" = [])), tag = "admin" )] pub async fn get_smtp_info( State(state): State>, ) -> Result { let smtp = &state.core.config.smtp; let info = SmtpInfoDto { enabled: smtp.is_enabled() && state.email_sender.is_some(), host: smtp.host.clone(), port: smtp.port, tls: match smtp.tls { crate::common::config::SmtpTlsMode::Starttls => "starttls".to_string(), crate::common::config::SmtpTlsMode::Tls => "tls".to_string(), crate::common::config::SmtpTlsMode::None => "none".to_string(), }, from: smtp.from.clone(), user_state: if smtp.user.is_empty() { "" } else { "" }, }; Ok(Json(info)) } /// GET /api/admin/smtp/test/captured?to= — test-only inbox lookup. /// /// Returns the most recently captured outbound message for `to` when /// `OXICLOUD_SMTP_MOCK=true`. In production / non-mock mode this /// returns 404 to keep the endpoint inert. async fn get_captured_email( State(state): State>, Query(params): Query, ) -> Result { if !std::env::var("OXICLOUD_SMTP_MOCK") .map(|v| v == "true" || v == "1") .unwrap_or(false) { return Err(AppError::not_found( "Capture endpoint is only available when OXICLOUD_SMTP_MOCK=true", )); } let recipient = params.to.trim(); if recipient.is_empty() { return Err(AppError::bad_request("`to` query parameter is required")); } let Some(mock) = state.mock_email_sender.as_ref() else { return Err(AppError::not_found( "Mock sender is not active (set OXICLOUD_SMTP_MOCK=true)", )); }; match mock.last_for(recipient).await { Some(captured) => Ok(Json((*captured).clone())), None => Err(AppError::not_found(format!( "No captured message for '{}'", recipient ))), } } #[derive(Debug, serde::Deserialize)] struct CapturedEmailQuery { to: String, } /// POST /api/admin/smtp/test — send a diagnostic email to `dto.to`. /// /// Returns 200 regardless of SMTP outcome; the body's `success` flag /// + `code`/`message` (or `error`) tell the frontend what to render. /// This keeps SMTP-level failures (4xx/5xx replies, connection /// timeouts) as ordinary diagnostic data rather than HTTP errors. #[utoipa::path( post, path = "/api/admin/smtp/test", request_body = SendSmtpTestDto, responses( (status = 200, description = "Send attempt completed", body = SmtpTestResultDto), (status = 401, description = "Unauthorized"), (status = 403, description = "Admin required"), (status = 503, description = "SMTP not configured"), ), security(("bearerAuth" = [])), tag = "admin" )] pub async fn send_smtp_test( State(state): State>, auth_user: AuthUser, Json(dto): Json, ) -> Result { let admin_id = auth_user.id; let recipient = dto.to.trim().to_string(); if recipient.is_empty() { return Err(AppError::bad_request("Recipient address is required")); } let sender = state.email_sender.as_ref().ok_or_else(|| { AppError::new( StatusCode::SERVICE_UNAVAILABLE, "SMTP is not configured (set OXICLOUD_SMTP_HOST in .env to enable)", "ServiceUnavailable", ) })?; let message = crate::application::ports::email_sender::EmailMessage { to: recipient.clone(), subject: "OxiCloud SMTP test".to_string(), text_body: format!( "This is a diagnostic message sent from your OxiCloud instance.\n\ \n\ If you are reading this, your SMTP relay accepted the message — \ outbound email is wired up correctly.\n\ \n\ Triggered by admin user id {} on {}.\n", admin_id, chrono::Utc::now().to_rfc3339(), ), html_body: None, }; tracing::info!( target: "audit", event = "smtp.test_send", admin_id = %admin_id, recipient = %recipient, ); let result = match sender.send(message).await { Ok(outcome) => { tracing::info!( target: "audit", event = "smtp.test_send_ok", admin_id = %admin_id, recipient = %recipient, code = outcome.code, message = %outcome.message, ); SmtpTestResultDto { success: true, code: Some(outcome.code), message: Some(outcome.message), error: None, } } Err(e) => { tracing::warn!( target: "audit", event = "smtp.test_send_failed", admin_id = %admin_id, recipient = %recipient, error = %e.message, ); SmtpTestResultDto { success: false, code: None, message: None, error: Some(e.message), } } }; Ok(Json(result)) } // ---- Plugin management ----------------------------------------------------- /// Resolve the plugin-management port, or 503 when plugins are compiled out or /// disabled via `OXICLOUD_ENABLE_PLUGINS`. The admin UI treats this 503 as the /// "plugins disabled" state rather than an error. fn plugin_mgmt(state: &AppState) -> Result<&Arc, AppError> { state.plugin_management.as_ref().ok_or_else(|| { AppError::new( StatusCode::SERVICE_UNAVAILABLE, "Plugins are disabled", "PluginsDisabled", ) }) } /// Map a management-layer error to an HTTP error. NotFound → 404, IdExists → /// 409, Rejected → 400 (with the stable reason key in the message), Io → 500. fn map_mgmt_err(err: &PluginMgmtError) -> AppError { match err { PluginMgmtError::NotFound => AppError::not_found("Plugin not found"), PluginMgmtError::IdExists => { AppError::conflict("A plugin with this id is already installed") } PluginMgmtError::Rejected(reason) => AppError::new( StatusCode::BAD_REQUEST, format!("Plugin rejected: {reason}"), "PluginRejected", ), PluginMgmtError::Io(msg) => { AppError::internal_error(format!("Plugin operation failed: {msg}")) } } } /// GET /api/admin/plugins — list installed plugins. pub async fn list_plugins( State(state): State>, ) -> Result { let mgmt = plugin_mgmt(&state)?; let plugins: Vec = mgmt.list().into_iter().map(PluginInfoDto::from).collect(); // `enabled` reports that the plugin *subsystem* is active (reaching here // means it is — `plugin_mgmt` returns 503 otherwise, which the UI reads as // the disabled state). Per-plugin enablement is each entry's own `enabled`. Ok(( StatusCode::OK, Json(serde_json::json!({ "enabled": true, "plugins": plugins })), )) } /// PUT /api/admin/plugins/{id}/enabled — enable or disable a plugin. pub async fn set_plugin_enabled( State(state): State>, auth_user: AuthUser, Path(id): Path, Json(dto): Json, ) -> Result { let admin_id = auth_user.id; let mgmt = plugin_mgmt(&state)?; mgmt.set_enabled(&id, dto.enabled) .map_err(|e| map_mgmt_err(&e))?; if dto.enabled { tracing::info!( target: "audit", event = "plugin.enabled", plugin_id = %id, admin_id = %admin_id, "👮🏻‍♂️ plugin enabled by admin" ); } else { tracing::info!( target: "audit", event = "plugin.disabled", plugin_id = %id, admin_id = %admin_id, "👮🏻‍♂️ plugin disabled by admin" ); } Ok(( StatusCode::OK, Json(serde_json::json!({ "message": if dto.enabled { "Plugin enabled" } else { "Plugin disabled" }, "id": id, "enabled": dto.enabled, })), )) } /// POST /api/admin/plugins — install a plugin from a multipart body with a /// single `bundle` part: a `.zip` containing `plugin.toml` and its `.wasm`. pub async fn install_plugin( State(state): State>, auth_user: AuthUser, mut multipart: Multipart, ) -> Result { let admin_id = auth_user.id; let mgmt = plugin_mgmt(&state)?; let mut bundle: Option> = None; while let Some(field) = multipart .next_field() .await .map_err(|e| AppError::bad_request(format!("Invalid multipart body: {e}")))? { if field.name() == Some("bundle") { bundle = Some( field .bytes() .await .map_err(|e| AppError::bad_request(format!("Invalid bundle part: {e}")))? .to_vec(), ); } } let bundle = match bundle { Some(b) => b, None => { tracing::warn!( target: "audit", event = "plugin.install_rejected", reason = "missing_part", admin_id = %admin_id, "👮🏻‍♂️ plugin install rejected: missing 'bundle' part" ); return Err(AppError::bad_request("A 'bundle' (.zip) part is required")); } }; match mgmt.install_bundle(bundle) { Ok(info) => { tracing::info!( target: "audit", event = "plugin.installed", plugin_id = %info.id, admin_id = %admin_id, "👮🏻‍♂️ plugin installed by admin" ); Ok((StatusCode::CREATED, Json(PluginInfoDto::from(info)))) } Err(e) => { tracing::warn!( target: "audit", event = "plugin.install_rejected", reason = e.reason(), admin_id = %admin_id, "👮🏻‍♂️ plugin install rejected" ); Err(map_mgmt_err(&e)) } } } /// DELETE /api/admin/plugins/{id} — uninstall a plugin and delete its files. pub async fn delete_plugin( State(state): State>, auth_user: AuthUser, Path(id): Path, ) -> Result { let admin_id = auth_user.id; let mgmt = plugin_mgmt(&state)?; mgmt.remove(&id).map_err(|e| map_mgmt_err(&e))?; tracing::info!( target: "audit", event = "plugin.removed", plugin_id = %id, admin_id = %admin_id, "👮🏻‍♂️ plugin removed by admin" ); Ok(( StatusCode::OK, Json(serde_json::json!({ "message": "Plugin removed", "id": id })), )) } /// GET /api/admin/plugins/{id}/logs — a filtered, paginated page of a plugin's /// structured log entries (newest first). pub async fn get_plugin_logs( State(state): State>, Path(id): Path, Query(q): Query, ) -> Result { let mgmt = plugin_mgmt(&state)?; let limit = q.limit.unwrap_or(50).clamp(1, 500); let offset = q.offset.unwrap_or(0); let page = mgmt .read_logs( &id, LogQuery { level: q.level, search: q.search, offset, limit, }, ) .await .map_err(|e| map_mgmt_err(&e))?; Ok(Json(PluginLogPageDto::from_page(page, limit, offset))) } /// DELETE /api/admin/plugins/{id}/logs — wipe a plugin's persisted logs. pub async fn clear_plugin_logs( State(state): State>, auth_user: AuthUser, Path(id): Path, ) -> Result { let admin_id = auth_user.id; let mgmt = plugin_mgmt(&state)?; mgmt.clear_logs(&id).await.map_err(|e| map_mgmt_err(&e))?; tracing::info!( target: "audit", event = "plugin.logs_cleared", plugin_id = %id, admin_id = %admin_id, "👮🏻‍♂️ plugin logs cleared by admin" ); Ok(( StatusCode::OK, Json(serde_json::json!({ "message": "Plugin logs cleared", "id": id })), )) } /// GET /api/admin/plugins/{id}/logs/stream — Server-Sent Events live tail. Each /// `message` event carries one new log entry (JSON); a `lagged` event signals /// the client should resync after falling behind. Auth rides the access cookie, /// so `EventSource` works without setting headers. pub async fn stream_plugin_logs( State(state): State>, Path(id): Path, ) -> Result { use tokio_stream::StreamExt; use tokio_stream::wrappers::{BroadcastStream, errors::BroadcastStreamRecvError}; let mgmt = plugin_mgmt(&state)?; if !mgmt.list().iter().any(|p| p.id == id) { return Err(AppError::not_found("Plugin not found")); } let rx = mgmt.subscribe_logs(); let want = id; let stream = BroadcastStream::new(rx).filter_map(move |res| match res { Ok(ev) if ev.plugin_id == want => { let dto = PluginLogEntryDto::from(ev.entry); let event = Event::default() .json_data(&dto) .unwrap_or_else(|_| Event::default().comment("serialize error")); Some(Ok::(event)) } Ok(_) => None, Err(BroadcastStreamRecvError::Lagged(n)) => { Some(Ok(Event::default().event("lagged").data(n.to_string()))) } }); Ok(Sse::new(stream).keep_alive(KeepAlive::default())) } /// GET /api/admin/plugins/{id}/retention — the plugin's effective retention. pub async fn get_plugin_retention( State(state): State>, Path(id): Path, ) -> Result { let mgmt = plugin_mgmt(&state)?; let settings = mgmt .get_retention(&id) .await .map_err(|e| map_mgmt_err(&e))?; Ok(Json(PluginRetentionDto::from(settings))) } /// PUT /api/admin/plugins/{id}/retention — set the plugin's retention policy. pub async fn set_plugin_retention( State(state): State>, auth_user: AuthUser, Path(id): Path, Json(dto): Json, ) -> Result { let admin_id = auth_user.id; let mgmt = plugin_mgmt(&state)?; mgmt.set_retention(&id, dto.into()) .await .map_err(|e| map_mgmt_err(&e))?; tracing::info!( target: "audit", event = "plugin.retention_updated", plugin_id = %id, admin_id = %admin_id, retention_days = dto.retention_days, max_bytes = dto.max_bytes, "👮🏻‍♂️ plugin log retention updated by admin" ); Ok((StatusCode::OK, Json(dto))) } /// GET /api/admin/drives — list every drive on the system, admin-only. /// /// Distinct from `GET /api/drives`, which is the caller's own listing /// filtered through `role_grants`. An admin who creates a shared drive /// for someone else has no grant on it — but the admin panel still /// needs to see the drive (to audit, to manage, to delete). The admin /// guard at the handler edge is the access control; no role filtering /// happens in the repo (see `drive_repository::list_all`). /// /// Returns rows ordered by display name. `caller_role` is omitted — /// the admin is not necessarily a drive member, so the field would be /// misleading here. #[utoipa::path( get, path = "/api/admin/drives", responses( (status = 200, description = "Every drive on the system", body = Vec), (status = 401, description = "Unauthorized"), (status = 403, description = "Admin required"), ), security(("bearerAuth" = [])), tag = "admin" )] pub async fn list_all_drives( State(state): State>, ) -> Result { let drives = state .drive_repo .list_all() .await .map_err(|e| AppError::internal_error(format!("Failed to list drives: {e}")))?; let dtos: Vec = drives.into_iter().map(DriveDto::from).collect(); Ok((StatusCode::OK, Json(dtos))) } /// GET /api/admin/drives/{id}/members — list every role grant on a drive, /// admin-only. /// /// Distinct from `GET /api/drives/{id}/members` which goes through /// `DriveManagementService::list_members` and requires `Permission::Read` /// on the drive. The admin who created the drive for someone else has /// no role on it, so the user-facing endpoint would 404 for them. /// /// This endpoint reuses the engine's `list_grants_on_resource` directly /// — same query, same shape, just gated by the admin middleware instead /// of by `authz.require`. Returns the same `Vec` so the /// frontend renders it through the existing grant types. #[utoipa::path( get, path = "/api/admin/drives/{id}/members", params(("id" = Uuid, Path, description = "Drive UUID")), responses( (status = 200, description = "Role grants on the drive", body = Vec), (status = 401, description = "Unauthorized"), (status = 403, description = "Admin required"), ), security(("bearerAuth" = [])), tag = "admin" )] pub async fn list_drive_members_admin( State(state): State>, axum::extract::Path(drive_id): axum::extract::Path, ) -> Result { let grants = state .authorization .list_grants_on_resource(Resource::Drive(drive_id)) .await .map_err(AppError::from)?; let dtos: Vec = grants.into_iter().map(GrantDto::from).collect(); Ok((StatusCode::OK, Json(dtos))) } /// Body for `POST /api/admin/drives/{id}/members` and /// `PATCH /api/admin/drives/{id}/members/{kind}/{sid}` — same wire shape /// as the user-facing endpoints, kept here so this handler doesn't pull /// in the regular drive-handler module's DTOs (which would create a /// circular feel between admin and user-facing surfaces). #[derive(Debug, serde::Deserialize, utoipa::ToSchema)] pub struct AdminAddDriveMemberDto { pub subject: SubjectDto, pub role: RoleDto, #[serde(default)] pub expires_at: Option>, } #[derive(Debug, serde::Deserialize, utoipa::ToSchema)] pub struct AdminUpdateDriveMemberDto { pub role: RoleDto, #[serde(default)] pub expires_at: Option>, } fn admin_parse_subject(kind: SubjectTypeDto, id: Uuid) -> Subject { match kind { SubjectTypeDto::User => Subject::User(id), SubjectTypeDto::Group => Subject::Group(id), SubjectTypeDto::Token => Subject::Token(id), } } /// POST /api/admin/drives/{id}/members — add or refresh a member's role /// without holding `Manage` on the drive. Admin-only; bypasses the /// per-drive authz check via the `caller_is_admin = true` argument on /// `DriveManagementService::set_member_role`. Personal-drive guard and /// last-owner protection still apply. #[utoipa::path( post, path = "/api/admin/drives/{id}/members", params(("id" = Uuid, Path, description = "Drive UUID")), request_body = AdminAddDriveMemberDto, responses( (status = 201, description = "Member added", body = GrantDto), (status = 400, description = "Validation error (e.g. last-owner constraint)"), (status = 401, description = "Unauthorized"), (status = 403, description = "Admin required"), (status = 405, description = "Personal drive — membership is immutable"), ), security(("bearerAuth" = [])), tag = "admin" )] pub async fn add_drive_member_admin( State(state): State>, auth_user: AuthUser, axum::extract::Path(drive_id): axum::extract::Path, Json(dto): Json, ) -> Result { let admin_id = auth_user.id; let subject = admin_parse_subject(dto.subject.kind, dto.subject.id); let grant = state .drive_management_service .set_member_role( admin_id, true, drive_id, subject, dto.role.into(), dto.expires_at, ) .await .map_err(AppError::from)?; Ok((StatusCode::CREATED, Json(GrantDto::from(grant)))) } /// PATCH /api/admin/drives/{id}/members/{kind}/{sid} — change a member's /// role / expiry as an admin. Same admin-bypass shape as /// `add_drive_member_admin`. #[utoipa::path( patch, path = "/api/admin/drives/{id}/members/{kind}/{sid}", params( ("id" = Uuid, Path, description = "Drive UUID"), ("kind" = String, Path, description = "Subject kind: user|group|token"), ("sid" = Uuid, Path, description = "Subject UUID"), ), request_body = AdminUpdateDriveMemberDto, responses( (status = 200, description = "Member role updated", body = GrantDto), (status = 400, description = "Validation error (e.g. last-owner demotion)"), (status = 401, description = "Unauthorized"), (status = 403, description = "Admin required"), (status = 405, description = "Personal drive — membership is immutable"), ), security(("bearerAuth" = [])), tag = "admin" )] pub async fn update_drive_member_admin( State(state): State>, auth_user: AuthUser, axum::extract::Path((drive_id, kind, subject_id)): axum::extract::Path<( Uuid, SubjectTypeDto, Uuid, )>, Json(dto): Json, ) -> Result { let admin_id = auth_user.id; let subject = admin_parse_subject(kind, subject_id); let grant = state .drive_management_service .set_member_role( admin_id, true, drive_id, subject, dto.role.into(), dto.expires_at, ) .await .map_err(AppError::from)?; Ok((StatusCode::OK, Json(GrantDto::from(grant)))) } /// DELETE /api/admin/drives/{id}/members/{kind}/{sid} — remove a /// member as an admin. Bypasses `Manage`; keeps last-owner protection. #[utoipa::path( delete, path = "/api/admin/drives/{id}/members/{kind}/{sid}", params( ("id" = Uuid, Path, description = "Drive UUID"), ("kind" = String, Path, description = "Subject kind: user|group|token"), ("sid" = Uuid, Path, description = "Subject UUID"), ), responses( (status = 204, description = "Member removed (or wasn't a member — idempotent)"), (status = 400, description = "Last-owner protection — promote another member first"), (status = 401, description = "Unauthorized"), (status = 403, description = "Admin required"), (status = 405, description = "Personal drive — membership is immutable"), ), security(("bearerAuth" = [])), tag = "admin" )] pub async fn remove_drive_member_admin( State(state): State>, auth_user: AuthUser, axum::extract::Path((drive_id, kind, subject_id)): axum::extract::Path<( Uuid, SubjectTypeDto, Uuid, )>, ) -> Result { let admin_id = auth_user.id; let subject = admin_parse_subject(kind, subject_id); state .drive_management_service .remove_member(admin_id, true, drive_id, subject) .await .map_err(AppError::from)?; Ok(StatusCode::NO_CONTENT) } /// `DELETE /api/admin/drives/{id}` — admin-only drive delete (D3b). /// /// Same shape as the user-facing `DELETE /api/drives/{id}`, but /// bypasses the per-drive `Manage` check (the admin guard at the /// route edge is the access control). The remaining invariants — /// default Personal drive is undeletable, drive must be empty — still /// apply: an admin can't accidentally wipe a populated drive or the /// default home folder of any user. Audit emits /// `drive.deleted_via_admin` on success. #[utoipa::path( delete, path = "/api/admin/drives/{id}", params(("id" = Uuid, Path, description = "Drive UUID")), responses( (status = 204, description = "Drive deleted"), (status = 401, description = "Unauthorized"), (status = 403, description = "Admin required"), (status = 405, description = "Default Personal drive — undeletable"), (status = 409, description = "Drive is not empty"), ), security(("bearerAuth" = [])), tag = "admin" )] pub async fn delete_drive_admin( State(state): State>, auth_user: AuthUser, axum::extract::Path(drive_id): axum::extract::Path, ) -> Result { let admin_id = auth_user.id; state .drive_management_service .delete_drive(admin_id, true, drive_id) .await .map_err(AppError::from)?; Ok(StatusCode::NO_CONTENT) } // ───────────────────────────────────────────────────── // JobRegistry admin surface (`/api/admin/jobs/*`) // ───────────────────────────────────────────────────── /// `GET /api/admin/jobs` — enumerate every registered job with its /// interval, next-run/last-run timestamps, and last outcome. /// /// Production endpoint, always on. Read-only, so no audit line — /// the standard admin-middleware auth check is enough. #[utoipa::path( get, path = "/api/admin/jobs", responses( (status = 200, description = "Jobs listed"), (status = 401, description = "Unauthorized"), (status = 403, description = "Admin required"), ), security(("bearerAuth" = [])), tag = "admin" )] pub async fn list_jobs(State(state): State>) -> impl IntoResponse { let mut summary = state.core.job_registry.snapshot().await; // Enrich with paused-run info for recoverable jobs so the admin // panel can render "Resume (scanned/total)" on the row instead of // just "Run". One indexed SELECT hits `jobs.recoverable_runs` // (`one_active_run_per_job` partial UNIQUE keys the lookup); // failures fall back to the pre-enrichment shape so the endpoint // stays useful when the jobs DB is temporarily unreachable. if let Some(pool) = state.db_pool.as_ref() { let paused_rows: Vec<(String, uuid::Uuid, Option, Option)> = sqlx::query_as( r#" SELECT job_name, id, (stats ->> 'scanned_count')::BIGINT AS scanned, (params ->> 'total_rows')::BIGINT AS total FROM jobs.recoverable_runs WHERE status = 'Paused' "#, ) .fetch_all(pool.as_ref()) .await .unwrap_or_default(); let by_name: std::collections::HashMap = paused_rows .into_iter() .map(|(name, id, scanned, total)| { ( name, PausedRunBrief { id, scanned: scanned.unwrap_or(0).max(0) as u64, total: total.filter(|t| *t > 0).map(|t| t as u64), }, ) }) .collect(); for job in summary.iter_mut() { if job.recoverable && !job.running && let Some(paused) = by_name.get(&job.name) { job.paused_run = Some(paused.clone()); } } } (StatusCode::OK, Json(summary)).into_response() } /// Query parameters for `POST /api/admin/jobs/{name}/trigger`. /// /// `force=true` requests acceleration semantics from handlers that /// support it (dedup_gc → grace = 0, grant_cleanup → grace = 0). /// Silently ignored by handlers that don't (trash_cleanup, /// usage_reconcile). /// /// `deep=true` opts into slow variants — `consistency_batch` fans it /// out to sub-jobs; `storage_consistency` (when implemented) will /// re-BLAKE3 each blob for bitrot detection. See `JobRunArgs.deep`. /// /// `repair=true` opts into corrective action on the refcount /// consistency tenants (`blobs_consistency`, `manifests_consistency`, /// and `consistency_batch` which fans out to both). Default `false` /// preserves discovery-only. See `JobRunArgs.repair` for the /// content-safety and race-safety guarantees. #[derive(serde::Deserialize)] pub struct TriggerJobQuery { #[serde(default)] pub force: bool, #[serde(default)] pub deep: bool, /// Optional named storage entry to scope the run against — used by /// tenants that respect `JobRunArgs.storage` (currently /// `backend_migration` for its target; `blobs_consistency` / /// `backend_consistency` will pick this up in slice 7 to probe a /// non-active entry). Ignored by tenants that don't declare a /// semantic for it. Unknown-name validation is per-tenant — the /// generic trigger endpoint doesn't cross-check against /// `AppConfig.storage_entries`. #[serde(default)] pub storage: Option, #[serde(default)] pub repair: bool, } /// `POST /api/admin/jobs/{name}/trigger` — dispatch one run off-schedule. /// /// Returns the job's `JobOutcome` inline. Idempotent under exclusivity: /// if the previous run is still in flight, the handler returns /// `Ok { count: 0, extra: { "skipped": "already_running" } }` rather /// than spawning a parallel dispatch. /// /// Emits an audit line before dispatch — bulk-mutation side effects on /// operator command belong on the audit stream. #[utoipa::path( post, path = "/api/admin/jobs/{name}/trigger", params(("name" = String, Path, description = "Registered job name")), responses( (status = 200, description = "Dispatched; outcome inline"), (status = 401, description = "Unauthorized"), (status = 403, description = "Admin required"), (status = 404, description = "Job not registered"), ), security(("bearerAuth" = [])), tag = "admin" )] pub async fn trigger_job( State(state): State>, axum::extract::Path(name): axum::extract::Path, axum::extract::Query(query): axum::extract::Query, ) -> impl IntoResponse { use crate::infrastructure::scheduler::JobRunArgs; // Audit line BEFORE dispatch so an operator triggering something // that then hangs still leaves a trail. tracing::info!( target: "audit", event = "job.trigger", job = %name, force = query.force, deep = query.deep, repair = query.repair, "👮🏻‍♂️ Admin triggered job {} (force={}, deep={}, repair={})", name, query.force, query.deep, query.repair, ); let args = JobRunArgs { force: query.force, deep: query.deep, storage: query.storage.clone(), repair: query.repair, }; // Jobs that can run for hours (backend_migration, future // reextract_*) are detached: `tokio::spawn` the trigger so the // HTTP request returns immediately. Without this, browser HTTP // timeouts drop the request future mid-await → the SemaphorePermit // gets released while the spawned handler task keeps running → // `current_run_start` goes stale → a second click enters the // "already_running" short-circuit and CLEARS the in-memory state // even though the original task is still copying blobs → the // Cancel button hides because `job.running = false`. Detaching // keeps the permit + `current_run_start` scoped to the actual // handler-task lifetime. // // Fast-completing jobs (consistency checks, batch coordinator) // stay inline so the operator sees the outcome envelope. if is_detached_job(&name) { let name_clone = name.clone(); let registry = state.core.job_registry.clone(); tokio::spawn(async move { registry.trigger(&name_clone, &args).await; }); return ( StatusCode::ACCEPTED, Json(serde_json::json!({ "ok": true, "dispatched": true, "detached": true, "name": name, "message": "Dispatched — poll /runs for status", })), ) .into_response(); } match state.core.job_registry.trigger(&name, &args).await { Some(outcome) => ( StatusCode::OK, Json(serde_json::json!({ "ok": true, "outcome": outcome })), ) .into_response(), None => ( StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "job not registered", "name": name, })), ) .into_response(), } } /// Jobs that MUST be dispatched with `tokio::spawn` because they run /// long enough to outlast an HTTP request timeout. Kept as a small /// hardcoded allowlist (rather than a flag on `JobEntry`) until /// there's a second long-running tenant that justifies the plumbing. /// See the comment in `trigger_job` for why detach matters. fn is_detached_job(name: &str) -> bool { matches!(name, "backend_migration") } /// `POST /api/admin/jobs/{name}/cancel` — cooperative cancel of the /// currently-running recoverable run for `{name}`. /// /// Flips the row's `status` from `Running` → `CancelRequested`. The /// handler is responsible for polling `store.status()` between batches /// and returning `RunOutcome::Paused` at the next safe boundary; if it /// doesn't, the cancel is a no-op until the run completes naturally. /// /// Returns 200 with the run_id when a Running row was flipped, 200 with /// `cancelled: false` when nothing was running (either no runs exist, /// or the latest is Paused / Completed / Failed / already CancelRequested). /// Never 404 on "no active run" — the job name is registered and the /// endpoint just reports the truth. #[utoipa::path( post, path = "/api/admin/jobs/{name}/cancel", params(("name" = String, Path, description = "Registered job name")), responses( (status = 200, description = "Cancel signalled (or no-op if nothing was running)"), (status = 401, description = "Unauthorized"), (status = 403, description = "Admin required"), (status = 500, description = "DB error"), ), security(("bearerAuth" = [])), tag = "admin" )] pub async fn cancel_job( State(state): State>, axum::extract::Path(name): axum::extract::Path, ) -> impl IntoResponse { use crate::infrastructure::scheduler::JobStoreProvider as _; tracing::info!( target: "audit", event = "job.cancel_requested", job = %name, "👮🏻‍♂️ Admin requested TERMINAL cancel for job {}", name, ); // Terminal semantics: stamps `params.cancel_intent = "terminate"` // when a Running / CancelRequested row is present so the engine // upgrades the handler's yield to `Cancelled` instead of `Paused`. // When the current row is `Paused` (no handler running), does a // direct DB flip Paused → Cancelled. See // `PgJobStoreProvider::request_terminal_cancel`. match state .core .job_store_provider .request_terminal_cancel(&name) .await { Ok(Some(run_id)) => ( StatusCode::OK, Json(serde_json::json!({ "cancelled": true, "run_id": run_id.to_string(), "note": "Running row → will land in Cancelled at next batch boundary; \ Paused row → flipped to Cancelled immediately.", })), ) .into_response(), Ok(None) => ( StatusCode::OK, Json(serde_json::json!({ "cancelled": false, "reason": "no non-terminal run for this job", })), ) .into_response(), Err(e) => AppError::internal_error(format!("cancel failed: {e}")).into_response(), } } /// `POST /api/admin/jobs/{name}/pause` — cooperative PAUSE of the /// currently-running recoverable run for `{name}`. /// /// Same DB mechanism as the old cancel (Running → CancelRequested, /// handler yields to Paused), but no `cancel_intent` stamp so the /// engine writes `Paused`. Use this to interrupt a long-running /// job and resume it later; use `/cancel` to abandon it terminally. /// /// Idempotent: if the row is already Paused, returns 200 with /// `paused: false, reason: "already_paused"`. #[utoipa::path( post, path = "/api/admin/jobs/{name}/pause", params(("name" = String, Path, description = "Registered job name")), responses( (status = 200, description = "Pause signalled (or no-op if nothing was running)"), (status = 401, description = "Unauthorized"), (status = 403, description = "Admin required"), (status = 500, description = "DB error"), ), security(("bearerAuth" = [])), tag = "admin" )] pub async fn pause_job( State(state): State>, axum::extract::Path(name): axum::extract::Path, ) -> impl IntoResponse { use crate::infrastructure::scheduler::JobStoreProvider as _; tracing::info!( target: "audit", event = "job.pause_requested", job = %name, "👮🏻‍♂️ Admin requested pause for job {}", name, ); match state.core.job_store_provider.request_cancel(&name).await { Ok(Some(run_id)) => ( StatusCode::OK, Json(serde_json::json!({ "paused": true, "run_id": run_id.to_string(), "note": "Handler will yield at the next batch boundary; row will land in Paused.", })), ) .into_response(), Ok(None) => ( StatusCode::OK, Json(serde_json::json!({ "paused": false, "reason": "no running run for this job", })), ) .into_response(), Err(e) => AppError::internal_error(format!("pause failed: {e}")).into_response(), } } /// Query parameters for `GET /api/admin/jobs/{name}/runs`. #[derive(serde::Deserialize)] pub struct ListRunsQuery { /// Cap on returned rows. Server-side clamps to 100 defensively. #[serde(default = "default_runs_limit")] pub limit: u32, } fn default_runs_limit() -> u32 { 20 } /// `GET /api/admin/jobs/{name}/runs?limit=N` — history of recoverable /// runs for a registered job, newest first. Includes terminal + /// non-terminal rows. /// /// Read-only, no audit line — standard admin-middleware auth is enough. #[utoipa::path( get, path = "/api/admin/jobs/{name}/runs", params( ("name" = String, Path, description = "Registered job name"), ("limit" = Option, Query, description = "Max rows to return (default 20, capped at 100)"), ), responses( (status = 200, description = "Runs listed (may be empty)"), (status = 401, description = "Unauthorized"), (status = 403, description = "Admin required"), (status = 500, description = "DB error"), ), security(("bearerAuth" = [])), tag = "admin" )] pub async fn list_job_runs( State(state): State>, axum::extract::Path(name): axum::extract::Path, axum::extract::Query(query): axum::extract::Query, ) -> impl IntoResponse { use crate::infrastructure::scheduler::JobStoreProvider as _; let limit = query.limit.clamp(1, 100); match state.core.job_store_provider.list_runs(&name, limit).await { Ok(runs) => (StatusCode::OK, Json(runs)).into_response(), Err(e) => AppError::internal_error(format!("list_runs failed: {e}")).into_response(), } } /// `GET /api/admin/jobs/{name}/runs/{id}` — single-run detail. /// /// Returns 404 when the id doesn't exist. `{name}` is not validated /// against the run's `job_name` — the id is globally unique — but /// keeping the name in the URL path lets operators build stable /// per-job history links without knowing individual run ids upfront. #[utoipa::path( get, path = "/api/admin/jobs/{name}/runs/{id}", params( ("name" = String, Path, description = "Registered job name"), ("id" = String, Path, description = "Run UUID"), ), responses( (status = 200, description = "Run detail"), (status = 401, description = "Unauthorized"), (status = 403, description = "Admin required"), (status = 404, description = "Run not found"), (status = 500, description = "DB error"), ), security(("bearerAuth" = [])), tag = "admin" )] pub async fn get_job_run( State(state): State>, axum::extract::Path((_name, id)): axum::extract::Path<(String, uuid::Uuid)>, ) -> impl IntoResponse { use crate::infrastructure::scheduler::JobStoreProvider as _; match state.core.job_store_provider.get_run_by_id(id).await { Ok(Some(run)) => (StatusCode::OK, Json(run)).into_response(), Ok(None) => ( StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "run not found", "id": id.to_string() })), ) .into_response(), Err(e) => AppError::internal_error(format!("get_run failed: {e}")).into_response(), } } /// Query parameters for `GET /api/admin/jobs/{name}/runs/{id}/findings`. #[derive(serde::Deserialize)] pub struct ListFindingsQuery { /// Page size — server clamps to 500 defensively. #[serde(default = "default_findings_limit")] pub limit: u32, #[serde(default)] pub offset: u32, } fn default_findings_limit() -> u32 { 100 } /// `GET /api/admin/jobs/{name}/runs/{id}/findings?limit=N&offset=M` — /// paginated findings emitted by a specific run of a recoverable job. /// /// 404 when the run id doesn't exist (anti-enum: caller knew the id /// somehow; we don't leak whether it was pruned vs never-existed). /// Read-only, no audit — standard admin-middleware auth is enough. /// /// `{name}` is not validated against the run's `job_name` (the id is /// globally unique) but keeps the URL path consistent with the other /// per-run endpoints for stable per-job history links. #[utoipa::path( get, path = "/api/admin/jobs/{name}/runs/{id}/findings", params( ("name" = String, Path, description = "Registered job name"), ("id" = String, Path, description = "Run UUID"), ("limit" = Option, Query, description = "Max rows (default 100, capped at 500)"), ("offset" = Option, Query, description = "Rows to skip (default 0)"), ), responses( (status = 200, description = "Findings listed (may be empty)"), (status = 401, description = "Unauthorized"), (status = 403, description = "Admin required"), (status = 404, description = "Run not found"), (status = 500, description = "DB error"), ), security(("bearerAuth" = [])), tag = "admin" )] pub async fn list_job_run_findings( State(state): State>, axum::extract::Path((_name, id)): axum::extract::Path<(String, uuid::Uuid)>, axum::extract::Query(query): axum::extract::Query, ) -> impl IntoResponse { use crate::infrastructure::scheduler::JobStoreProvider as _; // Existence check first — otherwise a paged listing of a // nonexistent run returns 200 [] which is indistinguishable from // "run exists, no findings" and breaks operator drill-down. match state.core.job_store_provider.get_run_by_id(id).await { Ok(Some(_)) => {} Ok(None) => { return ( StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "run not found", "id": id.to_string() })), ) .into_response(); } Err(e) => return AppError::internal_error(format!("get_run failed: {e}")).into_response(), } let limit = query.limit.clamp(1, 500); match state .core .job_store_provider .list_findings(id, limit, query.offset) .await { Ok(findings) => (StatusCode::OK, Json(findings)).into_response(), Err(e) => AppError::internal_error(format!("list_findings failed: {e}")).into_response(), } } /// Query parameters for `POST /api/admin/jobs/runs/purge`. /// /// `days` — retention window. Terminal runs (`Completed`, `Failed`) /// with `completed_at` older than this many days ago are deleted /// (with their findings via CASCADE). Default 30. Minimum enforced /// at 1 by the provider — zero would eat runs completed seconds /// ago. Non-terminal runs are ALWAYS preserved regardless of age. #[derive(serde::Deserialize)] pub struct PurgeJobRunsQuery { #[serde(default = "default_purge_days")] pub days: i32, } fn default_purge_days() -> i32 { 30 } /// `POST /api/admin/jobs/runs/purge?days=N` — operator-triggered /// cleanup of old terminal runs + their findings. Not periodic; /// admins fire this when they want to reclaim `jobs.*` history /// space. Delegates entirely to /// `JobStoreProvider::purge_terminal_runs` — no SQL in the handler /// (see `AGENTS.md` § handler thinness). #[utoipa::path( post, path = "/api/admin/jobs/runs/purge", params( ("days" = Option, Query, description = "Retention window in days (default 30, minimum 1). Terminal runs older than this are deleted with their findings; non-terminal runs are always preserved."), ), responses( (status = 200, description = "Purge complete"), (status = 401, description = "Unauthorized"), (status = 403, description = "Admin required"), (status = 500, description = "DB error"), ), security(("bearerAuth" = [])), tag = "admin" )] pub async fn purge_job_runs( State(state): State>, axum::extract::Query(query): axum::extract::Query, ) -> impl IntoResponse { use crate::infrastructure::scheduler::JobStoreProvider as _; let retention_days = query.days.max(1); match state .core .job_store_provider .purge_terminal_runs(retention_days) .await { Ok(purged) => { tracing::info!( target: "audit", event = "jobs.runs_purged", purged = purged, retention_days = retention_days, "👮🏻‍♂️ admin purged {purged} terminal recoverable-run row(s) past {retention_days} day retention (findings cascaded)", ); ( StatusCode::OK, Json(serde_json::json!({ "purged": purged, "retention_days": retention_days, })), ) .into_response() } Err(e) => AppError::internal_error(format!("purge failed: {e}")).into_response(), } }