b601c062bdfecf944f3163848088a2cfd57e4f7d
17 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
b601c062bd |
fix(audit-2): video playback, role identity, keepsake EXIF, recover ceiling, WebKit CI
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
|
||
|
|
3fb1b5d80d |
feat(comments): hide every comment mention when COMMENTS_ENABLED=false
The kill-switch previously left comment UI/text visible in several places. Sweep the whole surface so a comments-off instance shows no trace: Frontend (main app): - VirtualFeed grid tile: gate the comment button/count on $commentsEnabled (was ungated — the only feed surface still showing it). - admin stats: hide the "Kommentare" count card. - UploadSheet "Uploads geschlossen" notice, host ban-modal description, and host/admin unban confirmations: drop the "…und kommentieren" wording. - export page: drop "Kommentaren" from the keepsake description. Keepsake export (had no concept of the flag): - export.rs: thread comments_enabled into the exported data (ViewerEvent), wired through spawn_export_jobs/recover_exports and their call sites (host.rs, main.rs). - export-viewer: gate comment counts (list + grid) and the lightbox comments section; older exports without the field default to enabled (?? true). Backend still 403s comment writes when disabled (unchanged) — this is the UI half so stale clients and archives match. Verified on the running stack (COMMENTS_ENABLED=false): /event reports comments_enabled=false, a regenerated keepsake embeds "comments_enabled": false with no comment UI, and the uploads-closed notice renders "…ansehen und liken." Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
ee554e7f38 |
style(backend): rustfmt the whole tree; gate cargo fmt --check in CI
The backend had never been run through rustfmt. Doing it in one mechanical pass (134 files) so no future functional diff is buried under formatting churn, then gating `cargo fmt --check` in checks.yml so it stays clean. Formatting only — no logic, SQL, or behaviour changed. Verified after the reformat: cargo test 56 passed, clippy --all-targets -D warnings clean, cargo fmt --check clean. This is the deferred cleanup noted when CI's Format step was first left out. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
9666d74a46 |
fix(export): close all 13 findings from the multi-agent review
A 6-lens multi-agent review (103 agents; every finding adversarially verified by three
refute-by-default skeptics) confirmed the epoch redesign is sound — the core invariant
holds — and found 13 real defects around it. All are fixed here.
The redesign's invariant was re-verified and stands: an accepted upload is always in the
keepsake, and a downloadable keepsake always reflects the most recent release.
But a WEAKER adjacent invariant did NOT hold — "a downloadable keepsake reflects the
current content" — and that is the theme of the biggest fixes.
CONTENT REMOVAL NOW ALWAYS REACHES THE KEEPSAKE
Regeneration was wired only to host deletes. Ban, unban, guest self-delete and guest
comment-delete did not trigger it — yet the export query filters `is_banned = FALSE`,
so the pipeline already agreed that content must not be there. Ban someone for abusive
content after release and every guest kept downloading an archive containing it.
All five removal paths now invalidate and rebuild. `query_comments` also gained the
banned/hidden filter it was missing entirely (the ZIP had it; the viewer did not).
Each removal is now ATOMIC with its invalidation (one tx, models made executor-generic).
Previously the delete committed in its own tx and the regeneration in a second: a dropped
handler future between them left the taken-down photo permanently downloadable, and
nothing could notice — the keepsake still looked complete and the host could no longer
even find the upload to retry.
NO MORE TAKEDOWN STORM
Every removal spawned two uncancellable full exports. A superseded worker was INERT, not
STOPPED: it still ran every ffmpeg spawn and image resize and wrote a whole archive before
discovering it had lost. Five deletes left ten workers alive, each holding a
full-gallery-sized temp, in a 1 GB container.
Now: a debounce before `claim_job` (a superseded worker fails its claim and does ZERO
work, collapsing a burst into one export), plus `update_progress` — which already ran
exactly the right liveness predicate and threw the answer away — returning it so both file
loops bail the instant they are retired. Moderating a comment no longer rebuilds the
multi-GB ZIP either: the ZIP is media-only, so it is carried forward, with prune taught to
protect any file a live row still references.
AN ESCAPE HATCH
A failed export was terminal at runtime: release_gallery refuses an already-released event,
recovery only runs at boot, and there was no retry route — the only outs were restarting the
container or reopening uploads to every guest. Added POST /host/export/rebuild. Also widened
mark_failed's guard to `status IN ('running','pending')`: when claim_job itself ERRORED, the
row was still `pending`, so the failure was never recorded and the job sat at 0% forever.
SILENT INVALIDATION
Retiring the epoch 404s the download instantly, but nothing told the clients — guests kept
seeing an enabled download button through the whole rebuild. Now broadcasts an invalidation.
And the download was a top-level <a href>: a 404 (or a 429 from the per-IP export limit that
everyone on the venue WiFi shares) NAVIGATED THE USER OUT OF THE PWA onto raw JSON. It now
targets a hidden iframe, so an error response is harmless.
ALSO
- The HTML export still had the exists()-then-open TOCTOU the ZIP path was fixed to remove,
at three sites, two with a hard `?` that failed the ENTIRE viewer. It is reachable: the
compression worker hard-deletes an original when a transcode fails and can still be running
when the gallery is released.
- Crash-orphaned .tmp files were immortal (prune deliberately skips .tmp). Swept at boot,
where it is unambiguously safe.
- export_status read the epoch in one statement and used it in the next; now one query.
- DiskCache::snapshot IGNORED its path argument on a cache hit, returning whatever filesystem
was measured last. Latent only because both callers pass media_path — it would have silently
misreported the moment anyone measured the exports volume. Now keyed by path.
- Corrected two comments that asserted safety properties the code does not have (claim_job
does NOT prevent a retired-epoch claim — retirement is enforced at READ time; and a
multi-replica reap is NOT contained, it can corrupt the keepsake). Added a rollback runbook
to migration 014, the repo's first destructive migration.
TESTS
The regression guard for the headline FOR SHARE fix was probabilistic — it could pass on
broken code if the interleaving fell the wrong way. It is now STRUCTURAL: the upload is held
open mid-body with its pre-flight check already passed, so the release provably lands inside
the exact window. Verified 5/5 FAILING (201 instead of 403) with the fix reverted, 5/5
passing with it.
Verified: backend 40 tests, svelte-check 0 errors, e2e typecheck clean,
e2e 160 passed / 1 skipped on chromium-desktop.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
8e906d7866 |
refactor(export): single epoch authority; fix upload-path TOCTOU losing photos
A deep investigative review of the export concurrency (four parallel adversarial
reviewers) found that three rounds of race fixes had been chasing the wrong bug, and
that the model itself was the root cause. This replaces the model and fixes the real
data loss.
THE ACTUAL BUG (upload path, not the state machine)
`upload` checked `uploads_locked_at` BEFORE streaming the body — minutes, for a large
video — then committed the row without re-checking. A release landing mid-upload let the
row commit AFTER the export snapshot: the photo appeared in the live feed and was
PERMANENTLY missing from the keepsake, with nothing to regenerate it. release_gallery's
comment claims to prevent exactly this; it never did. Every prior round fixed the DB
state machine, and the leak was upstream of it.
Fix: re-assert the lock under `SELECT ... FOR SHARE` INSIDE the commit tx. That row lock
conflicts with release_gallery's `UPDATE event`, so either the upload commits first (and
the release — hence the snapshot — is ordered after it) or the release wins and the
upload is rejected as `uploads_locked` (reversible; the client keeps the blob).
Regression test verified NON-VACUOUS: with the fix reverted it fails, reporting accepted
uploads missing from the keepsake.
THE MODEL (migration 014)
"Which generation is current?" had no single answer: it was assembled from three
separately-written pieces across two tables (`export_released_at`, a per-job
`release_seq`, and cached `export_{zip,html}_ready` flags). Keeping them in agreement
took ~10 hand-written guards; each review round found one more path that slipped through.
Now: ONE monotonic `event.export_epoch`, bumped in the same UPDATE as any change to
`export_released_at`. Each job carries a copy. Downloadable IFF
`released AND job.epoch = event.export_epoch AND status = 'done'` — readiness DERIVED,
never stored. A retired-epoch worker is INERT BY CONSTRUCTION: losing a race can no
longer corrupt state, only waste work.
Deleted: both ready-flag columns, both ready-flip blocks, `if ready { continue }`, the
reopen seq-bump, open_event's transaction, and the cross-table claim guard.
That last one was also UNSOUND: under READ COMMITTED, a blocked UPDATE re-evaluates its
WHERE against the updated target row but answers subqueries on OTHER tables from the
original snapshot — so the previous `EXISTS (SELECT ... FROM event ...)` claim guard
could admit a claim against an already-reopened event while RETURNING the post-bump seq.
Every guard is now same-row (`epoch = $n`), which EPQ re-evaluates correctly.
OTHER REAL DEFECTS FIXED
- release_gallery committed the release and THEN awaited the enqueue inside the handler
future. Axum drops that future on client disconnect → event released, uploads locked,
ZERO export_job rows, host unable to retry ("bereits freigegeben"), restart-only
recovery. Now one tx; workers spawn (detached) after commit.
- No flush/fsync before the tmp→final rename. async_zip's close() never flushes the inner
file and tokio's File drop DISCARDS write errors — so a full disk silently produced a
truncated archive that was then marked done and published, after which the last good
generation was pruned. Now flush + sync_all, which also surfaces ENOSPC.
- Download read the ready flag and the file path in TWO queries; a reopen between them
served a keepsake that should 404. Now one read through the `export_current` view.
- `if !src.exists() { continue }` then `open()?` — a TOCTOU that turned a tolerated gap
into a hard failure of the ENTIRE keepsake (unretryable while released). Now open-first,
warn, and skip.
- Recovery was flag-driven and never checked the disk: a `done` row whose archive was gone
was a permanent dead end needing manual DB surgery. Now verifies the file and re-arms.
- Temp files/dirs leaked on every error path (the leak is what fills the disk that
corrupts the next archive). Now cleaned up.
- Export archives were not event-scoped (`viewer_tmp_` already was), so a second event
would collide on paths and one event's prune would delete another's live keepsake.
- Host deletes after release never regenerated the keepsake — a photo removed on request
lived on in the archive forever. Now bumps the epoch and rebuilds without it.
- claim_job swallowed DB errors as "someone else won", stranding a job pending at 0%.
- export_status reported retired-epoch rows (a progress bar that never moves).
- The startup sweep's single-instance assumption is now documented, not hidden.
Verified: backend 40 tests, svelte-check 0 errors, e2e typecheck clean,
e2e 160 passed / 1 skipped on chromium-desktop (incl. 2 new regressions; the
upload-acceptance one confirmed to fail without the fix).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
9f3712894d |
fix(export): pin export workers to a live release epoch; make reopen atomic
Adversarial re-review of |
||
|
|
d643256f36 |
fix(rereview): close residual export finalize↔flip double-race at reopen
Adversarial re-review of
|
||
|
|
36fe59caa5 |
fix(user-flow): persona-audit fixes — ban replay, locked-upload data loss, host UX, admin session
Follows the perf + security + user-flow work with a role/persona audit (guest, host, admin, projector) and fixes across three review rounds. Highlights: HIGH - Ban now replays on reconnect. A ban isn't a soft-delete, and the `user-hidden` SSE has no replay, so a client that missed it (esp. the unattended diashow) kept cycling a banned user's slides. New `uploads_hidden_at` (migration 013) + `hidden_user_ids` in /feed/delta; feed + diashow evict those users. Applied even on a truncated delta. MEDIUM - Locked-upload data loss: a photo staged offline during a lock/release was purged as a terminal 4xx and lost when the host reopened. New reversible `uploads_locked` error code; the queue keeps the blob and auto-resumes on the `event-opened` SSE. - Reopen after release now warns (ConfirmSheet) that it revokes the published keepsake. - Host "forgotten-PIN" badge updates live (`pin-reset-requested` was broadcast but never in KNOWN_EVENTS / subscribed); host page also refetches on `pin-reset` so a two-host race can't hand out a conflicting PIN. - Ban modal copy fixed (read-only ban, not "session ended"); Degradieren/Sperren/Entsperren hidden on peer-host rows for non-admins (they always 403'd). - Host dashboard shows live keepsake generation progress / ready state + link to /export. - Admin JWT moved to sessionStorage (§11.1) to bound exposure on shared devices. Export generation guard (H1 from the prior round, hardened): per-(event,type) `release_seq` (migration 012) with seq-guarded claim/finalize/mark_failed/update_progress, per-generation temp/final paths, download follows `file_path`, prune only strictly-older generations. LOW: diashow coalesces upload-processed (avoids self-rate-limit); event-closed reconciles galleryReleased; /recover gains a forgot-PIN request + drops a stale cached PIN on 401; delta `>=` tie-break + 429 retry; misc copy/labels. Adds e2e: ban-replay, upload-lock-code, and rewrites export-reopen-rerelease with a data-completeness test. Reconciles USER_JOURNEYS §9/§11. Verified: cargo build clean, 40 unit tests, svelte-check 0 errors, 33 frontend unit tests, 155 e2e passing on chromium-desktop. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
641174717c |
fix(user-flow): close offline-queue, export, session, ban & realtime gaps
Comprehensive user-flow review across guest/host/admin roles, then fixes with
e2e regression guards. Highlights:
- Offline upload queue auto-resumes on reconnect: network errors keep items
pending (not error), 4xx are terminal (no infinite retry), quota exhaustion
returns a distinct 413; queue cap + dedup.
- Export lifecycle: release atomically locks uploads; reopen invalidates and
re-release regenerates the keepsake; workers claim their job atomically so a
reopen->re-release can't corrupt the ZIP; startup re-spawns interrupted
exports.
- Sessions slide on activity (no 30-day cliff); JWT expiry deferred to the
revocable session row; sign-out-everywhere + revoke-on-PIN-reset.
- Ban always hides content (v_feed / find_visible_media / export filter
is_banned) but stays a read-only ban per USER_JOURNEYS §10 — sessions are
not revoked, read access + keepsake download preserved.
- Realtime: server-clock SSE delta cursor; event-closed/opened drive the UI
live; feed_delta rate-limited; like returns {liked, like_count} to fix
multi-device drift; lightbox live comments; diashow delta backfill.
- Forgotten-PIN in-app request flow; simultaneous same-name join returns 409;
quota increment is transactional; operator floor.
Adds e2e/specs/10-flow-review/ (offline resume, export integrity, deterministic
anti-race guard) and updates existing specs for the new contracts.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
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>
|
||
|
|
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> |
||
|
|
80179357a7 |
fix(review-2): high — read-only ban model, failed-compression cleanup, live SSE eviction
H1: corrected a round-1 over-reach. Bans are read-only per USER_JOURNEYS §10:
the base AuthUser extractor no longer rejects banned users (they keep read
access); Require{Host,Admin} and write handlers enforce the ban via
auth.is_banned → 403 (incl. self-unban). Demoted hosts still lose powers
immediately (live DB role) and are bounced off the dashboard. Also fixed the
login/recover redirect regression on unauthenticated 401.
H2: failed compression now self-heals instead of leaving a permanent broken
card — refund the quota, delete the orphaned original, soft-delete the row;
the frontend toasts the uploader and evicts the card on upload-error.
H3: self-delete, ban-with-hide (user-hidden), and comment-deletes now broadcast
SSE; feed, diashow, and lightbox evict the affected content live instead of
waiting for a reload. sse-eviction.spec.ts covers it.
Riders: feed/+page.svelte also carries the medium truncated-delta pill; host.rs
also carries the low atomic release_gallery claim; admin/+page.svelte also drops
the dead compression_concurrency control (medium).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
498b4e256b |
fix(security): critical — repair PIN reset + enforce real prod secrets
C1: reset_user_pin wrote to a non-existent column (pin_failed_attempts);
the real column is failed_pin_attempts, so every PIN reset 500'd. Fixed
the column name; new e2e (pin-reset.spec.ts) proves a reset returns a
usable PIN and the target can recover with it.
C2: config.rs::validate_secrets now rejects placeholder-ish secrets
(change_me/dev_secret/placeholder), enforces len>=32 in prod, and
requires a real ADMIN_PASSWORD_HASH. docker-compose.yml sets
APP_ENV=production so the guard actually runs. Corrected the false
"fixed" claim in SECURITY-BACKLOG.md. .env.example documents the rule.
Riders in these files (documented here since git can't split hunks):
- host.rs also carries the event-scoped ban_user fix and the
close_event/open_event no-op broadcast guard (medium).
- docker-compose.yml also adds ORIGIN (H6), app/frontend healthchecks with
Caddy waiting on health, and per-service memory limits (medium).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
d228676a56 |
fix(security): cross-event authz, SSE ticket flow, account hardening, audit logs
Follow-up to the comprehensive code review. Five batches: 1. Cross-event authorization: host_delete_upload, unban_user, and host_delete_comment now scope by auth.event_id. Adds Upload::find_by_id_and_event / soft_delete_in_event and a Comment::soft_delete_in_event variant that joins through upload. 2. Token exposure: SSE auth no longer puts the JWT in the URL. New /api/v1/stream/ticket endpoint mints a short-lived single-use ticket bound to the session; the EventSource passes ?ticket=... instead. Refuse to start in APP_ENV=production with the dev JWT sentinel; warn loudly otherwise. 3. Account hardening: per-IP+name rate limit on /recover (mitigates targeted lockout DoS), per-IP rate limit on /admin/login, random 32-char admin recovery PIN (replaces "0000"), structured tracing events for wrong PIN, lockout, failed admin login, ban/unban/role change/pin-reset/host-delete. 4. DoS / correctness: comment listing paginated (LIMIT 50 + ?before= cursor), hashtag extraction whitelisted to ASCII alnum+underscore (≤40 chars) with unit tests, display_name / caption / comment body length validated in chars rather than bytes. 5. Cleanup: session-touch failures now logged, DATABASE_MAX_CONNECTIONS env var (default 10). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
2e98f5ddf5 |
backend(features): quota enforcement, PIN reset, /me, original download, toggles
- handlers/me.rs (new): GET /api/v1/me/context (profile + role + privacy_note
+ quota toggle state, fetched once on app bootstrap) and GET /api/v1/me/quota
(live used / limit / active uploaders / free disk).
- handlers/upload.rs:
- quota enforcement via the dynamic formula
floor((free_disk * tolerance) / max(active_uploaders, 1)),
gated by quota_enabled + storage_quota_enabled toggles
- new GET /api/v1/upload/{id}/original — unauthed by design
(matches /media/previews/* — URL is the secret) so it works as
<img src> / <video src> / window.open
- rate-limit toggle wiring (rate_limits_enabled + upload_rate_enabled)
- handlers/host.rs:
- POST /api/v1/host/users/{id}/pin-reset — Host may reset guest PINs,
Admin may reset guest + host PINs (never another admin or self).
Returns the freshly-generated plaintext PIN once; emits a global
pin-reset SSE so the affected user's device can clear its localStorage.
- set_role guard expanded so hosts can demote other hosts (not self,
never admins) — backend match for the doc'd permission model.
- handlers/admin.rs: ALLOWED_KEYS split into NUMERIC_KEYS / BOOL_KEYS /
TEXT_KEYS with per-kind validation; saving privacy_note broadcasts an
event-updated SSE so other clients refresh live.
- handlers/feed.rs, handlers/admin.rs (export), auth/handlers.rs:
rate-limit toggle wiring at every limiter call site.
- auth/handlers.rs: when an expired PIN lockout is detected on /recover,
reset failed_pin_attempts to zero before the bcrypt check — without
this every wrong PIN re-locked the user after the cooldown.
- main.rs: wire startup_recovery + spawn_periodic_tasks, register the
new /me/context, /me/quota, /upload/{id}/original, and
/host/users/{id}/pin-reset routes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
258e2bd84d |
feat: implement export engine
Add async ZIP and HTML offline viewer export workers, download endpoints,
and a guest-facing /export page.
Backend — export workers (tokio::spawn, run after gallery release):
- ZIP worker: streams all non-deleted originals into Gallery.zip via
async_zip (Stored compression), organised into Photos/ and Videos/
with {date}_{uploader}_{id}.{ext} filenames; updates progress_pct in DB
- HTML worker: renders Memories.html via minijinja template (self-contained:
inlined CSS + JS, relative media paths); packs it with README.txt and
all media into Memories.zip (Deflate for text, Stored for media)
- Both workers mark export_job status (running → done/failed), update
export_zip_ready / export_html_ready on the event, and broadcast SSE
export-progress + export-available when both complete
Backend — new endpoints (AuthUser):
- GET /export/zip → streams Gallery.zip if export_zip_ready
- GET /export/html → streams Memories.zip if export_html_ready
- GET /export/status → released flag + per-type status/progress (moved from admin)
Memories.html features: warm keepsake aesthetic, responsive grid, fullscreen
lightbox with captions/comments/likes, client-side hashtag filter chips,
XSS-safe JS, fully offline (no external deps)
Frontend — /export page:
- Locked state: padlock illustration + message
- Released state: ZIP and HTML cards with progress bars (SSE-driven),
download buttons enabled only when ready
- HTML guide modal (unzip instructions + Wi-Fi tip) before download begins
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
||
|
|
71a2987a3e |
feat: implement host dashboard
Add Host Dashboard for event and guest management, accessible at /host.
Backend — new /api/v1/host/* endpoints (RequireHost auth):
- GET /host/event → event name + lock/release state
- POST /host/event/close|open → lock or unlock uploads; SSE broadcast
- POST /host/gallery/release → set release timestamp, enqueue export jobs
- GET /host/users → all guests with upload count & bytes
- POST /host/users/{id}/ban → ban with optional upload-hide choice
- POST /host/users/{id}/unban → lift ban
- PATCH /host/users/{id}/role → promote guest→host or demote host→guest
- DELETE /host/upload/{id} → host-level soft-delete + SSE
- DELETE /host/comment/{id} → host-level soft-delete
Frontend — /host page:
- Event controls: lock/unlock toggle and release-gallery button with status badges
- Guest table: display name, role badge, upload count, storage used
- Ban flow: modal asking whether to keep or hide the user's uploads
- Promote/demote buttons respecting caller role (host can promote guests; admin can demote hosts)
- auth.ts: getRole() decodes JWT payload client-side to gate the route
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|