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>
This commit is contained in:
@@ -7,8 +7,16 @@ export const showBottomNav = writable(true);
|
||||
// Controls the UploadSheet overlay. FAB sets true; sheet sets false.
|
||||
export const uploadSheetOpen = writable(false);
|
||||
|
||||
// Count of items currently pending or uploading — shown as FAB badge.
|
||||
export const uploadBadgeCount = derived(
|
||||
queueItems,
|
||||
($items) => $items.filter((i) => i.status === 'pending' || i.status === 'uploading').length
|
||||
// Count of items still needing attention — shown as FAB badge. Includes the terminal
|
||||
// states on purpose: counting only pending/uploading meant a rejected upload decremented
|
||||
// the badge exactly as if it had succeeded, so the failure was indistinguishable from a
|
||||
// completed upload. 'blocked' and 'error' stay counted until the user clears or retries them.
|
||||
export const uploadBadgeCount = derived(queueItems, ($items) =>
|
||||
$items.filter(
|
||||
(i) =>
|
||||
i.status === 'pending' ||
|
||||
i.status === 'uploading' ||
|
||||
i.status === 'error' ||
|
||||
i.status === 'blocked'
|
||||
).length
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { writable, get } from 'svelte/store';
|
||||
import { getToken, getUserId, clearAuth } from './auth';
|
||||
import { onSseEvent } from './sse';
|
||||
import { refreshQuota } from './quota-store';
|
||||
import { toast } from './toast-store';
|
||||
|
||||
export interface QueueItem {
|
||||
id: string;
|
||||
@@ -629,6 +630,11 @@ async function uploadItem(id: string): Promise<void> {
|
||||
entry.error = e.message;
|
||||
await database.put(STORE_NAME, entry);
|
||||
updateItemStatus(id, 'blocked', e.message);
|
||||
// Tell the user NOW. The queue list only lives on /upload, and the flow sends
|
||||
// them straight to /feed after staging a photo — so without this a rejected
|
||||
// upload was silently swallowed: the blob is gone, the FAB badge drops exactly
|
||||
// as if it had succeeded, and the photo simply never appears.
|
||||
toast(`${entry.fileName}: ${e.message}`, 'error');
|
||||
return;
|
||||
}
|
||||
const msg = e instanceof Error ? e.message : 'Upload fehlgeschlagen.';
|
||||
|
||||
Reference in New Issue
Block a user