Files
Oxicloud/src/interfaces/api/handlers/admin_handler.rs
T

1415 lines
46 KiB
Rust
Raw Normal View History

2026-02-14 01:29:34 +01:00
use axum::{
Router,
extract::{Json, Path, Query, State},
2026-05-30 23:35:47 +02:00
http::{HeaderMap, StatusCode},
2026-02-14 01:29:34 +01:00
response::IntoResponse,
routing::{delete, get, post, put},
};
use crate::application::dtos::settings_dto::{
AdminCreateUserDto, AdminResetPasswordDto, DashboardStatsDto, ListUsersQueryDto,
MigrationStateDto, SaveOidcSettingsDto, SaveStorageSettingsDto, SendSmtpTestDto, SmtpInfoDto,
SmtpTestResultDto, StartMigrationDto, TestOidcConnectionDto, TestStorageConnectionDto,
UpdateUserActiveDto, UpdateUserQuotaDto, UpdateUserRoleDto, VerifyMigrationDto,
2026-02-14 01:29:34 +01:00
};
use crate::common::di::AppState;
use crate::interfaces::errors::AppError;
2026-05-30 23:35:47 +02:00
use crate::interfaces::middleware::admin::require_admin;
use std::sync::Arc;
use uuid::Uuid;
2026-02-14 01:29:34 +01:00
/// Admin API routes — all require admin role.
pub fn admin_routes() -> Router<Arc<AppState>> {
2026-02-14 01:29:34 +01:00
Router::new()
// 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
.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
.route("/settings/general", get(get_general_settings))
// 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))
// Registration control
.route("/settings/registration", get(get_registration_setting))
.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))
// 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))
2026-02-14 01:29:34 +01:00
}
/// Validate JWT and require admin role. Returns (user_id, role).
2026-05-30 23:35:47 +02:00
///
/// Thin wrapper over the shared `require_admin` middleware helper so this
/// handler keeps a stable signature while the implementation lives next to
/// the new `subject_group_handler` that also needs it.
async fn admin_guard(state: &AppState, headers: &HeaderMap) -> Result<(Uuid, String), AppError> {
2026-05-30 23:35:47 +02:00
require_admin(state, headers).await
2026-02-14 01:29:34 +01:00
}
/// 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<Arc<AppState>>,
2026-02-14 01:29:34 +01:00
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
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<Arc<AppState>>,
2026-02-14 01:29:34 +01:00
headers: HeaderMap,
Json(dto): Json<SaveOidcSettingsDto>,
) -> Result<impl IntoResponse, AppError> {
let (user_id, _) = admin_guard(&state, &headers).await?;
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)
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(
State(state): State<Arc<AppState>>,
2026-02-14 01:29:34 +01:00
headers: HeaderMap,
Json(dto): Json<TestOidcConnectionDto>,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
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<Arc<AppState>>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
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<Arc<AppState>>,
headers: HeaderMap,
Json(dto): Json<SaveStorageSettingsDto>,
) -> Result<impl IntoResponse, AppError> {
let (user_id, _) = admin_guard(&state, &headers).await?;
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>>,
headers: HeaderMap,
Json(dto): Json<TestStorageConnectionDto>,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
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
#[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<Arc<AppState>>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
let s = state.migration_state.read().await;
Ok(Json(migration_state_to_dto(&s)))
}
/// POST /api/admin/storage/migration/start — begin background migration
#[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")
),
security(("bearerAuth" = [])),
tag = "admin"
)]
pub async fn start_migration(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(dto): Json<StartMigrationDto>,
) -> Result<impl IntoResponse, AppError> {
use crate::infrastructure::services::migration_blob_backend::MigrationStatus;
admin_guard(&state, &headers).await?;
// 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
#[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")
),
security(("bearerAuth" = [])),
tag = "admin"
)]
pub async fn pause_migration(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
use crate::infrastructure::services::migration_blob_backend::MigrationStatus;
admin_guard(&state, &headers).await?;
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
#[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")
),
security(("bearerAuth" = [])),
tag = "admin"
)]
pub async fn resume_migration(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
use crate::infrastructure::services::migration_blob_backend::MigrationStatus;
admin_guard(&state, &headers).await?;
// 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
#[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")
),
security(("bearerAuth" = [])),
tag = "admin"
)]
pub async fn complete_migration(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
use crate::infrastructure::services::migration_blob_backend::MigrationStatus;
admin_guard(&state, &headers).await?;
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
#[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")
),
security(("bearerAuth" = [])),
tag = "admin"
)]
pub async fn verify_migration(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(dto): Json<VerifyMigrationDto>,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
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.
#[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(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
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
/// GET /api/admin/settings/general — system overview (backward compat)
#[utoipa::path(
get,
path = "/api/admin/settings/general",
responses(
(status = 200, description = "General system settings"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Admin required")
),
security(("bearerAuth" = [])),
tag = "admin"
)]
pub async fn get_general_settings(
State(state): State<Arc<AppState>>,
2026-02-14 01:29:34 +01:00
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
let auth = state
.auth_service
.as_ref()
.ok_or_else(|| AppError::internal_error("Auth service not configured"))?;
let user_count = auth
.auth_application_service
.count_users_efficient()
.await
.unwrap_or(0);
let oidc_configured = auth.auth_application_service.oidc_enabled();
Ok(Json(serde_json::json!({
"server_version": env!("CARGO_PKG_VERSION"),
"auth_enabled": true,
"total_users": user_count,
"oidc_configured": oidc_configured,
})))
}
// ============================================================================
// 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<Arc<AppState>>,
2026-02-14 01:29:34 +01:00
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
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
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
"#
)
.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
#[utoipa::path(
get,
path = "/api/admin/users",
params(
("limit" = Option<i64>, Query, description = "Max users to return (default 100, max 500)"),
("offset" = Option<i64>, 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<Arc<AppState>>,
2026-02-14 01:29:34 +01:00
headers: HeaderMap,
Query(query): Query<ListUsersQueryDto>,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
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 users = auth
.auth_application_service
.list_users(limit, offset)
.await
.map_err(|e| AppError::internal_error(format!("Failed to list users: {}", e)))?;
let total = auth
.auth_application_service
.count_users_efficient()
.await
.unwrap_or(0);
Ok(Json(serde_json::json!({
"users": users,
"total": total,
"limit": limit,
"offset": 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<Arc<AppState>>,
2026-02-14 01:29:34 +01:00
headers: HeaderMap,
Path(id): Path<String>,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
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
.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
#[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<Arc<AppState>>,
2026-02-14 01:29:34 +01:00
headers: HeaderMap,
Path(id): Path<String>,
) -> Result<impl IntoResponse, AppError> {
let (admin_id, _) = admin_guard(&state, &headers).await?;
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
.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
#[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<Arc<AppState>>,
2026-02-14 01:29:34 +01:00
headers: HeaderMap,
Path(id): Path<String>,
Json(dto): Json<UpdateUserRoleDto>,
) -> Result<impl IntoResponse, AppError> {
let (admin_id, _) = admin_guard(&state, &headers).await?;
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
.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
#[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<Arc<AppState>>,
2026-02-14 01:29:34 +01:00
headers: HeaderMap,
Path(id): Path<String>,
Json(dto): Json<UpdateUserActiveDto>,
) -> Result<impl IntoResponse, AppError> {
let (admin_id, _) = admin_guard(&state, &headers).await?;
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
.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
#[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<Arc<AppState>>,
2026-02-14 01:29:34 +01:00
headers: HeaderMap,
Path(id): Path<String>,
Json(dto): Json<UpdateUserQuotaDto>,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
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
.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)
#[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<Arc<AppState>>,
2026-02-14 01:29:34 +01:00
headers: HeaderMap,
Json(dto): Json<AdminCreateUserDto>,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
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<Arc<AppState>>,
2026-02-14 01:29:34 +01:00
headers: HeaderMap,
Path(id): Path<String>,
Json(dto): Json<AdminResetPasswordDto>,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
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
.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"
})),
))
}
// ============================================================================
// Registration Control
// ============================================================================
/// GET /api/admin/settings/registration — check if public registration is enabled
#[utoipa::path(
get,
path = "/api/admin/settings/registration",
responses(
(status = 200, description = "Registration setting"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Admin required")
),
security(("bearerAuth" = [])),
tag = "admin"
)]
pub async fn get_registration_setting(
State(state): State<Arc<AppState>>,
2026-02-14 01:29:34 +01:00
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
let svc = state
.admin_settings_service
.as_ref()
.ok_or_else(|| AppError::internal_error("Admin settings service not available"))?;
let val = svc.get_registration_enabled().await;
Ok(Json(serde_json::json!({
"registration_enabled": val,
})))
}
/// 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<Arc<AppState>>,
2026-02-14 01:29:34 +01:00
headers: HeaderMap,
Json(body): Json<serde_json::Value>,
) -> Result<impl IntoResponse, AppError> {
let (admin_id, _) = admin_guard(&state, &headers).await?;
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)
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>>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
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,
})))
}
// ─────────────────────────────────────────────────────
// 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"
)]
async fn get_smtp_info(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
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))
}
/// 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>>,
headers: HeaderMap,
Query(params): Query<CapturedEmailQuery>,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
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"
)]
async fn send_smtp_test(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(dto): Json<SendSmtpTestDto>,
) -> Result<impl IntoResponse, AppError> {
let (admin_id, _) = admin_guard(&state, &headers).await?;
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))
}