fix(upload): a retry after release returns the stored photo instead of refusing it

The idempotency key was only readable as a multipart FIELD, and a field cannot
be read until the body is being parsed — which happens after the lock/release
pre-flight. So the replay was unreachable in exactly the case it exists for:

  the photo commits → the response is lost on the way back (the flaky-wifi
  failure the key was added for) → the host releases the gallery at the end of
  the night → the phone's retry answers `gallery_released`.

The guest is told a photo that is sitting in the gallery was never sent. And
the remedy the client offers is destructive: `open_event` clears
`export_released_at` AND bumps `export_epoch`, retiring the whole keepsake
generation and forcing a multi-GB rebuild on a 2-vCPU box at midnight — to
re-send a photo that was never missing. Several guests on one flaky evening
make this likely to happen at least once.

The key is now also sent as `X-Client-Upload-Id`, which arrives with the
request line, so the answer is knowable before anything is decided about
locks. The multipart field stays for the concurrent case and as a fallback.

Placed ahead of the hourly rate limiter too, which was the same mistake one
layer up: a 40-photo burst with two retries apiece exhausted the guest's hour
on uploads that had all committed the first time.

The body is still drained rather than abandoned — replying before reading it
makes the proxy see a broken pipe and turn a clean 200 into a 502.

The spec carries its own control: a DIFFERENT photo is asserted to still be
refused with `gallery_released` after the release, so the replay cannot be
green merely because the gate was open.
This commit is contained in:
fabi
2026-08-12 23:11:57 +02:00
parent 137b892480
commit ac04e27e34
4 changed files with 150 additions and 1 deletions

View File

@@ -7,6 +7,7 @@ import { BASE } from './env';
*
* Field shape matches [backend/src/handlers/upload.rs]:
* - file (binary; carries filename + content_type in the part headers)
* - client_upload_id (uuid, optional; also sent as the X-Client-Upload-Id header)
* - caption (text, optional)
* - hashtags (CSV text, optional)
*/
@@ -17,6 +18,12 @@ export type UploadOptions = {
contentType?: string;
caption?: string;
hashtags?: string;
/**
* Idempotency key. Sent BOTH as the `X-Client-Upload-Id` header and as the multipart field,
* exactly as the real client does — the header is what lets the server replay a stored upload
* before it evaluates the lock/release gate, and the field covers the concurrent case.
*/
clientUploadId?: string;
};
export async function uploadRaw(
@@ -29,9 +36,12 @@ export async function uploadRaw(
form.append('file', blob as any, opts.filename ?? 'upload.bin');
if (opts.caption !== undefined) form.append('caption', opts.caption);
if (opts.hashtags !== undefined) form.append('hashtags', opts.hashtags);
if (opts.clientUploadId !== undefined) form.append('client_upload_id', opts.clientUploadId);
const headers: Record<string, string> = { Authorization: `Bearer ${token}` };
if (opts.clientUploadId !== undefined) headers['X-Client-Upload-Id'] = opts.clientUploadId;
return fetch(`${BASE}/api/v1/upload`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
headers,
body: form as any,
});
}