Compare commits
109 Commits
fix/audit-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a77c2ddc00 | ||
|
|
40c6fd2ccb | ||
|
|
e69ec4d736 | ||
|
|
3fb1b5d80d | ||
|
|
e1ca9d192f | ||
|
|
5009590882 | ||
|
|
d9738a4cb9 | ||
|
|
669a191968 | ||
|
|
a1733b03d5 | ||
|
|
4026648f98 | ||
|
|
44641473ea | ||
|
|
57a907eca5 | ||
|
|
6e0a760271 | ||
|
|
0abf413693 | ||
|
|
002355ba40 | ||
|
|
6155b4123d | ||
|
|
7758270cac | ||
|
|
9b8698f86b | ||
|
|
3c3a7d0082 | ||
|
|
3654aca18b | ||
|
|
f243bfe89a | ||
|
|
5546fb82e6 | ||
|
|
e2b7e54af9 | ||
|
|
15d338eeb8 | ||
|
|
461b1eaf65 | ||
|
|
460258c451 | ||
|
|
bbdfae09a0 | ||
|
|
f8cba95e49 | ||
|
|
4d14df18d0 | ||
|
|
ee554e7f38 | ||
|
|
0fa40ddf80 | ||
|
|
c48d43f5b3 | ||
|
|
db7c4459d7 | ||
|
|
dd7b05415e | ||
|
|
d2ad560df2 | ||
|
|
d452afb00e | ||
|
|
3f74c65787 | ||
|
|
02971f3186 | ||
|
|
e5201a9889 | ||
|
|
c229b560d8 | ||
|
|
af997a84dd | ||
|
|
2bef6e19ef | ||
|
|
db88230221 | ||
|
|
bf68bc08f4 | ||
|
|
38e34fddf1 | ||
|
|
3c683247c0 | ||
|
|
0447a6ad0e | ||
|
|
9666d74a46 | ||
|
|
5affef47cf | ||
|
|
8e906d7866 | ||
|
|
1148c2e906 | ||
|
|
9f3712894d | ||
|
|
c197b2c025 | ||
|
|
d643256f36 | ||
|
|
99f79e2898 | ||
|
|
df275bbefa | ||
|
|
768e712a26 | ||
|
|
811c724685 | ||
|
|
c647ddfa6b | ||
|
|
36fe59caa5 | ||
|
|
641174717c | ||
|
|
16d1f356be | ||
|
|
a4b2c5bd1c | ||
|
|
683b1b6f65 | ||
|
|
d6c91974eb | ||
|
|
4cdb3ae14a | ||
|
|
faf7a2504a | ||
|
|
14c667c694 | ||
|
|
3f6dafba05 | ||
|
|
0ed97f45cf | ||
|
|
b3d876fa85 | ||
|
|
80179357a7 | ||
|
|
dba4d3f932 | ||
|
|
239459bb91 | ||
|
|
41ac742af8 | ||
|
|
a084ba86c5 | ||
|
|
91ff961850 | ||
|
|
f08d858281 | ||
|
|
498b4e256b | ||
|
|
5d21b3eefd | ||
|
|
74849c8d50 | ||
|
|
6ed0aaf0d7 | ||
|
|
226e455328 | ||
|
|
2c44006392 | ||
|
|
b1e2e66305 | ||
|
|
ec64fc361b | ||
|
|
5f523d55fe | ||
|
|
bbdb45155e | ||
|
|
564104ae23 | ||
|
|
104c3dde16 | ||
|
|
723a492d44 | ||
|
|
fabc6af656 | ||
|
|
136417d6b4 | ||
|
|
d4aa7b4932 | ||
|
|
8f6e1d4ff7 | ||
|
|
da586d0978 | ||
|
|
20125fb713 | ||
|
|
6ccf2d0011 | ||
|
|
a3c0082c4b | ||
|
|
792a4f0e4b | ||
|
|
b32231d4f4 | ||
|
|
1c7495e568 | ||
|
|
d9025f783a | ||
|
|
a490642f5f | ||
|
|
8f5afb9df7 | ||
|
|
0d7938aff5 | ||
|
|
e79e020566 | ||
|
|
1bb58b59ad | ||
|
|
edcef0258c |
9
.claude/settings.json
Normal file
9
.claude/settings.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(cargo check *)",
|
||||
"Bash(cargo clippy *)",
|
||||
"Bash(git --no-pager diff *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
62
.env.example
62
.env.example
@@ -4,27 +4,34 @@ DOMAIN=my-event.example.com
|
||||
|
||||
# ── App server ────────────────────────────────────────────────────────────────
|
||||
APP_PORT=3000
|
||||
# docker-compose.yml already forces APP_ENV=production for the `app` service.
|
||||
# Only set this for a non-compose (bare cargo) run; leave unset for local dev.
|
||||
# APP_ENV=production
|
||||
# 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
|
||||
|
||||
# ── Database ──────────────────────────────────────────────────────────────────
|
||||
# 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
|
||||
# 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
|
||||
POSTGRES_USER=eventsnap
|
||||
POSTGRES_PASSWORD=CHANGE_ME_strong_db_password
|
||||
POSTGRES_PASSWORD=CHANGE_ME_use_a_strong_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 ────────────────────────────────────────────────────────────
|
||||
# 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.
|
||||
# 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
|
||||
# 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'
|
||||
|
||||
# ── Event ─────────────────────────────────────────────────────────────────────
|
||||
EVENT_NAME=Max & Maria's Wedding
|
||||
@@ -32,20 +39,29 @@ 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
|
||||
|
||||
# ── 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.0–1.0)
|
||||
DEFAULT_QUOTA_TOLERANCE=0.75
|
||||
# ── 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.
|
||||
|
||||
# ── 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
45
.env.test
@@ -1,45 +0,0 @@
|
||||
# ── 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.0–1.0)
|
||||
DEFAULT_QUOTA_TOLERANCE=0.75
|
||||
|
||||
# ── Workers ───────────────────────────────────────────────────────────────────
|
||||
COMPRESSION_WORKER_CONCURRENCY=2
|
||||
73
.github/workflows/audit.yml
vendored
Normal file
73
.github/workflows/audit.yml
vendored
Normal file
@@ -0,0 +1,73 @@
|
||||
# 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
|
||||
129
.github/workflows/checks.yml
vendored
Normal file
129
.github/workflows/checks.yml
vendored
Normal file
@@ -0,0 +1,129 @@
|
||||
# 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
|
||||
8
.github/workflows/e2e.yml
vendored
8
.github/workflows/e2e.yml
vendored
@@ -46,6 +46,14 @@ 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
10
.gitignore
vendored
@@ -1,5 +1,7 @@
|
||||
# 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/
|
||||
@@ -20,11 +22,13 @@ 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
|
||||
|
||||
# Local audit/review reports — generated working artifacts, kept out of git
|
||||
docs/AUDIT-2026-06-27.md
|
||||
docs/FIX-VERIFICATION-2026-06-27.md
|
||||
# Claude Code personal (per-user) settings — shared settings.json IS committed
|
||||
.claude/settings.local.json
|
||||
|
||||
57
Caddyfile
57
Caddyfile
@@ -1,29 +1,42 @@
|
||||
{$DOMAIN} {
|
||||
encode zstd gzip
|
||||
# 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
|
||||
|
||||
# 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"
|
||||
# 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"
|
||||
}
|
||||
|
||||
# API — never cache
|
||||
@api path /api/*
|
||||
header @api Cache-Control "no-store"
|
||||
# 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"
|
||||
|
||||
# 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).
|
||||
# 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"
|
||||
|
||||
# Route API and media requests to the Rust backend. Set X-Real-IP from the
|
||||
# real TCP peer and overwrite any client-supplied value so the backend's
|
||||
# rate-limit keys can't be spoofed via a forged X-Forwarded-For.
|
||||
reverse_proxy /api/* app:3000 {
|
||||
header_up X-Real-IP {remote_host}
|
||||
}
|
||||
reverse_proxy /media/* app:3000 {
|
||||
header_up X-Real-IP {remote_host}
|
||||
}
|
||||
# API — never cache, 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"
|
||||
|
||||
# Everything else goes to SvelteKit frontend
|
||||
reverse_proxy frontend:3001
|
||||
# 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
|
||||
}
|
||||
|
||||
84
FOLLOWUPS.md
84
FOLLOWUPS.md
@@ -46,6 +46,90 @@ 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.
|
||||
|
||||
@@ -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', '10'),
|
||||
('upload_rate_per_hour', '100'), -- raised from 10 in migration 015 (guests upload bursts of 10-20)
|
||||
('feed_rate_per_min', '60'),
|
||||
('export_rate_per_day', '3'),
|
||||
('quota_tolerance', '0.75'),
|
||||
|
||||
45
README.md
45
README.md
@@ -77,6 +77,7 @@ eventsnap/
|
||||
│ ├── svelte.config.js
|
||||
│ └── Dockerfile
|
||||
├── docker-compose.yml
|
||||
├── docker-compose.dev.yml # opt-in dev overlay (publishes Postgres on the host)
|
||||
├── Caddyfile
|
||||
└── .env.example
|
||||
```
|
||||
@@ -93,9 +94,9 @@ eventsnap/
|
||||
### Deploy on a fresh VPS
|
||||
|
||||
```bash
|
||||
# 1. Clone the repository
|
||||
git clone https://git.mc02.dev/fabi/EventSnap.git
|
||||
cd EventSnap
|
||||
# 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
|
||||
|
||||
# 2. Configure environment
|
||||
cp .env.example .env
|
||||
@@ -107,6 +108,13 @@ 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
|
||||
@@ -174,6 +182,28 @@ 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.
|
||||
@@ -190,7 +220,14 @@ 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).
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -18,18 +18,20 @@ RUN touch src/main.rs && cargo build --release
|
||||
# --- Runtime stage ---
|
||||
FROM alpine:3.21
|
||||
|
||||
# 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
|
||||
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
|
||||
|
||||
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"]
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
-- 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;
|
||||
3
backend/migrations/010_review_indexes.down.sql
Normal file
3
backend/migrations/010_review_indexes.down.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
DROP INDEX IF EXISTS idx_comment_hashtag_hashtag;
|
||||
DROP INDEX IF EXISTS idx_comment_user;
|
||||
DROP INDEX IF EXISTS idx_upload_event_created_id;
|
||||
17
backend/migrations/010_review_indexes.up.sql
Normal file
17
backend/migrations/010_review_indexes.up.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
-- 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);
|
||||
@@ -1,4 +1,6 @@
|
||||
-- Restore the original join-based v_feed definition.
|
||||
DROP TABLE IF EXISTS pin_reset_request;
|
||||
|
||||
-- Restore v_feed without the is_banned filter.
|
||||
CREATE OR REPLACE VIEW v_feed AS
|
||||
SELECT
|
||||
u.id,
|
||||
39
backend/migrations/011_ban_hides_and_pin_reset.up.sql
Normal file
39
backend/migrations/011_ban_hides_and_pin_reset.up.sql
Normal file
@@ -0,0 +1,39 @@
|
||||
-- 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)
|
||||
);
|
||||
2
backend/migrations/012_export_release_seq.down.sql
Normal file
2
backend/migrations/012_export_release_seq.down.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE export_job
|
||||
DROP COLUMN IF EXISTS release_seq;
|
||||
11
backend/migrations/012_export_release_seq.up.sql
Normal file
11
backend/migrations/012_export_release_seq.up.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
-- 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;
|
||||
2
backend/migrations/013_uploads_hidden_at.down.sql
Normal file
2
backend/migrations/013_uploads_hidden_at.down.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "user"
|
||||
DROP COLUMN IF EXISTS uploads_hidden_at;
|
||||
12
backend/migrations/013_uploads_hidden_at.up.sql
Normal file
12
backend/migrations/013_uploads_hidden_at.up.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
-- 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;
|
||||
28
backend/migrations/014_export_epoch.down.sql
Normal file
28
backend/migrations/014_export_epoch.down.sql
Normal file
@@ -0,0 +1,28 @@
|
||||
-- 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;
|
||||
86
backend/migrations/014_export_epoch.up.sql
Normal file
86
backend/migrations/014_export_epoch.up.sql
Normal file
@@ -0,0 +1,86 @@
|
||||
-- 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;
|
||||
3
backend/migrations/015_raise_upload_rate.down.sql
Normal file
3
backend/migrations/015_raise_upload_rate.down.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
-- 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';
|
||||
10
backend/migrations/015_raise_upload_rate.up.sql
Normal file
10
backend/migrations/015_raise_upload_rate.up.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
-- 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';
|
||||
28
backend/migrations/016_display_derivative.down.sql
Normal file
28
backend/migrations/016_display_derivative.down.sql
Normal file
@@ -0,0 +1,28 @@
|
||||
-- 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;
|
||||
34
backend/migrations/016_display_derivative.up.sql
Normal file
34
backend/migrations/016_display_derivative.up.sql
Normal file
@@ -0,0 +1,34 @@
|
||||
-- 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;
|
||||
195
backend/scripts/rehearse-014.sh
Executable file
195
backend/scripts/rehearse-014.sh
Executable file
@@ -0,0 +1,195 @@
|
||||
#!/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
|
||||
@@ -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,7 +15,6 @@ 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;
|
||||
|
||||
@@ -38,25 +37,18 @@ 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.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
|
||||
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
|
||||
.rate_limiter
|
||||
.check(format!("join_day:{ip}"), 200, Duration::from_secs(86_400));
|
||||
if !burst_ok || !daily_ok {
|
||||
return Err(AppError::TooManyRequests(
|
||||
"Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
|
||||
None,
|
||||
));
|
||||
}
|
||||
.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,
|
||||
));
|
||||
}
|
||||
|
||||
let display_name = body.display_name.trim();
|
||||
@@ -89,12 +81,23 @@ pub async fn join(
|
||||
)));
|
||||
}
|
||||
|
||||
// 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?;
|
||||
// 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)))?;
|
||||
|
||||
let user = User::create(&state.pool, event.id, display_name, &pin_hash).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 token = jwt::create_token(
|
||||
user.id,
|
||||
@@ -132,6 +135,18 @@ 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,
|
||||
@@ -145,8 +160,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.pool, "rate_limits_enabled", true).await;
|
||||
let recover_rate_on = config::get_bool(&state.pool, "recover_rate_enabled", true).await;
|
||||
let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
|
||||
let recover_rate_on = config::get_bool(&state.config_cache, "recover_rate_enabled", true).await;
|
||||
if rate_limits_on && recover_rate_on {
|
||||
let name_key = display_name.to_lowercase();
|
||||
if !state.rate_limiter.check(
|
||||
@@ -165,32 +180,36 @@ 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() {
|
||||
return Err(AppError::NotFound(
|
||||
"Kein Benutzer mit diesem Namen gefunden.".into(),
|
||||
));
|
||||
// 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()));
|
||||
}
|
||||
|
||||
for user in &users {
|
||||
// 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.
|
||||
// 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.
|
||||
if let Some(locked_until) = user.pin_locked_until {
|
||||
if Utc::now() < locked_until {
|
||||
return Err(AppError::TooManyRequests(
|
||||
"Zu viele Versuche. Bitte warte und versuche es später erneut.".into(),
|
||||
"Zu viele Versuche. Bitte warte 15 Minuten.".into(),
|
||||
None,
|
||||
));
|
||||
}
|
||||
// Lockout window expired — wipe the counter and the timestamp.
|
||||
User::reset_pin_attempts(&state.pool, user.id).await?;
|
||||
}
|
||||
|
||||
let pin_matches =
|
||||
password::verify(body.pin.clone(), user.recovery_pin_hash.clone()).await;
|
||||
let pin_matches = bcrypt::verify(&body.pin, &user.recovery_pin_hash).unwrap_or(false);
|
||||
|
||||
if pin_matches {
|
||||
// Reset failed attempts on success
|
||||
@@ -225,19 +244,13 @@ pub async fn recover(
|
||||
"recover: wrong PIN"
|
||||
);
|
||||
if attempts >= 3 {
|
||||
// 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);
|
||||
let lockout = Utc::now() + chrono::Duration::minutes(15);
|
||||
User::lock_pin(&state.pool, user.id, lockout).await?;
|
||||
tracing::warn!(
|
||||
user_id = %user.id,
|
||||
event_id = %event.id,
|
||||
ip = %ip,
|
||||
minutes,
|
||||
"recover: account locked (escalating backoff)"
|
||||
"recover: account locked for 15 minutes"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -253,6 +266,10 @@ 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(
|
||||
@@ -271,30 +288,14 @@ pub async fn admin_login(
|
||||
// a long-running guess campaign. 5 attempts / minute / IP is plenty for
|
||||
// honest typos.
|
||||
let ip = client_ip(&headers, "unknown");
|
||||
|
||||
// Hard, non-disableable floor: admin_login is the one credential endpoint
|
||||
// whose brute-force protection must survive the DB rate-limit master toggle
|
||||
// being turned off. 30 attempts / 5 min / IP is generous for typos but caps
|
||||
// sustained guessing regardless of config.
|
||||
if !state.rate_limiter.check(
|
||||
format!("admin_login_floor:{ip}"),
|
||||
30,
|
||||
Duration::from_secs(300),
|
||||
) {
|
||||
return Err(AppError::TooManyRequests(
|
||||
"Zu viele Anmeldeversuche. Bitte warte kurz und versuche es erneut.".into(),
|
||||
None,
|
||||
));
|
||||
}
|
||||
|
||||
let rate_limits_on = config::get_bool(&state.pool, "rate_limits_enabled", true).await;
|
||||
let admin_rate_on = config::get_bool(&state.pool, "admin_login_rate_enabled", true).await;
|
||||
if rate_limits_on && admin_rate_on
|
||||
&& !state.rate_limiter.check(
|
||||
format!("admin_login:{ip}"),
|
||||
5,
|
||||
Duration::from_secs(60),
|
||||
)
|
||||
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))
|
||||
{
|
||||
return Err(AppError::TooManyRequests(
|
||||
"Zu viele Anmeldeversuche. Bitte warte kurz und versuche es erneut.".into(),
|
||||
@@ -302,11 +303,7 @@ pub async fn admin_login(
|
||||
));
|
||||
}
|
||||
|
||||
let valid = password::verify(
|
||||
body.password.clone(),
|
||||
state.config.admin_password_hash.clone(),
|
||||
)
|
||||
.await;
|
||||
let valid = bcrypt::verify(&body.password, &state.config.admin_password_hash).unwrap_or(false);
|
||||
|
||||
if !valid {
|
||||
tracing::warn!(ip = %ip, "admin_login: wrong password");
|
||||
@@ -332,7 +329,8 @@ 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 = password::hash(dummy_pin, 4).await?;
|
||||
let dummy_hash =
|
||||
bcrypt::hash(&dummy_pin, 4).map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
|
||||
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)
|
||||
@@ -358,13 +356,88 @@ 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 }))
|
||||
Ok(Json(AdminLoginResponse {
|
||||
jwt: token,
|
||||
user_id: admin_user.id,
|
||||
display_name: admin_user.display_name,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn logout(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
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(
|
||||
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", "{}"));
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
@@ -46,10 +46,20 @@ 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::default(),
|
||||
&validation,
|
||||
)?;
|
||||
Ok(data.claims)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,10 @@ 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,
|
||||
}
|
||||
|
||||
@@ -33,49 +37,51 @@ impl FromRequestParts<AppState> for AuthUser {
|
||||
.strip_prefix("Bearer ")
|
||||
.ok_or_else(|| AppError::Unauthorized("Ungültiges Token-Format.".into()))?;
|
||||
|
||||
let claims = jwt::verify_token(token, &state.config.jwt_secret)
|
||||
// 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)
|
||||
.map_err(|_| AppError::Unauthorized("Token ungültig oder abgelaufen.".into()))?;
|
||||
|
||||
let token_hash = jwt::hash_token(token);
|
||||
|
||||
// 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)
|
||||
// Single round-trip: resolve the session token to its *live* user row. A
|
||||
// role/ban stored in the token would survive a demote/ban for the full session
|
||||
// lifetime (up to 30d), so we always re-read the user (a demoted host loses host
|
||||
// powers immediately). We do NOT reject banned users here — they retain read
|
||||
// access by design; writes and host/admin actions enforce the ban downstream.
|
||||
let user = Session::find_user_by_token_hash(&state.pool, &token_hash)
|
||||
.await
|
||||
.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())
|
||||
})?;
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
let pool = state.pool.clone();
|
||||
let session_id = ctx.session_id;
|
||||
let touch_hash = token_hash.clone();
|
||||
let expiry_days = if user.role == UserRole::Admin {
|
||||
1
|
||||
} else {
|
||||
state.config.session_expiry_days
|
||||
};
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = Session::touch(&pool, session_id).await {
|
||||
tracing::warn!(error = ?e, session_id = %session_id, "session touch failed");
|
||||
if let Err(e) = Session::touch_and_renew(&pool, &touch_hash, expiry_days).await {
|
||||
tracing::warn!(error = ?e, "session touch/renew failed");
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Self {
|
||||
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,
|
||||
user_id: user.id,
|
||||
event_id: user.event_id,
|
||||
role: user.role,
|
||||
is_banned: user.is_banned,
|
||||
token_hash,
|
||||
})
|
||||
}
|
||||
@@ -92,6 +98,9 @@ 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())),
|
||||
@@ -110,6 +119,9 @@ 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())),
|
||||
|
||||
@@ -1,11 +1,52 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
|
||||
/// 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,
|
||||
@@ -15,63 +56,148 @@ 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")?;
|
||||
// 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");
|
||||
let admin_password_hash = std::env::var("ADMIN_PASSWORD_HASH").unwrap_or_default();
|
||||
|
||||
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."
|
||||
);
|
||||
}
|
||||
validate_secrets(is_prod, &jwt_secret, &admin_password_hash)?;
|
||||
|
||||
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: 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")?,
|
||||
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")?,
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use anyhow::{Context, Result};
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use sqlx::PgPool;
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
|
||||
const DEFAULT_MAX_CONNECTIONS: u32 = 10;
|
||||
|
||||
|
||||
@@ -6,10 +6,19 @@ 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),
|
||||
}
|
||||
|
||||
@@ -19,9 +28,11 @@ 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"),
|
||||
}
|
||||
}
|
||||
@@ -31,9 +42,11 @@ 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()
|
||||
@@ -62,10 +75,11 @@ impl IntoResponse for AppError {
|
||||
}
|
||||
|
||||
let mut resp = (status, axum::Json(body)).into_response();
|
||||
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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
resp
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::Json;
|
||||
use axum::extract::{Query, State};
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sysinfo::System;
|
||||
|
||||
use crate::auth::middleware::RequireAdmin;
|
||||
use crate::error::AppError;
|
||||
@@ -46,19 +45,17 @@ 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")
|
||||
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 (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
|
||||
@@ -68,23 +65,12 @@ pub async fn get_stats(
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
|
||||
// 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))
|
||||
});
|
||||
// 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));
|
||||
|
||||
let disk_used = disk_total.saturating_sub(disk_free);
|
||||
|
||||
@@ -102,14 +88,17 @@ 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>);
|
||||
|
||||
@@ -121,15 +110,18 @@ 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: ...`).
|
||||
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",
|
||||
// (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 BOOL_KEYS: &[&str] = &[
|
||||
"rate_limits_enabled",
|
||||
@@ -137,21 +129,61 @@ 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"];
|
||||
const TEXT_KEYS: &[&str] = &[
|
||||
"privacy_note",
|
||||
"theme_preset",
|
||||
"theme_primary",
|
||||
"theme_accent",
|
||||
];
|
||||
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 NUMERIC_KEYS.contains(&key_str) {
|
||||
if value.parse::<f64>().is_err() {
|
||||
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 {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"Ungültiger Wert für {key}: muss eine Zahl sein."
|
||||
"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})."
|
||||
)));
|
||||
}
|
||||
} else if BOOL_KEYS.contains(&key_str) {
|
||||
@@ -164,42 +196,83 @@ pub async fn patch_config(
|
||||
}
|
||||
}
|
||||
} else if TEXT_KEYS.contains(&key_str) {
|
||||
if value.len() > PRIVACY_NOTE_MAX_LEN {
|
||||
// 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 {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"Wert für {key} ist zu lang (max. {PRIVACY_NOTE_MAX_LEN} Zeichen)."
|
||||
)));
|
||||
}
|
||||
if key_str == "privacy_note" {
|
||||
privacy_note_changed = true;
|
||||
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;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
} 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(&state.pool)
|
||||
.execute(&mut *tx)
|
||||
.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 {
|
||||
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");
|
||||
}
|
||||
let _ = state.sse_tx.send(crate::state::SseEvent::new(
|
||||
"event-updated",
|
||||
serde_json::json!({ "keys": ["privacy_note"] }).to_string(),
|
||||
serde_json::json!({ "keys": keys }).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,
|
||||
@@ -223,53 +296,106 @@ 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>,
|
||||
_auth: crate::auth::middleware::AuthUser,
|
||||
Query(q): Query<DownloadQuery>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<axum::response::Response, AppError> {
|
||||
authenticate_download_ticket(&state, &q.ticket).await?;
|
||||
enforce_export_rate(&state, &headers).await?;
|
||||
|
||||
let event = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
|
||||
let path =
|
||||
resolve_export_file(&state, "zip", "Der ZIP-Export ist noch nicht verfügbar.").await?;
|
||||
serve_file(path, "Gallery.zip", "application/zip").await
|
||||
}
|
||||
|
||||
if !event.export_zip_ready {
|
||||
return Err(AppError::NotFound(
|
||||
"Der ZIP-Export ist noch nicht verfügbar.".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()))?;
|
||||
|
||||
let path = state.config.media_path.join("exports").join("Gallery.zip");
|
||||
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);
|
||||
if !path.exists() {
|
||||
return Err(AppError::NotFound("Exportdatei nicht gefunden.".into()));
|
||||
}
|
||||
|
||||
serve_file(path, "Gallery.zip", "application/zip").await
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
pub async fn download_html(
|
||||
State(state): State<AppState>,
|
||||
_auth: crate::auth::middleware::AuthUser,
|
||||
Query(q): Query<DownloadQuery>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<axum::response::Response, AppError> {
|
||||
authenticate_download_ticket(&state, &q.ticket).await?;
|
||||
enforce_export_rate(&state, &headers).await?;
|
||||
|
||||
let event = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug)
|
||||
.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()));
|
||||
}
|
||||
|
||||
let path =
|
||||
resolve_export_file(&state, "html", "Der HTML-Export ist noch nicht verfügbar.").await?;
|
||||
serve_file(path, "Memories.zip", "application/zip").await
|
||||
}
|
||||
|
||||
@@ -279,7 +405,7 @@ async fn serve_file(
|
||||
content_type: &str,
|
||||
) -> Result<axum::response::Response, AppError> {
|
||||
use axum::body::Body;
|
||||
use axum::http::{header, Response, StatusCode};
|
||||
use axum::http::{Response, StatusCode, header};
|
||||
use tokio_util::io::ReaderStream;
|
||||
|
||||
let file = tokio::fs::File::open(&path)
|
||||
@@ -315,8 +441,19 @@ 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 type::text, status::text, progress_pct FROM export_job WHERE event_id = $1",
|
||||
"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",
|
||||
)
|
||||
.bind(event.id)
|
||||
.fetch_all(&state.pool)
|
||||
@@ -325,9 +462,7 @@ 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 }))
|
||||
};
|
||||
|
||||
@@ -342,13 +477,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.pool, "rate_limits_enabled", true).await;
|
||||
let export_rate_on = config::get_bool(&state.pool, "export_rate_enabled", true).await;
|
||||
let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
|
||||
let export_rate_on = config::get_bool(&state.config_cache, "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.pool, "export_rate_per_day", 3).await;
|
||||
let limit = config::get_usize(&state.config_cache, "export_rate_per_day", 3).await;
|
||||
if !state
|
||||
.rate_limiter
|
||||
.check(format!("export:{ip}"), limit, Duration::from_secs(86400))
|
||||
|
||||
@@ -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,7 +10,6 @@ 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;
|
||||
|
||||
@@ -28,8 +27,8 @@ pub struct FeedUpload {
|
||||
pub uploader_name: String,
|
||||
pub preview_url: Option<String>,
|
||||
pub thumbnail_url: Option<String>,
|
||||
/// Signed gateway URL for the full-resolution original. Always present.
|
||||
pub original_url: Option<String>,
|
||||
/// Big-screen (~2048px) variant for the diashow. Absent until the derivative exists.
|
||||
pub display_url: Option<String>,
|
||||
pub mime_type: String,
|
||||
pub caption: Option<String>,
|
||||
pub like_count: i64,
|
||||
@@ -51,6 +50,7 @@ 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,33 +58,6 @@ 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,
|
||||
@@ -92,10 +65,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.pool, "rate_limits_enabled", true).await;
|
||||
let feed_rate_on = config::get_bool(&state.pool, "feed_rate_enabled", true).await;
|
||||
let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
|
||||
let feed_rate_on = config::get_bool(&state.config_cache, "feed_rate_enabled", true).await;
|
||||
if rate_limits_on && feed_rate_on {
|
||||
let rate_limit = config::get_usize(&state.pool, "feed_rate_per_min", 60).await;
|
||||
let rate_limit = config::get_usize(&state.config_cache, "feed_rate_per_min", 60).await;
|
||||
if !state
|
||||
.rate_limiter
|
||||
.check(format!("feed:{ip}"), rate_limit, Duration::from_secs(60))
|
||||
@@ -109,49 +82,51 @@ 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.mime_type, v.caption, v.like_count, v.comment_count, v.created_at
|
||||
v.display_path, 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 < $3)
|
||||
ORDER BY v.created_at DESC
|
||||
LIMIT $4",
|
||||
AND ($3::timestamptz IS NULL OR (v.created_at, v.id) < ($3, $4))
|
||||
ORDER BY v.created_at DESC, v.id DESC
|
||||
LIMIT $5",
|
||||
)
|
||||
.bind(&tag)
|
||||
.bind(auth.event_id)
|
||||
.bind(
|
||||
if let Some(cursor) = q.cursor {
|
||||
get_cursor_time(&state.pool, cursor).await
|
||||
} else {
|
||||
None
|
||||
},
|
||||
)
|
||||
.bind(cursor_time)
|
||||
.bind(cursor_id)
|
||||
.bind(limit + 1)
|
||||
.fetch_all(&state.pool)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_as::<_, FeedRow>(
|
||||
"SELECT id, user_id, uploader_name, preview_path, thumbnail_path,
|
||||
mime_type, caption, like_count, comment_count, created_at
|
||||
display_path, mime_type, caption, like_count, comment_count, created_at
|
||||
FROM v_feed
|
||||
WHERE event_id = $1
|
||||
AND ($2::timestamptz IS NULL OR created_at < $2)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $3",
|
||||
AND ($2::timestamptz IS NULL OR (created_at, id) < ($2, $3))
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT $4",
|
||||
)
|
||||
.bind(auth.event_id)
|
||||
.bind(
|
||||
if let Some(cursor) = q.cursor {
|
||||
get_cursor_time(&state.pool, cursor).await
|
||||
} else {
|
||||
None
|
||||
},
|
||||
)
|
||||
.bind(cursor_time)
|
||||
.bind(cursor_id)
|
||||
.bind(limit + 1)
|
||||
.fetch_all(&state.pool)
|
||||
.await?
|
||||
@@ -159,19 +134,48 @@ 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| {
|
||||
let liked = liked_set.contains(&r.id);
|
||||
to_feed_upload(r, liked, secret, now)
|
||||
// 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,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -190,29 +194,37 @@ pub struct DeltaQuery {
|
||||
pub struct DeltaResponse {
|
||||
pub uploads: Vec<FeedUpload>,
|
||||
pub deleted_ids: Vec<Uuid>,
|
||||
/// Set when the delta was clamped (too-old cursor) or hit the row cap — the
|
||||
/// client should do a full feed reload instead of trusting the partial set.
|
||||
pub reload_required: bool,
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
/// Hard cap on how many uploads one delta returns. Beyond this the client is
|
||||
/// told to reload rather than streaming the whole gallery through the view.
|
||||
const DELTA_LIMIT: i64 = 200;
|
||||
/// How far back a client-supplied `since` may reach. A tab backgrounded for days
|
||||
/// must not pull the entire event on reconnect.
|
||||
const DELTA_MAX_LOOKBACK_DAYS: i64 = 7;
|
||||
|
||||
pub async fn feed_delta(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Query(q): Query<DeltaQuery>,
|
||||
) -> Result<Json<DeltaResponse>, AppError> {
|
||||
// H7: feed_delta runs the (expensive) feed query and fires on every tab
|
||||
// refocus, so it needs the same rate limit as feed(), keyed per user.
|
||||
let rate_limits_on = config::get_bool(&state.pool, "rate_limits_enabled", true).await;
|
||||
let feed_rate_on = config::get_bool(&state.pool, "feed_rate_enabled", true).await;
|
||||
// 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;
|
||||
if rate_limits_on && feed_rate_on {
|
||||
let rate_limit = config::get_usize(&state.pool, "feed_rate_per_min", 60).await;
|
||||
let rate_limit = config::get_usize(&state.config_cache, "feed_rate_per_min", 60).await;
|
||||
if !state.rate_limiter.check(
|
||||
format!("feed_delta:{}", auth.user_id),
|
||||
rate_limit,
|
||||
@@ -225,55 +237,100 @@ pub async fn feed_delta(
|
||||
}
|
||||
}
|
||||
|
||||
// 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 };
|
||||
// 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?;
|
||||
|
||||
// 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,
|
||||
mime_type, caption, like_count, comment_count, created_at
|
||||
display_path, mime_type, caption, like_count, comment_count, created_at
|
||||
FROM v_feed
|
||||
WHERE event_id = $1 AND created_at > $2
|
||||
ORDER BY created_at DESC
|
||||
WHERE event_id = $1 AND created_at >= $2
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT $3",
|
||||
)
|
||||
.bind(auth.event_id)
|
||||
.bind(since)
|
||||
.bind(DELTA_LIMIT + 1)
|
||||
.bind(q.since)
|
||||
.bind(DELTA_LIMIT)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
let capped = rows.len() as i64 > DELTA_LIMIT;
|
||||
let rows: Vec<FeedRow> = rows.into_iter().take(DELTA_LIMIT as usize).collect();
|
||||
let reload_required = clamped || capped;
|
||||
// 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;
|
||||
|
||||
// `>=` 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(since)
|
||||
.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)
|
||||
.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| {
|
||||
let liked = liked_set.contains(&r.id);
|
||||
to_feed_upload(r, liked, secret, now)
|
||||
.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,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(DeltaResponse {
|
||||
uploads,
|
||||
deleted_ids: deleted_ids.into_iter().map(|r| r.0).collect(),
|
||||
reload_required,
|
||||
hidden_user_ids: hidden_user_ids.into_iter().map(|r| r.0).collect(),
|
||||
truncated,
|
||||
server_time,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -287,12 +344,11 @@ 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()
|
||||
@@ -301,14 +357,17 @@ pub async fn hashtags(
|
||||
))
|
||||
}
|
||||
|
||||
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")
|
||||
/// 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")
|
||||
.bind(cursor_id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.ok()?;
|
||||
row.map(|r| r.0)
|
||||
row
|
||||
}
|
||||
|
||||
async fn get_liked_set(
|
||||
@@ -319,14 +378,13 @@ 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()
|
||||
}
|
||||
|
||||
@@ -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,18 +12,9 @@ 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)]
|
||||
@@ -46,9 +37,24 @@ pub struct EventStatus {
|
||||
pub export_released: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct BanRequest {
|
||||
pub hide_uploads: 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)]
|
||||
@@ -104,11 +110,13 @@ 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",
|
||||
@@ -119,23 +127,67 @@ 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 = $2 WHERE id = $1",
|
||||
"UPDATE \"user\"
|
||||
SET is_banned = TRUE, uploads_hidden = TRUE, uploads_hidden_at = NOW()
|
||||
WHERE id = $1 AND event_id = $2",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(body.hide_uploads)
|
||||
.execute(&state.pool)
|
||||
.bind(auth.event_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
revoke_sessions(&state.pool, user_id, "ban_user").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);
|
||||
}
|
||||
|
||||
// Tell the banned user's online devices to log out immediately.
|
||||
// Evict their content live from every feed + the diashow so it disappears without
|
||||
// each viewer having to reload. (Their own SSE stream is separately dropped by the
|
||||
// is_banned revalidation in `sse::stream`, so they stop receiving live pushes while
|
||||
// retaining plain read access.)
|
||||
let _ = state.sse_tx.send(SseEvent::new(
|
||||
"user-banned",
|
||||
"user-hidden",
|
||||
serde_json::json!({ "user_id": user_id }).to_string(),
|
||||
));
|
||||
|
||||
@@ -143,7 +195,6 @@ 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"
|
||||
);
|
||||
|
||||
@@ -155,16 +206,56 @@ pub async fn unban_user(
|
||||
RequireHost(auth): RequireHost,
|
||||
Path(user_id): Path<Uuid>,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
let result = sqlx::query(
|
||||
"UPDATE \"user\" SET is_banned = FALSE WHERE id = $1 AND event_id = $2",
|
||||
// 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)
|
||||
.execute(&state.pool)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
|
||||
|
||||
if target.0 == "admin"
|
||||
|| (target.0 == "host" && auth.role != crate::models::user::UserRole::Admin)
|
||||
{
|
||||
return Err(AppError::Forbidden(
|
||||
"Du kannst diesen Benutzer nicht entsperren.".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Unban restores visibility too: ban set `uploads_hidden = TRUE`, so clearing only
|
||||
// `is_banned` would leave their content invisible. Clear all three (the timestamp too,
|
||||
// so a future ban stamps a fresh `uploads_hidden_at` the reconnect delta will replay).
|
||||
let mut tx = state.pool.begin().await?;
|
||||
let result = sqlx::query(
|
||||
"UPDATE \"user\"
|
||||
SET is_banned = FALSE, uploads_hidden = FALSE, uploads_hidden_at = NULL
|
||||
WHERE id = $1 AND event_id = $2",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(auth.event_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(AppError::NotFound("Benutzer nicht gefunden.".into()));
|
||||
}
|
||||
|
||||
// The mirror of the ban case: an unban RESTORES their uploads to the export query, so an
|
||||
// already-released keepsake is now missing content it should contain. Rebuild it.
|
||||
let regen = crate::services::export::invalidate_and_arm(
|
||||
&mut tx,
|
||||
&state.config.event_slug,
|
||||
Affects::Both,
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
if let Some(r) = regen {
|
||||
start_regen(&state, r);
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
actor_user_id = %auth.user_id,
|
||||
target_user_id = %user_id,
|
||||
@@ -174,6 +265,55 @@ 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,
|
||||
@@ -191,14 +331,15 @@ 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. 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).
|
||||
// Look up the current role so we can apply the host-vs-admin guard. A plain host may
|
||||
// promote/demote GUESTS only; it may not change any host's or admin's role (see the
|
||||
// guard below — this closes the demote-a-peer-host→ban/PIN-reset takeover chain, F1).
|
||||
// Only an admin may change a host's role. Admins may do anything except change
|
||||
// themselves (blocked above).
|
||||
let target = sqlx::query_as::<_, (String,)>(
|
||||
"SELECT role::text FROM \"user\" WHERE id = $1 AND event_id = $2",
|
||||
)
|
||||
@@ -208,9 +349,25 @@ pub async fn set_role(
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
|
||||
|
||||
if target.0 == "admin" {
|
||||
// Admins are untouchable by hosts. A plain host also may not demote another
|
||||
// *host*: without this guard a host could demote a peer host to guest and then
|
||||
// ban / PIN-reset (→ account-takeover via /recover) them — the ban/pin-reset peer
|
||||
// guards key off the target's *current* role, so a prior demotion would launder
|
||||
// past them. Only an admin may change a host's role.
|
||||
if target.0 == "admin" || (target.0 == "host" && auth.role != UserRole::Admin) {
|
||||
return Err(AppError::Forbidden(
|
||||
"Admins können nicht geändert werden.".into(),
|
||||
"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(),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -220,13 +377,6 @@ 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,
|
||||
@@ -278,28 +428,40 @@ pub async fn reset_user_pin(
|
||||
_ => {
|
||||
return Err(AppError::Forbidden(
|
||||
"Du darfst die PIN dieses Benutzers nicht zurücksetzen.".into(),
|
||||
))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let pin: String = format!("{:06}", rand::rng().random_range(0..1_000_000u32));
|
||||
let pin_hash = crate::services::password::hash(pin.clone(), 12).await?;
|
||||
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)))?;
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE \"user\"
|
||||
SET recovery_pin_hash = $1,
|
||||
failed_pin_attempts = 0,
|
||||
pin_locked_until = NULL
|
||||
WHERE id = $2",
|
||||
WHERE id = $2 AND event_id = $3",
|
||||
)
|
||||
.bind(&pin_hash)
|
||||
.bind(user_id)
|
||||
.bind(auth.event_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;
|
||||
// A PIN reset means the old credential is compromised/forgotten — revoke every
|
||||
// existing session so old devices must re-authenticate with the new PIN. This is a
|
||||
// security-relevant revoke: if it fails, the old sessions stay valid (sessions are
|
||||
// token- not PIN-bound), so surface the error in logs rather than swallowing it
|
||||
// silently while reporting success to the host.
|
||||
if let Err(e) = Session::delete_all_for_user(&state.pool, user_id).await {
|
||||
tracing::error!(error = ?e, user_id = %user_id, "PIN reset: failed to revoke sessions");
|
||||
}
|
||||
|
||||
// Resolve any pending in-app "I forgot my PIN" request for this user.
|
||||
let _ = sqlx::query("DELETE FROM pin_reset_request WHERE user_id = $1")
|
||||
.bind(user_id)
|
||||
.execute(&state.pool)
|
||||
.await;
|
||||
|
||||
// Notify the *recipient* device(s) if they happen to be online so they can clear
|
||||
// their cached local PIN. They'll save the new one on the next /recover.
|
||||
@@ -318,26 +480,125 @@ 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 paths = Upload::soft_delete_in_event(&state.pool, upload_id, auth.event_id)
|
||||
let upload = Upload::find_by_id_and_event(&state.pool, upload_id, auth.event_id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
|
||||
|
||||
crate::services::media_fs::unlink_media(&state.config.media_path, &paths).await;
|
||||
// The delete and the keepsake invalidation are ONE transaction: if the delete committed and the
|
||||
// invalidation didn't, the taken-down photo would stay downloadable forever and nothing would
|
||||
// notice (the keepsake still looks complete, and the host can no longer find the upload to retry).
|
||||
let mut tx = state.pool.begin().await?;
|
||||
let deleted = Upload::soft_delete_in_event(&mut tx, upload_id, auth.event_id).await?;
|
||||
if !deleted {
|
||||
return Err(AppError::NotFound("Upload nicht gefunden.".into()));
|
||||
}
|
||||
let regen = crate::services::export::invalidate_and_arm(
|
||||
&mut tx,
|
||||
&state.config.event_slug,
|
||||
Affects::Both,
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
let _ = state.sse_tx.send(SseEvent::new(
|
||||
"upload-deleted",
|
||||
serde_json::json!({ "upload_id": upload_id }).to_string(),
|
||||
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"
|
||||
);
|
||||
|
||||
@@ -349,11 +610,29 @@ pub async fn host_delete_comment(
|
||||
RequireHost(auth): RequireHost,
|
||||
Path(comment_id): Path<Uuid>,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
let deleted =
|
||||
Comment::soft_delete_in_event(&state.pool, comment_id, auth.event_id).await?;
|
||||
let mut tx = state.pool.begin().await?;
|
||||
let deleted = Comment::soft_delete_in_event(&mut tx, comment_id, auth.event_id).await?;
|
||||
if !deleted {
|
||||
return Err(AppError::NotFound("Kommentar nicht gefunden.".into()));
|
||||
}
|
||||
// Only the HTML viewer embeds comments — the ZIP is media-only, so it is carried forward rather
|
||||
// than rebuilt. Otherwise moderating one comment would 404 the photo download for minutes to
|
||||
// change nothing in it.
|
||||
let regen = crate::services::export::invalidate_and_arm(
|
||||
&mut tx,
|
||||
&state.config.event_slug,
|
||||
Affects::ViewerOnly,
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
let _ = state.sse_tx.send(SseEvent::new(
|
||||
"comment-deleted",
|
||||
serde_json::json!({ "comment_id": comment_id }).to_string(),
|
||||
));
|
||||
if let Some(r) = regen {
|
||||
start_regen(&state, r);
|
||||
}
|
||||
tracing::info!(
|
||||
actor_user_id = %auth.user_id,
|
||||
event_id = %auth.event_id,
|
||||
@@ -367,14 +646,18 @@ pub async fn close_event(
|
||||
State(state): State<AppState>,
|
||||
RequireHost(_auth): RequireHost,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
sqlx::query(
|
||||
let result = sqlx::query(
|
||||
"UPDATE event SET uploads_locked_at = NOW() WHERE slug = $1 AND uploads_locked_at IS NULL",
|
||||
)
|
||||
.bind(&state.config.event_slug)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
let _ = state.sse_tx.send(SseEvent::new("event-closed", "{}"));
|
||||
// Only broadcast when this call actually flipped the lock — closing an
|
||||
// already-closed event is a no-op and shouldn't spam listeners.
|
||||
if result.rows_affected() > 0 {
|
||||
let _ = state.sse_tx.send(SseEvent::new("event-closed", "{}"));
|
||||
}
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
@@ -383,14 +666,29 @@ pub async fn open_event(
|
||||
State(state): State<AppState>,
|
||||
RequireHost(_auth): RequireHost,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
sqlx::query(
|
||||
"UPDATE event SET uploads_locked_at = NULL WHERE slug = $1",
|
||||
// Reopening invalidates any prior release: the keepsake was snapshotted at release time, so
|
||||
// allowing new uploads afterwards would silently diverge the live feed from the frozen export.
|
||||
//
|
||||
// ONE statement retires the entire export generation. Bumping `export_epoch` in the same write
|
||||
// that clears `export_released_at` instantly invalidates every in-flight worker, every `done`
|
||||
// row and all readiness — because readiness is DERIVED from this epoch (migration 014), not
|
||||
// stored. There is no export_job row to touch and nothing to keep in sync, so this needs no
|
||||
// transaction. Any worker still streaming holds the old epoch and is now inert: its
|
||||
// epoch-guarded finalize matches nothing, and it discards its own output.
|
||||
let result = sqlx::query(
|
||||
"UPDATE event
|
||||
SET uploads_locked_at = NULL,
|
||||
export_released_at = NULL,
|
||||
export_epoch = export_epoch + 1
|
||||
WHERE slug = $1 AND (uploads_locked_at IS NOT NULL OR export_released_at IS NOT NULL)",
|
||||
)
|
||||
.bind(&state.config.event_slug)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
let _ = state.sse_tx.send(SseEvent::new("event-opened", "{}"));
|
||||
if result.rows_affected() > 0 {
|
||||
let _ = state.sse_tx.send(SseEvent::new("event-opened", "{}"));
|
||||
}
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
@@ -399,74 +697,69 @@ pub async fn release_gallery(
|
||||
State(state): State<AppState>,
|
||||
RequireHost(_auth): RequireHost,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
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.
|
||||
// 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:
|
||||
//
|
||||
// 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.
|
||||
// 1. Cancellation. This handler used to commit `export_released_at` and only THEN await the
|
||||
// enqueue. Axum drops the handler future when the client disconnects (closed tab, proxy
|
||||
// timeout), which left the event released and uploads locked with ZERO export_job rows and
|
||||
// no workers: downloads 404 forever and the host cannot retry, because release_gallery
|
||||
// rejects an already-released event ("bereits freigegeben"). Only a restart escaped it.
|
||||
// Now nothing is committed until every row is written, and the workers are spawned AFTER
|
||||
// the commit (a detached `tokio::spawn` survives cancellation).
|
||||
// 2. Atomicity vs. a concurrent reopen. Bumping the epoch in the same statement that sets
|
||||
// `export_released_at` means no worker and no reader can ever observe "released again but
|
||||
// the generation hasn't moved on yet" — the window every previous fix kept leaving open.
|
||||
//
|
||||
// Release also locks uploads in the same statement (release ⇒ lock), so the export snapshot is
|
||||
// taken against a frozen upload set. `COALESCE` preserves an earlier explicit lock time.
|
||||
let mut tx = state.pool.begin().await?;
|
||||
|
||||
let 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'
|
||||
)
|
||||
)
|
||||
)",
|
||||
let claimed: Option<(Uuid, String, i64)> = sqlx::query_as(
|
||||
"UPDATE event
|
||||
SET export_released_at = NOW(),
|
||||
uploads_locked_at = COALESCE(uploads_locked_at, NOW()),
|
||||
export_epoch = export_epoch + 1
|
||||
WHERE slug = $1 AND export_released_at IS NULL
|
||||
RETURNING id, name, export_epoch",
|
||||
)
|
||||
.bind(&state.config.event_slug)
|
||||
.execute(&mut *tx)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if claim.rows_affected() == 0 {
|
||||
tx.rollback().await.ok();
|
||||
return Err(AppError::BadRequest(
|
||||
"Galerie wurde bereits freigegeben.".into(),
|
||||
));
|
||||
}
|
||||
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())
|
||||
});
|
||||
};
|
||||
|
||||
// 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?;
|
||||
}
|
||||
// Arm both types at THIS epoch. No "skip the type that's already ready" check any more: the
|
||||
// epoch bump above retired every prior generation, so there is nothing to preserve and nothing
|
||||
// to be fooled by. (That skip was how a stale ready flag used to suppress regeneration.)
|
||||
crate::services::export::enqueue_jobs_at_epoch(&mut tx, event_id, epoch).await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
// Spawn export workers only after the claim+enqueue is durably committed.
|
||||
// Release locks uploads too — tell any open composer to flip to the locked UI live rather than
|
||||
// discovering it via a rejected upload.
|
||||
let _ = state.sse_tx.send(SseEvent::new("event-closed", "{}"));
|
||||
|
||||
// Detached — survives this handler being cancelled.
|
||||
crate::services::export::spawn_export_jobs(
|
||||
event.id,
|
||||
event.name,
|
||||
event_id,
|
||||
event_name,
|
||||
epoch,
|
||||
state.config.comments_enabled,
|
||||
std::time::Duration::ZERO,
|
||||
state.pool.clone(),
|
||||
state.config.media_path.clone(),
|
||||
state.config.export_path.clone(),
|
||||
state.sse_tx.clone(),
|
||||
);
|
||||
|
||||
|
||||
@@ -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::extract::State;
|
||||
use axum::Json;
|
||||
use axum::extract::State;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::auth::middleware::AuthUser;
|
||||
use crate::error::AppError;
|
||||
use crate::handlers::upload::compute_storage_quota;
|
||||
use crate::models::user::User;
|
||||
use crate::models::user::{User, UserRole};
|
||||
use crate::services::config;
|
||||
use crate::state::AppState;
|
||||
|
||||
@@ -37,12 +37,26 @@ 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: estimate.active_uploaders,
|
||||
free_disk_bytes: estimate.free_disk_bytes,
|
||||
active_uploaders: if is_staff {
|
||||
estimate.active_uploaders
|
||||
} else {
|
||||
0
|
||||
},
|
||||
free_disk_bytes: if is_staff {
|
||||
estimate.free_disk_bytes
|
||||
} else {
|
||||
0
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -55,9 +69,12 @@ pub struct MeContextDto {
|
||||
pub privacy_note: String,
|
||||
pub quota_enabled: bool,
|
||||
pub storage_quota_enabled: bool,
|
||||
/// Whether uploads are currently locked for the event, so the client can show
|
||||
/// a banner + disable the upload affordance on load (not just via SSE).
|
||||
/// 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.
|
||||
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(
|
||||
@@ -68,13 +85,21 @@ pub async fn get_context(
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
|
||||
|
||||
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?
|
||||
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()
|
||||
.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,
|
||||
@@ -84,5 +109,6 @@ pub async fn get_context(
|
||||
quota_enabled,
|
||||
storage_quota_enabled,
|
||||
uploads_locked,
|
||||
gallery_released,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
//! 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()))
|
||||
}
|
||||
@@ -2,7 +2,7 @@ pub mod admin;
|
||||
pub mod feed;
|
||||
pub mod host;
|
||||
pub mod me;
|
||||
pub mod media;
|
||||
pub mod public;
|
||||
pub mod social;
|
||||
pub mod sse;
|
||||
pub mod test_admin;
|
||||
|
||||
44
backend/src/handlers/public.rs
Normal file
44
backend/src/handlers/public.rs
Normal file
@@ -0,0 +1,44 @@
|
||||
//! 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,
|
||||
})
|
||||
}
|
||||
@@ -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;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth::middleware::AuthUser;
|
||||
@@ -12,19 +12,41 @@ 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<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).
|
||||
) -> 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.
|
||||
Upload::find_by_id_and_event(&state.pool, upload_id, auth.event_id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
|
||||
|
||||
// Try to insert; if conflict, delete (toggle)
|
||||
// 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.
|
||||
let result = sqlx::query(
|
||||
"INSERT INTO \"like\" (upload_id, user_id) VALUES ($1, $2)
|
||||
ON CONFLICT (upload_id, user_id) DO NOTHING",
|
||||
@@ -34,7 +56,8 @@ pub async fn toggle_like(
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
let liked = result.rows_affected() > 0;
|
||||
if !liked {
|
||||
// Already liked — remove
|
||||
sqlx::query("DELETE FROM \"like\" WHERE upload_id = $1 AND user_id = $2")
|
||||
.bind(upload_id)
|
||||
@@ -43,13 +66,30 @@ pub async fn toggle_like(
|
||||
.await?;
|
||||
}
|
||||
|
||||
// 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(),
|
||||
});
|
||||
// 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();
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
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 }))
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
@@ -68,8 +108,7 @@ pub async fn list_comments(
|
||||
Path(upload_id): Path<Uuid>,
|
||||
Query(q): Query<ListCommentsQuery>,
|
||||
) -> Result<Json<Vec<CommentDto>>, AppError> {
|
||||
// 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.
|
||||
// Event-scope: only list comments for an upload in the caller's event.
|
||||
Upload::find_by_id_and_event(&state.pool, upload_id, auth.event_id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
|
||||
@@ -90,15 +129,27 @@ pub async fn add_comment(
|
||||
Path(upload_id): Path<Uuid>,
|
||||
Json(body): Json<AddCommentRequest>,
|
||||
) -> Result<(StatusCode, Json<CommentDto>), AppError> {
|
||||
// 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()))?;
|
||||
// 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.
|
||||
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 text = body.body.trim();
|
||||
let text_chars = text.chars().count();
|
||||
@@ -108,26 +159,41 @@ pub async fn add_comment(
|
||||
));
|
||||
}
|
||||
|
||||
let comment = Comment::create(&state.pool, upload_id, auth.user_id, text).await?;
|
||||
|
||||
// Process hashtags in comment body
|
||||
// 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 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(&state.pool, auth.event_id, tag).await?;
|
||||
let h = Hashtag::upsert(&mut *tx, 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(&state.pool)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
tx.commit().await?;
|
||||
|
||||
// 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(),
|
||||
});
|
||||
// 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(),
|
||||
});
|
||||
}
|
||||
|
||||
let dto = CommentDto {
|
||||
id: comment.id,
|
||||
@@ -146,6 +212,10 @@ 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()))?;
|
||||
@@ -154,6 +224,27 @@ pub async fn delete_comment(
|
||||
return Err(AppError::Forbidden("Nur eigene Kommentare löschen.".into()));
|
||||
}
|
||||
|
||||
Comment::soft_delete(&state.pool, comment_id).await?;
|
||||
// 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(),
|
||||
));
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
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::wrappers::BroadcastStream;
|
||||
use tokio_stream::StreamExt;
|
||||
use tokio_stream::wrappers::BroadcastStream;
|
||||
use tokio_stream::wrappers::errors::BroadcastStreamRecvError;
|
||||
|
||||
use crate::auth::middleware::AuthUser;
|
||||
use crate::error::AppError;
|
||||
@@ -22,6 +23,10 @@ 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
|
||||
@@ -32,9 +37,15 @@ pub struct StreamTicketResponse {
|
||||
pub async fn issue_ticket(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
) -> Json<StreamTicketResponse> {
|
||||
) -> Result<Json<StreamTicketResponse>, AppError> {
|
||||
let ticket = state.sse_tickets.issue(auth.token_hash);
|
||||
Json(StreamTicketResponse { ticket })
|
||||
let server_time = sqlx::query_scalar("SELECT NOW()")
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
Ok(Json(StreamTicketResponse {
|
||||
ticket,
|
||||
server_time,
|
||||
}))
|
||||
}
|
||||
|
||||
/// SSE stream endpoint. Authenticates via a single-use ticket (see
|
||||
@@ -48,19 +59,57 @@ 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 stream = BroadcastStream::new(rx).filter_map(|msg| match msg {
|
||||
let events = BroadcastStream::new(rx).filter_map(|msg| match msg {
|
||||
Ok(sse_event) => Some(Ok(Event::default()
|
||||
.event(sse_event.event_type)
|
||||
.data(sse_event.data))),
|
||||
Err(_) => None,
|
||||
// A consumer that falls behind the broadcast buffer would otherwise silently
|
||||
// lose events, leaving its feed permanently stale. Instead of dropping the
|
||||
// gap, tell the client to resync — the frontend responds by running a full
|
||||
// feed-delta fetch (which reconciles both new uploads and deletions).
|
||||
Err(BroadcastStreamRecvError::Lagged(n)) => {
|
||||
tracing::warn!("SSE consumer lagged, dropped {n} event(s); emitting resync");
|
||||
Some(Ok(Event::default().event("resync").data(n.to_string())))
|
||||
}
|
||||
});
|
||||
|
||||
// The session is only checked once at open. Re-validate it periodically so a
|
||||
// logged-out, 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))
|
||||
|
||||
@@ -40,13 +40,13 @@ pub async fn truncate_all(
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
// Reseed config — mirrors migrations 005 and 009. Kept in sync by hand
|
||||
// Reseed config — mirrors migrations 005, 009 and 015. 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', '10'),
|
||||
('upload_rate_per_hour', '100'),
|
||||
('feed_rate_per_min', '60'),
|
||||
('export_rate_per_day', '3'),
|
||||
('quota_tolerance', '0.75'),
|
||||
@@ -70,10 +70,41 @@ 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
@@ -1,7 +1,8 @@
|
||||
use anyhow::Result;
|
||||
use axum::Router;
|
||||
use axum::extract::DefaultBodyLimit;
|
||||
use axum::routing::{delete, get, patch, post};
|
||||
use axum::Router;
|
||||
use tower_http::services::ServeDir;
|
||||
use tower_http::trace::TraceLayer;
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
@@ -17,14 +18,19 @@ 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();
|
||||
|
||||
@@ -39,13 +45,30 @@ 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
|
||||
@@ -53,19 +76,50 @@ 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))
|
||||
// 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)))
|
||||
// "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)),
|
||||
)
|
||||
.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))
|
||||
@@ -74,32 +128,77 @@ 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))
|
||||
.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/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/upload/{id}", delete(handlers::host::host_delete_upload))
|
||||
.route("/api/v1/host/comment/{id}", delete(handlers::host::host_delete_comment))
|
||||
.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),
|
||||
)
|
||||
// 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
|
||||
@@ -108,7 +207,10 @@ 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
|
||||
@@ -127,33 +229,105 @@ async fn main() -> Result<()> {
|
||||
api
|
||||
};
|
||||
|
||||
// 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(),
|
||||
)
|
||||
},
|
||||
);
|
||||
// Serve media files from disk
|
||||
let media_service = ServeDir::new(&config.media_path);
|
||||
|
||||
let router = Router::new()
|
||||
.route("/health", get(|| async { "ok" }))
|
||||
.merge(api)
|
||||
.route("/media/{kind}/{id}", get(handlers::media::serve))
|
||||
.layer(trace_layer)
|
||||
// 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())
|
||||
.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).await?;
|
||||
axum::serve(listener, router)
|
||||
.with_graceful_shutdown(shutdown_signal())
|
||||
.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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,6 +3,10 @@ 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,
|
||||
@@ -24,19 +28,24 @@ pub struct CommentDto {
|
||||
}
|
||||
|
||||
impl Comment {
|
||||
pub async fn create(
|
||||
pool: &PgPool,
|
||||
/// 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,
|
||||
upload_id: Uuid,
|
||||
user_id: Uuid,
|
||||
body: &str,
|
||||
) -> Result<Self, sqlx::Error> {
|
||||
) -> Result<Self, sqlx::Error>
|
||||
where
|
||||
E: sqlx::PgExecutor<'e>,
|
||||
{
|
||||
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(pool)
|
||||
.fetch_one(executor)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -74,26 +83,18 @@ 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
|
||||
}
|
||||
|
||||
pub async fn soft_delete(pool: &PgPool, id: Uuid) -> Result<(), sqlx::Error> {
|
||||
sqlx::query("UPDATE comment SET deleted_at = NOW() WHERE id = $1")
|
||||
sqlx::query_as::<_, Self>("SELECT * FROM comment WHERE id = $1 AND deleted_at IS NULL")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Event-scoped variant of [`Self::soft_delete`]. Returns `false` if the
|
||||
/// comment doesn't exist or belongs to a different event.
|
||||
/// 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_in_event(
|
||||
pool: &PgPool,
|
||||
conn: &mut sqlx::PgConnection,
|
||||
id: Uuid,
|
||||
event_id: Uuid,
|
||||
) -> Result<bool, sqlx::Error> {
|
||||
@@ -106,7 +107,7 @@ impl Comment {
|
||||
)
|
||||
.bind(id)
|
||||
.bind(event_id)
|
||||
.execute(pool)
|
||||
.execute(conn)
|
||||
.await?;
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,11 @@ 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,
|
||||
@@ -11,8 +16,11 @@ pub struct Event {
|
||||
pub is_active: bool,
|
||||
pub uploads_locked_at: Option<DateTime<Utc>>,
|
||||
pub export_released_at: Option<DateTime<Utc>>,
|
||||
pub export_zip_ready: bool,
|
||||
pub export_html_ready: bool,
|
||||
/// 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 created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
@@ -25,13 +33,11 @@ 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(
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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,
|
||||
@@ -10,7 +12,13 @@ pub struct Hashtag {
|
||||
|
||||
impl Hashtag {
|
||||
/// Upsert a hashtag (insert if not exists, return existing if it does).
|
||||
pub async fn upsert(pool: &PgPool, event_id: Uuid, tag: &str) -> Result<Self, sqlx::Error> {
|
||||
///
|
||||
/// 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>,
|
||||
{
|
||||
let normalized = tag.trim().trim_start_matches('#').to_lowercase();
|
||||
sqlx::query_as::<_, Self>(
|
||||
"INSERT INTO hashtag (event_id, tag) VALUES ($1, $2)
|
||||
@@ -19,52 +27,42 @@ impl Hashtag {
|
||||
)
|
||||
.bind(event_id)
|
||||
.bind(&normalized)
|
||||
.fetch_one(pool)
|
||||
.fetch_one(executor)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn link_to_upload(
|
||||
pool: &PgPool,
|
||||
pub async fn link_to_upload<'e, E>(
|
||||
executor: E,
|
||||
upload_id: Uuid,
|
||||
hashtag_id: Uuid,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
) -> Result<(), sqlx::Error>
|
||||
where
|
||||
E: sqlx::PgExecutor<'e>,
|
||||
{
|
||||
sqlx::query(
|
||||
"INSERT INTO upload_hashtag (upload_id, hashtag_id) VALUES ($1, $2)
|
||||
ON CONFLICT DO NOTHING",
|
||||
)
|
||||
.bind(upload_id)
|
||||
.bind(hashtag_id)
|
||||
.execute(pool)
|
||||
.execute(executor)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn unlink_all_from_upload(
|
||||
pool: &PgPool,
|
||||
pub async fn unlink_all_from_upload<'e, E>(
|
||||
executor: E,
|
||||
upload_id: Uuid,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
) -> Result<(), sqlx::Error>
|
||||
where
|
||||
E: sqlx::PgExecutor<'e>,
|
||||
{
|
||||
sqlx::query("DELETE FROM upload_hashtag WHERE upload_id = $1")
|
||||
.bind(upload_id)
|
||||
.execute(pool)
|
||||
.execute(executor)
|
||||
.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
|
||||
@@ -109,4 +107,44 @@ 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"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,9 @@ use chrono::{DateTime, Utc};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::models::user::UserRole;
|
||||
|
||||
// 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)]
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
pub struct Session {
|
||||
pub id: Uuid,
|
||||
@@ -14,18 +15,6 @@ 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,
|
||||
@@ -57,16 +46,19 @@ impl Session {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Reconcile a session against the live `user` row in a single round-trip.
|
||||
/// Returns `None` when the session is missing/expired. The PK join to
|
||||
/// `"user"` is cheap and lets the extractor read the *current* role/ban
|
||||
/// state instead of the JWT claim.
|
||||
pub async fn find_auth_context(
|
||||
/// 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(
|
||||
pool: &PgPool,
|
||||
token_hash: &str,
|
||||
) -> Result<Option<AuthContext>, sqlx::Error> {
|
||||
sqlx::query_as::<_, AuthContext>(
|
||||
"SELECT s.id AS session_id, u.id AS user_id, u.event_id, u.role, u.is_banned
|
||||
) -> Result<Option<crate::models::user::User>, sqlx::Error> {
|
||||
sqlx::query_as::<_, crate::models::user::User>(
|
||||
"SELECT u.*
|
||||
FROM session s
|
||||
JOIN \"user\" u ON u.id = s.user_id
|
||||
WHERE s.token_hash = $1 AND s.expires_at > NOW()",
|
||||
@@ -76,18 +68,29 @@ impl Session {
|
||||
.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?;
|
||||
/// 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?;
|
||||
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)
|
||||
@@ -95,14 +98,13 @@ impl Session {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Revoke every session belonging to a user — used on ban, role change, and
|
||||
/// PIN reset so existing JWTs stop working immediately. Returns the number
|
||||
/// of sessions removed. Best-effort: callers log but do not fail on error.
|
||||
pub async fn delete_by_user_id(pool: &PgPool, user_id: Uuid) -> Result<u64, sqlx::Error> {
|
||||
let result = sqlx::query("DELETE FROM session WHERE user_id = $1")
|
||||
/// 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")
|
||||
.bind(user_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(result.rows_affected())
|
||||
Ok(r.rows_affected())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,10 @@ 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,
|
||||
@@ -19,15 +23,6 @@ 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,
|
||||
@@ -35,8 +30,6 @@ 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>,
|
||||
@@ -46,10 +39,20 @@ 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 {
|
||||
/// 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).
|
||||
/// Takes any executor so the caller can run it inside a transaction (atomic
|
||||
/// quota + insert) or standalone against the pool.
|
||||
pub async fn create<'e, E>(
|
||||
executor: E,
|
||||
event_id: Uuid,
|
||||
@@ -77,9 +80,25 @@ impl Upload {
|
||||
.await
|
||||
}
|
||||
|
||||
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",
|
||||
/// 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",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
@@ -116,6 +135,19 @@ 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,
|
||||
@@ -136,21 +168,18 @@ impl Upload {
|
||||
///
|
||||
/// No-op if the row is already deleted — protects against a double-tap on the
|
||||
/// delete action double-decrementing the counter.
|
||||
///
|
||||
/// 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> {
|
||||
pub async fn soft_delete(pool: &PgPool, id: Uuid) -> Result<(), sqlx::Error> {
|
||||
let mut tx = pool.begin().await?;
|
||||
let row: Option<(Uuid, i64, String, Option<String>, Option<String>)> = sqlx::query_as(
|
||||
let row: Option<(Uuid, i64)> = sqlx::query_as(
|
||||
"UPDATE upload
|
||||
SET deleted_at = NOW()
|
||||
WHERE id = $1 AND deleted_at IS NULL
|
||||
RETURNING user_id, original_size_bytes, original_path, preview_path, thumbnail_path",
|
||||
RETURNING user_id, original_size_bytes",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
let paths = if let Some((user_id, bytes, original, preview, thumbnail)) = row {
|
||||
if let Some((user_id, bytes)) = row {
|
||||
sqlx::query(
|
||||
"UPDATE \"user\"
|
||||
SET total_upload_bytes = GREATEST(0, total_upload_bytes - $2)
|
||||
@@ -160,34 +189,36 @@ impl Upload {
|
||||
.bind(bytes)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
Some(DeletedPaths { original, preview, thumbnail })
|
||||
} else {
|
||||
None
|
||||
};
|
||||
}
|
||||
tx.commit().await?;
|
||||
Ok(paths)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Event-scoped variant of [`Self::soft_delete`]. Returns `None` if no row
|
||||
/// Event-scoped variant of [`Self::soft_delete`]. Returns `false` if no row
|
||||
/// matched (already deleted, wrong event, or unknown id) so host handlers
|
||||
/// can return a clean 404, and the artifact paths otherwise.
|
||||
/// 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.
|
||||
pub async fn soft_delete_in_event(
|
||||
pool: &PgPool,
|
||||
conn: &mut sqlx::PgConnection,
|
||||
id: Uuid,
|
||||
event_id: Uuid,
|
||||
) -> Result<Option<DeletedPaths>, sqlx::Error> {
|
||||
let mut tx = pool.begin().await?;
|
||||
let row: Option<(Uuid, i64, String, Option<String>, Option<String>)> = sqlx::query_as(
|
||||
) -> Result<bool, sqlx::Error> {
|
||||
let tx = conn;
|
||||
let row: Option<(Uuid, i64)> = 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, original_path, preview_path, thumbnail_path",
|
||||
RETURNING user_id, original_size_bytes",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(event_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
let paths = if let Some((user_id, bytes, original, preview, thumbnail)) = row {
|
||||
let deleted = if let Some((user_id, bytes)) = row {
|
||||
sqlx::query(
|
||||
"UPDATE \"user\"
|
||||
SET total_upload_bytes = GREATEST(0, total_upload_bytes - $2)
|
||||
@@ -197,23 +228,25 @@ impl Upload {
|
||||
.bind(bytes)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
Some(DeletedPaths { original, preview, thumbnail })
|
||||
true
|
||||
} else {
|
||||
None
|
||||
false
|
||||
};
|
||||
tx.commit().await?;
|
||||
Ok(paths)
|
||||
Ok(deleted)
|
||||
}
|
||||
|
||||
pub async fn update_caption(
|
||||
pool: &PgPool,
|
||||
pub async fn update_caption<'e, E>(
|
||||
executor: E,
|
||||
id: Uuid,
|
||||
caption: Option<&str>,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
) -> Result<(), sqlx::Error>
|
||||
where
|
||||
E: sqlx::PgExecutor<'e>,
|
||||
{
|
||||
sqlx::query("UPDATE upload SET caption = $2 WHERE id = $1")
|
||||
.bind(id)
|
||||
.bind(caption)
|
||||
.execute(pool)
|
||||
.execute(executor)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -22,6 +22,10 @@ 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,
|
||||
@@ -105,14 +109,16 @@ 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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
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::{broadcast, Semaphore};
|
||||
use tokio::sync::{Semaphore, broadcast};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::models::upload::Upload;
|
||||
@@ -11,41 +12,58 @@ use crate::state::SseEvent;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct CompressionWorker {
|
||||
/// 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>,
|
||||
semaphore: 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,
|
||||
image_concurrency: usize,
|
||||
video_concurrency: usize,
|
||||
concurrency: usize,
|
||||
sse_tx: broadcast::Sender<SseEvent>,
|
||||
) -> Self {
|
||||
Self {
|
||||
image_sem: Arc::new(Semaphore::new(image_concurrency)),
|
||||
video_sem: Arc::new(Semaphore::new(video_concurrency)),
|
||||
semaphore: Arc::new(Semaphore::new(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 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 {
|
||||
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
|
||||
{
|
||||
Ok(_) => {
|
||||
tracing::info!("compression completed for upload {upload_id}");
|
||||
let _ = worker.sse_tx.send(SseEvent {
|
||||
@@ -54,18 +72,30 @@ 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": "Verarbeitung fehlgeschlagen."
|
||||
}).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(),
|
||||
});
|
||||
let _ = Upload::set_compression_status(&worker.pool, upload_id, "failed").await;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -82,9 +112,12 @@ impl CompressionWorker {
|
||||
let original = self.media_path.join(original_path);
|
||||
|
||||
if mime_type.starts_with("image/") {
|
||||
let preview_rel = self.generate_image_preview(upload_id, &original, mime_type).await?;
|
||||
let (preview_rel, display_rel) = self
|
||||
.generate_image_derivatives(upload_id, &original, mime_type)
|
||||
.await?;
|
||||
Upload::set_preview_path(&self.pool, upload_id, &preview_rel).await?;
|
||||
tracing::info!("preview generated for upload {upload_id}");
|
||||
Upload::set_display_path(&self.pool, upload_id, &display_rel).await?;
|
||||
tracing::info!("preview + display 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?;
|
||||
@@ -95,31 +128,42 @@ impl CompressionWorker {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn generate_image_preview(
|
||||
/// 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(
|
||||
&self,
|
||||
upload_id: Uuid,
|
||||
original: &Path,
|
||||
mime_type: &str,
|
||||
) -> Result<String> {
|
||||
) -> Result<(String, 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 preview_filename = format!("{upload_id}.jpg");
|
||||
let preview_path = previews_dir.join(&preview_filename);
|
||||
let filename = format!("{upload_id}.jpg");
|
||||
let preview_path = previews_dir.join(&filename);
|
||||
let display_path = displays_dir.join(&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, 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)
|
||||
// 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)
|
||||
.context("failed to open image")?
|
||||
.with_guessed_format()
|
||||
.context("failed to read image header")?;
|
||||
@@ -130,10 +174,29 @@ impl CompressionWorker {
|
||||
reader.limits(limits);
|
||||
let img = reader.decode().context("failed to decode image")?;
|
||||
|
||||
// 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")?;
|
||||
// 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")?;
|
||||
|
||||
// If the original is PNG, try lossless compression in-place
|
||||
if mime_owned == "image/png" {
|
||||
@@ -149,21 +212,67 @@ impl CompressionWorker {
|
||||
}
|
||||
|
||||
Ok(())
|
||||
});
|
||||
})
|
||||
.await??;
|
||||
|
||||
match tokio::time::timeout(std::time::Duration::from_secs(120), handle).await {
|
||||
Ok(join) => join.context("image task panicked")??,
|
||||
Err(_) => anyhow::bail!("image processing timeout after 120s"),
|
||||
}
|
||||
|
||||
Ok(format!("previews/{preview_filename}"))
|
||||
Ok((
|
||||
format!("previews/{filename}"),
|
||||
format!("displays/{filename}"),
|
||||
))
|
||||
}
|
||||
|
||||
async fn generate_video_thumbnail(
|
||||
&self,
|
||||
upload_id: Uuid,
|
||||
original: &Path,
|
||||
) -> Result<String> {
|
||||
/// 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> {
|
||||
let thumbs_dir = self.media_path.join("thumbnails");
|
||||
tokio::fs::create_dir_all(&thumbs_dir).await?;
|
||||
|
||||
@@ -193,18 +302,14 @@ 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.
|
||||
@@ -213,10 +318,7 @@ 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}"))
|
||||
|
||||
@@ -1,46 +1,147 @@
|
||||
//! Reads of the runtime-tunable `config` table.
|
||||
//! Reads of the runtime-tunable `config` table, fronted by an in-memory cache.
|
||||
//!
|
||||
//! Each handler used to keep a small local copy of these helpers; consolidating them
|
||||
//! 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 someone calls `get_*`.
|
||||
//! the next time the cache reloads.
|
||||
//!
|
||||
//! ## Why a cache
|
||||
//!
|
||||
//! The `config` table is effectively static during an event, yet it was the busiest
|
||||
//! query in the system: every request re-read each key with its own `SELECT`
|
||||
//! (an upload touched it ~8 times). Against the small connection pool that was the
|
||||
//! throughput ceiling. [`ConfigCache`] loads the whole table once and serves reads
|
||||
//! from memory.
|
||||
//!
|
||||
//! ## Consistency contract
|
||||
//!
|
||||
//! Correctness comes from **synchronous invalidation on every write**, not from the
|
||||
//! TTL. The two runtime write paths — the admin `PATCH /admin/config` handler and the
|
||||
//! test-mode truncate/reseed — both call [`ConfigCache::invalidate`] after committing,
|
||||
//! so the *next* read reloads from the DB and sees the new value immediately. The
|
||||
//! [`RELOAD_TTL`] is only a safety net for out-of-band changes (e.g. a migration or a
|
||||
//! manual DB edit); it is deliberately short but never the primary mechanism.
|
||||
//!
|
||||
//! Values are read with a default fallback so the app still starts if a key is missing
|
||||
//! (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;
|
||||
|
||||
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)
|
||||
/// 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)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|(v,)| v)
|
||||
{
|
||||
Ok(rows) => rows,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = ?e, "config reload failed; using defaults for this read");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let values: HashMap<String, String> = rows.into_iter().collect();
|
||||
let result = values.get(key).cloned();
|
||||
*self.inner.write().unwrap() = Some(Snapshot {
|
||||
values,
|
||||
loaded_at: Instant::now(),
|
||||
});
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_str(pool: &PgPool, key: &str, default: &str) -> String {
|
||||
fetch_raw(pool, key).await.unwrap_or_else(|| default.to_string())
|
||||
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_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_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_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_usize(cache: &ConfigCache, key: &str, default: usize) -> usize {
|
||||
cache
|
||||
.get_raw(key)
|
||||
.await
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
pub async fn get_f64(pool: &PgPool, key: &str, default: f64) -> f64 {
|
||||
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)
|
||||
}
|
||||
|
||||
/// 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(pool: &PgPool, key: &str, default: bool) -> bool {
|
||||
let Some(raw) = fetch_raw(pool, key).await else { return default };
|
||||
pub async fn get_bool(cache: &ConfigCache, key: &str, default: bool) -> bool {
|
||||
let Some(raw) = cache.get_raw(key).await else {
|
||||
return default;
|
||||
};
|
||||
match raw.trim().to_ascii_lowercase().as_str() {
|
||||
"true" | "1" | "yes" | "on" => true,
|
||||
"false" | "0" | "no" | "off" => false,
|
||||
|
||||
158
backend/src/services/disk.rs
Normal file
158
backend/src/services/disk.rs
Normal file
@@ -0,0 +1,158 @@
|
||||
//! 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
@@ -1,73 +0,0 @@
|
||||
//! 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<()>;
|
||||
}
|
||||
@@ -13,7 +13,6 @@
|
||||
//! 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;
|
||||
@@ -45,8 +44,23 @@ pub async fn startup_recovery(pool: &PgPool) {
|
||||
Err(e) => tracing::error!("startup recovery: failed to sweep uploads: {e:#}"),
|
||||
}
|
||||
|
||||
// Export jobs interrupted mid-run. Mark 'failed' so the host can re-trigger.
|
||||
// The `UNIQUE(event_id, type)` constraint would otherwise block re-release.
|
||||
// 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.
|
||||
match sqlx::query(
|
||||
"UPDATE export_job
|
||||
SET status = 'failed',
|
||||
@@ -73,12 +87,7 @@ 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,
|
||||
media_path: PathBuf,
|
||||
) {
|
||||
pub fn spawn_periodic_tasks(pool: PgPool, rate_limiter: RateLimiter, sse_tickets: SseTicketStore) {
|
||||
tokio::spawn(async move {
|
||||
let mut tick = tokio::time::interval(Duration::from_secs(3600));
|
||||
// Fire the first tick immediately, then hourly.
|
||||
@@ -88,9 +97,6 @@ pub fn spawn_periodic_tasks(
|
||||
cleanup_sessions(&pool).await;
|
||||
rate_limiter.prune();
|
||||
sse_tickets.prune();
|
||||
// Reclaim disk for uploads soft-deleted more than the grace period
|
||||
// ago, and hard-delete those rows (FKs cascade).
|
||||
crate::services::media_fs::reap_deleted(&pool, &media_path).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
//! 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"),
|
||||
}
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
//! 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));
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,7 @@
|
||||
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;
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
//! bcrypt hashing/verification offloaded to the blocking pool.
|
||||
//!
|
||||
//! bcrypt cost-12 is ~250–400ms 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)
|
||||
}
|
||||
@@ -24,7 +24,12 @@ 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();
|
||||
@@ -71,27 +76,19 @@ impl RateLimiter {
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the client IP for rate-limit keys.
|
||||
/// Extract the client IP from X-Forwarded-For or fall back to a provided socket
|
||||
/// address string.
|
||||
///
|
||||
/// 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.
|
||||
/// 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.
|
||||
pub fn client_ip(headers: &axum::http::HeaderMap, fallback: &str) -> String {
|
||||
if let Some(ip) = headers
|
||||
.get("x-real-ip")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
return ip.to_owned();
|
||||
}
|
||||
headers
|
||||
.get("x-forwarded-for")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.split(',').next_back())
|
||||
.and_then(|s| s.rsplit(',').next())
|
||||
.map(|s| s.trim().to_owned())
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| fallback.to_owned())
|
||||
@@ -99,27 +96,198 @@ pub fn client_ip(headers: &axum::http::HeaderMap, fallback: &str) -> String {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::client_ip;
|
||||
use super::*;
|
||||
use axum::http::HeaderMap;
|
||||
|
||||
const MIN: Duration = Duration::from_secs(60);
|
||||
|
||||
#[test]
|
||||
fn prefers_x_real_ip() {
|
||||
let mut h = HeaderMap::new();
|
||||
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");
|
||||
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 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, 203.0.113.7".parse().unwrap());
|
||||
assert_eq!(client_ip(&h, "fb"), "203.0.113.7");
|
||||
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 falls_back_when_no_headers() {
|
||||
assert_eq!(client_ip(&HeaderMap::new(), "fb"), "fb");
|
||||
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.
|
||||
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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_ip_ignores_spoofed_leftmost_entry() {
|
||||
// A client prepending a fake IP to dodge throttles must not win.
|
||||
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");
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,13 @@ 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();
|
||||
@@ -72,3 +79,66 @@ 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,13 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use sqlx::PgPool;
|
||||
use tokio::sync::{broadcast, Semaphore};
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
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,
|
||||
@@ -40,21 +33,27 @@ pub struct AppState {
|
||||
pub compression: CompressionWorker,
|
||||
pub rate_limiter: RateLimiter,
|
||||
pub sse_tickets: SseTicketStore,
|
||||
/// Caps concurrent upload-body buffering (see UPLOAD_MAX_CONCURRENCY).
|
||||
pub upload_limiter: Arc<Semaphore>,
|
||||
/// In-memory cache in front of the `config` table. Reads go through here; the
|
||||
/// admin PATCH handler and the test reseed invalidate it after committing.
|
||||
pub config_cache: ConfigCache,
|
||||
/// Cached total/free bytes for the media filesystem (quota + admin stats).
|
||||
pub disk_cache: DiskCache,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new(pool: PgPool, config: AppConfig) -> Self {
|
||||
let (sse_tx, _) = broadcast::channel(256);
|
||||
// Independent image (2) and video (2) permit pools — see CompressionWorker.
|
||||
// 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 compression = CompressionWorker::new(
|
||||
pool.clone(),
|
||||
config.media_path.clone(),
|
||||
2,
|
||||
2,
|
||||
config.compression_concurrency,
|
||||
sse_tx.clone(),
|
||||
);
|
||||
let config_cache = ConfigCache::new(pool.clone());
|
||||
Self {
|
||||
pool,
|
||||
config,
|
||||
@@ -62,7 +61,8 @@ impl AppState {
|
||||
compression,
|
||||
rate_limiter: RateLimiter::new(),
|
||||
sse_tickets: SseTicketStore::new(),
|
||||
upload_limiter: Arc::new(Semaphore::new(UPLOAD_MAX_CONCURRENCY)),
|
||||
config_cache,
|
||||
disk_cache: DiskCache::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export const env={}
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
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};
|
||||
@@ -1 +0,0 @@
|
||||
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
@@ -1 +0,0 @@
|
||||
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
@@ -1 +0,0 @@
|
||||
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
@@ -1 +0,0 @@
|
||||
import{l as o,a as r}from"../chunks/eAGLaJx1.js";export{o as load_css,r as start};
|
||||
@@ -1 +0,0 @@
|
||||
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};
|
||||
@@ -1 +0,0 @@
|
||||
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
@@ -1 +0,0 @@
|
||||
{"version":"1778876725548"}
|
||||
File diff suppressed because one or more lines are too long
257
backend/tests/common/mod.rs
Normal file
257
backend/tests/common/mod.rs
Normal file
@@ -0,0 +1,257 @@
|
||||
//! 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()
|
||||
}
|
||||
435
backend/tests/export_epoch.rs
Normal file
435
backend/tests/export_epoch.rs
Normal file
@@ -0,0 +1,435 @@
|
||||
//! 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");
|
||||
}
|
||||
297
backend/tests/upload_concurrency.rs
Normal file
297
backend/tests/upload_concurrency.rs
Normal file
@@ -0,0 +1,297 @@
|
||||
//! 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();
|
||||
}
|
||||
41
docker-compose.dev.yml
Normal file
41
docker-compose.dev.yml
Normal file
@@ -0,0 +1,41 @@
|
||||
# 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}
|
||||
@@ -1,8 +0,0 @@
|
||||
# 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"
|
||||
@@ -14,6 +14,10 @@ services:
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512M
|
||||
|
||||
app:
|
||||
build:
|
||||
@@ -21,27 +25,42 @@ 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
|
||||
# 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
|
||||
# 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
|
||||
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:
|
||||
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://localhost:3000/health"]
|
||||
# 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"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
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
|
||||
|
||||
frontend:
|
||||
build:
|
||||
@@ -49,36 +68,54 @@ 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:
|
||||
condition: service_healthy
|
||||
- app
|
||||
expose:
|
||||
- "3001"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://localhost:3001/"]
|
||||
# 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"]
|
||||
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:
|
||||
|
||||
79
docs/DECISION-media-auth.md
Normal file
79
docs/DECISION-media-auth.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# 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).
|
||||
@@ -1,29 +1,77 @@
|
||||
# Security & Hardening Backlog
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## 🅱 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.
|
||||
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
|
||||
- ⬜ **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
|
||||
(`HKDF(jwt_secret, "media-url")`) would isolate the domains so a future change to one can't
|
||||
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.
|
||||
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.
|
||||
|
||||
## 🅲 Consciously won't-fix at ~100-guest single-box scale
|
||||
|
||||
@@ -36,16 +84,24 @@ 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 (documented, not bugs)
|
||||
## By-design notes (audit branch's signed-media model — see DECISION-media-auth.md)
|
||||
|
||||
- **Banned user retains ≤24h media access via already-held signed URLs.** The media gateway
|
||||
> 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
|
||||
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: 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.
|
||||
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.
|
||||
- **Two DB queries per *cold* media fetch** (`upload` row + uploader row). Mitigated by browser
|
||||
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.
|
||||
caching and the stable bucketed URL. Could be one JOIN if it ever shows up in profiling.
|
||||
|
||||
@@ -129,30 +129,49 @@ 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 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.
|
||||
- **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.
|
||||
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 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).
|
||||
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.
|
||||
|
||||
## 11. Admin — instance configuration
|
||||
|
||||
@@ -174,7 +193,18 @@ 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` and enqueues two background jobs.
|
||||
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.
|
||||
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
|
||||
@@ -182,6 +212,11 @@ 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.
|
||||
|
||||
4
e2e/.prettierignore
Normal file
4
e2e/.prettierignore
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
playwright-report/
|
||||
test-results/
|
||||
package-lock.json
|
||||
7
e2e/.prettierrc
Normal file
7
e2e/.prettierrc
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"useTabs": false,
|
||||
"tabWidth": 2,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "es5",
|
||||
"printWidth": 100
|
||||
}
|
||||
@@ -4,7 +4,17 @@
|
||||
# of HTTPS/Let's Encrypt.
|
||||
|
||||
:3101 {
|
||||
encode zstd gzip
|
||||
# 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"
|
||||
}
|
||||
|
||||
reverse_proxy /api/* app:3000
|
||||
reverse_proxy /media/* app:3000
|
||||
|
||||
@@ -6,6 +6,7 @@ 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.
|
||||
@@ -47,25 +48,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.
|
||||
@@ -104,17 +105,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
|
||||
@@ -127,14 +128,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
|
||||
@@ -223,7 +224,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
|
||||
|
||||
@@ -273,6 +274,7 @@ 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.
|
||||
@@ -282,6 +284,7 @@ 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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
47
e2e/eslint.config.js
Normal file
47
e2e/eslint.config.js
Normal file
@@ -0,0 +1,47 @@
|
||||
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'],
|
||||
}
|
||||
);
|
||||
@@ -47,7 +47,9 @@ 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],
|
||||
@@ -55,7 +57,11 @@ 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],
|
||||
@@ -91,7 +97,9 @@ 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;
|
||||
}
|
||||
|
||||
@@ -109,14 +117,43 @@ export class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async banUser(token: string, userId: string, hideUploads = false) {
|
||||
// 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) {
|
||||
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] });
|
||||
}
|
||||
|
||||
@@ -39,11 +39,16 @@ 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])
|
||||
);
|
||||
@@ -59,6 +64,26 @@ 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`, [
|
||||
@@ -68,16 +93,56 @@ export const db = {
|
||||
);
|
||||
},
|
||||
|
||||
/** 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') {
|
||||
/**
|
||||
* 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'
|
||||
) {
|
||||
await withClient(async (c) => {
|
||||
const ev = await c.query<{ id: string }>(`SELECT id FROM event WHERE slug = $1`, [eventSlug]);
|
||||
const ev = await c.query<{ id: string; export_epoch: string }>(
|
||||
`SELECT id, export_epoch 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)
|
||||
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]
|
||||
`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,
|
||||
]
|
||||
);
|
||||
});
|
||||
},
|
||||
|
||||
@@ -84,16 +84,13 @@ 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);
|
||||
|
||||
5
e2e/helpers/env.ts
Normal file
5
e2e/helpers/env.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* 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
Reference in New Issue
Block a user