diff --git a/backend/src/handlers/admin.rs b/backend/src/handlers/admin.rs new file mode 100644 index 0000000..e3162c6 --- /dev/null +++ b/backend/src/handlers/admin.rs @@ -0,0 +1,202 @@ +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)) +} + +/// 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"), + }))) +} diff --git a/backend/src/handlers/mod.rs b/backend/src/handlers/mod.rs index ff02441..295f2b8 100644 --- a/backend/src/handlers/mod.rs +++ b/backend/src/handlers/mod.rs @@ -1,3 +1,4 @@ +pub mod admin; pub mod feed; pub mod host; pub mod social; diff --git a/backend/src/main.rs b/backend/src/main.rs index f0b47c0..ab95007 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -69,7 +69,16 @@ async fn main() -> Result<()> { .route("/api/v1/host/users/{id}/unban", post(handlers::host::unban_user)) .route("/api/v1/host/users/{id}/role", patch(handlers::host::set_role)) .route("/api/v1/host/upload/{id}", delete(handlers::host::host_delete_upload)) - .route("/api/v1/host/comment/{id}", delete(handlers::host::host_delete_comment)); + .route("/api/v1/host/comment/{id}", delete(handlers::host::host_delete_comment)) + // Export status (all authenticated users) + .route("/api/v1/export/status", get(handlers::admin::export_status)) + // Admin Dashboard + .route("/api/v1/admin/stats", get(handlers::admin::get_stats)) + .route( + "/api/v1/admin/config", + get(handlers::admin::get_config).patch(handlers::admin::patch_config), + ) + .route("/api/v1/admin/export/jobs", get(handlers::admin::get_export_jobs)); // Serve media files from disk let media_service = ServeDir::new(&config.media_path); diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte new file mode 100644 index 0000000..065ba95 --- /dev/null +++ b/frontend/src/routes/admin/+page.svelte @@ -0,0 +1,266 @@ + + + +{#if toast} +
+ {toast} +
+{/if} + +
+ +
+
+

Admin Dashboard

+ +
+
+ +
+ {#if loading} +
Laden…
+ {:else if error} +
{error}
+ {:else} + + {#if stats} +
+

Statistiken

+
+
+

{stats.user_count}

+

Gäste

+
+
+

{stats.upload_count}

+

Uploads

+
+
+

{stats.comment_count}

+

Kommentare

+
+
+ + +
+
+ Speicher + {formatBytes(stats.disk_used_bytes)} / {formatBytes(stats.disk_total_bytes)} ({diskPct(stats)} %) +
+
+
+
+

{formatBytes(stats.disk_free_bytes)} frei

+
+
+ {/if} + + +
+

Konfiguration

+
+ {#each Object.entries(CONFIG_LABELS) as [key, label]} +
+ + +
+ {/each} +
+ +
+ + +
+
+

Export-Jobs

+ +
+ {#if exportJobs.length === 0} +

Noch keine Export-Jobs.

+ {:else} +
+ {#each exportJobs as job} +
+
+ {jobLabel(job.type)} + + {statusLabel(job.status)} + +
+ {#if job.status === 'running'} +
+
+ Fortschritt + {job.progress_pct} % +
+
+
+
+
+ {/if} + {#if job.error_message} +

{job.error_message}

+ {/if} +
+ {/each} +
+ {/if} +
+ {/if} +
+