Files
siftlode/backend/app/routes/_common.py
T
peter 3d7ef9e945 fix(uploads): drain an over-cap body before refusing it
Found in the browser UAT, not by the gate. Moving the cap onto the arriving
bytes meant it fires while the client is still sending, and answering + closing
mid-upload is what XHR reports as a network failure: the upload dialog said
"the server couldn't be reached" while the access log showed a correct 422
carrying the limit. A refusal the client cannot read is an outage as far as the
user is concerned.

`capped_stream_to` now reads the remainder to nowhere before raising, so the
client finishes sending and reads the real answer -- re-measured in the
browser, which now names the limit. Nothing past the cap is written, and the
request is still bounded by bodylimit's ceiling, so the cost is bandwidth an
AUTHENTICATED caller was sending anyway. The anonymous path is untouched:
auth still resolves before a byte is read.

The regression test sits on the helper, not the route, and the reason is worth
keeping: the route-level version was written first and PASSED with the drain
deleted. httpx's ASGI transport pumps the request body whether or not the app
reads it, so "drained" and "abandoned" are indistinguishable through
TestClient. Its own mutation check is what exposed it.
2026-09-22 02:11:21 +02:00

142 lines
6.4 KiB
Python

"""Route-layer shared helpers for the API contract (R6 S1a).
Small, dependency-light guards that several route modules had hand-rolled with subtle
drift. Keeping them here (imported by the route modules) means the "fetch a row or 404"
and "fetch a row this user owns or 404" idioms have one definition, one status code, and
one existence-leak policy.
"""
from typing import IO, Any, TypeVar
from fastapi import HTTPException, Request
from sqlalchemy.orm import Session
from app.db import Base
from app.models import User
from app.issues import gitea
from app.refusal import refused
from app.youtube.client import YouTubeError, refusal_reason
M = TypeVar("M", bound=Base)
def not_found(what: str = "generic") -> HTTPException:
"""The 404 for a row that is gone — structured, so the bilingual UI says it in the reader's
language (`errors.notFound.<what>`, generic fallback). This is the 404 a user actually meets: the
thing was deleted in another tab or on another device. It used to be a bare English string
("Unknown view", "Not found"), which the Hungarian UI toasted verbatim."""
return HTTPException(status_code=404, detail={"code": "not_found", "reason": what})
def get_or_404(db: Session, model: type[M], id_: Any, *, what: str = "generic") -> M:
"""Fetch a row by primary key or raise 404. Replaces the `db.get(Model, id); if None: 404`
idiom repeated across routes."""
obj = db.get(model, id_)
if obj is None:
raise not_found(what)
return obj
def owned_or_404(
db: Session,
model: type[M],
id_: Any,
user: User,
*,
owner_attr: str = "user_id",
what: str = "generic",
) -> M:
"""Fetch a row by primary key that `user` owns, or raise 404. A row owned by someone
else 404s (NOT 403) on purpose — a 403 would confirm the id exists to a stranger.
It replaced the near-identical per-route ownership helpers, and is called directly — a thin
`_own_x` wrapper around it is the ad-hoc shape CLAUDE.md's patterns table rules out."""
obj = get_or_404(db, model, id_, what=what)
if getattr(obj, owner_attr) != user.id:
raise not_found(what)
return obj
def youtube_refusal(exc: Exception) -> HTTPException:
"""The answer to an action YouTube refused: a 422 whose reason the UI translates
(`errors.youtube.<reason>`). One definition, because the routes had grown three shapes of it —
a bare English string quoting the upstream message, a playlist-scoped reason, and nothing at
all (a raw 500 from the manual syncs). The upstream failure is already logged where it
happened (`youtube.api.error`). A failure that is not the Data API's (the search scraper's) is
YouTube being unavailable to us — the retryable answer."""
reason = refusal_reason(exc) if isinstance(exc, YouTubeError) else "unavailable"
return HTTPException(status_code=422, detail={"code": "youtube", "reason": reason})
async def capped_body(request: Request, limit: int) -> bytes:
"""The request body, refused (413 `too_large`) the moment it passes `limit` bytes while still
arriving. Counted, not taken from Content-Length: a declared length can be absent (chunked) or
wrong, and counting stops reading at the same point a declared-length check would have."""
body = bytearray()
async for chunk in request.stream():
body += chunk
if len(body) > limit:
raise HTTPException(status_code=413, detail={"code": "too_large"})
return bytes(body)
async def capped_stream_to(
request: Request, out: IO[bytes], limit: int, refusal: HTTPException
) -> int:
"""`capped_body`'s sibling for a body too large to hold: the same counting rule, written
straight to `out` instead of accumulated, returning the size.
Two deliberate differences from `capped_body`. It never builds a `bytearray` — the upload route
admits 500 MiB by default, so holding the body would trade a disk problem for a memory one. And
the refusal is the CALLER'S, passed in rather than assumed: `capped_body`'s flat 413 is right
for a screenshot, while the upload route answers 422 `upload_too_large` carrying the admin
knob's own number, which is what its dialog shows the user. One counting rule, two answers,
neither of them a second copy of the loop.
The caller owns `out` and owns cleaning it up: a body refused half-way has already written what
arrived before the limit was passed.
**The refusal DRAINS the rest of the body before it is raised, and that is not politeness.**
Answering mid-upload and closing the connection is what the client sees as a network failure,
not as a refusal: the browser's XHR fires `onerror` before it can read the response, so a file
over the limit reported itself as "the server couldn't be reached" instead of naming the limit —
measured in a real browser, not reasoned about. Reading the remainder to nowhere lets the client
finish sending, read the 422 and say the true thing.
The bytes are discarded, never written, and the whole request is already bounded by
`bodylimit`'s per-route ceiling — so the worst case is bandwidth an authenticated caller was
sending anyway, against the old behaviour which wrote exactly those bytes to disk."""
size = 0
over = False
async for chunk in request.stream():
size += len(chunk)
if over:
continue # draining — see the docstring; counted, not stored
if size > limit:
over = True
continue
out.write(chunk)
if over:
raise refusal
return size
def issue_refused(exc: gitea.GiteaError) -> HTTPException:
"""The refusal an issue-tracker failure (E16) answers as — the send, the preview and the
admin's connection test alike. Literal reasons, one per case: the frontend's contract test reads
them to require a sentence for each in both languages."""
match exc.reason:
case "not_configured":
return refused(400, "issues_not_configured")
case "unreachable":
return refused(422, "issues_unreachable")
case "bad_token":
return refused(422, "issues_bad_token")
case "repo_not_found":
return refused(422, "issues_repo_not_found")
case "public_repo":
return refused(422, "issues_public_repo")
case "issues_disabled":
return refused(422, "issues_disabled")
case _:
return refused(422, "issues_rejected")