feat(files): API tokens — a machine reaches the library folder by folder

A user can make a token for a machine of theirs (`/api/tokens`, signed-in only): `sfl_` + 32
random bytes + a checksum, only its SHA-256 kept, shown once. Sent as a Bearer header it is
honoured on six `/api/files` routes only — folders, list, file, hash, upload, delete — and there
only in the folders its grants name, with the rights they give (list / read / write / delete).
One check, `FilesCaller.may`, in every route; a folder or file out of reach answers like a
missing one; the files shared with the user are never in reach. A token beats a cookie on the
same request.

The Hub preset makes the user's one Hub folder (a new folder kind) and grants it every right.
Refused tokens are slowed down per client, a runaway machine per token; last use is stamped at
most once a minute; making and revoking a token is audited, and a token's uploads and deletes
name it in their audit rows.
This commit is contained in:
2026-10-14 18:40:23 +02:00
parent c335d54929
commit b2d34e6e6c
18 changed files with 1080 additions and 48 deletions
+1 -1
View File
@@ -62,7 +62,7 @@ how it gets built. Concretely:
## Architecture shape (don't fight it) ## Architecture shape (don't fight it)
`routes/*` (HTTP only: validation, auth-dependency, serialization) → domain packages `sync/`, `plex/`, `routes/*` (HTTP only: validation, auth-dependency, serialization) → domain packages `sync/`, `plex/`,
`downloads/`, `youtube/`, `issues/` (shared by routes + scheduler + worker) → flat core (`auth`, `quota`, `downloads/`, `youtube/`, `issues/` (shared by routes + scheduler + worker) → flat core (`auth`, `apitokens`, `quota`,
`sysconfig`, `state`, `audit`, `notifications`, `userscope`, `ratelimit`, `bodylimit`, `progress`, `collation`, `sysconfig`, `state`, `audit`, `notifications`, `userscope`, `ratelimit`, `bodylimit`, `progress`, `collation`,
`security`, `logs`, `logsink`, `diagnostics`, `debugsession`) → `models/db/config`. **Dependency direction is one-way: core never imports routes.** `security`, `logs`, `logsink`, `diagnostics`, `debugsession`) → `models/db/config`. **Dependency direction is one-way: core never imports routes.**
Frontend: `main.tsx` provider tree → `App.tsx` shell (a shared `/watch` link boots from its own Frontend: `main.tsx` provider tree → `App.tsx` shell (a shared `/watch` link boots from its own
@@ -0,0 +1,94 @@
"""Machine access to the Files library: API tokens bound to folders, and the Hub folder
Revision ID: 0106_api_tokens
Revises: 0105_job_source_key
Create Date: 2026-10-14
`api_tokens` is a user's token for a machine (a script, the hub): only its SHA-256 is kept, plus the
first characters to recognise it by. `api_token_grants` says what it may do in which folder — list,
read, write, delete — so a token reaches nothing it was not given, folder by folder.
A third folder kind, `hub`: the one folder a user exchanges files with their hub machine through.
Like a source folder its name is the client's to render; like an own folder a file may be moved in.
One per user.
"""
import sqlalchemy as sa
from alembic import op
revision: str = "0106_api_tokens"
down_revision: str | None = "0105_job_source_key"
branch_labels: str | None = None
depends_on: str | None = None
_KIND_OLD = (
"(kind = 'source' AND source_key IS NOT NULL AND name IS NULL)"
" OR (kind = 'own' AND name IS NOT NULL AND source_key IS NULL)"
)
_KIND_NEW = _KIND_OLD + " OR (kind = 'hub' AND name IS NULL AND source_key IS NULL)"
def upgrade() -> None:
op.drop_constraint("ck_file_folder_kind", "file_folders", type_="check")
op.create_check_constraint("ck_file_folder_kind", "file_folders", _KIND_NEW)
op.create_index(
"uq_file_folder_hub",
"file_folders",
["user_id"],
unique=True,
postgresql_where=sa.text("kind = 'hub'"),
)
op.create_table(
"api_tokens",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"user_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True,
),
sa.Column("name", sa.String(80), nullable=False),
sa.Column("prefix", sa.String(16), nullable=False),
sa.Column("token_hash", sa.String(64), nullable=False, unique=True),
sa.Column("expires_at", sa.DateTime(timezone=True)),
sa.Column("last_used_at", sa.DateTime(timezone=True)),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
)
op.create_table(
"api_token_grants",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"token_id",
sa.Integer(),
sa.ForeignKey("api_tokens.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"folder_id",
sa.Integer(),
sa.ForeignKey("file_folders.id", ondelete="CASCADE"),
nullable=False,
index=True,
),
sa.Column("can_list", sa.Boolean(), nullable=False, server_default=sa.text("false")),
sa.Column("can_read", sa.Boolean(), nullable=False, server_default=sa.text("false")),
sa.Column("can_write", sa.Boolean(), nullable=False, server_default=sa.text("false")),
sa.Column("can_delete", sa.Boolean(), nullable=False, server_default=sa.text("false")),
sa.UniqueConstraint("token_id", "folder_id", name="uq_api_token_grant"),
sa.CheckConstraint(
"can_list OR can_read OR can_write OR can_delete", name="ck_api_token_grant_some"
),
)
def downgrade() -> None:
op.drop_table("api_token_grants")
op.drop_table("api_tokens")
op.drop_index("uq_file_folder_hub", table_name="file_folders")
# A Hub folder may hold files (their foreign key RESTRICTs a delete): it becomes an own folder.
op.execute("UPDATE file_folders SET kind = 'own', name = 'Hub ' || id WHERE kind = 'hub'")
op.drop_constraint("ck_file_folder_kind", "file_folders", type_="check")
op.create_check_constraint("ck_file_folder_kind", "file_folders", _KIND_OLD)
+2 -1
View File
@@ -20,7 +20,8 @@ Three layers, by *who* runs the code — not by feature:
run by both the admin route and the scheduler; `downloads/pipeline.py` is the worker's child pipeline; run by both the admin route and the scheduler; `downloads/pipeline.py` is the worker's child pipeline;
`issues/bundle.py` is the one definition of a bug report's files, for the zip export and the tracker. `issues/bundle.py` is the one definition of a bug report's files, for the zip export and the tracker.
3. **Flat `app/*.py` core modules — cross-cutting primitives.** `auth`, `sysconfig`, `quota`, `audit`, 3. **Flat `app/*.py` core modules — cross-cutting primitives.** `auth` (with `apitokens`, a machine's
folder-scoped way into the Files library), `sysconfig`, `quota`, `audit`,
`notifications`, `userscope`, `ratelimit`, `progress`, `logs` (the one logging `notifications`, `userscope`, `ratelimit`, `progress`, `logs` (the one logging
configuration), `logsink` (its `log_events` table), `diagnostics` (a user's consented log configuration), `logsink` (its `log_events` table), `diagnostics` (a user's consented log
level), `debugsession` (a recording that tags its user's lines, E16) and `collation` (A→Z in the level), `debugsession` (a recording that tags its user's lines, E16) and `collation` (A→Z in the
+209
View File
@@ -0,0 +1,209 @@
"""Machine access to the Files library: API tokens, and what each one may do where.
A user makes a token for a machine of theirs — their hub, a script — and it reaches their library as
`Authorization: Bearer sfl_…`. Only on `/api/files`: the routes there take `files_caller` instead of
`current_user`, and nothing else does (a test holds the route table to that), so a token can never
manage the account, the tokens, or anything outside the library. And only in the folders its grants
name, with the rights they give: list, read, write, delete. Every route asks the one question,
`FilesCaller.may(folder_id, right)`; a signed-in user may everything in their own folders.
The token is `sfl_` + 32 random bytes in base62 + a CRC32 of them: a mistyped or cut-off token is
refused without a database read, and a secret scanner can tell one by its shape. Only its SHA-256 is
kept (`security.hash_token`) — the user sees it once, when it is made — with its first characters to
tell tokens apart by. It is looked up by that hash on every request: no signed claims, so a deleted
token stops working at once.
"""
import secrets
import zlib
from collections.abc import Mapping
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from enum import StrEnum
from fastapi import Depends, Request
from sqlalchemy import or_, select, update
from sqlalchemy.orm import Session
from app import debugsession, logs
from app.auth import current_user
from app.db import get_db
from app.models import ApiToken, ApiTokenGrant, User
from app.ratelimit import RateLimiter, client_key
from app.refusal import refused
from app.security import hash_token
log = logs.get_logger(__name__)
PREFIX = "sfl_"
_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
_BODY_BYTES = 32
_BODY_LEN = 43 # 62**43 > 2**256: every 32-byte value fits
_CHECK_LEN = 6 # 62**6 > 2**32: every CRC32 fits
TOKEN_LEN = len(PREFIX) + _BODY_LEN + _CHECK_LEN
# What a token is shown by once it is made: enough to tell a user's tokens apart, far too little to
# narrow a guess (8 of its 43 random characters).
SHOWN_LEN = len(PREFIX) + 8
# A machine that runs away (a loop in a script) is held back per token; a caller trying tokens is
# held back per client, counting only the refusals.
_calls = RateLimiter(600, 60)
_bad = RateLimiter(20, 300)
# How stale `last_used_at` may get before a request writes it again: a busy script costs one write a
# minute, not one per request.
_TOUCH_EVERY = timedelta(minutes=1)
class Right(StrEnum):
LIST = "list"
READ = "read"
WRITE = "write"
DELETE = "delete"
# Each right's column on a grant. A dict over the enum, so a new right without a column fails here.
GRANT_COLUMNS = {
Right.LIST: "can_list",
Right.READ: "can_read",
Right.WRITE: "can_write",
Right.DELETE: "can_delete",
}
def _b62(value: int, width: int) -> str:
out = []
for _ in range(width):
value, digit = divmod(value, 62)
out.append(_ALPHABET[digit])
return "".join(reversed(out))
def _check(body: str) -> str:
return _b62(zlib.crc32(body.encode()), _CHECK_LEN)
def mint() -> str:
"""A new token, in full. The caller stores its hash and shows it to the user, once."""
body = _b62(int.from_bytes(secrets.token_bytes(_BODY_BYTES)), _BODY_LEN)
return PREFIX + body + _check(body)
def well_formed(raw: str) -> bool:
"""Whether `raw` has a token's shape and its checksum adds up."""
if len(raw) != TOKEN_LEN or not raw.startswith(PREFIX):
return False
body, check = raw[len(PREFIX) : -_CHECK_LEN], raw[-_CHECK_LEN:]
if any(c not in _ALPHABET for c in body):
return False
return secrets.compare_digest(_check(body), check)
def grant_rights(grant: ApiTokenGrant) -> frozenset[Right]:
return frozenset(r for r, col in GRANT_COLUMNS.items() if getattr(grant, col))
@dataclass(frozen=True)
class FilesCaller:
"""Who is asking the Files library, and where they may do what. `grants` is None for a signed-in
user (everything in their own folders); for a token, folder id → its rights there."""
user: User
token_id: int | None = None
token_prefix: str | None = None
grants: Mapping[int, frozenset[Right]] | None = None
def may(self, folder_id: int | None, right: Right) -> bool:
if self.grants is None:
return True
return folder_id is not None and right in self.grants.get(folder_id, frozenset())
def folders_with(self, right: Right) -> set[int] | None:
"""The folders `right` is granted in; None = any of the user's own."""
if self.grants is None:
return None
return {f for f, rights in self.grants.items() if right in rights}
@property
def via(self) -> str | None:
"""How an audit row says a token did it, None when the user did."""
return None if self.token_prefix is None else f"api token {self.token_prefix}…"
def bearer_token(request: Request) -> str | None:
"""The token of an `Authorization: Bearer` header; None when the request carries none (another
scheme counts as none — it is not ours to judge)."""
scheme, _, value = request.headers.get("authorization", "").partition(" ")
if scheme.lower() != "bearer":
return None
return value.strip()
def session_caller(request: Request, db: Session = Depends(get_db)) -> User | None:
"""The signed-in user, or None when the request brings a token instead: a token wins over a
cookie, so a machine's request is judged by its grants alone, whatever else it carries."""
if bearer_token(request) is not None:
return None
return current_user(request, db)
def files_caller(
request: Request,
db: Session = Depends(get_db),
user: User | None = Depends(session_caller),
) -> FilesCaller:
"""The `/api/files` caller: a signed-in user, or a valid token's user with its grants."""
if user is not None:
return FilesCaller(user=user)
return _token_caller(request, db, bearer_token(request) or "")
def _refuse_token(key: str, reason: str) -> Exception:
_bad.allow(key)
log.info(
"a request brought an API token that does not hold",
extra=logs.event("apitoken.refused", reason=reason),
)
return refused(401, "not_authenticated")
def _token_caller(request: Request, db: Session, raw: str) -> FilesCaller:
key = client_key(request)
if _bad.exhausted(key):
raise refused(429, "api_token_rate_limited")
if not well_formed(raw):
raise _refuse_token(key, "malformed")
token = db.execute(
select(ApiToken).where(ApiToken.token_hash == hash_token(raw))
).scalar_one_or_none()
if token is None:
raise _refuse_token(key, "unknown")
now = datetime.now(timezone.utc)
if token.expires_at is not None and token.expires_at <= now:
raise _refuse_token(key, "expired")
user = db.get(User, token.user_id)
if user is None or not user.is_active or user.is_suspended:
raise _refuse_token(key, "account")
if not _calls.allow(str(token.id)):
raise refused(429, "api_token_rate_limited")
grants = {
g.folder_id: grant_rights(g)
for g in db.execute(select(ApiTokenGrant).where(ApiTokenGrant.token_id == token.id))
.scalars()
.all()
}
caller = FilesCaller(user=user, token_id=token.id, token_prefix=token.prefix, grants=grants)
db.execute(
update(ApiToken)
.where(
ApiToken.id == token.id,
or_(ApiToken.last_used_at.is_(None), ApiToken.last_used_at < now - _TOUCH_EVERY),
)
.values(last_used_at=now)
.execution_options(synchronize_session=False)
)
# Committed here, ahead of the route: a read-only route never commits, and a write route must
# not carry this along into a rollback of its own.
db.commit()
logs.set_user(user.id, *debugsession.level_for(user, None))
request.state.user_id = user.id
return caller
+4
View File
@@ -69,6 +69,10 @@ class AuditAction:
FILE_FOLDER_RENAME = "file_folder_rename" FILE_FOLDER_RENAME = "file_folder_rename"
FILE_FOLDER_RETENTION = "file_folder_retention" FILE_FOLDER_RETENTION = "file_folder_retention"
FILE_FOLDER_DELETE = "file_folder_delete" FILE_FOLDER_DELETE = "file_folder_delete"
# A machine's token to the library (`routes.tokens`): made (its folders and rights) or revoked.
# What a token then did is the file rows above, their summary naming it.
API_TOKEN_CREATE = "api_token_create"
API_TOKEN_REVOKE = "api_token_revoke"
def record( def record(
+24
View File
@@ -7,6 +7,9 @@ shared asset can be held by several users), so a rename or a move is a column up
uploads, one per other site). Its `source_key` names the origin; the client renders its name uploads, one per other site). Its `source_key` names the origin; the client renders its name
from the key, in the reader's language. from the key, in the reader's language.
- an OWN folder is the user's, with the name they gave it. - an OWN folder is the user's, with the name they gave it.
- the HUB folder (one per user, made when they first give a machine a token for it, `hub_folder`) is
where they swap files with that machine. Its name, like a source folder's, is the client's; a file
may be moved in like into an own folder, and it keeps a file until it is deleted.
Every file of a user is in exactly one folder. The folder a new file lands in is decided here Every file of a user is in exactly one folder. The folder a new file lands in is decided here
(`source_key_for` + `source_folder`), at every place a job is created, so the rule lives once. A file (`source_key_for` + `source_folder`), at every place a job is created, so the rule lives once. A file
@@ -35,6 +38,7 @@ from app.models import DownloadJob, FileFolder, MediaAsset
class FolderKind(StrEnum): class FolderKind(StrEnum):
SOURCE = "source" SOURCE = "source"
OWN = "own" OWN = "own"
HUB = "hub"
class SourceKey(StrEnum): class SourceKey(StrEnum):
@@ -123,6 +127,26 @@ def source_folder(db: Session, user_id: int, key: str) -> FileFolder:
return folder return folder
def hub_folder(db: Session, user_id: int) -> FileFolder:
"""The user's Hub folder, made on first use; a race on its one-per-user index is settled like
`source_folder`'s."""
stmt = select(FileFolder).where(
FileFolder.user_id == user_id, FileFolder.kind == FolderKind.HUB
)
folder = db.execute(stmt).scalar_one_or_none()
if folder is not None:
return folder
folder = FileFolder(user_id=user_id, kind=FolderKind.HUB)
try:
with db.begin_nested():
db.add(folder)
db.flush()
# silent-ok: another request made the Hub folder first; re-selecting it is the whole handling.
except IntegrityError:
folder = db.execute(stmt).scalar_one()
return folder
def place(db: Session, job: DownloadJob) -> None: def place(db: Session, job: DownloadJob) -> None:
"""Put a new job's file in its source folder, and record that origin on it. Called wherever a """Put a new job's file in its source folder, and record that origin on it. Called wherever a
job is created; the job must carry its `user_id`, `source_kind` and source refs already.""" job is created; the job must carry its `user_id`, `source_kind` and source refs already."""
+11 -2
View File
@@ -57,12 +57,14 @@ def ingest_upload(
display_name: str | None = None, display_name: str | None = None,
sha256: str | None = None, sha256: str | None = None,
folder_id: int | None = None, folder_id: int | None = None,
via: str | None = None,
) -> DownloadJob: ) -> DownloadJob:
"""Ingest an already-uploaded file at `src_path` into `user_id`'s library and return its job — """Ingest an already-uploaded file at `src_path` into `user_id`'s library and return its job —
`done`, or `queued` for the worker's phone-safe conversion (see the module docstring). `src_path` `done`, or `queued` for the worker's phone-safe conversion (see the module docstring). `src_path`
is MOVED into place on success (the caller stages it to a temp file first). `sha256` is the is MOVED into place on success (the caller stages it to a temp file first). `sha256` is the
hash of those bytes, when the caller took it on the way in; `folder_id` the folder it asked for hash of those bytes, when the caller took it on the way in; `folder_id` the folder it asked for
(`folders.upload_target`), else its Uploads folder. (`folders.upload_target`), else its Uploads folder; `via` what the audit row names as the doer
when it was not the user themselves (an API token).
Raises folders.FolderError when that folder is not one an upload may land in (or is gone). Raises folders.FolderError when that folder is not one an upload may land in (or is gone).
@@ -145,7 +147,14 @@ def ingest_upload(
folders.stamp_done(db, job) folders.stamp_done(db, job)
service.incr_ref_count(db, asset.id) service.incr_ref_count(db, asset.id)
db.flush() db.flush()
audit.record(db, user_id, AuditAction.FILE_UPLOAD, target_type="file", target_id=job.id) audit.record(
db,
user_id,
AuditAction.FILE_UPLOAD,
target_type="file",
target_id=job.id,
summary=via,
)
db.commit() db.commit()
except Exception: except Exception:
db.rollback() db.rollback()
+2
View File
@@ -58,6 +58,7 @@ from app.routes import ( # noqa: E402 — logging is configured first
setup as setup_routes, setup as setup_routes,
sync, sync,
tags, tags,
tokens as tokens_routes,
version, version,
) )
from app.scheduler import shutdown_scheduler, start_scheduler # noqa: E402 — logging is configured first from app.scheduler import shutdown_scheduler, start_scheduler # noqa: E402 — logging is configured first
@@ -337,6 +338,7 @@ app.include_router(plex_routes.router)
app.include_router(downloads.router) app.include_router(downloads.router)
app.include_router(downloads.admin_router) app.include_router(downloads.admin_router)
app.include_router(files_routes.router) app.include_router(files_routes.router)
app.include_router(tokens_routes.router)
app.include_router(public_routes.router) app.include_router(public_routes.router)
app.include_router(admin.router) app.include_router(admin.router)
app.include_router(config_routes.router) app.include_router(config_routes.router)
+45 -2
View File
@@ -919,7 +919,8 @@ class FileFolder(Base, TimestampMixin):
"""A folder of the Files library (`app.downloads.folders`): a row, never a directory. """A folder of the Files library (`app.downloads.folders`): a row, never a directory.
A SOURCE folder (`source_key` set: youtube / plex / upload / site:<host>) is made the first time a A SOURCE folder (`source_key` set: youtube / plex / upload / site:<host>) is made the first time a
user gets a file from that origin; an OWN folder (`name` set) is one the user made. Every file — user gets a file from that origin; an OWN folder (`name` set) is one the user made; the HUB folder
(neither set, one per user) is where they swap files with a machine of theirs. Every file —
a `DownloadJob` — sits in exactly one (`DownloadJob.folder_id`). `retention_days` is how long a a `DownloadJob` — sits in exactly one (`DownloadJob.folder_id`). `retention_days` is how long a
file stays once done (NULL = until deleted); a file's own `expires_at` is stamped from it.""" file stays once done (NULL = until deleted); a file's own `expires_at` is stamped from it."""
@@ -933,9 +934,12 @@ class FileFolder(Base, TimestampMixin):
unique=True, unique=True,
postgresql_where=text("name IS NOT NULL"), postgresql_where=text("name IS NOT NULL"),
), ),
# One Hub folder per user.
Index("uq_file_folder_hub", "user_id", unique=True, postgresql_where=text("kind = 'hub'")),
CheckConstraint( CheckConstraint(
"(kind = 'source' AND source_key IS NOT NULL AND name IS NULL)" "(kind = 'source' AND source_key IS NOT NULL AND name IS NULL)"
" OR (kind = 'own' AND name IS NOT NULL AND source_key IS NULL)", " OR (kind = 'own' AND name IS NOT NULL AND source_key IS NULL)"
" OR (kind = 'hub' AND name IS NULL AND source_key IS NULL)",
name="ck_file_folder_kind", name="ck_file_folder_kind",
), ),
) )
@@ -949,6 +953,45 @@ class FileFolder(Base, TimestampMixin):
retention_days: Mapped[int | None] = mapped_column(Integer) retention_days: Mapped[int | None] = mapped_column(Integer)
class ApiToken(Base, TimestampMixin):
"""A user's token for a machine to reach their Files library with (`app.apitokens`): sent as
`Authorization: Bearer sfl_…`, honoured on `/api/files` only, and only in the folders its
grants name. Only the SHA-256 of the token is kept — it is shown once, when it is made — plus
its first characters (`prefix`), so the user can tell their tokens apart."""
__tablename__ = "api_tokens"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
name: Mapped[str] = mapped_column(String(80))
prefix: Mapped[str] = mapped_column(String(16))
token_hash: Mapped[str] = mapped_column(String(64), unique=True)
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) # NULL = never
last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
class ApiTokenGrant(Base):
"""What one token may do in one folder. A folder with no grant is out of the token's reach."""
__tablename__ = "api_token_grants"
__table_args__ = (
UniqueConstraint("token_id", "folder_id", name="uq_api_token_grant"),
CheckConstraint(
"can_list OR can_read OR can_write OR can_delete", name="ck_api_token_grant_some"
),
)
id: Mapped[int] = mapped_column(primary_key=True)
token_id: Mapped[int] = mapped_column(ForeignKey("api_tokens.id", ondelete="CASCADE"))
folder_id: Mapped[int] = mapped_column(
ForeignKey("file_folders.id", ondelete="CASCADE"), index=True
)
can_list: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
can_read: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
can_write: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
can_delete: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
def _ready_on_insert(context) -> datetime | None: def _ready_on_insert(context) -> datetime | None:
"""A job inserted already done (an upload kept as it came) was ready the moment it was made; one """A job inserted already done (an upload kept as it came) was ready the moment it was made; one
that becomes done later is stamped then (`folders.stamp_done`, `service.finalize_if_running`).""" that becomes done later is stamped then (`folders.stamp_done`, `service.finalize_if_running`)."""
+91 -34
View File
@@ -7,6 +7,11 @@ bytes. Folders are rows (`downloads.folders`), so a rename or a move never touch
In-flight jobs are not here: they are the download tray's (`routes.downloads`). The helpers above In-flight jobs are not here: they are the download tray's (`routes.downloads`). The helpers above
the routes are shared with that module — serialization, the ownership locks, the upload spool — the routes are shared with that module — serialization, the ownership locks, the upload spool —
because a download and a library file are one row at two moments. because a download and a library file are one row at two moments.
A machine with an API token (`app.apitokens`) may use the routes that take `files_caller`: list the
folders and files, fetch a file and its hash, upload, delete — each only in a folder its token was
granted that right in, and never the files shared with the user. A folder or file out of its reach
answers like a missing one. Everything else here is the signed-in user's alone (`current_user`).
""" """
import base64 import base64
@@ -29,6 +34,7 @@ from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app import audit, sysconfig from app import audit, sysconfig
from app.apitokens import FilesCaller, Right, files_caller
from app.audit import AuditAction from app.audit import AuditAction
from app.auth import current_user from app.auth import current_user
from app.collation import collated from app.collation import collated
@@ -208,7 +214,7 @@ UPLOAD_FOLDER_HEADER = "x-upload-folder"
@dataclass(frozen=True) @dataclass(frozen=True)
class SpooledUpload: class SpooledUpload:
"""What the body dependency hands the handler: the bytes already on disk and their hash, plus """What the body dependency hands the handler: the bytes already on disk and their hash, plus
the names and the folder that came alongside them.""" the names and the folder that came alongside them, and who sent them."""
path: Path path: Path
size: int size: int
@@ -217,6 +223,7 @@ class SpooledUpload:
filename: str filename: str
display_name: str | None display_name: str | None
folder_id: int | None folder_id: int | None
via: str | None = None
def _header_text(request: Request, name: str) -> str: def _header_text(request: Request, name: str) -> str:
@@ -233,15 +240,21 @@ def _header_text(request: Request, name: str) -> str:
return "" return ""
def _upload_folder(db: Session, user_id: int, raw: str) -> int | None: def _upload_folder(db: Session, caller: FilesCaller, raw: str) -> int | None:
"""The folder id an upload asked for (`UPLOAD_FOLDER_HEADER`), checked; None when it asked for """The folder id an upload asked for (`UPLOAD_FOLDER_HEADER`), checked; None when it asked for
none.""" none — which a token may not: it writes only where it was granted to."""
if not raw.strip(): if not raw.strip():
if caller.grants is not None:
raise refused(400, "api_token_folder_required")
return None return None
try: try:
return folders.upload_target(db, user_id, int(raw), hold=False).id folder_id = int(raw)
except ValueError as e: except ValueError as e:
raise refused(409, "file_folder_gone") from e raise refused(409, "file_folder_gone") from e
if not caller.may(folder_id, Right.WRITE):
raise not_found("folder")
try:
return folders.upload_target(db, caller.user.id, folder_id, hold=False).id
except folders.FolderError as e: except folders.FolderError as e:
raise refused(409, e.reason) from e raise refused(409, e.reason) from e
@@ -260,24 +273,26 @@ class _HashingSink:
async def spool_upload( async def spool_upload(
request: Request, request: Request,
user: User = Depends(current_user), caller: FilesCaller = Depends(files_caller),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> AsyncIterator[SpooledUpload]: ) -> AsyncIterator[SpooledUpload]:
"""Stream the request body to a staging temp file, enforcing the per-file cap as it arrives. """Stream the request body to a staging temp file, enforcing the per-file cap as it arrives.
`user` is taken but unused: it is the whole point. Dependencies resolve in declaration order, so `caller` comes first on purpose. Dependencies resolve in declaration order, so naming
naming `current_user` here makes the auth check a precondition of reading the body at all — a `files_caller` here makes the auth check a precondition of reading the body at all — a stranger,
stranger is refused before a byte moves. The `yield` shape owns the temp file's life: ingest or a token without the right to write in that folder, is refused before a byte moves. The
MOVES it into place on success (the unlink is then a no-op), and on any other exit — cap hit, `yield` shape owns the temp file's life: ingest MOVES it into place on success (the unlink is
empty, quota, probe or move error, a client that vanished mid-upload — it is removed.""" then a no-op), and on any other exit — cap hit, empty, quota, probe or move error, a client that
vanished mid-upload — it is removed."""
cap = sysconfig.get_int(db, "download_upload_max_bytes") cap = sysconfig.get_int(db, "download_upload_max_bytes")
user_id = user.id user_id = caller.user.id
via = caller.via
# A folder the file may not go in is refused before the transfer, not after it. # A folder the file may not go in is refused before the transfer, not after it.
folder_id = _upload_folder(db, user_id, request.headers.get(UPLOAD_FOLDER_HEADER, "")) folder_id = _upload_folder(db, caller, request.headers.get(UPLOAD_FOLDER_HEADER, ""))
# Hand the pooled connection back BEFORE the transfer starts — see `db.release_read_connection` # Hand the pooled connection back BEFORE the transfer starts — see `db.release_read_connection`
# for why, and for the precondition it enforces. Everything this dependency still needs is # for why, and for the precondition it enforces. Everything this dependency still needs is
# already an int or a header in hand: touching an ORM attribute below would reload `user` and # already an int, a string or a header in hand: touching an ORM attribute below (`caller.user`)
# check a connection straight back out. # would reload it and check a connection straight back out.
release_read_connection(db) release_read_connection(db)
staging = Path(settings.files_root) / ".staging" / "uploads" staging = Path(settings.files_root) / ".staging" / "uploads"
staging.mkdir(parents=True, exist_ok=True) staging.mkdir(parents=True, exist_ok=True)
@@ -300,6 +315,7 @@ async def spool_upload(
filename=_header_text(request, UPLOAD_FILENAME_HEADER) or "upload", filename=_header_text(request, UPLOAD_FILENAME_HEADER) or "upload",
display_name=display_text(_header_text(request, UPLOAD_DISPLAY_NAME_HEADER)), display_name=display_text(_header_text(request, UPLOAD_DISPLAY_NAME_HEADER)),
folder_id=folder_id, folder_id=folder_id,
via=via,
) )
finally: finally:
tmp_path.unlink(missing_ok=True) tmp_path.unlink(missing_ok=True)
@@ -322,6 +338,17 @@ def accessible_job(db: Session, user: User, job_id: int) -> DownloadJob:
return job return job
def readable_job(db: Session, caller: FilesCaller, job_id: int) -> DownloadJob:
"""A file `caller` may read: as `accessible_job` for the user; for a token, one of the user's
own in a folder it may read."""
if caller.grants is None:
return accessible_job(db, caller.user, job_id)
job = db.get(DownloadJob, job_id)
if job is None or job.user_id != caller.user.id or not caller.may(job.folder_id, Right.READ):
raise not_found("download")
return job
# --- folders ------------------------------------------------------------------------------- # --- folders -------------------------------------------------------------------------------
@@ -340,19 +367,24 @@ def _serialize_folder(folder: FileFolder, count: int, size: int) -> dict:
@router.get("/folders") @router.get("/folders")
def list_folders(user: User = Depends(current_user), db: Session = Depends(get_db)) -> list[dict]: def list_folders(
"""The caller's folders with how many files each holds and their size. A source folder its caller: FilesCaller = Depends(files_caller), db: Session = Depends(get_db)
files have all left stays (count 0): the client decides whether to show it.""" ) -> list[dict]:
"""The caller's folders with how many files each holds and their size (a token: the ones it may
list). A source folder its files have all left stays (count 0): the client decides whether to
show it."""
done = (DownloadJob.folder_id == FileFolder.id) & (DownloadJob.status == JobStatus.DONE) done = (DownloadJob.folder_id == FileFolder.id) & (DownloadJob.status == JobStatus.DONE)
stmt = select(
FileFolder,
func.count(DownloadJob.id),
func.coalesce(func.sum(MediaAsset.size_bytes), 0),
).where(FileFolder.user_id == caller.user.id)
listable = caller.folders_with(Right.LIST)
if listable is not None:
stmt = stmt.where(FileFolder.id.in_(listable))
rows = db.execute( rows = db.execute(
select( stmt.outerjoin(DownloadJob, done)
FileFolder,
func.count(DownloadJob.id),
func.coalesce(func.sum(MediaAsset.size_bytes), 0),
)
.outerjoin(DownloadJob, done)
.outerjoin(MediaAsset, MediaAsset.id == DownloadJob.asset_id) .outerjoin(MediaAsset, MediaAsset.id == DownloadJob.asset_id)
.where(FileFolder.user_id == user.id)
.group_by(FileFolder.id) .group_by(FileFolder.id)
.order_by(FileFolder.kind.desc(), FileFolder.source_key, FileFolder.name, FileFolder.id) .order_by(FileFolder.kind.desc(), FileFolder.source_key, FileFolder.name, FileFolder.id)
).all() ).all()
@@ -520,12 +552,20 @@ def list_files(
sort: Literal["added", "name", "size"] = "added", sort: Literal["added", "name", "size"] = "added",
order: Literal["asc", "desc"] = "desc", order: Literal["asc", "desc"] = "desc",
scope: Literal["mine", "shared"] = "mine", scope: Literal["mine", "shared"] = "mine",
user: User = Depends(current_user), caller: FilesCaller = Depends(files_caller),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> list[dict]: ) -> list[dict]:
"""The caller's done files (`scope=mine`, optionally one folder's), or the ones shared with """The caller's done files (`scope=mine`, optionally one folder's), or the ones shared with
them (`scope=shared`); searched by name and channel, sorted by when they were added, by name them (`scope=shared`); searched by name and channel, sorted by when they were added, by name
(in the reader's alphabet) or by size.""" (in the reader's alphabet) or by size. A token lists only in the folders it may list, and never
the shared ones."""
user = caller.user
listable = caller.folders_with(Right.LIST)
if listable is not None:
if scope == "shared":
raise refused(403, "api_token_scope")
if folder_id is not None and folder_id not in listable:
raise not_found("folder")
stmt = ( stmt = (
select(DownloadJob) select(DownloadJob)
.join(MediaAsset, MediaAsset.id == DownloadJob.asset_id) .join(MediaAsset, MediaAsset.id == DownloadJob.asset_id)
@@ -540,6 +580,8 @@ def list_files(
stmt = stmt.where(DownloadJob.user_id == user.id) stmt = stmt.where(DownloadJob.user_id == user.id)
if folder_id is not None: if folder_id is not None:
stmt = stmt.where(DownloadJob.folder_id == folder_id) stmt = stmt.where(DownloadJob.folder_id == folder_id)
elif listable is not None:
stmt = stmt.where(DownloadJob.folder_id.in_(listable))
name = func.coalesce(DownloadJob.display_name, MediaAsset.title, "") name = func.coalesce(DownloadJob.display_name, MediaAsset.title, "")
term = q.strip() term = q.strip()
if term: if term:
@@ -693,11 +735,23 @@ def update_file(
@router.delete("/{job_id}") @router.delete("/{job_id}")
def delete_file( def delete_file(
job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db) job_id: int, caller: FilesCaller = Depends(files_caller), db: Session = Depends(get_db)
) -> dict: ) -> dict:
"""Delete a file from the caller's library; the bytes go with their last holder.""" """Delete a file from the caller's library; the bytes go with their last holder. A token
job = own_job_for_update(db, user, job_id) deletes only in a folder it may delete in — judged on the row as locked, so a file moved out of
audit.record(db, user.id, AuditAction.FILE_DELETE, target_type="file", target_id=job_id) that folder meanwhile is out of its reach."""
job = own_job_for_update(db, caller.user, job_id)
if not caller.may(job.folder_id, Right.DELETE):
db.rollback() # lets go of the locks; nothing was written
raise not_found("download")
audit.record(
db,
caller.user.id,
AuditAction.FILE_DELETE,
target_type="file",
target_id=job_id,
summary=caller.via,
)
service.delete(db, job) # commits, the audit row with it service.delete(db, job) # commits, the audit row with it
return {"ok": True, "deleted": job_id} return {"ok": True, "deleted": job_id}
@@ -732,6 +786,7 @@ def ingest_spooled(db: Session, upload: SpooledUpload) -> DownloadJob:
display_name=upload.display_name, display_name=upload.display_name,
sha256=upload.sha256, sha256=upload.sha256,
folder_id=upload.folder_id, folder_id=upload.folder_id,
via=upload.via,
) )
except quota.QuotaExceeded as e: except quota.QuotaExceeded as e:
raise HTTPException(status_code=422, detail=quota_detail(e)) from e raise HTTPException(status_code=422, detail=quota_detail(e)) from e
@@ -782,8 +837,10 @@ def serve_file(db: Session, job: DownloadJob) -> FileResponse:
@router.get("/{job_id}/file") @router.get("/{job_id}/file")
def get_file(job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)): def get_file(
response = serve_file(db, accessible_job(db, user, job_id)) job_id: int, caller: FilesCaller = Depends(files_caller), db: Session = Depends(get_db)
):
response = serve_file(db, readable_job(db, caller, job_id))
db.commit() db.commit()
return response return response
@@ -840,13 +897,13 @@ def preview_file(
@router.get("/{job_id}/sha256") @router.get("/{job_id}/sha256")
def get_sha256( def get_sha256(
job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db) job_id: int, caller: FilesCaller = Depends(files_caller), db: Session = Depends(get_db)
) -> dict: ) -> dict:
"""The file's SHA-256 (hex), hashed now if it never was and kept on the asset for every holder. """The file's SHA-256 (hex), hashed now if it never was and kept on the asset for every holder.
A sync handler: hashing a large file is blocking disk work, so it runs in the threadpool — and A sync handler: hashing a large file is blocking disk work, so it runs in the threadpool — and
every other sync route shares that pool, so only `_HASHERS` files are hashed at a time (a caller every other sync route shares that pool, so only `_HASHERS` files are hashed at a time (a caller
past that is told to come back), and none of them holds a database connection while it reads.""" past that is told to come back), and none of them holds a database connection while it reads."""
asset, path = _ready_file(db, accessible_job(db, user, job_id)) asset, path = _ready_file(db, readable_job(db, caller, job_id))
if asset.sha256: if asset.sha256:
return {"sha256": asset.sha256} return {"sha256": asset.sha256}
asset_id = asset.id asset_id = asset.id
+177
View File
@@ -0,0 +1,177 @@
"""A user's API tokens for their Files library (`app.apitokens`): list, make, revoke.
Signed-in only — `current_user`, never a token: a token cannot make itself a wider one. A new
token's value is in the answer that made it and nowhere after; only its hash is kept.
The Hub preset is the one machine a user swaps files with: it makes (or reuses) their Hub folder
and gives the token every right in it, so the hub both drops files there and picks up the ones put
there for it.
"""
from datetime import datetime, timedelta, timezone
from typing import Literal
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app import apitokens, audit
from app.apitokens import GRANT_COLUMNS, Right
from app.audit import AuditAction
from app.auth import current_user
from app.db import get_db
from app.downloads import folders
from app.models import ApiToken, ApiTokenGrant, FileFolder, User
from app.refusal import refused
from app.routes._common import owned_or_404
from app.security import hash_token
from app.utils import iso
router = APIRouter(prefix="/api/tokens", tags=["tokens"])
# Far more than one person's machines; a ceiling, so a script gone wrong cannot mint without end.
MAX_TOKENS = 20
_MAX_GRANTS = 50
_MAX_EXPIRY_DAYS = 3650
class GrantIn(BaseModel):
folder_id: int
rights: list[Right] = Field(min_length=1, max_length=len(Right))
class TokenIn(BaseModel):
name: str = Field(min_length=1, max_length=80)
# None = never expires.
expires_days: int | None = Field(default=None, ge=1, le=_MAX_EXPIRY_DAYS)
preset: Literal["hub"] | None = None
grants: list[GrantIn] = Field(default_factory=list, max_length=_MAX_GRANTS)
def _serialize(db: Session, token: ApiToken) -> dict:
rows = db.execute(
select(ApiTokenGrant, FileFolder)
.join(FileFolder, FileFolder.id == ApiTokenGrant.folder_id)
.where(ApiTokenGrant.token_id == token.id)
.order_by(FileFolder.id)
).all()
now = datetime.now(timezone.utc)
return {
"id": token.id,
"name": token.name,
"prefix": token.prefix,
"created_at": iso(token.created_at),
"expires_at": iso(token.expires_at),
"expired": token.expires_at is not None and token.expires_at <= now,
"last_used_at": iso(token.last_used_at),
"grants": [
{
"folder_id": folder.id,
"kind": folder.kind,
"source_key": folder.source_key,
"name": folder.name,
"rights": sorted(apitokens.grant_rights(grant)),
}
for grant, folder in rows
],
}
@router.get("")
def list_tokens(user: User = Depends(current_user), db: Session = Depends(get_db)) -> list[dict]:
tokens = (
db.execute(select(ApiToken).where(ApiToken.user_id == user.id).order_by(ApiToken.id))
.scalars()
.all()
)
return [_serialize(db, t) for t in tokens]
def _wanted_grants(db: Session, user: User, body: TokenIn) -> dict[int, set[Right]]:
"""Folder id → the rights the new token gets there; every folder checked to be the user's, and
writing only where an upload may land (`folders.upload_target`)."""
if body.preset == "hub":
if body.grants:
raise refused(400, "api_token_preset_grants")
return {folders.hub_folder(db, user.id).id: set(Right)}
if not body.grants:
raise refused(400, "api_token_no_grants")
wanted: dict[int, set[Right]] = {}
for grant in body.grants:
wanted.setdefault(grant.folder_id, set()).update(grant.rights)
for folder_id, rights in wanted.items():
try:
if Right.WRITE in rights:
folders.upload_target(db, user.id, folder_id, hold=False)
else:
folders.own_folder(db, user.id, folder_id, hold=False)
except folders.FolderError as e:
raise refused(409, e.reason) from e
return wanted
@router.post("")
def create_token(
body: TokenIn, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict:
"""Make a token; its value is in this answer only."""
name = body.name.strip()
if not name:
raise refused(400, "api_token_name_required")
# The user's row, locked: two requests at once cannot both slip under the ceiling.
db.execute(select(User.id).where(User.id == user.id).with_for_update())
count = db.execute(
select(func.count(ApiToken.id)).where(ApiToken.user_id == user.id)
).scalar_one()
if count >= MAX_TOKENS:
raise refused(409, "api_token_limit", limit=MAX_TOKENS)
wanted = _wanted_grants(db, user, body)
raw = apitokens.mint()
token = ApiToken(
user_id=user.id,
name=name,
prefix=raw[: apitokens.SHOWN_LEN],
token_hash=hash_token(raw),
expires_at=None
if body.expires_days is None
else datetime.now(timezone.utc) + timedelta(days=body.expires_days),
)
db.add(token)
db.flush()
for folder_id, rights in wanted.items():
db.add(
ApiTokenGrant(
token_id=token.id,
folder_id=folder_id,
**{col: right in rights for right, col in GRANT_COLUMNS.items()},
)
)
db.flush()
audit.record(
db,
user.id,
AuditAction.API_TOKEN_CREATE,
target_type="api_token",
target_id=token.id,
after={
"grants": {str(f): sorted(r) for f, r in wanted.items()},
"expires_at": iso(token.expires_at),
},
)
db.commit()
return {"ok": True, **_serialize(db, token), "token": raw}
@router.delete("/{token_id}")
def revoke_token(
token_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict:
"""Revoke a token: it stops working at its next request (its grants go with it)."""
token = owned_or_404(db, ApiToken, token_id, user, what="api_token")
db.delete(token)
audit.record(
db, user.id, AuditAction.API_TOKEN_REVOKE, target_type="api_token", target_id=token_id
)
db.commit()
return {"ok": True, "deleted": token_id}
+9 -1
View File
@@ -27,7 +27,7 @@ from starlette.requests import HTTPConnection
import app.models # register every model on Base.metadata import app.models # register every model on Base.metadata
import app.state as app_state import app.state as app_state
from app import security from app import apitokens, security
from app.auth import current_user from app.auth import current_user
from app.config import settings from app.config import settings
from app.db import Base, SessionLocal, engine, get_db from app.db import Base, SessionLocal, engine, get_db
@@ -242,8 +242,16 @@ def as_user(db, override_dep, _configured):
raise HTTPException(status_code=401, detail="Not authenticated") raise HTTPException(status_code=401, detail="Not authenticated")
return registry[uid] return registry[uid]
def _session_or_token(request: Request) -> User | None:
# The Files routes' session half (`apitokens.session_caller`): a request carrying a token is
# left to the real token check, exactly as in production.
if apitokens.bearer_token(request) is not None:
return None
return _resolver(request)
override_dep(get_db, db.info["request"]) override_dep(get_db, db.info["request"])
override_dep(current_user, _resolver) override_dep(current_user, _resolver)
override_dep(apitokens.session_caller, _session_or_token)
def _as(user: User) -> TestClient: def _as(user: User) -> TestClient:
registry[user.id] = user registry[user.id] = user
+378
View File
@@ -0,0 +1,378 @@
"""API tokens for the Files library (`app.apitokens`, `routes.tokens`), DB-lane.
A token is made, listed and revoked by its signed-in user only; its value is in the answer that made
it and nowhere after. With it a machine reaches `/api/files` and nothing else, and there only the
folders its grants name, with the rights they give — every right checked in a folder it has and in
one it has not. A token that is malformed, unknown, expired, revoked, or whose account is suspended
holds nothing, and a client trying tokens is slowed down.
"""
from datetime import datetime, timedelta, timezone
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select
from app import apitokens
from app.apitokens import files_caller
from app.downloads import folders, service
from app.downloads import upload as uploadmod
from app.downloads.status import JobStatus
from app.config import settings
from app.main import app
from app.models import ApiToken, ApiTokenGrant, AuditLog, DownloadJob, FileFolder, MediaAsset
from app.ratelimit import RateLimiter
from app.routes import tokens as tokens_routes
@pytest.fixture
def _root(tmp_path, monkeypatch):
monkeypatch.setattr(settings, "files_root", str(tmp_path))
return tmp_path
@pytest.fixture
def _no_probe(monkeypatch):
# A file ffprobe cannot read: a generic file, done at once (no conversion).
monkeypatch.setattr(uploadmod.probe, "probe_json", lambda _p: None)
def _own(db, user_id, name):
folder = FileFolder(user_id=user_id, kind=folders.FolderKind.OWN, name=name)
db.add(folder)
db.commit()
return folder
def _file_in(db, root, user_id, folder, name):
"""A done file of `user_id`'s with bytes on disk, sitting in `folder`."""
asset = MediaAsset(
source_kind="upload",
source_ref=name,
format_sig=f"sig-{name}",
status="ready",
rel_path=f"{name}.bin",
size_bytes=4,
ref_count=0,
title=name,
media_kind="file",
container="bin",
)
db.add(asset)
db.flush()
job = DownloadJob(
user_id=user_id,
asset_id=asset.id,
source_kind="upload",
source_ref=name,
profile_snapshot={},
status=JobStatus.DONE,
)
db.add(job)
db.flush()
folders.place(db, job)
job.folder_id = folder.id
folders.stamp_done(db, job)
service.incr_ref_count(db, asset.id)
db.commit()
(root / f"{name}.bin").write_bytes(b"data")
return job
def _bearer(raw: str) -> TestClient:
return TestClient(app, headers={"authorization": f"Bearer {raw}"})
def _make(c, **body) -> dict:
r = c.post("/api/tokens", json={"name": "a machine", **body})
assert r.status_code == 200, r.text
return r.json()
def _audits(db, action):
return db.execute(select(AuditLog).where(AuditLog.action == action)).scalars().all()
# --- the token itself ----------------------------------------------------------------------------
def test_a_token_has_its_shape_and_a_checksum_that_refuses_a_typo():
raw = apitokens.mint()
assert raw.startswith("sfl_") and len(raw) == apitokens.TOKEN_LEN
assert apitokens.well_formed(raw)
assert apitokens.mint() != raw
flipped = raw[:10] + ("A" if raw[10] != "A" else "B") + raw[11:]
assert not apitokens.well_formed(flipped)
assert not apitokens.well_formed(raw[:-1])
assert not apitokens.well_formed("xxx_" + raw[4:])
def test_the_hub_preset_makes_one_hub_folder_with_every_right_and_shows_the_token_once(
db, make_user, as_user
):
alice = make_user()
c = as_user(alice)
made = _make(c, preset="hub")
raw = made["token"]
assert apitokens.well_formed(raw) and made["prefix"] == raw[: apitokens.SHOWN_LEN]
[grant] = made["grants"]
assert grant["kind"] == "hub" and grant["rights"] == ["delete", "list", "read", "write"]
# Only the hash is kept, and the value is not in the list.
stored = db.execute(select(ApiToken)).scalar_one()
assert stored.token_hash != raw and raw not in str(c.get("/api/tokens").json())
# A second hub token reuses the one Hub folder.
again = _make(c, preset="hub")
assert again["grants"][0]["folder_id"] == grant["folder_id"]
hubs = db.execute(select(FileFolder).where(FileFolder.kind == "hub")).scalars().all()
assert len(hubs) == 1
assert len(_audits(db, "api_token_create")) == 2
def test_a_token_is_granted_only_the_users_own_folders_and_writes_only_where_uploads_land(
db, make_user, as_user
):
alice, bob = make_user(), make_user()
theirs = _own(db, bob.id, "Bob's")
c = as_user(alice)
r = c.post(
"/api/tokens",
json={"name": "x", "grants": [{"folder_id": theirs.id, "rights": ["read"]}]},
)
assert r.status_code == 409 and r.json()["detail"]["reason"] == "file_folder_gone"
youtube = folders.source_folder(db, alice.id, folders.SourceKey.YOUTUBE)
db.commit()
r = c.post(
"/api/tokens",
json={"name": "x", "grants": [{"folder_id": youtube.id, "rights": ["write"]}]},
)
assert r.json()["detail"]["reason"] == "file_folder_not_its_source"
assert c.post("/api/tokens", json={"name": "x"}).json()["detail"]["reason"] == (
"api_token_no_grants"
)
assert db.execute(select(ApiToken)).all() == []
def test_a_user_has_at_most_the_ceiling_of_tokens(db, make_user, as_user, monkeypatch):
monkeypatch.setattr(tokens_routes, "MAX_TOKENS", 2)
c = as_user(make_user())
_make(c, preset="hub")
_make(c, preset="hub")
r = c.post("/api/tokens", json={"name": "third", "preset": "hub"})
assert r.status_code == 409 and r.json()["detail"]["reason"] == "api_token_limit"
# --- what a token reaches ------------------------------------------------------------------------
@pytest.mark.parametrize("right", ["list", "read", "write", "delete"])
def test_each_right_holds_in_its_folder_only(
db, make_user, as_user, _root, _no_probe, upload_headers, right
):
"""One right in folder A; folder B has none. Every Files route a token may use, in both."""
alice = make_user()
a, b = _own(db, alice.id, "A"), _own(db, alice.id, "B")
in_a, in_b = _file_in(db, _root, alice.id, a, "fa"), _file_in(db, _root, alice.id, b, "fb")
raw = _make(as_user(alice), grants=[{"folder_id": a.id, "rights": [right]}])["token"]
m = _bearer(raw)
folders_seen = [f["id"] for f in m.get("/api/files/folders").json()]
assert folders_seen == ([a.id] if right == "list" else [])
listed = m.get("/api/files", params={"folder_id": a.id})
assert listed.status_code == (200 if right == "list" else 404)
if right == "list":
assert [f["id"] for f in listed.json()] == [in_a.id]
assert [f["id"] for f in m.get("/api/files").json()] == [in_a.id]
else:
assert m.get("/api/files").json() == []
assert m.get("/api/files", params={"folder_id": b.id}).status_code == 404
read_a = m.get(f"/api/files/{in_a.id}/file")
assert read_a.status_code == (200 if right == "read" else 404)
assert m.get(f"/api/files/{in_a.id}/sha256").status_code == (200 if right == "read" else 404)
assert m.get(f"/api/files/{in_b.id}/file").status_code == 404
up_a = m.post("/api/files/upload", content=b"hi", headers=upload_headers("n.txt", folder=a.id))
assert up_a.status_code == (200 if right == "write" else 404)
if right == "write":
assert up_a.json()["folder_id"] == a.id
up_b = m.post("/api/files/upload", content=b"hi", headers=upload_headers("n.txt", folder=b.id))
assert up_b.status_code == 404
no_folder = m.post("/api/files/upload", content=b"hi", headers=upload_headers("n.txt"))
assert no_folder.status_code == 400
assert no_folder.json()["detail"]["reason"] == "api_token_folder_required"
assert m.delete(f"/api/files/{in_b.id}").status_code == 404
assert db.get(DownloadJob, in_b.id) is not None
del_a = m.delete(f"/api/files/{in_a.id}")
assert del_a.status_code == (200 if right == "delete" else 404)
db.expire_all()
assert (db.get(DownloadJob, in_a.id) is None) == (right == "delete")
def test_a_token_never_reaches_another_users_files_or_the_shared_ones(
db, make_user, as_user, _root
):
alice, bob = make_user(), make_user()
hub_raw = _make(as_user(alice), preset="hub")["token"]
bobs = _file_in(db, _root, bob.id, _own(db, bob.id, "B"), "bf")
m = _bearer(hub_raw)
assert m.get(f"/api/files/{bobs.id}/file").status_code == 404
assert m.delete(f"/api/files/{bobs.id}").status_code == 404
shared = m.get("/api/files", params={"scope": "shared"})
assert shared.status_code == 403 and shared.json()["detail"]["reason"] == "api_token_scope"
def test_a_token_is_judged_by_its_grants_even_beside_a_signed_in_cookie(db, make_user, client):
"""Through the real session stack (`client`, a real sign-in): the `as_user` fixture stands in
for the very dependency this is about."""
alice = make_user(email="alice@example.com")
a = _own(db, alice.id, "A")
c = client
signed_in = c.post(
"/auth/password-login",
json={"email": "alice@example.com", "password": "correct horse battery"},
)
assert signed_in.status_code == 200
raw = _make(c, grants=[{"folder_id": a.id, "rights": ["read"]}])["token"]
assert len(c.get("/api/files/folders").json()) == 1 # the user sees every folder
both = c.get("/api/files/folders", headers={"authorization": f"Bearer {raw}"})
assert both.json() == [] # the token may not list: its grants decide
def test_a_token_works_on_the_files_routes_only(db, make_user, as_user):
alice = make_user()
raw = _make(as_user(alice), preset="hub")["token"]
m = _bearer(raw)
assert m.get("/api/files/folders").status_code == 200
# Making a wider token, or reading the account, takes the user's session.
assert m.get("/api/tokens").status_code == 401
assert m.post("/api/tokens", json={"name": "wider", "preset": "hub"}).status_code == 401
assert m.get("/api/files/usage").status_code == 401
assert m.patch("/api/files/1", json={"name": "x"}).status_code == 401
def test_only_the_files_routes_take_a_token():
"""The route table, not a list kept by hand: every route that resolves `files_caller` is under
/api/files, and these are all of them. The walk recurses into included routers (see
`test_body_limit._declared_routes` for why `app.routes` alone sees none of ours)."""
takers, nodes, routes = set(), [app], []
while nodes:
for route in getattr(nodes.pop(), "routes", ()) or ():
inner = getattr(route, "original_router", None)
if inner is not None:
nodes.append(inner)
else:
routes.append(route)
assert len(routes) > 100 # the walk found the app's routes, not just the shell's
for route in routes:
dependant = getattr(route, "dependant", None)
if dependant is None:
continue
stack, calls = [dependant], set()
while stack:
d = stack.pop()
calls.add(d.call)
stack.extend(d.dependencies)
if files_caller in calls:
for method in route.methods:
takers.add((method, route.path))
assert all(path.startswith("/api/files") for _, path in takers)
assert takers == {
("GET", "/api/files/folders"),
("GET", "/api/files"),
("GET", "/api/files/{job_id}/file"),
("GET", "/api/files/{job_id}/sha256"),
("POST", "/api/files/upload"),
("DELETE", "/api/files/{job_id}"),
}
# --- a token that does not hold ------------------------------------------------------------------
def _refused(m: TestClient) -> int:
return m.get("/api/files/folders").status_code
def test_a_malformed_unknown_expired_revoked_or_suspended_token_holds_nothing(
db, make_user, as_user
):
alice = make_user()
c = as_user(alice)
raw = _make(c, preset="hub")["token"]
assert _refused(_bearer(raw)) == 200
assert _refused(_bearer("sfl_short")) == 401
assert _refused(_bearer(apitokens.mint())) == 401 # well formed, never made
expiring = _make(c, preset="hub", expires_days=1)
token = db.get(ApiToken, expiring["id"])
token.expires_at = datetime.now(timezone.utc) - timedelta(seconds=1)
db.commit()
assert _refused(_bearer(expiring["token"])) == 401
assert c.get("/api/tokens").json()[1]["expired"] is True
assert c.delete(f"/api/tokens/{expiring['id']}").json() == {
"ok": True,
"deleted": expiring["id"],
}
assert (
db.execute(select(ApiTokenGrant).where(ApiTokenGrant.token_id == expiring["id"])).all()
== []
)
assert len(_audits(db, "api_token_revoke")) == 1
revoked = _make(c, preset="hub")
assert c.delete(f"/api/tokens/{revoked['id']}").status_code == 200
assert _refused(_bearer(revoked["token"])) == 401
alice.is_suspended = True
db.commit()
assert _refused(_bearer(raw)) == 401
def test_another_users_token_cannot_be_revoked(db, make_user, as_user):
alice, bob = make_user(), make_user()
made = _make(as_user(alice), preset="hub")
assert as_user(bob).delete(f"/api/tokens/{made['id']}").status_code == 404
assert db.get(ApiToken, made["id"]) is not None
def test_a_client_trying_tokens_is_slowed_down(db, make_user, as_user, monkeypatch):
alice = make_user()
raw = _make(as_user(alice), preset="hub")["token"]
monkeypatch.setattr(apitokens, "_bad", RateLimiter(3, 300))
for _ in range(3):
assert _refused(_bearer(apitokens.mint())) == 401
slowed = _bearer(apitokens.mint()).get("/api/files/folders")
assert slowed.status_code == 429
assert slowed.json()["detail"]["reason"] == "api_token_rate_limited"
# Even a good token from that client waits out the window: a guesser cannot tell a hit.
assert _refused(_bearer(raw)) == 429
def test_a_runaway_machine_is_held_back_per_token(db, make_user, as_user, monkeypatch):
c = as_user(make_user())
first, second = _make(c, preset="hub")["token"], _make(c, preset="hub")["token"]
monkeypatch.setattr(apitokens, "_calls", RateLimiter(2, 60))
assert [_refused(_bearer(first)) for _ in range(3)] == [200, 200, 429]
assert _refused(_bearer(second)) == 200
# --- what a token leaves behind ------------------------------------------------------------------
def test_a_tokens_use_is_stamped_and_its_uploads_and_deletes_name_it_in_the_audit(
db, make_user, as_user, _root, _no_probe, upload_headers
):
alice = make_user()
made = _make(as_user(alice), preset="hub")
hub = made["grants"][0]["folder_id"]
assert db.get(ApiToken, made["id"]).last_used_at is None
m = _bearer(made["token"])
up = m.post("/api/files/upload", content=b"hi", headers=upload_headers("h.txt", folder=hub))
assert up.status_code == 200
db.expire_all()
assert db.get(ApiToken, made["id"]).last_used_at is not None
assert m.delete(f"/api/files/{up.json()['id']}").status_code == 200
via = f"api token {made['prefix']}…"
assert [a.summary for a in _audits(db, "file_upload")] == [via]
assert [a.summary for a in _audits(db, "file_delete")] == [via]
+7 -1
View File
@@ -15,6 +15,7 @@ import pytest
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy import select from sqlalchemy import select
from app.apitokens import FilesCaller
from app.db import SessionLocal from app.db import SessionLocal
from app.downloads import service from app.downloads import service
from app.models import DownloadJob, MediaAsset, User from app.models import DownloadJob, MediaAsset, User
@@ -50,11 +51,16 @@ def _asset_held_by_two_jobs(db, status="error"):
return mine, asset, jobs[0] return mine, asset, jobs[0]
def delete_file_as(job_id, *, user, db):
"""`delete_file` as the user's own request (it takes the Files caller, not the user)."""
return delete_file(job_id, caller=FilesCaller(user=user), db=db)
@pytest.mark.parametrize( @pytest.mark.parametrize(
("status", "transition", "route", "left"), ("status", "transition", "route", "left"),
[ [
("error", service.cancel, cancel_download, "canceled"), ("error", service.cancel, cancel_download, "canceled"),
("error", service.delete, delete_file, None), ("error", service.delete, delete_file_as, None),
("queued", service.cancel, pause_download, "canceled"), ("queued", service.cancel, pause_download, "canceled"),
], ],
) )
@@ -31,5 +31,7 @@
"file_folder_create": "Folder created", "file_folder_create": "Folder created",
"file_folder_rename": "Folder renamed", "file_folder_rename": "Folder renamed",
"file_folder_retention": "Folder retention changed", "file_folder_retention": "Folder retention changed",
"file_folder_delete": "Folder deleted" "file_folder_delete": "Folder deleted",
"api_token_create": "API token created",
"api_token_revoke": "API token revoked"
} }
+10 -2
View File
@@ -38,7 +38,8 @@
"profile": "This download profile doesn't exist any more", "profile": "This download profile doesn't exist any more",
"season": "This season isn't in the library any more", "season": "This season isn't in the library any more",
"show": "This show isn't in the library any more", "show": "This show isn't in the library any more",
"folder": "That folder no longer exists" "folder": "That folder no longer exists",
"api_token": "That API token no longer exists"
}, },
"admin": { "admin": {
"not_allowed": "Their address isn't approved any more. Add it to the whitelist first.", "not_allowed": "Their address isn't approved any more. Add it to the whitelist first.",
@@ -239,6 +240,13 @@
"file_folder_gone": "That folder no longer exists", "file_folder_gone": "That folder no longer exists",
"file_hash_busy": "Too many files are being checked right now — try again in a moment", "file_hash_busy": "Too many files are being checked right now — try again in a moment",
"file_folder_not_its_source": "A file can only go into one of your own folders or back to the folder it came from", "file_folder_not_its_source": "A file can only go into one of your own folders or back to the folder it came from",
"file_no_preview": "There is no preview for this file" "file_no_preview": "There is no preview for this file",
"api_token_rate_limited": "Too many requests with API tokens — wait a few minutes",
"api_token_folder_required": "An API token must name the folder it uploads into",
"api_token_scope": "An API token cannot reach the files shared with you",
"api_token_preset_grants": "The Hub token gets its folder by itself — leave the folders out",
"api_token_no_grants": "Choose at least one folder the token may use",
"api_token_name_required": "Give the token a name",
"api_token_limit": "You already have {{limit}} tokens — revoke one first"
} }
} }
@@ -31,5 +31,7 @@
"file_folder_create": "Mappa létrehozva", "file_folder_create": "Mappa létrehozva",
"file_folder_rename": "Mappa átnevezve", "file_folder_rename": "Mappa átnevezve",
"file_folder_retention": "Mappa megőrzési ideje módosítva", "file_folder_retention": "Mappa megőrzési ideje módosítva",
"file_folder_delete": "Mappa törölve" "file_folder_delete": "Mappa törölve",
"api_token_create": "API-token létrehozva",
"api_token_revoke": "API-token visszavonva"
} }
+10 -2
View File
@@ -38,7 +38,8 @@
"profile": "Ez a letöltési profil már nem létezik", "profile": "Ez a letöltési profil már nem létezik",
"season": "Ez az évad már nincs a könyvtárban", "season": "Ez az évad már nincs a könyvtárban",
"show": "Ez a sorozat már nincs a könyvtárban", "show": "Ez a sorozat már nincs a könyvtárban",
"folder": "Ez a mappa már nem létezik" "folder": "Ez a mappa már nem létezik",
"api_token": "Ez az API-token már nem létezik"
}, },
"admin": { "admin": {
"not_allowed": "A címe már nincs jóváhagyva. Előbb vedd fel a fehérlistára.", "not_allowed": "A címe már nincs jóváhagyva. Előbb vedd fel a fehérlistára.",
@@ -239,6 +240,13 @@
"file_folder_gone": "Ez a mappa már nem létezik", "file_folder_gone": "Ez a mappa már nem létezik",
"file_hash_busy": "Most túl sok fájl ellenőrzése fut — próbáld újra kicsit később", "file_hash_busy": "Most túl sok fájl ellenőrzése fut — próbáld újra kicsit később",
"file_folder_not_its_source": "Egy fájl csak a saját mappáidba vagy vissza az eredeti mappájába kerülhet", "file_folder_not_its_source": "Egy fájl csak a saját mappáidba vagy vissza az eredeti mappájába kerülhet",
"file_no_preview": "Ehhez a fájlhoz nincs előnézet" "file_no_preview": "Ehhez a fájlhoz nincs előnézet",
"api_token_rate_limited": "Túl sok kérés érkezett API-tokennel — várj néhány percet",
"api_token_folder_required": "Az API-tokennek meg kell neveznie a mappát, amelybe feltölt",
"api_token_scope": "API-tokennel nem érhetők el a veled megosztott fájlok",
"api_token_preset_grants": "A Hub-token magától kapja a mappáját — ne adj meg mappát",
"api_token_no_grants": "Válassz legalább egy mappát, amelyet a token használhat",
"api_token_name_required": "Adj nevet a tokennek",
"api_token_limit": "Már {{limit}} tokened van — előbb vonj vissza egyet"
} }
} }