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

@@ -115,9 +115,15 @@ pub async fn patch_config(
// accept but that silently revert to the hardcoded default at read time
// (get_usize/get_i64 can't parse negatives/NaN/fractionals). `compression_concurrency`
// is intentionally absent — it's read once at boot, so a live edit was a no-op.
// The two size limits are bounded by what the router's `DefaultBodyLimit` can carry, NOT by
// a round number. They used to allow 1024 and 10240 MB against a 576 MiB body cap, so the
// admin dashboard's plain "Max. Videogröße (MB)" field could be set to a value that destroys
// every video above the cap — the limit trips mid-body, surfaces as a 400, and the client
// purges the blob as terminal. See `crate::MAX_CONFIGURABLE_UPLOAD_MB` for the full chain.
const MAX_SIZE_MB: f64 = crate::MAX_CONFIGURABLE_UPLOAD_MB as f64;
const NUMERIC_SPECS: &[(&str, bool, f64, f64)] = &[
("max_image_size_mb", true, 1.0, 1024.0),
("max_video_size_mb", true, 1.0, 10240.0),
("max_image_size_mb", true, 1.0, MAX_SIZE_MB),
("max_video_size_mb", true, 1.0, MAX_SIZE_MB),
("upload_rate_per_hour", true, 1.0, 100_000.0),
("feed_rate_per_min", true, 1.0, 100_000.0),
("export_rate_per_day", true, 1.0, 100_000.0),

View File

@@ -25,10 +25,15 @@ const MAX_CAPTION_LENGTH: usize = 2000;
/// `MAX_CAPTION_LENGTH` check only ran afterwards, on a string that had already been built.
/// 4 bytes per code point is the worst case for UTF-8, so this can never reject a caption the
/// character limit would have accepted.
const MAX_CAPTION_BYTES: usize = MAX_CAPTION_LENGTH * 4;
pub(crate) const MAX_CAPTION_BYTES: usize = MAX_CAPTION_LENGTH * 4;
/// Byte ceiling for the raw hashtag CSV. Generous next to what the tag caps below allow.
const MAX_HASHTAGS_BYTES: usize = 4 * 1024;
pub(crate) const MAX_HASHTAGS_BYTES: usize = 4 * 1024;
/// Byte ceiling for the `client_upload_id` field. 64 bytes fits a hyphenated UUID (36) with
/// room to spare; named rather than inline so the multipart-envelope test in `main.rs` can
/// account for every text field this handler will read.
pub(crate) const MAX_CLIENT_UPLOAD_ID_BYTES: usize = 64;
/// Hashtags stored per upload. The CSV was never length-checked at all and was split into an
/// unbounded `Vec`, then upserted TAG BY TAG inside the commit transaction — which holds a
@@ -280,9 +285,24 @@ pub async fn upload(
return Err(AppError::UploadsLocked("Uploads sind gesperrt.".into()));
}
// Read config limits from DB
let max_image_mb: i64 = config::get_i64(&state.config_cache, "max_image_size_mb", 20).await;
let max_video_mb: i64 = config::get_i64(&state.config_cache, "max_video_size_mb", 500).await;
// Read config limits from DB.
//
// CLAMPED to what the router's `DefaultBodyLimit` can actually carry. `patch_config` now
// refuses a larger value, but that guard only covers values written THROUGH it: a config row
// stored before the bound existed, or edited straight into the table, would otherwise sail
// past it and hand the guest the worst failure in the app — the body limit tripping mid-upload,
// surfacing as a 400, and the client purging the blob as terminal. See
// `crate::MAX_CONFIGURABLE_UPLOAD_MB`.
//
// Clamping (rather than refusing the upload) is right here: the operator's intent was "allow
// bigger files", and the honest answer to an unsatisfiable limit is the largest one that
// works, applied consistently by both the streaming cap and the per-class check below.
let max_image_mb: i64 = config::get_i64(&state.config_cache, "max_image_size_mb", 20)
.await
.min(crate::MAX_CONFIGURABLE_UPLOAD_MB);
let max_video_mb: i64 = config::get_i64(&state.config_cache, "max_video_size_mb", 500)
.await
.min(crate::MAX_CONFIGURABLE_UPLOAD_MB);
// The uploaded file is streamed straight to a temp file on disk (never buffered
// whole in memory — a 500 MB video used to cost 500 MB of RAM per concurrent
@@ -379,8 +399,8 @@ pub async fn upload(
// `hashtags` were bounded by the helper for exactly this reason; this field
// arrived later (migration 022) and missed it.
//
// 64 bytes fits a hyphenated UUID (36) with room to spare.
let raw = read_text_field_bounded(field, 64).await?;
// See `MAX_CLIENT_UPLOAD_ID_BYTES`.
let raw = read_text_field_bounded(field, MAX_CLIENT_UPLOAD_ID_BYTES).await?;
// A malformed key is not worth rejecting an upload over — the photo is the
// thing the guest cares about. Drop the key and lose only the retry
// protection, which is exactly where we were before it existed.