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>
81 lines
3.2 KiB
TypeScript
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);
|
|
}
|
|
});
|
|
});
|