6 Commits

Author SHA1 Message Date
fabi
16d1f356be Merge branch 'security/audit-2026-07-07'
Some checks failed
E2E / Playwright E2E (chromium-desktop) (push) Failing after 5m57s
E2E / Cross-UA smoke matrix (push) Failing after 2m22s
Security audit fixes: host privilege-boundary (demote-then-act), gated
preview/thumbnail access for moderation, /recover enumeration + timing,
periodic SSE session revalidation, app-layer nosniff, and svelte/devalue
CVE bumps. 40 backend unit tests + 182 e2e passing.
2026-07-08 06:14:07 +02:00
fabi
a4b2c5bd1c fix(security): harden authz, media gating, recover, SSE, deps
Security audit follow-up. No Critical/High existed; these close the
Medium/Low findings.

F1 [Med] Host privilege boundary: a host could demote a peer host to
  guest and then ban / PIN-reset (→ account takeover via /recover) them,
  because the ban/pin-reset peer guards key off the target's *current*
  role. set_role now blocks a non-admin from demoting a host.

F2 [Med] Moderation now revokes preview/thumbnail access: they are served
  through visibility-checked aliases (/api/v1/upload/{id}/{preview,
  thumbnail}) that filter soft-deleted / ban-hidden uploads, and direct
  /media/previews|thumbnails is 404-blocked. Feed emits the gated URLs;
  Caddy keeps them privately cacheable (max-age=300, short so moderation
  revokes promptly — the live feed already evicts cards via SSE). Uses a
  lean find_visible_media() (paths+mime only) on the media hot path.

F3 [Med/Low] npm audit fix: svelte 5.55.1→5.56.4 (SSR-XSS advisories),
  devalue 5.6.4→5.8.1 (DoS). Prod deps: 0 vulnerabilities.

F4 [Low] /recover no longer leaks account existence: unknown display name
  returns the same 401 as a wrong PIN, and runs a dummy bcrypt verify so
  timing matches — closing the enumeration + timing oracle.

F5 [Low] SSE streams re-validate the session every ~60s and close once it
  is logged out / expired, instead of running until client disconnect.

F6 [Info] X-Content-Type-Options: nosniff set at the app layer on all
  media responses (defense-in-depth alongside the edge).

Tests: new e2e specs for F1 (host cannot demote peer host), F2 (preview
gated + 404 after delete + direct /media blocked), F4 (uniform 401).
40 backend unit tests + 182 e2e passing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 06:14:00 +02:00
fabi
683b1b6f65 Merge branch 'perf/stability-2026-07-07'
Performance & stability batch: config cache, streaming uploads, disk-snapshot
cache, export video streaming, single-query auth, SSE resync-on-lag, quota
fail-open, graceful shutdown, and tx-failure cleanup. 40 backend unit tests +
179 e2e passing.
2026-07-07 20:26:52 +02:00
fabi
d6c91974eb perf(backend): close config/upload/export hot paths + stability fixes
Performance:
- Cache the runtime `config` table in-memory (ConfigCache) with synchronous
  invalidation on every write (admin PATCH + test reseed). Was re-reading each
  key from Postgres on every request (~8 round-trips per upload).
- Stream uploads chunk-by-chunk to a temp file instead of buffering the whole
  body in RAM (peak was up to the per-class cap, e.g. 500 MB/video); only 512
  sniff-bytes are kept for magic-byte detection, then atomic rename into place.
- Cache the media-filesystem disk snapshot (DiskCache, 15s TTL) shared by the
  quota check and admin stats; drop the discarded System::refresh_all().
- HTML export streams video (and small-image) originals straight into the ZIP
  via a manifest instead of copying them to a temp dir first (removed the
  transient 2x disk usage) and drops the double directory scan.
- Auth extractor resolves session -> live user in one JOIN (was two queries),
  touching last_seen_at by token hash.

Stability:
- SSE: on broadcast lag, emit a `resync` event so the client runs a delta
  fetch instead of silently losing events; frontend reconciles adds, deletions,
  and (via an in-place refresh) like/comment counts on visible cards.
- Storage quota fails OPEN when the disk can't be read (was a 0-byte limit that
  locked out all uploads).
- Graceful shutdown drains in-flight requests on SIGTERM/SIGINT, bounded by a
  10s backstop so open SSE streams can't stall a deploy.
- Upload removes the persisted file if the DB transaction fails (no orphaned
  bytes with no row to reclaim them).

Tests:
- New pure select_disk() with 5 unit tests (longest-prefix, fallbacks, fail-open).
- New e2e export-video spec covering the HTML export's video-streaming branch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 20:26:45 +02:00
fabi
4cdb3ae14a Merge branch 'fix/audit-2026-07-07' 2026-07-07 07:28:32 +02:00
fabi
faf7a2504a 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>
2026-07-07 07:28:27 +02:00
36 changed files with 1422 additions and 450 deletions

View File

