Files
EventSnap/backend/src/handlers/me.rs
MechaCat02 44641473ea fix(quota): stop /me/quota leaking raw disk to guests
The per-user quota widget was shown to everyone and the /me/quota payload returned
free_disk_bytes (raw server free space) and active_uploaders to any authenticated
guest. Gate the widget to staff (host/admin) on the upload and account pages, and zero
the server-wide telemetry fields for non-staff in the handler. Guests still get their
own used/limit so enforcement stays transparent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 15:22:03 +02:00

115 lines
3.9 KiB
Rust

//! 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::Json;
use axum::extract::State;
use serde::Serialize;
use crate::auth::middleware::AuthUser;
use crate::error::AppError;
use crate::handlers::upload::compute_storage_quota;
use crate::models::user::{User, UserRole};
use crate::services::config;
use crate::state::AppState;
#[derive(Serialize)]
pub struct QuotaDto {
pub enabled: bool,
pub used_bytes: i64,
pub limit_bytes: Option<i64>,
pub active_uploaders: i64,
pub free_disk_bytes: i64,
}
pub async fn get_quota(
State(state): State<AppState>,
auth: AuthUser,
) -> Result<Json<QuotaDto>, 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;
// Raw server telemetry (free disk, concurrent uploader count) is staff-only — it
// must never reach a guest, even though the guest upload UI no longer renders it.
// A guest still gets their own `used`/`limit` so enforcement stays transparent to
// the code paths that consume it; only the server-wide fields are zeroed.
let is_staff = matches!(auth.role, UserRole::Host | UserRole::Admin);
Ok(Json(QuotaDto {
enabled: estimate.limit_bytes.is_some(),
used_bytes: user.total_upload_bytes,
limit_bytes: estimate.limit_bytes,
active_uploaders: if is_staff {
estimate.active_uploaders
} else {
0
},
free_disk_bytes: if is_staff {
estimate.free_disk_bytes
} else {
0
},
}))
}
#[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,
/// Uploads are locked (event closed) — the composer should show a locked state live
/// instead of letting a guest compose an upload only to eat a 403.
pub uploads_locked: bool,
/// The gallery has been released and the export snapshotted — uploads are permanently
/// closed for this run (release ⇒ lock, and reopening regenerates).
pub gallery_released: bool,
}
pub async fn get_context(
State(state): State<AppState>,
auth: AuthUser,
) -> Result<Json<MeContextDto>, 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.config_cache, "privacy_note", "").await;
let quota_enabled = config::get_bool(&state.config_cache, "quota_enabled", true).await;
let storage_quota_enabled =
config::get_bool(&state.config_cache, "storage_quota_enabled", true).await;
let event =
crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug).await?;
let uploads_locked = event
.as_ref()
.map(|e| e.uploads_locked_at.is_some())
.unwrap_or(false);
let gallery_released = event
.as_ref()
.map(|e| e.export_released_at.is_some())
.unwrap_or(false);
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,
uploads_locked,
gallery_released,
}))
}