Files
EventSnap/backend/src/handlers/admin.rs
fabi e79e020566 feat(eventsnap): UX review batch-3 — feed virtualization, a11y/UX fixes, shared primitives
Frontend
- Feed: DOM-windowing via @tanstack/svelte-virtual (new VirtualFeed). The window
  virtualizer keeps the sticky header, pull-to-refresh, infinite-scroll sentinel and
  bottom nav working. List = dynamic measured heights keyed by upload id +
  anchorTo:start so an SSE prepend doesn't jump a scrolled reader; grid = measured
  square rows. Drops the content-visibility band-aid and the old FeedGrid.
  measureElement(null) on row unmount prevents ResizeObserver retention; stable
  option callbacks + guarded setOptions avoid O(n) re-measure on like/comment patches.
- Global: viewport-fit=cover (activates safe-area insets), lang=de, PWA manifest +
  maskable icon + apple-touch metas, prefers-reduced-motion, safe-area-top headers.
- Feed reactivity: like/comment SSE patch the single card in place; upload-processed
  debounced in-place merge; IntersectionObserver leak fixed; shared 60s clock.
- CLS: list cards reserve the skeleton aspect box.
- Destructive actions (promote/demote/unban, gallery release) routed through
  ConfirmSheet (host + admin).
- Export OOM: streamed download via single-use ticket instead of res.blob().
- Shared primitives: IconButton (44px hit area), scrollLock action; UploadSheet a11y.
- Polish: api timeout + non-JSON guard, focus-trap offsetParent fix, pull-to-refresh
  passive:false + guards, diashow keyboard a11y, Toaster assertive errors, dark-mode
  gaps, aria-pressed on like/chip state, CameraCapture mic-on-video + retry,
  ConfirmSheet spinner, upload beforeunload warning, emoji->SVG icons.

Backend
- social.rs: like/comment SSE broadcasts now carry the fresh count so feed clients
  patch one card in place instead of refetching page 1.
- admin.rs / main.rs: supporting changes for the above.

Verified: svelte-check 0 errors, frontend build clean, /feed SSR 200. Runtime feed
scroll-feel validation still owed (documented in FOLLOWUPS.md).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 19:16:48 +02:00

397 lines
14 KiB
Rust

use std::collections::HashMap;
use std::time::Duration;
use axum::extract::{Query, 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<String>,
pub created_at: chrono::DateTime<chrono::Utc>,
pub completed_at: Option<chrono::DateTime<chrono::Utc>>,
}
// ── Handlers ─────────────────────────────────────────────────────────────────
pub async fn get_stats(
State(state): State<AppState>,
RequireAdmin(_auth): RequireAdmin,
) -> Result<Json<StatsDto>, 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<AppState>,
RequireAdmin(_auth): RequireAdmin,
) -> Result<Json<HashMap<String, String>>, 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<String, String>);
pub async fn patch_config(
State(state): State<AppState>,
RequireAdmin(_auth): RequireAdmin,
Json(body): Json<HashMap<String, String>>,
) -> Result<StatusCode, AppError> {
// 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::<f64>().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<AppState>,
RequireAdmin(_auth): RequireAdmin,
) -> Result<Json<Vec<ExportJobDto>>, 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) ─────────────────────────
#[derive(Deserialize)]
pub struct DownloadQuery {
pub ticket: String,
}
/// Mint a short-lived ticket for a browser-driven export download. The download
/// is a top-level navigation so the multi-GB ZIP streams straight to disk instead
/// of being buffered in memory by `fetch()` + `blob()` — but a navigation can't
/// carry an `Authorization` header, so the client exchanges its Bearer token for
/// an opaque ticket here, then hits `/export/zip?ticket=...`. Reuses the same
/// single-use, 30s-TTL store as the SSE stream.
pub async fn export_ticket(
State(state): State<AppState>,
auth: crate::auth::middleware::AuthUser,
) -> Json<serde_json::Value> {
let ticket = state.sse_tickets.issue(auth.token_hash);
Json(serde_json::json!({ "ticket": ticket }))
}
/// Validate a download ticket (single-use) and confirm its session still exists.
async fn authenticate_download_ticket(state: &AppState, ticket: &str) -> Result<(), AppError> {
let token_hash = state
.sse_tickets
.consume(ticket)
.ok_or_else(|| AppError::Unauthorized("Ticket ungültig oder abgelaufen.".into()))?;
crate::models::session::Session::find_by_token_hash(&state.pool, &token_hash)
.await
.map_err(|e| AppError::Internal(e.into()))?
.ok_or_else(|| AppError::Unauthorized("Sitzung nicht gefunden.".into()))?;
Ok(())
}
pub async fn download_zip(
State(state): State<AppState>,
Query(q): Query<DownloadQuery>,
headers: HeaderMap,
) -> Result<axum::response::Response, AppError> {
authenticate_download_ticket(&state, &q.ticket).await?;
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<AppState>,
Query(q): Query<DownloadQuery>,
headers: HeaderMap,
) -> Result<axum::response::Response, AppError> {
authenticate_download_ticket(&state, &q.ticket).await?;
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<axum::response::Response, AppError> {
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<AppState>,
_auth: crate::auth::middleware::AuthUser,
) -> Result<Json<serde_json::Value>, 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(())
}