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:
@@ -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)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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]
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user