15 Commits

Author SHA1 Message Date
MechaCat02
77d55e941c harden(deploy): add app/frontend healthchecks + readiness-gate Caddy
Caddy depended on app/frontend with no readiness gate, so it could proxy to a
not-yet-listening upstream and surface brief 502s on boot/restart. Add
wget-based healthchecks (busybox wget ships in both alpine runtime images):
app hits the unauthenticated /health, frontend hits / (SSR returns 200; the
redirect is client-side). app uses a 30s start_period to cover boot-time
migrations. Caddy now depends_on both with condition: service_healthy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 17:38:51 +02:00
MechaCat02
a895bebdc2 harden(deploy): close public DB exposure + make JWT prod guard effective
Two HIGH deployment fixes found in the go-live review, plus two cheap backstops.

HIGH 1 — Postgres was published to the public internet. The committed,
auto-merged docker-compose.override.yml mapped 5432:5432 on 0.0.0.0 (and a
Docker publish bypasses ufw), so a `docker compose up` on the prod host exposed
the DB with the .env password. Bind the dev port to 127.0.0.1 so it's reachable
only from the host's loopback even when the override lands in prod. .env.example
also stops shipping `secret` as the DB password (now CHANGE_ME_*).

HIGH 2 — the JWT-secret production guard was inert. APP_ENV was never set to
production (defaulted to development), so the guard never ran; and it only
matched one stale dev sentinel, not the `.env.example` placeholder that an
operator is most likely to leave in place. Now: docker-compose.yml forces
APP_ENV=production on the app service, and config.rs refuses to boot in
production on any placeholder (change/example/placeholder/replace) or any secret
< 64 chars. Verified: placeholder and short secrets are rejected; a 128-char
secret passes. This turns "looks protected" into actually protected.

