Two bugs surfaced while running the new E2E suite, plus a small test hook:
- jwt.rs: add a per-token `jti: Uuid` claim. Without it, two `create_token`
calls in the same wall-clock second for the same (sub, role, event_id)
produced identical JWT bytes — and identical sha256(token) hashes —
which then collided on `session.token_hash UNIQUE` with a 500. Manifests
in real use when an admin clicks "Anmelden" twice fast.
- auth/handlers.rs: reject display names containing 0x00. Postgres rejects
NUL in TEXT columns with `invalid byte sequence for encoding "UTF8"` and
the request leaks back as a 500. Now returns 400 with a clean message.
- handlers/test_admin.rs + main.rs: new POST /api/v1/admin/__truncate route,
compiled in always but only **registered** when EVENTSNAP_TEST_MODE=1 is
set on startup. Truncates every event-scoped table, reseeds config from
migration defaults, wipes media on disk, and clears the in-memory rate
limiter. RequireAdmin-gated so it's not anonymous even in test mode. In
production builds (no env var) the route returns 404 — verified by the
startup log message.
- services/rate_limiter.rs: add `clear()` so the truncate handler can wipe
the in-memory window map between tests.
- Dockerfile: bump rust:1.87 → rust:1.88 (current dep tree needs it) and
COPY ./migrations into the build context so the `sqlx::migrate!()` macro
can resolve at compile time.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Foundations for the v0.16 features. No new endpoints here — those land in
the next commit on top of these.
- migrations 008 + 009: commit the load-bearing compression_status column
that was uncommitted on disk; add 009_feature_toggles seeding the master
+ per-endpoint rate-limit switches, the master + per-area quota switches,
and the admin-editable privacy_note.
- services/config.rs (new): get_str / get_i64 / get_usize / get_f64 / get_bool
consolidating the scattered helpers that lived in three handlers.
- services/maintenance.rs (new):
- startup_recovery() — resets compression_status='processing' and
export_job.status='running' rows orphaned by a previous crashed
instance, so users never see permanent "Wird vorbereitet…" spinners.
- spawn_periodic_tasks() — hourly cleanup of expired sessions (rows
were never pruned) + rate-limiter HashMap pruning (windows kept one
entry per IP forever).
- services/jobs.rs (new sketch): BackgroundJob trait + JobContext for
future jobs to plug into the same progress + SSE pipeline as
compression/export. Not wired yet — codifies the convention.
- services/compression.rs: 120s hard timeout + kill_on_drop on ffmpeg
so a malformed video can't hang and leak a worker semaphore permit.
- services/rate_limiter.rs: new prune() called from the periodic task.
- state.rs: SseEvent::new() constructor so event-type strings stay
consistent instead of being typed inline at every emit site.
- models/user.rs: UserRole::as_str() for /me/context serialization.
- models/upload.rs: soft_delete() now runs in a transaction and
decrements the uploader's total_upload_bytes (GREATEST(0, …) guard) —
fixes a quota drift where deleting reclaimed no quota.
- Cargo.toml + Cargo.lock: add `infer = "0.15"` (multipart MIME sniffing
used by the upload handler).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Backend: rate limiter gains check_with_retry() returning seconds until
the next slot opens. Upload 429 responses include retry_after_secs in
JSON and a Retry-After header.
Frontend: upload queue catches 429 as RateLimitError, resets affected
item to pending, schedules processQueue() for the server-reported delay,
and shows a live countdown banner in the queue UI.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add sliding-window in-memory RateLimiter service (Arc<Mutex<HashMap>>)
with per-IP and per-user-id limits on all public endpoint classes:
- POST /api/v1/join: 5/min per IP
- GET /api/v1/feed: configurable per IP (feed_rate_per_min, default 60)
- POST /api/v1/upload: configurable per user (upload_rate_per_hour, default 10)
- GET /api/v1/export/zip|html: configurable per IP (export_rate_per_day, default 3)
Limits are hot-reloadable via the config table. All 429 responses use
German error messages. Client IP is read from X-Forwarded-For (Caddy).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>