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:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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
|
||||||
|
|||||||
@@ -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()));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 —
|
||||||
|
|||||||
Reference in New Issue
Block a user