The code cited its plan instead of saying why: `E28 review J4`, `(R6 S3b)`, `S1 justified…`,
`deep-review round 5`. That plan lives in the private ops repo, so for every reader of this one the
citation was noise, and a sentence whose subject was the plan step ("round 2 showed…") hid its
lesson behind an id. Each one is gone and the "why" kept: a bare citation is deleted, a sentence
carrying a lesson is reworded around it ("a later attempt showed…").
Measured (`scripts/audit_metrics.py`, product code): 652 lines in 270 files carried an epic code by
the definition the next commit holds (507 / 241 by the old, two-digit-only one) -> 0. Across all
code, tests, scripts and configs: ~1100 epic-code lines plus ~320 sprint/round ids.
Behaviour unchanged, checked mechanically: every changed .py file has the same AST once docstrings
are blanked; css/sh/toml/yml/json files are identical once comments are stripped; the only
non-comment changes in TS/JS are test titles, one description string in a contract table, and five
ESLint messages, whose text is all the gates snapshot diff holds. Not touched: Markdown (docs and
the changelog keep their history), Alembic migrations, TV episode notation (`S2 · E5`), DESIGN.md
ids (`C1`, `B4`, `S3h`), tracker ids (`SEC-16`, `DX-17`).
One stale claim fixed on the way: test_hls_reaper's docstring said the player checkpoints "every
10 s whether or not it is playing", which plex/stream.py calls wrong; it now says what the code does.
149 lines
6.8 KiB
Python
149 lines
6.8 KiB
Python
"""Route-layer shared helpers for the API contract.
|
|
|
|
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", lock: bool = False
|
|
) -> M:
|
|
"""Fetch a row by primary key or raise 404. Replaces the `db.get(Model, id); if None: 404`
|
|
idiom repeated across routes. `lock` takes it FOR UPDATE (bypassing the identity map), so two
|
|
requests that read-then-write its children (a playlist's item positions) run one after the other."""
|
|
obj = db.get(model, id_, with_for_update=True if lock else None)
|
|
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",
|
|
lock: bool = False,
|
|
) -> M:
|
|
"""Fetch a row by primary key that `user` owns, or raise 404 (`lock`: see `get_or_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, lock=lock)
|
|
if getattr(obj, owner_attr) != user.id:
|
|
raise not_found(what)
|
|
return obj
|
|
|
|
|
|
def youtube_refusal(exc: Exception, *, playlist: bool = False) -> 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. `playlist`: the route acts on one
|
|
playlist the user picked, so a missing playlist is THAT one, gone (`refusal_reason`)."""
|
|
reason = (
|
|
refusal_reason(exc, playlist=playlist) 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 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")
|