- handlers/me.rs (new): GET /api/v1/me/context (profile + role + privacy_note
+ quota toggle state, fetched once on app bootstrap) and GET /api/v1/me/quota
(live used / limit / active uploaders / free disk).
- handlers/upload.rs:
- quota enforcement via the dynamic formula
floor((free_disk * tolerance) / max(active_uploaders, 1)),
gated by quota_enabled + storage_quota_enabled toggles
- new GET /api/v1/upload/{id}/original — unauthed by design
(matches /media/previews/* — URL is the secret) so it works as
<img src> / <video src> / window.open
- rate-limit toggle wiring (rate_limits_enabled + upload_rate_enabled)
- handlers/host.rs:
- POST /api/v1/host/users/{id}/pin-reset — Host may reset guest PINs,
Admin may reset guest + host PINs (never another admin or self).
Returns the freshly-generated plaintext PIN once; emits a global
pin-reset SSE so the affected user's device can clear its localStorage.
- set_role guard expanded so hosts can demote other hosts (not self,
never admins) — backend match for the doc'd permission model.
- handlers/admin.rs: ALLOWED_KEYS split into NUMERIC_KEYS / BOOL_KEYS /
TEXT_KEYS with per-kind validation; saving privacy_note broadcasts an
event-updated SSE so other clients refresh live.
- handlers/feed.rs, handlers/admin.rs (export), auth/handlers.rs:
rate-limit toggle wiring at every limiter call site.
- auth/handlers.rs: when an expired PIN lockout is detected on /recover,
reset failed_pin_attempts to zero before the bcrypt check — without
this every wrong PIN re-locked the user after the cooldown.
- main.rs: wire startup_recovery + spawn_periodic_tasks, register the
new /me/context, /me/quota, /upload/{id}/original, and
/host/users/{id}/pin-reset routes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
363 lines
12 KiB
Rust
363 lines
12 KiB
Rust
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<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) ─────────────────────────
|
|
|
|
pub async fn download_zip(
|
|
State(state): State<AppState>,
|
|
_auth: crate::auth::middleware::AuthUser,
|
|
headers: HeaderMap,
|
|
) -> Result<axum::response::Response, AppError> {
|
|
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>,
|
|
_auth: crate::auth::middleware::AuthUser,
|
|
headers: HeaderMap,
|
|
) -> Result<axum::response::Response, AppError> {
|
|
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(())
|
|
}
|