Compare commits
72 Commits
641174717c
...
fix/video-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
402215d405 | ||
|
|
2b313e67e0 | ||
|
|
2551c25436 | ||
|
|
d51c6b8c4b | ||
|
|
3bcb7c6a76 | ||
|
|
08d92b7531 | ||
|
|
06bc9ddcb3 | ||
|
|
5f702f2b40 | ||
|
|
31faccfdf8 | ||
|
|
06ade4e158 | ||
|
|
b601c062bd | ||
|
|
0c0eed885a | ||
|
|
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 |
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 *)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
75
.env.example
75
.env.example
@@ -12,10 +12,25 @@ APP_ENV=production
|
|||||||
# ── Database ──────────────────────────────────────────────────────────────────
|
# ── Database ──────────────────────────────────────────────────────────────────
|
||||||
# Set a strong password and keep it in sync between DATABASE_URL and
|
# Set a strong password and keep it in sync between DATABASE_URL and
|
||||||
# POSTGRES_PASSWORD. Generate one with: openssl rand -hex 24
|
# POSTGRES_PASSWORD. Generate one with: openssl rand -hex 24
|
||||||
|
#
|
||||||
|
# SET THIS BEFORE THE FIRST `docker compose up -d`. Postgres reads POSTGRES_PASSWORD
|
||||||
|
# only when it initialises its data directory, on that very first boot. Change it
|
||||||
|
# afterwards and the app authenticates with the new password against a volume still
|
||||||
|
# holding the old one — a permanent restart loop ("password authentication failed").
|
||||||
|
# The only ways out are restoring the old password or `docker compose down -v`, which
|
||||||
|
# deletes the database, the media and the exports. In production the app refuses to
|
||||||
|
# boot while this is still the placeholder below, so it cannot be missed by accident.
|
||||||
DATABASE_URL=postgres://eventsnap:CHANGE_ME_use_a_strong_password@db:5432/eventsnap
|
DATABASE_URL=postgres://eventsnap:CHANGE_ME_use_a_strong_password@db:5432/eventsnap
|
||||||
POSTGRES_USER=eventsnap
|
POSTGRES_USER=eventsnap
|
||||||
POSTGRES_PASSWORD=CHANGE_ME_use_a_strong_password
|
POSTGRES_PASSWORD=CHANGE_ME_use_a_strong_password
|
||||||
POSTGRES_DB=eventsnap
|
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.
|
||||||
|
# PAIRED WITH THE DB CONTAINER'S MEMORY LIMIT: 30 backends plus Postgres 16's default
|
||||||
|
# shared_buffers is already snug in the 1G that docker-compose.yml allots the `db`
|
||||||
|
# service. If you raise this, raise `db.deploy.resources.limits.memory` with it — an
|
||||||
|
# OOM in Postgres doesn't degrade one feature, it takes the whole event down.
|
||||||
|
DATABASE_MAX_CONNECTIONS=30
|
||||||
|
|
||||||
# ── Authentication ────────────────────────────────────────────────────────────
|
# ── Authentication ────────────────────────────────────────────────────────────
|
||||||
# Generate with: openssl rand -hex 64
|
# Generate with: openssl rand -hex 64
|
||||||
@@ -23,8 +38,14 @@ JWT_SECRET=change_me_to_a_random_64_byte_hex_string
|
|||||||
SESSION_EXPIRY_DAYS=30
|
SESSION_EXPIRY_DAYS=30
|
||||||
|
|
||||||
# Admin dashboard password (bcrypt hash).
|
# Admin dashboard password (bcrypt hash).
|
||||||
# Generate with: htpasswd -bnBC 12 "" yourpassword | tr -d ':\n'
|
# Generate with an image the stack already pulls (htpasswd needs apache2-utils, which
|
||||||
ADMIN_PASSWORD_HASH=$2y$12$placeholder_replace_me
|
# a stock VPS does not have):
|
||||||
|
# docker run --rm caddy:2-alpine caddy hash-password --plaintext 'yourpassword'
|
||||||
|
# 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 ─────────────────────────────────────────────────────────────────────
|
||||||
EVENT_NAME=Max & Maria's Wedding
|
EVENT_NAME=Max & Maria's Wedding
|
||||||
@@ -36,19 +57,43 @@ MEDIA_PATH=/media
|
|||||||
# /media is publicly served, so exports here would be downloadable without auth.
|
# /media is publicly served, so exports here would be downloadable without auth.
|
||||||
EXPORT_PATH=/exports
|
EXPORT_PATH=/exports
|
||||||
|
|
||||||
# ── Upload limits ─────────────────────────────────────────────────────────────
|
# ── Runtime settings (upload limits, rate limits, capacity) ───────────────────
|
||||||
DEFAULT_MAX_IMAGE_SIZE_MB=20
|
# NOTE: These are NOT environment variables. Upload size caps, rate limits, guest
|
||||||
DEFAULT_MAX_VIDEO_SIZE_MB=500
|
# 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
|
||||||
# ── Rate limiting ─────────────────────────────────────────────────────────────
|
# not read them from .env. Setting them here has no effect. Current seeded defaults:
|
||||||
DEFAULT_UPLOAD_RATE_PER_HOUR=10
|
# upload rate 100 / hour / guest (raised from 10 by migration 015)
|
||||||
DEFAULT_FEED_RATE_PER_MIN=60
|
# feed rate 60 / minute
|
||||||
DEFAULT_EXPORT_RATE_PER_DAY=3
|
# export rate 3 / day
|
||||||
|
# max image size 20 MB
|
||||||
# ── Capacity ──────────────────────────────────────────────────────────────────
|
# max video size 500 MB
|
||||||
DEFAULT_ESTIMATED_GUEST_COUNT=100
|
# estimated guests 100
|
||||||
# Fraction of total storage that triggers the "low storage" warning (0.0–1.0)
|
# quota tolerance 0.75 (see below — NOT a warning threshold)
|
||||||
DEFAULT_QUOTA_TOLERANCE=0.75
|
# Adjust these in the admin UI before the event if needed.
|
||||||
|
#
|
||||||
|
# quota_tolerance is the MULTIPLIER IN THE PER-USER QUOTA FORMULA, not the point at
|
||||||
|
# which anything warns you:
|
||||||
|
#
|
||||||
|
# per_user_limit = floor(free_disk * quota_tolerance / active_uploaders)
|
||||||
|
#
|
||||||
|
# It is recomputed against LIVE free space on every upload, so it self-throttles: guests
|
||||||
|
# converge on a fixed point at tolerance/(1+tolerance) of the free space you started
|
||||||
|
# with — 43% at 0.75, i.e. ~30 GB of a fresh 70 GB.
|
||||||
|
#
|
||||||
|
# Raising it therefore AUTHORISES GUESTS TO FILL MORE OF THE DISK. Setting 0.95 in the
|
||||||
|
# belief that it means "warn me later" moves the fixed point to ~49% and eats the
|
||||||
|
# headroom the keepsake needs — and the keepsake needs a lot, because Gallery.zip and
|
||||||
|
# Memories.zip are each roughly a second copy of every original (both store media
|
||||||
|
# uncompressed). Budget for media + 2x media, or move exports to their own volume.
|
||||||
|
#
|
||||||
|
# 0.75 is the tested default. Lower it if the box is tight; raise it only if you have
|
||||||
|
# provisioned export headroom separately.
|
||||||
|
|
||||||
# ── Workers ───────────────────────────────────────────────────────────────────
|
# ── 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
|
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
|
||||||
28
.github/workflows/e2e.yml
vendored
28
.github/workflows/e2e.yml
vendored
@@ -7,7 +7,7 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
e2e:
|
e2e:
|
||||||
name: Playwright E2E (chromium-desktop)
|
name: Playwright E2E (chromium + webkit)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
steps:
|
steps:
|
||||||
@@ -25,7 +25,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Install Playwright browsers
|
- name: Install Playwright browsers
|
||||||
working-directory: ./e2e
|
working-directory: ./e2e
|
||||||
run: npx playwright install --with-deps chromium
|
run: npx playwright install --with-deps chromium webkit
|
||||||
|
|
||||||
- name: Bring up the test stack
|
- name: Bring up the test stack
|
||||||
working-directory: ./e2e
|
working-directory: ./e2e
|
||||||
@@ -46,6 +46,30 @@ jobs:
|
|||||||
working-directory: ./e2e
|
working-directory: ./e2e
|
||||||
run: npm run test:e2e -- --project=chromium-desktop
|
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
|
||||||
|
|
||||||
|
# iOS Safari is the app's stated primary user (a wedding guest opening a QR link), and
|
||||||
|
# WebKit is the ONLY engine here that reproduces two of its behaviours:
|
||||||
|
# - it enforces X-Frame-Options on the download iframe, so a site-wide `DENY` makes the
|
||||||
|
# keepsake download silently do nothing. Blink hands attachments to the download
|
||||||
|
# manager first and never notices. That shipped once already.
|
||||||
|
# - it abandons a <video> load without a 206 response to its Range probe.
|
||||||
|
# Both regressions are invisible to every Chromium project, so running WebKit is what
|
||||||
|
# actually gates them on a PR rather than on someone remembering to test locally.
|
||||||
|
#
|
||||||
|
# The project is scoped in playwright.config.ts to the journeys a guest walks
|
||||||
|
# (01-auth, 02-upload, 03-feed, 06-export); four IndexedDB-blob tests skip themselves
|
||||||
|
# there — see helpers/webkit.ts for why that is the harness and not the app.
|
||||||
|
- name: Run E2E tests (webkit / iOS)
|
||||||
|
working-directory: ./e2e
|
||||||
|
run: npm run test:e2e -- --project=webkit-iphone
|
||||||
|
|
||||||
- name: Upload Playwright report
|
- name: Upload Playwright report
|
||||||
if: failure()
|
if: failure()
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
|
|||||||
15
.gitignore
vendored
15
.gitignore
vendored
@@ -13,8 +13,16 @@ frontend/build/
|
|||||||
frontend/export-viewer/node_modules/
|
frontend/export-viewer/node_modules/
|
||||||
frontend/export-viewer/.svelte-kit/
|
frontend/export-viewer/.svelte-kit/
|
||||||
|
|
||||||
# Media uploads (mounted volume in production)
|
# Media uploads. In production these live in the `media_data` DOCKER VOLUME, never in the
|
||||||
media/
|
# working tree — so this pattern is anchored to the repo root and exists only for a local
|
||||||
|
# bind-mount experiment.
|
||||||
|
#
|
||||||
|
# It used to read `media/`, unanchored, which matches a directory of that name at ANY depth.
|
||||||
|
# The only one in the repo is `e2e/fixtures/media/`, so the rule's entire practical effect was
|
||||||
|
# to keep every E2E fixture untracked: a fresh clone got the specs and none of the images or
|
||||||
|
# videos they read. `.github/workflows/e2e.yml` does a plain checkout and generates nothing, so
|
||||||
|
# the committed CI job could not have run the upload, video or export suites at all.
|
||||||
|
/media/
|
||||||
|
|
||||||
# Playwright E2E suite — runtime artifacts (the suite itself is committed)
|
# Playwright E2E suite — runtime artifacts (the suite itself is committed)
|
||||||
e2e/node_modules/
|
e2e/node_modules/
|
||||||
@@ -29,3 +37,6 @@ e2e/.env.test
|
|||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# Claude Code personal (per-user) settings — shared settings.json IS committed
|
||||||
|
.claude/settings.local.json
|
||||||
|
|||||||
43
Caddyfile
43
Caddyfile
@@ -9,34 +9,63 @@
|
|||||||
header {
|
header {
|
||||||
Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
||||||
X-Content-Type-Options "nosniff"
|
X-Content-Type-Options "nosniff"
|
||||||
X-Frame-Options "DENY"
|
|
||||||
Referrer-Policy "strict-origin-when-cross-origin"
|
Referrer-Policy "strict-origin-when-cross-origin"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# X-Frame-Options: DENY everywhere EXCEPT the keepsake download endpoints, which
|
||||||
|
# are navigated in a HIDDEN, SAME-ORIGIN iframe so a 404/429 can't unload the PWA
|
||||||
|
# (see frontend/src/routes/export/+page.svelte). WebKit enforces XFO *before*
|
||||||
|
# honouring Content-Disposition, so a blanket DENY makes the download silently do
|
||||||
|
# nothing on iOS Safari — the app's primary platform. SAMEORIGIN still blocks
|
||||||
|
# cross-origin framing.
|
||||||
|
#
|
||||||
|
# Split into two disjoint matchers rather than an override: Caddy applies the
|
||||||
|
# FIRST header directive outermost, so it wins on write — a later, more specific
|
||||||
|
# `header` would be silently ignored.
|
||||||
|
@framable path /api/v1/export/zip /api/v1/export/html
|
||||||
|
@not_framable not path /api/v1/export/zip /api/v1/export/html
|
||||||
|
header @framable X-Frame-Options "SAMEORIGIN"
|
||||||
|
header @not_framable X-Frame-Options "DENY"
|
||||||
|
|
||||||
# SvelteKit frontend — static assets with long-lived cache (content-hashed filenames)
|
# 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)$
|
@hashed_assets path_regexp hashed /_app/immutable/.*\.[a-f0-9]{8,}\.(js|css|woff2)$
|
||||||
header @hashed_assets Cache-Control "public, max-age=31536000, immutable"
|
header @hashed_assets Cache-Control "public, max-age=31536000, immutable"
|
||||||
|
|
||||||
# Preview/thumbnail images. These are now served by the app through a
|
# Preview/thumbnail images. These are served by the app through a visibility-checked
|
||||||
# visibility-checked alias (/api/v1/upload/{id}/{preview,thumbnail}) so moderation
|
# alias (/api/v1/upload/{id}/{preview,thumbnail}) so moderation can revoke access;
|
||||||
# can revoke access; direct /media/previews|thumbnails is 404-blocked at the app.
|
# the app serves no /media route at all, so there is no direct path to the bytes.
|
||||||
# Privately cacheable for a short window (the app sets the same header; this is the
|
# 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
|
# edge carve-out from the blanket no-store below). Kept short so a moderated image
|
||||||
# stops being served to a direct-URL holder promptly.
|
# stops being served to a direct-URL holder promptly.
|
||||||
@media_api path /api/v1/upload/*/preview /api/v1/upload/*/thumbnail
|
@media_api path /api/v1/upload/*/preview /api/v1/upload/*/thumbnail
|
||||||
header @media_api Cache-Control "private, max-age=300"
|
header @media_api Cache-Control "private, max-age=300"
|
||||||
|
|
||||||
# API — never cache, EXCEPT the gated image routes above.
|
# API and health — never cache, EXCEPT the gated image routes above. A cached health
|
||||||
|
# response would report the last known state rather than the current one.
|
||||||
@api {
|
@api {
|
||||||
path /api/*
|
path /api/* /health
|
||||||
not path /api/v1/upload/*/preview /api/v1/upload/*/thumbnail
|
not path /api/v1/upload/*/preview /api/v1/upload/*/thumbnail
|
||||||
}
|
}
|
||||||
header @api Cache-Control "no-store"
|
header @api Cache-Control "no-store"
|
||||||
|
|
||||||
# Route API and media requests to the Rust backend
|
# Route API and media requests to the Rust backend.
|
||||||
|
#
|
||||||
|
# The app serves no /media route at all (see the note in backend/src/main.rs) — media
|
||||||
|
# bytes are reachable only through the visibility-checked /api/v1/upload aliases, so
|
||||||
|
# /media/* forwards to a plain 404. The proxy line is kept deliberately: it means the
|
||||||
|
# edge faithfully hands /media to the app, so if a future change ever re-introduces a
|
||||||
|
# static media route the e2e gating specs see it here exactly as production would,
|
||||||
|
# instead of being masked by the SvelteKit 404 page.
|
||||||
reverse_proxy /api/* app:3000
|
reverse_proxy /api/* app:3000
|
||||||
reverse_proxy /media/* app:3000
|
reverse_proxy /media/* app:3000
|
||||||
|
|
||||||
|
# The backend registers /health on its ROOT router, not under /api/v1, so it needs its
|
||||||
|
# own line — without it the catch-all below hands /health to SvelteKit, which has no
|
||||||
|
# such route and returns its 404 page. That made the documented post-deploy check
|
||||||
|
# (`curl -fsS https://DOMAIN/health`) fail 100% of the time on a perfectly healthy
|
||||||
|
# stack. e2e/Caddyfile.test has always carried this line; production never did.
|
||||||
|
reverse_proxy /health app:3000
|
||||||
|
|
||||||
# Everything else goes to SvelteKit frontend
|
# Everything else goes to SvelteKit frontend
|
||||||
reverse_proxy frontend:3001
|
reverse_proxy frontend:3001
|
||||||
}
|
}
|
||||||
|
|||||||
21
PROJECT.md
21
PROJECT.md
@@ -709,7 +709,7 @@ CREATE TABLE config (
|
|||||||
INSERT INTO config (key, value) VALUES
|
INSERT INTO config (key, value) VALUES
|
||||||
('max_image_size_mb', '20'),
|
('max_image_size_mb', '20'),
|
||||||
('max_video_size_mb', '500'),
|
('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'),
|
('feed_rate_per_min', '60'),
|
||||||
('export_rate_per_day', '3'),
|
('export_rate_per_day', '3'),
|
||||||
('quota_tolerance', '0.75'),
|
('quota_tolerance', '0.75'),
|
||||||
@@ -1133,16 +1133,19 @@ eventsnap/
|
|||||||
|
|
||||||
### Backup Strategy
|
### Backup Strategy
|
||||||
|
|
||||||
```bash
|
Three artefacts in three places: the database, the `media_data` volume
|
||||||
# Daily (e.g. as a separate Compose service or cron on the VPS)
|
(originals + derivatives), and the **separate** `exports_data` volume. See
|
||||||
pg_dump $DATABASE_URL | gzip > /media/backups/db_$(date +%Y-%m-%d).sql.gz
|
[README.md](README.md#backup) for the exact commands.
|
||||||
|
|
||||||
# Weekly: rsync /media volume to Hetzner Storage Box
|
Everything runs through `docker compose` / `docker run`, because `DATABASE_URL`
|
||||||
rsync -az /opt/eventsnap/media/ \
|
and the `/media` and `/exports` paths only exist inside the compose network —
|
||||||
user@u123456.your-storagebox.de:backup/eventsnap/
|
they are not host paths, and `DATABASE_URL` is never exported into an operator's
|
||||||
```
|
shell.
|
||||||
|
|
||||||
The `/media` volume contains originals, previews, thumbnails, generated exports, and DB backups — a single volume to back up.
|
Export archives are deliberately outside `MEDIA_PATH` (`EXPORT_PATH=/exports`): a
|
||||||
|
keepsake contains every photo in the event, and keeping it off the media tree is
|
||||||
|
what stops it being reachable except through the ticket-gated handler. A backup
|
||||||
|
of the media volume alone silently loses every generated keepsake.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
315
README.md
315
README.md
@@ -34,7 +34,6 @@ A guest scans the QR code on their way in, types their name, and is immediately
|
|||||||
### Planned (v1.x)
|
### Planned (v1.x)
|
||||||
|
|
||||||
- Individual file download button
|
- Individual file download button
|
||||||
- Low-disk alert (< 10 GB free)
|
|
||||||
- Event banner / cover image
|
- Event banner / cover image
|
||||||
- Chunked resumable upload for large videos
|
- Chunked resumable upload for large videos
|
||||||
- Host-curated story highlights
|
- Host-curated story highlights
|
||||||
@@ -98,33 +97,135 @@ eventsnap/
|
|||||||
git clone https://git.mc02.dev/fabi/EventSnap.git eventsnap
|
git clone https://git.mc02.dev/fabi/EventSnap.git eventsnap
|
||||||
cd eventsnap
|
cd eventsnap
|
||||||
|
|
||||||
# 2. Configure environment
|
# 2. Configure environment — set EVERY secret NOW, before step 3.
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
nano .env # set DOMAIN, JWT_SECRET, ADMIN_PASSWORD_HASH, EVENT_NAME, etc.
|
nano .env # DOMAIN, EVENT_NAME, EVENT_SLUG,
|
||||||
|
# JWT_SECRET, ADMIN_PASSWORD_HASH,
|
||||||
|
# POSTGRES_PASSWORD *and* the same password inside DATABASE_URL
|
||||||
|
# (see "Generate required secrets" below)
|
||||||
|
|
||||||
# 3. Start the stack
|
# 3. Start the stack
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **Set every secret before step 3 — `POSTGRES_PASSWORD` especially.** Postgres reads it **only
|
||||||
|
> when it initialises its data directory**, which happens on the very first `docker compose up -d`.
|
||||||
|
> Changing it in `.env` afterwards does not change the stored password: the app then authenticates
|
||||||
|
> with the new one against a volume holding the old one, and you get a permanent restart loop with
|
||||||
|
> `password authentication failed for user "eventsnap"`. The only fixes are restoring the old
|
||||||
|
> password or `docker compose down -v`, which **deletes the database, the media and the exports**.
|
||||||
|
> Getting it right once, up front, costs nothing; getting it wrong costs the volume.
|
||||||
|
|
||||||
Caddy automatically obtains a Let's Encrypt certificate on first start. The app is live at `https://DOMAIN` within ~30 seconds.
|
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.
|
> **If the site never comes up:** with `APP_ENV=production` the backend **refuses to boot** while
|
||||||
|
> `JWT_SECRET`, `ADMIN_PASSWORD_HASH` or the password inside `DATABASE_URL` still hold the
|
||||||
|
> `.env.example` placeholders (this is deliberate — a publicly-known signing key or database
|
||||||
|
> password 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 lists
|
||||||
|
> **every** unset secret at once, so one edit fixes them all.
|
||||||
|
>
|
||||||
|
> **If it comes up but keeps restarting with `password authentication failed for user
|
||||||
|
> "eventsnap"`:** `POSTGRES_PASSWORD` was changed after the database volume was created. Postgres
|
||||||
|
> applies that variable only at initialisation, so `.env` and the stored password have drifted
|
||||||
|
> apart permanently. `docker compose logs app` spells this out. Before the event, with nothing
|
||||||
|
> worth keeping:
|
||||||
|
>
|
||||||
|
> ```bash
|
||||||
|
> docker compose down -v && docker compose up -d # -v DELETES db + media + exports. No undo.
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> **Once the event has real data, never do that.** Put the original password back into
|
||||||
|
> `DATABASE_URL`, or change the stored one instead:
|
||||||
|
>
|
||||||
|
> ```bash
|
||||||
|
> docker compose exec db psql -U "$POSTGRES_USER" -c \
|
||||||
|
> "ALTER ROLE eventsnap WITH PASSWORD 'the-password-now-in-your-.env';"
|
||||||
|
> ```
|
||||||
|
|
||||||
> **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:
|
> **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
|
> ```bash
|
||||||
> docker compose -f docker-compose.yml -f docker-compose.dev.yml up
|
> docker compose -f docker-compose.yml -f docker-compose.dev.yml up
|
||||||
> ```
|
> ```
|
||||||
|
|
||||||
|
### Updating an existing deployment
|
||||||
|
|
||||||
|
> **`docker compose up -d` alone will NOT deploy your changes.** `app` and `frontend` are
|
||||||
|
> `build:` services with no published image tag, and Compose has no source-change detection:
|
||||||
|
> if an image with that name already exists it is reused. After a `git pull` the command
|
||||||
|
> reports `Container … Running`, changes nothing, and **exits 0** — so a deploy that shipped
|
||||||
|
> nothing looks exactly like a successful one. `--build` is what makes it real.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /path/to/eventsnap
|
||||||
|
|
||||||
|
# 1. Back up first — migrations run automatically on boot and are not reversible in place.
|
||||||
|
# (See "Backup" below; the database dump is the one that matters here.)
|
||||||
|
|
||||||
|
# 2. Fetch the new code.
|
||||||
|
git pull
|
||||||
|
|
||||||
|
# 3. Rebuild and restart the application services. --build is NOT optional.
|
||||||
|
docker compose up -d --build
|
||||||
|
|
||||||
|
# 4. Apply any Caddyfile change. Step 3 does NOT do this — see the warning below.
|
||||||
|
docker compose up -d --force-recreate caddy
|
||||||
|
|
||||||
|
# 5. Confirm the app came back up. Anything other than "ok" means check the logs.
|
||||||
|
curl -fsS https://DOMAIN/health && echo
|
||||||
|
|
||||||
|
# 6. Confirm a NEW image was actually built. Note the IMAGE ID before you start and
|
||||||
|
# compare — it must have changed. (Ignore the CREATED column; it reports the base
|
||||||
|
# layer's age, not this build's.) An unchanged ID means step 3 ran without --build
|
||||||
|
# and you are still serving the old code.
|
||||||
|
docker compose images app frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
Migrations are applied by the backend on startup, so step 3 covers them. If `app` stays
|
||||||
|
unhealthy afterwards, `docker compose logs app` will name the failing migration — and note
|
||||||
|
that a migration applied by a *newer* build is not removed by checking out an older commit,
|
||||||
|
so rolling back code without restoring the database snapshot from step 1 leaves the schema
|
||||||
|
ahead of the binary and the app refusing to boot.
|
||||||
|
|
||||||
|
> **Why step 4 exists.** `--build` only rebuilds services that have a `build:` section, and
|
||||||
|
> `caddy` is a pinned upstream image. Compose decides whether to recreate a container from its
|
||||||
|
> *config hash*, which covers the mount **specification** (`./Caddyfile:/etc/caddy/Caddyfile:ro`)
|
||||||
|
> but **not the file's contents** — so a `git pull` that changes `./Caddyfile` produces no
|
||||||
|
> delta, Compose reports `Running`, and Caddy keeps serving its old config indefinitely. Exit
|
||||||
|
> code 0 throughout.
|
||||||
|
>
|
||||||
|
> That is not hypothetical: the fix that made the keepsake download work on iOS
|
||||||
|
> (`137c4ee`) touched the Caddyfile and four e2e files and nothing else, so **all** of its
|
||||||
|
> production effect lives in that one file. Without step 4 you deploy it, watch both image IDs
|
||||||
|
> change, and iOS downloads stay broken.
|
||||||
|
>
|
||||||
|
> `--force-recreate` rather than `restart` or `caddy reload`: the bind mount is resolved to an
|
||||||
|
> **inode** when the container is created, and `git pull` replaces the file instead of editing
|
||||||
|
> it in place, so the container can still be bound to the old, now-unlinked inode. A restart
|
||||||
|
> then re-reads the stale content. Recreating the container re-resolves the path.
|
||||||
|
|
||||||
|
`db` is never touched, and recreating `caddy` does not disturb the `caddy_data` volume, so the
|
||||||
|
TLS certificate and all data volumes survive.
|
||||||
|
|
||||||
### Generate required secrets
|
### Generate required secrets
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# JWT secret (64 random bytes)
|
# JWT secret (64 random bytes)
|
||||||
openssl rand -hex 64
|
openssl rand -hex 64
|
||||||
|
|
||||||
# Admin password hash (bcrypt, cost 12)
|
# Database password (goes in BOTH DATABASE_URL and POSTGRES_PASSWORD)
|
||||||
htpasswd -bnBC 12 "" yourpassword | tr -d ':\n'
|
openssl rand -hex 24
|
||||||
|
|
||||||
|
# Admin password hash (bcrypt). Uses an image the stack already pulls, so it needs
|
||||||
|
# nothing installed on the host — `htpasswd` lives in apache2-utils, which a stock
|
||||||
|
# VPS does not have. Emits cost 14 rather than 12; that is fine (admin login is
|
||||||
|
# rate-limited and hashed off the async runtime), and any $2a/$2b/$2y hash verifies.
|
||||||
|
docker run --rm caddy:2-alpine caddy hash-password --plaintext 'yourpassword'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Wrap the resulting hash in **single quotes** in `.env` — see the note there; a bcrypt
|
||||||
|
hash is full of `$`, and both Compose and dotenvy would otherwise eat those segments.
|
||||||
|
|
||||||
### Environment Variables
|
### Environment Variables
|
||||||
|
|
||||||
See [.env.example](.env.example) for the full list with descriptions and defaults. Key variables:
|
See [.env.example](.env.example) for the full list with descriptions and defaults. Key variables:
|
||||||
@@ -162,26 +263,201 @@ See [.env.example](.env.example) for the full list with descriptions and default
|
|||||||
└────────┘
|
└────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
- `/api/*` and `/media/*` → Rust backend
|
- `/api/*` → Rust backend
|
||||||
- Everything else → SvelteKit frontend (`adapter-node`)
|
- Everything else → SvelteKit frontend (`adapter-node`)
|
||||||
- Named volumes: `postgres_data`, `media_data`, `caddy_data`
|
- Named volumes: `postgres_data`, `media_data`, `exports_data`, `caddy_data`
|
||||||
|
|
||||||
|
Media is **not** served as static files. Every image goes through a
|
||||||
|
visibility-checked alias (`/api/v1/upload/{id}/{preview,display,thumbnail,original}`)
|
||||||
|
so a host takedown or a ban actually revokes access to the bytes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sizing the disk
|
||||||
|
|
||||||
|
`postgres_data`, `media_data` and `exports_data` are all Docker named volumes under
|
||||||
|
`/var/lib/docker/volumes`, so **they share one filesystem**. Filling it does not
|
||||||
|
degrade one subsystem — Postgres stops being able to write and the whole event goes
|
||||||
|
down.
|
||||||
|
|
||||||
|
Uploads are self-limiting. `per_user_limit = free_disk × quota_tolerance ÷
|
||||||
|
active_uploaders` is recomputed against live free space on every upload, so guests
|
||||||
|
converge on a fixed point at `tolerance / (1 + tolerance)` of the free space you
|
||||||
|
started with — **43%** at the default 0.75. On an 80 GB box with ~70 GB free after
|
||||||
|
the OS and images, media settles at ~30 GB and stops.
|
||||||
|
|
||||||
|
**The keepsake is what the 80 GB baseline does not cover.** `Gallery.zip` and
|
||||||
|
`Memories.zip` are built concurrently and each is roughly a second copy of every
|
||||||
|
original: both write their media `Compression::Stored`, and `Memories.zip` streams the
|
||||||
|
untouched original for every video and for every image at or under 5 MB. So a release
|
||||||
|
wants room for **two more copies of the gallery** on top of the gallery itself.
|
||||||
|
|
||||||
|
| Stage | Used | Free (80 GB box) |
|
||||||
|
|---|---|---|
|
||||||
|
| Fresh box (OS + images) | ~10 GB | ~70 GB |
|
||||||
|
| Guests reach the quota fixed point | ~40 GB | ~40 GB |
|
||||||
|
| Host releases → both archives | ~100 GB | **ENOSPC** |
|
||||||
|
|
||||||
|
Two ways to size for it:
|
||||||
|
|
||||||
|
- **Provision ~3× your expected media** on one volume (media + two archives), or
|
||||||
|
- **give `exports_data` its own volume** so a full export cannot reach Postgres, and
|
||||||
|
size that one at ~2× expected media.
|
||||||
|
|
||||||
|
This is no longer silent. The export refuses up front with the two numbers rather than
|
||||||
|
hitting ENOSPC halfway through a multi-GB write, a rebuild reclaims the superseded
|
||||||
|
generation before it starts (so peak is one generation, not two), and the host
|
||||||
|
dashboard warns as soon as the keepsake would not fit — which is the only point at
|
||||||
|
which anyone can still do something about it.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Backup
|
## Backup
|
||||||
|
|
||||||
```bash
|
There are **three** things to back up, and they live in three different places.
|
||||||
# Database snapshot
|
`DATABASE_URL` and the container paths (`/media`, `/exports`) are meaningful only
|
||||||
pg_dump $DATABASE_URL | gzip > /media/backups/db_$(date +%Y-%m-%d).sql.gz
|
*inside* the compose network — they are not host paths, and `DATABASE_URL` is
|
||||||
|
never exported into an operator's shell — so every command below runs through
|
||||||
|
`docker compose` from the repo directory.
|
||||||
|
|
||||||
# Weekly offsite sync (Hetzner Storage Box or similar)
|
```bash
|
||||||
rsync -az /opt/eventsnap/media/ user@storagebox.example.com:backup/eventsnap/
|
# 1. Database snapshot. Runs pg_dump inside the db container (the app image has no
|
||||||
|
# postgres client), reading credentials from the compose environment.
|
||||||
|
# --clean --if-exists makes the dump SELF-CLEANING: without it the restore below
|
||||||
|
# aborts on the first "already exists" against a database that has ever booted,
|
||||||
|
# which is every database you would actually want to restore over.
|
||||||
|
mkdir -p ./backups
|
||||||
|
docker compose exec -T db \
|
||||||
|
sh -c 'pg_dump --clean --if-exists -U "$POSTGRES_USER" "$POSTGRES_DB"' \
|
||||||
|
| gzip > ./backups/db_$(date +%Y-%m-%d).sql.gz
|
||||||
|
|
||||||
|
# 2. Uploaded media (originals + derivatives) out of the named volume.
|
||||||
|
# NOTE the mountpoint is /src, not /media: if the volume is ever empty, Docker
|
||||||
|
# pre-populates a fresh mount from the image's own directory, and alpine ships a
|
||||||
|
# /media containing cdrom/floppy/usb. Mounting somewhere the image has nothing
|
||||||
|
# avoids silently tarring (and polluting the volume with) those.
|
||||||
|
docker run --rm \
|
||||||
|
-v eventsnap_media_data:/src:ro -v "$PWD/backups":/backup \
|
||||||
|
alpine tar czf /backup/media_$(date +%Y-%m-%d).tar.gz -C /src .
|
||||||
|
|
||||||
|
# 3. Export archives — a SEPARATE volume (see the security note below).
|
||||||
|
docker run --rm \
|
||||||
|
-v eventsnap_exports_data:/src:ro -v "$PWD/backups":/backup \
|
||||||
|
alpine tar czf /backup/exports_$(date +%Y-%m-%d).tar.gz -C /src .
|
||||||
|
|
||||||
|
# Offsite sync of the three artefacts above.
|
||||||
|
rsync -az ./backups/ user@storagebox.example.com:backup/eventsnap/
|
||||||
```
|
```
|
||||||
|
|
||||||
The `/media` volume holds originals, previews, thumbnails, exports, and DB backups — a single path to back up.
|
Volume names are prefixed with the compose project name — `eventsnap_` if you run
|
||||||
|
from a directory called `eventsnap`. Confirm yours with `docker volume ls`.
|
||||||
|
|
||||||
|
> **Exports are deliberately NOT under `/media`.** They live on their own
|
||||||
|
> `exports_data` volume (`EXPORT_PATH=/exports`) because a keepsake archive
|
||||||
|
> contains every photo in the event; keeping it outside the media tree is what
|
||||||
|
> stops it being reachable except through the ticket-gated download handler.
|
||||||
|
> Backing up only the media volume therefore loses every generated keepsake.
|
||||||
|
|
||||||
|
### When to run it
|
||||||
|
|
||||||
|
**A nightly cron is the wrong shape for this app.** Every irreplaceable byte is
|
||||||
|
created inside one eight-hour window, and nobody can retake a wedding. Run the three
|
||||||
|
commands above:
|
||||||
|
|
||||||
|
1. **The night of the event**, once uploads have stopped. This is the backup that
|
||||||
|
matters; everything else is a formality.
|
||||||
|
2. **After the host releases the gallery**, so the generated keepsake is captured too.
|
||||||
|
3. Weekly thereafter, until the event is archived and torn down.
|
||||||
|
|
||||||
|
Take the DB dump and the media tarball **back to back**, without uploads in flight
|
||||||
|
between them. Upload rows reference files by path — a database from 22:00 and a media
|
||||||
|
volume from 23:00 gives you rows pointing at files the dump doesn't know about, and
|
||||||
|
rows whose files aren't in the tarball. Locking uploads from the host dashboard first
|
||||||
|
(**Uploads sperren**) makes the pair genuinely consistent.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Restore
|
||||||
|
|
||||||
|
An untested backup is not a backup. Run this once against a scratch host **before**
|
||||||
|
the event — it is roughly ten minutes, and it is the only way to find out that your
|
||||||
|
tarball is empty or your dump is truncated while that is still a small problem.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 0. Stop the app FIRST. Migrations run on boot and a live pool will fight the
|
||||||
|
# restore — a booting app against a half-restored schema can leave the migration
|
||||||
|
# table and the schema disagreeing, which is its own recovery problem.
|
||||||
|
# Leave `db` running: the dump is restored through it.
|
||||||
|
docker compose stop app caddy
|
||||||
|
|
||||||
|
# 1. Database. The dump carries its own DROPs (step 1 of Backup), so this replaces
|
||||||
|
# rather than collides. A dump taken WITHOUT --clean --if-exists will abort here
|
||||||
|
# on the first "already exists" — restore that one into a fresh empty database
|
||||||
|
# instead.
|
||||||
|
gunzip -c ./backups/db_2026-07-29.sql.gz \
|
||||||
|
| docker compose exec -T db \
|
||||||
|
sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" --set ON_ERROR_STOP=1'
|
||||||
|
|
||||||
|
# 2. Media. NOTE the `--numeric-owner` and the chown: the app runs as a
|
||||||
|
# NON-ROOT user (uid 100, gid 101 — `addgroup -S app && adduser -S app`), and a
|
||||||
|
# restore that lands root-owned files makes every upload fail with EACCES deep in
|
||||||
|
# the write path, surfacing to the guest as a generic 500 with nothing in the UI
|
||||||
|
# to suggest permissions. The explicit chown is what guarantees it — BusyBox tar
|
||||||
|
# (which is what `alpine` ships) has no --same-owner, and restores ownership only
|
||||||
|
# because it runs as root here.
|
||||||
|
docker run --rm \
|
||||||
|
-v eventsnap_media_data:/dst -v "$PWD/backups":/backup:ro \
|
||||||
|
alpine sh -c 'tar xzf /backup/media_2026-07-29.tar.gz -C /dst \
|
||||||
|
--numeric-owner && chown -R 100:101 /dst'
|
||||||
|
|
||||||
|
# 3. Exports. Same volume-name caveat, same ownership rules.
|
||||||
|
docker run --rm \
|
||||||
|
-v eventsnap_exports_data:/dst -v "$PWD/backups":/backup:ro \
|
||||||
|
alpine sh -c 'tar xzf /backup/exports_2026-07-29.tar.gz -C /dst \
|
||||||
|
--numeric-owner && chown -R 100:101 /dst'
|
||||||
|
|
||||||
|
# 4. Back up. Migrations run, then export recovery re-arms any keepsake whose file
|
||||||
|
# didn't come back with the volume.
|
||||||
|
docker compose up -d app caddy
|
||||||
|
docker compose logs -f app # watch for "migrations applied"
|
||||||
|
|
||||||
|
# 5. Verify — all three, not just the first.
|
||||||
|
curl -fsS https://DOMAIN/health && echo # → ok
|
||||||
|
# … then sign in as host and confirm the feed renders images (proves the media
|
||||||
|
# volume restored AND is readable by uid 100), and that the keepsake downloads.
|
||||||
|
```
|
||||||
|
|
||||||
|
If the media volume restored but images 404 while the feed lists them, the paths are
|
||||||
|
there and the bytes aren't — check `docker compose exec app ls -ln /media/originals`
|
||||||
|
and confirm both the files and the `100:101` ownership.
|
||||||
|
|
||||||
|
The restore is deliberately **not** automated. It is rare, destructive, and the one
|
||||||
|
operation where a script that half-works is worse than a checklist someone reads.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
|
## 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.
|
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.
|
||||||
@@ -198,7 +474,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.
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -225,7 +508,7 @@ Open:
|
|||||||
- [ ] SSE delta-fetch on foreground reconnect (scaffolded in [sse.ts](frontend/src/lib/sse.ts), not wired)
|
- [ ] SSE delta-fetch on foreground reconnect (scaffolded in [sse.ts](frontend/src/lib/sse.ts), not wired)
|
||||||
- [ ] Live diashow / slideshow mode — see [docs/CONCEPT_DIASHOW.md](docs/CONCEPT_DIASHOW.md)
|
- [ ] Live diashow / slideshow mode — see [docs/CONCEPT_DIASHOW.md](docs/CONCEPT_DIASHOW.md)
|
||||||
- [ ] Individual file download button per post
|
- [ ] Individual file download button per post
|
||||||
- [ ] Low-disk alert (< 10 GB free)
|
- [x] Low-disk alert — host dashboard warns below 10 GB free, or whenever the keepsake would not fit
|
||||||
- [ ] Event banner / cover image
|
- [ ] Event banner / cover image
|
||||||
- [ ] Chunked resumable upload for files > 100 MB
|
- [ ] Chunked resumable upload for files > 100 MB
|
||||||
- [ ] Shared Tailwind config between main app and export-viewer
|
- [ ] Shared Tailwind config between main app and export-viewer
|
||||||
|
|||||||
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;
|
||||||
1
backend/migrations/017_join_ip_rate.down.sql
Normal file
1
backend/migrations/017_join_ip_rate.down.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
DELETE FROM config WHERE key IN ('join_ip_rate_per_min', 'admin_login_rate_enabled');
|
||||||
18
backend/migrations/017_join_ip_rate.up.sql
Normal file
18
backend/migrations/017_join_ip_rate.up.sql
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
-- Per-IP flood ceiling for /join, and the `admin_login_rate_enabled` toggle that
|
||||||
|
-- every prior migration forgot to seed.
|
||||||
|
--
|
||||||
|
-- Rationale: /join was throttled at 5 requests per 60s keyed on the client IP. At a
|
||||||
|
-- venue every guest is behind one NAT, so the whole party shared a single bucket —
|
||||||
|
-- 12 guests scanning the QR code within a few seconds meant 5 got in and 7 were
|
||||||
|
-- turned away. The handler now keys the real anti-spam bucket per (ip, name), the
|
||||||
|
-- same shape as `recover:{ip}:{name}`, and keeps only a loose per-IP ceiling to bound
|
||||||
|
-- raw volume. 60/min comfortably covers a whole wedding arriving at once while still
|
||||||
|
-- capping a flood from a single source.
|
||||||
|
--
|
||||||
|
-- `admin_login_rate_enabled` is read by auth::handlers::admin_login with a code
|
||||||
|
-- default of `true`, but no migration ever inserted it, so it was invisible to the
|
||||||
|
-- admin config UI and to the e2e reseed. Seed it explicitly.
|
||||||
|
INSERT INTO config (key, value) VALUES
|
||||||
|
('join_ip_rate_per_min', '60'),
|
||||||
|
('admin_login_rate_enabled', 'true')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
2
backend/migrations/018_derivatives_rev.down.sql
Normal file
2
backend/migrations/018_derivatives_rev.down.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
DROP INDEX IF EXISTS idx_upload_derivatives_rev;
|
||||||
|
ALTER TABLE upload DROP COLUMN IF EXISTS derivatives_rev;
|
||||||
18
backend/migrations/018_derivatives_rev.up.sql
Normal file
18
backend/migrations/018_derivatives_rev.up.sql
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
-- Track which revision of the derivative pipeline produced an upload's preview/display.
|
||||||
|
--
|
||||||
|
-- Rev 1 applies the EXIF orientation tag. Everything generated before it decoded the raw
|
||||||
|
-- sensor pixels and re-encoded to JPEG (which writes no EXIF), so every portrait phone photo
|
||||||
|
-- was stored sideways in the feed preview, the diashow display and the keepsake — while the
|
||||||
|
-- untouched original still rendered upright.
|
||||||
|
--
|
||||||
|
-- Existing rows default to 0 so the startup backfill can find and re-generate them exactly
|
||||||
|
-- once; bump the constant in services/compression.rs if the pipeline ever changes again.
|
||||||
|
ALTER TABLE upload ADD COLUMN IF NOT EXISTS derivatives_rev SMALLINT NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
-- Only image derivatives are affected — video thumbnails are extracted by ffmpeg, which
|
||||||
|
-- already honours the rotation matrix. Mark them current so the backfill skips them.
|
||||||
|
UPDATE upload SET derivatives_rev = 1 WHERE mime_type NOT LIKE 'image/%';
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_upload_derivatives_rev
|
||||||
|
ON upload (derivatives_rev)
|
||||||
|
WHERE deleted_at IS NULL;
|
||||||
1
backend/migrations/019_recover_ip_rate.down.sql
Normal file
1
backend/migrations/019_recover_ip_rate.down.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
DELETE FROM config WHERE key = 'recover_ip_rate_per_min';
|
||||||
19
backend/migrations/019_recover_ip_rate.up.sql
Normal file
19
backend/migrations/019_recover_ip_rate.up.sql
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
-- Per-IP flood ceiling for /recover, mirroring the one migration 017 added for /join.
|
||||||
|
--
|
||||||
|
-- Rationale: /recover is keyed `recover:{ip}:{name}` at 5 per 15 minutes. That is the
|
||||||
|
-- right shape for its actual job — stopping someone who knows a display name (they are
|
||||||
|
-- visible on the feed) from burning the victim's 3-strike PIN counter and locking them
|
||||||
|
-- out repeatedly. But the name is attacker-chosen, so cycling names mints a fresh bucket
|
||||||
|
-- every time and the per-IP cost is unbounded.
|
||||||
|
--
|
||||||
|
-- Behind that limiter sits a cost-12 bcrypt verify, including an UNCONDITIONAL throwaway
|
||||||
|
-- verify for names that don't exist — deliberately, to close a timing oracle. So an
|
||||||
|
-- unknown name is the cheapest possible way to make the server do ~200ms of hashing.
|
||||||
|
-- Without a ceiling, one client can saturate the box's CPU with a name generator.
|
||||||
|
--
|
||||||
|
-- 30/min is far above any real recovery attempt (a guest tries their PIN a handful of
|
||||||
|
-- times) while capping a name-cycling flood. The per-(ip, name) bucket is unchanged and
|
||||||
|
-- remains the anti-guessing control.
|
||||||
|
INSERT INTO config (key, value) VALUES
|
||||||
|
('recover_ip_rate_per_min', '30')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
1
backend/migrations/020_social_rate.down.sql
Normal file
1
backend/migrations/020_social_rate.down.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
DELETE FROM config WHERE key IN ('social_rate_per_min', 'social_rate_enabled');
|
||||||
16
backend/migrations/020_social_rate.up.sql
Normal file
16
backend/migrations/020_social_rate.up.sql
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
-- Per-user rate limit for social writes (likes, comments, comment deletions).
|
||||||
|
--
|
||||||
|
-- These were the only writes in the app with no limit at all. Every other mutating
|
||||||
|
-- path -- upload, join, recover, export, admin login -- carries one; social.rs
|
||||||
|
-- carried none, so the coverage was asymmetric rather than deliberately open.
|
||||||
|
--
|
||||||
|
-- Severity is genuinely low for an invited-guest event, and the amplification worry
|
||||||
|
-- turned out to be contained: a like fans an SSE broadcast to ~100 clients, but the
|
||||||
|
-- export regeneration it could otherwise trigger is debounced (REGEN_DEBOUNCE 20s)
|
||||||
|
-- and superseded workers are inert. So this closes the gap for symmetry, not urgency,
|
||||||
|
-- and the ceiling is set high enough that no real guest will ever meet it -- a
|
||||||
|
-- double-tapping enthusiast at a wedding is not the thing being defended against.
|
||||||
|
INSERT INTO config (key, value) VALUES
|
||||||
|
('social_rate_per_min', '120'),
|
||||||
|
('social_rate_enabled', 'true')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
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,11 +1,12 @@
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use axum::extract::State;
|
|
||||||
use axum::http::{HeaderMap, StatusCode};
|
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
|
use axum::extract::{ConnectInfo, State};
|
||||||
|
use axum::http::{HeaderMap, StatusCode};
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use rand::Rng;
|
use rand::Rng;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::net::SocketAddr;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::auth::jwt;
|
use crate::auth::jwt;
|
||||||
@@ -33,19 +34,32 @@ pub struct JoinResponse {
|
|||||||
|
|
||||||
pub async fn join(
|
pub async fn join(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
Json(body): Json<JoinRequest>,
|
Json(body): Json<JoinRequest>,
|
||||||
) -> Result<(StatusCode, Json<JoinResponse>), AppError> {
|
) -> Result<(StatusCode, Json<JoinResponse>), AppError> {
|
||||||
let ip = client_ip(&headers, "unknown");
|
let ip = client_ip(&headers, &peer.ip().to_string());
|
||||||
let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
|
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;
|
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:{ip}"), 5, Duration::from_secs(60))
|
// Coarse per-IP flood ceiling. `/join` is pre-auth so there is no user to key on, and
|
||||||
{
|
// at a venue EVERY guest arrives from one public IP — a tight per-IP bucket meant the
|
||||||
return Err(AppError::TooManyRequests(
|
// 6th person through the door was turned away by the 5 ahead of them. So the per-IP
|
||||||
"Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
|
// limit here only bounds raw volume; the real anti-spam bucket is per-name below.
|
||||||
None,
|
// Cheap enough to run before validation, which keeps a flood of malformed bodies from
|
||||||
));
|
// being free.
|
||||||
|
if rate_limits_on && join_rate_on {
|
||||||
|
let ip_ceiling = config::get_usize(&state.config_cache, "join_ip_rate_per_min", 60).await;
|
||||||
|
if let Err(retry_after_secs) = state.rate_limiter.check_with_retry(
|
||||||
|
format!("join_ip:{ip}"),
|
||||||
|
ip_ceiling,
|
||||||
|
Duration::from_secs(60),
|
||||||
|
) {
|
||||||
|
return Err(AppError::TooManyRequests(
|
||||||
|
"Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
|
||||||
|
Some(retry_after_secs),
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let display_name = body.display_name.trim();
|
let display_name = body.display_name.trim();
|
||||||
@@ -63,6 +77,23 @@ pub async fn join(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-guest bucket, keyed like the `recover:{ip}:{name}` limiter below. This carries
|
||||||
|
// the original 5/60s anti-spam intent, but one guest retrying can no longer consume
|
||||||
|
// the allowance of everyone else sharing the venue's NAT.
|
||||||
|
if rate_limits_on && join_rate_on {
|
||||||
|
let name_key = display_name.to_lowercase();
|
||||||
|
if let Err(retry_after_secs) = state.rate_limiter.check_with_retry(
|
||||||
|
format!("join:{ip}:{name_key}"),
|
||||||
|
5,
|
||||||
|
Duration::from_secs(60),
|
||||||
|
) {
|
||||||
|
return Err(AppError::TooManyRequests(
|
||||||
|
"Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
|
||||||
|
Some(retry_after_secs),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let event = Event::find_or_create(
|
let event = Event::find_or_create(
|
||||||
&state.pool,
|
&state.pool,
|
||||||
&state.config.event_slug,
|
&state.config.event_slug,
|
||||||
@@ -80,8 +111,7 @@ pub async fn join(
|
|||||||
|
|
||||||
// Generate a 4-digit PIN
|
// Generate a 4-digit PIN
|
||||||
let pin: String = format!("{:04}", rand::rng().random_range(0..10000u32));
|
let pin: String = format!("{:04}", rand::rng().random_range(0..10000u32));
|
||||||
let pin_hash =
|
let pin_hash = hash_password(pin.clone(), 12).await?;
|
||||||
bcrypt::hash(&pin, 12).map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
|
|
||||||
|
|
||||||
// The pre-check above is racy: two simultaneous joins with the same name can both
|
// 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
|
// pass it, and the DB's unique index then rejects the loser. Map that unique
|
||||||
@@ -145,8 +175,31 @@ fn dummy_pin_hash() -> &'static str {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Run a bcrypt verify on the blocking pool.
|
||||||
|
///
|
||||||
|
/// bcrypt at cost 12 is ~200ms of deliberate CPU. Called inline on an async task it pins a
|
||||||
|
/// tokio WORKER thread for that whole time, and the runtime only has one per core — so a
|
||||||
|
/// flood of `/recover` or `/admin/login` attempts stalls every other request on the box,
|
||||||
|
/// including the feed. Offloading moves that cost to the blocking pool, which is sized for
|
||||||
|
/// exactly this and whose saturation degrades logins rather than the whole app.
|
||||||
|
async fn verify_password(candidate: String, hash: String) -> bool {
|
||||||
|
tokio::task::spawn_blocking(move || bcrypt::verify(&candidate, &hash).unwrap_or(false))
|
||||||
|
.await
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hash a secret on the blocking pool. Same reasoning as [`verify_password`] — and this one
|
||||||
|
/// runs on the busiest auth path there is, since every guest who joins gets a PIN hashed.
|
||||||
|
pub async fn hash_password(secret: String, cost: u32) -> Result<String, AppError> {
|
||||||
|
tokio::task::spawn_blocking(move || bcrypt::hash(&secret, cost))
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?
|
||||||
|
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn recover(
|
pub async fn recover(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
Json(body): Json<RecoverRequest>,
|
Json(body): Json<RecoverRequest>,
|
||||||
) -> Result<Json<RecoverResponse>, AppError> {
|
) -> Result<Json<RecoverResponse>, AppError> {
|
||||||
@@ -157,19 +210,39 @@ pub async fn recover(
|
|||||||
// burn through 3 wrong PINs and lock the victim for 15 minutes — repeated
|
// burn through 3 wrong PINs and lock the victim for 15 minutes — repeated
|
||||||
// every 15 minutes, indefinitely. 5 attempts per 15 minutes per (IP, name)
|
// every 15 minutes, indefinitely. 5 attempts per 15 minutes per (IP, name)
|
||||||
// softens that into a real cost.
|
// softens that into a real cost.
|
||||||
let ip = client_ip(&headers, "unknown");
|
let ip = client_ip(&headers, &peer.ip().to_string());
|
||||||
let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_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;
|
let recover_rate_on = config::get_bool(&state.config_cache, "recover_rate_enabled", true).await;
|
||||||
if rate_limits_on && recover_rate_on {
|
if rate_limits_on && recover_rate_on {
|
||||||
|
// Coarse per-IP ceiling FIRST. The per-(ip, name) bucket below is the anti-guessing
|
||||||
|
// control, but the name is attacker-chosen, so cycling names mints a fresh bucket
|
||||||
|
// every time and leaves the per-IP cost unbounded. That matters more here than
|
||||||
|
// anywhere else: every call runs a cost-12 bcrypt verify, including an
|
||||||
|
// unconditional throwaway one for names that don't exist (see below), so an unknown
|
||||||
|
// name is the CHEAPEST way to make the server do ~200ms of hashing. Checked before
|
||||||
|
// the per-name bucket so a name generator can't walk past it.
|
||||||
|
let ip_ceiling =
|
||||||
|
config::get_usize(&state.config_cache, "recover_ip_rate_per_min", 30).await;
|
||||||
|
if let Err(retry_after_secs) = state.rate_limiter.check_with_retry(
|
||||||
|
format!("recover_ip:{ip}"),
|
||||||
|
ip_ceiling,
|
||||||
|
Duration::from_secs(60),
|
||||||
|
) {
|
||||||
|
return Err(AppError::TooManyRequests(
|
||||||
|
"Zu viele Versuche. Bitte warte kurz und versuche es erneut.".into(),
|
||||||
|
Some(retry_after_secs),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
let name_key = display_name.to_lowercase();
|
let name_key = display_name.to_lowercase();
|
||||||
if !state.rate_limiter.check(
|
if let Err(retry_after_secs) = state.rate_limiter.check_with_retry(
|
||||||
format!("recover:{ip}:{name_key}"),
|
format!("recover:{ip}:{name_key}"),
|
||||||
5,
|
5,
|
||||||
Duration::from_secs(15 * 60),
|
Duration::from_secs(15 * 60),
|
||||||
) {
|
) {
|
||||||
return Err(AppError::TooManyRequests(
|
return Err(AppError::TooManyRequests(
|
||||||
"Zu viele Versuche. Bitte warte kurz und versuche es erneut.".into(),
|
"Zu viele Versuche. Bitte warte kurz und versuche es erneut.".into(),
|
||||||
None,
|
Some(retry_after_secs),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -178,8 +251,7 @@ pub async fn recover(
|
|||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
|
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
|
||||||
|
|
||||||
let users =
|
let users = User::find_by_event_and_name(&state.pool, event.id, display_name).await?;
|
||||||
User::find_by_event_and_name(&state.pool, event.id, display_name).await?;
|
|
||||||
|
|
||||||
if users.is_empty() {
|
if users.is_empty() {
|
||||||
// No user with this name. Run a throwaway bcrypt verify so this branch takes
|
// No user with this name. Run a throwaway bcrypt verify so this branch takes
|
||||||
@@ -187,7 +259,7 @@ pub async fn recover(
|
|||||||
// PIN — so "no such name" and "wrong PIN" are indistinguishable by response or
|
// 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
|
// timing. Display names are already public on the feed, but this still closes
|
||||||
// the /recover enumeration + timing oracle.
|
// the /recover enumeration + timing oracle.
|
||||||
let _ = bcrypt::verify(&body.pin, dummy_pin_hash());
|
let _ = verify_password(body.pin.clone(), dummy_pin_hash().to_string()).await;
|
||||||
return Err(AppError::Unauthorized("PIN ist falsch.".into()));
|
return Err(AppError::Unauthorized("PIN ist falsch.".into()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,17 +271,19 @@ pub async fn recover(
|
|||||||
// is effectively permanently fragile.
|
// is effectively permanently fragile.
|
||||||
if let Some(locked_until) = user.pin_locked_until {
|
if let Some(locked_until) = user.pin_locked_until {
|
||||||
if Utc::now() < locked_until {
|
if Utc::now() < locked_until {
|
||||||
|
// The exact deadline is known, so surface it as Retry-After instead of
|
||||||
|
// making the client guess at the "15 Minuten" in the copy.
|
||||||
|
let retry_after_secs = (locked_until - Utc::now()).num_seconds().max(1) as u64;
|
||||||
return Err(AppError::TooManyRequests(
|
return Err(AppError::TooManyRequests(
|
||||||
"Zu viele Versuche. Bitte warte 15 Minuten.".into(),
|
"Zu viele Versuche. Bitte warte 15 Minuten.".into(),
|
||||||
None,
|
Some(retry_after_secs),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
// Lockout window expired — wipe the counter and the timestamp.
|
// Lockout window expired — wipe the counter and the timestamp.
|
||||||
User::reset_pin_attempts(&state.pool, user.id).await?;
|
User::reset_pin_attempts(&state.pool, user.id).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let pin_matches = bcrypt::verify(&body.pin, &user.recovery_pin_hash)
|
let pin_matches = verify_password(body.pin.clone(), user.recovery_pin_hash.clone()).await;
|
||||||
.unwrap_or(false);
|
|
||||||
|
|
||||||
if pin_matches {
|
if pin_matches {
|
||||||
// Reset failed attempts on success
|
// Reset failed attempts on success
|
||||||
@@ -274,6 +348,7 @@ pub struct AdminLoginResponse {
|
|||||||
|
|
||||||
pub async fn admin_login(
|
pub async fn admin_login(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
Json(body): Json<AdminLoginRequest>,
|
Json(body): Json<AdminLoginRequest>,
|
||||||
) -> Result<Json<AdminLoginResponse>, AppError> {
|
) -> Result<Json<AdminLoginResponse>, AppError> {
|
||||||
@@ -287,11 +362,15 @@ pub async fn admin_login(
|
|||||||
// verify) but with no IP-level limit a determined attacker can still mount
|
// verify) but with no IP-level limit a determined attacker can still mount
|
||||||
// a long-running guess campaign. 5 attempts / minute / IP is plenty for
|
// a long-running guess campaign. 5 attempts / minute / IP is plenty for
|
||||||
// honest typos.
|
// honest typos.
|
||||||
let ip = client_ip(&headers, "unknown");
|
let ip = client_ip(&headers, &peer.ip().to_string());
|
||||||
let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
|
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;
|
let admin_rate_on =
|
||||||
if rate_limits_on && admin_rate_on
|
config::get_bool(&state.config_cache, "admin_login_rate_enabled", true).await;
|
||||||
&& !state.rate_limiter.check(
|
// Stays keyed by IP on purpose: this guards a single shared credential, so a per-user
|
||||||
|
// or per-name key would just hand an attacker a fresh bucket per guess.
|
||||||
|
if rate_limits_on
|
||||||
|
&& admin_rate_on
|
||||||
|
&& let Err(retry_after_secs) = state.rate_limiter.check_with_retry(
|
||||||
format!("admin_login:{ip}"),
|
format!("admin_login:{ip}"),
|
||||||
5,
|
5,
|
||||||
Duration::from_secs(60),
|
Duration::from_secs(60),
|
||||||
@@ -299,12 +378,15 @@ pub async fn admin_login(
|
|||||||
{
|
{
|
||||||
return Err(AppError::TooManyRequests(
|
return Err(AppError::TooManyRequests(
|
||||||
"Zu viele Anmeldeversuche. Bitte warte kurz und versuche es erneut.".into(),
|
"Zu viele Anmeldeversuche. Bitte warte kurz und versuche es erneut.".into(),
|
||||||
None,
|
Some(retry_after_secs),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let valid = bcrypt::verify(&body.password, &state.config.admin_password_hash)
|
let valid = verify_password(
|
||||||
.unwrap_or(false);
|
body.password.clone(),
|
||||||
|
state.config.admin_password_hash.clone(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
if !valid {
|
if !valid {
|
||||||
tracing::warn!(ip = %ip, "admin_login: wrong password");
|
tracing::warn!(ip = %ip, "admin_login: wrong password");
|
||||||
@@ -330,8 +412,7 @@ pub async fn admin_login(
|
|||||||
let dummy_pin: String = (0..32)
|
let dummy_pin: String = (0..32)
|
||||||
.map(|_| rand::rng().random_range(b'a'..=b'z') as char)
|
.map(|_| rand::rng().random_range(b'a'..=b'z') as char)
|
||||||
.collect();
|
.collect();
|
||||||
let dummy_hash = bcrypt::hash(&dummy_pin, 4)
|
let dummy_hash = hash_password(dummy_pin.clone(), 4).await?;
|
||||||
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
|
|
||||||
let user = User::create(&state.pool, event.id, admin_name, &dummy_hash).await?;
|
let user = User::create(&state.pool, event.id, admin_name, &dummy_hash).await?;
|
||||||
sqlx::query("UPDATE \"user\" SET role = 'admin' WHERE id = $1")
|
sqlx::query("UPDATE \"user\" SET role = 'admin' WHERE id = $1")
|
||||||
.bind(user.id)
|
.bind(user.id)
|
||||||
@@ -364,10 +445,7 @@ pub async fn admin_login(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn logout(
|
pub async fn logout(State(state): State<AppState>, auth: AuthUser) -> Result<StatusCode, AppError> {
|
||||||
State(state): State<AppState>,
|
|
||||||
auth: AuthUser,
|
|
||||||
) -> Result<StatusCode, AppError> {
|
|
||||||
Session::delete_by_token_hash(&state.pool, &auth.token_hash).await?;
|
Session::delete_by_token_hash(&state.pool, &auth.token_hash).await?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
@@ -394,22 +472,23 @@ pub struct PinResetRequestBody {
|
|||||||
/// feed already exposes.
|
/// feed already exposes.
|
||||||
pub async fn request_pin_reset(
|
pub async fn request_pin_reset(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
Json(body): Json<PinResetRequestBody>,
|
Json(body): Json<PinResetRequestBody>,
|
||||||
) -> Result<StatusCode, AppError> {
|
) -> Result<StatusCode, AppError> {
|
||||||
let display_name = body.display_name.trim();
|
let display_name = body.display_name.trim();
|
||||||
let ip = client_ip(&headers, "unknown");
|
let ip = client_ip(&headers, &peer.ip().to_string());
|
||||||
let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
|
let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
|
||||||
if rate_limits_on {
|
if rate_limits_on {
|
||||||
let name_key = display_name.to_lowercase();
|
let name_key = display_name.to_lowercase();
|
||||||
if !state.rate_limiter.check(
|
if let Err(retry_after_secs) = state.rate_limiter.check_with_retry(
|
||||||
format!("pin_reset_req:{ip}:{name_key}"),
|
format!("pin_reset_req:{ip}:{name_key}"),
|
||||||
3,
|
3,
|
||||||
Duration::from_secs(15 * 60),
|
Duration::from_secs(15 * 60),
|
||||||
) {
|
) {
|
||||||
return Err(AppError::TooManyRequests(
|
return Err(AppError::TooManyRequests(
|
||||||
"Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
|
"Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
|
||||||
None,
|
Some(retry_after_secs),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,9 @@ impl FromRequestParts<AppState> for AuthUser {
|
|||||||
let user = Session::find_user_by_token_hash(&state.pool, &token_hash)
|
let user = Session::find_user_by_token_hash(&state.pool, &token_hash)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::Internal(e.into()))?
|
.map_err(|e| AppError::Internal(e.into()))?
|
||||||
.ok_or_else(|| AppError::Unauthorized("Sitzung nicht gefunden oder abgelaufen.".into()))?;
|
.ok_or_else(|| {
|
||||||
|
AppError::Unauthorized("Sitzung nicht gefunden oder abgelaufen.".into())
|
||||||
|
})?;
|
||||||
|
|
||||||
// Touch last_seen_at AND slide the session's expiry forward (fire-and-forget), so
|
// 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.
|
// an active client's session renews instead of hitting the fixed 30-day cliff.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use std::path::PathBuf;
|
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
|
/// Well-known dev JWT secret shipped in `.env.example`. If APP_ENV=production
|
||||||
/// we refuse to start with this value; otherwise we warn loudly.
|
/// we refuse to start with this value; otherwise we warn loudly.
|
||||||
@@ -20,21 +20,53 @@ fn looks_placeholder(s: &str) -> bool {
|
|||||||
/// Enforce secret hygiene. In production every guard is hard-fail: a booting app
|
/// 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.
|
/// 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.
|
/// 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<()> {
|
///
|
||||||
|
/// EVERY failure is collected and reported together. Returning on the first one made fixing two
|
||||||
|
/// secrets cost two boot cycles — the operator rotates JWT_SECRET, restarts, and only then learns
|
||||||
|
/// about ADMIN_PASSWORD_HASH. Restarting this stack is not free (Caddy waits on the unhealthy app),
|
||||||
|
/// and each avoidable cycle is another chance to reach for `down -v`.
|
||||||
|
fn validate_secrets(
|
||||||
|
is_prod: bool,
|
||||||
|
jwt_secret: &str,
|
||||||
|
admin_password_hash: &str,
|
||||||
|
database_url: &str,
|
||||||
|
) -> Result<()> {
|
||||||
if is_prod {
|
if is_prod {
|
||||||
|
let mut problems: Vec<&str> = Vec::new();
|
||||||
if looks_placeholder(jwt_secret) {
|
if looks_placeholder(jwt_secret) {
|
||||||
return Err(anyhow!(
|
problems.push(
|
||||||
"Refusing to start in production with a placeholder JWT_SECRET — \
|
"JWT_SECRET is still the .env.example placeholder — rotate it \
|
||||||
rotate it (openssl rand -hex 64)."
|
(openssl rand -hex 64).",
|
||||||
));
|
);
|
||||||
}
|
} else if jwt_secret.len() < 32 {
|
||||||
if jwt_secret.len() < 32 {
|
problems.push("JWT_SECRET must be at least 32 characters.");
|
||||||
return Err(anyhow!("JWT_SECRET must be at least 32 characters."));
|
|
||||||
}
|
}
|
||||||
if admin_password_hash.is_empty() || looks_placeholder(admin_password_hash) {
|
if admin_password_hash.is_empty() || looks_placeholder(admin_password_hash) {
|
||||||
|
problems.push(
|
||||||
|
"ADMIN_PASSWORD_HASH is unset or still the .env.example placeholder — generate one \
|
||||||
|
(docker run --rm caddy:2-alpine caddy hash-password --plaintext '<password>').",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// The DATABASE_URL carries the Postgres password, so a placeholder here means the stack is
|
||||||
|
// running on `CHANGE_ME_use_a_strong_password` — a credential published in the repo. The
|
||||||
|
// app used to boot green on it, because this guard only ever covered the two secrets it
|
||||||
|
// was written for and nothing else looked at POSTGRES_PASSWORD at all.
|
||||||
|
//
|
||||||
|
// Read POSTGRES_PASSWORD's docs before changing this: it is applied ONLY at initdb, so the
|
||||||
|
// remedy is not "edit .env and restart" — see the 28P01 diagnostic in db.rs.
|
||||||
|
if looks_placeholder(database_url) {
|
||||||
|
problems.push(
|
||||||
|
"DATABASE_URL still carries the .env.example placeholder password — set a strong \
|
||||||
|
one (openssl rand -hex 24) in BOTH DATABASE_URL and POSTGRES_PASSWORD.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if !problems.is_empty() {
|
||||||
return Err(anyhow!(
|
return Err(anyhow!(
|
||||||
"Refusing to start in production without a real ADMIN_PASSWORD_HASH — \
|
"Refusing to start in production — {} secret(s) still unset or placeholder:\n - {}\n\
|
||||||
generate one (htpasswd -bnBC 12 '' <password> | tr -d ':\\n')."
|
ALL secrets must be set BEFORE the first `docker compose up -d`: Postgres bakes \
|
||||||
|
POSTGRES_PASSWORD into its data directory on first boot and ignores later changes.",
|
||||||
|
problems.len(),
|
||||||
|
problems.join("\n - ")
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
} else if jwt_secret == DEV_JWT_SECRET_SENTINEL {
|
} else if jwt_secret == DEV_JWT_SECRET_SENTINEL {
|
||||||
@@ -63,32 +95,46 @@ pub struct AppConfig {
|
|||||||
pub app_port: u16,
|
pub app_port: u16,
|
||||||
/// Number of concurrent media compression workers (read once at boot).
|
/// Number of concurrent media compression workers (read once at boot).
|
||||||
pub compression_concurrency: usize,
|
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 {
|
impl AppConfig {
|
||||||
pub fn from_env() -> Result<Self> {
|
pub fn from_env() -> Result<Self> {
|
||||||
let app_env =
|
let app_env = std::env::var("APP_ENV").unwrap_or_else(|_| "development".to_string());
|
||||||
std::env::var("APP_ENV").unwrap_or_else(|_| "development".to_string());
|
|
||||||
let is_prod = app_env.eq_ignore_ascii_case("production");
|
let is_prod = app_env.eq_ignore_ascii_case("production");
|
||||||
|
|
||||||
let jwt_secret = std::env::var("JWT_SECRET").context("JWT_SECRET must be set")?;
|
let jwt_secret = std::env::var("JWT_SECRET").context("JWT_SECRET must be set")?;
|
||||||
let admin_password_hash = std::env::var("ADMIN_PASSWORD_HASH").unwrap_or_default();
|
let admin_password_hash = std::env::var("ADMIN_PASSWORD_HASH").unwrap_or_default();
|
||||||
|
let database_url = std::env::var("DATABASE_URL").context("DATABASE_URL must be set")?;
|
||||||
|
|
||||||
validate_secrets(is_prod, &jwt_secret, &admin_password_hash)?;
|
validate_secrets(is_prod, &jwt_secret, &admin_password_hash, &database_url)?;
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
database_url: std::env::var("DATABASE_URL")
|
database_url,
|
||||||
.context("DATABASE_URL must be set")?,
|
|
||||||
jwt_secret,
|
jwt_secret,
|
||||||
session_expiry_days: std::env::var("SESSION_EXPIRY_DAYS")
|
session_expiry_days: std::env::var("SESSION_EXPIRY_DAYS")
|
||||||
.unwrap_or_else(|_| "30".to_string())
|
.unwrap_or_else(|_| "30".to_string())
|
||||||
.parse()
|
.parse()
|
||||||
.context("SESSION_EXPIRY_DAYS must be a number")?,
|
.context("SESSION_EXPIRY_DAYS must be a number")?,
|
||||||
admin_password_hash,
|
admin_password_hash,
|
||||||
event_name: std::env::var("EVENT_NAME")
|
event_name: std::env::var("EVENT_NAME").unwrap_or_else(|_| "EventSnap".to_string()),
|
||||||
.unwrap_or_else(|_| "EventSnap".to_string()),
|
event_slug: std::env::var("EVENT_SLUG").context("EVENT_SLUG must be set")?,
|
||||||
event_slug: std::env::var("EVENT_SLUG")
|
|
||||||
.context("EVENT_SLUG must be set")?,
|
|
||||||
media_path: PathBuf::from(
|
media_path: PathBuf::from(
|
||||||
std::env::var("MEDIA_PATH").unwrap_or_else(|_| "/media".to_string()),
|
std::env::var("MEDIA_PATH").unwrap_or_else(|_| "/media".to_string()),
|
||||||
),
|
),
|
||||||
@@ -104,6 +150,20 @@ impl AppConfig {
|
|||||||
.and_then(|v| v.parse().ok())
|
.and_then(|v| v.parse().ok())
|
||||||
.filter(|&n| n >= 1)
|
.filter(|&n| n >= 1)
|
||||||
.unwrap_or(2),
|
.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()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -114,48 +174,152 @@ mod tests {
|
|||||||
|
|
||||||
const REAL_SECRET: &str = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2";
|
const REAL_SECRET: &str = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2";
|
||||||
const REAL_HASH: &str = "$2y$12$abcdefghijklmnopqrstuv.wxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012";
|
const REAL_HASH: &str = "$2y$12$abcdefghijklmnopqrstuv.wxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012";
|
||||||
|
const REAL_DB_URL: &str = "postgres://eventsnap:7f3a9c1e5b2d8a4f@db:5432/eventsnap";
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn prod_rejects_shipped_placeholder_secret() {
|
fn prod_rejects_shipped_placeholder_secret() {
|
||||||
// The exact string shipped in `.env` — >32 chars, so it must be caught by
|
// The exact string shipped in `.env` — >32 chars, so it must be caught by
|
||||||
// the substring guard, not the length check.
|
// the substring guard, not the length check.
|
||||||
let err = validate_secrets(true, "change_me_to_a_random_64_byte_hex_string", REAL_HASH);
|
let err = validate_secrets(
|
||||||
assert!(err.is_err(), "placeholder JWT_SECRET must be rejected in prod");
|
true,
|
||||||
|
"change_me_to_a_random_64_byte_hex_string",
|
||||||
|
REAL_HASH,
|
||||||
|
REAL_DB_URL,
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
err.is_err(),
|
||||||
|
"placeholder JWT_SECRET must be rejected in prod"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn prod_rejects_dev_sentinel_and_short_secret() {
|
fn prod_rejects_dev_sentinel_and_short_secret() {
|
||||||
assert!(validate_secrets(true, DEV_JWT_SECRET_SENTINEL, REAL_HASH).is_err());
|
assert!(validate_secrets(true, DEV_JWT_SECRET_SENTINEL, REAL_HASH, REAL_DB_URL).is_err());
|
||||||
assert!(validate_secrets(true, "tooshort", REAL_HASH).is_err());
|
assert!(validate_secrets(true, "tooshort", REAL_HASH, REAL_DB_URL).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn prod_rejects_missing_or_placeholder_admin_hash() {
|
fn prod_rejects_missing_or_placeholder_admin_hash() {
|
||||||
assert!(validate_secrets(true, REAL_SECRET, "").is_err());
|
assert!(validate_secrets(true, REAL_SECRET, "", REAL_DB_URL).is_err());
|
||||||
assert!(validate_secrets(true, REAL_SECRET, "$2y$12$placeholder_replace_me").is_err());
|
assert!(
|
||||||
|
validate_secrets(
|
||||||
|
true,
|
||||||
|
REAL_SECRET,
|
||||||
|
"$2y$12$placeholder_replace_me",
|
||||||
|
REAL_DB_URL
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The stack used to come up GREEN on the database password published in the repo: this guard
|
||||||
|
/// covered the two secrets it was written for, and nothing anywhere looked at the Postgres
|
||||||
|
/// credential. README step 2 doesn't name POSTGRES_PASSWORD either, so following the
|
||||||
|
/// documented procedure verbatim shipped it.
|
||||||
|
#[test]
|
||||||
|
fn prod_rejects_the_shipped_placeholder_database_password() {
|
||||||
|
let shipped = "postgres://eventsnap:CHANGE_ME_use_a_strong_password@db:5432/eventsnap";
|
||||||
|
let err = validate_secrets(true, REAL_SECRET, REAL_HASH, shipped).unwrap_err();
|
||||||
|
assert!(
|
||||||
|
err.to_string().contains("DATABASE_URL"),
|
||||||
|
"the refusal must name DATABASE_URL, not just fail: {err}"
|
||||||
|
);
|
||||||
|
// And it must point at the initdb trap, or the operator edits .env, restarts, and lands
|
||||||
|
// in a permanent auth-failure loop instead.
|
||||||
|
assert!(
|
||||||
|
err.to_string().contains("POSTGRES_PASSWORD"),
|
||||||
|
"the refusal must name POSTGRES_PASSWORD as the other half: {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every problem in ONE message. Reporting them one per boot made fixing two secrets cost two
|
||||||
|
/// restart cycles, on a stack where Caddy waits on the unhealthy app the whole time.
|
||||||
|
#[test]
|
||||||
|
fn prod_reports_every_placeholder_at_once() {
|
||||||
|
let err = validate_secrets(
|
||||||
|
true,
|
||||||
|
"change_me_to_a_random_64_byte_hex_string",
|
||||||
|
"$2y$12$placeholder_replace_me",
|
||||||
|
"postgres://eventsnap:CHANGE_ME_use_a_strong_password@db:5432/eventsnap",
|
||||||
|
)
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string();
|
||||||
|
for expected in ["JWT_SECRET", "ADMIN_PASSWORD_HASH", "DATABASE_URL"] {
|
||||||
|
assert!(err.contains(expected), "{expected} missing from: {err}");
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
err.contains("3 secret(s)"),
|
||||||
|
"the count must match what is listed: {err}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn prod_accepts_real_secrets() {
|
fn prod_accepts_real_secrets() {
|
||||||
assert!(validate_secrets(true, REAL_SECRET, REAL_HASH).is_ok());
|
assert!(validate_secrets(true, REAL_SECRET, REAL_HASH, REAL_DB_URL).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A real password that happens to contain no placeholder substring must pass — including one
|
||||||
|
/// with URL-ish punctuation, so the guard can't be mistaken for a URL validator.
|
||||||
|
#[test]
|
||||||
|
fn prod_accepts_a_real_database_url_with_awkward_punctuation() {
|
||||||
|
assert!(
|
||||||
|
validate_secrets(
|
||||||
|
true,
|
||||||
|
REAL_SECRET,
|
||||||
|
REAL_HASH,
|
||||||
|
"postgres://eventsnap:aB3%24xY9-_.qW@db:5432/eventsnap"
|
||||||
|
)
|
||||||
|
.is_ok()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The e2e stack runs without APP_ENV=production, so none of this applies there — but assert
|
||||||
|
/// it, because a guard that tripped in e2e would be found the hard way.
|
||||||
|
#[test]
|
||||||
|
fn non_prod_ignores_a_placeholder_database_url() {
|
||||||
|
assert!(
|
||||||
|
validate_secrets(
|
||||||
|
false,
|
||||||
|
REAL_SECRET,
|
||||||
|
"",
|
||||||
|
"postgres://eventsnap:CHANGE_ME_use_a_strong_password@db:5432/eventsnap"
|
||||||
|
)
|
||||||
|
.is_ok()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn non_prod_tolerates_dev_sentinel() {
|
fn non_prod_tolerates_dev_sentinel() {
|
||||||
assert!(validate_secrets(false, DEV_JWT_SECRET_SENTINEL, "").is_ok());
|
assert!(validate_secrets(false, DEV_JWT_SECRET_SENTINEL, "", REAL_DB_URL).is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn non_prod_still_rejects_short_non_sentinel_secret() {
|
fn non_prod_still_rejects_short_non_sentinel_secret() {
|
||||||
assert!(validate_secrets(false, "tooshort", "").is_err());
|
assert!(validate_secrets(false, "tooshort", "", REAL_DB_URL).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn placeholder_detection_is_case_insensitive() {
|
fn placeholder_detection_is_case_insensitive() {
|
||||||
// looks_placeholder lowercases before matching — an upper/mixed-case
|
// looks_placeholder lowercases before matching — an upper/mixed-case
|
||||||
// placeholder must still be rejected in prod.
|
// 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!(
|
||||||
assert!(validate_secrets(true, REAL_SECRET, "$2Y$12$PLACEHOLDER_replace_me").is_err());
|
validate_secrets(
|
||||||
|
true,
|
||||||
|
"CHANGE_ME_TO_A_RANDOM_64_BYTE_HEX_STRING",
|
||||||
|
REAL_HASH,
|
||||||
|
REAL_DB_URL
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
validate_secrets(
|
||||||
|
true,
|
||||||
|
REAL_SECRET,
|
||||||
|
"$2Y$12$PLACEHOLDER_replace_me",
|
||||||
|
REAL_DB_URL
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -165,7 +329,7 @@ mod tests {
|
|||||||
const LEN_31: &str = "abcdefghijklmnopqrstuvwxyz01234";
|
const LEN_31: &str = "abcdefghijklmnopqrstuvwxyz01234";
|
||||||
assert_eq!(LEN_32.len(), 32);
|
assert_eq!(LEN_32.len(), 32);
|
||||||
assert_eq!(LEN_31.len(), 31);
|
assert_eq!(LEN_31.len(), 31);
|
||||||
assert!(validate_secrets(true, LEN_32, REAL_HASH).is_ok());
|
assert!(validate_secrets(true, LEN_32, REAL_HASH, REAL_DB_URL).is_ok());
|
||||||
assert!(validate_secrets(true, LEN_31, REAL_HASH).is_err());
|
assert!(validate_secrets(true, LEN_31, REAL_HASH, REAL_DB_URL).is_err());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,68 @@
|
|||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use sqlx::postgres::PgPoolOptions;
|
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
use sqlx::postgres::PgPoolOptions;
|
||||||
|
|
||||||
const DEFAULT_MAX_CONNECTIONS: u32 = 10;
|
const DEFAULT_MAX_CONNECTIONS: u32 = 10;
|
||||||
|
|
||||||
|
/// SQLSTATE for `invalid_password`.
|
||||||
|
const PG_INVALID_PASSWORD: &str = "28P01";
|
||||||
|
|
||||||
|
/// Turn the one connect failure with an unguessable cause into a self-explaining one.
|
||||||
|
///
|
||||||
|
/// `POSTGRES_PASSWORD` is honoured ONLY when Postgres initialises its data directory. Change it in
|
||||||
|
/// `.env` afterwards and the app authenticates with the new password against a volume that still
|
||||||
|
/// holds the old one — a permanent restart loop whose only symptom is
|
||||||
|
/// `password authentication failed`.
|
||||||
|
///
|
||||||
|
/// The production secret guard makes that sequence NEARLY CERTAIN rather than rare: it stops the
|
||||||
|
/// app on the first `docker compose up -d`, but not the `db` service in that same command, which
|
||||||
|
/// initialises and bakes in whatever password was in `.env` at that moment. So the intended
|
||||||
|
/// recovery — see the refusal, fix your secrets, boot again — is exactly the sequence that breaks
|
||||||
|
/// it. Nothing in the error names the cause, and the remedy destroys data, so it is the last thing
|
||||||
|
/// an operator should guess at.
|
||||||
|
fn explain_auth_failure(err: &sqlx::Error) {
|
||||||
|
let is_auth_failure = match err {
|
||||||
|
sqlx::Error::Database(db) => db.code().as_deref() == Some(PG_INVALID_PASSWORD),
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
if !is_auth_failure {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tracing::error!(
|
||||||
|
"Postgres rejected the credentials in DATABASE_URL (SQLSTATE {PG_INVALID_PASSWORD}).\n\
|
||||||
|
\n\
|
||||||
|
This almost always means POSTGRES_PASSWORD was changed AFTER the database volume was \
|
||||||
|
first created. Postgres applies that variable only when it initialises its data \
|
||||||
|
directory; editing .env and restarting does not change the stored password, so the two \
|
||||||
|
drift apart permanently.\n\
|
||||||
|
\n\
|
||||||
|
If the event has NOT started and you have no data worth keeping:\n\n \
|
||||||
|
docker compose down -v && docker compose up -d\n\n\
|
||||||
|
(-v DELETES the database, the uploaded media and the exports. There is no undo.)\n\
|
||||||
|
\n\
|
||||||
|
If you DO have data: restore the old password into DATABASE_URL instead, or change the \
|
||||||
|
stored one with ALTER ROLE inside the running db container. Never reach for -v to fix a \
|
||||||
|
login problem on a live event."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn create_pool(database_url: &str) -> Result<PgPool> {
|
pub async fn create_pool(database_url: &str) -> Result<PgPool> {
|
||||||
let max_connections = std::env::var("DATABASE_MAX_CONNECTIONS")
|
let max_connections = std::env::var("DATABASE_MAX_CONNECTIONS")
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|s| s.parse::<u32>().ok())
|
.and_then(|s| s.parse::<u32>().ok())
|
||||||
.unwrap_or(DEFAULT_MAX_CONNECTIONS);
|
.unwrap_or(DEFAULT_MAX_CONNECTIONS);
|
||||||
|
|
||||||
let pool = PgPoolOptions::new()
|
let pool = match PgPoolOptions::new()
|
||||||
.max_connections(max_connections)
|
.max_connections(max_connections)
|
||||||
.connect(database_url)
|
.connect(database_url)
|
||||||
.await
|
.await
|
||||||
.context("failed to connect to database")?;
|
{
|
||||||
|
Ok(pool) => pool,
|
||||||
|
Err(e) => {
|
||||||
|
explain_auth_failure(&e);
|
||||||
|
return Err(e).context("failed to connect to database");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
sqlx::migrate!()
|
sqlx::migrate!()
|
||||||
.run(&pool)
|
.run(&pool)
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ pub enum AppError {
|
|||||||
BadRequest(String),
|
BadRequest(String),
|
||||||
Unauthorized(String),
|
Unauthorized(String),
|
||||||
Forbidden(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),
|
NotFound(String),
|
||||||
Conflict(String),
|
Conflict(String),
|
||||||
/// Second field: optional retry-after seconds to include in the response.
|
/// Second field: optional retry-after seconds to include in the response.
|
||||||
@@ -23,6 +28,7 @@ impl AppError {
|
|||||||
Self::BadRequest(_) => (StatusCode::BAD_REQUEST, "bad_request"),
|
Self::BadRequest(_) => (StatusCode::BAD_REQUEST, "bad_request"),
|
||||||
Self::Unauthorized(_) => (StatusCode::UNAUTHORIZED, "unauthorized"),
|
Self::Unauthorized(_) => (StatusCode::UNAUTHORIZED, "unauthorized"),
|
||||||
Self::Forbidden(_) => (StatusCode::FORBIDDEN, "forbidden"),
|
Self::Forbidden(_) => (StatusCode::FORBIDDEN, "forbidden"),
|
||||||
|
Self::UploadsLocked(_) => (StatusCode::FORBIDDEN, "uploads_locked"),
|
||||||
Self::NotFound(_) => (StatusCode::NOT_FOUND, "not_found"),
|
Self::NotFound(_) => (StatusCode::NOT_FOUND, "not_found"),
|
||||||
Self::Conflict(_) => (StatusCode::CONFLICT, "conflict"),
|
Self::Conflict(_) => (StatusCode::CONFLICT, "conflict"),
|
||||||
Self::TooManyRequests(..) => (StatusCode::TOO_MANY_REQUESTS, "too_many_requests"),
|
Self::TooManyRequests(..) => (StatusCode::TOO_MANY_REQUESTS, "too_many_requests"),
|
||||||
@@ -36,6 +42,7 @@ impl AppError {
|
|||||||
Self::BadRequest(msg)
|
Self::BadRequest(msg)
|
||||||
| Self::Unauthorized(msg)
|
| Self::Unauthorized(msg)
|
||||||
| Self::Forbidden(msg)
|
| Self::Forbidden(msg)
|
||||||
|
| Self::UploadsLocked(msg)
|
||||||
| Self::NotFound(msg)
|
| Self::NotFound(msg)
|
||||||
| Self::Conflict(msg) => msg.clone(),
|
| Self::Conflict(msg) => msg.clone(),
|
||||||
Self::TooManyRequests(msg, _) => msg.clone(),
|
Self::TooManyRequests(msg, _) => msg.clone(),
|
||||||
@@ -68,10 +75,11 @@ impl IntoResponse for AppError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut resp = (status, axum::Json(body)).into_response();
|
let mut resp = (status, axum::Json(body)).into_response();
|
||||||
if let Some(secs) = retry_after_secs {
|
if let Some(secs) = retry_after_secs
|
||||||
if let Ok(val) = axum::http::HeaderValue::from_str(&secs.to_string()) {
|
&& let Ok(val) = axum::http::HeaderValue::from_str(&secs.to_string())
|
||||||
resp.headers_mut().insert(axum::http::header::RETRY_AFTER, val);
|
{
|
||||||
}
|
resp.headers_mut()
|
||||||
|
.insert(axum::http::header::RETRY_AFTER, val);
|
||||||
}
|
}
|
||||||
resp
|
resp
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use axum::extract::{Query, State};
|
|
||||||
use axum::http::{HeaderMap, StatusCode};
|
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
|
use axum::extract::{Query, State};
|
||||||
|
use axum::http::StatusCode;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::auth::middleware::RequireAdmin;
|
use crate::auth::middleware::RequireAdmin;
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::services::config;
|
use crate::services::config;
|
||||||
use crate::services::rate_limiter::client_ip;
|
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
// ── DTOs ─────────────────────────────────────────────────────────────────────
|
// ── DTOs ─────────────────────────────────────────────────────────────────────
|
||||||
@@ -45,19 +45,17 @@ pub async fn get_stats(
|
|||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
|
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
|
||||||
|
|
||||||
let (user_count,): (i64,) =
|
let (user_count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM \"user\" WHERE event_id = $1")
|
||||||
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)
|
.bind(event.id)
|
||||||
.fetch_one(&state.pool)
|
.fetch_one(&state.pool)
|
||||||
.await?;
|
.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(
|
let (comment_count,): (i64,) = sqlx::query_as(
|
||||||
"SELECT COUNT(*) FROM comment c
|
"SELECT COUNT(*) FROM comment c
|
||||||
JOIN upload u ON u.id = c.upload_id
|
JOIN upload u ON u.id = c.upload_id
|
||||||
@@ -90,14 +88,17 @@ pub async fn get_config(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
RequireAdmin(_auth): RequireAdmin,
|
RequireAdmin(_auth): RequireAdmin,
|
||||||
) -> Result<Json<HashMap<String, String>>, AppError> {
|
) -> Result<Json<HashMap<String, String>>, AppError> {
|
||||||
let rows: Vec<(String, String)> =
|
let rows: Vec<(String, String)> = sqlx::query_as("SELECT key, value FROM config ORDER BY key")
|
||||||
sqlx::query_as("SELECT key, value FROM config ORDER BY key")
|
.fetch_all(&state.pool)
|
||||||
.fetch_all(&state.pool)
|
.await?;
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(Json(rows.into_iter().collect()))
|
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)]
|
#[derive(Deserialize)]
|
||||||
pub struct PatchConfigRequest(pub HashMap<String, String>);
|
pub struct PatchConfigRequest(pub HashMap<String, String>);
|
||||||
|
|
||||||
@@ -119,6 +120,16 @@ pub async fn patch_config(
|
|||||||
("upload_rate_per_hour", true, 1.0, 100_000.0),
|
("upload_rate_per_hour", true, 1.0, 100_000.0),
|
||||||
("feed_rate_per_min", 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),
|
("export_rate_per_day", true, 1.0, 100_000.0),
|
||||||
|
// Loose per-IP ceiling on /join. The real anti-spam bucket is per (ip, name); this
|
||||||
|
// only bounds raw volume from one source, so it must stay well above the size of a
|
||||||
|
// party arriving at once (see migration 017).
|
||||||
|
("join_ip_rate_per_min", true, 1.0, 100_000.0),
|
||||||
|
// Same shape for /recover: the per-(ip, name) bucket is the anti-guessing control,
|
||||||
|
// this only bounds a name-cycling flood in front of a cost-12 bcrypt (migration 019).
|
||||||
|
("recover_ip_rate_per_min", true, 1.0, 100_000.0),
|
||||||
|
// Aggregate ceiling on likes + comments + comment deletions, per user per minute.
|
||||||
|
// These were the only mutating endpoints with no limit at all (migration 020).
|
||||||
|
("social_rate_per_min", true, 1.0, 100_000.0),
|
||||||
("quota_tolerance", false, 0.0, 1.0),
|
("quota_tolerance", false, 0.0, 1.0),
|
||||||
("estimated_guest_count", true, 1.0, 1_000_000.0),
|
("estimated_guest_count", true, 1.0, 1_000_000.0),
|
||||||
];
|
];
|
||||||
@@ -128,14 +139,37 @@ pub async fn patch_config(
|
|||||||
"feed_rate_enabled",
|
"feed_rate_enabled",
|
||||||
"export_rate_enabled",
|
"export_rate_enabled",
|
||||||
"join_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",
|
||||||
|
"social_rate_enabled",
|
||||||
"quota_enabled",
|
"quota_enabled",
|
||||||
"storage_quota_enabled",
|
"storage_quota_enabled",
|
||||||
"upload_count_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
|
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 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
|
// Validate every key first so a bad value in the batch can't leave a partial
|
||||||
// update behind — validation must fully precede any write.
|
// update behind — validation must fully precede any write.
|
||||||
@@ -150,7 +184,7 @@ pub async fn patch_config(
|
|||||||
None => {
|
None => {
|
||||||
return Err(AppError::BadRequest(format!(
|
return Err(AppError::BadRequest(format!(
|
||||||
"Ungültiger Wert für {key}: muss eine Zahl sein."
|
"Ungültiger Wert für {key}: muss eine Zahl sein."
|
||||||
)))
|
)));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if integer_only && n.fract() != 0.0 {
|
if integer_only && n.fract() != 0.0 {
|
||||||
@@ -163,6 +197,23 @@ pub async fn patch_config(
|
|||||||
"Wert für {key} liegt außerhalb des zulässigen Bereichs ({min}–{max})."
|
"Wert für {key} liegt außerhalb des zulässigen Bereichs ({min}–{max})."
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
// Zero is in range and catastrophic. `quota_tolerance` is the multiplier in
|
||||||
|
// `free_disk * tolerance / active_uploaders`, so 0 makes every per-user limit 0 and
|
||||||
|
// refuses EVERY upload — mid-event, with "Du hast dein Upload-Limit für dieses Event
|
||||||
|
// erreicht", an error naming the wrong cause entirely. `storage_quota_enabled` is the
|
||||||
|
// intended off-switch.
|
||||||
|
//
|
||||||
|
// Rejecting the value rather than raising the floor: very small tolerances are
|
||||||
|
// legitimate (they are how a large disk is throttled down to a sensible per-guest
|
||||||
|
// ceiling, and how the e2e quota tests steer it — around 1e-5 on a 174 GB volume), so
|
||||||
|
// a floor of, say, 0.01 would forbid real configurations to prevent one typo.
|
||||||
|
if key_str == "quota_tolerance" && n == 0.0 {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"quota_tolerance = 0 würde jeden Upload blockieren. Zum Abschalten der \
|
||||||
|
Speicher-Quote stattdessen „Speicher-Quote aktiv“ ausschalten."
|
||||||
|
.into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
} else if BOOL_KEYS.contains(&key_str) {
|
} else if BOOL_KEYS.contains(&key_str) {
|
||||||
match value.trim().to_ascii_lowercase().as_str() {
|
match value.trim().to_ascii_lowercase().as_str() {
|
||||||
"true" | "false" | "1" | "0" | "yes" | "no" | "on" | "off" => {}
|
"true" | "false" | "1" | "0" | "yes" | "no" | "on" | "off" => {}
|
||||||
@@ -180,8 +231,23 @@ pub async fn patch_config(
|
|||||||
"Wert für {key} ist zu lang (max. {PRIVACY_NOTE_MAX_LEN} Zeichen)."
|
"Wert für {key} ist zu lang (max. {PRIVACY_NOTE_MAX_LEN} Zeichen)."
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
if key_str == "privacy_note" {
|
match key_str {
|
||||||
privacy_note_changed = true;
|
"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 {
|
} else {
|
||||||
return Err(AppError::BadRequest(format!(
|
return Err(AppError::BadRequest(format!(
|
||||||
@@ -211,16 +277,30 @@ pub async fn patch_config(
|
|||||||
|
|
||||||
// Notify all clients that a publicly-readable config value changed so their stores
|
// 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.
|
// (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(
|
let _ = state.sse_tx.send(crate::state::SseEvent::new(
|
||||||
"event-updated",
|
"event-updated",
|
||||||
serde_json::json!({ "keys": ["privacy_note"] }).to_string(),
|
serde_json::json!({ "keys": keys }).to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(StatusCode::NO_CONTENT)
|
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(
|
pub async fn get_export_jobs(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
RequireAdmin(_auth): RequireAdmin,
|
RequireAdmin(_auth): RequireAdmin,
|
||||||
@@ -268,67 +348,82 @@ pub async fn export_ticket(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Validate a download ticket (single-use) and confirm its session still exists.
|
/// Validate a download ticket (single-use) and confirm its session still exists.
|
||||||
async fn authenticate_download_ticket(state: &AppState, ticket: &str) -> Result<(), AppError> {
|
/// Resolve a single-use download ticket to the user who minted it. The caller needs the
|
||||||
|
/// id to key the export rate limit per-user (see `enforce_export_rate`).
|
||||||
|
async fn authenticate_download_ticket(state: &AppState, ticket: &str) -> Result<Uuid, AppError> {
|
||||||
let token_hash = state
|
let token_hash = state
|
||||||
.sse_tickets
|
.sse_tickets
|
||||||
.consume(ticket)
|
.consume(ticket)
|
||||||
.ok_or_else(|| AppError::Unauthorized("Ticket ungültig oder abgelaufen.".into()))?;
|
.ok_or_else(|| AppError::Unauthorized("Ticket ungültig oder abgelaufen.".into()))?;
|
||||||
crate::models::session::Session::find_by_token_hash(&state.pool, &token_hash)
|
let session = crate::models::session::Session::find_by_token_hash(&state.pool, &token_hash)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::Internal(e.into()))?
|
.map_err(|e| AppError::Internal(e.into()))?
|
||||||
.ok_or_else(|| AppError::Unauthorized("Sitzung nicht gefunden.".into()))?;
|
.ok_or_else(|| AppError::Unauthorized("Sitzung nicht gefunden.".into()))?;
|
||||||
Ok(())
|
Ok(session.user_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn download_zip(
|
pub async fn download_zip(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Query(q): Query<DownloadQuery>,
|
Query(q): Query<DownloadQuery>,
|
||||||
headers: HeaderMap,
|
|
||||||
) -> Result<axum::response::Response, AppError> {
|
) -> Result<axum::response::Response, AppError> {
|
||||||
authenticate_download_ticket(&state, &q.ticket).await?;
|
let user_id = authenticate_download_ticket(&state, &q.ticket).await?;
|
||||||
enforce_export_rate(&state, &headers).await?;
|
enforce_export_rate(&state, user_id).await?;
|
||||||
|
|
||||||
let event = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug)
|
let path =
|
||||||
.await?
|
resolve_export_file(&state, "zip", "Der ZIP-Export ist noch nicht verfügbar.").await?;
|
||||||
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
|
serve_file(path, "Gallery.zip", "application/zip").await
|
||||||
|
}
|
||||||
|
|
||||||
if !event.export_zip_ready {
|
/// Resolve the on-disk path of the CURRENT export generation — readiness check and path lookup in
|
||||||
return Err(AppError::NotFound(
|
/// ONE read, through the `export_current` view (migration 014).
|
||||||
"Der ZIP-Export ist noch nicht verfügbar.".into(),
|
///
|
||||||
));
|
/// 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.export_path.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() {
|
if !path.exists() {
|
||||||
return Err(AppError::NotFound("Exportdatei nicht gefunden.".into()));
|
return Err(AppError::NotFound("Exportdatei nicht gefunden.".into()));
|
||||||
}
|
}
|
||||||
|
Ok(path)
|
||||||
serve_file(path, "Gallery.zip", "application/zip").await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn download_html(
|
pub async fn download_html(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Query(q): Query<DownloadQuery>,
|
Query(q): Query<DownloadQuery>,
|
||||||
headers: HeaderMap,
|
|
||||||
) -> Result<axum::response::Response, AppError> {
|
) -> Result<axum::response::Response, AppError> {
|
||||||
authenticate_download_ticket(&state, &q.ticket).await?;
|
let user_id = authenticate_download_ticket(&state, &q.ticket).await?;
|
||||||
enforce_export_rate(&state, &headers).await?;
|
enforce_export_rate(&state, user_id).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.export_path.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
|
serve_file(path, "Memories.zip", "application/zip").await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,7 +433,7 @@ async fn serve_file(
|
|||||||
content_type: &str,
|
content_type: &str,
|
||||||
) -> Result<axum::response::Response, AppError> {
|
) -> Result<axum::response::Response, AppError> {
|
||||||
use axum::body::Body;
|
use axum::body::Body;
|
||||||
use axum::http::{header, Response, StatusCode};
|
use axum::http::{Response, StatusCode, header};
|
||||||
use tokio_util::io::ReaderStream;
|
use tokio_util::io::ReaderStream;
|
||||||
|
|
||||||
let file = tokio::fs::File::open(&path)
|
let file = tokio::fs::File::open(&path)
|
||||||
@@ -374,8 +469,24 @@ pub async fn export_status(
|
|||||||
|
|
||||||
let released = event.export_released_at.is_some();
|
let released = event.export_released_at.is_some();
|
||||||
|
|
||||||
let jobs: Vec<(String, String, i16)> = sqlx::query_as(
|
// ONE statement: the epoch comparison happens inside the query, against a single snapshot.
|
||||||
"SELECT type::text, status::text, progress_pct FROM export_job WHERE event_id = $1",
|
// 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.
|
||||||
|
// `error_message` is carried here, not just on the admin dashboard's job list. The host is the
|
||||||
|
// one who releases the keepsake and the one who owns the "Erneut versuchen" button, but this
|
||||||
|
// endpoint used to hand them a bare `failed` — so a fully actionable reason (notably the disk
|
||||||
|
// preflight's "needs X GB, Y GB free") was written to the row and then shown to nobody who
|
||||||
|
// could act on it. An admin-only diagnostic is not a diagnostic for the person on the spot.
|
||||||
|
let jobs: Vec<(String, String, i16, Option<String>)> = sqlx::query_as(
|
||||||
|
"SELECT j.type::text, j.status::text, j.progress_pct, j.error_message
|
||||||
|
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)
|
.bind(event.id)
|
||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
@@ -383,11 +494,21 @@ pub async fn export_status(
|
|||||||
|
|
||||||
let job_status = |type_name: &str| {
|
let job_status = |type_name: &str| {
|
||||||
jobs.iter()
|
jobs.iter()
|
||||||
.find(|(t, _, _)| t == type_name)
|
.find(|(t, _, _, _)| t == type_name)
|
||||||
.map(|(_, status, pct)| {
|
.map(|(_, status, pct, err)| {
|
||||||
serde_json::json!({ "status": status, "progress_pct": pct })
|
serde_json::json!({
|
||||||
|
"status": status,
|
||||||
|
"progress_pct": pct,
|
||||||
|
// Only on a failure. A stale message left on a row that has since been re-armed
|
||||||
|
// would otherwise show an error next to a running progress bar.
|
||||||
|
"error_message": if status == "failed" { err.clone() } else { None },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
serde_json::json!({
|
||||||
|
"status": "locked", "progress_pct": 0, "error_message": null,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
.unwrap_or_else(|| serde_json::json!({ "status": "locked", "progress_pct": 0 }))
|
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(Json(serde_json::json!({
|
Ok(Json(serde_json::json!({
|
||||||
@@ -400,21 +521,24 @@ pub async fn export_status(
|
|||||||
/// Centralised guard for the export rate limit. Same pattern as upload/feed: master
|
/// Centralised guard for the export rate limit. Same pattern as upload/feed: master
|
||||||
/// switch + per-endpoint switch + numeric value, all stored in `config` and read on
|
/// switch + per-endpoint switch + numeric value, all stored in `config` and read on
|
||||||
/// each request.
|
/// each request.
|
||||||
async fn enforce_export_rate(state: &AppState, headers: &HeaderMap) -> Result<(), AppError> {
|
async fn enforce_export_rate(state: &AppState, user_id: Uuid) -> Result<(), AppError> {
|
||||||
let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_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;
|
let export_rate_on = config::get_bool(&state.config_cache, "export_rate_enabled", true).await;
|
||||||
if !(rate_limits_on && export_rate_on) {
|
if !(rate_limits_on && export_rate_on) {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let ip = client_ip(headers, "unknown");
|
|
||||||
let limit = config::get_usize(&state.config_cache, "export_rate_per_day", 3).await;
|
let limit = config::get_usize(&state.config_cache, "export_rate_per_day", 3).await;
|
||||||
if !state
|
// Keyed per-user. This was the worst of the IP-keyed limiters: 3 downloads per DAY
|
||||||
.rate_limiter
|
// shared across every guest behind the venue's public IP, so the fourth person to
|
||||||
.check(format!("export:{ip}"), limit, Duration::from_secs(86400))
|
// fetch their keepsake was locked out until the next day.
|
||||||
{
|
if let Err(retry_after_secs) = state.rate_limiter.check_with_retry(
|
||||||
|
format!("export:{user_id}"),
|
||||||
|
limit,
|
||||||
|
Duration::from_secs(86400),
|
||||||
|
) {
|
||||||
return Err(AppError::TooManyRequests(
|
return Err(AppError::TooManyRequests(
|
||||||
"Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
|
"Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
|
||||||
None,
|
Some(retry_after_secs),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use axum::extract::{Query, State};
|
|
||||||
use axum::http::HeaderMap;
|
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
|
use axum::extract::{Query, State};
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -10,7 +9,6 @@ use uuid::Uuid;
|
|||||||
use crate::auth::middleware::AuthUser;
|
use crate::auth::middleware::AuthUser;
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::services::config;
|
use crate::services::config;
|
||||||
use crate::services::rate_limiter::client_ip;
|
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -27,6 +25,8 @@ pub struct FeedUpload {
|
|||||||
pub uploader_name: String,
|
pub uploader_name: String,
|
||||||
pub preview_url: Option<String>,
|
pub preview_url: Option<String>,
|
||||||
pub thumbnail_url: Option<String>,
|
pub thumbnail_url: Option<String>,
|
||||||
|
/// Big-screen (~2048px) variant for the diashow. Absent until the derivative exists.
|
||||||
|
pub display_url: Option<String>,
|
||||||
pub mime_type: String,
|
pub mime_type: String,
|
||||||
pub caption: Option<String>,
|
pub caption: Option<String>,
|
||||||
pub like_count: i64,
|
pub like_count: i64,
|
||||||
@@ -48,6 +48,7 @@ struct FeedRow {
|
|||||||
uploader_name: String,
|
uploader_name: String,
|
||||||
preview_path: Option<String>,
|
preview_path: Option<String>,
|
||||||
thumbnail_path: Option<String>,
|
thumbnail_path: Option<String>,
|
||||||
|
display_path: Option<String>,
|
||||||
mime_type: String,
|
mime_type: String,
|
||||||
caption: Option<String>,
|
caption: Option<String>,
|
||||||
like_count: i64,
|
like_count: i64,
|
||||||
@@ -58,21 +59,23 @@ struct FeedRow {
|
|||||||
pub async fn feed(
|
pub async fn feed(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
headers: HeaderMap,
|
|
||||||
Query(q): Query<FeedQuery>,
|
Query(q): Query<FeedQuery>,
|
||||||
) -> Result<Json<FeedResponse>, AppError> {
|
) -> Result<Json<FeedResponse>, AppError> {
|
||||||
let ip = client_ip(&headers, "unknown");
|
|
||||||
let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
|
let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
|
||||||
let feed_rate_on = config::get_bool(&state.config_cache, "feed_rate_enabled", true).await;
|
let feed_rate_on = config::get_bool(&state.config_cache, "feed_rate_enabled", true).await;
|
||||||
if rate_limits_on && feed_rate_on {
|
if rate_limits_on && feed_rate_on {
|
||||||
let rate_limit = config::get_usize(&state.config_cache, "feed_rate_per_min", 60).await;
|
let rate_limit = config::get_usize(&state.config_cache, "feed_rate_per_min", 60).await;
|
||||||
if !state
|
// Keyed per-user, exactly like `feed_delta` below: at a venue every guest shares
|
||||||
.rate_limiter
|
// one public IP, so an IP key gave the whole party a single 60/min bucket and the
|
||||||
.check(format!("feed:{ip}"), rate_limit, Duration::from_secs(60))
|
// fastest scroller starved everyone else.
|
||||||
{
|
if let Err(retry_after_secs) = state.rate_limiter.check_with_retry(
|
||||||
|
format!("feed:{}", auth.user_id),
|
||||||
|
rate_limit,
|
||||||
|
Duration::from_secs(60),
|
||||||
|
) {
|
||||||
return Err(AppError::TooManyRequests(
|
return Err(AppError::TooManyRequests(
|
||||||
"Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
|
"Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
|
||||||
None,
|
Some(retry_after_secs),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -94,7 +97,8 @@ pub async fn feed(
|
|||||||
let tag = hashtag.trim().trim_start_matches('#').to_lowercase();
|
let tag = hashtag.trim().trim_start_matches('#').to_lowercase();
|
||||||
sqlx::query_as::<_, FeedRow>(
|
sqlx::query_as::<_, FeedRow>(
|
||||||
"SELECT v.id, v.user_id, v.uploader_name, v.preview_path, v.thumbnail_path,
|
"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
|
FROM v_feed v
|
||||||
JOIN upload_hashtag uh ON uh.upload_id = v.id
|
JOIN upload_hashtag uh ON uh.upload_id = v.id
|
||||||
JOIN hashtag h ON h.id = uh.hashtag_id AND h.tag = $1
|
JOIN hashtag h ON h.id = uh.hashtag_id AND h.tag = $1
|
||||||
@@ -113,7 +117,7 @@ pub async fn feed(
|
|||||||
} else {
|
} else {
|
||||||
sqlx::query_as::<_, FeedRow>(
|
sqlx::query_as::<_, FeedRow>(
|
||||||
"SELECT id, user_id, uploader_name, preview_path, thumbnail_path,
|
"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
|
FROM v_feed
|
||||||
WHERE event_id = $1
|
WHERE event_id = $1
|
||||||
AND ($2::timestamptz IS NULL OR (created_at, id) < ($2, $3))
|
AND ($2::timestamptz IS NULL OR (created_at, id) < ($2, $3))
|
||||||
@@ -130,7 +134,11 @@ pub async fn feed(
|
|||||||
|
|
||||||
let has_more = rows.len() as i64 > limit;
|
let has_more = rows.len() as i64 > limit;
|
||||||
let rows: Vec<FeedRow> = rows.into_iter().take(limit as usize).collect();
|
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
|
// Batch check which uploads the current user has liked
|
||||||
let upload_ids: Vec<Uuid> = rows.iter().map(|r| r.id).collect();
|
let upload_ids: Vec<Uuid> = rows.iter().map(|r| r.id).collect();
|
||||||
@@ -150,6 +158,10 @@ pub async fn feed(
|
|||||||
.thumbnail_path
|
.thumbnail_path
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|_| format!("/api/v1/upload/{}/thumbnail", r.id));
|
.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 {
|
FeedUpload {
|
||||||
liked_by_me: liked_set.contains(&r.id),
|
liked_by_me: liked_set.contains(&r.id),
|
||||||
id: r.id,
|
id: r.id,
|
||||||
@@ -157,6 +169,7 @@ pub async fn feed(
|
|||||||
uploader_name: r.uploader_name,
|
uploader_name: r.uploader_name,
|
||||||
preview_url,
|
preview_url,
|
||||||
thumbnail_url,
|
thumbnail_url,
|
||||||
|
display_url,
|
||||||
mime_type: r.mime_type,
|
mime_type: r.mime_type,
|
||||||
caption: r.caption,
|
caption: r.caption,
|
||||||
like_count: r.like_count,
|
like_count: r.like_count,
|
||||||
@@ -181,6 +194,12 @@ pub struct DeltaQuery {
|
|||||||
pub struct DeltaResponse {
|
pub struct DeltaResponse {
|
||||||
pub uploads: Vec<FeedUpload>,
|
pub uploads: Vec<FeedUpload>,
|
||||||
pub deleted_ids: Vec<Uuid>,
|
pub deleted_ids: Vec<Uuid>,
|
||||||
|
/// Users whose uploads became hidden (banned / uploads_hidden) since `since`. A ban is
|
||||||
|
/// not a soft-delete, so it never appears in `deleted_ids`; without this, a client that
|
||||||
|
/// missed the ephemeral `user-hidden` SSE (a reconnecting projector, most acutely) would
|
||||||
|
/// keep displaying the banned user's already-loaded slides. The client evicts every
|
||||||
|
/// upload from these users on receipt.
|
||||||
|
pub hidden_user_ids: Vec<Uuid>,
|
||||||
/// True when the upload query hit `DELTA_LIMIT`: the response carries only the
|
/// 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
|
/// 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
|
/// rather than merging (the older missed uploads are absent and unrecoverable
|
||||||
@@ -206,14 +225,14 @@ pub async fn feed_delta(
|
|||||||
let feed_rate_on = config::get_bool(&state.config_cache, "feed_rate_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 {
|
if rate_limits_on && feed_rate_on {
|
||||||
let rate_limit = config::get_usize(&state.config_cache, "feed_rate_per_min", 60).await;
|
let rate_limit = config::get_usize(&state.config_cache, "feed_rate_per_min", 60).await;
|
||||||
if !state.rate_limiter.check(
|
if let Err(retry_after_secs) = state.rate_limiter.check_with_retry(
|
||||||
format!("feed_delta:{}", auth.user_id),
|
format!("feed_delta:{}", auth.user_id),
|
||||||
rate_limit,
|
rate_limit,
|
||||||
Duration::from_secs(60),
|
Duration::from_secs(60),
|
||||||
) {
|
) {
|
||||||
return Err(AppError::TooManyRequests(
|
return Err(AppError::TooManyRequests(
|
||||||
"Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
|
"Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
|
||||||
None,
|
Some(retry_after_secs),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -229,11 +248,17 @@ pub async fn feed_delta(
|
|||||||
// entire event's uploads in one response. If a client hits the cap it should
|
// 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.
|
// fall back to a full feed refresh rather than another delta.
|
||||||
const DELTA_LIMIT: i64 = 200;
|
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>(
|
let rows = sqlx::query_as::<_, FeedRow>(
|
||||||
"SELECT id, user_id, uploader_name, preview_path, thumbnail_path,
|
"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
|
FROM v_feed
|
||||||
WHERE event_id = $1 AND created_at > $2
|
WHERE event_id = $1 AND created_at >= $2
|
||||||
ORDER BY created_at DESC, id DESC
|
ORDER BY created_at DESC, id DESC
|
||||||
LIMIT $3",
|
LIMIT $3",
|
||||||
)
|
)
|
||||||
@@ -247,9 +272,23 @@ pub async fn feed_delta(
|
|||||||
// client to full-refresh instead of merging a partial delta.
|
// client to full-refresh instead of merging a partial delta.
|
||||||
let truncated = rows.len() as i64 >= DELTA_LIMIT;
|
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(
|
let deleted_ids: Vec<(Uuid,)> = sqlx::query_as(
|
||||||
"SELECT id FROM upload
|
"SELECT id FROM upload
|
||||||
WHERE event_id = $1 AND deleted_at IS NOT NULL AND deleted_at > $2",
|
WHERE event_id = $1 AND deleted_at IS NOT NULL AND deleted_at >= $2",
|
||||||
|
)
|
||||||
|
.bind(auth.event_id)
|
||||||
|
.bind(q.since)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Users hidden since the cursor (ban / uploads_hidden). Uncapped like `deleted_ids` and
|
||||||
|
// for the same reason — an eviction the client misses is unrecoverable via a later delta.
|
||||||
|
let hidden_user_ids: Vec<(Uuid,)> = sqlx::query_as(
|
||||||
|
"SELECT id FROM \"user\"
|
||||||
|
WHERE event_id = $1 AND uploads_hidden = TRUE
|
||||||
|
AND uploads_hidden_at IS NOT NULL AND uploads_hidden_at >= $2",
|
||||||
)
|
)
|
||||||
.bind(auth.event_id)
|
.bind(auth.event_id)
|
||||||
.bind(q.since)
|
.bind(q.since)
|
||||||
@@ -274,6 +313,10 @@ pub async fn feed_delta(
|
|||||||
.thumbnail_path
|
.thumbnail_path
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|_| format!("/api/v1/upload/{}/thumbnail", r.id)),
|
.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,
|
mime_type: r.mime_type,
|
||||||
caption: r.caption,
|
caption: r.caption,
|
||||||
like_count: r.like_count,
|
like_count: r.like_count,
|
||||||
@@ -285,6 +328,7 @@ pub async fn feed_delta(
|
|||||||
Ok(Json(DeltaResponse {
|
Ok(Json(DeltaResponse {
|
||||||
uploads,
|
uploads,
|
||||||
deleted_ids: deleted_ids.into_iter().map(|r| r.0).collect(),
|
deleted_ids: deleted_ids.into_iter().map(|r| r.0).collect(),
|
||||||
|
hidden_user_ids: hidden_user_ids.into_iter().map(|r| r.0).collect(),
|
||||||
truncated,
|
truncated,
|
||||||
server_time,
|
server_time,
|
||||||
}))
|
}))
|
||||||
@@ -300,12 +344,11 @@ pub async fn hashtags(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
) -> Result<Json<Vec<HashtagCount>>, AppError> {
|
) -> Result<Json<Vec<HashtagCount>>, AppError> {
|
||||||
let rows: Vec<(String, i64)> = sqlx::query_as(
|
let rows: Vec<(String, i64)> =
|
||||||
"SELECT tag, upload_count FROM v_hashtag_counts WHERE event_id = $1",
|
sqlx::query_as("SELECT tag, upload_count FROM v_hashtag_counts WHERE event_id = $1")
|
||||||
)
|
.bind(auth.event_id)
|
||||||
.bind(auth.event_id)
|
.fetch_all(&state.pool)
|
||||||
.fetch_all(&state.pool)
|
.await?;
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(Json(
|
Ok(Json(
|
||||||
rows.into_iter()
|
rows.into_iter()
|
||||||
@@ -335,14 +378,13 @@ async fn get_liked_set(
|
|||||||
if upload_ids.is_empty() {
|
if upload_ids.is_empty() {
|
||||||
return std::collections::HashSet::new();
|
return std::collections::HashSet::new();
|
||||||
}
|
}
|
||||||
let rows: Vec<(Uuid,)> = sqlx::query_as(
|
let rows: Vec<(Uuid,)> =
|
||||||
"SELECT upload_id FROM \"like\" WHERE user_id = $1 AND upload_id = ANY($2)",
|
sqlx::query_as("SELECT upload_id FROM \"like\" WHERE user_id = $1 AND upload_id = ANY($2)")
|
||||||
)
|
.bind(user_id)
|
||||||
.bind(user_id)
|
.bind(upload_ids)
|
||||||
.bind(upload_ids)
|
.fetch_all(pool)
|
||||||
.fetch_all(pool)
|
.await
|
||||||
.await
|
.unwrap_or_default();
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
rows.into_iter().map(|r| r.0).collect()
|
rows.into_iter().map(|r| r.0).collect()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
use axum::Json;
|
||||||
use axum::extract::{Path, State};
|
use axum::extract::{Path, State};
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use axum::Json;
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -12,6 +12,7 @@ use crate::models::event::Event;
|
|||||||
use crate::models::session::Session;
|
use crate::models::session::Session;
|
||||||
use crate::models::upload::Upload;
|
use crate::models::upload::Upload;
|
||||||
use crate::models::user::UserRole;
|
use crate::models::user::UserRole;
|
||||||
|
use crate::services::export::Affects;
|
||||||
use crate::state::{AppState, SseEvent};
|
use crate::state::{AppState, SseEvent};
|
||||||
|
|
||||||
// ── DTOs ─────────────────────────────────────────────────────────────────────
|
// ── DTOs ─────────────────────────────────────────────────────────────────────
|
||||||
@@ -34,6 +35,32 @@ pub struct EventStatus {
|
|||||||
pub is_active: bool,
|
pub is_active: bool,
|
||||||
pub uploads_locked: bool,
|
pub uploads_locked: bool,
|
||||||
pub export_released: bool,
|
pub export_released: bool,
|
||||||
|
/// Free space on the volume the keepsake is written to. `None` when the mount can't be
|
||||||
|
/// resolved — the UI hides the widget rather than rendering a confident zero.
|
||||||
|
pub disk_free_bytes: Option<u64>,
|
||||||
|
/// What a full keepsake build would need right now (both halves).
|
||||||
|
pub keepsake_required_bytes: u64,
|
||||||
|
/// Whether the host should be warned. See [`disk_is_low`].
|
||||||
|
pub disk_low: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Absolute floor below which free space is worth surfacing regardless of gallery size — the
|
||||||
|
/// threshold the README has carried on the roadmap since v1.
|
||||||
|
const LOW_DISK_FLOOR_BYTES: u64 = 10_000_000_000;
|
||||||
|
|
||||||
|
/// Is free space low enough that the host needs to know?
|
||||||
|
///
|
||||||
|
/// Two triggers, because a fixed threshold answers the wrong question. `postgres_data`,
|
||||||
|
/// `media_data` and `exports_data` are all Docker named volumes on one filesystem, so a full disk
|
||||||
|
/// does not degrade one subsystem — it stops Postgres writing and takes the event down. That is
|
||||||
|
/// what the absolute floor is for.
|
||||||
|
///
|
||||||
|
/// The second trigger is the one that actually earns its place: the keepsake needs room for two
|
||||||
|
/// gallery-sized archives, and the only moment a host can do anything about that is BEFORE they
|
||||||
|
/// release. Warning at "you could not build the keepsake right now" turns a post-event dead end
|
||||||
|
/// into a decision someone can still make.
|
||||||
|
fn disk_is_low(free: u64, keepsake_required: u64) -> bool {
|
||||||
|
free < LOW_DISK_FLOOR_BYTES || free < keepsake_required
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Count non-banned hosts/admins in the event OTHER than `excluding` — the operators
|
/// Count non-banned hosts/admins in the event OTHER than `excluding` — the operators
|
||||||
@@ -56,7 +83,6 @@ async fn remaining_operators(
|
|||||||
Ok(count)
|
Ok(count)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct SetRoleRequest {
|
pub struct SetRoleRequest {
|
||||||
pub role: String,
|
pub role: String,
|
||||||
@@ -72,11 +98,29 @@ pub async fn get_event_status(
|
|||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
|
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
|
||||||
|
|
||||||
|
// Measured on the EXPORT volume, not the media one: that is where the cliff is, and it is a
|
||||||
|
// distinct mount point even when both are backed by the same filesystem. The cached reading is
|
||||||
|
// right here — this is advisory, polled on every dashboard load, and a 15s-stale number costs
|
||||||
|
// nothing (unlike the export preflight, which reads uncached because it is about to write).
|
||||||
|
let free = state
|
||||||
|
.disk_cache
|
||||||
|
.snapshot(&state.config.export_path)
|
||||||
|
.map(|d| d.free);
|
||||||
|
let keepsake_required_bytes =
|
||||||
|
crate::services::export::keepsake_space_required(&state.pool, event.id)
|
||||||
|
.await
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
Ok(Json(EventStatus {
|
Ok(Json(EventStatus {
|
||||||
name: event.name,
|
name: event.name,
|
||||||
is_active: event.is_active,
|
is_active: event.is_active,
|
||||||
uploads_locked: event.uploads_locked_at.is_some(),
|
uploads_locked: event.uploads_locked_at.is_some(),
|
||||||
export_released: event.export_released_at.is_some(),
|
export_released: event.export_released_at.is_some(),
|
||||||
|
disk_free_bytes: free,
|
||||||
|
keepsake_required_bytes,
|
||||||
|
// Unknown free space is NOT low. Fails open, exactly as the upload quota and the export
|
||||||
|
// preflight do: a scary banner on an unreadable mount would train the host to ignore it.
|
||||||
|
disk_low: free.is_some_and(|f| disk_is_low(f, keepsake_required_bytes)),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,7 +158,9 @@ pub async fn ban_user(
|
|||||||
// The ban request carries no body — ban always hides (no per-request options).
|
// The ban request carries no body — ban always hides (no per-request options).
|
||||||
// Cannot ban yourself or another host/admin
|
// Cannot ban yourself or another host/admin
|
||||||
if user_id == auth.user_id {
|
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,)>(
|
let target = sqlx::query_as::<_, (String,)>(
|
||||||
"SELECT role::text FROM \"user\" WHERE id = $1 AND event_id = $2",
|
"SELECT role::text FROM \"user\" WHERE id = $1 AND event_id = $2",
|
||||||
@@ -125,8 +171,12 @@ pub async fn ban_user(
|
|||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
|
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
|
||||||
|
|
||||||
if target.0 == "admin" || (target.0 == "host" && auth.role != crate::models::user::UserRole::Admin) {
|
if target.0 == "admin"
|
||||||
return Err(AppError::Forbidden("Du kannst diesen Benutzer nicht sperren.".into()));
|
|| (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
|
// Floor: never leave the event with zero operators. Banning removes the target from
|
||||||
@@ -148,14 +198,34 @@ pub async fn ban_user(
|
|||||||
// (enforced live on the write handlers + Require{Host,Admin}). Revoking sessions would
|
// (enforced live on the write handlers + Require{Host,Admin}). Revoking sessions would
|
||||||
// contradict that model, break the documented "banned guest can still download the
|
// 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).
|
// 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(
|
sqlx::query(
|
||||||
"UPDATE \"user\" SET is_banned = TRUE, uploads_hidden = TRUE WHERE id = $1 AND event_id = $2",
|
"UPDATE \"user\"
|
||||||
|
SET is_banned = TRUE, uploads_hidden = TRUE, uploads_hidden_at = NOW()
|
||||||
|
WHERE id = $1 AND event_id = $2",
|
||||||
)
|
)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
.bind(auth.event_id)
|
.bind(auth.event_id)
|
||||||
.execute(&state.pool)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
// A ban hides the user's uploads EVERYWHERE — and the keepsake is the place that matters most,
|
||||||
|
// because it is the copy people keep. The export already filters `is_banned = FALSE`, so a
|
||||||
|
// FUTURE export excludes them; without this, an ALREADY-RELEASED archive would keep serving a
|
||||||
|
// banned user's photos forever. Same class as a takedown, so same treatment.
|
||||||
|
let regen = crate::services::export::invalidate_and_arm(
|
||||||
|
&mut tx,
|
||||||
|
&state.config.event_slug,
|
||||||
|
Affects::Both,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
if let Some(r) = regen {
|
||||||
|
start_regen(&state, r);
|
||||||
|
}
|
||||||
|
|
||||||
// Evict their content live from every feed + the diashow so it disappears without
|
// 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
|
// 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
|
// is_banned revalidation in `sse::stream`, so they stop receiving live pushes while
|
||||||
@@ -201,17 +271,35 @@ pub async fn unban_user(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Unban restores visibility too: ban set `uploads_hidden = TRUE`, so clearing only
|
// Unban restores visibility too: ban set `uploads_hidden = TRUE`, so clearing only
|
||||||
// `is_banned` would leave their content invisible. Clear both.
|
// `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(
|
let result = sqlx::query(
|
||||||
"UPDATE \"user\" SET is_banned = FALSE, uploads_hidden = FALSE WHERE id = $1 AND event_id = $2",
|
"UPDATE \"user\"
|
||||||
|
SET is_banned = FALSE, uploads_hidden = FALSE, uploads_hidden_at = NULL
|
||||||
|
WHERE id = $1 AND event_id = $2",
|
||||||
)
|
)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
.bind(auth.event_id)
|
.bind(auth.event_id)
|
||||||
.execute(&state.pool)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
if result.rows_affected() == 0 {
|
if result.rows_affected() == 0 {
|
||||||
return Err(AppError::NotFound("Benutzer nicht gefunden.".into()));
|
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!(
|
tracing::info!(
|
||||||
actor_user_id = %auth.user_id,
|
actor_user_id = %auth.user_id,
|
||||||
target_user_id = %user_id,
|
target_user_id = %user_id,
|
||||||
@@ -221,6 +309,55 @@ pub async fn unban_user(
|
|||||||
Ok(StatusCode::NO_CONTENT)
|
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(
|
pub async fn set_role(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
RequireHost(auth): RequireHost,
|
RequireHost(auth): RequireHost,
|
||||||
@@ -238,14 +375,15 @@ pub async fn set_role(
|
|||||||
_ => {
|
_ => {
|
||||||
return Err(AppError::BadRequest(
|
return Err(AppError::BadRequest(
|
||||||
"Ungültige Rolle. Erlaubt: guest, host.".into(),
|
"Ungültige Rolle. Erlaubt: guest, host.".into(),
|
||||||
))
|
));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Look up the current role so we can apply the host-vs-admin guard. Hosts may
|
// Look up the current role so we can apply the host-vs-admin guard. A plain host may
|
||||||
// promote guests and demote *other* hosts (the user explicitly requested this
|
// promote/demote GUESTS only; it may not change any host's or admin's role (see the
|
||||||
// expansion). Hosts may not touch admins. Admins may do anything (except change
|
// guard below — this closes the demote-a-peer-host→ban/PIN-reset takeover chain, F1).
|
||||||
// themselves, blocked above).
|
// Only an admin may change a host's role. Admins may do anything except change
|
||||||
|
// themselves (blocked above).
|
||||||
let target = sqlx::query_as::<_, (String,)>(
|
let target = sqlx::query_as::<_, (String,)>(
|
||||||
"SELECT role::text FROM \"user\" WHERE id = $1 AND event_id = $2",
|
"SELECT role::text FROM \"user\" WHERE id = $1 AND event_id = $2",
|
||||||
)
|
)
|
||||||
@@ -334,13 +472,12 @@ pub async fn reset_user_pin(
|
|||||||
_ => {
|
_ => {
|
||||||
return Err(AppError::Forbidden(
|
return Err(AppError::Forbidden(
|
||||||
"Du darfst die PIN dieses Benutzers nicht zurücksetzen.".into(),
|
"Du darfst die PIN dieses Benutzers nicht zurücksetzen.".into(),
|
||||||
))
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let pin: String = format!("{:04}", rand::rng().random_range(0..10000u32));
|
let pin: String = format!("{:04}", rand::rng().random_range(0..10000u32));
|
||||||
let pin_hash =
|
let pin_hash = crate::auth::handlers::hash_password(pin.clone(), 12).await?;
|
||||||
bcrypt::hash(&pin, 12).map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
|
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"UPDATE \"user\"
|
"UPDATE \"user\"
|
||||||
@@ -356,8 +493,13 @@ pub async fn reset_user_pin(
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// A PIN reset means the old credential is compromised/forgotten — revoke every
|
// A PIN reset means the old credential is compromised/forgotten — revoke every
|
||||||
// existing session so old devices must re-authenticate with the new PIN.
|
// existing session so old devices must re-authenticate with the new PIN. This is a
|
||||||
let _ = Session::delete_all_for_user(&state.pool, user_id).await;
|
// 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.
|
// 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")
|
let _ = sqlx::query("DELETE FROM pin_reset_request WHERE user_id = $1")
|
||||||
@@ -424,6 +566,46 @@ pub async fn dismiss_pin_reset_request(
|
|||||||
Ok(StatusCode::NO_CONTENT)
|
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(
|
pub async fn host_delete_upload(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
RequireHost(auth): RequireHost,
|
RequireHost(auth): RequireHost,
|
||||||
@@ -433,15 +615,29 @@ pub async fn host_delete_upload(
|
|||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
|
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
|
||||||
|
|
||||||
let deleted = Upload::soft_delete_in_event(&state.pool, upload_id, auth.event_id).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 {
|
if !deleted {
|
||||||
return Err(AppError::NotFound("Upload nicht gefunden.".into()));
|
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(
|
let _ = state.sse_tx.send(SseEvent::new(
|
||||||
"upload-deleted",
|
"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!(
|
tracing::info!(
|
||||||
actor_user_id = %auth.user_id,
|
actor_user_id = %auth.user_id,
|
||||||
@@ -458,15 +654,29 @@ pub async fn host_delete_comment(
|
|||||||
RequireHost(auth): RequireHost,
|
RequireHost(auth): RequireHost,
|
||||||
Path(comment_id): Path<Uuid>,
|
Path(comment_id): Path<Uuid>,
|
||||||
) -> Result<StatusCode, AppError> {
|
) -> Result<StatusCode, AppError> {
|
||||||
let deleted =
|
let mut tx = state.pool.begin().await?;
|
||||||
Comment::soft_delete_in_event(&state.pool, comment_id, auth.event_id).await?;
|
let deleted = Comment::soft_delete_in_event(&mut tx, comment_id, auth.event_id).await?;
|
||||||
if !deleted {
|
if !deleted {
|
||||||
return Err(AppError::NotFound("Kommentar nicht gefunden.".into()));
|
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(
|
let _ = state.sse_tx.send(SseEvent::new(
|
||||||
"comment-deleted",
|
"comment-deleted",
|
||||||
serde_json::json!({ "comment_id": comment_id }).to_string(),
|
serde_json::json!({ "comment_id": comment_id }).to_string(),
|
||||||
));
|
));
|
||||||
|
if let Some(r) = regen {
|
||||||
|
start_regen(&state, r);
|
||||||
|
}
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
actor_user_id = %auth.user_id,
|
actor_user_id = %auth.user_id,
|
||||||
event_id = %auth.event_id,
|
event_id = %auth.event_id,
|
||||||
@@ -500,16 +710,20 @@ pub async fn open_event(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
RequireHost(_auth): RequireHost,
|
RequireHost(_auth): RequireHost,
|
||||||
) -> Result<StatusCode, AppError> {
|
) -> Result<StatusCode, AppError> {
|
||||||
// Reopening also invalidates any prior release: the keepsake was snapshotted at
|
// Reopening invalidates any prior release: the keepsake was snapshotted at release time, so
|
||||||
// release time, so allowing new uploads afterwards would silently diverge the live
|
// allowing new uploads afterwards would silently diverge the live feed from the frozen export.
|
||||||
// feed from the frozen export. Clearing `export_released_at` (and the readiness
|
//
|
||||||
// flags) lets the host re-release later to regenerate a correct, complete keepsake.
|
// 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(
|
let result = sqlx::query(
|
||||||
"UPDATE event
|
"UPDATE event
|
||||||
SET uploads_locked_at = NULL,
|
SET uploads_locked_at = NULL,
|
||||||
export_released_at = NULL,
|
export_released_at = NULL,
|
||||||
export_zip_ready = FALSE,
|
export_epoch = export_epoch + 1
|
||||||
export_html_ready = FALSE
|
|
||||||
WHERE slug = $1 AND (uploads_locked_at IS NOT NULL OR export_released_at IS NOT NULL)",
|
WHERE slug = $1 AND (uploads_locked_at IS NOT NULL OR export_released_at IS NOT NULL)",
|
||||||
)
|
)
|
||||||
.bind(&state.config.event_slug)
|
.bind(&state.config.event_slug)
|
||||||
@@ -527,24 +741,37 @@ pub async fn release_gallery(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
RequireHost(_auth): RequireHost,
|
RequireHost(_auth): RequireHost,
|
||||||
) -> Result<StatusCode, AppError> {
|
) -> Result<StatusCode, AppError> {
|
||||||
// Atomic claim: the conditional UPDATE is the sole gate, so two concurrent
|
// The release claim, the epoch bump, the upload lock and BOTH job rows are written in ONE
|
||||||
// release calls can't both pass a check-then-set and double-enqueue exports.
|
// transaction. Two reasons, both of which were live bugs:
|
||||||
// rows_affected == 0 means someone already released.
|
|
||||||
//
|
//
|
||||||
// Releasing also locks uploads in the same statement (release ⇒ lock). Otherwise a
|
// 1. Cancellation. This handler used to commit `export_released_at` and only THEN await the
|
||||||
// guest whose offline upload reconnects *after* the export snapshot would land in the
|
// enqueue. Axum drops the handler future when the client disconnects (closed tab, proxy
|
||||||
// live feed but not in the downloaded keepsake — a silent, non-regenerable data loss.
|
// timeout), which left the event released and uploads locked with ZERO export_job rows and
|
||||||
// `COALESCE` preserves an earlier explicit lock time rather than overwriting it.
|
// no workers: downloads 404 forever and the host cannot retry, because release_gallery
|
||||||
let result = sqlx::query(
|
// rejects an already-released event ("bereits freigegeben"). Only a restart escaped it.
|
||||||
|
// Now nothing is committed until every row is written, and the workers are spawned AFTER
|
||||||
|
// the commit (a detached `tokio::spawn` survives cancellation).
|
||||||
|
// 2. Atomicity vs. a concurrent reopen. Bumping the epoch in the same statement that sets
|
||||||
|
// `export_released_at` means no worker and no reader can ever observe "released again but
|
||||||
|
// the generation hasn't moved on yet" — the window every previous fix kept leaving open.
|
||||||
|
//
|
||||||
|
// Release also locks uploads in the same statement (release ⇒ lock), so the export snapshot is
|
||||||
|
// taken against a frozen upload set. `COALESCE` preserves an earlier explicit lock time.
|
||||||
|
let mut tx = state.pool.begin().await?;
|
||||||
|
|
||||||
|
let claimed: Option<(Uuid, String, i64)> = sqlx::query_as(
|
||||||
"UPDATE event
|
"UPDATE event
|
||||||
SET export_released_at = NOW(),
|
SET export_released_at = NOW(),
|
||||||
uploads_locked_at = COALESCE(uploads_locked_at, NOW())
|
uploads_locked_at = COALESCE(uploads_locked_at, NOW()),
|
||||||
WHERE slug = $1 AND export_released_at IS NULL",
|
export_epoch = export_epoch + 1
|
||||||
|
WHERE slug = $1 AND export_released_at IS NULL
|
||||||
|
RETURNING id, name, export_epoch",
|
||||||
)
|
)
|
||||||
.bind(&state.config.event_slug)
|
.bind(&state.config.event_slug)
|
||||||
.execute(&state.pool)
|
.fetch_optional(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
if result.rows_affected() == 0 {
|
|
||||||
|
let Some((event_id, event_name, epoch)) = claimed else {
|
||||||
// Distinguish "no such event" from "already released" for a clean error.
|
// Distinguish "no such event" from "already released" for a clean error.
|
||||||
let exists = Event::find_by_slug(&state.pool, &state.config.event_slug)
|
let exists = Event::find_by_slug(&state.pool, &state.config.event_slug)
|
||||||
.await?
|
.await?
|
||||||
@@ -554,28 +781,73 @@ pub async fn release_gallery(
|
|||||||
} else {
|
} else {
|
||||||
AppError::NotFound("Event nicht gefunden.".into())
|
AppError::NotFound("Event nicht gefunden.".into())
|
||||||
});
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
// Release locked uploads too — tell any open composer to flip to the locked UI live
|
// Arm both types at THIS epoch. No "skip the type that's already ready" check any more: the
|
||||||
// rather than discovering it via a rejected upload.
|
// epoch bump above retired every prior generation, so there is nothing to preserve and nothing
|
||||||
|
// to be fooled by. (That skip was how a stale ready flag used to suppress regeneration.)
|
||||||
|
crate::services::export::enqueue_jobs_at_epoch(&mut tx, event_id, epoch).await?;
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
|
|
||||||
|
// Release locks uploads too — tell any open composer to flip to the locked UI live rather than
|
||||||
|
// discovering it via a rejected upload.
|
||||||
let _ = state.sse_tx.send(SseEvent::new("event-closed", "{}"));
|
let _ = state.sse_tx.send(SseEvent::new("event-closed", "{}"));
|
||||||
|
|
||||||
// We won the claim — load the event for its id/name to enqueue export jobs.
|
// Detached — survives this handler being cancelled.
|
||||||
let event = Event::find_by_slug(&state.pool, &state.config.event_slug)
|
crate::services::export::spawn_export_jobs(
|
||||||
.await?
|
event_id,
|
||||||
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
|
event_name,
|
||||||
|
epoch,
|
||||||
// Enqueue + spawn via the shared path so a re-release (after reopen) regenerates
|
state.config.comments_enabled,
|
||||||
// cleanly and startup recovery uses identical logic.
|
std::time::Duration::ZERO,
|
||||||
crate::services::export::enqueue_and_spawn_exports(
|
|
||||||
event.id,
|
|
||||||
event.name,
|
|
||||||
state.pool.clone(),
|
state.pool.clone(),
|
||||||
state.config.media_path.clone(),
|
state.config.media_path.clone(),
|
||||||
state.config.export_path.clone(),
|
state.config.export_path.clone(),
|
||||||
state.sse_tx.clone(),
|
state.sse_tx.clone(),
|
||||||
)
|
);
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{LOW_DISK_FLOOR_BYTES, disk_is_low};
|
||||||
|
|
||||||
|
const GB: u64 = 1_000_000_000;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_healthy_disk_with_room_for_the_keepsake_is_not_low() {
|
||||||
|
assert!(!disk_is_low(40 * GB, 25 * GB));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_absolute_floor_fires_even_when_the_gallery_is_tiny() {
|
||||||
|
// All three volumes share one filesystem, so running out doesn't degrade one subsystem —
|
||||||
|
// Postgres stops being able to write and the event goes down. A 1 GB gallery would clear
|
||||||
|
// the keepsake test comfortably; the floor is what catches this.
|
||||||
|
assert!(disk_is_low(5 * GB, GB));
|
||||||
|
assert!(disk_is_low(LOW_DISK_FLOOR_BYTES - 1, 0));
|
||||||
|
assert!(!disk_is_low(LOW_DISK_FLOOR_BYTES, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plenty_of_space_is_still_low_when_the_keepsake_would_not_fit() {
|
||||||
|
// THE case the fixed threshold misses, and the one that matters: 30 GB free is nowhere near
|
||||||
|
// any floor, but a 30 GB gallery needs room for TWO archives. The host can act on this
|
||||||
|
// before releasing; after releasing, they cannot.
|
||||||
|
assert!(disk_is_low(30 * GB, 66 * GB));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_keepsake_trigger_is_exact_at_the_boundary() {
|
||||||
|
assert!(!disk_is_low(66 * GB, 66 * GB), "exactly enough is enough");
|
||||||
|
assert!(disk_is_low(66 * GB - 1, 66 * GB));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_empty_gallery_needs_nothing_and_only_the_floor_applies() {
|
||||||
|
assert!(!disk_is_low(11 * GB, 0));
|
||||||
|
assert!(disk_is_low(9 * GB, 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,14 +7,14 @@
|
|||||||
//! account page loads this once on mount instead of issuing several round trips.
|
//! account page loads this once on mount instead of issuing several round trips.
|
||||||
//! - `GET /api/v1/me/quota` — live per-user storage quota estimate.
|
//! - `GET /api/v1/me/quota` — live per-user storage quota estimate.
|
||||||
|
|
||||||
use axum::extract::State;
|
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
|
use axum::extract::State;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
use crate::auth::middleware::AuthUser;
|
use crate::auth::middleware::AuthUser;
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::handlers::upload::compute_storage_quota;
|
use crate::handlers::upload::compute_storage_quota;
|
||||||
use crate::models::user::User;
|
use crate::models::user::{User, UserRole};
|
||||||
use crate::services::config;
|
use crate::services::config;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
@@ -37,12 +37,26 @@ pub async fn get_quota(
|
|||||||
|
|
||||||
let estimate = compute_storage_quota(&state).await;
|
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 {
|
Ok(Json(QuotaDto {
|
||||||
enabled: estimate.limit_bytes.is_some(),
|
enabled: estimate.limit_bytes.is_some(),
|
||||||
used_bytes: user.total_upload_bytes,
|
used_bytes: user.total_upload_bytes,
|
||||||
limit_bytes: estimate.limit_bytes,
|
limit_bytes: estimate.limit_bytes,
|
||||||
active_uploaders: estimate.active_uploaders,
|
active_uploaders: if is_staff {
|
||||||
free_disk_bytes: estimate.free_disk_bytes,
|
estimate.active_uploaders
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
},
|
||||||
|
free_disk_bytes: if is_staff {
|
||||||
|
estimate.free_disk_bytes
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,12 +87,19 @@ pub async fn get_context(
|
|||||||
|
|
||||||
let privacy_note = config::get_str(&state.config_cache, "privacy_note", "").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 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 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)
|
let event =
|
||||||
.await?;
|
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 uploads_locked = event
|
||||||
let gallery_released = event.as_ref().map(|e| e.export_released_at.is_some()).unwrap_or(false);
|
.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 {
|
Ok(Json(MeContextDto {
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
|
|||||||
@@ -1,24 +1,44 @@
|
|||||||
//! Unauthenticated, read-only endpoints safe to expose before a user has joined.
|
//! Unauthenticated, read-only endpoints safe to expose before a user has joined.
|
||||||
|
|
||||||
use axum::extract::State;
|
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
|
use axum::extract::State;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use crate::services::config;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct PublicEventDto {
|
pub struct PublicEventDto {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub slug: 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, used by the pre-auth join/recover screens so a guest can
|
/// Public event identity + presentation config, used by the pre-auth join/recover
|
||||||
/// see *which* event they're joining. Only the display name and slug are exposed —
|
/// screens (which event am I joining, what does it look like). Only non-user-scoped
|
||||||
/// nothing user-scoped — so this is safe without a token. Served straight from the
|
/// fields are exposed, so this is safe without a token. Identity comes straight from
|
||||||
/// instance config (no DB round-trip needed).
|
/// 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> {
|
pub async fn get_public_event(State(state): State<AppState>) -> Json<PublicEventDto> {
|
||||||
|
let cache = &state.config_cache;
|
||||||
Json(PublicEventDto {
|
Json(PublicEventDto {
|
||||||
name: state.config.event_name.clone(),
|
name: state.config.event_name.clone(),
|
||||||
slug: state.config.event_slug.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,6 +1,6 @@
|
|||||||
|
use axum::Json;
|
||||||
use axum::extract::{Path, Query, State};
|
use axum::extract::{Path, Query, State};
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use axum::Json;
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -10,8 +10,40 @@ use crate::error::AppError;
|
|||||||
use crate::models::comment::{Comment, CommentDto};
|
use crate::models::comment::{Comment, CommentDto};
|
||||||
use crate::models::hashtag::{self, Hashtag};
|
use crate::models::hashtag::{self, Hashtag};
|
||||||
use crate::models::upload::Upload;
|
use crate::models::upload::Upload;
|
||||||
|
use crate::services::config;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
/// Throttle a social write. Keyed PER USER, like the feed and upload limits and for the same
|
||||||
|
/// reason: at a venue every guest sits behind one NAT, so an IP key hands the whole party a
|
||||||
|
/// single bucket and the most active guest starves everyone else.
|
||||||
|
///
|
||||||
|
/// These were the only mutating endpoints in the app with no limit at all — the coverage was
|
||||||
|
/// asymmetric, not deliberately open. The ceiling is set well above anything a real guest
|
||||||
|
/// produces; this bounds a script, not an enthusiastic double-tapper.
|
||||||
|
async fn check_social_rate(state: &AppState, user_id: Uuid) -> Result<(), AppError> {
|
||||||
|
let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
|
||||||
|
let social_rate_on = config::get_bool(&state.config_cache, "social_rate_enabled", true).await;
|
||||||
|
if !(rate_limits_on && social_rate_on) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let rate_limit = config::get_usize(&state.config_cache, "social_rate_per_min", 120).await;
|
||||||
|
// ONE bucket across likes, comments and comment deletions. Separate buckets would let a
|
||||||
|
// caller triple the aggregate write rate just by alternating between them.
|
||||||
|
state
|
||||||
|
.rate_limiter
|
||||||
|
.check_with_retry(
|
||||||
|
format!("social:{user_id}"),
|
||||||
|
rate_limit,
|
||||||
|
std::time::Duration::from_secs(60),
|
||||||
|
)
|
||||||
|
.map_err(|retry_after_secs| {
|
||||||
|
AppError::TooManyRequests(
|
||||||
|
"Zu viele Aktionen. Bitte warte kurz und versuche es erneut.".into(),
|
||||||
|
Some(retry_after_secs),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct LikeResponse {
|
pub struct LikeResponse {
|
||||||
/// The caller's like state *after* this toggle. The client sets `liked_by_me` from
|
/// The caller's like state *after* this toggle. The client sets `liked_by_me` from
|
||||||
@@ -35,6 +67,7 @@ pub async fn toggle_like(
|
|||||||
if user.is_banned {
|
if user.is_banned {
|
||||||
return Err(AppError::Forbidden("Du bist gesperrt.".into()));
|
return Err(AppError::Forbidden("Du bist gesperrt.".into()));
|
||||||
}
|
}
|
||||||
|
check_social_rate(&state, auth.user_id).await?;
|
||||||
|
|
||||||
// Event-scope: the upload must belong to the caller's event (404 otherwise),
|
// Event-scope: the upload must belong to the caller's event (404 otherwise),
|
||||||
// matching the host handlers' find_by_id_and_event pattern.
|
// matching the host handlers' find_by_id_and_event pattern.
|
||||||
@@ -129,12 +162,19 @@ pub async fn add_comment(
|
|||||||
Path(upload_id): Path<Uuid>,
|
Path(upload_id): Path<Uuid>,
|
||||||
Json(body): Json<AddCommentRequest>,
|
Json(body): Json<AddCommentRequest>,
|
||||||
) -> Result<(StatusCode, Json<CommentDto>), AppError> {
|
) -> Result<(StatusCode, Json<CommentDto>), AppError> {
|
||||||
|
// Comments can be disabled instance-wide (env COMMENTS_ENABLED). The frontend hides
|
||||||
|
// the UI, but gate the API too so a stale client or direct call can't slip one in.
|
||||||
|
if !state.config.comments_enabled {
|
||||||
|
return Err(AppError::Forbidden("Kommentare sind deaktiviert.".into()));
|
||||||
|
}
|
||||||
|
|
||||||
let user = crate::models::user::User::find_by_id(&state.pool, auth.user_id)
|
let user = crate::models::user::User::find_by_id(&state.pool, auth.user_id)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
|
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
|
||||||
if user.is_banned {
|
if user.is_banned {
|
||||||
return Err(AppError::Forbidden("Du bist gesperrt.".into()));
|
return Err(AppError::Forbidden("Du bist gesperrt.".into()));
|
||||||
}
|
}
|
||||||
|
check_social_rate(&state, auth.user_id).await?;
|
||||||
|
|
||||||
// Event-scope: only comment on an upload that belongs to the caller's event.
|
// 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)
|
Upload::find_by_id_and_event(&state.pool, upload_id, auth.event_id)
|
||||||
@@ -210,6 +250,7 @@ pub async fn delete_comment(
|
|||||||
if auth.is_banned {
|
if auth.is_banned {
|
||||||
return Err(AppError::Forbidden("Du bist gesperrt.".into()));
|
return Err(AppError::Forbidden("Du bist gesperrt.".into()));
|
||||||
}
|
}
|
||||||
|
check_social_rate(&state, auth.user_id).await?;
|
||||||
let comment = Comment::find_by_id(&state.pool, comment_id)
|
let comment = Comment::find_by_id(&state.pool, comment_id)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| AppError::NotFound("Kommentar nicht gefunden.".into()))?;
|
.ok_or_else(|| AppError::NotFound("Kommentar nicht gefunden.".into()))?;
|
||||||
@@ -220,10 +261,22 @@ pub async fn delete_comment(
|
|||||||
|
|
||||||
// Event-scope: soft_delete_in_event only matches comments whose upload is in
|
// 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.
|
// the caller's event, so a cross-event comment_id resolves to a 404 here.
|
||||||
let deleted = Comment::soft_delete_in_event(&state.pool, comment_id, auth.event_id).await?;
|
let mut tx = state.pool.begin().await?;
|
||||||
|
let deleted = Comment::soft_delete_in_event(&mut tx, comment_id, auth.event_id).await?;
|
||||||
if !deleted {
|
if !deleted {
|
||||||
return Err(AppError::NotFound("Kommentar nicht gefunden.".into()));
|
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(
|
let _ = state.sse_tx.send(crate::state::SseEvent::new(
|
||||||
"comment-deleted",
|
"comment-deleted",
|
||||||
serde_json::json!({ "comment_id": comment_id, "upload_id": comment.upload_id }).to_string(),
|
serde_json::json!({ "comment_id": comment_id, "upload_id": comment.upload_id }).to_string(),
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
use std::convert::Infallible;
|
use std::convert::Infallible;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use axum::Json;
|
||||||
use axum::extract::{Query, State};
|
use axum::extract::{Query, State};
|
||||||
use axum::response::sse::{Event, KeepAlive, Sse};
|
use axum::response::sse::{Event, KeepAlive, Sse};
|
||||||
use axum::Json;
|
|
||||||
use futures::stream::Stream;
|
use futures::stream::Stream;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tokio_stream::wrappers::errors::BroadcastStreamRecvError;
|
|
||||||
use tokio_stream::wrappers::BroadcastStream;
|
|
||||||
use tokio_stream::StreamExt;
|
use tokio_stream::StreamExt;
|
||||||
|
use tokio_stream::wrappers::BroadcastStream;
|
||||||
|
use tokio_stream::wrappers::errors::BroadcastStreamRecvError;
|
||||||
|
|
||||||
use crate::auth::middleware::AuthUser;
|
use crate::auth::middleware::AuthUser;
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
@@ -42,7 +42,10 @@ pub async fn issue_ticket(
|
|||||||
let server_time = sqlx::query_scalar("SELECT NOW()")
|
let server_time = sqlx::query_scalar("SELECT NOW()")
|
||||||
.fetch_one(&state.pool)
|
.fetch_one(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(Json(StreamTicketResponse { ticket, server_time }))
|
Ok(Json(StreamTicketResponse {
|
||||||
|
ticket,
|
||||||
|
server_time,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// SSE stream endpoint. Authenticates via a single-use ticket (see
|
/// SSE stream endpoint. Authenticates via a single-use ticket (see
|
||||||
@@ -82,11 +85,11 @@ pub async fn stream(
|
|||||||
|
|
||||||
// The session is only checked once at open. Re-validate it periodically so a
|
// 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
|
// 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. Banning
|
// until the client happens to disconnect. Bounds a stale stream to ~60s. NOTE: a ban is
|
||||||
// already revokes the user's sessions (so the row is gone), but re-reading the live
|
// deliberately read-only and does NOT revoke sessions (see `ban_user`), so the
|
||||||
// user row and dropping on `is_banned` is a cheap defense-in-depth. Only a
|
// `is_banned` re-read below is LOAD-BEARING — it is the only thing that stops a banned
|
||||||
// *definitive* gone/expired/banned state ends the stream; a transient DB error just
|
// user's live push stream. Do not remove it. Only a *definitive* gone/expired/banned
|
||||||
// retries next tick.
|
// state ends the stream; a transient DB error just retries next tick.
|
||||||
let pool = state.pool.clone();
|
let pool = state.pool.clone();
|
||||||
let session_hash = token_hash.clone();
|
let session_hash = token_hash.clone();
|
||||||
let session_gone = async move {
|
let session_gone = async move {
|
||||||
|
|||||||
@@ -13,9 +13,10 @@ use crate::auth::middleware::RequireAdmin;
|
|||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
/// Truncates every event-scoped table, wipes media on disk, and reseeds the
|
/// Truncates every event-scoped table, wipes media on disk, and reseeds the `config`
|
||||||
/// `config` table from migration defaults. Requires an admin JWT — even with
|
/// table: numeric values from the migration defaults, but every feature toggle forced
|
||||||
/// `EVENTSNAP_TEST_MODE=1` it cannot be hit anonymously.
|
/// OFF (production seeds them ON — see the note at the reseed below). Requires an admin
|
||||||
|
/// JWT — even with `EVENTSNAP_TEST_MODE=1` it cannot be hit anonymously.
|
||||||
pub async fn truncate_all(
|
pub async fn truncate_all(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
RequireAdmin(_auth): RequireAdmin,
|
RequireAdmin(_auth): RequireAdmin,
|
||||||
@@ -40,15 +41,29 @@ pub async fn truncate_all(
|
|||||||
.execute(&state.pool)
|
.execute(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Reseed config — mirrors migrations 005 and 009. Kept in sync by hand
|
// Reseed config. The NUMERIC values mirror migrations 005/015/016/017/019; the BOOLEAN
|
||||||
// because pulling SQL out of the migration files at runtime is fragile.
|
// toggles deliberately do NOT — migration 009 seeds every one of them `true`
|
||||||
|
// (production), and this forces them `false` so the suite isn't fighting rate limits
|
||||||
|
// and quotas it isn't testing.
|
||||||
|
//
|
||||||
|
// Be aware of what that costs: this runs as an auto-fixture before EVERY test, so no
|
||||||
|
// test starts from production's config unless it explicitly turns a toggle back on
|
||||||
|
// (02-upload/rate-limit, 07-adversarial/ddos, 01-auth/rate-limit-nat, …). That blind
|
||||||
|
// spot is exactly why an entire class of per-IP limiter bugs went unnoticed: the
|
||||||
|
// limiters were simply off. When adding a limiter or quota, add a spec that enables it.
|
||||||
|
//
|
||||||
|
// Kept in sync by hand because pulling SQL out of the migration files at runtime is
|
||||||
|
// fragile — if you add a config key in a migration, add it here too.
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
r#"INSERT INTO config (key, value) VALUES
|
r#"INSERT INTO config (key, value) VALUES
|
||||||
('max_image_size_mb', '20'),
|
('max_image_size_mb', '20'),
|
||||||
('max_video_size_mb', '500'),
|
('max_video_size_mb', '500'),
|
||||||
('upload_rate_per_hour', '10'),
|
('upload_rate_per_hour', '100'),
|
||||||
('feed_rate_per_min', '60'),
|
('feed_rate_per_min', '60'),
|
||||||
('export_rate_per_day', '3'),
|
('export_rate_per_day', '3'),
|
||||||
|
('join_ip_rate_per_min', '60'),
|
||||||
|
('recover_ip_rate_per_min', '30'),
|
||||||
|
('social_rate_per_min', '120'),
|
||||||
('quota_tolerance', '0.75'),
|
('quota_tolerance', '0.75'),
|
||||||
('estimated_guest_count', '100'),
|
('estimated_guest_count', '100'),
|
||||||
('compression_concurrency', '2'),
|
('compression_concurrency', '2'),
|
||||||
@@ -57,6 +72,8 @@ pub async fn truncate_all(
|
|||||||
('feed_rate_enabled', 'false'),
|
('feed_rate_enabled', 'false'),
|
||||||
('export_rate_enabled', 'false'),
|
('export_rate_enabled', 'false'),
|
||||||
('join_rate_enabled', 'false'),
|
('join_rate_enabled', 'false'),
|
||||||
|
('social_rate_enabled', 'false'),
|
||||||
|
('admin_login_rate_enabled', 'false'),
|
||||||
('quota_enabled', 'false'),
|
('quota_enabled', 'false'),
|
||||||
('storage_quota_enabled', 'false'),
|
('storage_quota_enabled', 'false'),
|
||||||
('upload_count_quota_enabled', 'false'),
|
('upload_count_quota_enabled', 'false'),
|
||||||
@@ -85,6 +102,26 @@ pub async fn truncate_all(
|
|||||||
// could serve the previous test's toggles.
|
// could serve the previous test's toggles.
|
||||||
state.config_cache.invalidate();
|
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)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use axum::Json;
|
||||||
use axum::extract::{Multipart, Path, State};
|
use axum::extract::{Multipart, Path, State};
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use axum::Json;
|
use chrono::{DateTime, Utc};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -46,7 +47,8 @@ pub async fn upload(
|
|||||||
let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
|
let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
|
||||||
let upload_rate_on = config::get_bool(&state.config_cache, "upload_rate_enabled", true).await;
|
let upload_rate_on = config::get_bool(&state.config_cache, "upload_rate_enabled", true).await;
|
||||||
if rate_limits_on && upload_rate_on {
|
if rate_limits_on && upload_rate_on {
|
||||||
let upload_rate = config::get_i64(&state.config_cache, "upload_rate_per_hour", 10).await as usize;
|
let upload_rate =
|
||||||
|
config::get_i64(&state.config_cache, "upload_rate_per_hour", 100).await as usize;
|
||||||
if let Err(retry_after_secs) = state.rate_limiter.check_with_retry(
|
if let Err(retry_after_secs) = state.rate_limiter.check_with_retry(
|
||||||
format!("upload:{}", auth.user_id),
|
format!("upload:{}", auth.user_id),
|
||||||
upload_rate,
|
upload_rate,
|
||||||
@@ -75,14 +77,19 @@ pub async fn upload(
|
|||||||
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
|
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
|
||||||
if event.uploads_locked_at.is_some() {
|
if event.uploads_locked_at.is_some() {
|
||||||
drain_multipart(multipart).await;
|
drain_multipart(multipart).await;
|
||||||
return Err(AppError::Forbidden("Uploads sind gesperrt.".into()));
|
// Reversible: a host can reopen the event, so the client keeps the queued blob and
|
||||||
|
// retries on `event-opened` rather than purging it (UploadsLocked, not Forbidden).
|
||||||
|
return Err(AppError::UploadsLocked("Uploads sind gesperrt.".into()));
|
||||||
}
|
}
|
||||||
// Belt-and-suspenders on top of the lock (release ⇒ lock): once the gallery is
|
// Belt-and-suspenders on top of the lock (release ⇒ lock): once the gallery is
|
||||||
// released the export has been snapshotted, so a late upload could never make it into
|
// released the export has been snapshotted, so a late upload could never make it into
|
||||||
// the keepsake. Reject it explicitly rather than silently diverging the live feed.
|
// the keepsake. Reject it explicitly rather than silently diverging the live feed.
|
||||||
|
// Also reversible (reopen clears `export_released_at`), so likewise UploadsLocked.
|
||||||
if event.export_released_at.is_some() {
|
if event.export_released_at.is_some() {
|
||||||
drain_multipart(multipart).await;
|
drain_multipart(multipart).await;
|
||||||
return Err(AppError::Forbidden("Galerie wurde bereits freigegeben.".into()));
|
return Err(AppError::UploadsLocked(
|
||||||
|
"Galerie wurde bereits freigegeben.".into(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read config limits from DB
|
// Read config limits from DB
|
||||||
@@ -95,7 +102,10 @@ pub async fn upload(
|
|||||||
// On success the temp file is renamed into place under its detected extension.
|
// On success the temp file is renamed into place under its detected extension.
|
||||||
let upload_id = Uuid::new_v4();
|
let upload_id = Uuid::new_v4();
|
||||||
let event_slug = &state.config.event_slug;
|
let event_slug = &state.config.event_slug;
|
||||||
let originals_dir = state.config.media_path.join(format!("originals/{event_slug}"));
|
let originals_dir = state
|
||||||
|
.config
|
||||||
|
.media_path
|
||||||
|
.join(format!("originals/{event_slug}"));
|
||||||
let temp_abs = originals_dir.join(format!("{upload_id}.tmp"));
|
let temp_abs = originals_dir.join(format!("{upload_id}.tmp"));
|
||||||
|
|
||||||
let mut streamed: Option<(i64, Vec<u8>)> = None; // (size, head bytes for sniffing)
|
let mut streamed: Option<(i64, Vec<u8>)> = None; // (size, head bytes for sniffing)
|
||||||
@@ -133,12 +143,20 @@ pub async fn upload(
|
|||||||
streamed = Some(stream_field_to_file(field, &temp_abs, cap_bytes).await?);
|
streamed = Some(stream_field_to_file(field, &temp_abs, cap_bytes).await?);
|
||||||
}
|
}
|
||||||
"caption" => {
|
"caption" => {
|
||||||
caption =
|
caption = Some(
|
||||||
Some(field.text().await.map_err(|e| AppError::BadRequest(e.to_string()))?);
|
field
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::BadRequest(e.to_string()))?,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
"hashtags" => {
|
"hashtags" => {
|
||||||
hashtags_csv =
|
hashtags_csv = Some(
|
||||||
Some(field.text().await.map_err(|e| AppError::BadRequest(e.to_string()))?);
|
field
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::BadRequest(e.to_string()))?,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
@@ -162,14 +180,14 @@ pub async fn upload(
|
|||||||
// Validate caption length. Counted in chars (code points) to match the
|
// Validate caption length. Counted in chars (code points) to match the
|
||||||
// "Zeichen" wording in the error message — `.len()` would be bytes and
|
// "Zeichen" wording in the error message — `.len()` would be bytes and
|
||||||
// reject perfectly valid German/emoji captions early.
|
// reject perfectly valid German/emoji captions early.
|
||||||
if let Some(ref cap) = caption {
|
if let Some(ref cap) = caption
|
||||||
if cap.chars().count() > MAX_CAPTION_LENGTH {
|
&& cap.chars().count() > MAX_CAPTION_LENGTH
|
||||||
let _ = tokio::fs::remove_file(&temp_abs).await;
|
{
|
||||||
return Err(AppError::BadRequest(format!(
|
let _ = tokio::fs::remove_file(&temp_abs).await;
|
||||||
"Beschreibung ist zu lang. Maximum: {} Zeichen.",
|
return Err(AppError::BadRequest(format!(
|
||||||
MAX_CAPTION_LENGTH
|
"Beschreibung ist zu lang. Maximum: {} Zeichen.",
|
||||||
)));
|
MAX_CAPTION_LENGTH
|
||||||
}
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine the file type from its magic bytes and require it to be on the
|
// Determine the file type from its magic bytes and require it to be on the
|
||||||
@@ -215,11 +233,32 @@ pub async fn upload(
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Images only: refuse anything the compression worker could never decode, reading just
|
||||||
|
// the header. Without this the upload is accepted with a 201 and then silently
|
||||||
|
// soft-deleted minutes later when the worker gives up — the guest sees the photo
|
||||||
|
// vanish with, at best, a vague "could not be processed". Rejecting here gives them a
|
||||||
|
// reason at the door that they can act on, and it uses the SAME budget the worker
|
||||||
|
// enforces, so admission and processing cannot disagree.
|
||||||
|
if mime.starts_with("image/") && crate::services::imaging::exceeds_decode_budget(&temp_abs) {
|
||||||
|
let mp = crate::services::imaging::megapixels(&temp_abs);
|
||||||
|
tracing::info!(
|
||||||
|
%mime, megapixels = ?mp,
|
||||||
|
"rejecting an image that exceeds the decode budget at admission"
|
||||||
|
);
|
||||||
|
let _ = tokio::fs::remove_file(&temp_abs).await;
|
||||||
|
let detail = mp.map_or(String::new(), |mp| format!(" (ca. {mp:.0} Megapixel)"));
|
||||||
|
return Err(AppError::BadRequest(format!(
|
||||||
|
"Bild hat zu viele Bildpunkte{detail} und kann nicht verarbeitet werden. \
|
||||||
|
Bitte verkleinere es und lade es erneut hoch."
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
// Per-user storage quota — dynamic formula based on available disk space and the
|
// Per-user storage quota — dynamic formula based on available disk space and the
|
||||||
// number of active uploaders. Gated by master + per-area toggles so the admin can
|
// number of active uploaders. Gated by master + per-area toggles so the admin can
|
||||||
// disable it on trusted instances.
|
// disable it on trusted instances.
|
||||||
let quota_on = config::get_bool(&state.config_cache, "quota_enabled", true).await;
|
let quota_on = config::get_bool(&state.config_cache, "quota_enabled", true).await;
|
||||||
let storage_quota_on = config::get_bool(&state.config_cache, "storage_quota_enabled", true).await;
|
let storage_quota_on =
|
||||||
|
config::get_bool(&state.config_cache, "storage_quota_enabled", true).await;
|
||||||
// When quota is enforced, this holds the byte ceiling so the increment UPDATE below can
|
// When quota is enforced, this holds the byte ceiling so the increment UPDATE below can
|
||||||
// enforce it atomically (`WHERE total + size <= limit`). Without that guard, two
|
// enforce it atomically (`WHERE total + size <= limit`). Without that guard, two
|
||||||
// concurrent uploads from the same user (e.g. phone + laptop) both pass this stale
|
// concurrent uploads from the same user (e.g. phone + laptop) both pass this stale
|
||||||
@@ -269,6 +308,36 @@ pub async fn upload(
|
|||||||
// bytes with no row to reclaim them (silent quota erosion / spurious lockout).
|
// bytes with no row to reclaim them (silent quota erosion / spurious lockout).
|
||||||
let tx_result: Result<Upload, AppError> = async {
|
let tx_result: Result<Upload, AppError> = async {
|
||||||
let mut tx = state.pool.begin().await?;
|
let mut tx = state.pool.begin().await?;
|
||||||
|
|
||||||
|
// RE-CHECK THE LOCK, UNDER A ROW LOCK, INSIDE THE COMMIT TX.
|
||||||
|
//
|
||||||
|
// The pre-flight check at the top of this handler ran BEFORE we streamed the body — which
|
||||||
|
// for a 500 MB video is minutes. Trusting it here is a TOCTOU that silently loses photos
|
||||||
|
// from the keepsake, and it is the real cause of the "stale keepsake" bug that survived
|
||||||
|
// three rounds of fixes inside the export state machine:
|
||||||
|
//
|
||||||
|
// 1. guest starts a big upload; the lock check passes (event open)
|
||||||
|
// 2. host releases the gallery → uploads lock, export workers snapshot the uploads table
|
||||||
|
// 3. this upload commits AFTER that snapshot → it shows up in the live feed but is
|
||||||
|
// MISSING from the downloaded keepsake, permanently (nothing ever regenerates it)
|
||||||
|
//
|
||||||
|
// `FOR SHARE` conflicts with the `UPDATE event` in `release_gallery`, which serializes us
|
||||||
|
// against it. Either we take the lock first — and release (hence the export snapshot) is
|
||||||
|
// strictly ordered after our commit, so the snapshot CONTAINS this upload — or release
|
||||||
|
// commits first and we observe the lock here and reject. Either way the keepsake is
|
||||||
|
// complete. `UploadsLocked` (not Forbidden) is reversible: the client keeps the blob and
|
||||||
|
// resumes it when the host reopens.
|
||||||
|
let (locked_at, released_at): (Option<DateTime<Utc>>, Option<DateTime<Utc>>) =
|
||||||
|
sqlx::query_as(
|
||||||
|
"SELECT uploads_locked_at, export_released_at FROM event WHERE id = $1 FOR SHARE",
|
||||||
|
)
|
||||||
|
.bind(auth.event_id)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
if locked_at.is_some() || released_at.is_some() {
|
||||||
|
return Err(AppError::UploadsLocked("Uploads sind gesperrt.".into()));
|
||||||
|
}
|
||||||
|
|
||||||
// Increment the user's byte total. When a quota is in force, guard it atomically
|
// Increment the user's byte total. When a quota is in force, guard it atomically
|
||||||
// (`total + size <= limit`) so two concurrent uploads can't both slip past the
|
// (`total + size <= limit`) so two concurrent uploads can't both slip past the
|
||||||
// stale pre-check — the loser's UPDATE matches 0 rows and we abort with the same
|
// stale pre-check — the loser's UPDATE matches 0 rows and we abort with the same
|
||||||
@@ -285,11 +354,13 @@ pub async fn upload(
|
|||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await?
|
.await?
|
||||||
} else {
|
} else {
|
||||||
sqlx::query("UPDATE \"user\" SET total_upload_bytes = total_upload_bytes + $2 WHERE id = $1")
|
sqlx::query(
|
||||||
.bind(auth.user_id)
|
"UPDATE \"user\" SET total_upload_bytes = total_upload_bytes + $2 WHERE id = $1",
|
||||||
.bind(size)
|
)
|
||||||
.execute(&mut *tx)
|
.bind(auth.user_id)
|
||||||
.await?
|
.bind(size)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?
|
||||||
};
|
};
|
||||||
if inc.rows_affected() == 0 {
|
if inc.rows_affected() == 0 {
|
||||||
return Err(AppError::QuotaExceeded(
|
return Err(AppError::QuotaExceeded(
|
||||||
@@ -380,6 +451,15 @@ pub async fn edit_upload(
|
|||||||
|
|
||||||
// Caption update + hashtag wipe-then-relink in one transaction, so a crash
|
// Caption update + hashtag wipe-then-relink in one transaction, so a crash
|
||||||
// mid-relink can't leave the upload with its hashtags stripped.
|
// mid-relink can't leave the upload with its hashtags stripped.
|
||||||
|
//
|
||||||
|
// Editing is intentionally allowed while uploads are locked or the gallery is released — like
|
||||||
|
// comments and likes, the lock freezes *new uploads* only (USER_JOURNEYS §9.3). But a caption
|
||||||
|
// is embedded in the HTML viewer keepsake (the ZIP holds media only — see export.rs), so an
|
||||||
|
// edit AFTER release must regenerate the viewer, or the downloadable keepsake keeps showing the
|
||||||
|
// old caption forever while the live feed shows the new one. Same atomicity as delete_upload:
|
||||||
|
// the edit and its invalidation share one tx so a dropped handler can't leave them disagreeing.
|
||||||
|
// `Affects::ViewerOnly` carries the finished ZIP forward (the media didn't change); when the
|
||||||
|
// gallery isn't released, `invalidate_and_arm` returns None and this is a no-op.
|
||||||
let mut tx = state.pool.begin().await?;
|
let mut tx = state.pool.begin().await?;
|
||||||
if let Some(ref caption) = body.caption {
|
if let Some(ref caption) = body.caption {
|
||||||
Upload::update_caption(&mut *tx, upload_id, Some(caption)).await?;
|
Upload::update_caption(&mut *tx, upload_id, Some(caption)).await?;
|
||||||
@@ -391,7 +471,16 @@ pub async fn edit_upload(
|
|||||||
Hashtag::link_to_upload(&mut *tx, upload_id, h.id).await?;
|
Hashtag::link_to_upload(&mut *tx, upload_id, h.id).await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let regen = crate::services::export::invalidate_and_arm(
|
||||||
|
&mut tx,
|
||||||
|
&state.config.event_slug,
|
||||||
|
crate::services::export::Affects::ViewerOnly,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
tx.commit().await?;
|
tx.commit().await?;
|
||||||
|
if let Some(r) = regen {
|
||||||
|
crate::handlers::host::start_regen(&state, r);
|
||||||
|
}
|
||||||
|
|
||||||
Ok(StatusCode::OK)
|
Ok(StatusCode::OK)
|
||||||
}
|
}
|
||||||
@@ -413,7 +502,20 @@ pub async fn delete_upload(
|
|||||||
return Err(AppError::Forbidden("Nur eigene Uploads löschen.".into()));
|
return Err(AppError::Forbidden("Nur eigene Uploads löschen.".into()));
|
||||||
}
|
}
|
||||||
|
|
||||||
Upload::soft_delete_in_event(&state.pool, upload_id, auth.event_id).await?;
|
// Atomic with the keepsake invalidation: a guest removing their own photo must have it removed
|
||||||
|
// from the downloadable archive too, and a half-applied delete would leave it there forever.
|
||||||
|
let mut tx = state.pool.begin().await?;
|
||||||
|
Upload::soft_delete_in_event(&mut tx, upload_id, auth.event_id).await?;
|
||||||
|
let regen = crate::services::export::invalidate_and_arm(
|
||||||
|
&mut tx,
|
||||||
|
&state.config.event_slug,
|
||||||
|
crate::services::export::Affects::Both,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
if let Some(r) = regen {
|
||||||
|
crate::handlers::host::start_regen(&state, r);
|
||||||
|
}
|
||||||
|
|
||||||
// Evict the card live on every other feed + the projector diashow — otherwise
|
// Evict the card live on every other feed + the projector diashow — otherwise
|
||||||
// a self-deleted post lingers until each viewer manually reloads. Same event
|
// a self-deleted post lingers until each viewer manually reloads. Same event
|
||||||
@@ -506,6 +608,9 @@ pub struct QuotaEstimate {
|
|||||||
pub limit_bytes: Option<i64>,
|
pub limit_bytes: Option<i64>,
|
||||||
pub active_uploaders: i64,
|
pub active_uploaders: i64,
|
||||||
pub free_disk_bytes: i64,
|
pub free_disk_bytes: i64,
|
||||||
|
/// The tolerance factor the limit above was computed with. Carried on the snapshot so the
|
||||||
|
/// number is self-describing; no caller reads it back today.
|
||||||
|
#[allow(dead_code)]
|
||||||
pub tolerance: f64,
|
pub tolerance: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -522,15 +627,15 @@ fn quota_limit_bytes(free_disk: i64, tolerance: f64, active_uploaders: i64) -> i
|
|||||||
/// check (upload handler) or hide the UI (quota endpoint).
|
/// check (upload handler) or hide the UI (quota endpoint).
|
||||||
pub async fn compute_storage_quota(state: &AppState) -> QuotaEstimate {
|
pub async fn compute_storage_quota(state: &AppState) -> QuotaEstimate {
|
||||||
let quota_on = config::get_bool(&state.config_cache, "quota_enabled", true).await;
|
let quota_on = config::get_bool(&state.config_cache, "quota_enabled", true).await;
|
||||||
let storage_quota_on = config::get_bool(&state.config_cache, "storage_quota_enabled", true).await;
|
let storage_quota_on =
|
||||||
|
config::get_bool(&state.config_cache, "storage_quota_enabled", true).await;
|
||||||
let tolerance = config::get_f64(&state.config_cache, "quota_tolerance", 0.75).await;
|
let tolerance = config::get_f64(&state.config_cache, "quota_tolerance", 0.75).await;
|
||||||
|
|
||||||
let (active_count,): (i64,) = sqlx::query_as(
|
let (active_count,): (i64,) =
|
||||||
"SELECT COUNT(DISTINCT user_id) FROM upload WHERE deleted_at IS NULL",
|
sqlx::query_as("SELECT COUNT(DISTINCT user_id) FROM upload WHERE deleted_at IS NULL")
|
||||||
)
|
.fetch_one(&state.pool)
|
||||||
.fetch_one(&state.pool)
|
.await
|
||||||
.await
|
.unwrap_or((0,));
|
||||||
.unwrap_or((0,));
|
|
||||||
let active = active_count.max(1);
|
let active = active_count.max(1);
|
||||||
|
|
||||||
// Cached disk reading. `None` means we couldn't resolve the media filesystem.
|
// Cached disk reading. `None` means we couldn't resolve the media filesystem.
|
||||||
@@ -562,43 +667,156 @@ pub async fn compute_storage_quota(state: &AppState) -> QuotaEstimate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Outcome of parsing a `Range` request header against a known file length.
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
enum RangeSpec {
|
||||||
|
/// No `Range` header, or one we deliberately don't honour (multi-range, non-`bytes`
|
||||||
|
/// unit, malformed). RFC 9110 lets a server ignore a Range it can't process and reply
|
||||||
|
/// 200 with the full body, which is what every one of these cases does.
|
||||||
|
Full,
|
||||||
|
/// A single satisfiable range, resolved to inclusive absolute offsets.
|
||||||
|
Partial { start: u64, end: u64 },
|
||||||
|
/// Syntactically valid but starts beyond EOF — must be answered 416, not 200, or a
|
||||||
|
/// player can loop re-requesting it.
|
||||||
|
Unsatisfiable,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a single-range `bytes=` header against `len`.
|
||||||
|
///
|
||||||
|
/// Deliberately supports only the three forms a media element actually sends —
|
||||||
|
/// `bytes=N-`, `bytes=N-M`, `bytes=-S` (suffix) — and treats everything else as `Full`.
|
||||||
|
/// Multi-range responses need `multipart/byteranges`, which no `<video>` requires.
|
||||||
|
fn parse_range(header: Option<&str>, len: u64) -> RangeSpec {
|
||||||
|
let Some(raw) = header else {
|
||||||
|
return RangeSpec::Full;
|
||||||
|
};
|
||||||
|
let Some(spec) = raw.trim().strip_prefix("bytes=") else {
|
||||||
|
return RangeSpec::Full;
|
||||||
|
};
|
||||||
|
// Multi-range → fall back to the whole body rather than lie about the content.
|
||||||
|
if spec.contains(',') {
|
||||||
|
return RangeSpec::Full;
|
||||||
|
}
|
||||||
|
let Some((from, to)) = spec.split_once('-') else {
|
||||||
|
return RangeSpec::Full;
|
||||||
|
};
|
||||||
|
let (from, to) = (from.trim(), to.trim());
|
||||||
|
|
||||||
|
// A zero-length file can satisfy no range at all.
|
||||||
|
if len == 0 {
|
||||||
|
return if from.is_empty() && to.is_empty() {
|
||||||
|
RangeSpec::Full
|
||||||
|
} else {
|
||||||
|
RangeSpec::Unsatisfiable
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let (start, end) = if from.is_empty() {
|
||||||
|
// Suffix form: the last `to` bytes.
|
||||||
|
let Ok(suffix) = to.parse::<u64>() else {
|
||||||
|
return RangeSpec::Full;
|
||||||
|
};
|
||||||
|
if suffix == 0 {
|
||||||
|
return RangeSpec::Unsatisfiable;
|
||||||
|
}
|
||||||
|
(len.saturating_sub(suffix), len - 1)
|
||||||
|
} else {
|
||||||
|
let Ok(start) = from.parse::<u64>() else {
|
||||||
|
return RangeSpec::Full;
|
||||||
|
};
|
||||||
|
let end = if to.is_empty() {
|
||||||
|
len - 1
|
||||||
|
} else {
|
||||||
|
match to.parse::<u64>() {
|
||||||
|
// An end past EOF is clamped, not rejected (RFC 9110 §14.1.1).
|
||||||
|
Ok(end) => end.min(len - 1),
|
||||||
|
Err(_) => return RangeSpec::Full,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
(start, end)
|
||||||
|
};
|
||||||
|
|
||||||
|
if start >= len || start > end {
|
||||||
|
RangeSpec::Unsatisfiable
|
||||||
|
} else {
|
||||||
|
RangeSpec::Partial { start, end }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Stream a media file from disk into an HTTP response with a fixed set of security
|
/// Stream a media file from disk into an HTTP response with a fixed set of security
|
||||||
/// headers. Every media response (original, preview, thumbnail) goes through here so
|
/// headers. Every media response (original, preview, display, thumbnail) goes through here
|
||||||
/// they consistently carry `X-Content-Type-Options: nosniff` (defense-in-depth against
|
/// so they consistently carry `X-Content-Type-Options: nosniff` (defense-in-depth against
|
||||||
/// content-type confusion, even if the edge proxy is bypassed) plus an explicit
|
/// content-type confusion, even if the edge proxy is bypassed) plus an explicit
|
||||||
/// `Content-Disposition` and `Cache-Control`.
|
/// `Content-Disposition` and `Cache-Control`.
|
||||||
|
///
|
||||||
|
/// Honours a single `Range`. This is not an optimisation: iOS Safari opens every `<video>`
|
||||||
|
/// with a `Range: bytes=0-1` probe and abandons the load unless it gets a `206` with a
|
||||||
|
/// `Content-Range`. Without this, video is unplayable on the app's primary platform no
|
||||||
|
/// matter what `src` the element is given. `Accept-Ranges: bytes` is advertised on every
|
||||||
|
/// response so clients know seeking is available before they ask.
|
||||||
async fn stream_media_file(
|
async fn stream_media_file(
|
||||||
|
req_headers: &axum::http::HeaderMap,
|
||||||
absolute: &std::path::Path,
|
absolute: &std::path::Path,
|
||||||
content_type: String,
|
content_type: String,
|
||||||
disposition: &str,
|
disposition: &str,
|
||||||
cache_control: &str,
|
cache_control: &str,
|
||||||
) -> Result<axum::response::Response, AppError> {
|
) -> Result<axum::response::Response, AppError> {
|
||||||
use axum::body::Body;
|
use axum::body::Body;
|
||||||
use axum::http::{header, Response, StatusCode};
|
use axum::http::{Response, StatusCode, header};
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
||||||
use tokio_util::io::ReaderStream;
|
use tokio_util::io::ReaderStream;
|
||||||
|
|
||||||
if !absolute.exists() {
|
if !absolute.exists() {
|
||||||
return Err(AppError::NotFound("Datei nicht gefunden.".into()));
|
return Err(AppError::NotFound("Datei nicht gefunden.".into()));
|
||||||
}
|
}
|
||||||
|
|
||||||
let file = tokio::fs::File::open(absolute)
|
let mut file = tokio::fs::File::open(absolute)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::Internal(e.into()))?;
|
.map_err(|e| AppError::Internal(e.into()))?;
|
||||||
let metadata = file
|
let len = file
|
||||||
.metadata()
|
.metadata()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::Internal(e.into()))?;
|
.map_err(|e| AppError::Internal(e.into()))?
|
||||||
let stream = ReaderStream::new(file);
|
.len();
|
||||||
|
|
||||||
Response::builder()
|
let range = parse_range(
|
||||||
.status(StatusCode::OK)
|
req_headers.get(header::RANGE).and_then(|v| v.to_str().ok()),
|
||||||
.header(header::CONTENT_TYPE, content_type)
|
len,
|
||||||
.header(header::CONTENT_DISPOSITION, disposition)
|
);
|
||||||
.header(header::CONTENT_LENGTH, metadata.len())
|
|
||||||
.header(header::CACHE_CONTROL, cache_control)
|
let base = |status: StatusCode| {
|
||||||
.header(header::X_CONTENT_TYPE_OPTIONS, "nosniff")
|
Response::builder()
|
||||||
.body(Body::from_stream(stream))
|
.status(status)
|
||||||
.map_err(|e| AppError::Internal(e.into()))
|
.header(header::CONTENT_TYPE, content_type.clone())
|
||||||
|
.header(header::CONTENT_DISPOSITION, disposition)
|
||||||
|
.header(header::CACHE_CONTROL, cache_control)
|
||||||
|
.header(header::X_CONTENT_TYPE_OPTIONS, "nosniff")
|
||||||
|
.header(header::ACCEPT_RANGES, "bytes")
|
||||||
|
};
|
||||||
|
|
||||||
|
match range {
|
||||||
|
RangeSpec::Full => base(StatusCode::OK)
|
||||||
|
.header(header::CONTENT_LENGTH, len)
|
||||||
|
.body(Body::from_stream(ReaderStream::new(file)))
|
||||||
|
.map_err(|e| AppError::Internal(e.into())),
|
||||||
|
|
||||||
|
RangeSpec::Partial { start, end } => {
|
||||||
|
file.seek(std::io::SeekFrom::Start(start))
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(e.into()))?;
|
||||||
|
let span = end - start + 1;
|
||||||
|
base(StatusCode::PARTIAL_CONTENT)
|
||||||
|
.header(header::CONTENT_LENGTH, span)
|
||||||
|
.header(header::CONTENT_RANGE, format!("bytes {start}-{end}/{len}"))
|
||||||
|
.body(Body::from_stream(ReaderStream::new(file.take(span))))
|
||||||
|
.map_err(|e| AppError::Internal(e.into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
RangeSpec::Unsatisfiable => base(StatusCode::RANGE_NOT_SATISFIABLE)
|
||||||
|
.header(header::CONTENT_RANGE, format!("bytes */{len}"))
|
||||||
|
.body(Body::empty())
|
||||||
|
.map_err(|e| AppError::Internal(e.into())),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Streaming download of the original file behind an upload. Used by:
|
/// Streaming download of the original file behind an upload. Used by:
|
||||||
@@ -615,6 +833,7 @@ async fn stream_media_file(
|
|||||||
/// [`get_preview`] / [`get_thumbnail`]).
|
/// [`get_preview`] / [`get_thumbnail`]).
|
||||||
pub async fn get_original(
|
pub async fn get_original(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
Path(upload_id): Path<Uuid>,
|
Path(upload_id): Path<Uuid>,
|
||||||
) -> Result<axum::response::Response, AppError> {
|
) -> Result<axum::response::Response, AppError> {
|
||||||
let media = Upload::find_visible_media(&state.pool, upload_id)
|
let media = Upload::find_visible_media(&state.pool, upload_id)
|
||||||
@@ -626,10 +845,22 @@ pub async fn get_original(
|
|||||||
.file_name()
|
.file_name()
|
||||||
.and_then(|n| n.to_str())
|
.and_then(|n| n.to_str())
|
||||||
.unwrap_or("original");
|
.unwrap_or("original");
|
||||||
let disposition = format!("attachment; filename=\"{filename}\"");
|
// `inline`, not `attachment`. This route is the only source of playable video bytes
|
||||||
|
// (there is no video derivative), and an attachment disposition is hostile to a
|
||||||
|
// `<video>` element — Safari in particular. It also matches what the UI promises:
|
||||||
|
// the action is labelled "Original anzeigen", i.e. view, not download.
|
||||||
|
let disposition = format!("inline; filename=\"{filename}\"");
|
||||||
|
|
||||||
// Full-res original: force download, never cache at the edge.
|
// Full-res original: never cache at the edge, so a takedown revokes access promptly.
|
||||||
stream_media_file(&absolute, media.mime_type, &disposition, "no-store").await
|
// Range requests still work under no-store; the client simply re-fetches each range.
|
||||||
|
stream_media_file(
|
||||||
|
&headers,
|
||||||
|
&absolute,
|
||||||
|
media.mime_type,
|
||||||
|
&disposition,
|
||||||
|
"no-store",
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Streaming access to an upload's compressed **preview** image. Gated exactly like
|
/// Streaming access to an upload's compressed **preview** image. Gated exactly like
|
||||||
@@ -644,6 +875,7 @@ pub async fn get_original(
|
|||||||
/// `upload-deleted` / `user-hidden` SSE events, so this only bounds the raw-URL edge case.
|
/// `upload-deleted` / `user-hidden` SSE events, so this only bounds the raw-URL edge case.
|
||||||
pub async fn get_preview(
|
pub async fn get_preview(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
Path(upload_id): Path<Uuid>,
|
Path(upload_id): Path<Uuid>,
|
||||||
) -> Result<axum::response::Response, AppError> {
|
) -> Result<axum::response::Response, AppError> {
|
||||||
let media = Upload::find_visible_media(&state.pool, upload_id)
|
let media = Upload::find_visible_media(&state.pool, upload_id)
|
||||||
@@ -653,13 +885,47 @@ pub async fn get_preview(
|
|||||||
.preview_path
|
.preview_path
|
||||||
.ok_or_else(|| AppError::NotFound("Vorschau nicht verfügbar.".into()))?;
|
.ok_or_else(|| AppError::NotFound("Vorschau nicht verfügbar.".into()))?;
|
||||||
let absolute = state.config.media_path.join(&rel);
|
let absolute = state.config.media_path.join(&rel);
|
||||||
stream_media_file(&absolute, "image/jpeg".to_string(), "inline", "private, max-age=300").await
|
stream_media_file(
|
||||||
|
&headers,
|
||||||
|
&absolute,
|
||||||
|
"image/jpeg".to_string(),
|
||||||
|
"inline",
|
||||||
|
"private, max-age=300",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Streaming access to an upload's big-screen **display** derivative (~2048px), used by the
|
||||||
|
/// diashow. Gated identically to [`get_preview`]. 404s when the derivative doesn't exist yet
|
||||||
|
/// (still compressing, or an old upload the backfill hasn't reached) — the diashow then falls
|
||||||
|
/// back to the original.
|
||||||
|
pub async fn get_display(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
|
Path(upload_id): Path<Uuid>,
|
||||||
|
) -> Result<axum::response::Response, AppError> {
|
||||||
|
let media = Upload::find_visible_media(&state.pool, upload_id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
|
||||||
|
let rel = media
|
||||||
|
.display_path
|
||||||
|
.ok_or_else(|| AppError::NotFound("Anzeige nicht verfügbar.".into()))?;
|
||||||
|
let absolute = state.config.media_path.join(&rel);
|
||||||
|
stream_media_file(
|
||||||
|
&headers,
|
||||||
|
&absolute,
|
||||||
|
"image/jpeg".to_string(),
|
||||||
|
"inline",
|
||||||
|
"private, max-age=300",
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Streaming access to an upload's **thumbnail** (video poster). Gated identically to
|
/// Streaming access to an upload's **thumbnail** (video poster). Gated identically to
|
||||||
/// [`get_preview`].
|
/// [`get_preview`].
|
||||||
pub async fn get_thumbnail(
|
pub async fn get_thumbnail(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
Path(upload_id): Path<Uuid>,
|
Path(upload_id): Path<Uuid>,
|
||||||
) -> Result<axum::response::Response, AppError> {
|
) -> Result<axum::response::Response, AppError> {
|
||||||
let media = Upload::find_visible_media(&state.pool, upload_id)
|
let media = Upload::find_visible_media(&state.pool, upload_id)
|
||||||
@@ -669,12 +935,120 @@ pub async fn get_thumbnail(
|
|||||||
.thumbnail_path
|
.thumbnail_path
|
||||||
.ok_or_else(|| AppError::NotFound("Thumbnail nicht verfügbar.".into()))?;
|
.ok_or_else(|| AppError::NotFound("Thumbnail nicht verfügbar.".into()))?;
|
||||||
let absolute = state.config.media_path.join(&rel);
|
let absolute = state.config.media_path.join(&rel);
|
||||||
stream_media_file(&absolute, "image/jpeg".to_string(), "inline", "private, max-age=300").await
|
stream_media_file(
|
||||||
|
&headers,
|
||||||
|
&absolute,
|
||||||
|
"image/jpeg".to_string(),
|
||||||
|
"inline",
|
||||||
|
"private, max-age=300",
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::quota_limit_bytes;
|
use super::{RangeSpec, parse_range, quota_limit_bytes};
|
||||||
|
|
||||||
|
// `Range` handling exists because iOS Safari probes every `<video>` with
|
||||||
|
// `Range: bytes=0-1` and abandons the load without a 206. These pin the forms a
|
||||||
|
// media element actually sends, plus the edges that decide 206 vs 200 vs 416.
|
||||||
|
#[test]
|
||||||
|
fn no_range_header_is_a_full_response() {
|
||||||
|
assert_eq!(parse_range(None, 100), RangeSpec::Full);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn open_ended_range_runs_to_eof() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_range(Some("bytes=10-"), 100),
|
||||||
|
RangeSpec::Partial { start: 10, end: 99 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn closed_range_is_inclusive_on_both_ends() {
|
||||||
|
// The iOS probe. Two bytes, 0 and 1 — an exclusive end would return one.
|
||||||
|
assert_eq!(
|
||||||
|
parse_range(Some("bytes=0-1"), 100),
|
||||||
|
RangeSpec::Partial { start: 0, end: 1 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn suffix_range_returns_the_last_n_bytes() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_range(Some("bytes=-20"), 100),
|
||||||
|
RangeSpec::Partial { start: 80, end: 99 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn suffix_longer_than_the_file_clamps_to_the_whole_file() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_range(Some("bytes=-500"), 100),
|
||||||
|
RangeSpec::Partial { start: 0, end: 99 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn end_past_eof_is_clamped_not_rejected() {
|
||||||
|
// RFC 9110 §14.1.1 — players routinely ask for more than is there.
|
||||||
|
assert_eq!(
|
||||||
|
parse_range(Some("bytes=90-999"), 100),
|
||||||
|
RangeSpec::Partial { start: 90, end: 99 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn start_past_eof_is_416_not_a_full_body() {
|
||||||
|
// Answering 200 here makes a player re-request forever.
|
||||||
|
assert_eq!(
|
||||||
|
parse_range(Some("bytes=100-"), 100),
|
||||||
|
RangeSpec::Unsatisfiable
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_range(Some("bytes=200-300"), 100),
|
||||||
|
RangeSpec::Unsatisfiable
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inverted_range_is_unsatisfiable() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_range(Some("bytes=50-10"), 100),
|
||||||
|
RangeSpec::Unsatisfiable
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unsupported_or_malformed_forms_fall_back_to_the_full_body() {
|
||||||
|
// Ignoring a Range we can't process and sending 200 is explicitly allowed, and
|
||||||
|
// safer than guessing. Multi-range would need multipart/byteranges, which no
|
||||||
|
// <video> asks for.
|
||||||
|
for header in [
|
||||||
|
"bytes=0-10,20-30", // multi-range
|
||||||
|
"items=0-10", // non-bytes unit
|
||||||
|
"bytes=abc-def", // garbage
|
||||||
|
"bytes=", // empty spec
|
||||||
|
"nonsense", // no unit at all
|
||||||
|
] {
|
||||||
|
assert_eq!(parse_range(Some(header), 100), RangeSpec::Full, "{header}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn last_byte_is_reachable() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_range(Some("bytes=99-99"), 100),
|
||||||
|
RangeSpec::Partial { start: 99, end: 99 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_file_satisfies_no_range() {
|
||||||
|
assert_eq!(parse_range(Some("bytes=0-"), 0), RangeSpec::Unsatisfiable);
|
||||||
|
assert_eq!(parse_range(None, 0), RangeSpec::Full);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn divides_free_space_by_uploaders_with_tolerance() {
|
fn divides_free_space_by_uploaders_with_tolerance() {
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
use axum::Router;
|
||||||
use axum::extract::DefaultBodyLimit;
|
use axum::extract::DefaultBodyLimit;
|
||||||
use axum::routing::{delete, get, patch, post};
|
use axum::routing::{delete, get, patch, post};
|
||||||
use axum::Router;
|
|
||||||
use tower_http::services::ServeDir;
|
|
||||||
use tower_http::trace::TraceLayer;
|
use tower_http::trace::TraceLayer;
|
||||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
|
||||||
@@ -27,9 +26,10 @@ async fn main() -> Result<()> {
|
|||||||
dotenvy::dotenv().ok();
|
dotenvy::dotenv().ok();
|
||||||
|
|
||||||
tracing_subscriber::registry()
|
tracing_subscriber::registry()
|
||||||
.with(tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
|
.with(
|
||||||
"eventsnap_backend=debug,tower_http=debug".into()
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
}))
|
.unwrap_or_else(|_| "eventsnap_backend=debug,tower_http=debug".into()),
|
||||||
|
)
|
||||||
.with(tracing_subscriber::fmt::layer())
|
.with(tracing_subscriber::fmt::layer())
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
@@ -44,6 +44,13 @@ async fn main() -> Result<()> {
|
|||||||
|
|
||||||
let state = AppState::new(pool.clone(), config.clone());
|
let state = AppState::new(pool.clone(), config.clone());
|
||||||
|
|
||||||
|
// Regenerate image derivatives an older pipeline produced: the big-screen display for
|
||||||
|
// uploads processed before it existed (v0.17.x), and anything predating the current
|
||||||
|
// DERIVATIVES_REV (rev 1 applies the EXIF orientation, without which every portrait
|
||||||
|
// phone photo is stored sideways). Fire-and-forget behind the compression semaphore;
|
||||||
|
// originals are never touched, so a failure just retries on the next start.
|
||||||
|
state.compression.backfill_stale_derivatives().await;
|
||||||
|
|
||||||
// Re-spawn exports for events that were released but whose keepsake never finished
|
// 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
|
// (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
|
// rather than inside `startup_recovery`. Fire-and-forget: the workers run in the
|
||||||
@@ -52,6 +59,7 @@ async fn main() -> Result<()> {
|
|||||||
pool.clone(),
|
pool.clone(),
|
||||||
config.media_path.clone(),
|
config.media_path.clone(),
|
||||||
config.export_path.clone(),
|
config.export_path.clone(),
|
||||||
|
config.comments_enabled,
|
||||||
state.sse_tx.clone(),
|
state.sse_tx.clone(),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -62,6 +70,7 @@ async fn main() -> Result<()> {
|
|||||||
pool,
|
pool,
|
||||||
state.rate_limiter.clone(),
|
state.rate_limiter.clone(),
|
||||||
state.sse_tickets.clone(),
|
state.sse_tickets.clone(),
|
||||||
|
config.media_path.clone(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Ensure media directories exist
|
// Ensure media directories exist
|
||||||
@@ -73,7 +82,10 @@ async fn main() -> Result<()> {
|
|||||||
.route("/api/v1/join", post(auth::handlers::join))
|
.route("/api/v1/join", post(auth::handlers::join))
|
||||||
.route("/api/v1/recover", post(auth::handlers::recover))
|
.route("/api/v1/recover", post(auth::handlers::recover))
|
||||||
// Forgotten-PIN escape hatch: ask a host to reset it (unauthenticated, throttled).
|
// 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/recover/request",
|
||||||
|
post(auth::handlers::request_pin_reset),
|
||||||
|
)
|
||||||
.route("/api/v1/admin/login", post(auth::handlers::admin_login))
|
.route("/api/v1/admin/login", post(auth::handlers::admin_login))
|
||||||
.route("/api/v1/session", delete(auth::handlers::logout))
|
.route("/api/v1/session", delete(auth::handlers::logout))
|
||||||
// "Sign out everywhere" — revoke all of the caller's sessions.
|
// "Sign out everywhere" — revoke all of the caller's sessions.
|
||||||
@@ -83,8 +95,10 @@ async fn main() -> Result<()> {
|
|||||||
// layer just stops a multi-GB body from being buffered into memory before that
|
// 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
|
// 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.
|
// overhead — if an admin raises max_video_size_mb above this, bump MAX_UPLOAD_BYTES.
|
||||||
.route("/api/v1/upload", post(handlers::upload::upload)
|
.route(
|
||||||
.route_layer(DefaultBodyLimit::max(MAX_UPLOAD_BYTES)))
|
"/api/v1/upload",
|
||||||
|
post(handlers::upload::upload).route_layer(DefaultBodyLimit::max(MAX_UPLOAD_BYTES)),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/v1/upload/{id}",
|
"/api/v1/upload/{id}",
|
||||||
patch(handlers::upload::edit_upload).delete(handlers::upload::delete_upload),
|
patch(handlers::upload::edit_upload).delete(handlers::upload::delete_upload),
|
||||||
@@ -100,6 +114,10 @@ async fn main() -> Result<()> {
|
|||||||
"/api/v1/upload/{id}/preview",
|
"/api/v1/upload/{id}/preview",
|
||||||
get(handlers::upload::get_preview),
|
get(handlers::upload::get_preview),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/upload/{id}/display",
|
||||||
|
get(handlers::upload::get_display),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/v1/upload/{id}/thumbnail",
|
"/api/v1/upload/{id}/thumbnail",
|
||||||
get(handlers::upload::get_thumbnail),
|
get(handlers::upload::get_thumbnail),
|
||||||
@@ -112,24 +130,51 @@ async fn main() -> Result<()> {
|
|||||||
.route("/api/v1/feed/delta", get(handlers::feed::feed_delta))
|
.route("/api/v1/feed/delta", get(handlers::feed::feed_delta))
|
||||||
.route("/api/v1/hashtags", get(handlers::feed::hashtags))
|
.route("/api/v1/hashtags", get(handlers::feed::hashtags))
|
||||||
// Social
|
// Social
|
||||||
.route("/api/v1/upload/{id}/like", post(handlers::social::toggle_like))
|
.route(
|
||||||
|
"/api/v1/upload/{id}/like",
|
||||||
|
post(handlers::social::toggle_like),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/v1/upload/{id}/comments",
|
"/api/v1/upload/{id}/comments",
|
||||||
get(handlers::social::list_comments).post(handlers::social::add_comment),
|
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
|
// SSE
|
||||||
.route("/api/v1/stream", get(handlers::sse::stream))
|
.route("/api/v1/stream", get(handlers::sse::stream))
|
||||||
.route("/api/v1/stream/ticket", post(handlers::sse::issue_ticket))
|
.route("/api/v1/stream/ticket", post(handlers::sse::issue_ticket))
|
||||||
// Host Dashboard
|
// Host Dashboard
|
||||||
.route("/api/v1/host/event", get(handlers::host::get_event_status))
|
.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/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", get(handlers::host::list_users))
|
||||||
.route("/api/v1/host/users/{id}/ban", post(handlers::host::ban_user))
|
.route(
|
||||||
.route("/api/v1/host/users/{id}/unban", post(handlers::host::unban_user))
|
"/api/v1/host/users/{id}/ban",
|
||||||
.route("/api/v1/host/users/{id}/role", patch(handlers::host::set_role))
|
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(
|
.route(
|
||||||
"/api/v1/host/users/{id}/pin-reset",
|
"/api/v1/host/users/{id}/pin-reset",
|
||||||
post(handlers::host::reset_user_pin),
|
post(handlers::host::reset_user_pin),
|
||||||
@@ -142,11 +187,20 @@ async fn main() -> Result<()> {
|
|||||||
"/api/v1/host/pin-reset-requests/{id}",
|
"/api/v1/host/pin-reset-requests/{id}",
|
||||||
delete(handlers::host::dismiss_pin_reset_request),
|
delete(handlers::host::dismiss_pin_reset_request),
|
||||||
)
|
)
|
||||||
.route("/api/v1/host/upload/{id}", delete(handlers::host::host_delete_upload))
|
.route(
|
||||||
.route("/api/v1/host/comment/{id}", delete(handlers::host::host_delete_comment))
|
"/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)
|
// Export (all authenticated users)
|
||||||
.route("/api/v1/export/status", get(handlers::admin::export_status))
|
.route("/api/v1/export/status", get(handlers::admin::export_status))
|
||||||
.route("/api/v1/export/ticket", post(handlers::admin::export_ticket))
|
.route(
|
||||||
|
"/api/v1/export/ticket",
|
||||||
|
post(handlers::admin::export_ticket),
|
||||||
|
)
|
||||||
.route("/api/v1/export/zip", get(handlers::admin::download_zip))
|
.route("/api/v1/export/zip", get(handlers::admin::download_zip))
|
||||||
.route("/api/v1/export/html", get(handlers::admin::download_html))
|
.route("/api/v1/export/html", get(handlers::admin::download_html))
|
||||||
// Admin Dashboard
|
// Admin Dashboard
|
||||||
@@ -155,7 +209,10 @@ async fn main() -> Result<()> {
|
|||||||
"/api/v1/admin/config",
|
"/api/v1/admin/config",
|
||||||
get(handlers::admin::get_config).patch(handlers::admin::patch_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
|
// Test-only route: a hard reset for the Playwright E2E harness. The handler
|
||||||
// is compiled in always, but the route is only attached when
|
// is compiled in always, but the route is only attached when
|
||||||
@@ -174,41 +231,39 @@ async fn main() -> Result<()> {
|
|||||||
api
|
api
|
||||||
};
|
};
|
||||||
|
|
||||||
// Serve media files from disk
|
// NOTE: media is deliberately NOT served over HTTP.
|
||||||
let media_service = ServeDir::new(&config.media_path);
|
//
|
||||||
|
// Files live under `media_path` so the compression worker and the export job can read
|
||||||
|
// them off disk, but nothing may pull them straight from `/media/**` — that bypasses
|
||||||
|
// the visibility checks (soft-delete + ban-hide) that make a host takedown stick.
|
||||||
|
// Every legitimate fetch goes through `/api/v1/upload/{id}/{original,preview,display,
|
||||||
|
// thumbnail}`, which filter via `find_visible_media`; those are the only media URLs the
|
||||||
|
// backend ever emits (see `handlers::feed`).
|
||||||
|
//
|
||||||
|
// This used to be a `ServeDir` on `/media` with four `nest_service` blockers on the
|
||||||
|
// subtrees above it. That was bypassable: axum routes on the RAW path while `ServeDir`
|
||||||
|
// percent-decodes afterwards, so `/media/%70reviews/{id}.jpg` missed every blocker,
|
||||||
|
// fell through to the `ServeDir`, and was decoded back to `previews/` on disk — serving
|
||||||
|
// a taken-down photo to anyone, unauthenticated. Any single escaped byte worked, in all
|
||||||
|
// four subtrees. Deleting the route removes the vector outright rather than racing the
|
||||||
|
// decoder; `/media/**` now 404s regardless of encoding.
|
||||||
let router = Router::new()
|
let router = Router::new()
|
||||||
.route("/health", get(|| async { "ok" }))
|
.route("/health", get(|| async { "ok" }))
|
||||||
.merge(api)
|
.merge(api)
|
||||||
// Block direct HTTP access to ALL media subtrees. The files live under
|
|
||||||
// `media_path` (so the compression worker and export can read them off disk) but
|
|
||||||
// must NOT be pullable straight from `/media/**` — that bypasses the visibility
|
|
||||||
// checks (soft-delete + ban-hide) in the gated handlers. Every legitimate fetch
|
|
||||||
// goes through `/api/v1/upload/{id}/{original,preview,thumbnail}`, which filter
|
|
||||||
// via `find_visible_media`. The more specific nests take precedence over the
|
|
||||||
// `/media` ServeDir below (which, with all three subtrees blocked, now serves
|
|
||||||
// nothing — kept as a backstop).
|
|
||||||
.nest_service(
|
|
||||||
"/media/originals",
|
|
||||||
get(|| async { axum::http::StatusCode::NOT_FOUND }),
|
|
||||||
)
|
|
||||||
.nest_service(
|
|
||||||
"/media/previews",
|
|
||||||
get(|| async { axum::http::StatusCode::NOT_FOUND }),
|
|
||||||
)
|
|
||||||
.nest_service(
|
|
||||||
"/media/thumbnails",
|
|
||||||
get(|| async { axum::http::StatusCode::NOT_FOUND }),
|
|
||||||
)
|
|
||||||
.nest_service("/media", media_service)
|
|
||||||
.layer(TraceLayer::new_for_http())
|
.layer(TraceLayer::new_for_http())
|
||||||
.with_state(state);
|
.with_state(state);
|
||||||
|
|
||||||
let listener = tokio::net::TcpListener::bind(("0.0.0.0", config.app_port)).await?;
|
let listener = tokio::net::TcpListener::bind(("0.0.0.0", config.app_port)).await?;
|
||||||
tracing::info!("listening on {}", listener.local_addr()?);
|
tracing::info!("listening on {}", listener.local_addr()?);
|
||||||
axum::serve(listener, router)
|
// `into_make_service_with_connect_info` is required by the pre-auth handlers, which
|
||||||
.with_graceful_shutdown(shutdown_signal())
|
// extract `ConnectInfo<SocketAddr>` to use the peer address as the rate-limit key when
|
||||||
.await?;
|
// X-Forwarded-For is absent. Without it those extractors fail at runtime.
|
||||||
|
axum::serve(
|
||||||
|
listener,
|
||||||
|
router.into_make_service_with_connect_info::<std::net::SocketAddr>(),
|
||||||
|
)
|
||||||
|
.with_graceful_shutdown(shutdown_signal())
|
||||||
|
.await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ use serde::Serialize;
|
|||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
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)]
|
#[derive(Debug, sqlx::FromRow)]
|
||||||
pub struct Comment {
|
pub struct Comment {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
@@ -79,26 +83,18 @@ impl Comment {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_by_id(pool: &PgPool, id: Uuid) -> Result<Option<Self>, sqlx::Error> {
|
pub async fn find_by_id(pool: &PgPool, id: Uuid) -> Result<Option<Self>, sqlx::Error> {
|
||||||
sqlx::query_as::<_, Self>(
|
sqlx::query_as::<_, Self>("SELECT * FROM comment WHERE id = $1 AND deleted_at IS NULL")
|
||||||
"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")
|
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.execute(pool)
|
.fetch_optional(pool)
|
||||||
.await?;
|
.await
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Event-scoped variant of [`Self::soft_delete`]. Returns `false` if the
|
/// Event-scoped soft delete. Returns `false` if the comment doesn't exist or belongs to a
|
||||||
/// comment doesn't exist or belongs to a different event.
|
/// 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(
|
pub async fn soft_delete_in_event(
|
||||||
pool: &PgPool,
|
conn: &mut sqlx::PgConnection,
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
event_id: Uuid,
|
event_id: Uuid,
|
||||||
) -> Result<bool, sqlx::Error> {
|
) -> Result<bool, sqlx::Error> {
|
||||||
@@ -111,7 +107,7 @@ impl Comment {
|
|||||||
)
|
)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.bind(event_id)
|
.bind(event_id)
|
||||||
.execute(pool)
|
.execute(conn)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(result.rows_affected() > 0)
|
Ok(result.rows_affected() > 0)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ use chrono::{DateTime, Utc};
|
|||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
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)]
|
#[derive(Debug, sqlx::FromRow)]
|
||||||
pub struct Event {
|
pub struct Event {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
@@ -11,8 +16,11 @@ pub struct Event {
|
|||||||
pub is_active: bool,
|
pub is_active: bool,
|
||||||
pub uploads_locked_at: Option<DateTime<Utc>>,
|
pub uploads_locked_at: Option<DateTime<Utc>>,
|
||||||
pub export_released_at: Option<DateTime<Utc>>,
|
pub export_released_at: Option<DateTime<Utc>>,
|
||||||
pub export_zip_ready: bool,
|
/// Monotonic generation counter for the keepsake. Bumped in the SAME UPDATE as any change to
|
||||||
pub export_html_ready: bool,
|
/// `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>,
|
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> {
|
pub async fn create(pool: &PgPool, slug: &str, name: &str) -> Result<Self, sqlx::Error> {
|
||||||
sqlx::query_as::<_, Self>(
|
sqlx::query_as::<_, Self>("INSERT INTO event (slug, name) VALUES ($1, $2) RETURNING *")
|
||||||
"INSERT INTO event (slug, name) VALUES ($1, $2) RETURNING *",
|
.bind(slug)
|
||||||
)
|
.bind(name)
|
||||||
.bind(slug)
|
.fetch_one(pool)
|
||||||
.bind(name)
|
.await
|
||||||
.fetch_one(pool)
|
|
||||||
.await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_or_create(
|
pub async fn find_or_create(
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
use sqlx::PgPool;
|
|
||||||
use uuid::Uuid;
|
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)]
|
#[derive(Debug, sqlx::FromRow)]
|
||||||
pub struct Hashtag {
|
pub struct Hashtag {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
@@ -61,22 +63,6 @@ impl Hashtag {
|
|||||||
.await?;
|
.await?;
|
||||||
Ok(())
|
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
|
/// Extract `#hashtags` from text (caption or body). Tags are restricted to
|
||||||
@@ -136,14 +122,20 @@ mod tests {
|
|||||||
assert_eq!(extract_hashtags(&format!("#{ok}")), vec![ok.clone()]);
|
assert_eq!(extract_hashtags(&format!("#{ok}")), vec![ok.clone()]);
|
||||||
// 41+ chars → dropped entirely (not truncated).
|
// 41+ chars → dropped entirely (not truncated).
|
||||||
let too_long = "a".repeat(41);
|
let too_long = "a".repeat(41);
|
||||||
assert_eq!(extract_hashtags(&format!("#{too_long}")), Vec::<String>::new());
|
assert_eq!(
|
||||||
|
extract_hashtags(&format!("#{too_long}")),
|
||||||
|
Vec::<String>::new()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn duplicate_tags_are_returned_verbatim_not_deduplicated() {
|
fn duplicate_tags_are_returned_verbatim_not_deduplicated() {
|
||||||
// Dedup is the DB's job (Hashtag::upsert ON CONFLICT); extraction returns each
|
// 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.
|
// occurrence so callers can count/link them independently. Case folds to lower.
|
||||||
assert_eq!(extract_hashtags("#fun #Fun #fun!"), vec!["fun", "fun", "fun"]);
|
assert_eq!(
|
||||||
|
extract_hashtags("#fun #Fun #fun!"),
|
||||||
|
vec!["fun", "fun", "fun"]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ use chrono::{DateTime, Utc};
|
|||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
// Row shape for `session`, populated by sqlx from `RETURNING *`. Session validation is done in SQL
|
||||||
|
// (expiry/last-seen predicates), so no field is read in Rust — the struct is the row's shape.
|
||||||
|
#[allow(dead_code)]
|
||||||
#[derive(Debug, sqlx::FromRow)]
|
#[derive(Debug, sqlx::FromRow)]
|
||||||
pub struct Session {
|
pub struct Session {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
@@ -87,10 +90,7 @@ impl Session {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_by_token_hash(
|
pub async fn delete_by_token_hash(pool: &PgPool, token_hash: &str) -> Result<(), sqlx::Error> {
|
||||||
pool: &PgPool,
|
|
||||||
token_hash: &str,
|
|
||||||
) -> Result<(), sqlx::Error> {
|
|
||||||
sqlx::query("DELETE FROM session WHERE token_hash = $1")
|
sqlx::query("DELETE FROM session WHERE token_hash = $1")
|
||||||
.bind(token_hash)
|
.bind(token_hash)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ use serde::Serialize;
|
|||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
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)]
|
#[derive(Debug, sqlx::FromRow)]
|
||||||
pub struct Upload {
|
pub struct Upload {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
@@ -42,6 +46,7 @@ pub struct VisibleMedia {
|
|||||||
pub original_path: String,
|
pub original_path: String,
|
||||||
pub preview_path: Option<String>,
|
pub preview_path: Option<String>,
|
||||||
pub thumbnail_path: Option<String>,
|
pub thumbnail_path: Option<String>,
|
||||||
|
pub display_path: Option<String>,
|
||||||
pub mime_type: String,
|
pub mime_type: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,15 +80,6 @@ impl Upload {
|
|||||||
.await
|
.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",
|
|
||||||
)
|
|
||||||
.bind(id)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Lean lookup for the public media aliases (`get_original`/`get_preview`/
|
/// Lean lookup for the public media aliases (`get_original`/`get_preview`/
|
||||||
/// `get_thumbnail`): returns ONLY the file paths + mime for a visible upload —
|
/// `get_thumbnail`): returns ONLY the file paths + mime for a visible upload —
|
||||||
/// excluding soft-deleted rows, hidden owners (`uploads_hidden`), and banned owners
|
/// excluding soft-deleted rows, hidden owners (`uploads_hidden`), and banned owners
|
||||||
@@ -98,7 +94,7 @@ impl Upload {
|
|||||||
id: Uuid,
|
id: Uuid,
|
||||||
) -> Result<Option<VisibleMedia>, sqlx::Error> {
|
) -> Result<Option<VisibleMedia>, sqlx::Error> {
|
||||||
sqlx::query_as::<_, VisibleMedia>(
|
sqlx::query_as::<_, VisibleMedia>(
|
||||||
"SELECT up.original_path, up.preview_path, up.thumbnail_path, up.mime_type
|
"SELECT up.original_path, up.preview_path, up.thumbnail_path, up.display_path, up.mime_type
|
||||||
FROM upload up
|
FROM upload up
|
||||||
JOIN \"user\" u ON u.id = up.user_id
|
JOIN \"user\" u ON u.id = up.user_id
|
||||||
WHERE up.id = $1 AND up.deleted_at IS NULL
|
WHERE up.id = $1 AND up.deleted_at IS NULL
|
||||||
@@ -139,6 +135,30 @@ impl Upload {
|
|||||||
Ok(())
|
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(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stamp which revision of the derivative pipeline produced this row's preview/display,
|
||||||
|
/// so the startup backfill can find rows generated by an older one exactly once.
|
||||||
|
pub async fn set_derivatives_rev(pool: &PgPool, id: Uuid, rev: i16) -> Result<(), sqlx::Error> {
|
||||||
|
sqlx::query("UPDATE upload SET derivatives_rev = $2 WHERE id = $1")
|
||||||
|
.bind(id)
|
||||||
|
.bind(rev)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn set_thumbnail_path(
|
pub async fn set_thumbnail_path(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
@@ -188,12 +208,17 @@ impl Upload {
|
|||||||
/// Event-scoped variant of [`Self::soft_delete`]. Returns `false` 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
|
/// matched (already deleted, wrong event, or unknown id) so host handlers
|
||||||
/// can return a clean 404 instead of silently no-op'ing.
|
/// can return a clean 404 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(
|
pub async fn soft_delete_in_event(
|
||||||
pool: &PgPool,
|
conn: &mut sqlx::PgConnection,
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
event_id: Uuid,
|
event_id: Uuid,
|
||||||
) -> Result<bool, sqlx::Error> {
|
) -> Result<bool, sqlx::Error> {
|
||||||
let mut tx = pool.begin().await?;
|
let tx = conn;
|
||||||
let row: Option<(Uuid, i64)> = sqlx::query_as(
|
let row: Option<(Uuid, i64)> = sqlx::query_as(
|
||||||
"UPDATE upload
|
"UPDATE upload
|
||||||
SET deleted_at = NOW()
|
SET deleted_at = NOW()
|
||||||
@@ -218,7 +243,6 @@ impl Upload {
|
|||||||
} else {
|
} else {
|
||||||
false
|
false
|
||||||
};
|
};
|
||||||
tx.commit().await?;
|
|
||||||
Ok(deleted)
|
Ok(deleted)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)]
|
#[derive(Debug, sqlx::FromRow)]
|
||||||
pub struct User {
|
pub struct User {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
@@ -105,14 +109,16 @@ impl User {
|
|||||||
Ok(row.0)
|
Ok(row.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn lock_pin(pool: &PgPool, id: Uuid, until: DateTime<Utc>) -> Result<(), sqlx::Error> {
|
pub async fn lock_pin(
|
||||||
sqlx::query(
|
pool: &PgPool,
|
||||||
"UPDATE \"user\" SET pin_locked_until = $2 WHERE id = $1",
|
id: Uuid,
|
||||||
)
|
until: DateTime<Utc>,
|
||||||
.bind(id)
|
) -> Result<(), sqlx::Error> {
|
||||||
.bind(until)
|
sqlx::query("UPDATE \"user\" SET pin_locked_until = $2 WHERE id = $1")
|
||||||
.execute(pool)
|
.bind(id)
|
||||||
.await?;
|
.bind(until)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use tokio::sync::{broadcast, Semaphore};
|
use tokio::sync::{Semaphore, broadcast};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::models::upload::Upload;
|
use crate::models::upload::Upload;
|
||||||
@@ -15,24 +16,96 @@ pub struct CompressionWorker {
|
|||||||
pool: PgPool,
|
pool: PgPool,
|
||||||
media_path: PathBuf,
|
media_path: PathBuf,
|
||||||
sse_tx: broadcast::Sender<SseEvent>,
|
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 {
|
impl CompressionWorker {
|
||||||
pub fn new(pool: PgPool, media_path: PathBuf, concurrency: usize, sse_tx: broadcast::Sender<SseEvent>) -> Self {
|
pub fn new(
|
||||||
|
pool: PgPool,
|
||||||
|
media_path: PathBuf,
|
||||||
|
concurrency: usize,
|
||||||
|
sse_tx: broadcast::Sender<SseEvent>,
|
||||||
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
semaphore: Arc::new(Semaphore::new(concurrency)),
|
semaphore: Arc::new(Semaphore::new(concurrency)),
|
||||||
pool,
|
pool,
|
||||||
media_path,
|
media_path,
|
||||||
sse_tx,
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many times `do_process` is attempted before an upload is given up on. The
|
||||||
|
/// give-up path is user-visible (the photo disappears), so transient infrastructure
|
||||||
|
/// errors must not reach it.
|
||||||
|
const MAX_PROCESS_ATTEMPTS: u32 = 3;
|
||||||
|
|
||||||
|
/// Revision of the image-derivative pipeline. Bump this whenever a change makes existing
|
||||||
|
/// previews/displays wrong, so `backfill_stale_derivatives` regenerates them once on the
|
||||||
|
/// next start. Rev 1 = EXIF orientation is applied.
|
||||||
|
const DERIVATIVES_REV: i16 = 1;
|
||||||
|
|
||||||
/// Spawn a background task to process an uploaded file.
|
/// Spawn a background task to process an uploaded file.
|
||||||
pub fn process(&self, upload_id: Uuid, original_path: String, mime_type: String) {
|
pub fn process(&self, upload_id: Uuid, original_path: String, mime_type: String) {
|
||||||
let worker = self.clone();
|
let worker = self.clone();
|
||||||
|
let born_at = worker.generation.load(Ordering::SeqCst);
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let _permit = worker.semaphore.acquire().await;
|
let _permit = worker.semaphore.acquire().await;
|
||||||
match worker.do_process(upload_id, &original_path, &mime_type).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;
|
||||||
|
}
|
||||||
|
// Retry before giving up. Most failures here are transient and self-clearing —
|
||||||
|
// an ENOSPC spike while several guests upload at once, a momentary DB-pool
|
||||||
|
// exhaustion, a panic inside the image codec — and the give-up path is
|
||||||
|
// user-visible data loss, so it is worth a few seconds to avoid entering it.
|
||||||
|
//
|
||||||
|
// But only for failures that CAN clear. An image that exceeds the decode budget,
|
||||||
|
// is corrupt, or is in an unsupported format fails identically on every attempt,
|
||||||
|
// so retrying it just burns 2s + 4s of backoff and writes three near-identical
|
||||||
|
// warnings before reaching the same conclusion. Give up on those immediately.
|
||||||
|
let mut attempt = 1u32;
|
||||||
|
let outcome = loop {
|
||||||
|
match worker
|
||||||
|
.do_process(upload_id, &original_path, &mime_type)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(v) => break Ok(v),
|
||||||
|
Err(e)
|
||||||
|
if attempt < Self::MAX_PROCESS_ATTEMPTS
|
||||||
|
&& !crate::services::imaging::is_permanent_image_error(&e) =>
|
||||||
|
{
|
||||||
|
tracing::warn!(
|
||||||
|
error = ?e, %upload_id, attempt,
|
||||||
|
"compression attempt failed; retrying"
|
||||||
|
);
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(2u64.pow(attempt))).await;
|
||||||
|
attempt += 1;
|
||||||
|
// The data may have been reset while we slept (e2e TRUNCATE).
|
||||||
|
if worker.generation.load(Ordering::SeqCst) != born_at {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => break Err(e),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match outcome {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
tracing::info!("compression completed for upload {upload_id}");
|
tracing::info!("compression completed for upload {upload_id}");
|
||||||
let _ = worker.sse_tx.send(SseEvent {
|
let _ = worker.sse_tx.send(SseEvent {
|
||||||
@@ -41,24 +114,35 @@ impl CompressionWorker {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("compression failed for upload {upload_id}: {e:#}");
|
tracing::error!(
|
||||||
// Auto-cleanup: a failed transcode would otherwise leave a
|
"compression failed for upload {upload_id} after {attempt} attempt(s): {e:#}"
|
||||||
// permanently broken feed card, silently charge the uploader's
|
);
|
||||||
// quota, and orphan the original on disk. Refund + soft-delete
|
// Refund + soft-delete (one tx, so v_feed excludes it) so a failed
|
||||||
// (one tx, so v_feed excludes it), remove the orphan file, then
|
// transcode doesn't leave a permanently broken feed card or silently
|
||||||
// tell the uploader (upload-error toast) and evict the card
|
// charge the uploader's quota. Then tell the uploader (upload-error
|
||||||
// everywhere (upload-deleted, already handled by the feed).
|
// toast) and evict the card everywhere (upload-deleted).
|
||||||
|
//
|
||||||
|
// The ORIGINAL IS DELIBERATELY KEPT. This path used to `remove_file` it
|
||||||
|
// unconditionally, which meant any transient error — a disk-full blip
|
||||||
|
// while saving a derivative, a pool hiccup, a panic in the image codec —
|
||||||
|
// irreversibly destroyed the guest's only copy of a photo they can never
|
||||||
|
// retake. The row is only soft-deleted, so keeping the bytes makes the
|
||||||
|
// upload fully recoverable; the file is orphaned rather than lost, and
|
||||||
|
// the path is logged so it can be found. `backfill_stale_derivatives`
|
||||||
|
// already refuses to destroy data on error for exactly this reason.
|
||||||
let _ = Upload::set_compression_status(&worker.pool, upload_id, "failed").await;
|
let _ = Upload::set_compression_status(&worker.pool, upload_id, "failed").await;
|
||||||
if let Err(del) = Upload::soft_delete(&worker.pool, upload_id).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");
|
tracing::warn!(error = ?del, %upload_id, "failed to soft-delete after compression failure");
|
||||||
}
|
}
|
||||||
let orphan = worker.media_path.join(&original_path);
|
tracing::warn!(
|
||||||
if let Err(rm) = tokio::fs::remove_file(&orphan).await {
|
%upload_id,
|
||||||
tracing::warn!(error = ?rm, path = %orphan.display(), "failed to remove orphaned original");
|
path = %worker.media_path.join(&original_path).display(),
|
||||||
}
|
"original retained for recovery after compression failure"
|
||||||
|
);
|
||||||
let _ = worker.sse_tx.send(SseEvent {
|
let _ = worker.sse_tx.send(SseEvent {
|
||||||
event_type: "upload-error".to_string(),
|
event_type: "upload-error".to_string(),
|
||||||
data: serde_json::json!({ "upload_id": upload_id, "error": e.to_string() }).to_string(),
|
data: serde_json::json!({ "upload_id": upload_id, "error": e.to_string() })
|
||||||
|
.to_string(),
|
||||||
});
|
});
|
||||||
let _ = worker.sse_tx.send(SseEvent {
|
let _ = worker.sse_tx.send(SseEvent {
|
||||||
event_type: "upload-deleted".to_string(),
|
event_type: "upload-deleted".to_string(),
|
||||||
@@ -80,56 +164,97 @@ impl CompressionWorker {
|
|||||||
let original = self.media_path.join(original_path);
|
let original = self.media_path.join(original_path);
|
||||||
|
|
||||||
if mime_type.starts_with("image/") {
|
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?;
|
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?;
|
||||||
|
Upload::set_derivatives_rev(&self.pool, upload_id, Self::DERIVATIVES_REV).await?;
|
||||||
|
tracing::info!("preview + display generated for upload {upload_id}");
|
||||||
} else if mime_type.starts_with("video/") {
|
} else if mime_type.starts_with("video/") {
|
||||||
let thumb_rel = self.generate_video_thumbnail(upload_id, &original).await?;
|
// A missing poster must NOT fail the upload. `set_thumbnail_path` is only reached when
|
||||||
Upload::set_thumbnail_path(&self.pool, upload_id, &thumb_rel).await?;
|
// a file really exists, so `thumbnail_path` stays NULL otherwise — which every consumer
|
||||||
tracing::info!("thumbnail generated for upload {upload_id}");
|
// already handles (FeedListCard, VirtualFeed, LightboxModal are all null-safe).
|
||||||
|
//
|
||||||
|
// The `?` here used to hide the defect; making the check strict without also making
|
||||||
|
// this non-fatal would have been far worse than the bug. Every clip of a second or less
|
||||||
|
// would fail compression, exhaust its retries and be soft-deleted — a cosmetic defect
|
||||||
|
// turned into data loss, on exactly the mis-tap/Live-Photo clips guests produce most.
|
||||||
|
match self.generate_video_thumbnail(upload_id, &original).await? {
|
||||||
|
Some(thumb_rel) => {
|
||||||
|
Upload::set_thumbnail_path(&self.pool, upload_id, &thumb_rel).await?;
|
||||||
|
tracing::info!("thumbnail generated for upload {upload_id}");
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
tracing::warn!(
|
||||||
|
%upload_id,
|
||||||
|
"no poster frame could be extracted; the video keeps its own tile"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Upload::set_compression_status(&self.pool, upload_id, "done").await?;
|
Upload::set_compression_status(&self.pool, upload_id, "done").await?;
|
||||||
Ok(())
|
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,
|
&self,
|
||||||
upload_id: Uuid,
|
upload_id: Uuid,
|
||||||
original: &Path,
|
original: &Path,
|
||||||
mime_type: &str,
|
mime_type: &str,
|
||||||
) -> Result<String> {
|
) -> Result<(String, String)> {
|
||||||
let previews_dir = self.media_path.join("previews");
|
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(&previews_dir).await?;
|
||||||
|
tokio::fs::create_dir_all(&displays_dir).await?;
|
||||||
|
|
||||||
let preview_filename = format!("{upload_id}.jpg");
|
let filename = format!("{upload_id}.jpg");
|
||||||
let preview_path = previews_dir.join(&preview_filename);
|
let preview_path = previews_dir.join(&filename);
|
||||||
|
let display_path = displays_dir.join(&filename);
|
||||||
let original = original.to_path_buf();
|
let original = original.to_path_buf();
|
||||||
let preview_path_clone = preview_path.clone();
|
|
||||||
let mime_owned = mime_type.to_string();
|
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
|
// Run blocking image operations in a spawn_blocking task
|
||||||
tokio::task::spawn_blocking(move || -> Result<()> {
|
tokio::task::spawn_blocking(move || -> Result<()> {
|
||||||
// Reject decompression bombs *before* fully decoding: the upload body
|
// Decompression-bomb limits + EXIF orientation, both in one place — see
|
||||||
// cap bounds the file size on disk, but a small file can still decode to
|
// services::imaging for why neither may be skipped.
|
||||||
// enormous dimensions (e.g. a ~1 MB image expanding to 50k×50k px →
|
let img = crate::services::imaging::decode_oriented(&original)?;
|
||||||
// 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")?;
|
|
||||||
let mut limits = image::Limits::default();
|
|
||||||
limits.max_image_width = Some(12_000);
|
|
||||||
limits.max_image_height = Some(12_000);
|
|
||||||
limits.max_alloc = Some(256 * 1024 * 1024);
|
|
||||||
reader.limits(limits);
|
|
||||||
let img = reader.decode().context("failed to decode image")?;
|
|
||||||
|
|
||||||
// Resize to max 800px wide, preserving aspect ratio
|
// Preview: max 800px, preserving aspect ratio (data-saver feed).
|
||||||
let preview = img.resize(800, 800, image::imageops::FilterType::Lanczos3);
|
img.resize(
|
||||||
preview.save_with_format(&preview_path_clone, image::ImageFormat::Jpeg)
|
preview_max,
|
||||||
.context("failed to save preview")?;
|
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 the original is PNG, try lossless compression in-place
|
||||||
if mime_owned == "image/png" {
|
if mime_owned == "image/png" {
|
||||||
@@ -148,69 +273,92 @@ impl CompressionWorker {
|
|||||||
})
|
})
|
||||||
.await??;
|
.await??;
|
||||||
|
|
||||||
Ok(format!("previews/{preview_filename}"))
|
Ok((
|
||||||
|
format!("previews/{filename}"),
|
||||||
|
format!("displays/{filename}"),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Regenerate image derivatives that an older pipeline produced. Fire-and-forget from
|
||||||
|
/// startup; picks up two cases, both of which leave the ORIGINAL untouched:
|
||||||
|
///
|
||||||
|
/// - uploads processed before the `display` derivative existed (preview but no
|
||||||
|
/// `display_path`), and
|
||||||
|
/// - uploads whose derivatives predate `DERIVATIVES_REV` — currently rev 1, which applies
|
||||||
|
/// the EXIF orientation. Everything generated before it is stored sideways for any
|
||||||
|
/// portrait phone photo.
|
||||||
|
///
|
||||||
|
/// Unlike the failure path in `process`, a backfill error is logged and skipped — it must
|
||||||
|
/// NEVER destroy or soft-delete an upload that already has a working preview.
|
||||||
|
pub async fn backfill_stale_derivatives(&self) {
|
||||||
|
let rows = sqlx::query_as::<_, (Uuid, String, String)>(
|
||||||
|
"SELECT id, original_path, mime_type FROM upload
|
||||||
|
WHERE deleted_at IS NULL AND mime_type LIKE 'image/%'
|
||||||
|
AND original_path IS NOT NULL
|
||||||
|
AND (
|
||||||
|
(display_path IS NULL AND preview_path IS NOT NULL)
|
||||||
|
OR derivatives_rev < $1
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.bind(Self::DERIVATIVES_REV)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await;
|
||||||
|
let rows = match rows {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = ?e, "derivative backfill query failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if rows.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tracing::info!("regenerating derivatives 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;
|
||||||
|
let _ =
|
||||||
|
Upload::set_derivatives_rev(&worker.pool, id, Self::DERIVATIVES_REV)
|
||||||
|
.await;
|
||||||
|
tracing::info!("derivatives regenerated for upload {id}");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
// Leave the existing derivatives and the original intact; this row is
|
||||||
|
// simply retried on the next start. The rev stays behind, which is the
|
||||||
|
// marker that it still needs doing.
|
||||||
|
tracing::warn!(error = ?e, %id, "derivative backfill failed; leaving as-is");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract the feed poster for a video. `Ok(None)` when the clip yields no frame — see
|
||||||
|
/// [`crate::services::video::extract_poster_frame`], which owns the seek order, the timeout and
|
||||||
|
/// the artifact check that this function used to be missing.
|
||||||
async fn generate_video_thumbnail(
|
async fn generate_video_thumbnail(
|
||||||
&self,
|
&self,
|
||||||
upload_id: Uuid,
|
upload_id: Uuid,
|
||||||
original: &Path,
|
original: &Path,
|
||||||
) -> Result<String> {
|
) -> Result<Option<String>> {
|
||||||
let thumbs_dir = self.media_path.join("thumbnails");
|
let thumbs_dir = self.media_path.join("thumbnails");
|
||||||
tokio::fs::create_dir_all(&thumbs_dir).await?;
|
tokio::fs::create_dir_all(&thumbs_dir).await?;
|
||||||
|
|
||||||
let thumb_filename = format!("{upload_id}.jpg");
|
let thumb_filename = format!("{upload_id}.jpg");
|
||||||
let thumb_path = thumbs_dir.join(&thumb_filename);
|
let thumb_path = thumbs_dir.join(&thumb_filename);
|
||||||
|
|
||||||
// Hard timeout — a malformed video can hang `ffmpeg` indefinitely. Without a
|
let produced =
|
||||||
// cap, the held compression-worker semaphore permit is never released and the
|
crate::services::video::extract_poster_frame(original, &thumb_path, 800).await?;
|
||||||
// pool eventually deadlocks (no further uploads ever processed). 120s is well
|
|
||||||
// above the time to extract one frame from any sane input.
|
|
||||||
let mut child = tokio::process::Command::new("ffmpeg")
|
|
||||||
.args([
|
|
||||||
"-i",
|
|
||||||
original.to_str().unwrap_or_default(),
|
|
||||||
"-vframes",
|
|
||||||
"1",
|
|
||||||
"-ss",
|
|
||||||
"00:00:01",
|
|
||||||
"-vf",
|
|
||||||
"scale=800:-1",
|
|
||||||
"-y",
|
|
||||||
thumb_path.to_str().unwrap_or_default(),
|
|
||||||
])
|
|
||||||
.stdout(std::process::Stdio::piped())
|
|
||||||
.stderr(std::process::Stdio::piped())
|
|
||||||
.kill_on_drop(true)
|
|
||||||
.spawn()
|
|
||||||
.context("failed to spawn ffmpeg")?;
|
|
||||||
|
|
||||||
let status = match tokio::time::timeout(
|
Ok(produced.then(|| format!("thumbnails/{thumb_filename}")))
|
||||||
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.
|
|
||||||
let mut stderr = Vec::new();
|
|
||||||
if let Some(mut handle) = child.stderr.take() {
|
|
||||||
use tokio::io::AsyncReadExt;
|
|
||||||
let _ = handle.read_to_end(&mut stderr).await;
|
|
||||||
}
|
|
||||||
anyhow::bail!(
|
|
||||||
"ffmpeg failed: {}",
|
|
||||||
String::from_utf8_lossy(&stderr)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(format!("thumbnails/{thumb_filename}"))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,17 +81,18 @@ impl ConfigCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Cache miss or stale — reload the entire table in one query.
|
// Cache miss or stale — reload the entire table in one query.
|
||||||
let rows: Vec<(String, String)> =
|
let rows: Vec<(String, String)> = match sqlx::query_as::<_, (String, String)>(
|
||||||
match sqlx::query_as::<_, (String, String)>("SELECT key, value FROM config")
|
"SELECT key, value FROM config",
|
||||||
.fetch_all(&self.pool)
|
)
|
||||||
.await
|
.fetch_all(&self.pool)
|
||||||
{
|
.await
|
||||||
Ok(rows) => rows,
|
{
|
||||||
Err(e) => {
|
Ok(rows) => rows,
|
||||||
tracing::warn!(error = ?e, "config reload failed; using defaults for this read");
|
Err(e) => {
|
||||||
return None;
|
tracing::warn!(error = ?e, "config reload failed; using defaults for this read");
|
||||||
}
|
return None;
|
||||||
};
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let values: HashMap<String, String> = rows.into_iter().collect();
|
let values: HashMap<String, String> = rows.into_iter().collect();
|
||||||
let result = values.get(key).cloned();
|
let result = values.get(key).cloned();
|
||||||
@@ -104,26 +105,43 @@ impl ConfigCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_str(cache: &ConfigCache, key: &str, default: &str) -> String {
|
pub async fn get_str(cache: &ConfigCache, key: &str, default: &str) -> String {
|
||||||
cache.get_raw(key).await.unwrap_or_else(|| default.to_string())
|
cache
|
||||||
|
.get_raw(key)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|| default.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_i64(cache: &ConfigCache, key: &str, default: i64) -> i64 {
|
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)
|
cache
|
||||||
|
.get_raw(key)
|
||||||
|
.await
|
||||||
|
.and_then(|v| v.parse().ok())
|
||||||
|
.unwrap_or(default)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_usize(cache: &ConfigCache, key: &str, default: usize) -> usize {
|
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)
|
cache
|
||||||
|
.get_raw(key)
|
||||||
|
.await
|
||||||
|
.and_then(|v| v.parse().ok())
|
||||||
|
.unwrap_or(default)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_f64(cache: &ConfigCache, key: &str, default: f64) -> f64 {
|
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)
|
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.
|
/// 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
|
/// Accepts `true/false`, `1/0`, `yes/no`, `on/off` — case-insensitive. Anything else
|
||||||
/// returns `default`.
|
/// returns `default`.
|
||||||
pub async fn get_bool(cache: &ConfigCache, key: &str, default: bool) -> bool {
|
pub async fn get_bool(cache: &ConfigCache, key: &str, default: bool) -> bool {
|
||||||
let Some(raw) = cache.get_raw(key).await else { return default };
|
let Some(raw) = cache.get_raw(key).await else {
|
||||||
|
return default;
|
||||||
|
};
|
||||||
match raw.trim().to_ascii_lowercase().as_str() {
|
match raw.trim().to_ascii_lowercase().as_str() {
|
||||||
"true" | "1" | "yes" | "on" => true,
|
"true" | "1" | "yes" | "on" => true,
|
||||||
"false" | "0" | "no" | "off" => false,
|
"false" | "0" | "no" | "off" => false,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
//! barely moves. [`DiskCache`] refreshes it at most once per [`TTL`] and serves the
|
//! barely moves. [`DiskCache`] refreshes it at most once per [`TTL`] and serves the
|
||||||
//! rest from memory.
|
//! rest from memory.
|
||||||
|
|
||||||
use std::path::Path;
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::{Arc, RwLock};
|
use std::sync::{Arc, RwLock};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@ pub struct DiskInfo {
|
|||||||
/// `AppState`.
|
/// `AppState`.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct DiskCache {
|
pub struct DiskCache {
|
||||||
inner: Arc<RwLock<Option<(DiskInfo, Instant)>>>,
|
inner: Arc<RwLock<Option<(PathBuf, DiskInfo, Instant)>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DiskCache {
|
impl DiskCache {
|
||||||
@@ -34,19 +34,38 @@ impl DiskCache {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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`.
|
/// 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
|
/// 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
|
/// "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.)
|
/// `None`: enforcing a 0-byte limit would lock every user out of uploading.)
|
||||||
pub fn snapshot(&self, media_path: &Path) -> Option<DiskInfo> {
|
/// Cached free-space reading for `path`.
|
||||||
if let Some((info, at)) = *self.inner.read().unwrap() {
|
///
|
||||||
if at.elapsed() < TTL {
|
/// The cache is keyed BY PATH. It used to hold a single slot and ignore its argument on a hit,
|
||||||
return Some(info);
|
/// 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(media_path)?;
|
let info = read_disk_for_path(path)?;
|
||||||
*self.inner.write().unwrap() = Some((info, Instant::now()));
|
*self.inner.write().unwrap() = Some((path.to_path_buf(), info, Instant::now()));
|
||||||
Some(info)
|
Some(info)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -57,6 +76,17 @@ impl Default for DiskCache {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// UNCACHED free-space reading for the filesystem backing `path`.
|
||||||
|
///
|
||||||
|
/// Deliberately bypasses [`DiskCache`]. The cache exists for the quota poll, where a 15s-stale
|
||||||
|
/// number is fine because it is only ever advisory. The export preflight is the opposite case: it
|
||||||
|
/// decides whether to start writing a multi-GB archive, and the sibling export worker running
|
||||||
|
/// concurrently can move free space by tens of gigabytes well inside the TTL. A stale reading there
|
||||||
|
/// would authorise exactly the write that fills the disk.
|
||||||
|
pub fn free_bytes(path: &Path) -> Option<u64> {
|
||||||
|
read_disk_for_path(path).map(|d| d.free)
|
||||||
|
}
|
||||||
|
|
||||||
/// Resolve the filesystem backing `media_path` and read its total/free bytes.
|
/// 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
|
/// Snapshots the mount table via `sysinfo`, then delegates the selection to the pure
|
||||||
@@ -104,20 +134,14 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn picks_longest_matching_mount() {
|
fn picks_longest_matching_mount() {
|
||||||
// Both "/" and "/media" prefix the path; the dedicated volume must win.
|
// Both "/" and "/media" prefix the path; the dedicated volume must win.
|
||||||
let mounts = vec![
|
let mounts = vec![("/".to_string(), 100, 40), ("/media".to_string(), 200, 150)];
|
||||||
("/".to_string(), 100, 40),
|
|
||||||
("/media".to_string(), 200, 150),
|
|
||||||
];
|
|
||||||
let d = select_disk(&mounts, "/media/originals/x.jpg").unwrap();
|
let d = select_disk(&mounts, "/media/originals/x.jpg").unwrap();
|
||||||
assert_eq!((d.total, d.free), (200, 150));
|
assert_eq!((d.total, d.free), (200, 150));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn falls_back_to_root_when_no_specific_mount_matches() {
|
fn falls_back_to_root_when_no_specific_mount_matches() {
|
||||||
let mounts = vec![
|
let mounts = vec![("/".to_string(), 100, 40), ("/media".to_string(), 200, 150)];
|
||||||
("/".to_string(), 100, 40),
|
|
||||||
("/media".to_string(), 200, 150),
|
|
||||||
];
|
|
||||||
// "/var/lib" is only prefixed by "/".
|
// "/var/lib" is only prefixed by "/".
|
||||||
let d = select_disk(&mounts, "/var/lib/data").unwrap();
|
let d = select_disk(&mounts, "/var/lib/data").unwrap();
|
||||||
assert_eq!((d.total, d.free), (100, 40));
|
assert_eq!((d.total, d.free), (100, 40));
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
263
backend/src/services/imaging.rs
Normal file
263
backend/src/services/imaging.rs
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
//! Shared image decoding.
|
||||||
|
//!
|
||||||
|
//! Exists so there is exactly ONE way to turn a file on disk into a `DynamicImage` in this
|
||||||
|
//! codebase. Two properties have to hold everywhere an image is decoded, and both were
|
||||||
|
//! previously re-derived per call site — which is how they drifted apart:
|
||||||
|
//!
|
||||||
|
//! - **EXIF orientation must be applied.** Phones do not rotate sensor data; they record how
|
||||||
|
//! the camera was held in a tag and store the pixels as shot. `image::open` and
|
||||||
|
//! `ImageReader::decode` both hand back the raw pixels and ignore that tag, and re-encoding
|
||||||
|
//! to JPEG writes no EXIF, so the derivative is permanently sideways while the untouched
|
||||||
|
//! original still renders upright. The compression worker was fixed; the export worker was
|
||||||
|
//! not, so every portrait photo came out sideways in the keepsake's HTML viewer.
|
||||||
|
//! - **Decode limits must be set.** The upload body cap bounds the file on disk, but a small
|
||||||
|
//! file can decode to enormous dimensions (a ~1 MB image expanding to 50k×50k px), OOM-ing
|
||||||
|
//! the box. `image::open` applies NO limits at all, so the export path was also decoding
|
||||||
|
//! arbitrary user-supplied images unbounded.
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use image::{DynamicImage, ImageDecoder};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// Bounds for any decode of user-supplied image data. The per-axis cap covers any real phone
|
||||||
|
/// photo; `max_alloc` bounds the decoded buffer — but only because `decode_oriented` reserves
|
||||||
|
/// against it explicitly, see there.
|
||||||
|
///
|
||||||
|
/// Sized against the deployment: the app container is capped at 1 GiB and the compression
|
||||||
|
/// worker runs `compression_concurrency` decodes at once (default 2), so 256 MiB per decode
|
||||||
|
/// leaves headroom for the resize buffers and the runtime.
|
||||||
|
fn decode_limits() -> image::Limits {
|
||||||
|
let mut limits = image::Limits::default();
|
||||||
|
limits.max_image_width = Some(12_000);
|
||||||
|
limits.max_image_height = Some(12_000);
|
||||||
|
limits.max_alloc = Some(256 * 1024 * 1024);
|
||||||
|
limits
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when re-running the exact same work on the exact same bytes cannot possibly
|
||||||
|
/// succeed, so retrying only burns wall-clock and log noise.
|
||||||
|
///
|
||||||
|
/// Deliberately narrow. Only the `ImageError` variants that are a property of the *input*
|
||||||
|
/// count: the file will not shrink, gain codec support, or un-corrupt itself between
|
||||||
|
/// attempts. `IoError` is excluded on purpose — an ENOSPC while writing a derivative, or
|
||||||
|
/// EMFILE under load, is exactly the transient case the retry exists for.
|
||||||
|
pub fn is_permanent_image_error(err: &anyhow::Error) -> bool {
|
||||||
|
err.chain().any(|cause| {
|
||||||
|
matches!(
|
||||||
|
cause.downcast_ref::<image::ImageError>(),
|
||||||
|
Some(
|
||||||
|
image::ImageError::Limits(_)
|
||||||
|
| image::ImageError::Unsupported(_)
|
||||||
|
| image::ImageError::Decoding(_)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a decoder for `path` with the budget enforced, WITHOUT reading any pixels.
|
||||||
|
///
|
||||||
|
/// Single source of truth for "may this image be decoded at all": both the upload
|
||||||
|
/// admission check and the compression worker go through here, so they cannot disagree
|
||||||
|
/// about what is acceptable.
|
||||||
|
fn decoder_within_budget(path: &Path) -> Result<impl image::ImageDecoder> {
|
||||||
|
let mut reader = image::ImageReader::open(path)
|
||||||
|
.context("failed to open image")?
|
||||||
|
.with_guessed_format()
|
||||||
|
.context("failed to read image header")?;
|
||||||
|
let mut limits = decode_limits();
|
||||||
|
reader.limits(limits.clone());
|
||||||
|
|
||||||
|
// We need `into_decoder` rather than `decode()` to read the EXIF orientation tag before
|
||||||
|
// the pixels are consumed. But the two are NOT equivalent on safety: `decode()` performs
|
||||||
|
//
|
||||||
|
// limits.reserve(decoder.total_bytes())?;
|
||||||
|
//
|
||||||
|
// between building the decoder and reading the image, and `into_decoder()` skips it (the
|
||||||
|
// crate's own FIXME concedes `from_decoder` doesn't compensate). Nothing else enforces
|
||||||
|
// `max_alloc` — the JPEG decoder's `set_limits` only checks support and dimensions — so
|
||||||
|
// without the line below the budget is inert and the ONLY bound is the per-axis cap. That
|
||||||
|
// leaves 12000x12000 decodable at 412 MiB, and two concurrent at 824 MiB against a 1 GiB
|
||||||
|
// container. Re-add it, exactly as `decode()` does.
|
||||||
|
let mut decoder = reader.into_decoder().context("failed to decode image")?;
|
||||||
|
limits
|
||||||
|
.reserve(decoder.total_bytes())
|
||||||
|
.context("image too large to decode within the memory budget")?;
|
||||||
|
decoder
|
||||||
|
.set_limits(limits)
|
||||||
|
.context("image too large to decode within the memory budget")?;
|
||||||
|
Ok(decoder)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Megapixels an image would decode to, or `None` if its header can't be read. Used only
|
||||||
|
/// to put a concrete number in the message the guest sees.
|
||||||
|
pub fn megapixels(path: &Path) -> Option<f64> {
|
||||||
|
let reader = image::ImageReader::open(path)
|
||||||
|
.ok()?
|
||||||
|
.with_guessed_format()
|
||||||
|
.ok()?;
|
||||||
|
let (w, h) = reader.into_dimensions().ok()?;
|
||||||
|
Some(f64::from(w) * f64::from(h) / 1_000_000.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when an image cannot be decoded specifically because it would exceed the memory
|
||||||
|
/// budget — read from the header, no pixels touched.
|
||||||
|
///
|
||||||
|
/// Called at upload admission so a guest who sends a 100 MP photo is told at the door, with
|
||||||
|
/// a reason they can act on, instead of the upload being accepted with a 201 and then
|
||||||
|
/// silently soft-deleted minutes later when the worker gives up on it.
|
||||||
|
///
|
||||||
|
/// Deliberately narrow: ONLY the budget. A corrupt, truncated or unsupported file also
|
||||||
|
/// fails to build a decoder, but rejecting those here would change a contract the
|
||||||
|
/// adversarial suite pins on purpose — acceptance follows the magic bytes, and a payload
|
||||||
|
/// with a valid JPEG header is accepted regardless of what follows it. Those go to the
|
||||||
|
/// compression worker as before, which handles them gracefully and (since the retry
|
||||||
|
/// classifier) no longer burns backoff on them.
|
||||||
|
pub fn exceeds_decode_budget(path: &Path) -> bool {
|
||||||
|
match decoder_within_budget(path) {
|
||||||
|
Ok(_) => false,
|
||||||
|
Err(e) => e.chain().any(|cause| {
|
||||||
|
matches!(
|
||||||
|
cause.downcast_ref::<image::ImageError>(),
|
||||||
|
Some(image::ImageError::Limits(_))
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode an image from disk with decompression-bomb limits applied and its EXIF
|
||||||
|
/// orientation baked into the pixels.
|
||||||
|
///
|
||||||
|
/// Blocking — call inside `spawn_blocking`.
|
||||||
|
pub fn decode_oriented(path: &Path) -> Result<DynamicImage> {
|
||||||
|
let mut decoder = decoder_within_budget(path)?;
|
||||||
|
|
||||||
|
// Cheap, and it happens BEFORE any pixels are read: an oversized image costs a header
|
||||||
|
// parse, not an allocation.
|
||||||
|
let orientation = decoder
|
||||||
|
.orientation()
|
||||||
|
.unwrap_or(image::metadata::Orientation::NoTransforms);
|
||||||
|
let mut img = DynamicImage::from_decoder(decoder).context("failed to decode image")?;
|
||||||
|
img.apply_orientation(orientation);
|
||||||
|
Ok(img)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Shared with the e2e suite rather than duplicating 568 KiB of binary: the same file
|
||||||
|
/// drives `02-upload/oversized-image` so both layers assert on one artefact.
|
||||||
|
const HUGE: &str = concat!(
|
||||||
|
env!("CARGO_MANIFEST_DIR"),
|
||||||
|
"/../e2e/fixtures/media/huge-99mp.jpg"
|
||||||
|
);
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_an_image_that_would_blow_the_allocation_budget() {
|
||||||
|
// 11000x9000 = 99 MP. Deliberately UNDER the 12000px per-axis cap, so the axis check
|
||||||
|
// cannot reject it — the allocation budget is the only thing that can, which is
|
||||||
|
// exactly what makes this a regression test rather than a restatement of the axis cap.
|
||||||
|
// 283 MiB decoded as RGB8 against a 256 MiB budget, from 568 KiB on disk.
|
||||||
|
//
|
||||||
|
// This failed before the guard was restored: `ImageReader::decode` performs
|
||||||
|
// `limits.reserve(decoder.total_bytes())`, and `into_decoder()` — which we need for
|
||||||
|
// the EXIF tag — skips it, so `max_alloc` was inert and this decoded happily.
|
||||||
|
// Map the Ok arm to its dimensions first: on failure `expect_err` Debug-prints the
|
||||||
|
// value, and Debug on a DynamicImage dumps every pixel — 283 MiB of output.
|
||||||
|
let err = decode_oriented(Path::new(HUGE))
|
||||||
|
.map(|img| (img.width(), img.height()))
|
||||||
|
.expect_err("a 99 MP image must be refused, not allocated");
|
||||||
|
let msg = format!("{err:#}");
|
||||||
|
assert!(
|
||||||
|
msg.to_lowercase().contains("limit") || msg.to_lowercase().contains("memory"),
|
||||||
|
"expected a limits error, got: {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_oversized_image_is_a_permanent_failure() {
|
||||||
|
// The retry loop must not burn 2s + 4s of backoff on this: the file will not shrink
|
||||||
|
// between attempts, so all three attempts reach the identical conclusion.
|
||||||
|
let err = decode_oriented(Path::new(HUGE))
|
||||||
|
.map(|img| (img.width(), img.height()))
|
||||||
|
.expect_err("fixture must exceed the budget");
|
||||||
|
assert!(
|
||||||
|
is_permanent_image_error(&err),
|
||||||
|
"a Limits error can never succeed on retry: {err:#}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_plain_io_error_is_not_permanent() {
|
||||||
|
// The mirror that keeps the classifier honest. ENOSPC while writing a derivative, or
|
||||||
|
// EMFILE under load, is exactly what the retry exists for — misclassifying those as
|
||||||
|
// permanent would turn a transient blip back into the data loss round 1 fixed.
|
||||||
|
let err = decode_oriented(Path::new("/nonexistent/definitely-not-here.jpg"))
|
||||||
|
.map(|img| (img.width(), img.height()))
|
||||||
|
.expect_err("a missing file must error");
|
||||||
|
assert!(
|
||||||
|
!is_permanent_image_error(&err),
|
||||||
|
"an IO error must stay retryable: {err:#}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn admission_rejects_only_the_over_budget_case() {
|
||||||
|
// Admission and processing must agree about SIZE — a photo accepted at the door and
|
||||||
|
// then rejected by the worker for being too big is the failure this pair prevents.
|
||||||
|
assert!(
|
||||||
|
exceeds_decode_budget(Path::new(HUGE)),
|
||||||
|
"admission must reject what the decoder rejects for size"
|
||||||
|
);
|
||||||
|
let ordinary = concat!(
|
||||||
|
env!("CARGO_MANIFEST_DIR"),
|
||||||
|
"/../e2e/fixtures/media/portrait-exif6.jpg"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!exceeds_decode_budget(Path::new(ordinary)),
|
||||||
|
"admission must accept an ordinary photo"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn admission_does_not_reject_a_merely_undecodable_file() {
|
||||||
|
// The narrowing that keeps the adversarial contract intact: a payload with valid
|
||||||
|
// JPEG magic bytes and nothing behind them cannot be decoded, but acceptance follows
|
||||||
|
// the magic bytes by design (07-adversarial/file-upload-attacks). It is the worker's
|
||||||
|
// job to fail it, not admission's — admission is only the resource guard.
|
||||||
|
let dir = std::env::temp_dir().join("eventsnap-imaging-test");
|
||||||
|
std::fs::create_dir_all(&dir).expect("tmp dir");
|
||||||
|
let stub = dir.join("magic-only.jpg");
|
||||||
|
let mut bytes = vec![0u8; 1024];
|
||||||
|
bytes[..3].copy_from_slice(&[0xFF, 0xD8, 0xFF]);
|
||||||
|
std::fs::write(&stub, &bytes).expect("write stub");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!exceeds_decode_budget(&stub),
|
||||||
|
"a corrupt file is not an over-budget file"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
decode_oriented(&stub)
|
||||||
|
.map(|i| (i.width(), i.height()))
|
||||||
|
.is_err(),
|
||||||
|
"...but it must still fail in the worker"
|
||||||
|
);
|
||||||
|
let _ = std::fs::remove_file(&stub);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn still_decodes_an_ordinary_photo_and_applies_orientation() {
|
||||||
|
// The guard must not have become a blanket refusal. This fixture is 40x20 stored with
|
||||||
|
// EXIF Orientation=6, so a correct decode returns it rotated to 20x40 portrait.
|
||||||
|
let path = concat!(
|
||||||
|
env!("CARGO_MANIFEST_DIR"),
|
||||||
|
"/../e2e/fixtures/media/portrait-exif6.jpg"
|
||||||
|
);
|
||||||
|
let img = decode_oriented(Path::new(path)).expect("an ordinary photo must decode");
|
||||||
|
assert_eq!(
|
||||||
|
(img.width(), img.height()),
|
||||||
|
(20, 40),
|
||||||
|
"EXIF orientation must still be applied after restoring the guard"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<()>;
|
|
||||||
}
|
|
||||||
@@ -9,10 +9,13 @@
|
|||||||
//! users staring at a spinner. Resetting them on startup recovers gracefully.
|
//! users staring at a spinner. Resetting them on startup recovers gracefully.
|
||||||
//!
|
//!
|
||||||
//! 2. **Periodic tasks** — pruning that should happen "every hour" rather than per
|
//! 2. **Periodic tasks** — pruning that should happen "every hour" rather than per
|
||||||
//! request: expired sessions (otherwise the table grows unboundedly), and the
|
//! request: expired sessions (otherwise the table grows unboundedly), the
|
||||||
//! rate-limiter's in-memory windows (so keys for IPs that left long ago don't
|
//! rate-limiter's in-memory windows (so keys for IPs that left long ago don't
|
||||||
//! accumulate).
|
//! accumulate), and the media of soft-deleted uploads — both the ones whose compression
|
||||||
|
//! permanently failed and the ones a guest or host deliberately removed — which are
|
||||||
|
//! retained for a recovery window and then reclaimed.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
@@ -20,6 +23,42 @@ use sqlx::PgPool;
|
|||||||
use crate::services::rate_limiter::RateLimiter;
|
use crate::services::rate_limiter::RateLimiter;
|
||||||
use crate::services::sse_tickets::SseTicketStore;
|
use crate::services::sse_tickets::SseTicketStore;
|
||||||
|
|
||||||
|
/// How long a permanently-failed upload's original is kept on disk before it is
|
||||||
|
/// reclaimed.
|
||||||
|
///
|
||||||
|
/// The compression worker stops deleting originals on failure — a transient error must
|
||||||
|
/// never destroy the guest's only copy of a photo they can't retake. But the row is
|
||||||
|
/// soft-deleted and the uploader's quota IS refunded, so without a sweep those bytes are
|
||||||
|
/// invisible, unowned, and free: a reproducible codec failure lets one guest accumulate
|
||||||
|
/// orphans at no personal cost, and because `active_uploaders` counts only users with
|
||||||
|
/// non-deleted uploads, dropping out of that count actually RAISES everyone's per-user
|
||||||
|
/// ceiling while the disk gets fuller.
|
||||||
|
///
|
||||||
|
/// Two weeks is comfortably longer than any single event, so an operator investigating a
|
||||||
|
/// failed upload still has the file, while the leak stays bounded.
|
||||||
|
const FAILED_ORIGINAL_RETENTION_DAYS: i64 = 14;
|
||||||
|
|
||||||
|
/// How long a DELIBERATELY deleted upload's files are kept before they are reclaimed.
|
||||||
|
///
|
||||||
|
/// The same leak, reached by the ordinary path rather than the exceptional one.
|
||||||
|
/// `soft_delete_in_event` stamps `deleted_at` and refunds `total_upload_bytes`, but nothing ever
|
||||||
|
/// removed the bytes — so the quota stopped bounding the disk. Upload 500 MB, delete, quota is back
|
||||||
|
/// to zero, upload another 500 MB: not an attack, just a guest curating their camera roll, which is
|
||||||
|
/// what people do. The host then sees guests hitting "Du hast dein Upload-Limit erreicht" while the
|
||||||
|
/// admin widget shows a disk full of files no upload row points at, and the quota message is
|
||||||
|
/// actively misleading because the space really is gone — just not to anyone the accounting can
|
||||||
|
/// name.
|
||||||
|
///
|
||||||
|
/// Much shorter than the failure window on purpose. Fourteen days outlives the whole event, so a
|
||||||
|
/// deliberate delete would never reclaim anything while it mattered. A day still gives an operator
|
||||||
|
/// a recovery window for a mis-tap.
|
||||||
|
///
|
||||||
|
/// NOTE what this does NOT do: within the window the bytes are still spent and still unaccounted,
|
||||||
|
/// so a guest deleting and re-uploading through an eight-hour event can outrun the sweep. Bounding
|
||||||
|
/// that would mean holding the quota until the file is actually reclaimed rather than refunding at
|
||||||
|
/// `deleted_at` — a deliberate trade, and the reason the low-disk warning exists.
|
||||||
|
const DELETED_UPLOAD_RETENTION_HOURS: i64 = 24;
|
||||||
|
|
||||||
/// Reset rows left in flight by a previous crashed instance. Run once on startup,
|
/// Reset rows left in flight by a previous crashed instance. Run once on startup,
|
||||||
/// before the HTTP server starts taking requests, so users never observe the
|
/// before the HTTP server starts taking requests, so users never observe the
|
||||||
/// half-state.
|
/// half-state.
|
||||||
@@ -49,6 +88,18 @@ pub async fn startup_recovery(pool: &PgPool) {
|
|||||||
// rejects an already-released event), so `export::recover_exports` re-spawns these
|
// 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
|
// failed-but-released jobs from `main` once `AppState` exists (it needs the media
|
||||||
// paths + SSE sender this fn doesn't have).
|
// 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(
|
match sqlx::query(
|
||||||
"UPDATE export_job
|
"UPDATE export_job
|
||||||
SET status = 'failed',
|
SET status = 'failed',
|
||||||
@@ -79,6 +130,7 @@ pub fn spawn_periodic_tasks(
|
|||||||
pool: PgPool,
|
pool: PgPool,
|
||||||
rate_limiter: RateLimiter,
|
rate_limiter: RateLimiter,
|
||||||
sse_tickets: SseTicketStore,
|
sse_tickets: SseTicketStore,
|
||||||
|
media_path: PathBuf,
|
||||||
) {
|
) {
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut tick = tokio::time::interval(Duration::from_secs(3600));
|
let mut tick = tokio::time::interval(Duration::from_secs(3600));
|
||||||
@@ -87,12 +139,117 @@ pub fn spawn_periodic_tasks(
|
|||||||
loop {
|
loop {
|
||||||
tick.tick().await;
|
tick.tick().await;
|
||||||
cleanup_sessions(&pool).await;
|
cleanup_sessions(&pool).await;
|
||||||
|
cleanup_deleted_media(&pool, &media_path).await;
|
||||||
rate_limiter.prune();
|
rate_limiter.prune();
|
||||||
sse_tickets.prune();
|
sse_tickets.prune();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reclaim the media of soft-deleted uploads once they are past their retention window.
|
||||||
|
///
|
||||||
|
/// ONLY ever touches rows with `deleted_at IS NOT NULL`, so it can never reach a live upload. Two
|
||||||
|
/// classes, two windows, because the two deletes mean different things:
|
||||||
|
///
|
||||||
|
/// - a compression failure the guest didn't ask for and may want investigated —
|
||||||
|
/// [`FAILED_ORIGINAL_RETENTION_DAYS`];
|
||||||
|
/// - a deliberate removal by the guest or the host — [`DELETED_UPLOAD_RETENTION_HOURS`].
|
||||||
|
///
|
||||||
|
/// ALL FOUR paths are reclaimed, not just the original. The previous version cleared
|
||||||
|
/// `original_path` alone, which was right for its only case (a failed compression produces no
|
||||||
|
/// derivatives) but wrong the moment the sweep reaches a successfully processed upload: preview,
|
||||||
|
/// display and thumbnail are each a separate file on disk, none of them counted in
|
||||||
|
/// `original_size_bytes`, and nothing else ever removed them.
|
||||||
|
///
|
||||||
|
/// Every column is cleared in the same pass, which makes the sweep idempotent and stops a later run
|
||||||
|
/// re-reporting files that are already gone. The ROW is kept: it is the audit trail, it costs a few
|
||||||
|
/// hundred bytes, and `backfill_stale_derivatives` is guarded on `deleted_at IS NULL` so a nulled
|
||||||
|
/// `preview_path` can never make it regenerate what was just reclaimed.
|
||||||
|
async fn cleanup_deleted_media(pool: &PgPool, media_path: &std::path::Path) {
|
||||||
|
type Row = (
|
||||||
|
uuid::Uuid,
|
||||||
|
String,
|
||||||
|
Option<String>,
|
||||||
|
Option<String>,
|
||||||
|
Option<String>,
|
||||||
|
);
|
||||||
|
let rows = sqlx::query_as::<_, Row>(
|
||||||
|
"SELECT id, original_path, preview_path, display_path, thumbnail_path FROM upload
|
||||||
|
WHERE deleted_at IS NOT NULL
|
||||||
|
AND CASE WHEN compression_status = 'failed'
|
||||||
|
THEN deleted_at < NOW() - ($1 || ' days')::interval
|
||||||
|
ELSE deleted_at < NOW() - ($2 || ' hours')::interval
|
||||||
|
END
|
||||||
|
AND (original_path <> '' OR preview_path IS NOT NULL
|
||||||
|
OR display_path IS NOT NULL OR thumbnail_path IS NOT NULL)",
|
||||||
|
)
|
||||||
|
.bind(FAILED_ORIGINAL_RETENTION_DAYS.to_string())
|
||||||
|
.bind(DELETED_UPLOAD_RETENTION_HOURS.to_string())
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let rows = match rows {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = ?e, "deleted-media sweep query failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if rows.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut reclaimed = 0usize;
|
||||||
|
for (id, original, preview, display, thumbnail) in rows {
|
||||||
|
let paths: Vec<String> = std::iter::once(original)
|
||||||
|
.filter(|p| !p.is_empty())
|
||||||
|
.chain([preview, display, thumbnail].into_iter().flatten())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// All-or-nothing per row: the columns are only cleared once every file for that upload is
|
||||||
|
// gone. Clearing after a partial success would strand the survivors with nothing pointing
|
||||||
|
// at them — the same unowned-bytes state this sweep exists to drain.
|
||||||
|
let mut all_gone = true;
|
||||||
|
for rel in &paths {
|
||||||
|
let absolute = media_path.join(rel);
|
||||||
|
match tokio::fs::remove_file(&absolute).await {
|
||||||
|
Ok(()) => reclaimed += 1,
|
||||||
|
// Already gone (manual cleanup, restored backup) — still counts as reclaimed for
|
||||||
|
// the purpose of clearing the columns, or the row is re-selected every hour forever.
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = ?e, %id, path = %absolute.display(),
|
||||||
|
"could not reclaim deleted media; leaving the row for the next sweep");
|
||||||
|
all_gone = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !all_gone {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(e) = sqlx::query(
|
||||||
|
"UPDATE upload SET original_path = '', preview_path = NULL,
|
||||||
|
display_path = NULL, thumbnail_path = NULL
|
||||||
|
WHERE id = $1",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::warn!(error = ?e, %id, "reclaimed the files but could not clear the paths");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if reclaimed > 0 {
|
||||||
|
tracing::info!(
|
||||||
|
"reclaimed {reclaimed} file(s) from soft-deleted uploads (deliberate deletes after \
|
||||||
|
{DELETED_UPLOAD_RETENTION_HOURS}h, compression failures after \
|
||||||
|
{FAILED_ORIGINAL_RETENTION_DAYS}d)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn cleanup_sessions(pool: &PgPool) {
|
async fn cleanup_sessions(pool: &PgPool) {
|
||||||
match sqlx::query("DELETE FROM session WHERE expires_at < NOW() - INTERVAL '1 day'")
|
match sqlx::query("DELETE FROM session WHERE expires_at < NOW() - INTERVAL '1 day'")
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ pub mod compression;
|
|||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod disk;
|
pub mod disk;
|
||||||
pub mod export;
|
pub mod export;
|
||||||
pub mod jobs;
|
pub mod imaging;
|
||||||
pub mod maintenance;
|
pub mod maintenance;
|
||||||
pub mod rate_limiter;
|
pub mod rate_limiter;
|
||||||
pub mod sse_tickets;
|
pub mod sse_tickets;
|
||||||
|
pub mod video;
|
||||||
|
|||||||
@@ -17,14 +17,20 @@ impl RateLimiter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns `true` if the request is allowed, `false` if rate-limited.
|
|
||||||
pub fn check(&self, key: impl Into<String>, max: usize, window: Duration) -> bool {
|
|
||||||
self.check_with_retry(key, max, window).is_ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns `Ok(())` if allowed, `Err(retry_after_secs)` if rate-limited.
|
/// 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.
|
/// `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> {
|
///
|
||||||
|
/// This is deliberately the ONLY entry point. There used to be a `check()` wrapper
|
||||||
|
/// returning a plain bool, and 7 of the 8 call sites used it and then hard-coded
|
||||||
|
/// `None` for the response's `Retry-After` — so a throttled client was told to back
|
||||||
|
/// off but never for how long. Forcing every caller through the `Result` makes the
|
||||||
|
/// retry delay impossible to discard by accident.
|
||||||
|
pub fn check_with_retry(
|
||||||
|
&self,
|
||||||
|
key: impl Into<String>,
|
||||||
|
max: usize,
|
||||||
|
window: Duration,
|
||||||
|
) -> Result<(), u64> {
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
let key = key.into();
|
let key = key.into();
|
||||||
let mut map = self.windows.lock().unwrap();
|
let mut map = self.windows.lock().unwrap();
|
||||||
@@ -79,6 +85,11 @@ impl RateLimiter {
|
|||||||
/// appends is the real client. A client can prepend arbitrary spoofed values to
|
/// 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
|
/// the left of XFF to dodge throttles — those are ignored here. This assumes
|
||||||
/// exactly one trusted proxy (Caddy); revisit if that changes.
|
/// exactly one trusted proxy (Caddy); revisit if that changes.
|
||||||
|
///
|
||||||
|
/// Pass the peer address as `fallback`, never a constant. Every caller used to pass
|
||||||
|
/// the literal `"unknown"`, so any request that arrived without XFF — i.e. anything
|
||||||
|
/// reaching the app directly rather than through Caddy — shared ONE bucket with every
|
||||||
|
/// other such request, turning the limiter into a self-inflicted global throttle.
|
||||||
pub fn client_ip(headers: &axum::http::HeaderMap, fallback: &str) -> String {
|
pub fn client_ip(headers: &axum::http::HeaderMap, fallback: &str) -> String {
|
||||||
headers
|
headers
|
||||||
.get("x-forwarded-for")
|
.get("x-forwarded-for")
|
||||||
@@ -99,45 +110,159 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn allows_up_to_max_then_blocks() {
|
fn allows_up_to_max_then_blocks() {
|
||||||
let rl = RateLimiter::new();
|
let rl = RateLimiter::new();
|
||||||
assert!(rl.check("k", 3, MIN));
|
assert!(rl.check_with_retry("k", 3, MIN).is_ok());
|
||||||
assert!(rl.check("k", 3, MIN));
|
assert!(rl.check_with_retry("k", 3, MIN).is_ok());
|
||||||
assert!(rl.check("k", 3, MIN));
|
assert!(rl.check_with_retry("k", 3, MIN).is_ok());
|
||||||
assert!(!rl.check("k", 3, MIN), "the 4th request must be blocked");
|
assert!(
|
||||||
|
rl.check_with_retry("k", 3, MIN).is_err(),
|
||||||
|
"the 4th request must be blocked"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn keys_are_independent() {
|
fn keys_are_independent() {
|
||||||
let rl = RateLimiter::new();
|
let rl = RateLimiter::new();
|
||||||
assert!(rl.check("a", 1, MIN));
|
assert!(rl.check_with_retry("a", 1, MIN).is_ok());
|
||||||
assert!(!rl.check("a", 1, MIN));
|
assert!(rl.check_with_retry("a", 1, MIN).is_err());
|
||||||
assert!(rl.check("b", 1, MIN), "a different key has its own window");
|
assert!(
|
||||||
|
rl.check_with_retry("b", 1, MIN).is_ok(),
|
||||||
|
"a different key has its own window"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn window_slides_and_allows_again_after_expiry() {
|
fn window_slides_and_allows_again_after_expiry() {
|
||||||
let rl = RateLimiter::new();
|
let rl = RateLimiter::new();
|
||||||
let w = Duration::from_millis(40);
|
let w = Duration::from_millis(40);
|
||||||
assert!(rl.check("k", 1, w));
|
assert!(rl.check_with_retry("k", 1, w).is_ok());
|
||||||
assert!(!rl.check("k", 1, w));
|
assert!(rl.check_with_retry("k", 1, w).is_err());
|
||||||
std::thread::sleep(Duration::from_millis(55));
|
std::thread::sleep(Duration::from_millis(55));
|
||||||
assert!(rl.check("k", 1, w), "the slot should expire once the window passes");
|
assert!(
|
||||||
|
rl.check_with_retry("k", 1, w).is_ok(),
|
||||||
|
"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]
|
#[test]
|
||||||
fn retry_after_is_between_one_and_window() {
|
fn retry_after_counts_down_as_the_window_elapses() {
|
||||||
let rl = RateLimiter::new();
|
let rl = RateLimiter::new();
|
||||||
assert!(rl.check_with_retry("k", 1, MIN).is_ok());
|
let w = Duration::from_secs(30);
|
||||||
let retry = rl.check_with_retry("k", 1, MIN).unwrap_err();
|
assert!(rl.check_with_retry("k", 1, w).is_ok());
|
||||||
assert!((1..=60).contains(&retry), "retry_after {retry} out of range");
|
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]
|
#[test]
|
||||||
fn clear_resets_every_window() {
|
fn clear_resets_every_window() {
|
||||||
let rl = RateLimiter::new();
|
let rl = RateLimiter::new();
|
||||||
assert!(rl.check("k", 1, MIN));
|
assert!(rl.check_with_retry("k", 1, MIN).is_ok());
|
||||||
assert!(!rl.check("k", 1, MIN));
|
assert!(rl.check_with_retry("k", 1, MIN).is_err());
|
||||||
rl.clear();
|
rl.clear();
|
||||||
assert!(rl.check("k", 1, MIN), "clear() must free the window");
|
assert!(
|
||||||
|
rl.check_with_retry("k", 1, MIN).is_ok(),
|
||||||
|
"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_with_retry("live", 5, MIN).is_ok());
|
||||||
|
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_with_retry("k", 1, MIN).is_ok());
|
||||||
|
assert!(rl.check_with_retry("k", 1, MIN).is_err());
|
||||||
|
|
||||||
|
rl.prune();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
rl.check_with_retry("k", 1, MIN).is_err(),
|
||||||
|
"prune() must not clear a window that is still active"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -152,7 +277,10 @@ mod tests {
|
|||||||
fn client_ip_ignores_spoofed_leftmost_entry() {
|
fn client_ip_ignores_spoofed_leftmost_entry() {
|
||||||
// A client prepending a fake IP to dodge throttles must not win.
|
// A client prepending a fake IP to dodge throttles must not win.
|
||||||
let mut h = HeaderMap::new();
|
let mut h = HeaderMap::new();
|
||||||
h.insert("x-forwarded-for", "1.2.3.4, 9.9.9.9, 203.0.113.7".parse().unwrap());
|
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");
|
assert_eq!(client_ip(&h, "fallback"), "203.0.113.7");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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).
|
/// Mint a new ticket bound to the caller's session (identified by token hash).
|
||||||
pub fn issue(&self, token_hash: String) -> String {
|
pub fn issue(&self, token_hash: String) -> String {
|
||||||
let ticket = random_ticket();
|
let ticket = random_ticket();
|
||||||
@@ -83,7 +90,11 @@ mod tests {
|
|||||||
let ticket = store.issue("hash-1".into());
|
let ticket = store.issue("hash-1".into());
|
||||||
assert_eq!(store.consume(&ticket).as_deref(), Some("hash-1"));
|
assert_eq!(store.consume(&ticket).as_deref(), Some("hash-1"));
|
||||||
// Single-use: a replay of the same ticket is rejected.
|
// Single-use: a replay of the same ticket is rejected.
|
||||||
assert_eq!(store.consume(&ticket), None, "a consumed ticket must not be reusable");
|
assert_eq!(
|
||||||
|
store.consume(&ticket),
|
||||||
|
None,
|
||||||
|
"a consumed ticket must not be reusable"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -124,6 +135,10 @@ mod tests {
|
|||||||
.expect("host uptime should exceed the ticket TTL"),
|
.expect("host uptime should exceed the ticket TTL"),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
assert_eq!(store.consume(&stale), None, "an expired ticket must not authenticate");
|
assert_eq!(
|
||||||
|
store.consume(&stale),
|
||||||
|
None,
|
||||||
|
"an expired ticket must not authenticate"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
140
backend/src/services/video.rs
Normal file
140
backend/src/services/video.rs
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
//! Poster-frame extraction, shared by the compression worker and the HTML export.
|
||||||
|
//!
|
||||||
|
//! Both used to spawn `ffmpeg` themselves with the same broken invocation:
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! ffmpeg -i <src> -vframes 1 -ss 00:00:01 -vf scale=… -y <out>
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! `-ss` AFTER `-i` is an output-side seek. Against a clip of a second or less ffmpeg exits **0 and
|
||||||
|
//! writes nothing** — and both call sites gated on the exit status, so neither noticed. The worker
|
||||||
|
//! then wrote `thumbnail_path` for a file that was never created (404 in the live feed) and the
|
||||||
|
//! export listed the entry in `data.json` while the ZIP writer skipped it (a broken image tile in
|
||||||
|
//! the keepsake). Every server-side signal stayed green. Phones produce such clips constantly:
|
||||||
|
//! mis-taps, Live Photos, boomerangs.
|
||||||
|
//!
|
||||||
|
//! This module exists for the same reason `imaging.rs` does — that one was created when compression
|
||||||
|
//! and export duplicated decode logic, and it paid off immediately when the `max_alloc` fix landed
|
||||||
|
//! in both workers at once. Same duplication, same fix.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
|
||||||
|
/// A malformed video can hang `ffmpeg` indefinitely. In the compression worker that never releases
|
||||||
|
/// the semaphore permit and the pool eventually deadlocks; in the export worker it strands the job
|
||||||
|
/// at `running` so the keepsake never completes. `export.rs` had NO timeout at all before this
|
||||||
|
/// module — sharing the spawn fixes that too.
|
||||||
|
const FFMPEG_TIMEOUT: Duration = Duration::from_secs(120);
|
||||||
|
|
||||||
|
/// Seek positions to try, in order.
|
||||||
|
///
|
||||||
|
/// One second first: the opening frame of a real video is often black, a fade-in, or motion-blurred
|
||||||
|
/// as the camera settles, so it makes a poor poster. Zero second as the fallback, which is what
|
||||||
|
/// makes short clips work — and it is genuinely required, not defensive. Moving `-ss` before `-i`
|
||||||
|
/// (an input-side seek) is necessary but NOT sufficient: seeking to 1 s in a 1.000 s clip is still
|
||||||
|
/// past the last frame, and ffmpeg still exits 0 having written nothing. Verified against the real
|
||||||
|
/// production image.
|
||||||
|
const SEEK_POSITIONS: &[&str] = &["00:00:01", "0"];
|
||||||
|
|
||||||
|
/// Extract one poster frame from `src` into `dest`, scaled to `width` px wide.
|
||||||
|
///
|
||||||
|
/// `Ok(false)` means the video yielded no frame — a normal outcome for a very short or unusual
|
||||||
|
/// clip, NOT an error. Callers must degrade (no poster) rather than fail the upload: treating this
|
||||||
|
/// as an error would soft-delete every sub-second video, turning a cosmetic defect into data loss.
|
||||||
|
///
|
||||||
|
/// `Err` is reserved for something genuinely wrong — a hang we had to kill, or a failure to spawn.
|
||||||
|
pub async fn extract_poster_frame(src: &Path, dest: &Path, width: u32) -> Result<bool> {
|
||||||
|
for seek in SEEK_POSITIONS {
|
||||||
|
// A stale file from a previous attempt would be indistinguishable from a fresh success.
|
||||||
|
let _ = tokio::fs::remove_file(dest).await;
|
||||||
|
|
||||||
|
run_ffmpeg(src, dest, width, seek).await?;
|
||||||
|
|
||||||
|
// THE CHECK BOTH CALL SITES WERE MISSING: ask the filesystem, not the exit status.
|
||||||
|
// Non-empty, because a zero-byte file is not a poster either.
|
||||||
|
if tokio::fs::metadata(dest)
|
||||||
|
.await
|
||||||
|
.map(|m| m.is_file() && m.len() > 0)
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leave nothing behind for a caller to mistake for a result.
|
||||||
|
let _ = tokio::fs::remove_file(dest).await;
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run one ffmpeg attempt. A non-zero exit is NOT an error here — the artifact check above is the
|
||||||
|
/// authority, and a corrupt input that fails at 1 s may still yield a frame at 0.
|
||||||
|
async fn run_ffmpeg(src: &Path, dest: &Path, width: u32, seek: &str) -> Result<()> {
|
||||||
|
let mut child = tokio::process::Command::new("ffmpeg")
|
||||||
|
.args([
|
||||||
|
// BEFORE -i: an input-side seek. See SEEK_POSITIONS.
|
||||||
|
"-ss",
|
||||||
|
seek,
|
||||||
|
"-i",
|
||||||
|
src.to_str().unwrap_or_default(),
|
||||||
|
"-vframes",
|
||||||
|
"1",
|
||||||
|
"-vf",
|
||||||
|
&format!("scale={width}:-1"),
|
||||||
|
"-y",
|
||||||
|
dest.to_str().unwrap_or_default(),
|
||||||
|
])
|
||||||
|
.stdout(std::process::Stdio::piped())
|
||||||
|
.stderr(std::process::Stdio::piped())
|
||||||
|
.kill_on_drop(true)
|
||||||
|
.spawn()
|
||||||
|
.context("failed to spawn ffmpeg")?;
|
||||||
|
|
||||||
|
match tokio::time::timeout(FFMPEG_TIMEOUT, child.wait()).await {
|
||||||
|
Ok(res) => {
|
||||||
|
res.context("ffmpeg wait failed")?;
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
let _ = child.kill().await;
|
||||||
|
anyhow::bail!("ffmpeg timed out after {}s", FFMPEG_TIMEOUT.as_secs());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// The order is the whole fix. `-ss` must precede `-i`, and 0 must be tried after 1 s.
|
||||||
|
#[test]
|
||||||
|
fn the_fallback_seek_exists_and_comes_last() {
|
||||||
|
assert_eq!(
|
||||||
|
SEEK_POSITIONS,
|
||||||
|
&["00:00:01", "0"],
|
||||||
|
"1s first for a better poster, 0 as the fallback that makes short clips work"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A missing input yields no frame rather than an error: the caller must degrade to "no
|
||||||
|
/// poster", never fail the upload. `Err` is reserved for a hang or a spawn failure.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_missing_source_yields_no_frame_rather_than_an_error() {
|
||||||
|
let dir = std::env::temp_dir().join(format!("es-video-{}", std::process::id()));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let dest = dir.join("out.jpg");
|
||||||
|
|
||||||
|
let got = extract_poster_frame(Path::new("/nonexistent/clip.mp4"), &dest, 400).await;
|
||||||
|
|
||||||
|
match got {
|
||||||
|
Ok(false) => {}
|
||||||
|
other => panic!("expected Ok(false) for a missing input, got {other:?}"),
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
!dest.exists(),
|
||||||
|
"a failed extraction must leave nothing a caller could mistake for a poster"
|
||||||
|
);
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
345
backend/tests/common/mod.rs
Normal file
345
backend/tests/common/mod.rs
Normal file
@@ -0,0 +1,345 @@
|
|||||||
|
//! 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()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert an upload of `size` bytes, optionally already soft-deleted.
|
||||||
|
pub async fn seed_upload(
|
||||||
|
pool: &PgPool,
|
||||||
|
event_id: Uuid,
|
||||||
|
user_id: Uuid,
|
||||||
|
size: i64,
|
||||||
|
deleted: bool,
|
||||||
|
) -> Uuid {
|
||||||
|
sqlx::query_scalar(
|
||||||
|
"INSERT INTO upload (event_id, user_id, original_path, mime_type,
|
||||||
|
original_size_bytes, deleted_at)
|
||||||
|
VALUES ($1, $2, 'originals/x.jpg', 'image/jpeg', $3,
|
||||||
|
CASE WHEN $4 THEN NOW() ELSE NULL END)
|
||||||
|
RETURNING id",
|
||||||
|
)
|
||||||
|
.bind(event_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(size)
|
||||||
|
.bind(deleted)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.expect("seed upload")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flip the moderation flags a ban sets.
|
||||||
|
pub async fn set_user_moderation(pool: &PgPool, user_id: Uuid, banned: bool, hidden: bool) {
|
||||||
|
sqlx::query("UPDATE \"user\" SET is_banned = $2, uploads_hidden = $3 WHERE id = $1")
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(banned)
|
||||||
|
.bind(hidden)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.expect("set moderation");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SRC: `services/export.rs::query_uploads` — the visibility filter, verbatim, projected down to
|
||||||
|
/// `(id, original_size_bytes)`. This is the row set that ACTUALLY lands in the archives.
|
||||||
|
///
|
||||||
|
/// Production builds this WHERE from `export_visibility_where!()`, shared with
|
||||||
|
/// `estimate_export_bytes`. A copy here can pin the behaviour but CANNOT detect production moving
|
||||||
|
/// away from it — that is what sharing the fragment is for, not this.
|
||||||
|
pub async fn export_visible_uploads(pool: &PgPool, event_id: Uuid) -> Vec<(Uuid, i64)> {
|
||||||
|
sqlx::query_as(
|
||||||
|
"SELECT u.id, u.original_size_bytes
|
||||||
|
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
|
||||||
|
GROUP BY u.id, usr.display_name
|
||||||
|
ORDER BY u.created_at ASC",
|
||||||
|
)
|
||||||
|
.bind(event_id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.expect("export_visible_uploads")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SRC: `services/export.rs::estimate_export_bytes` — verbatim. Same caveat as above: production
|
||||||
|
/// shares its WHERE with `query_uploads` via `export_visibility_where!()`, so these two copies
|
||||||
|
/// agreeing proves the behaviour, not the absence of drift.
|
||||||
|
pub async fn estimate_export_bytes(pool: &PgPool, event_id: Uuid) -> i64 {
|
||||||
|
let (bytes,): (i64,) = sqlx::query_as(
|
||||||
|
"SELECT COALESCE(SUM(u.original_size_bytes), 0)::bigint
|
||||||
|
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_one(pool)
|
||||||
|
.await
|
||||||
|
.expect("estimate_export_bytes");
|
||||||
|
bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SRC: `services/export.rs::ensure_export_space` — the armed-job count, verbatim.
|
||||||
|
pub async fn armed_job_count(pool: &PgPool, event_id: Uuid) -> i64 {
|
||||||
|
let (n,): (i64,) = sqlx::query_as(
|
||||||
|
"SELECT COUNT(*) FROM export_job
|
||||||
|
WHERE event_id = $1 AND status IN ('pending', 'running')",
|
||||||
|
)
|
||||||
|
.bind(event_id)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.expect("armed_job_count");
|
||||||
|
n
|
||||||
|
}
|
||||||
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");
|
||||||
|
}
|
||||||
163
backend/tests/export_preflight.rs
Normal file
163
backend/tests/export_preflight.rs
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
//! DB-backed tests for the export disk preflight.
|
||||||
|
//!
|
||||||
|
//! The keepsake used to be built with NO free-space check at all, and the failure that produced was
|
||||||
|
//! not "the export failed" but "the deliverable is stuck and the escape hatch needs the space that
|
||||||
|
//! isn't there":
|
||||||
|
//!
|
||||||
|
//! 1. A takedown bumps the epoch and re-arms both halves.
|
||||||
|
//! 2. The ZIP hits ENOSPC partway through a multi-GB write.
|
||||||
|
//! 3. The job row is now `failed` at the CURRENT epoch, so readiness
|
||||||
|
//! (`epoch = event.export_epoch AND status = 'done'`) is false and `GET /export/zip` 404s —
|
||||||
|
//! while the last good archive sits on disk, unreferenced and unreachable.
|
||||||
|
//! 4. `POST /host/export/rebuild` re-arms the same doomed write.
|
||||||
|
//!
|
||||||
|
//! Two changes close it: reclaim the superseded generation BEFORE building (so peak usage is one
|
||||||
|
//! generation, not two) and refuse up front with a number the host can act on.
|
||||||
|
//!
|
||||||
|
//! What these tests pin is the ESTIMATE — the part that decides. The arithmetic on top of it lives
|
||||||
|
//! in `services/export.rs`'s unit tests; the filesystem selection lives in `is_superseded_archive`.
|
||||||
|
//!
|
||||||
|
//! ON DRIFT, precisely, because it is easy to overclaim here. The hazard is that `query_uploads`
|
||||||
|
//! (which selects the rows the archives are built from) and `estimate_export_bytes` (which sizes
|
||||||
|
//! them) could disagree — and an estimate missing rows the archive writes UNDER-reserves, the one
|
||||||
|
//! direction that reintroduces the ENOSPC. **These tests cannot catch that**, and neither can any
|
||||||
|
//! test in this harness: both sides here are `SRC:`-marked hand-copies in `tests/common/mod.rs`,
|
||||||
|
//! so if production moved and the copies didn't, they would sit still and keep passing.
|
||||||
|
//!
|
||||||
|
//! That is fixed where it can be — the two queries now share one `export_visibility_where!()`
|
||||||
|
//! fragment in `services/export.rs`, so they cannot diverge by construction. What is left for
|
||||||
|
//! these tests is what the convention is genuinely good at: pinning the BEHAVIOUR, so a change
|
||||||
|
//! that deliberately alters the filter has to come here and say so.
|
||||||
|
|
||||||
|
mod common;
|
||||||
|
|
||||||
|
use common::*;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
/// The estimate must equal the sum over EXACTLY the rows `query_uploads` returns — computed from
|
||||||
|
/// that row set, not from a restatement of its WHERE clause.
|
||||||
|
///
|
||||||
|
/// PINS: which uploads the preflight is allowed to count. Each excluded row below is excluded by a
|
||||||
|
/// DIFFERENT predicate, so a change that drops or weakens any one of them fails here and has to be
|
||||||
|
/// argued for. (It does not detect production drifting away from these copies — see the file
|
||||||
|
/// header; `export_visibility_where!()` is what makes that impossible.)
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn the_estimate_sums_exactly_the_rows_the_archive_will_contain(pool: PgPool) {
|
||||||
|
let event_id = seed_event(&pool, "wedding").await;
|
||||||
|
|
||||||
|
let visible = seed_user(&pool, event_id, "Anna").await;
|
||||||
|
let banned = seed_user(&pool, event_id, "Ben").await;
|
||||||
|
let hidden = seed_user(&pool, event_id, "Cara").await;
|
||||||
|
|
||||||
|
seed_upload(&pool, event_id, visible, 1_000, false).await;
|
||||||
|
seed_upload(&pool, event_id, visible, 2_500, false).await;
|
||||||
|
// Each of these is excluded from the archive by a DIFFERENT predicate.
|
||||||
|
seed_upload(&pool, event_id, visible, 9_000, true).await; // soft-deleted
|
||||||
|
seed_upload(&pool, event_id, banned, 9_000, false).await; // uploader banned
|
||||||
|
seed_upload(&pool, event_id, hidden, 9_000, false).await; // uploads hidden
|
||||||
|
|
||||||
|
set_user_moderation(&pool, banned, true, true).await;
|
||||||
|
set_user_moderation(&pool, hidden, false, true).await;
|
||||||
|
|
||||||
|
let rows = export_visible_uploads(&pool, event_id).await;
|
||||||
|
let expected: i64 = rows.iter().map(|(_, bytes)| bytes).sum();
|
||||||
|
|
||||||
|
assert_eq!(rows.len(), 2, "only Anna's two live uploads are archived");
|
||||||
|
assert_eq!(
|
||||||
|
estimate_export_bytes(&pool, event_id).await,
|
||||||
|
expected,
|
||||||
|
"the preflight must size the gallery the export will actually write"
|
||||||
|
);
|
||||||
|
assert_eq!(expected, 3_500);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An event with nothing to archive estimates zero rather than NULL.
|
||||||
|
///
|
||||||
|
/// PREVENTS: `SUM()` over no rows returning NULL and the decode blowing up — which would abort the
|
||||||
|
/// export with a type error instead of building an (entirely legitimate) empty keepsake.
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn an_empty_gallery_estimates_zero_not_null(pool: PgPool) {
|
||||||
|
let event_id = seed_event(&pool, "wedding").await;
|
||||||
|
assert_eq!(estimate_export_bytes(&pool, event_id).await, 0);
|
||||||
|
|
||||||
|
// And with a user who has uploaded nothing.
|
||||||
|
seed_user(&pool, event_id, "Anna").await;
|
||||||
|
assert_eq!(estimate_export_bytes(&pool, event_id).await, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A release arms both halves, so the preflight sees a count of 2 and reserves for the pair.
|
||||||
|
///
|
||||||
|
/// PREVENTS: the concurrency under-reservation. `spawn_export_jobs` starts the ZIP and HTML workers
|
||||||
|
/// at the same instant, and BOTH are gallery-sized (`Memories.zip` streams the original for every
|
||||||
|
/// video and every image at or under 5 MB, all `Compression::Stored`). A worker reserving only for
|
||||||
|
/// itself would see "it fits", its sibling would independently see the same, and together they
|
||||||
|
/// would ENOSPC — which is why `required_free_bytes` multiplies by this count.
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn a_release_arms_both_halves_so_the_preflight_reserves_for_two(pool: PgPool) {
|
||||||
|
let event_id = seed_event(&pool, "wedding").await;
|
||||||
|
let user = seed_user(&pool, event_id, "Anna").await;
|
||||||
|
seed_upload(&pool, event_id, user, 1_000, false).await;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
armed_job_count(&pool, event_id).await,
|
||||||
|
0,
|
||||||
|
"nothing is armed before the release"
|
||||||
|
);
|
||||||
|
|
||||||
|
let epoch = release_gallery(&pool, "wedding").await.expect("released");
|
||||||
|
assert_eq!(
|
||||||
|
armed_job_count(&pool, event_id).await,
|
||||||
|
2,
|
||||||
|
"a release arms zip AND html — both compete for the same disk"
|
||||||
|
);
|
||||||
|
|
||||||
|
// A worker that has claimed its half is still competing; `running` must keep counting.
|
||||||
|
assert!(claim_job(&pool, event_id, "zip", epoch).await);
|
||||||
|
assert_eq!(
|
||||||
|
armed_job_count(&pool, event_id).await,
|
||||||
|
2,
|
||||||
|
"claiming moves pending -> running, which must not drop out of the reservation"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Only a FINISHED half stops competing.
|
||||||
|
assert!(finalize_job(&pool, event_id, "zip", epoch, "exports/Gallery.zip").await);
|
||||||
|
assert_eq!(
|
||||||
|
armed_job_count(&pool, event_id).await,
|
||||||
|
1,
|
||||||
|
"a done half no longer needs space reserved for it"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A ViewerOnly regeneration re-arms only the HTML half, so the preflight reserves for one.
|
||||||
|
///
|
||||||
|
/// PREVENTS: over-reservation refusing a rebuild that fits perfectly well. Moderating a comment
|
||||||
|
/// carries the finished ZIP forward untouched; demanding room for a second copy of it would fail
|
||||||
|
/// the one operation that needs no new gallery-sized write at all.
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn a_viewer_only_regeneration_reserves_for_one_half(pool: PgPool) {
|
||||||
|
let event_id = seed_event(&pool, "wedding").await;
|
||||||
|
let user = seed_user(&pool, event_id, "Anna").await;
|
||||||
|
seed_upload(&pool, event_id, user, 1_000, false).await;
|
||||||
|
|
||||||
|
let epoch = release_gallery(&pool, "wedding").await.expect("released");
|
||||||
|
for t in ["zip", "html"] {
|
||||||
|
assert!(claim_job(&pool, event_id, t, epoch).await);
|
||||||
|
assert!(finalize_job(&pool, event_id, t, epoch, &format!("exports/{t}")).await);
|
||||||
|
}
|
||||||
|
assert_eq!(armed_job_count(&pool, event_id).await, 0);
|
||||||
|
|
||||||
|
// A moderated comment: bump the epoch, carry the ZIP forward, re-arm only the viewer.
|
||||||
|
let (_, _, next) = bump_epoch(&pool, "wedding").await.expect("bumped");
|
||||||
|
assert!(
|
||||||
|
carry_zip_forward(&pool, event_id, next).await,
|
||||||
|
"the finished ZIP is re-stamped, not rebuilt"
|
||||||
|
);
|
||||||
|
let mut conn = pool.acquire().await.expect("acquire");
|
||||||
|
enqueue_types_at_epoch(&mut conn, event_id, next, &["html"]).await;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
armed_job_count(&pool, event_id).await,
|
||||||
|
1,
|
||||||
|
"only the viewer is being rebuilt, so only one archive's worth of space is needed"
|
||||||
|
);
|
||||||
|
}
|
||||||
352
backend/tests/failed_original_sweep.rs
Normal file
352
backend/tests/failed_original_sweep.rs
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
//! DB-backed tests for the deleted-media sweep (`services/maintenance.rs`).
|
||||||
|
//!
|
||||||
|
//! Context, in two halves.
|
||||||
|
//!
|
||||||
|
//! The compression worker deliberately no longer deletes an upload's original when its transcode
|
||||||
|
//! fails — a transient ENOSPC or a codec panic must never destroy the only copy of a photo a guest
|
||||||
|
//! cannot retake. But the row is soft-deleted and the uploader's quota IS refunded, so those bytes
|
||||||
|
//! become invisible, unowned and free.
|
||||||
|
//!
|
||||||
|
//! The SAME hole was reachable by the ordinary path, and that one is not an edge case at all:
|
||||||
|
//! `soft_delete_in_event` refunds `total_upload_bytes` on every guest or host delete and nothing
|
||||||
|
//! removed the files, so the quota stopped bounding the disk. Upload 500 MB, delete, quota back to
|
||||||
|
//! zero, upload another 500 MB — a guest curating their camera roll, which is what people do. The
|
||||||
|
//! sweep used to reach only `compression_status = 'failed'`, so it never touched this case; the
|
||||||
|
//! test below that now asserts an owner-deleted upload IS reclaimed is the one that used to assert
|
||||||
|
//! the opposite.
|
||||||
|
//!
|
||||||
|
//! Two windows, because the two deletes mean different things: 14 days for a failure an operator
|
||||||
|
//! may want to investigate, 24 hours for a removal someone asked for (14 days outlives the whole
|
||||||
|
//! event, so a deliberate delete would never reclaim anything while it mattered).
|
||||||
|
//!
|
||||||
|
//! The selection predicate is the whole safety argument — it must reach both leftovers and never a
|
||||||
|
//! live upload — so that is what these pin, following the same "reproduce the SQL verbatim" pattern
|
||||||
|
//! as `upload_concurrency.rs`. `#[sqlx::test]` gives each test a fresh, migrated database.
|
||||||
|
|
||||||
|
mod common;
|
||||||
|
|
||||||
|
use common::*;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
const FAILED_DAYS: i64 = 14;
|
||||||
|
const DELETED_HOURS: i64 = 24;
|
||||||
|
|
||||||
|
/// SRC: `services/maintenance.rs::cleanup_deleted_media` — the selection, verbatim.
|
||||||
|
async fn sweep_selects(pool: &PgPool, failed_days: i64, deleted_hours: i64) -> Vec<Uuid> {
|
||||||
|
type Row = (Uuid, String, Option<String>, Option<String>, Option<String>);
|
||||||
|
sqlx::query_as::<_, Row>(
|
||||||
|
"SELECT id, original_path, preview_path, display_path, thumbnail_path FROM upload
|
||||||
|
WHERE deleted_at IS NOT NULL
|
||||||
|
AND CASE WHEN compression_status = 'failed'
|
||||||
|
THEN deleted_at < NOW() - ($1 || ' days')::interval
|
||||||
|
ELSE deleted_at < NOW() - ($2 || ' hours')::interval
|
||||||
|
END
|
||||||
|
AND (original_path <> '' OR preview_path IS NOT NULL
|
||||||
|
OR display_path IS NOT NULL OR thumbnail_path IS NOT NULL)",
|
||||||
|
)
|
||||||
|
.bind(failed_days.to_string())
|
||||||
|
.bind(deleted_hours.to_string())
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.expect("sweep query")
|
||||||
|
.into_iter()
|
||||||
|
.map(|(id, ..)| id)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seed an upload aged `deleted_hours_ago` (None = live), with optional derivative paths.
|
||||||
|
async fn seed_aged_upload(
|
||||||
|
pool: &PgPool,
|
||||||
|
event_id: Uuid,
|
||||||
|
user_id: Uuid,
|
||||||
|
status: &str,
|
||||||
|
deleted_hours_ago: Option<i64>,
|
||||||
|
original_path: &str,
|
||||||
|
derivatives: bool,
|
||||||
|
) -> Uuid {
|
||||||
|
sqlx::query_scalar(
|
||||||
|
"INSERT INTO upload (event_id, user_id, original_path, mime_type, original_size_bytes,
|
||||||
|
compression_status, deleted_at,
|
||||||
|
preview_path, display_path, thumbnail_path)
|
||||||
|
VALUES ($1, $2, $3, 'image/jpeg', 1000, $4,
|
||||||
|
CASE WHEN $5::bigint IS NULL THEN NULL
|
||||||
|
ELSE NOW() - ($5::text || ' hours')::interval END,
|
||||||
|
CASE WHEN $6 THEN 'previews/p.jpg' END,
|
||||||
|
CASE WHEN $6 THEN 'displays/d.jpg' END,
|
||||||
|
CASE WHEN $6 THEN 'thumbs/t.jpg' END)
|
||||||
|
RETURNING id",
|
||||||
|
)
|
||||||
|
.bind(event_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(original_path)
|
||||||
|
.bind(status)
|
||||||
|
.bind(deleted_hours_ago)
|
||||||
|
.bind(derivatives)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.expect("seed upload")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A live upload is untouchable no matter how the windows are configured.
|
||||||
|
///
|
||||||
|
/// PREVENTS: the catastrophic loosening. Everything else here is about reclaiming more; this is the
|
||||||
|
/// one assertion that must never bend.
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn a_live_upload_is_never_selected(pool: PgPool) {
|
||||||
|
let event_id = seed_event(&pool, "sweep-live").await;
|
||||||
|
let user_id = seed_user(&pool, event_id, "Sweeper").await;
|
||||||
|
|
||||||
|
for status in ["done", "failed", "processing", "pending"] {
|
||||||
|
let live = seed_aged_upload(
|
||||||
|
&pool,
|
||||||
|
event_id,
|
||||||
|
user_id,
|
||||||
|
status,
|
||||||
|
None,
|
||||||
|
"originals/e/live.jpg",
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
!sweep_selects(&pool, FAILED_DAYS, DELETED_HOURS)
|
||||||
|
.await
|
||||||
|
.contains(&live),
|
||||||
|
"a non-deleted upload with status {status} must never be swept"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// THE FIX. An upload a guest or host deliberately deleted is reclaimed once past 24 hours.
|
||||||
|
///
|
||||||
|
/// PREVENTS: the regression back to a sweep scoped to `compression_status = 'failed'`, which is
|
||||||
|
/// what let the quota stop bounding the disk. This assertion is the inverse of the one this file
|
||||||
|
/// used to make.
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn a_deliberately_deleted_upload_is_reclaimed_after_a_day(pool: PgPool) {
|
||||||
|
let event_id = seed_event(&pool, "sweep-deleted").await;
|
||||||
|
let user_id = seed_user(&pool, event_id, "Curator").await;
|
||||||
|
|
||||||
|
let deleted = seed_aged_upload(
|
||||||
|
&pool,
|
||||||
|
event_id,
|
||||||
|
user_id,
|
||||||
|
"done",
|
||||||
|
Some(48),
|
||||||
|
"originals/e/owner.jpg",
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
// Still inside the window — a mis-tap is recoverable for a day.
|
||||||
|
let recent = seed_aged_upload(
|
||||||
|
&pool,
|
||||||
|
event_id,
|
||||||
|
user_id,
|
||||||
|
"done",
|
||||||
|
Some(2),
|
||||||
|
"originals/e/recent.jpg",
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let selected = sweep_selects(&pool, FAILED_DAYS, DELETED_HOURS).await;
|
||||||
|
assert!(
|
||||||
|
selected.contains(&deleted),
|
||||||
|
"a deliberate delete past the window must be reclaimed — this is the leak"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!selected.contains(&recent),
|
||||||
|
"a delete inside the window keeps its recovery grace"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The two windows are independent: a failure is retained far longer than a deliberate delete.
|
||||||
|
///
|
||||||
|
/// PREVENTS: collapsing them into one. Applying 24h to failures would destroy the recovery window
|
||||||
|
/// the retained-original fix exists to provide; applying 14 days to deliberate deletes would mean
|
||||||
|
/// nothing is ever reclaimed during an event.
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn the_two_retention_windows_do_not_bleed_into_each_other(pool: PgPool) {
|
||||||
|
let event_id = seed_event(&pool, "sweep-windows").await;
|
||||||
|
let user_id = seed_user(&pool, event_id, "Windows").await;
|
||||||
|
|
||||||
|
// 48h old: past the deliberate window, nowhere near the failure window.
|
||||||
|
let failed_recent = seed_aged_upload(
|
||||||
|
&pool,
|
||||||
|
event_id,
|
||||||
|
user_id,
|
||||||
|
"failed",
|
||||||
|
Some(48),
|
||||||
|
"originals/e/f-recent.jpg",
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let deleted_same_age = seed_aged_upload(
|
||||||
|
&pool,
|
||||||
|
event_id,
|
||||||
|
user_id,
|
||||||
|
"done",
|
||||||
|
Some(48),
|
||||||
|
"originals/e/d-same.jpg",
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
// 30 days old: past both.
|
||||||
|
let failed_old = seed_aged_upload(
|
||||||
|
&pool,
|
||||||
|
event_id,
|
||||||
|
user_id,
|
||||||
|
"failed",
|
||||||
|
Some(30 * 24),
|
||||||
|
"originals/e/f-old.jpg",
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let selected = sweep_selects(&pool, FAILED_DAYS, DELETED_HOURS).await;
|
||||||
|
assert!(
|
||||||
|
!selected.contains(&failed_recent),
|
||||||
|
"a 2-day-old compression failure is still inside its 14-day recovery window"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
selected.contains(&deleted_same_age),
|
||||||
|
"a deliberate delete of the same age is past its 24-hour window"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
selected.contains(&failed_old),
|
||||||
|
"a 30-day-old failure is past both windows"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Boundary behaviour on both windows.
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn retention_windows_are_honoured_at_the_boundary(pool: PgPool) {
|
||||||
|
let event_id = seed_event(&pool, "sweep-boundary").await;
|
||||||
|
let user_id = seed_user(&pool, event_id, "Boundary").await;
|
||||||
|
|
||||||
|
let cases = [
|
||||||
|
("failed", 13 * 24, false, "13 days"),
|
||||||
|
("failed", 15 * 24, true, "15 days"),
|
||||||
|
("done", 23, false, "23 hours"),
|
||||||
|
("done", 25, true, "25 hours"),
|
||||||
|
];
|
||||||
|
for (status, hours, expected, label) in cases {
|
||||||
|
let id = seed_aged_upload(
|
||||||
|
&pool,
|
||||||
|
event_id,
|
||||||
|
user_id,
|
||||||
|
status,
|
||||||
|
Some(hours),
|
||||||
|
"originals/e/b.jpg",
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
sweep_selects(&pool, FAILED_DAYS, DELETED_HOURS)
|
||||||
|
.await
|
||||||
|
.contains(&id),
|
||||||
|
expected,
|
||||||
|
"a {status} upload deleted {label} ago: expected swept={expected}"
|
||||||
|
);
|
||||||
|
sqlx::query("DELETE FROM upload WHERE id = $1")
|
||||||
|
.bind(id)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.expect("clean up");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A row is re-selected until EVERY one of its paths is cleared.
|
||||||
|
///
|
||||||
|
/// PREVENTS: two failures at once. The sweep used to clear `original_path` alone, which was right
|
||||||
|
/// for its only case (a failed compression produces no derivatives) but leaves preview, display and
|
||||||
|
/// thumbnail on disk the moment it reaches a successfully processed upload — three files per
|
||||||
|
/// upload, none of them counted in `original_size_bytes`, that nothing else ever removes. And a row
|
||||||
|
/// whose paths are all cleared must stop coming back, or every hourly tick logs a phantom reclaim
|
||||||
|
/// forever.
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn a_row_is_reselected_until_every_path_is_cleared(pool: PgPool) {
|
||||||
|
let event_id = seed_event(&pool, "sweep-idempotent").await;
|
||||||
|
let user_id = seed_user(&pool, event_id, "Idem").await;
|
||||||
|
let id = seed_aged_upload(
|
||||||
|
&pool,
|
||||||
|
event_id,
|
||||||
|
user_id,
|
||||||
|
"done",
|
||||||
|
Some(48),
|
||||||
|
"originals/e/once.jpg",
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(sweep_selects(&pool, FAILED_DAYS, DELETED_HOURS).await, [id]);
|
||||||
|
|
||||||
|
// Clearing only the original is NOT enough — the derivatives are still on disk.
|
||||||
|
sqlx::query("UPDATE upload SET original_path = '' WHERE id = $1")
|
||||||
|
.bind(id)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.expect("clear original");
|
||||||
|
assert_eq!(
|
||||||
|
sweep_selects(&pool, FAILED_DAYS, DELETED_HOURS).await,
|
||||||
|
[id],
|
||||||
|
"derivatives left behind must keep the row selected"
|
||||||
|
);
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE upload SET preview_path = NULL, display_path = NULL, thumbnail_path = NULL
|
||||||
|
WHERE id = $1",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.expect("clear derivatives");
|
||||||
|
assert!(
|
||||||
|
sweep_selects(&pool, FAILED_DAYS, DELETED_HOURS)
|
||||||
|
.await
|
||||||
|
.is_empty(),
|
||||||
|
"a fully swept row must not come back"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The derivative backfill must never resurrect what the sweep just reclaimed.
|
||||||
|
///
|
||||||
|
/// PREVENTS: an interaction, not a bug in either piece. The sweep nulls `preview_path`, and
|
||||||
|
/// `backfill_stale_derivatives` selects on `display_path IS NULL AND preview_path IS NOT NULL` —
|
||||||
|
/// close enough that a future edit to either could have the backfill re-decode an original that is
|
||||||
|
/// no longer on disk, on every boot. `deleted_at IS NULL` is what keeps them apart.
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn the_backfill_ignores_swept_rows(pool: PgPool) {
|
||||||
|
let event_id = seed_event(&pool, "sweep-backfill").await;
|
||||||
|
let user_id = seed_user(&pool, event_id, "Backfill").await;
|
||||||
|
seed_aged_upload(
|
||||||
|
&pool,
|
||||||
|
event_id,
|
||||||
|
user_id,
|
||||||
|
"done",
|
||||||
|
Some(48),
|
||||||
|
"originals/e/gone.jpg",
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// SRC: `services/compression.rs::backfill_stale_derivatives` — the selection, verbatim.
|
||||||
|
let backfilled: Vec<(Uuid, String, String)> = sqlx::query_as(
|
||||||
|
"SELECT id, original_path, mime_type FROM upload
|
||||||
|
WHERE deleted_at IS NULL AND mime_type LIKE 'image/%'
|
||||||
|
AND original_path IS NOT NULL
|
||||||
|
AND (
|
||||||
|
(display_path IS NULL AND preview_path IS NOT NULL)
|
||||||
|
OR derivatives_rev < $1
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.bind(1i16)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.expect("backfill query");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
backfilled.is_empty(),
|
||||||
|
"a soft-deleted row must be invisible to the backfill, before or after sweeping"
|
||||||
|
);
|
||||||
|
}
|
||||||
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();
|
||||||
|
}
|
||||||
@@ -11,3 +11,31 @@ services:
|
|||||||
# is tolerated (warned) rather than rejected.
|
# is tolerated (warned) rather than rejected.
|
||||||
environment:
|
environment:
|
||||||
APP_ENV: development
|
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}
|
||||||
|
|||||||
@@ -17,7 +17,15 @@ services:
|
|||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
memory: 512M
|
# 1G, not 512M. DATABASE_MAX_CONNECTIONS defaults to 30 for a ~100-guest event
|
||||||
|
# (feed polling + SSE + uploads at once), and 30 backends plus Postgres 16's
|
||||||
|
# default shared_buffers leaves very little headroom at 512M. An OOM here does
|
||||||
|
# not degrade one feature — it takes the event down, because every request
|
||||||
|
# path touches the database. Memory is the cheaper knob than shrinking the
|
||||||
|
# pool back and reintroducing the queueing it was raised to fix.
|
||||||
|
#
|
||||||
|
# Raising DATABASE_MAX_CONNECTIONS further means raising this too.
|
||||||
|
memory: 1G
|
||||||
|
|
||||||
app:
|
app:
|
||||||
build:
|
build:
|
||||||
@@ -29,6 +37,12 @@ services:
|
|||||||
# Activates the production secret guard in config.rs — refuses to boot with
|
# Activates the production secret guard in config.rs — refuses to boot with
|
||||||
# placeholder JWT_SECRET / ADMIN_PASSWORD_HASH.
|
# placeholder JWT_SECRET / ADMIN_PASSWORD_HASH.
|
||||||
APP_ENV: production
|
APP_ENV: production
|
||||||
|
# The media volume is mounted at /media (below), so the app MUST write there.
|
||||||
|
# Pin it here rather than trusting .env: if MEDIA_PATH in .env points elsewhere
|
||||||
|
# (e.g. a host path used for running the backend natively) the container can't
|
||||||
|
# create it and every upload 500s with EACCES. `environment` overrides `env_file`,
|
||||||
|
# so this is authoritative for the container.
|
||||||
|
MEDIA_PATH: /media
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -40,7 +54,11 @@ services:
|
|||||||
expose:
|
expose:
|
||||||
- "3000"
|
- "3000"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "wget -q -O- http://localhost:3000/health || exit 1"]
|
# 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
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
@@ -67,7 +85,9 @@ services:
|
|||||||
expose:
|
expose:
|
||||||
- "3001"
|
- "3001"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "wget -q -O- http://localhost:3001/ >/dev/null 2>&1 || exit 1"]
|
# 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
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
@@ -80,6 +100,12 @@ services:
|
|||||||
caddy:
|
caddy:
|
||||||
image: caddy:2-alpine
|
image: caddy:2-alpine
|
||||||
restart: unless-stopped
|
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:
|
ports:
|
||||||
- "80:80"
|
- "80:80"
|
||||||
- "443:443"
|
- "443:443"
|
||||||
|
|||||||
@@ -132,15 +132,20 @@ the Host can clean up later).
|
|||||||
- **Sperren** opens a confirmation modal. Banning **always hides** the user's existing
|
- **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
|
uploads (a banned user's content is "gone" everywhere) — there is no opt-out. Submitting
|
||||||
calls `POST /host/users/{id}/ban` (no body).
|
calls `POST /host/users/{id}/ban` (no body).
|
||||||
- **Entsperren** lifts the ban.
|
- **Entsperren** lifts the ban. Same authority boundary as ban (below): a plain Host may
|
||||||
- **Host** promotes a guest to host.
|
only unban Guests; only an Admin may unban a Host.
|
||||||
- **Degradieren** — visible on Host rows. A Host can demote *other* Hosts back to
|
- **Host** promotes a guest to host (Hosts and Admins may do this).
|
||||||
guest (planned). The button is hidden on the Host's own row to prevent self-lockout;
|
- **Degradieren** — demote a Host back to guest. **Only an Admin may change a Host's
|
||||||
only an Admin can demote themselves out of moderation. Admins see Degradieren on
|
role.** A plain Host may *not* demote a peer Host: doing so would let them then ban or
|
||||||
every Host row.
|
PIN-reset (→ `/recover` account-takeover) that ex-peer, since those guards key off the
|
||||||
- **PIN zurücksetzen** (planned) — generates a new PIN and shows it once in a modal.
|
target's *current* role. So the backend rejects it (403) and the button is hidden for
|
||||||
See journey §4. Hosts see this on Guest rows only; Admins see it on Guest + Host
|
non-admin Hosts. Nobody may change their own role (self-lockout / self-escalation guard),
|
||||||
rows.
|
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
|
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
|
(`DELETE /host/upload/{id}`, `DELETE /host/comment/{id}`). On mobile this is also
|
||||||
reachable by long-pressing the content (planned, see §15).
|
reachable by long-pressing the content (planned, see §15).
|
||||||
@@ -162,7 +167,11 @@ the Host can clean up later).
|
|||||||
5. Banning **always hides**: the user's existing uploads are filtered out of the feed for
|
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
|
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
|
`is_banned = FALSE`), and a live `user-hidden` SSE event evicts their cards from every
|
||||||
open feed + the diashow without a reload.
|
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
|
## 11. Admin — instance configuration
|
||||||
|
|
||||||
@@ -184,7 +193,18 @@ the Host can clean up later).
|
|||||||
## 12. Releasing the export and downloading
|
## 12. Releasing the export and downloading
|
||||||
|
|
||||||
1. Host (or Admin) taps **Galerie freigeben** in the dashboard.
|
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
|
3. ZIP job: streams `Gallery.zip` (`Photos/` + `Videos/`, full-quality originals) directly
|
||||||
to disk via `async-zip`. Progress updates via `export-progress` SSE.
|
to disk via `async-zip`. Progress updates via `export-progress` SSE.
|
||||||
4. HTML-viewer job: copies the pre-built viewer assets from
|
4. HTML-viewer job: copies the pre-built viewer assets from
|
||||||
@@ -192,6 +212,11 @@ the Host can clean up later).
|
|||||||
`include_dir!`), generates `data.json` from the database, processes `_thumb`/`_full`
|
`include_dir!`), generates `data.json` from the database, processes `_thumb`/`_full`
|
||||||
variants for each upload, and assembles `Memories.zip`.
|
variants for each upload, and assembles `Memories.zip`.
|
||||||
5. Both jobs complete → server broadcasts `export-available` SSE.
|
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`:
|
6. Any user opens `/export`:
|
||||||
- Before release: friendly "Export not yet available" banner.
|
- Before release: friendly "Export not yet available" banner.
|
||||||
- During generation: progress bars per artifact.
|
- 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
|
||||||
|
}
|
||||||
@@ -12,10 +12,17 @@
|
|||||||
# Mirror prod's security headers (minus HSTS, which is HTTPS-only).
|
# Mirror prod's security headers (minus HSTS, which is HTTPS-only).
|
||||||
header {
|
header {
|
||||||
X-Content-Type-Options "nosniff"
|
X-Content-Type-Options "nosniff"
|
||||||
X-Frame-Options "DENY"
|
|
||||||
Referrer-Policy "strict-origin-when-cross-origin"
|
Referrer-Policy "strict-origin-when-cross-origin"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Mirror prod's export carve-out: the keepsake download targets a hidden
|
||||||
|
# same-origin iframe, and WebKit enforces XFO before Content-Disposition.
|
||||||
|
# Two disjoint matchers, not an override — see the comment in ../Caddyfile.
|
||||||
|
@framable path /api/v1/export/zip /api/v1/export/html
|
||||||
|
@not_framable not path /api/v1/export/zip /api/v1/export/html
|
||||||
|
header @framable X-Frame-Options "SAMEORIGIN"
|
||||||
|
header @not_framable X-Frame-Options "DENY"
|
||||||
|
|
||||||
reverse_proxy /api/* app:3000
|
reverse_proxy /api/* app:3000
|
||||||
reverse_proxy /media/* app:3000
|
reverse_proxy /media/* app:3000
|
||||||
reverse_proxy /health app:3000
|
reverse_proxy /health 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.
|
against a real Rust backend with rate limits and quotas disabled.
|
||||||
|
|
||||||
**Phases 1, 2, and 3-mobile-gestures are landed**:
|
**Phases 1, 2, and 3-mobile-gestures are landed**:
|
||||||
|
|
||||||
- **Phase 1** — happy-path coverage of every documented user journey, plus a
|
- **Phase 1** — happy-path coverage of every documented user journey, plus a
|
||||||
smoke matrix across nine browser/UA profiles to catch engine-level
|
smoke matrix across nine browser/UA profiles to catch engine-level
|
||||||
divergences.
|
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)
|
Every spec covers a journey from [`docs/USER_JOURNEYS.md`](../docs/USER_JOURNEYS.md)
|
||||||
or a security/chaos scenario. One folder per area:
|
or a security/chaos scenario. One folder per area:
|
||||||
|
|
||||||
| Folder | Phase | Journeys / Topic | Tests | Notes |
|
| 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/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/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/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/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/05-admin/` | 1 | §11, §16 | 11 | Config validation, foundational auth guards, stats. |
|
||||||
| `specs/06-export/` | 1 | §12 | 3 | Status, release, download stub. |
|
| `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/__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/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/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. |
|
| `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/`)
|
### Phase 2 — adversarial (`specs/07-adversarial/`)
|
||||||
|
|
||||||
- **`xss-injection.spec.ts`** — 13 tests. Six XSS payloads × display-name path
|
- **`xss-injection.spec.ts`** — 13 tests. Six XSS payloads × display-name path
|
||||||
+ four SQLi patterns + length/encoding edge cases (NUL byte, RTL override,
|
- four SQLi patterns + length/encoding edge cases (NUL byte, RTL override,
|
||||||
caption overflow). Asserts `window.__xssFired` never gets set and no
|
caption overflow). Asserts `window.__xssFired` never gets set and no
|
||||||
`dialog` event fires.
|
`dialog` event fires.
|
||||||
- **`ui-rendering.spec.ts`** — 2 tests. Belt-and-braces: even when a script-
|
- **`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
|
payload sits in localStorage as the user's display name, rendering through
|
||||||
`/account` keeps it as text.
|
`/account` keeps it as text.
|
||||||
@@ -104,17 +105,17 @@ are marked `test.fixme` and will activate when that helper lands.
|
|||||||
|
|
||||||
## Browser & UA matrix
|
## Browser & UA matrix
|
||||||
|
|
||||||
| Project | Engine | UA / Device | Why |
|
| Project | Engine | UA / Device | Why |
|
||||||
|---|---|---|---|
|
| --------------------- | -------- | ----------------------------------- | --------------------------------------------- |
|
||||||
| `chromium-desktop` | Chromium | Desktop Chrome | Baseline. Full suite runs here. |
|
| `chromium-desktop` | Chromium | Desktop Chrome | Baseline. Full suite runs here. |
|
||||||
| `chromium-pixel7` | Chromium | Pixel 7 device descriptor | Chrome Android. |
|
| `chromium-pixel7` | Chromium | Pixel 7 device descriptor | Chrome Android. |
|
||||||
| `chromium-galaxy-s22` | Chromium | Galaxy viewport + Samsung phone UA | Chrome on Samsung hardware. |
|
| `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.** |
|
| `samsung-internet` | Chromium | Galaxy viewport + SamsungBrowser UA | **Tier-A Samsung Internet baseline.** |
|
||||||
| `edge-android` | Chromium | Pixel viewport + EdgA UA | Edge Mobile (Blink-based). |
|
| `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). |
|
| `chrome-ios` | Chromium | iPhone viewport + CriOS UA | Chrome iOS (actually WebKit, but UA differs). |
|
||||||
| `webkit-iphone` | WebKit | iPhone 14 Pro | Real iOS Safari engine. |
|
| `webkit-iphone` | WebKit | iPhone 14 Pro | Real iOS Safari engine. |
|
||||||
| `firefox-android` | Firefox | Pixel viewport + Firefox Android UA | Gecko engine. |
|
| `firefox-android` | Firefox | Pixel viewport + Firefox Android UA | Gecko engine. |
|
||||||
| `firefox-desktop` | Firefox | Desktop Firefox | FF-specific quirks. |
|
| `firefox-desktop` | Firefox | Desktop Firefox | FF-specific quirks. |
|
||||||
|
|
||||||
Only the `@smoke` happy-path runs across all projects (controlled by
|
Only the `@smoke` happy-path runs across all projects (controlled by
|
||||||
`grep` in `playwright.config.ts`). The full Phase 1 suite is
|
`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
|
divergences (Smart Switch save-data mode, dark-mode injection, custom
|
||||||
autoplay, in-browser ad blocking) are only reproducible at Tier B+:
|
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`
|
Samsung Internet user-agent + Galaxy viewport. See the `samsung-internet`
|
||||||
project in `playwright.config.ts`.
|
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` →
|
install Samsung Internet APK → enable `--remote-debugging-port=9222` →
|
||||||
`chromium.connectOverCDP('http://localhost:9222')`. Setup docs live in
|
`chromium.connectOverCDP('http://localhost:9222')`. Setup docs live in
|
||||||
`docs/samsung-emulator.md` (to be written).
|
`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.
|
Real Galaxy S22/S23 hardware via Playwright's cloud integration.
|
||||||
|
|
||||||
## Test isolation
|
## Test isolation
|
||||||
@@ -223,7 +224,7 @@ Known findings surfaced (documented in tests, not silent failures):
|
|||||||
clears it).
|
clears it).
|
||||||
3. SVG uploads currently pass the magic-byte check (depends on `infer`'s
|
3. SVG uploads currently pass the magic-byte check (depends on `infer`'s
|
||||||
detection coverage) — consider adding `X-Content-Type-Options: nosniff`
|
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
|
### Phase 3 — Mobile gestures (`specs/09-mobile/`) ✅ landed
|
||||||
|
|
||||||
@@ -273,6 +274,7 @@ ignores this folder via `testIgnore` in [playwright.config.ts](playwright.config
|
|||||||
values.
|
values.
|
||||||
|
|
||||||
### Phase 3 — Real-device compat & visual / a11y (not landed)
|
### 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.
|
- 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.
|
- Safe-area inset visual diff on iPhone notch.
|
||||||
- Touch-target ≥ 44 px audit.
|
- 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.
|
- Visual regression with screenshot diffs.
|
||||||
|
|
||||||
### Out of scope (handed to other tools)
|
### Out of scope (handed to other tools)
|
||||||
|
|
||||||
- Load testing → k6 / Vegeta.
|
- Load testing → k6 / Vegeta.
|
||||||
- API contract testing → backend `cargo test` integration tests.
|
- API contract testing → backend `cargo test` integration tests.
|
||||||
- Static asset auditing → Lighthouse CI.
|
- Static asset auditing → Lighthouse CI.
|
||||||
|
|||||||
@@ -17,12 +17,12 @@ services:
|
|||||||
POSTGRES_PASSWORD: eventsnap_test
|
POSTGRES_PASSWORD: eventsnap_test
|
||||||
POSTGRES_DB: eventsnap_test
|
POSTGRES_DB: eventsnap_test
|
||||||
healthcheck:
|
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
|
interval: 3s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 30
|
retries: 30
|
||||||
ports:
|
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:
|
app:
|
||||||
build:
|
build:
|
||||||
@@ -40,15 +40,33 @@ services:
|
|||||||
ADMIN_PASSWORD_HASH: $$2b$$04$$XKJJkNX6BOi6y3S42DA5JOWwk4oxc8DHPL6.MrPfJI2vpnccZjP32
|
ADMIN_PASSWORD_HASH: $$2b$$04$$XKJJkNX6BOi6y3S42DA5JOWwk4oxc8DHPL6.MrPfJI2vpnccZjP32
|
||||||
EVENT_SLUG: e2e-test-event
|
EVENT_SLUG: e2e-test-event
|
||||||
EVENT_NAME: E2E Test Event
|
EVENT_NAME: E2E Test Event
|
||||||
APP_PORT: "3000"
|
APP_PORT: '3000'
|
||||||
MEDIA_PATH: /media
|
MEDIA_PATH: /media
|
||||||
SESSION_EXPIRY_DAYS: "30"
|
# Exports MUST live outside MEDIA_PATH — see the note on the volume below and
|
||||||
EVENTSNAP_TEST_MODE: "1" # ENABLES /admin/__truncate — never set in prod
|
# config.rs::validate. Omitting this left exports on the container's writable
|
||||||
|
# layer at the /exports default, so the test stack diverged from the prod layout
|
||||||
|
# it claims to mirror, and export-leak/export-video wrote real archives into
|
||||||
|
# ephemeral storage.
|
||||||
|
EXPORT_PATH: /exports
|
||||||
|
SESSION_EXPIRY_DAYS: '30'
|
||||||
|
EVENTSNAP_TEST_MODE: '1' # ENABLES /admin/__truncate — never set in prod
|
||||||
RUST_LOG: eventsnap_backend=info,tower_http=warn
|
RUST_LOG: eventsnap_backend=info,tower_http=warn
|
||||||
volumes:
|
volumes:
|
||||||
- media_data:/media
|
- media_data:/media
|
||||||
|
# Separate volume, exactly as in production: a keepsake archive contains every
|
||||||
|
# photo in the event, so it is kept off the media tree.
|
||||||
|
- exports_data:/exports
|
||||||
|
# Mirror production's cap (docker-compose.yml). The test stack having NO memory limit is
|
||||||
|
# why an unbounded image decode was invisible here: a 99 MP upload that would OOM-kill the
|
||||||
|
# 1 GiB production container simply succeeded in CI. A test environment more generous than
|
||||||
|
# production cannot catch a resource bug — the same shape as WebKit being absent from CI
|
||||||
|
# and /health existing only in Caddyfile.test.
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 1G
|
||||||
expose:
|
expose:
|
||||||
- "3000"
|
- '3000'
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
@@ -57,11 +75,11 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- app
|
- app
|
||||||
environment:
|
environment:
|
||||||
PORT: "3001"
|
PORT: '3001'
|
||||||
HOST: "0.0.0.0"
|
HOST: '0.0.0.0'
|
||||||
ORIGIN: "http://localhost:3101"
|
ORIGIN: 'http://localhost:3101'
|
||||||
expose:
|
expose:
|
||||||
- "3001"
|
- '3001'
|
||||||
|
|
||||||
caddy:
|
caddy:
|
||||||
image: caddy:2-alpine
|
image: caddy:2-alpine
|
||||||
@@ -71,7 +89,8 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./Caddyfile.test:/etc/caddy/Caddyfile:ro
|
- ./Caddyfile.test:/etc/caddy/Caddyfile:ro
|
||||||
ports:
|
ports:
|
||||||
- "3101:3101"
|
- '3101:3101'
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
media_data:
|
media_data:
|
||||||
|
exports_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 ───────────────────────────────────────────────────────────────
|
// ── 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', {
|
const { body } = await this.request<any>('POST', '/join', {
|
||||||
body: { display_name: displayName },
|
body: { display_name: displayName },
|
||||||
expectedStatus: [201],
|
expectedStatus: [201],
|
||||||
@@ -55,7 +57,11 @@ export class ApiClient {
|
|||||||
return body;
|
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', {
|
return this.request<any>('POST', '/recover', {
|
||||||
body: { display_name: displayName, pin },
|
body: { display_name: displayName, pin },
|
||||||
expectedStatus: opts.expectedStatus ?? [200],
|
expectedStatus: opts.expectedStatus ?? [200],
|
||||||
@@ -91,7 +97,9 @@ export class ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getConfig(adminToken: string): Promise<Record<string, string>> {
|
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;
|
return body;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,15 +117,20 @@ 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`, {
|
return this.request<void>('POST', `/host/users/${userId}/ban`, {
|
||||||
token,
|
token,
|
||||||
body: { hide_uploads: hideUploads },
|
|
||||||
expectedStatus: [200, 204],
|
expectedStatus: [200, 204],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async unbanUser(token: string, userId: string, opts: { expectedStatus?: number | number[] } = {}) {
|
async unbanUser(
|
||||||
|
token: string,
|
||||||
|
userId: string,
|
||||||
|
opts: { expectedStatus?: number | number[] } = {}
|
||||||
|
) {
|
||||||
return this.request<void>('POST', `/host/users/${userId}/unban`, {
|
return this.request<void>('POST', `/host/users/${userId}/unban`, {
|
||||||
token,
|
token,
|
||||||
expectedStatus: opts.expectedStatus ?? [200, 204],
|
expectedStatus: opts.expectedStatus ?? [200, 204],
|
||||||
@@ -136,6 +149,11 @@ export class ApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listPinResetRequests(token: string): Promise<any[]> {
|
||||||
|
const { body } = await this.request<any[]>('GET', '/host/pin-reset-requests', { token });
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
async closeEvent(token: string) {
|
async closeEvent(token: string) {
|
||||||
return this.request<void>('POST', '/host/event/close', { token, expectedStatus: [200, 204] });
|
return this.request<void>('POST', '/host/event/close', { token, expectedStatus: [200, 204] });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,16 +39,42 @@ export const db = {
|
|||||||
|
|
||||||
async expireSession(userId: string) {
|
async expireSession(userId: string) {
|
||||||
await withClient((c) =>
|
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) =>
|
await withClient((c) =>
|
||||||
c.query(`UPDATE upload SET compression_status = $2 WHERE id = $1`, [uploadId, status])
|
c.query(`UPDATE upload SET compression_status = $2 WHERE id = $1`, [uploadId, status])
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async compressionStatus(uploadId: string): Promise<string | null> {
|
||||||
|
return withClient(async (c) => {
|
||||||
|
const r = await c.query<{ compression_status: string }>(
|
||||||
|
`SELECT compression_status FROM upload WHERE id = $1`,
|
||||||
|
[uploadId]
|
||||||
|
);
|
||||||
|
return r.rows[0]?.compression_status ?? null;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Which revision of the derivative pipeline produced this row's preview/display. */
|
||||||
|
async derivativesRev(uploadId: string): Promise<number | null> {
|
||||||
|
return withClient(async (c) => {
|
||||||
|
const r = await c.query<{ derivatives_rev: number }>(
|
||||||
|
`SELECT derivatives_rev FROM upload WHERE id = $1`,
|
||||||
|
[uploadId]
|
||||||
|
);
|
||||||
|
return r.rows[0]?.derivatives_rev ?? null;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
async countUploadsForUser(userId: string): Promise<number> {
|
async countUploadsForUser(userId: string): Promise<number> {
|
||||||
return withClient(async (c) => {
|
return withClient(async (c) => {
|
||||||
const r = await c.query<{ count: string }>(
|
const r = await c.query<{ count: string }>(
|
||||||
@@ -59,6 +85,40 @@ 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);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overstate an upload's recorded size.
|
||||||
|
*
|
||||||
|
* The keepsake size estimate and the low-disk threshold are pure SQL over
|
||||||
|
* `original_size_bytes` — no file is read — so this is the lever for driving "the keepsake
|
||||||
|
* would not fit" without a genuinely full disk. The bytes on disk are unchanged; only the
|
||||||
|
* accounting the warning reads from moves.
|
||||||
|
*/
|
||||||
|
async setUploadSizeBytes(uploadId: string, bytes: number) {
|
||||||
|
await withClient((c) =>
|
||||||
|
c.query(`UPDATE upload SET original_size_bytes = $2 WHERE id = $1`, [uploadId, bytes])
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
async setExportReleased(slug: string, released: boolean) {
|
async setExportReleased(slug: string, released: boolean) {
|
||||||
await withClient((c) =>
|
await withClient((c) =>
|
||||||
c.query(`UPDATE event SET export_released_at = $2 WHERE slug = $1`, [
|
c.query(`UPDATE event SET export_released_at = $2 WHERE slug = $1`, [
|
||||||
@@ -69,26 +129,58 @@ export const db = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Flip the `export_zip_ready` gate directly. The download handler serves bytes
|
* Make an export "ready" (or not) in the epoch model. There is no `export_zip_ready` column any
|
||||||
* only when this boolean is true AND the file exists on disk, so setting it true
|
* more — readiness is DERIVED (`released AND job.epoch = event.export_epoch AND status='done'`),
|
||||||
* without a file lets tests exercise the "ready but file missing" 404 branch.
|
* 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) {
|
async setExportZipReady(slug: string, ready: boolean) {
|
||||||
await withClient((c) =>
|
await withClient((c) =>
|
||||||
c.query(`UPDATE event SET export_zip_ready = $2 WHERE slug = $1`, [slug, ready])
|
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. */
|
/**
|
||||||
async fakeExportJob(eventSlug: string, type: 'zip' | 'html', status: 'pending' | 'running' | 'done') {
|
* Insert a pre-baked export job row to skip the (slow) real compression path. 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' | 'failed',
|
||||||
|
errorMessage: string | null = null
|
||||||
|
) {
|
||||||
await withClient(async (c) => {
|
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}`);
|
if (ev.rows.length === 0) throw new Error(`No event with slug ${eventSlug}`);
|
||||||
await c.query(
|
await c.query(
|
||||||
`INSERT INTO export_job (event_id, type, status, progress_pct, completed_at)
|
`INSERT INTO export_job (event_id, type, status, progress_pct, completed_at, epoch,
|
||||||
VALUES ($1, $2::export_type, $3::export_status, $4, $5)
|
error_message)
|
||||||
ON CONFLICT (event_id, type) DO UPDATE SET status = EXCLUDED.status, progress_pct = EXCLUDED.progress_pct`,
|
VALUES ($1, $2::export_type, $3::export_status, $4, $5, $6, $7)
|
||||||
[ev.rows[0].id, type, status, status === 'done' ? 100 : 0, status === 'done' ? new Date() : null]
|
ON CONFLICT (event_id, type) DO UPDATE
|
||||||
|
SET status = EXCLUDED.status, progress_pct = EXCLUDED.progress_pct,
|
||||||
|
epoch = EXCLUDED.epoch, error_message = EXCLUDED.error_message`,
|
||||||
|
[
|
||||||
|
ev.rows[0].id,
|
||||||
|
type,
|
||||||
|
status,
|
||||||
|
status === 'done' ? 100 : 0,
|
||||||
|
status === 'done' ? new Date() : null,
|
||||||
|
ev.rows[0].export_epoch,
|
||||||
|
errorMessage,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
BIN
e2e/fixtures/media/huge-99mp.jpg
Normal file
BIN
e2e/fixtures/media/huge-99mp.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 568 KiB |
BIN
e2e/fixtures/media/portrait-exif6.jpg
Normal file
BIN
e2e/fixtures/media/portrait-exif6.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 807 B |
BIN
e2e/fixtures/media/sample-5s.mp4
Normal file
BIN
e2e/fixtures/media/sample-5s.mp4
Normal file
Binary file not shown.
BIN
e2e/fixtures/media/sample.jpg
Normal file
BIN
e2e/fixtures/media/sample.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
BIN
e2e/fixtures/media/sample.mp4
Normal file
BIN
e2e/fixtures/media/sample.mp4
Normal file
Binary file not shown.
BIN
e2e/fixtures/media/sample2.jpg
Normal file
BIN
e2e/fixtures/media/sample2.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
@@ -84,16 +84,13 @@ export const test = base.extend<Fixtures>({
|
|||||||
const fn = async (page: Page, handle: GuestHandle) => {
|
const fn = async (page: Page, handle: GuestHandle) => {
|
||||||
// Visit any in-app URL first so localStorage is scoped to the right origin.
|
// Visit any in-app URL first so localStorage is scoped to the right origin.
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await page.evaluate(
|
await page.evaluate(({ jwt, pin, userId, displayName }) => {
|
||||||
({ jwt, pin, userId, displayName }) => {
|
localStorage.setItem('eventsnap_jwt', jwt);
|
||||||
localStorage.setItem('eventsnap_jwt', jwt);
|
localStorage.setItem('eventsnap_pin', pin);
|
||||||
localStorage.setItem('eventsnap_pin', pin);
|
localStorage.setItem('eventsnap_user_id', userId);
|
||||||
localStorage.setItem('eventsnap_user_id', userId);
|
localStorage.setItem('eventsnap_display_name', displayName);
|
||||||
localStorage.setItem('eventsnap_display_name', displayName);
|
localStorage.setItem('eventsnap_guide_seen', 'true');
|
||||||
localStorage.setItem('eventsnap_guide_seen', 'true');
|
}, handle);
|
||||||
},
|
|
||||||
handle
|
|
||||||
);
|
|
||||||
await page.goto('/feed');
|
await page.goto('/feed');
|
||||||
};
|
};
|
||||||
await use(fn);
|
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