@@ -17,19 +17,20 @@
@hashed_assets path_regexp hashed /_app/immutable/.*\.[a-f0-9]{8,}\.(js|css|woff2)$ @hashed_assets path_regexp hashed /_app/immutable/.*\.[a-f0-9]{8,}\.(js|css|woff2)$
header @hashed_assets Cache-Control "public, max-age=31536000, immutable" header @hashed_assets Cache-Control "public, max-age=31536000, immutable"
# Media previews and thumbnails # Preview/thumbnail images. These are now served by the app through a
@previews path /media/previews/* /media/thumbnails/* # visibility-checked alias (/api/v1/upload/{id}/{preview,thumbnail}) so moderation
header @previews Cache-Control "public, max-age=3600" # can revoke access; direct /media/previews|thumbnails is 404-blocked at the app.
# Privately cacheable for a short window (the app sets the same header; this is the
# edge carve-out from the blanket no-store below). Kept short so a moderated image
# stops being served to a direct-URL holder promptly.
@media_api path /api/v1/upload/*/preview /api/v1/upload/*/thumbnail
header @media_api Cache-Control "private, max-age=300"
# Original media files (private — only host can download). Force download # API — never cache, EXCEPT the gated image routes above.
# rather than inline rendering as defense-in-depth against any future @api {
# content-type confusion (previews/thumbnails are re-encoded and stay inline). path /api/*
@originals path /media/originals/* not path /api/v1/upload/*/preview /api/v1/upload/*/thumbnail
header @originals Cache-Control "private, max-age=86400" }
header @originals Content-Disposition "attachment"
# API — never cache
@api path /api/*
header @api Cache-Control "no-store" header @api Cache-Control "no-store"
# Route API and media requests to the Rust backend # Route API and media requests to the Rust backend

View File

@@ -37,8 +37,8 @@ pub async fn join(
Json(body): Json<JoinRequest>, Json(body): Json<JoinRequest>,
) -> Result<(StatusCode, Json<JoinResponse>), AppError> { ) -> Result<(StatusCode, Json<JoinResponse>), AppError> {
let ip = client_ip(&headers, "unknown"); let ip = client_ip(&headers, "unknown");
let rate_limits_on = config::get_bool(&state.pool, "rate_limits_enabled", true).await; let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
let join_rate_on = config::get_bool(&state.pool, "join_rate_enabled", true).await; let join_rate_on = config::get_bool(&state.config_cache, "join_rate_enabled", true).await;
if rate_limits_on && join_rate_on if rate_limits_on && join_rate_on
&& !state.rate_limiter.check(format!("join:{ip}"), 5, Duration::from_secs(60)) && !state.rate_limiter.check(format!("join:{ip}"), 5, Duration::from_secs(60))
{ {
@@ -121,6 +121,18 @@ pub struct RecoverResponse {
pub user_id: Uuid, pub user_id: Uuid,
} }
/// A real cost-12 bcrypt hash of a fixed dummy value, computed once and cached. Used
/// to run a constant-time-ish verify on the "unknown display name" branch of
/// [`recover`], so that branch costs the same as a real (user-exists) verify and can't
/// be told apart by timing.
fn dummy_pin_hash() -> &'static str {
static HASH: std::sync::OnceLock<String> = std::sync::OnceLock::new();
HASH.get_or_init(|| {
bcrypt::hash("eventsnap-dummy-not-a-real-pin", 12)
.expect("bcrypt hashing of a static dummy value cannot fail")
})
}
pub async fn recover( pub async fn recover(
State(state): State<AppState>, State(state): State<AppState>,
headers: HeaderMap, headers: HeaderMap,
@@ -134,8 +146,8 @@ pub async fn recover(
// every 15 minutes, indefinitely. 5 attempts per 15 minutes per (IP, name) // every 15 minutes, indefinitely. 5 attempts per 15 minutes per (IP, name)
// softens that into a real cost. // softens that into a real cost.
let ip = client_ip(&headers, "unknown"); let ip = client_ip(&headers, "unknown");
let rate_limits_on = config::get_bool(&state.pool, "rate_limits_enabled", true).await; let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
let recover_rate_on = config::get_bool(&state.pool, "recover_rate_enabled", true).await; let recover_rate_on = config::get_bool(&state.config_cache, "recover_rate_enabled", true).await;
if rate_limits_on && recover_rate_on { if rate_limits_on && recover_rate_on {
let name_key = display_name.to_lowercase(); let name_key = display_name.to_lowercase();
if !state.rate_limiter.check( if !state.rate_limiter.check(
@@ -158,9 +170,13 @@ pub async fn recover(
User::find_by_event_and_name(&state.pool, event.id, display_name).await?; User::find_by_event_and_name(&state.pool, event.id, display_name).await?;
if users.is_empty() { if users.is_empty() {
return Err(AppError::NotFound( // No user with this name. Run a throwaway bcrypt verify so this branch takes
"Kein Benutzer mit diesem Namen gefunden.".into(), // the same time as the user-exists path, and return the SAME error as a wrong
)); // PIN — so "no such name" and "wrong PIN" are indistinguishable by response or
// timing. Display names are already public on the feed, but this still closes
// the /recover enumeration + timing oracle.
let _ = bcrypt::verify(&body.pin, dummy_pin_hash());
return Err(AppError::Unauthorized("PIN ist falsch.".into()));
} }
for user in &users { for user in &users {
@@ -238,6 +254,10 @@ pub struct AdminLoginRequest {
#[derive(Serialize)] #[derive(Serialize)]
pub struct AdminLoginResponse { pub struct AdminLoginResponse {
pub jwt: String, 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( pub async fn admin_login(
@@ -256,8 +276,8 @@ pub async fn admin_login(
// a long-running guess campaign. 5 attempts / minute / IP is plenty for // a long-running guess campaign. 5 attempts / minute / IP is plenty for
// honest typos. // honest typos.
let ip = client_ip(&headers, "unknown"); let ip = client_ip(&headers, "unknown");
let rate_limits_on = config::get_bool(&state.pool, "rate_limits_enabled", true).await; let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
let admin_rate_on = config::get_bool(&state.pool, "admin_login_rate_enabled", true).await; let admin_rate_on = config::get_bool(&state.config_cache, "admin_login_rate_enabled", true).await;
if rate_limits_on && admin_rate_on if rate_limits_on && admin_rate_on
&& !state.rate_limiter.check( && !state.rate_limiter.check(
format!("admin_login:{ip}"), format!("admin_login:{ip}"),
@@ -325,7 +345,11 @@ pub async fn admin_login(
let expires_at = Utc::now() + chrono::Duration::days(1); let expires_at = Utc::now() + chrono::Duration::days(1);
Session::create(&state.pool, admin_user.id, &token_hash, expires_at).await?; 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( pub async fn logout(

View File

@@ -37,34 +37,32 @@ impl FromRequestParts<AppState> for AuthUser {
.strip_prefix("Bearer ") .strip_prefix("Bearer ")
.ok_or_else(|| AppError::Unauthorized("Ungültiges Token-Format.".into()))?; .ok_or_else(|| AppError::Unauthorized("Ungültiges Token-Format.".into()))?;
let claims = jwt::verify_token(token, &state.config.jwt_secret) // Verify the JWT (signature + expiry). We deliberately do NOT trust its role/ban
// claims — the live user row below is authoritative — so the decoded claims
// themselves aren't needed beyond this check.
jwt::verify_token(token, &state.config.jwt_secret)
.map_err(|_| AppError::Unauthorized("Token ungültig oder abgelaufen.".into()))?; .map_err(|_| AppError::Unauthorized("Token ungültig oder abgelaufen.".into()))?;
let token_hash = jwt::hash_token(token); let token_hash = jwt::hash_token(token);
let session = Session::find_by_token_hash(&state.pool, &token_hash) // Single round-trip: resolve the session token to its *live* user row. A
// role/ban stored in the token would survive a demote/ban for the full session
// lifetime (up to 30d), so we always re-read the user (a demoted host loses host
// powers immediately). We do NOT reject banned users here — they retain read
// access by design; writes and host/admin actions enforce the ban downstream.
let user = Session::find_user_by_token_hash(&state.pool, &token_hash)
.await .await
.map_err(|e| AppError::Internal(e.into()))? .map_err(|e| AppError::Internal(e.into()))?
.ok_or_else(|| AppError::Unauthorized("Sitzung nicht gefunden oder abgelaufen.".into()))?; .ok_or_else(|| AppError::Unauthorized("Sitzung nicht gefunden oder abgelaufen.".into()))?;
// Trust *live* DB state, not the JWT: a role/ban stored in the token would
// survive a demote/ban for the full session lifetime (up to 30d). Re-read
// the user row so a demoted host loses host powers immediately. We do NOT
// reject banned users here — they retain read access by design; writes and
// host/admin actions enforce the ban downstream.
let user = crate::models::user::User::find_by_id(&state.pool, claims.sub)
.await
.map_err(|e| AppError::Internal(e.into()))?
.ok_or_else(|| AppError::Unauthorized("Benutzer nicht gefunden.".into()))?;
// Update last_seen_at in the background (fire-and-forget). Failures are // Update last_seen_at in the background (fire-and-forget). Failures are
// non-fatal but worth surfacing — silent swallowing hides DB connection // non-fatal but worth surfacing — silent swallowing hides DB connection
// pressure that would otherwise be the first symptom of a real problem. // pressure that would otherwise be the first symptom of a real problem.
let pool = state.pool.clone(); let pool = state.pool.clone();
let session_id = session.id; let touch_hash = token_hash.clone();
tokio::spawn(async move { tokio::spawn(async move {
if let Err(e) = Session::touch(&pool, session_id).await { if let Err(e) = Session::touch_by_token_hash(&pool, &touch_hash).await {
tracing::warn!(error = ?e, session_id = %session_id, "session touch failed"); tracing::warn!(error = ?e, "session touch failed");
} }
}); });

View File

@@ -5,7 +5,6 @@ use axum::extract::{Query, State};
use axum::http::{HeaderMap, StatusCode}; use axum::http::{HeaderMap, StatusCode};
use axum::Json; use axum::Json;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sysinfo::System;
use crate::auth::middleware::RequireAdmin; use crate::auth::middleware::RequireAdmin;
use crate::error::AppError; use crate::error::AppError;
@@ -68,23 +67,12 @@ pub async fn get_stats(
.fetch_one(&state.pool) .fetch_one(&state.pool)
.await?; .await?;
// Disk usage via sysinfo // Disk usage from the shared cache (unknown mount → zeros, same as before).
let mut sys = System::new(); let (disk_total, disk_free) = state
sys.refresh_all(); .disk_cache
.snapshot(&state.config.media_path)
let media_path = state.config.media_path.to_string_lossy().to_string(); .map(|d| (d.total, d.free))
let (disk_total, disk_free) = sysinfo::Disks::new_with_refreshed_list() .unwrap_or((0, 0));
.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); let disk_used = disk_total.saturating_sub(disk_free);
@@ -216,6 +204,11 @@ pub async fn patch_config(
} }
tx.commit().await?; tx.commit().await?;
// The config cache must reflect this write on the very next read (tests PATCH then
// immediately assert the new value takes effect). Invalidate synchronously here —
// the TTL is only a backstop and must not be relied on for correctness.
state.config_cache.invalidate();
// Notify all clients that a publicly-readable config value changed so their stores // 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. // (e.g. the privacy note in My Account) refresh without a manual reload.
if privacy_note_changed { if privacy_note_changed {
@@ -266,6 +259,10 @@ pub async fn export_ticket(
State(state): State<AppState>, State(state): State<AppState>,
auth: crate::auth::middleware::AuthUser, auth: crate::auth::middleware::AuthUser,
) -> Json<serde_json::Value> { ) -> 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); let ticket = state.sse_tickets.issue(auth.token_hash);
Json(serde_json::json!({ "ticket": ticket })) Json(serde_json::json!({ "ticket": ticket }))
} }
@@ -404,13 +401,13 @@ pub async fn export_status(
/// switch + per-endpoint switch + numeric value, all stored in `config` and read on /// switch + per-endpoint switch + numeric value, all stored in `config` and read on
/// each request. /// each request.
async fn enforce_export_rate(state: &AppState, headers: &HeaderMap) -> Result<(), AppError> { 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 rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
let export_rate_on = config::get_bool(&state.pool, "export_rate_enabled", true).await; let export_rate_on = config::get_bool(&state.config_cache, "export_rate_enabled", true).await;
if !(rate_limits_on && export_rate_on) { if !(rate_limits_on && export_rate_on) {
return Ok(()); return Ok(());
} }
let ip = client_ip(headers, "unknown"); let ip = client_ip(headers, "unknown");
let limit = config::get_usize(&state.pool, "export_rate_per_day", 3).await; let limit = config::get_usize(&state.config_cache, "export_rate_per_day", 3).await;
if !state if !state
.rate_limiter .rate_limiter
.check(format!("export:{ip}"), limit, Duration::from_secs(86400)) .check(format!("export:{ip}"), limit, Duration::from_secs(86400))

View File

@@ -62,10 +62,10 @@ pub async fn feed(
Query(q): Query<FeedQuery>, Query(q): Query<FeedQuery>,
) -> Result<Json<FeedResponse>, AppError> { ) -> Result<Json<FeedResponse>, AppError> {
let ip = client_ip(&headers, "unknown"); let ip = client_ip(&headers, "unknown");
let rate_limits_on = config::get_bool(&state.pool, "rate_limits_enabled", true).await; let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
let feed_rate_on = config::get_bool(&state.pool, "feed_rate_enabled", true).await; let feed_rate_on = config::get_bool(&state.config_cache, "feed_rate_enabled", true).await;
if rate_limits_on && feed_rate_on { if rate_limits_on && feed_rate_on {
let rate_limit = config::get_usize(&state.pool, "feed_rate_per_min", 60).await; let rate_limit = config::get_usize(&state.config_cache, "feed_rate_per_min", 60).await;
if !state if !state
.rate_limiter .rate_limiter
.check(format!("feed:{ip}"), rate_limit, Duration::from_secs(60)) .check(format!("feed:{ip}"), rate_limit, Duration::from_secs(60))
@@ -139,8 +139,17 @@ pub async fn feed(
let uploads = rows let uploads = rows
.into_iter() .into_iter()
.map(|r| { .map(|r| {
let preview_url = r.preview_path.map(|p| format!("/media/{p}")); // Gated media aliases (visibility-checked, direct /media blocked). Emit the
let thumbnail_url = r.thumbnail_path.map(|p| format!("/media/{p}")); // URL only when the variant actually exists — the URL is what signals the
// client which variant to load.
let preview_url = r
.preview_path
.as_ref()
.map(|_| format!("/api/v1/upload/{}/preview", r.id));
let thumbnail_url = r
.thumbnail_path
.as_ref()
.map(|_| format!("/api/v1/upload/{}/thumbnail", r.id));
FeedUpload { FeedUpload {
liked_by_me: liked_set.contains(&r.id), liked_by_me: liked_set.contains(&r.id),
id: r.id, id: r.id,
@@ -225,8 +234,14 @@ pub async fn feed_delta(
id: r.id, id: r.id,
user_id: r.user_id, user_id: r.user_id,
uploader_name: r.uploader_name, uploader_name: r.uploader_name,
preview_url: r.preview_path.map(|p| format!("/media/{p}")), preview_url: r
thumbnail_url: r.thumbnail_path.map(|p| format!("/media/{p}")), .preview_path
.as_ref()
.map(|_| format!("/api/v1/upload/{}/preview", r.id)),
thumbnail_url: r
.thumbnail_path
.as_ref()
.map(|_| format!("/api/v1/upload/{}/thumbnail", r.id)),
mime_type: r.mime_type, mime_type: r.mime_type,
caption: r.caption, caption: r.caption,
like_count: r.like_count, like_count: r.like_count,

View File

@@ -146,6 +146,26 @@ pub async fn unban_user(
RequireHost(auth): RequireHost, RequireHost(auth): RequireHost,
Path(user_id): Path<Uuid>, Path(user_id): Path<Uuid>,
) -> Result<StatusCode, AppError> { ) -> 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( let result = sqlx::query(
"UPDATE \"user\" SET is_banned = FALSE WHERE id = $1 AND event_id = $2", "UPDATE \"user\" SET is_banned = FALSE WHERE id = $1 AND event_id = $2",
) )
@@ -199,9 +219,14 @@ pub async fn set_role(
.await? .await?
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?; .ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
if target.0 == "admin" { // Admins are untouchable by hosts. A plain host also may not demote another
// *host*: without this guard a host could demote a peer host to guest and then
// ban / PIN-reset (→ account-takeover via /recover) them — the ban/pin-reset peer
// guards key off the target's *current* role, so a prior demotion would launder
// past them. Only an admin may change a host's role.
if target.0 == "admin" || (target.0 == "host" && auth.role != UserRole::Admin) {
return Err(AppError::Forbidden( return Err(AppError::Forbidden(
"Admins können nicht geändert werden.".into(), "Du darfst die Rolle dieses Benutzers nicht ändern.".into(),
)); ));
} }
@@ -275,10 +300,11 @@ pub async fn reset_user_pin(
SET recovery_pin_hash = $1, SET recovery_pin_hash = $1,
failed_pin_attempts = 0, failed_pin_attempts = 0,
pin_locked_until = NULL pin_locked_until = NULL
WHERE id = $2", WHERE id = $2 AND event_id = $3",
) )
.bind(&pin_hash) .bind(&pin_hash)
.bind(user_id) .bind(user_id)
.bind(auth.event_id)
.execute(&state.pool) .execute(&state.pool)
.await?; .await?;

View File

@@ -65,9 +65,9 @@ pub async fn get_context(
.await? .await?
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?; .ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
let privacy_note = config::get_str(&state.pool, "privacy_note", "").await; let privacy_note = config::get_str(&state.config_cache, "privacy_note", "").await;
let quota_enabled = config::get_bool(&state.pool, "quota_enabled", true).await; let quota_enabled = config::get_bool(&state.config_cache, "quota_enabled", true).await;
let storage_quota_enabled = config::get_bool(&state.pool, "storage_quota_enabled", true).await; let storage_quota_enabled = config::get_bool(&state.config_cache, "storage_quota_enabled", true).await;
Ok(Json(MeContextDto { Ok(Json(MeContextDto {
user_id: user.id, user_id: user.id,

View File

@@ -2,6 +2,7 @@ pub mod admin;
pub mod feed; pub mod feed;
pub mod host; pub mod host;
pub mod me; pub mod me;
pub mod public;
pub mod social; pub mod social;
pub mod sse; pub mod sse;
pub mod test_admin; 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::models::upload::Upload;
use crate::state::AppState; 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( pub async fn toggle_like(
State(state): State<AppState>, State(state): State<AppState>,
auth: AuthUser, auth: AuthUser,
@@ -43,8 +31,9 @@ pub async fn toggle_like(
.await? .await?
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?; .ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
// A closed event freezes social interaction too, matching the upload handler. // NOTE: liking is intentionally allowed while the event is locked. Locking
require_event_open(&state).await?; // ("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) // Try to insert; if conflict, delete (toggle)
let result = sqlx::query( let result = sqlx::query(
@@ -135,8 +124,9 @@ pub async fn add_comment(
.await? .await?
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?; .ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
// A closed event freezes social interaction too, matching the upload handler. // NOTE: commenting is intentionally allowed while the event is locked. Locking
require_event_open(&state).await?; // freezes *new uploads* only — likes, comments and browsing stay open
// (USER_JOURNEYS §9.3, FEATURES capability matrix).
let text = body.body.trim(); let text = body.body.trim();
let text_chars = text.chars().count(); let text_chars = text.chars().count();

View File

@@ -6,6 +6,7 @@ use axum::response::sse::{Event, KeepAlive, Sse};
use axum::Json; use axum::Json;
use futures::stream::Stream; use futures::stream::Stream;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tokio_stream::wrappers::errors::BroadcastStreamRecvError;
use tokio_stream::wrappers::BroadcastStream; use tokio_stream::wrappers::BroadcastStream;
use tokio_stream::StreamExt; use tokio_stream::StreamExt;
@@ -58,13 +59,43 @@ pub async fn stream(
.ok_or_else(|| AppError::Unauthorized("Sitzung nicht gefunden.".into()))?; .ok_or_else(|| AppError::Unauthorized("Sitzung nicht gefunden.".into()))?;
let rx = state.sse_tx.subscribe(); let rx = state.sse_tx.subscribe();
let stream = BroadcastStream::new(rx).filter_map(|msg| match msg { let events = BroadcastStream::new(rx).filter_map(|msg| match msg {
Ok(sse_event) => Some(Ok(Event::default() Ok(sse_event) => Some(Ok(Event::default()
.event(sse_event.event_type) .event(sse_event.event_type)
.data(sse_event.data))), .data(sse_event.data))),
Err(_) => None, // A consumer that falls behind the broadcast buffer would otherwise silently
// lose events, leaving its feed permanently stale. Instead of dropping the
// gap, tell the client to resync — the frontend responds by running a full
// feed-delta fetch (which reconciles both new uploads and deletions).
Err(BroadcastStreamRecvError::Lagged(n)) => {
tracing::warn!("SSE consumer lagged, dropped {n} event(s); emitting resync");
Some(Ok(Event::default().event("resync").data(n.to_string())))
}
}); });
// The session is only checked once at open. Re-validate it periodically so a
// logged-out or expired session's stream is closed rather than kept alive until the
// client happens to disconnect. Bounds a stale stream to ~60s. Only a *definitively*
// gone/expired session ends the stream; a transient DB error just retries next tick.
let pool = state.pool.clone();
let session_hash = token_hash.clone();
let session_gone = async move {
let mut ticker = tokio::time::interval(Duration::from_secs(60));
ticker.tick().await; // consume the immediate first tick
loop {
ticker.tick().await;
match Session::find_by_token_hash(&pool, &session_hash).await {
Ok(Some(_)) => {} // still valid — keep streaming
Ok(None) => break, // logged out or expired — stop
Err(e) => {
tracing::warn!(error = ?e, "SSE session revalidation query failed; retrying");
}
}
}
};
let stream = futures::StreamExt::take_until(events, Box::pin(session_gone));
Ok(Sse::new(stream).keep_alive( Ok(Sse::new(stream).keep_alive(
KeepAlive::new() KeepAlive::new()
.interval(Duration::from_secs(30)) .interval(Duration::from_secs(30))

View File

@@ -80,6 +80,11 @@ pub async fn truncate_all(
// counters don't leak into the next one. // counters don't leak into the next one.
state.rate_limiter.clear(); state.rate_limiter.clear();
// The reseed above wrote the `config` table directly (bypassing patch_config), so
// the cache must be invalidated too — otherwise the first request after a truncate
// could serve the previous test's toggles.
state.config_cache.invalidate();
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }

View File

@@ -43,10 +43,10 @@ pub async fn upload(
mut multipart: Multipart, mut multipart: Multipart,
) -> Result<(StatusCode, Json<UploadDto>), AppError> { ) -> Result<(StatusCode, Json<UploadDto>), AppError> {
// Rate limit: N uploads per hour per user. Gated by master + per-endpoint toggles. // 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 rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
let upload_rate_on = config::get_bool(&state.pool, "upload_rate_enabled", true).await; let upload_rate_on = config::get_bool(&state.config_cache, "upload_rate_enabled", true).await;
if rate_limits_on && upload_rate_on { if rate_limits_on && upload_rate_on {
let upload_rate = config::get_i64(&state.pool, "upload_rate_per_hour", 10).await as usize; let upload_rate = config::get_i64(&state.config_cache, "upload_rate_per_hour", 10).await as usize;
if let Err(retry_after_secs) = state.rate_limiter.check_with_retry( if let Err(retry_after_secs) = state.rate_limiter.check_with_retry(
format!("upload:{}", auth.user_id), format!("upload:{}", auth.user_id),
upload_rate, upload_rate,
@@ -79,50 +79,85 @@ pub async fn upload(
} }
// Read config limits from DB // Read config limits from DB
let max_image_mb: i64 = config::get_i64(&state.pool, "max_image_size_mb", 20).await; let max_image_mb: i64 = config::get_i64(&state.config_cache, "max_image_size_mb", 20).await;
let max_video_mb: i64 = config::get_i64(&state.pool, "max_video_size_mb", 500).await; let max_video_mb: i64 = config::get_i64(&state.config_cache, "max_video_size_mb", 500).await;
let mut file_data: Option<Vec<u8>> = None; // The uploaded file is streamed straight to a temp file on disk (never buffered
// whole in memory — a 500 MB video used to cost 500 MB of RAM per concurrent
// upload). We only keep the first ≤512 bytes in memory for magic-byte sniffing.
// On success the temp file is renamed into place under its detected extension.
let upload_id = Uuid::new_v4();
let event_slug = &state.config.event_slug;
let originals_dir = state.config.media_path.join(format!("originals/{event_slug}"));
let temp_abs = originals_dir.join(format!("{upload_id}.tmp"));
let mut streamed: Option<(i64, Vec<u8>)> = None; // (size, head bytes for sniffing)
let mut caption: Option<String> = None; let mut caption: Option<String> = None;
let mut hashtags_csv: Option<String> = None; let mut hashtags_csv: Option<String> = None;
while let Some(field) = multipart.next_field().await.map_err(|e| AppError::BadRequest(e.to_string()))? { // Wrap the multipart read so any error after the temp file is created still cleans
let name = field.name().unwrap_or_default().to_string(); // it up (a mid-stream parse failure must not leave a stray `.tmp` on disk).
match name.as_str() { let parse_result: Result<(), AppError> = async {
"file" => { while let Some(field) = multipart
// Note: the client-declared filename and Content-Type are intentionally .next_field()
// ignored — the stored MIME and extension are derived from the file's .await
// magic bytes below, so a mislabelled payload can't influence them. .map_err(|e| AppError::BadRequest(e.to_string()))?
file_data = Some( {
field.bytes().await let name = field.name().unwrap_or_default().to_string();
.map_err(|e| AppError::BadRequest(format!("Datei konnte nicht gelesen werden: {e}")))? match name.as_str() {
.to_vec(), "file" => {
); // The client-declared Content-Type does NOT determine the stored
// MIME/extension — those come from the file's magic bytes below. The
// declared type only picks the streaming cap so an oversized body is
// aborted early; a mislabelled type only makes the cap *stricter*
// (safe), and 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
};
tokio::fs::create_dir_all(&originals_dir)
.await
.map_err(|e| AppError::Internal(e.into()))?;
streamed = Some(stream_field_to_file(field, &temp_abs, cap_bytes).await?);
}
"caption" => {
caption =
Some(field.text().await.map_err(|e| AppError::BadRequest(e.to_string()))?);
}
"hashtags" => {
hashtags_csv =
Some(field.text().await.map_err(|e| AppError::BadRequest(e.to_string()))?);
}
_ => {}
} }
"caption" => {
caption = Some(
field.text().await
.map_err(|e| AppError::BadRequest(e.to_string()))?,
);
}
"hashtags" => {
hashtags_csv = Some(
field.text().await
.map_err(|e| AppError::BadRequest(e.to_string()))?,
);
}
_ => {}
} }
Ok(())
}
.await;
if let Err(e) = parse_result {
let _ = tokio::fs::remove_file(&temp_abs).await;
return Err(e);
} }
let data = file_data.ok_or_else(|| AppError::BadRequest("Keine Datei hochgeladen.".into()))?; // From here on the temp file may exist; every validation failure removes it before
let size = data.len() as i64; // returning so a rejected upload never leaves bytes behind.
let (size, head) = match streamed {
Some(s) => s,
None => return Err(AppError::BadRequest("Keine Datei hochgeladen.".into())),
};
// Validate caption length. Counted in chars (code points) to match the // Validate caption length. Counted in chars (code points) to match the
// "Zeichen" wording in the error message — `.len()` would be bytes and // "Zeichen" wording in the error message — `.len()` would be bytes and
// reject perfectly valid German/emoji captions early. // reject perfectly valid German/emoji captions early.
if let Some(ref cap) = caption { if let Some(ref cap) = caption {
if cap.chars().count() > MAX_CAPTION_LENGTH { if cap.chars().count() > MAX_CAPTION_LENGTH {
let _ = tokio::fs::remove_file(&temp_abs).await;
return Err(AppError::BadRequest(format!( return Err(AppError::BadRequest(format!(
"Beschreibung ist zu lang. Maximum: {} Zeichen.", "Beschreibung ist zu lang. Maximum: {} Zeichen.",
MAX_CAPTION_LENGTH MAX_CAPTION_LENGTH
@@ -135,26 +170,38 @@ pub async fn upload(
// those are rejected outright — closing the stored-XSS vector. Both the MIME // those are rejected outright — closing the stored-XSS vector. Both the MIME
// we persist and the on-disk extension come from the detected type, never from // we persist and the on-disk extension come from the detected type, never from
// client-supplied values. // client-supplied values.
let kind = infer::get(&data) let kind = match infer::get(&head) {
.ok_or_else(|| AppError::BadRequest("Dateityp nicht erkannt oder nicht unterstützt.".into()))?; Some(k) => k,
let (mime, ext) = ALLOWED_MEDIA None => {
let _ = tokio::fs::remove_file(&temp_abs).await;
return Err(AppError::BadRequest(
"Dateityp nicht erkannt oder nicht unterstützt.".into(),
));
}
};
let (mime, ext) = match ALLOWED_MEDIA
.iter() .iter()
.find(|(allowed, _)| *allowed == kind.mime_type()) .find(|(allowed, _)| *allowed == kind.mime_type())
.map(|(m, e)| ((*m).to_string(), *e)) .map(|(m, e)| ((*m).to_string(), *e))
.ok_or_else(|| { {
AppError::BadRequest(format!( Some(v) => v,
None => {
let _ = tokio::fs::remove_file(&temp_abs).await;
return Err(AppError::BadRequest(format!(
"Dateityp wird nicht unterstützt: {}.", "Dateityp wird nicht unterstützt: {}.",
kind.mime_type() kind.mime_type()
)) )));
})?; }
};
// Validate file size // Validate file size against the authoritative per-detected-class limit.
let max_bytes = if mime.starts_with("video/") { let max_bytes = if mime.starts_with("video/") {
max_video_mb * 1024 * 1024 max_video_mb * 1024 * 1024
} else { } else {
max_image_mb * 1024 * 1024 max_image_mb * 1024 * 1024
}; };
if size > max_bytes { if size > max_bytes {
let _ = tokio::fs::remove_file(&temp_abs).await;
return Err(AppError::BadRequest(format!( return Err(AppError::BadRequest(format!(
"Datei ist zu groß. Maximum: {} MB.", "Datei ist zu groß. Maximum: {} MB.",
max_bytes / (1024 * 1024) max_bytes / (1024 * 1024)
@@ -164,13 +211,14 @@ pub async fn upload(
// Per-user storage quota — dynamic formula based on available disk space and the // 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 // number of active uploaders. Gated by master + per-area toggles so the admin can
// disable it on trusted instances. // disable it on trusted instances.
let quota_on = config::get_bool(&state.pool, "quota_enabled", true).await; let quota_on = config::get_bool(&state.config_cache, "quota_enabled", true).await;
let storage_quota_on = config::get_bool(&state.pool, "storage_quota_enabled", true).await; let storage_quota_on = config::get_bool(&state.config_cache, "storage_quota_enabled", true).await;
if quota_on && storage_quota_on { if quota_on && storage_quota_on {
let estimate = compute_storage_quota(&state).await; let estimate = compute_storage_quota(&state).await;
if let Some(limit) = estimate.limit_bytes { if let Some(limit) = estimate.limit_bytes {
let prospective_total = user.total_upload_bytes.saturating_add(size); let prospective_total = user.total_upload_bytes.saturating_add(size);
if prospective_total > limit { if prospective_total > limit {
let _ = tokio::fs::remove_file(&temp_abs).await;
return Err(AppError::TooManyRequests( return Err(AppError::TooManyRequests(
"Du hast dein Upload-Limit für dieses Event erreicht.".into(), "Du hast dein Upload-Limit für dieses Event erreicht.".into(),
None, None,
@@ -179,16 +227,13 @@ pub async fn upload(
} }
} }
let upload_id = Uuid::new_v4(); // All checks passed — atomically move the temp file to its final, extension-correct
let event_slug = &state.config.event_slug; // path (same directory, so the rename is cheap and atomic).
let relative_path = format!("originals/{event_slug}/{upload_id}.{ext}"); let relative_path = format!("originals/{event_slug}/{upload_id}.{ext}");
let absolute_path = state.config.media_path.join(&relative_path); let absolute_path = state.config.media_path.join(&relative_path);
tokio::fs::rename(&temp_abs, &absolute_path)
// Ensure directory exists and write file .await
if let Some(parent) = absolute_path.parent() { .map_err(|e| AppError::Internal(e.into()))?;
tokio::fs::create_dir_all(parent).await.map_err(|e| AppError::Internal(e.into()))?;
}
tokio::fs::write(&absolute_path, &data).await.map_err(|e| AppError::Internal(e.into()))?;
// Process hashtags from caption and explicit CSV // Process hashtags from caption and explicit CSV
let mut tags: Vec<String> = Vec::new(); let mut tags: Vec<String> = Vec::new();
@@ -209,27 +254,41 @@ pub async fn upload(
// Quota accounting, the upload row, and its hashtag links must be atomic: a // Quota accounting, the upload row, and its hashtag links must be atomic: a
// crash between the bytes increment and the insert would permanently charge // crash between the bytes increment and the insert would permanently charge
// bytes with no row to reclaim them (silent quota erosion / spurious lockout). // bytes with no row to reclaim them (silent quota erosion / spurious lockout).
let mut tx = state.pool.begin().await?; let tx_result: Result<Upload, AppError> = async {
sqlx::query("UPDATE \"user\" SET total_upload_bytes = total_upload_bytes + $2 WHERE id = $1") let mut tx = state.pool.begin().await?;
.bind(auth.user_id) sqlx::query("UPDATE \"user\" SET total_upload_bytes = total_upload_bytes + $2 WHERE id = $1")
.bind(size) .bind(auth.user_id)
.execute(&mut *tx) .bind(size)
.execute(&mut *tx)
.await?;
let upload = Upload::create(
&mut *tx,
auth.event_id,
auth.user_id,
&relative_path,
&mime,
size,
caption.as_deref(),
)
.await?; .await?;
let upload = Upload::create( for tag in &tags {
&mut *tx, let h = Hashtag::upsert(&mut *tx, auth.event_id, tag).await?;
auth.event_id, Hashtag::link_to_upload(&mut *tx, upload.id, h.id).await?;
auth.user_id, }
&relative_path, tx.commit().await?;
&mime, Ok(upload)
size,
caption.as_deref(),
)
.await?;
for tag in &tags {
let h = Hashtag::upsert(&mut *tx, auth.event_id, tag).await?;
Hashtag::link_to_upload(&mut *tx, upload.id, h.id).await?;
} }
tx.commit().await?; .await;
// The file is already on disk at `absolute_path`. If the transaction failed, no DB
// row will ever reference it, so remove it now rather than orphan bytes on disk.
let upload = match tx_result {
Ok(u) => u,
Err(e) => {
let _ = tokio::fs::remove_file(&absolute_path).await;
return Err(e);
}
};
// Spawn compression task // Spawn compression task
state state
@@ -332,6 +391,70 @@ pub async fn delete_upload(
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
/// Number of leading bytes retained in memory for magic-byte (`infer`) sniffing. Every
/// allowed type's signature sits well within this; 512 is comfortably generous.
const HEAD_SNIFF_BYTES: usize = 512;
/// Stream a multipart field straight to `dest`, aborting with a 400 the moment it
/// exceeds `max_bytes`. Only the first [`HEAD_SNIFF_BYTES`] bytes are kept in memory
/// (for type detection); the rest goes chunk-by-chunk to disk, so peak memory is a
/// single chunk rather than the whole file. Returns `(total_size, head_bytes)`. On any
/// error the partial temp file is removed so no stray `.tmp` is left behind.
async fn stream_field_to_file(
mut field: axum::extract::multipart::Field<'_>,
dest: &std::path::Path,
max_bytes: usize,
) -> Result<(i64, Vec<u8>), AppError> {
use tokio::io::AsyncWriteExt;
let mut file = tokio::fs::File::create(dest)
.await
.map_err(|e| AppError::Internal(e.into()))?;
let mut total: usize = 0;
let mut head: Vec<u8> = Vec::with_capacity(HEAD_SNIFF_BYTES);
loop {
let chunk = match field.chunk().await {
Ok(Some(c)) => c,
Ok(None) => break,
Err(e) => {
let _ = file.shutdown().await;
let _ = tokio::fs::remove_file(dest).await;
return Err(AppError::BadRequest(format!(
"Datei konnte nicht gelesen werden: {e}"
)));
}
};
total = total.saturating_add(chunk.len());
if total > max_bytes {
let _ = file.shutdown().await;
let _ = tokio::fs::remove_file(dest).await;
return Err(AppError::BadRequest(format!(
"Datei ist zu groß. Maximum: {} MB.",
max_bytes / (1024 * 1024)
)));
}
if head.len() < HEAD_SNIFF_BYTES {
let need = HEAD_SNIFF_BYTES - head.len();
head.extend_from_slice(&chunk[..need.min(chunk.len())]);
}
if let Err(e) = file.write_all(&chunk).await {
let _ = tokio::fs::remove_file(dest).await;
return Err(AppError::Internal(e.into()));
}
}
if let Err(e) = file.flush().await {
let _ = tokio::fs::remove_file(dest).await;
return Err(AppError::Internal(e.into()));
}
Ok((total as i64, head))
}
/// Drain a multipart body so the HTTP connection stays clean when returning an early error. /// 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, /// 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. /// which can corrupt the keep-alive connection for subsequent requests.
@@ -363,9 +486,9 @@ fn quota_limit_bytes(free_disk: i64, tolerance: f64, active_uploaders: i64) -> i
/// None` whenever the storage quota is currently disabled — callers should skip the /// None` whenever the storage quota is currently disabled — callers should skip the
/// check (upload handler) or hide the UI (quota endpoint). /// check (upload handler) or hide the UI (quota endpoint).
pub async fn compute_storage_quota(state: &AppState) -> QuotaEstimate { pub async fn compute_storage_quota(state: &AppState) -> QuotaEstimate {
let quota_on = config::get_bool(&state.pool, "quota_enabled", true).await; let quota_on = config::get_bool(&state.config_cache, "quota_enabled", true).await;
let storage_quota_on = config::get_bool(&state.pool, "storage_quota_enabled", true).await; let storage_quota_on = config::get_bool(&state.config_cache, "storage_quota_enabled", true).await;
let tolerance = config::get_f64(&state.pool, "quota_tolerance", 0.75).await; let tolerance = config::get_f64(&state.config_cache, "quota_tolerance", 0.75).await;
let (active_count,): (i64,) = sqlx::query_as( let (active_count,): (i64,) = sqlx::query_as(
"SELECT COUNT(DISTINCT user_id) FROM upload WHERE deleted_at IS NULL", "SELECT COUNT(DISTINCT user_id) FROM upload WHERE deleted_at IS NULL",
@@ -375,21 +498,23 @@ pub async fn compute_storage_quota(state: &AppState) -> QuotaEstimate {
.unwrap_or((0,)); .unwrap_or((0,));
let active = active_count.max(1); let active = active_count.max(1);
let media_path = state.config.media_path.to_string_lossy().to_string(); // Cached disk reading. `None` means we couldn't resolve the media filesystem.
let free_disk = sysinfo::Disks::new_with_refreshed_list() let disk = state.disk_cache.snapshot(&state.config.media_path);
.iter() let free_disk = disk.map(|d| d.free as i64).unwrap_or(0);
.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 { let limit_bytes = if quota_on && storage_quota_on {
Some(quota_limit_bytes(free_disk, tolerance, active)) match disk {
Some(d) => Some(quota_limit_bytes(d.free as i64, tolerance, active)),
// Fail OPEN, not closed: if the disk can't be read we don't know the real
// free space, and enforcing a 0-byte limit would reject every upload with a
// spurious "quota reached". Skip enforcement this round and warn instead.
None => {
tracing::warn!(
"disk snapshot unavailable; skipping storage-quota enforcement this round"
);
None
}
}
} else { } else {
None None
}; };
@@ -402,34 +527,26 @@ pub async fn compute_storage_quota(state: &AppState) -> QuotaEstimate {
} }
} }
/// Streaming download of the original file behind an upload. Used by: /// Stream a media file from disk into an HTTP response with a fixed set of security
/// - the per-post "Original anzeigen" context action (`window.open`) /// headers. Every media response (original, preview, thumbnail) goes through here so
/// - `<img src>` / `<video src>` in the feed, lightbox, and diashow when the user is in /// they consistently carry `X-Content-Type-Options: nosniff` (defense-in-depth against
/// Data Mode = Original /// content-type confusion, even if the edge proxy is bypassed) plus an explicit
/// /// `Content-Disposition` and `Cache-Control`.
/// **Auth model:** the route is intentionally unauthenticated, matching how the rest of async fn stream_media_file(
/// `/media/*` is served (preview + thumbnail variants). The URL contains the upload's absolute: &std::path::Path,
/// UUID, which is unguessable — same security posture as `/media/originals/{slug}/{id}`. content_type: String,
/// Adding `Authorization: Bearer` here would make the endpoint unusable from `<img src>` disposition: &str,
/// and `window.open`, defeating the purpose of having the alias. cache_control: &str,
pub async fn get_original(
State(state): State<AppState>,
Path(upload_id): Path<Uuid>,
) -> Result<axum::response::Response, AppError> { ) -> Result<axum::response::Response, AppError> {
let upload = Upload::find_by_id(&state.pool, upload_id)
.await?
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
let absolute = state.config.media_path.join(&upload.original_path);
if !absolute.exists() {
return Err(AppError::NotFound("Datei nicht gefunden.".into()));
}
use axum::body::Body; use axum::body::Body;
use axum::http::{header, Response, StatusCode}; use axum::http::{header, Response, StatusCode};
use tokio_util::io::ReaderStream; use tokio_util::io::ReaderStream;
let file = tokio::fs::File::open(&absolute) if !absolute.exists() {
return Err(AppError::NotFound("Datei nicht gefunden.".into()));
}
let file = tokio::fs::File::open(absolute)
.await .await
.map_err(|e| AppError::Internal(e.into()))?; .map_err(|e| AppError::Internal(e.into()))?;
let metadata = file let metadata = file
@@ -438,19 +555,86 @@ pub async fn get_original(
.map_err(|e| AppError::Internal(e.into()))?; .map_err(|e| AppError::Internal(e.into()))?;
let stream = ReaderStream::new(file); let stream = ReaderStream::new(file);
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, content_type)
.header(header::CONTENT_DISPOSITION, disposition)
.header(header::CONTENT_LENGTH, metadata.len())
.header(header::CACHE_CONTROL, cache_control)
.header(header::X_CONTENT_TYPE_OPTIONS, "nosniff")
.body(Body::from_stream(stream))
.map_err(|e| AppError::Internal(e.into()))
}
/// Streaming download of the original file behind an upload. Used by:
/// - the per-post "Original anzeigen" context action (`window.open`)
/// - `<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 so it works from
/// `<img src>` / `window.open`. 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_visible_media`) so moderation actually
/// removes access to content. Preview and thumbnail variants are gated the same way (see
/// [`get_preview`] / [`get_thumbnail`]).
pub async fn get_original(
State(state): State<AppState>,
Path(upload_id): Path<Uuid>,
) -> Result<axum::response::Response, AppError> {
let media = Upload::find_visible_media(&state.pool, upload_id)
.await?
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
let absolute = state.config.media_path.join(&media.original_path);
let filename = absolute let filename = absolute
.file_name() .file_name()
.and_then(|n| n.to_str()) .and_then(|n| n.to_str())
.unwrap_or("original"); .unwrap_or("original");
let disposition = format!("attachment; filename=\"{filename}\""); let disposition = format!("attachment; filename=\"{filename}\"");
Response::builder() // Full-res original: force download, never cache at the edge.
.status(StatusCode::OK) stream_media_file(&absolute, media.mime_type, &disposition, "no-store").await
.header(header::CONTENT_TYPE, upload.mime_type) }
.header(header::CONTENT_DISPOSITION, disposition)
.header(header::CONTENT_LENGTH, metadata.len()) /// Streaming access to an upload's compressed **preview** image. Gated exactly like
.body(Body::from_stream(stream)) /// [`get_original`]: `find_visible_media` drops soft-deleted / ban-hidden uploads, so a
.map_err(|e| AppError::Internal(e.into())) /// deleted or moderated post's preview 404s here even though the file may still be on
/// disk. Direct `/media/previews/**` is blocked in the router, making this the only path
/// to a preview.
///
/// Served inline (it's an `<img src>`) and privately cacheable for a short window. The
/// window is intentionally short (5 min) so a moderated image stops being served to a
/// direct-URL holder promptly; the live feed already evicts the card instantly via the
/// `upload-deleted` / `user-hidden` SSE events, so this only bounds the raw-URL edge case.
pub async fn get_preview(
State(state): State<AppState>,
Path(upload_id): Path<Uuid>,
) -> Result<axum::response::Response, AppError> {
let media = Upload::find_visible_media(&state.pool, upload_id)
.await?
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
let rel = media
.preview_path
.ok_or_else(|| AppError::NotFound("Vorschau nicht verfügbar.".into()))?;
let absolute = state.config.media_path.join(&rel);
stream_media_file(&absolute, "image/jpeg".to_string(), "inline", "private, max-age=300").await
}
/// Streaming access to an upload's **thumbnail** (video poster). Gated identically to
/// [`get_preview`].
pub async fn get_thumbnail(
State(state): State<AppState>,
Path(upload_id): Path<Uuid>,
) -> Result<axum::response::Response, AppError> {
let media = Upload::find_visible_media(&state.pool, upload_id)
.await?
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
let rel = media
.thumbnail_path
.ok_or_else(|| AppError::NotFound("Thumbnail nicht verfügbar.".into()))?;
let absolute = state.config.media_path.join(&rel);
stream_media_file(&absolute, "image/jpeg".to_string(), "inline", "private, max-age=300").await
} }
#[cfg(test)] #[cfg(test)]

View File

@@ -57,6 +57,7 @@ async fn main() -> Result<()> {
let api = Router::new() let api = Router::new()
// Auth // Auth
.route("/api/v1/event", get(handlers::public::get_public_event))
.route("/api/v1/join", post(auth::handlers::join)) .route("/api/v1/join", post(auth::handlers::join))
.route("/api/v1/recover", post(auth::handlers::recover)) .route("/api/v1/recover", post(auth::handlers::recover))
.route("/api/v1/admin/login", post(auth::handlers::admin_login)) .route("/api/v1/admin/login", post(auth::handlers::admin_login))
@@ -76,6 +77,17 @@ async fn main() -> Result<()> {
"/api/v1/upload/{id}/original", "/api/v1/upload/{id}/original",
get(handlers::upload::get_original), get(handlers::upload::get_original),
) )
// Preview/thumbnail variants are gated the same way as originals (visibility
// check + direct /media block below) so moderation actually revokes access to
// the displayed images, not just the full-res download.
.route(
"/api/v1/upload/{id}/preview",
get(handlers::upload::get_preview),
)
.route(
"/api/v1/upload/{id}/thumbnail",
get(handlers::upload::get_thumbnail),
)
// Current-user endpoints (live quota estimate, profile + privacy note bundle) // Current-user endpoints (live quota estimate, profile + privacy note bundle)
.route("/api/v1/me/context", get(handlers::me::get_context)) .route("/api/v1/me/context", get(handlers::me::get_context))
.route("/api/v1/me/quota", get(handlers::me::get_quota)) .route("/api/v1/me/quota", get(handlers::me::get_quota))
@@ -144,13 +156,95 @@ async fn main() -> Result<()> {
let router = Router::new() let router = Router::new()
.route("/health", get(|| async { "ok" })) .route("/health", get(|| async { "ok" }))
.merge(api) .merge(api)
// Block direct HTTP access to ALL media subtrees. The files live under
// `media_path` (so the compression worker and export can read them off disk) but
// must NOT be pullable straight from `/media/**` — that bypasses the visibility
// checks (soft-delete + ban-hide) in the gated handlers. Every legitimate fetch
// goes through `/api/v1/upload/{id}/{original,preview,thumbnail}`, which filter
// via `find_visible_media`. The more specific nests take precedence over the
// `/media` ServeDir below (which, with all three subtrees blocked, now serves
// nothing — kept as a backstop).
.nest_service(
"/media/originals",
get(|| async { axum::http::StatusCode::NOT_FOUND }),
)
.nest_service(
"/media/previews",
get(|| async { axum::http::StatusCode::NOT_FOUND }),
)
.nest_service(
"/media/thumbnails",
get(|| async { axum::http::StatusCode::NOT_FOUND }),
)
.nest_service("/media", media_service) .nest_service("/media", media_service)
.layer(TraceLayer::new_for_http()) .layer(TraceLayer::new_for_http())
.with_state(state); .with_state(state);
let listener = tokio::net::TcpListener::bind(("0.0.0.0", config.app_port)).await?; let listener = tokio::net::TcpListener::bind(("0.0.0.0", config.app_port)).await?;
tracing::info!("listening on {}", listener.local_addr()?); tracing::info!("listening on {}", listener.local_addr()?);
axum::serve(listener, router).await?; axum::serve(listener, router)
.with_graceful_shutdown(shutdown_signal())
.await?;
Ok(()) Ok(())
} }
/// Hard cap on how long we wait for in-flight connections to drain after a shutdown
/// signal. Uploads (streamed to disk) finish in well under this; the cap exists because
/// long-lived SSE streams never end on their own and would otherwise keep the graceful
/// drain — and thus the process — pending until the orchestrator force-kills it.
const SHUTDOWN_GRACE: std::time::Duration = std::time::Duration::from_secs(10);
/// Resolves on SIGINT (Ctrl-C) or SIGTERM (container stop / deploy). Letting
/// `axum::serve` drain in-flight requests on this signal means a redeploy no longer
/// truncates uploads mid-flight; any background compression/export half-states that a
/// hard kill would leave are already reconciled by `startup_recovery` on the next boot.
///
/// Once the signal fires we also arm a detached backstop that force-exits after
/// [`SHUTDOWN_GRACE`]. Without it, open SSE streams (which have no natural end) would
/// hold the graceful drain open indefinitely; the backstop bounds shutdown regardless
/// of the orchestrator's own kill timeout. If the drain completes first, `main` returns
/// and the process exits before the timer ever fires.
async fn shutdown_signal() {
let ctrl_c = async {
let _ = tokio::signal::ctrl_c().await;
};
#[cfg(unix)]
let terminate = async {
match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
Ok(mut sig) => {
sig.recv().await;
}
Err(e) => {
tracing::warn!(error = ?e, "failed to install SIGTERM handler");
// Never resolve — fall back to ctrl_c only.
std::future::pending::<()>().await;
}
}
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {}
_ = terminate => {}
}
tracing::info!(
"shutdown signal received, draining in-flight requests (max {}s)",
SHUTDOWN_GRACE.as_secs()
);
// Backstop: if the graceful drain is still blocked after the grace window (almost
// always because SSE streams are still open), exit anyway so deploys aren't stalled.
tokio::spawn(async {
tokio::time::sleep(SHUTDOWN_GRACE).await;
tracing::warn!(
"graceful drain exceeded {}s (likely open SSE streams); forcing exit",
SHUTDOWN_GRACE.as_secs()
);
std::process::exit(0);
});
}

View File

@@ -43,9 +43,33 @@ impl Session {
.await .await
} }
pub async fn touch(pool: &PgPool, id: Uuid) -> Result<(), sqlx::Error> { /// Resolve a session token straight to its live user row in one round-trip.
sqlx::query("UPDATE session SET last_seen_at = NOW() WHERE id = $1") ///
.bind(id) /// The auth extractor runs on every authenticated request and used to do two
/// sequential queries (session lookup, then user lookup); this collapses them into
/// a single `session JOIN "user"`. The `expires_at` guard mirrors
/// [`Self::find_by_token_hash`], and the user row is read live (role/ban are never
/// trusted from the JWT). Returns `None` when the session is missing/expired.
pub async fn find_user_by_token_hash(
pool: &PgPool,
token_hash: &str,
) -> Result<Option<crate::models::user::User>, sqlx::Error> {
sqlx::query_as::<_, crate::models::user::User>(
"SELECT u.*
FROM session s
JOIN \"user\" u ON u.id = s.user_id
WHERE s.token_hash = $1 AND s.expires_at > NOW()",
)
.bind(token_hash)
.fetch_optional(pool)
.await
}
/// Update `last_seen_at`, keyed by the token hash so the auth extractor can touch a
/// session without a separate query to fetch its id first.
pub async fn touch_by_token_hash(pool: &PgPool, token_hash: &str) -> Result<(), sqlx::Error> {
sqlx::query("UPDATE session SET last_seen_at = NOW() WHERE token_hash = $1")
.bind(token_hash)
.execute(pool) .execute(pool)
.await?; .await?;
Ok(()) Ok(())

View File

@@ -35,6 +35,16 @@ pub struct UploadDto {
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
} }
/// Minimal projection of an upload's on-disk file paths, used by the visibility-gated
/// media aliases so they don't hydrate the entire `Upload` row per request.
#[derive(Debug, sqlx::FromRow)]
pub struct VisibleMedia {
pub original_path: String,
pub preview_path: Option<String>,
pub thumbnail_path: Option<String>,
pub mime_type: String,
}
impl Upload { impl Upload {
/// Takes any executor so the caller can run it inside a transaction (atomic /// Takes any executor so the caller can run it inside a transaction (atomic
/// quota + insert) or standalone against the pool. /// quota + insert) or standalone against the pool.
@@ -74,6 +84,30 @@ impl Upload {
.await .await
} }
/// Lean lookup for the public media aliases (`get_original`/`get_preview`/
/// `get_thumbnail`): returns ONLY the file paths + mime for a visible upload —
/// excluding soft-deleted rows and ban-hidden owners (`user.uploads_hidden`), the
/// same filter `v_feed` applies. So moderation that removes a post from the feed
/// also stops its original/preview/thumbnail from being pulled by UUID.
///
/// Selects four columns instead of the whole `Upload` row: this runs once per image
/// per cache-miss on the media hot path, so we avoid hydrating fields the response
/// never uses.
pub async fn find_visible_media(
pool: &PgPool,
id: Uuid,
) -> Result<Option<VisibleMedia>, sqlx::Error> {
sqlx::query_as::<_, VisibleMedia>(
"SELECT up.original_path, up.preview_path, up.thumbnail_path, up.mime_type
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 /// Event-scoped lookup used by host endpoints so a host of event A cannot
/// reach uploads belonging to event B. /// reach uploads belonging to event B.
pub async fn find_by_id_and_event( pub async fn find_by_id_and_event(

View File

@@ -1,46 +1,129 @@
//! Reads of the runtime-tunable `config` table. //! Reads of the runtime-tunable `config` table, fronted by an in-memory cache.
//! //!
//! Each handler used to keep a small local copy of these helpers; consolidating them //! Each handler used to keep a small local copy of these helpers; consolidating them
//! here means one place to add a parser, one place to mock for tests, and one place to //! here means one place to add a parser, one place to mock for tests, and one place to
//! find when a key changes. New keys do not require code changes — they're picked up //! find when a key changes. New keys do not require code changes — they're picked up
//! the next time someone calls `get_*`. //! the next time the cache reloads.
//!
//! ## Why a cache
//!
//! The `config` table is effectively static during an event, yet it was the busiest
//! query in the system: every request re-read each key with its own `SELECT`
//! (an upload touched it ~8 times). Against the small connection pool that was the
//! throughput ceiling. [`ConfigCache`] loads the whole table once and serves reads
//! from memory.
//!
//! ## Consistency contract
//!
//! Correctness comes from **synchronous invalidation on every write**, not from the
//! TTL. The two runtime write paths — the admin `PATCH /admin/config` handler and the
//! test-mode truncate/reseed — both call [`ConfigCache::invalidate`] after committing,
//! so the *next* read reloads from the DB and sees the new value immediately. The
//! [`RELOAD_TTL`] is only a safety net for out-of-band changes (e.g. a migration or a
//! manual DB edit); it is deliberately short but never the primary mechanism.
//! //!
//! Values are read with a default fallback so the app still starts if a key is missing //! Values are read with a default fallback so the app still starts if a key is missing
//! (e.g. during a migration window). Production seeds keys via migrations 005 and 009. //! (e.g. during a migration window). Production seeds keys via migrations 005 and 009.
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use std::time::{Duration, Instant};
use sqlx::PgPool; use sqlx::PgPool;
async fn fetch_raw(pool: &PgPool, key: &str) -> Option<String> { /// How long a loaded snapshot is trusted before the next read reloads it. This is a
sqlx::query_as::<_, (String,)>("SELECT value FROM config WHERE key = $1") /// backstop for out-of-band DB changes only — every in-process write invalidates the
.bind(key) /// cache synchronously, so tests that PATCH-then-assert never depend on this expiring.
.fetch_optional(pool) const RELOAD_TTL: Duration = Duration::from_secs(30);
.await
.ok() struct Snapshot {
.flatten() values: HashMap<String, String>,
.map(|(v,)| v) loaded_at: Instant,
} }
pub async fn get_str(pool: &PgPool, key: &str, default: &str) -> String { /// In-memory cache of the entire `config` table. Cheap to `clone` (shares the pool and
fetch_raw(pool, key).await.unwrap_or_else(|| default.to_string()) /// the `Arc`), so it lives in `AppState` and every handler reads through it.
#[derive(Clone)]
pub struct ConfigCache {
pool: PgPool,
inner: Arc<RwLock<Option<Snapshot>>>,
} }
pub async fn get_i64(pool: &PgPool, key: &str, default: i64) -> i64 { impl ConfigCache {
fetch_raw(pool, key).await.and_then(|v| v.parse().ok()).unwrap_or(default) pub fn new(pool: PgPool) -> Self {
Self {
pool,
inner: Arc::new(RwLock::new(None)),
}
}
/// Drop the cached snapshot so the next read reloads the whole table from the DB.
/// Call this after any write to the `config` table (admin PATCH, test reseed).
pub fn invalidate(&self) {
*self.inner.write().unwrap() = None;
}
/// Return the fresh snapshot if one is loaded and still within [`RELOAD_TTL`].
fn fresh_snapshot(&self) -> Option<HashMap<String, String>> {
let guard = self.inner.read().unwrap();
match guard.as_ref() {
Some(snap) if snap.loaded_at.elapsed() < RELOAD_TTL => Some(snap.values.clone()),
_ => None,
}
}
/// Read one key, loading the whole table into the cache on a miss/expiry. On a DB
/// error we return `None` (callers fall back to their default) without poisoning
/// the cache.
async fn get_raw(&self, key: &str) -> Option<String> {
if let Some(values) = self.fresh_snapshot() {
return values.get(key).cloned();
}
// Cache miss or stale — reload the entire table in one query.
let rows: Vec<(String, String)> =
match sqlx::query_as::<_, (String, String)>("SELECT key, value FROM config")
.fetch_all(&self.pool)
.await
{
Ok(rows) => rows,
Err(e) => {
tracing::warn!(error = ?e, "config reload failed; using defaults for this read");
return None;
}
};
let values: HashMap<String, String> = rows.into_iter().collect();
let result = values.get(key).cloned();
*self.inner.write().unwrap() = Some(Snapshot {
values,
loaded_at: Instant::now(),
});
result
}
} }
pub async fn get_usize(pool: &PgPool, key: &str, default: usize) -> usize { pub async fn get_str(cache: &ConfigCache, key: &str, default: &str) -> String {
fetch_raw(pool, key).await.and_then(|v| v.parse().ok()).unwrap_or(default) cache.get_raw(key).await.unwrap_or_else(|| default.to_string())
} }
pub async fn get_f64(pool: &PgPool, key: &str, default: f64) -> f64 { pub async fn get_i64(cache: &ConfigCache, key: &str, default: i64) -> i64 {
fetch_raw(pool, key).await.and_then(|v| v.parse().ok()).unwrap_or(default) cache.get_raw(key).await.and_then(|v| v.parse().ok()).unwrap_or(default)
}
pub async fn get_usize(cache: &ConfigCache, key: &str, default: usize) -> usize {
cache.get_raw(key).await.and_then(|v| v.parse().ok()).unwrap_or(default)
}
pub async fn get_f64(cache: &ConfigCache, key: &str, default: f64) -> f64 {
cache.get_raw(key).await.and_then(|v| v.parse().ok()).unwrap_or(default)
} }
/// Parses common truthy spellings used by both the migration seeds and the admin form. /// Parses common truthy spellings used by both the migration seeds and the admin form.
/// Accepts `true/false`, `1/0`, `yes/no`, `on/off` — case-insensitive. Anything else /// Accepts `true/false`, `1/0`, `yes/no`, `on/off` — case-insensitive. Anything else
/// returns `default`. /// returns `default`.
pub async fn get_bool(pool: &PgPool, key: &str, default: bool) -> bool { pub async fn get_bool(cache: &ConfigCache, key: &str, default: bool) -> bool {
let Some(raw) = fetch_raw(pool, key).await else { return default }; let Some(raw) = cache.get_raw(key).await else { return default };
match raw.trim().to_ascii_lowercase().as_str() { match raw.trim().to_ascii_lowercase().as_str() {
"true" | "1" | "yes" | "on" => true, "true" | "1" | "yes" | "on" => true,
"false" | "0" | "no" | "off" => false, "false" | "0" | "no" | "off" => false,

View File

@@ -0,0 +1,145 @@
//! Cached view of the filesystem backing the media directory.
//!
//! Free/total disk space is needed on two hot paths — the per-user storage quota
//! (checked on every upload *and* every `GET /me/quota` poll) and the admin stats
//! endpoint. Reading it means `sysinfo::Disks::new_with_refreshed_list()`, which stats
//! every mounted filesystem; doing that per request is wasteful for a number that
//! barely moves. [`DiskCache`] refreshes it at most once per [`TTL`] and serves the
//! rest from memory.
use std::path::Path;
use std::sync::{Arc, RwLock};
use std::time::{Duration, Instant};
/// How long a disk reading is trusted before the next call re-stats the filesystem.
const TTL: Duration = Duration::from_secs(15);
#[derive(Clone, Copy)]
pub struct DiskInfo {
pub total: u64,
pub free: u64,
}
/// Cheap-to-clone cache of the media filesystem's total/free bytes. Lives in
/// `AppState`.
#[derive(Clone)]
pub struct DiskCache {
inner: Arc<RwLock<Option<(DiskInfo, Instant)>>>,
}
impl DiskCache {
pub fn new() -> Self {
Self {
inner: Arc::new(RwLock::new(None)),
}
}
/// Cached `(total, free)` bytes for the filesystem that holds `media_path`.
///
/// Returns `None` when the mount can't be resolved — callers MUST treat that as
/// "unknown", never "zero free". (The quota path in particular fails *open* on
/// `None`: enforcing a 0-byte limit would lock every user out of uploading.)
pub fn snapshot(&self, media_path: &Path) -> Option<DiskInfo> {
if let Some((info, at)) = *self.inner.read().unwrap() {
if at.elapsed() < TTL {
return Some(info);
}
}
let info = read_disk_for_path(media_path)?;
*self.inner.write().unwrap() = Some((info, Instant::now()));
Some(info)
}
}
impl Default for DiskCache {
fn default() -> Self {
Self::new()
}
}
/// Resolve the filesystem backing `media_path` and read its total/free bytes.
///
/// Snapshots the mount table via `sysinfo`, then delegates the selection to the pure
/// [`select_disk`] so the (fiddly, edge-case-prone) matching logic is unit-testable
/// without touching the real filesystem.
fn read_disk_for_path(media_path: &Path) -> Option<DiskInfo> {
let disks = sysinfo::Disks::new_with_refreshed_list();
let mounts: Vec<(String, u64, u64)> = disks
.iter()
.map(|d| {
(
d.mount_point().to_string_lossy().to_string(),
d.total_space(),
d.available_space(),
)
})
.collect();
select_disk(&mounts, &media_path.to_string_lossy())
}
/// Pick the filesystem for `media_path` from a `(mount_point, total, free)` table.
///
/// Chooses the **longest** mount point that is a prefix of `media_path` (the most
/// specific filesystem) rather than the first match — otherwise the root `/` mount,
/// which prefixes every absolute path, could shadow a dedicated `/media` volume. Falls
/// back to `/` when nothing prefixes the path (e.g. a relative media path), and to
/// `None` when even that is absent — the caller treats `None` as "unknown" and fails
/// open on quota.
fn select_disk(mounts: &[(String, u64, u64)], media_path: &str) -> Option<DiskInfo> {
mounts
.iter()
.filter(|(mp, _, _)| media_path.starts_with(mp.as_str()))
.max_by_key(|(mp, _, _)| mp.len())
.or_else(|| mounts.iter().find(|(mp, _, _)| mp == "/"))
.map(|(_, total, free)| DiskInfo {
total: *total,
free: *free,
})
}
#[cfg(test)]
mod tests {
use super::select_disk;
#[test]
fn picks_longest_matching_mount() {
// Both "/" and "/media" prefix the path; the dedicated volume must win.
let mounts = vec![
("/".to_string(), 100, 40),
("/media".to_string(), 200, 150),
];
let d = select_disk(&mounts, "/media/originals/x.jpg").unwrap();
assert_eq!((d.total, d.free), (200, 150));
}
#[test]
fn falls_back_to_root_when_no_specific_mount_matches() {
let mounts = vec![
("/".to_string(), 100, 40),
("/media".to_string(), 200, 150),
];
// "/var/lib" is only prefixed by "/".
let d = select_disk(&mounts, "/var/lib/data").unwrap();
assert_eq!((d.total, d.free), (100, 40));
}
#[test]
fn relative_path_uses_root_fallback() {
let mounts = vec![("/".to_string(), 100, 40)];
// A relative path prefixes nothing, so the explicit "/" fallback applies.
let d = select_disk(&mounts, "media/originals").unwrap();
assert_eq!((d.total, d.free), (100, 40));
}
#[test]
fn none_when_no_mount_matches_and_no_root() {
// No "/" present and nothing prefixes the relative path → unknown (fail-open).
let mounts = vec![("/data".to_string(), 100, 40)];
assert!(select_disk(&mounts, "relative/path").is_none());
}
#[test]
fn none_on_empty_mount_table() {
assert!(select_disk(&[], "/media/x").is_none());
}
}

View File

@@ -192,6 +192,23 @@ async fn run_zip_export(
// ── HTML viewer export ────────────────────────────────────────────────────── // ── HTML viewer export ──────────────────────────────────────────────────────
/// Where a media entry's bytes come from at ZIP-writing time. Derived variants
/// (thumbnails, compressed images) are staged under the temp dir; original-fidelity
/// variants (videos, small images) are streamed straight from the source on disk so
/// the export never transiently doubles disk usage by copying large files to temp.
enum MediaSource {
Temp(PathBuf),
Original(PathBuf),
}
impl MediaSource {
fn path(&self) -> &Path {
match self {
MediaSource::Temp(p) | MediaSource::Original(p) => p,
}
}
}
async fn run_html_export( async fn run_html_export(
event_id: Uuid, event_id: Uuid,
event_name: &str, event_name: &str,
@@ -221,6 +238,9 @@ async fn run_html_export(
// 3. Process media and build post data // 3. Process media and build post data
let mut viewer_posts: Vec<ViewerPost> = Vec::new(); let mut viewer_posts: Vec<ViewerPost> = Vec::new();
// (zip entry name under media/, where its bytes come from). Built here, streamed
// into the ZIP in step 5 — so we also know the exact file count without a rescan.
let mut media_manifest: Vec<(String, MediaSource)> = Vec::new();
for (i, row) in uploads.iter().enumerate() { for (i, row) in uploads.iter().enumerate() {
let src = media_path.join(&row.original_path); let src = media_path.join(&row.original_path);
@@ -231,8 +251,10 @@ async fn run_html_export(
let is_video = row.mime_type.starts_with("video/"); let is_video = row.mime_type.starts_with("video/");
let id_str = row.id.to_string(); let id_str = row.id.to_string();
// Generate thumbnail and full variant // Generate thumbnail and full variant. `full_source` says where the full-res
let (thumb_name, full_name) = if is_video { // bytes come from at ZIP time — for videos and small images that's the original
// on disk (streamed directly, never copied to temp).
let (thumb_name, full_name, full_source) = if is_video {
let thumb = format!("{id_str}_thumb.jpg"); let thumb = format!("{id_str}_thumb.jpg");
let full_ext = ext_from_path(&row.original_path); let full_ext = ext_from_path(&row.original_path);
let full = format!("{id_str}.{full_ext}"); let full = format!("{id_str}.{full_ext}");
@@ -259,14 +281,13 @@ async fn run_html_export(
Ok(output) if output.status.success() => {} Ok(output) if output.status.success() => {}
_ => { _ => {
tracing::warn!("ffmpeg thumbnail failed for upload {}, skipping thumb", row.id); tracing::warn!("ffmpeg thumbnail failed for upload {}, skipping thumb", row.id);
// Create empty thumb entry — viewer handles missing thumbs gracefully // Missing thumb entry — viewer handles missing thumbs gracefully.
} }
} }
// Copy video as-is // Stream the video full-res straight from the original at ZIP time — no
tokio::fs::copy(&src, media_tmp.join(&full)).await?; // copy to temp (that used to transiently double disk usage per video).
(thumb, full, MediaSource::Original(src.clone()))
(thumb, full)
} else { } else {
let thumb = format!("{id_str}_thumb.jpg"); let thumb = format!("{id_str}_thumb.jpg");
let ext = ext_from_path(&row.original_path); let ext = ext_from_path(&row.original_path);
@@ -291,13 +312,12 @@ async fn run_html_export(
tracing::warn!("thumbnail generation failed for upload {}: {e:#}", row.id); tracing::warn!("thumbnail generation failed for upload {}: {e:#}", row.id);
} }
// Full variant: compress if >5MB, otherwise copy original // Full variant: compress to temp if >5MB, otherwise stream the original
// as-is (no temp copy).
let src_meta = tokio::fs::metadata(&src).await?; let src_meta = tokio::fs::metadata(&src).await?;
let full_path = media_tmp.join(&full); let full_source = if src_meta.len() > 5_000_000 {
if src_meta.len() > 5_000_000 {
// Resize to max 2000px
let src_clone = src.clone(); let src_clone = src.clone();
let full_path = media_tmp.join(&full);
let full_path_clone = full_path.clone(); let full_path_clone = full_path.clone();
let compress_result = tokio::task::spawn_blocking(move || -> Result<()> { let compress_result = tokio::task::spawn_blocking(move || -> Result<()> {
@@ -311,17 +331,31 @@ async fn run_html_export(
}) })
.await?; .await?;
if let Err(e) = compress_result { match compress_result {
tracing::warn!("compression failed for upload {}, copying original: {e:#}", row.id); Ok(()) => MediaSource::Temp(full_path),
tokio::fs::copy(&src, &full_path).await?; Err(e) => {
tracing::warn!(
"compression failed for upload {}, using original: {e:#}",
row.id
);
MediaSource::Original(src.clone())
}
} }
} else { } else {
tokio::fs::copy(&src, &full_path).await?; MediaSource::Original(src.clone())
} };
(thumb, full) (thumb, full, full_source)
}; };
// Register this post's two media entries. Thumbnails always come from temp
// (they're freshly generated); the full variant's source was decided above.
media_manifest.push((
thumb_name.clone(),
MediaSource::Temp(media_tmp.join(&thumb_name)),
));
media_manifest.push((full_name.clone(), full_source));
// Build comments for this upload // Build comments for this upload
let post_comments: Vec<ViewerComment> = comments let post_comments: Vec<ViewerComment> = comments
.iter() .iter()
@@ -409,27 +443,23 @@ async fn run_html_export(
update_progress(pool, event_id, "html", 78).await; update_progress(pool, event_id, "html", 78).await;
// Write media files from temp directory // Write media files from the manifest built in step 3. Thumbnails and derived
let mut media_entries = tokio::fs::read_dir(&media_tmp).await?; // image variants stream from temp; video and small-image full variants stream
let mut file_count = 0u32; // straight from the original on disk. Sources that don't exist (e.g. a thumb
// whose ffmpeg step failed) are skipped — the viewer tolerates gaps.
let file_total = media_manifest.len().max(1) as f32;
let mut files_written = 0u32; let mut files_written = 0u32;
// Count files first for (name, source) in &media_manifest {
{ let path = source.path();
let mut counter = tokio::fs::read_dir(&media_tmp).await?; if !path.exists() {
while counter.next_entry().await?.is_some() { continue;
file_count += 1;
} }
}
let file_total = file_count.max(1) as f32;
while let Some(dir_entry) = media_entries.next_entry().await? {
let filename = dir_entry.file_name();
let entry_name = format!("media/{}", filename.to_string_lossy());
let entry_name = format!("media/{name}");
let builder = ZipEntryBuilder::new(entry_name.into(), Compression::Stored); let builder = ZipEntryBuilder::new(entry_name.into(), Compression::Stored);
let mut zip_entry = zip.write_entry_stream(builder).await?; let mut zip_entry = zip.write_entry_stream(builder).await?;
let mut f = tokio::fs::File::open(dir_entry.path()).await?.compat(); let mut f = tokio::fs::File::open(path).await?.compat();
fcopy(&mut f, &mut zip_entry).await?; fcopy(&mut f, &mut zip_entry).await?;
zip_entry.close().await?; zip_entry.close().await?;

View File

@@ -1,5 +1,6 @@
pub mod compression; pub mod compression;
pub mod config; pub mod config;
pub mod disk;
pub mod export; pub mod export;
pub mod jobs; pub mod jobs;
pub mod maintenance; pub mod maintenance;

View File

@@ -3,6 +3,8 @@ use tokio::sync::broadcast;
use crate::config::AppConfig; use crate::config::AppConfig;
use crate::services::compression::CompressionWorker; use crate::services::compression::CompressionWorker;
use crate::services::config::ConfigCache;
use crate::services::disk::DiskCache;
use crate::services::rate_limiter::RateLimiter; use crate::services::rate_limiter::RateLimiter;
use crate::services::sse_tickets::SseTicketStore; use crate::services::sse_tickets::SseTicketStore;
@@ -31,6 +33,11 @@ pub struct AppState {
pub compression: CompressionWorker, pub compression: CompressionWorker,
pub rate_limiter: RateLimiter, pub rate_limiter: RateLimiter,
pub sse_tickets: SseTicketStore, pub sse_tickets: SseTicketStore,
/// In-memory cache in front of the `config` table. Reads go through here; the
/// admin PATCH handler and the test reseed invalidate it after committing.
pub config_cache: ConfigCache,
/// Cached total/free bytes for the media filesystem (quota + admin stats).
pub disk_cache: DiskCache,
} }
impl AppState { impl AppState {
@@ -42,6 +49,7 @@ impl AppState {
config.compression_concurrency, config.compression_concurrency,
sse_tx.clone(), sse_tx.clone(),
); );
let config_cache = ConfigCache::new(pool.clone());
Self { Self {
pool, pool,
config, config,
@@ -49,6 +57,8 @@ impl AppState {
compression, compression,
rate_limiter: RateLimiter::new(), rate_limiter: RateLimiter::new(),
sse_tickets: SseTicketStore::new(), sse_tickets: SseTicketStore::new(),
config_cache,
disk_cache: DiskCache::new(),
} }
} }
} }

View File

@@ -0,0 +1,26 @@
/**
* Security fix F4: /recover no longer leaks whether a display name exists. Previously an
* unknown name returned 404 ("Kein Benutzer …") while an existing name + wrong PIN
* returned 401 ("PIN ist falsch") — a response (and bcrypt-timing) oracle for account
* enumeration. Now both return an identical 401.
*/
import { test, expect } from '../../fixtures/test';
test.describe('Auth — recover does not leak account existence (F4)', () => {
test('unknown name and wrong PIN both return an identical 401', async ({ api, guest }) => {
// Establish the event + a real user first (a truncate wipes the event row; the join
// recreates it — otherwise recover 404s on "event not found" before name resolution).
const g = await guest('RealPerson');
// Unknown display name → 401 "PIN ist falsch" (NOT a 404 revealing non-existence).
const unknown = await api.recover('NoSuchPersonXYZ', '0000', { expectedStatus: [401] });
expect(unknown.status).toBe(401);
expect(JSON.stringify(unknown.body)).toContain('PIN');
// A real user with a wrong PIN returns the same 401 shape.
const wrongPin = g.pin === '0001' ? '0002' : '0001';
const wrong = await api.recover('RealPerson', wrongPin, { expectedStatus: [401] });
expect(wrong.status).toBe(401);
expect(JSON.stringify(wrong.body)).toContain('PIN');
});
});

