Files
EventSnap/frontend/src/lib/components/LightboxModal.svelte
fabi dba4d3f932 fix(review-2): critical — repair comment posting + close export data leak
CR1: the lightbox comment button POSTed to /upload/{id}/comment (singular);
     no such route exists, so every UI-submitted comment 404'd and was lost.
     Fixed to /comments (plural). The prior e2e passed because its seed helper
     POSTs the API directly — added comment-ui.spec.ts which drives the real
     component so this can't regress silently again.

CR2: export archives (Gallery.zip / Memories.zip / HTML viewer) were written
     under media_path, which is a public ServeDir — so GET /media/exports/
     Gallery.zip served the entire event (every photo, caption, comment,
     uploader name) to any anonymous visitor at a guessable URL, bypassing the
     ticket + release gate. Moved exports to a separate EXPORT_PATH (=/exports)
     on its own volume (Dockerfile chown, compose volume, gated handler reads
     the new path). export-leak.spec.ts asserts /media/exports/Gallery.zip → 404.

Riders (git can't split hunks): LightboxModal also gains H3 live-eviction on
comment-deleted/upload-deleted; config.rs also wires compression_concurrency
from boot config (medium).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 22:19:32 +02:00

259 lines
8.5 KiB
Svelte

<script lang="ts">
import { onDestroy } from 'svelte';
import type { FeedUpload } from '$lib/types';
import { api } from '$lib/api';
import { onSseEvent } from '$lib/sse';
import { getUserId } from '$lib/auth';
import { dataMode, pickMediaUrl } from '$lib/data-mode-store';
import { doubletap } from '$lib/actions/doubletap';
import { focusTrap } from '$lib/actions/focus-trap';
import { scrollLock } from '$lib/actions/scroll-lock';
import { modalInert } from '$lib/actions/modal-inert';
import { toastError } from '$lib/toast-store';
import { vibrate } from '$lib/haptics';
import HeartBurst from './HeartBurst.svelte';
const COMMENT_MAX = 500;
interface CommentDto {
id: string;
upload_id: string;
user_id: string;
uploader_name: string;
body: string;
created_at: string;
}
interface Props {
upload: FeedUpload;
onclose: () => void;
onlike: (id: string) => void;
}
let { upload, onclose, onlike }: Props = $props();
let comments = $state<CommentDto[]>([]);
let newComment = $state('');
let loading = $state(false);
let userId = getUserId();
let heartBurst = $state(false);
let burstTimer: ReturnType<typeof setTimeout> | null = null;
const mediaSrc = $derived(pickMediaUrl($dataMode, upload));
function triggerHeartBurst() {
heartBurst = true;
vibrate(10);
onlike(upload.id);
if (burstTimer) clearTimeout(burstTimer);
burstTimer = setTimeout(() => (heartBurst = false), 700);
}
// Drop a comment live when it's deleted elsewhere (host moderation or the
// author on another device), so the open panel doesn't show a ghost comment.
const unsubCommentDeleted = onSseEvent('comment-deleted', (data) => {
try {
const { comment_id } = JSON.parse(data) as { comment_id: string };
comments = comments.filter((c) => c.id !== comment_id);
} catch { /* ignore */ }
});
onDestroy(() => {
if (burstTimer) clearTimeout(burstTimer);
unsubCommentDeleted();
});
// Only refetch when a *different* upload is shown. The feed reassigns the
// `upload` prop object on every SSE like/comment count update; keying the
// effect off the memoized id avoids a refetch storm that would also clobber
// a just-posted optimistic comment.
const uploadId = $derived(upload.id);
$effect(() => {
loadComments(uploadId);
});
async function loadComments(id: string) {
try {
comments = await api.get<CommentDto[]>(`/upload/${id}/comments`);
} catch {
// Background fetch — failure leaves the panel empty; reopening the lightbox retries.
}
}
async function submitComment() {
if (!newComment.trim()) return;
loading = true;
try {
const comment = await api.post<CommentDto>(`/upload/${upload.id}/comments`, {
body: newComment.trim()
});
comments = [...comments, comment];
newComment = '';
} catch (e) {
toastError(e);
} finally {
loading = false;
}
}
async function deleteComment(id: string) {
try {
await api.delete(`/comment/${id}`);
comments = comments.filter((c) => c.id !== id);
} catch (e) {
toastError(e);
}
}
function isVideo(mime: string): boolean {
return mime.startsWith('video/');
}
function formatTime(iso: string): string {
return new Date(iso).toLocaleString('de-DE', {
day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit'
});
}
</script>
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4"
role="dialog"
aria-modal="true"
aria-labelledby="lightbox-title"
use:focusTrap={{ onclose }}
use:scrollLock
use:modalInert
>
<div class="flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-xl bg-white dark:bg-gray-900">
<!-- Media -->
<div class="relative bg-black">
<button
onclick={onclose}
aria-label="Schließen"
class="absolute right-2 top-2 z-10 inline-flex min-h-11 min-w-11 items-center justify-center rounded-full bg-black/50 text-white hover:bg-black/70 active:bg-black/70"
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<div
class="relative"
use:doubletap
ondoubletap={triggerHeartBurst}
>
{#if isVideo(upload.mime_type)}
<video
src={mediaSrc}
controls
class="max-h-[60vh] w-full object-contain"
poster={upload.thumbnail_url ?? undefined}
></video>
{:else}
<img
src={mediaSrc}
alt=""
class="max-h-[60vh] w-full object-contain select-none"
draggable="false"
/>
{/if}
<HeartBurst active={heartBurst} />
</div>
</div>
<!-- Info + Comments -->
<div class="flex flex-1 flex-col overflow-hidden">
<div class="border-b border-gray-100 p-3 dark:border-gray-800">
<div class="flex items-center justify-between">
<div>
<span id="lightbox-title" class="font-medium text-gray-900 dark:text-gray-100">{upload.uploader_name}</span>
<span class="ml-2 text-xs text-gray-400 dark:text-gray-500">{formatTime(upload.created_at)}</span>
</div>
<button
onclick={() => onlike(upload.id)}
class="flex items-center gap-1 rounded-full px-2.5 py-1 text-sm transition {
upload.liked_by_me
? 'bg-red-50 text-red-600 dark:bg-red-950/40 dark:text-red-300'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'
}"
>
<svg class="h-4 w-4 {upload.liked_by_me ? 'fill-current' : ''}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
{upload.like_count}
</button>
</div>
{#if upload.caption}
<p class="mt-1 text-sm text-gray-700 dark:text-gray-300">{upload.caption}</p>
{/if}
</div>
<!-- Comments list -->
<div class="flex-1 overflow-y-auto p-3">
{#if comments.length === 0}
<p class="text-center text-sm text-gray-400 dark:text-gray-500">Noch keine Kommentare.</p>
{:else}
<div class="space-y-3">
{#each comments as comment (comment.id)}
<div class="flex items-start gap-2">
<div class="flex-1">
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">{comment.uploader_name}</span>
<span class="ml-1 text-sm text-gray-700 dark:text-gray-300">{comment.body}</span>
<div class="mt-0.5 text-xs text-gray-400 dark:text-gray-500">{formatTime(comment.created_at)}</div>
</div>
{#if comment.user_id === userId}
<button
onclick={() => deleteComment(comment.id)}
class="shrink-0 text-gray-400 hover:text-red-500 dark:text-gray-500 dark:hover:text-red-400"
aria-label="Löschen"
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{/if}
</div>
{/each}
</div>
{/if}
</div>
<!-- Comment input -->
<form
onsubmit={(e) => { e.preventDefault(); submitComment(); }}
class="border-t border-gray-100 p-3 dark:border-gray-800"
>
<div class="flex gap-2">
<input
type="text"
bind:value={newComment}
placeholder="Kommentar schreiben..."
maxlength={COMMENT_MAX}
class="flex-1 rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-blue-500 focus:outline-none dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-500"
/>
<button
type="submit"
disabled={loading || !newComment.trim()}
class="rounded-lg bg-blue-600 px-3 py-2 text-sm font-medium text-white transition hover:bg-blue-700 active:bg-blue-700 disabled:opacity-50 dark:bg-blue-500 dark:hover:bg-blue-400 dark:active:bg-blue-400"
>
Senden
</button>
</div>
<div
class="mt-1 text-right text-xs"
class:text-gray-400={newComment.length < 450}
class:dark:text-gray-500={newComment.length < 450}
class:text-amber-600={newComment.length >= 450 && newComment.length < COMMENT_MAX}
class:dark:text-amber-400={newComment.length >= 450 && newComment.length < COMMENT_MAX}
class:text-red-600={newComment.length >= COMMENT_MAX}
class:dark:text-red-400={newComment.length >= COMMENT_MAX}
>
{newComment.length}/{COMMENT_MAX}
</div>
</form>
</div>
</div>
</div>