diff --git a/backend/src/auth/handlers.rs b/backend/src/auth/handlers.rs index f082ed8..1912a82 100644 --- a/backend/src/auth/handlers.rs +++ b/backend/src/auth/handlers.rs @@ -14,6 +14,7 @@ use crate::error::AppError; use crate::models::event::Event; use crate::models::session::Session; use crate::models::user::{User, UserRole}; +use crate::services::config; use crate::services::rate_limiter::client_ip; use crate::state::AppState; @@ -36,7 +37,11 @@ pub async fn join( Json(body): Json, ) -> Result<(StatusCode, Json), AppError> { let ip = client_ip(&headers, "unknown"); - if !state.rate_limiter.check(format!("join:{ip}"), 5, Duration::from_secs(60)) { + let rate_limits_on = config::get_bool(&state.pool, "rate_limits_enabled", true).await; + let join_rate_on = config::get_bool(&state.pool, "join_rate_enabled", true).await; + if rate_limits_on && join_rate_on + && !state.rate_limiter.check(format!("join:{ip}"), 5, Duration::from_secs(60)) + { return Err(AppError::TooManyRequests( "Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(), None, @@ -128,7 +133,11 @@ pub async fn recover( } for user in &users { - // Check PIN lockout + // Check PIN lockout. If the lockout has expired, also reset the failed-attempt + // counter so the user gets a fresh 3-strike window — otherwise the counter + // stays at 3+ and every subsequent wrong PIN immediately re-locks them, even + // after waiting out the cooldown. Without this reset, a once-locked account + // is effectively permanently fragile. if let Some(locked_until) = user.pin_locked_until { if Utc::now() < locked_until { return Err(AppError::TooManyRequests( @@ -136,6 +145,8 @@ pub async fn recover( None, )); } + // Lockout window expired — wipe the counter and the timestamp. + User::reset_pin_attempts(&state.pool, user.id).await?; } let pin_matches = bcrypt::verify(&body.pin, &user.recovery_pin_hash) diff --git a/backend/src/handlers/admin.rs b/backend/src/handlers/admin.rs index 7b1d039..05eb849 100644 --- a/backend/src/handlers/admin.rs +++ b/backend/src/handlers/admin.rs @@ -9,6 +9,7 @@ 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; @@ -117,7 +118,10 @@ pub async fn patch_config( RequireAdmin(_auth): RequireAdmin, Json(body): Json>, ) -> Result { - const ALLOWED_KEYS: &[&str] = &[ + // 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", @@ -127,15 +131,53 @@ pub async fn patch_config( "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 { - 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."))); + 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()", @@ -146,6 +188,15 @@ pub async fn patch_config( .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) } @@ -177,11 +228,7 @@ pub async fn download_zip( _auth: crate::auth::middleware::AuthUser, headers: HeaderMap, ) -> Result { - let ip = client_ip(&headers, "unknown"); - let limit = get_config_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)); - } + enforce_export_rate(&state, &headers).await?; let event = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug) .await? @@ -206,11 +253,7 @@ pub async fn download_html( _auth: crate::auth::middleware::AuthUser, headers: HeaderMap, ) -> Result { - let ip = client_ip(&headers, "unknown"); - let limit = get_config_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)); - } + enforce_export_rate(&state, &headers).await?; let event = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug) .await? @@ -295,12 +338,25 @@ pub async fn export_status( }))) } -async fn get_config_usize(pool: &sqlx::PgPool, key: &str, default: usize) -> usize { - let row: Option<(String,)> = - sqlx::query_as("SELECT value FROM config WHERE key = $1") - .bind(key) - .fetch_optional(pool) - .await - .unwrap_or(None); - row.and_then(|r| r.0.parse().ok()).unwrap_or(default) +/// 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(()) } diff --git a/backend/src/handlers/feed.rs b/backend/src/handlers/feed.rs index 55ae632..0a6f21f 100644 --- a/backend/src/handlers/feed.rs +++ b/backend/src/handlers/feed.rs @@ -9,6 +9,7 @@ use uuid::Uuid; use crate::auth::middleware::AuthUser; use crate::error::AppError; +use crate::services::config; use crate::services::rate_limiter::client_ip; use crate::state::AppState; @@ -61,9 +62,19 @@ pub async fn feed( Query(q): Query, ) -> Result, AppError> { let ip = client_ip(&headers, "unknown"); - let rate_limit = get_config_usize(&state.pool, "feed_rate_per_min", 60).await; - if !state.rate_limiter.check(format!("feed:{ip}"), rate_limit, Duration::from_secs(60)) { - return Err(AppError::TooManyRequests("Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(), None)); + let rate_limits_on = config::get_bool(&state.pool, "rate_limits_enabled", true).await; + let feed_rate_on = config::get_bool(&state.pool, "feed_rate_enabled", true).await; + if rate_limits_on && feed_rate_on { + let rate_limit = config::get_usize(&state.pool, "feed_rate_per_min", 60).await; + if !state + .rate_limiter + .check(format!("feed:{ip}"), rate_limit, Duration::from_secs(60)) + { + return Err(AppError::TooManyRequests( + "Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(), + None, + )); + } } let limit = q.limit.unwrap_or(20).min(100); @@ -238,16 +249,6 @@ pub async fn hashtags( )) } -async fn get_config_usize(pool: &sqlx::PgPool, key: &str, default: usize) -> usize { - let row: Option<(String,)> = - sqlx::query_as("SELECT value FROM config WHERE key = $1") - .bind(key) - .fetch_optional(pool) - .await - .unwrap_or(None); - row.and_then(|r| r.0.parse().ok()).unwrap_or(default) -} - async fn get_cursor_time(pool: &sqlx::PgPool, cursor_id: Uuid) -> Option> { let row: Option<(DateTime,)> = sqlx::query_as("SELECT created_at FROM upload WHERE id = $1") diff --git a/backend/src/handlers/host.rs b/backend/src/handlers/host.rs index 34377b0..ae52f86 100644 --- a/backend/src/handlers/host.rs +++ b/backend/src/handlers/host.rs @@ -10,7 +10,8 @@ use crate::error::AppError; use crate::models::comment::Comment; use crate::models::event::Event; use crate::models::upload::Upload; -use crate::state::AppState; +use crate::models::user::UserRole; +use crate::state::{AppState, SseEvent}; // ── DTOs ───────────────────────────────────────────────────────────────────── @@ -141,13 +142,39 @@ pub async fn set_role( Json(body): Json, ) -> Result { if user_id == auth.user_id { - return Err(AppError::BadRequest("Du kannst deine eigene Rolle nicht ändern.".into())); + return Err(AppError::BadRequest( + "Du kannst deine eigene Rolle nicht ändern.".into(), + )); } let new_role = match body.role.as_str() { "guest" => "guest", "host" => "host", - _ => return Err(AppError::BadRequest("Ungültige Rolle. Erlaubt: guest, host.".into())), + _ => { + return Err(AppError::BadRequest( + "Ungültige Rolle. Erlaubt: guest, host.".into(), + )) + } }; + + // Look up the current role so we can apply the host-vs-admin guard. Hosts may + // promote guests and demote *other* hosts (the user explicitly requested this + // expansion). Hosts may not touch admins. Admins may do anything (except change + // themselves, blocked above). + let target = sqlx::query_as::<_, (String,)>( + "SELECT role::text FROM \"user\" WHERE id = $1 AND event_id = $2", + ) + .bind(user_id) + .bind(auth.event_id) + .fetch_optional(&state.pool) + .await? + .ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?; + + if target.0 == "admin" { + return Err(AppError::Forbidden( + "Admins können nicht geändert werden.".into(), + )); + } + sqlx::query("UPDATE \"user\" SET role = $2::user_role WHERE id = $1 AND event_id = $3") .bind(user_id) .bind(new_role) @@ -157,6 +184,76 @@ pub async fn set_role( Ok(StatusCode::NO_CONTENT) } +#[derive(Serialize)] +pub struct PinResetResponse { + /// Plaintext PIN — shown to the operator **once**. Never persisted client-side. + pub pin: String, +} + +/// Generate a fresh PIN for another user, returning the plaintext exactly once. +/// +/// Authorisation: +/// - Host caller → may reset **guest** PINs only. +/// - Admin caller → may reset **guest** and **host** PINs (never another admin). +/// - Target ≠ caller. +pub async fn reset_user_pin( + State(state): State, + RequireHost(auth): RequireHost, + Path(user_id): Path, +) -> Result, AppError> { + use rand::Rng; + + if user_id == auth.user_id { + return Err(AppError::BadRequest( + "Du kannst deine eigene PIN nicht über diese Funktion zurücksetzen.".into(), + )); + } + + let target = sqlx::query_as::<_, (String,)>( + "SELECT role::text FROM \"user\" WHERE id = $1 AND event_id = $2", + ) + .bind(user_id) + .bind(auth.event_id) + .fetch_optional(&state.pool) + .await? + .ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?; + + match (auth.role.clone(), target.0.as_str()) { + (UserRole::Admin, "guest" | "host") => {} + (UserRole::Host, "guest") => {} + _ => { + return Err(AppError::Forbidden( + "Du darfst die PIN dieses Benutzers nicht zurücksetzen.".into(), + )) + } + } + + let pin: String = format!("{:04}", rand::rng().random_range(0..10000u32)); + let pin_hash = + bcrypt::hash(&pin, 12).map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?; + + sqlx::query( + "UPDATE \"user\" + SET recovery_pin_hash = $1, + pin_failed_attempts = 0, + pin_locked_until = NULL + WHERE id = $2", + ) + .bind(&pin_hash) + .bind(user_id) + .execute(&state.pool) + .await?; + + // Notify the *recipient* device(s) if they happen to be online so they can clear + // their cached local PIN. They'll save the new one on the next /recover. + let _ = state.sse_tx.send(SseEvent::new( + "pin-reset", + serde_json::json!({ "user_id": user_id }).to_string(), + )); + + Ok(Json(PinResetResponse { pin })) +} + pub async fn host_delete_upload( State(state): State, RequireHost(_auth): RequireHost, @@ -168,10 +265,10 @@ pub async fn host_delete_upload( Upload::soft_delete(&state.pool, upload_id).await?; - let _ = state.sse_tx.send(crate::state::SseEvent { - event_type: "upload-deleted".to_string(), - data: serde_json::json!({ "upload_id": upload.id }).to_string(), - }); + let _ = state.sse_tx.send(SseEvent::new( + "upload-deleted", + serde_json::json!({ "upload_id": upload.id }).to_string(), + )); Ok(StatusCode::NO_CONTENT) } @@ -200,10 +297,7 @@ pub async fn close_event( .execute(&state.pool) .await?; - let _ = state.sse_tx.send(crate::state::SseEvent { - event_type: "event-closed".to_string(), - data: "{}".to_string(), - }); + let _ = state.sse_tx.send(SseEvent::new("event-closed", "{}")); Ok(StatusCode::NO_CONTENT) } @@ -219,10 +313,7 @@ pub async fn open_event( .execute(&state.pool) .await?; - let _ = state.sse_tx.send(crate::state::SseEvent { - event_type: "event-opened".to_string(), - data: "{}".to_string(), - }); + let _ = state.sse_tx.send(SseEvent::new("event-opened", "{}")); Ok(StatusCode::NO_CONTENT) } diff --git a/backend/src/handlers/me.rs b/backend/src/handlers/me.rs new file mode 100644 index 0000000..71c1df1 --- /dev/null +++ b/backend/src/handlers/me.rs @@ -0,0 +1,80 @@ +//! Endpoints scoped to the *current user*. Kept separate from `auth::handlers` because +//! these aren't about acquiring / refreshing a session — they're about reading my own +//! state once I'm already signed in. +//! +//! Current routes: +//! - `GET /api/v1/me/context` — bundled profile + feature flags + privacy note. The +//! account page loads this once on mount instead of issuing several round trips. +//! - `GET /api/v1/me/quota` — live per-user storage quota estimate. + +use axum::extract::State; +use axum::Json; +use serde::Serialize; + +use crate::auth::middleware::AuthUser; +use crate::error::AppError; +use crate::handlers::upload::compute_storage_quota; +use crate::models::user::User; +use crate::services::config; +use crate::state::AppState; + +#[derive(Serialize)] +pub struct QuotaDto { + pub enabled: bool, + pub used_bytes: i64, + pub limit_bytes: Option, + pub active_uploaders: i64, + pub free_disk_bytes: i64, +} + +pub async fn get_quota( + State(state): State, + auth: AuthUser, +) -> Result, AppError> { + let user = User::find_by_id(&state.pool, auth.user_id) + .await? + .ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?; + + let estimate = compute_storage_quota(&state).await; + + Ok(Json(QuotaDto { + enabled: estimate.limit_bytes.is_some(), + used_bytes: user.total_upload_bytes, + limit_bytes: estimate.limit_bytes, + active_uploaders: estimate.active_uploaders, + free_disk_bytes: estimate.free_disk_bytes, + })) +} + +#[derive(Serialize)] +pub struct MeContextDto { + pub user_id: uuid::Uuid, + pub display_name: String, + pub role: String, + /// Plain-text Datenschutzhinweis set by the admin. Empty string when not configured. + pub privacy_note: String, + pub quota_enabled: bool, + pub storage_quota_enabled: bool, +} + +pub async fn get_context( + State(state): State, + auth: AuthUser, +) -> Result, AppError> { + let user = User::find_by_id(&state.pool, auth.user_id) + .await? + .ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?; + + let privacy_note = config::get_str(&state.pool, "privacy_note", "").await; + let quota_enabled = config::get_bool(&state.pool, "quota_enabled", true).await; + let storage_quota_enabled = config::get_bool(&state.pool, "storage_quota_enabled", true).await; + + Ok(Json(MeContextDto { + user_id: user.id, + display_name: user.display_name, + role: user.role.as_str().to_string(), + privacy_note, + quota_enabled, + storage_quota_enabled, + })) +} diff --git a/backend/src/handlers/mod.rs b/backend/src/handlers/mod.rs index 295f2b8..652fab2 100644 --- a/backend/src/handlers/mod.rs +++ b/backend/src/handlers/mod.rs @@ -1,6 +1,7 @@ pub mod admin; pub mod feed; pub mod host; +pub mod me; pub mod social; pub mod sse; pub mod upload; diff --git a/backend/src/handlers/upload.rs b/backend/src/handlers/upload.rs index 001c8a4..0191354 100644 --- a/backend/src/handlers/upload.rs +++ b/backend/src/handlers/upload.rs @@ -11,24 +11,32 @@ use crate::error::AppError; use crate::models::hashtag::{self, Hashtag}; use crate::models::upload::{Upload, UploadDto}; use crate::models::user::User; +use crate::services::config; use crate::state::AppState; +const MAX_CAPTION_LENGTH: usize = 2000; + pub async fn upload( State(state): State, auth: AuthUser, mut multipart: Multipart, ) -> Result<(StatusCode, Json), AppError> { - // Rate limit: N uploads per hour per user - let upload_rate = get_config_i64(&state.pool, "upload_rate_per_hour", 10).await as usize; - if let Err(retry_after_secs) = state - .rate_limiter - .check_with_retry(format!("upload:{}", auth.user_id), upload_rate, Duration::from_secs(3600)) - { - drain_multipart(multipart).await; - return Err(AppError::TooManyRequests( - "Du hast dein Upload-Limit für diese Stunde erreicht.".into(), - Some(retry_after_secs), - )); + // Rate limit: N uploads per hour per user. Gated by master + per-endpoint toggles. + let rate_limits_on = config::get_bool(&state.pool, "rate_limits_enabled", true).await; + let upload_rate_on = config::get_bool(&state.pool, "upload_rate_enabled", true).await; + if rate_limits_on && upload_rate_on { + let upload_rate = config::get_i64(&state.pool, "upload_rate_per_hour", 10).await as usize; + if let Err(retry_after_secs) = state.rate_limiter.check_with_retry( + format!("upload:{}", auth.user_id), + upload_rate, + Duration::from_secs(3600), + ) { + drain_multipart(multipart).await; + return Err(AppError::TooManyRequests( + "Du hast dein Upload-Limit für diese Stunde erreicht.".into(), + Some(retry_after_secs), + )); + } } // Check if user is banned @@ -50,8 +58,8 @@ pub async fn upload( } // Read config limits from DB - let max_image_mb: i64 = get_config_i64(&state.pool, "max_image_size_mb", 20).await; - let max_video_mb: i64 = get_config_i64(&state.pool, "max_video_size_mb", 500).await; + let max_image_mb: i64 = config::get_i64(&state.pool, "max_image_size_mb", 20).await; + let max_video_mb: i64 = config::get_i64(&state.pool, "max_video_size_mb", 500).await; let mut file_data: Option> = None; let mut file_name: Option = None; @@ -91,6 +99,33 @@ pub async fn upload( let mime = content_type.unwrap_or_else(|| "application/octet-stream".to_string()); let size = data.len() as i64; + // Validate caption length + if let Some(ref cap) = caption { + if cap.len() > MAX_CAPTION_LENGTH { + return Err(AppError::BadRequest(format!( + "Beschreibung ist zu lang. Maximum: {} Zeichen.", + MAX_CAPTION_LENGTH + ))); + } + } + + // Validate file MIME type using magic bytes + let detected_mime = infer::get(&data); + if let Some(detected) = detected_mime { + let detected_type = detected.mime_type(); + // Ensure detected type is compatible with declared MIME type + let declared_category = mime.split('/').next().unwrap_or(""); + let detected_category = detected_type.split('/').next().unwrap_or(""); + + // Only reject if categories don't match (e.g., image vs video) + if declared_category != "application" && declared_category != detected_category { + return Err(AppError::BadRequest(format!( + "Dateiinhalt entspricht nicht dem deklarierten Typ. Erwartet: {}, erkannt: {}", + mime, detected_type + ))); + } + } + // Validate file size let max_bytes = if mime.starts_with("video/") { max_video_mb * 1024 * 1024 @@ -104,6 +139,24 @@ pub async fn upload( ))); } + // Per-user storage quota — dynamic formula based on available disk space and the + // number of active uploaders. Gated by master + per-area toggles so the admin can + // disable it on trusted instances. + let quota_on = config::get_bool(&state.pool, "quota_enabled", true).await; + let storage_quota_on = config::get_bool(&state.pool, "storage_quota_enabled", true).await; + if quota_on && storage_quota_on { + let estimate = compute_storage_quota(&state).await; + if let Some(limit) = estimate.limit_bytes { + let prospective_total = user.total_upload_bytes.saturating_add(size); + if prospective_total > limit { + return Err(AppError::TooManyRequests( + "Du hast dein Upload-Limit für dieses Event erreicht.".into(), + None, + )); + } + } + } + // Determine file extension let ext = file_name .as_deref() @@ -182,10 +235,10 @@ pub async fn upload( created_at: upload.created_at, }; - let _ = state.sse_tx.send(crate::state::SseEvent { - event_type: "new-upload".to_string(), - data: serde_json::to_string(&dto).unwrap_or_default(), - }); + let _ = state.sse_tx.send(crate::state::SseEvent::new( + "new-upload", + serde_json::to_string(&dto).unwrap_or_default(), + )); Ok((StatusCode::CREATED, Json(dto))) } @@ -252,12 +305,107 @@ async fn drain_multipart(mut mp: Multipart) { } } -async fn get_config_i64(pool: &sqlx::PgPool, key: &str, default: i64) -> i64 { - let row: Option<(String,)> = - sqlx::query_as("SELECT value FROM config WHERE key = $1") - .bind(key) - .fetch_optional(pool) - .await - .unwrap_or(None); - row.and_then(|r| r.0.parse().ok()).unwrap_or(default) +/// Snapshot of the dynamic per-user quota used both by the upload pre-check and the +/// `GET /me/quota` endpoint. `limit_bytes = None` means quota enforcement is currently +/// off (the frontend hides the widget in that case). +pub struct QuotaEstimate { + pub limit_bytes: Option, + pub active_uploaders: i64, + pub free_disk_bytes: i64, + pub tolerance: f64, +} + +/// Computes the per-user storage quota using +/// `floor((free_disk * tolerance) / max(active_uploaders, 1))`. Returns `limit_bytes = +/// None` whenever the storage quota is currently disabled — callers should skip the +/// check (upload handler) or hide the UI (quota endpoint). +pub async fn compute_storage_quota(state: &AppState) -> QuotaEstimate { + let quota_on = config::get_bool(&state.pool, "quota_enabled", true).await; + let storage_quota_on = config::get_bool(&state.pool, "storage_quota_enabled", true).await; + let tolerance = config::get_f64(&state.pool, "quota_tolerance", 0.75).await; + + let (active_count,): (i64,) = sqlx::query_as( + "SELECT COUNT(DISTINCT user_id) FROM upload WHERE deleted_at IS NULL", + ) + .fetch_one(&state.pool) + .await + .unwrap_or((0,)); + let active = active_count.max(1); + + let media_path = state.config.media_path.to_string_lossy().to_string(); + let free_disk = sysinfo::Disks::new_with_refreshed_list() + .iter() + .find(|d| media_path.starts_with(d.mount_point().to_string_lossy().as_ref())) + .map(|d| d.available_space()) + .unwrap_or_else(|| { + sysinfo::Disks::new_with_refreshed_list() + .iter() + .find(|d| d.mount_point().to_string_lossy() == "/") + .map(|d| d.available_space()) + .unwrap_or(0) + }) as i64; + + let limit_bytes = if quota_on && storage_quota_on { + Some(((free_disk as f64 * tolerance) / active as f64).floor() as i64) + } else { + None + }; + + QuotaEstimate { + limit_bytes, + active_uploaders: active, + free_disk_bytes: free_disk, + tolerance, + } +} + +/// Streaming download of the original file behind an upload. Used by: +/// - the per-post "Original anzeigen" context action (`window.open`) +/// - `` / `