fix(audit): restore broken upload pipeline + role-based E2E audit fixes

A comprehensive role-based E2E audit (guest/host/admin, across browser
sessions) surfaced one critical and several smaller issues; this addresses
them and hardens the tests that missed them.

Critical
- The client upload pipeline was fully broken: the IndexedDB v1->v2 upgrade
  opened a *new* transaction inside the upgrade callback, which throws during
  a version-change transaction and aborted the whole upgrade, leaving the
  queue object store uncreated -- so no UI upload ever fired. Reuse the
  version-change transaction the callback provides, and bump the DB to v3 with
  a contains() guard so installs already corrupted by the shipped bug
  self-heal on next load. Re-enabled the previously-fixme'd UI upload E2E test.

High / Medium
- Event lock is uploads-only again: likes, comments and browsing stay open
  while the event is locked (USER_JOURNEYS 9.3 / FEATURES) -- it was wrongly
  freezing social interaction. Updated the event-lock spec accordingly.
- get_original now excludes soft-deleted and ban-hidden uploads, and direct
  /media/originals/** serving is blocked, so a hidden user's originals can no
  longer be pulled by UUID (all originals go through the checked alias).
- The upload handler reads the file field with an early-abort size cap chosen
  from the declared content-type, instead of buffering the entire body before
  the size check.

Low
- unban_user mirrors the ban role guard (a host can no longer unban a
  host/admin banned by an admin).
- reset_user_pin's UPDATE is event-scoped.
- Admin login returns and stores a real identity (user_id + display name)
  instead of a blank session.
- The host user list no longer renders target-actions (ban/promote/demote/PIN)
  on the caller's own row, where the backend always rejected them.
- /diashow gains a client-side auth guard like the other protected routes.
- The join page shows the event name via a new public GET /api/v1/event.

Verified: backend cargo build clean, frontend svelte-check 0 errors, full
Playwright E2E suite 144 passed / 1 skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
fabi
2026-07-07 07:28:27 +02:00
parent 14c667c694
commit faf7a2504a
16 changed files with 220 additions and 62 deletions

View File

@@ -238,6 +238,10 @@ pub struct AdminLoginRequest {
#[derive(Serialize)]
pub struct AdminLoginResponse {
pub jwt: String,
/// The admin's user id + display name, so the client can populate a real identity
/// (own-post affordances, a name on the Account page) instead of a blank session.
pub user_id: Uuid,
pub display_name: String,
}
pub async fn admin_login(
@@ -325,7 +329,11 @@ pub async fn admin_login(
let expires_at = Utc::now() + chrono::Duration::days(1);
Session::create(&state.pool, admin_user.id, &token_hash, expires_at).await?;
Ok(Json(AdminLoginResponse { jwt: token }))
Ok(Json(AdminLoginResponse {
jwt: token,
user_id: admin_user.id,
display_name: admin_user.display_name,
}))
}
pub async fn logout(

View File

@@ -266,6 +266,10 @@ pub async fn export_ticket(
State(state): State<AppState>,
auth: crate::auth::middleware::AuthUser,
) -> Json<serde_json::Value> {
// NOTE: intentionally NOT gated on `is_banned`. A banned user keeps *read* access
// by design (USER_JOURNEYS §10.3, FEATURES: "Can still download the export once
// released — Spec design choice"). The export is read-only, so it stays available
// to them, consistent with the read-only-ban model.
let ticket = state.sse_tickets.issue(auth.token_hash);
Json(serde_json::json!({ "ticket": ticket }))
}

View File

@@ -146,6 +146,26 @@ pub async fn unban_user(
RequireHost(auth): RequireHost,
Path(user_id): Path<Uuid>,
) -> Result<StatusCode, AppError> {
// Mirror the ban guard: a host may only lift bans on guests, never on hosts or
// admins. Without this a host could override an admin's ban of another host,
// which is asymmetric with `ban_user` and lets a host escalate a peer back in.
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"
|| (target.0 == "host" && auth.role != crate::models::user::UserRole::Admin)
{
return Err(AppError::Forbidden(
"Du kannst diesen Benutzer nicht entsperren.".into(),
));
}
let result = sqlx::query(
"UPDATE \"user\" SET is_banned = FALSE WHERE id = $1 AND event_id = $2",
)
@@ -275,10 +295,11 @@ pub async fn reset_user_pin(
SET recovery_pin_hash = $1,
failed_pin_attempts = 0,
pin_locked_until = NULL
WHERE id = $2",
WHERE id = $2 AND event_id = $3",
)
.bind(&pin_hash)
.bind(user_id)
.bind(auth.event_id)
.execute(&state.pool)
.await?;

View File

@@ -2,6 +2,7 @@ pub mod admin;
pub mod feed;
pub mod host;
pub mod me;
pub mod public;
pub mod social;
pub mod sse;
pub mod test_admin;

View File

@@ -0,0 +1,24 @@
//! Unauthenticated, read-only endpoints safe to expose before a user has joined.
use axum::extract::State;
use axum::Json;
use serde::Serialize;
use crate::state::AppState;
#[derive(Serialize)]
pub struct PublicEventDto {
pub name: String,
pub slug: String,
}
/// Public event identity, used by the pre-auth join/recover screens so a guest can
/// see *which* event they're joining. Only the display name and slug are exposed —
/// nothing user-scoped — so this is safe without a token. Served straight from the
/// instance config (no DB round-trip needed).
pub async fn get_public_event(State(state): State<AppState>) -> Json<PublicEventDto> {
Json(PublicEventDto {
name: state.config.event_name.clone(),
slug: state.config.event_slug.clone(),
})
}

View File

@@ -12,18 +12,6 @@ use crate::models::hashtag::{self, Hashtag};
use crate::models::upload::Upload;
use crate::state::AppState;
/// Reject the request when the event's uploads (and, by extension, social
/// interaction) are locked. Mirrors the guard in the upload handler.
async fn require_event_open(state: &AppState) -> 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()))?;
if event.uploads_locked_at.is_some() {
return Err(AppError::Forbidden("Das Event ist geschlossen.".into()));
}
Ok(())
}
pub async fn toggle_like(
State(state): State<AppState>,
auth: AuthUser,
@@ -43,8 +31,9 @@ pub async fn toggle_like(
.await?
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
// A closed event freezes social interaction too, matching the upload handler.
require_event_open(&state).await?;
// NOTE: liking is intentionally allowed while the event is locked. Locking
// ("Event schließen") freezes *new uploads* only — likes, comments and
// browsing stay open (USER_JOURNEYS §9.3, FEATURES capability matrix).
// Try to insert; if conflict, delete (toggle)
let result = sqlx::query(
@@ -135,8 +124,9 @@ pub async fn add_comment(
.await?
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
// A closed event freezes social interaction too, matching the upload handler.
require_event_open(&state).await?;
// NOTE: commenting is intentionally allowed while the event is locked. Locking
// freezes *new uploads* only — likes, comments and browsing stay open
// (USER_JOURNEYS §9.3, FEATURES capability matrix).
let text = body.body.trim();
let text_chars = text.chars().count();

View File

@@ -90,14 +90,21 @@ pub async fn upload(
let name = field.name().unwrap_or_default().to_string();
match name.as_str() {
"file" => {
// Note: the client-declared filename and Content-Type are intentionally
// ignored — the stored MIME and extension are derived from the file's
// magic bytes below, so a mislabelled payload can't influence them.
file_data = Some(
field.bytes().await
.map_err(|e| AppError::BadRequest(format!("Datei konnte nicht gelesen werden: {e}")))?
.to_vec(),
);
// Note: the client-declared filename and Content-Type do NOT determine
// the stored MIME/extension — those come from the file's magic bytes
// below. The declared type is used only to pick a memory cap so an
// oversized body can't be fully buffered before the size check. A
// mislabelled type only makes the cap *stricter* (safe); the
// authoritative per-class check still runs on the detected type.
let declared = field.content_type().unwrap_or("").to_string();
let cap_bytes = if declared.starts_with("video/") {
(max_video_mb * 1024 * 1024) as usize
} else if declared.starts_with("image/") {
(max_image_mb * 1024 * 1024) as usize
} else {
(max_image_mb.max(max_video_mb) * 1024 * 1024) as usize
};
file_data = Some(read_field_capped(field, cap_bytes).await?);
}
"caption" => {
caption = Some(
@@ -332,6 +339,32 @@ pub async fn delete_upload(
Ok(StatusCode::NO_CONTENT)
}
/// Read a multipart field into memory, aborting with a 400 the moment it exceeds
/// `max_bytes`. Without this the whole field is buffered (up to the HTTP body cap)
/// before the post-read size check runs, so a request claiming to be a tiny image
/// could still force hundreds of MB of allocation. Streaming with an early abort
/// bounds peak memory to roughly the applicable per-class limit.
async fn read_field_capped(
mut field: axum::extract::multipart::Field<'_>,
max_bytes: usize,
) -> Result<Vec<u8>, AppError> {
let mut buf: Vec<u8> = Vec::new();
while let Some(chunk) = field
.chunk()
.await
.map_err(|e| AppError::BadRequest(format!("Datei konnte nicht gelesen werden: {e}")))?
{
if buf.len().saturating_add(chunk.len()) > max_bytes {
return Err(AppError::BadRequest(format!(
"Datei ist zu groß. Maximum: {} MB.",
max_bytes / (1024 * 1024)
)));
}
buf.extend_from_slice(&chunk);
}
Ok(buf)
}
/// Drain a multipart body so the HTTP connection stays clean when returning an early error.
/// Without draining, the client may still be sending the body after we've sent our response,
/// which can corrupt the keep-alive connection for subsequent requests.
@@ -407,16 +440,17 @@ pub async fn compute_storage_quota(state: &AppState) -> QuotaEstimate {
/// - `<img src>` / `<video src>` in the feed, lightbox, and diashow when the user is in
/// Data Mode = Original
///
/// **Auth model:** the route is intentionally unauthenticated, matching how the rest of
/// `/media/*` is served (preview + thumbnail variants). The URL contains the upload's
/// UUID, which is unguessable — same security posture as `/media/originals/{slug}/{id}`.
/// Adding `Authorization: Bearer` here would make the endpoint unusable from `<img src>`
/// and `window.open`, defeating the purpose of having the alias.
/// **Auth model:** the route is intentionally unauthenticated so it works from
/// `<img src>` / `window.open`, matching how preview + thumbnail variants are served.
/// The URL contains the upload's unguessable UUID. Unlike raw `/media` files, this
/// alias is the *only* way to fetch an original: direct `/media/originals/**` access is
/// blocked in the router, and this handler filters out soft-deleted and ban-hidden
/// uploads (via `find_by_id_visible`) so moderation actually removes access to content.
pub async fn get_original(
State(state): State<AppState>,
Path(upload_id): Path<Uuid>,
) -> Result<axum::response::Response, AppError> {
let upload = Upload::find_by_id(&state.pool, upload_id)
let upload = Upload::find_by_id_visible(&state.pool, upload_id)
.await?
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;

View File

@@ -57,6 +57,7 @@ async fn main() -> Result<()> {
let api = Router::new()
// Auth
.route("/api/v1/event", get(handlers::public::get_public_event))
.route("/api/v1/join", post(auth::handlers::join))
.route("/api/v1/recover", post(auth::handlers::recover))
.route("/api/v1/admin/login", post(auth::handlers::admin_login))
@@ -144,6 +145,16 @@ async fn main() -> Result<()> {
let router = Router::new()
.route("/health", get(|| async { "ok" }))
.merge(api)
// Block direct HTTP access to originals. They live under `media_path` (so the
// compression worker and export can read them off disk) but must NOT be pullable
// straight from `/media/originals/**` — that would bypass the visibility checks in
// `get_original` (soft-delete + ban-hide). Every legitimate original fetch goes
// through `/api/v1/upload/{id}/original`; previews/thumbnails under `/media` stay
// public. The more specific nest takes precedence over `/media` below.
.nest_service(
"/media/originals",
get(|| async { axum::http::StatusCode::NOT_FOUND }),
)
.nest_service("/media", media_service)
.layer(TraceLayer::new_for_http())
.with_state(state);

View File

@@ -74,6 +74,22 @@ impl Upload {
.await
}
/// Like [`Self::find_by_id`] but also excludes uploads whose owner has been
/// ban-hidden (`user.uploads_hidden`). Used by the public original-file alias
/// so that moderation which hides a user's content from the feed/export also
/// stops their full-resolution originals from being pulled by UUID. Mirrors the
/// `uploads_hidden` filter that `v_feed` already applies to the feed.
pub async fn find_by_id_visible(pool: &PgPool, id: Uuid) -> Result<Option<Self>, sqlx::Error> {
sqlx::query_as::<_, Self>(
"SELECT up.* FROM upload up
JOIN \"user\" u ON u.id = up.user_id
WHERE up.id = $1 AND up.deleted_at IS NULL AND u.uploads_hidden = false",
)
.bind(id)
.fetch_optional(pool)
.await
}
/// Event-scoped lookup used by host endpoints so a host of event A cannot
/// reach uploads belonging to event B.
pub async fn find_by_id_and_event(