refactor(capture): the HTTP layer reads through queries and writes through the service

Capture's routes (downloads, files, public watch links) and the token list no longer build SQL, call
the session or set a model's fields: reads are downloads/queries, writes the unit's service (service,
folders, links, quota), and each write flushes for the handler to commit. Whose a row is goes
through userscope (owned_by, owned_row), which owned_or_404 now uses too.

The fence holds Capture below its routes as well: it reads only Catalog's and Plex's tables, writes
no other unit's, filters owners through userscope and commits only in its jobs and the upload
ingest. A test per service use case, among them that the tray lists only the caller's files.
This commit is contained in:
2026-10-19 00:19:03 +02:00
parent 3a688fa0f5
commit e9c60f72cd
20 changed files with 1632 additions and 706 deletions
+73 -5
View File
@@ -1,13 +1,54 @@
"""Shared "who is an active, non-suspended account" predicate.
"""Whose a row is, and who an account is to the others — one place for both.
Used wherever a user must be filtered out of a recipient list or rejected as a target: the download
share-by-email and recipient-picker endpoints. Kept in one place so the two forms (SQL clauses for a
query, Python check for a loaded row) can't drift apart, and so a route module doesn't have to import
another route module just to reuse the rule.
**Ownership.** A user-owned table says whose a row is in one column (`user_id`, or `to_user_id` for
something handed to them). A unit's reads and writes name that column through `owned_by` and fetch a
row by id through `owned_row`, never with an inline `Model.user_id == …`: the rule "a row owned by
someone else is answered like a missing one" then lives here, and a contract test
(`test_unit_fences.py`) holds a switched-on unit to it.
**Recipients.** "An active, non-suspended account" — the download share-by-email and the recipient
picker filter on it. Kept here so the two forms (SQL clauses for a query, a Python check for a loaded
row) can't drift apart.
"""
from typing import Any, TypeVar
from sqlalchemy import ColumnElement, func, select
from sqlalchemy.orm import InstrumentedAttribute, Session
from app.collation import collated
from app.db import Base
from app.models import User
M = TypeVar("M", bound=Base)
def _owner_id(owner: User | int) -> int:
return owner if isinstance(owner, int) else owner.id
def owned_by(column: InstrumentedAttribute, owner: User | int) -> ColumnElement[bool]:
"""`column` — a table's owner column — names `owner`."""
return column == _owner_id(owner)
def owned_row(
db: Session,
model: type[M],
id_: Any,
owner: User | int,
*,
owner_attr: str = "user_id",
lock: bool = False,
) -> M | None:
"""The row of `model` with this primary key when `owner` owns it, else None — a row of someone
else's is the same answer as a missing one, so a caller can never tell the two apart. `lock`
takes it FOR UPDATE (past the identity map)."""
row = db.get(model, id_, with_for_update=True if lock else None)
if row is None or getattr(row, owner_attr) != _owner_id(owner):
return None
return row
def messageable_clauses() -> list:
"""SQL clauses for "an active, non-suspended account" — splat into a recipient query."""
@@ -20,3 +61,30 @@ def messageable_clauses() -> list:
def is_messageable_user(u: User | None) -> bool:
"""Python mirror of `messageable_clauses()` for an already-loaded row (a share recipient)."""
return bool(u and u.is_active and not u.is_suspended)
def user_by_email(db: Session, email: str) -> User | None:
"""The account with this address, matched without regard to case."""
return db.execute(
select(User).where(func.lower(User.email) == email.strip().lower())
).scalar_one_or_none()
def messageable_others(db: Session, user: User, lang: str) -> list[User]:
"""Every account `user` may hand something to: active, not suspended, not themselves; in the
reader's alphabet by address."""
return list(
db.execute(
select(User)
.where(User.id != user.id, *messageable_clauses())
.order_by(collated(User.email, lang))
).scalars()
)
def emails_of(db: Session, ids: set[int] | None = None) -> dict[int, str]:
"""Each account's address by id, in address order: these ids, or every account."""
stmt = select(User.id, User.email).order_by(User.email)
if ids is not None:
stmt = stmt.where(User.id.in_(ids))
return dict(db.execute(stmt).tuples().all())