Backstops:
- frontend container now runs as the non-root `node` user (the backend was
  already hardened; the frontend wasn't).
- app gets mem_limit 3g so a miss in the in-app upload RAM caps OOM-kills the
  container instead of the 8 GB host.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 17:35:57 +02:00
MechaCat02
2d7169e971 harden: low-severity bucket — unspoofable IP, admin floor, log/SSE hygiene
The cheap, real LOWs from the review (bucket 🅰); 🅱 items and won't-fix
decisions are recorded in docs/SECURITY-BACKLOG.md.

- XFF spoofing (highest-value): client_ip now prefers an unspoofable X-Real-IP
  (Caddy `header_up X-Real-IP {remote_host}`) and otherwise takes the *rightmost*
  X-Forwarded-For token, never the client-controlled leftmost. The join cap,
  recover throttle, and admin-login floor all keyed on this. Unit-tested.
  Caddyfile also drops the dead /media/previews|originals cache matchers (the
  gateway sets its own Cache-Control) and the false "only host can download"
  comment.
- admin_login: add a hard, non-disableable rate floor (30/5min/IP) so the DB
  rate-limit master toggle can't remove brute-force protection.
- SSE: the broadcast upload-error payload no longer includes the raw error
  (absolute filesystem paths) — generic message to clients, details logged
  server-side only.
- Access logs: the request span logs the path only, never the query string, so
  the replayable media `?sig=` capability isn't written to logs.
- Reaper TOCTOU: reap_deleted now deletes rows by the ids it actually unlinked
  (DELETE … WHERE id = ANY) instead of re-evaluating the time predicate, so a
  row crossing the grace line mid-sweep can't be row-deleted without its files
  unlinked.
- Dockerfile: run the backend as a non-root user; create /media owned by `app`
  so a fresh volume is writable.
- a11y: aria-labels on the upload caption and feed search inputs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 18:01:41 +02:00
MechaCat02
db1e5b8833 fix: post-review punch-list — upload concurrency cap, atomic release, ban SSE
Follow-up to the adversarial re-review of the audit-fix branch.

MUST-FIX:
- H3: bound aggregate upload RAM. The body limit alone didn't cap concurrency,
  so ~N parallel 550MB uploads could OOM the box. Add an Arc<Semaphore>
  (UPLOAD_MAX_CONCURRENCY=4) in AppState, acquired at the top of the upload
  handler before the multipart body is read, so waiting requests hold only a
  connection — peak buffered RAM ≈ 4×550MB. (Matches the CompressionWorker
  semaphore pattern; avoids tower's non-default `limit` feature.)
- M8: release_gallery now runs the claim UPDATE + both job INSERTs in one
  transaction, so the row lock is held until the jobs exist — closing the
  cross-table TOCTOU where two concurrent presses could both spawn workers
  racing the same output files. Export temp filenames are now per-run
  (Gallery.{uuid}.zip.tmp, viewer_tmp_{event}_{uuid}, Memories.{uuid}.zip.tmp)
  so overlapping runs can't truncate each other. Final served names unchanged.
- M11: 'user-banned' was missing from sse.ts KNOWN_EVENTS, so EventSource
  silently dropped the frame and the forced-logout handler never fired. Added.

SHOULD-FIX:
- M8-3: re-release is now allowed when nothing is in progress and at least one
  job failed (was: only when *every* job failed), so a one-sided failure (zip
  done, html failed) is no longer permanently unrecoverable.
- M18: two light-mode contrast spots the earlier sweep missed — the LightboxModal
  char-counter class: directives and the account PIN-missing notice.
- M15: the diashow auto-advance is now slowed to a ≥30s floor under
  prefers-reduced-motion (WCAG 2.2.2), making the app.css comment accurate.

Verified: cargo build + 6 tests, npm run check (0 errors), and a live smoke test
against Postgres — first release 204, second 400, one-sided-failure re-release
204, no SQL errors; a normal upload still returns 201 through the new permit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 17:40:54 +02:00
MechaCat02
d4181b1119 chore(git): ignore local audit/review reports
The audit + fix-verification reports (docs/AUDIT-2026-06-27.md,
docs/FIX-VERIFICATION-2026-06-27.md) are local working artifacts; one was
accidentally swept into the media-gateway commit. Dropped from history and
ignored so they stay untracked local files.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 17:22:20 +02:00
MechaCat02
23a7d89a89 fix(migrations): renumber feed-view migration 006 → 010
The new v_feed-rewrite migration collided with the existing 006_user_pin_lockout
(versions must be unique). Verified against a fresh Postgres: all migrations
001–010 now apply cleanly on startup.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 16:47:27 +02:00
MechaCat02
8272197cea fix(auth): escalating recovery-PIN lockout backoff (M4)
The failed-attempt counter is no longer reset when the lockout window expires,
so each further wrong PIN past the 3-strike threshold doubles the lockout —
15min, 30, 60, … capped at 24h. Combined with the now 6-digit PIN (1M space),
this makes sustained guessing via /recover infeasible. A successful recovery or
a host PIN reset clears the counter; legitimate users (who don't fail) are
unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 16:28:31 +02:00
MechaCat02
0737288ed9 a11y: viewport-fit, reduced-motion, like aria-pressed, labels, contrast (M13-M18)
M13: add viewport-fit=cover so env(safe-area-inset-*) resolves on notched phones.
M14: like buttons (list card, grid overlay, lightbox) get aria-pressed + a
descriptive aria-label so AT announces self-state, not just the shared count.
M15: a prefers-reduced-motion media query neutralizes the decorative keyframes
(Ken Burns, crossfade, HeartBurst) and snaps transitions.
M16: join/recover name + PIN inputs and the lightbox comment input get aria-labels.
M17: FeedGrid overlay like/comment buttons get aria-labels and ≥44px hit areas.
M18: bump light-mode secondary text from gray-400 to gray-500 (keeping
dark:text-gray-400) across the app for WCAG AA contrast.

Also raises the PIN inputs to 6 digits (placeholder, maxlength, slice, and the
auto-submit threshold) to match the new 6-digit generated PINs; legacy 4-digit
PINs still submit via the button.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 16:26:21 +02:00
MechaCat02
5bd008591b fix(frontend): surface upload errors, push ban/lock, route guards (WS8)
H11: UploadSheet now honors the modal contract — role=dialog/aria-modal +
accessible name, a focus-trap/Escape/focus-restore $effect mirroring
ContextSheet, and inert + pointer-events-none when closed so its controls leave
the tab order and AT tree.

H12: a layout-level $effect toasts each upload-queue item the first time it
transitions to 'error', so a banned/locked/over-quota guest is actually told
why their photo didn't post instead of the composer silently closing.

M10: api.ts now redirects to /join after clearAuth() on a 401 (guarded against
loops on the auth screens), so a 401 no longer strands the user on a dead page.

M11: ban and event-lock are pushed to guests. A new uploadsLocked store is
seeded from /me/context (now exposes uploads_locked) and kept live by
event-closed/opened SSE; the feed shows a banner and the FAB is disabled when
locked. A user-banned SSE event force-logs-out the targeted user.

M19: feed and export pages track a distinct loadError and render an "Erneut
versuchen" retry card instead of collapsing a fetch failure into "empty" /
"not released". Light-mode empty-state text bumped to gray-500 for contrast.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 16:16:01 +02:00
MechaCat02
cf428725b9 perf(feed): rewrite v_feed, bound feed_delta, server-time SSE cursor (H6,H7,M9)
H6: migration 006 replaces v_feed's double LEFT JOIN + COUNT(DISTINCT) (which
materialized a likes×comments Cartesian per upload) with correlated scalar
subqueries that each use their own index. Output columns are unchanged, so
feed + hashtag-filtered paths both benefit.

H7: feed_delta now applies the feed rate limit (keyed per user), caps results
at 200 rows, and clamps how far back a client `since` may reach (7 days). When
clamped or capped it returns reload_required=true; the SSE client turns that
into a full feed reload instead of streaming the whole gallery through the view
on every tab refocus.

M9: the SSE reconnect cursor is now advanced only from server timestamps (an
upload's created_at, seeded from the feed and updated on new-upload events and
delta responses), never the client clock — so a skewed phone clock can't drop
events missed while backgrounded.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 15:58:35 +02:00
MechaCat02
ae6c496f94 fix(authz): event-scope social handlers, atomic gallery release (M1, M3, M8)
M1: toggle_like / add_comment / list_comments now resolve the target via
Upload::find_by_id_and_event, returning 404 for uploads outside the caller's
event or already soft-deleted — closing the cross-event IDOR and the
write-to-deleted-post path.

M3: the redundant in-handler is_banned fetches (upload, like, comment) are
removed; the AuthUser extractor (WS1) already rejects banned users on every
authenticated route, which also covers the previously-ungated edit_upload /
delete_upload / delete_comment.

M8: release_gallery claims the release with a single conditional UPDATE and only
proceeds when it matches a row, so concurrent presses can't spawn duplicate
export workers racing on the same files. Re-release is now permitted when all
prior export jobs terminally failed (e.g. a crash mid-export), and the job
upsert resets failed rows so the workers re-run cleanly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 15:43:12 +02:00
MechaCat02
9364cb624a fix(dos): bound uploads, offload bcrypt, cap image decode, split queues
H3: re-enable a request-body limit (550MB, sized to the largest allowed video +
multipart overhead) instead of DefaultBodyLimit::disable(), and drop the
second full-file copy by keeping the upload as Bytes.

H4: image decode now goes through ImageReader with explicit dimension
(12000x12000) and allocation caps, rejecting decompression bombs before the
expensive resize, and the whole spawn_blocking is wrapped in a 120s timeout
mirroring the ffmpeg guard.

H8: every bcrypt hash/verify (join, recover, admin_login, host PIN reset) now
runs via spawn_blocking through a new services::password helper, so concurrent
auths can't pin the async workers.

M7: images and videos get independent compression permit pools, so slow videos
can no longer starve image-preview generation.

H5: add a generous per-IP daily account-creation cap (NAT-safe at ~100 guests)
on top of the burst cap, and wire up the previously-dead upload_count_quota_*
config as an event-wide file-count cap that a re-join cannot reset.

Also bumps generated recovery PINs from 4 to 6 digits (part of M4).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 15:38:59 +02:00
MechaCat02
8faf702208 feat(media): authenticated signed media gateway (C1, C2-sink, C3, H2, H10)
Replaces the DB-blind `/media` ServeDir with a signed, DB-aware gateway at
`GET /media/{kind}/{id}`. Every media byte now flows through an HMAC-SHA256
signature check (minted into feed/upload DTOs for authenticated members; <img>
can't carry a Bearer header) plus a DB lookup:

- C1: export ZIP/HTML have no upload row, so they are unreachable by path —
  download stays behind the authenticated /export endpoints.
- C2 (sink): responses carry X-Content-Type-Options: nosniff and a locked-down
  CSP (default-src 'none'; sandbox), neutralizing any active content.
- C3 / H2: find_by_id filters deleted_at and the handler rejects ban-hidden
  uploaders, so deleted and moderated artifacts 404 — and the unauthenticated
  get_original alias (the H2 hole) is removed entirely.
- H10: delete paths (owner + host) now unlink original/preview/thumbnail after
  commit; soft_delete returns the paths; an hourly reaper reclaims disk for
  rows soft-deleted past a 1-day grace and hard-deletes them (FKs cascade).

Signed URLs are bucketed to a 1h window so they stay stable across feed polls
(browser cache hits) while expiring within 24h. media_token sign/verify has a
unit test (roundtrip + tamper + expiry).

Frontend: FeedUpload/pickMediaUrl now use the backend-provided signed
original_url; no client constructs a media path anymore.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 15:30:30 +02:00
MechaCat02
ab9f1d89b2 fix(upload): server-side MIME/ext allowlist + atomic transactional write
C2: stored-XSS via the application/* MIME bypass is closed — the declared
Content-Type and client filename are no longer trusted. New classify() derives
both the canonical MIME and a safe extension purely from magic bytes against a
strict allowlist (jpeg/png/webp/heic/mp4/mov/webm); anything infer can't
positively identify (incl. HTML/SVG script payloads) is rejected. Unit tests
cover accept + reject paths.

M12: the stored extension can no longer carry '/' or arbitrary client text —
it's one of the allowlist's safe constants, ending directory-pollution and the
export DoS.

M5/M6: quota is now reserved with a single conditional UPDATE that row-locks
(no TOCTOU overshoot) and the reservation + row INSERT run in one transaction;
the file is written before commit and unlinked on any rollback, so a crash
mid-write can no longer leak quota bytes or orphan a row. Upload::create is now
executor-generic to run inside the tx.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 15:19:34 +02:00
MechaCat02
2068e8c1f3 fix(auth): reconcile role/ban from DB, revoke sessions, fix pin/comment bugs
H1: AuthUser now JOINs session→user and sources role/ban/event from the live
DB row instead of trusting JWT claims. Bans take effect on the next request;
demotion/promotion is immediate. Adds Session::find_auth_context and rejects
tokens whose sub/event_id disagree with the session row. This also closes M2
(banned export) and M3 (banned edit/delete) globally — banned users can no
longer pass the AuthUser extractor.

Adds Session::delete_by_user_id and revokes all of a user's sessions on
ban_user, set_role, and reset_user_pin so existing JWTs die immediately.
ban_user also emits a user-banned SSE for forced client logout.

H9: reset_user_pin used a non-existent column pin_failed_attempts (runtime
sqlx, so it 500'd in prod). Corrected to failed_pin_attempts — the only
account-recovery path now works.

C4: LightboxModal posted comments to /comment (singular); backend only
registers /comments. One-character route fix re-enables the comment feature.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 15:14:22 +02:00
113 changed files with 1951 additions and 3709 deletions

View File

@@ -4,17 +4,21 @@ DOMAIN=my-event.example.com
# ── App server ────────────────────────────────────────────────────────────────
APP_PORT=3000
# docker-compose.yml already forces APP_ENV=production for the `app` service.
# Only set this for a non-compose (bare cargo) run; leave unset for local dev.
# APP_ENV=production
# ── Database ──────────────────────────────────────────────────────────────────
# Set a strong password and keep it in sync between DATABASE_URL and
# POSTGRES_PASSWORD. Generate one with: openssl rand -hex 24
DATABASE_URL=postgres://eventsnap:CHANGE_ME_use_a_strong_password@db:5432/eventsnap
# Set a strong password and keep it identical in DATABASE_URL and POSTGRES_PASSWORD.
DATABASE_URL=postgres://eventsnap:CHANGE_ME_strong_db_password@db:5432/eventsnap
POSTGRES_USER=eventsnap
POSTGRES_PASSWORD=CHANGE_ME_use_a_strong_password
POSTGRES_PASSWORD=CHANGE_ME_strong_db_password
POSTGRES_DB=eventsnap
# ── Authentication ────────────────────────────────────────────────────────────
# Generate with: openssl rand -hex 64
# REQUIRED in production: generate with `openssl rand -hex 64` (128 hex chars).
# The backend refuses to start in production with this placeholder or any value
# that is short or contains change/example/placeholder/replace.
JWT_SECRET=change_me_to_a_random_64_byte_hex_string
SESSION_EXPIRY_DAYS=30

4
.gitignore vendored
View File

@@ -24,3 +24,7 @@ e2e/.env.test
# OS
.DS_Store
Thumbs.db
# Local audit/review reports — generated working artifacts, kept out of git
docs/AUDIT-2026-06-27.md
docs/FIX-VERIFICATION-2026-06-27.md

View File

@@ -1,38 +1,29 @@
{$DOMAIN} {
encode zstd gzip
encode zstd gzip
# Site-wide security headers (defense-in-depth). HSTS is free since Caddy
# already terminates TLS. nosniff also covers all of /media/*.
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "strict-origin-when-cross-origin"
}
# SvelteKit frontend — static assets with long-lived cache (content-hashed filenames)
@hashed_assets path_regexp hashed /_app/immutable/.*\.[a-f0-9]{8,}\.(js|css|woff2)$
header @hashed_assets Cache-Control "public, max-age=31536000, immutable"
# SvelteKit frontend — static assets with long-lived cache (content-hashed filenames)
@hashed_assets path_regexp hashed /_app/immutable/.*\.[a-f0-9]{8,}\.(js|css|woff2)$
header @hashed_assets Cache-Control "public, max-age=31536000, immutable"
# API — never cache
@api path /api/*
header @api Cache-Control "no-store"
# Media previews and thumbnails
@previews path /media/previews/* /media/thumbnails/*
header @previews Cache-Control "public, max-age=3600"
# Media is served by the authenticated gateway at /media/{kind}/{id}, which
# sets its own Cache-Control (private) and security headers per response — no
# Caddy-side cache rules (the old /media/previews|originals matchers were for
# the retired static mount).
# Original media files (private — only host can download). Force download
# rather than inline rendering as defense-in-depth against any future
# content-type confusion (previews/thumbnails are re-encoded and stay inline).
@originals path /media/originals/*
header @originals Cache-Control "private, max-age=86400"
header @originals Content-Disposition "attachment"
# Route API and media requests to the Rust backend. Set X-Real-IP from the
# real TCP peer and overwrite any client-supplied value so the backend's
# rate-limit keys can't be spoofed via a forged X-Forwarded-For.
reverse_proxy /api/* app:3000 {
header_up X-Real-IP {remote_host}
}
reverse_proxy /media/* app:3000 {
header_up X-Real-IP {remote_host}
}
# API — never cache
@api path /api/*
header @api Cache-Control "no-store"
# Route API and media requests to the Rust backend
reverse_proxy /api/* app:3000
reverse_proxy /media/* app:3000
# Everything else goes to SvelteKit frontend
reverse_proxy frontend:3001
# Everything else goes to SvelteKit frontend
reverse_proxy frontend:3001
}

View File

@@ -46,90 +46,6 @@ is the smallest patch.
- [frontend/src/lib/components/Toaster.svelte](frontend/src/lib/components/Toaster.svelte) — add passthrough marker (if approach 2) or move to a portal (if approach 1)
- [frontend/src/app.html](frontend/src/app.html) — add `<div id="modal-root">` (if approach 1)
## Feed — DOM-windowing virtualization (IMPLEMENTED — residual validation owed)
**Status.** Implemented in [frontend/src/lib/components/VirtualFeed.svelte](frontend/src/lib/components/VirtualFeed.svelte)
using `@tanstack/svelte-virtual`'s `createWindowVirtualizer`. Both the **list**
view (dynamic `measureElement` heights, keyed by upload id with `anchorTo:'start'`
so an SSE prepend doesn't yank a scrolled-down reader) and the **grid** view
(three measured square tiles per row) now keep only the on-screen window (+overscan)
in the DOM instead of one node per upload. The window virtualizer scrolls the
document, so the sticky header, pull-to-refresh, the infinite-scroll sentinel and
the bottom nav are untouched. The earlier `content-visibility` band-aid was removed
from `FeedListCard` (it interferes with real-height measurement), and the old
`FeedGrid.svelte` was deleted (its sole consumer migrated to `VirtualFeed`).
**Verified.** `svelte-check` 0 errors, production build clean. The integration
follows the library's documented window-virtualizer contract (confirmed against
`virtual-core` source: item `start` includes `scrollMargin`, `getTotalSize()`
excludes it; the SSR path is guarded by `getScrollElement()` returning null).
**Residual validation owed (needs the running app — could not be done headless).**
- Manual scroll testing on a ~1000-item event: confirm no jank, correct scrollbar
size, and that an SSE `new-upload` / `feed-delta` prepend while scrolled down does
not jump the viewport (the `anchorTo:'start'` + id-key path).
- `scrollMargin` re-measure when grid filter chips change the header height (handled
reactively via the `uploads`-length-driven effect, but unverified visually).
- A new e2e spec that scrolls far down, likes an item via SSE, and asserts scroll
position is retained — the existing suite only asserts a single card is visible,
so it cannot catch a scroll regression.
**Files.**
- [frontend/src/lib/components/VirtualFeed.svelte](frontend/src/lib/components/VirtualFeed.svelte) — new windowing component (list + grid)
- [frontend/src/routes/feed/+page.svelte](frontend/src/routes/feed/+page.svelte) — renders `VirtualFeed` for both views
- [frontend/src/lib/components/FeedListCard.svelte](frontend/src/lib/components/FeedListCard.svelte) — `content-visibility` removed
**Known limitations (surfaced in the post-commit review, left as-is — low impact).**
- **Grid prepend reflows tiles.** Grid rows are index-keyed and pack 3 tiles each, so
an SSE `new-upload` shifts every tile by one position; `anchorTo:'start'` can only
anchor a scrolled grid reader when the prepend crosses a 3-item boundary (it adds a
*row*). List view is unaffected (one id-keyed row per upload). A fix would key rows
by the first tile's id and accept partial-row churn; not worth it for the rarer
"new upload while browsing the grid" case.
- **Filtered grid can auto-load the whole feed.** When a grid filter matches few items
the `VirtualFeed` is short, so the infinite-scroll sentinel sits in-viewport and
`loadMore()` fires until `nextCursor` is null — pulling all pages to widen the
client-side search. This is pre-existing (the old `FeedGrid` had the same shape, and
the empty-filter copy even says "scrolle weiter"), not a virtualization regression.
If undesired, gate auto-load to list view or to actual user scroll.
## Feed — comment deletion leaves a stale live count
**Problem.** `add_comment` now broadcasts a fresh `comment_count` so feed clients patch
the card in place, but `delete_comment` and `host_delete_comment`
([backend/src/handlers/social.rs](backend/src/handlers/social.rs),
[host.rs](backend/src/handlers/host.rs)) soft-delete without broadcasting any
count/event. So a deletion leaves the count too high on every client until a full
refetch (pull-to-refresh or an unrelated `upload-processed` merge). `toggle_like`
already broadcasts on both add and remove, so likes are fine — the gap is
comments-on-delete. Pre-existing, but the in-place-patch scheme makes it observable.
**Fix.** Emit a `new-comment` (or a `comment-deleted`) event carrying the refreshed
`comment_count` from both delete paths, the same best-effort way `add_comment` does;
the frontend `patchCount(..., 'comment_count')` handler already consumes it.
**Note — count ordering.** The broadcast count is read just after the (auto-committed)
mutation, not inside it, so under concurrent likes/comments on one upload the SSE
messages are last-write-wins. The frontend *replaces* (not increments) the count, so
steady state is correct and self-healing; only document this if strict per-event
ordering is ever required (then compute the count in-tx with a monotonic sequence).
## Feed — per-image exact CLS reservation
**Problem.** `FeedListCard` now reserves a default `aspect-[4/5]` box for photos so
the card doesn't collapse to height 0 and reflow as images stream in (matching
`Skeleton`). But no image dimensions are stored anywhere (not on `FeedUpload`, the
`upload` table, or any migration), so the box is a uniform guess that crops to fit —
the original is one tap away in the lightbox.
**Acceptance criterion.** Extract image width/height during the compression worker,
store them on `upload`, expose them on `FeedUpload`, and have the card reserve the
*true* aspect ratio (no crop, zero shift).
**Files to touch.**
- backend: `services/compression.rs`, `models/upload.rs`, `handlers/feed.rs`, a migration
- [frontend/src/lib/types.ts](frontend/src/lib/types.ts), [FeedListCard.svelte](frontend/src/lib/components/FeedListCard.svelte)
## Smaller nits, optional
- **Auto-submit on retried 4th digit.** [recover/+page.svelte](frontend/src/routes/recover/+page.svelte), [join/+page.svelte](frontend/src/routes/join/+page.svelte) — after a wrong PIN, deleting one digit and retyping triggers an immediate submit. Backend's 3-attempts/15-min lockout makes this safe; could feel hair-trigger after a typo. Consider gating the second auto-submit per input session behind an explicit button press.

View File

@@ -77,7 +77,6 @@ eventsnap/
│ ├── svelte.config.js
│ └── Dockerfile
├── docker-compose.yml
├── docker-compose.dev.yml # opt-in dev overlay (publishes Postgres on the host)
├── Caddyfile
└── .env.example
```
@@ -108,11 +107,6 @@ docker compose up -d
Caddy automatically obtains a Let's Encrypt certificate on first start. The app is live at `https://DOMAIN` within ~30 seconds.
> **Production note:** `docker compose up -d` does **not** expose the database — Postgres is reachable only on the internal Docker network. For local development where you need host access to Postgres, opt into the dev overlay explicitly:
> ```bash
> docker compose -f docker-compose.yml -f docker-compose.dev.yml up
> ```
### Generate required secrets
```bash

View File

@@ -18,18 +18,18 @@ RUN touch src/main.rs && cargo build --release
# --- Runtime stage ---
FROM alpine:3.21
RUN apk add --no-cache ca-certificates ffmpeg
# Run as a non-root user. Pre-create and chown the media mount path so the fresh
# named volume inherits the non-root ownership (Docker seeds an empty named volume
# from the image directory, preserving its uid/gid) and uploads can be written.
RUN addgroup -S app && adduser -S app -G app
# Run as an unprivileged user (defense-in-depth). The media volume mounts at
# /media; creating it here as `app` means a fresh named volume inherits app
# ownership and is writable without running as root. (An existing root-owned
# volume from a prior deploy needs a one-time `chown -R app:app` — see deploy notes.)
RUN apk add --no-cache ca-certificates ffmpeg \
&& addgroup -S app && adduser -S -G app app \
&& mkdir -p /media && chown app:app /media
WORKDIR /app
COPY --from=builder /app/target/release/eventsnap-backend ./
RUN chown -R app:app /app
RUN mkdir -p /media && chown -R app:app /app /media
USER app
EXPOSE 3000
CMD ["./eventsnap-backend"]

View File

@@ -0,0 +1,23 @@
-- Restore the original join-based v_feed definition.
CREATE OR REPLACE VIEW v_feed AS
SELECT
u.id,
u.event_id,
u.user_id,
usr.display_name AS uploader_name,
usr.is_banned,
usr.uploads_hidden,
u.preview_path,
u.thumbnail_path,
u.mime_type,
u.caption,
u.created_at,
COUNT(DISTINCT l.user_id) AS like_count,
COUNT(DISTINCT c.id) AS comment_count
FROM upload u
JOIN "user" usr ON u.user_id = usr.id
LEFT JOIN "like" l ON l.upload_id = u.id
LEFT JOIN comment c ON c.upload_id = u.id AND c.deleted_at IS NULL
WHERE u.deleted_at IS NULL
AND usr.uploads_hidden = FALSE
GROUP BY u.id, usr.display_name, usr.is_banned, usr.uploads_hidden;

View File

@@ -0,0 +1,26 @@
-- H6: replace v_feed's double LEFT JOIN + COUNT(DISTINCT) (which materializes a
-- likes×comments Cartesian per upload before de-duping) with correlated scalar
-- subqueries. Each count now uses its own index (idx_like_upload /
-- idx_comment_upload) and there is no GROUP BY. The output columns are
-- unchanged, so every consumer keeps working.
CREATE OR REPLACE VIEW v_feed AS
SELECT
u.id,
u.event_id,
u.user_id,
usr.display_name AS uploader_name,
usr.is_banned,
usr.uploads_hidden,
u.preview_path,
u.thumbnail_path,
u.mime_type,
u.caption,
u.created_at,
(SELECT COUNT(*) FROM "like" l
WHERE l.upload_id = u.id) AS like_count,
(SELECT COUNT(*) FROM comment c
WHERE c.upload_id = u.id AND c.deleted_at IS NULL) AS comment_count
FROM upload u
JOIN "user" usr ON u.user_id = usr.id
WHERE u.deleted_at IS NULL
AND usr.uploads_hidden = FALSE;

View File

@@ -15,6 +15,7 @@ use crate::models::event::Event;
use crate::models::session::Session;
use crate::models::user::{User, UserRole};
use crate::services::config;
use crate::services::password;
use crate::services::rate_limiter::client_ip;
use crate::state::AppState;
@@ -39,13 +40,23 @@ pub async fn join(
let ip = client_ip(&headers, "unknown");
let rate_limits_on = config::get_bool(&state.pool, "rate_limits_enabled", true).await;
let join_rate_on = config::get_bool(&state.pool, "join_rate_enabled", true).await;
if rate_limits_on && join_rate_on
&& !state.rate_limiter.check(format!("join:{ip}"), 5, Duration::from_secs(60))
{
return Err(AppError::TooManyRequests(
"Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
None,
));
if rate_limits_on && join_rate_on {
// Short burst cap (typos / double-taps) AND a generous per-IP daily cap
// on account creation. The daily cap bounds mass account-minting (which
// otherwise resets the per-user upload budget, see the event-wide count
// quota in the upload handler) while staying well above a real ~100-guest
// venue sharing one NAT'd IP.
let burst_ok =
state.rate_limiter.check(format!("join:{ip}"), 5, Duration::from_secs(60));
let daily_ok = state
.rate_limiter
.check(format!("join_day:{ip}"), 200, Duration::from_secs(86_400));
if !burst_ok || !daily_ok {
return Err(AppError::TooManyRequests(
"Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
None,
));
}
}
let display_name = body.display_name.trim();
@@ -78,10 +89,10 @@ pub async fn join(
)));
}
// Generate a 4-digit PIN
let pin: String = format!("{:04}", rand::rng().random_range(0..10000u32));
let pin_hash =
bcrypt::hash(&pin, 12).map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
// Generate a 6-digit PIN (≈20 bits vs the old ~13.3, so brute-forcing it
// against the 3-strike lockout is far harder).
let pin: String = format!("{:06}", rand::rng().random_range(0..1_000_000u32));
let pin_hash = password::hash(pin.clone(), 12).await?;
let user = User::create(&state.pool, event.id, display_name, &pin_hash).await?;
@@ -164,24 +175,22 @@ pub async fn recover(
}
for user in &users {
// Check PIN lockout. If the lockout has expired, also reset the failed-attempt
// counter so the user gets a fresh 3-strike window — otherwise the counter
// stays at 3+ and every subsequent wrong PIN immediately re-locks them, even
// after waiting out the cooldown. Without this reset, a once-locked account
// is effectively permanently fragile.
// Check PIN lockout. The failed-attempt counter is deliberately NOT reset
// when the window expires — it drives the *escalating* backoff below, so a
// determined guesser faces an exponentially growing wait. A successful
// recovery (and a host PIN reset) is what clears it. The counter only ever
// grows on a *wrong* PIN, so a legitimate user is unaffected.
if let Some(locked_until) = user.pin_locked_until {
if Utc::now() < locked_until {
return Err(AppError::TooManyRequests(
"Zu viele Versuche. Bitte warte 15 Minuten.".into(),
"Zu viele Versuche. Bitte warte und versuche es später erneut.".into(),
None,
));
}
// Lockout window expired — wipe the counter and the timestamp.
User::reset_pin_attempts(&state.pool, user.id).await?;
}
let pin_matches = bcrypt::verify(&body.pin, &user.recovery_pin_hash)
.unwrap_or(false);
let pin_matches =
password::verify(body.pin.clone(), user.recovery_pin_hash.clone()).await;
if pin_matches {
// Reset failed attempts on success
@@ -216,13 +225,19 @@ pub async fn recover(
"recover: wrong PIN"
);
if attempts >= 3 {
let lockout = Utc::now() + chrono::Duration::minutes(15);
// Escalating backoff: 15min, 30, 60, … doubling per failure past the
// threshold, capped at 24h. Makes sustained guessing against the
// 6-digit space (1M combinations) astronomically slow.
let exp = (attempts as u32).saturating_sub(3).min(10);
let minutes = 15i64.saturating_mul(1i64 << exp).min(24 * 60);
let lockout = Utc::now() + chrono::Duration::minutes(minutes);
User::lock_pin(&state.pool, user.id, lockout).await?;
tracing::warn!(
user_id = %user.id,
event_id = %event.id,
ip = %ip,
"recover: account locked for 15 minutes"
minutes,
"recover: account locked (escalating backoff)"
);
}
}
@@ -256,6 +271,22 @@ pub async fn admin_login(
// a long-running guess campaign. 5 attempts / minute / IP is plenty for
// honest typos.
let ip = client_ip(&headers, "unknown");
// Hard, non-disableable floor: admin_login is the one credential endpoint
// whose brute-force protection must survive the DB rate-limit master toggle
// being turned off. 30 attempts / 5 min / IP is generous for typos but caps
// sustained guessing regardless of config.
if !state.rate_limiter.check(
format!("admin_login_floor:{ip}"),
30,
Duration::from_secs(300),
) {
return Err(AppError::TooManyRequests(
"Zu viele Anmeldeversuche. Bitte warte kurz und versuche es erneut.".into(),
None,
));
}
let rate_limits_on = config::get_bool(&state.pool, "rate_limits_enabled", true).await;
let admin_rate_on = config::get_bool(&state.pool, "admin_login_rate_enabled", true).await;
if rate_limits_on && admin_rate_on
@@ -271,8 +302,11 @@ pub async fn admin_login(
));
}
let valid = bcrypt::verify(&body.password, &state.config.admin_password_hash)
.unwrap_or(false);
let valid = password::verify(
body.password.clone(),
state.config.admin_password_hash.clone(),
)
.await;
if !valid {
tracing::warn!(ip = %ip, "admin_login: wrong password");
@@ -298,8 +332,7 @@ pub async fn admin_login(
let dummy_pin: String = (0..32)
.map(|_| rand::rng().random_range(b'a'..=b'z') as char)
.collect();
let dummy_hash = bcrypt::hash(&dummy_pin, 4)
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
let dummy_hash = password::hash(dummy_pin, 4).await?;
let user = User::create(&state.pool, event.id, admin_name, &dummy_hash).await?;
sqlx::query("UPDATE \"user\" SET role = 'admin' WHERE id = $1")
.bind(user.id)

View File

@@ -1,4 +1,4 @@
use axum::extract::{FromRequestParts, State};
use axum::extract::FromRequestParts;
use axum::http::request::Parts;
use uuid::Uuid;
@@ -38,16 +38,32 @@ impl FromRequestParts<AppState> for AuthUser {
let token_hash = jwt::hash_token(token);
let session = Session::find_by_token_hash(&state.pool, &token_hash)
// Reconcile the session against the live `user` row. We do NOT trust the
// JWT claims for role/ban/event — a token can outlive a ban or demotion
// (default lifetime 30 days). The single JOIN query is the same number
// of round-trips as the old existence check.
let ctx = Session::find_auth_context(&state.pool, &token_hash)
.await
.map_err(|e| AppError::Internal(e.into()))?
.ok_or_else(|| AppError::Unauthorized("Sitzung nicht gefunden oder abgelaufen.".into()))?;
// Ban takes effect immediately on the next request, regardless of the
// token's remaining lifetime.
if ctx.is_banned {
return Err(AppError::Forbidden("Du bist gesperrt.".into()));
}
// Defend against a JWT that was issued for a different user/event than
// the session's DB row points at (e.g. a swapped or replayed token).
if claims.sub != ctx.user_id || claims.event_id != ctx.event_id {
return Err(AppError::Unauthorized("Token passt nicht zur Sitzung.".into()));
}
// Update last_seen_at in the background (fire-and-forget). Failures are
// non-fatal but worth surfacing — silent swallowing hides DB connection
// pressure that would otherwise be the first symptom of a real problem.
let pool = state.pool.clone();
let session_id = session.id;
let session_id = ctx.session_id;
tokio::spawn(async move {
if let Err(e) = Session::touch(&pool, session_id).await {
tracing::warn!(error = ?e, session_id = %session_id, "session touch failed");
@@ -55,9 +71,11 @@ impl FromRequestParts<AppState> for AuthUser {
});
Ok(Self {
user_id: claims.sub,
event_id: claims.event_id,
role: claims.role,
user_id: ctx.user_id,
event_id: ctx.event_id,
// Live role from the DB, not the claim — demotion/promotion takes
// effect on the next request.
role: ctx.role,
token_hash,
})
}

View File

@@ -25,18 +25,30 @@ impl AppConfig {
let is_prod = app_env.eq_ignore_ascii_case("production");
let jwt_secret = std::env::var("JWT_SECRET").context("JWT_SECRET must be set")?;
if jwt_secret == DEV_JWT_SECRET_SENTINEL {
if is_prod {
// A weak/placeholder signing key lets anyone forge any token (including
// admin). Detect the known dev sentinel, the `.env.example` placeholder,
// and anything that smells like an unreplaced template value.
let lower = jwt_secret.to_ascii_lowercase();
let looks_placeholder = jwt_secret == DEV_JWT_SECRET_SENTINEL
|| lower.contains("change")
|| lower.contains("example")
|| lower.contains("placeholder")
|| lower.contains("replace");
if is_prod {
// Production must use a real, strong secret — no placeholders, ≥64
// chars (an `openssl rand -hex 64` is 128 hex chars).
if looks_placeholder || jwt_secret.len() < 64 {
return Err(anyhow!(
"Refusing to start in production with the well-known dev JWT_SECRET — \
rotate it (openssl rand -hex 64)."
"Refusing to start in production: JWT_SECRET is a placeholder or too short. \
Generate a real one (openssl rand -hex 64) and set it in the prod environment."
));
}
} else if looks_placeholder || jwt_secret.len() < 32 {
tracing::warn!(
"JWT_SECRET is the dev sentinel — fine for local development, NEVER ship this."
"JWT_SECRET looks like a dev/placeholder value — fine for local development, \
NEVER ship this to production."
);
} else if jwt_secret.len() < 32 {
return Err(anyhow!("JWT_SECRET must be at least 32 characters."));
}
Ok(Self {

View File

@@ -1,7 +1,7 @@
use std::collections::HashMap;
use std::time::Duration;
use axum::extract::{Query, State};
use axum::extract::State;
use axum::http::{HeaderMap, StatusCode};
use axum::Json;
use serde::{Deserialize, Serialize};
@@ -223,44 +223,11 @@ pub async fn get_export_jobs(
// ── Export download endpoints (authenticated guests) ─────────────────────────
#[derive(Deserialize)]
pub struct DownloadQuery {
pub ticket: String,
}
/// Mint a short-lived ticket for a browser-driven export download. The download
/// is a top-level navigation so the multi-GB ZIP streams straight to disk instead
/// of being buffered in memory by `fetch()` + `blob()` — but a navigation can't
/// carry an `Authorization` header, so the client exchanges its Bearer token for
/// an opaque ticket here, then hits `/export/zip?ticket=...`. Reuses the same
/// single-use, 30s-TTL store as the SSE stream.
pub async fn export_ticket(
State(state): State<AppState>,
auth: crate::auth::middleware::AuthUser,
) -> Json<serde_json::Value> {
let ticket = state.sse_tickets.issue(auth.token_hash);
Json(serde_json::json!({ "ticket": ticket }))
}
/// Validate a download ticket (single-use) and confirm its session still exists.
async fn authenticate_download_ticket(state: &AppState, ticket: &str) -> Result<(), AppError> {
let token_hash = state
.sse_tickets
.consume(ticket)
.ok_or_else(|| AppError::Unauthorized("Ticket ungültig oder abgelaufen.".into()))?;
crate::models::session::Session::find_by_token_hash(&state.pool, &token_hash)
.await
.map_err(|e| AppError::Internal(e.into()))?
.ok_or_else(|| AppError::Unauthorized("Sitzung nicht gefunden.".into()))?;
Ok(())
}
pub async fn download_zip(
State(state): State<AppState>,
Query(q): Query<DownloadQuery>,
_auth: crate::auth::middleware::AuthUser,
headers: HeaderMap,
) -> Result<axum::response::Response, AppError> {
authenticate_download_ticket(&state, &q.ticket).await?;
enforce_export_rate(&state, &headers).await?;
let event = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug)
@@ -283,10 +250,9 @@ pub async fn download_zip(
pub async fn download_html(
State(state): State<AppState>,
Query(q): Query<DownloadQuery>,
_auth: crate::auth::middleware::AuthUser,
headers: HeaderMap,
) -> Result<axum::response::Response, AppError> {
authenticate_download_ticket(&state, &q.ticket).await?;
enforce_export_rate(&state, &headers).await?;
let event = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug)

View File

@@ -10,6 +10,7 @@ use uuid::Uuid;
use crate::auth::middleware::AuthUser;
use crate::error::AppError;
use crate::services::config;
use crate::services::media_token;
use crate::services::rate_limiter::client_ip;
use crate::state::AppState;
@@ -27,6 +28,8 @@ pub struct FeedUpload {
pub uploader_name: String,
pub preview_url: Option<String>,
pub thumbnail_url: Option<String>,
/// Signed gateway URL for the full-resolution original. Always present.
pub original_url: Option<String>,
pub mime_type: String,
pub caption: Option<String>,
pub like_count: i64,
@@ -55,6 +58,33 @@ struct FeedRow {
created_at: DateTime<Utc>,
}
/// Build a feed DTO, minting fresh signed gateway URLs for each artifact. The
/// preview/thumbnail URLs are present only when the derivative exists so the
/// client can show a skeleton while compression is still running; the original
/// URL is always present.
fn to_feed_upload(r: FeedRow, liked: bool, secret: &str, now: i64) -> FeedUpload {
FeedUpload {
liked_by_me: liked,
preview_url: r
.preview_path
.as_ref()
.map(|_| media_token::signed_url(secret, "preview", r.id, now)),
thumbnail_url: r
.thumbnail_path
.as_ref()
.map(|_| media_token::signed_url(secret, "thumbnail", r.id, now)),
original_url: Some(media_token::signed_url(secret, "original", r.id, now)),
id: r.id,
user_id: r.user_id,
uploader_name: r.uploader_name,
mime_type: r.mime_type,
caption: r.caption,
like_count: r.like_count,
comment_count: r.comment_count,
created_at: r.created_at,
}
}
pub async fn feed(
State(state): State<AppState>,
auth: AuthUser,
@@ -135,24 +165,13 @@ pub async fn feed(
let upload_ids: Vec<Uuid> = rows.iter().map(|r| r.id).collect();
let liked_set = get_liked_set(&state.pool, auth.user_id, &upload_ids).await;
let now = Utc::now().timestamp();
let secret = &state.config.jwt_secret;
let uploads = rows
.into_iter()
.map(|r| {
let preview_url = r.preview_path.map(|p| format!("/media/{p}"));
let thumbnail_url = r.thumbnail_path.map(|p| format!("/media/{p}"));
FeedUpload {
liked_by_me: liked_set.contains(&r.id),
id: r.id,
user_id: r.user_id,
uploader_name: r.uploader_name,
preview_url,
thumbnail_url,
mime_type: r.mime_type,
caption: r.caption,
like_count: r.like_count,
comment_count: r.comment_count,
created_at: r.created_at,
}
let liked = liked_set.contains(&r.id);
to_feed_upload(r, liked, secret, now)
})
.collect();
@@ -171,57 +190,90 @@ pub struct DeltaQuery {
pub struct DeltaResponse {
pub uploads: Vec<FeedUpload>,
pub deleted_ids: Vec<Uuid>,
/// Set when the delta was clamped (too-old cursor) or hit the row cap — the
/// client should do a full feed reload instead of trusting the partial set.
pub reload_required: bool,
}
/// Hard cap on how many uploads one delta returns. Beyond this the client is
/// told to reload rather than streaming the whole gallery through the view.
const DELTA_LIMIT: i64 = 200;
/// How far back a client-supplied `since` may reach. A tab backgrounded for days
/// must not pull the entire event on reconnect.
const DELTA_MAX_LOOKBACK_DAYS: i64 = 7;
pub async fn feed_delta(
State(state): State<AppState>,
auth: AuthUser,
Query(q): Query<DeltaQuery>,
) -> Result<Json<DeltaResponse>, AppError> {
// H7: feed_delta runs the (expensive) feed query and fires on every tab
// refocus, so it needs the same rate limit as feed(), keyed per user.
let rate_limits_on = config::get_bool(&state.pool, "rate_limits_enabled", true).await;
let feed_rate_on = config::get_bool(&state.pool, "feed_rate_enabled", true).await;
if rate_limits_on && feed_rate_on {
let rate_limit = config::get_usize(&state.pool, "feed_rate_per_min", 60).await;
if !state.rate_limiter.check(
format!("feed_delta:{}", auth.user_id),
rate_limit,
Duration::from_secs(60),
) {
return Err(AppError::TooManyRequests(
"Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
None,
));
}
}
// Clamp the lookback server-side; signal a full reload if we had to.
let min_since = Utc::now() - chrono::Duration::days(DELTA_MAX_LOOKBACK_DAYS);
let clamped = q.since < min_since;
let since = if clamped { min_since } else { q.since };
let rows = sqlx::query_as::<_, FeedRow>(
"SELECT id, user_id, uploader_name, preview_path, thumbnail_path,
mime_type, caption, like_count, comment_count, created_at
FROM v_feed
WHERE event_id = $1 AND created_at > $2
ORDER BY created_at DESC",
ORDER BY created_at DESC
LIMIT $3",
)
.bind(auth.event_id)
.bind(q.since)
.bind(since)
.bind(DELTA_LIMIT + 1)
.fetch_all(&state.pool)
.await?;
let capped = rows.len() as i64 > DELTA_LIMIT;
let rows: Vec<FeedRow> = rows.into_iter().take(DELTA_LIMIT as usize).collect();
let reload_required = clamped || capped;
let deleted_ids: Vec<(Uuid,)> = sqlx::query_as(
"SELECT id FROM upload
WHERE event_id = $1 AND deleted_at IS NOT NULL AND deleted_at > $2",
)
.bind(auth.event_id)
.bind(q.since)
.bind(since)
.fetch_all(&state.pool)
.await?;
let upload_ids: Vec<Uuid> = rows.iter().map(|r| r.id).collect();
let liked_set = get_liked_set(&state.pool, auth.user_id, &upload_ids).await;
let now = Utc::now().timestamp();
let secret = &state.config.jwt_secret;
let uploads = rows
.into_iter()
.map(|r| FeedUpload {
liked_by_me: liked_set.contains(&r.id),
id: r.id,
user_id: r.user_id,
uploader_name: r.uploader_name,
preview_url: r.preview_path.map(|p| format!("/media/{p}")),
thumbnail_url: r.thumbnail_path.map(|p| format!("/media/{p}")),
mime_type: r.mime_type,
caption: r.caption,
like_count: r.like_count,
comment_count: r.comment_count,
created_at: r.created_at,
.map(|r| {
let liked = liked_set.contains(&r.id);
to_feed_upload(r, liked, secret, now)
})
.collect();
Ok(Json(DeltaResponse {
uploads,
deleted_ids: deleted_ids.into_iter().map(|r| r.0).collect(),
reload_required,
}))
}

View File

@@ -9,10 +9,21 @@ 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::state::{AppState, SseEvent};
/// Revoke all of a user's sessions (best-effort). A failure here is logged but
/// never fails the surrounding admin action — the live-role/ban reconciliation
/// in `AuthUser` is the authoritative gate; revocation is defense-in-depth.
async fn revoke_sessions(pool: &sqlx::PgPool, user_id: Uuid, action: &str) {
match Session::delete_by_user_id(pool, user_id).await {
Ok(n) => tracing::info!(target_user_id = %user_id, revoked = n, action, "sessions revoked"),
Err(e) => tracing::warn!(error = ?e, target_user_id = %user_id, action, "session revoke failed"),
}
}
// ── DTOs ─────────────────────────────────────────────────────────────────────
#[derive(Serialize, sqlx::FromRow)]
@@ -120,6 +131,14 @@ pub async fn ban_user(
.execute(&state.pool)
.await?;
revoke_sessions(&state.pool, user_id, "ban_user").await;
// Tell the banned user's online devices to log out immediately.
let _ = state.sse_tx.send(SseEvent::new(
"user-banned",
serde_json::json!({ "user_id": user_id }).to_string(),
));
tracing::info!(
actor_user_id = %auth.user_id,
target_user_id = %user_id,
@@ -201,6 +220,13 @@ pub async fn set_role(
.bind(auth.event_id)
.execute(&state.pool)
.await?;
// Force a clean re-auth so the new role can't be cached client-side and so
// any in-flight tokens carrying the old role are invalidated. The live-role
// reconciliation in AuthUser already prevents privilege escalation, but
// revoking is cheaper to reason about.
revoke_sessions(&state.pool, user_id, "set_role").await;
tracing::info!(
actor_user_id = %auth.user_id,
target_user_id = %user_id,
@@ -256,14 +282,13 @@ pub async fn reset_user_pin(
}
}
let pin: String = format!("{:04}", rand::rng().random_range(0..10000u32));
let pin_hash =
bcrypt::hash(&pin, 12).map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
let pin: String = format!("{:06}", rand::rng().random_range(0..1_000_000u32));
let pin_hash = crate::services::password::hash(pin.clone(), 12).await?;
sqlx::query(
"UPDATE \"user\"
SET recovery_pin_hash = $1,
pin_failed_attempts = 0,
failed_pin_attempts = 0,
pin_locked_until = NULL
WHERE id = $2",
)
@@ -272,6 +297,10 @@ pub async fn reset_user_pin(
.execute(&state.pool)
.await?;
// A PIN reset must invalidate existing sessions so a compromised/old token
// can't outlive the reset.
revoke_sessions(&state.pool, user_id, "reset_user_pin").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(
@@ -294,24 +323,21 @@ pub async fn host_delete_upload(
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)
let paths = Upload::soft_delete_in_event(&state.pool, upload_id, auth.event_id)
.await?
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
let deleted = Upload::soft_delete_in_event(&state.pool, upload_id, auth.event_id).await?;
if !deleted {
return Err(AppError::NotFound("Upload nicht gefunden.".into()));
}
crate::services::media_fs::unlink_media(&state.config.media_path, &paths).await;
let _ = state.sse_tx.send(SseEvent::new(
"upload-deleted",
serde_json::json!({ "upload_id": upload.id }).to_string(),
serde_json::json!({ "upload_id": upload_id }).to_string(),
));
tracing::info!(
actor_user_id = %auth.user_id,
event_id = %auth.event_id,
upload_id = %upload.id,
upload_id = %upload_id,
"host: host_delete_upload"
);
@@ -377,28 +403,65 @@ pub async fn release_gallery(
.await?
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
if event.export_released_at.is_some() {
return Err(AppError::BadRequest("Galerie wurde bereits freigegeben.".into()));
// Atomically claim the release AND enqueue the jobs in one transaction (M8).
// The claim UPDATE row-locks the event row and the lock is held until commit
// — *after* the export_job rows exist — so a second concurrent press re-reads
// the now-present jobs and matches 0 rows (clean 400). Doing the UPDATE and
// INSERTs as separate autocommit statements left a cross-table TOCTOU window
// where both presses could win and race the same output files.
//
// Re-release guard (M8-3): allow when never released, OR nothing is currently
// in progress and at least one job terminally failed. The old "every job
// failed" guard left a one-sided failure (zip done, html failed) permanently
// unrecoverable.
let mut tx = state.pool.begin().await?;
let claim = sqlx::query(
"UPDATE event SET export_released_at = NOW()
WHERE slug = $1
AND (
export_released_at IS NULL
OR (
NOT EXISTS (
SELECT 1 FROM export_job j
WHERE j.event_id = event.id AND j.status IN ('running', 'pending')
)
AND EXISTS (
SELECT 1 FROM export_job j
WHERE j.event_id = event.id AND j.status = 'failed'
)
)
)",
)
.bind(&state.config.event_slug)
.execute(&mut *tx)
.await?;
if claim.rows_affected() == 0 {
tx.rollback().await.ok();
return Err(AppError::BadRequest(
"Galerie wurde bereits freigegeben.".into(),
));
}
sqlx::query("UPDATE event SET export_released_at = NOW() WHERE slug = $1")
.bind(&state.config.event_slug)
.execute(&state.pool)
.await?;
// Enqueue export jobs
// Enqueue export jobs, resetting any prior terminal rows so the workers
// re-run cleanly (including an already-`done` sibling on re-release).
for export_type in ["zip", "html"] {
sqlx::query(
"INSERT INTO export_job (event_id, type) VALUES ($1, $2::export_type)
ON CONFLICT (event_id, type) DO NOTHING",
ON CONFLICT (event_id, type)
DO UPDATE SET status = 'pending', progress_pct = 0,
error_message = NULL, completed_at = NULL",
)
.bind(event.id)
.bind(export_type)
.execute(&state.pool)
.execute(&mut *tx)
.await?;
}
// Spawn export workers
tx.commit().await?;
// Spawn export workers only after the claim+enqueue is durably committed.
crate::services::export::spawn_export_jobs(
event.id,
event.name,

View File

@@ -55,6 +55,9 @@ pub struct MeContextDto {
pub privacy_note: String,
pub quota_enabled: bool,
pub storage_quota_enabled: bool,
/// Whether uploads are currently locked for the event, so the client can show
/// a banner + disable the upload affordance on load (not just via SSE).
pub uploads_locked: bool,
}
pub async fn get_context(
@@ -68,6 +71,10 @@ pub async fn get_context(
let privacy_note = config::get_str(&state.pool, "privacy_note", "").await;
let quota_enabled = config::get_bool(&state.pool, "quota_enabled", true).await;
let storage_quota_enabled = config::get_bool(&state.pool, "storage_quota_enabled", true).await;
let uploads_locked = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug)
.await?
.map(|e| e.uploads_locked_at.is_some())
.unwrap_or(false);
Ok(Json(MeContextDto {
user_id: user.id,
@@ -76,5 +83,6 @@ pub async fn get_context(
privacy_note,
quota_enabled,
storage_quota_enabled,
uploads_locked,
}))
}

View File

@@ -0,0 +1,117 @@
//! Authenticated media gateway.
//!
//! Replaces the raw `/media` `ServeDir`. Every media byte now flows through a
//! signature check plus a DB lookup, so:
//! - soft-deleted uploads 404 (`find_by_id` filters `deleted_at`) — closes C3,
//! - ban-hidden uploaders' artifacts 404 (mirrors the `v_feed` rule) — H2,
//! - the export archives (which have no `upload` row) are unreachable — C1,
//! - HTML/SVG can't be served as an active document — the response carries
//! `nosniff` + a locked-down CSP (defense-in-depth behind the upload-time
//! allowlist) — C2 sink.
//!
//! Access is authorized by the embedded HMAC signature (see `media_token`), not
//! a Bearer header, because `<img>`/`<video>` cannot send one.
use axum::extract::{Path, Query, State};
use axum::response::Response;
use chrono::Utc;
use serde::Deserialize;
use uuid::Uuid;
use crate::error::AppError;
use crate::models::upload::Upload;
use crate::models::user::User;
use crate::services::media_token;
use crate::state::AppState;
#[derive(Deserialize)]
pub struct MediaQuery {
pub exp: i64,
pub sig: String,
}
pub async fn serve(
State(state): State<AppState>,
Path((kind, id)): Path<(String, Uuid)>,
Query(q): Query<MediaQuery>,
) -> Result<Response, AppError> {
if !media_token::verify(
&state.config.jwt_secret,
&kind,
id,
q.exp,
&q.sig,
Utc::now().timestamp(),
) {
return Err(AppError::Unauthorized(
"Ungültige oder abgelaufene Medien-URL.".into(),
));
}
// `find_by_id` filters `deleted_at IS NULL`, so soft-deleted uploads 404.
let upload = Upload::find_by_id(&state.pool, id)
.await?
.ok_or_else(|| AppError::NotFound("Datei nicht gefunden.".into()))?;
// Hide artifacts of users whose uploads were hidden by a host (ban + hide),
// matching the `usr.uploads_hidden = FALSE` predicate in `v_feed`.
let uploader = User::find_by_id(&state.pool, upload.user_id)
.await?
.ok_or_else(|| AppError::NotFound("Datei nicht gefunden.".into()))?;
if uploader.uploads_hidden {
return Err(AppError::NotFound("Datei nicht gefunden.".into()));
}
let (rel_path, content_type) = match kind.as_str() {
"original" => (upload.original_path.clone(), upload.mime_type.clone()),
"preview" => (
upload
.preview_path
.clone()
.ok_or_else(|| AppError::NotFound("Vorschau nicht verfügbar.".into()))?,
"image/jpeg".to_string(),
),
"thumbnail" => (
upload
.thumbnail_path
.clone()
.ok_or_else(|| AppError::NotFound("Vorschau nicht verfügbar.".into()))?,
"image/jpeg".to_string(),
),
_ => return Err(AppError::NotFound("Unbekannter Medientyp.".into())),
};
stream_file(&state.config.media_path.join(&rel_path), &content_type).await
}
async fn stream_file(path: &std::path::Path, content_type: &str) -> Result<Response, AppError> {
use axum::body::Body;
use axum::http::{header, StatusCode};
use tokio_util::io::ReaderStream;
let file = tokio::fs::File::open(path)
.await
.map_err(|_| AppError::NotFound("Datei nicht gefunden.".into()))?;
let metadata = file
.metadata()
.await
.map_err(|e| AppError::Internal(e.into()))?;
let stream = ReaderStream::new(file);
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, content_type)
.header(header::CONTENT_LENGTH, metadata.len())
// URLs are signed + time-boxed; cache only privately, aligned to the
// 1h URL-stability bucket.
.header(header::CACHE_CONTROL, "private, max-age=3600")
// Defense-in-depth against any active content slipping past the
// upload-time allowlist: never sniff, never script.
.header(header::X_CONTENT_TYPE_OPTIONS, "nosniff")
.header(
header::CONTENT_SECURITY_POLICY,
"default-src 'none'; sandbox; frame-ancestors 'none'",
)
.body(Body::from_stream(stream))
.map_err(|e| AppError::Internal(e.into()))
}

View File

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

View File

@@ -17,16 +17,9 @@ pub async fn toggle_like(
auth: AuthUser,
Path(upload_id): Path<Uuid>,
) -> Result<StatusCode, AppError> {
// Check if user is banned
let user = crate::models::user::User::find_by_id(&state.pool, auth.user_id)
.await?
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
if user.is_banned {
return Err(AppError::Forbidden("Du bist gesperrt.".into()));
}
// Event-scope: the upload must belong to the caller's event (404 otherwise),
// matching the host handlers' find_by_id_and_event pattern.
// Ban is already rejected by the AuthUser extractor. Scope the target to the
// caller's event and reject deleted uploads (M1: no cross-event IDOR, no
// likes on soft-deleted posts).
Upload::find_by_id_and_event(&state.pool, upload_id, auth.event_id)
.await?
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
@@ -50,23 +43,11 @@ pub async fn toggle_like(
.await?;
}
// Fresh count so feed clients can patch the single card in place instead of
// refetching page 1 (mirrors v_feed.like_count = COUNT(DISTINCT user_id)). The
// count + broadcast are a UI optimisation — the like itself is already committed,
// so a failure here must not fail the request. Swallow the error and skip the
// broadcast; the next event or a pull-to-refresh reconciles the count.
if let Ok(like_count) = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(DISTINCT user_id) FROM \"like\" WHERE upload_id = $1",
)
.bind(upload_id)
.fetch_one(&state.pool)
.await
{
let _ = state.sse_tx.send(crate::state::SseEvent {
event_type: "like-update".to_string(),
data: serde_json::json!({ "upload_id": upload_id, "like_count": like_count }).to_string(),
});
}
// Broadcast SSE
let _ = state.sse_tx.send(crate::state::SseEvent {
event_type: "like-update".to_string(),
data: serde_json::json!({ "upload_id": upload_id }).to_string(),
});
Ok(StatusCode::NO_CONTENT)
}
@@ -87,7 +68,8 @@ pub async fn list_comments(
Path(upload_id): Path<Uuid>,
Query(q): Query<ListCommentsQuery>,
) -> Result<Json<Vec<CommentDto>>, AppError> {
// Event-scope: only list comments for an upload in the caller's event.
// M1: a pure read behind any valid token must still be scoped to the
// caller's event and must not leak comments on soft-deleted uploads.
Upload::find_by_id_and_event(&state.pool, upload_id, auth.event_id)
.await?
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
@@ -108,18 +90,16 @@ pub async fn add_comment(
Path(upload_id): Path<Uuid>,
Json(body): Json<AddCommentRequest>,
) -> Result<(StatusCode, Json<CommentDto>), AppError> {
let user = crate::models::user::User::find_by_id(&state.pool, auth.user_id)
.await?
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
if user.is_banned {
return Err(AppError::Forbidden("Du bist gesperrt.".into()));
}
// Event-scope: only comment on an upload that belongs to the caller's event.
// M1: scope the target upload to the caller's event and reject deleted
// posts. (Ban is already handled by the AuthUser extractor.)
Upload::find_by_id_and_event(&state.pool, upload_id, auth.event_id)
.await?
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
let user = crate::models::user::User::find_by_id(&state.pool, auth.user_id)
.await?
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
let text = body.body.trim();
let text_chars = text.chars().count();
if text_chars == 0 || text_chars > 500 {
@@ -143,24 +123,11 @@ pub async fn add_comment(
.await?;
}
// Fresh count so feed clients can patch the single card in place instead of
// refetching page 1 (mirrors v_feed.comment_count = COUNT(DISTINCT c.id); COUNT(*)
// over the same deleted_at filter is identical since comment.id is the PK). The
// count + broadcast are a UI optimisation — the comment is already committed, so a
// failure here must not fail the request. Swallow the error and skip the broadcast.
if let Ok(comment_count) = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM comment WHERE upload_id = $1 AND deleted_at IS NULL",
)
.bind(upload_id)
.fetch_one(&state.pool)
.await
{
let _ = state.sse_tx.send(crate::state::SseEvent {
event_type: "new-comment".to_string(),
data: serde_json::json!({ "upload_id": upload_id, "comment_count": comment_count })
.to_string(),
});
}
// Broadcast SSE
let _ = state.sse_tx.send(crate::state::SseEvent {
event_type: "new-comment".to_string(),
data: serde_json::json!({ "upload_id": upload_id }).to_string(),
});
let dto = CommentDto {
id: comment.id,
@@ -187,11 +154,6 @@ pub async fn delete_comment(
return Err(AppError::Forbidden("Nur eigene Kommentare löschen.".into()));
}
// Event-scope: soft_delete_in_event only matches comments whose upload is in
// the caller's event, so a cross-event comment_id resolves to a 404 here.
let deleted = Comment::soft_delete_in_event(&state.pool, comment_id, auth.event_id).await?;
if !deleted {
return Err(AppError::NotFound("Kommentar nicht gefunden.".into()));
}
Comment::soft_delete(&state.pool, comment_id).await?;
Ok(StatusCode::NO_CONTENT)
}

View File

@@ -16,26 +16,34 @@ use crate::state::AppState;
const MAX_CAPTION_LENGTH: usize = 2000;
/// Allowlist of accepted media types, keyed by the MIME that `infer` derives from
/// the file's magic bytes. The detected MIME (not the client-declared one) is what
/// we trust, store, and hand to the compression pipeline — so a text-based payload
/// (SVG/HTML/JS) can never be stored or served on-origin. Each entry maps to the
/// server-controlled file extension we persist the original under.
/// Derive the canonical MIME type and a *safe* file extension purely from the
/// file's magic bytes, against a strict allowlist. The client-declared
/// `Content-Type` and the client filename are never trusted: the old code
/// skipped validation entirely for `application/*` and copied the extension
/// verbatim from the filename, which allowed storing `{uuid}.html`/`.svg` with
/// a script payload (stored XSS) and `/`-bearing extensions (path pollution +
/// export DoS).
///
/// HEIC/HEIF are deliberately excluded: the preview pipeline (`image` crate, and
/// the bundled ffmpeg 6.1) cannot decode them, so accepting them would store files
/// that never get a thumbnail. iOS Safari already transcodes HEIC→JPEG when a photo
/// is selected via a file input, so this rejects only the rare HEIC-preserving
/// upload path — with a clear error rather than a silently broken post.
const ALLOWED_MEDIA: &[(&str, &str)] = &[
("image/jpeg", "jpg"),
("image/png", "png"),
("image/webp", "webp"),
("image/gif", "gif"),
("video/mp4", "mp4"),
("video/quicktime", "mov"),
("video/webm", "webm"),
];
/// `infer` cannot fingerprint HTML/SVG, so the reject-by-default arm is exactly
/// what closes the XSS vector — anything not positively identified as an allowed
/// raster image or video is refused. Returns `(canonical_mime, safe_ext, is_video)`.
fn classify(data: &[u8]) -> Result<(&'static str, &'static str, bool), AppError> {
let kind = infer::get(data)
.ok_or_else(|| AppError::BadRequest("Dateityp konnte nicht erkannt werden.".into()))?;
match kind.mime_type() {
"image/jpeg" => Ok(("image/jpeg", "jpg", false)),
"image/png" => Ok(("image/png", "png", false)),
"image/webp" => Ok(("image/webp", "webp", false)),
// infer reports HEIC/HEIF as image/heif
"image/heif" | "image/heic" => Ok(("image/heic", "heic", false)),
"video/mp4" => Ok(("video/mp4", "mp4", true)),
"video/quicktime" => Ok(("video/quicktime", "mov", true)),
"video/webm" => Ok(("video/webm", "webm", true)),
other => Err(AppError::BadRequest(format!(
"Dateityp nicht erlaubt: {other}. Erlaubt sind JPEG, PNG, WebP, HEIC, MP4, MOV, WebM."
))),
}
}
pub async fn upload(
State(state): State<AppState>,
@@ -60,14 +68,11 @@ pub async fn upload(
}
}
// Check if user is banned
// Ban is already rejected by the AuthUser extractor. We still load the user
// for the uploader's display name in the response DTO.
let user = User::find_by_id(&state.pool, auth.user_id)
.await?
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
if user.is_banned {
drain_multipart(multipart).await;
return Err(AppError::Forbidden("Du bist gesperrt.".into()));
}
// Check if uploads are locked
let event = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug)
@@ -82,7 +87,17 @@ pub async fn upload(
let max_image_mb: i64 = config::get_i64(&state.pool, "max_image_size_mb", 20).await;
let max_video_mb: i64 = config::get_i64(&state.pool, "max_video_size_mb", 500).await;
let mut file_data: Option<Vec<u8>> = None;
// Bound concurrent upload-body buffering to cap aggregate RAM (H3). Acquired
// *before* the multipart body is read, so requests waiting on a permit hold
// only a connection, not a buffered ~550 MB file. The permit is held for the
// rest of the handler (body read + write) and released on return.
let _upload_permit = state
.upload_limiter
.acquire()
.await
.map_err(|e| AppError::Internal(e.into()))?;
let mut file_data: Option<axum::body::Bytes> = None;
let mut caption: Option<String> = None;
let mut hashtags_csv: Option<String> = None;
@@ -90,13 +105,13 @@ pub async fn upload(
let name = field.name().unwrap_or_default().to_string();
match name.as_str() {
"file" => {
// Note: the client-declared filename and Content-Type are intentionally
// ignored — the stored MIME and extension are derived from the file's
// magic bytes below, so a mislabelled payload can't influence them.
// The client-declared filename and Content-Type are deliberately
// ignored — the stored MIME and extension are derived from the
// file's magic bytes (see `classify`). Kept as `Bytes` (one copy
// off the wire) rather than an extra `.to_vec()`.
file_data = Some(
field.bytes().await
.map_err(|e| AppError::BadRequest(format!("Datei konnte nicht gelesen werden: {e}")))?
.to_vec(),
.map_err(|e| AppError::BadRequest(format!("Datei konnte nicht gelesen werden: {e}")))?,
);
}
"caption" => {
@@ -130,26 +145,13 @@ pub async fn upload(
}
}
// Determine the file type from its magic bytes and require it to be on the
// allowlist. `infer` returns None for text-based payloads (SVG/HTML/JS), so
// 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
// client-supplied values.
let kind = infer::get(&data)
.ok_or_else(|| AppError::BadRequest("Dateityp nicht erkannt oder nicht unterstützt.".into()))?;
let (mime, ext) = ALLOWED_MEDIA
.iter()
.find(|(allowed, _)| *allowed == kind.mime_type())
.map(|(m, e)| ((*m).to_string(), *e))
.ok_or_else(|| {
AppError::BadRequest(format!(
"Dateityp wird nicht unterstützt: {}.",
kind.mime_type()
))
})?;
// Derive canonical MIME + safe extension from magic bytes against a strict
// allowlist. The client Content-Type and filename are never trusted.
let (canonical_mime, safe_ext, is_video) = classify(&data)?;
let canonical_mime = canonical_mime.to_string();
// Validate file size
let max_bytes = if mime.starts_with("video/") {
// Validate file size against the per-category limit.
let max_bytes = if is_video {
max_video_mb * 1024 * 1024
} else {
max_image_mb * 1024 * 1024
@@ -161,53 +163,120 @@ pub async fn upload(
)));
}
let upload_id = Uuid::new_v4();
let event_slug = &state.config.event_slug;
let relative_path = format!("originals/{event_slug}/{upload_id}.{safe_ext}");
let absolute_path = state.config.media_path.join(&relative_path);
// 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
// disable it on trusted instances.
// disable it on trusted instances. `None` ⇒ enforcement off.
let quota_on = config::get_bool(&state.pool, "quota_enabled", true).await;
let storage_quota_on = config::get_bool(&state.pool, "storage_quota_enabled", true).await;
if quota_on && storage_quota_on {
let estimate = compute_storage_quota(&state).await;
if let Some(limit) = estimate.limit_bytes {
let prospective_total = user.total_upload_bytes.saturating_add(size);
if prospective_total > limit {
return Err(AppError::TooManyRequests(
"Du hast dein Upload-Limit für dieses Event erreicht.".into(),
None,
));
}
let quota_limit: Option<i64> = if quota_on && storage_quota_on {
compute_storage_quota(&state).await.limit_bytes
} else {
None
};
// Event-wide file-count cap. Unlike the per-user budget, this can't be reset
// by minting a fresh account via /join, so it bounds total uploads no matter
// how many accounts an abuser creates. Wires up the previously-dead
// `upload_count_quota_*` config (default off to preserve existing behavior).
if quota_on && config::get_bool(&state.pool, "upload_count_quota_enabled", false).await {
let max_count = config::get_i64(&state.pool, "upload_count_quota_max", 10_000).await;
let (count,): (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM upload WHERE event_id = $1 AND deleted_at IS NULL",
)
.bind(auth.event_id)
.fetch_one(&state.pool)
.await?;
if count >= max_count {
return Err(AppError::TooManyRequests(
"Das Upload-Limit für dieses Event ist erreicht.".into(),
None,
));
}
}
let upload_id = Uuid::new_v4();
let event_slug = &state.config.event_slug;
let relative_path = format!("originals/{event_slug}/{upload_id}.{ext}");
let absolute_path = state.config.media_path.join(&relative_path);
// Reserve quota and insert the row in a single transaction so the byte
// counter and the upload row are atomic (M6: no quota leak if we crash
// between them) and the reservation is a conditional UPDATE that row-locks
// (M5: concurrent uploads can no longer all pass a stale snapshot and
// overshoot the limit). The file is written before commit and unlinked on
// any rollback so a failed transaction leaves nothing behind.
let mut tx = state.pool.begin().await?;
// Ensure directory exists and write file
if let Some(parent) = absolute_path.parent() {
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()))?;
// Update user's total upload bytes
sqlx::query("UPDATE \"user\" SET total_upload_bytes = total_upload_bytes + $2 WHERE id = $1")
let reserved: Option<(Uuid,)> = if let Some(limit) = quota_limit {
sqlx::query_as(
"UPDATE \"user\"
SET total_upload_bytes = total_upload_bytes + $2
WHERE id = $1 AND total_upload_bytes + $2 <= $3
RETURNING id",
)
.bind(auth.user_id)
.bind(size)
.execute(&state.pool)
.await?;
.bind(limit)
.fetch_optional(&mut *tx)
.await?
} else {
sqlx::query_as(
"UPDATE \"user\"
SET total_upload_bytes = total_upload_bytes + $2
WHERE id = $1
RETURNING id",
)
.bind(auth.user_id)
.bind(size)
.fetch_optional(&mut *tx)
.await?
};
if reserved.is_none() {
// The user exists (checked above), so a missing row here means the
// conditional limit guard rejected the reservation → over quota.
tx.rollback().await.ok();
return Err(AppError::TooManyRequests(
"Du hast dein Upload-Limit für dieses Event erreicht.".into(),
None,
));
}
// Insert upload record
let upload = Upload::create(
&state.pool,
// Write the file. On any failure, roll back (releasing the reservation) and
// remove a possibly-partial file.
if let Some(parent) = absolute_path.parent() {
if let Err(e) = tokio::fs::create_dir_all(parent).await {
tx.rollback().await.ok();
return Err(AppError::Internal(e.into()));
}
}
if let Err(e) = tokio::fs::write(&absolute_path, data.as_ref()).await {
tx.rollback().await.ok();
return Err(AppError::Internal(e.into()));
}
let upload = match Upload::create(
&mut *tx,
auth.event_id,
auth.user_id,
&relative_path,
&mime,
&canonical_mime,
size,
caption.as_deref(),
)
.await?;
.await
{
Ok(u) => u,
Err(e) => {
tx.rollback().await.ok();
let _ = tokio::fs::remove_file(&absolute_path).await;
return Err(e.into());
}
};
if let Err(e) = tx.commit().await {
let _ = tokio::fs::remove_file(&absolute_path).await;
return Err(e.into());
}
// Process hashtags from caption and explicit CSV
let mut tags: Vec<String> = Vec::new();
@@ -233,7 +302,7 @@ pub async fn upload(
// Spawn compression task
state
.compression
.process(upload.id, relative_path, mime.clone());
.process(upload.id, relative_path, canonical_mime.clone());
// Broadcast SSE event
let dto = UploadDto {
@@ -242,7 +311,13 @@ pub async fn upload(
uploader_name: user.display_name,
preview_url: None,
thumbnail_url: None,
mime_type: mime,
original_url: Some(crate::services::media_token::signed_url(
&state.config.jwt_secret,
"original",
upload.id,
chrono::Utc::now().timestamp(),
)),
mime_type: canonical_mime,
caption,
hashtags: tags,
like_count: 0,
@@ -271,7 +346,7 @@ pub async fn edit_upload(
Path(upload_id): Path<Uuid>,
Json(body): Json<EditUploadRequest>,
) -> Result<StatusCode, AppError> {
let upload = Upload::find_by_id_and_event(&state.pool, upload_id, auth.event_id)
let upload = Upload::find_by_id(&state.pool, upload_id)
.await?
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
@@ -299,7 +374,7 @@ pub async fn delete_upload(
auth: AuthUser,
Path(upload_id): Path<Uuid>,
) -> Result<StatusCode, AppError> {
let upload = Upload::find_by_id_and_event(&state.pool, upload_id, auth.event_id)
let upload = Upload::find_by_id(&state.pool, upload_id)
.await?
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
@@ -307,7 +382,15 @@ pub async fn delete_upload(
return Err(AppError::Forbidden("Nur eigene Uploads löschen.".into()));
}
Upload::soft_delete_in_event(&state.pool, upload_id, auth.event_id).await?;
if let Some(paths) = Upload::soft_delete(&state.pool, upload_id).await? {
crate::services::media_fs::unlink_media(&state.config.media_path, &paths).await;
}
// Tell other clients to drop the photo immediately (mirrors host delete).
let _ = state.sse_tx.send(crate::state::SseEvent::new(
"upload-deleted",
serde_json::json!({ "upload_id": upload_id }).to_string(),
));
Ok(StatusCode::NO_CONTENT)
}
@@ -331,13 +414,6 @@ pub struct QuotaEstimate {
pub tolerance: f64,
}
/// Pure per-user quota formula: `floor((free_disk * tolerance) / max(active, 1))`.
/// Extracted from `compute_storage_quota` so it's unit-testable without a DB or disk.
fn quota_limit_bytes(free_disk: i64, tolerance: f64, active_uploaders: i64) -> i64 {
let active = active_uploaders.max(1);
((free_disk as f64 * tolerance) / active as f64).floor() as i64
}
/// Computes the per-user storage quota using
/// `floor((free_disk * tolerance) / max(active_uploaders, 1))`. Returns `limit_bytes =
/// None` whenever the storage quota is currently disabled — callers should skip the
@@ -369,7 +445,7 @@ pub async fn compute_storage_quota(state: &AppState) -> QuotaEstimate {
}) as i64;
let limit_bytes = if quota_on && storage_quota_on {
Some(quota_limit_bytes(free_disk, tolerance, active))
Some(((free_disk as f64 * tolerance) / active as f64).floor() as i64)
} else {
None
};
@@ -382,87 +458,44 @@ pub async fn compute_storage_quota(state: &AppState) -> QuotaEstimate {
}
}
/// 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, matching how the rest of
/// `/media/*` is served (preview + thumbnail variants). The URL contains the upload's
/// UUID, which is unguessable — same security posture as `/media/originals/{slug}/{id}`.
/// Adding `Authorization: Bearer` here would make the endpoint unusable from `<img src>`
/// and `window.open`, defeating the purpose of having the alias.
pub async fn get_original(
State(state): State<AppState>,
Path(upload_id): Path<Uuid>,
) -> Result<axum::response::Response, AppError> {
let upload = Upload::find_by_id(&state.pool, upload_id)
.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::http::{header, Response, StatusCode};
use tokio_util::io::ReaderStream;
let file = tokio::fs::File::open(&absolute)
.await
.map_err(|e| AppError::Internal(e.into()))?;
let metadata = file
.metadata()
.await
.map_err(|e| AppError::Internal(e.into()))?;
let stream = ReaderStream::new(file);
let filename = absolute
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("original");
let disposition = format!("attachment; filename=\"{filename}\"");
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, upload.mime_type)
.header(header::CONTENT_DISPOSITION, disposition)
.header(header::CONTENT_LENGTH, metadata.len())
.body(Body::from_stream(stream))
.map_err(|e| AppError::Internal(e.into()))
}
// The original-file download is now served by the authenticated, signed media
// gateway (`handlers::media::serve`). The old unauthenticated `get_original`
// alias was the H2 vulnerability and has been removed.
#[cfg(test)]
mod tests {
use super::quota_limit_bytes;
use super::classify;
#[test]
fn divides_free_space_by_uploaders_with_tolerance() {
// 1000 * 0.75 / 3 = 250
assert_eq!(quota_limit_bytes(1000, 0.75, 3), 250);
fn classify_accepts_allowed_image_types() {
// PNG signature
let png = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 0];
let (mime, ext, is_video) = classify(&png).expect("png allowed");
assert_eq!(mime, "image/png");
assert_eq!(ext, "png");
assert!(!is_video);
// JPEG signature
let jpeg = [0xFF, 0xD8, 0xFF, 0xE0, 0, 0, 0, 0, 0, 0, 0, 0];
let (mime, ext, _) = classify(&jpeg).expect("jpeg allowed");
assert_eq!(mime, "image/jpeg");
assert_eq!(ext, "jpg");
}
#[test]
fn floors_fractional_results() {
// 1000 * 0.75 / 7 = 107.14… → 107
assert_eq!(quota_limit_bytes(1000, 0.75, 7), 107);
}
fn classify_rejects_unrecognized_and_html_svg() {
// Plain HTML / script payload — infer cannot fingerprint it, so it must
// be rejected (this is the stored-XSS vector the old code let through
// via a declared `application/*` Content-Type).
let html = b"<html><script>alert(1)</script></html>";
assert!(classify(html).is_err());
#[test]
fn active_uploaders_below_one_is_clamped_to_one() {
// Guards against divide-by-zero when no one has uploaded yet.
assert_eq!(quota_limit_bytes(1000, 1.0, 0), 1000);
assert_eq!(quota_limit_bytes(1000, 1.0, -5), 1000);
}
// SVG (text-based) is likewise unrecognized → rejected.
let svg = b"<svg xmlns=\"http://www.w3.org/2000/svg\"><script>x</script></svg>";
assert!(classify(svg).is_err());
#[test]
fn zero_free_disk_yields_zero() {
assert_eq!(quota_limit_bytes(0, 0.75, 3), 0);
}
#[test]
fn full_tolerance_is_identity_for_a_single_uploader() {
assert_eq!(quota_limit_bytes(500, 1.0, 1), 500);
// Empty / garbage.
assert!(classify(&[]).is_err());
assert!(classify(&[0x00, 0x01, 0x02, 0x03]).is_err());
}
}

View File

@@ -2,7 +2,6 @@ use anyhow::Result;
use axum::extract::DefaultBodyLimit;
use axum::routing::{delete, get, patch, post};
use axum::Router;
use tower_http::services::ServeDir;
use tower_http::trace::TraceLayer;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
@@ -18,10 +17,6 @@ mod state;
use config::AppConfig;
use state::AppState;
/// Hard HTTP body cap for the upload endpoint (576 MiB). Backstop against
/// memory-exhaustion; precise per-class size limits are enforced in the handler.
const MAX_UPLOAD_BYTES: usize = 576 * 1024 * 1024;
#[tokio::main]
async fn main() -> Result<()> {
dotenvy::dotenv().ok();
@@ -50,6 +45,7 @@ async fn main() -> Result<()> {
pool,
state.rate_limiter.clone(),
state.sse_tickets.clone(),
config.media_path.clone(),
);
// Ensure media directories exist
@@ -61,21 +57,15 @@ async fn main() -> Result<()> {
.route("/api/v1/recover", post(auth::handlers::recover))
.route("/api/v1/admin/login", post(auth::handlers::admin_login))
.route("/api/v1/session", delete(auth::handlers::logout))
// Upload — HTTP-level body cap as an OOM backstop. The handler still enforces
// the precise per-class limits from DB config (max_image/video_size_mb); this
// layer just stops a multi-GB body from being buffered into memory before that
// check runs. Sized generously above the default 500 MB video limit + multipart
// overhead — if an admin raises max_video_size_mb above this, bump MAX_UPLOAD_BYTES.
// Upload — cap the request body so a single request can't buffer the box
// into OOM. Sized to the largest allowed video (500 MB) plus multipart
// overhead; the per-category limit is still enforced inside the handler.
.route("/api/v1/upload", post(handlers::upload::upload)
.route_layer(DefaultBodyLimit::max(MAX_UPLOAD_BYTES)))
.route_layer(DefaultBodyLimit::max(550 * 1024 * 1024)))
.route(
"/api/v1/upload/{id}",
patch(handlers::upload::edit_upload).delete(handlers::upload::delete_upload),
)
.route(
"/api/v1/upload/{id}/original",
get(handlers::upload::get_original),
)
// Current-user endpoints (live quota estimate, profile + privacy note bundle)
.route("/api/v1/me/context", get(handlers::me::get_context))
.route("/api/v1/me/quota", get(handlers::me::get_quota))
@@ -110,7 +100,6 @@ async fn main() -> Result<()> {
.route("/api/v1/host/comment/{id}", delete(handlers::host::host_delete_comment))
// Export (all authenticated users)
.route("/api/v1/export/status", get(handlers::admin::export_status))
.route("/api/v1/export/ticket", post(handlers::admin::export_ticket))
.route("/api/v1/export/zip", get(handlers::admin::download_zip))
.route("/api/v1/export/html", get(handlers::admin::download_html))
// Admin Dashboard
@@ -138,14 +127,28 @@ async fn main() -> Result<()> {
api
};
// Serve media files from disk
let media_service = ServeDir::new(&config.media_path);
// Media is served exclusively through the authenticated, signed gateway —
// there is no raw static mount. The gateway verifies an HMAC signature and
// consults the DB (deleted / ban-hidden / type), so private artifacts and
// the export archives are never reachable by guessing a path.
// Trace spans log the request *path* only, never the query string — signed
// media URLs carry a replayable `?sig=` capability that must not land in
// access logs.
let trace_layer = TraceLayer::new_for_http().make_span_with(
|req: &axum::http::Request<axum::body::Body>| {
tracing::info_span!(
"request",
method = %req.method(),
path = %req.uri().path(),
)
},
);
let router = Router::new()
.route("/health", get(|| async { "ok" }))
.merge(api)
.nest_service("/media", media_service)
.layer(TraceLayer::new_for_http())
.route("/media/{kind}/{id}", get(handlers::media::serve))
.layer(trace_layer)
.with_state(state);
let listener = tokio::net::TcpListener::bind(("0.0.0.0", config.app_port)).await?;

View File

@@ -109,38 +109,4 @@ mod tests {
fn empty_or_bare_hash_skipped() {
assert_eq!(extract_hashtags("# #"), Vec::<String>::new());
}
#[test]
fn tag_stops_at_first_non_word_char() {
// A tag runs until the first char that isn't ascii-alphanumeric or '_'.
assert_eq!(extract_hashtags("#foo#bar"), vec!["foo"]);
assert_eq!(extract_hashtags("#foo-bar"), vec!["foo"]);
assert_eq!(extract_hashtags("##tag"), Vec::<String>::new()); // '#' after the strip is non-word
}
#[test]
fn tag_length_is_capped_at_40_chars() {
let ok = "a".repeat(40);
assert_eq!(extract_hashtags(&format!("#{ok}")), vec![ok.clone()]);
// 41+ chars → dropped entirely (not truncated).
let too_long = "a".repeat(41);
assert_eq!(extract_hashtags(&format!("#{too_long}")), Vec::<String>::new());
}
#[test]
fn duplicate_tags_are_returned_verbatim_not_deduplicated() {
// Dedup is the DB's job (Hashtag::upsert ON CONFLICT); extraction returns each
// occurrence so callers can count/link them independently. Case folds to lower.
assert_eq!(extract_hashtags("#fun #Fun #fun!"), vec!["fun", "fun", "fun"]);
}
#[test]
fn non_ascii_word_chars_truncate_the_tag() {
// KNOWN LIMITATION for a German app: `is_ascii_alphanumeric` excludes umlauts
// and ß, so a tag truncates at the first non-ASCII letter. Pinned here so a
// future Unicode-aware change is a deliberate, test-visible decision.
assert_eq!(extract_hashtags("#Grüße"), vec!["gr"]);
assert_eq!(extract_hashtags("#Straße"), vec!["stra"]);
assert_eq!(extract_hashtags("#café"), vec!["caf"]);
}
}

View File

@@ -2,6 +2,8 @@ use chrono::{DateTime, Utc};
use sqlx::PgPool;
use uuid::Uuid;
use crate::models::user::UserRole;
#[derive(Debug, sqlx::FromRow)]
pub struct Session {
pub id: Uuid,
@@ -12,6 +14,18 @@ pub struct Session {
pub created_at: DateTime<Utc>,
}
/// Live identity reconciled from the DB for a valid, unexpired session. Used by
/// the `AuthUser` extractor so role/ban/event are sourced from the `user` row
/// rather than trusted from (potentially stale) JWT claims.
#[derive(Debug, sqlx::FromRow)]
pub struct AuthContext {
pub session_id: Uuid,
pub user_id: Uuid,
pub event_id: Uuid,
pub role: UserRole,
pub is_banned: bool,
}
impl Session {
pub async fn create(
pool: &PgPool,
@@ -43,6 +57,25 @@ impl Session {
.await
}
/// Reconcile a session against the live `user` row in a single round-trip.
/// Returns `None` when the session is missing/expired. The PK join to
/// `"user"` is cheap and lets the extractor read the *current* role/ban
/// state instead of the JWT claim.
pub async fn find_auth_context(
pool: &PgPool,
token_hash: &str,
) -> Result<Option<AuthContext>, sqlx::Error> {
sqlx::query_as::<_, AuthContext>(
"SELECT s.id AS session_id, u.id AS user_id, u.event_id, u.role, u.is_banned
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
}
pub async fn touch(pool: &PgPool, id: Uuid) -> Result<(), sqlx::Error> {
sqlx::query("UPDATE session SET last_seen_at = NOW() WHERE id = $1")
.bind(id)
@@ -61,4 +94,15 @@ impl Session {
.await?;
Ok(())
}
/// Revoke every session belonging to a user — used on ban, role change, and
/// PIN reset so existing JWTs stop working immediately. Returns the number
/// of sessions removed. Best-effort: callers log but do not fail on error.
pub async fn delete_by_user_id(pool: &PgPool, user_id: Uuid) -> Result<u64, sqlx::Error> {
let result = sqlx::query("DELETE FROM session WHERE user_id = $1")
.bind(user_id)
.execute(pool)
.await?;
Ok(result.rows_affected())
}
}

View File

@@ -19,6 +19,15 @@ pub struct Upload {
pub deleted_at: Option<DateTime<Utc>>,
}
/// On-disk artifact paths returned by the soft-delete methods so the caller can
/// unlink the files after the DB commit.
#[derive(Debug, sqlx::FromRow)]
pub struct DeletedPaths {
pub original: String,
pub preview: Option<String>,
pub thumbnail: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct UploadDto {
pub id: Uuid,
@@ -26,6 +35,8 @@ pub struct UploadDto {
pub uploader_name: String,
pub preview_url: Option<String>,
pub thumbnail_url: Option<String>,
/// Signed gateway URL for the full-resolution original. Always present.
pub original_url: Option<String>,
pub mime_type: String,
pub caption: Option<String>,
pub hashtags: Vec<String>,
@@ -36,15 +47,21 @@ pub struct UploadDto {
}
impl Upload {
pub async fn create(
pool: &PgPool,
/// Generic over the executor so it can run inside the upload transaction
/// (`&mut *tx`) alongside the quota-counter reservation, keeping the
/// byte-counter and the row atomic (no quota leak on a mid-write crash).
pub async fn create<'e, E>(
executor: E,
event_id: Uuid,
user_id: Uuid,
original_path: &str,
mime_type: &str,
original_size_bytes: i64,
caption: Option<&str>,
) -> Result<Self, sqlx::Error> {
) -> Result<Self, sqlx::Error>
where
E: sqlx::PgExecutor<'e>,
{
sqlx::query_as::<_, Self>(
"INSERT INTO upload (event_id, user_id, original_path, mime_type, original_size_bytes, caption)
VALUES ($1, $2, $3, $4, $5, $6)
@@ -56,7 +73,7 @@ impl Upload {
.bind(mime_type)
.bind(original_size_bytes)
.bind(caption)
.fetch_one(pool)
.fetch_one(executor)
.await
}
@@ -119,18 +136,21 @@ impl Upload {
///
/// No-op if the row is already deleted — protects against a double-tap on the
/// delete action double-decrementing the counter.
pub async fn soft_delete(pool: &PgPool, id: Uuid) -> Result<(), sqlx::Error> {
///
/// Returns the artifact paths of the row that was deleted (`None` if nothing
/// matched) so the caller can unlink the files after the commit.
pub async fn soft_delete(pool: &PgPool, id: Uuid) -> Result<Option<DeletedPaths>, sqlx::Error> {
let mut tx = pool.begin().await?;
let row: Option<(Uuid, i64)> = sqlx::query_as(
let row: Option<(Uuid, i64, String, Option<String>, Option<String>)> = sqlx::query_as(
"UPDATE upload
SET deleted_at = NOW()
WHERE id = $1 AND deleted_at IS NULL
RETURNING user_id, original_size_bytes",
RETURNING user_id, original_size_bytes, original_path, preview_path, thumbnail_path",
)
.bind(id)
.fetch_optional(&mut *tx)
.await?;
if let Some((user_id, bytes)) = row {
let paths = if let Some((user_id, bytes, original, preview, thumbnail)) = row {
sqlx::query(
"UPDATE \"user\"
SET total_upload_bytes = GREATEST(0, total_upload_bytes - $2)
@@ -140,31 +160,34 @@ impl Upload {
.bind(bytes)
.execute(&mut *tx)
.await?;
}
Some(DeletedPaths { original, preview, thumbnail })
} else {
None
};
tx.commit().await?;
Ok(())
Ok(paths)
}
/// Event-scoped variant of [`Self::soft_delete`]. Returns `false` if no row
/// Event-scoped variant of [`Self::soft_delete`]. Returns `None` if no row
/// matched (already deleted, wrong event, or unknown id) so host handlers
/// can return a clean 404 instead of silently no-op'ing.
/// can return a clean 404, and the artifact paths otherwise.
pub async fn soft_delete_in_event(
pool: &PgPool,
id: Uuid,
event_id: Uuid,
) -> Result<bool, sqlx::Error> {
) -> Result<Option<DeletedPaths>, sqlx::Error> {
let mut tx = pool.begin().await?;
let row: Option<(Uuid, i64)> = sqlx::query_as(
let row: Option<(Uuid, i64, String, Option<String>, Option<String>)> = sqlx::query_as(
"UPDATE upload
SET deleted_at = NOW()
WHERE id = $1 AND event_id = $2 AND deleted_at IS NULL
RETURNING user_id, original_size_bytes",
RETURNING user_id, original_size_bytes, original_path, preview_path, thumbnail_path",
)
.bind(id)
.bind(event_id)
.fetch_optional(&mut *tx)
.await?;
let deleted = if let Some((user_id, bytes)) = row {
let paths = if let Some((user_id, bytes, original, preview, thumbnail)) = row {
sqlx::query(
"UPDATE \"user\"
SET total_upload_bytes = GREATEST(0, total_upload_bytes - $2)
@@ -174,12 +197,12 @@ impl Upload {
.bind(bytes)
.execute(&mut *tx)
.await?;
true
Some(DeletedPaths { original, preview, thumbnail })
} else {
false
None
};
tx.commit().await?;
Ok(deleted)
Ok(paths)
}
pub async fn update_caption(

View File

@@ -11,16 +11,27 @@ use crate::state::SseEvent;
#[derive(Clone)]
pub struct CompressionWorker {
semaphore: Arc<Semaphore>,
/// Separate permit pools for images and videos so a couple of slow/large
/// videos (each holding a permit across the full ffmpeg wait) can never
/// starve image-preview generation, and vice versa.
image_sem: Arc<Semaphore>,
video_sem: Arc<Semaphore>,
pool: PgPool,
media_path: PathBuf,
sse_tx: broadcast::Sender<SseEvent>,
}
impl CompressionWorker {
pub fn new(pool: PgPool, media_path: PathBuf, concurrency: usize, sse_tx: broadcast::Sender<SseEvent>) -> Self {
pub fn new(
pool: PgPool,
media_path: PathBuf,
image_concurrency: usize,
video_concurrency: usize,
sse_tx: broadcast::Sender<SseEvent>,
) -> Self {
Self {
semaphore: Arc::new(Semaphore::new(concurrency)),
image_sem: Arc::new(Semaphore::new(image_concurrency)),
video_sem: Arc::new(Semaphore::new(video_concurrency)),
pool,
media_path,
sse_tx,
@@ -31,7 +42,9 @@ impl CompressionWorker {
pub fn process(&self, upload_id: Uuid, original_path: String, mime_type: String) {
let worker = self.clone();
tokio::spawn(async move {
let _permit = worker.semaphore.acquire().await;
let is_video = mime_type.starts_with("video/");
let sem = if is_video { &worker.video_sem } else { &worker.image_sem };
let _permit = sem.acquire().await;
match worker.do_process(upload_id, &original_path, &mime_type).await {
Ok(_) => {
tracing::info!("compression completed for upload {upload_id}");
@@ -41,10 +54,16 @@ impl CompressionWorker {
});
}
Err(e) => {
// Log the detailed error (incl. paths) server-side only; the
// SSE channel is broadcast to every client, so send a generic
// message — never leak absolute filesystem paths.
tracing::error!("compression failed for upload {upload_id}: {e:#}");
let _ = worker.sse_tx.send(SseEvent {
event_type: "upload-error".to_string(),
data: serde_json::json!({ "upload_id": upload_id, "error": e.to_string() }).to_string(),
data: serde_json::json!({
"upload_id": upload_id,
"error": "Verarbeitung fehlgeschlagen."
}).to_string(),
});
let _ = Upload::set_compression_status(&worker.pool, upload_id, "failed").await;
}
@@ -91,14 +110,16 @@ impl CompressionWorker {
let preview_path_clone = preview_path.clone();
let mime_owned = mime_type.to_string();
// Run blocking image operations in a spawn_blocking task
tokio::task::spawn_blocking(move || -> Result<()> {
// Reject decompression bombs *before* fully decoding: the upload body
// cap bounds the file size on disk, but a small file can still decode to
// enormous dimensions (e.g. a ~1 MB image expanding to 50k×50k px →
// gigabytes), OOM-ing the box during decode/resize. 12000×12000 covers
// any real phone photo; max_alloc hard-caps the decode allocation.
let mut reader = image::ImageReader::open(&original)
// Run blocking image operations in a spawn_blocking task, bounded by a
// hard timeout (mirrors the ffmpeg guard) so a pathological decode can't
// hold the permit forever.
let handle = tokio::task::spawn_blocking(move || -> Result<()> {
use image::ImageReader;
// Reject decompression bombs *before* fully decoding: a small file can
// otherwise expand to enormous dimensions and a very expensive resize.
// 12000×12000 covers any real phone photo; max_alloc caps memory.
let mut reader = ImageReader::open(&original)
.context("failed to open image")?
.with_guessed_format()
.context("failed to read image header")?;
@@ -128,8 +149,12 @@ impl CompressionWorker {
}
Ok(())
})
.await??;
});
match tokio::time::timeout(std::time::Duration::from_secs(120), handle).await {
Ok(join) => join.context("image task panicked")??,
Err(_) => anyhow::bail!("image processing timeout after 120s"),
}
Ok(format!("previews/{preview_filename}"))
}

View File

@@ -94,8 +94,14 @@ pub fn spawn_export_jobs(
let sse_tx2 = sse_tx.clone();
let event_name2 = event_name.clone();
// Per-run id so two runs (e.g. a re-release after a crash mid-export, where
// startup_recovery marked the old run 'failed' but its task may still be
// winding down) write to distinct temp paths and can't truncate each other's
// output (M8). The final served filenames stay fixed.
let run_id = Uuid::new_v4();
tokio::spawn(async move {
if let Err(e) = run_zip_export(event_id, &pool, &media_path, &sse_tx).await {
if let Err(e) = run_zip_export(event_id, run_id, &pool, &media_path, &sse_tx).await {
tracing::error!("ZIP export failed for event {event_id}: {e:#}");
mark_failed(&pool, event_id, "zip", &e.to_string()).await;
}
@@ -104,7 +110,7 @@ pub fn spawn_export_jobs(
tokio::spawn(async move {
if let Err(e) =
run_html_export(event_id, &event_name2, &pool2, &media_path2, &sse_tx2).await
run_html_export(event_id, run_id, &event_name2, &pool2, &media_path2, &sse_tx2).await
{
tracing::error!("HTML export failed for event {event_id}: {e:#}");
mark_failed(&pool2, event_id, "html", &e.to_string()).await;
@@ -117,6 +123,7 @@ pub fn spawn_export_jobs(
async fn run_zip_export(
event_id: Uuid,
run_id: Uuid,
pool: &PgPool,
media_path: &Path,
sse_tx: &broadcast::Sender<SseEvent>,
@@ -129,7 +136,8 @@ async fn run_zip_export(
let exports_dir = media_path.join("exports");
tokio::fs::create_dir_all(&exports_dir).await?;
let tmp_path = exports_dir.join("Gallery.zip.tmp");
// Per-run temp name; final served name stays fixed.
let tmp_path = exports_dir.join(format!("Gallery.{run_id}.zip.tmp"));
let out_path = exports_dir.join("Gallery.zip");
{
@@ -190,6 +198,7 @@ async fn run_zip_export(
async fn run_html_export(
event_id: Uuid,
run_id: Uuid,
event_name: &str,
pool: &PgPool,
media_path: &Path,
@@ -208,8 +217,8 @@ async fn run_html_export(
let exports_dir = media_path.join("exports");
tokio::fs::create_dir_all(&exports_dir).await?;
// 2. Create temp directory for media processing
let tmp_dir = exports_dir.join(format!("viewer_tmp_{event_id}"));
// 2. Create temp directory for media processing (per-run, see run_id).
let tmp_dir = exports_dir.join(format!("viewer_tmp_{event_id}_{run_id}"));
let media_tmp = tmp_dir.join("media");
tokio::fs::create_dir_all(&media_tmp).await?;
@@ -370,8 +379,8 @@ async fn run_html_export(
update_progress(pool, event_id, "html", 72).await;
// 5. Create ZIP
let tmp_path = exports_dir.join("Memories.zip.tmp");
// 5. Create ZIP (per-run temp name; final served name stays fixed).
let tmp_path = exports_dir.join(format!("Memories.{run_id}.zip.tmp"));
let out_path = exports_dir.join("Memories.zip");
{

View File

@@ -13,6 +13,7 @@
//! rate-limiter's in-memory windows (so keys for IPs that left long ago don't
//! accumulate).
use std::path::PathBuf;
use std::time::Duration;
use sqlx::PgPool;
@@ -76,6 +77,7 @@ pub fn spawn_periodic_tasks(
pool: PgPool,
rate_limiter: RateLimiter,
sse_tickets: SseTicketStore,
media_path: PathBuf,
) {
tokio::spawn(async move {
let mut tick = tokio::time::interval(Duration::from_secs(3600));
@@ -86,6 +88,9 @@ pub fn spawn_periodic_tasks(
cleanup_sessions(&pool).await;
rate_limiter.prune();
sse_tickets.prune();
// Reclaim disk for uploads soft-deleted more than the grace period
// ago, and hard-delete those rows (FKs cascade).
crate::services::media_fs::reap_deleted(&pool, &media_path).await;
}
});
}

View File

@@ -0,0 +1,83 @@
//! Filesystem lifecycle for media artifacts.
//!
//! The DB-aware gateway hides deleted/hidden uploads, but the bytes still sit on
//! a fixed-size disk until something removes them. This module is that
//! something: best-effort unlinking on delete, plus a periodic reaper that
//! sweeps files belonging to soft-deleted rows (catching anything an in-process
//! unlink missed — e.g. a crash between the DB commit and the unlink).
use std::path::Path;
use sqlx::PgPool;
use crate::models::upload::DeletedPaths;
/// Best-effort removal of an upload's three on-disk artifacts. A missing file is
/// not an error (it may already be gone, or never existed for videos without a
/// preview); anything else is logged but never propagated — losing the bytes
/// must not fail the user's delete.
pub async fn unlink_media(media_path: &Path, paths: &DeletedPaths) {
let candidates = [
Some(&paths.original),
paths.preview.as_ref(),
paths.thumbnail.as_ref(),
];
for rel in candidates.into_iter().flatten() {
let abs = media_path.join(rel);
match tokio::fs::remove_file(&abs).await {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => tracing::warn!(path = %rel, error = ?e, "media unlink failed"),
}
}
}
/// Reaper: remove on-disk files for uploads that were soft-deleted more than
/// `grace` ago, then hard-delete those rows so they don't accumulate. Strictly
/// DB-row-driven — it never walks the filesystem, so it can never remove a file
/// belonging to a live upload.
pub async fn reap_deleted(pool: &PgPool, media_path: &Path) {
// Snapshot the exact rows (id + paths) we are about to unlink, and delete by
// those ids — NOT by re-evaluating the `deleted_at < now - 1 day` predicate.
// A row that crosses the 1-day line *during* the unlink loop would otherwise
// be hard-deleted by the second predicate without ever having its files
// unlinked → a permanent orphan.
let rows: Vec<(uuid::Uuid, String, Option<String>, Option<String>)> = match sqlx::query_as(
"SELECT id, original_path, preview_path, thumbnail_path
FROM upload
WHERE deleted_at IS NOT NULL AND deleted_at < NOW() - INTERVAL '1 day'",
)
.fetch_all(pool)
.await
{
Ok(rows) => rows,
Err(e) => {
tracing::warn!(error = ?e, "media reaper: query failed");
return;
}
};
if rows.is_empty() {
return;
}
let mut ids = Vec::with_capacity(rows.len());
for (id, original, preview, thumbnail) in &rows {
unlink_media(media_path, &DeletedPaths {
original: original.clone(),
preview: preview.clone(),
thumbnail: thumbnail.clone(),
})
.await;
ids.push(*id);
}
match sqlx::query("DELETE FROM upload WHERE id = ANY($1)")
.bind(&ids)
.execute(pool)
.await
{
Ok(r) => tracing::info!("media reaper: removed {} deleted upload(s)", r.rows_affected()),
Err(e) => tracing::warn!(error = ?e, "media reaper: row delete failed"),
}
}

View File

@@ -0,0 +1,122 @@
//! Stateless signed URLs for the authenticated media gateway.
//!
//! Media (`<img>`/`<video>` sources) cannot carry an `Authorization` header, so
//! access is granted by an HMAC-SHA256 signature embedded in the URL. The
//! feed/upload DTOs are serialized for an already-authenticated event member, so
//! that is where fresh signatures are minted; the gateway handler verifies them
//! without any DB/session state.
//!
//! HMAC is implemented over the in-tree `sha2` crate (no new dependency). The
//! key is the app's `jwt_secret`.
use sha2::{Digest, Sha256};
use uuid::Uuid;
/// Validity window of a signed URL.
const TTL_SECS: i64 = 24 * 3600;
/// Issue-time bucket. Expiry (and therefore the URL) is stable within this
/// window so the browser caches image bytes across feed polls instead of
/// re-downloading on every refresh.
const BUCKET_SECS: i64 = 3600;
fn hmac_sha256(key: &[u8], msg: &[u8]) -> [u8; 32] {
const BLOCK: usize = 64;
let mut key_block = [0u8; BLOCK];
if key.len() > BLOCK {
let digest = Sha256::digest(key);
key_block[..32].copy_from_slice(&digest);
} else {
key_block[..key.len()].copy_from_slice(key);
}
let mut ipad = [0x36u8; BLOCK];
let mut opad = [0x5cu8; BLOCK];
for i in 0..BLOCK {
ipad[i] ^= key_block[i];
opad[i] ^= key_block[i];
}
let mut inner = Sha256::new();
inner.update(ipad);
inner.update(msg);
let inner_digest = inner.finalize();
let mut outer = Sha256::new();
outer.update(opad);
outer.update(inner_digest);
outer.finalize().into()
}
fn sign(secret: &str, kind: &str, id: Uuid, exp: i64) -> String {
let msg = format!("{kind}:{id}:{exp}");
let mac = hmac_sha256(secret.as_bytes(), msg.as_bytes());
let mut hex = String::with_capacity(64);
for b in mac {
use std::fmt::Write;
let _ = write!(hex, "{b:02x}");
}
hex
}
/// Build a signed, time-boxed gateway URL for one of an upload's artifacts.
/// `kind` is `original` | `preview` | `thumbnail`.
pub fn signed_url(secret: &str, kind: &str, id: Uuid, now: i64) -> String {
let exp = ((now / BUCKET_SECS) * BUCKET_SECS) + TTL_SECS;
let sig = sign(secret, kind, id, exp);
format!("/media/{kind}/{id}?exp={exp}&sig={sig}")
}
/// Verify a signed URL: signature must match and the expiry must not have
/// passed. Signature comparison is constant-time.
pub fn verify(secret: &str, kind: &str, id: Uuid, exp: i64, sig: &str, now: i64) -> bool {
if exp < now {
return false;
}
let expected = sign(secret, kind, id, exp);
constant_time_eq(expected.as_bytes(), sig.as_bytes())
}
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut diff = 0u8;
for (x, y) in a.iter().zip(b.iter()) {
diff |= x ^ y;
}
diff == 0
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sign_verify_roundtrip() {
let secret = "test_secret_at_least_32_chars_long_xxxx";
let id = Uuid::new_v4();
let now = 1_700_000_000;
let url = signed_url(secret, "original", id, now);
// Extract exp + sig from the query string.
let (_, query) = url.split_once('?').unwrap();
let mut exp = 0i64;
let mut sig = String::new();
for pair in query.split('&') {
let (k, v) = pair.split_once('=').unwrap();
match k {
"exp" => exp = v.parse().unwrap(),
"sig" => sig = v.to_string(),
_ => {}
}
}
assert!(verify(secret, "original", id, exp, &sig, now));
// Tampered kind / id / sig must fail.
assert!(!verify(secret, "preview", id, exp, &sig, now));
assert!(!verify(secret, "original", Uuid::new_v4(), exp, &sig, now));
assert!(!verify(secret, "original", id, exp, "deadbeef", now));
// Wrong secret must fail.
assert!(!verify("other_secret_at_least_32_chars_long_yy", "original", id, exp, &sig, now));
// Expired must fail.
assert!(!verify(secret, "original", id, exp, &sig, exp + 1));
}
}

View File

@@ -3,5 +3,8 @@ pub mod config;
pub mod export;
pub mod jobs;
pub mod maintenance;
pub mod media_fs;
pub mod media_token;
pub mod password;
pub mod rate_limiter;
pub mod sse_tickets;

View File

@@ -0,0 +1,25 @@
//! bcrypt hashing/verification offloaded to the blocking pool.
//!
//! bcrypt cost-12 is ~250400ms of synchronous CPU. Called directly in an async
//! handler it pins a Tokio worker for that whole time, so a handful of
//! concurrent joins/recovers/admin-logins can starve every other request
//! (feed, SSE, upload). Routing the work through `spawn_blocking` keeps the
//! async reactor responsive.
use crate::error::AppError;
/// Hash a secret with the given bcrypt cost, off the async reactor.
pub async fn hash(plain: String, cost: u32) -> Result<String, AppError> {
tokio::task::spawn_blocking(move || bcrypt::hash(&plain, cost))
.await
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))
}
/// Verify a secret against a bcrypt hash, off the async reactor. Returns `false`
/// on any error (mirrors the previous `unwrap_or(false)` fail-closed behavior).
pub async fn verify(plain: String, hash: String) -> bool {
tokio::task::spawn_blocking(move || bcrypt::verify(&plain, &hash).unwrap_or(false))
.await
.unwrap_or(false)
}

View File

@@ -71,84 +71,55 @@ impl RateLimiter {
}
}
/// Extract the client IP from X-Forwarded-For (Caddy sets this) or fall back
/// to a provided socket address string.
/// Extract the client IP for rate-limit keys.
///
/// Prefers `X-Real-IP`, which our reverse proxy (Caddy) overwrites with the real
/// TCP peer (`header_up X-Real-IP {remote_host}`) — a client cannot spoof it.
/// Falls back to the **rightmost** `X-Forwarded-For` token (the entry the proxy
/// appended), never the leftmost: the leftmost is fully client-controlled, and
/// trusting it let an attacker rotate the key to bypass the join cap, the
/// recover throttle, and the admin-login floor.
pub fn client_ip(headers: &axum::http::HeaderMap, fallback: &str) -> String {
if let Some(ip) = headers
.get("x-real-ip")
.and_then(|v| v.to_str().ok())
.map(|s| s.trim())
.filter(|s| !s.is_empty())
{
return ip.to_owned();
}
headers
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.split(',').next())
.and_then(|s| s.split(',').next_back())
.map(|s| s.trim().to_owned())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| fallback.to_owned())
}
#[cfg(test)]
mod tests {
use super::*;
use super::client_ip;
use axum::http::HeaderMap;
const MIN: Duration = Duration::from_secs(60);
#[test]
fn allows_up_to_max_then_blocks() {
let rl = RateLimiter::new();
assert!(rl.check("k", 3, MIN));
assert!(rl.check("k", 3, MIN));
assert!(rl.check("k", 3, MIN));
assert!(!rl.check("k", 3, MIN), "the 4th request must be blocked");
}
#[test]
fn keys_are_independent() {
let rl = RateLimiter::new();
assert!(rl.check("a", 1, MIN));
assert!(!rl.check("a", 1, MIN));
assert!(rl.check("b", 1, MIN), "a different key has its own window");
}
#[test]
fn window_slides_and_allows_again_after_expiry() {
let rl = RateLimiter::new();
let w = Duration::from_millis(40);
assert!(rl.check("k", 1, w));
assert!(!rl.check("k", 1, w));
std::thread::sleep(Duration::from_millis(55));
assert!(rl.check("k", 1, w), "the slot should expire once the window passes");
}
#[test]
fn retry_after_is_between_one_and_window() {
let rl = RateLimiter::new();
assert!(rl.check_with_retry("k", 1, MIN).is_ok());
let retry = rl.check_with_retry("k", 1, MIN).unwrap_err();
assert!((1..=60).contains(&retry), "retry_after {retry} out of range");
}
#[test]
fn clear_resets_every_window() {
let rl = RateLimiter::new();
assert!(rl.check("k", 1, MIN));
assert!(!rl.check("k", 1, MIN));
rl.clear();
assert!(rl.check("k", 1, MIN), "clear() must free the window");
}
#[test]
fn client_ip_prefers_first_forwarded_for_entry() {
fn prefers_x_real_ip() {
let mut h = HeaderMap::new();
h.insert("x-forwarded-for", "203.0.113.7, 10.0.0.1".parse().unwrap());
assert_eq!(client_ip(&h, "fallback"), "203.0.113.7");
h.insert("x-real-ip", "9.9.9.9".parse().unwrap());
h.insert("x-forwarded-for", "1.1.1.1, 9.9.9.9".parse().unwrap());
assert_eq!(client_ip(&h, "fb"), "9.9.9.9");
}
#[test]
fn client_ip_trims_surrounding_whitespace() {
fn xff_uses_rightmost_not_spoofable_leftmost() {
// Attacker prepends a forged entry; the proxy-appended real peer is last.
let mut h = HeaderMap::new();
h.insert("x-forwarded-for", " 198.51.100.5 ".parse().unwrap());
assert_eq!(client_ip(&h, "fb"), "198.51.100.5");
h.insert("x-forwarded-for", "1.2.3.4, 203.0.113.7".parse().unwrap());
assert_eq!(client_ip(&h, "fb"), "203.0.113.7");
}
#[test]
fn client_ip_falls_back_when_header_absent() {
assert_eq!(client_ip(&HeaderMap::new(), "127.0.0.1"), "127.0.0.1");
fn falls_back_when_no_headers() {
assert_eq!(client_ip(&HeaderMap::new(), "fb"), "fb");
}
}

View File

@@ -72,58 +72,3 @@ fn random_ticket() -> String {
rng.fill(&mut bytes);
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn issue_then_consume_returns_the_hash_exactly_once() {
let store = SseTicketStore::new();
let ticket = store.issue("hash-1".into());
assert_eq!(store.consume(&ticket).as_deref(), Some("hash-1"));
// Single-use: a replay of the same ticket is rejected.
assert_eq!(store.consume(&ticket), None, "a consumed ticket must not be reusable");
}
#[test]
fn unknown_ticket_consumes_to_none() {
let store = SseTicketStore::new();
assert_eq!(store.consume("never-issued"), None);
}
#[test]
fn issued_tickets_are_unique_and_hex() {
let store = SseTicketStore::new();
let a = store.issue("h".into());
let b = store.issue("h".into());
assert_ne!(a, b, "each ticket must be unique");
assert_eq!(a.len(), 48, "24 random bytes → 48 hex chars");
assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn fresh_ticket_survives_prune() {
let store = SseTicketStore::new();
let ticket = store.issue("h".into());
store.prune(); // not expired → kept
assert_eq!(store.consume(&ticket).as_deref(), Some("h"));
}
#[test]
fn expired_ticket_consumes_to_none() {
// Construct an entry that is already past the TTL and confirm consume() rejects it.
let store = SseTicketStore::new();
let stale = "stale-ticket".to_string();
store.inner.lock().unwrap().insert(
stale.clone(),
Entry {
token_hash: "h".into(),
issued_at: Instant::now()
.checked_sub(TTL + Duration::from_secs(1))
.expect("host uptime should exceed the ticket TTL"),
},
);
assert_eq!(store.consume(&stale), None, "an expired ticket must not authenticate");
}
}

View File

@@ -1,11 +1,20 @@
use std::sync::Arc;
use sqlx::PgPool;
use tokio::sync::broadcast;
use tokio::sync::{broadcast, Semaphore};
use crate::config::AppConfig;
use crate::services::compression::CompressionWorker;
use crate::services::rate_limiter::RateLimiter;
use crate::services::sse_tickets::SseTicketStore;
/// Max concurrent in-flight `/upload` requests. Each holds its whole file in
/// memory while reading the multipart body, so this bounds aggregate upload RAM
/// to ~N × the 550 MB body cap (≈2.2 GB here) — keeping headroom for Postgres +
/// the app on an 8 GB box. The per-user rate limiter is a *count* limiter
/// (10/hr), not a concurrency cap, so this is the actual OOM guard. Tunable.
pub const UPLOAD_MAX_CONCURRENCY: usize = 4;
#[derive(Clone, Debug)]
pub struct SseEvent {
pub event_type: String,
@@ -31,13 +40,21 @@ pub struct AppState {
pub compression: CompressionWorker,
pub rate_limiter: RateLimiter,
pub sse_tickets: SseTicketStore,
/// Caps concurrent upload-body buffering (see UPLOAD_MAX_CONCURRENCY).
pub upload_limiter: Arc<Semaphore>,
}
impl AppState {
pub fn new(pool: PgPool, config: AppConfig) -> Self {
let (sse_tx, _) = broadcast::channel(256);
let compression =
CompressionWorker::new(pool.clone(), config.media_path.clone(), 2, sse_tx.clone());
// Independent image (2) and video (2) permit pools — see CompressionWorker.
let compression = CompressionWorker::new(
pool.clone(),
config.media_path.clone(),
2,
2,
sse_tx.clone(),
);
Self {
pool,
config,
@@ -45,6 +62,7 @@ impl AppState {
compression,
rate_limiter: RateLimiter::new(),
sse_tickets: SseTicketStore::new(),
upload_limiter: Arc::new(Semaphore::new(UPLOAD_MAX_CONCURRENCY)),
}
}
}

View File

@@ -1,8 +0,0 @@
# Dev-only overlay. NOT loaded automatically (unlike docker-compose.override.yml).
# Opt in explicitly for local development when you need host access to Postgres:
# docker compose -f docker-compose.yml -f docker-compose.dev.yml up
# Never use this overlay in production — it publishes the database port on the host.
services:
db:
ports:
- "5432:5432"

View File

@@ -0,0 +1,8 @@
# Auto-merged by `docker compose up`. Exposes Postgres for LOCAL tooling only —
# bound to 127.0.0.1 so that even when this committed file lands on the prod host
# the DB is NOT reachable on the public IP (a `0.0.0.0` publish would also bypass
# ufw). Never widen this to 0.0.0.0.
services:
db:
ports:
- "127.0.0.1:5432:5432"

View File

@@ -21,6 +21,13 @@ services:
dockerfile: Dockerfile
restart: unless-stopped
env_file: .env
# Force production mode here (not via .env, which is easy to forget) so the
# backend's JWT-secret guard actually fires on this deployment.
environment:
APP_ENV: production
# Backstop the in-app upload RAM caps: if anything slips the per-request
# bounds, the container is OOM-killed instead of the 8 GB host.
mem_limit: 3g
depends_on:
db:
condition: service_healthy
@@ -28,6 +35,13 @@ services:
- media_data:/media
expose:
- "3000"
# /health is unauthenticated; start_period covers the boot-time migrations.
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://localhost:3000/health"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
frontend:
build:
@@ -36,9 +50,16 @@ services:
restart: unless-stopped
env_file: .env
depends_on:
- app
app:
condition: service_healthy
expose:
- "3001"
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://localhost:3001/"]
interval: 10s
timeout: 5s
retries: 5
start_period: 15s
caddy:
image: caddy:2-alpine
@@ -49,9 +70,13 @@ services:
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
# Gate on readiness so Caddy doesn't proxy to a not-yet-listening upstream
# (which otherwise shows brief 502s on boot/restart).
depends_on:
- app
- frontend
app:
condition: service_healthy
frontend:
condition: service_healthy
volumes:
postgres_data:

View File

@@ -1,79 +0,0 @@
# Decision: media serving — unauthenticated UUID vs. signed gateway
**Status:** OPEN — needs a call. Written 2026-06-30 from the 2026-06-27 audit
(`fix/audit-2026-06-27-critical-medium`), which implemented the signed-gateway option that
`main` did not adopt.
**Scope:** how `main` serves uploaded photos/videos (originals + previews/thumbnails) to the
`<img>`/`<video>` tags in the feed, lightbox, and diashow.
---
## The two models
### A. Current `main` — unauthenticated, UUID-as-capability
- `/api/v1/upload/{id}/original`**no auth**; the unguessable upload UUID *is* the capability.
(Documented as intentional in `frontend/src/lib/data-mode-store.ts`.)
- `/media/*` — static `axum` `ServeDir`, **no auth layer**, serves preview/thumbnail files by path.
- No expiry, no signature, no per-request authorization on the bytes.
- **Works with** plain `<img src>` (no Authorization header needed) and browser caching, at zero
per-request backend cost.
### B. Audit branch — authenticated signed gateway
- `/media/{kind}/{id}?sig=…` via `handlers::media::serve`.
- HMAC signature (keyed off `jwt_secret`), **time-boxed** (~24 h, bucketed to a 1 h
URL-stability window so warm fetches stay cacheable).
- Authorizes by **signature + uploader visibility**, not requester identity.
- Still `<img>`-compatible (capability rides in the query string, not a header).
- Cost: token mint/verify, ~2 DB queries per *cold* fetch, and the HMAC key currently reuses
`jwt_secret` (see "Media HMAC domain separation" in [SECURITY-BACKLOG.md](SECURITY-BACKLOG.md)).
---
## What actually differs (the tradeoff)
| | A. UUID (main) | B. Signed gateway (audit) |
|---|---|---|
| Leaked URL/UUID (forwarded link, browser history, referer, logs) | **Permanent** full-res access | Access **expires** (~24 h); URL can't be re-minted |
| Banned / departed guest | Retains **permanent** access to every original whose UUID they hold | Can't mint new URLs; can replay **held** URLs ≤ TTL only |
| Revocation | None (UUID is forever) | Rotate the signing key → all outstanding URLs die |
| Enumeration | Mitigated by UUIDv4 unguessability | Same, plus signature |
| Per-request cost | Zero (static serve) | Token verify + ~2 DB queries (cold) |
| Plumbing / failure surface | Minimal | Token mint/verify, key mgmt, cache-bucket logic |
Neither model solves the fundamental `<img>`-can't-send-a-Bearer constraint: once a browser holds
a media URL, it is replayable for that URL's lifetime. Model B simply **bounds that lifetime** and
**adds revocability**; Model A's lifetime is *forever*.
## Threat model (this deployment)
Private single-box event app, ~100 guests, ~1 000 files, one event at a time. Media is **shared
with all guests by design** — it is personal but not secret *within* the event. The real risk is
**a URL escaping the event boundary** (a guest forwards a link; it lands in chat history, a public
post, or server/proxy logs) granting an outsider — or a removed guest — access.
## Recommendation
This is a judgment call, not a clear-cut bug, so it's yours to make. My leaning:
- **Adopt Model B (signed gateway)** if post-event link leakage or removed-guest access is a real
concern for you — it's the materially stronger posture (bounded exposure + key-rotation
revocation) and the implementation already exists on the audit branch. If adopted, also do the
cheap **HKDF domain-separation** for the HMAC key (backlog 🅱).
- **Keep Model A (UUID)** as an *explicitly accepted risk* if you're comfortable that a leaked
link = permanent access at this scale. If so, two cheap hardening steps are still worth doing:
1. **Log hygiene** — ensure the upload UUID never lands in access logs with enough context to
correlate (the `TraceLayer` logs the request *path*, and `/api/v1/upload/{id}/original`
puts the UUID *in the path*). Confirm logs aren't shipped/retained where that matters.
2. **Document the decision** in `README`/`PROJECT.md` so "unauthenticated media" is a recorded
choice, not an oversight.
**Sharpest single fact to decide on:** in Model A, a guest you *ban* keeps full-resolution access
to every original they ever loaded, forever. In Model B, that access dies within ~24 h. If that
asymmetry matters for your events, adopt the gateway.
## If you adopt B
The implementation lives on `origin/fix/audit-2026-06-27-critical-medium`:
`backend/src/handlers/media.rs` (+ `media_token`), the `/media/{kind}/{id}` route in `main.rs`, and
the feed/upload/host changes that emit signed URLs (`models/upload.rs`, `handlers/feed.rs`). It
would need re-basing onto current `main` (which has since diverged across ~80 files).

View File

@@ -1,64 +1,29 @@
# Security & Hardening Backlog
Tracks the deliberately-deferred items from the 2026-06-27 security audit and its review passes.
**Provenance & reconciliation.** This document was extracted from the
`fix/audit-2026-06-27-critical-medium` branch and reconciled against `main` on 2026-06-30.
That audit's Critical→Medium findings were **largely re-implemented into `main`** through the
later batch branches (security-review-followups, security-review-batch-2, the UX batches) rather
than by merging the audit branch — so `git` shows no merge, but the controls are present. Each
item below is tagged with its **current status in `main`**:
-**Done in main** — addressed (possibly via a different implementation).
-**Open** — still applies to `main`.
- 🔀 **Contingent** — only relevant if `main` adopts the audit's signed-media gateway (see
[DECISION-media-auth.md](DECISION-media-auth.md)).
> The audit branch additionally implemented an **authenticated, signed media gateway** that `main`
> did **not** adopt — `main` serves media unauthenticated (static `ServeDir` + UUID-capability
> `/api/v1/upload/{id}/original`). That architectural choice is written up separately in
> [DECISION-media-auth.md](DECISION-media-auth.md); the "by-design notes" at the bottom of this
> file describe the *audit branch's* model and apply to `main` only if that gateway is adopted.
---
Tracks the deliberately-deferred items from the 2026-06-27 audit and its review passes. The
Critical→Medium findings and the cheap "bucket 🅰" LOWs are fixed on
`fix/audit-2026-06-27-critical-medium`. What remains is recorded here so the decisions are
explicit, not forgotten.
## 🅱 Worth a tracked ticket (real, not one-liners)
- **Moderation UI gap** — the backend `DELETE /host/upload/{id}` and `DELETE /host/comment/{id}`
endpoints have **no frontend caller**, so a host cannot remove a *guest's* content from the UI
(the feed `ContextSheet` only offers delete for the viewer's *own* uploads —
`target.user_id === myUserId`). Still a functional hole in `main`. Needs a host-facing "remove"
action wired to those endpoints, gated on host/admin role.
- ✅/⬜ **Feed reactivity***Mostly fixed in `main`.* The full-reload-on-every-SSE-event problem
(a single `like-update`/`new-comment`/`upload-processed` calling `loadFeed(true)` and collapsing
a scrolled feed) was fixed in the UX batch: `main` now patches the affected card in place
(`patchCount`) and debounces processing (`scheduleInPlaceRefresh`). ⬜ **Remaining sub-item:**
owner-deleted uploads are still not broadcast — `delete_upload` returns `204` with no
`upload-deleted` SSE, so other clients keep showing a deleted post until refresh (only host
deletes broadcast). Emit `upload-deleted` from the owner delete path too.
- 🔀 **Media HMAC domain separation***Only applies if the signed-media gateway is adopted.* The
audit's signed-media tokens reuse `jwt_secret` as the HMAC key; a dedicated derived key
- **Moderation UI gap** — the backend `DELETE /host/upload/{id}` and `DELETE /host/comment/{id}`
endpoints have **no frontend caller**, so a host cannot remove a guest's content from the UI.
This is a functional hole, not polish. Needs a host-facing "remove" action wired to those
endpoints (a `ContextSheet` action gated on host role).
- **Feed reactivity** — a single global SSE event (`like-update`/`new-comment`/`upload-processed`)
triggers `loadFeed(true)`, which full-replaces the list with the first 20, collapsing a
scrolled feed; and owner-deleted uploads aren't broadcast to other clients (only host deletes
are). Patch the affected item from the SSE payload instead of full-reloading; emit
`upload-deleted` from the owner `delete_upload` path too.
- **Media HMAC domain separation** — the signed-media tokens reuse `jwt_secret` as the HMAC key.
Correct today (different message structure), but a dedicated derived key
(`HKDF(jwt_secret, "media-url")`) would isolate the domains so a future change to one can't
weaken the other. N/A to `main` as it stands (no signed media).
-**Quota mount-detection + low-disk guard***Still applies to `main`.* `compute_storage_quota`
(`backend/src/handlers/upload.rs`) and `admin.rs` pick the disk via `starts_with`, so root (`/`)
is a wildcard prefix that can match the wrong device; and there's no hard min-free-space
precheck when the quota is disabled. Use longest-prefix match; add an unconditional 507/429 when
free space is critically low.
## ✅ Fixed in main since the audit (for the record)
These audit findings are present in `main` today (verified 2026-06-30): event-scoped social
handlers (cross-event authz), server-side MIME/ext allowlist on upload, recovery-PIN lockout
backoff, unspoofable client IP in the rate limiter, effective JWT production-secret guard, DB port
no longer publicly exposed, container healthchecks, bcrypt offloaded via `spawn_blocking`,
bounded compression concurrency (semaphore), bounded feed queries (`LIMIT ≤ 100`), and the
viewport-fit / reduced-motion / aria a11y pass. **New since the audit:** an image-decode
decompression-bomb cap (`image::Limits` 12000×12000 / 256 MiB) ported into
`backend/src/services/compression.rs`.
weaken the other.
- **Quota mount-detection + low-disk guard** — `compute_storage_quota` picks the disk via
`starts_with` (root is a wildcard prefix → can match the wrong device) and there's no hard
min-free-space precheck when the quota is disabled. Use longest-prefix match; add an
unconditional 507/429 when free space is critically low.
## 🅲 Consciously won't-fix at ~100-guest single-box scale
@@ -72,17 +37,15 @@ tenancy model changes.
are sub-ms at this row count.
- Optimistic-like in-flight guard, ownership-snapshot-at-mount, assorted copy tweaks — UX polish.
## By-design notes (audit branch's signed-media model — see DECISION-media-auth.md)
## By-design notes (documented, not bugs)
> These describe the **audit branch's** signed-gateway model. `main` does **not** serve media this
> way; for `main`'s actual media-access posture and the tradeoff, see
> [DECISION-media-auth.md](DECISION-media-auth.md).
- **Banned user retains ≤24h media access via already-held signed URLs.** The audit's media gateway
- **Banned user retains ≤24h media access via already-held signed URLs.** The media gateway
authorizes by *signature + uploader visibility*, not requester identity, and signed URLs are
time-boxed (24h, bucketed). A banned/revoked-session user cannot mint new URLs (every API call
401/403s) but can replay URLs they already hold until expiry — only for content they already saw.
Accepted there: tightening would require per-request identity on every `<img>` load, which the
`<img>`-can't-send-a-Bearer constraint precludes.
401/403s) but can replay URLs they already hold until expiry — only for content they already
saw. Accepted: tightening this would require per-request identity on every `<img>` load, which
the `<img>`-can't-send-a-Bearer constraint precludes. Shorten the TTL in `media_token.rs` if a
stricter bound is ever needed.
- **Two DB queries per *cold* media fetch** (`upload` row + uploader row). Mitigated by browser
caching and the stable bucketed URL. Could be one JOIN if it ever shows up in profiling.
caching and the stable bucketed URL (so warm fetches don't hit the backend at all). Could be one
JOIN if it ever shows up in profiling.

View File

@@ -68,17 +68,6 @@ export const db = {
);
},
/**
* Flip the `export_zip_ready` gate directly. The download handler serves bytes
* only when this boolean is true AND the file exists on disk, so setting it true
* without a file lets tests exercise the "ready but file missing" 404 branch.
*/
async setExportZipReady(slug: string, ready: boolean) {
await withClient((c) =>
c.query(`UPDATE event SET export_zip_ready = $2 WHERE slug = $1`, [slug, ready])
);
},
/** Insert a pre-baked export job row to skip the (slow) real compression path. */
async fakeExportJob(eventSlug: string, type: 'zip' | 'html', status: 'pending' | 'running' | 'done') {
await withClient(async (c) => {

View File

@@ -1,55 +0,0 @@
/**
* Shared seed helpers so specs don't each hand-roll the upload/comment create
* flow. Centralising the API contract (routes, field names, expected statuses)
* means an API change is a one-file edit, not a 7-file hunt.
*/
import { uploadRaw, JPEG_MAGIC } from './upload-client';
import { db } from '../fixtures/db';
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
export type SeedUploadOptions = {
caption?: string;
/** Mark compression done so the card is fully rendered in the feed. Default true. */
visible?: boolean;
};
/** Seed a real, accepted upload owned by `jwt` and return its id. */
export async function seedUpload(jwt: string, opts: SeedUploadOptions = {}): Promise<string> {
const body = new Uint8Array(1024);
body.set(JPEG_MAGIC, 0);
const res = await uploadRaw(jwt, body, {
filename: 'a.jpg',
contentType: 'image/jpeg',
caption: opts.caption,
});
if (res.status !== 201) throw new Error(`seedUpload failed: ${res.status} ${await res.text()}`);
const { id } = await res.json();
if (opts.visible !== false) await db.setUploadCompressionStatus(id, 'done');
return id;
}
/** Seed a comment on `uploadId` authored by `jwt`; return its id. */
export async function seedComment(jwt: string, uploadId: string, body: string): Promise<string> {
const res = await fetch(`${BASE}/api/v1/upload/${uploadId}/comments`, {
method: 'POST',
headers: { Authorization: `Bearer ${jwt}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ body }),
});
if (res.status !== 201) throw new Error(`seedComment failed: ${res.status} ${await res.text()}`);
return (await res.json()).id;
}
/** Read the comments for an upload as `jwt`. */
export async function listComments(jwt: string, uploadId: string): Promise<any[]> {
const res = await fetch(`${BASE}/api/v1/upload/${uploadId}/comments`, {
headers: { Authorization: `Bearer ${jwt}` },
});
return res.json();
}
/** Resolve a single upload row from a feed response whose envelope shape isn't pinned. */
export function findFeedRow(feed: any, id: string): any {
const list: any[] = feed.uploads ?? feed.items ?? feed;
return Array.isArray(list) ? list.find((u: any) => u.id === id) : undefined;
}

View File

@@ -3,13 +3,10 @@
* `like-update` arrived for upload X within 5 seconds" without driving a
* second browser tab.
*
* The backend authenticates the SSE endpoint via a single-use `?ticket=` minted
* at POST /api/v1/stream/ticket (the raw JWT is never put in the URL). This helper
* does that exchange internally, so callers still just pass a JWT to `start()`.
* The backend authenticates the SSE endpoint via `?token=` query param
* (the EventSource API can't set headers).
*/
import { mintSseTicket } from './sse';
export type SseEvent = { type: string; data: any; receivedAt: number };
export class SseListener {
@@ -20,10 +17,7 @@ export class SseListener {
constructor(private baseUrl: string = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') {}
async start(token: string): Promise<void> {
// Exchange the JWT for a single-use SSE ticket (the stream endpoint no longer
// accepts ?token=).
const ticket = await mintSseTicket(token);
const url = `${this.baseUrl}/api/v1/stream?ticket=${encodeURIComponent(ticket)}`;
const url = `${this.baseUrl}/api/v1/stream?token=${encodeURIComponent(token)}`;
// Use fetch with streaming since Node has no EventSource by default.
const res = await fetch(url, { signal: this.controller.signal });
if (!res.body) throw new Error('SSE response has no body');

View File

@@ -1,43 +0,0 @@
/**
* Shared SSE-flow helpers. The stream auth flow (mint a single-use ticket, open
* with `?ticket=`) is security-sensitive and recently changed from `?token=`, so
* it lives in one place instead of being re-inlined per spec.
*/
import type { Page } from '@playwright/test';
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
/** Exchange a JWT for a single-use SSE ticket via POST /api/v1/stream/ticket. */
export async function mintSseTicket(jwt: string): Promise<string> {
const res = await fetch(`${BASE}/api/v1/stream/ticket`, {
method: 'POST',
headers: { Authorization: `Bearer ${jwt}` },
});
if (res.status !== 200) throw new Error(`mintSseTicket failed: ${res.status} ${await res.text()}`);
return (await res.json()).ticket;
}
/** Open the SSE stream with a ticket, return the HTTP status, and tear the stream down. */
export async function openStream(ticket: string): Promise<number> {
const c = new AbortController();
try {
const res = await fetch(`${BASE}/api/v1/stream?ticket=${encodeURIComponent(ticket)}`, {
signal: c.signal,
});
return res.status;
} finally {
c.abort();
}
}
/**
* Count EventSource opens (GET /api/v1/stream?ticket=…) on a page — NOT the ticket
* POST. Returns a getter for the running count.
*/
export function trackStreamOpens(page: Page): () => number {
let n = 0;
page.on('request', (req) => {
if (req.method() === 'GET' && req.url().includes('/api/v1/stream?')) n++;
});
return () => n;
}

View File

@@ -21,15 +21,7 @@ export class RecoverPage {
async recover(name: string, pin: string) {
await this.nameInput.fill(name);
// Filling the 4th digit fires the form's auto-submit (the onPinInput handler
// calls handleRecover once pin.length === 4, see pin-auto-submit.spec). An explicit
// submit click would race the ensuing navigation and detach mid-click, so only click
// as a fallback if the button is still around (e.g. a partial / failed PIN).
await this.pinInput.fill(pin);
if (await this.submitButton.isEnabled().catch(() => false)) {
await this.submitButton.click().catch(() => {
/* auto-submit already navigated — nothing to click */
});
}
await this.submitButton.click();
}
}

View File

@@ -12,12 +12,6 @@ import { defineConfig, devices } from '@playwright/test';
* engine-level divergences. The rest of the suite only runs against
* `chromium-desktop` to keep the wall-clock reasonable.
*/
// camera/microphone/clipboard are Chromium-only permissions; passing them to
// firefox/webkit projects throws "Unknown permission: camera" and fails the
// whole test before it runs. Granted per-Chromium-project below instead of in
// the global `use` block.
const CHROMIUM_PERMISSIONS = ['camera', 'microphone', 'clipboard-read', 'clipboard-write'];
export default defineConfig({
testDir: './specs',
outputDir: './test-results',
@@ -41,8 +35,9 @@ export default defineConfig({
video: 'retain-on-failure',
actionTimeout: 10_000,
navigationTimeout: 30_000,
// No camera/mic/clipboard here — those are Chromium-only and are granted on
// the Chromium projects below (see CHROMIUM_PERMISSIONS).
// Camera/mic permissions granted by default; the fake-media launch args
// (set per-project below for Chromium) supply the actual stream.
permissions: ['camera', 'microphone', 'clipboard-read', 'clipboard-write'],
},
projects: [
@@ -54,7 +49,6 @@ export default defineConfig({
testIgnore: ['**/09-mobile/**'],
use: {
...devices['Desktop Chrome'],
permissions: CHROMIUM_PERMISSIONS,
launchOptions: {
args: [
'--use-fake-ui-for-media-stream',
@@ -74,13 +68,13 @@ export default defineConfig({
{
name: 'chromium-mobile',
testMatch: ['**/09-mobile/**/*.spec.ts'],
use: { ...devices['Pixel 7'], permissions: CHROMIUM_PERMISSIONS },
use: { ...devices['Pixel 7'] },
},
// ── Mobile UA smoke matrix (runs only @smoke specs in CI) ────────────
{
name: 'chromium-pixel7',
use: { ...devices['Pixel 7'], permissions: CHROMIUM_PERMISSIONS },
use: { ...devices['Pixel 7'] },
grep: /@smoke/,
},
{
@@ -90,7 +84,6 @@ export default defineConfig({
viewport: { width: 360, height: 780 },
userAgent:
'Mozilla/5.0 (Linux; Android 14; SM-S911B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36',
permissions: CHROMIUM_PERMISSIONS,
},
grep: /@smoke/,
},
@@ -101,7 +94,6 @@ export default defineConfig({
viewport: { width: 360, height: 780 },
userAgent:
'Mozilla/5.0 (Linux; Android 14; SM-S911B) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/24.0 Chrome/124.0.0.0 Mobile Safari/537.36',
permissions: CHROMIUM_PERMISSIONS,
},
grep: /@smoke/,
},
@@ -111,7 +103,6 @@ export default defineConfig({
...devices['Pixel 7'],
userAgent:
'Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36 EdgA/124.0.0.0',
permissions: CHROMIUM_PERMISSIONS,
},
grep: /@smoke/,
},
@@ -134,9 +125,6 @@ export default defineConfig({
use: {
...devices['Pixel 7'],
defaultBrowserType: 'firefox',
// Firefox rejects `isMobile` (Chromium-only). Keep the phone viewport +
// Android UA for coverage, but drop the unsupported flag.
isMobile: false,
userAgent:
'Mozilla/5.0 (Android 14; Mobile; rv:124.0) Gecko/124.0 Firefox/124.0',
},

View File

@@ -54,13 +54,9 @@ test.describe('Auth — join flow', () => {
await expect(join.recoveryPinInput).toBeVisible();
await expect(page.getByText(/Charlie.*bereits vergeben/)).toBeVisible();
// Type correct PIN → land on /feed with a new JWT. Filling the 4th digit
// auto-submits (see pin-auto-submit.spec), so an explicit submit click would
// race the navigation; click only as a fallback if the button is still around.
// Type correct PIN → land on /feed with a new JWT
await join.recoveryPinInput.fill(original.pin);
if (await join.recoverySubmit.isEnabled().catch(() => false)) {
await join.recoverySubmit.click().catch(() => {});
}
await join.recoverySubmit.click();
await page.waitForURL('**/feed');
const storage = await readStorage(page);

View File

@@ -6,12 +6,8 @@
*/
import { test, expect } from '../../fixtures/test';
import { join } from 'node:path';
import { readFileSync } from 'node:fs';
const SAMPLE_JPG = join(process.cwd(), 'fixtures', 'media', 'sample.jpg');
// A real, decodable JPEG — the upload handler validates magic bytes, so a
// zero-filled buffer would be rejected with 400 before the rate limiter is reached.
const SAMPLE_BYTES = readFileSync(SAMPLE_JPG);
test.describe('Upload — rate limit', () => {
test('4th upload in one hour returns 429 with Retry-After', async ({ api, adminToken, guest }) => {
@@ -28,7 +24,7 @@ test.describe('Upload — rate limit', () => {
// Hit the API directly for speed — UI behavior is asserted in gallery-path.spec.
const upload = async (n: number) => {
const form = new FormData();
const blob = new Blob([SAMPLE_BYTES], { type: 'image/jpeg' });
const blob = new Blob([new Uint8Array(640)], { type: 'image/jpeg' });
form.append('file', blob, `file${n}.jpg`);
form.append('content_type', 'image/jpeg');
return fetch((process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') + '/api/v1/upload', {
@@ -51,6 +47,7 @@ test.describe('Upload — rate limit', () => {
// The 429 response carries Retry-After.
const limited = responses.find((r) => r.status === 429)!;
expect(limited.headers.get('retry-after')).toBeTruthy();
void SAMPLE_JPG;
});
test('flipping upload_rate_enabled off bypasses the limit', async ({ api, adminToken, guest }) => {
@@ -59,7 +56,7 @@ test.describe('Upload — rate limit', () => {
const h = await guest('NoQuota');
const upload = async (n: number) => {
const form = new FormData();
const blob = new Blob([SAMPLE_BYTES], { type: 'image/jpeg' });
const blob = new Blob([new Uint8Array(640)], { type: 'image/jpeg' });
form.append('file', blob, `file${n}.jpg`);
form.append('content_type', 'image/jpeg');
return fetch((process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') + '/api/v1/upload', {

View File

@@ -1,68 +1,29 @@
/**
* USER_JOURNEYS.md §8 — grid-view search & filter chips. Verifies the OR / AND
* combination rules end-to-end by seeding uploads with known captions/uploaders,
* activating chips, and counting the resulting grid tiles.
* USER_JOURNEYS.md §8 — search and filter chips. Asserts the OR / AND
* combination rules described in the journey.
*
* (The pure combination logic is also unit-tested in
* frontend/src/lib/feed-filter.test.ts.)
* Most of this test currently drives the UI; the data-seeding happens
* via API once a Node-side upload helper lands. For now we ship the
* structure and the UI assertions, marked with `test.fixme` where they
* depend on seeded data we can't yet create.
*/
import { test, expect } from '../../fixtures/test';
import { seedUpload } from '../../helpers/seed';
import type { Page } from '@playwright/test';
/** Grid tiles render as buttons labelled "Upload anzeigen". */
const tiles = (page: Page) => page.getByRole('button', { name: 'Upload anzeigen' });
async function switchToGrid(page: Page) {
await page.getByRole('button', { name: 'Rasteransicht' }).click();
}
/** Type a query into the grid search box and click the matching suggestion. */
async function addFilter(page: Page, query: string, suggestionName: string | RegExp) {
const search = page.getByPlaceholder('Nutzer oder #Tag suchen…');
await search.click();
await search.fill(query);
await page.getByRole('button', { name: suggestionName }).click();
}
test.describe('Feed — filter & search', () => {
test('two hashtag chips combine with OR', async ({ page, guest, signIn }) => {
const a = await guest('OrUser');
await seedUpload(a.jwt, { caption: 'pic #wedding' });
await seedUpload(a.jwt, { caption: 'pic #party' });
await seedUpload(a.jwt, { caption: 'pic #other' });
await signIn(page, a); // lands on /feed
await switchToGrid(page);
await expect(tiles(page)).toHaveCount(3);
// One tag → only its card.
await addFilter(page, '#wedding', /wedding/i);
await expect(tiles(page)).toHaveCount(1);
// Adding a second tag widens the result (OR), not narrows it.
await addFilter(page, '#party', /party/i);
await expect(tiles(page)).toHaveCount(2);
test.fixme('two hashtag chips combine with OR', async ({ page, guest, signIn }) => {
const h = await guest('Searcher');
await signIn(page, h);
// TODO: seed 2 uploads with different hashtags, then activate two chips
// and assert both cards remain visible.
await page.goto('/feed');
expect(true).toBe(true);
});
test('uploader chip + hashtag chip combines with AND', async ({ page, guest, signIn }) => {
const alice = await guest('AndAlice');
const bob = await guest('AndBob');
await seedUpload(alice.jwt, { caption: 'pic #wedding' }); // Alice + wedding
await seedUpload(bob.jwt, { caption: 'pic #wedding' }); // Bob + wedding
await seedUpload(alice.jwt, { caption: 'pic #party' }); // Alice + party
await signIn(page, alice); // feed is event-wide → sees all three
await switchToGrid(page);
await expect(tiles(page)).toHaveCount(3);
// Filter by uploader → Alice's two uploads.
await addFilter(page, 'AndAlice', 'AndAlice');
await expect(tiles(page)).toHaveCount(2);
// Add a tag → must satisfy BOTH (Alice AND #wedding) → just the one.
await addFilter(page, '#wedding', /wedding/i);
await expect(tiles(page)).toHaveCount(1);
test.fixme('uploader chip + hashtag chip combines with AND', async ({ page, guest, signIn }) => {
const h = await guest('Searcher2');
await signIn(page, h);
await page.goto('/feed');
expect(true).toBe(true);
});
test('feed page renders without crashing for an authed user', async ({ page, guest, signIn }) => {

View File

@@ -1,80 +1,36 @@
/**
* USER_JOURNEYS.md §7 — liking and commenting.
*
* Like behavior is asserted deterministically via the feed snapshot; the comment
* SSE round-trip is asserted by subscribing to the stream as a second user and
* waiting for the `new-comment` event to arrive.
* USER_JOURNEYS.md §7 — liking and commenting. SSE round-trip is
* asserted by opening a second tab as a different user.
*/
import { test, expect } from '../../fixtures/test';
import { SseListener } from '../../helpers/sse-listener';
import { seedUpload, seedComment, findFeedRow } from '../../helpers/seed';
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
async function like(jwt: string, uploadId: string): Promise<number> {
const res = await fetch(`${BASE}/api/v1/upload/${uploadId}/like`, {
method: 'POST',
headers: { Authorization: `Bearer ${jwt}` },
});
return res.status;
}
test.describe('Feed — like + comment', () => {
test('a like counts once per user and toggles off on repeat (no double-count)', async ({ api, guest }) => {
const author = await guest('Author');
const liker = await guest('Liker');
const uploadId = await seedUpload(author.jwt);
// Baseline: nobody has liked yet.
let row = findFeedRow(await api.getFeed(liker.jwt), uploadId);
expect(row.like_count).toBe(0);
expect(row.liked_by_me).toBe(false);
// First like → counted exactly once.
expect(await like(liker.jwt, uploadId)).toBe(204);
row = findFeedRow(await api.getFeed(liker.jwt), uploadId);
expect(row.like_count).toBe(1);
expect(row.liked_by_me).toBe(true);
// Liking again is a toggle → back to zero (guards against a regression that
// double-counts a repeated like instead of removing it).
expect(await like(liker.jwt, uploadId)).toBe(204);
row = findFeedRow(await api.getFeed(liker.jwt), uploadId);
expect(row.like_count).toBe(0);
expect(row.liked_by_me).toBe(false);
// A second distinct user's like is counted independently (per-user semantics).
expect(await like(liker.jwt, uploadId)).toBe(204); // liker likes again → 1
expect(await like(author.jwt, uploadId)).toBe(204); // author likes too → 2
row = findFeedRow(await api.getFeed(liker.jwt), uploadId);
expect(row.like_count).toBe(2);
test('like is idempotent against rapid double-click', async ({ api, guest }) => {
const a = await guest('Liker');
// Seed an upload from a second user so `a` has something to like.
const b = await guest('Author');
// Without a multipart helper in Node, we exercise the like endpoint directly
// and assert behavior via the public feed snapshot.
// (Spec is a placeholder until we add a Node-side upload helper or do
// the seed via UI.)
const feed = await api.getFeed(a.jwt);
void feed;
void b;
});
test('comment by user A → SSE new-comment delivered to user B', async ({ guest }) => {
// SSE frames can take a keep-alive tick to flush through the reverse proxy, so
// both the stream connect and the delivery may cost up to ~30s each.
test.setTimeout(120_000);
const a = await guest('CommenterA');
const b = await guest('ListenerB');
const a = await guest('A');
const b = await guest('B');
// B subscribes to the stream BEFORE A comments, so the broadcast is captured.
const sse = new SseListener();
await sse.start(b.jwt);
try {
const uploadId = await seedUpload(a.jwt, { caption: 'pic' });
await seedComment(a.jwt, uploadId, 'hello from A');
// B must receive the new-comment event for this upload. Generous timeout: SSE
// frames flush on the keep-alive tick through the compressing reverse proxy.
const evt = await sse.waitForEvent(
'new-comment',
(e) => e.data?.upload_id === uploadId,
45_000
);
expect(evt.data.comment_count).toBe(1);
} finally {
sse.stop();
}
// Without an upload helper, this currently only verifies that the SSE stream
// *connects* for a guest. The comment send + receive assertion lands as soon
// as we add a backend-side helper to inject uploads bypassing multipart.
expect(sse.allEvents().length).toBeGreaterThanOrEqual(0);
sse.stop();
void a;
});
});

View File

@@ -1,49 +1,27 @@
/**
* SSE reconnection after tab background. USER_JOURNEYS.md §17 / edge cases.
*
* The app closes the EventSource on `document.hidden` and reopens it (minting a
* fresh ticket + new EventSource) when the tab becomes visible again — see
* frontend/src/lib/sse.ts. We assert the reconnect by counting stream-open
* requests rather than event delivery, which keeps the test fast and independent
* of the reverse proxy's SSE buffering.
*/
import { test, expect } from '../../fixtures/test';
import { trackStreamOpens } from '../../helpers/sse';
test.describe('Feed — SSE behavior', () => {
test('backgrounding then foregrounding the tab opens a fresh SSE connection', async ({ page, guest, signIn }) => {
const g = await guest('SseReconnect');
const streamOpens = trackStreamOpens(page);
test('SSE reconnects after tab visibility goes hidden then visible', async ({ page, guest, signIn }) => {
const h = await guest('SseReconnect');
await signIn(page, h);
await page.goto('/feed');
await signIn(page, g); // lands on /feed, which calls connectSse() on mount
// Initial connection established.
await expect.poll(streamOpens, { timeout: 10_000 }).toBeGreaterThanOrEqual(1);
// Background: the visibility handler reads document.hidden, so override that
// (not just visibilityState) before dispatching, or the close never fires.
// disconnectSse() closes the EventSource and clears any reconnect timer, so no
// new stream opens while hidden.
// Force-fire a visibilitychange to hidden, then back to visible. The app's
// sse.ts is expected to close + reopen the EventSource around this.
await page.evaluate(() => {
Object.defineProperty(document, 'hidden', { configurable: true, get: () => true });
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'hidden' });
Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'hidden' });
document.dispatchEvent(new Event('visibilitychange'));
});
// Snapshot the count AFTER backgrounding — this baselines out the initial open (and
// any spurious native/error reconnect before now), so the assertion below can only
// be satisfied by a NEW open attributable to the foreground event itself.
const afterHidden = streamOpens();
// Foreground again → connectSse() mints a new ticket and opens a new EventSource.
await page.waitForTimeout(500);
await page.evaluate(() => {
Object.defineProperty(document, 'hidden', { configurable: true, get: () => false });
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'visible' });
Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'visible' });
document.dispatchEvent(new Event('visibilitychange'));
});
// The reconnect is a brand-new stream GET that appears only after foregrounding.
await expect.poll(streamOpens, { timeout: 10_000 }).toBeGreaterThan(afterHidden);
// And the app is still functional.
// App should still be functional — assert the bottom nav remains visible.
await expect(page.getByRole('link', { name: 'Galerie' })).toBeVisible();
});
});

View File

@@ -41,9 +41,8 @@ test.describe('Feed — error toast on user action failures', () => {
const card = page.locator('article').filter({ hasText: author.displayName }).first();
await expect(card).toBeVisible({ timeout: 10_000 });
// Click the like button by its stable aria-label (the liker hasn't liked yet).
// Avoids matching a different digit-bearing button (e.g. the comment count).
await card.getByRole('button', { name: 'Gefällt mir' }).click();
// Click the like button in the actions row — first visible match inside the card.
await card.locator('button').filter({ hasText: /\d+/ }).first().click();
// The toast is rendered inside the global Toaster region with aria-live="polite".
const toast = page.getByTestId('toast').first();

View File

@@ -63,10 +63,8 @@ test.describe('Admin — stats', () => {
await guest('Stat2');
await guest('Stat3');
const stats = await api.getStats(adminToken);
// Deterministic after the per-test truncate: 3 seeded guests + the Admin account
// (recreated by the adminToken fixture's login) = exactly 4. An exact assertion
// catches undercount/overcount regressions a `>= 3` lower bound would miss.
expect(stats.user_count).toBe(4);
// Three guests + the Admin account auto-created on first admin login = 4 users.
expect(stats.user_count).toBeGreaterThanOrEqual(3);
expect(typeof stats.disk_total_bytes).toBe('number');
});
});

View File

@@ -42,42 +42,16 @@ test.describe('Export — release and download', () => {
expect(body.html.status).toBe('done');
});
const base = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
// Browser downloads stream to disk via a top-level navigation, so the download
// endpoint authenticates with a single-use ticket (no Bearer header).
async function mintTicket(jwt: string): Promise<string> {
const res = await fetch(base + '/api/v1/export/ticket', {
method: 'POST',
headers: { Authorization: `Bearer ${jwt}` },
test('ZIP download returns 404 when no file is on disk (export released but never compressed)', async ({ guest, db }) => {
const g = await guest('NoFile');
await db.setExportReleased(SLUG, true);
await db.fakeExportJob(SLUG, 'zip', 'done');
// Real backend additionally checks event.export_zip_ready. The faked row is
// enough for /status; the download path needs the boolean flag too.
const res = await fetch((process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') + '/api/v1/export/zip', {
headers: { Authorization: `Bearer ${g.jwt}` },
});
return (await res.json()).ticket;
}
test('ZIP download 404s when the export is not yet marked ready', async ({ guest, db }) => {
const g = await guest('NotReady');
// Released flag set, but export_zip_ready is still false → must refuse, never serve.
await db.setExportReleased(SLUG, true);
await db.fakeExportJob(SLUG, 'zip', 'done');
const ticket = await mintTicket(g.jwt);
const res = await fetch(base + '/api/v1/export/zip?ticket=' + encodeURIComponent(ticket));
// Pinned to 404 (not [404,200]): a 200 here would mean serving an export that was
// never released for download — a data-exposure regression. This hits the
// `!export_zip_ready` guard.
expect(res.status).toBe(404);
});
test('ZIP download 404s when marked ready but the file is missing on disk', async ({ guest, db }) => {
const g = await guest('ReadyNoFile');
// Released AND ready, but no Gallery.zip on disk (we never ran a real export) →
// the handler must 404 on the missing-file check, not 200/500 or serve a stale file.
await db.setExportReleased(SLUG, true);
await db.setExportZipReady(SLUG, true);
await db.fakeExportJob(SLUG, 'zip', 'done');
const ticket = await mintTicket(g.jwt);
const res = await fetch(base + '/api/v1/export/zip?ticket=' + encodeURIComponent(ticket));
expect(res.status).toBe(404);
// Either 404 ("not available" OR "file not found") — both are valid states for this setup.
expect([404, 200]).toContain(res.status);
});
});

View File

@@ -5,79 +5,26 @@
* with cross-user and banned-user scenarios that span multiple resources.
*/
import { test, expect } from '../../fixtures/test';
import { seedUpload, seedComment, listComments, findFeedRow } from '../../helpers/seed';
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
test.describe('Adversarial — deep authorization', () => {
// IDOR: user B must not be able to delete user A's REAL comment. This exercises the
// ownership guard (`comment.user_id != auth.user_id` → 403) — the previous version fired
// at the all-zeros UUID, which 404s at the lookup BEFORE that guard runs, so it never
// tested authorization at all.
test('user B cannot delete user A\'s comment (real resource → 403, comment survives)', async ({ guest }) => {
const a = await guest('CommentOwnerA');
const b = await guest('AttackerB');
test('user A cannot delete user B\'s comment via /api/v1/comment/{id}', async ({ api, guest }) => {
const a = await guest('CommentA');
const b = await guest('CommentB');
const uploadId = await seedUpload(a.jwt);
const commentId = await seedComment(a.jwt, uploadId, 'A owns this');
const res = await fetch(`${BASE}/api/v1/comment/${commentId}`, {
// We need an upload first; without a multipart helper here we use a placeholder:
// post a comment on a non-existent upload to force the path to return 404 / 403 / 401.
// The real intent is verified once an upload helper feeds this test a real upload_id.
const fakeId = '00000000-0000-0000-0000-000000000000';
const res = await fetch(`${BASE}/api/v1/comment/${fakeId}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${b.jwt}` },
});
// Must be 403 specifically — the comment exists and is in B's event, so a 404 would
// mean the ownership check was skipped/reordered.
expect(res.status).toBe(403);
// No state change: the comment is still there.
const after = await listComments(a.jwt, uploadId);
expect(after.some((c: any) => c.id === commentId)).toBe(true);
// Control: the real owner CAN delete it (proves the 403 was about identity, not a broken route).
const ownerDel = await fetch(`${BASE}/api/v1/comment/${commentId}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${a.jwt}` },
});
expect(ownerDel.status).toBe(204);
});
// IDOR: user B must not be able to delete user A's REAL upload.
test('user B cannot delete user A\'s upload (403, upload survives)', async ({ guest, db }) => {
const a = await guest('UploadOwnerA');
const b = await guest('AttackerB2');
const uploadId = await seedUpload(a.jwt);
expect(await db.countUploadsForUser(a.userId)).toBe(1);
const res = await fetch(`${BASE}/api/v1/upload/${uploadId}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${b.jwt}` },
});
expect(res.status).toBe(403);
// No state change: A's upload is still present (not soft-deleted).
expect(await db.countUploadsForUser(a.userId)).toBe(1);
});
// IDOR: user B must not be able to edit (re-caption / re-tag) user A's upload.
test('user B cannot edit user A\'s upload caption (403, caption unchanged)', async ({ guest }) => {
const a = await guest('UploadOwnerA2');
const b = await guest('AttackerB3');
// seedUpload marks compression done, so the upload is feed-visible for the read-back.
const uploadId = await seedUpload(a.jwt, { caption: 'original caption' });
const res = await fetch(`${BASE}/api/v1/upload/${uploadId}`, {
method: 'PATCH',
headers: { Authorization: `Bearer ${b.jwt}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ caption: 'hacked by B' }),
});
expect(res.status).toBe(403);
// No state change: the caption A set is intact.
const feedRes = await fetch(`${BASE}/api/v1/feed`, { headers: { Authorization: `Bearer ${a.jwt}` } });
const row = findFeedRow(await feedRes.json(), uploadId);
expect(row?.caption).toBe('original caption');
// Acceptable: 403 (not your comment), 404 (no such comment), 401.
expect([401, 403, 404]).toContain(res.status);
void a;
void api;
});
test('banned user cannot toggle a like', async ({ api, host, guest }) => {

View File

@@ -4,7 +4,6 @@
* or rejected gracefully without crashing the backend.
*/
import { test, expect } from '../../fixtures/test';
import { mintSseTicket } from '../../helpers/sse';
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
@@ -47,15 +46,9 @@ test.describe('Adversarial — small-scale abuse', () => {
test('SSE: 10 concurrent streams from one user do not crash the server', async ({ guest }) => {
const g = await guest('SseFlood');
// The stream endpoint authenticates via single-use tickets (POST /stream/ticket),
// not the raw JWT — a `?token=` open is rejected with 400. Mint one ticket per stream.
// (These streams must be held open concurrently, so we can't use the openStream
// helper which opens-and-aborts a single stream.)
const tickets = await Promise.all(Array.from({ length: 10 }, () => mintSseTicket(g.jwt)));
const controllers = tickets.map(() => new AbortController());
const requests = tickets.map((ticket, i) =>
fetch(`${BASE}/api/v1/stream?ticket=${encodeURIComponent(ticket)}`, { signal: controllers[i].signal })
const controllers = Array.from({ length: 10 }, () => new AbortController());
const requests = controllers.map((c) =>
fetch(`${BASE}/api/v1/stream?token=${encodeURIComponent(g.jwt)}`, { signal: c.signal })
);
const responses = await Promise.all(requests);
// All accepted (or some rate-limited — both fine).

View File

@@ -1,40 +0,0 @@
/**
* Phase 2 adversarial — SSE ticket capability abuse.
*
* The stream endpoint authenticates via short-lived, single-use tickets minted at
* POST /api/v1/stream/ticket (never the raw JWT in the URL). These tests pin the
* security properties of that flow: minting requires auth, and a ticket is consumed
* on first use so it cannot be replayed.
*/
import { test, expect } from '../../fixtures/test';
import { mintSseTicket, openStream } from '../../helpers/sse';
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
test.describe('Adversarial — SSE ticket abuse', () => {
test('minting a ticket requires authentication', async () => {
const res = await fetch(`${BASE}/api/v1/stream/ticket`, { method: 'POST' });
expect(res.status).toBe(401);
});
test('a ticket is single-use: replay after first open is rejected', async ({ guest }) => {
const g = await guest('SseReplay');
const ticket = await mintSseTicket(g.jwt);
// First open consumes the ticket.
expect(await openStream(ticket)).toBe(200);
// Replaying the exact same ticket must fail — it was consumed, so `consume` returns
// None → 401. A 200 here would mean tickets are reusable (capability replay).
expect(await openStream(ticket)).toBe(401);
});
test('an unminted / garbage ticket is rejected', async () => {
// 24-byte-shaped hex string that was never issued.
const bogus = 'deadbeef'.repeat(6);
expect(await openStream(bogus)).toBe(401);
});
// Note: the replay test's first open (→ 200) already proves a freshly-minted ticket
// works, so there is no separate "fresh ticket" sanity test — a second successful open
// would just add ~30s (SSE headers flush on the keep-alive tick through Caddy).
});

View File

@@ -3,25 +3,18 @@
* browser process.
*/
import { test, expect } from '../../fixtures/test';
import { trackStreamOpens } from '../../helpers/sse';
test.describe('Browser chaos — multi-tab', () => {
test('same user in two tabs — each tab establishes its own SSE stream', async ({ page, context, guest, signIn }) => {
test('same user in two tabs — SSE delivers to both', async ({ page, context, guest, signIn }) => {
const g = await guest('Twin');
// Count each tab's own EventSource open. Asserting *connection establishment* is
// fast and reliable; asserting event *delivery* would depend on the reverse proxy's
// ~30s SSE buffering and isn't worth the flake here.
const opens1 = trackStreamOpens(page);
await signIn(page, g); // → /feed, connectSse() on mount
await expect.poll(opens1, { timeout: 10_000 }).toBeGreaterThanOrEqual(1);
await signIn(page, g);
await page.goto('/feed');
const tab2 = await context.newPage();
const opens2 = trackStreamOpens(tab2);
await signIn(tab2, g);
await expect.poll(opens2, { timeout: 10_000 }).toBeGreaterThanOrEqual(1);
await tab2.goto('/feed');
// Both tabs mounted and each opened its own independent stream.
// Both tabs should mount the bottom nav.
await expect(page.getByRole('link', { name: 'Galerie' })).toBeVisible();
await expect(tab2.getByRole('link', { name: 'Galerie' })).toBeVisible();
await tab2.close();

View File

@@ -61,10 +61,7 @@ test.describe('Browser chaos — network', () => {
const g = await guest('Throttled');
let attempts = 0;
// Match /api/v1/feed with or without a query string (the app requests
// `/feed?limit=20`), but NOT /api/v1/feed/delta. A plain `**/api/v1/feed` glob
// fails to match the query-string URL, so this route never fired before.
await page.route(/\/api\/v1\/feed(\?|$)/, async (route) => {
await page.route('**/api/v1/feed', async (route) => {
attempts++;
await route.fulfill({
status: 429,
@@ -75,29 +72,9 @@ test.describe('Browser chaos — network', () => {
await signIn(page, g);
await page.goto('/feed');
// First make sure the throttled endpoint was actually hit — otherwise the
// stabilize-poll below could settle at attempts=0 (feed request not yet fired)
// and pass vacuously without ever exercising the 429 path.
await expect.poll(() => attempts, { timeout: 8_000 }).toBeGreaterThanOrEqual(1);
// Then wait until the retry count stops climbing instead of sleeping a fixed 3s:
// a well-behaved client surfaces the 429 and stops, so the count settles quickly;
// a retry storm would keep incrementing and never stabilize (→ this poll times
// out and the test fails, which is the outcome we want).
let prev = -1;
await expect
.poll(
() => {
const stable = attempts === prev;
prev = attempts;
return stable;
},
{ timeout: 8_000, intervals: [300] }
)
.toBe(true);
await page.waitForTimeout(3_000);
// Sanity: client did not hammer the endpoint > a few times under throttle.
expect(attempts, `retry attempts=${attempts}`).toBeLessThan(15);
expect(attempts).toBeLessThan(15);
});
});

View File

@@ -75,11 +75,10 @@ test.describe('Mobile — double-tap gesture', () => {
await expect(imageButton).toBeVisible({ timeout: 10_000 });
await imageButton.click();
// LightboxModal is the only dialog labelled by #lightbox-title, so target it
// directly. (Closed sheets no longer expose role=dialog — that semantics is
// gated on `open` — so a plain [role=dialog] match would be ambiguous only
// while a sheet is open; the labelledby scope keeps this unambiguous.)
const lightbox = page.locator('[role="dialog"][aria-labelledby="lightbox-title"]');
// LightboxModal is `role="dialog"` (no aria-modal). The other dialog on the
// page is the ContextSheet which has `aria-modal="true"` even when closed,
// so scope to NOT-aria-modal to pick the lightbox specifically.
const lightbox = page.locator('[role="dialog"]:not([aria-modal])');
await expect(lightbox).toBeVisible();
// Find the inner image element to tap.

View File

@@ -2,7 +2,7 @@
* Phase 3 mobile — long-press gesture.
*
* The `longpress` action attaches to `<article>` in FeedListCard and to
* grid cells in VirtualFeed (grid mode). Holding for ≥ 500 ms fires `onlongpress`,
* grid cells in FeedGrid. Holding for ≥ 500 ms fires `onlongpress`,
* which opens the ContextSheet bottom sheet via the feed page's
* `contextTarget` state.
*
@@ -45,11 +45,13 @@ test.describe('Mobile — long-press gesture', () => {
await longPress(page, card, 600);
// The ContextSheet is always mounted (it translates off-screen when closed).
// Target it by its stable data-testid, gated on aria-modal="true" which the
// component sets only while open — unambiguous vs. the centered LightboxModal
// (which also has aria-modal) and independent of the animation classes.
const sheet = page.locator('[data-testid="context-sheet"][aria-modal="true"]');
// The ContextSheet renders a dialog with role="dialog" + aria-modal="true".
// Multiple sheets (UploadSheet, ContextSheet) may be in the DOM — match the
// one that actually has aria-modal=true (i.e. the open one).
// ContextSheet is always mounted (it just translates off-screen when closed).
// Match the OPEN state by the `translate-y-0` class the component applies
// when `open === true`.
const sheet = page.locator('[role="dialog"][aria-modal="true"].translate-y-0');
await expect(sheet).toBeVisible({ timeout: 2_000 });
await expect(sheet.getByRole('button', { name: /abbrechen/i })).toBeVisible();
});
@@ -66,9 +68,10 @@ test.describe('Mobile — long-press gesture', () => {
// Simulate a short press (200 ms — well under the 500 ms threshold).
await longPress(page, card, 200);
// Within 1 s, the ContextSheet must not be open (aria-modal is set only when
// open). A quick tap opens the lightbox instead, which is a different element.
await expect(page.locator('[data-testid="context-sheet"][aria-modal="true"]')).toHaveCount(0, { timeout: 1_000 });
// Within 1 s, no aria-modal=true dialog should be open (the ContextSheet
// is "open" only when its aria-modal flag is true).
// The ContextSheet stays mounted but `translate-y-0` is only set when open.
await expect(page.locator('[role="dialog"][aria-modal="true"].translate-y-0')).toHaveCount(0, { timeout: 1_000 });
});
test('long-press suppresses the click that lands at pointerup (no double-open of lightbox)', async ({ page, guest, signIn }) => {

View File

@@ -45,8 +45,20 @@ test.describe('Mobile — safe-area insets', () => {
expect(distanceFromBottom).toBeLessThanOrEqual(2);
});
// (A vacuous `/join` probe that only asserted `Array.isArray(...)` — always true —
// was removed; the real sheet-level env() check is the structural test below.)
test('context sheet (when opened) carries the same safe-area declaration', async ({ page }) => {
// We can't easily open the context sheet without a feed card to long-press,
// but the markup lives in the layout once the route mounts. We probe by
// scanning every element with a `style` attribute for the env() reference.
await page.goto('/join');
const candidateStyles: string[] = await page.evaluate(() => {
return Array.from(document.querySelectorAll<HTMLElement>('[style]'))
.map((el) => el.getAttribute('style') ?? '')
.filter((s) => s.includes('env(safe-area-inset-bottom)'));
});
// On /join there may be zero — the assertion is more of a sanity check.
// On /feed and /account it would be ≥ 1. We assert that on /feed below.
expect(Array.isArray(candidateStyles)).toBe(true);
});
test('upload sheet and context sheet both honor env() (structural check)', async ({ page, guest, signIn }) => {
const g = await guest('SafeAreaSheets');

View File

@@ -12,9 +12,7 @@ test.describe('Mobile a11y — sheets dismiss on Escape', () => {
await page.goto('/account');
// Click the "Original" radio in the Datennutzung section to open the warning sheet.
// The radio's accessible name is its title + description ("Original Lädt die
// Originaldateien…"), so anchor on the start, not the end.
const originalRadio = page.getByRole('radio', { name: /^Original/i });
const originalRadio = page.getByRole('radio', { name: /Original$/i });
await originalRadio.click();
const sheet = page.locator('[role="dialog"][aria-labelledby="data-mode-title"]');

View File

@@ -16,8 +16,8 @@ COPY --from=builder /app/build ./build
COPY --from=builder /app/package.json ./
RUN npm install --omit=dev
# Run as the image's built-in non-root `node` user.
RUN chown -R node:node /app
# Run as the image's built-in unprivileged `node` user (defense-in-depth). The
# adapter-node server only reads from /app, so no chown is needed.
USER node
EXPOSE 3001

File diff suppressed because it is too large Load Diff

View File

@@ -9,9 +9,7 @@
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"test:unit": "vitest run",
"test:unit:watch": "vitest"
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^7.0.0",
@@ -20,16 +18,13 @@
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/vite": "^4.2.2",
"@types/qrcode": "^1.5.6",
"jsdom": "^29.1.1",
"svelte": "^5.54.0",
"svelte-check": "^4.4.2",
"tailwindcss": "^4.2.2",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vitest": "^4.1.9"
"vite": "^7.3.1"
},
"dependencies": {
"@tanstack/svelte-virtual": "^3.13.30",
"idb": "^8.0.3",
"qrcode": "^1.5.4"
}

View File

@@ -1,8 +1,11 @@
@import "./tailwind-theme.css";
/* Respect the OS "reduce motion" setting. Collapses every animation/transition
* (HeartBurst, Skeleton pulse, diashow cross-fades, sheet/FAB slides) to a
* near-instant change rather than removing them outright, so state still updates. */
/*
* Respect the OS "reduce motion" setting (WCAG 2.3.3 / 2.2.2). Neutralizes the
* decorative animations — Ken Burns zoom, slideshow crossfade, HeartBurst — and
* snaps transitions, which is a vestibular/migraine safeguard especially for the
* big-screen diashow. Auto-advance timing is additionally slowed in JS.
*/
@media (prefers-reduced-motion: reduce) {
*,
*::before,

View File

@@ -1,21 +1,10 @@
<!doctype html>
<html lang="de">
<html lang="en">
<head>
<meta charset="utf-8" />
<!-- viewport-fit=cover activates the env(safe-area-inset-*) padding used by the
bottom nav, sheets, toasts and FAB on notched / home-indicator devices. -->
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="text-scale" content="scale" />
<!-- Light/dark theme-color so the browser chrome matches the active theme. -->
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#030712" media="(prefers-color-scheme: dark)" />
<!-- Installable PWA keepsake: manifest + Apple home-screen metadata. -->
<link rel="manifest" href="%sveltekit.assets%/manifest.webmanifest" />
<link rel="icon" href="%sveltekit.assets%/icon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="%sveltekit.assets%/icon.svg" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="EventSnap" />
<meta name="theme-color" content="#ffffff" />
<!--
FOUC guard: apply the dark class *before* paint, so reloads of pages with
theme=dark don't flash a white screen. Mirrors the logic in

View File

@@ -1,14 +1,9 @@
// Svelte action — the single source of truth for tap gestures on a media element.
// Fires a `doubletap` CustomEvent when two pointerup events occur within `interval`
// ms on roughly the same spot (Instagram-style "double-tap to like"), and a
// `singletap` CustomEvent once `interval` has elapsed with no second tap. Owning
// both gestures in one place means a component no longer juggles its own debounce
// timer alongside a separate onclick handler.
// Svelte action — fires a `doubletap` CustomEvent when two pointerup events occur
// within `interval` ms on roughly the same spot. Used in the lightbox for the
// Instagram-style "double-tap to like" gesture.
//
// Native `dblclick` exists, but on iOS Safari it also zooms the page; gating on
// pointer events lets us preventDefault selectively and avoid the zoom. Keyboard
// activation still arrives as a normal `click` (detail === 0) — handle that on the
// element itself for an immediate, latency-free open.
// pointer events lets us preventDefault selectively and avoid the zoom.
import type { ActionReturn } from 'svelte/action';
@@ -21,7 +16,6 @@ export interface DoubletapOptions {
interface DoubletapAttributes {
'ondoubletap'?: (event: CustomEvent<void>) => void;
'onsingletap'?: (event: CustomEvent<void>) => void;
}
export function doubletap(
@@ -32,21 +26,12 @@ export function doubletap(
let lastTime = 0;
let lastX = 0;
let lastY = 0;
let singleTimer: ReturnType<typeof setTimeout> | null = null;
const clearSingle = () => {
if (singleTimer) {
clearTimeout(singleTimer);
singleTimer = null;
}
};
const onPointerUp = (e: PointerEvent) => {
const now = performance.now();
const dx = Math.abs(e.clientX - lastX);
const dy = Math.abs(e.clientY - lastY);
if (now - lastTime < interval && dx < MOVE_THRESHOLD && dy < MOVE_THRESHOLD) {
clearSingle(); // the pending single-tap was actually the first of a double
e.preventDefault();
node.dispatchEvent(new CustomEvent('doubletap'));
lastTime = 0; // reset so a triple-tap doesn't re-fire
@@ -55,12 +40,6 @@ export function doubletap(
lastTime = now;
lastX = e.clientX;
lastY = e.clientY;
// Defer the single-tap action until we're sure no second tap follows.
clearSingle();
singleTimer = setTimeout(() => {
singleTimer = null;
node.dispatchEvent(new CustomEvent('singletap'));
}, interval);
};
node.addEventListener('pointerup', onPointerUp);
@@ -70,7 +49,6 @@ export function doubletap(
interval = newOptions.interval ?? INTERVAL_MS;
},
destroy() {
clearSingle();
node.removeEventListener('pointerup', onPointerUp);
}
};

View File

@@ -17,11 +17,8 @@ const FOCUSABLE =
'a[href], area[href], input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), iframe, object, embed, [tabindex]:not([tabindex="-1"]), [contenteditable="true"]';
function focusables(root: HTMLElement): HTMLElement[] {
// `offsetParent` is null for any `position: fixed` element (and our sheets/modals
// are fixed), so it would wrongly drop their buttons. `getClientRects().length`
// is true whenever the element is actually rendered — fixed or not.
return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(
(el) => !el.hasAttribute('disabled') && el.getClientRects().length > 0
(el) => !el.hasAttribute('disabled') && el.offsetParent !== null
);
}
@@ -64,16 +61,11 @@ export function focusTrap(
node.addEventListener('keydown', onKeyDown);
if (opts.autoFocus !== false) {
// Focus the container synchronously on mount so the trap owns the keyboard
// immediately — otherwise an Escape pressed before the deferred focus below
// lands on an element *outside* the node, where this node-scoped listener
// never sees it (the keystroke is silently dropped). Then defer one frame to
// move focus onto the first control once the sheet has laid out / animated in.
if (!node.hasAttribute('tabindex')) node.setAttribute('tabindex', '-1');
node.focus({ preventScroll: true });
// Defer one frame so the element is fully laid out (sheets animate in).
requestAnimationFrame(() => {
const list = focusables(node);
const target = list[0] ?? node;
if (!node.hasAttribute('tabindex')) node.setAttribute('tabindex', '-1');
target.focus({ preventScroll: true });
});
}

View File

@@ -22,17 +22,8 @@ export function pullToRefresh(
): ActionReturn<PullToRefreshOptions> {
let opts = options;
let startY = 0;
let startX = 0;
let pulling = false;
let triggered = false;
let intentLocked = false; // have we decided this gesture is a vertical pull?
// Distance the finger must travel before we commit to "this is a vertical pull"
// vs. a horizontal swipe or an incidental tap.
const INTENT_SLOP = 8;
// Rubber-band resistance: the sheet follows the finger at a fraction of 1:1 so
// the pull feels elastic rather than rigid.
const RESISTANCE = 0.5;
function scroller(): HTMLElement | (Window & typeof globalThis) {
return node.scrollHeight > node.clientHeight ? node : window;
@@ -49,54 +40,17 @@ export function pullToRefresh(
opts.onpull(Math.max(0, delta), Math.max(0, delta) / threshold);
}
function reset() {
pulling = false;
intentLocked = false;
}
function onTouchStart(e: TouchEvent) {
if (opts.disabled) return;
// Ignore pinch / multi-finger gestures entirely.
if (e.touches.length !== 1) {
reset();
return;
}
if (scrollTop() > 0) return;
startY = e.touches[0].clientY;
startX = e.touches[0].clientX;
pulling = true;
triggered = false;
intentLocked = false;
}
function onTouchMove(e: TouchEvent) {
if (!pulling || triggered) return;
// A second finger landing mid-gesture cancels the pull.
if (e.touches.length !== 1) {
reset();
return;
}
const rawDy = e.touches[0].clientY - startY;
const dx = e.touches[0].clientX - startX;
// Decide intent once, after the finger has moved past the slop. A
// horizontal-dominant or upward move is not a pull — bail and let the
// browser scroll normally.
if (!intentLocked) {
if (Math.abs(rawDy) < INTENT_SLOP && Math.abs(dx) < INTENT_SLOP) return;
if (rawDy <= 0 || Math.abs(rawDy) <= Math.abs(dx)) {
reset();
return;
}
intentLocked = true;
}
// Committed vertical pull at the top edge: stop the browser's own
// overscroll / pull-to-refresh from competing for the gesture.
if (e.cancelable) e.preventDefault();
const delta = rawDy * RESISTANCE;
const delta = e.touches[0].clientY - startY;
reportPull(delta);
if (delta > (opts.threshold ?? 60)) {
triggered = true;
@@ -106,12 +60,11 @@ export function pullToRefresh(
function onTouchEnd() {
if (pulling && !triggered) reportPull(0);
reset();
pulling = false;
}
node.addEventListener('touchstart', onTouchStart, { passive: true });
// Non-passive so we can preventDefault() once a top-edge pull is in progress.
node.addEventListener('touchmove', onTouchMove, { passive: false });
node.addEventListener('touchmove', onTouchMove, { passive: true });
node.addEventListener('touchend', onTouchEnd);
node.addEventListener('touchcancel', onTouchEnd);

View File

@@ -1,50 +0,0 @@
// Body scroll-lock for open dialogs/sheets. While any locker is active, the
// document body can't scroll behind the overlay. Ref-counted so that nested or
// stacked dialogs (e.g. a ConfirmSheet opened from the LightboxModal) don't let
// the first one to close unlock the page out from under the others. Compensates
// for the vanished scrollbar width so the layout doesn't jump on lock.
import type { ActionReturn } from 'svelte/action';
let lockCount = 0;
let savedOverflow = '';
let savedPaddingRight = '';
function lock() {
if (typeof document === 'undefined') return;
if (lockCount === 0) {
const body = document.body;
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
savedOverflow = body.style.overflow;
savedPaddingRight = body.style.paddingRight;
body.style.overflow = 'hidden';
if (scrollbarWidth > 0) {
const current = parseFloat(getComputedStyle(body).paddingRight) || 0;
body.style.paddingRight = `${current + scrollbarWidth}px`;
}
}
lockCount++;
}
function unlock() {
if (typeof document === 'undefined') return;
lockCount = Math.max(0, lockCount - 1);
if (lockCount === 0) {
document.body.style.overflow = savedOverflow;
document.body.style.paddingRight = savedPaddingRight;
}
}
/**
* Locks body scroll for the lifetime of the node. Mount the node only while the
* dialog is open (e.g. inside `{#if open}` or alongside a `class:` toggle) so the
* action's create/destroy line up with open/close.
*/
export function scrollLock(node: HTMLElement): ActionReturn {
lock();
return {
destroy() {
unlock();
}
};
}

View File

@@ -1,3 +1,5 @@
import { goto } from '$app/navigation';
import { browser } from '$app/environment';
import { getToken, clearAuth } from './auth';
const BASE = '/api/v1';
@@ -13,8 +15,6 @@ export class ApiError extends Error {
}
}
const TIMEOUT_MS = 20_000;
async function request<T>(
method: string,
path: string,
@@ -29,50 +29,28 @@ async function request<T>(
headers['Content-Type'] = 'application/json';
}
// Abort hung requests so a dead connection surfaces as a friendly error
// instead of a spinner that never resolves.
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
let res: Response;
try {
res = await fetch(`${BASE}${path}`, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
signal: controller.signal
});
} catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') {
throw new ApiError(0, 'timeout', 'Zeitüberschreitung bitte erneut versuchen.');
}
throw new ApiError(0, 'network', 'Netzwerkfehler bitte Verbindung prüfen.');
} finally {
clearTimeout(timer);
}
const res = await fetch(`${BASE}${path}`, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined
});
if (res.status === 204) {
return undefined as T;
}
// A 5xx behind a proxy (or a crash page) can return HTML, not JSON — parsing
// it directly would throw an opaque SyntaxError. Read text, parse defensively.
const raw = await res.text();
let data: { error?: string; message?: string } | unknown = null;
if (raw) {
try {
data = JSON.parse(raw);
} catch {
data = null;
}
}
const data = await res.json();
if (!res.ok) {
if (res.status === 401) {
clearAuth();
// Don't strand the user on a now-unauthenticated page with no nav —
// send them to /join. Guard against loops on the auth screens.
if (browser && !/^\/(join|recover|admin\/login)/.test(window.location.pathname)) {
void goto('/join');
}
}
const d = (data ?? {}) as { error?: string; message?: string };
throw new ApiError(res.status, d.error ?? 'unknown', d.message ?? `Serverfehler (${res.status}).`);
throw new ApiError(res.status, data.error ?? 'unknown', data.message ?? 'Fehler');
}
return data as T;

View File

@@ -1,68 +0,0 @@
// @vitest-environment jsdom
import { describe, it, expect, beforeEach, vi } from 'vitest';
// auth.ts guards every localStorage access behind `browser`; force it true so the
// jsdom localStorage is actually used (the shared mock stubs it to false).
vi.mock('$app/environment', () => ({ browser: true, dev: false, building: false, version: 'test' }));
import { setAuth, getToken, getPin, getExpiry, getRole, clearAuth, clearPin } from './auth';
/** Build a JWT-shaped string (header.payload.sig) with the given claims. */
function makeJwt(claims: object): string {
const seg = (o: object) => btoa(JSON.stringify(o));
return `${seg({ alg: 'HS256', typ: 'JWT' })}.${seg(claims)}.sig`;
}
beforeEach(() => localStorage.clear());
describe('auth — token storage', () => {
it('setAuth then getToken/getPin round-trips', () => {
setAuth('a.b.c', '1234', 'uid-1', 'Alice');
expect(getToken()).toBe('a.b.c');
expect(getPin()).toBe('1234');
});
it('setAuth without a PIN leaves the PIN unset', () => {
setAuth('a.b.c', null, 'uid-1');
expect(getToken()).toBe('a.b.c');
expect(getPin()).toBeNull();
});
it('clearAuth removes the token but KEEPS the PIN (needed for recovery)', () => {
setAuth('a.b.c', '1234', 'uid');
clearAuth();
expect(getToken()).toBeNull();
expect(getPin()).toBe('1234');
});
it('clearPin removes the cached PIN', () => {
setAuth('a.b.c', '1234', 'uid');
clearPin();
expect(getPin()).toBeNull();
});
});
describe('auth — JWT claim decode', () => {
it('getExpiry decodes the exp claim (seconds → ms Date)', () => {
const exp = 2_000_000_000; // far-future unix seconds
setAuth(makeJwt({ exp }), null, 'u');
expect(getExpiry()?.getTime()).toBe(exp * 1000);
});
it('getExpiry is null with no token, no exp claim, or a malformed token', () => {
expect(getExpiry()).toBeNull(); // no token
setAuth(makeJwt({ role: 'guest' }), null, 'u'); // token without exp
expect(getExpiry()).toBeNull();
localStorage.setItem('eventsnap_jwt', 'not-a-jwt'); // unparseable
expect(getExpiry()).toBeNull();
});
it('getRole extracts the role claim; null when absent or malformed', () => {
setAuth(makeJwt({ role: 'host' }), null, 'u');
expect(getRole()).toBe('host');
setAuth(makeJwt({ exp: 1 }), null, 'u'); // no role claim
expect(getRole()).toBeNull();
localStorage.setItem('eventsnap_jwt', 'x.y.z'); // unparseable payload
expect(getRole()).toBeNull();
});
});

View File

@@ -1,39 +0,0 @@
import { describe, it, expect } from 'vitest';
import { avatarPalette, initials } from './avatar';
describe('avatarPalette', () => {
it('returns the neutral palette for empty / nullish names', () => {
expect(avatarPalette(null)).toContain('bg-gray-100');
expect(avatarPalette(undefined)).toContain('bg-gray-100');
expect(avatarPalette('')).toContain('bg-gray-100');
});
it('is deterministic for the same name', () => {
expect(avatarPalette('Alice')).toBe(avatarPalette('Alice'));
});
it('returns a real palette entry (not neutral) for a non-empty name', () => {
expect(avatarPalette('Bob')).toMatch(/bg-(blue|purple|green|amber|rose|teal)-100/);
});
});
describe('initials', () => {
it('returns "?" for empty / nullish / whitespace-only names', () => {
expect(initials(null)).toBe('?');
expect(initials(undefined)).toBe('?');
expect(initials('')).toBe('?');
expect(initials(' ')).toBe('?');
});
it('uses the first letter (uppercased) for a single word', () => {
expect(initials('alice')).toBe('A');
});
it('uses the first letters of the first two words', () => {
expect(initials('Alice Bob Carol')).toBe('AB');
});
it('collapses runs of whitespace between words', () => {
expect(initials(' john doe ')).toBe('JD');
});
});

View File

@@ -3,6 +3,7 @@
import { page } from '$app/stores';
import { uploadSheetOpen, uploadBadgeCount } from '$lib/ui-store';
import { exportStatus, initExportStatus } from '$lib/export-status-store';
import { uploadsLocked } from '$lib/event-state-store';
function isActive(path: string): boolean {
return $page.url.pathname.startsWith(path);
@@ -43,8 +44,9 @@
<div class="relative -translate-y-3">
<button
onclick={() => ($uploadSheetOpen = true)}
class="relative flex h-14 w-14 items-center justify-center rounded-full bg-blue-600 text-white shadow-lg transition active:scale-95 hover:bg-blue-700"
aria-label="Hochladen"
disabled={$uploadsLocked}
class="relative flex h-14 w-14 items-center justify-center rounded-full bg-blue-600 text-white shadow-lg transition active:scale-95 hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-blue-600 disabled:active:scale-100"
aria-label={$uploadsLocked ? 'Uploads gesperrt' : 'Hochladen'}
>
<!-- Camera + plus icon -->
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">

View File

@@ -48,9 +48,7 @@
try {
stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode, width: { ideal: 1920 }, height: { ideal: 1080 } },
// Only request the mic for video — a photo-only session shouldn't trigger
// a mic permission prompt (which also blocks capture on mic-less devices).
audio: mode === 'video'
audio: true
});
if (videoEl) {
videoEl.srcObject = stream;
@@ -80,14 +78,6 @@
await startCamera();
}
// Switching between photo and video changes whether we need the mic, so
// re-acquire the stream (lazily adding the mic for video, dropping it for photo).
async function setMode(next: 'photo' | 'video') {
if (mode === next) return;
mode = next;
await startCamera();
}
function capturePhoto() {
if (!videoEl || !canvasEl) return;
const ctx = canvasEl.getContext('2d');
@@ -166,20 +156,12 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 10l-4 4m0-4l4 4m6-4a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p class="text-sm text-white">{error}</p>
<div class="mt-4 flex justify-center gap-2">
<button
onclick={startCamera}
class="rounded-lg bg-white px-4 py-2 text-sm font-medium text-gray-900"
>
Erneut versuchen
</button>
<button
onclick={onclose}
class="rounded-lg bg-white/20 px-4 py-2 text-sm text-white"
>
Schließen
</button>
</div>
<button
onclick={onclose}
class="mt-4 rounded-lg bg-white/20 px-4 py-2 text-sm text-white"
>
Schliessen
</button>
</div>
</div>
{:else}
@@ -216,7 +198,7 @@
type="button"
role="tab"
aria-selected={mode === 'photo'}
onclick={() => setMode('photo')}
onclick={() => (mode = 'photo')}
data-testid="camera-mode-photo"
class="rounded-full px-4 py-1 text-sm font-medium transition {mode === 'photo' ? 'bg-white text-gray-900' : 'text-white/70 hover:text-white active:text-white'}"
>
@@ -226,7 +208,7 @@
type="button"
role="tab"
aria-selected={mode === 'video'}
onclick={() => setMode('video')}
onclick={() => (mode = 'video')}
data-testid="camera-mode-video"
class="rounded-full px-4 py-1 text-sm font-medium transition {mode === 'video' ? 'bg-white text-gray-900' : 'text-white/70 hover:text-white active:text-white'}"
>

View File

@@ -6,7 +6,6 @@
<script lang="ts">
import { focusTrap } from '$lib/actions/focus-trap';
import { scrollLock } from '$lib/actions/scroll-lock';
interface Props {
open: boolean;
@@ -51,7 +50,6 @@
class="fixed inset-0 z-50 bg-black/40"
aria-label="Schließen"
onclick={onCancel}
disabled={busy}
tabindex="-1"
></button>
<div
@@ -62,7 +60,6 @@
aria-labelledby={titleId}
data-testid="confirm-sheet"
use:focusTrap={{ onclose: onCancel }}
use:scrollLock
>
<div class="mb-4 flex justify-center">
<div class="h-1 w-10 rounded-full bg-gray-300 dark:bg-gray-600"></div>
@@ -80,21 +77,17 @@
onclick={handleConfirm}
disabled={busy}
data-testid="confirm-sheet-confirm"
class="mb-3 flex w-full items-center justify-center gap-2 rounded-xl py-3 text-sm font-semibold text-white transition disabled:opacity-60 {tone === 'danger'
class="mb-3 w-full rounded-xl py-3 text-sm font-semibold text-white transition disabled:opacity-60 {tone === 'danger'
? 'bg-red-600 hover:bg-red-700 active:bg-red-700 dark:bg-red-500 dark:hover:bg-red-400 dark:active:bg-red-400'
: 'bg-blue-600 hover:bg-blue-700 active:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-400 dark:active:bg-blue-400'}"
>
{#if busy}
<span class="inline-block h-4 w-4 animate-spin rounded-full border-2 border-white/40 border-t-white" aria-hidden="true"></span>
{/if}
{confirmLabel}
</button>
<button
type="button"
onclick={onCancel}
disabled={busy}
data-testid="confirm-sheet-cancel"
class="w-full rounded-xl border border-gray-200 py-3 text-sm font-medium text-gray-700 transition hover:bg-gray-50 active:bg-gray-100 disabled:opacity-60 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800 dark:active:bg-gray-800"
class="w-full rounded-xl border border-gray-200 py-3 text-sm font-medium text-gray-700 transition hover:bg-gray-50 active:bg-gray-100 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800 dark:active:bg-gray-800"
>
{cancelLabel}
</button>

View File

@@ -22,8 +22,6 @@
title?: string;
}
import { scrollLock } from '$lib/actions/scroll-lock';
let { open, actions, onClose, title }: Props = $props();
let sheet = $state<HTMLDivElement | null>(null);
@@ -78,12 +76,6 @@
}
</script>
<!-- This sheet stays mounted (translate-y animation), so a sentinel that mounts only
while open drives the shared body scroll-lock. -->
{#if open}
<div use:scrollLock class="hidden"></div>
{/if}
<!-- Backdrop — real <button> so keyboard / switch-control users get parity. -->
<button
type="button"
@@ -103,12 +95,9 @@
class:translate-y-full={!open}
class:translate-y-0={open}
style="padding-bottom: env(safe-area-inset-bottom)"
role={open ? 'dialog' : undefined}
aria-modal={open ? 'true' : undefined}
aria-hidden={!open}
inert={!open}
role="dialog"
aria-modal="true"
tabindex="-1"
data-testid="context-sheet"
>
<div class="flex justify-center pt-3 pb-1">
<div class="h-1 w-10 rounded-full bg-gray-300 dark:bg-gray-600"></div>

View File

@@ -0,0 +1,94 @@
<script lang="ts">
import type { FeedUpload } from '$lib/types';
import { dataMode } from '$lib/data-mode-store';
import { longpress } from '$lib/actions/longpress';
interface Props {
uploads: FeedUpload[];
onlike: (id: string) => void;
oncomment: (id: string) => void;
onselect: (upload: FeedUpload) => void;
oncontextmenu?: (upload: FeedUpload) => void;
threeCol?: boolean;
}
let { uploads, onlike, oncomment, onselect, oncontextmenu, threeCol = false }: Props =
$props();
function isVideo(mime: string): boolean {
return mime.startsWith('video/');
}
// Grid uses small thumbnails by design even in Original mode — full media is one tap
// away in the lightbox, where the data-mode picker decides for real.
function tileUrl(upload: FeedUpload): string {
if (upload.thumbnail_url) return upload.thumbnail_url;
if (upload.preview_url) return upload.preview_url;
return $dataMode === 'original' ? (upload.original_url ?? '') : '';
}
</script>
<div class="grid gap-0.5 {threeCol ? 'grid-cols-3' : 'grid-cols-2 sm:grid-cols-3'}">
{#each uploads as upload (upload.id)}
<div
class="group relative aspect-square cursor-pointer overflow-hidden rounded-lg bg-gray-100 dark:bg-gray-800"
use:longpress={{ duration: 500 }}
onlongpress={() => oncontextmenu?.(upload)}
>
<button
onclick={() => onselect(upload)}
class="block h-full w-full"
aria-label="Upload anzeigen"
>
{#if isVideo(upload.mime_type)}
<div class="flex h-full items-center justify-center bg-gray-800">
{#if tileUrl(upload)}
<img src={tileUrl(upload)} alt="" class="h-full w-full object-cover" />
{/if}
<div class="absolute inset-0 flex items-center justify-center">
<svg class="h-10 w-10 text-white/80" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
</div>
</div>
{:else if tileUrl(upload)}
<img src={tileUrl(upload)} alt="" class="h-full w-full object-cover" loading="lazy" />
{:else}
<div class="flex h-full items-center justify-center text-gray-400">
<svg class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
{/if}
</button>
<!-- Overlay with name and stats -->
<div class="pointer-events-none absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/60 to-transparent p-2">
<p class="truncate text-xs font-medium text-white">{upload.uploader_name}</p>
<div class="mt-0.5 flex items-center gap-3 text-xs text-white/80">
<button
class="pointer-events-auto -m-2 flex min-h-11 min-w-11 items-center justify-center gap-0.5 p-2"
aria-pressed={upload.liked_by_me}
aria-label="Gefällt mir ({upload.like_count})"
onclick={(e) => { e.stopPropagation(); onlike(upload.id); }}
>
<svg class="h-3.5 w-3.5 {upload.liked_by_me ? 'fill-red-400 text-red-400' : ''}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
{upload.like_count}
</button>
<button
class="pointer-events-auto -m-2 flex min-h-11 min-w-11 items-center justify-center gap-0.5 p-2"
aria-label="Kommentare ({upload.comment_count})"
onclick={(e) => { e.stopPropagation(); oncomment(upload.id); }}
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
{upload.comment_count}
</button>
</div>
</div>
</div>
{/each}
</div>

View File

@@ -6,9 +6,11 @@
import { doubletap } from '$lib/actions/doubletap';
import { avatarPalette, initials } from '$lib/avatar';
import { vibrate } from '$lib/haptics';
import { now } from '$lib/now';
import HeartBurst from './HeartBurst.svelte';
// Single-tap debounce so a double-tap doesn't briefly open the lightbox.
const SINGLE_TAP_DELAY_MS = 260;
interface Props {
upload: FeedUpload;
isOwn?: boolean;
@@ -33,8 +35,8 @@
const mediaSrc = $derived(pickMediaUrl($dataMode, upload));
function relativeTime(iso: string, nowMs: number): string {
const diff = nowMs - new Date(iso).getTime();
function relativeTime(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'gerade eben';
if (mins < 60) return `vor ${mins} Min.`;
@@ -44,35 +46,44 @@
return `vor ${days} Tag${days === 1 ? '' : 'en'}`;
}
// Re-derives off the shared 60s clock so "vor 5 Min." actually advances.
const relTime = $derived(relativeTime(upload.created_at, $now));
function openContext() {
oncontextmenu?.(upload);
}
// Inline heart-burst on double-tap (consistent with the lightbox).
let heartBurst = $state(false);
let burstTimer: ReturnType<typeof setTimeout> | null = null;
let singleTapTimer: ReturnType<typeof setTimeout> | null = null;
function handleMediaClick() {
// Delay single-tap so a quick second tap (double-tap-to-like) wins.
if (singleTapTimer) clearTimeout(singleTapTimer);
singleTapTimer = setTimeout(() => {
singleTapTimer = null;
onselect(upload);
}, SINGLE_TAP_DELAY_MS);
}
function handleDoubleTap() {
if (singleTapTimer) {
clearTimeout(singleTapTimer);
singleTapTimer = null;
}
heartBurst = true;
vibrate(10);
onlike(upload.id);
if (burstTimer) clearTimeout(burstTimer);
burstTimer = setTimeout(() => (heartBurst = false), 700);
setTimeout(() => (heartBurst = false), 700);
}
// Clear the burst timer on unmount (a feed-delta SSE event can remove this
// card mid-animation) so it can't fire against a stale component.
// A feed-delta SSE event can remove this card mid-pending-tap. Clear the timer
// on unmount so we don't call onselect with a stale upload reference.
onDestroy(() => {
if (burstTimer) clearTimeout(burstTimer);
if (singleTapTimer) {
clearTimeout(singleTapTimer);
singleTapTimer = null;
}
});
</script>
<!-- Off-screen cards are removed from the DOM entirely by the parent VirtualFeed
window-virtualizer, so this card no longer needs content-visibility — and must
not use it, since the virtualizer measures each card's real rendered height. -->
<article
class="bg-white dark:bg-gray-900"
use:longpress={{ duration: 500 }}
@@ -89,7 +100,7 @@
</div>
<div class="min-w-0">
<p class="truncate text-sm font-semibold text-gray-900 dark:text-gray-100">{upload.uploader_name}</p>
<p class="text-xs text-gray-400 dark:text-gray-500">{relTime}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">{relativeTime(upload.created_at)}</p>
</div>
</div>
@@ -109,10 +120,9 @@
<!-- Media -->
<button
class="relative block w-full"
onclick={handleMediaClick}
use:doubletap
onsingletap={() => onselect(upload)}
ondoubletap={handleDoubleTap}
onclick={(e) => { if (e.detail === 0) onselect(upload); }}
aria-label="Bild vergrößern"
>
<HeartBurst active={heartBurst} />
@@ -123,8 +133,6 @@
src={upload.thumbnail_url ?? upload.preview_url ?? ''}
alt=""
class="h-full w-full object-cover opacity-80"
loading="lazy"
decoding="async"
/>
{/if}
<div class="absolute inset-0 flex items-center justify-center">
@@ -136,18 +144,13 @@
</div>
</div>
{:else if mediaSrc}
<!-- Reserve the same 4/5 box the skeleton uses so the card doesn't collapse
to height 0 and reflow as images stream in. The uncropped original is one
tap away in the lightbox. -->
<div class="aspect-[4/5] w-full bg-gray-100 dark:bg-gray-800">
<img
src={mediaSrc}
alt=""
class="h-full w-full object-cover"
loading="lazy"
decoding="async"
/>
</div>
<img
src={mediaSrc}
alt=""
class="w-full object-cover"
style="max-height: 80svh"
loading="lazy"
/>
{:else}
<div class="flex aspect-square w-full items-center justify-center bg-gray-100 dark:bg-gray-800">
<svg class="h-12 w-12 text-gray-300 dark:text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -162,7 +165,7 @@
<button
onclick={() => { vibrate(10); onlike(upload.id); }}
aria-pressed={upload.liked_by_me}
aria-label={upload.liked_by_me ? 'Gefällt mir nicht mehr' : 'Gefällt mir'}
aria-label="Gefällt mir ({upload.like_count})"
class="flex items-center gap-1.5 text-sm font-medium transition-colors
{upload.liked_by_me ? 'text-red-500 dark:text-red-400' : 'text-gray-500 hover:text-red-400 active:text-red-400 dark:text-gray-400 dark:hover:text-red-400 dark:active:text-red-400'}"
>
@@ -187,7 +190,7 @@
{upload.comment_count}
</button>
{#if isOwn}
<span class="ml-auto text-xs text-gray-400 dark:text-gray-500">Eigener Beitrag</span>
<span class="ml-auto text-xs text-gray-500 dark:text-gray-400">Eigener Beitrag</span>
{/if}
</div>

View File

@@ -17,8 +17,7 @@
<div class="flex gap-2 overflow-x-auto pb-2">
<button
onclick={() => onselect(null)}
aria-pressed={selected === null}
class="inline-flex min-h-11 shrink-0 items-center rounded-full px-4 py-2 text-sm font-medium transition {
class="shrink-0 rounded-full px-3 py-1 text-sm font-medium transition {
selected === null
? 'bg-blue-600 text-white dark:bg-blue-500'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300 active:bg-gray-300 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700 dark:active:bg-gray-700'
@@ -29,8 +28,7 @@
{#each hashtags as h (h.tag)}
<button
onclick={() => onselect(h.tag)}
aria-pressed={selected === h.tag}
class="inline-flex min-h-11 shrink-0 items-center rounded-full px-4 py-2 text-sm font-medium transition {
class="shrink-0 rounded-full px-3 py-1 text-sm font-medium transition {
selected === h.tag
? 'bg-blue-600 text-white dark:bg-blue-500'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300 active:bg-gray-300 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700 dark:active:bg-gray-700'

View File

@@ -1,55 +0,0 @@
<script lang="ts">
// Icon-only button with a guaranteed ≥44px touch target (WCAG 2.5.5 / Apple HIG).
// The visible icon stays whatever size the caller renders; the hit area is
// enforced via min-h-11 min-w-11 (44px) so small glyphs are still comfortably
// tappable. Always require an `aria-label` since there's no text content.
import type { Snippet } from 'svelte';
interface Props {
/** Accessible name — required, the button has no visible text. */
label: string;
onclick?: (e: MouseEvent) => void;
disabled?: boolean;
title?: string;
type?: 'button' | 'submit';
tone?: 'neutral' | 'danger';
/** Marks toggle state for AT (e.g. like buttons). Omit for plain actions. */
pressed?: boolean;
/** Extra classes appended after the base styles. */
class?: string;
'data-testid'?: string;
children: Snippet;
}
let {
label,
onclick,
disabled = false,
title,
type = 'button',
tone = 'neutral',
pressed,
class: extra = '',
'data-testid': testid,
children
}: Props = $props();
const toneClass = $derived(
tone === 'danger'
? 'text-red-600 hover:bg-red-50 active:bg-red-100 dark:text-red-400 dark:hover:bg-red-950/40 dark:active:bg-red-950/60'
: 'text-gray-500 hover:bg-gray-100 active:bg-gray-200 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:active:bg-gray-700 dark:hover:text-gray-100'
);
</script>
<button
{type}
{onclick}
{disabled}
{title}
aria-label={label}
aria-pressed={pressed}
data-testid={testid}
class="inline-flex min-h-11 min-w-11 items-center justify-center rounded-full transition-colors disabled:opacity-50 disabled:pointer-events-none {toneClass} {extra}"
>
{@render children()}
</button>

View File

@@ -1,12 +1,10 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import type { FeedUpload } from '$lib/types';
import { api } from '$lib/api';
import { getUserId } from '$lib/auth';
import { dataMode, pickMediaUrl } from '$lib/data-mode-store';
import { doubletap } from '$lib/actions/doubletap';
import { focusTrap } from '$lib/actions/focus-trap';
import { scrollLock } from '$lib/actions/scroll-lock';
import { toastError } from '$lib/toast-store';
import { vibrate } from '$lib/haptics';
import HeartBurst from './HeartBurst.svelte';
@@ -35,7 +33,6 @@
let loading = $state(false);
let userId = getUserId();
let heartBurst = $state(false);
let burstTimer: ReturnType<typeof setTimeout> | null = null;
const mediaSrc = $derived(pickMediaUrl($dataMode, upload));
@@ -43,14 +40,9 @@
heartBurst = true;
vibrate(10);
onlike(upload.id);
if (burstTimer) clearTimeout(burstTimer);
burstTimer = setTimeout(() => (heartBurst = false), 700);
setTimeout(() => (heartBurst = false), 700);
}
onDestroy(() => {
if (burstTimer) clearTimeout(burstTimer);
});
$effect(() => {
loadComments();
});
@@ -67,7 +59,7 @@
if (!newComment.trim()) return;
loading = true;
try {
const comment = await api.post<CommentDto>(`/upload/${upload.id}/comment`, {
const comment = await api.post<CommentDto>(`/upload/${upload.id}/comments`, {
body: newComment.trim()
});
comments = [...comments, comment];
@@ -105,7 +97,6 @@
aria-modal="true"
aria-labelledby="lightbox-title"
use:focusTrap={{ onclose }}
use:scrollLock
>
<div class="flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-xl bg-white dark:bg-gray-900">
<!-- Media -->
@@ -113,7 +104,7 @@
<button
onclick={onclose}
aria-label="Schließen"
class="absolute right-2 top-2 z-10 inline-flex min-h-11 min-w-11 items-center justify-center rounded-full bg-black/50 text-white hover:bg-black/70 active:bg-black/70"
class="absolute right-2 top-2 z-10 rounded-full bg-black/50 p-1.5 text-white hover:bg-black/70 active:bg-black/70"
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
@@ -150,10 +141,12 @@
<div class="flex items-center justify-between">
<div>
<span id="lightbox-title" class="font-medium text-gray-900 dark:text-gray-100">{upload.uploader_name}</span>
<span class="ml-2 text-xs text-gray-400 dark:text-gray-500">{formatTime(upload.created_at)}</span>
<span class="ml-2 text-xs text-gray-500 dark:text-gray-400">{formatTime(upload.created_at)}</span>
</div>
<button
onclick={() => onlike(upload.id)}
aria-pressed={upload.liked_by_me}
aria-label="Gefällt mir ({upload.like_count})"
class="flex items-center gap-1 rounded-full px-2.5 py-1 text-sm transition {
upload.liked_by_me
? 'bg-red-50 text-red-600 dark:bg-red-950/40 dark:text-red-300'
@@ -174,7 +167,7 @@
<!-- Comments list -->
<div class="flex-1 overflow-y-auto p-3">
{#if comments.length === 0}
<p class="text-center text-sm text-gray-400 dark:text-gray-500">Noch keine Kommentare.</p>
<p class="text-center text-sm text-gray-500 dark:text-gray-400">Noch keine Kommentare.</p>
{:else}
<div class="space-y-3">
{#each comments as comment (comment.id)}
@@ -182,7 +175,7 @@
<div class="flex-1">
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">{comment.uploader_name}</span>
<span class="ml-1 text-sm text-gray-700 dark:text-gray-300">{comment.body}</span>
<div class="mt-0.5 text-xs text-gray-400 dark:text-gray-500">{formatTime(comment.created_at)}</div>
<div class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">{formatTime(comment.created_at)}</div>
</div>
{#if comment.user_id === userId}
<button
@@ -210,6 +203,7 @@
<input
type="text"
bind:value={newComment}
aria-label="Kommentar schreiben"
placeholder="Kommentar schreiben..."
maxlength={COMMENT_MAX}
class="flex-1 rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-blue-500 focus:outline-none dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-500"
@@ -224,8 +218,8 @@
</div>
<div
class="mt-1 text-right text-xs"
class:text-gray-400={newComment.length < 450}
class:dark:text-gray-500={newComment.length < 450}
class:text-gray-500={newComment.length < 450}
class:dark:text-gray-400={newComment.length < 450}
class:text-amber-600={newComment.length >= 450 && newComment.length < COMMENT_MAX}
class:dark:text-amber-400={newComment.length >= 450 && newComment.length < COMMENT_MAX}
class:text-red-600={newComment.length >= COMMENT_MAX}

View File

@@ -1,6 +1,5 @@
<script lang="ts">
import { focusTrap } from '$lib/actions/focus-trap';
import { scrollLock } from '$lib/actions/scroll-lock';
import type { Snippet } from 'svelte';
// Accessible name is REQUIRED. Pass `titleId` when the dialog renders its own
@@ -49,7 +48,6 @@
aria-labelledby={titleId}
aria-label={titleId ? undefined : ariaLabel}
use:focusTrap={{ onclose: onClose }}
use:scrollLock
>
<div class="pointer-events-auto w-full max-w-sm rounded-2xl bg-white p-6 shadow-xl dark:bg-gray-900">
{@render children()}

View File

@@ -3,7 +3,6 @@
import { privacyNote } from '$lib/privacy-note-store';
import { themePreference, type ThemePreference } from '$lib/theme-store';
import { focusTrap } from '$lib/actions/focus-trap';
import { scrollLock } from '$lib/actions/scroll-lock';
import { vibrate } from '$lib/haptics';
const GUIDE_SEEN_KEY = 'eventsnap_guide_seen';
@@ -101,7 +100,6 @@
aria-modal="true"
aria-labelledby="onboarding-title"
use:focusTrap={{ onclose: dismiss }}
use:scrollLock
>
<!-- Step indicator — tap a pip to jump back. The visible dot is small but
the touch target is padded to ~44 px so it remains tappable on mobile. -->

View File

@@ -19,17 +19,15 @@
class="pointer-events-none fixed inset-x-0 z-[60] flex flex-col items-center gap-2 px-4"
style="bottom: calc(env(safe-area-inset-bottom) + 5rem)"
role="region"
aria-live="polite"
aria-label="Benachrichtigungen"
data-testid="toaster"
>
{#each $toasts as t (t.id)}
<!-- Errors/warnings interrupt (assertive/alert); successes wait their turn (polite/status). -->
<button
type="button"
onclick={() => dismissToast(t.id)}
class="pointer-events-auto w-full max-w-sm rounded-xl px-4 py-3 text-left text-sm font-medium shadow-lg transition active:scale-[0.98] {toneClasses(t.tone)}"
role={t.tone === 'error' || t.tone === 'warning' ? 'alert' : 'status'}
aria-live={t.tone === 'error' || t.tone === 'warning' ? 'assertive' : 'polite'}
data-testid="toast"
data-toast-tone={t.tone}
>

View File

@@ -19,10 +19,10 @@
function statusColor(status: QueueItem['status']): string {
switch (status) {
case 'pending': return 'text-gray-500 dark:text-gray-400';
case 'uploading': return 'text-blue-600 dark:text-blue-400';
case 'done': return 'text-green-600 dark:text-green-400';
case 'error': return 'text-red-600 dark:text-red-400';
case 'pending': return 'text-gray-500';
case 'uploading': return 'text-blue-600';
case 'done': return 'text-green-600';
case 'error': return 'text-red-600';
}
}
@@ -50,9 +50,9 @@
</script>
{#if items.length > 0}
<div class="mt-4 rounded-lg border border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900">
<div class="flex items-center justify-between border-b border-gray-100 px-4 py-3 dark:border-gray-800">
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100">
<div class="mt-4 rounded-lg border border-gray-200 bg-white">
<div class="flex items-center justify-between border-b border-gray-100 px-4 py-3">
<h3 class="text-sm font-semibold text-gray-900">
Upload-Warteschlange
{#if $isProcessing}
<span class="ml-2 inline-block h-2 w-2 animate-pulse rounded-full bg-blue-500"></span>
@@ -61,7 +61,7 @@
{#if hasCompleted}
<button
onclick={() => clearCompleted()}
class="inline-flex min-h-11 items-center px-1 text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
class="text-xs text-gray-500 hover:text-gray-700"
>
Fertige entfernen
</button>
@@ -69,18 +69,18 @@
</div>
{#if $rateLimitRetryAt && countdown > 0}
<div class="border-b border-amber-100 bg-amber-50 px-4 py-2 text-sm text-amber-800 dark:border-amber-900/40 dark:bg-amber-950/30 dark:text-amber-300">
<div class="border-b border-amber-100 bg-amber-50 px-4 py-2 text-sm text-amber-800">
Upload-Limit erreicht. Wird in {countdown} Sek. automatisch fortgesetzt.
</div>
{/if}
<ul class="divide-y divide-gray-100 dark:divide-gray-800">
<ul class="divide-y divide-gray-100">
{#each items as item (item.id)}
<li class="px-4 py-3">
<div class="flex items-center justify-between">
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium text-gray-900 dark:text-gray-100">{item.fileName}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">{formatSize(item.fileSize)}</p>
<p class="truncate text-sm font-medium text-gray-900">{item.fileName}</p>
<p class="text-xs text-gray-500">{formatSize(item.fileSize)}</p>
</div>
<div class="ml-3 flex items-center gap-2">
<span class="text-xs font-medium {statusColor(item.status)}">
@@ -89,7 +89,7 @@
{#if item.status === 'error'}
<button
onclick={() => retryItem(item.id)}
class="inline-flex min-h-11 items-center rounded bg-red-100 px-3 text-xs font-medium text-red-700 hover:bg-red-200 dark:bg-red-950/40 dark:text-red-300 dark:hover:bg-red-950/60"
class="rounded bg-red-100 px-2 py-0.5 text-xs text-red-700 hover:bg-red-200"
>
Erneut
</button>
@@ -97,7 +97,7 @@
{#if item.status === 'done' || item.status === 'error'}
<button
onclick={() => removeItem(item.id)}
class="inline-flex h-9 w-9 items-center justify-center text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
class="text-gray-400 hover:text-gray-600"
aria-label="Entfernen"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -109,17 +109,17 @@
</div>
{#if item.status === 'uploading'}
<div class="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
<div class="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-gray-200">
<div
class="h-full rounded-full bg-blue-500 transition-all duration-300"
style="width: {item.progress}%"
></div>
</div>
<p class="mt-1 text-right text-xs text-gray-400 dark:text-gray-500">{item.progress}%</p>
<p class="mt-1 text-right text-xs text-gray-400">{item.progress}%</p>
{/if}
{#if item.error}
<p class="mt-1 text-xs text-red-500 dark:text-red-400">{item.error}</p>
<p class="mt-1 text-xs text-red-500">{item.error}</p>
{/if}
</li>
{/each}

View File

@@ -2,7 +2,6 @@
import { goto } from '$app/navigation';
import { uploadSheetOpen } from '$lib/ui-store';
import { pendingFiles } from '$lib/pending-upload-store';
import { scrollLock } from '$lib/actions/scroll-lock';
import CameraCapture from '$lib/components/CameraCapture.svelte';
import type { PendingFile } from '$lib/pending-upload-store';
@@ -18,11 +17,11 @@
uploadSheetOpen.set(false);
}
// Focus-trap + Escape, wired manually because the sheet stays mounted for its
// translate-y animation (so use:focusTrap, which activates on mount, won't do).
// Mirrors ContextSheet. Suspended while the camera overlay owns the screen.
// Modal contract (mirrors ContextSheet): trap Tab within the sheet, close on
// Escape, and restore focus on close. The sheet is permanently mounted (CSS
// translate animation), so this is wired via $effect — and `inert`/`aria-hidden`
// below keep its controls out of the tab order + AT tree while closed.
function onKeyDown(e: KeyboardEvent) {
if (showCamera) return;
if (e.key === 'Escape') {
e.preventDefault();
close();
@@ -47,14 +46,17 @@
if (open) {
returnFocus = (document.activeElement as HTMLElement | null) ?? null;
requestAnimationFrame(() => {
if (showCamera) return;
const first = sheet?.querySelector<HTMLButtonElement>('button:not([disabled])');
first?.focus({ preventScroll: true });
});
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
} else if (returnFocus) {
try { returnFocus.focus({ preventScroll: true }); } catch { /* element gone */ }
try {
returnFocus.focus({ preventScroll: true });
} catch {
/* element gone */
}
returnFocus = null;
}
});
@@ -112,22 +114,15 @@
onchange={handleFiles}
/>
<!-- Lock body scroll only while open (sheet stays mounted for its animation). -->
{#if open && !showCamera}
<div use:scrollLock class="hidden"></div>
{/if}
<!-- Backdrop — real <button> so keyboard / switch-control users get parity. -->
<button
type="button"
<!-- Backdrop -->
<div
class="fixed inset-0 z-40 bg-black/50 transition-opacity duration-300"
class:opacity-0={!open}
class:pointer-events-none={!open}
class:opacity-100={open}
onclick={close}
tabindex="-1"
aria-label="Schließen"
></button>
aria-hidden="true"
></div>
<!-- Sheet -->
<div
@@ -135,13 +130,13 @@
class="fixed inset-x-0 bottom-0 z-50 rounded-t-2xl bg-white transition-transform duration-300 dark:bg-gray-900"
class:translate-y-full={!open}
class:translate-y-0={open}
class:pointer-events-none={!open}
style="padding-bottom: env(safe-area-inset-bottom)"
role={open ? 'dialog' : undefined}
aria-modal={open ? 'true' : undefined}
role="dialog"
aria-modal="true"
aria-label="Hochladen"
aria-hidden={!open}
inert={!open}
tabindex="-1"
inert={!open}
>
<!-- Drag handle -->
<div class="flex justify-center pt-3 pb-1">

View File

@@ -1,305 +0,0 @@
<script lang="ts">
// DOM-windowing for the feed. Only the cards/rows inside (and a small overscan
// around) the viewport are kept in the DOM — at ~1000 uploads this is the
// difference between ~1000 heavy cards (each with its own image, HeartBurst,
// long-press + double-tap listeners) and ~10-15.
//
// We use TanStack's *window* virtualizer (not an inner scroll container) on
// purpose: the feed scrolls the document, and the page's sticky header,
// pull-to-refresh, infinite-scroll sentinel and bottom nav all rely on that.
// The window virtualizer measures against `window` scroll, so every one of
// those keeps working untouched.
//
// Two layouts share one mechanism:
// list — one full-width FeedListCard per row, heights *measured* (captions
// make them variable). Keyed by upload id + `anchorTo:'start'` so an
// SSE prepend (new upload) doesn't yank a scrolled-down reader.
// grid — three square tiles per row, uniform height; still measured to shrug
// off sub-pixel drift over hundreds of rows.
import { createWindowVirtualizer } from '@tanstack/svelte-virtual';
import { untrack } from 'svelte';
import { get } from 'svelte/store';
import { browser } from '$app/environment';
import type { FeedUpload } from '$lib/types';
import { dataMode } from '$lib/data-mode-store';
import { longpress } from '$lib/actions/longpress';
import FeedListCard from './FeedListCard.svelte';
interface Props {
uploads: FeedUpload[];
mode: 'list' | 'grid';
myUserId: string | null;
onlike: (id: string) => void;
oncomment: (id: string) => void;
onselect: (upload: FeedUpload) => void;
oncontextmenu?: (upload: FeedUpload) => void;
}
let { uploads, mode, myUserId, onlike, oncomment, onselect, oncontextmenu }: Props =
$props();
const COLS = 3;
const GRID_GAP = 2; // px — matches the `gap-0.5` the non-virtual grid used.
const LIST_ESTIMATE = 700; // px — first-paint guess; real heights replace it on measure.
let listEl = $state<HTMLDivElement>();
let containerWidth = $state(0);
// Distance from the top of the document to the list container, i.e. the height
// of everything above it (sticky header + chips/search). Items' `start` values
// are document-absolute (they include this margin), so we feed it back as
// `scrollMargin` and subtract it again when positioning. Read live from layout
// (getBoundingClientRect + scrollY is scroll-independent) so it self-corrects
// when the header grows — e.g. grid filter chips appear.
let scrollMargin = $state(0);
const rowCount = $derived(mode === 'grid' ? Math.ceil(uploads.length / COLS) : uploads.length);
const colWidth = $derived(
containerWidth > 0 ? (containerWidth - (COLS - 1) * GRID_GAP) / COLS : 120
);
function isVideo(mime: string): boolean {
return mime.startsWith('video/');
}
// Grid tiles always use the small thumbnail — full media is one tap away in the
// lightbox where the data-mode picker decides for real.
function tileUrl(upload: FeedUpload): string {
if (upload.thumbnail_url) return upload.thumbnail_url;
if (upload.preview_url) return upload.preview_url;
return $dataMode === 'original' ? `/api/v1/upload/${upload.id}/original` : '';
}
// STABLE option callbacks — created once, never swapped. They read the live
// reactive values (`colWidth`, `uploads`, `mode`) at *call* time, so they stay
// current without needing a new function reference. This matters: `getItemKey`
// is a dependency of virtual-core's measurements memo, so handing it a fresh
// closure on every render would force an O(n) recompute. List keys by upload id
// (so an SSE prepend keeps measured heights attached to the right card); grid
// keys by row index (uniform rows, nothing to preserve).
const estimateSize = (_i: number) => (mode === 'grid' ? colWidth : LIST_ESTIMATE);
const getItemKey = (i: number) => (mode === 'list' ? (uploads[i]?.id ?? i) : i);
// `mode` is fixed for the lifetime of an instance (list and grid are rendered as
// separate <VirtualFeed> elements in the parent's {#if} branches, so toggling
// remounts rather than mutating this prop). Snapshot it without a reactive read
// to set the layout-constant options once.
const isGrid = untrack(() => mode === 'grid');
// Created with static placeholder count/margin; `applyOptions` pushes those.
const virtualizer = createWindowVirtualizer<HTMLDivElement>({
count: 0,
estimateSize,
getItemKey,
overscan: isGrid ? 4 : 3,
gap: isGrid ? GRID_GAP : 0,
anchorTo: 'start',
scrollMargin: 0
});
// Only `count` (load-more / prepend / delete) and `scrollMargin` (header height
// shifts) actually need to be pushed into the virtualizer. Like/comment SSE
// patches reassign `uploads` without changing its length — those must NOT
// trigger a setOptions (and its getBoundingClientRect reflow + store churn); the
// affected card re-renders through normal reactivity instead. We read the raw
// instance via `get()` rather than `$virtualizer` so writing never re-triggers
// this effect (that would loop).
let appliedCount = -1;
let appliedMargin = Number.NaN;
let appliedWidth = Number.NaN;
function applyOptions() {
const count = rowCount;
const width = containerWidth;
const margin = listEl ? listEl.getBoundingClientRect().top + window.scrollY : 0;
scrollMargin = margin; // drives the template transforms
// A width change (rotation, or the first 0→real clientWidth landing) changes
// every card's / tile-row's real height, so the heights cached from the old
// width are stale even when count and margin are unchanged. Invalidate the
// measurement cache so getTotalSize + positions recompute (rendered rows then
// re-measure immediately via their ResizeObserver) instead of trusting them.
const widthChanged = width > 0 && Math.abs(width - appliedWidth) > 0.5;
if (count === appliedCount && Math.abs(margin - appliedMargin) < 0.5 && !widthChanged) return;
appliedCount = count;
appliedMargin = margin;
appliedWidth = width;
get(virtualizer).setOptions({ count, scrollMargin: margin });
if (widthChanged) get(virtualizer).measure();
}
// Re-apply when the row count or container width changes (load-more, prepend,
// delete, filter, rotate). `containerWidth`/`rowCount` are the only tracked
// reads, so a length-stable like/comment patch doesn't re-run this.
$effect(() => {
void rowCount;
void containerWidth;
applyOptions();
});
// Header offset can also shift on resize without a count change (orientation,
// on-screen keyboard, font scaling).
$effect(() => {
if (!browser) return;
const onResize = () => applyOptions();
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
});
// Hand each rendered row to the virtualizer's ResizeObserver (it reads the row's
// `data-index` and caches the real height by item key). On `destroy` we call
// `measureElement(null)`, which sweeps now-disconnected nodes out of the
// internal cache + ResizeObserver — without it, every card that scrolls out of
// the window stays observed and retained, defeating the point of virtualizing.
function measure(node: HTMLDivElement) {
get(virtualizer).measureElement(node);
return {
update() {
get(virtualizer).measureElement(node);
},
destroy() {
get(virtualizer).measureElement(null);
}
};
}
</script>
<div bind:this={listEl} bind:clientWidth={containerWidth} class="w-full">
{#if browser}
<div style="position: relative; width: 100%; height: {$virtualizer.getTotalSize()}px;">
{#each $virtualizer.getVirtualItems() as item (item.key)}
{#if mode === 'list'}
{@const upload = uploads[item.index]}
<div
data-index={item.index}
use:measure
style="position: absolute; top: 0; left: 0; width: 100%; transform: translateY({item.start -
scrollMargin}px);"
>
{#if upload}
<FeedListCard
{upload}
isOwn={upload.user_id === myUserId}
{onlike}
{oncomment}
{onselect}
{oncontextmenu}
/>
{/if}
</div>
{:else}
<div
data-index={item.index}
use:measure
style="position: absolute; top: 0; left: 0; width: 100%; transform: translateY({item.start -
scrollMargin}px);"
>
<div class="grid grid-cols-3 gap-0.5">
{#each uploads.slice(item.index * COLS, item.index * COLS + COLS) as upload (upload.id)}
<!-- Tile — mirrors the markup the old FeedGrid used. -->
<div
class="group relative aspect-square cursor-pointer overflow-hidden rounded-lg bg-gray-100 dark:bg-gray-800"
use:longpress={{ duration: 500 }}
onlongpress={() => oncontextmenu?.(upload)}
>
<button
onclick={() => onselect(upload)}
class="block h-full w-full"
aria-label="Upload anzeigen"
>
{#if isVideo(upload.mime_type)}
<div class="flex h-full items-center justify-center bg-gray-800">
{#if tileUrl(upload)}
<img
src={tileUrl(upload)}
alt=""
class="h-full w-full object-cover"
loading="lazy"
decoding="async"
/>
{/if}
<div class="absolute inset-0 flex items-center justify-center">
<svg class="h-10 w-10 text-white/80" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
</div>
</div>
{:else if tileUrl(upload)}
<img
src={tileUrl(upload)}
alt=""
class="h-full w-full object-cover"
loading="lazy"
decoding="async"
/>
{:else}
<div class="flex h-full items-center justify-center text-gray-400">
<svg class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
</div>
{/if}
</button>
<div
class="pointer-events-none absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/60 to-transparent p-2"
>
<p class="truncate text-xs font-medium text-white">{upload.uploader_name}</p>
<div class="mt-0.5 flex items-center gap-3 text-xs text-white/80">
<button
class="pointer-events-auto -m-1 flex items-center gap-0.5 p-1"
onclick={(e) => {
e.stopPropagation();
onlike(upload.id);
}}
aria-pressed={upload.liked_by_me}
aria-label={upload.liked_by_me ? 'Gefällt mir nicht mehr' : 'Gefällt mir'}
>
<svg
class="h-4 w-4 {upload.liked_by_me ? 'fill-red-400 text-red-400' : ''}"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
/>
</svg>
{upload.like_count}
</button>
<button
class="pointer-events-auto -m-1 flex items-center gap-0.5 p-1"
onclick={(e) => {
e.stopPropagation();
oncomment(upload.id);
}}
aria-label="Kommentare anzeigen"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
/>
</svg>
{upload.comment_count}
</button>
</div>
</div>
</div>
{/each}
</div>
</div>
{/if}
{/each}
</div>
{/if}
</div>

View File

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

View File

@@ -1,13 +1,11 @@
// Per-device "Datenmodus" — Saver loads compressed previews (default), Original loads
// the full file via the `/api/v1/upload/{id}/original` endpoint. That route is
// intentionally unauthenticated (the UUID acts as the capability) so it works from
// plain `<img src>` / `<video src>` without an Authorization header.
// the full file via the signed `original_url` from the media gateway.
//
// Stored per-device in localStorage (not per-user) because data plans are a property
// of the device the guest is currently holding, not their identity.
//
// Used by:
// - Feed cards (FeedListCard / VirtualFeed grid tiles) to pick which URL to render
// - Feed cards (FeedListCard / FeedGrid) to pick which URL to render
// - Lightbox
// - Diashow
// See [docs/FEATURES.md §2.5] for the user-facing model.
@@ -42,17 +40,24 @@ if (browser) {
* Build the URL for a feed upload given the current data mode and the URL variants
* the backend returned. Centralised so every consumer (cards, lightbox, diashow)
* follows the same fallback rule:
* Original mode → original API route. Falls back to preview if no upload id is
* available (defensive — shouldn't happen in practice).
* Original mode → signed original URL, falling back to preview/thumbnail.
* Saver mode → preview URL (compressed), falling back to thumbnail and then
* original.
* the signed original.
*
* All URLs are minted (and signed) by the backend; the client never constructs
* a media path itself.
*/
export function pickMediaUrl(
mode: DataMode,
upload: { id: string; preview_url: string | null; thumbnail_url: string | null }
upload: {
id: string;
preview_url: string | null;
thumbnail_url: string | null;
original_url: string | null;
}
): string {
if (mode === 'original') {
return `/api/v1/upload/${upload.id}/original`;
return upload.original_url ?? upload.preview_url ?? upload.thumbnail_url ?? '';
}
return upload.preview_url ?? upload.thumbnail_url ?? `/api/v1/upload/${upload.id}/original`;
return upload.preview_url ?? upload.thumbnail_url ?? upload.original_url ?? '';
}

View File

@@ -0,0 +1,10 @@
// Shared, reactive event state pushed from the backend.
//
// `uploadsLocked` is seeded from `/me/context` on boot and kept live by the
// `event-closed` / `event-opened` SSE events (wired in the root layout). Pages
// read it to show an "Uploads gesperrt" banner and disable the upload FAB, so a
// locked event stops inviting uploads that would only fail server-side.
import { writable } from 'svelte/store';
export const uploadsLocked = writable<boolean>(false);

View File

@@ -1,59 +0,0 @@
import { describe, it, expect } from 'vitest';
import { filterUploads, type FeedFilter } from './feed-filter';
// filterUploads only reads `caption` and `uploader_name`, so a partial shape suffices.
const u = (id: string, uploader_name: string, caption: string | null) =>
({ id, uploader_name, caption }) as any;
const uploads = [
u('1', 'Alice', 'pic #wedding'),
u('2', 'Bob', 'party #party'),
u('3', 'Alice', 'more #wedding #party'),
u('4', 'Carol', 'no tags here'),
u('5', 'Bob', null), // no caption
];
const ids = (r: any[]) => r.map((x) => x.id);
const tag = (value: string): FeedFilter => ({ type: 'tag', value });
const user = (value: string): FeedFilter => ({ type: 'user', value });
describe('filterUploads', () => {
it('no filters → returns all uploads unchanged', () => {
expect(filterUploads(uploads, [])).toBe(uploads);
});
it('a single tag matches uploads whose caption contains it', () => {
expect(ids(filterUploads(uploads, [tag('wedding')]))).toEqual(['1', '3']);
});
it('two tags combine with OR', () => {
expect(ids(filterUploads(uploads, [tag('wedding'), tag('party')]))).toEqual(['1', '2', '3']);
});
it('a single user matches only that uploader', () => {
expect(ids(filterUploads(uploads, [user('Alice')]))).toEqual(['1', '3']);
});
it('two users combine with OR', () => {
expect(ids(filterUploads(uploads, [user('Alice'), user('Bob')]))).toEqual(['1', '2', '3', '5']);
});
it('a user chip and a tag chip combine with AND', () => {
// Alice AND #wedding → only Alice's wedding uploads (not Bob's #wedding, not Alice's #party-only)
expect(ids(filterUploads(uploads, [user('Alice'), tag('wedding')]))).toEqual(['1', '3']);
});
it('AND excludes an uploader-match that lacks the tag', () => {
// Bob AND #wedding → none (Bob has #party and a null caption, no #wedding)
expect(filterUploads(uploads, [user('Bob'), tag('wedding')])).toHaveLength(0);
});
it('tag matching is case-insensitive against the caption', () => {
expect(filterUploads([u('9', 'X', 'PIC #WeDDing')], [tag('wedding')])).toHaveLength(1);
});
it('a null caption never matches a tag but can match a user', () => {
expect(filterUploads([u('5', 'Bob', null)], [tag('party')])).toHaveLength(0);
expect(filterUploads([u('5', 'Bob', null)], [user('Bob')])).toHaveLength(1);
});
});

View File

@@ -1,35 +0,0 @@
// Grid-view feed filtering. Extracted from feed/+page.svelte so the OR/AND
// combination rules are unit-testable without mounting the page.
import type { FeedUpload } from './types';
export interface FeedFilter {
type: 'tag' | 'user';
value: string;
}
/**
* Apply the active grid filters to a list of uploads.
*
* Combination rules (mirroring the chip UI):
* - Tags combine with **OR**: a card passes the tag group if its caption contains
* ANY selected `#tag`.
* - Users combine with **OR** within the user group (uploader is one of the
* selected names).
* - The tag group and the user group combine with **AND**: a card must satisfy
* both groups. An empty group is a pass-through.
*
* Tags are matched against the caption text (the autocomplete source), so `value`
* is expected lowercase (as produced by the tag suggestions).
*/
export function filterUploads(uploads: FeedUpload[], filters: FeedFilter[]): FeedUpload[] {
if (filters.length === 0) return uploads;
const tags = filters.filter((f) => f.type === 'tag').map((f) => f.value);
const users = filters.filter((f) => f.type === 'user').map((f) => f.value);
return uploads.filter((u) => {
const cap = (u.caption ?? '').toLowerCase();
const passTag = !tags.length || tags.some((t) => cap.includes('#' + t));
const passUser = !users.length || users.includes(u.uploader_name);
return passTag && passUser;
});
}

View File

@@ -1,10 +0,0 @@
import { readable } from 'svelte/store';
// A single 60-second clock shared by every component that renders a relative
// timestamp ("vor 5 Min."). One interval for the whole app — not one per card —
// so a 1000-item feed stays cheap, and the interval is torn down automatically
// when the last subscriber unsubscribes.
export const now = readable(Date.now(), (set) => {
const id = setInterval(() => set(Date.now()), 60_000);
return () => clearInterval(id);
});

View File

@@ -38,6 +38,7 @@ const KNOWN_EVENTS = [
'upload-deleted',
'like-update',
'new-comment',
'user-banned',
'event-closed',
'event-opened',
'event-updated',
@@ -50,7 +51,18 @@ const KNOWN_EVENTS = [
* Synthetic event types — not emitted by the server, dispatched locally to fan out
* cross-cutting state changes (e.g. delta-fetch results after a reconnect).
*/
export type SyntheticEvent = 'feed-delta';
export type SyntheticEvent = 'feed-delta' | 'feed-reload';
/**
* Advance the reconnect cursor using a **server** timestamp (an upload's
* `created_at`), never the client clock. A phone clock skewed ahead would
* otherwise make the cursor jump past events that happened while the tab was
* backgrounded. ISO-8601 UTC strings compare correctly lexicographically.
*/
function noteServerTime(ts: string | null | undefined): void {
if (!ts) return;
if (!lastEventTime || ts > lastEventTime) lastEventTime = ts;
}
export function onSseEvent(eventType: string, handler: EventHandler): () => void {
if (!handlers.has(eventType)) {
@@ -93,12 +105,13 @@ export function connectSse(): void {
eventSource.onopen = () => {
// Successful connection — reset the backoff counter.
reconnectAttempt = 0;
// If we have a previous timestamp this is a reconnect — fetch the gap.
// If we have a previous (server-derived) timestamp this is a reconnect
// — fetch the gap. The cursor is only ever advanced from server
// timestamps (noteServerTime), so client clock skew can't drop events.
const since = lastEventTime;
if (since) {
void deltaFetchAndFan(since);
}
lastEventTime = new Date().toISOString();
};
for (const eventName of KNOWN_EVENTS) {
@@ -146,7 +159,15 @@ export function setLastEventTime(time: string): void {
}
function dispatch(eventType: string, data: string): void {
lastEventTime = new Date().toISOString();
// Advance the cursor from the server timestamp carried by a new upload.
// Other event types don't carry one and must not bump it off the client clock.
if (eventType === 'new-upload') {
try {
noteServerTime((JSON.parse(data) as { created_at?: string }).created_at);
} catch {
// payload not JSON — ignore
}
}
const list = handlers.get(eventType);
if (list) {
for (const handler of list) {
@@ -166,6 +187,14 @@ async function deltaFetchAndFan(since: string): Promise<void> {
const response = await api.get<DeltaResponse>(
`/feed/delta?since=${encodeURIComponent(since)}`
);
// Advance the cursor from the newest server timestamp in the delta.
for (const u of response.uploads) noteServerTime(u.created_at);
// The server clamped the window or hit the row cap — the partial delta
// can't be trusted, so ask the page to do a full reload instead.
if (response.reload_required) {
dispatch('feed-reload', '{}');
return;
}
dispatch('feed-delta', JSON.stringify(response));
} catch {
// non-fatal

View File

@@ -9,6 +9,7 @@ export interface FeedUpload {
uploader_name: string;
preview_url: string | null;
thumbnail_url: string | null;
original_url: string | null;
mime_type: string;
caption: string | null;
like_count: number;
@@ -27,6 +28,7 @@ export interface FeedResponse {
export interface DeltaResponse {
uploads: FeedUpload[];
deleted_ids: string[];
reload_required: boolean;
}
// mirrors backend/src/handlers/feed.rs::HashtagCount
@@ -53,6 +55,7 @@ export interface MeContextDto {
privacy_note: string;
quota_enabled: boolean;
storage_quota_enabled: boolean;
uploads_locked: boolean;
}
// mirrors backend/src/handlers/host.rs::PinResetResponse

View File

@@ -1,8 +1,9 @@
<script lang="ts">
import favicon from '$lib/assets/favicon.svg';
import '../app.css';
import { initAuth, getToken, getUserId, clearPin } from '$lib/auth';
import { initAuth, getToken, getUserId, clearPin, clearAuth } from '$lib/auth';
import { initTheme } from '$lib/theme-store';
import { goto } from '$app/navigation';
import { onMount, onDestroy } from 'svelte';
import BottomNav from '$lib/components/BottomNav.svelte';
import UploadSheet from '$lib/components/UploadSheet.svelte';
@@ -14,12 +15,28 @@
import { refreshQuota } from '$lib/quota-store';
import { onSseEvent } from '$lib/sse';
import { api } from '$lib/api';
import { toast } from '$lib/toast-store';
import { uploadsLocked } from '$lib/event-state-store';
import type { MeContextDto } from '$lib/types';
let { children } = $props();
let unsubs: Array<() => void> = [];
// H12: surface upload failures. The queue marks failed items 'error' in
// IndexedDB but nothing rendered them, so a banned/locked/over-quota guest saw
// the composer close to a normal feed and assumed success. Toast each item the
// first time it transitions to 'error'.
let toastedErrors = new Set<string>();
$effect(() => {
for (const item of $queueItems) {
if (item.status === 'error' && !toastedErrors.has(item.id)) {
toastedErrors.add(item.id);
toast(item.error ?? 'Upload fehlgeschlagen.', 'error');
}
}
});
// Slim progress bar: ratio of completed items to total, shown while processing.
let progressPct = $derived.by(() => {
const total = $queueItems.length;
@@ -39,6 +56,7 @@
try {
const ctx = await api.get<MeContextDto>('/me/context');
privacyNote.set(ctx.privacy_note);
uploadsLocked.set(ctx.uploads_locked);
} catch {
// Cross-cutting hydration on boot — failure is non-fatal; users without
// a session land on /join anyway, and the per-page mount will retry.
@@ -61,7 +79,26 @@
} catch {
// Malformed payload — discard; nothing actionable for the user.
}
})
}),
// M11: a host banned someone. If it's us, the session has already been
// revoked server-side — drop local auth and send us to /join so the UI
// doesn't keep pretending we're signed in.
onSseEvent('user-banned', (data) => {
try {
const payload = JSON.parse(data) as { user_id: string };
if (payload.user_id === getUserId()) {
clearAuth();
toast('Du wurdest vom Event entfernt.', 'error');
void goto('/join');
}
} catch {
/* malformed — ignore */
}
}),
// M11: reflect event lock state live so the feed/upload pages can toggle
// their "Uploads gesperrt" banner and disable the FAB.
onSseEvent('event-closed', () => uploadsLocked.set(true)),
onSseEvent('event-opened', () => uploadsLocked.set(false))
);
});

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { getToken } from '$lib/auth';
import { browser } from '$app/environment';
import { onMount } from 'svelte';
onMount(() => {
@@ -11,11 +12,3 @@
}
});
</script>
<!-- Brief branded splash so the redirect doesn't flash a blank page. -->
<div class="flex min-h-screen items-center justify-center bg-gray-50 dark:bg-gray-950">
<div class="flex flex-col items-center gap-3">
<div class="h-8 w-8 animate-spin rounded-full border-2 border-gray-300 border-t-blue-600 dark:border-gray-700 dark:border-t-blue-400"></div>
<p class="text-sm font-medium text-gray-400 dark:text-gray-500">EventSnap</p>
</div>
</div>

Some files were not shown because too many files have changed in this diff Show More