Files
EventSnap/frontend/src/routes/upload/+page.svelte
MechaCat02 44641473ea fix(quota): stop /me/quota leaking raw disk to guests
The per-user quota widget was shown to everyone and the /me/quota payload returned
free_disk_bytes (raw server free space) and active_uploaders to any authenticated
guest. Gate the widget to staff (host/admin) on the upload and account pages, and zero
the server-wide telemetry fields for non-staff in the handler. Guests still get their
own used/limit so enforcement stays transparent.

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

349 lines
12 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { goto } from '$app/navigation';
import { getToken, getRole } from '$lib/auth';
import { addToQueue, loadQueue } from '$lib/upload-queue';
import { toast } from '$lib/toast-store';
import { showBottomNav } from '$lib/ui-store';
import { pendingFiles, pendingCaption, clearPending } from '$lib/pending-upload-store';
import { get } from 'svelte/store';
import { onMount, onDestroy } from 'svelte';
import { quotaStore, refreshQuota } from '$lib/quota-store';
import ConfirmSheet from '$lib/components/ConfirmSheet.svelte';
import IconButton from '$lib/components/IconButton.svelte';
import { vibrate } from '$lib/haptics';
import type { PendingFile } from '$lib/pending-upload-store';
// StagedFile is just PendingFile under a domain-specific name (previewUrl + file).
type StagedFile = PendingFile;
let stagedFiles = $state<StagedFile[]>([]);
let caption = $state('');
let submitting = $state(false);
let captionEl: HTMLTextAreaElement;
let discardConfirmOpen = $state(false);
const MAX_CAPTION_LENGTH = 2000;
// The storage widget is staff-only (host/admin). Guests never see server-derived
// storage figures — the backend also zeroes the raw-disk fields for non-staff, so
// this is UI-consistency on top of an API guarantee, not the security boundary.
const isStaff = getRole() === 'host' || getRole() === 'admin';
// Quick-tag chips derived from caption as the user types
let captionTags = $derived.by(() => {
const matches = [...caption.matchAll(/#(\w+)/g)];
return [...new Set(matches.map((m) => m[1].toLowerCase()))];
});
onMount(() => {
showBottomNav.set(false);
if (!getToken()) {
goto('/join');
return;
}
loadQueue();
void refreshQuota();
// Pull staged files from the pending store (written by UploadSheet)
const pf = get(pendingFiles);
const pc = get(pendingCaption);
stagedFiles = pf;
caption = pc;
// Auto-focus caption textarea after a short delay (let layout settle)
setTimeout(() => captionEl?.focus(), 80);
// Warn before a hard browser navigation (close / reload / external link) drops
// staged files or an unsent caption. In-app navigation is already guarded by
// the discard ConfirmSheet in cancel().
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
if (stagedFiles.length > 0 || caption.trim().length > 0) {
e.preventDefault();
e.returnValue = '';
}
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload);
};
});
onDestroy(() => {
showBottomNav.set(true);
// Revoke any staged preview blob URLs on leave so they don't leak. The queue
// holds the underlying File objects, so this is safe after submit too.
for (const sf of stagedFiles) URL.revokeObjectURL(sf.previewUrl);
});
function removeFile(idx: number) {
const removed = stagedFiles[idx];
URL.revokeObjectURL(removed.previewUrl);
stagedFiles = stagedFiles.filter((_, i) => i !== idx);
}
function cancel() {
if (stagedFiles.length > 0 || caption.trim().length > 0) {
discardConfirmOpen = true;
return;
}
clearPending();
goto('/feed');
}
function confirmDiscard() {
discardConfirmOpen = false;
clearPending();
goto('/feed');
}
async function handleSubmit() {
if (stagedFiles.length === 0 || submitting) return;
if (caption.length > MAX_CAPTION_LENGTH) return;
submitting = true;
vibrate(10);
const hashtagsString = captionTags.join(',');
let full = 0;
for (const sf of stagedFiles) {
const result = await addToQueue(sf.file, caption, hashtagsString);
if (result === 'full') full++;
}
// Don't let a full queue silently swallow photos the user thinks were queued.
if (full > 0) {
toast(
`Warteschlange voll ${full} ${full === 1 ? 'Foto' : 'Fotos'} nicht hinzugefügt. Bitte warte, bis laufende Uploads fertig sind.`,
'error',
6000
);
}
clearPending();
goto('/feed');
}
function isVideo(file: File): boolean {
return file.type.startsWith('video/');
}
function formatBytes(bytes: number | null | undefined): string {
if (bytes == null || bytes <= 0) return '0 MB';
const mb = bytes / (1024 * 1024);
if (mb < 1024) return `${mb.toFixed(mb < 10 ? 1 : 0)} MB`;
return `${(mb / 1024).toFixed(1)} GB`;
}
const totalStagedBytes = $derived(stagedFiles.reduce((sum, sf) => sum + sf.file.size, 0));
const quotaPercent = $derived(
$quotaStore.limit_bytes && $quotaStore.limit_bytes > 0
? Math.min(100, (($quotaStore.used_bytes + totalStagedBytes) / $quotaStore.limit_bytes) * 100)
: 0
);
</script>
<!-- Full-screen composer — bottom nav is suppressed -->
<div class="flex min-h-screen flex-col bg-white dark:bg-gray-950">
<!-- Header -->
<div
class="flex items-center justify-between border-b border-gray-100 px-4 py-3 pt-[calc(env(safe-area-inset-top)+0.75rem)] dark:border-gray-800"
>
<IconButton label="Abbrechen" onclick={cancel}>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</IconButton>
<h1 class="text-base font-semibold text-gray-900 dark:text-gray-100">Neuer Beitrag</h1>
<!-- Submit button in header for desktop convenience -->
<button
onclick={handleSubmit}
disabled={stagedFiles.length === 0 || submitting}
data-testid="upload-submit-header"
class="btn btn-primary btn-sm"
>
{submitting ? 'Wird hochgeladen…' : 'Hochladen'}
</button>
</div>
<div class="flex flex-1 flex-col overflow-y-auto">
<!-- Thumbnail strip -->
{#if stagedFiles.length > 0}
<div class="flex gap-2 overflow-x-auto px-4 py-3 scrollbar-none">
{#each stagedFiles as sf, i (sf.previewUrl)}
<div
class="relative h-20 w-20 shrink-0 overflow-hidden rounded-xl bg-gray-100 dark:bg-gray-800"
>
{#if isVideo(sf.file)}
<div
class="flex h-full w-full items-center justify-center bg-gray-800 dark:bg-gray-700"
>
<svg class="h-7 w-7 text-white/70" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
</div>
{:else}
<img src={sf.previewUrl} alt="" class="h-full w-full object-cover" />
{/if}
<button
onclick={() => removeFile(i)}
class="absolute right-1 top-1 flex h-8 w-8 items-center justify-center rounded-full bg-black/60 text-white"
aria-label="Entfernen"
>
<svg
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="3"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
</div>
{/each}
</div>
<div class="border-b border-gray-100 dark:border-gray-800"></div>
{:else}
<!-- No files: prompt to go back and pick some -->
<div class="flex flex-1 flex-col items-center justify-center gap-4 p-8 text-center">
<div
class="flex h-20 w-20 items-center justify-center rounded-2xl bg-primary-50 dark:bg-primary-900/20"
>
<svg
class="h-9 w-9 text-primary-500 dark:text-primary-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M9 9.75h.008v.008H9V9.75zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zM6 20.25h12A2.25 2.25 0 0020.25 18V6A2.25 2.25 0 0018 3.75H6A2.25 2.25 0 003.75 6v12A2.25 2.25 0 006 20.25z"
/>
</svg>
</div>
<div>
<p class="font-medium text-gray-500 dark:text-gray-400">Keine Dateien ausgewählt</p>
<p class="mt-1 text-sm text-gray-400 dark:text-gray-500">
Geh zurück und tippe auf den Plus-Button.
</p>
</div>
<button onclick={cancel} class="btn btn-secondary btn-sm"> Zurück </button>
</div>
{/if}
<!-- Caption textarea -->
<div class="px-4 pt-4">
<textarea
bind:this={captionEl}
bind:value={caption}
maxlength={MAX_CAPTION_LENGTH}
data-testid="upload-caption"
placeholder="Beschreibung hinzufügen… (#hashtags möglich)"
rows="4"
class="input resize-none text-sm"
></textarea>
<div class="mt-1 text-xs text-gray-500 text-right dark:text-gray-400">
{caption.length} / {MAX_CAPTION_LENGTH}
</div>
</div>
<!-- Quick-tag chips (derived from typed caption) -->
{#if captionTags.length > 0}
<div class="flex flex-wrap gap-1.5 px-4 pt-2">
{#each captionTags as tag (tag)}
<span
class="rounded-full bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-blue-600 dark:bg-blue-950/40 dark:text-blue-300"
>
#{tag}
</span>
{/each}
</div>
{/if}
<!-- Per-user quota — staff-only (never shown to guests), and also hidden when
admin disabled enforcement. -->
{#if isStaff && $quotaStore.enabled && $quotaStore.limit_bytes != null}
<div class="px-4 pt-3 text-xs text-gray-500 dark:text-gray-400">
<div class="flex items-center justify-between">
<span
>Speicher: {formatBytes($quotaStore.used_bytes + totalStagedBytes)} / {formatBytes(
$quotaStore.limit_bytes
)}</span
>
<span
class:text-amber-600={quotaPercent >= 80}
class:dark:text-amber-400={quotaPercent >= 80}
class:text-red-600={quotaPercent >= 95}
class:dark:text-red-400={quotaPercent >= 95}
>
{Math.round(quotaPercent)}%
</span>
</div>
<div class="mt-1 h-1.5 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
<div
class="h-full transition-all"
class:bg-blue-500={quotaPercent < 80}
class:bg-amber-500={quotaPercent >= 80 && quotaPercent < 95}
class:bg-red-500={quotaPercent >= 95}
style="width: {quotaPercent}%"
></div>
</div>
{#if quotaPercent >= 100}
<p class="mt-1 font-medium text-red-600 dark:text-red-400">
Limit erreicht — bitte alte Beiträge löschen.
</p>
{:else if quotaPercent >= 95}
<p class="mt-1 font-medium text-amber-600 dark:text-amber-400">Fast voll.</p>
{/if}
</div>
{/if}
<div class="h-8"></div>
</div>
<!-- Discard confirmation — appears only when the composer has unsaved content. -->
<ConfirmSheet
open={discardConfirmOpen}
title="Verwerfen?"
message="Deine Auswahl und der Text gehen verloren."
confirmLabel="Verwerfen"
cancelLabel="Weiter bearbeiten"
tone="danger"
onConfirm={confirmDiscard}
onCancel={() => (discardConfirmOpen = false)}
/>
<!-- Sticky submit button at bottom (mobile-primary) -->
<div class="border-t border-gray-100 px-4 py-3 dark:border-gray-800">
<button
onclick={handleSubmit}
disabled={stagedFiles.length === 0 || submitting}
data-testid="upload-submit"
class="btn btn-primary btn-block"
>
{#if submitting}
<svg class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
></path>
</svg>
Wird hochgeladen…
{:else}
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"
/>
</svg>
{stagedFiles.length > 0
? `${stagedFiles.length} Datei${stagedFiles.length > 1 ? 'en' : ''} hochladen`
: 'Hochladen'}
{/if}
</button>
</div>
</div>