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:
@@ -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;
|
||||
`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
|
||||
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
|
||||
|
||||
@@ -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_RETENTION = "file_folder_retention"
|
||||
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(
|
||||
|
||||
@@ -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
|
||||
from the key, in the reader's language.
|
||||
- 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
|
||||
(`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):
|
||||
SOURCE = "source"
|
||||
OWN = "own"
|
||||
HUB = "hub"
|
||||
|
||||
|
||||
class SourceKey(StrEnum):
|
||||
@@ -123,6 +127,26 @@ def source_folder(db: Session, user_id: int, key: str) -> FileFolder:
|
||||
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:
|
||||
"""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."""
|
||||
|
||||
@@ -57,12 +57,14 @@ def ingest_upload(
|
||||
display_name: str | None = None,
|
||||
sha256: str | None = None,
|
||||
folder_id: int | None = None,
|
||||
via: str | None = None,
|
||||
) -> DownloadJob:
|
||||
"""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`
|
||||
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
|
||||
(`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).
|
||||
|
||||
@@ -145,7 +147,14 @@ def ingest_upload(
|
||||
folders.stamp_done(db, job)
|
||||
service.incr_ref_count(db, asset.id)
|
||||
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()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
|
||||
@@ -58,6 +58,7 @@ from app.routes import ( # noqa: E402 — logging is configured first
|
||||
setup as setup_routes,
|
||||
sync,
|
||||
tags,
|
||||
tokens as tokens_routes,
|
||||
version,
|
||||
)
|
||||
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.admin_router)
|
||||
app.include_router(files_routes.router)
|
||||
app.include_router(tokens_routes.router)
|
||||
app.include_router(public_routes.router)
|
||||
app.include_router(admin.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 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
|
||||
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,
|
||||
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(
|
||||
"(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",
|
||||
),
|
||||
)
|
||||
@@ -949,6 +953,45 @@ class FileFolder(Base, TimestampMixin):
|
||||
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:
|
||||
"""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`)."""
|
||||
|
||||
+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
|
||||
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.
|
||||
|
||||
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
|
||||
@@ -29,6 +34,7 @@ from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import audit, sysconfig
|
||||
from app.apitokens import FilesCaller, Right, files_caller
|
||||
from app.audit import AuditAction
|
||||
from app.auth import current_user
|
||||
from app.collation import collated
|
||||
@@ -208,7 +214,7 @@ UPLOAD_FOLDER_HEADER = "x-upload-folder"
|
||||
@dataclass(frozen=True)
|
||||
class SpooledUpload:
|
||||
"""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
|
||||
size: int
|
||||
@@ -217,6 +223,7 @@ class SpooledUpload:
|
||||
filename: str
|
||||
display_name: str | None
|
||||
folder_id: int | None
|
||||
via: str | None = None
|
||||
|
||||
|
||||
def _header_text(request: Request, name: str) -> str:
|
||||
@@ -233,15 +240,21 @@ def _header_text(request: Request, name: str) -> str:
|
||||
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
|
||||
none."""
|
||||
none — which a token may not: it writes only where it was granted to."""
|
||||
if not raw.strip():
|
||||
if caller.grants is not None:
|
||||
raise refused(400, "api_token_folder_required")
|
||||
return None
|
||||
try:
|
||||
return folders.upload_target(db, user_id, int(raw), hold=False).id
|
||||
folder_id = int(raw)
|
||||
except ValueError as 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:
|
||||
raise refused(409, e.reason) from e
|
||||
|
||||
@@ -260,24 +273,26 @@ class _HashingSink:
|
||||
|
||||
async def spool_upload(
|
||||
request: Request,
|
||||
user: User = Depends(current_user),
|
||||
caller: FilesCaller = Depends(files_caller),
|
||||
db: Session = Depends(get_db),
|
||||
) -> AsyncIterator[SpooledUpload]:
|
||||
"""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
|
||||
naming `current_user` here makes the auth check a precondition of reading the body at all — a
|
||||
stranger is refused before a byte moves. The `yield` shape owns the temp file's life: ingest
|
||||
MOVES it into place on success (the unlink is 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."""
|
||||
`caller` comes first on purpose. Dependencies resolve in declaration order, so naming
|
||||
`files_caller` here makes the auth check a precondition of reading the body at all — a stranger,
|
||||
or a token without the right to write in that folder, is refused before a byte moves. The
|
||||
`yield` shape owns the temp file's life: ingest MOVES it into place on success (the unlink is
|
||||
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")
|
||||
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.
|
||||
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`
|
||||
# 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
|
||||
# check a connection straight back out.
|
||||
# already an int, a string or a header in hand: touching an ORM attribute below (`caller.user`)
|
||||
# would reload it and check a connection straight back out.
|
||||
release_read_connection(db)
|
||||
staging = Path(settings.files_root) / ".staging" / "uploads"
|
||||
staging.mkdir(parents=True, exist_ok=True)
|
||||
@@ -300,6 +315,7 @@ async def spool_upload(
|
||||
filename=_header_text(request, UPLOAD_FILENAME_HEADER) or "upload",
|
||||
display_name=display_text(_header_text(request, UPLOAD_DISPLAY_NAME_HEADER)),
|
||||
folder_id=folder_id,
|
||||
via=via,
|
||||
)
|
||||
finally:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
@@ -322,6 +338,17 @@ def accessible_job(db: Session, user: User, job_id: int) -> DownloadJob:
|
||||
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 -------------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -340,19 +367,24 @@ def _serialize_folder(folder: FileFolder, count: int, size: int) -> dict:
|
||||
|
||||
|
||||
@router.get("/folders")
|
||||
def list_folders(user: User = Depends(current_user), db: Session = Depends(get_db)) -> list[dict]:
|
||||
"""The caller's folders with how many files each holds and their size. A source folder its
|
||||
files have all left stays (count 0): the client decides whether to show it."""
|
||||
def list_folders(
|
||||
caller: FilesCaller = Depends(files_caller), db: Session = Depends(get_db)
|
||||
) -> 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)
|
||||
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(
|
||||
select(
|
||||
FileFolder,
|
||||
func.count(DownloadJob.id),
|
||||
func.coalesce(func.sum(MediaAsset.size_bytes), 0),
|
||||
)
|
||||
.outerjoin(DownloadJob, done)
|
||||
stmt.outerjoin(DownloadJob, done)
|
||||
.outerjoin(MediaAsset, MediaAsset.id == DownloadJob.asset_id)
|
||||
.where(FileFolder.user_id == user.id)
|
||||
.group_by(FileFolder.id)
|
||||
.order_by(FileFolder.kind.desc(), FileFolder.source_key, FileFolder.name, FileFolder.id)
|
||||
).all()
|
||||
@@ -520,12 +552,20 @@ def list_files(
|
||||
sort: Literal["added", "name", "size"] = "added",
|
||||
order: Literal["asc", "desc"] = "desc",
|
||||
scope: Literal["mine", "shared"] = "mine",
|
||||
user: User = Depends(current_user),
|
||||
caller: FilesCaller = Depends(files_caller),
|
||||
db: Session = Depends(get_db),
|
||||
) -> list[dict]:
|
||||
"""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
|
||||
(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 = (
|
||||
select(DownloadJob)
|
||||
.join(MediaAsset, MediaAsset.id == DownloadJob.asset_id)
|
||||
@@ -540,6 +580,8 @@ def list_files(
|
||||
stmt = stmt.where(DownloadJob.user_id == user.id)
|
||||
if folder_id is not None:
|
||||
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, "")
|
||||
term = q.strip()
|
||||
if term:
|
||||
@@ -693,11 +735,23 @@ def update_file(
|
||||
|
||||
@router.delete("/{job_id}")
|
||||
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:
|
||||
"""Delete a file from the caller's library; the bytes go with their last holder."""
|
||||
job = own_job_for_update(db, user, job_id)
|
||||
audit.record(db, user.id, AuditAction.FILE_DELETE, target_type="file", target_id=job_id)
|
||||
"""Delete a file from the caller's library; the bytes go with their last holder. A token
|
||||
deletes only in a folder it may delete in — judged on the row as locked, so a file moved out of
|
||||
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
|
||||
return {"ok": True, "deleted": job_id}
|
||||
|
||||
@@ -732,6 +786,7 @@ def ingest_spooled(db: Session, upload: SpooledUpload) -> DownloadJob:
|
||||
display_name=upload.display_name,
|
||||
sha256=upload.sha256,
|
||||
folder_id=upload.folder_id,
|
||||
via=upload.via,
|
||||
)
|
||||
except quota.QuotaExceeded as 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")
|
||||
def get_file(job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)):
|
||||
response = serve_file(db, accessible_job(db, user, job_id))
|
||||
def get_file(
|
||||
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()
|
||||
return response
|
||||
|
||||
@@ -840,13 +897,13 @@ def preview_file(
|
||||
|
||||
@router.get("/{job_id}/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:
|
||||
"""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
|
||||
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."""
|
||||
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:
|
||||
return {"sha256": asset.sha256}
|
||||
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}
|
||||
Reference in New Issue
Block a user