use std::collections::HashMap; use std::time::Duration; use axum::extract::State; use axum::http::{HeaderMap, StatusCode}; use axum::Json; use serde::{Deserialize, Serialize}; use sysinfo::System; use crate::auth::middleware::RequireAdmin; use crate::error::AppError; use crate::services::config; use crate::services::rate_limiter::client_ip; 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 { // Numeric keys validated as f64; boolean keys validated as truthy strings; the // privacy note is free text. Splitting these explicitly is verbose but makes the // failure mode for typos obvious (`Unbekannter Schlüssel: ...`). const NUMERIC_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", ]; const BOOL_KEYS: &[&str] = &[ "rate_limits_enabled", "upload_rate_enabled", "feed_rate_enabled", "export_rate_enabled", "join_rate_enabled", "quota_enabled", "storage_quota_enabled", "upload_count_quota_enabled", ]; const TEXT_KEYS: &[&str] = &["privacy_note"]; const PRIVACY_NOTE_MAX_LEN: usize = 16 * 1024; // 16 KiB free text is plenty let mut privacy_note_changed = false; for (key, value) in &body { let key_str = key.as_str(); if NUMERIC_KEYS.contains(&key_str) { if value.parse::().is_err() { return Err(AppError::BadRequest(format!( "Ungültiger Wert für {key}: muss eine Zahl sein." ))); } } else if BOOL_KEYS.contains(&key_str) { match value.trim().to_ascii_lowercase().as_str() { "true" | "false" | "1" | "0" | "yes" | "no" | "on" | "off" => {} _ => { return Err(AppError::BadRequest(format!( "Ungültiger Wert für {key}: muss true oder false sein." ))); } } } else if TEXT_KEYS.contains(&key_str) { if value.len() > PRIVACY_NOTE_MAX_LEN { return Err(AppError::BadRequest(format!( "Wert für {key} ist zu lang (max. {PRIVACY_NOTE_MAX_LEN} Zeichen)." ))); } if key_str == "privacy_note" { privacy_note_changed = true; } } else { return Err(AppError::BadRequest(format!( "Unbekannter Konfigurationsschlüssel: {key}" ))); } 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?; } // Notify all clients that a publicly-readable config value changed so their stores // (e.g. the privacy note in My Account) refresh without a manual reload. if privacy_note_changed { let _ = state.sse_tx.send(crate::state::SseEvent::new( "event-updated", serde_json::json!({ "keys": ["privacy_note"] }).to_string(), )); } 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, headers: HeaderMap, ) -> Result { enforce_export_rate(&state, &headers).await?; 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, headers: HeaderMap, ) -> Result { enforce_export_rate(&state, &headers).await?; 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"), }))) } /// Centralised guard for the export rate limit. Same pattern as upload/feed: master /// switch + per-endpoint switch + numeric value, all stored in `config` and read on /// each request. async fn enforce_export_rate(state: &AppState, headers: &HeaderMap) -> Result<(), AppError> { let rate_limits_on = config::get_bool(&state.pool, "rate_limits_enabled", true).await; let export_rate_on = config::get_bool(&state.pool, "export_rate_enabled", true).await; if !(rate_limits_on && export_rate_on) { return Ok(()); } let ip = client_ip(headers, "unknown"); let limit = config::get_usize(&state.pool, "export_rate_per_day", 3).await; if !state .rate_limiter .check(format!("export:{ip}"), limit, Duration::from_secs(86400)) { return Err(AppError::TooManyRequests( "Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(), None, )); } Ok(()) }