View File

@@ -38,15 +38,14 @@ test.describe('Upload — gallery path', () => {
await expect.poll(() => db.countUploadsForUser(h.userId), { timeout: 10_000 }).toBe(2); await expect.poll(() => db.countUploadsForUser(h.userId), { timeout: 10_000 }).toBe(2);
}); });
test.fixme('UI flow: FAB → UploadSheet → /upload → submit drives a real XHR upload', async ({ page, guest, signIn, db }) => { test('UI flow: FAB → UploadSheet → /upload → submit drives a real XHR upload', async ({ page, guest, signIn, db }) => {
// The full UI flow (BottomNav FAB → UploadSheet → /upload page → handleSubmit → // Previously fixme'd: the UI queue never fired a POST. Root cause was NOT a
// upload-queue.ts XHR) does not currently complete within the test window in // navigation/blob timing quirk but an IndexedDB upgrade bug — the v1→v2
// Playwright. The XHR doesn't appear in backend logs. Suspected cause: the // `upgrade` callback opened a *new* transaction, which throws during a
// queue worker fires after the page navigates from /upload to /feed via // version-change transaction and aborted the whole upgrade, so the `queue`
// SvelteKit's goto(), but the blob/IDB chain may not survive the unmount/ // object store was never created and the worker could never persist an item.
// remount cycle in Playwright's headless Chromium. Needs deeper // Fixed in upload-queue.ts by reusing the callback's version-change
// investigation; tracked as a fixme for now. API-driven tests above cover // transaction. This test guards against regressing that.
// the data contract.
const h = await guest('UploaderUI'); const h = await guest('UploaderUI');
await signIn(page, h); await signIn(page, h);
const feed = new FeedPage(page); const feed = new FeedPage(page);

View File

@@ -34,10 +34,11 @@ test.describe('Host — event lock', () => {
// and flip fixme to test once it lands. // and flip fixme to test once it lands.
}); });
// Regression for the review: likes/comments used to ignore uploads_locked_at, // Locking is uploads-only: likes, comments and browsing stay open on a closed
// so social writes still landed on a closed event. They now share the upload // event (USER_JOURNEYS §9.3, FEATURES capability matrix). Only new uploads are
// handler's lock guard. // rejected. (An earlier revision froze social interaction too; that contradicted
test('a closed event rejects likes and comments', async ({ api, host, guest }) => { // the documented behavior and was reverted.)
test('a closed event still allows likes and comments, but blocks new uploads', async ({ api, host, guest }) => {
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101'; const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
const g = await guest('SocialLocked'); const g = await guest('SocialLocked');
@@ -55,17 +56,26 @@ test.describe('Host — event lock', () => {
await api.closeEvent(host.jwt); await api.closeEvent(host.jwt);
// Likes stay open on a locked event.
const likeRes = await fetch(`${BASE}/api/v1/upload/${id}/like`, { const likeRes = await fetch(`${BASE}/api/v1/upload/${id}/like`, {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${g.jwt}` }, headers: { Authorization: `Bearer ${g.jwt}` },
}); });
expect(likeRes.status).toBe(403); expect(likeRes.status).toBe(204);
// Comments stay open on a locked event.
const commentRes = await fetch(`${BASE}/api/v1/upload/${id}/comments`, { const commentRes = await fetch(`${BASE}/api/v1/upload/${id}/comments`, {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${g.jwt}`, 'Content-Type': 'application/json' }, headers: { Authorization: `Bearer ${g.jwt}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ body: 'sollte blockiert sein' }), body: JSON.stringify({ body: 'darf durchgehen' }),
}); });
expect(commentRes.status).toBe(403); expect(commentRes.status).toBe(201);
// New uploads, however, are rejected while locked.
const blockedUpload = await uploadRaw(g.jwt, readFileSync(sample), {
filename: 'y.jpg',
contentType: 'image/jpeg',
});
expect(blockedUpload.status).toBe(403);
}); });
}); });

View File

@@ -0,0 +1,64 @@
/**
* Covers the HTML export's VIDEO path (perf/stability fix P4). The export used to
* copy every original — including full-size videos — into a temp dir and then re-read
* them into the ZIP (transient 2× disk). It now streams video originals straight from
* disk into the archive. The image-only export specs never exercised that branch, so
* this drives a real video upload → export → and proves the video entry lands in
* Memories.zip (i.e. the streamed-from-original path works and the video isn't dropped).
*
* The fixture clip is <1s, so ffmpeg extracts no thumbnail frame — but exits 0, so the
* compression worker keeps the upload (it isn't auto-cleaned). That's the intended
* shape here: the full video is exported even when its thumbnail is absent.
*/
import { test, expect } from '../../fixtures/test';
import { uploadRaw } from '../../helpers/upload-client';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
test.describe('Export — video streaming (P4)', () => {
test('a real video upload is streamed into Memories.zip (not dropped)', async ({ host }) => {
test.setTimeout(60_000);
const bearer = { Authorization: `Bearer ${host.jwt}` };
// Seed a real video upload owned by the host.
const mp4 = readFileSync(join(process.cwd(), 'fixtures', 'media', 'sample.mp4'));
const up = await uploadRaw(host.jwt, mp4, { filename: 'clip.mp4', contentType: 'video/mp4' });
expect(up.status).toBe(201);
const { id } = await up.json();
// Release the gallery → spawns the real zip + html export jobs.
const rel = await fetch(`${BASE}/api/v1/host/gallery/release`, { method: 'POST', headers: bearer });
expect(rel.status).toBe(204);
// Wait for the HTML (Memories.zip) job — that's the one whose media staging P4 changed.
await expect
.poll(
async () => {
const res = await fetch(`${BASE}/api/v1/export/status`, { headers: bearer });
return (await res.json()).html?.status;
},
{ timeout: 45_000, intervals: [500] }
)
.toBe('done');
// Download Memories.zip via the gated single-use ticket.
const ticketRes = await fetch(`${BASE}/api/v1/export/ticket`, { method: 'POST', headers: bearer });
const { ticket } = await ticketRes.json();
const dl = await fetch(`${BASE}/api/v1/export/html?ticket=${encodeURIComponent(ticket)}`);
expect(dl.status).toBe(200);
const bytes = new Uint8Array(await dl.arrayBuffer());
// Valid ZIP (PK local-file-header magic).
expect(Array.from(bytes.subarray(0, 2))).toEqual([0x50, 0x4b]);
// ZIP entry names are stored verbatim (uncompressed) in the archive, so the video's
// media entry name appears as plaintext bytes. Its presence means the streamed
// video entry was written; if the stream had failed, entry.close() would have
// errored and the job would never have reached 'done'.
const needle = `media/${id}.mp4`;
const haystack = new TextDecoder('latin1').decode(bytes);
expect(haystack.includes(needle), `Memories.zip must contain ${needle}`).toBe(true);
});
});

View File

@@ -0,0 +1,59 @@
/**
* Security fix F2: preview/thumbnail images are now served through a visibility-checked
* alias (/api/v1/upload/{id}/preview) instead of the unauthenticated /media ServeDir,
* so moderation (delete / ban-hide) actually revokes access to the displayed image —
* not just the full-res original. Direct /media/previews access is blocked.
*/
import { test, expect } from '../../fixtures/test';
import { seedUpload } from '../../helpers/seed';
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
test.describe('Media gating — moderation revokes preview access (F2)', () => {
test('preview served via gated alias, blocked directly, and 404 after delete', async ({
host,
api,
}) => {
test.setTimeout(30_000);
const id = await seedUpload(host.jwt, { caption: 'gated' });
// Wait for the compression worker to produce the preview — the feed exposes
// `preview_url` only once `preview_path` is set.
let previewUrl: string | undefined;
await expect
.poll(
async () => {
const feed = await api.getFeed(host.jwt);
const row = (feed.uploads ?? []).find((u: any) => u.id === id);
previewUrl = row?.preview_url ?? undefined;
return previewUrl;
},
{ timeout: 20_000, intervals: [500] }
)
.toBeTruthy();
// Feed now emits the gated alias, not a /media path.
expect(previewUrl).toBe(`/api/v1/upload/${id}/preview`);
// The gated alias serves the image with the app-layer nosniff header.
const ok = await fetch(`${BASE}${previewUrl}`);
expect(ok.status).toBe(200);
expect(ok.headers.get('content-type')).toContain('image/jpeg');
// App sets nosniff (F6); the edge proxy may also set it → value can be doubled.
expect(ok.headers.get('x-content-type-options')).toContain('nosniff');
// Direct /media access is blocked (404) — the alias is the only way in.
const direct = await fetch(`${BASE}/media/previews/${id}.jpg`);
expect(direct.status, 'direct /media/previews must be blocked').toBe(404);
// Host deletes the upload → the preview must stop being served.
const del = await fetch(`${BASE}/api/v1/host/upload/${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${host.jwt}` },
});
expect(del.status).toBe(204);
const afterDelete = await fetch(`${BASE}/api/v1/upload/${id}/preview`);
expect(afterDelete.status, 'moderation must revoke preview access').toBe(404);
});
});

View File

@@ -0,0 +1,45 @@
/**
* Security fix F1: a plain host must not be able to demote a *peer host*. Before the
* fix, `set_role` only blocked `target == admin`, so a host could demote another host
* to guest and then ban / PIN-reset (→ account takeover via /recover) them, because the
* ban/pin-reset peer guards key off the target's *current* role. This proves the
* demotion itself is now blocked for non-admins, while admins and guest-management
* still work.
*/
import { test, expect } from '../../fixtures/test';
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
function patchRole(token: string, userId: string, role: string) {
return fetch(`${BASE}/api/v1/host/users/${userId}/role`, {
method: 'PATCH',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ role }),
});
}
test.describe('AuthZ — host cannot demote a peer host (F1)', () => {
test('host→host demotion is 403; admin may demote; host may still manage guests', async ({
host,
guest,
adminToken,
api,
}) => {
// A second host (the victim) and an ordinary guest.
const peer = await guest('PeerHost');
await api.setRole(adminToken, peer.userId, 'host');
const plain = await guest('PlainGuest');
// Attacker host tries to demote the peer host — must be refused.
const attack = await patchRole(host.jwt, peer.userId, 'guest');
expect(attack.status, 'a host must not be able to demote a peer host').toBe(403);
// An admin may still demote a host.
const byAdmin = await patchRole(adminToken, peer.userId, 'guest');
expect(byAdmin.status).toBe(204);
// A host may still manage guests (promote one to host).
const promote = await patchRole(host.jwt, plain.userId, 'host');
expect(promote.status).toBe(204);
});
});

View File

@@ -233,9 +233,9 @@
} }
}, },
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
"integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@@ -250,9 +250,9 @@
} }
}, },
"node_modules/@esbuild/android-arm": { "node_modules/@esbuild/android-arm": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
"integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -267,9 +267,9 @@
} }
}, },
"node_modules/@esbuild/android-arm64": { "node_modules/@esbuild/android-arm64": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
"integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -284,9 +284,9 @@
} }
}, },
"node_modules/@esbuild/android-x64": { "node_modules/@esbuild/android-x64": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
"integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -301,9 +301,9 @@
} }
}, },
"node_modules/@esbuild/darwin-arm64": { "node_modules/@esbuild/darwin-arm64": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
"integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -318,9 +318,9 @@
} }
}, },
"node_modules/@esbuild/darwin-x64": { "node_modules/@esbuild/darwin-x64": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
"integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -335,9 +335,9 @@
} }
}, },
"node_modules/@esbuild/freebsd-arm64": { "node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
"integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -352,9 +352,9 @@
} }
}, },
"node_modules/@esbuild/freebsd-x64": { "node_modules/@esbuild/freebsd-x64": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
"integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -369,9 +369,9 @@
} }
}, },
"node_modules/@esbuild/linux-arm": { "node_modules/@esbuild/linux-arm": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
"integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -386,9 +386,9 @@
} }
}, },
"node_modules/@esbuild/linux-arm64": { "node_modules/@esbuild/linux-arm64": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
"integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -403,9 +403,9 @@
} }
}, },
"node_modules/@esbuild/linux-ia32": { "node_modules/@esbuild/linux-ia32": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
"integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@@ -420,9 +420,9 @@
} }
}, },
"node_modules/@esbuild/linux-loong64": { "node_modules/@esbuild/linux-loong64": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
"integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
@@ -437,9 +437,9 @@
} }
}, },
"node_modules/@esbuild/linux-mips64el": { "node_modules/@esbuild/linux-mips64el": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
"integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
"cpu": [ "cpu": [
"mips64el" "mips64el"
], ],
@@ -454,9 +454,9 @@
} }
}, },
"node_modules/@esbuild/linux-ppc64": { "node_modules/@esbuild/linux-ppc64": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
"integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@@ -471,9 +471,9 @@
} }
}, },
"node_modules/@esbuild/linux-riscv64": { "node_modules/@esbuild/linux-riscv64": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
"integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@@ -488,9 +488,9 @@
} }
}, },
"node_modules/@esbuild/linux-s390x": { "node_modules/@esbuild/linux-s390x": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
"integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
@@ -505,9 +505,9 @@
} }
}, },
"node_modules/@esbuild/linux-x64": { "node_modules/@esbuild/linux-x64": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
"integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -522,9 +522,9 @@
} }
}, },
"node_modules/@esbuild/netbsd-arm64": { "node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
"integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -539,9 +539,9 @@
} }
}, },
"node_modules/@esbuild/netbsd-x64": { "node_modules/@esbuild/netbsd-x64": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
"integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -556,9 +556,9 @@
} }
}, },
"node_modules/@esbuild/openbsd-arm64": { "node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
"integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -573,9 +573,9 @@
} }
}, },
"node_modules/@esbuild/openbsd-x64": { "node_modules/@esbuild/openbsd-x64": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
"integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -590,9 +590,9 @@
} }
}, },
"node_modules/@esbuild/openharmony-arm64": { "node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
"integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -607,9 +607,9 @@
} }
}, },
"node_modules/@esbuild/sunos-x64": { "node_modules/@esbuild/sunos-x64": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
"integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -624,9 +624,9 @@
} }
}, },
"node_modules/@esbuild/win32-arm64": { "node_modules/@esbuild/win32-arm64": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
"integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -641,9 +641,9 @@
} }
}, },
"node_modules/@esbuild/win32-ia32": { "node_modules/@esbuild/win32-ia32": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
"integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@@ -658,9 +658,9 @@
} }
}, },
"node_modules/@esbuild/win32-x64": { "node_modules/@esbuild/win32-x64": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
"integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1208,9 +1208,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@sveltejs/acorn-typescript": { "node_modules/@sveltejs/acorn-typescript": {
"version": "1.0.9", "version": "1.0.11",
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz", "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.11.tgz",
"integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==", "integrity": "sha512-LFuZUkjJ9iF7JZye/aG5XM0SFcQ5VyL0oVX4WJ9dc0Va3R3s0OauX1BESVCb+YN/ol8TAfqGDDAQsTG627Y5kw==",
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"acorn": "^8.9.0" "acorn": "^8.9.0"
@@ -1243,18 +1243,18 @@
} }
}, },
"node_modules/@sveltejs/kit": { "node_modules/@sveltejs/kit": {
"version": "2.55.0", "version": "2.69.1",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.55.0.tgz", "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.69.1.tgz",
"integrity": "sha512-MdFRjevVxmAknf2NbaUkDF16jSIzXMWd4Nfah0Qp8TtQVoSp3bV4jKt8mX7z7qTUTWvgSaxtR0EG5WJf53gcuA==", "integrity": "sha512-+nz8Fx/cElzb2ZPHC+6Ll3y3NEVIe4Na75PeplLlyTmd1dBXAjz2KR14y1ZgNjb2ThfAYzulu+PFy1UE3RCUzA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@standard-schema/spec": "^1.0.0", "@standard-schema/spec": "^1.0.0",
"@sveltejs/acorn-typescript": "^1.0.5", "@sveltejs/acorn-typescript": "^1.0.9",
"@types/cookie": "^0.6.0", "@types/cookie": "^0.6.0",
"acorn": "^8.14.1", "acorn": "^8.16.0",
"cookie": "^0.6.0", "cookie": "^0.6.0",
"devalue": "^5.6.4", "devalue": "^5.8.1",
"esm-env": "^1.2.2", "esm-env": "^1.2.2",
"kleur": "^4.1.5", "kleur": "^4.1.5",
"magic-string": "^0.30.5", "magic-string": "^0.30.5",
@@ -1272,7 +1272,7 @@
"@opentelemetry/api": "^1.0.0", "@opentelemetry/api": "^1.0.0",
"@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0",
"svelte": "^4.0.0 || ^5.0.0-next.0", "svelte": "^4.0.0 || ^5.0.0-next.0",
"typescript": "^5.3.3", "typescript": "^5.3.3 || ^6.0.0",
"vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
@@ -1685,19 +1685,6 @@
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@typescript-eslint/types": {
"version": "8.58.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.0.tgz",
"integrity": "sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==",
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@vitest/expect": { "node_modules/@vitest/expect": {
"version": "4.1.9", "version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz",
@@ -2057,9 +2044,9 @@
} }
}, },
"node_modules/devalue": { "node_modules/devalue": {
"version": "5.6.4", "version": "5.8.1",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz", "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz",
"integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==", "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/dijkstrajs": { "node_modules/dijkstrajs": {
@@ -2109,9 +2096,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
"integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
"dev": true, "dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
@@ -2122,32 +2109,32 @@
"node": ">=18" "node": ">=18"
}, },
"optionalDependencies": { "optionalDependencies": {
"@esbuild/aix-ppc64": "0.27.4", "@esbuild/aix-ppc64": "0.28.1",
"@esbuild/android-arm": "0.27.4", "@esbuild/android-arm": "0.28.1",
"@esbuild/android-arm64": "0.27.4", "@esbuild/android-arm64": "0.28.1",
"@esbuild/android-x64": "0.27.4", "@esbuild/android-x64": "0.28.1",
"@esbuild/darwin-arm64": "0.27.4", "@esbuild/darwin-arm64": "0.28.1",
"@esbuild/darwin-x64": "0.27.4", "@esbuild/darwin-x64": "0.28.1",
"@esbuild/freebsd-arm64": "0.27.4", "@esbuild/freebsd-arm64": "0.28.1",
"@esbuild/freebsd-x64": "0.27.4", "@esbuild/freebsd-x64": "0.28.1",
"@esbuild/linux-arm": "0.27.4", "@esbuild/linux-arm": "0.28.1",
"@esbuild/linux-arm64": "0.27.4", "@esbuild/linux-arm64": "0.28.1",
"@esbuild/linux-ia32": "0.27.4", "@esbuild/linux-ia32": "0.28.1",
"@esbuild/linux-loong64": "0.27.4", "@esbuild/linux-loong64": "0.28.1",
"@esbuild/linux-mips64el": "0.27.4", "@esbuild/linux-mips64el": "0.28.1",
"@esbuild/linux-ppc64": "0.27.4", "@esbuild/linux-ppc64": "0.28.1",
"@esbuild/linux-riscv64": "0.27.4", "@esbuild/linux-riscv64": "0.28.1",
"@esbuild/linux-s390x": "0.27.4", "@esbuild/linux-s390x": "0.28.1",
"@esbuild/linux-x64": "0.27.4", "@esbuild/linux-x64": "0.28.1",
"@esbuild/netbsd-arm64": "0.27.4", "@esbuild/netbsd-arm64": "0.28.1",
"@esbuild/netbsd-x64": "0.27.4", "@esbuild/netbsd-x64": "0.28.1",
"@esbuild/openbsd-arm64": "0.27.4", "@esbuild/openbsd-arm64": "0.28.1",
"@esbuild/openbsd-x64": "0.27.4", "@esbuild/openbsd-x64": "0.28.1",
"@esbuild/openharmony-arm64": "0.27.4", "@esbuild/openharmony-arm64": "0.28.1",
"@esbuild/sunos-x64": "0.27.4", "@esbuild/sunos-x64": "0.28.1",
"@esbuild/win32-arm64": "0.27.4", "@esbuild/win32-arm64": "0.28.1",
"@esbuild/win32-ia32": "0.27.4", "@esbuild/win32-ia32": "0.28.1",
"@esbuild/win32-x64": "0.27.4" "@esbuild/win32-x64": "0.28.1"
} }
}, },
"node_modules/esm-env": { "node_modules/esm-env": {
@@ -2157,13 +2144,20 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/esrap": { "node_modules/esrap": {
"version": "2.2.4", "version": "2.2.13",
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.4.tgz", "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.13.tgz",
"integrity": "sha512-suICpxAmZ9A8bzJjEl/+rLJiDKC0X4gYWUxT6URAWBLvlXmtbZd5ySMu/N2ZGEtMCAmflUDPSehrP9BQcsGcSg==", "integrity": "sha512-m8jH5hZgJE2RRUK/jjkGPcJEDAV+dYnZYFkosQaPTcE+Yw4xynXHOo6FUdwaWBtdR3b1MMa7wEDTSHeR2VWsGA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@jridgewell/sourcemap-codec": "^1.4.15", "@jridgewell/sourcemap-codec": "^1.4.15"
},
"peerDependencies": {
"@typescript-eslint/types": "^8.2.0" "@typescript-eslint/types": "^8.2.0"
},
"peerDependenciesMeta": {
"@typescript-eslint/types": {
"optional": true
}
} }
}, },
"node_modules/estree-walker": { "node_modules/estree-walker": {
@@ -2722,9 +2716,9 @@
} }
}, },
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.11", "version": "3.3.15",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -2853,9 +2847,9 @@
} }
}, },
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.8", "version": "8.5.16",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -2873,7 +2867,7 @@
], ],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"nanoid": "^3.3.11", "nanoid": "^3.3.12",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
"source-map-js": "^1.2.1" "source-map-js": "^1.2.1"
}, },
@@ -3138,23 +3132,23 @@
} }
}, },
"node_modules/svelte": { "node_modules/svelte": {
"version": "5.55.1", "version": "5.56.4",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.1.tgz", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.4.tgz",
"integrity": "sha512-QjvU7EFemf6mRzdMGlAFttMWtAAVXrax61SZYHdkD6yoVGQ89VeyKfZD4H1JrV1WLmJBxWhFch9H6ig/87VGjw==", "integrity": "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@jridgewell/remapping": "^2.3.4", "@jridgewell/remapping": "^2.3.4",
"@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/sourcemap-codec": "^1.5.0",
"@sveltejs/acorn-typescript": "^1.0.5", "@sveltejs/acorn-typescript": "^1.0.10",
"@types/estree": "^1.0.5", "@types/estree": "^1.0.5",
"@types/trusted-types": "^2.0.7", "@types/trusted-types": "^2.0.7",
"acorn": "^8.12.1", "acorn": "^8.12.1",
"aria-query": "5.3.1", "aria-query": "5.3.1",
"axobject-query": "^4.1.0", "axobject-query": "^4.1.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"devalue": "^5.6.4", "devalue": "^5.8.1",
"esm-env": "^1.2.1", "esm-env": "^1.2.1",
"esrap": "^2.2.4", "esrap": "^2.2.12",
"is-reference": "^3.0.3", "is-reference": "^3.0.3",
"locate-character": "^3.0.0", "locate-character": "^3.0.0",
"magic-string": "^0.30.11", "magic-string": "^0.30.11",
@@ -3348,13 +3342,13 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "7.3.1", "version": "7.3.6",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"esbuild": "^0.27.0", "esbuild": "^0.27.0 || ^0.28.0",
"fdir": "^6.5.0", "fdir": "^6.5.0",
"picomatch": "^4.0.3", "picomatch": "^4.0.3",
"postcss": "^8.5.6", "postcss": "^8.5.6",

View File

@@ -3,8 +3,8 @@ import { pickMediaUrl } from './data-mode-store';
const upload = { const upload = {
id: 'abc-123', id: 'abc-123',
preview_url: '/media/previews/p.jpg', preview_url: '/api/v1/upload/abc-123/preview',
thumbnail_url: '/media/thumbnails/t.jpg', thumbnail_url: '/api/v1/upload/abc-123/thumbnail',
}; };
describe('pickMediaUrl', () => { describe('pickMediaUrl', () => {
@@ -13,11 +13,13 @@ describe('pickMediaUrl', () => {
}); });
it('saver mode → preview_url when present', () => { it('saver mode → preview_url when present', () => {
expect(pickMediaUrl('saver', upload)).toBe('/media/previews/p.jpg'); expect(pickMediaUrl('saver', upload)).toBe('/api/v1/upload/abc-123/preview');
}); });
it('saver mode → thumbnail_url when preview is null', () => { it('saver mode → thumbnail_url when preview is null', () => {
expect(pickMediaUrl('saver', { ...upload, preview_url: null })).toBe('/media/thumbnails/t.jpg'); expect(pickMediaUrl('saver', { ...upload, preview_url: null })).toBe(
'/api/v1/upload/abc-123/thumbnail'
);
}); });
it('saver mode → original route when both preview and thumbnail are null', () => { it('saver mode → original route when both preview and thumbnail are null', () => {

View File

@@ -109,6 +109,17 @@ export function connectSse(): void {
); );
} }
// `resync` is emitted by the server when our broadcast subscription fell
// behind and events were dropped. Rather than let those losses leave the feed
// stale, fetch the gap since the last event we actually saw and fan it out as
// a feed-delta (which reconciles new uploads AND deletions). Handled with its
// own listener — not via `dispatch` — so reading `lastEventTime` as the gap
// start isn't clobbered by dispatch bumping it to "now".
eventSource.addEventListener('resync', () => {
const since = lastEventTime;
if (since) void deltaFetchAndFan(since);
});
eventSource.onerror = () => { eventSource.onerror = () => {
// EventSource auto-reconnects but the connection state can stay broken; close // EventSource auto-reconnects but the connection state can stay broken; close
// and try again ourselves with exponential backoff capped at 60s. Prevents // and try again ourselves with exponential backoff capped at 60s. Prevents

View File

@@ -33,16 +33,23 @@ async function getDb(): Promise<IDBPDatabase> {
// v1 → v2: add `userId` index so each guest's queue is isolated on shared devices. // v1 → v2: add `userId` index so each guest's queue is isolated on shared devices.
// Pre-existing entries (no userId) are dropped on upgrade; nothing useful was ever // Pre-existing entries (no userId) are dropped on upgrade; nothing useful was ever
// persisted across logouts before this version. // persisted across logouts before this version.
db = await openDB(DB_NAME, 2, { // Version 3 self-heals installs corrupted by a shipped v1→v2 bug: that upgrade
upgrade(database, oldVersion) { // opened a *new* transaction inside the callback, which throws InvalidStateError
if (oldVersion < 1) { // ("A version change transaction is running") and aborts the whole upgrade —
// leaving some browsers at version 2 with NO 'queue' object store (so every queue
// write failed and no upload ever fired). Bumping to 3 re-runs this upgrade for
// those installs; the contains() guard recreates the missing store instead of
// assuming createObjectStore only ever runs on a brand-new DB.
db = await openDB(DB_NAME, 3, {
upgrade(database, oldVersion, _newVersion, transaction) {
if (!database.objectStoreNames.contains(STORE_NAME)) {
database.createObjectStore(STORE_NAME, { keyPath: 'id' }); database.createObjectStore(STORE_NAME, { keyPath: 'id' });
} } else if (oldVersion < 2) {
if (oldVersion < 2) { // Existing v1 store: its entries predate the `userId` field, so drop them
// Wipe any pre-v2 entries — they have no userId field and would belong // rather than misattribute them to whoever is signed in now. Reuse the
// to a now-indeterminate user. Safer to drop than to misattribute. // active version-change transaction (never open a new one here — see above).
const tx = database.transaction(STORE_NAME, 'readwrite'); // Skipped when we just created the store, which is already empty.
tx.objectStore(STORE_NAME).clear(); transaction.objectStore(STORE_NAME).clear();
} }
} }
}); });

View File

@@ -18,9 +18,14 @@
loading = true; loading = true;
error = ''; error = '';
try { try {
const res = await api.post<{ jwt: string }>('/admin/login', { password }); const res = await api.post<{ jwt: string; user_id: string; display_name: string }>(
// Admin sessions have no PIN; pass null so setAuth doesn't overwrite a guest PIN '/admin/login',
setAuth(res.jwt, null, ''); { password }
);
// Admin sessions have no PIN; pass null so setAuth doesn't overwrite a guest PIN.
// Persist the real user id + name so the admin has an identity (own-post
// affordances on the feed, a name on the Account page rather than "Unbekannt").
setAuth(res.jwt, null, res.user_id, res.display_name);
goto('/admin'); goto('/admin');
} catch (e) { } catch (e) {
if (e instanceof ApiError) { if (e instanceof ApiError) {

View File

@@ -2,6 +2,7 @@
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { api } from '$lib/api'; import { api } from '$lib/api';
import { getToken } from '$lib/auth';
import { showBottomNav } from '$lib/ui-store'; import { showBottomNav } from '$lib/ui-store';
import { dataMode, pickMediaUrl } from '$lib/data-mode-store'; import { dataMode, pickMediaUrl } from '$lib/data-mode-store';
import { onSseEvent } from '$lib/sse'; import { onSseEvent } from '$lib/sse';
@@ -151,6 +152,13 @@
} }
onMount(() => { onMount(() => {
// Auth guard — mirror the other protected routes. Without this an
// unauthenticated visitor lands on a permanently-loading empty slideshow
// instead of being sent to /join.
if (!getToken()) {
goto('/join');
return;
}
showBottomNav.set(false); showBottomNav.set(false);
void acquireWakeLock(); void acquireWakeLock();
unsubs.push(onSseEvent('upload-processed', handleUploadProcessed)); unsubs.push(onSseEvent('upload-processed', handleUploadProcessed));

View File

@@ -249,6 +249,11 @@
const dead = new Set(delta.deleted_ids); const dead = new Set(delta.deleted_ids);
uploads = uploads.filter((u) => !dead.has(u.id)); uploads = uploads.filter((u) => !dead.has(u.id));
} }
// A delta reconciles new uploads and deletions, but not like/comment
// counts that changed on already-visible cards while we were
// disconnected or lagged (a `resync`). Debounced page-1 refresh merges
// those fresh counts in place without disturbing scroll.
scheduleInPlaceRefresh();
} catch { /* ignore */ } } catch { /* ignore */ }
}) })
); );

View File

@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { getToken, getRole } from '$lib/auth'; import { getToken, getRole, getUserId } from '$lib/auth';
import { api } from '$lib/api'; import { api } from '$lib/api';
import type { MeContextDto } from '$lib/types'; import type { MeContextDto } from '$lib/types';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
@@ -57,6 +57,7 @@
let pinModal = $state<{ name: string; pin: string } | null>(null); let pinModal = $state<{ name: string; pin: string } | null>(null);
const myRole = getRole(); const myRole = getRole();
const myUserId = getUserId();
// Generic confirm-then-run for the irreversible / privilege-changing actions // Generic confirm-then-run for the irreversible / privilege-changing actions
// (promote, demote, unban, release gallery) that previously fired on one tap. // (promote, demote, unban, release gallery) that previously fired on one tap.
@@ -486,7 +487,11 @@
> >
Entsperren Entsperren
</button> </button>
{:else} {:else if user.id !== myUserId}
<!-- Never render target-actions (promote/demote/PIN/ban) on the
caller's own row: the backend rejects every self-action
(self-ban / self-demote / self-PIN) with a 400, so the button
would only ever fail. -->
{#if user.role === 'guest' && (myRole === 'host' || myRole === 'admin')} {#if user.role === 'guest' && (myRole === 'host' || myRole === 'admin')}
<button <button
onclick={() => (confirmAction = { onclick={() => (confirmAction = {

View File

@@ -1,9 +1,21 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { api, ApiError } from '$lib/api'; import { api, ApiError } from '$lib/api';
import { setAuth } from '$lib/auth'; import { setAuth } from '$lib/auth';
import { focusTrap } from '$lib/actions/focus-trap'; import { focusTrap } from '$lib/actions/focus-trap';
// Show which event the guest is joining (USER_JOURNEYS §1). Public, pre-auth.
let eventName = $state('');
onMount(async () => {
try {
const ev = await api.get<{ name: string; slug: string }>('/event');
eventName = ev.name;
} catch {
// Non-fatal — fall back to the generic heading if the lookup fails.
}
});
let displayName = $state(''); let displayName = $state('');
let error = $state(''); let error = $state('');
let loading = $state(false); let loading = $state(false);
@@ -164,6 +176,9 @@
{:else} {:else}
<!-- Normal join form --> <!-- Normal join form -->
<h1 class="mb-2 text-center text-2xl font-bold text-gray-900 dark:text-gray-100">Willkommen!</h1> <h1 class="mb-2 text-center text-2xl font-bold text-gray-900 dark:text-gray-100">Willkommen!</h1>
{#if eventName}
<p class="mb-1 text-center text-lg font-semibold text-blue-600 dark:text-blue-400" data-testid="join-event-name">{eventName}</p>
{/if}
<p class="mb-6 text-center text-gray-600 dark:text-gray-400">Gib deinen Namen ein, um dem Event beizutreten.</p> <p class="mb-6 text-center text-gray-600 dark:text-gray-400">Gib deinen Namen ein, um dem Event beizutreten.</p>
<form onsubmit={(e) => { e.preventDefault(); handleJoin(); }}> <form onsubmit={(e) => { e.preventDefault(); handleJoin(); }}>