Second round: the two regressions the first round introduced, the remaining
gaps it left, and the CI change that makes the iOS guarantees actually gate a
change rather than being asserted.
Squashed from 9 commits, original messages preserved below.
──────── docs(deploy): document the update path — `up -d` alone ships nothing
The README only ever described a fresh install. There was no update section
anywhere, and `--build` appeared nowhere in the docs.
That matters because `app` and `frontend` are `build:` services with no published
image tag, and Compose has no source-change detection: if an image by that name
exists it is reused. So the natural `git pull && docker compose up -d` reports
"Container app-1 Running", rebuilds nothing, and exits 0. A deploy that shipped
none of the new code is indistinguishable from a successful one — which is how
eleven merged fixes can sit in the repo and never reach the box.
Verified both halves against a real stack rather than asserting them: with a
source change staged, `up -d` left the image ID untouched; `up -d --build`
produced a new image ID and a healthy /health.
Adds an "Updating an existing deployment" section covering backup-before-migrate,
pull, rebuild, health check, and an image-ID comparison to prove a build actually
happened. Also spells out the rollback trap: migrations run on boot and are not
undone by checking out an older commit, so rolling back code without restoring
the snapshot leaves the schema ahead of the binary and the app refusing to start.
──────── fix(video): play the actual video, and answer Range requests
Every video in the app was unplayable. Two independent defects, either one
sufficient on its own, and nothing in the suite covered either — no test
anywhere played media or asserted a `<video>` src.
1. The lightbox handed `<video>` a JPEG.
`pickMediaUrl` is mime-agnostic, and compression only ever produces a THUMBNAIL
for a video (one `ffmpeg -vframes 1` frame) — no preview, no display. So in the
DEFAULT saver mode the element's src resolved to `/api/v1/upload/{id}/thumbnail`,
served as `image/jpeg` with `nosniff` so the browser can't even sniff its way
out. Chromium reports DEMUXER_ERROR_COULD_NOT_OPEN.
Fixed in the lightbox rather than in `pickMediaUrl`: FeedListCard shares that
helper and legitimately wants the thumbnail for its `<img>` poster, so a central
mime branch would break the feed. This mirrors the rule the diashow already
applies ("videos play the original file directly"). Added `preload="none"` so
saver-mode guests on cellular still fetch nothing until they press play — there
is no smaller video derivative to offer them — plus `playsinline`, without which
iOS hijacks playback into fullscreen.
2. `stream_media_file` ignored Range entirely.
It took no request headers, so it could not see `Range`; it always returned 200
with the whole body and never sent Accept-Ranges or Content-Range. iOS Safari
opens every `<video>` with a `Range: bytes=0-1` probe and abandons the load
without a 206 — so video failed on the app's primary platform even in `original`
mode, where the src was already correct.
Adds single-range support (`bytes=N-`, `bytes=N-M`, `bytes=-S`) with 206 +
Content-Range, 416 + `bytes */len` past EOF, and Accept-Ranges advertised on
every response. Anything it won't handle — multi-range, non-bytes units, garbage
— falls back to a full 200, which RFC 9110 explicitly permits and which is safer
than guessing. All four media routes share the helper, so seeking works
uniformly.
`get_original` now serves `inline` instead of `attachment`. An attachment
disposition is hostile to a `<video>` element, and this route is the only source
of playable video bytes; it also matches what the UI promises, since the action
is labelled "Original anzeigen" — view, not download. `no-store` is deliberately
kept so a takedown still revokes access promptly; ranges work fine under it, the
client just re-fetches.
Tests: 11 unit tests pin the parser (the iOS `bytes=0-1` probe, inclusive ends,
suffix ranges, clamping past EOF, 416 vs 200, malformed fallbacks). A new
03-feed/video-playback spec asserts the src is the original and not the
thumbnail, that the browser accepts the bytes as media (readyState > 0, no
MediaError), that no video bytes are delivered before play, and that Range
returns the correct 206 slices and a 416 past EOF — verified on both Chromium
and WebKit.
The "not downloaded before play" test asserts no *delivered body* rather than no
request: WebKit opens a connection for a preload="none" video and immediately
aborts it (GET, no Range, status 0, nothing transferred) while Chromium issues
nothing at all. The portable guarantee is that no response carrying bytes
completes.
──────── fix(auth): bind the role store to the identity, not to the tab
The role store I added in the moderation work is a module-level singleton seeded
ONCE at import. `goto()` is a client-side navigation, so leaving and re-joining in
the same tab re-imports no module and re-runs no onMount — the previous user's
role simply stayed resident. Nothing reset it: not join, recover, admin login,
"Event verlassen", `clearAuth`, nor the api.ts 401 auto-clear.
So a host who left, followed by a guest joining on the same phone, left that guest
with `isStaff === true` and a "🚫 Beitrag entfernen" action on other people's
photos. The backend 403s the delete, so this was a false affordance rather than a
privilege escalation — but `/feed` never fetched `/me/context`, so unlike every
other route it never self-corrected either. It survived until a hard reload.
The mirror case was equally broken and easier to overlook: a guest who recovered
into a host account got NO host affordances.
`clearAuth` already had a hook registry for exactly this shape of problem, with a
comment explaining it exists to avoid circular imports. Add the missing mirror,
`onSetAuth`, fired by both `setAuth` and `setAdminAuth` after the new token is
resident, and have the role store register on both sides: clear to null on
logout, re-seed from the new token on login. That also gives
`syncRoleFromToken` — dead code with zero callers since I introduced it — its
intended purpose.
Seeding from the claim fixes the reported bug, but the claim is frozen for the
token's 30-day life, so a promotion or demotion still wouldn't reach the feed.
`/feed` now calls the existing `refreshEventState()` on mount, which fetches
`/me/context` and applies both the authoritative role and the lock/release state
in one request. The feed is the one route gating a destructive action on the role,
so it should not be the only route running on a stale claim.
Tests: 04-host/role-identity-reset drives the real flows. The first asserts the
host DOES see the action before asserting the newcomer does not — a negative
assertion alone would pass against a build that shipped no moderation at all. The
second covers the mirror, promoting a guest server-side while their resident token
still claims `role: guest`, so a fix that only cleared the role would fail it.
──────── fix(compression): reclaim failed originals instead of leaking them
Round 1 stopped the compression worker deleting an upload's original on failure —
a transient ENOSPC or a codec panic must never destroy the only copy of a photo a
guest cannot retake. But it left `Upload::soft_delete`'s quota refund in place, so
the bytes stayed on disk while the uploader was charged nothing for them.
That is worse than it first looks. The row is soft-deleted, so the file is
invisible and unowned; a guest hitting a reproducible codec failure can accumulate
orphans indefinitely at zero personal cost. And `active_uploaders` counts only
users with non-deleted uploads, so dropping out of that count RAISES everyone's
per-user ceiling — the leak loosens the very quota meant to contain it.
Keep the refund: the uploader didn't cause the failure and shouldn't silently lose
quota to it. Bound the leak instead, with an hourly sweep alongside the existing
session cleanup in `spawn_periodic_tasks`, reclaiming failed originals older than
14 days — comfortably longer than any single event, so an operator investigating a
failed upload still has the file.
The selection predicate is the entire safety argument, so it is deliberately
narrow: `compression_status = 'failed'` AND soft-deleted AND past the window AND
`original_path <> ''`. That is exactly the state the give-up path leaves behind,
and it cannot reach a live upload, an owner-deleted one, or a failure still inside
its recovery window. `original_path` is cleared after a successful reclaim, which
makes the sweep idempotent — otherwise a row whose file is already gone is
re-selected on every tick forever. The row itself is kept as the audit trail.
Tests reproduce the selection verbatim (same pattern as upload_concurrency) and
assert it against five near-misses that must survive, both sides of the retention
boundary, and the idempotence property.
Also fixes two comments in export.rs still claiming "the compression worker
hard-deletes an original when its transcode fails" — no longer true, and the
defensive handling they justify is now justified by this sweep and by ordinary
deletes instead.
──────── fix(export): apply EXIF orientation in the keepsake too
Round 1 fixed EXIF orientation in the compression worker, which corrected the live
app — feed preview and diashow display. The export worker was missed, and it does
not reuse those derivatives: it re-decodes the originals itself with `image::open`,
which ignores the orientation tag, then re-encodes to JPEG, which drops the tag —
so the viewer has no way to recover it.
The damage was oddly shaped, which is exactly why it reads as a viewer bug:
Gallery.zip originals correct (byte-copied, EXIF intact)
Memories viewer grid thumbnails SIDEWAYS (always)
Memories viewer full image >5 MB SIDEWAYS (re-encoded at 2000px)
Memories viewer full image ≤5 MB correct (streamed byte-for-byte)
So in the keepsake people actually keep, every portrait photo in the grid was on
its side, and clicking through silently "fixed" small photos but not large ones.
Rather than paste the decoder dance a third time, extract `services::imaging::
decode_oriented` and route both workers through it, so there is exactly one way to
turn a file on disk into a DynamicImage. It carries a second invariant that had
also drifted: `image::open` applies NO decode limits, so the export path was
decoding arbitrary user-supplied images unbounded — the decompression-bomb cap
existed only in the compression worker. Both now come as a pair, which is the
point of having one function.
Not done: switching export to consume the existing `display` derivative. It would
fix orientation and drop a redundant full-resolution decode per photo, but it
would also replace the pristine ≤5 MB originals in the keepsake with 2048px
re-encodes — a real quality regression in the one artefact people keep forever.
Test uploads the round-1 fixture (40x20 landscape tagged Orientation=6), runs a
real export, pulls the thumbnail out of Memories.zip and asserts it came back
portrait — with a sanity check that the source really is stored landscape, so the
test can't pass against a pipeline that does nothing.
──────── fix(recover): cap name cycling, and stop bcrypt blocking the runtime
Round 1 gave /join a per-IP ceiling and left /recover with only its
`recover:{ip}:{name}` bucket. That key is right for the job it was written for —
stopping someone who knows a display name (they're listed on the feed) from
burning the victim's 3-strike PIN counter and locking them out on repeat. But the
name is ATTACKER-CHOSEN, so cycling names mints a fresh 5-attempt bucket every
time and the per-IP cost is unbounded.
What sits behind that limiter makes it worse than a normal flood: every call runs
a cost-12 bcrypt verify, including an UNCONDITIONAL throwaway verify for names
that don't exist — added deliberately to close a timing oracle. So an unknown name
is the single cheapest way to make the server do ~200ms of hashing.
Adds `recover_ip_rate_per_min` (default 30, migration 019), checked BEFORE the
per-name bucket so a name generator can't walk past it. 30/min is far above any
real recovery attempt while capping a flood. The per-name bucket is untouched and
remains the anti-guessing control.
The second half matters as much as the first: bcrypt was running inline on the
async runtime everywhere. At cost 12 that pins a tokio worker thread for ~200ms,
and there is only one per core — so a login flood stalled every other request on
the box, including the feed. There was no spawn_blocking anywhere in the auth
module, despite SECURITY-BACKLOG claiming bcrypt had been offloaded.
Route all of it through `verify_password` / `hash_password` on the blocking pool.
That covers /recover, /admin/login, the host PIN reset, and — the one most likely
to bite at a real event — the PIN hash minted on every single /join. Saturating
the blocking pool degrades logins; saturating the worker threads degrades
everything.
Tests: cycling distinct names from one IP now hits the ceiling with a Retry-After,
and — the assertion that keeps the fix honest — repeated wrong PINs against ONE
name are still throttled with the ceiling set generously high, so the ceiling
added protection rather than replacing it.
──────── ci(e2e): run WebKit, so the iOS guarantees actually gate a PR
The workflow installed only Chromium and ran chromium-desktop + chromium-mobile.
iOS Safari is the app's stated primary user — a wedding guest opening a QR link —
and WebKit is the only engine in the matrix that reproduces two of its behaviours:
- it enforces X-Frame-Options on the hidden download iframe, so a site-wide DENY
makes the keepsake download silently do nothing. Blink hands attachments to
the download manager before the frame check and never notices.
- it abandons a <video> load unless its Range probe gets a 206.
Both of those shipped. Adding 06-export to the webkit project in the round-1 fix
bought nothing on a PR, because CI never ran that project at all — the regression
test written specifically to catch the blocker only ever executed locally.
Runs 71 tests (67 pass, 4 skip on the documented IndexedDB-blob harness
limitation) in ~1.5 minutes locally, using the exact command added here.
──────── chore(backend): satisfy cargo fmt
`checks.yml` runs `cargo fmt --check`, and it has been failing since the round-1
audit fixes: I gated those on `cargo build` and `cargo clippy` but never ran fmt,
so three files drifted then and eight more this round. Pure formatting — no
behaviour change; clippy stays at zero and all 70 backend tests still pass.
Worth noting for next time: clippy passing is not evidence fmt does.
──────── chore: satisfy prettier in frontend and e2e
`checks.yml` runs `npm run format:check` for both projects and both were failing.
- frontend/src/lib/ui-store.ts is mine, unformatted since the round-1 upload-queue
badge fix — the same miss as the rustfmt one: I gated on svelte-check and eslint
but never on format:check.
- e2e/loadtest/* and e2e/shots.mjs have been unformatted since 7758270 and are
unrelated to the audit work. Fixed here because they block the same gate and the
fix is mechanical; no behaviour change in either project.
Still red and deliberately NOT fixed here: `npm run lint` in the frontend reports
`svelte/prefer-svelte-reactivity` on routes/diashow/+page.svelte:208 (a mutable
`Set` where the rule wants `SvelteSet`), pre-existing since 5009590. That one is a
real reactivity change in code I have no test coverage for, so it belongs in its
own change rather than smuggled into a formatting commit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
768 lines
28 KiB
Rust
768 lines
28 KiB
Rust
use axum::Json;
|
|
use axum::extract::{Path, State};
|
|
use axum::http::StatusCode;
|
|
use chrono::{DateTime, Utc};
|
|
use serde::{Deserialize, Serialize};
|
|
use uuid::Uuid;
|
|
|
|
use crate::auth::middleware::RequireHost;
|
|
use crate::error::AppError;
|
|
use crate::models::comment::Comment;
|
|
use crate::models::event::Event;
|
|
use crate::models::session::Session;
|
|
use crate::models::upload::Upload;
|
|
use crate::models::user::UserRole;
|
|
use crate::services::export::Affects;
|
|
use crate::state::{AppState, SseEvent};
|
|
|
|
// ── DTOs ─────────────────────────────────────────────────────────────────────
|
|
|
|
#[derive(Serialize, sqlx::FromRow)]
|
|
pub struct UserSummary {
|
|
pub id: Uuid,
|
|
pub display_name: String,
|
|
pub role: String,
|
|
pub is_banned: bool,
|
|
pub uploads_hidden: bool,
|
|
pub upload_count: i64,
|
|
pub total_upload_bytes: i64,
|
|
pub created_at: DateTime<Utc>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct EventStatus {
|
|
pub name: String,
|
|
pub is_active: bool,
|
|
pub uploads_locked: bool,
|
|
pub export_released: bool,
|
|
}
|
|
|
|
/// Count non-banned hosts/admins in the event OTHER than `excluding` — the operators
|
|
/// who would remain if `excluding` were demoted or banned. Used to enforce the "an event
|
|
/// always keeps at least one operator" floor.
|
|
async fn remaining_operators(
|
|
state: &AppState,
|
|
event_id: Uuid,
|
|
excluding: Uuid,
|
|
) -> Result<i64, AppError> {
|
|
let count = sqlx::query_scalar::<_, i64>(
|
|
"SELECT COUNT(*) FROM \"user\"
|
|
WHERE event_id = $1 AND id != $2
|
|
AND role IN ('host', 'admin') AND is_banned = FALSE",
|
|
)
|
|
.bind(event_id)
|
|
.bind(excluding)
|
|
.fetch_one(&state.pool)
|
|
.await?;
|
|
Ok(count)
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct SetRoleRequest {
|
|
pub role: String,
|
|
}
|
|
|
|
// ── Handlers ─────────────────────────────────────────────────────────────────
|
|
|
|
pub async fn get_event_status(
|
|
State(state): State<AppState>,
|
|
RequireHost(_auth): RequireHost,
|
|
) -> Result<Json<EventStatus>, AppError> {
|
|
let event = Event::find_by_slug(&state.pool, &state.config.event_slug)
|
|
.await?
|
|
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
|
|
|
|
Ok(Json(EventStatus {
|
|
name: event.name,
|
|
is_active: event.is_active,
|
|
uploads_locked: event.uploads_locked_at.is_some(),
|
|
export_released: event.export_released_at.is_some(),
|
|
}))
|
|
}
|
|
|
|
pub async fn list_users(
|
|
State(state): State<AppState>,
|
|
RequireHost(auth): RequireHost,
|
|
) -> Result<Json<Vec<UserSummary>>, AppError> {
|
|
let rows = sqlx::query_as::<_, UserSummary>(
|
|
"SELECT u.id,
|
|
u.display_name,
|
|
u.role::text AS role,
|
|
u.is_banned,
|
|
u.uploads_hidden,
|
|
COALESCE(COUNT(up.id), 0) AS upload_count,
|
|
u.total_upload_bytes,
|
|
u.created_at
|
|
FROM \"user\" u
|
|
LEFT JOIN upload up ON up.user_id = u.id AND up.deleted_at IS NULL
|
|
WHERE u.event_id = $1
|
|
GROUP BY u.id
|
|
ORDER BY u.created_at ASC",
|
|
)
|
|
.bind(auth.event_id)
|
|
.fetch_all(&state.pool)
|
|
.await?;
|
|
|
|
Ok(Json(rows))
|
|
}
|
|
|
|
pub async fn ban_user(
|
|
State(state): State<AppState>,
|
|
RequireHost(auth): RequireHost,
|
|
Path(user_id): Path<Uuid>,
|
|
) -> Result<StatusCode, AppError> {
|
|
// The ban request carries no body — ban always hides (no per-request options).
|
|
// Cannot ban yourself or another host/admin
|
|
if user_id == auth.user_id {
|
|
return Err(AppError::BadRequest(
|
|
"Du kannst dich nicht selbst sperren.".into(),
|
|
));
|
|
}
|
|
let target = sqlx::query_as::<_, (String,)>(
|
|
"SELECT role::text FROM \"user\" WHERE id = $1 AND event_id = $2",
|
|
)
|
|
.bind(user_id)
|
|
.bind(auth.event_id)
|
|
.fetch_optional(&state.pool)
|
|
.await?
|
|
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
|
|
|
|
if target.0 == "admin"
|
|
|| (target.0 == "host" && auth.role != crate::models::user::UserRole::Admin)
|
|
{
|
|
return Err(AppError::Forbidden(
|
|
"Du kannst diesen Benutzer nicht sperren.".into(),
|
|
));
|
|
}
|
|
|
|
// Floor: never leave the event with zero operators. Banning removes the target from
|
|
// the active-operator pool, so refuse if they're the last non-banned host/admin.
|
|
if target.0 == "host" && remaining_operators(&state, auth.event_id, user_id).await? == 0 {
|
|
return Err(AppError::BadRequest(
|
|
"Der letzte Host kann nicht gesperrt werden.".into(),
|
|
));
|
|
}
|
|
|
|
// Ban ALWAYS hides: a banned user's content is "gone" everywhere. The visibility
|
|
// views/queries now also filter on `is_banned` (defense in depth), and we set
|
|
// `uploads_hidden` so the existing `user-hidden` live-eviction path fires too. The old
|
|
// opt-out checkbox is gone — `hide_uploads` in the request is ignored.
|
|
//
|
|
// We deliberately do NOT revoke the banned user's sessions. This is a *read-only ban*
|
|
// by design (USER_JOURNEYS §10.3): the user keeps read access to the feed and can still
|
|
// download the released export — writes and host/admin actions are what the ban blocks
|
|
// (enforced live on the write handlers + Require{Host,Admin}). Revoking sessions would
|
|
// contradict that model, break the documented "banned guest can still download the
|
|
// keepsake" flow, and be ineffective anyway (the user could just /recover a new session).
|
|
//
|
|
// The ban and the keepsake invalidation are ONE transaction — see `host_delete_upload`.
|
|
let mut tx = state.pool.begin().await?;
|
|
sqlx::query(
|
|
"UPDATE \"user\"
|
|
SET is_banned = TRUE, uploads_hidden = TRUE, uploads_hidden_at = NOW()
|
|
WHERE id = $1 AND event_id = $2",
|
|
)
|
|
.bind(user_id)
|
|
.bind(auth.event_id)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
// A ban hides the user's uploads EVERYWHERE — and the keepsake is the place that matters most,
|
|
// because it is the copy people keep. The export already filters `is_banned = FALSE`, so a
|
|
// FUTURE export excludes them; without this, an ALREADY-RELEASED archive would keep serving a
|
|
// banned user's photos forever. Same class as a takedown, so same treatment.
|
|
let regen = crate::services::export::invalidate_and_arm(
|
|
&mut tx,
|
|
&state.config.event_slug,
|
|
Affects::Both,
|
|
)
|
|
.await?;
|
|
tx.commit().await?;
|
|
if let Some(r) = regen {
|
|
start_regen(&state, r);
|
|
}
|
|
|
|
// Evict their content live from every feed + the diashow so it disappears without
|
|
// each viewer having to reload. (Their own SSE stream is separately dropped by the
|
|
// is_banned revalidation in `sse::stream`, so they stop receiving live pushes while
|
|
// retaining plain read access.)
|
|
let _ = state.sse_tx.send(SseEvent::new(
|
|
"user-hidden",
|
|
serde_json::json!({ "user_id": user_id }).to_string(),
|
|
));
|
|
|
|
tracing::info!(
|
|
actor_user_id = %auth.user_id,
|
|
target_user_id = %user_id,
|
|
event_id = %auth.event_id,
|
|
"host: ban_user"
|
|
);
|
|
|
|
Ok(StatusCode::NO_CONTENT)
|
|
}
|
|
|
|
pub async fn unban_user(
|
|
State(state): State<AppState>,
|
|
RequireHost(auth): RequireHost,
|
|
Path(user_id): Path<Uuid>,
|
|
) -> Result<StatusCode, AppError> {
|
|
// Mirror the ban guard: a host may only lift bans on guests, never on hosts or
|
|
// admins. Without this a host could override an admin's ban of another host,
|
|
// which is asymmetric with `ban_user` and lets a host escalate a peer back in.
|
|
let target = sqlx::query_as::<_, (String,)>(
|
|
"SELECT role::text FROM \"user\" WHERE id = $1 AND event_id = $2",
|
|
)
|
|
.bind(user_id)
|
|
.bind(auth.event_id)
|
|
.fetch_optional(&state.pool)
|
|
.await?
|
|
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
|
|
|
|
if target.0 == "admin"
|
|
|| (target.0 == "host" && auth.role != crate::models::user::UserRole::Admin)
|
|
{
|
|
return Err(AppError::Forbidden(
|
|
"Du kannst diesen Benutzer nicht entsperren.".into(),
|
|
));
|
|
}
|
|
|
|
// Unban restores visibility too: ban set `uploads_hidden = TRUE`, so clearing only
|
|
// `is_banned` would leave their content invisible. Clear all three (the timestamp too,
|
|
// so a future ban stamps a fresh `uploads_hidden_at` the reconnect delta will replay).
|
|
let mut tx = state.pool.begin().await?;
|
|
let result = sqlx::query(
|
|
"UPDATE \"user\"
|
|
SET is_banned = FALSE, uploads_hidden = FALSE, uploads_hidden_at = NULL
|
|
WHERE id = $1 AND event_id = $2",
|
|
)
|
|
.bind(user_id)
|
|
.bind(auth.event_id)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
if result.rows_affected() == 0 {
|
|
return Err(AppError::NotFound("Benutzer nicht gefunden.".into()));
|
|
}
|
|
|
|
// The mirror of the ban case: an unban RESTORES their uploads to the export query, so an
|
|
// already-released keepsake is now missing content it should contain. Rebuild it.
|
|
let regen = crate::services::export::invalidate_and_arm(
|
|
&mut tx,
|
|
&state.config.event_slug,
|
|
Affects::Both,
|
|
)
|
|
.await?;
|
|
tx.commit().await?;
|
|
if let Some(r) = regen {
|
|
start_regen(&state, r);
|
|
}
|
|
|
|
tracing::info!(
|
|
actor_user_id = %auth.user_id,
|
|
target_user_id = %user_id,
|
|
event_id = %auth.event_id,
|
|
"host: unban_user"
|
|
);
|
|
Ok(StatusCode::NO_CONTENT)
|
|
}
|
|
|
|
/// Force a keepsake rebuild. The ESCAPE HATCH.
|
|
///
|
|
/// Without this, a failed or stranded export is terminal at runtime: `release_gallery` refuses an
|
|
/// already-released event ("bereits freigegeben"), `recover_exports` only runs at boot, and there is
|
|
/// no other retry path — so the host's only options were restarting the container or reopening the
|
|
/// event (which unlocks uploads to every guest and discards the release). This is also the recovery
|
|
/// path for a keepsake that went stale for any reason we haven't thought of.
|
|
pub async fn rebuild_export(
|
|
State(state): State<AppState>,
|
|
RequireHost(auth): RequireHost,
|
|
) -> Result<StatusCode, AppError> {
|
|
let mut tx = state.pool.begin().await?;
|
|
let regen = crate::services::export::invalidate_and_arm(
|
|
&mut tx,
|
|
&state.config.event_slug,
|
|
Affects::Both,
|
|
)
|
|
.await?;
|
|
tx.commit().await?;
|
|
|
|
let Some(r) = regen else {
|
|
return Err(AppError::BadRequest(
|
|
"Die Galerie ist nicht freigegeben — es gibt nichts neu zu erzeugen.".into(),
|
|
));
|
|
};
|
|
|
|
// No debounce: this is an explicit, deliberate host action, not a burst.
|
|
for export_type in ["zip", "html"] {
|
|
let _ = state.sse_tx.send(SseEvent::new(
|
|
"export-progress",
|
|
serde_json::json!({ "type": export_type, "progress_pct": 0 }).to_string(),
|
|
));
|
|
}
|
|
crate::services::export::spawn_export_jobs(
|
|
r.event_id,
|
|
r.event_name,
|
|
r.epoch,
|
|
state.config.comments_enabled,
|
|
std::time::Duration::ZERO,
|
|
state.pool.clone(),
|
|
state.config.media_path.clone(),
|
|
state.config.export_path.clone(),
|
|
state.sse_tx.clone(),
|
|
);
|
|
|
|
tracing::info!(actor_user_id = %auth.user_id, "host: rebuild_export");
|
|
Ok(StatusCode::NO_CONTENT)
|
|
}
|
|
|
|
pub async fn set_role(
|
|
State(state): State<AppState>,
|
|
RequireHost(auth): RequireHost,
|
|
Path(user_id): Path<Uuid>,
|
|
Json(body): Json<SetRoleRequest>,
|
|
) -> Result<StatusCode, AppError> {
|
|
if user_id == auth.user_id {
|
|
return Err(AppError::BadRequest(
|
|
"Du kannst deine eigene Rolle nicht ändern.".into(),
|
|
));
|
|
}
|
|
let new_role = match body.role.as_str() {
|
|
"guest" => "guest",
|
|
"host" => "host",
|
|
_ => {
|
|
return Err(AppError::BadRequest(
|
|
"Ungültige Rolle. Erlaubt: guest, host.".into(),
|
|
));
|
|
}
|
|
};
|
|
|
|
// Look up the current role so we can apply the host-vs-admin guard. A plain host may
|
|
// promote/demote GUESTS only; it may not change any host's or admin's role (see the
|
|
// guard below — this closes the demote-a-peer-host→ban/PIN-reset takeover chain, F1).
|
|
// Only an admin may change a host's role. Admins may do anything except change
|
|
// themselves (blocked above).
|
|
let target = sqlx::query_as::<_, (String,)>(
|
|
"SELECT role::text FROM \"user\" WHERE id = $1 AND event_id = $2",
|
|
)
|
|
.bind(user_id)
|
|
.bind(auth.event_id)
|
|
.fetch_optional(&state.pool)
|
|
.await?
|
|
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
|
|
|
|
// 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(
|
|
"Du darfst die Rolle dieses Benutzers nicht ändern.".into(),
|
|
));
|
|
}
|
|
|
|
// Floor: demoting the last non-banned host/admin to guest would leave the event with
|
|
// no operator. Refuse.
|
|
if new_role == "guest"
|
|
&& target.0 == "host"
|
|
&& remaining_operators(&state, auth.event_id, user_id).await? == 0
|
|
{
|
|
return Err(AppError::BadRequest(
|
|
"Der letzte Host kann nicht zum Gast gemacht werden.".into(),
|
|
));
|
|
}
|
|
|
|
sqlx::query("UPDATE \"user\" SET role = $2::user_role WHERE id = $1 AND event_id = $3")
|
|
.bind(user_id)
|
|
.bind(new_role)
|
|
.bind(auth.event_id)
|
|
.execute(&state.pool)
|
|
.await?;
|
|
tracing::info!(
|
|
actor_user_id = %auth.user_id,
|
|
target_user_id = %user_id,
|
|
event_id = %auth.event_id,
|
|
old_role = %target.0,
|
|
new_role,
|
|
"host: set_role"
|
|
);
|
|
Ok(StatusCode::NO_CONTENT)
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct PinResetResponse {
|
|
/// Plaintext PIN — shown to the operator **once**. Never persisted client-side.
|
|
pub pin: String,
|
|
}
|
|
|
|
/// Generate a fresh PIN for another user, returning the plaintext exactly once.
|
|
///
|
|
/// Authorisation:
|
|
/// - Host caller → may reset **guest** PINs only.
|
|
/// - Admin caller → may reset **guest** and **host** PINs (never another admin).
|
|
/// - Target ≠ caller.
|
|
pub async fn reset_user_pin(
|
|
State(state): State<AppState>,
|
|
RequireHost(auth): RequireHost,
|
|
Path(user_id): Path<Uuid>,
|
|
) -> Result<Json<PinResetResponse>, AppError> {
|
|
use rand::Rng;
|
|
|
|
if user_id == auth.user_id {
|
|
return Err(AppError::BadRequest(
|
|
"Du kannst deine eigene PIN nicht über diese Funktion zurücksetzen.".into(),
|
|
));
|
|
}
|
|
|
|
let target = sqlx::query_as::<_, (String,)>(
|
|
"SELECT role::text FROM \"user\" WHERE id = $1 AND event_id = $2",
|
|
)
|
|
.bind(user_id)
|
|
.bind(auth.event_id)
|
|
.fetch_optional(&state.pool)
|
|
.await?
|
|
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
|
|
|
|
match (auth.role.clone(), target.0.as_str()) {
|
|
(UserRole::Admin, "guest" | "host") => {}
|
|
(UserRole::Host, "guest") => {}
|
|
_ => {
|
|
return Err(AppError::Forbidden(
|
|
"Du darfst die PIN dieses Benutzers nicht zurücksetzen.".into(),
|
|
));
|
|
}
|
|
}
|
|
|
|
let pin: String = format!("{:04}", rand::rng().random_range(0..10000u32));
|
|
let pin_hash = crate::auth::handlers::hash_password(pin.clone(), 12).await?;
|
|
|
|
sqlx::query(
|
|
"UPDATE \"user\"
|
|
SET recovery_pin_hash = $1,
|
|
failed_pin_attempts = 0,
|
|
pin_locked_until = NULL
|
|
WHERE id = $2 AND event_id = $3",
|
|
)
|
|
.bind(&pin_hash)
|
|
.bind(user_id)
|
|
.bind(auth.event_id)
|
|
.execute(&state.pool)
|
|
.await?;
|
|
|
|
// A PIN reset means the old credential is compromised/forgotten — revoke every
|
|
// existing session so old devices must re-authenticate with the new PIN. This is a
|
|
// security-relevant revoke: if it fails, the old sessions stay valid (sessions are
|
|
// token- not PIN-bound), so surface the error in logs rather than swallowing it
|
|
// silently while reporting success to the host.
|
|
if let Err(e) = Session::delete_all_for_user(&state.pool, user_id).await {
|
|
tracing::error!(error = ?e, user_id = %user_id, "PIN reset: failed to revoke sessions");
|
|
}
|
|
|
|
// Resolve any pending in-app "I forgot my PIN" request for this user.
|
|
let _ = sqlx::query("DELETE FROM pin_reset_request WHERE user_id = $1")
|
|
.bind(user_id)
|
|
.execute(&state.pool)
|
|
.await;
|
|
|
|
// Notify the *recipient* device(s) if they happen to be online so they can clear
|
|
// their cached local PIN. They'll save the new one on the next /recover.
|
|
let _ = state.sse_tx.send(SseEvent::new(
|
|
"pin-reset",
|
|
serde_json::json!({ "user_id": user_id }).to_string(),
|
|
));
|
|
|
|
tracing::info!(
|
|
actor_user_id = %auth.user_id,
|
|
target_user_id = %user_id,
|
|
event_id = %auth.event_id,
|
|
"host: reset_user_pin"
|
|
);
|
|
|
|
Ok(Json(PinResetResponse { pin }))
|
|
}
|
|
|
|
#[derive(Serialize, sqlx::FromRow)]
|
|
pub struct PinResetRequestSummary {
|
|
pub id: Uuid,
|
|
pub user_id: Uuid,
|
|
pub display_name: String,
|
|
pub created_at: DateTime<Utc>,
|
|
}
|
|
|
|
/// List pending in-app PIN-reset requests so a host can action them (via the existing
|
|
/// `reset_user_pin`, which also clears the request).
|
|
pub async fn list_pin_reset_requests(
|
|
State(state): State<AppState>,
|
|
RequireHost(auth): RequireHost,
|
|
) -> Result<Json<Vec<PinResetRequestSummary>>, AppError> {
|
|
let rows = sqlx::query_as::<_, PinResetRequestSummary>(
|
|
"SELECT r.id, r.user_id, u.display_name, r.created_at
|
|
FROM pin_reset_request r
|
|
JOIN \"user\" u ON u.id = r.user_id
|
|
WHERE r.event_id = $1
|
|
ORDER BY r.created_at ASC",
|
|
)
|
|
.bind(auth.event_id)
|
|
.fetch_all(&state.pool)
|
|
.await?;
|
|
Ok(Json(rows))
|
|
}
|
|
|
|
/// Dismiss a PIN-reset request without resetting (e.g. the host couldn't verify the
|
|
/// requester's identity).
|
|
pub async fn dismiss_pin_reset_request(
|
|
State(state): State<AppState>,
|
|
RequireHost(auth): RequireHost,
|
|
Path(id): Path<Uuid>,
|
|
) -> Result<StatusCode, AppError> {
|
|
sqlx::query("DELETE FROM pin_reset_request WHERE id = $1 AND event_id = $2")
|
|
.bind(id)
|
|
.bind(auth.event_id)
|
|
.execute(&state.pool)
|
|
.await?;
|
|
Ok(StatusCode::NO_CONTENT)
|
|
}
|
|
|
|
/// Content changed AFTER the gallery was released — regenerate the keepsake.
|
|
///
|
|
/// Deleting a photo used to remove it from the live feed but leave it in the already-generated
|
|
/// archive FOREVER: the old `if ready { continue }` skip guaranteed the export was never rebuilt.
|
|
/// For a takedown ("please remove my photo") that is the one place it most needs to disappear.
|
|
///
|
|
/// Bumping the epoch (while staying released) retires the current generation, so the stale archive
|
|
/// stops being downloadable the instant the delete commits, and a fresh worker rebuilds it without
|
|
/// the removed content. The download 404s in the meantime, which is the correct answer — serving
|
|
/// the old archive would serve the deleted photo.
|
|
/// Start the workers for a regeneration that was armed inside a (now-committed) transaction, and
|
|
/// tell every client the current keepsake just became undownloadable.
|
|
///
|
|
/// The SSE matters: bumping the epoch retires the archive INSTANTLY, so `/export/zip` starts 404ing
|
|
/// the moment the change commits. Without a nudge, a guest sitting on `/export` keeps rendering an
|
|
/// enabled "download" button for the whole rebuild. Both the nav badge and the export page already
|
|
/// refetch `/export/status` on `export-progress`, so a 0% tick is the cheapest correct signal.
|
|
pub fn start_regen(state: &AppState, regen: crate::services::export::PendingRegen) {
|
|
for export_type in ["zip", "html"] {
|
|
let _ = state.sse_tx.send(SseEvent::new(
|
|
"export-progress",
|
|
serde_json::json!({ "type": export_type, "progress_pct": 0 }).to_string(),
|
|
));
|
|
}
|
|
crate::services::export::spawn_export_jobs(
|
|
regen.event_id,
|
|
regen.event_name,
|
|
regen.epoch,
|
|
state.config.comments_enabled,
|
|
// Debounced: a takedown pass is a burst, and each request retires the last generation. The
|
|
// delay lets superseded workers fail their claim and do zero work instead of each building
|
|
// a full archive. See export::REGEN_DEBOUNCE.
|
|
crate::services::export::REGEN_DEBOUNCE,
|
|
state.pool.clone(),
|
|
state.config.media_path.clone(),
|
|
state.config.export_path.clone(),
|
|
state.sse_tx.clone(),
|
|
);
|
|
}
|
|
|
|
pub async fn host_delete_upload(
|
|
State(state): State<AppState>,
|
|
RequireHost(auth): RequireHost,
|
|
Path(upload_id): Path<Uuid>,
|
|
) -> Result<StatusCode, AppError> {
|
|
let upload = Upload::find_by_id_and_event(&state.pool, upload_id, auth.event_id)
|
|
.await?
|
|
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
|
|
|
|
// The delete and the keepsake invalidation are ONE transaction: if the delete committed and the
|
|
// invalidation didn't, the taken-down photo would stay downloadable forever and nothing would
|
|
// notice (the keepsake still looks complete, and the host can no longer find the upload to retry).
|
|
let mut tx = state.pool.begin().await?;
|
|
let deleted = Upload::soft_delete_in_event(&mut tx, upload_id, auth.event_id).await?;
|
|
if !deleted {
|
|
return Err(AppError::NotFound("Upload nicht gefunden.".into()));
|
|
}
|
|
let regen = crate::services::export::invalidate_and_arm(
|
|
&mut tx,
|
|
&state.config.event_slug,
|
|
Affects::Both,
|
|
)
|
|
.await?;
|
|
tx.commit().await?;
|
|
|
|
let _ = state.sse_tx.send(SseEvent::new(
|
|
"upload-deleted",
|
|
serde_json::json!({ "upload_id": upload.id }).to_string(),
|
|
));
|
|
if let Some(r) = regen {
|
|
start_regen(&state, r);
|
|
}
|
|
|
|
tracing::info!(
|
|
actor_user_id = %auth.user_id,
|
|
event_id = %auth.event_id,
|
|
upload_id = %upload.id,
|
|
"host: host_delete_upload"
|
|
);
|
|
|
|
Ok(StatusCode::NO_CONTENT)
|
|
}
|
|
|
|
pub async fn host_delete_comment(
|
|
State(state): State<AppState>,
|
|
RequireHost(auth): RequireHost,
|
|
Path(comment_id): Path<Uuid>,
|
|
) -> Result<StatusCode, AppError> {
|
|
let mut tx = state.pool.begin().await?;
|
|
let deleted = Comment::soft_delete_in_event(&mut tx, comment_id, auth.event_id).await?;
|
|
if !deleted {
|
|
return Err(AppError::NotFound("Kommentar nicht gefunden.".into()));
|
|
}
|
|
// Only the HTML viewer embeds comments — the ZIP is media-only, so it is carried forward rather
|
|
// than rebuilt. Otherwise moderating one comment would 404 the photo download for minutes to
|
|
// change nothing in it.
|
|
let regen = crate::services::export::invalidate_and_arm(
|
|
&mut tx,
|
|
&state.config.event_slug,
|
|
Affects::ViewerOnly,
|
|
)
|
|
.await?;
|
|
tx.commit().await?;
|
|
|
|
let _ = state.sse_tx.send(SseEvent::new(
|
|
"comment-deleted",
|
|
serde_json::json!({ "comment_id": comment_id }).to_string(),
|
|
));
|
|
if let Some(r) = regen {
|
|
start_regen(&state, r);
|
|
}
|
|
tracing::info!(
|
|
actor_user_id = %auth.user_id,
|
|
event_id = %auth.event_id,
|
|
comment_id = %comment_id,
|
|
"host: host_delete_comment"
|
|
);
|
|
Ok(StatusCode::NO_CONTENT)
|
|
}
|
|
|
|
pub async fn close_event(
|
|
State(state): State<AppState>,
|
|
RequireHost(_auth): RequireHost,
|
|
) -> Result<StatusCode, AppError> {
|
|
let result = sqlx::query(
|
|
"UPDATE event SET uploads_locked_at = NOW() WHERE slug = $1 AND uploads_locked_at IS NULL",
|
|
)
|
|
.bind(&state.config.event_slug)
|
|
.execute(&state.pool)
|
|
.await?;
|
|
|
|
// Only broadcast when this call actually flipped the lock — closing an
|
|
// already-closed event is a no-op and shouldn't spam listeners.
|
|
if result.rows_affected() > 0 {
|
|
let _ = state.sse_tx.send(SseEvent::new("event-closed", "{}"));
|
|
}
|
|
|
|
Ok(StatusCode::NO_CONTENT)
|
|
}
|
|
|
|
pub async fn open_event(
|
|
State(state): State<AppState>,
|
|
RequireHost(_auth): RequireHost,
|
|
) -> Result<StatusCode, AppError> {
|
|
// Reopening invalidates any prior release: the keepsake was snapshotted at release time, so
|
|
// allowing new uploads afterwards would silently diverge the live feed from the frozen export.
|
|
//
|
|
// ONE statement retires the entire export generation. Bumping `export_epoch` in the same write
|
|
// that clears `export_released_at` instantly invalidates every in-flight worker, every `done`
|
|
// row and all readiness — because readiness is DERIVED from this epoch (migration 014), not
|
|
// stored. There is no export_job row to touch and nothing to keep in sync, so this needs no
|
|
// transaction. Any worker still streaming holds the old epoch and is now inert: its
|
|
// epoch-guarded finalize matches nothing, and it discards its own output.
|
|
let result = sqlx::query(
|
|
"UPDATE event
|
|
SET uploads_locked_at = NULL,
|
|
export_released_at = NULL,
|
|
export_epoch = export_epoch + 1
|
|
WHERE slug = $1 AND (uploads_locked_at IS NOT NULL OR export_released_at IS NOT NULL)",
|
|
)
|
|
.bind(&state.config.event_slug)
|
|
.execute(&state.pool)
|
|
.await?;
|
|
|
|
if result.rows_affected() > 0 {
|
|
let _ = state.sse_tx.send(SseEvent::new("event-opened", "{}"));
|
|
}
|
|
|
|
Ok(StatusCode::NO_CONTENT)
|
|
}
|
|
|
|
pub async fn release_gallery(
|
|
State(state): State<AppState>,
|
|
RequireHost(_auth): RequireHost,
|
|
) -> Result<StatusCode, AppError> {
|
|
// The release claim, the epoch bump, the upload lock and BOTH job rows are written in ONE
|
|
// transaction. Two reasons, both of which were live bugs:
|
|
//
|
|
// 1. Cancellation. This handler used to commit `export_released_at` and only THEN await the
|
|
// enqueue. Axum drops the handler future when the client disconnects (closed tab, proxy
|
|
// timeout), which left the event released and uploads locked with ZERO export_job rows and
|
|
// no workers: downloads 404 forever and the host cannot retry, because release_gallery
|
|
// rejects an already-released event ("bereits freigegeben"). Only a restart escaped it.
|
|
// Now nothing is committed until every row is written, and the workers are spawned AFTER
|
|
// the commit (a detached `tokio::spawn` survives cancellation).
|
|
// 2. Atomicity vs. a concurrent reopen. Bumping the epoch in the same statement that sets
|
|
// `export_released_at` means no worker and no reader can ever observe "released again but
|
|
// the generation hasn't moved on yet" — the window every previous fix kept leaving open.
|
|
//
|
|
// Release also locks uploads in the same statement (release ⇒ lock), so the export snapshot is
|
|
// taken against a frozen upload set. `COALESCE` preserves an earlier explicit lock time.
|
|
let mut tx = state.pool.begin().await?;
|
|
|
|
let claimed: Option<(Uuid, String, i64)> = sqlx::query_as(
|
|
"UPDATE event
|
|
SET export_released_at = NOW(),
|
|
uploads_locked_at = COALESCE(uploads_locked_at, NOW()),
|
|
export_epoch = export_epoch + 1
|
|
WHERE slug = $1 AND export_released_at IS NULL
|
|
RETURNING id, name, export_epoch",
|
|
)
|
|
.bind(&state.config.event_slug)
|
|
.fetch_optional(&mut *tx)
|
|
.await?;
|
|
|
|
let Some((event_id, event_name, epoch)) = claimed else {
|
|
// Distinguish "no such event" from "already released" for a clean error.
|
|
let exists = Event::find_by_slug(&state.pool, &state.config.event_slug)
|
|
.await?
|
|
.is_some();
|
|
return Err(if exists {
|
|
AppError::BadRequest("Galerie wurde bereits freigegeben.".into())
|
|
} else {
|
|
AppError::NotFound("Event nicht gefunden.".into())
|
|
});
|
|
};
|
|
|
|
// Arm both types at THIS epoch. No "skip the type that's already ready" check any more: the
|
|
// epoch bump above retired every prior generation, so there is nothing to preserve and nothing
|
|
// to be fooled by. (That skip was how a stale ready flag used to suppress regeneration.)
|
|
crate::services::export::enqueue_jobs_at_epoch(&mut tx, event_id, epoch).await?;
|
|
|
|
tx.commit().await?;
|
|
|
|
// Release locks uploads too — tell any open composer to flip to the locked UI live rather than
|
|
// discovering it via a rejected upload.
|
|
let _ = state.sse_tx.send(SseEvent::new("event-closed", "{}"));
|
|
|
|
// Detached — survives this handler being cancelled.
|
|
crate::services::export::spawn_export_jobs(
|
|
event_id,
|
|
event_name,
|
|
epoch,
|
|
state.config.comments_enabled,
|
|
std::time::Duration::ZERO,
|
|
state.pool.clone(),
|
|
state.config.media_path.clone(),
|
|
state.config.export_path.clone(),
|
|
state.sse_tx.clone(),
|
|
);
|
|
|
|
Ok(StatusCode::NO_CONTENT)
|
|
}
|