Files
EventSnap/e2e/specs/02-upload/exif-orientation.spec.ts
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

81 lines
3.2 KiB
TypeScript

/**
* Regression guard — EXIF orientation must be applied when generating derivatives.
*
* Phones do not rotate sensor data. They shoot in the sensor's native landscape and record
* how the camera was held in an EXIF `Orientation` tag. `image`'s `decode()` returns the raw
* pixels and ignores that tag, and the JPEG re-encode writes no EXIF at all — so every
* portrait photo was stored SIDEWAYS in the 800px feed preview, the 2048px diashow display
* and the keepsake, while "Original anzeigen" still rendered it upright (the original keeps
* its tag). That asymmetry is why it reads as a viewer bug instead of a pipeline one.
*
* The fixture is 40x20 landscape pixels tagged Orientation=6 ("rotate 90° CW to display"),
* so a correctly-processed derivative is PORTRAIT (20x40). Asserting on the aspect ratio
* rather than the bytes keeps this robust across encoder changes.
*/
import { test, expect } from '../../fixtures/test';
import { uploadRaw } from '../../helpers/upload-client';
import { BASE } from '../../helpers/env';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
const EXIF_FIXTURE = join(process.cwd(), 'fixtures', 'media', 'portrait-exif6.jpg');
/**
* Read a baseline/progressive JPEG's pixel dimensions from its SOF marker.
* Avoids pulling an image dependency into the suite for one assertion.
*/
function jpegSize(buf: Buffer): { width: number; height: number } {
let i = 2; // skip SOI
while (i < buf.length) {
if (buf[i] !== 0xff) {
i++;
continue;
}
const marker = buf[i + 1];
// SOF0..SOF15, excluding DHT (c4), JPGA (c8) and DAC (cc)
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
return { height: buf.readUInt16BE(i + 5), width: buf.readUInt16BE(i + 7) };
}
i += 2 + buf.readUInt16BE(i + 2);
}
throw new Error('no SOF marker found — not a JPEG?');
}
test.describe('Upload — EXIF orientation', () => {
test('a rotated photo is upright in the preview and the display derivative', async ({
guest,
db,
}) => {
const g = await guest('SidewaysShooter');
const res = await uploadRaw(g.jwt, readFileSync(EXIF_FIXTURE), {
filename: 'portrait-exif6.jpg',
contentType: 'image/jpeg',
caption: 'hochkant',
});
expect(res.status).toBe(201);
const { id } = (await res.json()) as { id: string };
// Sanity: the SOURCE really is stored landscape with the tag, otherwise this test
// could pass against a pipeline that does nothing.
const source = jpegSize(readFileSync(EXIF_FIXTURE));
expect(source.width).toBeGreaterThan(source.height);
await expect
.poll(() => db.compressionStatus(id), { timeout: 30_000, intervals: [250] })
.toBe('done');
for (const variant of ['preview', 'display'] as const) {
const r = await fetch(`${BASE}/api/v1/upload/${id}/${variant}`, {
headers: { Authorization: `Bearer ${g.jwt}` },
});
expect(r.status, `${variant} must be served`).toBe(200);
const { width, height } = jpegSize(Buffer.from(await r.arrayBuffer()));
expect(
height,
`${variant} must be portrait (${width}x${height}) — EXIF orientation was not applied`
).toBeGreaterThan(width);
}
});
});