fix(review-2): medium/low + docs — config validation, hygiene, doc sync

- compression_concurrency wired to boot config (state.rs) and removed as a
  dead admin control.
- patch_config gains per-key integer/range validation so numerically-valid-but-
  nonsensical values (-1/NaN/3.5) are rejected instead of silently reverting to
  default at read time.
- clearAuth now also clears the display name (shared-device residual).
- e2e/Caddyfile.test mirrors prod security headers + the SSE encode-exclusion so
  the test stack is representative and can catch header regressions.
- .gitignore: ignore root-level Playwright artifacts (test-results/, report).
- docs: USER_JOURNEYS §10 and SECURITY-BACKLOG updated to match the implemented
  read-only-ban behavior and the export-path fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
fabi
2026-07-02 22:19:56 +02:00
parent 80179357a7
commit b3d876fa85
8 changed files with 90 additions and 29 deletions

3
.gitignore vendored
View File

@@ -22,6 +22,9 @@ e2e/playwright-report/
e2e/test-results/ e2e/test-results/
e2e/.cache/ e2e/.cache/
e2e/.env.test e2e/.env.test
# Playwright artifacts when run from the repo root instead of e2e/
/test-results/
/playwright-report/
# OS # OS
.DS_Store .DS_Store

View File

@@ -121,15 +121,18 @@ pub async fn patch_config(
// Numeric keys validated as f64; boolean keys validated as truthy strings; the // Numeric keys validated as f64; boolean keys validated as truthy strings; the
// privacy note is free text. Splitting these explicitly is verbose but makes the // privacy note is free text. Splitting these explicitly is verbose but makes the
// failure mode for typos obvious (`Unbekannter Schlüssel: ...`). // failure mode for typos obvious (`Unbekannter Schlüssel: ...`).
const NUMERIC_KEYS: &[&str] = &[ // (key, integer_only, min, max). Ranges reject values that `parse::<f64>` would
"max_image_size_mb", // accept but that silently revert to the hardcoded default at read time
"max_video_size_mb", // (get_usize/get_i64 can't parse negatives/NaN/fractionals). `compression_concurrency`
"upload_rate_per_hour", // is intentionally absent — it's read once at boot, so a live edit was a no-op.
"feed_rate_per_min", const NUMERIC_SPECS: &[(&str, bool, f64, f64)] = &[
"export_rate_per_day", ("max_image_size_mb", true, 1.0, 1024.0),
"quota_tolerance", ("max_video_size_mb", true, 1.0, 10240.0),
"estimated_guest_count", ("upload_rate_per_hour", true, 1.0, 100_000.0),
"compression_concurrency", ("feed_rate_per_min", true, 1.0, 100_000.0),
("export_rate_per_day", true, 1.0, 100_000.0),
("quota_tolerance", false, 0.0, 1.0),
("estimated_guest_count", true, 1.0, 1_000_000.0),
]; ];
const BOOL_KEYS: &[&str] = &[ const BOOL_KEYS: &[&str] = &[
"rate_limits_enabled", "rate_limits_enabled",
@@ -150,10 +153,26 @@ pub async fn patch_config(
// update behind — validation must fully precede any write. // update behind — validation must fully precede any write.
for (key, value) in &body { for (key, value) in &body {
let key_str = key.as_str(); let key_str = key.as_str();
if NUMERIC_KEYS.contains(&key_str) { if let Some(&(_, integer_only, min, max)) =
if value.parse::<f64>().is_err() { NUMERIC_SPECS.iter().find(|(k, ..)| *k == key_str)
{
let n = value.trim().parse::<f64>().ok().filter(|n| n.is_finite());
let n = match n {
Some(n) => n,
None => {
return Err(AppError::BadRequest(format!(
"Ungültiger Wert für {key}: muss eine Zahl sein."
)))
}
};
if integer_only && n.fract() != 0.0 {
return Err(AppError::BadRequest(format!( return Err(AppError::BadRequest(format!(
"Ungültiger Wert für {key}: muss eine Zahl sein." "Ungültiger Wert für {key}: muss eine ganze Zahl sein."
)));
}
if n < min || n > max {
return Err(AppError::BadRequest(format!(
"Wert für {key} liegt außerhalb des zulässigen Bereichs ({min}{max})."
))); )));
} }
} else if BOOL_KEYS.contains(&key_str) { } else if BOOL_KEYS.contains(&key_str) {
@@ -282,7 +301,7 @@ pub async fn download_zip(
)); ));
} }
let path = state.config.media_path.join("exports").join("Gallery.zip"); let path = state.config.export_path.join("Gallery.zip");
if !path.exists() { if !path.exists() {
return Err(AppError::NotFound("Exportdatei nicht gefunden.".into())); return Err(AppError::NotFound("Exportdatei nicht gefunden.".into()));
} }
@@ -308,7 +327,7 @@ pub async fn download_html(
)); ));
} }
let path = state.config.media_path.join("exports").join("Memories.zip"); let path = state.config.export_path.join("Memories.zip");
if !path.exists() { if !path.exists() {
return Err(AppError::NotFound("Exportdatei nicht gefunden.".into())); return Err(AppError::NotFound("Exportdatei nicht gefunden.".into()));
} }

