fix(security): medium/low — event-lock, atomic config, a11y, diashow, hygiene

- social: likes/comments rejected on a closed event (e2e proven).
- admin: patch_config validates-then-writes atomically; char-based length.
- hashtag/comment models: atomic tag writes in edit + comment paths.
- Modal: inert the background (modal-inert action) for screen readers.
- sse.ts: idempotent visibilitychange listener (no duplicate reconnects).
- diashow: videos advance on `ended` (transitions + page wiring).
- docs/hygiene: README clone-case fix; removed stale committed .env.test and
  gitignored it; docker-compose.dev.yml tidy.

Left documented as accepted-risk per plan: PIN-persist-after-logout,
UUID-reachable hidden uploads, DECISION-media-auth.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
fabi
2026-07-01 21:25:58 +02:00
parent f08d858281
commit 91ff961850
15 changed files with 188 additions and 90 deletions

View File

@@ -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.01.0)
DEFAULT_QUOTA_TOLERANCE=0.75
# ── Workers ───────────────────────────────────────────────────────────────────
COMPRESSION_WORKER_CONCURRENCY=2

2
.gitignore vendored
View File

@@ -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/

View File

@@ -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

View File

@@ -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.

View File

@@ -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<AppState>,
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(*)

View File

@@ -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<Self, sqlx::Error> {
) -> Result<Self, sqlx::Error>
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
}

View File

@@ -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

View File

@@ -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);
});
});

View File

@@ -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 <body> 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');
}
};
}

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { focusTrap } from '$lib/actions/focus-trap';
import { scrollLock } from '$lib/actions/scroll-lock';
import { modalInert } from '$lib/actions/modal-inert';
import type { Snippet } from 'svelte';
// Accessible name is REQUIRED. Pass `titleId` when the dialog renders its own
@@ -35,24 +36,29 @@
</script>
{#if open}
<button
type="button"
class="fixed inset-0 z-50 bg-black/50"
aria-label="Schließen"
tabindex="-1"
onclick={closeOnBackdrop ? onClose : () => {}}
></button>
<div
class="pointer-events-none fixed inset-0 z-50 flex items-center justify-center p-4"
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-label={titleId ? undefined : ariaLabel}
use:focusTrap={{ onclose: onClose }}
use:scrollLock
>
<div class="pointer-events-auto w-full max-w-sm rounded-2xl bg-white p-6 shadow-xl dark:bg-gray-900">
{@render children()}
<!-- display:contents wrapper: keeps the backdrop + dialog visually unchanged
while giving `modalInert` a single node whose siblings (the background
page, nav, etc.) get inerted for assistive tech. -->
<div class="contents" use:modalInert>
<button
type="button"
class="fixed inset-0 z-50 bg-black/50"
aria-label="Schließen"
tabindex="-1"
onclick={closeOnBackdrop ? onClose : () => {}}
></button>
<div
class="pointer-events-none fixed inset-0 z-50 flex items-center justify-center p-4"
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-label={titleId ? undefined : ariaLabel}
use:focusTrap={{ onclose: onClose }}
use:scrollLock
>
<div class="pointer-events-auto w-full max-w-sm rounded-2xl bg-white p-6 shadow-xl dark:bg-gray-900">
{@render children()}
</div>
</div>
</div>
{/if}

View File

@@ -7,9 +7,11 @@
src: string;
isVideo: boolean;
durationMs: number;
/** Fired when a video slide finishes so the parent can advance early. */
onended?: () => void;
}
let { src, isVideo, durationMs }: Props = $props();
let { src, isVideo, durationMs, onended }: Props = $props();
</script>
<div
@@ -23,6 +25,7 @@
autoplay
muted
playsinline
{onended}
class="h-full w-full object-contain"
></video>
{:else}

View File

@@ -25,6 +25,8 @@ export interface TransitionProps {
src: string;
isVideo: boolean;
durationMs: number;
/** Fired when a video slide finishes so the parent can advance early. */
onended?: () => void;
}
export const transitions: SlideTransition[] = [

View File

@@ -6,9 +6,11 @@
src: string;
isVideo: boolean;
durationMs: number;
/** Fired when a video slide finishes so the parent can advance early. */
onended?: () => void;
}
let { src, isVideo, durationMs }: Props = $props();
let { src, isVideo, durationMs, onended }: Props = $props();
// Mild random pan so each slide feels different. Range chosen so the image never
// pans out of frame given the object-fit: cover.
@@ -27,6 +29,7 @@
autoplay
muted
playsinline
{onended}
class="h-full w-full object-contain"
></video>
{:else}

View File

@@ -174,15 +174,31 @@ async function deltaFetchAndFan(since: string): Promise<void> {
// Page Visibility API: close while hidden, reopen on focus. On reopen `connectSse`'s
// `onopen` runs the delta fetch.
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
disconnectSse();
} else {
// User-initiated reconnect — clear backoff so we don't wait out a long
// retry delay that was scheduled from a prior background error.
reconnectAttempt = 0;
connectSse();
}
});
function handleVisibilityChange() {
if (document.hidden) {
disconnectSse();
} else {
// User-initiated reconnect — clear backoff so we don't wait out a long
// retry delay that was scheduled from a prior background error.
reconnectAttempt = 0;
connectSse();
}
}
let visibilityBound = false;
/** Idempotent: safe to call more than once; only the first registration sticks. */
function bindVisibility() {
if (visibilityBound || typeof document === 'undefined') return;
document.addEventListener('visibilitychange', handleVisibilityChange);
visibilityBound = true;
}
/** Remove the visibility listener (e.g. on teardown / test cleanup). */
export function teardownVisibility() {
if (!visibilityBound || typeof document === 'undefined') return;
document.removeEventListener('visibilitychange', handleVisibilityChange);
visibilityBound = false;
}
bindVisibility();

View File

@@ -52,6 +52,13 @@
if (current) scheduleNext();
}
// A video finished before its dwell/12s cap — advance immediately (unless paused).
// The {#key current.id} block destroys the old <video> on advance, so a fallback
// timer that already fired can't trigger this handler for a stale slide.
function handleVideoEnded() {
if (!paused) advance();
}
async function loadInitial() {
try {
const feed = await api.get<FeedResponse>('/feed?limit=200');
@@ -163,6 +170,7 @@
src={mediaSrc}
{isVideo}
durationMs={transitionDef.defaultDurationMs}
onended={handleVideoEnded}
/>
{/key}
{:else if isEmpty()}