use axum::extract::State; use axum::http::StatusCode; use axum::Json; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use sysinfo::System; use crate::auth::middleware::RequireAdmin; use crate::error::AppError; use crate::state::AppState; // ── DTOs ───────────────────────────────────────────────────────────────────── #[derive(Serialize)] pub struct StatsDto { pub user_count: i64, pub upload_count: i64, pub comment_count: i64, pub disk_total_bytes: u64, pub disk_used_bytes: u64, pub disk_free_bytes: u64, } #[derive(Serialize, sqlx::FromRow)] pub struct ExportJobDto { pub id: uuid::Uuid, pub r#type: String, pub status: String, pub progress_pct: i16, pub error_message: Option, pub created_at: chrono::DateTime, pub completed_at: Option>, } // ── Handlers ───────────────────────────────────────────────────────────────── pub async fn get_stats( State(state): State, RequireAdmin(_auth): RequireAdmin, ) -> Result, AppError> { let event = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug) .await? .ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?; let (user_count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM \"user\" WHERE event_id = $1") .bind(event.id) .fetch_one(&state.pool) .await?; let (upload_count,): (i64,) = sqlx::query_as( "SELECT COUNT(*) FROM upload WHERE event_id = $1 AND deleted_at IS NULL", ) .bind(event.id) .fetch_one(&state.pool) .await?; let (comment_count,): (i64,) = sqlx::query_as( "SELECT COUNT(*) FROM comment c JOIN upload u ON u.id = c.upload_id WHERE u.event_id = $1 AND c.deleted_at IS NULL", ) .bind(event.id) .fetch_one(&state.pool) .await?; // Disk usage via sysinfo let mut sys = System::new(); sys.refresh_all(); let media_path = state.config.media_path.to_string_lossy().to_string(); let (disk_total, disk_free) = sysinfo::Disks::new_with_refreshed_list() .iter() .find(|d| media_path.starts_with(d.mount_point().to_string_lossy().as_ref())) .map(|d| (d.total_space(), d.available_space())) .unwrap_or_else(|| { // Fall back to the root disk sysinfo::Disks::new_with_refreshed_list() .iter() .find(|d| d.mount_point().to_string_lossy() == "/") .map(|d| (d.total_space(), d.available_space())) .unwrap_or((0, 0)) }); let disk_used = disk_total.saturating_sub(disk_free); Ok(Json(StatsDto { user_count, upload_count, comment_count, disk_total_bytes: disk_total, disk_used_bytes: disk_used, disk_free_bytes: disk_free, })) } pub async fn get_config( State(state): State, RequireAdmin(_auth): RequireAdmin, ) -> Result>, AppError> { let rows: Vec<(String, String)> = sqlx::query_as("SELECT key, value FROM config ORDER BY key") .fetch_all(&state.pool) .await?; Ok(Json(rows.into_iter().collect())) } #[derive(Deserialize)] pub struct PatchConfigRequest(pub HashMap); pub async fn patch_config( State(state): State, RequireAdmin(_auth): RequireAdmin, Json(body): Json>, ) -> Result { const ALLOWED_KEYS: &[&str] = &[ "max_image_size_mb", "max_video_size_mb", "upload_rate_per_hour", "feed_rate_per_min", "export_rate_per_day", "quota_tolerance", "estimated_guest_count", "compression_concurrency", ]; for (key, value) in &body { if !ALLOWED_KEYS.contains(&key.as_str()) { return Err(AppError::BadRequest(format!("Unbekannter Konfigurationsschlüssel: {key}"))); } // Validate numeric values if value.parse::().is_err() { return Err(AppError::BadRequest(format!("Ungültiger Wert für {key}: muss eine Zahl sein."))); } sqlx::query( "INSERT INTO config (key, value, updated_at) VALUES ($1, $2, NOW()) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()", ) .bind(key) .bind(value) .execute(&state.pool) .await?; } Ok(StatusCode::NO_CONTENT) } pub async fn get_export_jobs( State(state): State, RequireAdmin(_auth): RequireAdmin, ) -> Result>, AppError> { let event = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug) .await? .ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?; let jobs = sqlx::query_as::<_, ExportJobDto>( "SELECT id, type::text, status::text, progress_pct, error_message, created_at, completed_at FROM export_job WHERE event_id = $1 ORDER BY created_at DESC", ) .bind(event.id) .fetch_all(&state.pool) .await?; Ok(Json(jobs)) } // ── Export download endpoints (authenticated guests) ───────────────────────── pub async fn download_zip( State(state): State, _auth: crate::auth::middleware::AuthUser, ) -> Result { let event = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug) .await? .ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?; if !event.export_zip_ready { return Err(AppError::NotFound( "Der ZIP-Export ist noch nicht verfügbar.".into(), )); } let path = state.config.media_path.join("exports").join("Gallery.zip"); if !path.exists() { return Err(AppError::NotFound("Exportdatei nicht gefunden.".into())); } serve_file(path, "Gallery.zip", "application/zip").await } pub async fn download_html( State(state): State, _auth: crate::auth::middleware::AuthUser, ) -> Result { let event = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug) .await? .ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?; if !event.export_html_ready { return Err(AppError::NotFound( "Der HTML-Export ist noch nicht verfügbar.".into(), )); } let path = state.config.media_path.join("exports").join("Memories.zip"); if !path.exists() { return Err(AppError::NotFound("Exportdatei nicht gefunden.".into())); } serve_file(path, "Memories.zip", "application/zip").await } async fn serve_file( path: std::path::PathBuf, filename: &str, content_type: &str, ) -> Result { use axum::body::Body; use axum::http::{header, Response, StatusCode}; use tokio_util::io::ReaderStream; let file = tokio::fs::File::open(&path) .await .map_err(|e| AppError::Internal(e.into()))?; let metadata = file .metadata() .await .map_err(|e| AppError::Internal(e.into()))?; let stream = ReaderStream::new(file); let disposition = format!("attachment; filename=\"{filename}\""); let response = Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, content_type) .header(header::CONTENT_DISPOSITION, disposition) .header(header::CONTENT_LENGTH, metadata.len()) .body(Body::from_stream(stream)) .map_err(|e| AppError::Internal(e.into()))?; Ok(response) } /// Also expose export status to all authenticated users (guests need it for the export page) pub async fn export_status( State(state): State, _auth: crate::auth::middleware::AuthUser, ) -> Result, AppError> { let event = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug) .await? .ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?; let released = event.export_released_at.is_some(); let jobs: Vec<(String, String, i16)> = sqlx::query_as( "SELECT type::text, status::text, progress_pct FROM export_job WHERE event_id = $1", ) .bind(event.id) .fetch_all(&state.pool) .await?; let job_status = |type_name: &str| { jobs.iter() .find(|(t, _, _)| t == type_name) .map(|(_, status, pct)| { serde_json::json!({ "status": status, "progress_pct": pct }) }) .unwrap_or_else(|| serde_json::json!({ "status": "locked", "progress_pct": 0 })) }; Ok(Json(serde_json::json!({ "released": released, "zip": job_status("zip"), "html": job_status("html"), }))) }