View File

@@ -36,8 +36,12 @@ pub struct AppState {
impl AppState { impl AppState {
pub fn new(pool: PgPool, config: AppConfig) -> Self { pub fn new(pool: PgPool, config: AppConfig) -> Self {
let (sse_tx, _) = broadcast::channel(256); let (sse_tx, _) = broadcast::channel(256);
let compression = let compression = CompressionWorker::new(
CompressionWorker::new(pool.clone(), config.media_path.clone(), 2, sse_tx.clone()); pool.clone(),
config.media_path.clone(),
config.compression_concurrency,
sse_tx.clone(),
);
Self { Self {
pool, pool,
config, config,

View File

@@ -64,8 +64,11 @@ decompression-bomb cap (`image::Limits` 12000×12000 / 256 MiB) lives in
(not just the exact dev sentinel) and enforces `len ≥ 32` unconditionally in prod. (not just the exact dev sentinel) and enforces `len ≥ 32` unconditionally in prod.
- **Unspoofable client IP in the rate limiter** — `client_ip` now takes the right-most - **Unspoofable client IP in the rate limiter** — `client_ip` now takes the right-most
`X-Forwarded-For` entry (the hop Caddy appends), so a client-supplied left-most value is ignored. `X-Forwarded-For` entry (the hop Caddy appends), so a client-supplied left-most value is ignored.
- **Live role/ban re-check** — the auth extractor re-reads the user row and trusts the DB role and - **Live role/ban re-check** — the auth extractor re-reads the user row and trusts the DB `role`
`is_banned` flag rather than the JWT claim, revoking demoted/banned sessions immediately. and `is_banned` flag rather than the JWT claim, so a demote/ban takes effect immediately (no
30-day token window). Bans are enforced on write handlers + the host/admin extractors, preserving
the documented read-only access for banned guests (USER_JOURNEYS §10); demoted hosts lose host
powers at once.
- **Container healthchecks** — `app` and `frontend` now have healthchecks and Caddy waits on - **Container healthchecks** — `app` and `frontend` now have healthchecks and Caddy waits on
`service_healthy`. `service_healthy`.
- **Event-scoped `ban_user`** — the ban UPDATE is now scoped by `event_id` like its siblings. - **Event-scoped `ban_user`** — the ban UPDATE is now scoped by `event_id` like its siblings.

View File

@@ -147,12 +147,18 @@ the Host can clean up later).
## 10. Banned-guest experience ## 10. Banned-guest experience
1. The banned user's next authenticated request returns HTTP 403 with a clear message 1. The ban takes effect immediately on the banned user's existing session — the auth
("Du bist gesperrt."). layer re-reads the live `is_banned` flag on every request rather than trusting the
2. They can still browse the read-only feed (and download the export once it's released). JWT, so there's no 30-day token-lifetime window.
3. They cannot upload, like, or comment. 2. Their next authenticated *write* request returns HTTP 403 with a clear message
4. If `hide_uploads` was set on the ban, their existing uploads are filtered out of the ("Du bist gesperrt."). Ban enforcement lives on the write handlers and the
feed for everyone (the `v_feed` view already enforces this). host/admin extractors, not the base auth extractor.
3. They can still browse the read-only feed (and download the export once it's released).
4. They cannot upload, like, or comment; a banned host also loses all host/admin
actions (including unbanning themselves).
5. If `hide_uploads` was set on the ban, their existing uploads are filtered out of the
feed for everyone (`v_feed` enforces this), and a live `user-hidden` SSE event evicts
their cards from every open feed + the diashow without a reload.
## 11. Admin — instance configuration ## 11. Admin — instance configuration

View File

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

View File

@@ -3,11 +3,26 @@
* flow. Centralising the API contract (routes, field names, expected statuses) * flow. Centralising the API contract (routes, field names, expected statuses)
* means an API change is a one-file edit, not a 7-file hunt. * means an API change is a one-file edit, not a 7-file hunt.
*/ */
import { uploadRaw, JPEG_MAGIC } from './upload-client'; import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { uploadRaw } from './upload-client';
import { db } from '../fixtures/db'; import { db } from '../fixtures/db';
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101'; const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
// A real, decodable JPEG. Magic-bytes-only fakes now get auto-cleaned by the
// backend when compression fails (a failed transcode refunds quota + deletes the
// row), which would delete the seeded upload out from under the test. Read lazily
// at call time (cwd is the e2e dir at runtime, like the gallery-path spec) so test
// collection can't trip on a module-load read.
let sampleJpg: Buffer | null = null;
function sampleImage(): Buffer {
if (!sampleJpg) {
sampleJpg = readFileSync(join(process.cwd(), 'fixtures', 'media', 'sample.jpg'));
}
return sampleJpg;
}
export type SeedUploadOptions = { export type SeedUploadOptions = {
caption?: string; caption?: string;
/** Mark compression done so the card is fully rendered in the feed. Default true. */ /** Mark compression done so the card is fully rendered in the feed. Default true. */
@@ -16,9 +31,7 @@ export type SeedUploadOptions = {
/** Seed a real, accepted upload owned by `jwt` and return its id. */ /** Seed a real, accepted upload owned by `jwt` and return its id. */
export async function seedUpload(jwt: string, opts: SeedUploadOptions = {}): Promise<string> { export async function seedUpload(jwt: string, opts: SeedUploadOptions = {}): Promise<string> {
const body = new Uint8Array(1024); const res = await uploadRaw(jwt, sampleImage(), {
body.set(JPEG_MAGIC, 0);
const res = await uploadRaw(jwt, body, {
filename: 'a.jpg', filename: 'a.jpg',
contentType: 'image/jpeg', contentType: 'image/jpeg',
caption: opts.caption, caption: opts.caption,

View File

@@ -83,6 +83,9 @@ export function clearAuth(): void {
if (!browser) return; if (!browser) return;
localStorage.removeItem(TOKEN_KEY); localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_ID_KEY); localStorage.removeItem(USER_ID_KEY);
// Clear the display name too — on a shared device the next user shouldn't see
// the previous guest's name pre-filled on /join.
localStorage.removeItem(DISPLAY_NAME_KEY);
// PIN is intentionally kept so the user can recover // PIN is intentionally kept so the user can recover
isAuthenticated.set(false); isAuthenticated.set(false);
// Hooks fire in registration order. Keep them dependency-free of each other — // Hooks fire in registration order. Keep them dependency-free of each other —