fix(upload): a raised size limit destroyed videos instead of refusing them

`MAX_UPLOAD_BYTES` (576 MiB) is the router's `DefaultBodyLimit` on the upload
route. Its coupling to the admin-tunable `max_image_size_mb` /
`max_video_size_mb` was enforced by a COMMENT — "if an admin raises
max_video_size_mb above this, bump MAX_UPLOAD_BYTES" — while `patch_config`
accepted 1024 and 10240 respectively and the dashboard rendered
"Max. Videogröße (MB)" as a bare number field with no stated ceiling.

Set it to 1000 and every video between 576 MB and the new limit is not refused,
it is DESTROYED, and the shape is worse than the size:

  * The body limit trips MID-UPLOAD, inside `field.chunk()`, so
    `stream_field_to_file` maps it to `AppError::BadRequest` — a 400, not a 413
    carrying the `quota_exceeded` code the client knows how to keep a blob for.
  * `classifyUploadStatus` puts every non-401/408/429 4xx in the `terminal`
    bucket, and `isReversibleLock(400, 'bad_request')` is false — so the queue
    DELETES the blob from IndexedDB and moves the row to `blocked`, which by
    design offers no retry button.
  * All of that after the guest pushed 600 MB over cellular, and the message
    they get names a read failure rather than a limit.

"Raise the video limit" is exactly the change a host makes after a guest
complains a clip was too big, so this is reachable by an operator doing the
obvious thing.

The ceiling is now DERIVED from the body limit rather than written down twice
(`MAX_CONFIGURABLE_UPLOAD_MB`, 575) and enforced at both ends, because either
alone leaves a hole: `patch_config` bounds what can be WRITTEN, and the upload
handler clamps what it READS, since a row stored before this bound existed — or
edited straight into the `config` table — would sail past the first check.

A compile-time assertion pins both directions against the ACTUAL field caps
(`MAX_CAPTION_BYTES + MAX_HASHTAGS_BYTES + MAX_CLIENT_UPLOAD_ID_BYTES` plus
framing), so raising `MAX_CAPTION_LENGTH` fails the build rather than silently
eating the envelope margin; a lower bound keeps the ceiling clear of the 500 MB
`max_video_size_mb` seeded by migration 005.

Ordering is now guaranteed: at 575 MiB the handler's own cap trips while the
body is ~1 MiB short of axum's, so the clean "Datei ist zu groß" 400 always wins
the race against the mid-stream abort.

Frontend, both halves of the same rule:
  * the composer's pre-flight moves from 576 MiB (the raw body limit) to 575 MB,
    so a guest is rejected locally against the same number the server enforces
    and never pushes the file to find out;
  * both size fields gain a hint naming the 575 ceiling, so the operator learns
    the bound from the form instead of from an error after typing 1000.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-08-17 17:53:56 +02:00
parent 9759c7c669
commit 8dcc3a7a98
5 changed files with 153 additions and 18 deletions

View File

@@ -80,8 +80,25 @@
{
title: 'Limits & Größen',
fields: [
{ key: 'max_image_size_mb', label: 'Max. Bildgröße (MB)', kind: 'number' },
{ key: 'max_video_size_mb', label: 'Max. Videogröße (MB)', kind: 'number' }
// The 575 MB ceiling is not arbitrary and it is not a policy choice: it is what the
// upload route's HTTP body limit can carry (backend MAX_CONFIGURABLE_UPLOAD_MB,
// derived from MAX_UPLOAD_BYTES minus the multipart envelope). Above it the body
// limit trips MID-UPLOAD, which the upload queue reads as a terminal 4xx and
// answers by deleting the guest's photo — so the backend refuses the value, and
// these hints exist so the operator learns the bound from the form rather than
// from an error after typing 1000.
{
key: 'max_image_size_mb',
label: 'Max. Bildgröße (MB)',
kind: 'number',
hint: 'Maximal 575 — darüber kann der Server den Upload nicht mehr entgegennehmen.'
},
{
key: 'max_video_size_mb',
label: 'Max. Videogröße (MB)',
kind: 'number',
hint: 'Maximal 575 — darüber kann der Server den Upload nicht mehr entgegennehmen.'
}
// compression_concurrency is set via COMPRESSION_WORKER_CONCURRENCY at
// boot, not live — omitted so it isn't a dead no-op control.
]

View File

@@ -26,11 +26,19 @@
const MAX_CAPTION_LENGTH = 2000;
// Mirrors MAX_UPLOAD_BYTES in backend/src/main.rs — the axum body limit, which is a
// BOOT CONSTANT rather than an admin-tunable value, so checking it here cannot drift
// out of sync with the dashboard the way max_image_size_mb / max_video_size_mb would.
// Anything above this is refused by the server no matter how the event is configured.
const HARD_MAX_UPLOAD_BYTES = 576 * 1024 * 1024;
// Mirrors MAX_CONFIGURABLE_UPLOAD_MB in backend/src/main.rs — the largest per-file limit an
// operator can configure, itself derived from the axum body limit. Both are BOOT CONSTANTS
// rather than admin-tunable values, so checking it here cannot drift out of sync with the
// dashboard the way max_image_size_mb / max_video_size_mb would. Anything above this is
// refused by the server no matter how the event is configured.
//
// 575 MB, not the raw 576 MiB body limit this used to mirror. The gap is the multipart
// envelope, and it is the difference between two failure shapes: at or below the file
// ceiling the UPLOAD HANDLER rejects with "Datei ist zu groß. Maximum: 575 MB." — a clean,
// self-explaining 400 — whereas past the body limit axum aborts the request MID-STREAM and
// the handler answers with a read error instead. Rejecting here at the lower of the two
// means a guest never reaches the confusing one, and never pushes 575 MB to find out.
const HARD_MAX_UPLOAD_BYTES = 575 * 1024 * 1024;
/**
* Reject files the server is certain to refuse, BEFORE any bytes leave the phone.