Files
EventSnap/backend/src/main.rs
MechaCat02 8dcc3a7a98 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>
2026-08-17 17:53:56 +02:00

554 lines
26 KiB
Rust

use anyhow::Result;
use axum::Router;
use axum::extract::DefaultBodyLimit;
use axum::routing::{delete, get, patch, post};
use tower_http::trace::TraceLayer;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
mod auth;
mod config;
mod db;
mod error;
mod handlers;
mod models;
mod services;
mod state;
use config::AppConfig;
use state::AppState;
/// Hard HTTP body cap for the upload endpoint (576 MiB). Backstop against
/// memory-exhaustion; precise per-class size limits are enforced in the handler.
pub(crate) const MAX_UPLOAD_BYTES: usize = 576 * 1024 * 1024;
/// Largest per-file size limit (MB) an operator may configure for `max_image_size_mb` /
/// `max_video_size_mb`, derived from [`MAX_UPLOAD_BYTES`] rather than written down twice.
///
/// This used to be enforced by a COMMENT — "if an admin raises max_video_size_mb above this,
/// bump MAX_UPLOAD_BYTES" — while `patch_config` happily accepted 10240 and the admin dashboard
/// rendered "Max. Videogröße (MB)" as a bare number field. Set it to 1000 and every video
/// between 576 MB and the new limit is destroyed, in a shape that is much worse than a refusal:
///
/// * The body limit trips mid-upload, inside `field.chunk()`, so `stream_field_to_file` maps
/// it to `AppError::BadRequest` — a 400, not a 413 with a `quota_exceeded` code.
/// * `classifyUploadStatus` (frontend/src/lib/upload-queue.ts) 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 has already pushed 600 MB over cellular, and the message they
/// get names a read failure rather than a limit.
///
/// So the ceiling is enforced where the value is SET (`patch_config`) and again where it is READ
/// (`handlers::upload`), because a value stored before this bound existed — or written by hand
/// into the `config` table — would otherwise walk straight past the first check.
///
/// The subtraction is the multipart envelope: the body carries the file PLUS the boundary
/// framing and the `caption` / `hashtags` / `client_upload_id` fields. 1 MiB is enormously more
/// than those can occupy (see the test below, which pins it against their actual caps) and costs
/// nothing — the alternative is a limit that is satisfiable in theory and off-by-a-header in
/// practice.
pub(crate) const MAX_CONFIGURABLE_UPLOAD_MB: i64 =
(MAX_UPLOAD_BYTES as i64 - 1024 * 1024) / (1024 * 1024);
#[tokio::main]
async fn main() -> Result<()> {
dotenvy::dotenv().ok();
tracing_subscriber::registry()
.with(
// `info`, not `debug`. A stock deploy sets RUST_LOG nowhere (it is absent from
// .env.example and was absent from docker-compose.yml), so this fallback IS the
// production level — and at `debug` the TraceLayer below emits a line per request
// AND per response, into a log file that had no rotation. `tower_http=warn`
// rather than `info` states the intent: those spans are diagnostics, not an
// access log, and a future `DefaultOnResponse::new().level(Level::INFO)` should
// not silently re-enable them.
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "eventsnap_backend=info,tower_http=warn".into()),
)
.with(tracing_subscriber::fmt::layer())
.init();
let config = AppConfig::from_env()?;
// Prove both media directories are writable BEFORE anything else runs. This is first
// because everything downstream — the derivative backfill, export recovery, every upload —
// assumes it silently.
//
// This used to be `create_dir_all(&media_path).await.ok()` far below, which discarded the
// only signal there was, and EXPORT_PATH was never created or probed at all. The failure
// mode that produced: a wrong bind mount or a root-owned volume left the app booting
// *green* — `/health` only probes the database — so Caddy routed traffic to it, guests
// joined, and every single upload failed with EACCES. Existence is not the property we
// need; writability is, and the only way to know is to write.
ensure_writable_dir(&config.media_path, "MEDIA_PATH").await?;
ensure_writable_dir(&config.export_path, "EXPORT_PATH").await?;
let pool = db::create_pool(&config.database_url).await?;
// Reset any rows left mid-flight by a previous (possibly crashed) instance —
// stuck `compression_status='processing'` uploads and `status='running'` export
// jobs. Must run before the server starts taking requests so clients never see
// the half-state.
services::maintenance::startup_recovery(&pool).await;
let state = AppState::new(pool.clone(), config.clone());
// Regenerate image derivatives an older pipeline produced: the big-screen display for
// uploads processed before it existed (v0.17.x), and anything predating the current
// DERIVATIVES_REV (rev 1 applies the EXIF orientation, without which every portrait
// phone photo is stored sideways). Fire-and-forget behind the compression semaphore;
// originals are never touched, so a failure just retries on the next start.
state.compression.backfill_stale_derivatives().await;
// Re-extract poster frames for videos a restart interrupted. `startup_recovery` above
// marks their compression `failed` but nothing re-enqueued them, so `thumbnail_path`
// stayed NULL for the rest of the event. Shares the attempt budget with the image
// backfill, so a clip that genuinely yields no frame stops being retried.
state.compression.backfill_video_posters().await;
// Re-spawn exports for events that were released but whose keepsake never finished
// (crash mid-export). Needs the media/export paths + SSE sender, so it runs here
// rather than inside `startup_recovery`. Fire-and-forget: the workers run in the
// background; the HTTP server can start accepting requests meanwhile.
services::export::recover_exports(
pool.clone(),
config.media_path.clone(),
config.export_path.clone(),
config.comments_enabled,
state.sse_tx.clone(),
)
.await;
// Hourly background hygiene: prune expired sessions, evict cold rate-limiter
// keys. Keeps the DB and process from growing unboundedly over multi-day events.
services::maintenance::spawn_periodic_tasks(
pool,
state.rate_limiter.clone(),
state.sse_tickets.clone(),
config.media_path.clone(),
);
let api = Router::new()
// Auth
.route("/api/v1/event", get(handlers::public::get_public_event))
.route("/api/v1/join", post(auth::handlers::join))
.route("/api/v1/recover", post(auth::handlers::recover))
// Forgotten-PIN escape hatch: ask a host to reset it (unauthenticated, throttled).
.route(
"/api/v1/recover/request",
post(auth::handlers::request_pin_reset),
)
.route("/api/v1/admin/login", post(auth::handlers::admin_login))
.route("/api/v1/session", delete(auth::handlers::logout))
// "Sign out everywhere" — revoke all of the caller's sessions.
.route("/api/v1/sessions", delete(auth::handlers::logout_all))
// Upload — HTTP-level body cap as an OOM backstop. The handler still enforces
// the precise per-class limits from DB config (max_image/video_size_mb); this
// layer just stops a multi-GB body from being buffered into memory before that
// check runs. Sized generously above the default 500 MB video limit + multipart
// overhead. The DB-configured limits can no longer exceed it: both are clamped to
// MAX_CONFIGURABLE_UPLOAD_MB at write time and at read time — see that constant
// for why a comment was not enough.
.route(
"/api/v1/upload",
post(handlers::upload::upload).route_layer(DefaultBodyLimit::max(MAX_UPLOAD_BYTES)),
)
.route(
"/api/v1/upload/{id}",
patch(handlers::upload::edit_upload).delete(handlers::upload::delete_upload),
)
.route(
"/api/v1/upload/{id}/original",
get(handlers::upload::get_original),
)
// Preview/thumbnail variants are gated the same way as originals (visibility
// check + direct /media block below) so moderation actually revokes access to
// the displayed images, not just the full-res download.
.route(
"/api/v1/upload/{id}/preview",
get(handlers::upload::get_preview),
)
.route(
"/api/v1/upload/{id}/display",
get(handlers::upload::get_display),
)
.route(
"/api/v1/upload/{id}/thumbnail",
get(handlers::upload::get_thumbnail),
)
// Current-user endpoints (live quota estimate, profile + privacy note bundle)
.route("/api/v1/me/context", get(handlers::me::get_context))
.route("/api/v1/me/quota", get(handlers::me::get_quota))
// Self-service erasure. There was no user-deletion route at any role, so an erasure
// request could only be honoured with hand-written SQL against production — and the join
// page's data notice now promises this exists. See `me::delete_account`.
.route("/api/v1/me", delete(handlers::me::delete_account))
// Feed
.route("/api/v1/feed", get(handlers::feed::feed))
.route("/api/v1/feed/delta", get(handlers::feed::feed_delta))
.route("/api/v1/hashtags", get(handlers::feed::hashtags))
.route("/api/v1/uploaders", get(handlers::feed::uploaders))
// Social
.route(
"/api/v1/upload/{id}/like",
post(handlers::social::toggle_like),
)
.route(
"/api/v1/upload/{id}/comments",
get(handlers::social::list_comments).post(handlers::social::add_comment),
)
.route(
"/api/v1/comment/{id}",
delete(handlers::social::delete_comment),
)
// SSE
.route("/api/v1/stream", get(handlers::sse::stream))
.route("/api/v1/stream/ticket", post(handlers::sse::issue_ticket))
// Host Dashboard
.route("/api/v1/host/event", get(handlers::host::get_event_status))
.route(
"/api/v1/host/event/close",
post(handlers::host::close_event),
)
.route("/api/v1/host/event/open", post(handlers::host::open_event))
.route(
"/api/v1/host/gallery/release",
post(handlers::host::release_gallery),
)
// Escape hatch: force a keepsake rebuild. Without it a failed export is terminal at runtime
// (release_gallery refuses an already-released event; recovery only runs at boot).
.route(
"/api/v1/host/export/rebuild",
post(handlers::host::rebuild_export),
)
.route("/api/v1/host/users", get(handlers::host::list_users))
.route(
"/api/v1/host/users/{id}/ban",
post(handlers::host::ban_user),
)
.route(
"/api/v1/host/users/{id}/unban",
post(handlers::host::unban_user),
)
.route(
"/api/v1/host/users/{id}/role",
patch(handlers::host::set_role),
)
.route(
"/api/v1/host/users/{id}/pin-reset",
post(handlers::host::reset_user_pin),
)
.route(
"/api/v1/host/pin-reset-requests",
get(handlers::host::list_pin_reset_requests),
)
.route(
"/api/v1/host/pin-reset-requests/{id}",
delete(handlers::host::dismiss_pin_reset_request),
)
.route(
"/api/v1/host/upload/{id}",
delete(handlers::host::host_delete_upload),
)
.route(
"/api/v1/host/comment/{id}",
delete(handlers::host::host_delete_comment),
)
// Export (all authenticated users)
.route("/api/v1/export/status", get(handlers::admin::export_status))
.route(
"/api/v1/export/ticket",
post(handlers::admin::export_ticket),
)
.route("/api/v1/export/zip", get(handlers::admin::download_zip))
.route("/api/v1/export/html", get(handlers::admin::download_html))
// Admin Dashboard
.route("/api/v1/admin/stats", get(handlers::admin::get_stats))
.route(
"/api/v1/admin/config",
get(handlers::admin::get_config).patch(handlers::admin::patch_config),
)
.route(
"/api/v1/admin/export/jobs",
get(handlers::admin::get_export_jobs),
);
// Test-only route: a hard reset for the Playwright E2E harness. The handler
// is compiled in always, but the route is only attached when
// `EVENTSNAP_TEST_MODE=1`. In production the call returns 404 — the route
// simply isn't there.
let api = if handlers::test_admin::is_test_mode() {
tracing::warn!(
"EVENTSNAP_TEST_MODE=1 — registering /api/v1/admin/__truncate. \
DO NOT enable this in production."
);
api.route(
"/api/v1/admin/__truncate",
post(handlers::test_admin::truncate_all),
)
} else {
api
};
// NOTE: media is deliberately NOT served over HTTP.
//
// Files live under `media_path` so the compression worker and the export job can read
// them off disk, but nothing may pull them straight from `/media/**` — that bypasses
// the visibility checks (soft-delete + ban-hide) that make a host takedown stick.
// Every legitimate fetch goes through `/api/v1/upload/{id}/{original,preview,display,
// thumbnail}`, which filter via `find_visible_media`; those are the only media URLs the
// backend ever emits (see `handlers::feed`).
//
// This used to be a `ServeDir` on `/media` with four `nest_service` blockers on the
// subtrees above it. That was bypassable: axum routes on the RAW path while `ServeDir`
// percent-decodes afterwards, so `/media/%70reviews/{id}.jpg` missed every blocker,
// fell through to the `ServeDir`, and was decoded back to `previews/` on disk — serving
// a taken-down photo to anyone, unauthenticated. Any single escaped byte worked, in all
// four subtrees. Deleting the route removes the vector outright rather than racing the
// decoder; `/media/**` now 404s regardless of encoding.
let router = Router::new()
// ONE probe, and it touches the database. The merge of the unattended-blockers work
// brought a competing design — a dependency-free `/health` for the compose gate plus
// a DB-backed `/health/ready` for an external monitor. That split is defensible, and
// it was rejected deliberately:
//
// * `/health` returning a constant "ok" is the exact defect faea555 fixed and
// verified live (stop Postgres → 503 → start → 200, with no app restart). Every
// request path touches the database, so a constant probe reports healthy while
// the app is useless — the disk-full endgame stayed green all the way down.
// * The split's motive was that Caddy's `depends_on: app: service_healthy` would
// be blocked by a Postgres hiccup at boot. But `app` itself already gates on
// `db: service_healthy`, so the DB is up before this probe ever runs, and the
// healthcheck carries a 20s start_period plus 5 retries on top.
// * The two handlers were the same `SELECT 1` with the same 2s timeout under two
// names, so keeping both bought nothing.
//
// The external uptime monitor points at this route — DEPLOYMENT_RUNBOOK.md §10.4,
// which documents the response table and is the only thing in this deployment that
// can page a human. (That section previously did not exist and this comment claimed
// it did; if you are removing §10.4, this route loses its only consumer.)
.route("/health", get(health))
.merge(api)
.layer(TraceLayer::new_for_http())
.with_state(state);
let listener = tokio::net::TcpListener::bind(("0.0.0.0", config.app_port)).await?;
tracing::info!("listening on {}", listener.local_addr()?);
// `into_make_service_with_connect_info` is required by the pre-auth handlers, which
// extract `ConnectInfo<SocketAddr>` to use the peer address as the rate-limit key when
// X-Forwarded-For is absent. Without it those extractors fail at runtime.
axum::serve(
listener,
router.into_make_service_with_connect_info::<std::net::SocketAddr>(),
)
.with_graceful_shutdown(shutdown_signal())
.await?;
Ok(())
}
/// Create `dir` if absent, then prove we can actually write inside it. Hard error otherwise.
///
/// `create_dir_all` succeeding proves nothing: it is a no-op on an existing directory, so a
/// root-owned volume, a read-only bind mount and a full filesystem all "succeed". The probe
/// below is the only thing that distinguishes them, and it is worth the two syscalls once per
/// boot to turn a silent evening of failed uploads into a container that refuses to start.
///
/// `label` is the env var name so the operator gets the name of the knob to fix, not a path
/// they then have to trace back to a variable.
async fn ensure_writable_dir(dir: &std::path::Path, label: &str) -> anyhow::Result<()> {
use anyhow::Context;
tokio::fs::create_dir_all(dir)
.await
.with_context(|| format!("{label}: cannot create {}", dir.display()))?;
// A fixed name is fine: this runs once, before the server accepts requests, and two
// instances sharing one volume would be a misconfiguration in its own right. Removed on
// both the success and failure paths so a crashed boot cannot leave litter behind.
let probe = dir.join(".eventsnap-write-probe");
let result = async {
let mut f = tokio::fs::File::create(&probe)
.await
.with_context(|| format!("{label}: cannot create a file in {}", dir.display()))?;
// Write and fsync rather than just create: a full filesystem lets the create succeed
// and fails at the first byte, which is exactly the disk-full endgame this guards.
tokio::io::AsyncWriteExt::write_all(&mut f, b"ok")
.await
.with_context(|| format!("{label}: cannot write to {}", dir.display()))?;
f.sync_all()
.await
.with_context(|| format!("{label}: cannot flush to {}", dir.display()))?;
anyhow::Ok(())
}
.await;
let _ = tokio::fs::remove_file(&probe).await;
result.with_context(|| {
format!(
"{label} ({}) is not writable. The app refuses to start rather than accept uploads \
it cannot store — check the bind mount and that the volume is owned by the \
container's non-root user.",
dir.display()
)
})?;
tracing::info!(path = %dir.display(), "{label} is writable");
Ok(())
}
/// How long `/health` waits for the database before calling the app unhealthy. Deliberately
/// short: the point is to answer "can this process actually serve a request right now", and a
/// probe that blocks for the acquire timeout is itself a symptom.
const HEALTH_DB_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
/// Readiness probe — the Docker healthcheck, Caddy's `depends_on` gate, and the runbook's
/// event-day `curl` all hit this.
///
/// It used to return the literal string `"ok"` and touch nothing. Every request in the app needs
/// the database, so that answered a question nobody asked: the container reported healthy while
/// every real request 500'd, and with no operator watching during the event there was no signal
/// at all. Note what this does NOT buy: Compose's `restart: unless-stopped` does not react to
/// healthcheck state, so nothing restarts on a red probe — this is a diagnostic, and it is
/// deliberately not wired to automatic recovery, because the pool already heals itself across a
/// Postgres restart (sqlx revalidates on acquire) and an auto-restart would truncate every
/// in-flight upload to "fix" an outage that was about to clear on its own.
async fn health(
axum::extract::State(state): axum::extract::State<AppState>,
) -> impl axum::response::IntoResponse {
use axum::http::StatusCode;
match tokio::time::timeout(
HEALTH_DB_TIMEOUT,
sqlx::query("SELECT 1").execute(&state.pool),
)
.await
{
Ok(Ok(_)) => (StatusCode::OK, "ok"),
Ok(Err(e)) => {
tracing::error!(error = ?e, "health check: database query failed");
(StatusCode::SERVICE_UNAVAILABLE, "database unavailable")
}
Err(_) => {
tracing::error!(
timeout_s = HEALTH_DB_TIMEOUT.as_secs(),
"health check: database did not respond"
);
(StatusCode::SERVICE_UNAVAILABLE, "database timeout")
}
}
}
/// Hard cap on how long we wait for in-flight connections to drain after a shutdown
/// signal. Uploads (streamed to disk) finish in well under this; the cap exists because
/// long-lived SSE streams never end on their own and would otherwise keep the graceful
/// drain — and thus the process — pending until the orchestrator force-kills it.
const SHUTDOWN_GRACE: std::time::Duration = std::time::Duration::from_secs(10);
/// Resolves on SIGINT (Ctrl-C) or SIGTERM (container stop / deploy). Letting
/// `axum::serve` drain in-flight requests on this signal means a redeploy no longer
/// truncates uploads mid-flight; any background compression/export half-states that a
/// hard kill would leave are already reconciled by `startup_recovery` on the next boot.
///
/// Once the signal fires we also arm a detached backstop that force-exits after
/// [`SHUTDOWN_GRACE`]. Without it, open SSE streams (which have no natural end) would
/// hold the graceful drain open indefinitely; the backstop bounds shutdown regardless
/// of the orchestrator's own kill timeout. If the drain completes first, `main` returns
/// and the process exits before the timer ever fires.
async fn shutdown_signal() {
let ctrl_c = async {
let _ = tokio::signal::ctrl_c().await;
};
#[cfg(unix)]
let terminate = async {
match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
Ok(mut sig) => {
sig.recv().await;
}
Err(e) => {
tracing::warn!(error = ?e, "failed to install SIGTERM handler");
// Never resolve — fall back to ctrl_c only.
std::future::pending::<()>().await;
}
}
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {}
_ = terminate => {}
}
tracing::info!(
"shutdown signal received, draining in-flight requests (max {}s)",
SHUTDOWN_GRACE.as_secs()
);
// Backstop: if the graceful drain is still blocked after the grace window (almost
// always because SSE streams are still open), exit anyway so deploys aren't stalled.
tokio::spawn(async {
tokio::time::sleep(SHUTDOWN_GRACE).await;
tracing::warn!(
"graceful drain exceeded {}s (likely open SSE streams); forcing exit",
SHUTDOWN_GRACE.as_secs()
);
std::process::exit(0);
});
}
#[cfg(test)]
mod tests {
use super::{MAX_CONFIGURABLE_UPLOAD_MB, MAX_UPLOAD_BYTES};
use crate::handlers::upload::{
MAX_CAPTION_BYTES, MAX_CLIENT_UPLOAD_ID_BYTES, MAX_HASHTAGS_BYTES,
};
/// Boundary lines, `Content-Disposition` / `Content-Type` headers and CRLFs for the four
/// fields the handler reads. A few hundred bytes in reality; 4 KiB is a deliberately fat
/// allowance so this test asserts the invariant rather than a precise byte count.
const MULTIPART_FRAMING_BYTES: usize = 4 * 1024;
/// Both bounds on `MAX_CONFIGURABLE_UPLOAD_MB`, asserted at COMPILE time.
///
/// `const _: () = assert!(...)` rather than a runtime `assert!`, matching the precedent in
/// `handlers::upload` and `services::compression`: every operand is a constant, so a
/// violation is a build failure rather than something that has to be run to be noticed. The
/// cost is that a const panic takes a static message — the reasoning lives here instead.
///
/// UPPER: a file at the largest configurable limit must still fit inside the body limit the
/// router enforces, envelope included. If it does not, an operator can set a limit the
/// handler accepts and the router then refuses MID-BODY — a 400 that the upload queue
/// classifies as terminal and answers by deleting the guest's only copy of the photo. See
/// `MAX_CONFIGURABLE_UPLOAD_MB`. Written against the field caps rather than a hardcoded
/// number, so raising `MAX_CAPTION_LENGTH` (or adding another text field to the envelope)
/// fails HERE instead of silently eating the margin.
///
/// LOWER: the ceiling must not be so conservative that it forbids the shipped default.
/// `max_video_size_mb` is seeded at 500 (migration 005), so a bound below that would clamp
/// every video upload on a stock install and reject the stock config through `patch_config`.
#[test]
fn the_configurable_ceiling_is_bounded_at_both_ends() {
const FILE: usize = MAX_CONFIGURABLE_UPLOAD_MB as usize * 1024 * 1024;
const ENVELOPE: usize = MAX_CAPTION_BYTES
+ MAX_HASHTAGS_BYTES
+ MAX_CLIENT_UPLOAD_ID_BYTES
+ MULTIPART_FRAMING_BYTES;
const _: () = {
assert!(
FILE + ENVELOPE <= MAX_UPLOAD_BYTES,
"a file at MAX_CONFIGURABLE_UPLOAD_MB plus its multipart envelope exceeds \
MAX_UPLOAD_BYTES — an operator could configure a limit that DESTROYS uploads \
(400 mid-body, blob purged as terminal) instead of refusing them"
);
assert!(
MAX_CONFIGURABLE_UPLOAD_MB >= 500,
"MAX_CONFIGURABLE_UPLOAD_MB must clear the 500 MB max_video_size_mb default \
seeded by migration 005, or a stock install clamps every video upload"
);
};
}
}