Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c8795ddfac | |||
| 0fba8defc2 | |||
| 2b57f1728e | |||
|
|
2dd563b3ee |
15
.env.example
15
.env.example
@@ -231,6 +231,21 @@ COMMENTS_ENABLED=true
|
|||||||
# Prefer a bigger disk if you can: ~45 GB holds a 9.7 GB library WITH the keepsake.
|
# Prefer a bigger disk if you can: ~45 GB holds a 9.7 GB library WITH the keepsake.
|
||||||
KEEPSAKE_ENABLED=true
|
KEEPSAKE_ENABLED=true
|
||||||
|
|
||||||
|
# ── In-app camera ─────────────────────────────────────────────────────────────
|
||||||
|
# Whether the upload sheet offers "Kamera — Jetzt aufnehmen" alongside "Galerie".
|
||||||
|
# Read at RUNTIME by the frontend container, so changing it is this line plus
|
||||||
|
# `docker compose up -d frontend` — no rebuild.
|
||||||
|
#
|
||||||
|
# Set to false when the in-app camera misbehaves on the guests' actual phones: switching
|
||||||
|
# between front and back throwing "Kamera konnte nicht gestartet werden", or video capture
|
||||||
|
# failing its permission prompt. Those failures are per-device and cannot be diagnosed
|
||||||
|
# mid-event, so this removes the broken path instead of letting guests find it.
|
||||||
|
#
|
||||||
|
# Nothing is lost by turning it off. The gallery picker opens the phone's own file chooser,
|
||||||
|
# which reaches the camera app on both iOS and Android and handles video — it is the path
|
||||||
|
# most guests use anyway. The onboarding text and the upload sheet adjust themselves.
|
||||||
|
PUBLIC_CAMERA_ENABLED=true
|
||||||
|
|
||||||
# ── Logging ───────────────────────────────────────────────────────────────────
|
# ── Logging ───────────────────────────────────────────────────────────────────
|
||||||
# SET THIS IN PRODUCTION. Without it the app falls back to
|
# SET THIS IN PRODUCTION. Without it the app falls back to
|
||||||
# `eventsnap_backend=debug,tower_http=debug` (see main.rs), and with TraceLayer that is a
|
# `eventsnap_backend=debug,tower_http=debug` (see main.rs), and with TraceLayer that is a
|
||||||
|
|||||||
@@ -226,6 +226,14 @@ services:
|
|||||||
# produces `https://` here and collapses the Caddyfile's site block below, so the stack
|
# produces `https://` here and collapses the Caddyfile's site block below, so the stack
|
||||||
# comes up with no TLS and no site and the only symptom is a browser error.
|
# comes up with no TLS and no site and the only symptom is a browser error.
|
||||||
ORIGIN: "https://${DOMAIN:?set DOMAIN in .env}"
|
ORIGIN: "https://${DOMAIN:?set DOMAIN in .env}"
|
||||||
|
# In-app camera switch, read at RUNTIME by adapter-node — so flipping it is this line
|
||||||
|
# plus `docker compose up -d frontend`, not a rebuild. Set it to "false" when
|
||||||
|
# `getUserMedia` misbehaves on the guests' phones (front/back switching throwing
|
||||||
|
# "Kamera konnte nicht gestartet werden", video capture failing its permission prompt).
|
||||||
|
# Guests then upload through the gallery picker, which still reaches the phone's own
|
||||||
|
# camera app and handles video. Lives on the FRONTEND service, not `app`: the backend
|
||||||
|
# cannot tell a camera upload from a gallery upload and has no stake in the choice.
|
||||||
|
PUBLIC_CAMERA_ENABLED: "${PUBLIC_CAMERA_ENABLED:-true}"
|
||||||
# V8 sizes its old-space heap from the cgroup limit, but lands on ~101% of it (measured:
|
# V8 sizes its old-space heap from the cgroup limit, but lands on ~101% of it (measured:
|
||||||
# heap_size_limit 259 MB inside a 256M container). So the JS heap ceiling sits ABOVE the
|
# heap_size_limit 259 MB inside a 256M container). So the JS heap ceiling sits ABOVE the
|
||||||
# container's entire budget — before base RSS (~60-90 MB), the C++ heap, or SSR response
|
# container's entire budget — before base RSS (~60-90 MB), the C++ heap, or SSR response
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ services:
|
|||||||
PORT: '3001'
|
PORT: '3001'
|
||||||
HOST: '0.0.0.0'
|
HOST: '0.0.0.0'
|
||||||
ORIGIN: 'http://localhost:3102'
|
ORIGIN: 'http://localhost:3102'
|
||||||
|
PUBLIC_CAMERA_ENABLED: ${SIM_CAMERA:-true}
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import { scrollLock } from '$lib/actions/scroll-lock';
|
import { scrollLock } from '$lib/actions/scroll-lock';
|
||||||
import { vibrate } from '$lib/haptics';
|
import { vibrate } from '$lib/haptics';
|
||||||
import { hasSeenGuide, markGuideSeen } from '$lib/onboarding';
|
import { hasSeenGuide, markGuideSeen } from '$lib/onboarding';
|
||||||
|
import { cameraEnabled } from '$lib/feature-flags';
|
||||||
|
|
||||||
type Step =
|
type Step =
|
||||||
| { kind: 'text'; icon: string; title: string; body: string }
|
| { kind: 'text'; icon: string; title: string; body: string }
|
||||||
@@ -30,7 +31,13 @@
|
|||||||
kind: 'text',
|
kind: 'text',
|
||||||
icon: '⬆️',
|
icon: '⬆️',
|
||||||
title: 'Fotos & Videos hochladen',
|
title: 'Fotos & Videos hochladen',
|
||||||
body: 'Tippe auf den Kamera-Button unten in der Mitte, um Fotos aus deiner Galerie zu wählen oder direkt mit der Kamera aufzunehmen. Mehrere Dateien auf einmal sind kein Problem!'
|
// The second half is conditional: with the in-app camera switched off, promising
|
||||||
|
// "direkt mit der Kamera aufnehmen" describes a button that is not there. The
|
||||||
|
// gallery picker still reaches the phone's camera app on both iOS and Android, so
|
||||||
|
// the capability survives — only the in-app shortcut is gone.
|
||||||
|
body: cameraEnabled
|
||||||
|
? 'Tippe auf den Kamera-Button unten in der Mitte, um Fotos aus deiner Galerie zu wählen oder direkt mit der Kamera aufzunehmen. Mehrere Dateien auf einmal sind kein Problem!'
|
||||||
|
: 'Tippe auf den Kamera-Button unten in der Mitte und wähle Fotos oder Videos aus deiner Galerie. Frisch aufnehmen kannst du direkt in der Auswahl deines Handys. Mehrere Dateien auf einmal sind kein Problem!'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
kind: 'text',
|
kind: 'text',
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
import type { PendingFile } from '$lib/pending-upload-store';
|
import type { PendingFile } from '$lib/pending-upload-store';
|
||||||
import { eventState, uploadsClosed } from '$lib/event-state-store';
|
import { eventState, uploadsClosed } from '$lib/event-state-store';
|
||||||
import { commentsEnabled } from '$lib/event-config-store';
|
import { commentsEnabled } from '$lib/event-config-store';
|
||||||
|
import { cameraEnabled } from '$lib/feature-flags';
|
||||||
import { isBanned } from '$lib/ban-store';
|
import { isBanned } from '$lib/ban-store';
|
||||||
|
|
||||||
// A ban closes uploads just as hard as an event lock does — the backend refuses every
|
// A ban closes uploads just as hard as an event lock does — the backend refuses every
|
||||||
@@ -150,8 +151,12 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Camera (rendered outside sheet so it gets full viewport) -->
|
<!-- Camera (rendered outside sheet so it gets full viewport).
|
||||||
{#if showCamera}
|
`cameraEnabled` is checked here as well as on the button: `showCamera` is ordinary
|
||||||
|
component state, and a belt-and-braces guard means no future entry point (a deep link, a
|
||||||
|
restored state, a stray keyboard shortcut) can mount the capture UI while it is switched
|
||||||
|
off for the event. -->
|
||||||
|
{#if showCamera && cameraEnabled}
|
||||||
<CameraCapture
|
<CameraCapture
|
||||||
oncapture={handleCapture}
|
oncapture={handleCapture}
|
||||||
onclose={handleCameraClose}
|
onclose={handleCameraClose}
|
||||||
@@ -262,7 +267,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Camera option -->
|
<!-- Camera option. Hidden entirely when PUBLIC_CAMERA_ENABLED=false: on some devices
|
||||||
|
`getUserMedia` fails when switching front/back or when asked for video, and a
|
||||||
|
button that throws an error modal is worse than no button. The gallery entry
|
||||||
|
above still reaches the OS camera and handles video. -->
|
||||||
|
{#if cameraEnabled}
|
||||||
<button
|
<button
|
||||||
onclick={openCamera}
|
onclick={openCamera}
|
||||||
class="flex w-full items-center gap-4 rounded-xl bg-gray-50 px-5 py-4 text-left transition hover:bg-gray-100 active:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 dark:active:bg-gray-600"
|
class="flex w-full items-center gap-4 rounded-xl bg-gray-50 px-5 py-4 text-left transition hover:bg-gray-100 active:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 dark:active:bg-gray-600"
|
||||||
@@ -294,6 +303,7 @@
|
|||||||
<p class="text-sm text-gray-500 dark:text-gray-400">Jetzt aufnehmen</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">Jetzt aufnehmen</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- The in-app-browser escape hatch.
|
<!-- The in-app-browser escape hatch.
|
||||||
The join link travels through WhatsApp groups, and a link tapped inside one opens
|
The join link travels through WhatsApp groups, and a link tapped inside one opens
|
||||||
@@ -311,8 +321,8 @@
|
|||||||
hint they cannot act on into an instruction they can. -->
|
hint they cannot act on into an instruction they can. -->
|
||||||
<p class="px-1 pt-1 text-center text-xs text-gray-500 dark:text-gray-400">
|
<p class="px-1 pt-1 text-center text-xs text-gray-500 dark:text-gray-400">
|
||||||
Nichts passiert beim Tippen? Dann bist du wahrscheinlich im Browser von WhatsApp o. Ä.
|
Nichts passiert beim Tippen? Dann bist du wahrscheinlich im Browser von WhatsApp o. Ä.
|
||||||
Öffne diese Seite in Safari oder Chrome — dort funktionieren Kamera und Galerie. (Im Menü
|
Öffne diese Seite in Safari oder Chrome — dort funktioniert die Auswahl. (Im Menü des
|
||||||
des In-App-Browsers: „In Safari öffnen“ bzw. „Im Browser öffnen“.)
|
In-App-Browsers: „In Safari öffnen“ bzw. „Im Browser öffnen“.)
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
36
frontend/src/lib/feature-flags.ts
Normal file
36
frontend/src/lib/feature-flags.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { env } from '$env/dynamic/public';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build-independent feature switches read from the frontend container's environment.
|
||||||
|
*
|
||||||
|
* Deliberately NOT routed through the backend's `/api/v1/event` payload the way
|
||||||
|
* `comments_enabled` is. That flag describes the EVENT (whether guests may comment at all);
|
||||||
|
* this one describes what the CLIENT can do on the device in front of it. The backend has no
|
||||||
|
* stake in how bytes were captured — an upload from the camera and an upload from the gallery
|
||||||
|
* arrive on the same endpoint, indistinguishable — so putting the switch on the server would
|
||||||
|
* add a schema, a DTO field and a release of the app image to answer a question only the
|
||||||
|
* browser can ask.
|
||||||
|
*
|
||||||
|
* `$env/dynamic/public` is read at RUNTIME by adapter-node, so this is a compose variable and
|
||||||
|
* a restart, not a rebuild.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Interpret a flag the same way `config.rs` does, so operators only learn one convention. */
|
||||||
|
function flag(value: string | undefined, fallback: boolean): boolean {
|
||||||
|
if (value === undefined || value.trim() === '') return fallback;
|
||||||
|
return !['false', '0', 'no', 'off'].includes(value.trim().toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the in-app camera is offered (`PUBLIC_CAMERA_ENABLED`, default true).
|
||||||
|
*
|
||||||
|
* Turned off for events where `getUserMedia` misbehaves on the guests' actual phones —
|
||||||
|
* switching between front and back cameras throwing "Kamera konnte nicht gestartet werden",
|
||||||
|
* or video capture failing the permission prompt outright. Those failures are per-device and
|
||||||
|
* cannot be diagnosed mid-event, so the switch removes the broken path rather than leaving
|
||||||
|
* guests to discover it.
|
||||||
|
*
|
||||||
|
* Nothing is lost by disabling it: the gallery picker reaches the same OS camera through
|
||||||
|
* `capture`-less `<input type="file">`, handles video, and is the path most guests use anyway.
|
||||||
|
*/
|
||||||
|
export const cameraEnabled = flag(env.PUBLIC_CAMERA_ENABLED, true);
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import {
|
import {
|
||||||
classifyUploadStatus,
|
classifyUploadStatus,
|
||||||
|
isIncompleteBody,
|
||||||
isReversibleLock,
|
isReversibleLock,
|
||||||
entryToQueueItem,
|
entryToQueueItem,
|
||||||
shouldAbortForStall,
|
shouldAbortForStall,
|
||||||
@@ -54,6 +55,91 @@ describe('classifyUploadStatus', () => {
|
|||||||
* reopen and the photo resumes) or PURGES it (permanent ban / quota). Getting this wrong either
|
* 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.
|
* 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', () => {
|
describe('isReversibleLock', () => {
|
||||||
it('an `uploads_locked` code is reversible at any status (event closed / released)', () => {
|
it('an `uploads_locked` code is reversible at any status (event closed / released)', () => {
|
||||||
expect(isReversibleLock(403, 'uploads_locked')).toBe(true);
|
expect(isReversibleLock(403, 'uploads_locked')).toBe(true);
|
||||||
|
|||||||
@@ -628,6 +628,14 @@ class TerminalError extends Error {
|
|||||||
*/
|
*/
|
||||||
class NetworkError 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
|
* 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
|
* 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';
|
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,
|
* 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).
|
* 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
|
/** 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). */
|
* 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(
|
export async function addToQueue(
|
||||||
file: File,
|
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
|
// 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.
|
// exactly ONCE per file here and reused by every retry — see uploadItem.
|
||||||
const id = uuid();
|
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 = {
|
const entry: QueueEntry = {
|
||||||
id,
|
id,
|
||||||
userId,
|
userId,
|
||||||
@@ -897,7 +1014,7 @@ export async function addToQueue(
|
|||||||
caption,
|
caption,
|
||||||
hashtags,
|
hashtags,
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
blob: file
|
blob
|
||||||
};
|
};
|
||||||
await storePut(entry);
|
await storePut(entry);
|
||||||
|
|
||||||
@@ -1070,6 +1187,11 @@ async function processQueue(): Promise<void> {
|
|||||||
// NetworkError, which it extends.)
|
// NetworkError, which it extends.)
|
||||||
continue;
|
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) {
|
if (e instanceof NetworkError) {
|
||||||
// Connectivity dropped mid-flight. If offline the item is back to 'pending'
|
// Connectivity dropped mid-flight. If offline the item is back to 'pending'
|
||||||
// and the `online` listener resumes it; if the failure hit while nominally
|
// 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
|
// and charging the guest's quota twice. Both 200 (deduped) and 201 (created) are
|
||||||
// success; `classifyUploadStatus` already treats the whole 2xx range that way.
|
// success; `classifyUploadStatus` already treats the whole 2xx range that way.
|
||||||
formData.append('client_upload_id', entry.id);
|
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.caption) formData.append('caption', entry.caption);
|
||||||
if (entry.hashtags) formData.append('hashtags', entry.hashtags);
|
if (entry.hashtags) formData.append('hashtags', entry.hashtags);
|
||||||
|
|
||||||
@@ -1252,6 +1393,15 @@ async function uploadItem(id: string): Promise<void> {
|
|||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case 'terminal': {
|
case 'terminal': {
|
||||||
|
// 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'))
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
// A REVERSIBLE lock (event closed / gallery released) is tagged
|
// A REVERSIBLE lock (event closed / gallery released) is tagged
|
||||||
// `uploads_locked` by the backend — keep the blob and park it retryable so
|
// `uploads_locked` by the backend — keep the blob and park it retryable so
|
||||||
// a host reopen resumes it, instead of purging it like a permanent 4xx.
|
// a host reopen resumes it, instead of purging it like a permanent 4xx.
|
||||||
@@ -1429,6 +1579,20 @@ async function uploadItem(id: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
throw e;
|
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) {
|
if (e instanceof TerminalError) {
|
||||||
// Permanent rejection — drop the blob (we'll never resend it) and mark blocked
|
// Permanent rejection — drop the blob (we'll never resend it) and mark blocked
|
||||||
// so the UI shows a clear reason and offers no retry.
|
// so the UI shows a clear reason and offers no retry.
|
||||||
|
|||||||
@@ -163,6 +163,16 @@
|
|||||||
}
|
}
|
||||||
const result = await addToQueue(sf.file, caption, hashtagsString);
|
const result = await addToQueue(sf.file, caption, hashtagsString);
|
||||||
if (result === 'full') full++;
|
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.
|
// Don't let a full queue silently swallow photos the user thinks were queued.
|
||||||
if (full > 0) {
|
if (full > 0) {
|
||||||
|
|||||||
Reference in New Issue
Block a user