diff --git a/.env.test b/.env.test deleted file mode 100644 index b204567..0000000 --- a/.env.test +++ /dev/null @@ -1,45 +0,0 @@ -# ── Domain ──────────────────────────────────────────────────────────────────── -# Public domain Caddy will serve and obtain a TLS certificate for. -DOMAIN=my-event.example.com - -# ── App server ──────────────────────────────────────────────────────────────── -APP_PORT=3000 - -# ── Database ────────────────────────────────────────────────────────────────── -DATABASE_URL=postgres://eventsnap:secret@db:5432/eventsnap -POSTGRES_USER=eventsnap -POSTGRES_PASSWORD=secret -POSTGRES_DB=eventsnap - -# ── Authentication ──────────────────────────────────────────────────────────── -# Generate with: openssl rand -hex 64 -JWT_SECRET=change_me_to_a_random_64_byte_hex_string -SESSION_EXPIRY_DAYS=30 - -# Admin dashboard password (bcrypt hash). -# Generate with: htpasswd -bnBC 12 "" yourpassword | tr -d ':\n' -ADMIN_PASSWORD_HASH=$2y$12$placeholder_replace_me - -# ── Event ───────────────────────────────────────────────────────────────────── -EVENT_NAME=Max & Maria's Wedding -EVENT_SLUG=max-maria-2026 - -# ── Storage ─────────────────────────────────────────────────────────────────── -MEDIA_PATH=/media - -# ── Upload limits ───────────────────────────────────────────────────────────── -DEFAULT_MAX_IMAGE_SIZE_MB=20 -DEFAULT_MAX_VIDEO_SIZE_MB=500 - -# ── Rate limiting ───────────────────────────────────────────────────────────── -DEFAULT_UPLOAD_RATE_PER_HOUR=10 -DEFAULT_FEED_RATE_PER_MIN=60 -DEFAULT_EXPORT_RATE_PER_DAY=3 - -# ── Capacity ────────────────────────────────────────────────────────────────── -DEFAULT_ESTIMATED_GUEST_COUNT=100 -# Fraction of total storage that triggers the "low storage" warning (0.0–1.0) -DEFAULT_QUOTA_TOLERANCE=0.75 - -# ── Workers ─────────────────────────────────────────────────────────────────── -COMPRESSION_WORKER_CONCURRENCY=2 diff --git a/.gitignore b/.gitignore index 55d4381..d03afd4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ # Environment secrets — never commit the real .env .env +# Stale local scratch copy of .env.example; nothing in the test stack reads it. +.env.test # Rust backend/target/ diff --git a/README.md b/README.md index 91f5cea..a3db1fe 100644 --- a/README.md +++ b/README.md @@ -94,9 +94,9 @@ eventsnap/ ### Deploy on a fresh VPS ```bash -# 1. Clone the repository -git clone https://git.mc02.dev/fabi/EventSnap.git -cd EventSnap +# 1. Clone the repository (into a lowercase dir, matching the paths used below) +git clone https://git.mc02.dev/fabi/EventSnap.git eventsnap +cd eventsnap # 2. Configure environment cp .env.example .env diff --git a/backend/src/handlers/admin.rs b/backend/src/handlers/admin.rs index 69f0753..987a1f0 100644 --- a/backend/src/handlers/admin.rs +++ b/backend/src/handlers/admin.rs @@ -146,6 +146,8 @@ pub async fn patch_config( let mut privacy_note_changed = false; + // Validate every key first so a bad value in the batch can't leave a partial + // update behind — validation must fully precede any write. for (key, value) in &body { let key_str = key.as_str(); if NUMERIC_KEYS.contains(&key_str) { @@ -164,7 +166,9 @@ pub async fn patch_config( } } } else if TEXT_KEYS.contains(&key_str) { - if value.len() > PRIVACY_NOTE_MAX_LEN { + // Count characters, not bytes — the message says "Zeichen" and a + // multi-byte grapheme shouldn't count against the limit multiple times. + if value.chars().count() > PRIVACY_NOTE_MAX_LEN { return Err(AppError::BadRequest(format!( "Wert für {key} ist zu lang (max. {PRIVACY_NOTE_MAX_LEN} Zeichen)." ))); @@ -177,16 +181,21 @@ pub async fn patch_config( "Unbekannter Konfigurationsschlüssel: {key}" ))); } + } + // Apply all writes in one transaction — the batch is all-or-nothing. + let mut tx = state.pool.begin().await?; + for (key, value) in &body { sqlx::query( "INSERT INTO config (key, value, updated_at) VALUES ($1, $2, NOW()) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()", ) .bind(key) .bind(value) - .execute(&state.pool) + .execute(&mut *tx) .await?; } + tx.commit().await?; // 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. diff --git a/backend/src/handlers/social.rs b/backend/src/handlers/social.rs index 79d83d0..168f6e0 100644 --- a/backend/src/handlers/social.rs +++ b/backend/src/handlers/social.rs @@ -12,6 +12,18 @@ use crate::models::hashtag::{self, Hashtag}; use crate::models::upload::Upload; use crate::state::AppState; +/// Reject the request when the event's uploads (and, by extension, social +/// interaction) are locked. Mirrors the guard in the upload handler. +async fn require_event_open(state: &AppState) -> Result<(), AppError> { + 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.uploads_locked_at.is_some() { + return Err(AppError::Forbidden("Das Event ist geschlossen.".into())); + } + Ok(()) +} + pub async fn toggle_like( State(state): State, auth: AuthUser, @@ -31,6 +43,9 @@ pub async fn toggle_like( .await? .ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?; + // A closed event freezes social interaction too, matching the upload handler. + require_event_open(&state).await?; + // Try to insert; if conflict, delete (toggle) let result = sqlx::query( "INSERT INTO \"like\" (upload_id, user_id) VALUES ($1, $2) @@ -120,6 +135,9 @@ pub async fn add_comment( .await? .ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?; + // A closed event freezes social interaction too, matching the upload handler. + require_event_open(&state).await?; + let text = body.body.trim(); let text_chars = text.chars().count(); if text_chars == 0 || text_chars > 500 { @@ -128,20 +146,22 @@ pub async fn add_comment( )); } - let comment = Comment::create(&state.pool, upload_id, auth.user_id, text).await?; - - // Process hashtags in comment body + // Insert the comment and link its hashtags atomically, so a crash mid-loop + // can't leave a committed comment with only some of its tags indexed. let tags = hashtag::extract_hashtags(text); + let mut tx = state.pool.begin().await?; + let comment = Comment::create(&mut *tx, upload_id, auth.user_id, text).await?; for tag in &tags { - let h = Hashtag::upsert(&state.pool, auth.event_id, tag).await?; + let h = Hashtag::upsert(&mut *tx, auth.event_id, tag).await?; sqlx::query( "INSERT INTO comment_hashtag (comment_id, hashtag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING", ) .bind(comment.id) .bind(h.id) - .execute(&state.pool) + .execute(&mut *tx) .await?; } + tx.commit().await?; // Fresh count so feed clients can patch the single card in place instead of // refetching page 1 (mirrors v_feed.comment_count = COUNT(DISTINCT c.id); COUNT(*) diff --git a/backend/src/models/comment.rs b/backend/src/models/comment.rs index ec08e26..ac5d0cc 100644 --- a/backend/src/models/comment.rs +++ b/backend/src/models/comment.rs @@ -24,19 +24,24 @@ pub struct CommentDto { } impl Comment { - pub async fn create( - pool: &PgPool, + /// Takes any executor so the caller can insert the comment and link its + /// hashtags inside a single transaction. + pub async fn create<'e, E>( + executor: E, upload_id: Uuid, user_id: Uuid, body: &str, - ) -> Result { + ) -> Result + where + E: sqlx::PgExecutor<'e>, + { sqlx::query_as::<_, Self>( "INSERT INTO comment (upload_id, user_id, body) VALUES ($1, $2, $3) RETURNING *", ) .bind(upload_id) .bind(user_id) .bind(body) - .fetch_one(pool) + .fetch_one(executor) .await } diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 48b4937..2d341c3 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -6,3 +6,8 @@ services: db: ports: - "5432:5432" + app: + # Relax the production secret guard for local dev — the dev sentinel JWT_SECRET + # is tolerated (warned) rather than rejected. + environment: + APP_ENV: development diff --git a/e2e/specs/04-host/event-lock.spec.ts b/e2e/specs/04-host/event-lock.spec.ts index 4925a22..37d311b 100644 --- a/e2e/specs/04-host/event-lock.spec.ts +++ b/e2e/specs/04-host/event-lock.spec.ts @@ -33,4 +33,39 @@ test.describe('Host — event lock', () => { // Currently no UI consumes the event-closed SSE on /feed. Add this banner // and flip fixme to test once it lands. }); + + // Regression for the review: likes/comments used to ignore uploads_locked_at, + // so social writes still landed on a closed event. They now share the upload + // handler's lock guard. + test('a closed event rejects likes and comments', async ({ api, host, guest }) => { + const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101'; + const g = await guest('SocialLocked'); + + // Upload while still open so there's a target to interact with. + const { uploadRaw } = await import('../../helpers/upload-client'); + const { readFileSync } = await import('node:fs'); + const { join } = await import('node:path'); + const sample = join(process.cwd(), 'fixtures', 'media', 'sample.jpg'); + const upRes = await uploadRaw(g.jwt, readFileSync(sample), { + filename: 'x.jpg', + contentType: 'image/jpeg', + }); + expect(upRes.status).toBe(201); + const { id } = await upRes.json(); + + await api.closeEvent(host.jwt); + + const likeRes = await fetch(`${BASE}/api/v1/upload/${id}/like`, { + method: 'POST', + headers: { Authorization: `Bearer ${g.jwt}` }, + }); + expect(likeRes.status).toBe(403); + + const commentRes = await fetch(`${BASE}/api/v1/upload/${id}/comments`, { + method: 'POST', + headers: { Authorization: `Bearer ${g.jwt}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ body: 'sollte blockiert sein' }), + }); + expect(commentRes.status).toBe(403); + }); }); diff --git a/frontend/src/lib/actions/modal-inert.ts b/frontend/src/lib/actions/modal-inert.ts new file mode 100644 index 0000000..8b9233f --- /dev/null +++ b/frontend/src/lib/actions/modal-inert.ts @@ -0,0 +1,29 @@ +// Make everything *outside* a modal's subtree inert while it's open, so screen +// readers and tab order can't reach the background (focus-trap only covers +// keyboard focus; `inert` also hides the content from assistive tech). +// +// Walks from the node up to and marks every sibling along the path as +// inert, then restores exactly those it changed on teardown. Applying it to a +// `display: contents` wrapper keeps the modal's own backdrop + dialog interactive. +export function modalInert(node: HTMLElement) { + const changed: HTMLElement[] = []; + + let el: HTMLElement | null = node; + while (el && el !== document.body) { + const parent: HTMLElement | null = el.parentElement; + if (!parent) break; + for (const sib of Array.from(parent.children)) { + if (sib !== el && sib instanceof HTMLElement && !sib.hasAttribute('inert')) { + sib.setAttribute('inert', ''); + changed.push(sib); + } + } + el = parent; + } + + return { + destroy() { + for (const sib of changed) sib.removeAttribute('inert'); + } + }; +} diff --git a/frontend/src/lib/components/Modal.svelte b/frontend/src/lib/components/Modal.svelte index da5ef79..1de7285 100644 --- a/frontend/src/lib/components/Modal.svelte +++ b/frontend/src/lib/components/Modal.svelte @@ -1,6 +1,7 @@ {#if open} - -