Files
EventSnap/frontend/src/routes/upload/+page.svelte
fabi 05948d8268 fix(upload): stop destroying originals, apply EXIF orientation, surface rejections
Three defects in the same pipeline, each of which loses a photo or misrepresents
one.

1. A transient error destroyed the guest's only copy.

`process`'s error arm unconditionally `remove_file`d the original. Every failure
routed there: `create_dir_all`, both derivative `save_with_format` calls (disk
full is the canonical case, and it arrives exactly when many guests upload at
once), a panic inside the image codec, or a momentary DB-pool exhaustion. The
row is only SOFT-deleted, so the bytes were the sole unrecoverable part — and
they were the part we deleted. The author already knew this was wrong next door:
`backfill_missing_display` says it "must NEVER soft-delete an upload that already
has a working preview".

Retry up to 3 times with backoff (re-checking the e2e generation guard after each
sleep), and on final failure keep the refund + soft-delete but leave the original
on disk, logging its path. A failed upload is now recoverable instead of gone.

2. Every portrait photo was stored sideways.

Phones don't rotate sensor data — they record the camera orientation in EXIF and
store the pixels as shot. `decode()` returns those raw pixels and the JPEG
re-encode writes no EXIF, so the 800px preview, the 2048px diashow display and
the keepsake were all rotated 90°, while "Original anzeigen" rendered upright
because the original keeps its tag. That asymmetry is why it reads as a viewer
bug. There was no EXIF handling anywhere in the repo and no exif crate.

Read the tag via `into_decoder()` (which carries the decode Limits through, so
the decompression-bomb cap is untouched) and apply it. Missing/malformed tags
fall back to NoTransforms — most images have none.

Existing derivatives are already baked wrong, so migration 018 adds
`derivatives_rev` and `backfill_missing_display` becomes
`backfill_stale_derivatives`: it now also picks up anything below the current rev
and regenerates it once from the original, which still carries its EXIF. Videos
are marked current in the migration — ffmpeg already honours the rotation matrix.
Bump DERIVATIVES_REV for any future change that invalidates derivatives.

3. A rejected upload vanished without a word.

`UploadQueue.svelte` — 162 lines holding the ONLY renderer of an item's error
text, the only "Erneut" retry button and the only rate-limit countdown — was
never imported anywhere, so `retryItem`, `removeItem` and `clearCompleted` were
unreachable at runtime. On a terminal rejection the store purged the blob and
wrote a clear German reason into `entry.error` "so the UI shows a clear reason".
There was no such UI. And `uploadBadgeCount` counted only pending/uploading, so
the badge decremented exactly as if the upload had succeeded.

Mount the queue on /upload, toast the reason immediately (the flow sends the user
to /feed straight after staging, so the list alone would still miss them), and
count blocked/error in the badge so a failure can't read as success.

Tests: 02-upload/exif-orientation uploads a 40x20 fixture tagged Orientation=6
and asserts both derivatives come back PORTRAIT, with a sanity check that the
source really is stored landscape. 02-upload/rejection-visible bans the uploader
between staging and sending, then asserts the toast, the queue row with the
server's reason, and that the item is still counted.

Note: 02-upload/quota's 4 failures are pre-existing and unrelated — see the next
commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 07:19:25 +02:00

358 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 } from '$lib/auth';
import { isStaff } from '$lib/role-store';
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 UploadQueue from '$lib/components/UploadQueue.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.
// 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>
<!--
The queue list. This component existed, complete with the per-item error text, the
"Erneut" retry button and the rate-limit countdown — and was never imported anywhere,
so none of it could be reached. A rejected upload wrote a clear reason into a store
nothing rendered.
-->
<UploadQueue />
</div>
</div>