A session is one recording, capped at 60 minutes by the server. While it is open, a request naming the caller's own session in X-Debug-Session is logged at the session's level with every line tagged; a header naming anything else is ignored. The browser's batches count a session as consent to its level and keep the tag only on its own events. Screenshots are judged by their bytes, capped in size and count, and served back to their owner only. - debug_sessions / debug_screenshots tables, one open session per user (partial unique index), log_events.debug_session_id indexed - /api/debug-sessions: start (joins a running one), stop, list, get, events, screenshots; /api/me reports the live session - the hourly log job closes expired sessions and drops them with the logs - logsink.serialize and routes._common.capped_body shared by their readers
78 lines
3.4 KiB
Python
78 lines
3.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 Any, TypeVar
|
|
|
|
from fastapi import HTTPException, Request
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.db import Base
|
|
from app.models import User
|
|
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)
|