2026-02-14 01:29:34 +01:00
use axum ::{
Router ,
2026-06-17 00:28:10 -06:00
extract ::{ DefaultBodyLimit , Json , Multipart , Path , Query , State },
2026-07-17 19:09:46 +02:00
http ::StatusCode ,
2026-06-16 23:00:23 -06:00
response ::{
IntoResponse ,
sse ::{ Event , KeepAlive , Sse },
},
2026-02-14 01:29:34 +01:00
routing ::{ delete , get , post , put },
};
2026-06-24 01:51:05 +02:00
use crate ::application ::dtos ::drive_dto ::DriveDto ;
use crate ::application ::dtos ::grant_dto ::{ GrantDto , RoleDto , SubjectDto , SubjectTypeDto };
2026-06-16 23:00:23 -06:00
use crate ::application ::dtos ::plugin_dto ::{
PluginInfoDto , PluginLogEntryDto , PluginLogPageDto , PluginLogQueryDto , PluginRetentionDto ,
SetEnabledDto ,
};
2026-02-14 01:29:34 +01:00
use crate ::application ::dtos ::settings_dto ::{
AdminCreateUserDto , AdminResetPasswordDto , DashboardStatsDto , ListUsersQueryDto ,
2026-06-02 00:35:45 +02:00
MigrationStateDto , SaveOidcSettingsDto , SaveStorageSettingsDto , SendSmtpTestDto , SmtpInfoDto ,
SmtpTestResultDto , StartMigrationDto , TestOidcConnectionDto , TestStorageConnectionDto ,
UpdateUserActiveDto , UpdateUserQuotaDto , UpdateUserRoleDto , VerifyMigrationDto ,
2026-02-14 01:29:34 +01:00
};
2026-07-22 02:06:04 +02:00
use crate ::application ::dtos ::user_dto ::{ AdminUserSummaryDto , UserDto };
2026-06-24 01:20:44 +02:00
use crate ::application ::ports ::authorization_ports ::AuthorizationEngine ;
2026-06-16 23:00:23 -06:00
use crate ::application ::ports ::plugin_ports ::{ LogQuery , PluginManagementPort , PluginMgmtError };
2026-02-14 01:29:34 +01:00
use crate ::common ::di ::AppState ;
2026-06-24 01:20:44 +02:00
use crate ::domain ::repositories ::drive_repository ::DriveRepository ;
use crate ::domain ::services ::authorization ::{ Resource , Subject };
2026-07-17 21:33:47 +02:00
use crate ::interfaces ::api ::handlers ::dedup_handler ::{ get_stats , recalculate_stats };
2026-07-17 00:43:15 +02:00
use crate ::interfaces ::api ::handlers ::search_handler ::clear_search_cache ;
2026-02-14 01:29:34 +01:00
use crate ::interfaces ::errors ::AppError ;
2026-07-17 19:09:46 +02:00
use crate ::interfaces ::middleware ::auth ::AuthUser ;
2026-02-24 15:11:56 +01:00
use std ::sync ::Arc ;
2026-03-07 14:59:32 +01:00
use uuid ::Uuid ;
2026-02-14 01:29:34 +01:00
2026-07-22 02:06:04 +02:00
#[derive(serde::Serialize)]
#[serde(untagged)]
enum AdminUsersPayload {
Full ( Vec < UserDto > ),
Summary ( Vec < AdminUserSummaryDto > ),
}
#[derive(serde::Serialize)]
struct AdminUsersPageResponse {
users : AdminUsersPayload ,
total : i64 ,
limit : i64 ,
offset : i64 ,
}
2026-02-14 01:29:34 +01:00
/// Admin API routes — all require admin role.
2026-02-24 15:11:56 +01:00
pub fn admin_routes () -> Router < Arc < AppState >> {
2026-06-25 01:07:03 -06:00
use super ::admin_external_mounts as ext_mounts ;
2026-02-14 01:29:34 +01:00
Router ::new ()
2026-06-25 01:07:03 -06:00
// 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 ),
)
2026-02-14 01:29:34 +01:00
// OIDC settings
. route ( "/settings/oidc" , get ( get_oidc_settings ))
. route ( "/settings/oidc" , put ( save_oidc_settings ))
. route ( "/settings/oidc/test" , post ( test_oidc_connection ))
2026-04-14 21:33:38 +02:00
// 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
. 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 ))
. route ( "/storage/migration/complete" , post ( complete_migration ))
. route ( "/storage/migration/verify" , post ( verify_migration ))
// Encryption key generation
. route (
"/settings/storage/generate-key" ,
post ( generate_encryption_key ),
)
2026-02-14 01:29:34 +01:00
// 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 ))
. 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 ))
2026-07-19 16:06:28 +02:00
. route (
"/users/{id}/promote-to-internal" ,
post ( admin_promote_external_to_internal ),
)
2026-02-14 01:29:34 +01:00
// Registration control
. route ( "/settings/registration" , put ( set_registration_setting ))
2026-04-08 15:14:03 +03:00
// Audio metadata
. route ( "/audio/metadata/reextract" , post ( reextract_audio_metadata ))
2026-06-15 00:17:17 +02:00
// Image/video capture metadata (Photos timeline backfill)
. route ( "/photos/metadata/reextract" , post ( reextract_image_metadata ))
2026-06-16 21:26:36 -06:00
// Plugin management
. route ( "/plugins" , get ( list_plugins ))
2026-06-17 00:28:10 -06:00
// 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 )),
)
2026-06-16 21:26:36 -06:00
. route ( "/plugins/{id}/enabled" , put ( set_plugin_enabled ))
. route ( "/plugins/{id}" , delete ( delete_plugin ))
2026-06-16 23:00:23 -06:00
// 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 ))
2026-07-17 00:43:15 +02:00
// 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 ))
2026-07-17 21:33:47 +02:00
// 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 ))
2026-06-02 00:35:45 +02:00
// SMTP diagnostics
. route ( "/smtp/info" , get ( get_smtp_info ))
. route ( "/smtp/test" , post ( send_smtp_test ))
2026-06-02 10:47:37 +02:00
// 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 ))
2026-07-27 23:23:23 +02:00
// JobRegistry admin surface — production, always-on,
// audit-logged. See `docs/plan/job-registry.md` §Cross-cutting.
2026-07-27 23:40:40 +02:00
// Retired the `/internal/trigger-sweep|gc|grant-cleanup` shims
// that used to sit here (Stage 2 of the job-registry rollout).
2026-07-28 23:11:24 +02:00
//
// `/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.
2026-07-27 23:23:23 +02:00
. route ( "/jobs" , get ( list_jobs ))
. route ( "/jobs/{name}/trigger" , post ( trigger_job ))
2026-07-28 23:11:24 +02:00
. route ( "/jobs/{name}/cancel" , post ( cancel_job ))
. route ( "/jobs/{name}/runs" , get ( list_job_runs ))
. route ( "/jobs/{name}/runs/{id}" , get ( get_job_run ))
2026-07-29 07:57:34 +02:00
. route (
"/jobs/{name}/runs/{id}/findings" ,
get ( list_job_run_findings ),
)
2026-07-30 00:21:29 +02:00
// Retention cleanup — operator-triggered, not periodic.
// See `purge_job_runs` docstring for the semantics.
. route ( "/jobs/runs/purge" , post ( purge_job_runs ))
2026-06-24 01:20:44 +02:00
// Drives — admin-wide view (distinct from `/api/drives` which
// is filtered to the caller's role grants).
. route ( "/drives" , get ( list_all_drives ))
2026-06-24 21:17:24 +02:00
. route ( "/drives/{id}" , delete ( delete_drive_admin ))
2026-06-24 01:20:44 +02:00
. 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 ),
)
2026-02-14 01:29:34 +01:00
}
2026-07-17 19:09:46 +02:00
// 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`.
2026-02-14 01:29:34 +01:00
/// GET /api/admin/settings/oidc — get OIDC settings for the admin panel
2026-04-27 22:59:18 +02:00
#[utoipa::path(
get,
path = "/api/admin/settings/oidc" ,
responses(
(status = 200, description = "OIDC settings" ),
(status = 401, description = "Unauthorized" ),
(status = 403, description = "Admin required" )
),
2026-05-27 12:43:07 +02:00
security(( "bearerAuth" = [])),
2026-04-27 22:59:18 +02:00
tag = "admin"
)]
pub async fn get_oidc_settings (
2026-02-24 15:11:56 +01:00
State ( state ) : State < Arc < AppState >> ,
2026-02-14 01:29:34 +01:00
) -> Result < impl IntoResponse , AppError > {
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
2026-04-27 22:59:18 +02:00
#[utoipa::path(
put,
path = "/api/admin/settings/oidc" ,
responses(
(status = 200, description = "OIDC settings saved" ),
(status = 401, description = "Unauthorized" ),
(status = 403, description = "Admin required" )
),
2026-05-27 12:43:07 +02:00
security(( "bearerAuth" = [])),
2026-04-27 22:59:18 +02:00
tag = "admin"
)]
pub async fn save_oidc_settings (
2026-02-24 15:11:56 +01:00
State ( state ) : State < Arc < AppState >> ,
2026-07-17 19:09:46 +02:00
auth_user : AuthUser ,
2026-02-14 01:29:34 +01:00
Json ( dto ) : Json < SaveOidcSettingsDto > ,
) -> Result < impl IntoResponse , AppError > {
2026-07-17 19:09:46 +02:00
let user_id = auth_user . id ;
2026-02-14 01:29:34 +01:00
let svc = state
. admin_settings_service
. as_ref ()
. ok_or_else ( || AppError ::internal_error ( "Admin settings service not available" )) ? ;
2026-03-07 14:59:32 +01:00
svc . save_oidc_settings ( dto , user_id )
2026-02-14 01:29:34 +01:00
. 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 (
2026-02-24 15:11:56 +01:00
State ( state ) : State < Arc < AppState >> ,
2026-02-14 01:29:34 +01:00
Json ( dto ) : Json < TestOidcConnectionDto > ,
) -> Result < impl IntoResponse , AppError > {
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 ))
}
2026-04-14 21:33:38 +02:00
// ─────────────────────────────────────────────────────
// Storage settings handlers
// ─────────────────────────────────────────────────────
/// GET /api/admin/settings/storage — get storage backend settings
2026-04-27 22:59:18 +02:00
#[utoipa::path(
get,
path = "/api/admin/settings/storage" ,
responses(
(status = 200, description = "Storage settings" ),
(status = 401, description = "Unauthorized" ),
(status = 403, description = "Admin required" )
),
2026-05-27 12:43:07 +02:00
security(( "bearerAuth" = [])),
2026-04-27 22:59:18 +02:00
tag = "admin"
)]
pub async fn get_storage_settings (
2026-04-14 21:33:38 +02:00
State ( state ) : State < Arc < AppState >> ,
) -> Result < impl IntoResponse , AppError > {
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
2026-04-27 22:59:18 +02:00
#[utoipa::path(
put,
path = "/api/admin/settings/storage" ,
responses(
(status = 200, description = "Storage settings saved" ),
(status = 401, description = "Unauthorized" ),
(status = 403, description = "Admin required" )
),
2026-05-27 12:43:07 +02:00
security(( "bearerAuth" = [])),
2026-04-27 22:59:18 +02:00
tag = "admin"
)]
pub async fn save_storage_settings (
2026-04-14 21:33:38 +02:00
State ( state ) : State < Arc < AppState >> ,
2026-07-17 19:09:46 +02:00
auth_user : AuthUser ,
2026-04-14 21:33:38 +02:00
Json ( dto ) : Json < SaveStorageSettingsDto > ,
) -> Result < impl IntoResponse , AppError > {
2026-07-17 19:09:46 +02:00
let user_id = auth_user . id ;
2026-04-14 21:33:38 +02:00
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 < Arc < AppState >> ,
Json ( dto ) : Json < TestStorageConnectionDto > ,
) -> Result < impl IntoResponse , AppError > {
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
2026-04-27 22:59:18 +02:00
#[utoipa::path(
get,
path = "/api/admin/storage/migration" ,
responses(
(status = 200, description = "Current migration status" ),
(status = 401, description = "Unauthorized" ),
(status = 403, description = "Admin required" )
),
2026-05-27 12:43:07 +02:00
security(( "bearerAuth" = [])),
2026-04-27 22:59:18 +02:00
tag = "admin"
)]
pub async fn get_migration_status (
2026-04-14 21:33:38 +02:00
State ( state ) : State < Arc < AppState >> ,
) -> Result < impl IntoResponse , AppError > {
let s = state . migration_state . read (). await ;
Ok ( Json ( migration_state_to_dto ( & s )))
}
/// POST /api/admin/storage/migration/start — begin background migration
2026-04-27 22:59:18 +02:00
#[utoipa::path(
post,
path = "/api/admin/storage/migration/start" ,
responses(
(status = 200, description = "Migration started" ),
(status = 400, description = "Migration already running" ),
(status = 401, description = "Unauthorized" ),
(status = 403, description = "Admin required" )
),
2026-05-27 12:43:07 +02:00
security(( "bearerAuth" = [])),
2026-04-27 22:59:18 +02:00
tag = "admin"
)]
pub async fn start_migration (
2026-04-14 21:33:38 +02:00
State ( state ) : State < Arc < AppState >> ,
Json ( dto ) : Json < StartMigrationDto > ,
) -> Result < impl IntoResponse , AppError > {
use crate ::infrastructure ::services ::migration_blob_backend ::MigrationStatus ;
// Check not already running.
{
let s = state . migration_state . read (). await ;
if s . status == MigrationStatus ::Running {
return Err ( AppError ::bad_request ( "A migration is already running" ));
}
}
let pool = state
. db_pool
. clone ()
. ok_or_else ( || AppError ::internal_error ( "Database not available" )) ? ;
let source = state . core . dedup_service . backend (). clone ();
let svc = state
. storage_settings_service
. as_ref ()
. ok_or_else ( || AppError ::internal_error ( "Storage settings service not available" )) ? ;
// Build target backend from saved settings.
let effective = svc
. load_effective_storage_config ()
. await
. map_err ( | e | AppError ::internal_error ( format! ( "Failed to load storage config: {} " , e ))) ? ;
let target = build_backend_from_config ( & effective )
. map_err ( | e | AppError ::internal_error ( format! ( "Failed to build target backend: {} " , e ))) ? ;
target
. initialize ()
. await
. map_err ( | e | AppError ::internal_error ( format! ( "Target backend init failed: {} " , e ))) ? ;
let concurrency = dto . concurrency . unwrap_or ( 4 ). clamp ( 1 , 16 );
let migration_state = state . migration_state . clone ();
// Spawn the background migration job.
tokio ::spawn ( async move {
if let Err ( e ) = crate ::infrastructure ::services ::migration_job ::run_migration (
source ,
target ,
pool ,
migration_state ,
concurrency ,
)
. await
{
tracing ::error! ( "Migration job error: {}" , e );
}
});
Ok ((
StatusCode ::OK ,
Json ( serde_json ::json! ({ "message" : "Migration started" })),
))
}
/// POST /api/admin/storage/migration/pause — pause running migration
2026-04-27 22:59:18 +02:00
#[utoipa::path(
post,
path = "/api/admin/storage/migration/pause" ,
responses(
(status = 200, description = "Migration paused" ),
(status = 400, description = "No running migration" ),
(status = 401, description = "Unauthorized" ),
(status = 403, description = "Admin required" )
),
2026-05-27 12:43:07 +02:00
security(( "bearerAuth" = [])),
2026-04-27 22:59:18 +02:00
tag = "admin"
)]
pub async fn pause_migration (
2026-04-14 21:33:38 +02:00
State ( state ) : State < Arc < AppState >> ,
) -> Result < impl IntoResponse , AppError > {
use crate ::infrastructure ::services ::migration_blob_backend ::MigrationStatus ;
let mut s = state . migration_state . write (). await ;
if s . status != MigrationStatus ::Running {
return Err ( AppError ::bad_request ( "No running migration to pause" ));
}
s . status = MigrationStatus ::Paused ;
Ok ((
StatusCode ::OK ,
Json ( serde_json ::json! ({ "message" : "Migration paused" })),
))
}
/// POST /api/admin/storage/migration/resume — resume paused migration
2026-04-27 22:59:18 +02:00
#[utoipa::path(
post,
path = "/api/admin/storage/migration/resume" ,
responses(
(status = 200, description = "Migration resumed" ),
(status = 400, description = "No paused migration" ),
(status = 401, description = "Unauthorized" ),
(status = 403, description = "Admin required" )
),
2026-05-27 12:43:07 +02:00
security(( "bearerAuth" = [])),
2026-04-27 22:59:18 +02:00
tag = "admin"
)]
pub async fn resume_migration (
2026-04-14 21:33:38 +02:00
State ( state ) : State < Arc < AppState >> ,
) -> Result < impl IntoResponse , AppError > {
use crate ::infrastructure ::services ::migration_blob_backend ::MigrationStatus ;
// Set status back to Running — the background task checks on each blob.
let mut s = state . migration_state . write (). await ;
if s . status != MigrationStatus ::Paused {
return Err ( AppError ::bad_request ( "No paused migration to resume" ));
}
s . status = MigrationStatus ::Running ;
Ok ((
StatusCode ::OK ,
Json ( serde_json ::json! ({ "message" : "Migration resumed" })),
))
}
/// POST /api/admin/storage/migration/complete — finalize migration
2026-04-27 22:59:18 +02:00
#[utoipa::path(
post,
path = "/api/admin/storage/migration/complete" ,
responses(
(status = 200, description = "Migration finalized" ),
(status = 400, description = "Migration not completed" ),
(status = 401, description = "Unauthorized" ),
(status = 403, description = "Admin required" )
),
2026-05-27 12:43:07 +02:00
security(( "bearerAuth" = [])),
2026-04-27 22:59:18 +02:00
tag = "admin"
)]
pub async fn complete_migration (
2026-04-14 21:33:38 +02:00
State ( state ) : State < Arc < AppState >> ,
) -> Result < impl IntoResponse , AppError > {
use crate ::infrastructure ::services ::migration_blob_backend ::MigrationStatus ;
let s = state . migration_state . read (). await ;
if s . status != MigrationStatus ::Completed {
return Err ( AppError ::bad_request (
"Migration must be completed (100%) before finalizing" ,
));
}
drop ( s );
// Mark as idle — the admin has acknowledged completion.
let mut s = state . migration_state . write (). await ;
s . status = MigrationStatus ::Idle ;
Ok ((
StatusCode ::OK ,
Json (
serde_json ::json! ({ "message" : "Migration finalized. Restart the server to use the new backend." }),
),
))
}
/// POST /api/admin/storage/migration/verify — run integrity check
2026-04-27 22:59:18 +02:00
#[utoipa::path(
post,
path = "/api/admin/storage/migration/verify" ,
responses(
(status = 200, description = "Verification result" ),
(status = 401, description = "Unauthorized" ),
(status = 403, description = "Admin required" ),
(status = 500, description = "Verification failed" )
),
2026-05-27 12:43:07 +02:00
security(( "bearerAuth" = [])),
2026-04-27 22:59:18 +02:00
tag = "admin"
)]
pub async fn verify_migration (
2026-04-14 21:33:38 +02:00
State ( state ) : State < Arc < AppState >> ,
Json ( dto ) : Json < VerifyMigrationDto > ,
) -> Result < impl IntoResponse , AppError > {
let pool = state
. db_pool
. clone ()
. ok_or_else ( || AppError ::internal_error ( "Database not available" )) ? ;
let svc = state
. storage_settings_service
. as_ref ()
. ok_or_else ( || AppError ::internal_error ( "Storage settings service not available" )) ? ;
let effective = svc
. load_effective_storage_config ()
. await
. map_err ( | e | AppError ::internal_error ( format! ( "Failed to load storage config: {} " , e ))) ? ;
let target = build_backend_from_config ( & effective )
. map_err ( | e | AppError ::internal_error ( format! ( "Failed to build target backend: {} " , e ))) ? ;
target
. initialize ()
. await
. map_err ( | e | AppError ::internal_error ( format! ( "Target backend init failed: {} " , e ))) ? ;
let sample_size = dto . sample_size . unwrap_or ( 100 ). clamp ( 1 , 1000 );
let result =
crate ::infrastructure ::services ::migration_job ::verify_migration ( target , pool , sample_size )
. await
. map_err ( | e | AppError ::internal_error ( format! ( "Verification failed: {} " , e ))) ? ;
Ok ( Json ( result ))
}
/// Helper: convert MigrationState to DTO for JSON serialization.
fn migration_state_to_dto (
s : & crate ::infrastructure ::services ::migration_blob_backend ::MigrationState ,
) -> MigrationStateDto {
let throughput = match ( s . started_at , s . migrated_bytes ) {
( Some ( start ), bytes ) if bytes > 0 => {
let elapsed = chrono ::Utc ::now ()
. signed_duration_since ( start )
. num_seconds ()
. max ( 1 ) as f64 ;
Some ( bytes as f64 / elapsed )
}
_ => None ,
};
MigrationStateDto {
status : format ! ( "{:?}" , s . status ). to_lowercase (),
total_blobs : s . total_blobs ,
migrated_blobs : s . migrated_blobs ,
migrated_bytes : s . migrated_bytes ,
failed_blobs : s . failed_blobs . clone (),
started_at : s . started_at . map ( | d | d . to_rfc3339 ()),
completed_at : s . completed_at . map ( | d | d . to_rfc3339 ()),
throughput_bytes_per_sec : throughput ,
}
}
/// POST /api/admin/settings/storage/generate-key — generate a random AES-256 key.
2026-04-27 22:59:18 +02:00
#[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" )
),
2026-05-27 12:43:07 +02:00
security(( "bearerAuth" = [])),
2026-04-27 22:59:18 +02:00
tag = "admin"
)]
2026-07-17 19:09:46 +02:00
pub async fn generate_encryption_key () -> Result < impl IntoResponse , AppError > {
2026-04-14 21:33:38 +02:00
let key =
crate ::infrastructure ::services ::encrypted_blob_backend ::EncryptedBlobBackend ::generate_key (
);
let key_b64 = base64 ::Engine ::encode ( & base64 ::engine ::general_purpose ::STANDARD , key );
Ok ( Json ( serde_json ::json! ({
"key" : key_b64 ,
"warning" : "Store this key securely. If lost, encrypted data is IRRECOVERABLY LOST."
})))
}
/// Helper: build a BlobStorageBackend from StorageConfig.
fn build_backend_from_config (
config : & crate ::common ::config ::StorageConfig ,
) -> Result <
std ::sync ::Arc < dyn crate ::application ::ports ::blob_storage_ports ::BlobStorageBackend > ,
String ,
> {
match config . backend {
crate ::common ::config ::StorageBackendType ::Local => Ok ( std ::sync ::Arc ::new (
crate ::infrastructure ::services ::local_blob_backend ::LocalBlobBackend ::new (
std ::path ::Path ::new ( & config . root_dir ),
),
)),
crate ::common ::config ::StorageBackendType ::S3 => {
let s3 = config . s3 . as_ref (). ok_or ( "S3 config missing" ) ? ;
Ok ( std ::sync ::Arc ::new (
crate ::infrastructure ::services ::s3_blob_backend ::S3BlobBackend ::new ( s3 ),
))
}
crate ::common ::config ::StorageBackendType ::Azure => {
let az = config . azure . as_ref (). ok_or ( "Azure config missing" ) ? ;
Ok ( std ::sync ::Arc ::new (
crate ::infrastructure ::services ::azure_blob_backend ::AzureBlobBackend ::new ( az ),
))
}
}
}
2026-02-14 01:29:34 +01:00
// ============================================================================
// Dashboard / Stats
// ============================================================================
/// GET /api/admin/dashboard — full dashboard statistics
2026-04-27 22:59:18 +02:00
#[utoipa::path(
get,
path = "/api/admin/dashboard" ,
responses(
(status = 200, description = "Dashboard statistics" ),
(status = 401, description = "Unauthorized" ),
(status = 403, description = "Admin required" )
),
2026-05-27 12:43:07 +02:00
security(( "bearerAuth" = [])),
2026-04-27 22:59:18 +02:00
tag = "admin"
)]
pub async fn get_dashboard_stats (
2026-02-24 15:11:56 +01:00
State ( state ) : State < Arc < AppState >> ,
2026-02-14 01:29:34 +01:00
) -> Result < impl IntoResponse , AppError > {
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" )) ? ;
2026-07-19 16:40:52 +02:00
// 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.
2026-02-14 01:29:34 +01:00
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,
COALESCE(SUM(storage_quota_bytes)::INT8, 0) as total_quota_bytes,
COALESCE(SUM(storage_used_bytes)::INT8, 0) as total_used_bytes,
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
2026-07-19 16:40:52 +02:00
WHERE is_external = false
2026-02-14 01:29:34 +01:00
"#
)
. fetch_one ( db_pool . as_ref ())
. await
. map_err ( | e | AppError ::internal_error ( format! ( "Database query failed: {} " , e ))) ? ;
use sqlx ::Row ;
let total_quota : i64 = stats_row . get ( "total_quota_bytes" );
let total_used : i64 = stats_row . get ( "total_used_bytes" );
let usage_percent = if total_quota > 0 {
( total_used as f64 / total_quota as f64 ) * 100.0
} else {
0.0
};
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" ),
total_quota_bytes : total_quota ,
total_used_bytes : total_used ,
storage_usage_percent : ( usage_percent * 100.0 ). round () / 100.0 ,
users_over_80_percent : stats_row . get ( "users_over_80" ),
users_over_quota : stats_row . get ( "users_over_quota" ),
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
2026-04-27 22:59:18 +02:00
#[utoipa::path(
get,
path = "/api/admin/users" ,
params(
( "limit" = Option<i64>, Query, description = "Max users to return (default 100, max 500)" ),
2026-07-22 02:06:04 +02:00
( "offset" = Option<i64>, Query, description = "Pagination offset" ),
( "summary" = Option<bool>, Query, description = "Return the compact management-table projection" )
2026-04-27 22:59:18 +02:00
),
responses(
(status = 200, description = "List of users" ),
(status = 401, description = "Unauthorized" ),
(status = 403, description = "Admin required" )
),
2026-05-27 12:43:07 +02:00
security(( "bearerAuth" = [])),
2026-04-27 22:59:18 +02:00
tag = "admin"
)]
pub async fn list_users (
2026-02-24 15:11:56 +01:00
State ( state ) : State < Arc < AppState >> ,
2026-07-22 02:06:04 +02:00
auth_user : AuthUser ,
2026-02-14 01:29:34 +01:00
Query ( query ) : Query < ListUsersQueryDto > ,
) -> Result < impl IntoResponse , AppError > {
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 );
2026-07-19 15:45:09 +02:00
// 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.
2026-07-22 02:06:04 +02:00
let users = if query . summary . unwrap_or ( false ) {
AdminUsersPayload ::Summary (
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 ) ? ,
)
} else {
AdminUsersPayload ::Full (
auth . auth_application_service
. list_users_including_external_with_perms (
state . authorization . as_ref (),
auth_user . id ,
limit ,
offset ,
)
. await
. map_err ( AppError ::from ) ? ,
)
};
2026-02-14 01:29:34 +01:00
let total = auth
. auth_application_service
. count_users_efficient ()
. await
. unwrap_or ( 0 );
2026-07-22 02:06:04 +02:00
Ok ( Json ( AdminUsersPageResponse {
users ,
total ,
limit ,
offset ,
}))
2026-02-14 01:29:34 +01:00
}
/// GET /api/admin/users/:id — get single user
2026-04-27 22:59:18 +02:00
#[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" )
),
2026-05-27 12:43:07 +02:00
security(( "bearerAuth" = [])),
2026-04-27 22:59:18 +02:00
tag = "admin"
)]
pub async fn get_user (
2026-02-24 15:11:56 +01:00
State ( state ) : State < Arc < AppState >> ,
2026-02-14 01:29:34 +01:00
Path ( id ) : Path < String > ,
) -> Result < impl IntoResponse , AppError > {
2026-03-07 14:59:32 +01:00
let id = Uuid ::parse_str ( & id ). map_err ( | _ | AppError ::bad_request ( "Invalid UUID" )) ? ;
2026-02-14 01:29:34 +01:00
let auth = state
. auth_service
. as_ref ()
. ok_or_else ( || AppError ::internal_error ( "Auth service not configured" )) ? ;
let user = auth
. auth_application_service
2026-03-07 14:59:32 +01:00
. get_user_admin ( id )
2026-02-14 01:29:34 +01:00
. await
. map_err ( | e | AppError ::not_found ( format! ( "User not found: {} " , e ))) ? ;
Ok ( Json ( user ))
}
/// DELETE /api/admin/users/:id — delete a user
2026-04-27 22:59:18 +02:00
#[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" )
),
2026-05-27 12:43:07 +02:00
security(( "bearerAuth" = [])),
2026-04-27 22:59:18 +02:00
tag = "admin"
)]
pub async fn delete_user (
2026-02-24 15:11:56 +01:00
State ( state ) : State < Arc < AppState >> ,
2026-07-17 19:09:46 +02:00
auth_user : AuthUser ,
2026-02-14 01:29:34 +01:00
Path ( id ) : Path < String > ,
) -> Result < impl IntoResponse , AppError > {
2026-07-17 19:09:46 +02:00
let admin_id = auth_user . id ;
2026-02-14 01:29:34 +01:00
2026-03-07 14:59:32 +01:00
let id = Uuid ::parse_str ( & id ). map_err ( | _ | AppError ::bad_request ( "Invalid UUID" )) ? ;
2026-02-14 01:29:34 +01:00
// 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
2026-03-07 14:59:32 +01:00
. delete_user_admin ( id )
2026-02-14 01:29:34 +01:00
. await
. map_err ( | e | AppError ::internal_error ( format! ( "Failed to delete user: {} " , e ))) ? ;
Ok ((
StatusCode ::OK ,
Json ( serde_json ::json! ({
"message" : "User deleted successfully"
})),
))
}
/// PUT /api/admin/users/:id/role — change user role
2026-04-27 22:59:18 +02:00
#[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" )
),
2026-05-27 12:43:07 +02:00
security(( "bearerAuth" = [])),
2026-04-27 22:59:18 +02:00
tag = "admin"
)]
pub async fn update_user_role (
2026-02-24 15:11:56 +01:00
State ( state ) : State < Arc < AppState >> ,
2026-07-17 19:09:46 +02:00
auth_user : AuthUser ,
2026-02-14 01:29:34 +01:00
Path ( id ) : Path < String > ,
Json ( dto ) : Json < UpdateUserRoleDto > ,
) -> Result < impl IntoResponse , AppError > {
2026-07-17 19:09:46 +02:00
let admin_id = auth_user . id ;
2026-02-14 01:29:34 +01:00
2026-03-07 14:59:32 +01:00
let id = Uuid ::parse_str ( & id ). map_err ( | _ | AppError ::bad_request ( "Invalid UUID" )) ? ;
2026-02-14 01:29:34 +01:00
// 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
2026-03-07 14:59:32 +01:00
. change_user_role ( id , & dto . role )
2026-02-14 01:29:34 +01:00
. 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
2026-04-27 22:59:18 +02:00
#[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" )
),
2026-05-27 12:43:07 +02:00
security(( "bearerAuth" = [])),
2026-04-27 22:59:18 +02:00
tag = "admin"
)]
pub async fn update_user_active (
2026-02-24 15:11:56 +01:00
State ( state ) : State < Arc < AppState >> ,
2026-07-17 19:09:46 +02:00
auth_user : AuthUser ,
2026-02-14 01:29:34 +01:00
Path ( id ) : Path < String > ,
Json ( dto ) : Json < UpdateUserActiveDto > ,
) -> Result < impl IntoResponse , AppError > {
2026-07-17 19:09:46 +02:00
let admin_id = auth_user . id ;
2026-02-14 01:29:34 +01:00
2026-03-07 14:59:32 +01:00
let id = Uuid ::parse_str ( & id ). map_err ( | _ | AppError ::bad_request ( "Invalid UUID" )) ? ;
2026-02-14 01:29:34 +01:00
// 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
2026-03-07 14:59:32 +01:00
. set_user_active ( id , dto . active )
2026-02-14 01:29:34 +01:00
. 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
2026-04-27 22:59:18 +02:00
#[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" )
),
2026-05-27 12:43:07 +02:00
security(( "bearerAuth" = [])),
2026-04-27 22:59:18 +02:00
tag = "admin"
)]
pub async fn update_user_quota (
2026-02-24 15:11:56 +01:00
State ( state ) : State < Arc < AppState >> ,
2026-02-14 01:29:34 +01:00
Path ( id ) : Path < String > ,
Json ( dto ) : Json < UpdateUserQuotaDto > ,
) -> Result < impl IntoResponse , AppError > {
2026-03-07 14:59:32 +01:00
let id = Uuid ::parse_str ( & id ). map_err ( | _ | AppError ::bad_request ( "Invalid UUID" )) ? ;
2026-02-14 01:29:34 +01:00
let auth = state
. auth_service
. as_ref ()
. ok_or_else ( || AppError ::internal_error ( "Auth service not configured" )) ? ;
auth . auth_application_service
2026-03-07 14:59:32 +01:00
. update_user_quota ( id , dto . quota_bytes )
2026-02-14 01:29:34 +01:00
. 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)
2026-04-27 22:59:18 +02:00
#[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" )
),
2026-05-27 12:43:07 +02:00
security(( "bearerAuth" = [])),
2026-04-27 22:59:18 +02:00
tag = "admin"
)]
pub async fn create_user (
2026-02-24 15:11:56 +01:00
State ( state ) : State < Arc < AppState >> ,
2026-02-14 01:29:34 +01:00
Json ( dto ) : Json < AdminCreateUserDto > ,
) -> Result < impl IntoResponse , AppError > {
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)
2026-04-27 22:59:18 +02:00
#[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" )
),
2026-05-27 12:43:07 +02:00
security(( "bearerAuth" = [])),
2026-04-27 22:59:18 +02:00
tag = "admin"
)]
pub async fn reset_user_password (
2026-02-24 15:11:56 +01:00
State ( state ) : State < Arc < AppState >> ,
2026-02-14 01:29:34 +01:00
Path ( id ) : Path < String > ,
Json ( dto ) : Json < AdminResetPasswordDto > ,
) -> Result < impl IntoResponse , AppError > {
2026-03-07 14:59:32 +01:00
let id = Uuid ::parse_str ( & id ). map_err ( | _ | AppError ::bad_request ( "Invalid UUID" )) ? ;
2026-02-14 01:29:34 +01:00
let auth = state
. auth_service
. as_ref ()
. ok_or_else ( || AppError ::internal_error ( "Auth service not configured" )) ? ;
auth . auth_application_service
2026-03-07 14:59:32 +01:00
. admin_reset_password ( id , & dto . new_password )
2026-02-14 01:29:34 +01:00
. 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"
})),
))
}
2026-07-19 16:06:28 +02:00
/// 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 = UserDto),
(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 < Arc < AppState >> ,
auth_user : AuthUser ,
Path ( id ) : Path < String > ,
) -> Result < impl IntoResponse , AppError > {
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 )))
}
2026-02-14 01:29:34 +01:00
// ============================================================================
// Registration Control
// ============================================================================
/// PUT /api/admin/settings/registration — enable/disable public registration
2026-04-27 22:59:18 +02:00
#[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" )
),
2026-05-27 12:43:07 +02:00
security(( "bearerAuth" = [])),
2026-04-27 22:59:18 +02:00
tag = "admin"
)]
pub async fn set_registration_setting (
2026-02-24 15:11:56 +01:00
State ( state ) : State < Arc < AppState >> ,
2026-07-17 19:09:46 +02:00
auth_user : AuthUser ,
2026-02-14 01:29:34 +01:00
Json ( body ) : Json < serde_json ::Value > ,
) -> Result < impl IntoResponse , AppError > {
2026-07-17 19:09:46 +02:00
let admin_id = auth_user . id ;
2026-02-14 01:29:34 +01:00
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" )) ? ;
2026-03-07 14:59:32 +01:00
svc . set_registration_enabled ( enabled , admin_id )
2026-02-14 01:29:34 +01:00
. 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 ,
})),
))
}
2026-04-08 15:14:03 +03:00
async fn reextract_audio_metadata (
State ( state ) : State < Arc < AppState >> ,
) -> Result < impl IntoResponse , AppError > {
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 ,
})))
}
2026-06-02 00:35:45 +02:00
2026-06-15 00:17:17 +02:00
/// 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 < Arc < AppState >> ,
) -> Result < impl IntoResponse , AppError > {
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 ,
})))
}
2026-06-02 00:35:45 +02:00
// ─────────────────────────────────────────────────────
// 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"
)]
2026-07-17 20:34:01 +02:00
async fn get_smtp_info ( State ( state ) : State < Arc < AppState >> ) -> Result < impl IntoResponse , AppError > {
2026-06-02 00:35:45 +02:00
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 () {
"<anon>"
} else {
"<set>"
},
};
Ok ( Json ( info ))
}
2026-06-02 10:47:37 +02:00
/// GET /api/admin/smtp/test/captured?to=<email> — 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 < Arc < AppState >> ,
Query ( params ) : Query < CapturedEmailQuery > ,
) -> Result < impl IntoResponse , AppError > {
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 ,
}
2026-06-02 00:35:45 +02:00
/// 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"
)]
async fn send_smtp_test (
State ( state ) : State < Arc < AppState >> ,
2026-07-17 19:09:46 +02:00
auth_user : AuthUser ,
2026-06-02 00:35:45 +02:00
Json ( dto ) : Json < SendSmtpTestDto > ,
) -> Result < impl IntoResponse , AppError > {
2026-07-17 19:09:46 +02:00
let admin_id = auth_user . id ;
2026-06-02 00:35:45 +02:00
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 ))
}
2026-06-16 21:26:36 -06:00
// ---- 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 < dyn PluginManagementPort > , 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 < Arc < AppState >> ,
) -> Result < impl IntoResponse , AppError > {
let mgmt = plugin_mgmt ( & state ) ? ;
let plugins : Vec < PluginInfoDto > = mgmt . list (). into_iter (). map ( PluginInfoDto ::from ). collect ();
2026-06-17 00:28:10 -06:00
// `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`.
2026-06-16 21:26:36 -06:00
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 < Arc < AppState >> ,
2026-07-17 19:09:46 +02:00
auth_user : AuthUser ,
2026-06-16 21:26:36 -06:00
Path ( id ) : Path < String > ,
Json ( dto ) : Json < SetEnabledDto > ,
) -> Result < impl IntoResponse , AppError > {
2026-07-17 19:09:46 +02:00
let admin_id = auth_user . id ;
2026-06-16 21:26:36 -06:00
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 < Arc < AppState >> ,
2026-07-17 19:09:46 +02:00
auth_user : AuthUser ,
2026-06-16 21:26:36 -06:00
mut multipart : Multipart ,
) -> Result < impl IntoResponse , AppError > {
2026-07-17 19:09:46 +02:00
let admin_id = auth_user . id ;
2026-06-16 21:26:36 -06:00
let mgmt = plugin_mgmt ( & state ) ? ;
let mut bundle : Option < Vec < u8 >> = 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 < Arc < AppState >> ,
2026-07-17 19:09:46 +02:00
auth_user : AuthUser ,
2026-06-16 21:26:36 -06:00
Path ( id ) : Path < String > ,
) -> Result < impl IntoResponse , AppError > {
2026-07-17 19:09:46 +02:00
let admin_id = auth_user . id ;
2026-06-16 21:26:36 -06:00
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 })),
))
}
2026-06-16 23:00:23 -06:00
/// 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 < Arc < AppState >> ,
Path ( id ) : Path < String > ,
Query ( q ) : Query < PluginLogQueryDto > ,
) -> Result < impl IntoResponse , AppError > {
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 < Arc < AppState >> ,
2026-07-17 19:09:46 +02:00
auth_user : AuthUser ,
2026-06-16 23:00:23 -06:00
Path ( id ) : Path < String > ,
) -> Result < impl IntoResponse , AppError > {
2026-07-17 19:09:46 +02:00
let admin_id = auth_user . id ;
2026-06-16 23:00:23 -06:00
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 < Arc < AppState >> ,
Path ( id ) : Path < String > ,
) -> Result < impl IntoResponse , AppError > {
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 , std ::convert ::Infallible > ( 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 < Arc < AppState >> ,
Path ( id ) : Path < String > ,
) -> Result < impl IntoResponse , AppError > {
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 < Arc < AppState >> ,
2026-07-17 19:09:46 +02:00
auth_user : AuthUser ,
2026-06-16 23:00:23 -06:00
Path ( id ) : Path < String > ,
Json ( dto ) : Json < PluginRetentionDto > ,
) -> Result < impl IntoResponse , AppError > {
2026-07-17 19:09:46 +02:00
let admin_id = auth_user . id ;
2026-06-16 23:00:23 -06:00
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 )))
}
2026-06-24 01:20:44 +02:00
/// 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<DriveDto>),
(status = 401, description = "Unauthorized" ),
(status = 403, description = "Admin required" ),
),
security(( "bearerAuth" = [])),
tag = "admin"
)]
pub async fn list_all_drives (
State ( state ) : State < Arc < AppState >> ,
) -> Result < impl IntoResponse , AppError > {
let drives = state
. drive_repo
. list_all ()
. await
. map_err ( | e | AppError ::internal_error ( format! ( "Failed to list drives: {e} " ))) ? ;
let dtos : Vec < DriveDto > = 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<GrantDto>` 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<GrantDto>),
(status = 401, description = "Unauthorized" ),
(status = 403, description = "Admin required" ),
),
security(( "bearerAuth" = [])),
tag = "admin"
)]
pub async fn list_drive_members_admin (
State ( state ) : State < Arc < AppState >> ,
axum ::extract ::Path ( drive_id ) : axum ::extract ::Path < Uuid > ,
) -> Result < impl IntoResponse , AppError > {
let grants = state
. authorization
. list_grants_on_resource ( Resource ::Drive ( drive_id ))
. await
. map_err ( AppError ::from ) ? ;
let dtos : Vec < GrantDto > = 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 < chrono ::DateTime < chrono ::Utc >> ,
}
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
pub struct AdminUpdateDriveMemberDto {
pub role : RoleDto ,
#[serde(default)]
pub expires_at : Option < chrono ::DateTime < chrono ::Utc >> ,
}
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 < Arc < AppState >> ,
2026-07-17 19:09:46 +02:00
auth_user : AuthUser ,
2026-06-24 01:20:44 +02:00
axum ::extract ::Path ( drive_id ) : axum ::extract ::Path < Uuid > ,
Json ( dto ) : Json < AdminAddDriveMemberDto > ,
) -> Result < impl IntoResponse , AppError > {
2026-07-17 19:09:46 +02:00
let admin_id = auth_user . id ;
2026-06-24 01:20:44 +02:00
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 < Arc < AppState >> ,
2026-07-17 19:09:46 +02:00
auth_user : AuthUser ,
2026-06-24 01:20:44 +02:00
axum ::extract ::Path (( drive_id , kind , subject_id )) : axum ::extract ::Path < (
Uuid ,
SubjectTypeDto ,
Uuid ,
) > ,
Json ( dto ) : Json < AdminUpdateDriveMemberDto > ,
) -> Result < impl IntoResponse , AppError > {
2026-07-17 19:09:46 +02:00
let admin_id = auth_user . id ;
2026-06-24 01:20:44 +02:00
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 < Arc < AppState >> ,
2026-07-17 19:09:46 +02:00
auth_user : AuthUser ,
2026-06-24 01:20:44 +02:00
axum ::extract ::Path (( drive_id , kind , subject_id )) : axum ::extract ::Path < (
Uuid ,
SubjectTypeDto ,
Uuid ,
) > ,
) -> Result < impl IntoResponse , AppError > {
2026-07-17 19:09:46 +02:00
let admin_id = auth_user . id ;
2026-06-24 01:20:44 +02:00
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 )
}
2026-06-24 21:17:24 +02:00
/// `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 < Arc < AppState >> ,
2026-07-17 19:09:46 +02:00
auth_user : AuthUser ,
2026-06-24 21:17:24 +02:00
axum ::extract ::Path ( drive_id ) : axum ::extract ::Path < Uuid > ,
) -> Result < impl IntoResponse , AppError > {
2026-07-17 19:09:46 +02:00
let admin_id = auth_user . id ;
2026-06-24 21:17:24 +02:00
state
. drive_management_service
. delete_drive ( admin_id , true , drive_id )
. await
. map_err ( AppError ::from ) ? ;
Ok ( StatusCode ::NO_CONTENT )
}
2026-06-24 23:00:21 +02:00
2026-07-27 23:23:23 +02:00
// ─────────────────────────────────────────────────────
// 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.
///
2026-07-27 23:40:40 +02:00
/// Production endpoint, always on. Read-only, so no audit line —
/// the standard admin-middleware auth check is enough.
2026-07-27 23:23:23 +02:00
#[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 < Arc < AppState >> ) -> impl IntoResponse {
let summary = state . core . job_registry . snapshot (). await ;
( 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,
/// storage_reconcile).
2026-07-29 00:16:18 +02:00
///
/// `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`.
2026-07-27 23:23:23 +02:00
#[derive(serde::Deserialize)]
pub struct TriggerJobQuery {
#[serde(default)]
pub force : bool ,
2026-07-29 00:16:18 +02:00
#[serde(default)]
pub deep : bool ,
2026-07-27 23:23:23 +02:00
}
/// `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 < Arc < AppState >> ,
axum ::extract ::Path ( name ) : axum ::extract ::Path < String > ,
axum ::extract ::Query ( query ) : axum ::extract ::Query < TriggerJobQuery > ,
) -> 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 ,
2026-07-29 00:16:18 +02:00
deep = query . deep ,
"👮🏻♂️ Admin triggered job {} (force={}, deep={})" ,
2026-07-27 23:23:23 +02:00
name ,
query . force ,
2026-07-29 00:16:18 +02:00
query . deep ,
2026-07-27 23:23:23 +02:00
);
2026-07-29 00:16:18 +02:00
let args = JobRunArgs {
force : query . force ,
deep : query . deep ,
};
2026-07-27 23:23:23 +02:00
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 (),
}
}
2026-07-28 23:11:24 +02:00
/// `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 < Arc < AppState >> ,
axum ::extract ::Path ( name ) : axum ::extract ::Path < String > ,
) -> impl IntoResponse {
use crate ::infrastructure ::scheduler ::JobStoreProvider as _ ;
tracing ::info! (
target : "audit" ,
event = "job.cancel_requested" ,
job = % name ,
"👮🏻♂️ Admin requested cancel for job {}" ,
name ,
);
match state . core . job_store_provider . request_cancel ( & name ). await {
Ok ( Some ( run_id )) => (
StatusCode ::OK ,
Json ( serde_json ::json! ({
"cancelled" : true ,
"run_id" : run_id . to_string (),
"status" : "CancelRequested" ,
})),
)
. into_response (),
Ok ( None ) => (
StatusCode ::OK ,
Json ( serde_json ::json! ({
"cancelled" : false ,
"reason" : "no running run for this job" ,
})),
)
. into_response (),
Err ( e ) => AppError ::internal_error ( format! ( "cancel 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<u32>, 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 < Arc < AppState >> ,
axum ::extract ::Path ( name ) : axum ::extract ::Path < String > ,
axum ::extract ::Query ( query ) : axum ::extract ::Query < ListRunsQuery > ,
) -> 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 < Arc < AppState >> ,
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 (),
}
}
2026-07-29 01:50:26 +02:00
/// 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<u32>, Query, description = "Max rows (default 100, capped at 500)" ),
( "offset" = Option<u32>, 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 < Arc < AppState >> ,
axum ::extract ::Path (( _name , id )) : axum ::extract ::Path < ( String , uuid ::Uuid ) > ,
axum ::extract ::Query ( query ) : axum ::extract ::Query < ListFindingsQuery > ,
) -> 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 (),
}
}
2026-07-30 00:21:29 +02:00
/// 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<i32>, 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 < Arc < AppState >> ,
axum ::extract ::Query ( query ) : axum ::extract ::Query < PurgeJobRunsQuery > ,
) -> 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 (),
}
}