fix(uploads): make both caps answerable, and stop holding a connection
The epic-end deep review found three defects in P1 and four tests that did not hold what they claimed. They land together because they share the upload route and its test module; splitting by hunk would have produced commits that do not individually run green. * The edge ceiling and the route's own cap are the SAME number at the default configuration, so the edge always answers first: a declared Content-Length over the cap is refused before a byte is read, and an undeclared body trips the middleware on the same chunk the route would have tripped on. The route's numbered 422 -- and the drain written for it last week -- are reachable only once an admin LOWERS the knob, which is the one shape the browser UAT tested. Measured: headroom does not fix this, because the declared-length check fires first for any body big enough to matter and the drain pushes the count back over the edge for the rest. So the edge's own refusal is made readable instead: `errors.tooLarge` localizes `too_large` the way `upload_too_large` was already localized, and the dialog refuses an over-cap file BEFORE sending it, off the `upload_max_bytes` that GET /api/downloads/usage now reports. Measured in Chrome against the API directly: that 413 arrives as `onload` with a readable body in 43 ms. * Reading the body behind the auth dependency put the whole transfer inside the request's database session -- the auth SELECT autobegins a transaction, so one of the twenty pooled connections sat `idle in transaction`, with nothing to time it out, for as long as the client cared to keep sending. That is the invariant db.py sizes the pool on and plex/playback.py records an incident for. The read transaction now ends before the await. Measured on the dev server: an 1818 ms upload shows no idle-in-transaction sample while the bytes move, only afterwards, when the handler actually writes. * The display name moved into a request header, where nothing bounds its length, while the column stayed varchar(255) -- so a long enough name was a DataError 500 after the whole file had already transferred. One `display_text` helper now serves all three writers (the metadata PATCH already truncated, the upload ingest did not), and the dialog stops at the same number. * The gate invariant asked `route.dependant.body_params`, which holds only the route's OWN callable's parameters: a body declared in a SUB-DEPENDENCY -- the upload route's own idiom -- leaves it empty while FastAPI still reads the body before auth. It asks `route.body_field` now, which is what FastAPI itself consults. The AST registry of body-reading call sites had also never followed `capped_stream_to`, so the one route the whole invariant exists for was the single site the drift guard could not see. Four tests that passed over their own mutation are fixed: the cap boundary (a `>` loosened to `>=` survived the entire suite), the percent-decoding (every filename the suite sends is ASCII, where quote() is the identity function), the display-name header (no test at all), and the staging cleanup (deleting the unlink left the suite green while every refused upload leaked its partial bytes onto the volume). Every test added or changed here was mutation-checked.
This commit is contained in:
@@ -119,6 +119,13 @@ function localizeDetail(d: unknown): string {
|
||||
if (o?.code === "upload_too_large")
|
||||
return t("downloads.errors.upload.tooLarge", { size: formatBytes(o.limit) });
|
||||
if (o?.code === "upload_empty") return t("downloads.errors.upload.empty");
|
||||
// The EDGE ceiling's refusal (`bodylimit.TOO_LARGE_BODY`), shared by every route and carrying no
|
||||
// number. Not a theoretical case: on a DEFAULT install it is what an over-cap upload actually
|
||||
// gets, because the ceiling and the route's own cap are the same figure and the edge answers
|
||||
// first — the route's numbered `upload_too_large` above is the answer only once an admin has
|
||||
// LOWERED the knob (see bodylimit.ROUTE_MAX_BODY_BYTES). Without this branch that refusal fell
|
||||
// through to the generic "couldn't carry out that action", the one message no retry can fix.
|
||||
if (o?.code === "too_large") return t("errors.tooLarge");
|
||||
// SEC-12: a download URL the server refuses to fetch. The reasons are deliberately distinct —
|
||||
// "we could not look that host up" is a different thing to tell someone than "we will not fetch
|
||||
// from that address" — so an unknown reason falls back to the generic refusal rather than a key.
|
||||
@@ -378,16 +385,17 @@ export async function req<T = unknown>(
|
||||
// credentials, the structured-detail localization, the global error modal for 4xx/5xx, and an
|
||||
// `HttpError` throw — so callers handle failures exactly as they do a `req` call.
|
||||
//
|
||||
// `body` is a `Blob`/`File` for a raw upload or `FormData` for a multipart one. The raw shape is
|
||||
// what `/api/downloads/upload` uses since P1, and it is not a preference: a multipart part is a
|
||||
// DECLARED body, which FastAPI spools before the route's auth dependency runs. `cfg.headers` is how
|
||||
// such a caller sends the metadata that used to be sibling form fields.
|
||||
// `body` is a `Blob`/`File` — the bytes themselves, and nothing else. `FormData` was the other
|
||||
// accepted shape until P1 and is deliberately gone: a multipart part is a DECLARED body, which
|
||||
// FastAPI spools to disk before the route's auth dependency runs, and the backend dropped
|
||||
// `python-multipart` altogether, so there is no longer anything on the other end that could parse
|
||||
// one. `cfg.headers` is how a caller sends the metadata that used to ride as sibling form fields.
|
||||
//
|
||||
// Content-Type is never set here. The browser derives it — the multipart boundary for FormData, the
|
||||
// Blob's own type otherwise — and overriding it would break the boundary.
|
||||
// Content-Type is never set here: the browser derives it from the Blob's own type, and a wrong one
|
||||
// is worse than none.
|
||||
export function uploadReq<T = unknown>(
|
||||
url: string,
|
||||
body: Blob | FormData,
|
||||
body: Blob,
|
||||
cfg: {
|
||||
onProgress?: (pct: number) => void;
|
||||
signal?: AbortSignal;
|
||||
@@ -401,7 +409,10 @@ export function uploadReq<T = unknown>(
|
||||
const requestId = newRequestId();
|
||||
const started = performance.now();
|
||||
xhr.setRequestHeader(REQUEST_ID_HEADER, requestId);
|
||||
for (const [name, value] of Object.entries({ ...identityHeaders(), ...cfg.headers }))
|
||||
// `identityHeaders()` LAST: who the request is from is this layer's to state, not the caller's.
|
||||
// Merged the other way round, a caller's header of the same name would silently re-attribute
|
||||
// the upload to another account — the one thing in here that must not be overridable.
|
||||
for (const [name, value] of Object.entries({ ...cfg.headers, ...identityHeaders() }))
|
||||
xhr.setRequestHeader(name, value);
|
||||
if (cfg.onProgress) {
|
||||
xhr.upload.onprogress = (e) => {
|
||||
|
||||
Reference in New Issue
Block a user