feat(pages): raise upload limits for large wasm (per-file 16 MiB, total 48 MiB)

The 5 MiB per-file cap rejected wasm binaries larger than that
("exceeds the maximum per-file size of 5242880 bytes"). Raise the
coherent limit set to fit large wasm while staying well under the
Worker's ~128 MiB memory ceiling (the body is buffered and decompressed
in memory):

- max_file:               5  -> 16 MiB
- max_uncompressed_total: 25 -> 48 MiB
- max_compressed / MAX_UPLOAD_BYTES / axum body cap: 10 -> 24 MiB
- max_entries / max_path_len / max_depth: unchanged

Updates UploadLimits::default, routes::api::MAX_UPLOAD_BYTES (the
DefaultBodyLimit layer follows it), the frontend client-side pre-check
and message, and the design doc §2.2 numbers.

Bean: pages-ufk2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
main
Elijah Voigt 1 day ago
parent f42ae56bb4
commit f7fa11071d

@ -0,0 +1,27 @@
---
# pages-ufk2
title: 'Raise upload limits: per-file 16 MiB, total 48 MiB, body 24 MiB'
status: completed
type: task
priority: high
created_at: 2026-07-21T16:35:01Z
updated_at: 2026-07-21T16:36:48Z
---
User's wasm binary (bin_bg.wasm) exceeds the 5 MiB per-file cap ("exceeds the maximum per-file size of 5242880 bytes"). Moderate bump chosen to fit large wasm while staying well under the Worker's ~128 MiB memory ceiling (body is buffered + decompressed in memory).
New coherent limit set:
- max_file: 5 -> 16 MiB
- max_uncompressed_total: 25 -> 48 MiB
- max_compressed (== MAX_UPLOAD_BYTES / body cap): 10 -> 24 MiB
- max_entries / max_path_len / max_depth: unchanged
Spots:
- core/upload.rs UploadLimits::default() (values + doc comment)
- routes/api.rs MAX_UPLOAD_BYTES const (+ comment); DefaultBodyLimit in routes/mod.rs follows it automatically
- static/index.html client-side MAX_UPLOAD_BYTES pre-check (line ~871)
Then: validate (all six), commit, push, deploy.
## Summary of Changes
Raised default upload limits to fit large wasm: max_file 5 to 16 MiB, max_uncompressed_total 25 to 48 MiB, max_compressed / MAX_UPLOAD_BYTES / axum body cap 10 to 24 MiB (max_entries/path_len/depth unchanged). Updated UploadLimits::default, routes::api::MAX_UPLOAD_BYTES, the frontend client-side pre-check + message, and design doc §2.2 numbers. Still well under the Worker ~128 MiB memory ceiling. Validation green: 196 unit + 20 doctests.

