Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
164c7d2aa3 | ||
| c8795ddfac | |||
| 0fba8defc2 | |||
| 2b57f1728e |
150
e2e/loadtest/upload-integrity-check.mjs
Normal file
150
e2e/loadtest/upload-integrity-check.mjs
Normal file
@@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Drives a REAL upload through the composer's file picker and proves the bytes survived.
|
||||
*
|
||||
* Written during the v0.18.3–v0.18.6 run of hotfixes, which were all one failure wearing
|
||||
* different masks: the browser handing the queue something that was not the file. First an
|
||||
* empty body (WebKit stores a picked `File` as a reference to an OS file iOS then deletes),
|
||||
* then — once the bytes were copied — the risk of a SHORT one, because that copy is a
|
||||
* multi-second chunked loop for a video and the same purge can land partway through it.
|
||||
*
|
||||
* Every check that missed those bugs shared one shortcut: it asserted the upload was
|
||||
* ACCEPTED. A truncated file is accepted. It passes the magic-byte sniff, the size cap and the
|
||||
* decode budget, is stored, gets a preview, and shows in the gallery — and is still not the
|
||||
* guest's video. So this asserts the only thing that actually settles it: fetch the stored
|
||||
* original back and compare its SHA-256 to the file on disk.
|
||||
*
|
||||
* Two paths matter and they are not the same code:
|
||||
* * at or below MATERIALISE_CHUNK_BYTES (4 MB) the copy is a single `arrayBuffer()`
|
||||
* * above it, a chunked loop — the one that can produce a short blob
|
||||
* Pass at least one file of each, or the run proves half of what it claims.
|
||||
*
|
||||
* Requires the sim stack (e2e/docker-compose.sim.yml) on :3102.
|
||||
*
|
||||
* node e2e/loadtest/upload-integrity-check.mjs photo.jpg big-video.mp4
|
||||
*/
|
||||
import { chromium, devices } from '@playwright/test';
|
||||
import { readFile, stat } from 'node:fs/promises';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { basename } from 'node:path';
|
||||
|
||||
const BASE = process.env.SIM_BASE ?? 'http://localhost:3102';
|
||||
const API = `${BASE}/api/v1`;
|
||||
/** Mirrors MATERIALISE_CHUNK_BYTES in frontend/src/lib/upload-queue.ts. */
|
||||
const CHUNK_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
const sha = (buf) => createHash('sha256').update(buf).digest('hex');
|
||||
|
||||
const files = process.argv.slice(2);
|
||||
if (!files.length) {
|
||||
console.error('usage: upload-integrity-check.mjs <file> [file...] (include one >4 MB)');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const browser = await chromium.launch();
|
||||
const context = await browser.newContext({ ...devices['Pixel 7'] });
|
||||
// The guide is a modal over the composer; leaving it up means the picker is never reached.
|
||||
await context.addInitScript(() => localStorage.setItem('eventsnap_guide_seen', '1'));
|
||||
const page = await context.newPage();
|
||||
const pageErrors = [];
|
||||
page.on('pageerror', (e) => pageErrors.push(String(e).slice(0, 140)));
|
||||
|
||||
await page.goto(`${BASE}/join`, { waitUntil: 'load' });
|
||||
await page.waitForTimeout(1200);
|
||||
await page.fill('input[type=text]', `Integrity ${Date.now() % 100000}`);
|
||||
await page.locator('button[type=submit]').first().click();
|
||||
await page.waitForTimeout(4500);
|
||||
for (const label of ['Weiter', 'Verstanden', 'Los geht']) {
|
||||
const btn = page.getByRole('button', { name: new RegExp(label, 'i') });
|
||||
if ((await btn.count()) && (await btn.first().isVisible())) {
|
||||
await btn.first().click();
|
||||
await page.waitForTimeout(700);
|
||||
break;
|
||||
}
|
||||
}
|
||||
const token = await page.evaluate(() => localStorage.getItem('eventsnap_jwt'));
|
||||
|
||||
// Report which upload entries the sheet offers, so a run also records whether
|
||||
// PUBLIC_CAMERA_ENABLED was in effect — the composer path differs with it.
|
||||
await page.locator('button[aria-label="Hochladen"]').last().click();
|
||||
await page.waitForTimeout(900);
|
||||
const sheet = await page.locator('body').innerText();
|
||||
console.log(
|
||||
`[sheet] Galerie=${sheet.includes('Foto oder Video wählen')} ` +
|
||||
`Kamera=${sheet.includes('Jetzt aufnehmen')}\n`
|
||||
);
|
||||
// Close it again. The FAB TOGGLES, so leaving the sheet open here makes the loop's first
|
||||
// "open the sheet" click close it instead — and the file chooser then never fires.
|
||||
await page.getByRole('button', { name: /Abbrechen/i }).first().click();
|
||||
await page.waitForTimeout(600);
|
||||
|
||||
const results = [];
|
||||
for (const path of files) {
|
||||
const name = basename(path);
|
||||
const local = await readFile(path);
|
||||
const size = (await stat(path)).size;
|
||||
const caption = `integrity ${name} ${Date.now()}`;
|
||||
const chunked = size > CHUNK_BYTES;
|
||||
|
||||
// Open the sheet fresh for every file — submitting returns to the feed, and the
|
||||
// invariant this relies on is simply that the sheet is CLOSED at the top of each pass.
|
||||
await page.locator('button[aria-label="Hochladen"]').last().click();
|
||||
await page.waitForTimeout(900);
|
||||
const chooser = page.waitForEvent('filechooser');
|
||||
await page.getByText('Foto oder Video wählen').click();
|
||||
(await chooser).setFiles(path);
|
||||
await page.waitForTimeout(2500);
|
||||
await page.locator('textarea').fill(caption);
|
||||
await page.getByRole('button', { name: /^Hochladen$/ }).first().click();
|
||||
// Generous: the queue retries, and a large file on a throttled box takes its time.
|
||||
await page.waitForTimeout(Math.max(9000, (size / 1e6) * 900));
|
||||
|
||||
// Find it server-side. The feed is the guest's own view, so this also proves the photo
|
||||
// is actually visible rather than merely stored.
|
||||
const feed = await fetch(`${API}/feed?limit=50`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
}).then((r) => r.json());
|
||||
const row = feed.uploads?.find((u) => u.caption === caption);
|
||||
if (!row) {
|
||||
results.push({ name, size, chunked, ok: false, why: 'never appeared in the feed' });
|
||||
continue;
|
||||
}
|
||||
// The whole point: compare the bytes that came BACK, not the status that went out.
|
||||
const served = Buffer.from(
|
||||
await fetch(`${API}/upload/${row.id}/original`).then((r) => r.arrayBuffer())
|
||||
);
|
||||
const ok = served.length === size && sha(served) === sha(local);
|
||||
results.push({
|
||||
name,
|
||||
size,
|
||||
chunked,
|
||||
ok,
|
||||
why: ok ? '' : `stored ${served.length} of ${size} bytes / hash mismatch`,
|
||||
});
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
|
||||
console.log('═'.repeat(66));
|
||||
console.log('UPLOAD INTEGRITY');
|
||||
console.log('═'.repeat(66));
|
||||
for (const r of results) {
|
||||
console.log(
|
||||
` ${r.ok ? '✓' : '✗'} ${r.name.padEnd(24)} ${(r.size / 1e6).toFixed(1).padStart(6)} MB ` +
|
||||
`${r.chunked ? 'chunked copy' : 'single read '} ${r.why}`
|
||||
);
|
||||
}
|
||||
if (!results.some((r) => r.chunked))
|
||||
console.log('\n ⚠ no file above 4 MB — the chunked copy path was NOT exercised');
|
||||
if (pageErrors.length) console.log(`\n page errors: ${pageErrors[0]}`);
|
||||
const failed = results.filter((r) => !r.ok).length;
|
||||
console.log(`\n${failed ? `✗ ${failed} of ${results.length} corrupted` : `✓ all ${results.length} byte-identical`}`);
|
||||
console.log('═'.repeat(66));
|
||||
process.exit(failed ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error('integrity check failed:', e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
classifyUploadStatus,
|
||||
isIncompleteBody,
|
||||
isReversibleLock,
|
||||
entryToQueueItem,
|
||||
shouldAbortForStall,
|
||||
@@ -54,6 +55,91 @@ describe('classifyUploadStatus', () => {
|
||||
* reopen and the photo resumes) or PURGES it (permanent ban / quota). Getting this wrong either
|
||||
* loses a photo the guest expected to survive a reopen, or lets a banned device retry forever.
|
||||
*/
|
||||
/**
|
||||
* Regression guard for the data loss this was written for: an iPhone guest on the live event
|
||||
* got "Error parsing `multipart/form-data` request", the item went terminal, and the ONLY copy
|
||||
* of the photo was purged from IndexedDB with no retry offered.
|
||||
*
|
||||
* The first attempt at the fix keyed on the envelope (`body.error !== 'bad_request'`) and was
|
||||
* inert, because the backend wraps the multipart error in its own `bad_request` envelope. These
|
||||
* cases are transcribed from real responses captured against the running backend, so they fail
|
||||
* if that reasoning is ever reverted.
|
||||
*/
|
||||
/**
|
||||
* The live-event failure this exists to prevent, recorded so it cannot be reintroduced.
|
||||
*
|
||||
* iPhone Safari sent POSTs to /api/v1/upload with Content-Length: 0 in 7-22 ms — measured at
|
||||
* the reverse proxy, alongside an Android upload of 6,449,056 bytes that returned 201. Cause:
|
||||
* `addToQueue` stored the picked `File` in IndexedDB, and WebKit persists that as a reference
|
||||
* to an OS file which iOS then deletes. The File keeps its name and size and reads as nothing,
|
||||
* and `xhr.send()` does not throw — it puts an empty body on the wire.
|
||||
*
|
||||
* Two rules follow, and both are asserted by the behaviour under test elsewhere in this file:
|
||||
* 1. bytes are copied at pick time, so IndexedDB owns data rather than a file reference;
|
||||
* 2. an unreadable blob is TERMINAL, never retried — retrying an empty body produced 79
|
||||
* failed requests during the event and could never have succeeded.
|
||||
*
|
||||
* Rule 2 is the one with a pure predicate to pin: a 400 whose body carries the multipart parse
|
||||
* error is only retryable when the request actually had bytes in it. `isIncompleteBody`
|
||||
* classifies the RESPONSE; the emptiness check happens before send and short-circuits it.
|
||||
*/
|
||||
describe('empty-body regression (iPhone neutered File)', () => {
|
||||
it('the server response to an empty body still looks like a truncation', () => {
|
||||
// Same 400 either way — which is exactly why the client must not rely on the response
|
||||
// to tell a truncated upload from one that never had bytes. The pre-send readability
|
||||
// probe is what separates them.
|
||||
expect(
|
||||
isIncompleteBody(400, {
|
||||
error: 'bad_request',
|
||||
message: 'Error parsing `multipart/form-data` request'
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isIncompleteBody', () => {
|
||||
const parseError = 'Error parsing `multipart/form-data` request';
|
||||
|
||||
it('the exact live failure: bad_request envelope carrying the parse error → incomplete', () => {
|
||||
expect(isIncompleteBody(400, { error: 'bad_request', message: parseError, status: 400 })).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('the same error raised mid-file, with the German prefix → incomplete', () => {
|
||||
expect(
|
||||
isIncompleteBody(400, {
|
||||
error: 'bad_request',
|
||||
message: `Datei konnte nicht gelesen werden: ${parseError}`,
|
||||
status: 400
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('an unparseable body (plain-text rejection, proxy, WAF) → incomplete', () => {
|
||||
expect(isIncompleteBody(400, null)).toBe(true);
|
||||
expect(isIncompleteBody(400, undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it('a real verdict on the file → NOT incomplete, so it still purges', () => {
|
||||
expect(
|
||||
isIncompleteBody(400, { error: 'bad_request', message: 'Datei ist zu groß. Maximum: 500 MB.' })
|
||||
).toBe(false);
|
||||
expect(
|
||||
isIncompleteBody(400, { error: 'bad_request', message: 'Keine Datei hochgeladen.' })
|
||||
).toBe(false);
|
||||
expect(
|
||||
isIncompleteBody(400, { error: 'bad_request', message: 'Dateityp wird nicht unterstützt.' })
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('only applies to 400 — other statuses keep their own rules', () => {
|
||||
expect(isIncompleteBody(413, { error: 'quota_exceeded' })).toBe(false);
|
||||
expect(isIncompleteBody(403, null)).toBe(false);
|
||||
expect(isIncompleteBody(500, null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isReversibleLock', () => {
|
||||
it('an `uploads_locked` code is reversible at any status (event closed / released)', () => {
|
||||
expect(isReversibleLock(403, 'uploads_locked')).toBe(true);
|
||||
|
||||
@@ -628,6 +628,14 @@ class TerminalError extends Error {
|
||||
*/
|
||||
class NetworkError extends Error {}
|
||||
|
||||
/**
|
||||
* The blob is in IndexedDB but its bytes are unreadable — iOS purged the OS file behind a
|
||||
* stored `File`. Deliberately NOT a NetworkError: retrying cannot bring the bytes back, and
|
||||
* treating it as transient is what produced an empty-POST retry storm during the event. The
|
||||
* guest has to re-pick the photo, and the message says so.
|
||||
*/
|
||||
class UnreadableBlobError extends Error {}
|
||||
|
||||
/**
|
||||
* The guest aborted this upload themselves (the ✕ on an in-flight row). A NetworkError
|
||||
* subclass because the transport outcome is identical — but it must NOT stop the batch or
|
||||
@@ -701,6 +709,48 @@ export function classifyUploadStatus(status: number): UploadOutcome {
|
||||
return 'transient';
|
||||
}
|
||||
|
||||
/**
|
||||
* Within the `terminal` bucket, is this 400 a TRUNCATED REQUEST rather than a verdict on the
|
||||
* file? Keep the blob and retry if so. Pure + exported for the same reason as
|
||||
* `isReversibleLock`: it decides whether a guest keeps their photo.
|
||||
*
|
||||
* Keyed on the MESSAGE, not the envelope. The obvious rule — "an app-raised 400 carries
|
||||
* `bad_request`, so a 400 without it is Axum's plain-text rejection" — does not hold, and was
|
||||
* verified against the running backend rather than reasoned about:
|
||||
*
|
||||
* stream breaks between parts → 400 application/json
|
||||
* {"error":"bad_request","message":"Error parsing `multipart/…"}
|
||||
* stream breaks mid-file → 400 application/json, same code, message prefixed
|
||||
* "Datei konnte nicht gelesen werden: …"
|
||||
* no boundary in Content-Type → 400 text/plain "Invalid `boundary` for `multipart/…"
|
||||
*
|
||||
* Only the third is Axum's own extractor rejection. The first two — the ones a webview or a
|
||||
* dropping mobile link actually produce — never reach it: the handler pulls the fields itself
|
||||
* and wraps `MultipartError` in `AppError::BadRequest` (`upload.rs` field loop and chunk loop),
|
||||
* so they arrive as an ordinary `bad_request` envelope, indistinguishable by code from "file too
|
||||
* large" or "caption too long". An envelope check therefore never fires for the case this
|
||||
* exists to catch. Confirmed live: the log line for a real guest failure and for a synthetic
|
||||
* truncation are byte-identical.
|
||||
*
|
||||
* Retrying is safe: nothing was parsed, so nothing was stored and no quota was charged, and
|
||||
* `X-Client-Upload-Id` makes a duplicate impossible even if the server did see it.
|
||||
*
|
||||
* The substring is Axum's `MultipartError` Display text and is therefore an UPSTREAM contract
|
||||
* this file does not own — an axum upgrade could reword it and silently re-open the data loss.
|
||||
* The durable fix is a distinct backend code (e.g. `body_incomplete`) that this can prefer once
|
||||
* it exists; the match is kept as the fallback because it needs no app-image release.
|
||||
*/
|
||||
export function isIncompleteBody(status: number, body: unknown): boolean {
|
||||
if (status !== 400) return false;
|
||||
const envelope = body as { error?: unknown; message?: unknown } | null | undefined;
|
||||
// An unparseable body (proxy, WAF, captive portal) cannot be a considered rejection either.
|
||||
if (!envelope || envelope.error !== 'bad_request') return true;
|
||||
return (
|
||||
typeof envelope.message === 'string' &&
|
||||
envelope.message.includes('Error parsing `multipart/form-data` request')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Within the `terminal` bucket, decide whether a 4xx is a REVERSIBLE lock (keep the blob,
|
||||
* park retryable for a host reopen) rather than a permanent rejection (purge the blob).
|
||||
@@ -839,7 +889,49 @@ export async function releaseResolvedParks(state: {
|
||||
|
||||
/** Outcome of an `addToQueue` call, so the caller can tell the user when a file was NOT
|
||||
* actually queued (deduped, or the queue is full of un-evictable in-flight items). */
|
||||
export type EnqueueResult = 'queued' | 'duplicate' | 'full';
|
||||
export type EnqueueResult = 'queued' | 'duplicate' | 'full' | 'unreadable';
|
||||
|
||||
/** Chunk size for `materialise`. Bounds peak JS heap, not total copy size. */
|
||||
const MATERIALISE_CHUNK_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Copy a picked file's bytes into a Blob this origin owns, so IndexedDB stores DATA rather
|
||||
* than a reference to an OS file that iOS will delete. See the call site in `addToQueue` for
|
||||
* why that reference is the bug.
|
||||
*
|
||||
* Chunked deliberately. `new Blob([await file.arrayBuffer()])` is one line and correct for a
|
||||
* 3 MB photo, but it pulls the whole file into the JS heap — and this queue accepts videos up
|
||||
* to 500 MB, where that would very likely get the tab killed by the OS. Trading a crash for a
|
||||
* failed upload is not a fix. Reading a slice at a time and letting each chunk become its own
|
||||
* Blob keeps peak heap at one chunk; the browser's blob store owns the accumulated parts and
|
||||
* can spill them to disk, which is exactly where a half-gigabyte video should live.
|
||||
*/
|
||||
async function materialise(file: File): Promise<Blob> {
|
||||
let out: Blob;
|
||||
if (file.size <= MATERIALISE_CHUNK_BYTES) {
|
||||
out = new Blob([await file.arrayBuffer()], { type: file.type });
|
||||
} else {
|
||||
const parts: Blob[] = [];
|
||||
for (let offset = 0; offset < file.size; offset += MATERIALISE_CHUNK_BYTES) {
|
||||
const slice = file.slice(offset, offset + MATERIALISE_CHUNK_BYTES);
|
||||
parts.push(new Blob([await slice.arrayBuffer()]));
|
||||
}
|
||||
out = new Blob(parts, { type: file.type });
|
||||
}
|
||||
// Verify the copy. The purge this whole function exists to defeat can also land PARTWAY
|
||||
// THROUGH the loop above: a 500 MB video is many seconds of reading, and once the OS file
|
||||
// is gone the remaining slices read as nothing. `new Blob` is happy to build a short blob
|
||||
// out of them, and short is far worse than absent — it uploads, the server stores it, and
|
||||
// the guest gets a truncated video that looks like it worked. A read that returns fewer
|
||||
// bytes than the file claims is never legitimate, so refuse it here, while the guest is
|
||||
// still holding the phone and can pick the file again.
|
||||
if (out.size !== file.size) {
|
||||
throw new UnreadableBlobError(
|
||||
'Diese Datei konnte nicht vollständig gelesen werden — bitte wähle sie noch einmal aus.'
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function addToQueue(
|
||||
file: File,
|
||||
@@ -887,6 +979,31 @@ export async function addToQueue(
|
||||
// This id is also the server-side idempotency key (`client_upload_id`), so it is minted
|
||||
// exactly ONCE per file here and reused by every retry — see uploadItem.
|
||||
const id = uuid();
|
||||
// MATERIALISE THE BYTES. Do not store the `File` itself.
|
||||
//
|
||||
// WebKit persists a File in IndexedDB as a REFERENCE to the OS backing file rather than a
|
||||
// copy of its contents. iOS purges that file soon after the picker closes, which leaves a
|
||||
// "neutered File": `.name` and `.size` still read correctly, so nothing looks wrong, but
|
||||
// the bytes are gone. WebKit then does NOT throw on `xhr.send()` — the note at the send
|
||||
// site assumed it would — it puts the request on the wire with an EMPTY BODY, the server
|
||||
// cannot parse a multipart with no parts, and the guest sees a 400.
|
||||
//
|
||||
// Measured on the live event rather than inferred: every failing iPhone upload reached
|
||||
// Caddy with `Content-Length: 0` in 7-22 ms, while an Android upload in the same minute
|
||||
// sent 6,449,056 bytes and got a 201.
|
||||
//
|
||||
// Reading the file here makes IndexedDB own real bytes that no OS purge can reach. It
|
||||
// costs one full read at pick time, which is also the moment the file is guaranteed still
|
||||
// readable — the picker has only just handed it over.
|
||||
// A file the browser cannot fully read is not a queueable item. Returning a result rather
|
||||
// than throwing keeps the composer's per-file loop intact: one bad photo out of five must
|
||||
// not abandon the other four, which is what an exception here would do.
|
||||
let blob: Blob;
|
||||
try {
|
||||
blob = await materialise(file);
|
||||
} catch {
|
||||
return 'unreadable';
|
||||
}
|
||||
const entry: QueueEntry = {
|
||||
id,
|
||||
userId,
|
||||
@@ -897,7 +1014,7 @@ export async function addToQueue(
|
||||
caption,
|
||||
hashtags,
|
||||
status: 'pending',
|
||||
blob: file
|
||||
blob
|
||||
};
|
||||
await storePut(entry);
|
||||
|
||||
@@ -1070,6 +1187,11 @@ async function processQueue(): Promise<void> {
|
||||
// NetworkError, which it extends.)
|
||||
continue;
|
||||
}
|
||||
if (e instanceof UnreadableBlobError) {
|
||||
// This one photo is unrecoverable, but the others in the queue may be fine
|
||||
// (a re-picked copy, or one taken after the fix). Keep draining.
|
||||
continue;
|
||||
}
|
||||
if (e instanceof NetworkError) {
|
||||
// Connectivity dropped mid-flight. If offline the item is back to 'pending'
|
||||
// and the `online` listener resumes it; if the failure hit while nominally
|
||||
@@ -1120,7 +1242,26 @@ async function uploadItem(id: string): Promise<void> {
|
||||
// and charging the guest's quota twice. Both 200 (deduped) and 201 (created) are
|
||||
// success; `classifyUploadStatus` already treats the whole 2xx range that way.
|
||||
formData.append('client_upload_id', entry.id);
|
||||
formData.append('file', entry.blob, entry.fileName);
|
||||
// Never send a body we cannot read. `entry.blob.size` is NOT sufficient on WebKit: a
|
||||
// neutered File keeps its metadata and reports the original size while reading as
|
||||
// nothing. Only an actual read tells the truth, so probe one byte.
|
||||
//
|
||||
// This covers items queued BEFORE the materialise-on-pick fix above, which are still
|
||||
// sitting in IndexedDB holding a dead File reference. Without it those retry until the
|
||||
// budget is spent, every attempt an empty POST — 79 of them during the event.
|
||||
const blob = entry.blob;
|
||||
let readable = false;
|
||||
try {
|
||||
readable = (await blob.slice(0, 1).arrayBuffer()).byteLength > 0;
|
||||
} catch {
|
||||
readable = false;
|
||||
}
|
||||
if (!readable && entry.fileSize > 0) {
|
||||
throw new UnreadableBlobError(
|
||||
'Dieses Foto ist auf dem Gerät nicht mehr lesbar — bitte wähle es noch einmal aus.'
|
||||
);
|
||||
}
|
||||
formData.append('file', blob, entry.fileName);
|
||||
if (entry.caption) formData.append('caption', entry.caption);
|
||||
if (entry.hashtags) formData.append('hashtags', entry.hashtags);
|
||||
|
||||
@@ -1252,23 +1393,10 @@ async function uploadItem(id: string): Promise<void> {
|
||||
);
|
||||
break;
|
||||
case 'terminal': {
|
||||
// A 400 the APP raised always carries `bad_request` in a JSON envelope
|
||||
// (too large, wrong type, caption too long, NUL byte). Axum's own multipart
|
||||
// rejection does not: it is a PLAIN-TEXT 400 ("Error parsing
|
||||
// `multipart/form-data` request"), so `body` is null here.
|
||||
//
|
||||
// That distinction decides whether a guest keeps their photo. An
|
||||
// unparseable 400 means the request body never arrived intact — a transport
|
||||
// failure, not a verdict on the file — and it is exactly what an iOS in-app
|
||||
// browser (WhatsApp) produces when it truncates an XHR upload. Classified as
|
||||
// terminal, it purged the blob from IndexedDB and offered no retry, so a
|
||||
// webview hiccup destroyed the only copy the guest had.
|
||||
//
|
||||
// Same reasoning the 403 rule below already applies to an unparseable body,
|
||||
// and safe to retry: nothing was parsed, so nothing was stored and no quota
|
||||
// was charged — and `X-Client-Upload-Id` makes a duplicate impossible even
|
||||
// if the server did see it.
|
||||
if (xhr.status === 400 && body?.error !== 'bad_request') {
|
||||
// A truncated body is a transport failure, not a verdict on the file, so it
|
||||
// must not purge the blob. See `isIncompleteBody` for why the envelope alone
|
||||
// cannot decide this.
|
||||
if (isIncompleteBody(xhr.status, body)) {
|
||||
settle(() =>
|
||||
reject(new NetworkError('Übertragung unvollständig — bitte erneut versuchen'))
|
||||
);
|
||||
@@ -1451,6 +1579,20 @@ async function uploadItem(id: string): Promise<void> {
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
if (e instanceof UnreadableBlobError) {
|
||||
// The bytes are gone from the browser's storage (iOS purged the OS file behind a
|
||||
// stored `File`). No retry can recover them, so this is terminal — but unlike a
|
||||
// server rejection the photo itself is fine and still in the camera roll, so the
|
||||
// message asks for a re-pick rather than reporting the file as refused. Dropping
|
||||
// the dead blob also frees the queue slot for the re-picked copy.
|
||||
delete entry.blob;
|
||||
entry.status = 'blocked';
|
||||
entry.error = e.message;
|
||||
await storePut(entry);
|
||||
updateItemStatus(id, 'blocked', e.message);
|
||||
toast(`${entry.fileName}: ${e.message}`, 'error', 8000);
|
||||
throw e;
|
||||
}
|
||||
if (e instanceof TerminalError) {
|
||||
// Permanent rejection — drop the blob (we'll never resend it) and mark blocked
|
||||
// so the UI shows a clear reason and offers no retry.
|
||||
|
||||
@@ -163,6 +163,16 @@
|
||||
}
|
||||
const result = await addToQueue(sf.file, caption, hashtagsString);
|
||||
if (result === 'full') full++;
|
||||
// The browser could not read this file's bytes (iOS purges the OS file behind a
|
||||
// picked photo). Named per file rather than counted like `full`: the guest has to
|
||||
// find and re-pick this specific one, so a bare number would not be actionable.
|
||||
if (result === 'unreadable') {
|
||||
toast(
|
||||
`„${sf.file.name}“ konnte nicht gelesen werden. Bitte wähle das Foto noch einmal aus.`,
|
||||
'error',
|
||||
8000
|
||||
);
|
||||
}
|
||||
}
|
||||
// Don't let a full queue silently swallow photos the user thinks were queued.
|
||||
if (full > 0) {
|
||||
|
||||
Reference in New Issue
Block a user