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
263 changed files with 4321 additions and 23124 deletions

View File

@@ -1,9 +0,0 @@
{
"permissions": {
"allow": [
"Bash(cargo check *)",
"Bash(cargo clippy *)",
"Bash(git --no-pager diff *)"
]
}
}

View File

@@ -4,34 +4,27 @@ DOMAIN=my-event.example.com
# ── App server ────────────────────────────────────────────────────────────────
APP_PORT=3000
# Set to `production` in real deployments. This activates the secret guard that
# refuses to boot with placeholder JWT_SECRET / ADMIN_PASSWORD_HASH values.
# (docker-compose.yml already sets APP_ENV=production for the app service.)
APP_ENV=production
# 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
# Connection pool size. Default 10. For a busy event (~100 guests polling the feed
# + SSE + uploads at once) raise to ~30 so requests don't queue on a pool permit.
DATABASE_MAX_CONNECTIONS=30
# ── 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
# Admin dashboard password (bcrypt hash).
# Generate with: htpasswd -bnBC 12 "" yourpassword | tr -d ':\n'
# IMPORTANT: keep the SINGLE QUOTES. A bcrypt hash is full of `$` (e.g. $2b$12$…$…),
# and both Docker Compose's env_file interpolation and dotenvy's variable substitution
# would otherwise eat the `$…` segments (reading them as unset vars) and corrupt the
# hash — every admin login then 401s. Single quotes make both read it literally.
ADMIN_PASSWORD_HASH='$2y$12$placeholder_replace_me'
ADMIN_PASSWORD_HASH=$2y$12$placeholder_replace_me
# ── Event ─────────────────────────────────────────────────────────────────────
EVENT_NAME=Max & Maria's Wedding
@@ -39,29 +32,20 @@ EVENT_SLUG=max-maria-2026
# ── Storage ───────────────────────────────────────────────────────────────────
MEDIA_PATH=/media
# Export archives (Gallery.zip / Memories.zip). MUST be outside MEDIA_PATH —
# /media is publicly served, so exports here would be downloadable without auth.
EXPORT_PATH=/exports
# ── Runtime settings (upload limits, rate limits, capacity) ───────────────────
# NOTE: These are NOT environment variables. Upload size caps, rate limits, guest
# count and quota tolerance are stored in the database `config` table (seeded once
# at first boot) and changed at runtime from the ADMIN DASHBOARD — the backend does
# not read them from .env. Setting them here has no effect. Current seeded defaults:
# upload rate 100 / hour / guest (raised from 10 by migration 015)
# feed rate 60 / minute
# export rate 3 / day
# max image size 20 MB
# max video size 500 MB
# estimated guests 100
# quota tolerance 0.75 (fraction of disk that triggers the low-storage warning)
# Adjust these in the admin UI before the event if needed.
# ── Upload limits ─────────────────────────────────────────────────────────────
DEFAULT_MAX_IMAGE_SIZE_MB=20
DEFAULT_MAX_VIDEO_SIZE_MB=500
# ── Rate limiting ─────────────────────────────────────────────────────────────
DEFAULT_UPLOAD_RATE_PER_HOUR=10
DEFAULT_FEED_RATE_PER_MIN=60
DEFAULT_EXPORT_RATE_PER_DAY=3
# ── Capacity ──────────────────────────────────────────────────────────────────
DEFAULT_ESTIMATED_GUEST_COUNT=100
# Fraction of total storage that triggers the "low storage" warning (0.01.0)
DEFAULT_QUOTA_TOLERANCE=0.75
# ── Workers ───────────────────────────────────────────────────────────────────
# Number of parallel image/video compression workers. Default 2. This is the main
# throughput bottleneck: with 2 workers a burst of uploads can take ~5s to appear.
# For a large event (100+ guests) 4 is a good target — but each worker can run an
# ffmpeg transcode, so if you raise this ALSO raise the app container's memory limit
# in docker-compose.yml (`app.deploy.resources.limits.memory`) from 1G to ~2G, or a
# burst of large videos can OOM the box and take Postgres down with it.
COMPRESSION_WORKER_CONCURRENCY=2

45
.env.test Normal file
View File

@@ -0,0 +1,45 @@
# ── Domain ────────────────────────────────────────────────────────────────────
# Public domain Caddy will serve and obtain a TLS certificate for.
DOMAIN=my-event.example.com
# ── App server ────────────────────────────────────────────────────────────────
APP_PORT=3000
# ── Database ──────────────────────────────────────────────────────────────────
DATABASE_URL=postgres://eventsnap:secret@db:5432/eventsnap
POSTGRES_USER=eventsnap
POSTGRES_PASSWORD=secret
POSTGRES_DB=eventsnap
# ── Authentication ────────────────────────────────────────────────────────────
# Generate with: openssl rand -hex 64
JWT_SECRET=change_me_to_a_random_64_byte_hex_string
SESSION_EXPIRY_DAYS=30
# Admin dashboard password (bcrypt hash).
# Generate with: htpasswd -bnBC 12 "" yourpassword | tr -d ':\n'
ADMIN_PASSWORD_HASH=$2y$12$placeholder_replace_me
# ── Event ─────────────────────────────────────────────────────────────────────
EVENT_NAME=Max & Maria's Wedding
EVENT_SLUG=max-maria-2026
# ── Storage ───────────────────────────────────────────────────────────────────
MEDIA_PATH=/media
# ── Upload limits ─────────────────────────────────────────────────────────────
DEFAULT_MAX_IMAGE_SIZE_MB=20
DEFAULT_MAX_VIDEO_SIZE_MB=500
# ── Rate limiting ─────────────────────────────────────────────────────────────
DEFAULT_UPLOAD_RATE_PER_HOUR=10
DEFAULT_FEED_RATE_PER_MIN=60
DEFAULT_EXPORT_RATE_PER_DAY=3
# ── Capacity ──────────────────────────────────────────────────────────────────
DEFAULT_ESTIMATED_GUEST_COUNT=100
# Fraction of total storage that triggers the "low storage" warning (0.01.0)
DEFAULT_QUOTA_TOLERANCE=0.75
# ── Workers ───────────────────────────────────────────────────────────────────
COMPRESSION_WORKER_CONCURRENCY=2

View File

@@ -1,73 +0,0 @@
# Dependency advisory scan.
#
# Lives in .github/workflows/ because Gitea Actions scans BOTH .gitea/workflows and
# .github/workflows, and e2e.yml already proves this instance resolves `actions/*` from GitHub
# (DEFAULT_ACTIONS_URL). Keeping one directory avoids a split where half the CI is invisible
# depending on which convention you look under.
#
# Unlike the GitHub-hosted runners this workflow does NOT assume a preinstalled Rust toolchain —
# the common Gitea runner images (gitea/runner-images, catthehacker/ubuntu) ship Node and Docker
# but not cargo. So we install it explicitly instead of relying on the ambient environment.
name: Audit
on:
pull_request:
push:
branches: [main]
schedule:
# New advisories land against unchanged code, so a push-only trigger would never see them.
- cron: '0 6 * * 1'
jobs:
cargo-audit:
name: cargo audit (backend)
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- name: Install Rust toolchain
run: |
if ! command -v cargo > /dev/null; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
fi
- name: Cache cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/bin
key: cargo-audit-${{ hashFiles('backend/Cargo.lock') }}
restore-keys: cargo-audit-
- name: Install cargo-audit
run: cargo install cargo-audit --locked || true
# `rsa` 0.9 (RUSTSEC-2023-0071, "Marvin") is a transitive dep of the SQLx MySQL driver that
# this app never exercises: auth is HS256 + bcrypt, and the only database is Postgres. There
# is no patched release, so failing the build on it would mean a permanently red pipeline
# that everyone learns to ignore — which is worse than not scanning at all. Ignore it
# SPECIFICALLY, so that any OTHER advisory still fails the job.
- name: Audit
working-directory: ./backend
run: cargo audit --deny warnings --ignore RUSTSEC-2023-0071
npm-audit:
name: npm audit (frontend)
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- name: Install deps
working-directory: ./frontend
run: npm install
# Production dependencies only: a dev-only advisory (build tooling, test runners) can't be
# reached by a guest at the party, and gating merges on it just trains people to skip the gate.
- name: Audit
working-directory: ./frontend
run: npm audit --omit=dev --audit-level=high

View File

@@ -1,129 +0,0 @@
# The checks that were only ever running on a laptop.
#
# Before this file, CI ran Playwright (chromium-desktop) and the dependency audit — and nothing
# else. `cargo test` (40 tests), the frontend vitest suite (5 files, including the offline
# upload-queue and auth-token logic), svelte-check, and the e2e typecheck were all green on a
# developer's machine and gated NOTHING. A check that only ever runs locally is not running.
#
# In .github/workflows/ because Gitea Actions scans it too (see audit.yml). Rust is installed
# explicitly: the common Gitea runner images ship Node and Docker, not cargo.
name: Checks
on:
pull_request:
push:
branches: [main]
jobs:
backend:
name: Backend — cargo test + clippy + fmt
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- name: Install Rust toolchain
run: |
if ! command -v cargo > /dev/null; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
fi
rustup component add clippy rustfmt
- uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
backend/target
key: cargo-${{ hashFiles('backend/Cargo.lock') }}
restore-keys: cargo-
# SQLx runs its queries against a live database at TEST time (see backend/tests/), so the
# DB-backed tests need one. The pure unit tests don't care, but starting it unconditionally
# keeps the job simple and honest about what it covers.
- name: Start Postgres
run: |
docker run -d --name ci-pg -p 5432:5432 \
-e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=eventsnap_ci \
postgres:16-alpine
for _ in $(seq 1 30); do
docker exec ci-pg pg_isready -U postgres > /dev/null 2>&1 && break
sleep 1
done
- name: Test
working-directory: ./backend
env:
DATABASE_URL: postgres://postgres:postgres@localhost:5432/eventsnap_ci
# `#[sqlx::test]` creates a fresh database per test and opens its own pool; run at unbounded
# parallelism against a default `max_connections=100` Postgres, a full suite can exhaust the
# server's connection slots and fail with PoolTimedOut — a pure infra flake, not a real
# failure. Cap the concurrency so the gate stays trustworthy on a loaded runner.
run: cargo test --all-features -- --test-threads=8
- name: Clippy
working-directory: ./backend
run: cargo clippy --all-targets -- -D warnings
- name: Format
working-directory: ./backend
run: cargo fmt --check
frontend:
name: Frontend — vitest + svelte-check
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: 'frontend/package-lock.json'
- name: Install deps
working-directory: ./frontend
run: npm ci || npm install
- name: Unit tests
working-directory: ./frontend
run: npm run test:unit
- name: svelte-check
working-directory: ./frontend
run: npx svelte-check --threshold error
- name: ESLint
working-directory: ./frontend
run: npm run lint
- name: Prettier
working-directory: ./frontend
run: npm run format:check
e2e-typecheck:
name: E2E — typecheck + lint
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- name: Install deps
working-directory: ./e2e
run: npm install
# Playwright TRANSPILES specs without typechecking them, so a type error in a spec is
# invisible until the assertion it guards silently does the wrong thing at runtime.
- name: tsc --noEmit
working-directory: ./e2e
run: npx tsc --noEmit
- name: ESLint
working-directory: ./e2e
run: npm run lint
- name: Prettier
working-directory: ./e2e
run: npm run format:check

View File

@@ -46,14 +46,6 @@ jobs:
working-directory: ./e2e
run: npm run test:e2e -- --project=chromium-desktop
# 09-mobile is `testIgnore`d on chromium-desktop (it needs hasTouch + a phone viewport), so
# running only that project left 22 tests — focus traps, touch targets, safe-area insets,
# viewport reflow, upload-cancel — never executing in CI. On a phone-first event app, where
# essentially every real guest is on a phone, that was the wrong half of the suite to skip.
- name: Run E2E tests (mobile)
working-directory: ./e2e
run: npm run test:e2e -- --project=chromium-mobile
- name: Upload Playwright report
if: failure()
uses: actions/upload-artifact@v4

10
.gitignore vendored
View File