@ -74,10 +74,12 @@ so it should be granted even more sparingly than a single-page grant.
- **Path traversal:** zip entry names like `../../x`, absolute paths (`/x`,
`C:\x`), backslash separators, or `..` segments anywhere → reject upload.
- **Zip bombs:** enforce **before/while extracting**: max compressed upload
10 MiB (checked via `Content-Length` and while buffering), max total
uncompressed 25 MiB, max 5 MiB per file, max 300 entries, max path length
180 bytes, max depth 10. Counting is done on decompressed bytes as they
stream out, aborting on breach — never trust zip headers alone.
24 MiB (checked via `Content-Length` and while buffering), max total
uncompressed 48 MiB, max 16 MiB per file, max 300 entries, max path length
180 bytes, max depth 10 (raised from 10/25/5 MiB to accommodate large wasm
binaries, still well under the Worker's ~128 MiB memory ceiling). Counting
is done on decompressed bytes as they stream out, aborting on breach —
never trust zip headers alone.
- **Weird entries:** directories are skipped, symlinks don't exist for us
(we never touch a filesystem — entries go straight to R2), duplicate entry
names → reject, nested zips are stored as opaque files, never recursed.
@ -116,7 +118,7 @@ so it should be granted even more sparingly than a single-page grant.
- Upload size caps (§2.2) and a **per-user page quota** (default 50).
- Cloudflare rate-limiting rule on `POST /api/*` and `/auth/*` (infra).
- R2/D1 are pay-per-use with generous free tiers; caps above keep worst-case
storage bounded (50 pages × 25 MiB per user, and uploads are gated by an
storage bounded (50 pages × 48 MiB per user, and uploads are gated by an
admin-approved allowlist anyway).
### 2.6 Content-type handling
@ -248,7 +250,7 @@ infra/*.tf
1. Auth middleware resolves session → user; reject 401/403 (`can_upload`).
2. Origin check (mutating request).
3. Validate `?name=` slug (grammar + reserved + quota) → 400/409.
4. Enforce `Content-Length`10 MiB, buffer body with a hard cap → 413.
4. Enforce `Content-Length`24 MiB, buffer body with a hard cap → 413.
5. `Content-Type: text/html` → manifest of one file `index.html`.
`application/zip` → extract with the §2.2 limits → manifest of
`(path, bytes)`; must contain a root `index.html` (else 400).

@ -42,15 +42,19 @@ pub struct UploadLimits {
}
impl Default for UploadLimits {
/// Production limits from design §2.2: 10 MiB max compressed upload,
/// 25 MiB max total uncompressed, 5 MiB max per file, 300 max entries,
/// 180-byte max path length, depth 10.
/// Production limits (design §2.2, raised to accommodate large wasm
/// binaries): 24 MiB max compressed upload, 48 MiB max total uncompressed,
/// 16 MiB max per file, 300 max entries, 180-byte max path length,
/// depth 10. The sizes stay well under the Worker's ~128 MiB memory
/// ceiling — the body is buffered and decompressed in memory — and
/// `max_compressed` must stay in lockstep with
/// [`MAX_UPLOAD_BYTES`](crate::routes::api::MAX_UPLOAD_BYTES).
fn default() -> Self {
const MIB: u64 = 1024 * 1024;
Self {
max_compressed: (10 * MIB) as usize,
max_uncompressed_total: 25 * MIB,
max_file: 5 * MIB,
max_compressed: (24 * MIB) as usize,
max_uncompressed_total: 48 * MIB,
max_file: 16 * MIB,
max_entries: 300,
max_path_len: 180,
max_depth: 10,

@ -35,12 +35,12 @@ use crate::core::slug::validate_slug;
use crate::core::upload::{build_manifest, detect_kind, ManifestEntry, UploadLimits};
use crate::db::DbError;
/// Maximum accepted raw upload size — 10 MiB (design §2.2, §4.6 step 4).
/// Maximum accepted raw upload size — 24 MiB (design §2.2, §4.6 step 4).
/// Mirrors [`UploadLimits::default`]'s `max_compressed`; kept as a separate
/// constant (rather than reading it off a constructed `UploadLimits`) so the
/// `Content-Length` pre-check in [`create_page`] doesn't need to allocate
/// one before the request body is available.
pub(crate) const MAX_UPLOAD_BYTES: u64 = 10 * 1024 * 1024;
pub(crate) const MAX_UPLOAD_BYTES: u64 = 24 * 1024 * 1024;
/// Builds a `{"error": "<message>"}` JSON response with the given status.
fn json_error(status: StatusCode, message: impl Into<String>) -> Response {

@ -866,9 +866,9 @@
nameInput.addEventListener("input", validateNameField);
// Mirrors the server's 10 MiB compressed-upload cap (design §2.2) so an
// Mirrors the server's 24 MiB compressed-upload cap (design §2.2) so an
// oversize file gets a friendly message instead of a raw HTTP 413.
var MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
var MAX_UPLOAD_BYTES = 24 * 1024 * 1024;
uploadSubmit.addEventListener("click", function () {
var replacing = uploadMode === "replace";
@ -876,7 +876,7 @@
if (!currentFile || !isValidSlug(name)) return;
var file = currentFile;
if (file.size > MAX_UPLOAD_BYTES) {
showBanner(uploadBanner, "error", "File is too large — the limit is 10 MiB.");
showBanner(uploadBanner, "error", "File is too large — the limit is 24 MiB.");
return;
}
uploadSubmit.disabled = true;

Loading…
Cancel
Save