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>
171 lines
5.5 KiB
TypeScript
171 lines
5.5 KiB
TypeScript
/**
|
|
* Direct PostgreSQL escape hatch for setting up states the public API doesn't
|
|
* expose — e.g. forcing a user into the locked-PIN state to assert the 429
|
|
* recovery path, or expiring sessions for chaos tests.
|
|
*
|
|
* Most tests should NOT use this: prefer `ApiClient` so the tests exercise
|
|
* the same code paths real users do. Reach for direct SQL only when the API
|
|
* can't get you where you need to go.
|
|
*/
|
|
import { Client } from 'pg';
|
|
|
|
const CONN = {
|
|
host: process.env.E2E_DB_HOST ?? 'localhost',
|
|
port: Number(process.env.E2E_DB_PORT ?? '55432'),
|
|
user: process.env.E2E_DB_USER ?? 'eventsnap_test',
|
|
password: process.env.E2E_DB_PASSWORD ?? 'eventsnap_test',
|
|
database: process.env.E2E_DB_NAME ?? 'eventsnap_test',
|
|
};
|
|
|
|
async function withClient<T>(fn: (c: Client) => Promise<T>): Promise<T> {
|
|
const client = new Client(CONN);
|
|
await client.connect();
|
|
try {
|
|
return await fn(client);
|
|
} finally {
|
|
await client.end();
|
|
}
|
|
}
|
|
|
|
export const db = {
|
|
async lockUserPin(userId: string, minutesFromNow = 15) {
|
|
await withClient((c) =>
|
|
c.query(
|
|
`UPDATE "user" SET pin_locked_until = NOW() + ($2 || ' minutes')::interval, failed_pin_attempts = 3 WHERE id = $1`,
|
|
[userId, String(minutesFromNow)]
|
|
)
|
|
);
|
|
},
|
|
|
|
async expireSession(userId: string) {
|
|
await withClient((c) =>
|
|
c.query(`UPDATE session SET expires_at = NOW() - interval '1 hour' WHERE user_id = $1`, [
|
|
userId,
|
|
])
|
|
);
|
|
},
|
|
|
|
async setUploadCompressionStatus(
|
|
uploadId: string,
|
|
status: 'pending' | 'processing' | 'done' | 'failed'
|
|
) {
|
|
await withClient((c) =>
|
|
c.query(`UPDATE upload SET compression_status = $2 WHERE id = $1`, [uploadId, status])
|
|
);
|
|
},
|
|
|
|
async compressionStatus(uploadId: string): Promise<string | null> {
|
|
return withClient(async (c) => {
|
|
const r = await c.query<{ compression_status: string }>(
|
|
`SELECT compression_status FROM upload WHERE id = $1`,
|
|
[uploadId]
|
|
);
|
|
return r.rows[0]?.compression_status ?? null;
|
|
});
|
|
},
|
|
|
|
/** Which revision of the derivative pipeline produced this row's preview/display. */
|
|
async derivativesRev(uploadId: string): Promise<number | null> {
|
|
return withClient(async (c) => {
|
|
const r = await c.query<{ derivatives_rev: number }>(
|
|
`SELECT derivatives_rev FROM upload WHERE id = $1`,
|
|
[uploadId]
|
|
);
|
|
return r.rows[0]?.derivatives_rev ?? null;
|
|
});
|
|
},
|
|
|
|
async countUploadsForUser(userId: string): Promise<number> {
|
|
return withClient(async (c) => {
|
|
const r = await c.query<{ count: string }>(
|
|
`SELECT COUNT(*)::text AS count FROM upload WHERE user_id = $1 AND deleted_at IS NULL`,
|
|
[userId]
|
|
);
|
|
return Number(r.rows[0].count);
|
|
});
|
|
},
|
|
|
|
async countSessionsForUser(userId: string): Promise<number> {
|
|
return withClient(async (c) => {
|
|
const r = await c.query<{ count: string }>(
|
|
`SELECT COUNT(*)::text AS count FROM session WHERE user_id = $1`,
|
|
[userId]
|
|
);
|
|
return Number(r.rows[0].count);
|
|
});
|
|
},
|
|
|
|
async countPinResetRequestsForUser(userId: string): Promise<number> {
|
|
return withClient(async (c) => {
|
|
const r = await c.query<{ count: string }>(
|
|
`SELECT COUNT(*)::text AS count FROM pin_reset_request WHERE user_id = $1`,
|
|
[userId]
|
|
);
|
|
return Number(r.rows[0].count);
|
|
});
|
|
},
|
|
|
|
async setExportReleased(slug: string, released: boolean) {
|
|
await withClient((c) =>
|
|
c.query(`UPDATE event SET export_released_at = $2 WHERE slug = $1`, [
|
|
slug,
|
|
released ? new Date() : null,
|
|
])
|
|
);
|
|
},
|
|
|
|
/**
|
|
* Make an export "ready" (or not) in the epoch model. There is no `export_zip_ready` column any
|
|
* more — readiness is DERIVED (`released AND job.epoch = event.export_epoch AND status='done'`),
|
|
* so a job is ready exactly when its row carries the event's live epoch. To make a `done` job NOT
|
|
* ready we retire it to a dead epoch (-1), which is what a reopen effectively does.
|
|
*
|
|
* `file_path` is deliberately left NULL, so a "ready" job with no file on disk still exercises
|
|
* the download's missing-file 404 branch.
|
|
*/
|
|
async setExportZipReady(slug: string, ready: boolean) {
|
|
await withClient((c) =>
|
|
c.query(
|
|
`UPDATE export_job ej
|
|
SET epoch = CASE WHEN $2 THEN e.export_epoch ELSE -1 END
|
|
FROM event e
|
|
WHERE e.id = ej.event_id AND e.slug = $1 AND ej.type = 'zip'`,
|
|
[slug, ready]
|
|
)
|
|
);
|
|
},
|
|
|
|
/**
|
|
* Insert a pre-baked export job row to skip the (slow) real compression path. Stamped with the
|
|
* event's CURRENT epoch so it counts as the live generation.
|
|
*/
|
|
async fakeExportJob(
|
|
eventSlug: string,
|
|
type: 'zip' | 'html',
|
|
status: 'pending' | 'running' | 'done'
|
|
) {
|
|
await withClient(async (c) => {
|
|
const ev = await c.query<{ id: string; export_epoch: string }>(
|
|
`SELECT id, export_epoch FROM event WHERE slug = $1`,
|
|
[eventSlug]
|
|
);
|
|
if (ev.rows.length === 0) throw new Error(`No event with slug ${eventSlug}`);
|
|
await c.query(
|
|
`INSERT INTO export_job (event_id, type, status, progress_pct, completed_at, epoch)
|
|
VALUES ($1, $2::export_type, $3::export_status, $4, $5, $6)
|
|
ON CONFLICT (event_id, type) DO UPDATE
|
|
SET status = EXCLUDED.status, progress_pct = EXCLUDED.progress_pct,
|
|
epoch = EXCLUDED.epoch`,
|
|
[
|
|
ev.rows[0].id,
|
|
type,
|
|
status,
|
|
status === 'done' ? 100 : 0,
|
|
status === 'done' ? new Date() : null,
|
|
ev.rows[0].export_epoch,
|
|
]
|
|
);
|
|
});
|
|
},
|
|
};
|