@@ -1,7 +1,5 @@
# Environment secrets — never commit the real .env
.env
# Stale local scratch copy of .env.example; nothing in the test stack reads it.
.env.test
# Rust
backend/target/
@@ -22,13 +20,11 @@ e2e/playwright-report/
e2e/test-results/
e2e/.cache/
e2e/.env.test
# Playwright artifacts when run from the repo root instead of e2e/
/test-results/
/playwright-report/
# OS
.DS_Store
Thumbs.db
# Claude Code personal (per-user) settings — shared settings.json IS committed
.claude/settings.local.json
# 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,42 +1,29 @@
{$DOMAIN} {
# Compress everything EXCEPT the SSE stream — gzip buffering delays
# "real-time" likes/comments until the ~30s keep-alive tick.
@compressible not path /api/v1/stream
encode @compressible 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"
# Preview/thumbnail images. These are now served by the app through a
# visibility-checked alias (/api/v1/upload/{id}/{preview,thumbnail}) so moderation
# can revoke access; direct /media/previews|thumbnails is 404-blocked at the app.
# Privately cacheable for a short window (the app sets the same header; this is the
# edge carve-out from the blanket no-store below). Kept short so a moderated image
# stops being served to a direct-URL holder promptly.
@media_api path /api/v1/upload/*/preview /api/v1/upload/*/thumbnail
header @media_api Cache-Control "private, max-age=300"
# 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).
# API — never cache, EXCEPT the gated image routes above.
@api {
path /api/*
not path /api/v1/upload/*/preview /api/v1/upload/*/thumbnail
}
header @api Cache-Control "no-store"
# 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}
}
# 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

@@ -709,7 +709,7 @@ CREATE TABLE config (
INSERT INTO config (key, value) VALUES
('max_image_size_mb', '20'),
('max_video_size_mb', '500'),
('upload_rate_per_hour', '100'), -- raised from 10 in migration 015 (guests upload bursts of 10-20)
('upload_rate_per_hour', '10'),
('feed_rate_per_min', '60'),
('export_rate_per_day', '3'),
('quota_tolerance', '0.75'),

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
```
@@ -94,9 +93,9 @@ eventsnap/
### Deploy on a fresh VPS
```bash
# 1. Clone the repository (into a lowercase dir, matching the paths used below)
git clone https://git.mc02.dev/fabi/EventSnap.git eventsnap
cd eventsnap
# 1. Clone the repository
git clone https://git.mc02.dev/fabi/EventSnap.git
cd EventSnap
# 2. Configure environment
cp .env.example .env
@@ -108,13 +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.
> **If the site never comes up:** with `APP_ENV=production` the backend **refuses to boot** while `JWT_SECRET`/`ADMIN_PASSWORD_HASH` still hold the `.env.example` placeholders (this is deliberate — a publicly-known signing key is worse than downtime). Caddy then waits on the unhealthy `app` container and never serves. Check `docker compose logs app` — a "Refusing to start … placeholder …" line means you skipped step 2. Rotate the secrets (see below) and restart.
> **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
@@ -182,28 +174,6 @@ The `/media` volume holds originals, previews, thumbnails, exports, and DB backu
---
## Running the backend test suite
```bash
cd backend
# The DB-backed integration tests (backend/tests/) need a live Postgres. `#[sqlx::test]` creates a
# throwaway database per test and runs backend/migrations/ into it — it does NOT touch this one's data.
docker run -d --name eventsnap-test-pg -p 55433:5432 \
-e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=eventsnap postgres:16-alpine
export DATABASE_URL=postgres://postgres:postgres@localhost:55433/eventsnap
cargo test # 44 unit + 12 DB-backed
cargo clippy --all-targets -- -D warnings
```
**`cargo test` requires `DATABASE_URL`** — without it the integration tests panic rather than skip.
That is deliberate. The riskiest code in this repo is SQL (the export epoch state machine, the
atomic quota increment, the `FOR SHARE` upload lock), and for a long time *not one line of it* was
executed by `cargo test` — every backend test was a pure-function test, so the tests clustered
tightly around the code that could not break and stopped exactly where it started to. Tests that
silently skip when the database is absent recreate that hole; they were meant to be a gate.
## Running the E2E test suite
Playwright-based end-to-end tests live in [`e2e/`](e2e/). They spin up an isolated docker-compose stack (Postgres on `:55432`, Caddy on `:3101`) and exercise the SvelteKit frontend against the real Rust backend with rate limits disabled.
@@ -220,14 +190,7 @@ npm run stack:down # tear it down
See [`e2e/README.md`](e2e/README.md) for the full UA matrix, Samsung Internet escalation tiers, and the Phase 2/3 roadmap.
CI runs this on every PR — see [`.github/workflows/e2e.yml`](.github/workflows/e2e.yml) (desktop **and**
mobile projects), plus [`checks.yml`](.github/workflows/checks.yml) for `cargo test`/clippy, the frontend
unit tests, svelte-check and the e2e typecheck, and [`audit.yml`](.github/workflows/audit.yml) for
dependency advisories.
**Playwright runs with `retries: 0`, including in CI.** This repo's real bugs are races, and from the
outside a race is indistinguishable from a flake — so a retry silently resolves that ambiguity in
favour of "flake" every time. A flake here is a bug report; treat it as one.
CI runs this on every PR — see [`.github/workflows/e2e.yml`](.github/workflows/e2e.yml).
---

View File

@@ -18,20 +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 + export mount paths so
# the fresh named volumes inherit the non-root ownership (Docker seeds an empty
# named volume from the image directory, preserving its uid/gid) and uploads +
# export archives can be written. Exports live OUTSIDE /media on purpose so the
# public media ServeDir can't reach them.
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 /exports && chown -R app:app /app /media /exports
USER app
EXPOSE 3000
CMD ["./eventsnap-backend"]

View File

@@ -1,6 +1,4 @@
DROP TABLE IF EXISTS pin_reset_request;
-- Restore v_feed without the is_banned filter.
-- Restore the original join-based v_feed definition.
CREATE OR REPLACE VIEW v_feed AS
SELECT
u.id,

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

@@ -1,3 +0,0 @@
DROP INDEX IF EXISTS idx_comment_hashtag_hashtag;
DROP INDEX IF EXISTS idx_comment_user;
DROP INDEX IF EXISTS idx_upload_event_created_id;

View File

@@ -1,17 +0,0 @@
-- Composite feed index with an id tiebreaker so keyset pagination
-- (ORDER BY created_at DESC, id DESC) stays index-covered and stable when
-- multiple uploads share a created_at timestamp.
CREATE INDEX idx_upload_event_created_id
ON upload(event_id, created_at DESC, id DESC)
WHERE deleted_at IS NULL;
-- A user's own comments (moderation, "who commented"). The sibling
-- idx_upload_user already exists for uploads; comment(user_id) was missing.
CREATE INDEX idx_comment_user
ON comment(user_id)
WHERE deleted_at IS NULL;
-- Hashtag filtering over comments — mirrors idx_upload_hashtag_hashtag, which
-- only covered upload_hashtag.
CREATE INDEX idx_comment_hashtag_hashtag
ON comment_hashtag(hashtag_id);

View File

@@ -1,39 +0,0 @@
-- Ban now ALWAYS hides: exclude banned uploaders from the feed view. Previously ban and
-- hide were decoupled (a host could ban without hiding), leaving a banned user's photos on
-- the feed and baked into the export. `find_visible_media` and the export query get the
-- same `is_banned = FALSE` filter in code.
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
AND usr.is_banned = FALSE
GROUP BY u.id, usr.display_name, usr.is_banned, usr.uploads_hidden;
-- pin_reset_request: a guest who forgot their PIN (localStorage was the only copy) can ask
-- a host to reset it in-app, instead of being permanently orphaned. One pending request per
-- user; cleared when the host resets the PIN or dismisses it, and cascades if the user/event
-- is removed.
CREATE TABLE pin_reset_request (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_id UUID NOT NULL REFERENCES event(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (user_id)
);

View File

@@ -1,2 +0,0 @@
ALTER TABLE export_job
DROP COLUMN IF EXISTS release_seq;

View File

@@ -1,11 +0,0 @@
-- H1: export generation guard. A reopen (which clears `export_released_at`) followed by a
-- re-release could spawn a fresh export worker while a worker from the PRIOR release was
-- still streaming a now-stale snapshot to `Gallery.zip`. The stale worker's finalize then
-- re-set `export_*_ready = TRUE` on an archive that predated the reopen — a silent,
-- permanent data loss (new uploads missing from a "ready" keepsake).
--
-- `release_seq` is a per-(event,type) generation counter bumped on every (re)release.
-- A worker captures the seq it claimed and only finalizes / flips the ready flag while
-- that seq is still current; a superseded worker discards its output instead.
ALTER TABLE export_job
ADD COLUMN release_seq BIGINT NOT NULL DEFAULT 0;

View File

@@ -1,2 +0,0 @@
ALTER TABLE "user"
DROP COLUMN IF EXISTS uploads_hidden_at;

View File

@@ -1,12 +0,0 @@
-- Ban-replay on reconnect. A ban is not a soft-delete (it sets `uploads_hidden`/`is_banned`,
-- never `upload.deleted_at`), and live eviction rode only on the ephemeral `user-hidden` SSE
-- broadcast — which has no reconnect-replay. So a client (especially the unattended diashow
-- projector) that missed that broadcast kept cycling the banned user's already-loaded slides.
--
-- `uploads_hidden_at` stamps WHEN a user's uploads became hidden, so the reconnect delta can
-- return the set of users hidden since the client's cursor and the client can evict them.
ALTER TABLE "user"
ADD COLUMN uploads_hidden_at TIMESTAMPTZ;
-- Backfill already-hidden users so a reconnect right after this migration still evicts them.
UPDATE "user" SET uploads_hidden_at = NOW() WHERE uploads_hidden = TRUE;

View File

@@ -1,28 +0,0 @@
-- Restore the stored ready flags and the per-job counter.
DROP VIEW IF EXISTS export_current;
ALTER TABLE event ADD COLUMN export_zip_ready BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE event ADD COLUMN export_html_ready BOOLEAN NOT NULL DEFAULT FALSE;
-- Re-derive the flags from what the epoch model considers downloadable, so the old code sees
-- exactly the state it would have written itself.
UPDATE event e
SET export_zip_ready = EXISTS (
SELECT 1 FROM export_job j
WHERE j.event_id = e.id AND j.type = 'zip'
AND j.status = 'done' AND j.epoch = e.export_epoch
),
export_html_ready = EXISTS (
SELECT 1 FROM export_job j
WHERE j.event_id = e.id AND j.type = 'html'
AND j.status = 'done' AND j.epoch = e.export_epoch
)
WHERE e.export_released_at IS NOT NULL;
ALTER TABLE export_job RENAME COLUMN epoch TO release_seq;
-- The old code treats release_seq as a non-negative per-job counter; the -1 retirement sentinel
-- has no meaning there, so floor it back to 0.
UPDATE export_job SET release_seq = 0 WHERE release_seq < 0;
ALTER TABLE event DROP COLUMN export_epoch;

View File

@@ -1,86 +0,0 @@
-- Export generation, unified.
--
-- ⚠ ROLLBACK RUNBOOK. This is the repo's first DESTRUCTIVE migration (it DROPs two columns). The
-- previous image will NOT boot against this schema: sqlx runs migrations before serving and errors
-- with VersionMissing on an unknown version, so you get a crash loop, not a degraded service.
-- To roll back to the previous release you must run 014_export_epoch.down.sql BY HAND FIRST, then
-- deploy the old image. The down migration re-derives the old ready flags from the epoch state, so
-- no keepsake is lost.
--
-- Before this migration, "which generation of the keepsake is current?" had no single answer.
-- It was assembled at runtime from three separately-written pieces of state across two tables:
-- * event.export_released_at — a release marker with NO identity (you cannot ask *which* release)
-- * export_job.release_seq — a per-row counter, in a different table, bumped in a different statement
-- * event.export_{zip,html}_ready — a CACHED COPY of the derivation of the other two
-- Keeping those three in agreement took ~10 hand-written guards, and every guard was a repair of the
-- same missing invariant. Three consecutive review rounds each found another path that slipped through.
--
-- This replaces all of it with ONE authority:
--
-- event.export_epoch — a monotonic counter bumped in the SAME UPDATE as any change to
-- export_released_at (release and reopen are its only writers).
-- export_job.epoch — a COPY of event.export_epoch taken when the job was enqueued.
-- NEVER independently incremented.
--
-- The single invariant, which replaces the entire proof:
--
-- An export is downloadable IFF
-- event.export_released_at IS NOT NULL
-- AND export_job.epoch = event.export_epoch
-- AND export_job.status = 'done'
--
-- Readiness is therefore DERIVED, never stored — so it cannot drift, and no worker can set it.
-- A worker holding a dead epoch is INERT BY CONSTRUCTION: anything it writes is invisible to every
-- reader, with no guard involved. Losing a race can no longer corrupt state; it can only waste work.
--
-- Crucially, epoch equality is checked on the SAME ROW the worker updates (export_job), never via a
-- cross-table EXISTS. That matters: under READ COMMITTED, when an UPDATE blocks on a row lock and the
-- blocker commits, Postgres re-evaluates the WHERE against the *updated target row* but answers
-- subqueries on OTHER tables from the statement's ORIGINAL snapshot. The old cross-row guard
-- (`EXISTS (SELECT 1 FROM event WHERE ... export_released_at IS NOT NULL)`) was unsound for exactly
-- that reason — it could admit a claim against an already-reopened event while RETURNING the
-- post-bump seq. A same-row `epoch = $n` predicate is re-evaluated correctly by EPQ.
ALTER TABLE event ADD COLUMN export_epoch BIGINT NOT NULL DEFAULT 0;
-- A currently-released event's live generation becomes epoch 1.
UPDATE event SET export_epoch = 1 WHERE export_released_at IS NOT NULL;
-- The per-job counter becomes a plain copy of the event epoch it was enqueued for.
ALTER TABLE export_job RENAME COLUMN release_seq TO epoch;
-- Carry the OLD notion of "downloadable" across exactly: a job the old model considered ready
-- (released + done + its ready flag) adopts the event's live epoch. Everything else is retired to
-- -1, which can never equal a non-negative event.export_epoch, so it is inert forever.
UPDATE export_job j
SET epoch = CASE
WHEN e.export_released_at IS NOT NULL
AND j.status = 'done'
AND ((j.type = 'zip' AND e.export_zip_ready)
OR (j.type = 'html' AND e.export_html_ready))
THEN e.export_epoch
ELSE -1
END
FROM event e
WHERE e.id = j.event_id;
-- The duplicated derivation. Gone — along with the whole class of bugs where a stale worker
-- resurrected it, or where it disagreed with the job row it was supposed to summarise.
ALTER TABLE event DROP COLUMN export_zip_ready;
ALTER TABLE event DROP COLUMN export_html_ready;
-- The ONE definition of "the current generation". Every reader goes through this, so readiness and
-- the file path are resolved in a SINGLE read — closing the download-path TOCTOU where the ready
-- flag was checked in one query and file_path fetched in another (a reopen between the two served a
-- keepsake that should have 404'd).
CREATE VIEW export_current AS
SELECT j.event_id,
j.type,
j.status,
j.progress_pct,
j.file_path,
j.epoch
FROM export_job j
JOIN event e ON e.id = j.event_id
WHERE e.export_released_at IS NOT NULL -- implied by the epoch match; kept as an assertion
AND j.epoch = e.export_epoch;

View File

@@ -1,3 +0,0 @@
-- Revert the default upload rate to 10/hour for installs still on the raised
-- default (preserves any explicit admin override at another value).
UPDATE config SET value = '10' WHERE key = 'upload_rate_per_hour' AND value = '100';

View File

@@ -1,10 +0,0 @@
-- Raise the default per-guest upload rate from 10/hour to 100/hour.
--
-- Rationale: guests routinely upload a burst of 10-20 photos at once (phone
-- multi-select). At the old default of 10/hour a real guest's first burst was
-- throttled — surfaced by the 2026-07-18 load test. 100/hour comfortably covers
-- several bursts across an event while still bounding abuse.
--
-- Only bump installs still on the old default; an admin who deliberately set a
-- different value keeps it (migration 005 seeded 10; this UPDATE is scoped to '10').
UPDATE config SET value = '100' WHERE key = 'upload_rate_per_hour' AND value = '10';

View File

@@ -1,28 +0,0 @@
-- Drop the view (frees the column dependency), remove the column, then restore the
-- pre-016 view definition (matches migration 011).
DROP VIEW IF EXISTS v_feed;
ALTER TABLE upload DROP COLUMN display_path;
CREATE 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
AND usr.is_banned = FALSE
GROUP BY u.id, usr.display_name, usr.is_banned, usr.uploads_hidden;

View File

@@ -1,34 +0,0 @@
-- Display derivative: a big-screen-quality image (~2048px long edge) for the diashow.
-- The 800px `preview_path` is sized for phone feeds (data saver); upscaled on a projector
-- it looks soft. The diashow uses `display_path` instead — bounded in size (safe to decode
-- on weak kiosk hardware) yet sharp on 1080p/4K. NULL until the compression worker (or the
-- one-time backfill) generates it; consumers fall back to the original when absent.
ALTER TABLE upload ADD COLUMN display_path TEXT;
-- Recreate (not CREATE OR REPLACE, which only allows appending columns at the end) so the
-- new column can sit alongside preview_path/thumbnail_path.
DROP VIEW IF EXISTS v_feed;
CREATE 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.display_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
AND usr.is_banned = FALSE
GROUP BY u.id, usr.display_name, usr.is_banned, usr.uploads_hidden;

View File

@@ -1,195 +0,0 @@
#!/usr/bin/env bash
#
# Dress rehearsal for migration 014 (export_epoch) — the repo's first DESTRUCTIVE migration.
#
# 014 DROPs event.export_zip_ready / export_html_ready and rewrites export_job.release_seq into an
# epoch. The dangerous part is not the DDL, it's the BACKFILL: it has to carry the old notion of
# "downloadable" across exactly. Get it wrong and a released event's keepsake silently 404s for
# every guest, on a dataset you cannot re-create.
#
# This script proves the backfill preserves downloadability, against a scratch copy of a REAL dump:
#
# ./rehearse-014.sh /path/to/prod-dump.sql # rehearse against production data
# ./rehearse-014.sh # synthetic: build a pre-014 DB covering every case
#
# It asserts:
# 1. up: {(event,type) downloadable BEFORE} == {(event,type) downloadable AFTER}
# 2. down: the old ready flags come back identical (so a rollback is actually a rollback)
# 3. up again: still identical (so a roll-forward after a rollback is safe)
#
# It NEVER touches your real database. Everything runs in a throwaway container.
#
# NOTE ON ROLLBACK (see the runbook header in 014_export_epoch.up.sql): sqlx runs migrations before
# serving and errors with VersionMissing on an unknown version, so the PREVIOUS image will not boot
# against the 014 schema — it crash-loops. Rolling back means running 014_export_epoch.down.sql BY
# HAND FIRST, then deploying the old image. Assertion 2 is what makes that safe. Rehearse it before
# you need it, not while the party is happening.
set -euo pipefail
DUMP="${1:-}"
MIG_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../migrations" && pwd)"
CONTAINER="eventsnap-rehearse-014"
PGPASSWORD="rehearse"
DB="eventsnap_rehearsal"
cleanup() { docker rm -f "$CONTAINER" > /dev/null 2>&1 || true; }
trap cleanup EXIT
cleanup
echo "▸ Starting a throwaway postgres:16 (nothing here touches your real DB)…"
docker run --rm -d --name "$CONTAINER" \
-e POSTGRES_PASSWORD="$PGPASSWORD" -e POSTGRES_DB="$DB" \
postgres:16-alpine > /dev/null
psql() { docker exec -i -e PGPASSWORD="$PGPASSWORD" "$CONTAINER" psql -U postgres -d "$DB" -v ON_ERROR_STOP=1 "$@"; }
for _ in $(seq 1 30); do
if docker exec "$CONTAINER" pg_isready -U postgres > /dev/null 2>&1; then break; fi
sleep 1
done
if [[ -n "$DUMP" ]]; then
echo "▸ Restoring $DUMP"
psql -q < "$DUMP"
else
echo "▸ No dump given — building a synthetic pre-014 DB (migrations 001→013)…"
for f in "$MIG_DIR"/0[0-1][0-9]_*.up.sql; do
[[ "$(basename "$f")" == 014_* ]] && continue
psql -q < "$f"
done
# Every state the backfill has to classify. If 014 mishandles ANY of these, assertion 1 fails.
# Every state the backfill has to classify. export_job is UNIQUE (event_id, type), so each event
# has at most one job per type — the cases are distinguished by event, not by piling up rows.
# A: released, both flags, both done → both stay downloadable
# B: released, ZIP flag only → ZIP downloadable, HTML not (a half-built keepsake)
# C: released, jobs done, NO flags → NOT downloadable (a superseded worker's finished job:
# the exact state the old ready-flag model used to leak)
# D: never released, jobs done → NOT downloadable (reopened, or built before release)
# E: released, ZIP flag set but the job is FAILED/RUNNING → NOT downloadable (flag/job disagree,
# which the old two-source model made representable at all)
echo "▸ Seeding: released+both, zip-only, done-but-stale, unreleased, flag/job-disagreement…"
psql -q <<'SQL'
INSERT INTO event (id, slug, name, export_released_at, export_zip_ready, export_html_ready) VALUES
('aaaaaaaa-0000-0000-0000-000000000001','ev-a','A', now(), TRUE, TRUE),
('aaaaaaaa-0000-0000-0000-000000000002','ev-b','B', now(), TRUE, FALSE),
('aaaaaaaa-0000-0000-0000-000000000003','ev-c','C', now(), FALSE, FALSE),
('aaaaaaaa-0000-0000-0000-000000000004','ev-d','D', NULL, FALSE, FALSE),
('aaaaaaaa-0000-0000-0000-000000000005','ev-e','E', now(), TRUE, TRUE);
INSERT INTO export_job (event_id, type, status, progress_pct, file_path, release_seq)
SELECT e.id, t::export_type, 'done'::export_status, 100, '/x/' || e.slug || '.' || t, 0
FROM event e CROSS JOIN (VALUES ('zip'),('html')) AS v(t);
-- E: the flags claim ready, the jobs say otherwise. Old model required BOTH, so neither is
-- downloadable — the migration must not "trust the flag" and resurrect them.
UPDATE export_job SET status = 'failed', file_path = NULL, progress_pct = 40
WHERE event_id = 'aaaaaaaa-0000-0000-0000-000000000005' AND type = 'zip';
UPDATE export_job SET status = 'running', file_path = NULL, progress_pct = 70
WHERE event_id = 'aaaaaaaa-0000-0000-0000-000000000005' AND type = 'html';
SQL
fi
echo "▸ Snapshotting what is downloadable under the OLD model…"
psql -q <<'SQL'
CREATE TABLE _rehearsal_before AS
SELECT j.event_id, j.type
FROM export_job j JOIN event e ON e.id = j.event_id
WHERE e.export_released_at IS NOT NULL
AND j.status = 'done'
AND ((j.type = 'zip' AND e.export_zip_ready) OR (j.type = 'html' AND e.export_html_ready));
-- For assertion 2: the exact flags a rollback has to reproduce.
CREATE TABLE _rehearsal_flags AS
SELECT id, export_zip_ready, export_html_ready FROM event;
SQL
before=$(psql -tAc "SELECT count(*) FROM _rehearsal_before")
echo "$before (event,type) pair(s) downloadable before."
# The assertion. A symmetric difference of zero means the backfill carried the old notion of
# "downloadable" across EXACTLY: nothing gained (a stale keepsake resurrected), nothing lost (a
# guest's keepsake silently 404s).
assert_up_preserves() {
local label="$1" diff
diff=$(psql -tAc "
SELECT count(*) FROM (
(SELECT event_id, type FROM _rehearsal_before
EXCEPT SELECT event_id, type FROM export_current WHERE status = 'done')
UNION ALL
(SELECT event_id, type FROM export_current WHERE status = 'done'
EXCEPT SELECT event_id, type FROM _rehearsal_before)
) d")
if [[ "$diff" != "0" ]]; then
echo "✗ FAIL ($label): $diff (event,type) pair(s) changed downloadability across the migration."
psql -c "
(SELECT 'LOST — guests can no longer download this' AS problem, event_id, type FROM _rehearsal_before
EXCEPT ALL SELECT 'LOST — guests can no longer download this', event_id, type FROM export_current WHERE status='done')
UNION ALL
(SELECT 'GAINED — a stale keepsake became downloadable', event_id, type FROM export_current WHERE status='done'
EXCEPT ALL SELECT 'GAINED — a stale keepsake became downloadable', event_id, type FROM _rehearsal_before)"
exit 1
fi
echo "$label: downloadability preserved exactly ($before pair(s))."
}
echo "▸ Applying 014 (up)…"
psql -q < "$MIG_DIR/014_export_epoch.up.sql"
assert_up_preserves "up"
echo "▸ Applying 014 (down) — the rollback path…"
psql -q < "$MIG_DIR/014_export_epoch.down.sql"
# The down migration is an inverse UP TO DRIFT, not a bit-exact inverse — deliberately.
#
# The old model let a ready flag disagree with its job row (flag TRUE over a running/failed job):
# the flag was a CACHED copy of a derivation, and keeping a cache in agreement with its source
# across concurrent workers is precisely what it kept failing at. The down migration re-derives the
# flags from the epoch state, so such a pair comes back FALSE. That HEALS the drift rather than
# faithfully restoring corruption, and it costs nothing: a flag over a non-done job had no file to
# serve (file_path is NULL until the worker finishes), so the old code 404'd on it anyway.
#
# What a rollback must NOT do is drop a flag for a keepsake that was genuinely downloadable
# (flag set AND the job actually done). That is the assertion.
lost=$(psql -tAc "
SELECT count(*) FROM _rehearsal_before b
WHERE NOT EXISTS (
SELECT 1 FROM event e
WHERE e.id = b.event_id
AND ((b.type = 'zip' AND e.export_zip_ready) OR (b.type = 'html' AND e.export_html_ready)))")
if [[ "$lost" != "0" ]]; then
echo "✗ FAIL (down): $lost downloadable keepsake(s) lost their ready flag — the rollback is LOSSY."
psql -c "
SELECT b.event_id, b.type, 'was downloadable, flag not restored' AS problem
FROM _rehearsal_before b
WHERE NOT EXISTS (
SELECT 1 FROM event e
WHERE e.id = b.event_id
AND ((b.type = 'zip' AND e.export_zip_ready) OR (b.type = 'html' AND e.export_html_ready)))"
exit 1
fi
echo "✓ down: every downloadable keepsake kept its flag — the old image sees a working state."
healed=$(psql -tAc "
SELECT count(*) FROM event e JOIN _rehearsal_flags f ON f.id = e.id
WHERE (e.export_zip_ready, e.export_html_ready) IS DISTINCT FROM (f.export_zip_ready, f.export_html_ready)")
if [[ "$healed" != "0" ]]; then
echo " $healed event(s) came back with a CLEARED flag that had drifted from its job row."
echo " Not a loss — those had no file to serve. The rollback normalises them; the old code"
echo " will rebuild on the next release. Listing them so the behaviour is not a surprise:"
psql -c "SELECT e.id, f.export_zip_ready AS was_zip, e.export_zip_ready AS now_zip,
f.export_html_ready AS was_html, e.export_html_ready AS now_html
FROM event e JOIN _rehearsal_flags f ON f.id = e.id
WHERE (e.export_zip_ready, e.export_html_ready)
IS DISTINCT FROM (f.export_zip_ready, f.export_html_ready)"
fi
echo "▸ Re-applying 014 (up) — roll forward after a rollback…"
psql -q < "$MIG_DIR/014_export_epoch.up.sql"
assert_up_preserves "up (after rollback)"
echo
echo "✓ Rehearsal passed. 014 is safe to deploy against this dataset."
[[ -z "$DUMP" ]] && echo " (synthetic data — re-run with a real pg_dump before you deploy for real)"
exit 0

View File

@@ -1,8 +1,8 @@
use std::time::Duration;
use axum::Json;
use axum::extract::State;
use axum::http::{HeaderMap, StatusCode};
use axum::Json;
use chrono::Utc;
use rand::Rng;
use serde::{Deserialize, Serialize};
@@ -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;
@@ -37,18 +38,25 @@ pub async fn join(
Json(body): Json<JoinRequest>,
) -> Result<(StatusCode, Json<JoinResponse>), AppError> {
let ip = client_ip(&headers, "unknown");
let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
let join_rate_on = config::get_bool(&state.config_cache, "join_rate_enabled", true).await;
if rate_limits_on
&& join_rate_on
&& !state
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 {
// 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:{ip}"), 5, Duration::from_secs(60))
{
return Err(AppError::TooManyRequests(
"Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
None,
));
.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();
@@ -81,23 +89,12 @@ 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?;
// The pre-check above is racy: two simultaneous joins with the same name can both
// pass it, and the DB's unique index then rejects the loser. Map that unique
// violation to the same clean 409 the pre-check returns, not a generic 500.
let user = match User::create(&state.pool, event.id, display_name, &pin_hash).await {
Ok(u) => u,
Err(sqlx::Error::Database(db)) if db.is_unique_violation() => {
return Err(AppError::Conflict(format!(
"Der Name \"{}\" ist bereits vergeben.",
display_name
)));
}
Err(e) => return Err(e.into()),
};
let user = User::create(&state.pool, event.id, display_name, &pin_hash).await?;
let token = jwt::create_token(
user.id,
@@ -135,18 +132,6 @@ pub struct RecoverResponse {
pub user_id: Uuid,
}
/// A real cost-12 bcrypt hash of a fixed dummy value, computed once and cached. Used
/// to run a constant-time-ish verify on the "unknown display name" branch of
/// [`recover`], so that branch costs the same as a real (user-exists) verify and can't
/// be told apart by timing.
fn dummy_pin_hash() -> &'static str {
static HASH: std::sync::OnceLock<String> = std::sync::OnceLock::new();
HASH.get_or_init(|| {
bcrypt::hash("eventsnap-dummy-not-a-real-pin", 12)
.expect("bcrypt hashing of a static dummy value cannot fail")
})
}
pub async fn recover(
State(state): State<AppState>,
headers: HeaderMap,
@@ -160,8 +145,8 @@ pub async fn recover(
// every 15 minutes, indefinitely. 5 attempts per 15 minutes per (IP, name)
// softens that into a real cost.
let ip = client_ip(&headers, "unknown");
let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
let recover_rate_on = config::get_bool(&state.config_cache, "recover_rate_enabled", true).await;
let rate_limits_on = config::get_bool(&state.pool, "rate_limits_enabled", true).await;
let recover_rate_on = config::get_bool(&state.pool, "recover_rate_enabled", true).await;
if rate_limits_on && recover_rate_on {
let name_key = display_name.to_lowercase();
if !state.rate_limiter.check(
@@ -180,36 +165,32 @@ pub async fn recover(
.await?
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
let users = User::find_by_event_and_name(&state.pool, event.id, display_name).await?;
let users =
User::find_by_event_and_name(&state.pool, event.id, display_name).await?;
if users.is_empty() {
// No user with this name. Run a throwaway bcrypt verify so this branch takes
// the same time as the user-exists path, and return the SAME error as a wrong
// PIN — so "no such name" and "wrong PIN" are indistinguishable by response or
// timing. Display names are already public on the feed, but this still closes
// the /recover enumeration + timing oracle.
let _ = bcrypt::verify(&body.pin, dummy_pin_hash());
return Err(AppError::Unauthorized("PIN ist falsch.".into()));
return Err(AppError::NotFound(
"Kein Benutzer mit diesem Namen gefunden.".into(),
));
}
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
@@ -244,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)"
);
}
}
@@ -266,10 +253,6 @@ pub struct AdminLoginRequest {
#[derive(Serialize)]
pub struct AdminLoginResponse {
pub jwt: String,
/// The admin's user id + display name, so the client can populate a real identity
/// (own-post affordances, a name on the Account page) instead of a blank session.
pub user_id: Uuid,
pub display_name: String,
}
pub async fn admin_login(
@@ -288,14 +271,30 @@ 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");
let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
let admin_rate_on =
config::get_bool(&state.config_cache, "admin_login_rate_enabled", true).await;
if rate_limits_on
&& admin_rate_on
&& !state
.rate_limiter
.check(format!("admin_login:{ip}"), 5, Duration::from_secs(60))
// 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
&& !state.rate_limiter.check(
format!("admin_login:{ip}"),
5,
Duration::from_secs(60),
)
{
return Err(AppError::TooManyRequests(
"Zu viele Anmeldeversuche. Bitte warte kurz und versuche es erneut.".into(),
@@ -303,7 +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");
@@ -329,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)
@@ -356,88 +358,13 @@ pub async fn admin_login(
let expires_at = Utc::now() + chrono::Duration::days(1);
Session::create(&state.pool, admin_user.id, &token_hash, expires_at).await?;
Ok(Json(AdminLoginResponse {
jwt: token,
user_id: admin_user.id,
display_name: admin_user.display_name,
}))
Ok(Json(AdminLoginResponse { jwt: token }))
}
pub async fn logout(State(state): State<AppState>, auth: AuthUser) -> Result<StatusCode, AppError> {
Session::delete_by_token_hash(&state.pool, &auth.token_hash).await?;
Ok(StatusCode::NO_CONTENT)
}
/// "Sign out everywhere" — revoke every session for the caller, not just the current one.
/// A single leaked/kept-alive device (a shared phone, a laptop recovered via PIN) can then
/// be cut from any of the user's devices.
pub async fn logout_all(
pub async fn logout(
State(state): State<AppState>,
auth: AuthUser,
) -> Result<StatusCode, AppError> {
Session::delete_all_for_user(&state.pool, auth.user_id).await?;
Ok(StatusCode::NO_CONTENT)
}
#[derive(Deserialize)]
pub struct PinResetRequestBody {
pub display_name: String,
}
/// A guest who forgot their PIN asks a host to reset it in-app. Unauthenticated (they
/// can't log in without the PIN) and rate-limited. Always returns 204 regardless of
/// whether the name exists, so it can't enumerate display names beyond what the public
/// feed already exposes.
pub async fn request_pin_reset(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<PinResetRequestBody>,
) -> Result<StatusCode, AppError> {
let display_name = body.display_name.trim();
let ip = client_ip(&headers, "unknown");
let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
if rate_limits_on {
let name_key = display_name.to_lowercase();
if !state.rate_limiter.check(
format!("pin_reset_req:{ip}:{name_key}"),
3,
Duration::from_secs(15 * 60),
) {
return Err(AppError::TooManyRequests(
"Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
None,
));
}
}
if display_name.is_empty() {
return Ok(StatusCode::NO_CONTENT);
}
// Single statement so the existing-name and unknown-name paths do IDENTICAL work
// (same event+user index scans, an INSERT that matches 0 rows for an unknown name) —
// no timing oracle despite the always-204 contract. Admins recover via password, so
// they're excluded from the join and never get a reset request queued.
let _ = sqlx::query(
"INSERT INTO pin_reset_request (event_id, user_id)
SELECT e.id, u.id
FROM event e
JOIN \"user\" u
ON u.event_id = e.id
AND lower(u.display_name) = lower($2)
AND u.role <> 'admin'
WHERE e.slug = $1
ON CONFLICT (user_id) DO NOTHING",
)
.bind(&state.config.event_slug)
.bind(display_name)
.execute(&state.pool)
.await;
// Broadcast unconditionally (carries no per-name info) so an online host's request
// badge updates without revealing whether the name existed.
let _ = state
.sse_tx
.send(crate::state::SseEvent::new("pin-reset-requested", "{}"));
Session::delete_by_token_hash(&state.pool, &auth.token_hash).await?;
Ok(StatusCode::NO_CONTENT)
}

View File

@@ -46,20 +46,10 @@ pub fn create_token(
}
pub fn verify_token(token: &str, secret: &str) -> Result<Claims, jsonwebtoken::errors::Error> {
// We deliberately do NOT enforce the JWT's own `exp`. The authoritative session
// lifetime lives in the `session` row (`expires_at > NOW()`), which SLIDES forward on
// every authenticated request (see `Session::touch_and_renew`). Enforcing the JWT's
// fixed +Nd `exp` here would hard-log-out an actively-used client on day N+1 even
// though its session was renewed — the "30-day cliff" from the review. With server-
// side sliding sessions, the token is a signature-checked bearer credential and its
// lifetime is revocable (logout / expiry / ban all delete the row), which is strictly
// stronger than a stateless non-revocable exp.
let mut validation = Validation::default();
validation.validate_exp = false;
let data = jsonwebtoken::decode::<Claims>(
token,
&DecodingKey::from_secret(secret.as_bytes()),
&validation,
&Validation::default(),
)?;
Ok(data.claims)
}

View File

@@ -13,10 +13,6 @@ pub struct AuthUser {
pub user_id: Uuid,
pub event_id: Uuid,
pub role: UserRole,
/// Live ban flag. Banned users keep *read* access (per USER_JOURNEYS §10), so
/// the base extractor does NOT reject them — write handlers and the
/// Require{Host,Admin} extractors enforce the ban instead.
pub is_banned: bool,
pub token_hash: String,
}
@@ -37,51 +33,49 @@ impl FromRequestParts<AppState> for AuthUser {
.strip_prefix("Bearer ")
.ok_or_else(|| AppError::Unauthorized("Ungültiges Token-Format.".into()))?;
// Verify the JWT's signature. Expiry is deliberately NOT enforced here (see
// `jwt::verify_token`) — the authoritative, sliding session lifetime lives in the
// `session` row read below. We also don't trust the token's role/ban claims; the
// live user row is authoritative, so the decoded claims aren't needed beyond this.
jwt::verify_token(token, &state.config.jwt_secret)
let claims = jwt::verify_token(token, &state.config.jwt_secret)
.map_err(|_| AppError::Unauthorized("Token ungültig oder abgelaufen.".into()))?;
let token_hash = jwt::hash_token(token);
// Single round-trip: resolve the session token to its *live* user row. A
// role/ban stored in the token would survive a demote/ban for the full session
// lifetime (up to 30d), so we always re-read the user (a demoted host loses host
// powers immediately). We do NOT reject banned users here — they retain read
// access by design; writes and host/admin actions enforce the ban downstream.
let user = Session::find_user_by_token_hash(&state.pool, &token_hash)
// 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())
})?;
.ok_or_else(|| AppError::Unauthorized("Sitzung nicht gefunden oder abgelaufen.".into()))?;
// Touch last_seen_at AND slide the session's expiry forward (fire-and-forget), so
// an active client's session renews instead of hitting the fixed 30-day cliff.
// Admin sessions keep their tighter 1-day window (they renew on activity but still
// lapse a day after the admin goes idle). Failures are non-fatal but worth
// surfacing — silent swallowing hides DB connection pressure that would otherwise
// be the first symptom of a real problem.
// 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 touch_hash = token_hash.clone();
let expiry_days = if user.role == UserRole::Admin {
1
} else {
state.config.session_expiry_days
};
let session_id = ctx.session_id;
tokio::spawn(async move {
if let Err(e) = Session::touch_and_renew(&pool, &touch_hash, expiry_days).await {
tracing::warn!(error = ?e, "session touch/renew failed");
if let Err(e) = Session::touch(&pool, session_id).await {
tracing::warn!(error = ?e, session_id = %session_id, "session touch failed");
}
});
Ok(Self {
user_id: user.id,
event_id: user.event_id,
role: user.role,
is_banned: user.is_banned,
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,
})
}
@@ -98,9 +92,6 @@ impl FromRequestParts<AppState> for RequireHost {
state: &AppState,
) -> Result<Self, Self::Rejection> {
let auth = AuthUser::from_request_parts(parts, state).await?;
if auth.is_banned {
return Err(AppError::Forbidden("Du bist gesperrt.".into()));
}
match auth.role {
UserRole::Host | UserRole::Admin => Ok(Self(auth)),
_ => Err(AppError::Forbidden("Nur für Hosts und Admins.".into())),
@@ -119,9 +110,6 @@ impl FromRequestParts<AppState> for RequireAdmin {
state: &AppState,
) -> Result<Self, Self::Rejection> {
let auth = AuthUser::from_request_parts(parts, state).await?;
if auth.is_banned {
return Err(AppError::Forbidden("Du bist gesperrt.".into()));
}
match auth.role {
UserRole::Admin => Ok(Self(auth)),
_ => Err(AppError::Forbidden("Nur für Admins.".into())),

View File

@@ -1,52 +1,11 @@
use std::path::PathBuf;
use anyhow::{Context, Result, anyhow};
use anyhow::{anyhow, Context, Result};
/// Well-known dev JWT secret shipped in `.env.example`. If APP_ENV=production
/// we refuse to start with this value; otherwise we warn loudly.
const DEV_JWT_SECRET_SENTINEL: &str = "dev_secret_do_not_use_in_production_32byteslong_aaaa";
/// A secret is "placeholder-ish" if it's the shipped dev sentinel or still carries
/// the tell-tale scaffolding substrings from `.env.example`. Length alone is not
/// enough — the shipped `change_me_...` placeholder is >32 chars.
fn looks_placeholder(s: &str) -> bool {
let lower = s.to_ascii_lowercase();
s == DEV_JWT_SECRET_SENTINEL
|| lower.contains("change_me")
|| lower.contains("dev_secret")
|| lower.contains("placeholder")
}
/// Enforce secret hygiene. In production every guard is hard-fail: a booting app
/// with a publicly-known signing key is worse than one that refuses to start.
/// Outside production the dev sentinel is tolerated (warned) so local dev is frictionless.
fn validate_secrets(is_prod: bool, jwt_secret: &str, admin_password_hash: &str) -> Result<()> {
if is_prod {
if looks_placeholder(jwt_secret) {
return Err(anyhow!(
"Refusing to start in production with a placeholder JWT_SECRET — \
rotate it (openssl rand -hex 64)."
));
}
if jwt_secret.len() < 32 {
return Err(anyhow!("JWT_SECRET must be at least 32 characters."));
}
if admin_password_hash.is_empty() || looks_placeholder(admin_password_hash) {
return Err(anyhow!(
"Refusing to start in production without a real ADMIN_PASSWORD_HASH — \
generate one (htpasswd -bnBC 12 '' <password> | tr -d ':\\n')."
));
}
} else if jwt_secret == DEV_JWT_SECRET_SENTINEL {
tracing::warn!(
"JWT_SECRET is the dev sentinel — fine for local development, NEVER ship this."
);
} else if jwt_secret.len() < 32 {
return Err(anyhow!("JWT_SECRET must be at least 32 characters."));
}
Ok(())
}
#[derive(Clone, Debug)]
pub struct AppConfig {
pub database_url: String,
@@ -56,148 +15,63 @@ pub struct AppConfig {
pub event_name: String,
pub event_slug: String,
pub media_path: PathBuf,
/// Where export archives are written. MUST be outside `media_path` — that
/// directory is served by a public `ServeDir`, so a predictable archive name
/// under it would leak the whole gallery to anonymous visitors.
pub export_path: PathBuf,
pub app_port: u16,
/// Number of concurrent media compression workers (read once at boot).
pub compression_concurrency: usize,
/// Master switch for the comment feature (env `COMMENTS_ENABLED`, default true).
/// When false the backend rejects new comments and the frontend hides the whole
/// comment UI. Existing comments stay in the DB (hidden), so flipping it back
/// restores them. Boot-time immutable, like `compression_concurrency`.
pub comments_enabled: bool,
/// Default colour theme, used as the fallback when the DB config keys are unset.
/// Runtime overrides live in the `config` table (admin UI); these env vars only
/// seed the initial default. `preset` is an id the frontend knows (e.g.
/// "champagne-gold", "rose", … or "custom"); the two seeds are `#rrggbb` brand +
/// accent colours the whole palette is derived from.
pub default_theme_preset: String,
pub default_theme_primary: String,
pub default_theme_accent: String,
}
/// The shipped default brand/accent seed (champagne gold — matches the hand-tuned
/// ramp in tailwind-theme.css). Kept here so an unset env still yields the current look.
const DEFAULT_THEME_SEED: &str = "#8a6a2b";
impl AppConfig {
pub fn from_env() -> Result<Self> {
let app_env = std::env::var("APP_ENV").unwrap_or_else(|_| "development".to_string());
let app_env =
std::env::var("APP_ENV").unwrap_or_else(|_| "development".to_string());
let is_prod = app_env.eq_ignore_ascii_case("production");
let jwt_secret = std::env::var("JWT_SECRET").context("JWT_SECRET must be set")?;
let admin_password_hash = std::env::var("ADMIN_PASSWORD_HASH").unwrap_or_default();
// 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");
validate_secrets(is_prod, &jwt_secret, &admin_password_hash)?;
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: 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 looks like a dev/placeholder value — fine for local development, \
NEVER ship this to production."
);
}
Ok(Self {
database_url: std::env::var("DATABASE_URL").context("DATABASE_URL must be set")?,
database_url: std::env::var("DATABASE_URL")
.context("DATABASE_URL must be set")?,
jwt_secret,
session_expiry_days: std::env::var("SESSION_EXPIRY_DAYS")
.unwrap_or_else(|_| "30".to_string())
.parse()
.context("SESSION_EXPIRY_DAYS must be a number")?,
admin_password_hash,
event_name: std::env::var("EVENT_NAME").unwrap_or_else(|_| "EventSnap".to_string()),
event_slug: std::env::var("EVENT_SLUG").context("EVENT_SLUG must be set")?,
admin_password_hash: std::env::var("ADMIN_PASSWORD_HASH")
.unwrap_or_default(),
event_name: std::env::var("EVENT_NAME")
.unwrap_or_else(|_| "EventSnap".to_string()),
event_slug: std::env::var("EVENT_SLUG")
.context("EVENT_SLUG must be set")?,
media_path: PathBuf::from(
std::env::var("MEDIA_PATH").unwrap_or_else(|_| "/media".to_string()),
),
export_path: PathBuf::from(
std::env::var("EXPORT_PATH").unwrap_or_else(|_| "/exports".to_string()),
),
app_port: std::env::var("APP_PORT")
.unwrap_or_else(|_| "3000".to_string())
.parse()
.context("APP_PORT must be a number")?,
compression_concurrency: std::env::var("COMPRESSION_WORKER_CONCURRENCY")
.ok()
.and_then(|v| v.parse().ok())
.filter(|&n| n >= 1)
.unwrap_or(2),
comments_enabled: std::env::var("COMMENTS_ENABLED")
.map(|v| {
!matches!(
v.trim().to_ascii_lowercase().as_str(),
"false" | "0" | "no" | "off"
)
})
.unwrap_or(true),
default_theme_preset: std::env::var("THEME_PRESET")
.unwrap_or_else(|_| "champagne-gold".to_string()),
default_theme_primary: std::env::var("THEME_PRIMARY")
.unwrap_or_else(|_| DEFAULT_THEME_SEED.to_string()),
default_theme_accent: std::env::var("THEME_ACCENT")
.unwrap_or_else(|_| DEFAULT_THEME_SEED.to_string()),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
const REAL_SECRET: &str = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2";
const REAL_HASH: &str = "$2y$12$abcdefghijklmnopqrstuv.wxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012";
#[test]
fn prod_rejects_shipped_placeholder_secret() {
// The exact string shipped in `.env` — >32 chars, so it must be caught by
// the substring guard, not the length check.
let err = validate_secrets(true, "change_me_to_a_random_64_byte_hex_string", REAL_HASH);
assert!(
err.is_err(),
"placeholder JWT_SECRET must be rejected in prod"
);
}
#[test]
fn prod_rejects_dev_sentinel_and_short_secret() {
assert!(validate_secrets(true, DEV_JWT_SECRET_SENTINEL, REAL_HASH).is_err());
assert!(validate_secrets(true, "tooshort", REAL_HASH).is_err());
}
#[test]
fn prod_rejects_missing_or_placeholder_admin_hash() {
assert!(validate_secrets(true, REAL_SECRET, "").is_err());
assert!(validate_secrets(true, REAL_SECRET, "$2y$12$placeholder_replace_me").is_err());
}
#[test]
fn prod_accepts_real_secrets() {
assert!(validate_secrets(true, REAL_SECRET, REAL_HASH).is_ok());
}
#[test]
fn non_prod_tolerates_dev_sentinel() {
assert!(validate_secrets(false, DEV_JWT_SECRET_SENTINEL, "").is_ok());
}
#[test]
fn non_prod_still_rejects_short_non_sentinel_secret() {
assert!(validate_secrets(false, "tooshort", "").is_err());
}
#[test]
fn placeholder_detection_is_case_insensitive() {
// looks_placeholder lowercases before matching — an upper/mixed-case
// placeholder must still be rejected in prod.
assert!(
validate_secrets(true, "CHANGE_ME_TO_A_RANDOM_64_BYTE_HEX_STRING", REAL_HASH).is_err()
);
assert!(validate_secrets(true, REAL_SECRET, "$2Y$12$PLACEHOLDER_replace_me").is_err());
}
#[test]
fn prod_len_boundary_at_32() {
// Exactly 32 non-placeholder chars is the minimum accepted; 31 is rejected.
const LEN_32: &str = "abcdefghijklmnopqrstuvwxyz012345";
const LEN_31: &str = "abcdefghijklmnopqrstuvwxyz01234";
assert_eq!(LEN_32.len(), 32);
assert_eq!(LEN_31.len(), 31);
assert!(validate_secrets(true, LEN_32, REAL_HASH).is_ok());
assert!(validate_secrets(true, LEN_31, REAL_HASH).is_err());
}
}

View File

@@ -1,6 +1,6 @@
use anyhow::{Context, Result};
use sqlx::PgPool;
use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;
const DEFAULT_MAX_CONNECTIONS: u32 = 10;

View File

@@ -6,19 +6,10 @@ pub enum AppError {
BadRequest(String),
Unauthorized(String),
Forbidden(String),
/// Uploads are temporarily locked (event closed / gallery released). Distinct from
/// `Forbidden` so the client can tell this REVERSIBLE 403 apart from a permanent one
/// (banned user, quota): the queued blob is kept and retried if the host reopens,
/// instead of being purged like a genuinely-terminal rejection.
UploadsLocked(String),
NotFound(String),
Conflict(String),
/// Second field: optional retry-after seconds to include in the response.
TooManyRequests(String, Option<u64>),
/// Per-user storage quota exhausted. Distinct from `TooManyRequests` (rate limit) so
/// the client can treat it as *terminal* (413, no retry) instead of backing off and
/// retrying a permanently-failing upload forever.
QuotaExceeded(String),
Internal(anyhow::Error),
}
@@ -28,11 +19,9 @@ impl AppError {
Self::BadRequest(_) => (StatusCode::BAD_REQUEST, "bad_request"),
Self::Unauthorized(_) => (StatusCode::UNAUTHORIZED, "unauthorized"),
Self::Forbidden(_) => (StatusCode::FORBIDDEN, "forbidden"),
Self::UploadsLocked(_) => (StatusCode::FORBIDDEN, "uploads_locked"),
Self::NotFound(_) => (StatusCode::NOT_FOUND, "not_found"),
Self::Conflict(_) => (StatusCode::CONFLICT, "conflict"),
Self::TooManyRequests(..) => (StatusCode::TOO_MANY_REQUESTS, "too_many_requests"),
Self::QuotaExceeded(_) => (StatusCode::PAYLOAD_TOO_LARGE, "quota_exceeded"),
Self::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, "internal_error"),
}
}
@@ -42,11 +31,9 @@ impl AppError {
Self::BadRequest(msg)
| Self::Unauthorized(msg)
| Self::Forbidden(msg)
| Self::UploadsLocked(msg)
| Self::NotFound(msg)
| Self::Conflict(msg) => msg.clone(),
Self::TooManyRequests(msg, _) => msg.clone(),
Self::QuotaExceeded(msg) => msg.clone(),
Self::Internal(err) => {
tracing::error!("internal error: {err:#}");
"Ein interner Fehler ist aufgetreten.".to_string()
@@ -75,11 +62,10 @@ impl IntoResponse for AppError {
}
let mut resp = (status, axum::Json(body)).into_response();
if let Some(secs) = retry_after_secs
&& let Ok(val) = axum::http::HeaderValue::from_str(&secs.to_string())
{
resp.headers_mut()
.insert(axum::http::header::RETRY_AFTER, val);
if let Some(secs) = retry_after_secs {
if let Ok(val) = axum::http::HeaderValue::from_str(&secs.to_string()) {
resp.headers_mut().insert(axum::http::header::RETRY_AFTER, val);
}
}
resp
}

View File

@@ -1,10 +1,11 @@
use std::collections::HashMap;
use std::time::Duration;
use axum::Json;
use axum::extract::{Query, State};
use axum::extract::State;
use axum::http::{HeaderMap, StatusCode};
use axum::Json;
use serde::{Deserialize, Serialize};
use sysinfo::System;
use crate::auth::middleware::RequireAdmin;
use crate::error::AppError;
@@ -45,17 +46,19 @@ pub async fn get_stats(
.await?
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
let (user_count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM \"user\" WHERE event_id = $1")
.bind(event.id)
.fetch_one(&state.pool)
.await?;
let (upload_count,): (i64,) =
sqlx::query_as("SELECT COUNT(*) FROM upload WHERE event_id = $1 AND deleted_at IS NULL")
let (user_count,): (i64,) =
sqlx::query_as("SELECT COUNT(*) FROM \"user\" WHERE event_id = $1")
.bind(event.id)
.fetch_one(&state.pool)
.await?;
let (upload_count,): (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM upload WHERE event_id = $1 AND deleted_at IS NULL",
)
.bind(event.id)
.fetch_one(&state.pool)
.await?;
let (comment_count,): (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM comment c
JOIN upload u ON u.id = c.upload_id
@@ -65,12 +68,23 @@ pub async fn get_stats(
.fetch_one(&state.pool)
.await?;
// Disk usage from the shared cache (unknown mount → zeros, same as before).
let (disk_total, disk_free) = state
.disk_cache
.snapshot(&state.config.media_path)
.map(|d| (d.total, d.free))
.unwrap_or((0, 0));
// Disk usage via sysinfo
let mut sys = System::new();
sys.refresh_all();
let media_path = state.config.media_path.to_string_lossy().to_string();
let (disk_total, disk_free) = sysinfo::Disks::new_with_refreshed_list()
.iter()
.find(|d| media_path.starts_with(d.mount_point().to_string_lossy().as_ref()))
.map(|d| (d.total_space(), d.available_space()))
.unwrap_or_else(|| {
// Fall back to the root disk
sysinfo::Disks::new_with_refreshed_list()
.iter()
.find(|d| d.mount_point().to_string_lossy() == "/")
.map(|d| (d.total_space(), d.available_space()))
.unwrap_or((0, 0))
});
let disk_used = disk_total.saturating_sub(disk_free);
@@ -88,17 +102,14 @@ pub async fn get_config(
State(state): State<AppState>,
RequireAdmin(_auth): RequireAdmin,
) -> Result<Json<HashMap<String, String>>, AppError> {
let rows: Vec<(String, String)> = sqlx::query_as("SELECT key, value FROM config ORDER BY key")
.fetch_all(&state.pool)
.await?;
let rows: Vec<(String, String)> =
sqlx::query_as("SELECT key, value FROM config ORDER BY key")
.fetch_all(&state.pool)
.await?;
Ok(Json(rows.into_iter().collect()))
}
/// Documents the wire shape of `PATCH /admin/config` (a flat `{key: value}` object).
/// `patch_config` extracts the `HashMap` directly rather than going through this newtype, so it is
/// never constructed in Rust — it stays as the serde-derived description of the request body.
#[allow(dead_code)]
#[derive(Deserialize)]
pub struct PatchConfigRequest(pub HashMap<String, String>);
@@ -110,18 +121,15 @@ pub async fn patch_config(
// Numeric keys validated as f64; boolean keys validated as truthy strings; the
// privacy note is free text. Splitting these explicitly is verbose but makes the
// failure mode for typos obvious (`Unbekannter Schlüssel: ...`).
// (key, integer_only, min, max). Ranges reject values that `parse::<f64>` would
// accept but that silently revert to the hardcoded default at read time
// (get_usize/get_i64 can't parse negatives/NaN/fractionals). `compression_concurrency`
// is intentionally absent — it's read once at boot, so a live edit was a no-op.
const NUMERIC_SPECS: &[(&str, bool, f64, f64)] = &[
("max_image_size_mb", true, 1.0, 1024.0),
("max_video_size_mb", true, 1.0, 10240.0),
("upload_rate_per_hour", true, 1.0, 100_000.0),
("feed_rate_per_min", true, 1.0, 100_000.0),
("export_rate_per_day", true, 1.0, 100_000.0),
("quota_tolerance", false, 0.0, 1.0),
("estimated_guest_count", true, 1.0, 1_000_000.0),
const NUMERIC_KEYS: &[&str] = &[
"max_image_size_mb",
"max_video_size_mb",
"upload_rate_per_hour",
"feed_rate_per_min",
"export_rate_per_day",
"quota_tolerance",
"estimated_guest_count",
"compression_concurrency",
];
const BOOL_KEYS: &[&str] = &[
"rate_limits_enabled",
@@ -129,61 +137,21 @@ pub async fn patch_config(
"feed_rate_enabled",
"export_rate_enabled",
"join_rate_enabled",
// These two per-area rate toggles are HONOURED by their handlers (auth/handlers.rs reads
// `admin_login_rate_enabled` and `recover_rate_enabled`, both defaulting true) but were
// missing from this allowlist — so the switch existed in code and could never be flipped.
"admin_login_rate_enabled",
"recover_rate_enabled",
"quota_enabled",
"storage_quota_enabled",
"upload_count_quota_enabled",
];
const TEXT_KEYS: &[&str] = &[
"privacy_note",
"theme_preset",
"theme_primary",
"theme_accent",
];
const TEXT_KEYS: &[&str] = &["privacy_note"];
const PRIVACY_NOTE_MAX_LEN: usize = 16 * 1024; // 16 KiB free text is plenty
// Preset ids the frontend knows how to render (mirror of PRESETS in
// frontend/src/lib/theme/palette.ts). "custom" means "use the theme_primary/accent
// seeds verbatim". Kept in sync by hand — a new preset must be added in both places.
const THEME_PRESETS: &[&str] = &[
"champagne-gold",
"rose",
"sage",
"dusk-blue",
"classic-silver",
"custom",
];
let mut privacy_note_changed = false;
let mut theme_changed = false;
// Validate every key first so a bad value in the batch can't leave a partial
// update behind — validation must fully precede any write.
for (key, value) in &body {
let key_str = key.as_str();
if let Some(&(_, integer_only, min, max)) =
NUMERIC_SPECS.iter().find(|(k, ..)| *k == key_str)
{
let n = value.trim().parse::<f64>().ok().filter(|n| n.is_finite());
let n = match n {
Some(n) => n,
None => {
return Err(AppError::BadRequest(format!(
"Ungültiger Wert für {key}: muss eine Zahl sein."
)));
}
};
if integer_only && n.fract() != 0.0 {
if NUMERIC_KEYS.contains(&key_str) {
if value.parse::<f64>().is_err() {
return Err(AppError::BadRequest(format!(
"Ungültiger Wert für {key}: muss eine ganze Zahl sein."
)));
}
if n < min || n > max {
return Err(AppError::BadRequest(format!(
"Wert für {key} liegt außerhalb des zulässigen Bereichs ({min}{max})."
"Ungültiger Wert für {key}: muss eine Zahl sein."
)));
}
} else if BOOL_KEYS.contains(&key_str) {
@@ -196,83 +164,42 @@ pub async fn patch_config(
}
}
} else if TEXT_KEYS.contains(&key_str) {
// Count characters, not bytes — the message says "Zeichen" and a
// multi-byte grapheme shouldn't count against the limit multiple times.
if value.chars().count() > PRIVACY_NOTE_MAX_LEN {
if value.len() > PRIVACY_NOTE_MAX_LEN {
return Err(AppError::BadRequest(format!(
"Wert für {key} ist zu lang (max. {PRIVACY_NOTE_MAX_LEN} Zeichen)."
)));
}
match key_str {
"privacy_note" => privacy_note_changed = true,
"theme_preset" => {
if !THEME_PRESETS.contains(&value.trim()) {
return Err(AppError::BadRequest(format!("Ungültiges Theme: {value}.")));
}
theme_changed = true;
}
"theme_primary" | "theme_accent" => {
if !is_hex_color(value.trim()) {
return Err(AppError::BadRequest(format!(
"Ungültige Farbe für {key}: muss #rrggbb sein."
)));
}
theme_changed = true;
}
_ => {}
if key_str == "privacy_note" {
privacy_note_changed = true;
}
} else {
return Err(AppError::BadRequest(format!(
"Unbekannter Konfigurationsschlüssel: {key}"
)));
}
}
// Apply all writes in one transaction — the batch is all-or-nothing.
let mut tx = state.pool.begin().await?;
for (key, value) in &body {
sqlx::query(
"INSERT INTO config (key, value, updated_at) VALUES ($1, $2, NOW())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()",
)
.bind(key)
.bind(value)
.execute(&mut *tx)
.execute(&state.pool)
.await?;
}
tx.commit().await?;
// The config cache must reflect this write on the very next read (tests PATCH then
// immediately assert the new value takes effect). Invalidate synchronously here —
// the TTL is only a backstop and must not be relied on for correctness.
state.config_cache.invalidate();
// Notify all clients that a publicly-readable config value changed so their stores
// (e.g. the privacy note in My Account) refresh without a manual reload.
if privacy_note_changed || theme_changed {
let mut keys: Vec<&str> = Vec::new();
if privacy_note_changed {
keys.push("privacy_note");
}
if theme_changed {
keys.push("theme");
}
if privacy_note_changed {
let _ = state.sse_tx.send(crate::state::SseEvent::new(
"event-updated",
serde_json::json!({ "keys": keys }).to_string(),
serde_json::json!({ "keys": ["privacy_note"] }).to_string(),
));
}
Ok(StatusCode::NO_CONTENT)
}
/// A strict `#rrggbb` hex-colour check (6 hex digits, leading `#`). Deliberately not
/// accepting shorthand/`#rgba` so the value is safe to drop straight into CSS.
fn is_hex_color(s: &str) -> bool {
let bytes = s.as_bytes();
bytes.len() == 7 && bytes[0] == b'#' && bytes[1..].iter().all(|b| b.is_ascii_hexdigit())
}
pub async fn get_export_jobs(
State(state): State<AppState>,
RequireAdmin(_auth): RequireAdmin,
@@ -296,106 +223,53 @@ 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> {
// NOTE: intentionally NOT gated on `is_banned`. A banned user keeps *read* access
// by design (USER_JOURNEYS §10.3, FEATURES: "Can still download the export once
// released — Spec design choice"). The export is read-only, so it stays available
// to them, consistent with the read-only-ban model.
let ticket = state.sse_tickets.issue(auth.token_hash);
Json(serde_json::json!({ "ticket": ticket }))
}
/// 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 path =
resolve_export_file(&state, "zip", "Der ZIP-Export ist noch nicht verfügbar.").await?;
serve_file(path, "Gallery.zip", "application/zip").await
}
let event = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug)
.await?
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
/// Resolve the on-disk path of the CURRENT export generation — readiness check and path lookup in
/// ONE read, through the `export_current` view (migration 014).
///
/// This used to be two statements: the caller checked `event.export_zip_ready`, then this fetched
/// `export_job.file_path`. A reopen landing between them served a keepsake that should have 404'd —
/// the same stale-keepsake class, leaking through the read path, and it existed only because
/// readiness was a stored copy rather than a derivation. `export_current` gives both facts from one
/// consistent snapshot: it yields a row only when the event is released AND the job is `done` at the
/// event's current epoch, so "is it ready" and "which file" can no longer disagree.
async fn resolve_export_file(
state: &AppState,
export_type: &str,
not_ready_msg: &str,
) -> Result<std::path::PathBuf, AppError> {
let file_path: Option<(Option<String>,)> = sqlx::query_as(
"SELECT c.file_path FROM export_current c
JOIN event e ON e.id = c.event_id
WHERE e.slug = $1 AND c.type = $2::export_type AND c.status = 'done'",
)
.bind(&state.config.event_slug)
.bind(export_type)
.fetch_optional(&state.pool)
.await
.map_err(|e| AppError::Internal(e.into()))?;
if !event.export_zip_ready {
return Err(AppError::NotFound(
"Der ZIP-Export ist noch nicht verfügbar.".into(),
));
}
let Some((Some(rel),)) = file_path else {
return Err(AppError::NotFound(not_ready_msg.into()));
};
// `file_path` is stored as `exports/<name>`; the base dir is already `export_path`, so
// join only the file name (defends against any absolute/`..` content too).
let name = std::path::Path::new(&rel)
.file_name()
.ok_or_else(|| AppError::NotFound("Exportdatei nicht gefunden.".into()))?;
let path = state.config.export_path.join(name);
let path = state.config.media_path.join("exports").join("Gallery.zip");
if !path.exists() {
return Err(AppError::NotFound("Exportdatei nicht gefunden.".into()));
}
Ok(path)
serve_file(path, "Gallery.zip", "application/zip").await
}
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 path =
resolve_export_file(&state, "html", "Der HTML-Export ist noch nicht verfügbar.").await?;
let event = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug)
.await?
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
if !event.export_html_ready {
return Err(AppError::NotFound(
"Der HTML-Export ist noch nicht verfügbar.".into(),
));
}
let path = state.config.media_path.join("exports").join("Memories.zip");
if !path.exists() {
return Err(AppError::NotFound("Exportdatei nicht gefunden.".into()));
}
serve_file(path, "Memories.zip", "application/zip").await
}
@@ -405,7 +279,7 @@ async fn serve_file(
content_type: &str,
) -> Result<axum::response::Response, AppError> {
use axum::body::Body;
use axum::http::{Response, StatusCode, header};
use axum::http::{header, Response, StatusCode};
use tokio_util::io::ReaderStream;
let file = tokio::fs::File::open(&path)
@@ -441,19 +315,8 @@ pub async fn export_status(
let released = event.export_released_at.is_some();
// ONE statement: the epoch comparison happens inside the query, against a single snapshot.
// Binding the epoch read by a previous statement would let a release/regeneration commit in
// between and yield `{released: true, zip: locked, html: locked}` — a state that never existed.
//
// Only jobs at the CURRENT epoch are reported. A row left behind by a retired generation (a
// worker superseded mid-run) is meaningless — surfacing its frozen `running`/77% would show a
// progress bar that never moves for a keepsake nobody is building. It reads as "locked" (no
// current job), which is exactly what it is.
let jobs: Vec<(String, String, i16)> = sqlx::query_as(
"SELECT j.type::text, j.status::text, j.progress_pct
FROM export_job j
JOIN event e ON e.id = j.event_id
WHERE e.id = $1 AND j.epoch = e.export_epoch",
"SELECT type::text, status::text, progress_pct FROM export_job WHERE event_id = $1",
)
.bind(event.id)
.fetch_all(&state.pool)
@@ -462,7 +325,9 @@ pub async fn export_status(
let job_status = |type_name: &str| {
jobs.iter()
.find(|(t, _, _)| t == type_name)
.map(|(_, status, pct)| serde_json::json!({ "status": status, "progress_pct": pct }))
.map(|(_, status, pct)| {
serde_json::json!({ "status": status, "progress_pct": pct })
})
.unwrap_or_else(|| serde_json::json!({ "status": "locked", "progress_pct": 0 }))
};
@@ -477,13 +342,13 @@ pub async fn export_status(
/// switch + per-endpoint switch + numeric value, all stored in `config` and read on
/// each request.
async fn enforce_export_rate(state: &AppState, headers: &HeaderMap) -> Result<(), AppError> {
let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
let export_rate_on = config::get_bool(&state.config_cache, "export_rate_enabled", true).await;
let rate_limits_on = config::get_bool(&state.pool, "rate_limits_enabled", true).await;
let export_rate_on = config::get_bool(&state.pool, "export_rate_enabled", true).await;
if !(rate_limits_on && export_rate_on) {
return Ok(());
}
let ip = client_ip(headers, "unknown");
let limit = config::get_usize(&state.config_cache, "export_rate_per_day", 3).await;
let limit = config::get_usize(&state.pool, "export_rate_per_day", 3).await;
if !state
.rate_limiter
.check(format!("export:{ip}"), limit, Duration::from_secs(86400))

View File

@@ -1,8 +1,8 @@
use std::time::Duration;
use axum::Json;
use axum::extract::{Query, State};
use axum::http::HeaderMap;
use axum::Json;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
@@ -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,8 +28,8 @@ pub struct FeedUpload {
pub uploader_name: String,
pub preview_url: Option<String>,
pub thumbnail_url: Option<String>,
/// Big-screen (~2048px) variant for the diashow. Absent until the derivative exists.
pub display_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,
@@ -50,7 +51,6 @@ struct FeedRow {
uploader_name: String,
preview_path: Option<String>,
thumbnail_path: Option<String>,
display_path: Option<String>,
mime_type: String,
caption: Option<String>,
like_count: i64,
@@ -58,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,
@@ -65,10 +92,10 @@ pub async fn feed(
Query(q): Query<FeedQuery>,
) -> Result<Json<FeedResponse>, AppError> {
let ip = client_ip(&headers, "unknown");
let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
let feed_rate_on = config::get_bool(&state.config_cache, "feed_rate_enabled", true).await;
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.config_cache, "feed_rate_per_min", 60).await;
let rate_limit = config::get_usize(&state.pool, "feed_rate_per_min", 60).await;
if !state
.rate_limiter
.check(format!("feed:{ip}"), rate_limit, Duration::from_secs(60))
@@ -82,51 +109,49 @@ pub async fn feed(
let limit = q.limit.unwrap_or(20).min(100);
// Resolve the cursor to a (created_at, id) position. The pair is compared as a
// tuple so ties on created_at break on id — keyset pagination on created_at
// alone would silently drop rows sharing a timestamp across a page boundary.
let (cursor_time, cursor_id) = match q.cursor {
Some(c) => match get_cursor_pos(&state.pool, c).await {
Some((t, id)) => (Some(t), Some(id)),
None => (None, None),
},
None => (None, None),
};
let rows = if let Some(hashtag) = &q.hashtag {
let tag = hashtag.trim().trim_start_matches('#').to_lowercase();
sqlx::query_as::<_, FeedRow>(
"SELECT v.id, v.user_id, v.uploader_name, v.preview_path, v.thumbnail_path,
v.display_path, v.mime_type, v.caption, v.like_count, v.comment_count,
v.created_at
v.mime_type, v.caption, v.like_count, v.comment_count, v.created_at
FROM v_feed v
JOIN upload_hashtag uh ON uh.upload_id = v.id
JOIN hashtag h ON h.id = uh.hashtag_id AND h.tag = $1
WHERE v.event_id = $2
AND ($3::timestamptz IS NULL OR (v.created_at, v.id) < ($3, $4))
ORDER BY v.created_at DESC, v.id DESC
LIMIT $5",
AND ($3::timestamptz IS NULL OR v.created_at < $3)
ORDER BY v.created_at DESC
LIMIT $4",
)
.bind(&tag)
.bind(auth.event_id)
.bind(cursor_time)
.bind(cursor_id)
.bind(
if let Some(cursor) = q.cursor {
get_cursor_time(&state.pool, cursor).await
} else {
None
},
)
.bind(limit + 1)
.fetch_all(&state.pool)
.await?
} else {
sqlx::query_as::<_, FeedRow>(
"SELECT id, user_id, uploader_name, preview_path, thumbnail_path,
display_path, mime_type, caption, like_count, comment_count, created_at
mime_type, caption, like_count, comment_count, created_at
FROM v_feed
WHERE event_id = $1
AND ($2::timestamptz IS NULL OR (created_at, id) < ($2, $3))
ORDER BY created_at DESC, id DESC
LIMIT $4",
AND ($2::timestamptz IS NULL OR created_at < $2)
ORDER BY created_at DESC
LIMIT $3",
)
.bind(auth.event_id)
.bind(cursor_time)
.bind(cursor_id)
.bind(
if let Some(cursor) = q.cursor {
get_cursor_time(&state.pool, cursor).await
} else {
None
},
)
.bind(limit + 1)
.fetch_all(&state.pool)
.await?
@@ -134,48 +159,19 @@ pub async fn feed(
let has_more = rows.len() as i64 > limit;
let rows: Vec<FeedRow> = rows.into_iter().take(limit as usize).collect();
let next_cursor = if has_more {
rows.last().map(|r| r.id)
} else {
None
};
let next_cursor = if has_more { rows.last().map(|r| r.id) } else { None };
// Batch check which uploads the current user has liked
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| {
// Gated media aliases (visibility-checked, direct /media blocked). Emit the
// URL only when the variant actually exists — the URL is what signals the
// client which variant to load.
let preview_url = r
.preview_path
.as_ref()
.map(|_| format!("/api/v1/upload/{}/preview", r.id));
let thumbnail_url = r
.thumbnail_path
.as_ref()
.map(|_| format!("/api/v1/upload/{}/thumbnail", r.id));
let display_url = r
.display_path
.as_ref()
.map(|_| format!("/api/v1/upload/{}/display", r.id));
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,
display_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();
@@ -194,37 +190,29 @@ pub struct DeltaQuery {
pub struct DeltaResponse {
pub uploads: Vec<FeedUpload>,
pub deleted_ids: Vec<Uuid>,
/// Users whose uploads became hidden (banned / uploads_hidden) since `since`. A ban is
/// not a soft-delete, so it never appears in `deleted_ids`; without this, a client that
/// missed the ephemeral `user-hidden` SSE (a reconnecting projector, most acutely) would
/// keep displaying the banned user's already-loaded slides. The client evicts every
/// upload from these users on receipt.
pub hidden_user_ids: Vec<Uuid>,
/// True when the upload query hit `DELTA_LIMIT`: the response carries only the
/// newest slice of the gap, so the client must fall back to a full feed refresh
/// rather than merging (the older missed uploads are absent and unrecoverable
/// via a later delta, which advances `since` past them).
pub truncated: bool,
/// The server's clock at the moment this delta was computed. The client advances its
/// reconnect cursor from THIS, never `new Date()` — a browser clock even seconds fast
/// would otherwise silently skip uploads whose server `created_at` falls in the skew
/// window (they'd never reappear without a hard refresh).
pub server_time: DateTime<Utc>,
/// 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> {
// Rate-limit the delta the same way as the paginated feed. Without this, ~100 clients
// reconnecting at once (post-outage, or a flapping network) each fire an unbounded
// delta fetch — a reconnect stampede. Keyed per-user so one client can't starve others
// behind a shared NAT.
let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
let feed_rate_on = config::get_bool(&state.config_cache, "feed_rate_enabled", true).await;
// 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.config_cache, "feed_rate_per_min", 60).await;
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,
@@ -237,100 +225,55 @@ pub async fn feed_delta(
}
}
// Anchor the next cursor to the DB clock, captured *before* the queries so an upload
// committed during this handler is re-fetched next time rather than skipped (a
// duplicate id merges idempotently on the client; a miss is unrecoverable).
let server_time: DateTime<Utc> = sqlx::query_scalar("SELECT NOW()")
.fetch_one(&state.pool)
.await?;
// 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 };
// Bounded like the paginated feed: a stale `since` could otherwise pull the
// entire event's uploads in one response. If a client hits the cap it should
// fall back to a full feed refresh rather than another delta.
const DELTA_LIMIT: i64 = 200;
// `>= since` (not `>`): `created_at` is not unique, so a strict `>` anchored to the exact
// timestamp of the last-seen upload silently drops a SECOND upload committed in the same
// microsecond — an unrecoverable live-update miss. `>=` re-includes the boundary rows;
// the client merges by id (see the feed-delta handler's `seen` set), so the duplicate is
// harmless while the tied upload is no longer lost. The cursor still advances to the
// response's `server_time`, so this doesn't re-fetch on every subsequent delta.
let rows = sqlx::query_as::<_, FeedRow>(
"SELECT id, user_id, uploader_name, preview_path, thumbnail_path,
display_path, mime_type, caption, like_count, comment_count, created_at
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, id DESC
WHERE event_id = $1 AND created_at > $2
ORDER BY created_at DESC
LIMIT $3",
)
.bind(auth.event_id)
.bind(q.since)
.bind(DELTA_LIMIT)
.bind(since)
.bind(DELTA_LIMIT + 1)
.fetch_all(&state.pool)
.await?;
// Hit the cap => this is only the newest slice of a larger gap. Signal the
// client to full-refresh instead of merging a partial delta.
let truncated = rows.len() as i64 >= DELTA_LIMIT;
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;
// `>=` for the same tie-break reason as the uploads query above; re-signalling an
// already-removed id is idempotent on the client (it filters its list by these ids).
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",
WHERE event_id = $1 AND deleted_at IS NOT NULL AND deleted_at > $2",
)
.bind(auth.event_id)
.bind(q.since)
.fetch_all(&state.pool)
.await?;
// Users hidden since the cursor (ban / uploads_hidden). Uncapped like `deleted_ids` and
// for the same reason — an eviction the client misses is unrecoverable via a later delta.
let hidden_user_ids: Vec<(Uuid,)> = sqlx::query_as(
"SELECT id FROM \"user\"
WHERE event_id = $1 AND uploads_hidden = TRUE
AND uploads_hidden_at IS NOT NULL AND uploads_hidden_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
.as_ref()
.map(|_| format!("/api/v1/upload/{}/preview", r.id)),
thumbnail_url: r
.thumbnail_path
.as_ref()
.map(|_| format!("/api/v1/upload/{}/thumbnail", r.id)),
display_url: r
.display_path
.as_ref()
.map(|_| format!("/api/v1/upload/{}/display", r.id)),
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(),
hidden_user_ids: hidden_user_ids.into_iter().map(|r| r.0).collect(),
truncated,
server_time,
reload_required,
}))
}
@@ -344,11 +287,12 @@ pub async fn hashtags(
State(state): State<AppState>,
auth: AuthUser,
) -> Result<Json<Vec<HashtagCount>>, AppError> {
let rows: Vec<(String, i64)> =
sqlx::query_as("SELECT tag, upload_count FROM v_hashtag_counts WHERE event_id = $1")
.bind(auth.event_id)
.fetch_all(&state.pool)
.await?;
let rows: Vec<(String, i64)> = sqlx::query_as(
"SELECT tag, upload_count FROM v_hashtag_counts WHERE event_id = $1",
)
.bind(auth.event_id)
.fetch_all(&state.pool)
.await?;
Ok(Json(
rows.into_iter()
@@ -357,17 +301,14 @@ pub async fn hashtags(
))
}
/// Resolve a cursor id to its `(created_at, id)` position. Both are needed:
/// `created_at` alone isn't unique, so pagination must break ties on `id` to
/// avoid silently dropping rows that share a timestamp across a page boundary.
async fn get_cursor_pos(pool: &sqlx::PgPool, cursor_id: Uuid) -> Option<(DateTime<Utc>, Uuid)> {
let row: Option<(DateTime<Utc>, Uuid)> =
sqlx::query_as("SELECT created_at, id FROM upload WHERE id = $1")
async fn get_cursor_time(pool: &sqlx::PgPool, cursor_id: Uuid) -> Option<DateTime<Utc>> {
let row: Option<(DateTime<Utc>,)> =
sqlx::query_as("SELECT created_at FROM upload WHERE id = $1")
.bind(cursor_id)
.fetch_optional(pool)
.await
.ok()?;
row
row.map(|r| r.0)
}
async fn get_liked_set(
@@ -378,13 +319,14 @@ async fn get_liked_set(
if upload_ids.is_empty() {
return std::collections::HashSet::new();
}
let rows: Vec<(Uuid,)> =
sqlx::query_as("SELECT upload_id FROM \"like\" WHERE user_id = $1 AND upload_id = ANY($2)")
.bind(user_id)
.bind(upload_ids)
.fetch_all(pool)
.await
.unwrap_or_default();
let rows: Vec<(Uuid,)> = sqlx::query_as(
"SELECT upload_id FROM \"like\" WHERE user_id = $1 AND upload_id = ANY($2)",
)
.bind(user_id)
.bind(upload_ids)
.fetch_all(pool)
.await
.unwrap_or_default();
rows.into_iter().map(|r| r.0).collect()
}

View File

@@ -1,6 +1,6 @@
use axum::Json;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::Json;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
@@ -12,9 +12,18 @@ use crate::models::event::Event;
use crate::models::session::Session;
use crate::models::upload::Upload;
use crate::models::user::UserRole;
use crate::services::export::Affects;
use crate::state::{AppState, SseEvent};
/// 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)]
@@ -37,24 +46,9 @@ pub struct EventStatus {
pub export_released: bool,
}
/// Count non-banned hosts/admins in the event OTHER than `excluding` — the operators
/// who would remain if `excluding` were demoted or banned. Used to enforce the "an event
/// always keeps at least one operator" floor.
async fn remaining_operators(
state: &AppState,
event_id: Uuid,
excluding: Uuid,
) -> Result<i64, AppError> {
let count = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM \"user\"
WHERE event_id = $1 AND id != $2
AND role IN ('host', 'admin') AND is_banned = FALSE",
)
.bind(event_id)
.bind(excluding)
.fetch_one(&state.pool)
.await?;
Ok(count)
#[derive(Deserialize)]
pub struct BanRequest {
pub hide_uploads: bool,
}
#[derive(Deserialize)]
@@ -110,13 +104,11 @@ pub async fn ban_user(
State(state): State<AppState>,
RequireHost(auth): RequireHost,
Path(user_id): Path<Uuid>,
Json(body): Json<BanRequest>,
) -> Result<StatusCode, AppError> {
// The ban request carries no body — ban always hides (no per-request options).
// Cannot ban yourself or another host/admin
if user_id == auth.user_id {
return Err(AppError::BadRequest(
"Du kannst dich nicht selbst sperren.".into(),
));
return Err(AppError::BadRequest("Du kannst dich nicht selbst sperren.".into()));
}
let target = sqlx::query_as::<_, (String,)>(
"SELECT role::text FROM \"user\" WHERE id = $1 AND event_id = $2",
@@ -127,67 +119,23 @@ pub async fn ban_user(
.await?
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
if target.0 == "admin"
|| (target.0 == "host" && auth.role != crate::models::user::UserRole::Admin)
{
return Err(AppError::Forbidden(
"Du kannst diesen Benutzer nicht sperren.".into(),
));
if target.0 == "admin" || (target.0 == "host" && auth.role != crate::models::user::UserRole::Admin) {
return Err(AppError::Forbidden("Du kannst diesen Benutzer nicht sperren.".into()));
}
// Floor: never leave the event with zero operators. Banning removes the target from
// the active-operator pool, so refuse if they're the last non-banned host/admin.
if target.0 == "host" && remaining_operators(&state, auth.event_id, user_id).await? == 0 {
return Err(AppError::BadRequest(
"Der letzte Host kann nicht gesperrt werden.".into(),
));
}
// Ban ALWAYS hides: a banned user's content is "gone" everywhere. The visibility
// views/queries now also filter on `is_banned` (defense in depth), and we set
// `uploads_hidden` so the existing `user-hidden` live-eviction path fires too. The old
// opt-out checkbox is gone — `hide_uploads` in the request is ignored.
//
// We deliberately do NOT revoke the banned user's sessions. This is a *read-only ban*
// by design (USER_JOURNEYS §10.3): the user keeps read access to the feed and can still
// download the released export — writes and host/admin actions are what the ban blocks
// (enforced live on the write handlers + Require{Host,Admin}). Revoking sessions would
// contradict that model, break the documented "banned guest can still download the
// keepsake" flow, and be ineffective anyway (the user could just /recover a new session).
//
// The ban and the keepsake invalidation are ONE transaction — see `host_delete_upload`.
let mut tx = state.pool.begin().await?;
sqlx::query(
"UPDATE \"user\"
SET is_banned = TRUE, uploads_hidden = TRUE, uploads_hidden_at = NOW()
WHERE id = $1 AND event_id = $2",
"UPDATE \"user\" SET is_banned = TRUE, uploads_hidden = $2 WHERE id = $1",
)
.bind(user_id)
.bind(auth.event_id)
.execute(&mut *tx)
.bind(body.hide_uploads)
.execute(&state.pool)
.await?;
// A ban hides the user's uploads EVERYWHERE — and the keepsake is the place that matters most,
// because it is the copy people keep. The export already filters `is_banned = FALSE`, so a
// FUTURE export excludes them; without this, an ALREADY-RELEASED archive would keep serving a
// banned user's photos forever. Same class as a takedown, so same treatment.
let regen = crate::services::export::invalidate_and_arm(
&mut tx,
&state.config.event_slug,
Affects::Both,
)
.await?;
tx.commit().await?;
if let Some(r) = regen {
start_regen(&state, r);
}
revoke_sessions(&state.pool, user_id, "ban_user").await;
// Evict their content live from every feed + the diashow so it disappears without
// each viewer having to reload. (Their own SSE stream is separately dropped by the
// is_banned revalidation in `sse::stream`, so they stop receiving live pushes while
// retaining plain read access.)
// Tell the banned user's online devices to log out immediately.
let _ = state.sse_tx.send(SseEvent::new(
"user-hidden",
"user-banned",
serde_json::json!({ "user_id": user_id }).to_string(),
));
@@ -195,6 +143,7 @@ pub async fn ban_user(
actor_user_id = %auth.user_id,
target_user_id = %user_id,
event_id = %auth.event_id,
hide_uploads = body.hide_uploads,
"host: ban_user"
);
@@ -206,56 +155,16 @@ pub async fn unban_user(
RequireHost(auth): RequireHost,
Path(user_id): Path<Uuid>,
) -> Result<StatusCode, AppError> {
// Mirror the ban guard: a host may only lift bans on guests, never on hosts or
// admins. Without this a host could override an admin's ban of another host,
// which is asymmetric with `ban_user` and lets a host escalate a peer back in.
let target = sqlx::query_as::<_, (String,)>(
"SELECT role::text FROM \"user\" WHERE id = $1 AND event_id = $2",
)
.bind(user_id)
.bind(auth.event_id)
.fetch_optional(&state.pool)
.await?
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
if target.0 == "admin"
|| (target.0 == "host" && auth.role != crate::models::user::UserRole::Admin)
{
return Err(AppError::Forbidden(
"Du kannst diesen Benutzer nicht entsperren.".into(),
));
}
// Unban restores visibility too: ban set `uploads_hidden = TRUE`, so clearing only
// `is_banned` would leave their content invisible. Clear all three (the timestamp too,
// so a future ban stamps a fresh `uploads_hidden_at` the reconnect delta will replay).
let mut tx = state.pool.begin().await?;
let result = sqlx::query(
"UPDATE \"user\"
SET is_banned = FALSE, uploads_hidden = FALSE, uploads_hidden_at = NULL
WHERE id = $1 AND event_id = $2",
"UPDATE \"user\" SET is_banned = FALSE WHERE id = $1 AND event_id = $2",
)
.bind(user_id)
.bind(auth.event_id)
.execute(&mut *tx)
.execute(&state.pool)
.await?;
if result.rows_affected() == 0 {
return Err(AppError::NotFound("Benutzer nicht gefunden.".into()));
}
// The mirror of the ban case: an unban RESTORES their uploads to the export query, so an
// already-released keepsake is now missing content it should contain. Rebuild it.
let regen = crate::services::export::invalidate_and_arm(
&mut tx,
&state.config.event_slug,
Affects::Both,
)
.await?;
tx.commit().await?;
if let Some(r) = regen {
start_regen(&state, r);
}
tracing::info!(
actor_user_id = %auth.user_id,
target_user_id = %user_id,
@@ -265,55 +174,6 @@ pub async fn unban_user(
Ok(StatusCode::NO_CONTENT)
}
/// Force a keepsake rebuild. The ESCAPE HATCH.
///
/// Without this, a failed or stranded export is terminal at runtime: `release_gallery` refuses an
/// already-released event ("bereits freigegeben"), `recover_exports` only runs at boot, and there is
/// no other retry path — so the host's only options were restarting the container or reopening the
/// event (which unlocks uploads to every guest and discards the release). This is also the recovery
/// path for a keepsake that went stale for any reason we haven't thought of.
pub async fn rebuild_export(
State(state): State<AppState>,
RequireHost(auth): RequireHost,
) -> Result<StatusCode, AppError> {
let mut tx = state.pool.begin().await?;
let regen = crate::services::export::invalidate_and_arm(
&mut tx,
&state.config.event_slug,
Affects::Both,
)
.await?;
tx.commit().await?;
let Some(r) = regen else {
return Err(AppError::BadRequest(
"Die Galerie ist nicht freigegeben — es gibt nichts neu zu erzeugen.".into(),
));
};
// No debounce: this is an explicit, deliberate host action, not a burst.
for export_type in ["zip", "html"] {
let _ = state.sse_tx.send(SseEvent::new(
"export-progress",
serde_json::json!({ "type": export_type, "progress_pct": 0 }).to_string(),
));
}
crate::services::export::spawn_export_jobs(
r.event_id,
r.event_name,
r.epoch,
state.config.comments_enabled,
std::time::Duration::ZERO,
state.pool.clone(),
state.config.media_path.clone(),
state.config.export_path.clone(),
state.sse_tx.clone(),
);
tracing::info!(actor_user_id = %auth.user_id, "host: rebuild_export");
Ok(StatusCode::NO_CONTENT)
}
pub async fn set_role(
State(state): State<AppState>,
RequireHost(auth): RequireHost,
@@ -331,15 +191,14 @@ pub async fn set_role(
_ => {
return Err(AppError::BadRequest(
"Ungültige Rolle. Erlaubt: guest, host.".into(),
));
))
}
};
// Look up the current role so we can apply the host-vs-admin guard. A plain host may
// promote/demote GUESTS only; it may not change any host's or admin's role (see the
// guard below — this closes the demote-a-peer-host→ban/PIN-reset takeover chain, F1).
// Only an admin may change a host's role. Admins may do anything except change
// themselves (blocked above).
// Look up the current role so we can apply the host-vs-admin guard. Hosts may
// promote guests and demote *other* hosts (the user explicitly requested this
// expansion). Hosts may not touch admins. Admins may do anything (except change
// themselves, blocked above).
let target = sqlx::query_as::<_, (String,)>(
"SELECT role::text FROM \"user\" WHERE id = $1 AND event_id = $2",
)
@@ -349,25 +208,9 @@ pub async fn set_role(
.await?
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
// Admins are untouchable by hosts. A plain host also may not demote another
// *host*: without this guard a host could demote a peer host to guest and then
// ban / PIN-reset (→ account-takeover via /recover) them — the ban/pin-reset peer
// guards key off the target's *current* role, so a prior demotion would launder
// past them. Only an admin may change a host's role.
if target.0 == "admin" || (target.0 == "host" && auth.role != UserRole::Admin) {
if target.0 == "admin" {
return Err(AppError::Forbidden(
"Du darfst die Rolle dieses Benutzers nicht ändern.".into(),
));
}
// Floor: demoting the last non-banned host/admin to guest would leave the event with
// no operator. Refuse.
if new_role == "guest"
&& target.0 == "host"
&& remaining_operators(&state, auth.event_id, user_id).await? == 0
{
return Err(AppError::BadRequest(
"Der letzte Host kann nicht zum Gast gemacht werden.".into(),
"Admins können nicht geändert werden.".into(),
));
}
@@ -377,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,
@@ -428,40 +278,28 @@ pub async fn reset_user_pin(
_ => {
return Err(AppError::Forbidden(
"Du darfst die PIN dieses Benutzers nicht zurücksetzen.".into(),
));
))
}
}
let pin: String = format!("{:04}", rand::rng().random_range(0..10000u32));
let pin_hash = 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,
failed_pin_attempts = 0,
pin_locked_until = NULL
WHERE id = $2 AND event_id = $3",
WHERE id = $2",
)
.bind(&pin_hash)
.bind(user_id)
.bind(auth.event_id)
.execute(&state.pool)
.await?;
// A PIN reset means the old credential is compromised/forgotten — revoke every
// existing session so old devices must re-authenticate with the new PIN. This is a
// security-relevant revoke: if it fails, the old sessions stay valid (sessions are
// token- not PIN-bound), so surface the error in logs rather than swallowing it
// silently while reporting success to the host.
if let Err(e) = Session::delete_all_for_user(&state.pool, user_id).await {
tracing::error!(error = ?e, user_id = %user_id, "PIN reset: failed to revoke sessions");
}
// Resolve any pending in-app "I forgot my PIN" request for this user.
let _ = sqlx::query("DELETE FROM pin_reset_request WHERE user_id = $1")
.bind(user_id)
.execute(&state.pool)
.await;
// 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.
@@ -480,125 +318,26 @@ pub async fn reset_user_pin(
Ok(Json(PinResetResponse { pin }))
}
#[derive(Serialize, sqlx::FromRow)]
pub struct PinResetRequestSummary {
pub id: Uuid,
pub user_id: Uuid,
pub display_name: String,
pub created_at: DateTime<Utc>,
}
/// List pending in-app PIN-reset requests so a host can action them (via the existing
/// `reset_user_pin`, which also clears the request).
pub async fn list_pin_reset_requests(
State(state): State<AppState>,
RequireHost(auth): RequireHost,
) -> Result<Json<Vec<PinResetRequestSummary>>, AppError> {
let rows = sqlx::query_as::<_, PinResetRequestSummary>(
"SELECT r.id, r.user_id, u.display_name, r.created_at
FROM pin_reset_request r
JOIN \"user\" u ON u.id = r.user_id
WHERE r.event_id = $1
ORDER BY r.created_at ASC",
)
.bind(auth.event_id)
.fetch_all(&state.pool)
.await?;
Ok(Json(rows))
}
/// Dismiss a PIN-reset request without resetting (e.g. the host couldn't verify the
/// requester's identity).
pub async fn dismiss_pin_reset_request(
State(state): State<AppState>,
RequireHost(auth): RequireHost,
Path(id): Path<Uuid>,
) -> Result<StatusCode, AppError> {
sqlx::query("DELETE FROM pin_reset_request WHERE id = $1 AND event_id = $2")
.bind(id)
.bind(auth.event_id)
.execute(&state.pool)
.await?;
Ok(StatusCode::NO_CONTENT)
}
/// Content changed AFTER the gallery was released — regenerate the keepsake.
///
/// Deleting a photo used to remove it from the live feed but leave it in the already-generated
/// archive FOREVER: the old `if ready { continue }` skip guaranteed the export was never rebuilt.
/// For a takedown ("please remove my photo") that is the one place it most needs to disappear.
///
/// Bumping the epoch (while staying released) retires the current generation, so the stale archive
/// stops being downloadable the instant the delete commits, and a fresh worker rebuilds it without
/// the removed content. The download 404s in the meantime, which is the correct answer — serving
/// the old archive would serve the deleted photo.
/// Start the workers for a regeneration that was armed inside a (now-committed) transaction, and
/// tell every client the current keepsake just became undownloadable.
///
/// The SSE matters: bumping the epoch retires the archive INSTANTLY, so `/export/zip` starts 404ing
/// the moment the change commits. Without a nudge, a guest sitting on `/export` keeps rendering an
/// enabled "download" button for the whole rebuild. Both the nav badge and the export page already
/// refetch `/export/status` on `export-progress`, so a 0% tick is the cheapest correct signal.
pub fn start_regen(state: &AppState, regen: crate::services::export::PendingRegen) {
for export_type in ["zip", "html"] {
let _ = state.sse_tx.send(SseEvent::new(
"export-progress",
serde_json::json!({ "type": export_type, "progress_pct": 0 }).to_string(),
));
}
crate::services::export::spawn_export_jobs(
regen.event_id,
regen.event_name,
regen.epoch,
state.config.comments_enabled,
// Debounced: a takedown pass is a burst, and each request retires the last generation. The
// delay lets superseded workers fail their claim and do zero work instead of each building
// a full archive. See export::REGEN_DEBOUNCE.
crate::services::export::REGEN_DEBOUNCE,
state.pool.clone(),
state.config.media_path.clone(),
state.config.export_path.clone(),
state.sse_tx.clone(),
);
}
pub async fn host_delete_upload(
State(state): State<AppState>,
RequireHost(auth): RequireHost,
Path(upload_id): Path<Uuid>,
) -> Result<StatusCode, AppError> {
let upload = Upload::find_by_id_and_event(&state.pool, upload_id, auth.event_id)
let paths = Upload::soft_delete_in_event(&state.pool, upload_id, auth.event_id)
.await?
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
// The delete and the keepsake invalidation are ONE transaction: if the delete committed and the
// invalidation didn't, the taken-down photo would stay downloadable forever and nothing would
// notice (the keepsake still looks complete, and the host can no longer find the upload to retry).
let mut tx = state.pool.begin().await?;
let deleted = Upload::soft_delete_in_event(&mut tx, upload_id, auth.event_id).await?;
if !deleted {
return Err(AppError::NotFound("Upload nicht gefunden.".into()));
}
let regen = crate::services::export::invalidate_and_arm(
&mut tx,
&state.config.event_slug,
Affects::Both,
)
.await?;
tx.commit().await?;
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(),
));
if let Some(r) = regen {
start_regen(&state, r);
}
tracing::info!(
actor_user_id = %auth.user_id,
event_id = %auth.event_id,
upload_id = %upload.id,
upload_id = %upload_id,
"host: host_delete_upload"
);
@@ -610,29 +349,11 @@ pub async fn host_delete_comment(
RequireHost(auth): RequireHost,
Path(comment_id): Path<Uuid>,
) -> Result<StatusCode, AppError> {
let mut tx = state.pool.begin().await?;
let deleted = Comment::soft_delete_in_event(&mut tx, comment_id, auth.event_id).await?;
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()));
}
// Only the HTML viewer embeds comments — the ZIP is media-only, so it is carried forward rather
// than rebuilt. Otherwise moderating one comment would 404 the photo download for minutes to
// change nothing in it.
let regen = crate::services::export::invalidate_and_arm(
&mut tx,
&state.config.event_slug,
Affects::ViewerOnly,
)
.await?;
tx.commit().await?;
let _ = state.sse_tx.send(SseEvent::new(
"comment-deleted",
serde_json::json!({ "comment_id": comment_id }).to_string(),
));
if let Some(r) = regen {
start_regen(&state, r);
}
tracing::info!(
actor_user_id = %auth.user_id,
event_id = %auth.event_id,
@@ -646,18 +367,14 @@ pub async fn close_event(
State(state): State<AppState>,
RequireHost(_auth): RequireHost,
) -> Result<StatusCode, AppError> {
let result = sqlx::query(
sqlx::query(
"UPDATE event SET uploads_locked_at = NOW() WHERE slug = $1 AND uploads_locked_at IS NULL",
)
.bind(&state.config.event_slug)
.execute(&state.pool)
.await?;
// Only broadcast when this call actually flipped the lock — closing an
// already-closed event is a no-op and shouldn't spam listeners.
if result.rows_affected() > 0 {
let _ = state.sse_tx.send(SseEvent::new("event-closed", "{}"));
}
let _ = state.sse_tx.send(SseEvent::new("event-closed", "{}"));
Ok(StatusCode::NO_CONTENT)
}
@@ -666,29 +383,14 @@ pub async fn open_event(
State(state): State<AppState>,
RequireHost(_auth): RequireHost,
) -> Result<StatusCode, AppError> {
// Reopening invalidates any prior release: the keepsake was snapshotted at release time, so
// allowing new uploads afterwards would silently diverge the live feed from the frozen export.
//
// ONE statement retires the entire export generation. Bumping `export_epoch` in the same write
// that clears `export_released_at` instantly invalidates every in-flight worker, every `done`
// row and all readiness — because readiness is DERIVED from this epoch (migration 014), not
// stored. There is no export_job row to touch and nothing to keep in sync, so this needs no
// transaction. Any worker still streaming holds the old epoch and is now inert: its
// epoch-guarded finalize matches nothing, and it discards its own output.
let result = sqlx::query(
"UPDATE event
SET uploads_locked_at = NULL,
export_released_at = NULL,
export_epoch = export_epoch + 1
WHERE slug = $1 AND (uploads_locked_at IS NOT NULL OR export_released_at IS NOT NULL)",
sqlx::query(
"UPDATE event SET uploads_locked_at = NULL WHERE slug = $1",
)
.bind(&state.config.event_slug)
.execute(&state.pool)
.await?;
if result.rows_affected() > 0 {
let _ = state.sse_tx.send(SseEvent::new("event-opened", "{}"));
}
let _ = state.sse_tx.send(SseEvent::new("event-opened", "{}"));
Ok(StatusCode::NO_CONTENT)
}
@@ -697,69 +399,74 @@ pub async fn release_gallery(
State(state): State<AppState>,
RequireHost(_auth): RequireHost,
) -> Result<StatusCode, AppError> {
// The release claim, the epoch bump, the upload lock and BOTH job rows are written in ONE
// transaction. Two reasons, both of which were live bugs:
let event = Event::find_by_slug(&state.pool, &state.config.event_slug)
.await?
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".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.
//
// 1. Cancellation. This handler used to commit `export_released_at` and only THEN await the
// enqueue. Axum drops the handler future when the client disconnects (closed tab, proxy
// timeout), which left the event released and uploads locked with ZERO export_job rows and
// no workers: downloads 404 forever and the host cannot retry, because release_gallery
// rejects an already-released event ("bereits freigegeben"). Only a restart escaped it.
// Now nothing is committed until every row is written, and the workers are spawned AFTER
// the commit (a detached `tokio::spawn` survives cancellation).
// 2. Atomicity vs. a concurrent reopen. Bumping the epoch in the same statement that sets
// `export_released_at` means no worker and no reader can ever observe "released again but
// the generation hasn't moved on yet" — the window every previous fix kept leaving open.
//
// Release also locks uploads in the same statement (release ⇒ lock), so the export snapshot is
// taken against a frozen upload set. `COALESCE` preserves an earlier explicit lock time.
// 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 claimed: Option<(Uuid, String, i64)> = sqlx::query_as(
"UPDATE event
SET export_released_at = NOW(),
uploads_locked_at = COALESCE(uploads_locked_at, NOW()),
export_epoch = export_epoch + 1
WHERE slug = $1 AND export_released_at IS NULL
RETURNING id, name, export_epoch",
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)
.fetch_optional(&mut *tx)
.execute(&mut *tx)
.await?;
let Some((event_id, event_name, epoch)) = claimed else {
// Distinguish "no such event" from "already released" for a clean error.
let exists = Event::find_by_slug(&state.pool, &state.config.event_slug)
.await?
.is_some();
return Err(if exists {
AppError::BadRequest("Galerie wurde bereits freigegeben.".into())
} else {
AppError::NotFound("Event nicht gefunden.".into())
});
};
if claim.rows_affected() == 0 {
tx.rollback().await.ok();
return Err(AppError::BadRequest(
"Galerie wurde bereits freigegeben.".into(),
));
}
// Arm both types at THIS epoch. No "skip the type that's already ready" check any more: the
// epoch bump above retired every prior generation, so there is nothing to preserve and nothing
// to be fooled by. (That skip was how a stale ready flag used to suppress regeneration.)
crate::services::export::enqueue_jobs_at_epoch(&mut tx, event_id, epoch).await?;
// 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 UPDATE SET status = 'pending', progress_pct = 0,
error_message = NULL, completed_at = NULL",
)
.bind(event.id)
.bind(export_type)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
// Release locks uploads too — tell any open composer to flip to the locked UI live rather than
// discovering it via a rejected upload.
let _ = state.sse_tx.send(SseEvent::new("event-closed", "{}"));
// Detached — survives this handler being cancelled.
// Spawn export workers only after the claim+enqueue is durably committed.
crate::services::export::spawn_export_jobs(
event_id,
event_name,
epoch,
state.config.comments_enabled,
std::time::Duration::ZERO,
event.id,
event.name,
state.pool.clone(),
state.config.media_path.clone(),
state.config.export_path.clone(),
state.sse_tx.clone(),
);

View File

@@ -7,14 +7,14 @@
//! account page loads this once on mount instead of issuing several round trips.
//! - `GET /api/v1/me/quota` — live per-user storage quota estimate.
use axum::Json;
use axum::extract::State;
use axum::Json;
use serde::Serialize;
use crate::auth::middleware::AuthUser;
use crate::error::AppError;
use crate::handlers::upload::compute_storage_quota;
use crate::models::user::{User, UserRole};
use crate::models::user::User;
use crate::services::config;
use crate::state::AppState;
@@ -37,26 +37,12 @@ pub async fn get_quota(
let estimate = compute_storage_quota(&state).await;
// Raw server telemetry (free disk, concurrent uploader count) is staff-only — it
// must never reach a guest, even though the guest upload UI no longer renders it.
// A guest still gets their own `used`/`limit` so enforcement stays transparent to
// the code paths that consume it; only the server-wide fields are zeroed.
let is_staff = matches!(auth.role, UserRole::Host | UserRole::Admin);
Ok(Json(QuotaDto {
enabled: estimate.limit_bytes.is_some(),
used_bytes: user.total_upload_bytes,
limit_bytes: estimate.limit_bytes,
active_uploaders: if is_staff {
estimate.active_uploaders
} else {
0
},
free_disk_bytes: if is_staff {
estimate.free_disk_bytes
} else {
0
},
active_uploaders: estimate.active_uploaders,
free_disk_bytes: estimate.free_disk_bytes,
}))
}
@@ -69,12 +55,9 @@ pub struct MeContextDto {
pub privacy_note: String,
pub quota_enabled: bool,
pub storage_quota_enabled: bool,
/// Uploads are locked (event closed) — the composer should show a locked state live
/// instead of letting a guest compose an upload only to eat a 403.
/// 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,
/// The gallery has been released and the export snapshotted — uploads are permanently
/// closed for this run (release ⇒ lock, and reopening regenerates).
pub gallery_released: bool,
}
pub async fn get_context(
@@ -85,21 +68,13 @@ pub async fn get_context(
.await?
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
let privacy_note = config::get_str(&state.config_cache, "privacy_note", "").await;
let quota_enabled = config::get_bool(&state.config_cache, "quota_enabled", true).await;
let storage_quota_enabled =
config::get_bool(&state.config_cache, "storage_quota_enabled", true).await;
let event =
crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug).await?;
let uploads_locked = event
.as_ref()
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);
let gallery_released = event
.as_ref()
.map(|e| e.export_released_at.is_some())
.unwrap_or(false);
Ok(Json(MeContextDto {
user_id: user.id,
@@ -109,6 +84,5 @@ pub async fn get_context(
quota_enabled,
storage_quota_enabled,
uploads_locked,
gallery_released,
}))
}

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,7 +2,7 @@ pub mod admin;
pub mod feed;
pub mod host;
pub mod me;
pub mod public;
pub mod media;
pub mod social;
pub mod sse;
pub mod test_admin;

View File

@@ -1,44 +0,0 @@
//! Unauthenticated, read-only endpoints safe to expose before a user has joined.
use axum::Json;
use axum::extract::State;
use serde::Serialize;
use crate::services::config;
use crate::state::AppState;
#[derive(Serialize)]
pub struct PublicEventDto {
pub name: String,
pub slug: String,
/// Whether the comment feature is on (env `COMMENTS_ENABLED`). The frontend hides
/// the whole comment UI when false; exposed here so even the pre-auth shell knows.
pub comments_enabled: bool,
/// Active colour theme. `preset` is an id the frontend maps to a palette (or
/// "custom"); `primary`/`accent` are the `#rrggbb` seeds the ramps derive from.
/// Resolved as DB-config override → env default. Public so the theme applies on
/// the very first (pre-auth) paint without a flash.
pub theme_preset: String,
pub theme_primary: String,
pub theme_accent: String,
}
/// Public event identity + presentation config, used by the pre-auth join/recover
/// screens (which event am I joining, what does it look like). Only non-user-scoped
/// fields are exposed, so this is safe without a token. Identity comes straight from
/// instance config; the theme is resolved from the runtime `config` table (admin UI)
/// falling back to the env-seeded default.
pub async fn get_public_event(State(state): State<AppState>) -> Json<PublicEventDto> {
let cache = &state.config_cache;
Json(PublicEventDto {
name: state.config.event_name.clone(),
slug: state.config.event_slug.clone(),
comments_enabled: state.config.comments_enabled,
theme_preset: config::get_str(cache, "theme_preset", &state.config.default_theme_preset)
.await,
theme_primary: config::get_str(cache, "theme_primary", &state.config.default_theme_primary)
.await,
theme_accent: config::get_str(cache, "theme_accent", &state.config.default_theme_accent)
.await,
})
}

View File

@@ -1,8 +1,8 @@
use axum::Json;
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::Json;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde::Deserialize;
use uuid::Uuid;
use crate::auth::middleware::AuthUser;
@@ -12,41 +12,19 @@ use crate::models::hashtag::{self, Hashtag};
use crate::models::upload::Upload;
use crate::state::AppState;
#[derive(Serialize)]
pub struct LikeResponse {
/// The caller's like state *after* this toggle. The client sets `liked_by_me` from
/// this rather than blind-inverting local state — otherwise a second device (same
/// recovered user) drifts, since the `like-update` broadcast only carries `like_count`.
pub liked: bool,
/// Fresh like count, or `null` if the (best-effort) count query hiccuped. The client
/// keeps its current count when this is null rather than adopting a wrong number.
pub like_count: Option<i64>,
}
pub async fn toggle_like(
State(state): State<AppState>,
auth: AuthUser,
Path(upload_id): Path<Uuid>,
) -> Result<Json<LikeResponse>, 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.
) -> Result<StatusCode, AppError> {
// 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()))?;
// NOTE: liking is intentionally allowed while the event is locked. Locking
// ("Event schließen") freezes *new uploads* only — likes, comments and
// browsing stay open (USER_JOURNEYS §9.3, FEATURES capability matrix).
// Try to insert; if conflict, delete (toggle). `liked` = the caller's state afterwards.
// Try to insert; if conflict, delete (toggle)
let result = sqlx::query(
"INSERT INTO \"like\" (upload_id, user_id) VALUES ($1, $2)
ON CONFLICT (upload_id, user_id) DO NOTHING",
@@ -56,8 +34,7 @@ pub async fn toggle_like(
.execute(&state.pool)
.await?;
let liked = result.rows_affected() > 0;
if !liked {
if result.rows_affected() == 0 {
// Already liked — remove
sqlx::query("DELETE FROM \"like\" WHERE upload_id = $1 AND user_id = $2")
.bind(upload_id)
@@ -66,30 +43,13 @@ 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 like
// itself is already committed, so a failed count must not fail the request — but we
// also must NOT broadcast/return a bogus 0 (that would push like_count: 0 to every
// client until the next event). On error we skip the broadcast and return null.
let 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
.ok();
// 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(),
});
if let Some(count) = like_count {
// Broadcast the new count so other clients patch their card. Only `like_count` is
// shared — each client's own `liked_by_me` only changes via its own toggle (which
// now reads it straight from this response).
let _ = state.sse_tx.send(crate::state::SseEvent {
event_type: "like-update".to_string(),
data: serde_json::json!({ "upload_id": upload_id, "like_count": count }).to_string(),
});
}
Ok(Json(LikeResponse { liked, like_count }))
Ok(StatusCode::NO_CONTENT)
}
#[derive(Deserialize, Default)]
@@ -108,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()))?;
@@ -129,27 +90,15 @@ pub async fn add_comment(
Path(upload_id): Path<Uuid>,
Json(body): Json<AddCommentRequest>,
) -> Result<(StatusCode, Json<CommentDto>), AppError> {
// Comments can be disabled instance-wide (env COMMENTS_ENABLED). The frontend hides
// the UI, but gate the API too so a stale client or direct call can't slip one in.
if !state.config.comments_enabled {
return Err(AppError::Forbidden("Kommentare sind deaktiviert.".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()))?;
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()))?;
// NOTE: commenting is intentionally allowed while the event is locked. Locking
// freezes *new uploads* only — likes, comments and browsing stay open
// (USER_JOURNEYS §9.3, FEATURES capability matrix).
let 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();
@@ -159,41 +108,26 @@ pub async fn add_comment(
));
}
// Insert the comment and link its hashtags atomically, so a crash mid-loop
// can't leave a committed comment with only some of its tags indexed.
let comment = Comment::create(&state.pool, upload_id, auth.user_id, text).await?;
// Process hashtags in comment body
let tags = hashtag::extract_hashtags(text);
let mut tx = state.pool.begin().await?;
let comment = Comment::create(&mut *tx, upload_id, auth.user_id, text).await?;
for tag in &tags {
let h = Hashtag::upsert(&mut *tx, auth.event_id, tag).await?;
let h = Hashtag::upsert(&state.pool, auth.event_id, tag).await?;
sqlx::query(
"INSERT INTO comment_hashtag (comment_id, hashtag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING",
)
.bind(comment.id)
.bind(h.id)
.execute(&mut *tx)
.execute(&state.pool)
.await?;
}
tx.commit().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,
@@ -212,10 +146,6 @@ pub async fn delete_comment(
auth: AuthUser,
Path(comment_id): Path<Uuid>,
) -> Result<StatusCode, AppError> {
// Banned users keep read access but cannot mutate (USER_JOURNEYS §10).
if auth.is_banned {
return Err(AppError::Forbidden("Du bist gesperrt.".into()));
}
let comment = Comment::find_by_id(&state.pool, comment_id)
.await?
.ok_or_else(|| AppError::NotFound("Kommentar nicht gefunden.".into()))?;
@@ -224,27 +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 mut tx = state.pool.begin().await?;
let deleted = Comment::soft_delete_in_event(&mut tx, comment_id, auth.event_id).await?;
if !deleted {
return Err(AppError::NotFound("Kommentar nicht gefunden.".into()));
}
// Comments live only in the HTML viewer, so the ZIP is carried forward, not rebuilt.
let regen = crate::services::export::invalidate_and_arm(
&mut tx,
&state.config.event_slug,
crate::services::export::Affects::ViewerOnly,
)
.await?;
tx.commit().await?;
if let Some(r) = regen {
crate::handlers::host::start_regen(&state, r);
}
let _ = state.sse_tx.send(crate::state::SseEvent::new(
"comment-deleted",
serde_json::json!({ "comment_id": comment_id, "upload_id": comment.upload_id }).to_string(),
));
Comment::soft_delete(&state.pool, comment_id).await?;
Ok(StatusCode::NO_CONTENT)
}

View File

@@ -1,14 +1,13 @@
use std::convert::Infallible;
use std::time::Duration;
use axum::Json;
use axum::extract::{Query, State};
use axum::response::sse::{Event, KeepAlive, Sse};
use axum::Json;
use futures::stream::Stream;
use serde::{Deserialize, Serialize};
use tokio_stream::StreamExt;
use tokio_stream::wrappers::BroadcastStream;
use tokio_stream::wrappers::errors::BroadcastStreamRecvError;
use tokio_stream::StreamExt;
use crate::auth::middleware::AuthUser;
use crate::error::AppError;
@@ -23,10 +22,6 @@ pub struct SseQuery {
#[derive(Serialize)]
pub struct StreamTicketResponse {
pub ticket: String,
/// Server clock at mint time — the client seeds its SSE reconnect cursor from this
/// instead of `new Date()`, so a skewed browser clock can't drop uploads. See
/// `DeltaResponse::server_time`.
pub server_time: chrono::DateTime<chrono::Utc>,
}
/// Mint a short-lived single-use SSE ticket. The browser's `EventSource` cannot
@@ -37,15 +32,9 @@ pub struct StreamTicketResponse {
pub async fn issue_ticket(
State(state): State<AppState>,
auth: AuthUser,
) -> Result<Json<StreamTicketResponse>, AppError> {
) -> Json<StreamTicketResponse> {
let ticket = state.sse_tickets.issue(auth.token_hash);
let server_time = sqlx::query_scalar("SELECT NOW()")
.fetch_one(&state.pool)
.await?;
Ok(Json(StreamTicketResponse {
ticket,
server_time,
}))
Json(StreamTicketResponse { ticket })
}
/// SSE stream endpoint. Authenticates via a single-use ticket (see
@@ -59,57 +48,19 @@ pub async fn stream(
.consume(&q.ticket)
.ok_or_else(|| AppError::Unauthorized("Ticket ungültig oder abgelaufen.".into()))?;
// NOTE: this authenticates via ticket→session, not the `AuthUser` extractor. The
// SSE stream is read-only, and under the read-only-ban model (USER_JOURNEYS §10)
// banned users retain read access — so both minting a ticket and holding a stream
// open are intentionally allowed for banned users; only writes are blocked.
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()))?;
let rx = state.sse_tx.subscribe();
let events = BroadcastStream::new(rx).filter_map(|msg| match msg {
let stream = BroadcastStream::new(rx).filter_map(|msg| match msg {
Ok(sse_event) => Some(Ok(Event::default()
.event(sse_event.event_type)
.data(sse_event.data))),
// A consumer that falls behind the broadcast buffer would otherwise silently
// lose events, leaving its feed permanently stale. Instead of dropping the
// gap, tell the client to resync — the frontend responds by running a full
// feed-delta fetch (which reconciles both new uploads and deletions).
Err(BroadcastStreamRecvError::Lagged(n)) => {
tracing::warn!("SSE consumer lagged, dropped {n} event(s); emitting resync");
Some(Ok(Event::default().event("resync").data(n.to_string())))
}
Err(_) => None,
});
// The session is only checked once at open. Re-validate it periodically so a
// logged-out, expired, OR banned session's stream is closed rather than kept alive
// until the client happens to disconnect. Bounds a stale stream to ~60s. NOTE: a ban is
// deliberately read-only and does NOT revoke sessions (see `ban_user`), so the
// `is_banned` re-read below is LOAD-BEARING — it is the only thing that stops a banned
// user's live push stream. Do not remove it. Only a *definitive* gone/expired/banned
// state ends the stream; a transient DB error just retries next tick.
let pool = state.pool.clone();
let session_hash = token_hash.clone();
let session_gone = async move {
let mut ticker = tokio::time::interval(Duration::from_secs(60));
ticker.tick().await; // consume the immediate first tick
loop {
ticker.tick().await;
match Session::find_user_by_token_hash(&pool, &session_hash).await {
Ok(Some(user)) if !user.is_banned => {} // still valid — keep streaming
Ok(Some(_)) => break, // banned — cut the stream
Ok(None) => break, // logged out or expired — stop
Err(e) => {
tracing::warn!(error = ?e, "SSE session revalidation query failed; retrying");
}
}
}
};
let stream = futures::StreamExt::take_until(events, Box::pin(session_gone));
Ok(Sse::new(stream).keep_alive(
KeepAlive::new()
.interval(Duration::from_secs(30))

View File

@@ -40,13 +40,13 @@ pub async fn truncate_all(
.execute(&state.pool)
.await?;
// Reseed config — mirrors migrations 005, 009 and 015. Kept in sync by hand
// Reseed config — mirrors migrations 005 and 009. Kept in sync by hand
// because pulling SQL out of the migration files at runtime is fragile.
sqlx::query(
r#"INSERT INTO config (key, value) VALUES
('max_image_size_mb', '20'),
('max_video_size_mb', '500'),
('upload_rate_per_hour', '100'),
('upload_rate_per_hour', '10'),
('feed_rate_per_min', '60'),
('export_rate_per_day', '3'),
('quota_tolerance', '0.75'),
@@ -70,41 +70,10 @@ pub async fn truncate_all(
let _ = tokio::fs::remove_dir_all(&state.config.media_path).await;
let _ = tokio::fs::create_dir_all(&state.config.media_path).await;
// Wipe the export directory too. Exports moved OUT of media_path (CR2 fix), so
// the media wipe above no longer covers them — without this a real export in
// one test would leave Gallery.zip on disk and contaminate the next.
let _ = tokio::fs::remove_dir_all(&state.config.export_path).await;
let _ = tokio::fs::create_dir_all(&state.config.export_path).await;
// The rate limiter holds an in-memory HashMap; clear it so a previous test's
// counters don't leak into the next one.
state.rate_limiter.clear();
// The reseed above wrote the `config` table directly (bypassing patch_config), so
// the cache must be invalidated too — otherwise the first request after a truncate
// could serve the previous test's toggles.
state.config_cache.invalidate();
// The other two in-memory singletons that TRUNCATE used to leave standing.
//
// `disk_cache` holds a free-space reading for up to its TTL. TRUNCATE has just deleted every
// uploaded file, which materially changes free space — so without this the next test can
// compute a storage quota from the PREVIOUS test's disk. That was harmless only while quotas
// were globally disabled in e2e (they no longer are: see specs/02-upload/quota.spec.ts, which
// steers the per-user limit off `free_disk_bytes`), i.e. two holes were masking each other.
state.disk_cache.invalidate();
// `sse_tickets` maps a ticket to a session token hash. TRUNCATE deletes the sessions, so every
// surviving ticket is a dangling reference to a user that no longer exists.
state.sse_tickets.clear();
// Invalidate any in-flight/queued compression task spawned by the previous test. Without this a
// task still waiting on the concurrency semaphore wakes AFTER this wipe, fails to find its
// (now-deleted) file, and broadcasts upload-error/upload-deleted into the NEXT test's SSE
// stream. (Export workers are already inert across a truncate: they are epoch-guarded on the
// event row, and truncate gives the event a fresh random UUID, so their writes match nothing.)
state.compression.bump_generation();
Ok(StatusCode::NO_CONTENT)
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,7 @@
use anyhow::Result;
use axum::Router;
use axum::extract::DefaultBodyLimit;
use axum::routing::{delete, get, patch, post};
use tower_http::services::ServeDir;
use axum::Router;
use tower_http::trace::TraceLayer;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
@@ -18,19 +17,14 @@ 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();
tracing_subscriber::registry()
.with(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "eventsnap_backend=debug,tower_http=debug".into()),
)
.with(tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
"eventsnap_backend=debug,tower_http=debug".into()
}))
.with(tracing_subscriber::fmt::layer())
.init();
@@ -45,30 +39,13 @@ async fn main() -> Result<()> {
let state = AppState::new(pool.clone(), config.clone());
// Backfill the big-screen display derivative for image uploads processed before it
// existed (v0.17.x). Fire-and-forget behind the compression semaphore; each upload
// falls back to its original in the diashow until its display is generated.
state.compression.backfill_missing_display().await;
// Re-spawn exports for events that were released but whose keepsake never finished
// (crash mid-export). Needs the media/export paths + SSE sender, so it runs here
// rather than inside `startup_recovery`. Fire-and-forget: the workers run in the
// background; the HTTP server can start accepting requests meanwhile.
services::export::recover_exports(
pool.clone(),
config.media_path.clone(),
config.export_path.clone(),
config.comments_enabled,
state.sse_tx.clone(),
)
.await;
// Hourly background hygiene: prune expired sessions, evict cold rate-limiter
// keys. Keeps the DB and process from growing unboundedly over multi-day events.
services::maintenance::spawn_periodic_tasks(
pool,
state.rate_limiter.clone(),
state.sse_tickets.clone(),
config.media_path.clone(),
);
// Ensure media directories exist
@@ -76,50 +53,19 @@ async fn main() -> Result<()> {
let api = Router::new()
// Auth
.route("/api/v1/event", get(handlers::public::get_public_event))
.route("/api/v1/join", post(auth::handlers::join))
.route("/api/v1/recover", post(auth::handlers::recover))
// Forgotten-PIN escape hatch: ask a host to reset it (unauthenticated, throttled).
.route(
"/api/v1/recover/request",
post(auth::handlers::request_pin_reset),
)
.route("/api/v1/admin/login", post(auth::handlers::admin_login))
.route("/api/v1/session", delete(auth::handlers::logout))
// "Sign out everywhere" — revoke all of the caller's sessions.
.route("/api/v1/sessions", delete(auth::handlers::logout_all))
// 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.
.route(
"/api/v1/upload",
post(handlers::upload::upload).route_layer(DefaultBodyLimit::max(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(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),
)
// Preview/thumbnail variants are gated the same way as originals (visibility
// check + direct /media block below) so moderation actually revokes access to
// the displayed images, not just the full-res download.
.route(
"/api/v1/upload/{id}/preview",
get(handlers::upload::get_preview),
)
.route(
"/api/v1/upload/{id}/display",
get(handlers::upload::get_display),
)
.route(
"/api/v1/upload/{id}/thumbnail",
get(handlers::upload::get_thumbnail),
)
// 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))
@@ -128,77 +74,32 @@ async fn main() -> Result<()> {
.route("/api/v1/feed/delta", get(handlers::feed::feed_delta))
.route("/api/v1/hashtags", get(handlers::feed::hashtags))
// Social
.route(
"/api/v1/upload/{id}/like",
post(handlers::social::toggle_like),
)
.route("/api/v1/upload/{id}/like", post(handlers::social::toggle_like))
.route(
"/api/v1/upload/{id}/comments",
get(handlers::social::list_comments).post(handlers::social::add_comment),
)
.route(
"/api/v1/comment/{id}",
delete(handlers::social::delete_comment),
)
.route("/api/v1/comment/{id}", delete(handlers::social::delete_comment))
// SSE
.route("/api/v1/stream", get(handlers::sse::stream))
.route("/api/v1/stream/ticket", post(handlers::sse::issue_ticket))
// Host Dashboard
.route("/api/v1/host/event", get(handlers::host::get_event_status))
.route(
"/api/v1/host/event/close",
post(handlers::host::close_event),
)
.route("/api/v1/host/event/close", post(handlers::host::close_event))
.route("/api/v1/host/event/open", post(handlers::host::open_event))
.route(
"/api/v1/host/gallery/release",
post(handlers::host::release_gallery),
)
// Escape hatch: force a keepsake rebuild. Without it a failed export is terminal at runtime
// (release_gallery refuses an already-released event; recovery only runs at boot).
.route(
"/api/v1/host/export/rebuild",
post(handlers::host::rebuild_export),
)
.route("/api/v1/host/gallery/release", post(handlers::host::release_gallery))
.route("/api/v1/host/users", get(handlers::host::list_users))
.route(
"/api/v1/host/users/{id}/ban",
post(handlers::host::ban_user),
)
.route(
"/api/v1/host/users/{id}/unban",
post(handlers::host::unban_user),
)
.route(
"/api/v1/host/users/{id}/role",
patch(handlers::host::set_role),
)
.route("/api/v1/host/users/{id}/ban", post(handlers::host::ban_user))
.route("/api/v1/host/users/{id}/unban", post(handlers::host::unban_user))
.route("/api/v1/host/users/{id}/role", patch(handlers::host::set_role))
.route(
"/api/v1/host/users/{id}/pin-reset",
post(handlers::host::reset_user_pin),
)
.route(
"/api/v1/host/pin-reset-requests",
get(handlers::host::list_pin_reset_requests),
)
.route(
"/api/v1/host/pin-reset-requests/{id}",
delete(handlers::host::dismiss_pin_reset_request),
)
.route(
"/api/v1/host/upload/{id}",
delete(handlers::host::host_delete_upload),
)
.route(
"/api/v1/host/comment/{id}",
delete(handlers::host::host_delete_comment),
)
.route("/api/v1/host/upload/{id}", delete(handlers::host::host_delete_upload))
.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
@@ -207,10 +108,7 @@ async fn main() -> Result<()> {
"/api/v1/admin/config",
get(handlers::admin::get_config).patch(handlers::admin::patch_config),
)
.route(
"/api/v1/admin/export/jobs",
get(handlers::admin::get_export_jobs),
);
.route("/api/v1/admin/export/jobs", get(handlers::admin::get_export_jobs));
// Test-only route: a hard reset for the Playwright E2E harness. The handler
// is compiled in always, but the route is only attached when
@@ -229,105 +127,33 @@ 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)
// Block direct HTTP access to ALL media subtrees. The files live under
// `media_path` (so the compression worker and export can read them off disk) but
// must NOT be pullable straight from `/media/**` — that bypasses the visibility
// checks (soft-delete + ban-hide) in the gated handlers. Every legitimate fetch
// goes through `/api/v1/upload/{id}/{original,preview,thumbnail}`, which filter
// via `find_visible_media`. The more specific nests take precedence over the
// `/media` ServeDir below (which, with all three subtrees blocked, now serves
// nothing — kept as a backstop).
.nest_service(
"/media/originals",
get(|| async { axum::http::StatusCode::NOT_FOUND }),
)
.nest_service(
"/media/previews",
get(|| async { axum::http::StatusCode::NOT_FOUND }),
)
.nest_service(
"/media/displays",
get(|| async { axum::http::StatusCode::NOT_FOUND }),
)
.nest_service(
"/media/thumbnails",
get(|| async { axum::http::StatusCode::NOT_FOUND }),
)
.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?;
tracing::info!("listening on {}", listener.local_addr()?);
axum::serve(listener, router)
.with_graceful_shutdown(shutdown_signal())
.await?;
axum::serve(listener, router).await?;
Ok(())
}
/// Hard cap on how long we wait for in-flight connections to drain after a shutdown
/// signal. Uploads (streamed to disk) finish in well under this; the cap exists because
/// long-lived SSE streams never end on their own and would otherwise keep the graceful
/// drain — and thus the process — pending until the orchestrator force-kills it.
const SHUTDOWN_GRACE: std::time::Duration = std::time::Duration::from_secs(10);
/// Resolves on SIGINT (Ctrl-C) or SIGTERM (container stop / deploy). Letting
/// `axum::serve` drain in-flight requests on this signal means a redeploy no longer
/// truncates uploads mid-flight; any background compression/export half-states that a
/// hard kill would leave are already reconciled by `startup_recovery` on the next boot.
///
/// Once the signal fires we also arm a detached backstop that force-exits after
/// [`SHUTDOWN_GRACE`]. Without it, open SSE streams (which have no natural end) would
/// hold the graceful drain open indefinitely; the backstop bounds shutdown regardless
/// of the orchestrator's own kill timeout. If the drain completes first, `main` returns
/// and the process exits before the timer ever fires.
async fn shutdown_signal() {
let ctrl_c = async {
let _ = tokio::signal::ctrl_c().await;
};
#[cfg(unix)]
let terminate = async {
match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
Ok(mut sig) => {
sig.recv().await;
}
Err(e) => {
tracing::warn!(error = ?e, "failed to install SIGTERM handler");
// Never resolve — fall back to ctrl_c only.
std::future::pending::<()>().await;
}
}
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {}
_ = terminate => {}
}
tracing::info!(
"shutdown signal received, draining in-flight requests (max {}s)",
SHUTDOWN_GRACE.as_secs()
);
// Backstop: if the graceful drain is still blocked after the grace window (almost
// always because SSE streams are still open), exit anyway so deploys aren't stalled.
tokio::spawn(async {
tokio::time::sleep(SHUTDOWN_GRACE).await;
tracing::warn!(
"graceful drain exceeded {}s (likely open SSE streams); forcing exit",
SHUTDOWN_GRACE.as_secs()
);
std::process::exit(0);
});
}

View File

@@ -3,10 +3,6 @@ use serde::Serialize;
use sqlx::PgPool;
use uuid::Uuid;
// Row shape for `comment`: every field is populated by sqlx from `SELECT *` / `RETURNING *`.
// `deleted_at` is not read in Rust today (the soft-delete filter lives in SQL), but it is part of
// the row and stays here so the struct keeps mirroring the table.
#[allow(dead_code)]
#[derive(Debug, sqlx::FromRow)]
pub struct Comment {
pub id: Uuid,
@@ -28,24 +24,19 @@ pub struct CommentDto {
}
impl Comment {
/// Takes any executor so the caller can insert the comment and link its
/// hashtags inside a single transaction.
pub async fn create<'e, E>(
executor: E,
pub async fn create(
pool: &PgPool,
upload_id: Uuid,
user_id: Uuid,
body: &str,
) -> Result<Self, sqlx::Error>
where
E: sqlx::PgExecutor<'e>,
{
) -> Result<Self, sqlx::Error> {
sqlx::query_as::<_, Self>(
"INSERT INTO comment (upload_id, user_id, body) VALUES ($1, $2, $3) RETURNING *",
)
.bind(upload_id)
.bind(user_id)
.bind(body)
.fetch_one(executor)
.fetch_one(pool)
.await
}
@@ -83,18 +74,26 @@ impl Comment {
}
pub async fn find_by_id(pool: &PgPool, id: Uuid) -> Result<Option<Self>, sqlx::Error> {
sqlx::query_as::<_, Self>("SELECT * FROM comment WHERE id = $1 AND deleted_at IS NULL")
.bind(id)
.fetch_optional(pool)
.await
sqlx::query_as::<_, Self>(
"SELECT * FROM comment WHERE id = $1 AND deleted_at IS NULL",
)
.bind(id)
.fetch_optional(pool)
.await
}
/// Event-scoped soft delete. Returns `false` if the comment doesn't exist or belongs to a
/// different event.
/// Executor-generic so the delete and the keepsake regeneration can share one transaction
/// (see `Upload::soft_delete_in_event` for why that must be atomic).
pub async fn soft_delete(pool: &PgPool, id: Uuid) -> Result<(), sqlx::Error> {
sqlx::query("UPDATE comment SET deleted_at = NOW() WHERE id = $1")
.bind(id)
.execute(pool)
.await?;
Ok(())
}
/// Event-scoped variant of [`Self::soft_delete`]. Returns `false` if the
/// comment doesn't exist or belongs to a different event.
pub async fn soft_delete_in_event(
conn: &mut sqlx::PgConnection,
pool: &PgPool,
id: Uuid,
event_id: Uuid,
) -> Result<bool, sqlx::Error> {
@@ -107,7 +106,7 @@ impl Comment {
)
.bind(id)
.bind(event_id)
.execute(conn)
.execute(pool)
.await?;
Ok(result.rows_affected() > 0)
}

View File

@@ -2,11 +2,6 @@ use chrono::{DateTime, Utc};
use sqlx::PgPool;
use uuid::Uuid;
// Row shape for `event`: every field is populated by sqlx from `SELECT *` / `RETURNING *`. Several
// (`slug`, `cover_image_path`, `export_epoch`, `created_at`) are not read through this struct today
// — callers that need them query the column directly — but they are part of the row and stay here so
// the struct keeps mirroring the table.
#[allow(dead_code)]
#[derive(Debug, sqlx::FromRow)]
pub struct Event {
pub id: Uuid,
@@ -16,11 +11,8 @@ pub struct Event {
pub is_active: bool,
pub uploads_locked_at: Option<DateTime<Utc>>,
pub export_released_at: Option<DateTime<Utc>>,
/// Monotonic generation counter for the keepsake. Bumped in the SAME UPDATE as any change to
/// `export_released_at` (release and reopen are its only writers). An export is downloadable
/// iff a `done` `export_job` row carries this exact epoch — readiness is derived from that,
/// never stored, so it cannot drift and no worker can resurrect it. See migration 014.
pub export_epoch: i64,
pub export_zip_ready: bool,
pub export_html_ready: bool,
pub created_at: DateTime<Utc>,
}
@@ -33,11 +25,13 @@ impl Event {
}
pub async fn create(pool: &PgPool, slug: &str, name: &str) -> Result<Self, sqlx::Error> {
sqlx::query_as::<_, Self>("INSERT INTO event (slug, name) VALUES ($1, $2) RETURNING *")
.bind(slug)
.bind(name)
.fetch_one(pool)
.await
sqlx::query_as::<_, Self>(
"INSERT INTO event (slug, name) VALUES ($1, $2) RETURNING *",
)
.bind(slug)
.bind(name)
.fetch_one(pool)
.await
}
pub async fn find_or_create(

View File

@@ -1,8 +1,6 @@
use sqlx::PgPool;
use uuid::Uuid;
// Row shape for `hashtag`, populated by sqlx from `RETURNING *` in `upsert`. Callers only use
// `id` today; `event_id`/`tag` are the rest of the row and stay part of the struct.
#[allow(dead_code)]
#[derive(Debug, sqlx::FromRow)]
pub struct Hashtag {
pub id: Uuid,
@@ -12,13 +10,7 @@ pub struct Hashtag {
impl Hashtag {
/// Upsert a hashtag (insert if not exists, return existing if it does).
///
/// Takes any executor so callers can run it inside a transaction (atomic
/// upload/comment writes) or standalone against the pool.
pub async fn upsert<'e, E>(executor: E, event_id: Uuid, tag: &str) -> Result<Self, sqlx::Error>
where
E: sqlx::PgExecutor<'e>,
{
pub async fn upsert(pool: &PgPool, event_id: Uuid, tag: &str) -> Result<Self, sqlx::Error> {
let normalized = tag.trim().trim_start_matches('#').to_lowercase();
sqlx::query_as::<_, Self>(
"INSERT INTO hashtag (event_id, tag) VALUES ($1, $2)
@@ -27,42 +19,52 @@ impl Hashtag {
)
.bind(event_id)
.bind(&normalized)
.fetch_one(executor)
.fetch_one(pool)
.await
}
pub async fn link_to_upload<'e, E>(
executor: E,
pub async fn link_to_upload(
pool: &PgPool,
upload_id: Uuid,
hashtag_id: Uuid,
) -> Result<(), sqlx::Error>
where
E: sqlx::PgExecutor<'e>,
{
) -> Result<(), sqlx::Error> {
sqlx::query(
"INSERT INTO upload_hashtag (upload_id, hashtag_id) VALUES ($1, $2)
ON CONFLICT DO NOTHING",
)
.bind(upload_id)
.bind(hashtag_id)
.execute(executor)
.execute(pool)
.await?;
Ok(())
}
pub async fn unlink_all_from_upload<'e, E>(
executor: E,
pub async fn unlink_all_from_upload(
pool: &PgPool,
upload_id: Uuid,
) -> Result<(), sqlx::Error>
where
E: sqlx::PgExecutor<'e>,
{
) -> Result<(), sqlx::Error> {
sqlx::query("DELETE FROM upload_hashtag WHERE upload_id = $1")
.bind(upload_id)
.execute(executor)
.execute(pool)
.await?;
Ok(())
}
pub async fn tags_for_upload(
pool: &PgPool,
upload_id: Uuid,
) -> Result<Vec<String>, sqlx::Error> {
let rows: Vec<(String,)> = sqlx::query_as(
"SELECT h.tag FROM hashtag h
JOIN upload_hashtag uh ON uh.hashtag_id = h.id
WHERE uh.upload_id = $1
ORDER BY h.tag",
)
.bind(upload_id)
.fetch_all(pool)
.await?;
Ok(rows.into_iter().map(|r| r.0).collect())
}
}
/// Extract `#hashtags` from text (caption or body). Tags are restricted to
@@ -107,44 +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,9 +2,8 @@ use chrono::{DateTime, Utc};
use sqlx::PgPool;
use uuid::Uuid;
// Row shape for `session`, populated by sqlx from `RETURNING *`. Session validation is done in SQL
// (expiry/last-seen predicates), so no field is read in Rust — the struct is the row's shape.
#[allow(dead_code)]
use crate::models::user::UserRole;
#[derive(Debug, sqlx::FromRow)]
pub struct Session {
pub id: Uuid,
@@ -15,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,
@@ -46,19 +57,16 @@ impl Session {
.await
}
/// Resolve a session token straight to its live user row in one round-trip.
///
/// The auth extractor runs on every authenticated request and used to do two
/// sequential queries (session lookup, then user lookup); this collapses them into
/// a single `session JOIN "user"`. The `expires_at` guard mirrors
/// [`Self::find_by_token_hash`], and the user row is read live (role/ban are never
/// trusted from the JWT). Returns `None` when the session is missing/expired.
pub async fn find_user_by_token_hash(
/// 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<crate::models::user::User>, sqlx::Error> {
sqlx::query_as::<_, crate::models::user::User>(
"SELECT u.*
) -> 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()",
@@ -68,29 +76,18 @@ impl Session {
.await
}
/// Touch `last_seen_at` AND slide `expires_at` forward by `expiry_days` from now, so
/// an actively-used session never hits the fixed 30-day cliff (it renews on every
/// authenticated request). An idle session still expires `expiry_days` after its last
/// activity. Keyed by token hash so the auth extractor needs no prior id lookup.
pub async fn touch_and_renew(
pool: &PgPool,
token_hash: &str,
expiry_days: i64,
) -> Result<(), sqlx::Error> {
sqlx::query(
"UPDATE session
SET last_seen_at = NOW(),
expires_at = NOW() + ($2 || ' days')::interval
WHERE token_hash = $1",
)
.bind(token_hash)
.bind(expiry_days.to_string())
.execute(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)
.execute(pool)
.await?;
Ok(())
}
pub async fn delete_by_token_hash(pool: &PgPool, token_hash: &str) -> Result<(), sqlx::Error> {
pub async fn delete_by_token_hash(
pool: &PgPool,
token_hash: &str,
) -> Result<(), sqlx::Error> {
sqlx::query("DELETE FROM session WHERE token_hash = $1")
.bind(token_hash)
.execute(pool)
@@ -98,13 +95,14 @@ impl Session {
Ok(())
}
/// Revoke every session for a user. Backs "sign out everywhere" and the forced
/// re-auth after a host PIN-reset or a ban. Returns the number of sessions cleared.
pub async fn delete_all_for_user(pool: &PgPool, user_id: Uuid) -> Result<u64, sqlx::Error> {
let r = sqlx::query("DELETE FROM session WHERE user_id = $1")
/// 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(r.rows_affected())
Ok(result.rows_affected())
}
}

View File

@@ -3,10 +3,6 @@ use serde::Serialize;
use sqlx::PgPool;
use uuid::Uuid;
// Row shape for `upload`: every field is populated by sqlx from `RETURNING *` in `create`. Callers
// mostly use `id` and hand the rest to the compression/feed queries, so most fields are never read
// through this struct — they stay here so it keeps mirroring the table.
#[allow(dead_code)]
#[derive(Debug, sqlx::FromRow)]
pub struct Upload {
pub id: Uuid,
@@ -23,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,
@@ -30,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>,
@@ -39,20 +46,10 @@ pub struct UploadDto {
pub created_at: DateTime<Utc>,
}
/// Minimal projection of an upload's on-disk file paths, used by the visibility-gated
/// media aliases so they don't hydrate the entire `Upload` row per request.
#[derive(Debug, sqlx::FromRow)]
pub struct VisibleMedia {
pub original_path: String,
pub preview_path: Option<String>,
pub thumbnail_path: Option<String>,
pub display_path: Option<String>,
pub mime_type: String,
}
impl Upload {
/// Takes any executor so the caller can run it inside a transaction (atomic
/// quota + insert) or standalone against the pool.
/// 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,
@@ -80,25 +77,9 @@ impl Upload {
.await
}
/// Lean lookup for the public media aliases (`get_original`/`get_preview`/
/// `get_thumbnail`): returns ONLY the file paths + mime for a visible upload —
/// excluding soft-deleted rows, hidden owners (`uploads_hidden`), and banned owners
/// (`is_banned`) — the same filter `v_feed` applies. So moderation that removes a post
/// from the feed also stops its original/preview/thumbnail from being pulled by UUID.
///
/// Selects four columns instead of the whole `Upload` row: this runs once per image
/// per cache-miss on the media hot path, so we avoid hydrating fields the response
/// never uses.
pub async fn find_visible_media(
pool: &PgPool,
id: Uuid,
) -> Result<Option<VisibleMedia>, sqlx::Error> {
sqlx::query_as::<_, VisibleMedia>(
"SELECT up.original_path, up.preview_path, up.thumbnail_path, up.display_path, up.mime_type
FROM upload up
JOIN \"user\" u ON u.id = up.user_id
WHERE up.id = $1 AND up.deleted_at IS NULL
AND u.uploads_hidden = false AND u.is_banned = false",
pub async fn find_by_id(pool: &PgPool, id: Uuid) -> Result<Option<Self>, sqlx::Error> {
sqlx::query_as::<_, Self>(
"SELECT * FROM upload WHERE id = $1 AND deleted_at IS NULL",
)
.bind(id)
.fetch_optional(pool)
@@ -135,19 +116,6 @@ impl Upload {
Ok(())
}
pub async fn set_display_path(
pool: &PgPool,
id: Uuid,
display_path: &str,
) -> Result<(), sqlx::Error> {
sqlx::query("UPDATE upload SET display_path = $2 WHERE id = $1")
.bind(id)
.bind(display_path)
.execute(pool)
.await?;
Ok(())
}
pub async fn set_thumbnail_path(
pool: &PgPool,
id: Uuid,
@@ -168,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)
@@ -189,36 +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.
/// Executor-generic so a caller can run the delete and the keepsake regeneration in ONE
/// transaction. They must be atomic: if the delete commits and the regeneration doesn't (a
/// dropped handler future, a failed second tx), the taken-down photo stays in the downloadable
/// archive forever, and recovery can't tell — the keepsake still looks complete at the current
/// epoch, and the host can no longer even find the upload to retry.
/// can return a clean 404, and the artifact paths otherwise.
pub async fn soft_delete_in_event(
conn: &mut sqlx::PgConnection,
pool: &PgPool,
id: Uuid,
event_id: Uuid,
) -> Result<bool, sqlx::Error> {
let tx = conn;
let row: Option<(Uuid, i64)> = sqlx::query_as(
) -> Result<Option<DeletedPaths>, sqlx::Error> {
let mut tx = pool.begin().await?;
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)
@@ -228,25 +197,23 @@ impl Upload {
.bind(bytes)
.execute(&mut *tx)
.await?;
true
Some(DeletedPaths { original, preview, thumbnail })
} else {
false
None
};
Ok(deleted)
tx.commit().await?;
Ok(paths)
}
pub async fn update_caption<'e, E>(
executor: E,
pub async fn update_caption(
pool: &PgPool,
id: Uuid,
caption: Option<&str>,
) -> Result<(), sqlx::Error>
where
E: sqlx::PgExecutor<'e>,
{
) -> Result<(), sqlx::Error> {
sqlx::query("UPDATE upload SET caption = $2 WHERE id = $1")
.bind(id)
.bind(caption)
.execute(executor)
.execute(pool)
.await?;
Ok(())
}

View File

@@ -22,10 +22,6 @@ impl UserRole {
}
}
// Row shape for `user`: every field is populated by sqlx from `SELECT *` / `RETURNING *`.
// `uploads_hidden`, `failed_pin_attempts` and `created_at` are enforced/updated in SQL rather than
// read in Rust, but they are part of the row and stay here so the struct keeps mirroring the table.
#[allow(dead_code)]
#[derive(Debug, sqlx::FromRow)]
pub struct User {
pub id: Uuid,
@@ -109,16 +105,14 @@ impl User {
Ok(row.0)
}
pub async fn lock_pin(
pool: &PgPool,
id: Uuid,
until: DateTime<Utc>,
) -> Result<(), sqlx::Error> {
sqlx::query("UPDATE \"user\" SET pin_locked_until = $2 WHERE id = $1")
.bind(id)
.bind(until)
.execute(pool)
.await?;
pub async fn lock_pin(pool: &PgPool, id: Uuid, until: DateTime<Utc>) -> Result<(), sqlx::Error> {
sqlx::query(
"UPDATE \"user\" SET pin_locked_until = $2 WHERE id = $1",
)
.bind(id)
.bind(until)
.execute(pool)
.await?;
Ok(())
}

View File

@@ -1,10 +1,9 @@
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use anyhow::{Context, Result};
use sqlx::PgPool;
use tokio::sync::{Semaphore, broadcast};
use tokio::sync::{broadcast, Semaphore};
use uuid::Uuid;
use crate::models::upload::Upload;
@@ -12,58 +11,41 @@ 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>,
/// Bumped whenever the underlying data is reset out from under in-flight work (only the e2e
/// TRUNCATE does this today). A task captures the value at spawn and abandons itself if it has
/// changed by the time it runs — see `process`.
generation: Arc<AtomicU64>,
}
impl CompressionWorker {
pub fn new(
pool: PgPool,
media_path: PathBuf,
concurrency: usize,
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,
generation: Arc::new(AtomicU64::new(0)),
}
}
/// Invalidate all in-flight and queued compression work. Called by the e2e TRUNCATE endpoint:
/// truncating deletes the upload rows and wipes `media/`, so a worker that was queued on the
/// semaphore when the wipe happened would otherwise wake in the NEXT test, fail to find its
/// file, and broadcast `upload-error` / `upload-deleted` into that test's live SSE stream —
/// corrupting any test that asserts on toasts or feed contents. Bumping the generation makes
/// those stale tasks return silently instead. A no-op in production (never called there).
pub fn bump_generation(&self) {
self.generation.fetch_add(1, Ordering::SeqCst);
}
/// Spawn a background task to process an uploaded file.
pub fn process(&self, upload_id: Uuid, original_path: String, mime_type: String) {
let worker = self.clone();
let born_at = worker.generation.load(Ordering::SeqCst);
tokio::spawn(async move {
let _permit = worker.semaphore.acquire().await;
// The data this task was queued against may have been reset while it waited for a permit
// (e2e TRUNCATE). If so, its file and row are gone; doing anything — including
// broadcasting a failure — would leak into an unrelated test. Abandon quietly.
if worker.generation.load(Ordering::SeqCst) != born_at {
return;
}
match worker
.do_process(upload_id, &original_path, &mime_type)
.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}");
let _ = worker.sse_tx.send(SseEvent {
@@ -72,30 +54,18 @@ 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:#}");
// Auto-cleanup: a failed transcode would otherwise leave a
// permanently broken feed card, silently charge the uploader's
// quota, and orphan the original on disk. Refund + soft-delete
// (one tx, so v_feed excludes it), remove the orphan file, then
// tell the uploader (upload-error toast) and evict the card
// everywhere (upload-deleted, already handled by the feed).
let _ = Upload::set_compression_status(&worker.pool, upload_id, "failed").await;
if let Err(del) = Upload::soft_delete(&worker.pool, upload_id).await {
tracing::warn!(error = ?del, %upload_id, "failed to soft-delete after compression failure");
}
let orphan = worker.media_path.join(&original_path);
if let Err(rm) = tokio::fs::remove_file(&orphan).await {
tracing::warn!(error = ?rm, path = %orphan.display(), "failed to remove orphaned original");
}
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(),
});
let _ = worker.sse_tx.send(SseEvent {
event_type: "upload-deleted".to_string(),
data: serde_json::json!({ "upload_id": upload_id }).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;
}
}
});
@@ -112,12 +82,9 @@ impl CompressionWorker {
let original = self.media_path.join(original_path);
if mime_type.starts_with("image/") {
let (preview_rel, display_rel) = self
.generate_image_derivatives(upload_id, &original, mime_type)
.await?;
let preview_rel = self.generate_image_preview(upload_id, &original, mime_type).await?;
Upload::set_preview_path(&self.pool, upload_id, &preview_rel).await?;
Upload::set_display_path(&self.pool, upload_id, &display_rel).await?;
tracing::info!("preview + display generated for upload {upload_id}");
tracing::info!("preview generated for upload {upload_id}");
} else if mime_type.starts_with("video/") {
let thumb_rel = self.generate_video_thumbnail(upload_id, &original).await?;
Upload::set_thumbnail_path(&self.pool, upload_id, &thumb_rel).await?;
@@ -128,42 +95,31 @@ impl CompressionWorker {
Ok(())
}
/// Longest edge of the big-screen "display" derivative used by the diashow. Sized to be
/// sharp on 1080p/4K while staying bounded (a ~2048px JPEG decodes to ~16 MB — trivial
/// for any kiosk, unlike a raw multi-thousand-pixel original).
const DISPLAY_MAX_EDGE: u32 = 2048;
/// Longest edge of the phone-feed "preview" (data-saver default).
const PREVIEW_MAX_EDGE: u32 = 800;
/// Decode the image ONCE and emit both derivatives — the 800px `preview` (phone feed)
/// and the 2048px `display` (diashow). Returns `(preview_rel, display_rel)`.
async fn generate_image_derivatives(
async fn generate_image_preview(
&self,
upload_id: Uuid,
original: &Path,
mime_type: &str,
) -> Result<(String, String)> {
) -> Result<String> {
let previews_dir = self.media_path.join("previews");
let displays_dir = self.media_path.join("displays");
tokio::fs::create_dir_all(&previews_dir).await?;
tokio::fs::create_dir_all(&displays_dir).await?;
let filename = format!("{upload_id}.jpg");
let preview_path = previews_dir.join(&filename);
let display_path = displays_dir.join(&filename);
let preview_filename = format!("{upload_id}.jpg");
let preview_path = previews_dir.join(&preview_filename);
let original = original.to_path_buf();
let preview_path_clone = preview_path.clone();
let mime_owned = mime_type.to_string();
let preview_max = Self::PREVIEW_MAX_EDGE;
let display_max = Self::DISPLAY_MAX_EDGE;
// 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")?;
@@ -174,29 +130,10 @@ impl CompressionWorker {
reader.limits(limits);
let img = reader.decode().context("failed to decode image")?;
// Preview: max 800px, preserving aspect ratio (data-saver feed).
img.resize(
preview_max,
preview_max,
image::imageops::FilterType::Lanczos3,
)
.save_with_format(&preview_path, image::ImageFormat::Jpeg)
.context("failed to save preview")?;
// Display: max 2048px for the diashow. Only DOWNSCALE — never upscale a smaller
// original (that adds bytes with no quality gain); re-encode it as JPEG as-is.
let display = if img.width() > display_max || img.height() > display_max {
img.resize(
display_max,
display_max,
image::imageops::FilterType::Lanczos3,
)
} else {
img
};
display
.save_with_format(&display_path, image::ImageFormat::Jpeg)
.context("failed to save display")?;
// Resize to max 800px wide, preserving aspect ratio
let preview = img.resize(800, 800, image::imageops::FilterType::Lanczos3);
preview.save_with_format(&preview_path_clone, image::ImageFormat::Jpeg)
.context("failed to save preview")?;
// If the original is PNG, try lossless compression in-place
if mime_owned == "image/png" {
@@ -212,67 +149,21 @@ impl CompressionWorker {
}
Ok(())
})
.await??;
});
Ok((
format!("previews/{filename}"),
format!("displays/{filename}"),
))
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}"))
}
/// One-time backfill: existing image uploads processed before the display derivative
/// existed have a preview but no `display_path`. Regenerate both derivatives for them
/// (decode is cheap and idempotent) and set the path. Unlike the failure path in
/// `process`, a backfill error is logged and skipped — it must NEVER soft-delete an
/// upload that already has a working preview. Fire-and-forget from startup.
pub async fn backfill_missing_display(&self) {
let rows = sqlx::query_as::<_, (Uuid, String, String)>(
"SELECT id, original_path, mime_type FROM upload
WHERE display_path IS NULL AND preview_path IS NOT NULL
AND deleted_at IS NULL AND mime_type LIKE 'image/%'",
)
.fetch_all(&self.pool)
.await;
let rows = match rows {
Ok(r) => r,
Err(e) => {
tracing::warn!(error = ?e, "display backfill query failed");
return;
}
};
if rows.is_empty() {
return;
}
tracing::info!(
"backfilling display derivative for {} upload(s)",
rows.len()
);
for (id, original_path, mime_type) in rows {
let worker = self.clone();
tokio::spawn(async move {
let _permit = worker.semaphore.acquire().await;
let original = worker.media_path.join(&original_path);
match worker
.generate_image_derivatives(id, &original, &mime_type)
.await
{
Ok((preview_rel, display_rel)) => {
let _ = Upload::set_preview_path(&worker.pool, id, &preview_rel).await;
let _ = Upload::set_display_path(&worker.pool, id, &display_rel).await;
tracing::info!("display backfilled for upload {id}");
}
Err(e) => {
// Leave the existing preview intact; the diashow falls back to the
// original for this upload until a later successful pass.
tracing::warn!(error = ?e, %id, "display backfill failed; leaving as-is");
}
}
});
}
}
async fn generate_video_thumbnail(&self, upload_id: Uuid, original: &Path) -> Result<String> {
async fn generate_video_thumbnail(
&self,
upload_id: Uuid,
original: &Path,
) -> Result<String> {
let thumbs_dir = self.media_path.join("thumbnails");
tokio::fs::create_dir_all(&thumbs_dir).await?;
@@ -302,14 +193,18 @@ impl CompressionWorker {
.spawn()
.context("failed to spawn ffmpeg")?;
let status =
match tokio::time::timeout(std::time::Duration::from_secs(120), child.wait()).await {
Ok(res) => res.context("ffmpeg wait failed")?,
Err(_) => {
let _ = child.kill().await;
anyhow::bail!("ffmpeg timeout after 120s");
}
};
let status = match tokio::time::timeout(
std::time::Duration::from_secs(120),
child.wait(),
)
.await
{
Ok(res) => res.context("ffmpeg wait failed")?,
Err(_) => {
let _ = child.kill().await;
anyhow::bail!("ffmpeg timeout after 120s");
}
};
if !status.success() {
// Best-effort: drain stderr for the log.
@@ -318,7 +213,10 @@ impl CompressionWorker {
use tokio::io::AsyncReadExt;
let _ = handle.read_to_end(&mut stderr).await;
}
anyhow::bail!("ffmpeg failed: {}", String::from_utf8_lossy(&stderr));
anyhow::bail!(
"ffmpeg failed: {}",
String::from_utf8_lossy(&stderr)
);
}
Ok(format!("thumbnails/{thumb_filename}"))

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,73 @@
//! Shared shape for long-running background work.
//!
//! Today's [`compression`](crate::services::compression) and [`export`](crate::services::export)
//! pipelines each implement their own progress + SSE plumbing. They could converge on the
//! trait sketched here so future jobs (analytics, archival, ...) plug into one progress
//! pipeline.
//!
//! This module is intentionally a *sketch*: the existing services are not yet wired to
//! it. The aim is to (a) document the convention so new jobs follow it, (b) make the
//! refactor mechanical when someone is ready to do it. See `docs/IDEAS.md` —
//! "Maintainability principles" — for the rationale.
//!
//! Example of an eventual implementor:
//!
//! ```ignore
//! struct ZipExport { event_id: Uuid, /* … */ }
//!
//! impl BackgroundJob for ZipExport {
//! fn name(&self) -> &'static str { "zip-export" }
//! async fn run(self, ctx: JobContext) -> Result<()> {
//! for (i, item) in items.iter().enumerate() {
//! ctx.report(percent(i, items.len())).await?;
//! // … write to zip …
//! }
//! Ok(())
//! }
//! }
//! ```
use anyhow::Result;
/// Handle handed to a running job: reports progress and emits SSE events.
///
/// Wraps the existing SSE broadcaster and an optional `export_job` row. Implementors
/// don't need to know about `state.sse_tx` directly — they call [`JobContext::report`]
/// and get the same effect.
pub struct JobContext {
pub job_id: Option<uuid::Uuid>,
pub event_kind: &'static str,
pub sse_tx: tokio::sync::broadcast::Sender<crate::state::SseEvent>,
pub pool: sqlx::PgPool,
}
impl JobContext {
/// Update progress (0..=100) and broadcast an SSE tick. Cheap to call often —
/// rate-limit at the call site if a job emits at > 10 Hz.
pub async fn report(&self, percent: u8) -> Result<()> {
if let Some(job_id) = self.job_id {
sqlx::query("UPDATE export_job SET progress_pct = $1 WHERE id = $2")
.bind(percent as i16)
.bind(job_id)
.execute(&self.pool)
.await?;
}
let _ = self.sse_tx.send(crate::state::SseEvent::new(
self.event_kind,
serde_json::json!({ "progress_pct": percent }).to_string(),
));
Ok(())
}
}
/// One unit of work that publishes progress through a [`JobContext`].
///
/// `run` consumes `self`; spawn with `tokio::spawn` at the caller. Errors propagate;
/// the caller is responsible for mapping them to `export_job.error_message` or
/// equivalent. Implementors stay small — the trait deliberately has no `cancel`
/// or `pause`; we have not needed those yet.
#[allow(async_fn_in_trait)]
pub trait BackgroundJob: Send + 'static {
fn name(&self) -> &'static str;
async fn run(self, ctx: JobContext) -> Result<()>;
}

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;
@@ -44,23 +45,8 @@ pub async fn startup_recovery(pool: &PgPool) {
Err(e) => tracing::error!("startup recovery: failed to sweep uploads: {e:#}"),
}
// Export jobs interrupted mid-run are marked 'failed' here so they aren't left
// 'running' forever. The host CANNOT re-trigger a released export (release_gallery
// rejects an already-released event), so `export::recover_exports` re-spawns these
// failed-but-released jobs from `main` once `AppState` exists (it needs the media
// paths + SSE sender this fn doesn't have).
//
// SINGLE-INSTANCE ASSUMPTION: this sweep is unscoped — it reaps every `running` row, not just
// this process's. That is correct for the supported deployment (one app container; see
// docker-compose.yml), where no other process can own a job at boot. If EventSnap is ever run
// with more than one replica, a booting replica would mark a peer's live workers `failed`.
// This is NOT merely wasteful — do not assume the epoch model contains it. Recovery re-arms the
// job at the SAME epoch, so the reaped-but-still-alive worker and the fresh one would BOTH hold
// that epoch; the guards carry no owner/lease token, so they share the same artifact paths and
// either can win the other's `finalize_job`. That can corrupt or delete the keepsake. Running
// more than one replica therefore requires an owner/lease/heartbeat on export_job first. That
// machinery is not worth building for a single-container app — so this is a documented
// CONSTRAINT, not a hidden assumption: do not scale this service horizontally as-is.
// Export jobs interrupted mid-run. Mark 'failed' so the host can re-trigger.
// The `UNIQUE(event_id, type)` constraint would otherwise block re-release.
match sqlx::query(
"UPDATE export_job
SET status = 'failed',
@@ -87,7 +73,12 @@ pub async fn startup_recovery(pool: &PgPool) {
/// - drops expired SSE tickets (30s TTL but the map keeps the slot until pruned)
///
/// Cadence is 1h — fine for both jobs at our scale.
pub fn spawn_periodic_tasks(pool: PgPool, rate_limiter: RateLimiter, sse_tickets: SseTicketStore) {
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));
// Fire the first tick immediately, then hourly.
@@ -97,6 +88,9 @@ pub fn spawn_periodic_tasks(pool: PgPool, rate_limiter: RateLimiter, sse_tickets
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

@@ -1,7 +1,10 @@
pub mod compression;
pub mod config;
pub mod disk;
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

@@ -24,12 +24,7 @@ impl RateLimiter {
/// Returns `Ok(())` if allowed, `Err(retry_after_secs)` if rate-limited.
/// `retry_after_secs` is how long until the oldest slot in the window expires.
pub fn check_with_retry(
&self,
key: impl Into<String>,
max: usize,
window: Duration,
) -> Result<(), u64> {
pub fn check_with_retry(&self, key: impl Into<String>, max: usize, window: Duration) -> Result<(), u64> {
let now = Instant::now();
let key = key.into();
let mut map = self.windows.lock().unwrap();
@@ -76,19 +71,27 @@ impl RateLimiter {
}
}
/// Extract the client IP from X-Forwarded-For or fall back to a provided socket
/// address string.
/// Extract the client IP for rate-limit keys.
///
/// We take the **right-most** entry, not the left-most. Caddy is the sole ingress
/// and the app port is only `expose`d (never published), so the last hop Caddy
/// appends is the real client. A client can prepend arbitrary spoofed values to
/// the left of XFF to dodge throttles — those are ignored here. This assumes
/// exactly one trusted proxy (Caddy); revisit if that changes.
/// 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.rsplit(',').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())
@@ -96,198 +99,27 @@ pub fn client_ip(headers: &axum::http::HeaderMap, fallback: &str) -> String {
#[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"
);
}
/// `retry_after` is not a "some number in range" — it is the time until the oldest slot
/// in the window frees up, and it is surfaced to clients as the backoff they sleep for
/// (see `upload-queue.ts`). Asserting only `(1..=60)` spans the entire reachable domain
/// of a 60s window, so a hardcoded `Err(1)` would satisfy it while telling every client
/// to hammer the server a second later. Pin the actual value.
#[test]
fn retry_after_is_the_remaining_window() {
let rl = RateLimiter::new();
// The slot was consumed just now, so essentially the whole window remains.
// `as_secs()` truncates the sub-second remainder, so a 30s window reports 29.
let w30 = Duration::from_secs(30);
assert!(rl.check_with_retry("a", 1, w30).is_ok());
let a = rl.check_with_retry("a", 1, w30).unwrap_err();
assert_eq!(a, 29, "retry_after must be the remaining window, got {a}");
// A different window must yield a different retry_after: no single constant can
// satisfy both this and the assertion above.
let w10 = Duration::from_secs(10);
assert!(rl.check_with_retry("b", 1, w10).is_ok());
let b = rl.check_with_retry("b", 1, w10).unwrap_err();
assert_eq!(b, 9, "retry_after must scale with the window, got {b}");
}
#[test]
fn retry_after_counts_down_as_the_window_elapses() {
let rl = RateLimiter::new();
let w = Duration::from_secs(30);
assert!(rl.check_with_retry("k", 1, w).is_ok());
let first = rl.check_with_retry("k", 1, w).unwrap_err();
std::thread::sleep(Duration::from_millis(1200));
let second = rl.check_with_retry("k", 1, w).unwrap_err();
// A client that waits 1.2s must be told to wait ~1.2s less — otherwise the advertised
// backoff is a constant, not a deadline.
let shaved = first - second;
assert!(
(1..=2).contains(&shaved),
"1.2s of waiting must shorten the advertised backoff by ~1s (got {first} then {second})"
);
}
#[test]
fn retry_after_floors_at_one_second() {
let rl = RateLimiter::new();
let w = Duration::from_millis(800);
assert!(rl.check_with_retry("k", 1, w).is_ok());
let retry = rl.check_with_retry("k", 1, w).unwrap_err();
// The sub-second remainder truncates to 0; clients must never be told "retry in 0s"
// (that's a busy-loop). The `.max(1)` floor is what prevents it.
assert_eq!(
retry, 1,
"a sub-second remainder must floor to 1, got {retry}"
);
}
#[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");
}
/// `prune()` is a memory-leak guard: without it a long-lived process keeps one HashMap
/// entry per IP that ever connected. Nothing in the public API observes the map size, so
/// the only way to catch a no-op body (`fn prune(&self) {}`) is to look at the map — the
/// tests module can see the private field.
#[test]
fn prune_drops_keys_whose_windows_have_fully_expired() {
let rl = RateLimiter::new();
// A key whose only timestamp is older than the 24h ceiling. We can't sleep for a day,
// so backdate the Instant directly.
let ancient = Instant::now()
.checked_sub(Duration::from_secs(25 * 60 * 60))
.expect("backdating an Instant by 25h");
rl.windows
.lock()
.unwrap()
.insert("stale".to_string(), vec![ancient]);
// ...alongside a key that is still inside its window.
assert!(rl.check("live", 5, MIN));
assert_eq!(rl.windows.lock().unwrap().len(), 2);
rl.prune();
let map = rl.windows.lock().unwrap();
assert!(
!map.contains_key("stale"),
"prune() must drop keys whose timestamps have all expired"
);
assert!(
map.contains_key("live"),
"prune() must keep keys that still have live timestamps"
);
assert_eq!(map.len(), 1, "exactly one key should survive the prune");
}
#[test]
fn prune_does_not_reset_a_live_window() {
// The counterpart to the test above: pruning must reclaim memory, never quota. If
// prune() dropped live keys, every background sweep would hand attackers a fresh
// budget.
let rl = RateLimiter::new();
assert!(rl.check("k", 1, MIN));
assert!(!rl.check("k", 1, MIN));
rl.prune();
assert!(
!rl.check("k", 1, MIN),
"prune() must not clear a window that is still active"
);
}
#[test]
fn client_ip_takes_rightmost_forwarded_for_entry() {
// The right-most entry is the hop our trusted proxy (Caddy) appended.
fn prefers_x_real_ip() {
let mut h = HeaderMap::new();
h.insert("x-forwarded-for", "10.0.0.1, 203.0.113.7".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_ignores_spoofed_leftmost_entry() {
// A client prepending a fake IP to dodge throttles must not win.
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",
"1.2.3.4, 9.9.9.9, 203.0.113.7".parse().unwrap(),
);
assert_eq!(client_ip(&h, "fallback"), "203.0.113.7");
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_trims_surrounding_whitespace() {
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");
}
#[test]
fn client_ip_falls_back_when_header_absent() {
assert_eq!(client_ip(&HeaderMap::new(), "127.0.0.1"), "127.0.0.1");
}
#[test]
fn client_ip_falls_back_on_trailing_comma_empty_entry() {
// A trailing comma leaves an empty right-most segment after trimming; the
// `.filter(!is_empty)` must reject it and fall through to the fallback
// rather than returning "" (which would collapse callers into one bucket).
let mut h = HeaderMap::new();
h.insert("x-forwarded-for", "203.0.113.7, ".parse().unwrap());
assert_eq!(client_ip(&h, "127.0.0.1"), "127.0.0.1");
fn falls_back_when_no_headers() {
assert_eq!(client_ip(&HeaderMap::new(), "fb"), "fb");
}
}

View File

@@ -31,13 +31,6 @@ impl SseTicketStore {
}
}
/// Drop every outstanding ticket. Used by the e2e TRUNCATE endpoint: tickets are bound to a
/// session token hash, and TRUNCATE deletes the sessions out from under them, so anything left
/// here is a dangling reference to a user that no longer exists.
pub fn clear(&self) {
self.inner.lock().unwrap().clear();
}
/// Mint a new ticket bound to the caller's session (identified by token hash).
pub fn issue(&self, token_hash: String) -> String {
let ticket = random_ticket();
@@ -79,66 +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,13 +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::config::ConfigCache;
use crate::services::disk::DiskCache;
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,
@@ -33,27 +40,21 @@ pub struct AppState {
pub compression: CompressionWorker,
pub rate_limiter: RateLimiter,
pub sse_tickets: SseTicketStore,
/// In-memory cache in front of the `config` table. Reads go through here; the
/// admin PATCH handler and the test reseed invalidate it after committing.
pub config_cache: ConfigCache,
/// Cached total/free bytes for the media filesystem (quota + admin stats).
pub disk_cache: DiskCache,
/// Caps concurrent upload-body buffering (see UPLOAD_MAX_CONCURRENCY).
pub upload_limiter: Arc<Semaphore>,
}
impl AppState {
pub fn new(pool: PgPool, config: AppConfig) -> Self {
// Broadcast buffer for live SSE fan-out. Sized to absorb a burst (e.g. many
// uploads landing at once during a busy moment) before a slow consumer lags and
// has to `resync`. The resync path is a correctness backstop, not the happy path —
// a roomier buffer keeps ~1000 concurrent clients from all resyncing at once.
let (sse_tx, _) = broadcast::channel(1024);
let (sse_tx, _) = broadcast::channel(256);
// Independent image (2) and video (2) permit pools — see CompressionWorker.
let compression = CompressionWorker::new(
pool.clone(),
config.media_path.clone(),
config.compression_concurrency,
2,
2,
sse_tx.clone(),
);
let config_cache = ConfigCache::new(pool.clone());
Self {
pool,
config,
@@ -61,8 +62,7 @@ impl AppState {
compression,
rate_limiter: RateLimiter::new(),
sse_tickets: SseTicketStore::new(),
config_cache,
disk_cache: DiskCache::new(),
upload_limiter: Arc::new(Semaphore::new(UPLOAD_MAX_CONCURRENCY)),
}
}
}

View File

@@ -0,0 +1 @@
export const env={}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
import{u as o,n as t,o as c}from"./CcONa1Mr.js";function u(e){throw new Error("https://svelte.dev/e/lifecycle_outside_component")}function r(e){t===null&&u(),o(()=>{const n=c(e);if(typeof n=="function")return n})}export{r as o};

View File

@@ -0,0 +1 @@
import{f as l,g as o,p as u,i as n,j as d,k as m,h as p,e as _,m as v,l as k}from"./CcONa1Mr.js";class w{anchor;#t=new Map;#s=new Map;#e=new Map;#i=new Set;#f=!0;constructor(t,s=!0){this.anchor=t,this.#f=s}#a=t=>{if(this.#t.has(t)){var s=this.#t.get(t),e=this.#s.get(s);if(e)l(e),this.#i.delete(s);else{var f=this.#e.get(s);f&&(this.#s.set(s,f.effect),this.#e.delete(s),f.fragment.lastChild.remove(),this.anchor.before(f.fragment),e=f.effect)}for(const[i,a]of this.#t){if(this.#t.delete(i),i===t)break;const r=this.#e.get(a);r&&(o(r.effect),this.#e.delete(a))}for(const[i,a]of this.#s){if(i===s||this.#i.has(i))continue;const r=()=>{if(Array.from(this.#t.values()).includes(i)){var c=document.createDocumentFragment();v(a,c),c.append(n()),this.#e.set(i,{effect:a,fragment:c})}else o(a);this.#i.delete(i),this.#s.delete(i)};this.#f||!e?(this.#i.add(i),u(a,r,!1)):r()}}};#r=t=>{this.#t.delete(t);const s=Array.from(this.#t.values());for(const[e,f]of this.#e)s.includes(e)||(o(f.effect),this.#e.delete(e))};ensure(t,s){var e=m,f=k();if(s&&!this.#s.has(t)&&!this.#e.has(t))if(f){var i=document.createDocumentFragment(),a=n();i.append(a),this.#e.set(t,{effect:d(()=>s(a)),fragment:i})}else this.#s.set(t,d(()=>s(this.anchor)));if(this.#t.set(e,t),f){for(const[r,h]of this.#s)r===t?e.unskip_effect(h):e.skip_effect(h);for(const[r,h]of this.#e)r===t?e.unskip_effect(h.effect):e.skip_effect(h.effect);e.oncommit(this.#a),e.ondiscard(this.#r)}else p&&(this.anchor=_),this.#a(e)}}export{w as B};

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
import{b as c,h as o,a as l,E as b,r as p,s as v,c as g,d,e as m}from"./CcONa1Mr.js";import{B as y}from"./BRDva_z9.js";function k(f,h,_=!1){var n;o&&(n=m,l());var s=new y(f),u=_?b:0;function t(a,r){if(o){var e=p(n);if(a!==parseInt(e.substring(1))){var i=v();g(i),s.anchor=i,d(!1),s.ensure(a,r),d(!0);return}}s.ensure(a,r)}c(()=>{var a=!1;h((r,e=0)=>{a=!0,t(e,r)}),a||t(-1,null)},u)}export{k as i};

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
import{A as v,i as d,B as l,C as u,D as T,T as p,F as h,h as i,e as s,R as E,a as y,G as g,c as w,H as N}from"./CcONa1Mr.js";const A=globalThis?.window?.trustedTypes&&globalThis.window.trustedTypes.createPolicy("svelte-trusted-html",{createHTML:t=>t});function M(t){return A?.createHTML(t)??t}function x(t){var r=v("template");return r.innerHTML=M(t.replaceAll("<!>","<!---->")),r.content}function n(t,r){var e=l;e.nodes===null&&(e.nodes={start:t,end:r,a:null,t:null})}function b(t,r){var e=(r&p)!==0,f=(r&h)!==0,a,_=!t.startsWith("<!>");return()=>{if(i)return n(s,null),s;a===void 0&&(a=x(_?t:"<!>"+t),e||(a=u(a)));var o=f||T?document.importNode(a,!0):a.cloneNode(!0);if(e){var c=u(o),m=o.lastChild;n(c,m)}else n(o,o);return o}}function C(t=""){if(!i){var r=d(t+"");return n(r,r),r}var e=s;return e.nodeType!==g?(e.before(e=d()),w(e)):N(e),n(e,e),e}function O(){if(i)return n(s,null),s;var t=document.createDocumentFragment(),r=document.createComment(""),e=d();return t.append(r,e),n(r,e),t}function P(t,r){if(i){var e=l;((e.f&E)===0||e.nodes.end===null)&&(e.nodes.end=s),y();return}t!==null&&t.before(r)}const L="5";typeof window<"u"&&((window.__svelte??={}).v??=new Set).add(L);export{P as a,n as b,O as c,b as f,C as t};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
import{l as o,a as r}from"../chunks/eAGLaJx1.js";export{o as load_css,r as start};

View File

@@ -0,0 +1 @@
import{c as s,a as c}from"../chunks/RsTAN2PN.js";import{b as l,E as p,t as i}from"../chunks/CcONa1Mr.js";import{B as m}from"../chunks/BRDva_z9.js";function u(n,r,...e){var o=new m(n);l(()=>{const t=r()??null;o.ensure(t,t&&(a=>t(a,...e)))},p)}const f=!0,_=!1,g=Object.freeze(Object.defineProperty({__proto__:null,prerender:f,ssr:_},Symbol.toStringTag,{value:"Module"}));function h(n,r){var e=s(),o=i(e);u(o,()=>r.children),c(n,e)}export{h as component,g as universal};

View File

@@ -0,0 +1 @@
import{a as i,f as h}from"../chunks/RsTAN2PN.js";import{q as g,t as v,v as d,w as l,x as s,y as a,z as x}from"../chunks/CcONa1Mr.js";import{s as o}from"../chunks/Bb9JxzU7.js";import{s as _,p}from"../chunks/eAGLaJx1.js";const $={get error(){return p.error},get status(){return p.status}};_.updated.check;const m=$;var k=h("<h1> </h1> <p> </p>",1);function z(c,f){g(f,!0);var t=k(),r=v(t),n=s(r,!0);a(r);var e=x(r,2),u=s(e,!0);a(e),d(()=>{o(n,m.status),o(u,m.error?.message)}),i(c,t),l()}export{z as component};

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"version":"1778876725548"}

File diff suppressed because one or more lines are too long

View File

@@ -1,257 +0,0 @@
//! Shared fixtures for the DB-backed integration tests.
//!
//! Every helper here executes SQL that is **character-for-character identical** to what `src/`
//! actually runs (see the `// SRC:` markers). That is the whole point: a paraphrased query is a
//! query nobody runs, and a test that passes against a paraphrase proves nothing about production.
#![allow(dead_code)] // each integration-test crate uses a different subset of these helpers
use sqlx::PgPool;
use uuid::Uuid;
/// Insert a bare, unreleased event (epoch 0, uploads open).
pub async fn seed_event(pool: &PgPool, slug: &str) -> Uuid {
sqlx::query_scalar("INSERT INTO event (slug, name) VALUES ($1, $2) RETURNING id")
.bind(slug)
.bind("Hochzeit")
.fetch_one(pool)
.await
.expect("seed event")
}
/// Insert a guest with a zeroed byte total.
pub async fn seed_user(pool: &PgPool, event_id: Uuid, name: &str) -> Uuid {
sqlx::query_scalar(
"INSERT INTO \"user\" (event_id, display_name, recovery_pin_hash)
VALUES ($1, $2, 'x') RETURNING id",
)
.bind(event_id)
.bind(name)
.fetch_one(pool)
.await
.expect("seed user")
}
/// SRC: `handlers/host.rs::release_gallery` — the claim + epoch bump, verbatim.
/// Returns the POST-increment epoch, exactly as the handler consumes it.
pub async fn release_gallery(pool: &PgPool, slug: &str) -> Option<i64> {
let claimed: Option<(Uuid, String, i64)> = sqlx::query_as(
"UPDATE event
SET export_released_at = NOW(),
uploads_locked_at = COALESCE(uploads_locked_at, NOW()),
export_epoch = export_epoch + 1
WHERE slug = $1 AND export_released_at IS NULL
RETURNING id, name, export_epoch",
)
.bind(slug)
.fetch_optional(pool)
.await
.expect("release_gallery");
if let Some((event_id, _, epoch)) = claimed {
// The handler arms both jobs in the SAME transaction; for a single-connection fixture the
// sequencing is equivalent.
let mut conn = pool.acquire().await.expect("acquire");
enqueue_types_at_epoch(&mut conn, event_id, epoch, &["zip", "html"]).await;
Some(epoch)
} else {
None
}
}
/// SRC: `handlers/host.rs::open_event` — the one statement that retires an entire generation.
/// Returns rows affected.
pub async fn open_event(pool: &PgPool, slug: &str) -> u64 {
sqlx::query(
"UPDATE event
SET uploads_locked_at = NULL,
export_released_at = NULL,
export_epoch = export_epoch + 1
WHERE slug = $1 AND (uploads_locked_at IS NOT NULL OR export_released_at IS NOT NULL)",
)
.bind(slug)
.execute(pool)
.await
.expect("open_event")
.rows_affected()
}
/// SRC: `services/export.rs::enqueue_types_at_epoch` — verbatim upsert.
pub async fn enqueue_types_at_epoch(
conn: &mut sqlx::PgConnection,
event_id: Uuid,
epoch: i64,
types: &[&str],
) {
for export_type in types {
sqlx::query(
"INSERT INTO export_job (event_id, type, status, progress_pct, epoch)
VALUES ($1, $2::export_type, 'pending', 0, $3)
ON CONFLICT (event_id, type) DO UPDATE
SET status = 'pending', progress_pct = 0, file_path = NULL,
error_message = NULL, completed_at = NULL,
epoch = EXCLUDED.epoch
WHERE export_job.status <> 'done' OR export_job.epoch <> EXCLUDED.epoch",
)
.bind(event_id)
.bind(export_type)
.bind(epoch)
.execute(&mut *conn)
.await
.expect("enqueue_types_at_epoch");
}
}
/// SRC: `services/export.rs::claim_job` — verbatim. `true` = we won the generation.
pub async fn claim_job(pool: &PgPool, event_id: Uuid, export_type: &str, epoch: i64) -> bool {
sqlx::query(
"UPDATE export_job SET status = 'running'
WHERE event_id = $1 AND type = $2::export_type
AND epoch = $3 AND status = 'pending'",
)
.bind(event_id)
.bind(export_type)
.bind(epoch)
.execute(pool)
.await
.expect("claim_job")
.rows_affected()
> 0
}
/// SRC: `services/export.rs::finalize_job` — verbatim. This IS the publish step.
pub async fn finalize_job(
pool: &PgPool,
event_id: Uuid,
export_type: &str,
epoch: i64,
file_path: &str,
) -> bool {
sqlx::query(
"UPDATE export_job
SET status = 'done', progress_pct = 100, file_path = $3, completed_at = NOW()
WHERE event_id = $1 AND type = $2::export_type
AND epoch = $4 AND status = 'running'",
)
.bind(event_id)
.bind(export_type)
.bind(file_path)
.bind(epoch)
.execute(pool)
.await
.expect("finalize_job")
.rows_affected()
> 0
}
/// SRC: `services/export.rs::update_progress` — verbatim. Doubles as the worker's liveness check:
/// `false` means "your generation was retired, stop working".
pub async fn update_progress(
pool: &PgPool,
event_id: Uuid,
export_type: &str,
epoch: i64,
pct: i16,
) -> bool {
sqlx::query(
"UPDATE export_job SET progress_pct = $3
WHERE event_id = $1 AND type = $2::export_type
AND epoch = $4 AND status = 'running'",
)
.bind(event_id)
.bind(export_type)
.bind(pct)
.bind(epoch)
.execute(pool)
.await
.expect("update_progress")
.rows_affected()
> 0
}
/// SRC: `services/export.rs::invalidate_and_arm` — the ViewerOnly ZIP carry-forward, verbatim.
/// Returns `rows_affected() == 1`, which is what the production code branches on.
pub async fn carry_zip_forward(pool: &PgPool, event_id: Uuid, epoch: i64) -> bool {
sqlx::query(
"UPDATE export_job SET epoch = $2
WHERE event_id = $1 AND type = 'zip'::export_type
AND status = 'done' AND epoch = $2 - 1",
)
.bind(event_id)
.bind(epoch)
.execute(pool)
.await
.expect("carry_zip_forward")
.rows_affected()
== 1
}
/// SRC: `services/export.rs::invalidate_and_arm` — the epoch bump, verbatim.
pub async fn bump_epoch(pool: &PgPool, slug: &str) -> Option<(Uuid, String, i64)> {
sqlx::query_as(
"UPDATE event SET export_epoch = export_epoch + 1
WHERE slug = $1 AND export_released_at IS NOT NULL
RETURNING id, name, export_epoch",
)
.bind(slug)
.fetch_optional(pool)
.await
.expect("bump_epoch")
}
/// The event's authoritative epoch.
pub async fn event_epoch(pool: &PgPool, event_id: Uuid) -> i64 {
sqlx::query_scalar("SELECT export_epoch FROM event WHERE id = $1")
.bind(event_id)
.fetch_one(pool)
.await
.expect("event_epoch")
}
/// The raw job row, bypassing `export_current` — what the WORKER sees.
pub async fn job_row(
pool: &PgPool,
event_id: Uuid,
export_type: &str,
) -> Option<(String, i64, Option<String>)> {
sqlx::query_as(
"SELECT status::text, epoch, file_path FROM export_job
WHERE event_id = $1 AND type = $2::export_type",
)
.bind(event_id)
.bind(export_type)
.fetch_optional(pool)
.await
.expect("job_row")
}
/// Does `export_current` expose this job at all? (The view itself, without the `status` filter —
/// it is what `handlers/admin.rs::export_status` reports to the host UI.)
pub async fn in_export_current(pool: &PgPool, event_id: Uuid, export_type: &str) -> bool {
sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM export_current
WHERE event_id = $1 AND type = $2::export_type",
)
.bind(event_id)
.bind(export_type)
.fetch_one(pool)
.await
.expect("in_export_current")
> 0
}
/// THE download predicate. SRC: `handlers/admin.rs::download_export` reads exactly this shape —
/// `SELECT c.file_path FROM export_current c WHERE ... AND c.status = 'done'`. If this returns
/// `Some`, a guest can download the keepsake; if `None`, they get a 404.
pub async fn downloadable(pool: &PgPool, event_id: Uuid, export_type: &str) -> Option<String> {
sqlx::query_scalar(
"SELECT c.file_path FROM export_current c
WHERE c.event_id = $1 AND c.type = $2::export_type AND c.status = 'done'",
)
.bind(event_id)
.bind(export_type)
.fetch_optional(pool)
.await
.expect("downloadable")
.flatten()
}

View File

@@ -1,435 +0,0 @@
//! DB-backed integration tests for the export epoch state machine (migration 014).
//!
//! These run against a REAL Postgres: `#[sqlx::test]` creates a throwaway database per test and
//! runs `backend/migrations/` into it, so the schema, the enums, the `UNIQUE (event_id, type)`
//! constraint and the `export_current` view are the production ones — not a mock.
//!
//! THE INVARIANT, from migration 014:
//!
//! An export is downloadable IFF
//! event.export_released_at IS NOT NULL
//! AND export_job.epoch = event.export_epoch
//! AND export_job.status = 'done'
//!
//! Readiness is DERIVED (the `export_current` view), never stored. Every test below pins one leg of
//! that invariant with the exact SQL `src/` executes (see `tests/common/mod.rs`).
mod common;
use common::*;
use sqlx::PgPool;
// ─────────────────────────────────────────────────────────────────────────────
// 1. Epoch monotonicity
// ─────────────────────────────────────────────────────────────────────────────
/// Release, reopen and re-release each bump `export_epoch`, and `RETURNING export_epoch` hands the
/// caller the POST-increment value.
///
/// PREVENTS: a worker born with the PRE-increment epoch. It would be inert from the instant it
/// started — every one of its writes is `epoch`-guarded, so `claim_job`/`finalize_job` would match
/// nothing, the job row would sit at `pending` 0% forever with no live worker, and the host's
/// download button would spin and then 404. The keepsake would never be built at all.
#[sqlx::test]
async fn release_returns_post_increment_epoch(pool: PgPool) {
let event_id = seed_event(&pool, "wedding").await;
assert_eq!(
event_epoch(&pool, event_id).await,
0,
"a fresh event starts at epoch 0"
);
let released = release_gallery(&pool, "wedding")
.await
.expect("release claims the event");
assert_eq!(
released, 1,
"RETURNING must give the epoch AFTER the +1, not before"
);
assert_eq!(
event_epoch(&pool, event_id).await,
released,
"worker's epoch == event's epoch"
);
// The jobs armed by the release carry exactly that epoch — this is what makes the worker's
// guarded writes match.
for t in ["zip", "html"] {
let (status, epoch, _) = job_row(&pool, event_id, t).await.expect("job armed");
assert_eq!(status, "pending");
assert_eq!(
epoch, released,
"{t} job must be armed at the epoch the worker was born with"
);
}
}
/// Epoch is strictly monotonic across the whole release/reopen/re-release cycle, and a second
/// release attempt while already released is rejected WITHOUT bumping.
///
/// PREVENTS: epoch reuse. If a reopen could return the event to an epoch some old `done` row still
/// carries, a retired keepsake — one that a guest asked to be taken down from — would silently
/// become downloadable again.
#[sqlx::test]
async fn epoch_is_strictly_monotonic_across_reopen(pool: PgPool) {
let event_id = seed_event(&pool, "wedding").await;
assert_eq!(release_gallery(&pool, "wedding").await, Some(1));
// A duplicate release is a no-op (`WHERE export_released_at IS NULL`) and must NOT bump.
assert_eq!(
release_gallery(&pool, "wedding").await,
None,
"already released"
);
assert_eq!(
event_epoch(&pool, event_id).await,
1,
"a rejected release must not move the epoch"
);
// Reopen retires the generation with ONE write.
assert_eq!(open_event(&pool, "wedding").await, 1);
assert_eq!(event_epoch(&pool, event_id).await, 2, "reopen bumps");
// And re-releasing bumps again — never back to 1.
assert_eq!(
release_gallery(&pool, "wedding").await,
Some(3),
"re-release bumps again"
);
assert_eq!(event_epoch(&pool, event_id).await, 3);
}
// ─────────────────────────────────────────────────────────────────────────────
// 2. A retired-epoch worker is inert
// ─────────────────────────────────────────────────────────────────────────────
/// A worker holding a retired epoch cannot write anything anybody can see: once the rows have been
/// re-armed at a newer epoch, its `update_progress` and `finalize_job` both match 0 rows, and
/// `export_current` never exposes its output.
///
/// PREVENTS: the classic lost race — a slow worker from BEFORE a takedown finishing afterwards and
/// publishing an archive that still contains the photo a guest asked to have removed. "Please take
/// my photo out" is the one request that most needs to reach the keepsake, and the keepsake is the
/// artifact people keep forever.
#[sqlx::test]
async fn retired_epoch_worker_writes_are_no_ops(pool: PgPool) {
let event_id = seed_event(&pool, "wedding").await;
let old_epoch = release_gallery(&pool, "wedding").await.unwrap();
// Worker A is born at epoch 1 and claims the ZIP.
assert!(
claim_job(&pool, event_id, "zip", old_epoch).await,
"worker A wins its claim"
);
assert!(
update_progress(&pool, event_id, "zip", old_epoch, 40).await,
"still live at 40%"
);
// ── A takedown lands mid-export: `invalidate_and_arm(Affects::Both)` bumps and re-arms. ──
let (_, _, new_epoch) = bump_epoch(&pool, "wedding")
.await
.expect("bump on a released event");
assert_eq!(new_epoch, old_epoch + 1);
let mut conn = pool.acquire().await.unwrap();
enqueue_types_at_epoch(&mut conn, event_id, new_epoch, &["zip", "html"]).await;
drop(conn);
// Worker A is now INERT BY CONSTRUCTION. Every write is guarded on its own birth epoch.
assert!(
!update_progress(&pool, event_id, "zip", old_epoch, 90).await,
"the liveness check must report `false` so worker A stops grinding through the gallery"
);
assert!(
!finalize_job(&pool, event_id, "zip", old_epoch, "exports/Gallery.1.zip").await,
"worker A's finalize MUST affect 0 rows — this is the write that would have published a \
keepsake still containing the taken-down photo"
);
// The re-armed row is untouched by the loser: still pending at the LIVE epoch, waiting for the
// fresh worker. (If worker A had won, this row would read `done` at epoch 1.)
let (status, epoch, file_path) = job_row(&pool, event_id, "zip").await.unwrap();
assert_eq!((status.as_str(), epoch), ("pending", new_epoch));
assert_eq!(
file_path, None,
"the loser's file_path must never be recorded"
);
// And nothing is downloadable — not the stale archive, not anything.
assert_eq!(downloadable(&pool, event_id, "zip").await, None);
}
/// The documented, deliberate nuance in `claim_job`: after a bare `open_event` (which writes
/// NOTHING to `export_job` — that is the point of the design), a worker at the old epoch still WINS
/// its claim and can still write `done`. That is wasted work, not incorrectness: retirement is
/// enforced at READ time. `export_current` must refuse to expose the row.
///
/// PREVENTS: someone "optimising" `claim_job` into a cross-table `EXISTS (SELECT ... FROM event)`
/// guard — the exact unsound guard migration 014 removed (under READ COMMITTED, a blocked UPDATE
/// re-evaluates same-row predicates but answers other-table subqueries from a stale snapshot).
/// This test pins the read-time enforcement so the write-time guard is never re-added.
#[sqlx::test]
async fn reopen_retires_at_read_time_not_write_time(pool: PgPool) {
let event_id = seed_event(&pool, "wedding").await;
let epoch = release_gallery(&pool, "wedding").await.unwrap();
assert!(claim_job(&pool, event_id, "zip", epoch).await);
// Host reopens uploads. No export_job row is touched.
assert_eq!(open_event(&pool, "wedding").await, 1);
// The in-flight worker's row-local writes still match — it was never told to stop.
assert!(
finalize_job(&pool, event_id, "zip", epoch, "exports/Gallery.1.zip").await,
"documented: the claim/finalize is guarded on the JOB row's epoch, not the event's"
);
let (status, _, _) = job_row(&pool, event_id, "zip").await.unwrap();
assert_eq!(status, "done", "the row really does say done");
// …and yet it is invisible. `export_current` requires the event to be released AND the epochs to
// match; the reopen broke both. A worker at a dead epoch writes a row nobody can see.
assert!(!in_export_current(&pool, event_id, "zip").await);
assert_eq!(
downloadable(&pool, event_id, "zip").await,
None,
"a reopened event must serve NO keepsake, however finished the job row looks"
);
}
// ─────────────────────────────────────────────────────────────────────────────
// 3. `export_current` exactness (table-driven)
// ─────────────────────────────────────────────────────────────────────────────
/// The view is the ONE definition of "downloadable". Released + `done` + matching epoch ⇒ present;
/// break ANY single leg ⇒ absent. Nothing else may make it appear or disappear.
///
/// PREVENTS, leg by leg:
/// * `released` — serving a keepsake for an event whose uploads are still open, i.e. an archive
/// missing every photo taken after the snapshot.
/// * `done` — handing out a half-written ZIP (a corrupt keepsake, downloaded once, kept forever).
/// * `epoch` — the retired-generation download: the 404-forever keepsake, or worse, the archive
/// still containing content that was taken down.
#[sqlx::test]
async fn export_current_is_exactly_the_invariant(pool: PgPool) {
// (name, released?, status, job epoch offset from the event epoch, expected visible)
let cases: &[(&str, bool, &str, i64, bool)] = &[
("released + done + current epoch", true, "done", 0, true),
(
"NOT released (done, epoch matches)",
false,
"done",
0,
false,
),
("NOT done: pending", true, "pending", 0, false),
("NOT done: running", true, "running", 0, false),
("NOT done: failed", true, "failed", 0, false),
("stale epoch (done, released)", true, "done", -1, false),
("future epoch (done, released)", true, "done", 1, false),
(
"migration-014 retired sentinel epoch -1",
true,
"done",
-2,
false,
),
];
for (i, (name, released, status, offset, expect_visible)) in cases.iter().enumerate() {
let slug = format!("case{i}");
let event_id = seed_event(&pool, &slug).await;
// Get the event to a known epoch (1) either by releasing it, or — for the unreleased case —
// by releasing and reopening, which leaves it unreleased at a non-zero epoch.
let event_epoch_now = if *released {
release_gallery(&pool, &slug).await.unwrap()
} else {
release_gallery(&pool, &slug).await.unwrap();
open_event(&pool, &slug).await;
event_epoch(&pool, event_id).await
};
// Plant a single ZIP job row in the exact state under test. `-2` encodes "the sentinel the
// migration stamps on retired rows", which must never equal a non-negative event epoch.
// (Clear the rows the release armed first — `UNIQUE (event_id, type)`.)
sqlx::query("DELETE FROM export_job WHERE event_id = $1")
.bind(event_id)
.execute(&pool)
.await
.expect("clear armed jobs");
let job_epoch = if *offset == -2 {
-1
} else {
event_epoch_now + offset
};
sqlx::query(
"INSERT INTO export_job (event_id, type, status, progress_pct, epoch, file_path)
VALUES ($1, 'zip', $2::export_status, 100, $3, 'exports/Gallery.zip')",
)
.bind(event_id)
.bind(*status)
.bind(job_epoch)
.execute(&pool)
.await
.expect("plant job row");
let visible = downloadable(&pool, event_id, "zip").await.is_some();
assert_eq!(
visible, *expect_visible,
"export_current exactness violated for case: {name} \
(released={released}, status={status}, job_epoch={job_epoch}, event_epoch={event_epoch_now})"
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// 4. The ViewerOnly ZIP carry-forward
// ─────────────────────────────────────────────────────────────────────────────
/// Branch A — the ZIP is `done` at the outgoing epoch: the carry-forward re-stamps it to the new
/// epoch (rows_affected = 1), so only the HTML viewer is rebuilt and the finished ZIP stays
/// downloadable throughout.
///
/// PREVENTS: rebuilding a multi-GB archive because someone deleted a comment. The ZIP holds media,
/// not comments — a needless rebuild would 404 the photo download for minutes to change nothing
/// inside it.
#[sqlx::test]
async fn viewer_only_carries_a_done_zip_forward(pool: PgPool) {
let event_id = seed_event(&pool, "wedding").await;
let e1 = release_gallery(&pool, "wedding").await.unwrap();
// Both halves finish at epoch 1 — the keepsake is live.
for t in ["zip", "html"] {
assert!(claim_job(&pool, event_id, t, e1).await);
assert!(finalize_job(&pool, event_id, t, e1, &format!("exports/{t}.{e1}.zip")).await);
}
let zip_file = downloadable(&pool, event_id, "zip")
.await
.expect("zip is live");
// ── A comment is moderated: invalidate_and_arm(Affects::ViewerOnly). ──
let (_, _, e2) = bump_epoch(&pool, "wedding").await.unwrap();
let carried = carry_zip_forward(&pool, event_id, e2).await;
assert!(
carried,
"a `done` ZIP at epoch-1 MUST be carried forward (rows_affected == 1)"
);
// Only the viewer is re-armed…
let mut conn = pool.acquire().await.unwrap();
enqueue_types_at_epoch(&mut conn, event_id, e2, &["html"]).await;
drop(conn);
// …and the ZIP is STILL DOWNLOADABLE, at the new epoch, pointing at the same, unrenamed file.
let (status, epoch, _) = job_row(&pool, event_id, "zip").await.unwrap();
assert_eq!(
(status.as_str(), epoch),
("done", e2),
"the ZIP row rode the epoch bump"
);
assert_eq!(
downloadable(&pool, event_id, "zip").await,
Some(zip_file),
"the carried archive must never stop being served — same file, new epoch"
);
// The viewer, meanwhile, is correctly retired and pending a rebuild.
assert_eq!(job_row(&pool, event_id, "html").await.unwrap().0, "pending");
assert_eq!(downloadable(&pool, event_id, "html").await, None);
}
/// Branch B — THE BUG WE JUST FIXED. If the ZIP is still `pending`/`running` when the comment is
/// moderated (which is MINUTES for a real multi-GB gallery, and deleting a comment right after
/// release is an utterly ordinary thing to do), the carry-forward matches NOTHING
/// (rows_affected = 0) — so the caller must NOT assume it carried, and must re-arm the ZIP too.
///
/// PREVENTS: the stranded ZIP. Blindly re-arming only the viewer would leave the ZIP row at the
/// retired epoch; the in-flight worker then finishes and writes `done` at an epoch `export_current`
/// no longer matches, nothing ever re-arms it, and `GET /export/zip` 404s FOREVER — a keepsake the
/// couple paid for that simply never appears, short of a reboot.
#[sqlx::test]
async fn viewer_only_carry_forward_matches_nothing_when_zip_unfinished(pool: PgPool) {
for zip_state in ["pending", "running"] {
let slug = format!("wedding-{zip_state}");
let event_id = seed_event(&pool, &slug).await;
let e1 = release_gallery(&pool, &slug).await.unwrap();
// The ZIP worker is still going; only the viewer has finished.
if zip_state == "running" {
assert!(claim_job(&pool, event_id, "zip", e1).await);
}
assert!(claim_job(&pool, event_id, "html", e1).await);
assert!(finalize_job(&pool, event_id, "html", e1, "exports/Memories.1.zip").await);
// ── The comment is moderated. ──
let (_, _, e2) = bump_epoch(&pool, &slug).await.unwrap();
let carried = carry_zip_forward(&pool, event_id, e2).await;
assert!(
!carried,
"a {zip_state} ZIP has nothing to carry forward — the UPDATE must affect 0 rows \
(its `status = 'done'` predicate is the whole precondition)"
);
// The carry-forward's OWN result decides. It didn't match ⇒ rebuild the ZIP as well.
let types: &[&str] = if carried { &["html"] } else { &["zip", "html"] };
let mut conn = pool.acquire().await.unwrap();
enqueue_types_at_epoch(&mut conn, event_id, e2, types).await;
drop(conn);
// THE ASSERTION THAT WOULD HAVE CAUGHT THE BUG: the ZIP must not be stranded at the dead
// epoch. It is re-armed at the live one, so a fresh worker will actually build it.
let (status, epoch, _) = job_row(&pool, event_id, "zip").await.unwrap();
assert_eq!(
(status.as_str(), epoch),
("pending", e2),
"the unfinished ZIP MUST be re-armed at the new epoch, not left stranded at {e1}"
);
// Even if the old in-flight worker now "finishes", it is inert and cannot resurrect itself.
assert!(!finalize_job(&pool, event_id, "zip", e1, "exports/Gallery.1.zip").await);
assert_eq!(downloadable(&pool, event_id, "zip").await, None);
}
}
/// The re-arm upsert must never clobber the archive it just carried forward.
///
/// `enqueue_types_at_epoch`'s `WHERE export_job.status <> 'done' OR export_job.epoch <> EXCLUDED.epoch`
/// is the "startup recovery must not clobber a good half" rule, expressed as the readiness predicate
/// itself. PREVENTS: boot recovery resetting a perfectly good, downloadable ZIP back to `pending`
/// and making the keepsake 404 while it needlessly rebuilds.
#[sqlx::test]
async fn enqueue_preserves_a_done_half_at_the_current_epoch(pool: PgPool) {
let event_id = seed_event(&pool, "wedding").await;
let e1 = release_gallery(&pool, "wedding").await.unwrap();
// The ZIP finished; the HTML worker was killed mid-flight (crash) and sits at `running`.
assert!(claim_job(&pool, event_id, "zip", e1).await);
assert!(finalize_job(&pool, event_id, "zip", e1, "exports/Gallery.1.zip").await);
assert!(claim_job(&pool, event_id, "html", e1).await);
// Boot recovery re-arms both types at the SAME epoch.
let mut conn = pool.acquire().await.unwrap();
enqueue_types_at_epoch(&mut conn, event_id, e1, &["zip", "html"]).await;
drop(conn);
// The good half survives untouched…
let (status, epoch, file_path) = job_row(&pool, event_id, "zip").await.unwrap();
assert_eq!(
(status.as_str(), epoch),
("done", e1),
"a done half at the live epoch is preserved"
);
assert_eq!(
file_path.as_deref(),
Some("exports/Gallery.1.zip"),
"file_path not nulled"
);
assert!(downloadable(&pool, event_id, "zip").await.is_some());
// …and only the missing half is re-armed.
assert_eq!(job_row(&pool, event_id, "html").await.unwrap().0, "pending");
}

View File

@@ -1,297 +0,0 @@
//! DB-backed integration tests for the two concurrency guards in the upload commit path
//! (`handlers/upload.rs`). Both are SQL — a `FOR SHARE` row lock and an atomic compare-and-increment
//! — and both are load-bearing for things a user can actually lose: a wedding photo, or the disk.
//!
//! `#[sqlx::test]` gives each test a fresh database with the real migrations applied.
mod common;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use chrono::{DateTime, Utc};
use common::*;
use sqlx::PgPool;
use uuid::Uuid;
/// SRC: `handlers/upload.rs:313-322` — the guarded quota increment, verbatim.
/// Returns `rows_affected()`; the handler aborts the whole upload tx when this is 0.
async fn quota_inc(exec: impl sqlx::PgExecutor<'_>, user_id: Uuid, size: i64, limit: i64) -> u64 {
sqlx::query(
"UPDATE \"user\" SET total_upload_bytes = total_upload_bytes + $2
WHERE id = $1 AND total_upload_bytes + $2 <= $3",
)
.bind(user_id)
.bind(size)
.bind(limit)
.execute(exec)
.await
.expect("quota_inc")
.rows_affected()
}
async fn total_bytes(pool: &PgPool, user_id: Uuid) -> i64 {
sqlx::query_scalar("SELECT total_upload_bytes FROM \"user\" WHERE id = $1")
.bind(user_id)
.fetch_one(pool)
.await
.expect("total_bytes")
}
// ─────────────────────────────────────────────────────────────────────────────
// 5. The atomic quota increment
// ─────────────────────────────────────────────────────────────────────────────
/// Two attempts sized off ONE stale snapshot, each of which "fits" on its own, cannot both commit.
/// The predicate re-reads `total_upload_bytes` inside the UPDATE, so the second matches 0 rows.
///
/// PREVENTS: one guest filling the disk. The handler's pre-flight quota check runs BEFORE the body is
/// streamed — minutes earlier, for a 500 MB video. If the commit trusted that snapshot, a guest could
/// start N uploads that each individually fit under the limit and land all N, blowing straight through
/// the quota and (in a 1 GB container) taking the event down for everyone.
#[sqlx::test]
async fn quota_two_attempts_from_one_stale_snapshot_cannot_both_commit(pool: PgPool) {
let event_id = seed_event(&pool, "wedding").await;
let user_id = seed_user(&pool, event_id, "Gierige Gudrun").await;
const LIMIT: i64 = 100;
const SIZE: i64 = 60;
// THE STALE SNAPSHOT: the pre-flight check both uploads were admitted on.
let snapshot = total_bytes(&pool, user_id).await;
assert_eq!(snapshot, 0);
// Each upload, judged against that snapshot alone, fits: 0 + 60 <= 100. Twice.
assert!(snapshot + SIZE <= LIMIT);
assert_eq!(
quota_inc(&pool, user_id, SIZE, LIMIT).await,
1,
"the first upload commits"
);
assert_eq!(
quota_inc(&pool, user_id, SIZE, LIMIT).await,
0,
"the second MUST affect 0 rows — it was admitted on a snapshot that is now a lie \
(60 + 60 = 120 > 100). rows_affected() == 0 is what makes the handler abort."
);
assert_eq!(
total_bytes(&pool, user_id).await,
SIZE,
"never 120 — the quota held"
);
}
/// The same, but genuinely CONCURRENT: two transactions that both read `total = 0`, then both try to
/// commit 60 bytes against a 100-byte limit. The second UPDATE blocks on the first's row lock and —
/// because every predicate is on the ROW BEING UPDATED — Postgres re-evaluates it against the
/// post-commit row (EPQ) rather than the statement's original snapshot. It matches nothing.
///
/// PREVENTS: exactly the same disk-filling overrun, on the path it actually happens — two uploads
/// in flight at once, which is the normal case at a party.
#[sqlx::test]
async fn quota_guard_is_atomic_under_concurrent_transactions(pool: PgPool) {
let event_id = seed_event(&pool, "wedding").await;
let user_id = seed_user(&pool, event_id, "Gierige Gudrun").await;
const LIMIT: i64 = 100;
const SIZE: i64 = 60;
let mut tx1 = pool.begin().await.unwrap();
let mut tx2 = pool.begin().await.unwrap();
// Both transactions read the same snapshot and both would pass a naive `total + size <= limit`
// check done in Rust.
for tx in [&mut tx1, &mut tx2] {
let seen: i64 = sqlx::query_scalar("SELECT total_upload_bytes FROM \"user\" WHERE id = $1")
.bind(user_id)
.fetch_one(&mut **tx)
.await
.unwrap();
assert_eq!(seen, 0, "both see an empty quota");
}
// tx1 takes the row lock and commits.
assert_eq!(quota_inc(&mut *tx1, user_id, SIZE, LIMIT).await, 1);
tx1.commit().await.unwrap();
// tx2's UPDATE was written against the stale snapshot but is evaluated against the row as it
// now stands.
assert_eq!(
quota_inc(&mut *tx2, user_id, SIZE, LIMIT).await,
0,
"the loser MUST see 0 rows affected — this is the entire quota guarantee"
);
tx2.rollback().await.unwrap();
assert_eq!(total_bytes(&pool, user_id).await, SIZE);
// And an upload that legitimately fits in what's left still succeeds — the guard rejects
// overruns, not everything.
assert_eq!(
quota_inc(&pool, user_id, 40, LIMIT).await,
1,
"0 + 60 + 40 == 100, exactly at the limit"
);
assert_eq!(total_bytes(&pool, user_id).await, LIMIT);
assert_eq!(
quota_inc(&pool, user_id, 1, LIMIT).await,
0,
"and one byte more is refused"
);
}
// ─────────────────────────────────────────────────────────────────────────────
// 6. The `FOR SHARE` upload lock vs. the release
// ─────────────────────────────────────────────────────────────────────────────
/// SRC: `handlers/upload.rs:297-303` — the in-transaction re-check under a row lock, verbatim.
async fn lock_and_read_event(
tx: &mut sqlx::PgConnection,
event_id: Uuid,
) -> (Option<DateTime<Utc>>, Option<DateTime<Utc>>) {
sqlx::query_as(
"SELECT uploads_locked_at, export_released_at FROM event WHERE id = $1 FOR SHARE",
)
.bind(event_id)
.fetch_one(tx)
.await
.expect("FOR SHARE re-check")
}
/// THE GUARD AGAINST SILENT, PERMANENT PHOTO LOSS.
///
/// An upload holding `FOR SHARE` on the event row must BLOCK the `UPDATE event SET
/// export_released_at = NOW()` in `release_gallery` until it commits. Either the upload commits first
/// — and the release (hence the export snapshot) is strictly ordered after it, so the keepsake
/// CONTAINS the photo — or the release commits first and the upload observes the lock and rejects
/// (reversibly: the client keeps the blob and resumes after a reopen).
///
/// PREVENTS: the lost wedding photo. Without this serialization: a guest starts a 500 MB video, the
/// pre-flight lock check passes, the host releases the gallery, the export workers snapshot the
/// uploads table, and THEN the upload commits. The photo appears in the live feed but is missing from
/// the downloaded keepsake, forever — nothing ever regenerates it and nobody ever notices.
#[sqlx::test]
async fn for_share_upload_lock_serializes_against_release(pool: PgPool) {
let event_id = seed_event(&pool, "wedding").await;
let user_id = seed_user(&pool, event_id, "Fotograf Fritz").await;
// ── The guest's upload transaction takes the share lock. ──
let mut upload_tx = pool.begin().await.unwrap();
let (locked, released) = lock_and_read_event(&mut upload_tx, event_id).await;
assert!(
locked.is_none() && released.is_none(),
"uploads are open, so we proceed to commit"
);
// ── Concurrently, the host hits "Galerie freigeben". ──
let release_done = Arc::new(AtomicBool::new(false));
let release_task = {
let pool = pool.clone();
let release_done = release_done.clone();
tokio::spawn(async move {
sqlx::query(
"UPDATE event
SET export_released_at = NOW(),
uploads_locked_at = COALESCE(uploads_locked_at, NOW()),
export_epoch = export_epoch + 1
WHERE id = $1 AND export_released_at IS NULL",
)
.bind(event_id)
.execute(&pool)
.await
.expect("release");
release_done.store(true, Ordering::SeqCst);
})
};
// The release MUST be stuck behind our `FOR SHARE` row lock. (`FOR SHARE` conflicts with the
// `FOR UPDATE` lock the UPDATE needs, so Postgres makes it wait — this is not a timing race,
// it is a lock-conflict guarantee; the sleep only gives it every chance to wrongly proceed.)
tokio::time::sleep(Duration::from_millis(750)).await;
assert!(
!release_done.load(Ordering::SeqCst),
"the release MUST block while an upload holds FOR SHARE — if it can slip past, the export \
snapshot is taken while a photo is still committing and that photo is lost forever"
);
// The photo commits. It is now unambiguously part of the upload set.
let upload_id: Uuid = sqlx::query_scalar(
"INSERT INTO upload (event_id, user_id, original_path, mime_type, original_size_bytes)
VALUES ($1, $2, 'originals/wedding/x.jpg', 'image/jpeg', 1234) RETURNING id",
)
.bind(event_id)
.bind(user_id)
.fetch_one(&mut *upload_tx)
.await
.unwrap();
upload_tx.commit().await.unwrap();
// Only now can the release proceed.
tokio::time::timeout(Duration::from_secs(5), release_task)
.await
.expect("the release must unblock once the upload commits")
.unwrap();
// THE PAYOFF: the export snapshot — the very query the ZIP worker runs — sees the photo. Order
// enforced by the lock: upload commit < release < snapshot.
let snapshot: Vec<Uuid> = sqlx::query_scalar(
"SELECT u.id FROM upload u
JOIN \"user\" usr ON usr.id = u.user_id
WHERE u.event_id = $1 AND u.deleted_at IS NULL
AND usr.uploads_hidden = FALSE AND usr.is_banned = FALSE",
)
.bind(event_id)
.fetch_all(&pool)
.await
.unwrap();
assert_eq!(
snapshot,
vec![upload_id],
"the released keepsake CONTAINS the in-flight photo"
);
}
/// The other side of the same lock: once the release has COMMITTED, the next upload's `FOR SHARE`
/// re-read sees `export_released_at` set and the handler rejects it with `UploadsLocked`.
///
/// PREVENTS: the same lost photo, on the losing side of the race — a photo committing AFTER the
/// export snapshot would be in the live feed but missing from the keepsake. Rejecting is the correct
/// outcome, and it is reversible: `UploadsLocked` (not Forbidden) tells the client to keep the blob
/// and resume when the host reopens.
#[sqlx::test]
async fn upload_after_release_commits_sees_the_lock_and_is_rejected(pool: PgPool) {
let event_id = seed_event(&pool, "wedding").await;
// Before the release, the re-check passes.
let mut tx = pool.begin().await.unwrap();
let (locked, released) = lock_and_read_event(&mut tx, event_id).await;
assert!(locked.is_none() && released.is_none());
tx.rollback().await.unwrap();
assert_eq!(release_gallery(&pool, "wedding").await, Some(1));
// After it, the identical re-check sees the release and the handler bails out.
let mut tx = pool.begin().await.unwrap();
let (locked, released) = lock_and_read_event(&mut tx, event_id).await;
assert!(
released.is_some(),
"the FOR SHARE re-read MUST observe the committed release"
);
assert!(
locked.is_some(),
"release locks uploads in the same statement (release ⇒ lock)"
);
tx.rollback().await.unwrap();
// And a reopen makes it uploadable again — the rejection was reversible, not terminal.
assert_eq!(open_event(&pool, "wedding").await, 1);
let mut tx = pool.begin().await.unwrap();
let (locked, released) = lock_and_read_event(&mut tx, event_id).await;
assert!(
locked.is_none() && released.is_none(),
"the guest can resume their upload"
);
tx.rollback().await.unwrap();
}

View File

@@ -1,41 +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"
app:
# Relax the production secret guard for local dev — the dev sentinel JWT_SECRET
# is tolerated (warned) rather than rejected.
environment:
APP_ENV: development
# `.env` sets DATABASE_URL to @localhost for the run-backend-natively workflow
# (the db port is published above for that). When the app runs IN a container,
# localhost is the app itself — point it at the `db` service instead. Creds are
# interpolated from .env so nothing is hardcoded.
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
# `.env` sets MEDIA_PATH to a HOST path (/home/fabi/EventSnap/media) for the
# run-backend-natively workflow. In a container that path doesn't exist and the
# app user can't create it from `/`, so every upload 500s with EACCES. The media
# volume is mounted at /media (see docker-compose.yml) — point the app there.
MEDIA_PATH: /media
# Recent Docker Compose interpolates env_file values, so the `$` segments of the
# bcrypt ADMIN_PASSWORD_HASH in .env get eaten (the salt reads as an unset var) —
# every admin login then 401s. Re-supply it here with `$` doubled to `$$` so Compose
# passes the literal hash. NOTE: production (docker-compose.yml + .env) has the SAME
# bug — escape the hash as `$$` in .env, or set it via `environment:` there too.
ADMIN_PASSWORD_HASH: "$$2b$$12$$PAteqCNpsbm6d0HTJcywfOaUovjAU.iNVlsL7EDYaRC/z4P/xv7ye"
# Smoke-testing the comment kill-switch: boot-time flag, so it needs a restart
# (not an admin-UI toggle). Backend rejects new comments (403) and the frontend
# hides the whole comment UI. Flip back to true (or drop this line) to restore.
COMMENTS_ENABLED: "false"
caddy:
# The prod caddy service has no env_file, so the Caddyfile's `{$DOMAIN}` expands
# to empty and the site block collapses into a malformed global block. Supply it
# for local dev (from .env → localhost, which Caddy serves with a local self-signed
# cert). NOTE: the prod compose likely needs DOMAIN wired to caddy too.
environment:
DOMAIN: ${DOMAIN}

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

@@ -14,10 +14,6 @@ services:
interval: 5s
timeout: 5s
retries: 10
deploy:
resources:
limits:
memory: 512M
app:
build:
@@ -25,42 +21,27 @@ 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:
# Activates the production secret guard in config.rs — refuses to boot with
# placeholder JWT_SECRET / ADMIN_PASSWORD_HASH.
APP_ENV: production
# The media volume is mounted at /media (below), so the app MUST write there.
# Pin it here rather than trusting .env: if MEDIA_PATH in .env points elsewhere
# (e.g. a host path used for running the backend natively) the container can't
# create it and every upload 500s with EACCES. `environment` overrides `env_file`,
# so this is authoritative for the container.
MEDIA_PATH: /media
# 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
volumes:
- media_data:/media
# Export archives live OUTSIDE /media so the public media ServeDir can't
# serve them — downloads go only through the ticket-gated handler.
- exports_data:/exports
expose:
- "3000"
# /health is unauthenticated; start_period covers the boot-time migrations.
healthcheck:
# Use 127.0.0.1, NOT localhost: the app binds IPv4 (0.0.0.0) but `localhost`
# resolves to ::1 (IPv6) first inside the container, so a localhost probe gets
# "connection refused" and the container never turns healthy — which would leave
# Caddy (gated on `condition: service_healthy` below) blocked forever on boot.
test: ["CMD-SHELL", "wget -q -O- http://127.0.0.1:3000/health || exit 1"]
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://localhost:3000/health"]
interval: 10s
timeout: 5s
retries: 5
start_period: 20s
deploy:
resources:
limits:
# Bounds a runaway ffmpeg transcode (large uploads, 2 workers) so it can't
# OOM the single box and take down Postgres.
memory: 1G
start_period: 30s
frontend:
build:
@@ -68,54 +49,36 @@ services:
dockerfile: Dockerfile
restart: unless-stopped
env_file: .env
environment:
# adapter-node behind Caddy TLS needs the public origin for CSRF checks on
# POST form actions — without it they fail only in production.
ORIGIN: "https://${DOMAIN}"
depends_on:
- app
app:
condition: service_healthy
expose:
- "3001"
healthcheck:
# 127.0.0.1, not localhost — see the app healthcheck note above (IPv4 bind vs
# ::1 resolution would leave this container permanently unhealthy).
test: ["CMD-SHELL", "wget -q -O- http://127.0.0.1:3001/ >/dev/null 2>&1 || exit 1"]
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://localhost:3001/"]
interval: 10s
timeout: 5s
retries: 5
start_period: 15s
deploy:
resources:
limits:
memory: 256M
caddy:
image: caddy:2-alpine
restart: unless-stopped
environment:
# The Caddyfile's site address is `{$DOMAIN}`, read from THIS container's env.
# Without it, `{$DOMAIN}` expands to empty, the site block collapses, and Caddy
# serves nothing / fails to obtain a TLS cert. `env_file` alone wouldn't help —
# Caddy needs it in `environment`, and this keeps the Caddyfile the single source.
DOMAIN: ${DOMAIN}
ports:
- "80:80"
- "443:443"
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:
condition: service_healthy
frontend:
condition: service_healthy
deploy:
resources:
limits:
memory: 256M
volumes:
postgres_data:
media_data:
exports_data:
caddy_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,77 +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): server-side MIME/ext
allowlist on upload, recovery-PIN lockout backoff, DB port no longer publicly exposed, bcrypt
offloaded via `spawn_blocking`, bounded compression concurrency (semaphore), bounded feed queries
(`LIMIT ≤ 100`), and the viewport-fit / reduced-motion / aria a11y pass. An image-decode
decompression-bomb cap (`image::Limits` 12000×12000 / 256 MiB) lives in
`backend/src/services/compression.rs`.
**Fixed in the 2026-07 review pass** (previously *claimed* fixed here but were not — corrected):
- **Effective JWT production-secret guard** — `APP_ENV=production` is now set for the app service,
and `config.rs::validate_secrets` rejects any placeholder-ish `JWT_SECRET`/`ADMIN_PASSWORD_HASH`
(not just the exact dev sentinel) and enforces `len ≥ 32` unconditionally in prod.
- **Unspoofable client IP in the rate limiter** — `client_ip` now takes the right-most
`X-Forwarded-For` entry (the hop Caddy appends), so a client-supplied left-most value is ignored.
- **Live role/ban re-check** — the auth extractor re-reads the user row and trusts the DB `role`
and `is_banned` flag rather than the JWT claim, so a demote/ban takes effect immediately (no
30-day token window). Bans are enforced on write handlers + the host/admin extractors, preserving
the documented read-only access for banned guests (USER_JOURNEYS §10); demoted hosts lose host
powers at once.
- **Container healthchecks** — `app` and `frontend` now have healthchecks and Caddy waits on
`service_healthy`.
- **Event-scoped `ban_user`** — the ban UPDATE is now scoped by `event_id` like its siblings.
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
@@ -84,24 +36,16 @@ tenancy model changes.
- Performance micro-indexes (`idx_like_user_upload`, comment pagination index) — current queries
are sub-ms at this row count.
- Optimistic-like in-flight guard, ownership-snapshot-at-mount, assorted copy tweaks — UX polish.
- **Mid-session ban does not tear down an already-open SSE stream.** The live-ban check lives in the
`AuthUser` extractor, but the stream authenticates via ticket→session (`handlers/sse.rs::stream`),
so a user banned while connected keeps receiving broadcast events until their stream drops. New
tickets *are* blocked (`issue_ticket` uses `AuthUser`), so they cannot reconnect. Low blast radius
(read-only feed events, no re-subscribe); tearing live streams down would need a per-session
broadcast filter. Revisit only if bans must take effect within seconds.
## 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

@@ -129,49 +129,30 @@ the Host can clean up later).
viewer). Progress is visible in the Admin dashboard's Export tab; SSE
`export-progress` keeps it live; `export-available` notifies all guests when ready.
5. **Nutzerverwaltung** — search users; per-user controls:
- **Sperren** opens a confirmation modal. Banning **always hides** the user's existing
uploads (a banned user's content is "gone" everywhere) — there is no opt-out. Submitting
calls `POST /host/users/{id}/ban` (no body).
- **Entsperren** lifts the ban. Same authority boundary as ban (below): a plain Host may
only unban Guests; only an Admin may unban a Host.
- **Host** promotes a guest to host (Hosts and Admins may do this).
- **Degradieren** — demote a Host back to guest. **Only an Admin may change a Host's
role.** A plain Host may *not* demote a peer Host: doing so would let them then ban or
PIN-reset (→ `/recover` account-takeover) that ex-peer, since those guards key off the
target's *current* role. So the backend rejects it (403) and the button is hidden for
non-admin Hosts. Nobody may change their own role (self-lockout / self-escalation guard),
and Admins are un-demotable and un-bannable by anyone. (This tightens an earlier
"Hosts may demote other Hosts" design — see the F1 security fix.)
- **Sperren / PIN zurücksetzen** — a plain Host may act on Guests only; an Admin may act on
Guests + Hosts; nobody may act on an Admin. The buttons are hidden where they'd 403.
PIN reset generates a new PIN, shows it once in a modal, and revokes the target's
existing sessions (forcing re-auth with the new PIN). See journey §4.
- **Sperren** opens a confirmation modal with a checkbox "Uploads aus der Galerie
ausblenden" — Host chooses whether to hide the user's existing uploads or leave them
visible. Submitting calls `POST /host/users/{id}/ban` with `hide_uploads`.
- **Entsperren** lifts the ban.
- **Host** promotes a guest to host.
- **Degradieren** — visible on Host rows. A Host can demote *other* Hosts back to
guest (planned). The button is hidden on the Host's own row to prevent self-lockout;
only an Admin can demote themselves out of moderation. Admins see Degradieren on
every Host row.
- **PIN zurücksetzen** (planned) — generates a new PIN and shows it once in a modal.
See journey §4. Hosts see this on Guest rows only; Admins see it on Guest + Host
rows.
6. **Deleting content** — Host can delete any upload or comment via the moderation routes
(`DELETE /host/upload/{id}`, `DELETE /host/comment/{id}`). On mobile this is also
reachable by long-pressing the content (planned, see §15).
## 10. Banned-guest experience
1. The ban takes effect immediately on the banned user's existing session — the auth
layer re-reads the live `is_banned` flag on every request rather than trusting the
JWT, so there's no 30-day token-lifetime window.
2. Their next authenticated *write* request returns HTTP 403 with a clear message
("Du bist gesperrt."). Ban enforcement lives on the write handlers and the
host/admin extractors, not the base auth extractor.
3. They can still browse the read-only feed (and download the export once it's released).
Their sessions are **not** revoked — this is a read-only ban, not a logout. Their own
live SSE stream is dropped by the `is_banned` revalidation (no live pushes), but plain
feed reads still work.
4. They cannot upload, like, or comment; a banned host also loses all host/admin
actions (including unbanning themselves).
5. Banning **always hides**: the user's existing uploads are filtered out of the feed for
everyone (`v_feed`, `find_visible_media`, and the export query all enforce
`is_banned = FALSE`), and a live `user-hidden` SSE event evicts their cards from every
open feed + the diashow without a reload. A client that was offline/disconnected during
the live event doesn't miss the eviction: `uploads_hidden_at` is stamped at ban time
(migration 013) and the reconnect delta (`GET /feed/delta`) returns the banned users in
`hidden_user_ids`, so the feed and diashow replay the eviction on the next reconnect —
the projector-missed-the-live-push case is exactly why this exists.
1. The banned user's next authenticated request returns HTTP 403 with a clear message
("Du bist gesperrt.").
2. They can still browse the read-only feed (and download the export once it's released).
3. They cannot upload, like, or comment.
4. If `hide_uploads` was set on the ban, their existing uploads are filtered out of the
feed for everyone (the `v_feed` view already enforces this).
## 11. Admin — instance configuration
@@ -193,18 +174,7 @@ the Host can clean up later).
## 12. Releasing the export and downloading
1. Host (or Admin) taps **Galerie freigeben** in the dashboard.
2. Server sets `event.export_released_at`, locks uploads, **bumps `event.export_epoch`**, and
enqueues two background jobs — all in ONE transaction (workers spawn after it commits, so a
client disconnecting mid-request can never leave the event released with no export to build).
The **epoch** is the whole generation model (migration 014). It is bumped in the same UPDATE as
any change to `export_released_at` — release and reopen are its only writers — and each
`export_job` row carries a copy of the epoch it was enqueued for. An export is downloadable
**iff** `released AND job.epoch = event.export_epoch AND job.status = 'done'`. Readiness is
therefore *derived*, never stored: it cannot drift, and a worker whose epoch has been retired
(by a reopen, a re-release, or a takedown) is inert — anything it writes is simply invisible.
A reopen retires the current keepsake instantly, which is why a reopened event serves no export
until the host releases again.
2. Server sets `event.export_released_at` and enqueues two background jobs.
3. ZIP job: streams `Gallery.zip` (`Photos/` + `Videos/`, full-quality originals) directly
to disk via `async-zip`. Progress updates via `export-progress` SSE.
4. HTML-viewer job: copies the pre-built viewer assets from
@@ -212,11 +182,6 @@ the Host can clean up later).
`include_dir!`), generates `data.json` from the database, processes `_thumb`/`_full`
variants for each upload, and assembles `Memories.zip`.
5. Both jobs complete → server broadcasts `export-available` SSE.
**Takedowns:** if a host deletes an upload (or a comment) while the gallery is released, the
epoch is bumped and the keepsake is REGENERATED without it. Otherwise a photo removed on request
would live on forever in the already-generated archive — the one place it most needs to be gone.
The download 404s for the few seconds it takes to rebuild, which is the correct answer: serving
the old archive would serve the deleted photo.
6. Any user opens `/export`:
- Before release: friendly "Export not yet available" banner.
- During generation: progress bars per artifact.

View File

@@ -1,4 +0,0 @@
node_modules/
playwright-report/
test-results/
package-lock.json

View File

@@ -1,7 +0,0 @@
{
"useTabs": false,
"tabWidth": 2,
"singleQuote": true,
"trailingComma": "es5",
"printWidth": 100
}

View File

@@ -4,17 +4,7 @@
# of HTTPS/Let's Encrypt.
:3101 {
# Mirror prod: exclude the SSE stream from compression so buffering doesn't
# delay real-time events (and so the test stack exercises the real behavior).
@compressible not path /api/v1/stream
encode @compressible zstd gzip
# Mirror prod's security headers (minus HSTS, which is HTTPS-only).
header {
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "strict-origin-when-cross-origin"
}
encode zstd gzip
reverse_proxy /api/* app:3000
reverse_proxy /media/* app:3000

View File

@@ -6,7 +6,6 @@ backend) and `:55432` (Postgres), and exercises the SvelteKit frontend
against a real Rust backend with rate limits and quotas disabled.
**Phases 1, 2, and 3-mobile-gestures are landed**:
- **Phase 1** — happy-path coverage of every documented user journey, plus a
smoke matrix across nine browser/UA profiles to catch engine-level
divergences.
@@ -48,25 +47,25 @@ The CI workflow at `.github/workflows/e2e.yml` runs both jobs on every PR.
Every spec covers a journey from [`docs/USER_JOURNEYS.md`](../docs/USER_JOURNEYS.md)
or a security/chaos scenario. One folder per area:
| Folder | Phase | Journeys / Topic | Tests | Notes |
| ------------------------- | ----- | ----------------------------------------------------------------------------------------------- | --------- | ------------------------------------------------------------- |
| `specs/01-auth/` | 1 | §1, §2, §3, §11, §15 | 13 | Join, recover, PIN lockout, admin login, leave event. |
| `specs/02-upload/` | 1 | §5, §6, §18 | 5 | Gallery picker, multi-file, rate-limit, admin toggle. |
| `specs/03-feed/` | 1 | §7, §8, §17 | 5 | Like/comment SSE, filter chips, SSE reconnect. |
| `specs/04-host/` | 1 | §9 | 5 | Event lock, ban/unban, role change. |
| `specs/05-admin/` | 1 | §11, §16 | 11 | Config validation, foundational auth guards, stats. |
| `specs/06-export/` | 1 | §12 | 3 | Status, release, download stub. |
| `specs/__smoke/` | 1 | (matrix) | 1 × 9 UAs | `@smoke`-tagged happy-path on every UA project. |
| `specs/07-adversarial/` | **2** | Input attacks, file upload boundaries, JWT forgery, brute-force, deep authorization, small DDoS | ~40 | See breakdown below. |
| `specs/08-browser-chaos/` | **2** | Storage purge, IndexedDB, offline/slow-3G, multi-tab, no-JS, clock skew, quota | ~20 | See breakdown below. |
| `specs/09-mobile/` | **3** | Touch-target audit, safe-area, long-press, double-tap, viewport reflow, fixme stubs | 23 | Runs only on `chromium-mobile` (Pixel 7 viewport). See below. |
| Folder | Phase | Journeys / Topic | Tests | Notes |
|---|---|---|---|---|
| `specs/01-auth/` | 1 | §1, §2, §3, §11, §15 | 13 | Join, recover, PIN lockout, admin login, leave event. |
| `specs/02-upload/` | 1 | §5, §6, §18 | 5 | Gallery picker, multi-file, rate-limit, admin toggle. |
| `specs/03-feed/` | 1 | §7, §8, §17 | 5 | Like/comment SSE, filter chips, SSE reconnect. |
| `specs/04-host/` | 1 | §9 | 5 | Event lock, ban/unban, role change. |
| `specs/05-admin/` | 1 | §11, §16 | 11 | Config validation, foundational auth guards, stats. |
| `specs/06-export/` | 1 | §12 | 3 | Status, release, download stub. |
| `specs/__smoke/` | 1 | (matrix) | 1 × 9 UAs | `@smoke`-tagged happy-path on every UA project. |
| `specs/07-adversarial/` | **2** | Input attacks, file upload boundaries, JWT forgery, brute-force, deep authorization, small DDoS | ~40 | See breakdown below. |
| `specs/08-browser-chaos/` | **2** | Storage purge, IndexedDB, offline/slow-3G, multi-tab, no-JS, clock skew, quota | ~20 | See breakdown below. |
| `specs/09-mobile/` | **3** | Touch-target audit, safe-area, long-press, double-tap, viewport reflow, fixme stubs | 23 | Runs only on `chromium-mobile` (Pixel 7 viewport). See below. |
### Phase 2 — adversarial (`specs/07-adversarial/`)
- **`xss-injection.spec.ts`** — 13 tests. Six XSS payloads × display-name path
- four SQLi patterns + length/encoding edge cases (NUL byte, RTL override,
caption overflow). Asserts `window.__xssFired` never gets set and no
`dialog` event fires.
+ four SQLi patterns + length/encoding edge cases (NUL byte, RTL override,
caption overflow). Asserts `window.__xssFired` never gets set and no
`dialog` event fires.
- **`ui-rendering.spec.ts`** — 2 tests. Belt-and-braces: even when a script-
payload sits in localStorage as the user's display name, rendering through
`/account` keeps it as text.
@@ -105,17 +104,17 @@ are marked `test.fixme` and will activate when that helper lands.
## Browser & UA matrix
| Project | Engine | UA / Device | Why |
| --------------------- | -------- | ----------------------------------- | --------------------------------------------- |
| `chromium-desktop` | Chromium | Desktop Chrome | Baseline. Full suite runs here. |
| `chromium-pixel7` | Chromium | Pixel 7 device descriptor | Chrome Android. |
| `chromium-galaxy-s22` | Chromium | Galaxy viewport + Samsung phone UA | Chrome on Samsung hardware. |
| `samsung-internet` | Chromium | Galaxy viewport + SamsungBrowser UA | **Tier-A Samsung Internet baseline.** |
| `edge-android` | Chromium | Pixel viewport + EdgA UA | Edge Mobile (Blink-based). |
| `chrome-ios` | Chromium | iPhone viewport + CriOS UA | Chrome iOS (actually WebKit, but UA differs). |
| `webkit-iphone` | WebKit | iPhone 14 Pro | Real iOS Safari engine. |
| `firefox-android` | Firefox | Pixel viewport + Firefox Android UA | Gecko engine. |
| `firefox-desktop` | Firefox | Desktop Firefox | FF-specific quirks. |
| Project | Engine | UA / Device | Why |
|---|---|---|---|
| `chromium-desktop` | Chromium | Desktop Chrome | Baseline. Full suite runs here. |
| `chromium-pixel7` | Chromium | Pixel 7 device descriptor | Chrome Android. |
| `chromium-galaxy-s22` | Chromium | Galaxy viewport + Samsung phone UA | Chrome on Samsung hardware. |
| `samsung-internet` | Chromium | Galaxy viewport + SamsungBrowser UA | **Tier-A Samsung Internet baseline.** |
| `edge-android` | Chromium | Pixel viewport + EdgA UA | Edge Mobile (Blink-based). |
| `chrome-ios` | Chromium | iPhone viewport + CriOS UA | Chrome iOS (actually WebKit, but UA differs). |
| `webkit-iphone` | WebKit | iPhone 14 Pro | Real iOS Safari engine. |
| `firefox-android` | Firefox | Pixel viewport + Firefox Android UA | Gecko engine. |
| `firefox-desktop` | Firefox | Desktop Firefox | FF-specific quirks. |
Only the `@smoke` happy-path runs across all projects (controlled by
`grep` in `playwright.config.ts`). The full Phase 1 suite is
@@ -128,14 +127,14 @@ It's **Blink-based**, so Tier-A catches ~90% of regressions. Real Samsung
divergences (Smart Switch save-data mode, dark-mode injection, custom
autoplay, in-browser ad blocking) are only reproducible at Tier B+:
- **Tier A** _(this repo, free, in CI)_: Playwright Chromium with the
- **Tier A** *(this repo, free, in CI)*: Playwright Chromium with the
Samsung Internet user-agent + Galaxy viewport. See the `samsung-internet`
project in `playwright.config.ts`.
- **Tier B** _(free, manual, future)_: Android Studio emulator on Linux →
- **Tier B** *(free, manual, future)*: Android Studio emulator on Linux →
install Samsung Internet APK → enable `--remote-debugging-port=9222`
`chromium.connectOverCDP('http://localhost:9222')`. Setup docs live in
`docs/samsung-emulator.md` (to be written).
- **Tier C** _(paid, optional)_: BrowserStack or LambdaTest cloud devices.
- **Tier C** *(paid, optional)*: BrowserStack or LambdaTest cloud devices.
Real Galaxy S22/S23 hardware via Playwright's cloud integration.
## Test isolation
@@ -224,7 +223,7 @@ Known findings surfaced (documented in tests, not silent failures):
clears it).
3. SVG uploads currently pass the magic-byte check (depends on `infer`'s
detection coverage) — consider adding `X-Content-Type-Options: nosniff`
- CSP on `/media/*` if SVGs are ever expected as user content.
+ CSP on `/media/*` if SVGs are ever expected as user content.
### Phase 3 — Mobile gestures (`specs/09-mobile/`) ✅ landed
@@ -274,7 +273,6 @@ ignores this folder via `testIgnore` in [playwright.config.ts](playwright.config
values.
### Phase 3 — Real-device compat & visual / a11y (not landed)
- Long-press own/other post, swipe lightbox L/R, swipe-down dismiss, pull-to-refresh, double-tap like.
- Safe-area inset visual diff on iPhone notch.
- Touch-target ≥ 44 px audit.
@@ -284,7 +282,6 @@ ignores this folder via `testIgnore` in [playwright.config.ts](playwright.config
- Visual regression with screenshot diffs.
### Out of scope (handed to other tools)
- Load testing → k6 / Vegeta.
- API contract testing → backend `cargo test` integration tests.
- Static asset auditing → Lighthouse CI.

View File

@@ -17,12 +17,12 @@ services:
POSTGRES_PASSWORD: eventsnap_test
POSTGRES_DB: eventsnap_test
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U eventsnap_test -d eventsnap_test']
test: ["CMD-SHELL", "pg_isready -U eventsnap_test -d eventsnap_test"]
interval: 3s
timeout: 3s
retries: 30
ports:
- '55432:5432' # exposed so the e2e harness can connect via pg for fixture setup
- "55432:5432" # exposed so the e2e harness can connect via pg for fixture setup
app:
build:
@@ -40,15 +40,15 @@ services:
ADMIN_PASSWORD_HASH: $$2b$$04$$XKJJkNX6BOi6y3S42DA5JOWwk4oxc8DHPL6.MrPfJI2vpnccZjP32
EVENT_SLUG: e2e-test-event
EVENT_NAME: E2E Test Event
APP_PORT: '3000'
APP_PORT: "3000"
MEDIA_PATH: /media
SESSION_EXPIRY_DAYS: '30'
EVENTSNAP_TEST_MODE: '1' # ENABLES /admin/__truncate — never set in prod
SESSION_EXPIRY_DAYS: "30"
EVENTSNAP_TEST_MODE: "1" # ENABLES /admin/__truncate — never set in prod
RUST_LOG: eventsnap_backend=info,tower_http=warn
volumes:
- media_data:/media
expose:
- '3000'
- "3000"
frontend:
build:
@@ -57,11 +57,11 @@ services:
depends_on:
- app
environment:
PORT: '3001'
HOST: '0.0.0.0'
ORIGIN: 'http://localhost:3101'
PORT: "3001"
HOST: "0.0.0.0"
ORIGIN: "http://localhost:3101"
expose:
- '3001'
- "3001"
caddy:
image: caddy:2-alpine
@@ -71,7 +71,7 @@ services:
volumes:
- ./Caddyfile.test:/etc/caddy/Caddyfile:ro
ports:
- '3101:3101'
- "3101:3101"
volumes:
media_data:

View File

@@ -1,47 +0,0 @@
import js from '@eslint/js';
import ts from 'typescript-eslint';
import prettier from 'eslint-config-prettier';
import globals from 'globals';
/**
* Flat-config ESLint for the Playwright TypeScript suite. Prettier owns formatting (its config is
* last so it disables every stylistic rule). The rules kept here catch real test bugs — unused
* setup, floating promises that make a test race, `any` that hides a wrong assertion shape.
*/
export default ts.config(
js.configs.recommended,
...ts.configs.recommended,
prettier,
{
languageOptions: {
globals: { ...globals.node },
},
},
{
rules: {
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' },
],
// A floating promise in a test is a real hazard: an un-awaited request or assertion can let
// the test end before it runs, passing vacuously. Keep it on.
'@typescript-eslint/no-floating-promises': 'error',
// Off for the suite: this is all test code, where `any` is the honest type for an untyped
// `res.json()` body or a `page.evaluate()` return. Threading DTO types through every
// assertion is churn that buys nothing — the assertion values are what's checked, not the
// static shape. (no-floating-promises and no-unused-vars, which catch real test bugs, stay on.)
'@typescript-eslint/no-explicit-any': 'off',
// Off: Playwright fixtures with no dependencies are declared `async ({}, use) => {}` — the
// empty destructure is required by the fixtures API, not an accident.
'no-empty-pattern': 'off',
},
},
{
languageOptions: {
parserOptions: { projectService: true },
},
},
{
ignores: ['node_modules/', 'playwright-report/', 'test-results/', '*.config.js'],
}
);

View File

@@ -47,9 +47,7 @@ export class ApiClient {
}
// ── Auth ───────────────────────────────────────────────────────────────
async join(
displayName: string
): Promise<{ jwt: string; pin: string; user_id: string; is_new: boolean }> {
async join(displayName: string): Promise<{ jwt: string; pin: string; user_id: string; is_new: boolean }> {
const { body } = await this.request<any>('POST', '/join', {
body: { display_name: displayName },
expectedStatus: [201],
@@ -57,11 +55,7 @@ export class ApiClient {
return body;
}
async recover(
displayName: string,
pin: string,
opts: { expectedStatus?: number | number[] } = {}
) {
async recover(displayName: string, pin: string, opts: { expectedStatus?: number | number[] } = {}) {
return this.request<any>('POST', '/recover', {
body: { display_name: displayName, pin },
expectedStatus: opts.expectedStatus ?? [200],
@@ -97,9 +91,7 @@ export class ApiClient {
}
async getConfig(adminToken: string): Promise<Record<string, string>> {
const { body } = await this.request<Record<string, string>>('GET', '/admin/config', {
token: adminToken,
});
const { body } = await this.request<Record<string, string>>('GET', '/admin/config', { token: adminToken });
return body;
}
@@ -117,43 +109,14 @@ export class ApiClient {
});
}
// A ban ALWAYS hides the user's uploads — the backend takes no body and ignores any
// `hide_uploads` flag (the old opt-out was removed). No per-request options.
async banUser(token: string, userId: string) {
async banUser(token: string, userId: string, hideUploads = false) {
return this.request<void>('POST', `/host/users/${userId}/ban`, {
token,
body: { hide_uploads: hideUploads },
expectedStatus: [200, 204],
});
}
async unbanUser(
token: string,
userId: string,
opts: { expectedStatus?: number | number[] } = {}
) {
return this.request<void>('POST', `/host/users/${userId}/unban`, {
token,
expectedStatus: opts.expectedStatus ?? [200, 204],
});
}
/** Reset another user's PIN. Returns the plaintext PIN the host must relay once. */
async resetUserPin(
token: string,
userId: string,
opts: { expectedStatus?: number | number[] } = {}
): Promise<{ status: number; body: { pin?: string } }> {
return this.request<{ pin?: string }>('POST', `/host/users/${userId}/pin-reset`, {
token,
expectedStatus: opts.expectedStatus ?? [200],
});
}
async listPinResetRequests(token: string): Promise<any[]> {
const { body } = await this.request<any[]>('GET', '/host/pin-reset-requests', { token });
return body;
}
async closeEvent(token: string) {
return this.request<void>('POST', '/host/event/close', { token, expectedStatus: [200, 204] });
}

View File

@@ -39,16 +39,11 @@ export const db = {
async expireSession(userId: string) {
await withClient((c) =>
c.query(`UPDATE session SET expires_at = NOW() - interval '1 hour' WHERE user_id = $1`, [
userId,
])
c.query(`UPDATE session SET expires_at = NOW() - interval '1 hour' WHERE user_id = $1`, [userId])
);
},
async setUploadCompressionStatus(
uploadId: string,
status: 'pending' | 'processing' | 'done' | 'failed'
) {
async setUploadCompressionStatus(uploadId: string, status: 'pending' | 'processing' | 'done' | 'failed') {
await withClient((c) =>
c.query(`UPDATE upload SET compression_status = $2 WHERE id = $1`, [uploadId, status])
);
@@ -64,26 +59,6 @@ export const db = {
});
},
async countSessionsForUser(userId: string): Promise<number> {
return withClient(async (c) => {
const r = await c.query<{ count: string }>(
`SELECT COUNT(*)::text AS count FROM session WHERE user_id = $1`,
[userId]
);
return Number(r.rows[0].count);
});
},
async countPinResetRequestsForUser(userId: string): Promise<number> {
return withClient(async (c) => {
const r = await c.query<{ count: string }>(
`SELECT COUNT(*)::text AS count FROM pin_reset_request WHERE user_id = $1`,
[userId]
);
return Number(r.rows[0].count);
});
},
async setExportReleased(slug: string, released: boolean) {
await withClient((c) =>
c.query(`UPDATE event SET export_released_at = $2 WHERE slug = $1`, [
@@ -93,56 +68,16 @@ export const db = {
);
},
/**
* Make an export "ready" (or not) in the epoch model. There is no `export_zip_ready` column any
* more — readiness is DERIVED (`released AND job.epoch = event.export_epoch AND status='done'`),
* so a job is ready exactly when its row carries the event's live epoch. To make a `done` job NOT
* ready we retire it to a dead epoch (-1), which is what a reopen effectively does.
*
* `file_path` is deliberately left NULL, so a "ready" job with no file on disk still exercises
* the download's missing-file 404 branch.
*/
async setExportZipReady(slug: string, ready: boolean) {
await withClient((c) =>
c.query(
`UPDATE export_job ej
SET epoch = CASE WHEN $2 THEN e.export_epoch ELSE -1 END
FROM event e
WHERE e.id = ej.event_id AND e.slug = $1 AND ej.type = 'zip'`,
[slug, ready]
)
);
},
/**
* Insert a pre-baked export job row to skip the (slow) real compression path. Stamped with the
* event's CURRENT epoch so it counts as the live generation.
*/
async fakeExportJob(
eventSlug: string,
type: 'zip' | 'html',
status: 'pending' | 'running' | 'done'
) {
/** 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) => {
const ev = await c.query<{ id: string; export_epoch: string }>(
`SELECT id, export_epoch FROM event WHERE slug = $1`,
[eventSlug]
);
const ev = await c.query<{ id: string }>(`SELECT id FROM event WHERE slug = $1`, [eventSlug]);
if (ev.rows.length === 0) throw new Error(`No event with slug ${eventSlug}`);
await c.query(
`INSERT INTO export_job (event_id, type, status, progress_pct, completed_at, epoch)
VALUES ($1, $2::export_type, $3::export_status, $4, $5, $6)
ON CONFLICT (event_id, type) DO UPDATE
SET status = EXCLUDED.status, progress_pct = EXCLUDED.progress_pct,
epoch = EXCLUDED.epoch`,
[
ev.rows[0].id,
type,
status,
status === 'done' ? 100 : 0,
status === 'done' ? new Date() : null,
ev.rows[0].export_epoch,
]
`INSERT INTO export_job (event_id, type, status, progress_pct, completed_at)
VALUES ($1, $2::export_type, $3::export_status, $4, $5)
ON CONFLICT (event_id, type) DO UPDATE SET status = EXCLUDED.status, progress_pct = EXCLUDED.progress_pct`,
[ev.rows[0].id, type, status, status === 'done' ? 100 : 0, status === 'done' ? new Date() : null]
);
});
},

View File

@@ -84,13 +84,16 @@ export const test = base.extend<Fixtures>({
const fn = async (page: Page, handle: GuestHandle) => {
// Visit any in-app URL first so localStorage is scoped to the right origin.
await page.goto('/');
await page.evaluate(({ jwt, pin, userId, displayName }) => {
localStorage.setItem('eventsnap_jwt', jwt);
localStorage.setItem('eventsnap_pin', pin);
localStorage.setItem('eventsnap_user_id', userId);
localStorage.setItem('eventsnap_display_name', displayName);
localStorage.setItem('eventsnap_guide_seen', 'true');
}, handle);
await page.evaluate(
({ jwt, pin, userId, displayName }) => {
localStorage.setItem('eventsnap_jwt', jwt);
localStorage.setItem('eventsnap_pin', pin);
localStorage.setItem('eventsnap_user_id', userId);
localStorage.setItem('eventsnap_display_name', displayName);
localStorage.setItem('eventsnap_guide_seen', 'true');
},
handle
);
await page.goto('/feed');
};
await use(fn);

View File

@@ -1,5 +0,0 @@
/**
* Shared test environment constants. The frontend base URL was redeclared verbatim in ~23 specs;
* one source of truth means a port/scheme change is a single edit, not a sweep.
*/
export const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';

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