From b2d34e6e6c87fa3325cb70d365b6ab413ddbb183 Mon Sep 17 00:00:00 2001
From: npeter83
Date: Wed, 14 Oct 2026 18:40:23 +0200
Subject: [PATCH 01/11] =?UTF-8?q?feat(files):=20API=20tokens=20=E2=80=94?=
=?UTF-8?q?=20a=20machine=20reaches=20the=20library=20folder=20by=20folder?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
CLAUDE.md | 2 +-
backend/alembic/versions/0106_api_tokens.py | 94 +++++
backend/app/README.md | 3 +-
backend/app/apitokens.py | 209 ++++++++++
backend/app/audit.py | 4 +
backend/app/downloads/folders.py | 24 ++
backend/app/downloads/upload.py | 13 +-
backend/app/main.py | 2 +
backend/app/models.py | 47 ++-
backend/app/routes/files.py | 125 ++++--
backend/app/routes/tokens.py | 177 ++++++++
backend/tests/conftest.py | 10 +-
backend/tests/test_api_tokens.py | 378 ++++++++++++++++++
backend/tests/test_download_job_locks.py | 8 +-
.../src/i18n/locales/en/auditActions.json | 4 +-
frontend/src/i18n/locales/en/errors.json | 12 +-
.../src/i18n/locales/hu/auditActions.json | 4 +-
frontend/src/i18n/locales/hu/errors.json | 12 +-
18 files changed, 1080 insertions(+), 48 deletions(-)
create mode 100644 backend/alembic/versions/0106_api_tokens.py
create mode 100644 backend/app/apitokens.py
create mode 100644 backend/app/routes/tokens.py
create mode 100644 backend/tests/test_api_tokens.py
diff --git a/CLAUDE.md b/CLAUDE.md
index 347e1f3a..7aa7203b 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -62,7 +62,7 @@ how it gets built. Concretely:
## Architecture shape (don't fight it)
`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`,
`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
diff --git a/backend/alembic/versions/0106_api_tokens.py b/backend/alembic/versions/0106_api_tokens.py
new file mode 100644
index 00000000..019872a4
--- /dev/null
+++ b/backend/alembic/versions/0106_api_tokens.py
@@ -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)
diff --git a/backend/app/README.md b/backend/app/README.md
index b8cd114c..6ff33ab1 100644
--- a/backend/app/README.md
+++ b/backend/app/README.md
@@ -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
diff --git a/backend/app/apitokens.py b/backend/app/apitokens.py
new file mode 100644
index 00000000..12e43b52
--- /dev/null
+++ b/backend/app/apitokens.py
@@ -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
diff --git a/backend/app/audit.py b/backend/app/audit.py
index c7180113..6a5f5170 100644
--- a/backend/app/audit.py
+++ b/backend/app/audit.py
@@ -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(
diff --git a/backend/app/downloads/folders.py b/backend/app/downloads/folders.py
index 6908f06c..46aa3682 100644
--- a/backend/app/downloads/folders.py
+++ b/backend/app/downloads/folders.py
@@ -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."""
diff --git a/backend/app/downloads/upload.py b/backend/app/downloads/upload.py
index 7e382a19..49291c39 100644
--- a/backend/app/downloads/upload.py
+++ b/backend/app/downloads/upload.py
@@ -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()
diff --git a/backend/app/main.py b/backend/app/main.py
index 795456b7..9fd2f132 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -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)
diff --git a/backend/app/models.py b/backend/app/models.py
index 6fe9726f..af2224ac 100644
--- a/backend/app/models.py
+++ b/backend/app/models.py
@@ -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:) 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`)."""
diff --git a/backend/app/routes/files.py b/backend/app/routes/files.py
index 25be682b..109b4607 100644
--- a/backend/app/routes/files.py
+++ b/backend/app/routes/files.py
@@ -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
diff --git a/backend/app/routes/tokens.py b/backend/app/routes/tokens.py
new file mode 100644
index 00000000..6e0bbfee
--- /dev/null
+++ b/backend/app/routes/tokens.py
@@ -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}
diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py
index f2e1b127..32e614ae 100644
--- a/backend/tests/conftest.py
+++ b/backend/tests/conftest.py
@@ -27,7 +27,7 @@ from starlette.requests import HTTPConnection
import app.models # register every model on Base.metadata
import app.state as app_state
-from app import security
+from app import apitokens, security
from app.auth import current_user
from app.config import settings
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")
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(current_user, _resolver)
+ override_dep(apitokens.session_caller, _session_or_token)
def _as(user: User) -> TestClient:
registry[user.id] = user
diff --git a/backend/tests/test_api_tokens.py b/backend/tests/test_api_tokens.py
new file mode 100644
index 00000000..4ff8e29c
--- /dev/null
+++ b/backend/tests/test_api_tokens.py
@@ -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]
diff --git a/backend/tests/test_download_job_locks.py b/backend/tests/test_download_job_locks.py
index ada7e477..0d69faf5 100644
--- a/backend/tests/test_download_job_locks.py
+++ b/backend/tests/test_download_job_locks.py
@@ -15,6 +15,7 @@ import pytest
from fastapi import HTTPException
from sqlalchemy import select
+from app.apitokens import FilesCaller
from app.db import SessionLocal
from app.downloads import service
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]
+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(
("status", "transition", "route", "left"),
[
("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"),
],
)
diff --git a/frontend/src/i18n/locales/en/auditActions.json b/frontend/src/i18n/locales/en/auditActions.json
index c12f7b0a..9d702ee3 100644
--- a/frontend/src/i18n/locales/en/auditActions.json
+++ b/frontend/src/i18n/locales/en/auditActions.json
@@ -31,5 +31,7 @@
"file_folder_create": "Folder created",
"file_folder_rename": "Folder renamed",
"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"
}
diff --git a/frontend/src/i18n/locales/en/errors.json b/frontend/src/i18n/locales/en/errors.json
index 9da019e3..55224226 100644
--- a/frontend/src/i18n/locales/en/errors.json
+++ b/frontend/src/i18n/locales/en/errors.json
@@ -38,7 +38,8 @@
"profile": "This download profile doesn't exist any more",
"season": "This season 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": {
"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_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_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"
}
}
diff --git a/frontend/src/i18n/locales/hu/auditActions.json b/frontend/src/i18n/locales/hu/auditActions.json
index 3ef023cc..0f4f8081 100644
--- a/frontend/src/i18n/locales/hu/auditActions.json
+++ b/frontend/src/i18n/locales/hu/auditActions.json
@@ -31,5 +31,7 @@
"file_folder_create": "Mappa létrehozva",
"file_folder_rename": "Mappa átnevezve",
"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"
}
diff --git a/frontend/src/i18n/locales/hu/errors.json b/frontend/src/i18n/locales/hu/errors.json
index 97d11cac..1e9c15aa 100644
--- a/frontend/src/i18n/locales/hu/errors.json
+++ b/frontend/src/i18n/locales/hu/errors.json
@@ -38,7 +38,8 @@
"profile": "Ez a letöltési profil már nem létezik",
"season": "Ez az évad 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": {
"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_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_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"
}
}
From db56f253879a51611f1d9f029ad6ade3f951faec Mon Sep 17 00:00:00 2001
From: npeter83
Date: Wed, 14 Oct 2026 23:33:34 +0200
Subject: [PATCH 02/11] feat(files): API tokens in Settings, the Hub folder in
Files, a notice before a token runs out
Settings gains an API tokens tab: a row per token (name and prefix; each
folder with its rights; last use and expiry, "expires soon" marked),
revoked from its menu behind a confirm. A new token is the Hub exchange in
one click, or folders and rights picked per folder (no Write where an
upload cannot land); expiry 30/90 days, 1 year or never (warned). The
token shows once in the same dialog, with Copy and a ready curl line.
The Hub folder closes the Sources in the Files rail, takes uploads and
moved-in files. A token about to run out warns its user once, a week
before (the Files GC's sixth pass); the notice opens the tokens tab.
---
backend/alembic/versions/0106_api_tokens.py | 3 +-
backend/app/apitokens.py | 42 +-
backend/app/downloads/gc.py | 10 +-
backend/app/models.py | 2 +
backend/app/routes/tokens.py | 1 +
backend/tests/test_api_tokens.py | 39 +-
docs/DESIGN.md | 1 +
frontend/src/App.tsx | 5 +-
.../src/components/ApiTokens.dom.test.tsx | 257 +++++++++
frontend/src/components/ApiTokens.tsx | 509 +++++++++++++++++
.../src/components/Files.menu.dom.test.tsx | 21 +
frontend/src/components/Files.tsx | 8 +-
.../src/components/FilesRail.dom.test.tsx | 13 +
frontend/src/components/FilesRail.tsx | 6 +-
.../NotificationsPanel.menu.dom.test.tsx | 32 +-
.../src/components/NotificationsPanel.tsx | 42 +-
frontend/src/components/SettingsPanel.tsx | 7 +-
frontend/src/components/fileFolders.tsx | 19 +-
.../ui/pressableSelectable.contract.test.ts | 4 +
frontend/src/components/useFileActions.tsx | 6 +-
frontend/src/design-lab/DesignLab.tsx | 12 +
frontend/src/design-lab/tokensRound.tsx | 515 ++++++++++++++++++
frontend/src/i18n/locales/en/errors.json | 4 +
frontend/src/i18n/locales/en/files.json | 1 +
frontend/src/i18n/locales/en/inbox.json | 5 +
frontend/src/i18n/locales/en/scheduler.json | 1 +
frontend/src/i18n/locales/en/settings.json | 52 +-
frontend/src/i18n/locales/hu/errors.json | 4 +
frontend/src/i18n/locales/hu/files.json | 1 +
frontend/src/i18n/locales/hu/inbox.json | 5 +
frontend/src/i18n/locales/hu/scheduler.json | 1 +
frontend/src/i18n/locales/hu/settings.json | 52 +-
frontend/src/lib/api.ts | 4 +
frontend/src/lib/api/facade.test.ts | 1 +
frontend/src/lib/api/files.ts | 5 +-
frontend/src/lib/api/tokens.contract.test.ts | 26 +
frontend/src/lib/api/tokens.ts | 52 ++
frontend/src/lib/queryKeys.ts | 1 +
frontend/src/lib/settingsTab.ts | 16 +
39 files changed, 1759 insertions(+), 26 deletions(-)
create mode 100644 frontend/src/components/ApiTokens.dom.test.tsx
create mode 100644 frontend/src/components/ApiTokens.tsx
create mode 100644 frontend/src/design-lab/tokensRound.tsx
create mode 100644 frontend/src/lib/api/tokens.contract.test.ts
create mode 100644 frontend/src/lib/api/tokens.ts
create mode 100644 frontend/src/lib/settingsTab.ts
diff --git a/backend/alembic/versions/0106_api_tokens.py b/backend/alembic/versions/0106_api_tokens.py
index 019872a4..11151462 100644
--- a/backend/alembic/versions/0106_api_tokens.py
+++ b/backend/alembic/versions/0106_api_tokens.py
@@ -5,7 +5,7 @@ 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,
+first characters to recognise it by, and whether its user was told it is about to run out. `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.
@@ -53,6 +53,7 @@ def upgrade() -> None:
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("expiry_warned", sa.Boolean(), nullable=False, server_default=sa.text("false")),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
diff --git a/backend/app/apitokens.py b/backend/app/apitokens.py
index 12e43b52..6514399c 100644
--- a/backend/app/apitokens.py
+++ b/backend/app/apitokens.py
@@ -11,7 +11,8 @@ The token is `sfl_` + 32 random bytes in base62 + a CRC32 of them: a mistyped or
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.
+token stops working at once. A token about to run out warns its user once, a week before
+(`warn_expiring`), so a machine does not lose its key unannounced.
"""
import secrets
@@ -29,9 +30,11 @@ 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.notifications import create_notification
from app.ratelimit import RateLimiter, client_key
from app.refusal import refused
from app.security import hash_token
+from app.utils import iso
log = logs.get_logger(__name__)
@@ -52,6 +55,9 @@ _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)
+# How long before a token runs out its user is told (`warn_expiring`); the token list marks it
+# "expires soon" for the same span.
+EXPIRY_WARNING = timedelta(days=7)
class Right(StrEnum):
@@ -207,3 +213,37 @@ def _token_caller(request: Request, db: Session, raw: str) -> FilesCaller:
logs.set_user(user.id, *debugsession.level_for(user, None))
request.state.user_id = user.id
return caller
+
+
+def expiring(token: ApiToken, now: datetime) -> bool:
+ """Whether `token` runs out within the warning span (and has not yet)."""
+ return token.expires_at is not None and now < token.expires_at <= now + EXPIRY_WARNING
+
+
+def warn_expiring(db: Session) -> int:
+ """Tell each user whose token runs out within `EXPIRY_WARNING` — once per token: the mark and the
+ notice commit together, and a token made again (a new row) warns afresh. Returns how many."""
+ now = datetime.now(timezone.utc)
+ hits = db.execute(
+ update(ApiToken)
+ .where(
+ ApiToken.expiry_warned.is_(False),
+ ApiToken.expires_at.is_not(None),
+ ApiToken.expires_at > now,
+ ApiToken.expires_at <= now + EXPIRY_WARNING,
+ )
+ .values(expiry_warned=True)
+ .returning(ApiToken.id, ApiToken.user_id, ApiToken.name, ApiToken.expires_at)
+ .execution_options(synchronize_session=False)
+ ).all()
+ for token_id, user_id, name, expires_at in hits:
+ create_notification(
+ db,
+ user_id,
+ "api_token_expiring",
+ title=f"API token {name} expires soon", # English fallback; the client names it from data
+ data={"token_id": token_id, "name": name, "expires_at": iso(expires_at)},
+ commit=False,
+ )
+ db.commit()
+ return len(hits)
diff --git a/backend/app/downloads/gc.py b/backend/app/downloads/gc.py
index 32d9a83a..50f6bb42 100644
--- a/backend/app/downloads/gc.py
+++ b/backend/app/downloads/gc.py
@@ -1,6 +1,6 @@
"""Download retention garbage collector (a scheduler job).
-Five passes each run:
+Six passes each run:
1. Pre-expiry warning — a done file whose `expires_at` (its folder's retention, `folders`) falls
within the grace window, and that its user did not keep, warns its user once (`expiry_warned`).
2. Expiry — such a file past its `expires_at` leaves the library: the job is deleted and its hold
@@ -14,6 +14,8 @@ Five passes each run:
re-download renames an asset, e.g. old accented channel dirs, or an upload's stranded file).
It skips the dot-prefixed system trees (all but `.uploads`) and anything newer than an hour,
so an in-flight download is never touched.
+ 6. Token expiry warning — a machine's token for the library about to run out warns its user once
+ (`apitokens.warn_expiring`): the library's other clock.
Runs in the API process (which mounts FILES_ROOT). Pure disk/DB work — no YouTube quota.
"""
@@ -25,7 +27,7 @@ from pathlib import Path
from sqlalchemy import func, or_, select, update
from sqlalchemy.orm import Session
-from app import logs, sysconfig
+from app import apitokens, logs, sysconfig
from app.config import settings
from app.downloads import quota, service, storage
from app.downloads.status import JobStatus
@@ -366,12 +368,16 @@ def run_download_gc(db: Session) -> dict:
# posters). Runs last so the keep-set reflects the assets that survived passes 2-4.
swept = _sweep_orphan_files(db, settings.files_root)
+ # 6. A machine's token about to run out.
+ tokens_warned = apitokens.warn_expiring(db)
+
result = {
"warned": warned,
"expired": expired,
"evicted": evicted,
"reaped": reaped,
"swept": swept,
+ "tokens_warned": tokens_warned,
}
log.info("download_gc %s", result)
return result
diff --git a/backend/app/models.py b/backend/app/models.py
index af2224ac..5c520e68 100644
--- a/backend/app/models.py
+++ b/backend/app/models.py
@@ -968,6 +968,8 @@ class ApiToken(Base, TimestampMixin):
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))
+ # The one notice its user gets before it runs out (`apitokens.warn_expiring`).
+ expiry_warned: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
class ApiTokenGrant(Base):
diff --git a/backend/app/routes/tokens.py b/backend/app/routes/tokens.py
index 6e0bbfee..616a0e3f 100644
--- a/backend/app/routes/tokens.py
+++ b/backend/app/routes/tokens.py
@@ -64,6 +64,7 @@ def _serialize(db: Session, token: ApiToken) -> dict:
"created_at": iso(token.created_at),
"expires_at": iso(token.expires_at),
"expired": token.expires_at is not None and token.expires_at <= now,
+ "expiring": apitokens.expiring(token, now),
"last_used_at": iso(token.last_used_at),
"grants": [
{
diff --git a/backend/tests/test_api_tokens.py b/backend/tests/test_api_tokens.py
index 4ff8e29c..878c4859 100644
--- a/backend/tests/test_api_tokens.py
+++ b/backend/tests/test_api_tokens.py
@@ -16,11 +16,20 @@ from sqlalchemy import select
from app import apitokens
from app.apitokens import files_caller
from app.downloads import folders, service
+from app.downloads import gc as gcmod
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.models import (
+ ApiToken,
+ ApiTokenGrant,
+ AuditLog,
+ DownloadJob,
+ FileFolder,
+ MediaAsset,
+ Notification,
+)
from app.ratelimit import RateLimiter
from app.routes import tokens as tokens_routes
@@ -329,6 +338,34 @@ def test_a_malformed_unknown_expired_revoked_or_suspended_token_holds_nothing(
assert _refused(_bearer(raw)) == 401
+def test_a_token_about_to_run_out_warns_its_user_once_and_is_marked_in_the_list(
+ db, make_user, as_user
+):
+ alice, bob = make_user(), make_user()
+ c = as_user(alice)
+ soon = _make(c, preset="hub", expires_days=3)
+ _make(c, preset="hub", expires_days=30) # outside the span
+ _make(c, preset="hub") # never runs out
+ gone = _make(c, preset="hub", expires_days=1)
+ db.get(ApiToken, gone["id"]).expires_at = datetime.now(timezone.utc) - timedelta(seconds=1)
+ _make(as_user(bob), preset="hub", expires_days=2)
+ db.commit()
+ assert [t["expiring"] for t in c.get("/api/tokens").json()] == [True, False, False, False]
+
+ assert apitokens.warn_expiring(db) == 2 # alice's and bob's; the run-out one is not "soon"
+ notes = db.execute(select(Notification).where(Notification.user_id == alice.id)).scalars()
+ [note] = notes.all()
+ assert note.type == "api_token_expiring"
+ assert note.data["token_id"] == soon["id"] and note.data["name"] == "a machine"
+ assert note.data["expires_at"] == soon["expires_at"]
+ assert apitokens.warn_expiring(db) == 0 # once per token
+
+
+def test_the_files_gc_runs_the_token_warning(db, make_user, as_user, _root):
+ _make(as_user(make_user()), preset="hub", expires_days=3)
+ assert gcmod.run_download_gc(db)["tokens_warned"] == 1
+
+
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")
diff --git a/docs/DESIGN.md b/docs/DESIGN.md
index f2becf4d..524d7f88 100644
--- a/docs/DESIGN.md
+++ b/docs/DESIGN.md
@@ -781,3 +781,4 @@ of a full-width panel and nothing on the right.
| 2026-10-11 | Library row quick icons (owner A) | On a wide list only the ⋯ holds a place; Copy link, Share and Save come up over the date while the row is hovered or holds the keyboard; a card (phone) keeps them in its own line; Keeps narrower | Three icons always in sight (the name kept ~150px at 1200); dropping Added below 64rem | Owner: the name is what a row is read by; every action stays in the ⋯ and the right-click menu |
| 2026-10-12 | Files preview (owner ★) | A file that is not media opens from its name in the dialog shell, wide: an image, a PDF in the browser's own viewer, text as numbered plain text (HTML and SVG too) with a wrap toggle, else a no-preview card with Save; ← / → page the list's such files in its order; an image under 8 MB is its own thumbnail | A side panel beside the list; a new browser tab; pdf.js; syntax highlighting; a click that saves a file without a preview | Owner: one way to open anything, paged like the list; the browser's PDF viewer is free and isolated; a page is never rendered on our origin |
| 2026-10-12 | A running download's step (owner ★) | The tray row names the step beside its bar ("Downloading audio · 12%", "Merging" with a pulse); a paused-only tray shows a pause mark, never the done check | A bare bar and % | A download fetches video and audio as two streams: unnamed, the second fill reads as a restart; the tray is the only place a download is followed |
+| 2026-10-14 | API tokens (owner A/A/A) | Settings → API tokens: a row per token in three lines (name + prefix, its folders and rights, last use and expiry), revoked from its ⋯; a new token asks the kind first (Hub exchange or Custom: rights as chips per folder), expiry 1 year by default; the token shows once in the same dialog, with Copy and a curl line | A table; one form with a preset link; the token in a banner above the list | A table truncates the rights at this width; the common token never shows a matrix to get wrong; closing the dialog is plainly when the token is gone |
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index d773ab27..548faf26 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -16,7 +16,7 @@ import {
type PanelLayout,
} from "./lib/panelLayout";
import { notify, removeByMetaKind } from "./lib/notifications";
-import { LS, setTabRaw } from "./lib/storage";
+import { LS } from "./lib/storage";
import { usePref } from "./lib/usePref";
import { useVerifyEntry } from "./lib/useVerifyEntry";
import { useNavigation, useNavigationActions } from "./components/navigationContext";
@@ -46,6 +46,7 @@ import ErrorDialog from "./components/ErrorDialog";
import VersionBanner from "./components/VersionBanner";
import VerifyBanner from "./components/VerifyBanner";
import { focusAccessRequestsTab } from "./lib/adminUsersTab";
+import { focusSettingsTab } from "./lib/settingsTab";
// The built app version, NOT imported from releaseNotes — importing anything from that module
// pulls the release-notes loader into the eager App chunk and defeats the lazy ReleaseNotes
// split. FRONTEND_VERSION equals the newest release note's version by release convention (both bump together).
@@ -261,7 +262,7 @@ export default function App() {
if (result === "ok") {
notify({ level: "success", message: t("settings.account.googleLink.linked") });
void refetchMe();
- setTabRaw(LS.settingsTab, "account");
+ focusSettingsTab("account");
setPage("settings");
} else {
const key = result === "conflict" || result === "mismatch" ? result : "error";
diff --git a/frontend/src/components/ApiTokens.dom.test.tsx b/frontend/src/components/ApiTokens.dom.test.tsx
new file mode 100644
index 00000000..d2cdaaf0
--- /dev/null
+++ b/frontend/src/components/ApiTokens.dom.test.tsx
@@ -0,0 +1,257 @@
+// @vitest-environment jsdom
+//
+// Settings → API tokens: a row per token says what it is, what it opens with which rights, when it was
+// last used and when it runs out (marked when that is soon); revoking asks first. A new token is the
+// Hub exchange in one click, or folders and rights picked one by one — never Write where an upload
+// cannot land — and its value is shown once, with a curl line to try it. i18n is key-passthrough
+// (a key's options ride along in brackets, so a test can read what was named).
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { api, type ApiToken, type FileFolder } from "../lib/api";
+
+const mocks = vi.hoisted(() => ({ confirm: vi.fn(async (_o?: unknown) => true) }));
+vi.mock("react-i18next", async (importOriginal) => ({
+ ...(await importOriginal()),
+ useTranslation: () => ({
+ i18n: { language: "en" },
+ t: (key: string, o?: Record) =>
+ o && Object.keys(o).length ? `${key}(${Object.values(o).join(",")})` : key,
+ }),
+}));
+vi.mock("./confirmContext", () => ({ useConfirm: () => mocks.confirm }));
+
+import ApiTokens from "./ApiTokens";
+
+afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+ mocks.confirm.mockClear();
+});
+
+const token = (id: number, extra: Partial = {}): ApiToken => ({
+ id,
+ name: `Token ${id}`,
+ prefix: `sfl_${id}abcdefg`,
+ created_at: "2026-10-01T00:00:00Z",
+ expires_at: "2027-10-01T00:00:00Z",
+ expired: false,
+ expiring: false,
+ last_used_at: null,
+ grants: [],
+ ...extra,
+});
+const folder = (id: number, extra: Partial): FileFolder => ({
+ id,
+ kind: "source",
+ source_key: null,
+ name: null,
+ retention_days: 30,
+ count: 1,
+ size_bytes: 0,
+ created_at: null,
+ ...extra,
+});
+
+function mount(tokens: ApiToken[] | Error, folders: FileFolder[] = []) {
+ if (tokens instanceof Error) vi.spyOn(api, "apiTokens").mockRejectedValue(tokens);
+ else vi.spyOn(api, "apiTokens").mockResolvedValue(tokens);
+ vi.spyOn(api, "fileFolders").mockResolvedValue(folders);
+ render(
+
+
+ ,
+ );
+}
+
+const rows = () => screen.getAllByTestId("api-token-row");
+const dialog = () => screen.getByRole("dialog");
+const pressed = (b: HTMLElement) => b.getAttribute("aria-pressed") === "true";
+const inRow = (group: string, name: string) =>
+ [...screen.getByRole("group", { name: group }).querySelectorAll("button")].find(
+ (b) => b.textContent === name,
+ );
+
+describe("API tokens · the list", () => {
+ it("reads what a token opens, with which rights, when it was used and when it runs out", async () => {
+ mount([
+ token(1, {
+ name: "Hub exchange",
+ last_used_at: new Date().toISOString(),
+ grants: [
+ {
+ folder_id: 7,
+ kind: "hub",
+ source_key: null,
+ name: null,
+ rights: ["write", "list", "read", "delete"],
+ },
+ ],
+ }),
+ token(2, {
+ expiring: true,
+ expires_at: null,
+ grants: [
+ { folder_id: 3, kind: "own", source_key: null, name: "Receptek", rights: ["read"] },
+ ],
+ }),
+ token(3, { expired: true }),
+ ]);
+ await waitFor(() => expect(rows()).toHaveLength(3));
+ const [hub, soon, gone] = rows();
+ expect(hub!.textContent).toContain("sfl_1abcdefg…");
+ // Rights in the server's order, whatever order they came in.
+ expect(hub!.textContent).toContain(
+ "files.folder.hub· settings.tokens.rights.list, settings.tokens.rights.read, settings.tokens.rights.write, settings.tokens.rights.delete",
+ );
+ expect(hub!.textContent).toContain("settings.tokens.lastUsed");
+ expect(hub!.textContent).not.toContain("settings.tokens.expiring");
+ expect(soon!.textContent).toContain("Receptek· settings.tokens.rights.read");
+ expect(soon!.textContent).toContain("settings.tokens.expiring");
+ expect(soon!.textContent).toContain("settings.tokens.neverUsed");
+ expect(soon!.textContent).toContain("settings.tokens.neverExpires");
+ expect(gone!.textContent).toContain("settings.tokens.expired");
+ expect(gone!.textContent).toContain("settings.tokens.expiredOn(");
+ });
+
+ it("says when there is none, and when the list could not be read", async () => {
+ mount([]);
+ expect(await screen.findByText("settings.tokens.empty")).toBeTruthy();
+ cleanup();
+ mount(new Error("down"));
+ expect(await screen.findByText("common.loadError")).toBeTruthy();
+ expect(screen.queryByText("settings.tokens.empty")).toBeNull();
+ });
+
+ it("revokes from the row's menu, only once the confirm says yes", async () => {
+ mount([token(5, { name: "Phone" })]);
+ const revoke = vi.spyOn(api, "revokeApiToken").mockResolvedValue({ ok: true, deleted: 5 });
+ await waitFor(() => expect(rows()).toHaveLength(1));
+ mocks.confirm.mockResolvedValueOnce(false);
+ const open = async () => {
+ act(() => {
+ fireEvent.contextMenu(rows()[0]!);
+ });
+ await waitFor(() =>
+ expect(screen.getAllByRole("menuitem", { hidden: true }).length).toBeGreaterThan(0),
+ );
+ fireEvent.click(
+ screen
+ .getAllByRole("menuitem", { hidden: true })
+ .find((b) => b.textContent === "settings.tokens.revoke")!,
+ );
+ };
+ await open();
+ await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1));
+ expect(mocks.confirm.mock.calls[0]![0]).toMatchObject({
+ title: "settings.tokens.revokeTitle(Phone)",
+ danger: true,
+ });
+ expect(revoke).not.toHaveBeenCalled();
+ await open();
+ await waitFor(() => expect(revoke).toHaveBeenCalledWith(5));
+ });
+});
+
+describe("API tokens · making one", () => {
+ const made = (extra: Partial = {}) => ({
+ ok: true as const,
+ ...token(9, extra),
+ token: "sfl_SECRETVALUE",
+ });
+
+ it("makes the Hub exchange in one click, a year long, and shows the token once with a curl line", async () => {
+ mount([]);
+ const create = vi.spyOn(api, "createApiToken").mockResolvedValue(made());
+ fireEvent.click(await screen.findByText("settings.tokens.new"));
+ expect(pressed(screen.getByRole("button", { name: "settings.tokens.hubTitle" }))).toBe(true);
+ expect((screen.getByLabelText("settings.tokens.name") as HTMLInputElement).value).toBe(
+ "settings.tokens.hubName",
+ );
+ fireEvent.click(screen.getByRole("button", { name: "settings.tokens.create" }));
+ await waitFor(() =>
+ expect(create).toHaveBeenCalledWith({
+ name: "settings.tokens.hubName",
+ expires_days: 365,
+ preset: "hub",
+ }),
+ );
+ await screen.findByText("settings.tokens.madeTitle");
+ const codes = [...dialog().querySelectorAll("code")].map((c) => c.textContent);
+ expect(codes[0]).toBe("sfl_SECRETVALUE");
+ expect(codes[1]).toBe(
+ `curl -H "Authorization: Bearer sfl_SECRETVALUE" ${window.location.origin}/api/files/folders`,
+ );
+ // The keyboard lands on copying it, not on the closed form's button.
+ expect(document.activeElement?.textContent).toBe("settings.tokens.copy");
+ fireEvent.click(screen.getByRole("button", { name: "settings.tokens.done" }));
+ expect(screen.queryByRole("dialog")).toBeNull();
+ });
+
+ it("a custom token takes the folders and rights picked; no Write where nothing can be uploaded", async () => {
+ mount(
+ [],
+ [
+ folder(1, { source_key: "youtube" }),
+ folder(2, { source_key: "upload" }),
+ folder(3, { source_key: "site:x.com", count: 0 }), // empty: not offered, as the rail hides it
+ folder(4, { kind: "hub", count: 0 }),
+ folder(5, { kind: "own", name: "Receptek" }),
+ ],
+ );
+ const create = vi.spyOn(api, "createApiToken").mockResolvedValue(made());
+ fireEvent.click(await screen.findByText("settings.tokens.new"));
+ fireEvent.click(screen.getByRole("button", { name: "settings.tokens.customTitle" }));
+ const name = screen.getByLabelText("settings.tokens.name") as HTMLInputElement;
+ expect(name.value).toBe("");
+ await screen.findByRole("group", { name: "Receptek" });
+ expect(
+ screen
+ .getAllByRole("group")
+ .map((g) => g.getAttribute("aria-label"))
+ .filter((l) => l !== "settings.tokens.kind" && l !== "settings.tokens.expiry"),
+ ).toEqual(["files.source.upload", "files.source.youtube", "files.folder.hub", "Receptek"]);
+ expect(inRow("files.source.youtube", "settings.tokens.rights.write")).toBeUndefined();
+ expect(inRow("files.source.upload", "settings.tokens.rights.write")).toBeTruthy();
+ expect(inRow("files.folder.hub", "settings.tokens.rights.write")).toBeTruthy();
+
+ const createBtn = screen.getByRole("button", { name: "settings.tokens.create" });
+ fireEvent.change(name, { target: { value: " Phone " } });
+ expect(createBtn.hasAttribute("disabled")).toBe(true); // a name, but no right yet
+ fireEvent.click(inRow("Receptek", "settings.tokens.rights.list")!);
+ fireEvent.click(inRow("Receptek", "settings.tokens.rights.write")!);
+ fireEvent.click(inRow("files.source.youtube", "settings.tokens.rights.read")!);
+ // Picked and dropped again: no grant for that folder.
+ fireEvent.click(inRow("files.folder.hub", "settings.tokens.rights.read")!);
+ fireEvent.click(inRow("files.folder.hub", "settings.tokens.rights.read")!);
+ fireEvent.click(screen.getByRole("button", { name: "settings.tokens.expiryOption.30" }));
+ expect(createBtn.hasAttribute("disabled")).toBe(false);
+ fireEvent.click(createBtn);
+ await waitFor(() => expect(create).toHaveBeenCalledTimes(1));
+ const body = create.mock.calls[0]![0];
+ expect(body).toMatchObject({ name: "Phone", expires_days: 30 });
+ expect(body.preset).toBeUndefined();
+ expect(body.grants).toEqual(
+ expect.arrayContaining([
+ { folder_id: 5, rights: ["list", "write"] },
+ { folder_id: 1, rights: ["read"] },
+ ]),
+ );
+ expect(body.grants).toHaveLength(2);
+ });
+
+ it("a token that never runs out says what that means, and is sent without an expiry", async () => {
+ mount([]);
+ const create = vi.spyOn(api, "createApiToken").mockResolvedValue(made());
+ fireEvent.click(await screen.findByText("settings.tokens.new"));
+ expect(screen.queryByText("settings.tokens.neverWarning")).toBeNull();
+ fireEvent.click(screen.getByRole("button", { name: "settings.tokens.expiryOption.never" }));
+ expect(screen.getByText("settings.tokens.neverWarning")).toBeTruthy();
+ fireEvent.click(screen.getByRole("button", { name: "settings.tokens.create" }));
+ await waitFor(() =>
+ expect(create).toHaveBeenCalledWith(expect.objectContaining({ expires_days: null })),
+ );
+ });
+});
diff --git a/frontend/src/components/ApiTokens.tsx b/frontend/src/components/ApiTokens.tsx
new file mode 100644
index 00000000..d9ccf06c
--- /dev/null
+++ b/frontend/src/components/ApiTokens.tsx
@@ -0,0 +1,509 @@
+import { useId, useState, type ReactNode } from "react";
+import { useTranslation } from "react-i18next";
+import type { TFunction } from "i18next";
+import { useMutation, useQuery, useQueryClient, type UseQueryResult } from "@tanstack/react-query";
+import { Check, Copy, KeyRound, Plus, SlidersHorizontal, Trash2 } from "lucide-react";
+import {
+ api,
+ TOKEN_EXPIRY_DAYS,
+ TOKEN_EXPIRY_DEFAULT,
+ TOKEN_RIGHTS,
+ type ApiToken,
+ type FileFolder,
+ type TokenGrant,
+ type TokenRight,
+} from "../lib/api";
+import { qk } from "../lib/queryKeys";
+import { copyText } from "../lib/clipboard";
+import { compareText } from "../lib/collate";
+import { formatDate } from "../lib/format";
+import { navItemAttrs } from "../lib/listNav";
+import { notify } from "../lib/notifications";
+import { useConfirm } from "./confirmContext";
+import { folderIcon, folderLabel, takesUploads } from "./fileFolders";
+import { type ActionMenuItem } from "./ui/ActionMenu";
+import { Badge } from "./ui/Badge";
+import { Button } from "./ui/Button";
+import { Chip } from "./ui/Chip";
+import { ContextMenu } from "./ui/ContextMenu";
+import InlineNotice from "./ui/InlineNotice";
+import { Input } from "./ui/Input";
+import Modal, { ModalFooter } from "./ui/Modal";
+import { MoreButton } from "./ui/MoreButton";
+import NavGrid from "./ui/NavGrid";
+import { Pressable } from "./ui/Pressable";
+import { LoadingState, StateMessage } from "./ui/QueryState";
+import { RelativeTime } from "./ui/RelativeTime";
+import SegmentedToggle from "./ui/SegmentedToggle";
+import { SURFACE } from "./ui/surfaces";
+import { useContextMenu } from "./ui/useContextMenu";
+
+// Settings → API tokens: a machine's key to the user's Files library (docs/DESIGN.md decision log,
+// "API tokens"). A row per token in three lines — what it is, what it opens, when it was used and
+// when it runs out — revoked from its "…" behind a confirm. A new token is made in a dialog that
+// asks the kind first (the one-click Hub exchange, or folders and rights of the user's own picking)
+// and, once made, shows the token in the same dialog: the one time it can be seen.
+
+/** The rights a token holds in a folder, in the reader's words: "list, read, write". */
+function rightsText(rights: readonly TokenRight[], t: TFunction, lang: string): string {
+ return TOKEN_RIGHTS.filter((r) => rights.includes(r))
+ .map((r) => t(`settings.tokens.rights.${r}`).toLocaleLowerCase(lang))
+ .join(", ");
+}
+
+/** A folder's glyph (`folderIcon`), drawn at a row's size. */
+function Glyph({ icon: Icon }: { icon: ReturnType }) {
+ return ;
+}
+
+function GrantLine({ grant }: { grant: TokenGrant }) {
+ const { t, i18n } = useTranslation();
+ return (
+
+
+ {folderLabel(grant, t)}
+ · {rightsText(grant.rights, t, i18n.language)}
+
+ );
+}
+
+function TokenRow({
+ token,
+ index,
+ rowProps,
+ more,
+}: {
+ token: ApiToken;
+ index: number;
+ rowProps: Record;
+ more: ReactNode;
+}) {
+ const { t, i18n } = useTranslation();
+ const expires = formatDate(token.expires_at, i18n.language);
+ return (
+
+ );
+}
diff --git a/frontend/src/components/Files.menu.dom.test.tsx b/frontend/src/components/Files.menu.dom.test.tsx
index 710ae732..b45bf33b 100644
--- a/frontend/src/components/Files.menu.dom.test.tsx
+++ b/frontend/src/components/Files.menu.dom.test.tsx
@@ -155,6 +155,7 @@ const folder = (id: number, extra: Partial): FileFolder => ({
});
const YT = folder(1, { source_key: "youtube" });
const OWN = folder(2, { kind: "own", name: "Cimboráknak", retention_days: null });
+const HUB = folder(4, { kind: "hub", retention_days: null, count: 0 });
afterEach(() => {
cleanup();
@@ -377,6 +378,18 @@ describe("Files · my file's row", () => {
await waitFor(() => expect(update).toHaveBeenCalledWith(2, { folder_id: 1 }));
});
+ it("moves into the Hub folder too, the one its hub picks files up from", async () => {
+ mocks.files = [file(2, { folder_id: 1 })];
+ mocks.folders = [YT, HUB, OWN];
+ const update = vi.spyOn(api, "updateFile").mockResolvedValue({} as never);
+ mount();
+ await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
+ fireEvent.keyDown(menuItem("files.actions.move"), { key: "ArrowRight" });
+ await waitFor(() => expect(menuRows()).toContain("files.folder.hub"));
+ fireEvent.click(menuItem("files.folder.hub"));
+ await waitFor(() => expect(update).toHaveBeenCalledWith(2, { folder_id: 4 }));
+ });
+
it("offers no move home while it is home, and says so when there is nowhere to go", async () => {
mocks.files = [file(2, { folder_id: 1 })];
mocks.folders = [YT];
@@ -1043,6 +1056,14 @@ describe("Files · dropping a file on the page", () => {
expect(await screen.findByText("recept.pdf")).toBeTruthy();
});
+ it("lands in the Hub folder when dropped there", () => {
+ mocks.files = [];
+ mocks.folders = [YT, HUB];
+ mount({ kind: "folder", id: 4 });
+ drag("dragenter");
+ expect(screen.getByText("files.drop.here:files.folder.hub")).toBeTruthy();
+ });
+
it("is no target where nothing can be uploaded — but the browser never gets the file", () => {
mocks.files = [];
mocks.folders = [YT];
diff --git a/frontend/src/components/Files.tsx b/frontend/src/components/Files.tsx
index da78f1e9..cfe983ee 100644
--- a/frontend/src/components/Files.tsx
+++ b/frontend/src/components/Files.tsx
@@ -26,7 +26,7 @@ import { MetaEditModal } from "./downloadModals";
import { jobTitle } from "./downloadTitle";
import { FileCard } from "./FileCard";
import { PhoneBadge, RetentionBadge, SourceBadge } from "./fileBadges";
-import { folderLabel } from "./fileFolders";
+import { folderLabel, takesUploads } from "./fileFolders";
import { ALL_FILES, useFiles, useFilesActions, viewParam, type FilesView } from "./filesContext";
import { useFileActions } from "./useFileActions";
import { useFolderActions } from "./useFolderActions";
@@ -109,12 +109,12 @@ function matches(file: LibraryFile, q: string, lang: string): boolean {
return hay.toLocaleLowerCase(lang).includes(q.toLocaleLowerCase(lang));
}
-/** Where an upload from this view lands: the folder itself when it may take one (an own folder, the
- * Uploads folder — `folders.upload_target`), else the Uploads folder. */
+/** Where an upload from this view lands: the folder itself when it may take one (`takesUploads`),
+ * else the Uploads folder. */
function uploadFolder(view: FilesView, folders: readonly FileFolder[]): number | null {
if (view.kind !== "folder") return null;
const f = folders.find((x) => x.id === view.id);
- return f && (f.kind === "own" || f.source_key === "upload") ? f.id : null;
+ return f && takesUploads(f) ? f.id : null;
}
/** The files the viewer pages through: the list's, and the open one even when the list moved on
diff --git a/frontend/src/components/FilesRail.dom.test.tsx b/frontend/src/components/FilesRail.dom.test.tsx
index dfbfd493..b2e31c5b 100644
--- a/frontend/src/components/FilesRail.dom.test.tsx
+++ b/frontend/src/components/FilesRail.dom.test.tsx
@@ -136,6 +136,19 @@ describe("FilesRail · folders", () => {
await waitFor(() => expect(menuRows()).toEqual(["files.folder.retention"]));
});
+ it("the Hub folder closes the sources, shown while empty, its menu only its retention", async () => {
+ const hub = folder(4, { kind: "hub", count: 0, retention_days: null });
+ await mount([hub, folder(3, { source_key: "site:tiktok.com" }), YT, OWN]);
+ const sources = entry("files.source.youtube").parentElement!;
+ const names = [...sources.querySelectorAll("button")].map((b) => b.textContent);
+ // Last, though its name sorts first: the sources by name, then the Hub folder.
+ expect(names).toEqual(["files.source.youtube1", "tiktok.com1", "files.folder.hub0"]);
+ act(() => {
+ fireEvent.contextMenu(entry("files.folder.hub"));
+ });
+ await waitFor(() => expect(menuRows()).toEqual(["files.folder.retention"]));
+ });
+
it("renames in the entry, and Enter hands the keyboard back to it", async () => {
await mount();
const update = vi.spyOn(api, "updateFolder").mockResolvedValue(OWN);
diff --git a/frontend/src/components/FilesRail.tsx b/frontend/src/components/FilesRail.tsx
index faa16032..37006805 100644
--- a/frontend/src/components/FilesRail.tsx
+++ b/frontend/src/components/FilesRail.tsx
@@ -124,6 +124,9 @@ export default function FilesRail({
compareText(folderLabel(a, t), folderLabel(b, t), i18n.language);
const sources = folders.filter((f) => f.kind === "source" && shown(f)).sort(byName);
const own = folders.filter((f) => f.kind === "own").sort(byName);
+ // The Hub folder closes the sources, shown even while empty: it exists only once a hub token made
+ // it, and an empty one is where the hub's files will turn up.
+ const hub = folders.filter((f) => f.kind === "hub");
const [renamingId, setRenamingId] = useState(null);
const [creating, setCreating] = useState(false);
@@ -202,8 +205,9 @@ export default function FilesRail({
{failed}
{sources.map(entry)}
+ {hub.map(entry)}
{/* Only when there is nothing at all: files moved into own folders were still downloaded. */}
- {foldersQ.data && sources.length === 0 && total === 0 && (
+ {foldersQ.data && sources.length + hub.length === 0 && total === 0 && (
{t("files.rail.noSources")}
)}
diff --git a/frontend/src/components/NotificationsPanel.menu.dom.test.tsx b/frontend/src/components/NotificationsPanel.menu.dom.test.tsx
index 9f7f099a..6aacb9af 100644
--- a/frontend/src/components/NotificationsPanel.menu.dom.test.tsx
+++ b/frontend/src/components/NotificationsPanel.menu.dom.test.tsx
@@ -16,6 +16,7 @@ const h = vi.hoisted(() => ({
client: [] as unknown[],
setPage: vi.fn(),
openFile: vi.fn(),
+ focusSettingsTab: vi.fn(),
setFilters: vi.fn(),
remove: vi.fn(),
clearAll: vi.fn(),
@@ -44,6 +45,7 @@ vi.mock("../lib/notifications", async (importOriginal) => ({
remove: (id: number) => h.remove(id),
clearAll: () => h.clearAll(),
}));
+vi.mock("../lib/settingsTab", () => ({ focusSettingsTab: h.focusSettingsTab }));
vi.mock("./navigationContext", () => ({ useNavigationActions: () => ({ setPage: h.setPage }) }));
vi.mock("./filesContext", () => ({ useFilesActions: () => ({ openFile: h.openFile }) }));
vi.mock("./channelsContext", () => ({ useChannelsActions: () => ({ focusChannel: vi.fn() }) }));
@@ -83,7 +85,15 @@ import PageShell from "./ui/PageShell";
afterEach(() => {
cleanup();
vi.restoreAllMocks();
- for (const f of [h.setPage, h.openFile, h.setFilters, h.remove, h.clearAll, h.confirm])
+ for (const f of [
+ h.setPage,
+ h.openFile,
+ h.focusSettingsTab,
+ h.setFilters,
+ h.remove,
+ h.clearAll,
+ h.confirm,
+ ])
f.mockClear();
});
@@ -171,6 +181,26 @@ describe("NotificationsPanel · a server row", () => {
expect(h.openFile).toHaveBeenCalledTimes(1);
});
+ it("a token about to run out is named in the reader's words and opens Settings on its tab", async () => {
+ h.server = [
+ server(1, {
+ type: "api_token_expiring",
+ title: "API token Phone expires soon",
+ data: { token_id: 4, name: "Phone", expires_at: "2026-10-21T10:00:00+00:00" },
+ }),
+ ];
+ h.client = [];
+ mount();
+ const row = rowOf("inbox.apiTokenExpiring.title");
+ expect(row.textContent).toContain("inbox.apiTokenExpiring.body");
+ expect(row.textContent).not.toContain("API token Phone expires soon");
+ fireEvent.click(within(row).getByText("inbox.openTokens"));
+ expect(h.focusSettingsTab).toHaveBeenCalledWith("tokens");
+ expect(h.setPage).toHaveBeenCalledWith("settings");
+ await openMenu(() => fireEvent.click(moreOf("inbox.apiTokenExpiring.title")));
+ expect(menuRows()).toEqual(["inbox.openTokens", "inbox.markRead", "inbox.dismiss"]);
+ });
+
it("holds the link, mark read and dismiss", async () => {
h.server = [server(1)];
h.client = [];
diff --git a/frontend/src/components/NotificationsPanel.tsx b/frontend/src/components/NotificationsPanel.tsx
index 0720694c..98f19f26 100644
--- a/frontend/src/components/NotificationsPanel.tsx
+++ b/frontend/src/components/NotificationsPanel.tsx
@@ -11,6 +11,7 @@ import {
ExternalLink,
Eye,
FolderOpen,
+ KeyRound,
RotateCcw,
Search,
Trash2,
@@ -23,6 +24,7 @@ import { jobRunText } from "../lib/jobResult";
import { notifyActionError } from "../lib/actionErrors";
import { useLiveQuery } from "../lib/useLiveQuery";
import { focusAccessRequestsTab } from "../lib/adminUsersTab";
+import { focusSettingsTab } from "../lib/settingsTab";
import {
clearAll as clearClient,
dismiss as dismissClient,
@@ -37,7 +39,7 @@ import {
type VideoHiddenMeta,
type VideoWatchedMeta,
} from "../lib/notifications";
-import { channelYouTubeUrl } from "../lib/format";
+import { channelYouTubeUrl, formatDate } from "../lib/format";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import { LEVEL_STYLE } from "./ui/toastLevels";
@@ -186,6 +188,10 @@ export default function NotificationsPanel() {
const serverMenu = useContextMenu({ keyOf: byId });
const clientMenu = useContextMenu({ keyOf: byId });
const openScheduler = () => setPage("scheduler");
+ const openTokens = () => {
+ focusSettingsTab("tokens");
+ setPage("settings");
+ };
// A finished download opens on its file, in its folder; a notice from before it named one, on Files.
const openDone = (n: AppNotification) => {
const { job_id: id, folder_id: folder } = n.data ?? {};
@@ -225,7 +231,16 @@ export default function NotificationsPanel() {
onSelect: () => openDone(n),
},
]
- : []),
+ : kind === "token"
+ ? [
+ {
+ id: "open",
+ label: t("inbox.openTokens"),
+ icon: icon(KeyRound),
+ onSelect: openTokens,
+ },
+ ]
+ : []),
...(!n.read
? [
{
@@ -397,6 +412,7 @@ export default function NotificationsPanel() {
onDismiss={() => dismissMut.mutate(n.id)}
onOpenScheduler={openScheduler}
onOpenFile={() => openDone(n)}
+ onOpenTokens={openTokens}
/>
);
})}
@@ -462,9 +478,10 @@ const DONE_TITLE: Record = {
upload: "inbox.uploadDone.title",
};
-function serverKind(n: AppNotification): "scheduler" | "download" | null {
+function serverKind(n: AppNotification): "scheduler" | "download" | "token" | null {
if (n.type === "scheduler" && typeof n.data?.job_id === "string") return "scheduler";
if (n.type === "download_done") return "download";
+ if (n.type === "api_token_expiring" && typeof n.data?.name === "string") return "token";
return null;
}
@@ -511,6 +528,7 @@ function NotificationRow({
onDismiss,
onOpenScheduler,
onOpenFile,
+ onOpenTokens,
}: {
n: AppNotification;
t: TFunction;
@@ -521,6 +539,7 @@ function NotificationRow({
onDismiss: () => void;
onOpenScheduler: () => void;
onOpenFile: () => void;
+ onOpenTokens: () => void;
}) {
const lang = useTranslation().i18n.language;
// The maintenance producer ships the affected videos in `data.videos`; list a few titles.
@@ -532,6 +551,7 @@ function NotificationRow({
const isMaintenance = n.type === "maintenance" && typeof n.data?.count === "number";
const isScheduler = serverKind(n) === "scheduler";
const isDownloadDone = serverKind(n) === "download";
+ const isTokenExpiring = serverKind(n) === "token";
const isEmailUndelivered = n.type === "email_undelivered" && typeof n.data?.reason === "string";
// A download's life after it is done: shared with you, about to expire, expired, evicted.
const lifecycle = DOWNLOAD_LIFECYCLE[n.type];
@@ -566,6 +586,11 @@ function NotificationRow({
const d = n.data as { title?: string | null; from?: string };
title = t(`inbox.${lifecycle}.title`, { from: d.from ?? "" });
body = d.title || null;
+ } else if (isTokenExpiring) {
+ // A machine's key to the library runs out within a week: which one, and when.
+ const d = n.data as { name: string; expires_at?: string | null };
+ title = t("inbox.apiTokenExpiring.title");
+ body = t("inbox.apiTokenExpiring.body", { name: d.name, date: formatDate(d.expires_at, lang) });
} else if (isDownloadDone) {
// "Download ready" / "Edit ready" / "Upload ready" per the producer's kind, with the video's title
// as the body (shipped in data.title). Same notification type; the copy differs so an edit or an
@@ -614,6 +639,17 @@ function NotificationRow({
{t("inbox.openScheduler")}
)}
+ {isTokenExpiring && (
+ }
+ >
+ {t("inbox.openTokens")}
+
+ )}
{isDownloadDone && (
}
{tab === "notifications" && }
{tab === "downloads" && }
+ {tab === "tokens" && }
{tab === "account" && }
{tab === "diagnostics" && }
diff --git a/frontend/src/components/fileFolders.tsx b/frontend/src/components/fileFolders.tsx
index 0649e7d8..4705e9d5 100644
--- a/frontend/src/components/fileFolders.tsx
+++ b/frontend/src/components/fileFolders.tsx
@@ -1,10 +1,11 @@
-import { Film, Folder, Globe, Upload, type LucideIcon } from "lucide-react";
+import { Film, Folder, Globe, Server, Upload, type LucideIcon } from "lucide-react";
import { YouTubeIcon } from "../lib/brandIcons";
import type { FileFolder } from "../lib/api";
// A library folder's and a file's origin, named and drawn. The backend sends a source folder's KEY
// (`youtube`, `plex`, `upload`, `site:`, see backend downloads/folders.py) and leaves
-// its name to the client, in the reader's language; an own folder carries the name its user gave it.
+// its name to the client, in the reader's language; an own folder carries the name its user gave it;
+// the Hub folder (one per user, made by a hub token) is named by the client too.
const SITE_PREFIX = "site:";
@@ -24,10 +25,20 @@ export function sourceIcon(key: string): LucideIcon | typeof YouTubeIcon {
return Globe;
}
-export function folderLabel(folder: FileFolder, t: T): string {
+type Folderish = Pick;
+
+export function folderLabel(folder: Folderish, t: T): string {
+ if (folder.kind === "hub") return t("files.folder.hub");
return folder.kind === "own" ? (folder.name ?? "") : sourceLabel(folder.source_key ?? "", t);
}
-export function folderIcon(folder: FileFolder): LucideIcon | typeof YouTubeIcon {
+export function folderIcon(folder: Folderish): LucideIcon | typeof YouTubeIcon {
+ if (folder.kind === "hub") return Server;
return folder.kind === "own" ? Folder : sourceIcon(folder.source_key ?? "");
}
+
+/** Whether an upload may land in `folder` (the backend's `folders.upload_target`): an own folder,
+ * the Hub folder, or Uploads — a file put in a site's folder would claim an origin it does not have. */
+export function takesUploads(folder: Folderish): boolean {
+ return folder.kind !== "source" || folder.source_key === "upload";
+}
diff --git a/frontend/src/components/ui/pressableSelectable.contract.test.ts b/frontend/src/components/ui/pressableSelectable.contract.test.ts
index 0ee75241..6749d15c 100644
--- a/frontend/src/components/ui/pressableSelectable.contract.test.ts
+++ b/frontend/src/components/ui/pressableSelectable.contract.test.ts
@@ -17,6 +17,10 @@ const files = readSources(__dirname, [
/** file → [how many plain Pressables, why they are not titles]. */
const PLAIN: Record = {
+ "ApiTokens.tsx": [
+ 1,
+ "a token kind's tile in the new-token dialog (an option: its text is its label)",
+ ],
"DownloadTray.tsx": [
2,
"the folded pill (its summary is its label); a failure's reason, which opens its details",
diff --git a/frontend/src/components/useFileActions.tsx b/frontend/src/components/useFileActions.tsx
index 552a39a1..8b30a065 100644
--- a/frontend/src/components/useFileActions.tsx
+++ b/frontend/src/components/useFileActions.tsx
@@ -186,7 +186,11 @@ export function useFileActions({
},
],
};
- const own = folders.filter((f) => f.kind === "own");
+ // The user's own folders, then the Hub folder: the ones a file may be moved into from anywhere.
+ const own = [
+ ...folders.filter((f) => f.kind === "own"),
+ ...folders.filter((f) => f.kind === "hub"),
+ ];
const home = folders.find((f) => f.kind === "source" && f.source_key === file.source_key);
const to = (f: FileFolder) => () => update.mutate({ id: file.id, patch: { folder_id: f.id } });
const items: ActionMenuItem[] = own.map((f) => ({
diff --git a/frontend/src/design-lab/DesignLab.tsx b/frontend/src/design-lab/DesignLab.tsx
index bf846765..0f43b25f 100644
--- a/frontend/src/design-lab/DesignLab.tsx
+++ b/frontend/src/design-lab/DesignLab.tsx
@@ -5,6 +5,7 @@ import { BatchB } from "./batchB";
import { BatchC } from "./batchC";
import { BatchD } from "./batchD";
import { FilesRound } from "./filesRound";
+import { TokensRound } from "./tokensRound";
import { Chip } from "../components/ui/Chip";
import { Slider } from "../components/ui/NativeControls";
import { SURFACE } from "../components/ui/surfaces";
@@ -75,6 +76,9 @@ export default function DesignLab() {
/>
+
+
Decided · API tokens, a machine's key to Files
+
+ Settings → API tokens. Picked: A, A, A — the variants with the accent edge (built; the
+ expiry is a segmented control, DESIGN.md "A choice between a few values").
+
+
+
Open · Files, the one library
diff --git a/frontend/src/design-lab/tokensRound.tsx b/frontend/src/design-lab/tokensRound.tsx
new file mode 100644
index 00000000..b879b845
--- /dev/null
+++ b/frontend/src/design-lab/tokensRound.tsx
@@ -0,0 +1,515 @@
+import clsx from "clsx";
+import type { ReactNode } from "react";
+import {
+ AlertTriangle,
+ Check,
+ Copy,
+ Folder,
+ KeyRound,
+ MoreHorizontal,
+ Plus,
+ Server,
+ SlidersHorizontal,
+ X,
+} from "lucide-react";
+import { Decision, Sample, Variant } from "./labKit";
+import { Badge } from "../components/ui/Badge";
+import { Chip } from "../components/ui/Chip";
+import { SURFACE } from "../components/ui/surfaces";
+import { buttonClass, iconButtonClass } from "../components/ui/buttonStyles";
+import { fieldClass } from "../components/ui/fieldStyles";
+
+// The API tokens round: how a machine (the hub) gets a key to the Files library. Static markup on
+// purpose (the lab's rule); the sample tokens are the owner's own kind (the hub, a phone script).
+
+const ICON = iconButtonClass({ variant: "ghost", size: "sm" });
+const noop = () => {};
+
+type Right = "list" | "read" | "write" | "delete";
+const RIGHT_LABEL: Record = {
+ list: "Lista",
+ read: "Olvasás",
+ write: "Írás",
+ delete: "Törlés",
+};
+
+type Token = {
+ name: string;
+ prefix: string;
+ grants: { folder: string; hub?: boolean; rights: Right[] }[];
+ used: string;
+ expires: string;
+ soon?: boolean;
+};
+
+const TOKENS: Token[] = [
+ {
+ name: "Hub csere",
+ prefix: "sfl_7Qm2xK9a",
+ grants: [{ folder: "Hub", hub: true, rights: ["list", "read", "write", "delete"] }],
+ used: "12 perce",
+ expires: "2027. okt. 14.",
+ },
+ {
+ name: "Telefon – receptek",
+ prefix: "sfl_Bw41LdZr",
+ grants: [
+ { folder: "Receptek", rights: ["list", "read"] },
+ { folder: "Cimboráknak", rights: ["list", "read", "write"] },
+ ],
+ used: "soha",
+ expires: "6 nap múlva",
+ soon: true,
+ },
+];
+
+/** The Settings page frame the tab lives in: the tab rail on the left, the tab on the right. */
+function SettingsFrame({ children }: { children: ReactNode }) {
+ return (
+
+
+
+ „Hub csere” elkészült — másold ki most, később nem látod.
+
+ {RAW}
+
+ curl példa másolása
+
+
+
+
+ );
+}
+
+// ── The round ───────────────────────────────────────────────────────────────────────────────────
+
+export function TokensRound() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/i18n/locales/en/errors.json b/frontend/src/i18n/locales/en/errors.json
index 55224226..e32fe8f1 100644
--- a/frontend/src/i18n/locales/en/errors.json
+++ b/frontend/src/i18n/locales/en/errors.json
@@ -110,6 +110,10 @@
"rename": "Couldn't rename the folder",
"retention": "Couldn't change how long the folder keeps its files",
"delete": "Couldn't delete the folder"
+ },
+ "apiToken": {
+ "create": "Couldn't create the token",
+ "revoke": "Couldn't revoke the token"
}
},
"ok": "OK",
diff --git a/frontend/src/i18n/locales/en/files.json b/frontend/src/i18n/locales/en/files.json
index ed9a8844..71940885 100644
--- a/frontend/src/i18n/locales/en/files.json
+++ b/frontend/src/i18n/locales/en/files.json
@@ -75,6 +75,7 @@
"menu": "Folder actions",
"new": "New folder",
"newName": "Folder name",
+ "hub": "Hub",
"rename": "Rename",
"retention": "Keeps files",
"forever": "Until I delete them",
diff --git a/frontend/src/i18n/locales/en/inbox.json b/frontend/src/i18n/locales/en/inbox.json
index 66fee6b2..4a97180a 100644
--- a/frontend/src/i18n/locales/en/inbox.json
+++ b/frontend/src/i18n/locales/en/inbox.json
@@ -15,6 +15,7 @@
"andMore_other": "and {{count}} more",
"openScheduler": "Open Scheduler",
"openFile": "Open the file",
+ "openTokens": "Open API tokens",
"revertFailed": "Couldn't undo that — please try again",
"downloadDone": {
"title": "Download ready"
@@ -46,6 +47,10 @@
"downloadExpiring": {
"title": "A download expires soon"
},
+ "apiTokenExpiring": {
+ "title": "An API token expires soon",
+ "body": "“{{name}}” stops working on {{date}}."
+ },
"downloadExpired": {
"title": "A download expired and was removed"
},
diff --git a/frontend/src/i18n/locales/en/scheduler.json b/frontend/src/i18n/locales/en/scheduler.json
index 87d9de9b..79bfdd94 100644
--- a/frontend/src/i18n/locales/en/scheduler.json
+++ b/frontend/src/i18n/locales/en/scheduler.json
@@ -145,6 +145,7 @@
"evicted": "evicted",
"reaped": "cleaned up",
"swept": "swept",
+ "tokens_warned": "tokens warned",
"libraries": "libraries",
"movies": "movies",
"shows": "shows",
diff --git a/frontend/src/i18n/locales/en/settings.json b/frontend/src/i18n/locales/en/settings.json
index 9c3c097a..f20bcedf 100644
--- a/frontend/src/i18n/locales/en/settings.json
+++ b/frontend/src/i18n/locales/en/settings.json
@@ -5,7 +5,8 @@
"notifications": "Notifications",
"account": "Account",
"diagnostics": "Diagnostics",
- "downloads": "Downloads"
+ "downloads": "Downloads",
+ "tokens": "API tokens"
},
"plexSync": {
"title": "Plex watch sync",
@@ -235,5 +236,54 @@
"downloads": {
"formats": "Download formats",
"formatsIntro": "The choices the download dialog offers. The built-in ones are fixed; duplicate one to make your own."
+ },
+ "tokens": {
+ "intro": "A token gives one machine a key to the folders you pick in your Files library — and to nothing else.",
+ "new": "New token",
+ "empty": "No tokens yet. Make one for your hub or for a script of yours.",
+ "rowMenu": "Token actions",
+ "expiring": "Expires soon",
+ "expired": "Expired",
+ "lastUsed": "Last used",
+ "neverUsed": "Never used",
+ "expiresOn": "Expires {{date}}",
+ "expiredOn": "Expired {{date}}",
+ "neverExpires": "Never expires",
+ "revoke": "Revoke…",
+ "revokeTitle": "Revoke “{{name}}”?",
+ "revokeBody": "The machine that uses it loses access at its next request. This is final: a new token has to be made in its place.",
+ "revokeAction": "Revoke",
+ "rights": {
+ "list": "List",
+ "read": "Read",
+ "write": "Write",
+ "delete": "Delete"
+ },
+ "newTitle": "New API token",
+ "kind": "Kind of token",
+ "hubTitle": "Hub exchange",
+ "hubText": "One shared Hub folder; the token may do anything in it.",
+ "hubName": "Hub exchange",
+ "customTitle": "Custom",
+ "customText": "You pick the folders and the rights.",
+ "name": "Name",
+ "folders": "Folders and rights",
+ "noFolders": "You have no folders yet.",
+ "expiry": "Expires",
+ "expiryOption": {
+ "30": "30 days",
+ "90": "90 days",
+ "365": "1 year",
+ "never": "Never"
+ },
+ "neverWarning": "A token that never expires opens its folders until you revoke it.",
+ "create": "Create",
+ "madeTitle": "Your token is ready",
+ "once": "This is the only time you see it: only its fingerprint is kept. If you lose it, make a new one.",
+ "token": "Token",
+ "tryIt": "Try it (curl)",
+ "copy": "Copy",
+ "copied": "Copied",
+ "done": "Done"
}
}
diff --git a/frontend/src/i18n/locales/hu/errors.json b/frontend/src/i18n/locales/hu/errors.json
index 1e9c15aa..35c75180 100644
--- a/frontend/src/i18n/locales/hu/errors.json
+++ b/frontend/src/i18n/locales/hu/errors.json
@@ -110,6 +110,10 @@
"rename": "Nem sikerült átnevezni a mappát",
"retention": "Nem sikerült módosítani a mappa megőrzési idejét",
"delete": "Nem sikerült törölni a mappát"
+ },
+ "apiToken": {
+ "create": "Nem sikerült létrehozni a tokent",
+ "revoke": "Nem sikerült visszavonni a tokent"
}
},
"ok": "OK",
diff --git a/frontend/src/i18n/locales/hu/files.json b/frontend/src/i18n/locales/hu/files.json
index 5faa47aa..78f4c206 100644
--- a/frontend/src/i18n/locales/hu/files.json
+++ b/frontend/src/i18n/locales/hu/files.json
@@ -75,6 +75,7 @@
"menu": "Mappa műveletei",
"new": "Új mappa",
"newName": "Mappa neve",
+ "hub": "Hub",
"rename": "Átnevezés",
"retention": "Fájlok megőrzése",
"forever": "Amíg ki nem törlöm",
diff --git a/frontend/src/i18n/locales/hu/inbox.json b/frontend/src/i18n/locales/hu/inbox.json
index 3b0dc05e..1eea1a57 100644
--- a/frontend/src/i18n/locales/hu/inbox.json
+++ b/frontend/src/i18n/locales/hu/inbox.json
@@ -15,6 +15,7 @@
"andMore_other": "és még {{count}}",
"openScheduler": "Ütemező megnyitása",
"openFile": "Fájl megnyitása",
+ "openTokens": "API-tokenek megnyitása",
"revertFailed": "Nem sikerült visszavonni — próbáld újra",
"downloadDone": {
"title": "Letöltés kész"
@@ -46,6 +47,10 @@
"downloadExpiring": {
"title": "Hamarosan lejár egy letöltés"
},
+ "apiTokenExpiring": {
+ "title": "Hamarosan lejár egy API-token",
+ "body": "„{{name}}” lejár: {{date}}."
+ },
"downloadExpired": {
"title": "Egy letöltés lejárt, és törlődött"
},
diff --git a/frontend/src/i18n/locales/hu/scheduler.json b/frontend/src/i18n/locales/hu/scheduler.json
index 8919266e..36ab8e92 100644
--- a/frontend/src/i18n/locales/hu/scheduler.json
+++ b/frontend/src/i18n/locales/hu/scheduler.json
@@ -145,6 +145,7 @@
"evicted": "helyhiány miatt törölve",
"reaped": "eltakarítva",
"swept": "kisöpörve",
+ "tokens_warned": "token figyelmeztetve",
"libraries": "könyvtár",
"movies": "film",
"shows": "sorozat",
diff --git a/frontend/src/i18n/locales/hu/settings.json b/frontend/src/i18n/locales/hu/settings.json
index 4be96686..57b2073e 100644
--- a/frontend/src/i18n/locales/hu/settings.json
+++ b/frontend/src/i18n/locales/hu/settings.json
@@ -5,7 +5,8 @@
"notifications": "Értesítések",
"account": "Fiók",
"diagnostics": "Diagnosztika",
- "downloads": "Letöltések"
+ "downloads": "Letöltések",
+ "tokens": "API-tokenek"
},
"plexSync": {
"title": "Plex nézettség-szinkron",
@@ -235,5 +236,54 @@
"downloads": {
"formats": "Letöltési formátumok",
"formatsIntro": "Ezek közül választhatsz a letöltés ablakában. A beépítettek nem módosíthatók; másold le valamelyiket, ha sajátot szeretnél."
+ },
+ "tokens": {
+ "intro": "Egy token egy gépnek ad kulcsot a Fájlok könyvtár általad kijelölt mappáihoz — semmi máshoz.",
+ "new": "Új token",
+ "empty": "Még nincs tokened. Csinálj egyet a hubodnak vagy egy saját szkriptnek.",
+ "rowMenu": "Token műveletei",
+ "expiring": "Hamarosan lejár",
+ "expired": "Lejárt",
+ "lastUsed": "Utoljára használva:",
+ "neverUsed": "Még nem használták",
+ "expiresOn": "Lejár: {{date}}",
+ "expiredOn": "Lejárt: {{date}}",
+ "neverExpires": "Nem jár le",
+ "revoke": "Visszavonás…",
+ "revokeTitle": "Visszavonod: „{{name}}”?",
+ "revokeBody": "Az a gép, amelyik használja, a következő kérésénél elveszíti a hozzáférést. Ez végleges: helyette új tokent kell csinálni.",
+ "revokeAction": "Visszavonás",
+ "rights": {
+ "list": "Lista",
+ "read": "Olvasás",
+ "write": "Írás",
+ "delete": "Törlés"
+ },
+ "newTitle": "Új API-token",
+ "kind": "A token fajtája",
+ "hubTitle": "Hub csere",
+ "hubText": "Egy közös Hub mappa; a token mindent tehet benne.",
+ "hubName": "Hub csere",
+ "customTitle": "Egyedi",
+ "customText": "Te választod a mappákat és a jogokat.",
+ "name": "Név",
+ "folders": "Mappák és jogok",
+ "noFolders": "Még nincs mappád.",
+ "expiry": "Lejárat",
+ "expiryOption": {
+ "30": "30 nap",
+ "90": "90 nap",
+ "365": "1 év",
+ "never": "Soha"
+ },
+ "neverWarning": "Egy soha le nem járó token addig nyitja a mappáit, amíg vissza nem vonod.",
+ "create": "Létrehozás",
+ "madeTitle": "A token elkészült",
+ "once": "Most látod utoljára: csak a lenyomatát őrizzük. Ha elveszik, csinálj újat.",
+ "token": "Token",
+ "tryIt": "Kipróbálás (curl)",
+ "copy": "Másolás",
+ "copied": "Másolva",
+ "done": "Kész"
}
}
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index 6697a913..25fb0963 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -63,6 +63,9 @@ export type * from "./api/downloads";
import { filesApi } from "./api/files";
export type * from "./api/files";
export { FILES_LIST_CAP, FILE_SORTS } from "./api/files";
+import { tokensApi } from "./api/tokens";
+export type * from "./api/tokens";
+export { TOKEN_EXPIRY_DAYS, TOKEN_EXPIRY_DEFAULT, TOKEN_RIGHTS } from "./api/tokens";
import { debugSessionsApi, diagnosticsApi } from "./api/diagnostics";
export type * from "./api/diagnostics";
export { DIAGNOSTICS_EVENTS_PATH } from "./api/diagnostics";
@@ -84,6 +87,7 @@ export const api = {
...plexApi,
...downloadsApi,
...filesApi,
+ ...tokensApi,
...diagnosticsApi,
...debugSessionsApi,
};
diff --git a/frontend/src/lib/api/facade.test.ts b/frontend/src/lib/api/facade.test.ts
index 24e8ceb3..3a2a77a1 100644
--- a/frontend/src/lib/api/facade.test.ts
+++ b/frontend/src/lib/api/facade.test.ts
@@ -33,6 +33,7 @@ const SLICE_MODULES = [
["plexApi", () => import("./plex")],
["downloadsApi", () => import("./downloads")],
["filesApi", () => import("./files")],
+ ["tokensApi", () => import("./tokens")],
["diagnosticsApi", () => import("./diagnostics")],
["debugSessionsApi", () => import("./diagnostics")],
] as const;
diff --git a/frontend/src/lib/api/files.ts b/frontend/src/lib/api/files.ts
index b14b90ac..b1c168fe 100644
--- a/frontend/src/lib/api/files.ts
+++ b/frontend/src/lib/api/files.ts
@@ -3,8 +3,9 @@
import { getActiveAccount, req, uploadReq } from "./core";
import type { DownloadJob } from "./downloads";
-// "source": filled by where a file came from (YouTube, Plex, a site, uploads); "own": made by the user.
-export type FolderKind = "source" | "own";
+// "source": filled by where a file came from (YouTube, Plex, a site, uploads); "own": made by the user;
+// "hub": the one folder the user swaps files with their hub machine through (made by its token).
+export type FolderKind = "source" | "own" | "hub";
export interface FileFolder {
id: number;
diff --git a/frontend/src/lib/api/tokens.contract.test.ts b/frontend/src/lib/api/tokens.contract.test.ts
new file mode 100644
index 00000000..b9b6c824
--- /dev/null
+++ b/frontend/src/lib/api/tokens.contract.test.ts
@@ -0,0 +1,26 @@
+// @vitest-environment jsdom
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
+import { describe, expect, it } from "vitest";
+import { TOKEN_EXPIRY_DAYS, TOKEN_RIGHTS } from "./tokens";
+
+// The token dialog's choices, held to what the backend takes (apitokens.py, routes/tokens.py), read
+// from their source as text — the backend cannot be imported here. A right the server does not know
+// is a 422 for the whole token; an expiry past its ceiling likewise.
+const backend = (file: string) =>
+ readFileSync(resolve(__dirname, `../../../../backend/app/${file}`), "utf-8");
+
+describe("the token dialog's choices", () => {
+ it("are the backend's rights, in its order", () => {
+ const body = backend("apitokens.py").match(/^class Right\(StrEnum\):\n([\s\S]*?)\n\n/m)?.[1];
+ expect(body, "apitokens.py no longer defines Right").toBeTruthy();
+ const rights = [...body!.matchAll(/=\s*"(\w+)"/g)].map((m) => m[1]);
+ expect(TOKEN_RIGHTS).toEqual(rights);
+ });
+
+ it("last no longer than the backend allows", () => {
+ const max = Number(backend("routes/tokens.py").match(/^_MAX_EXPIRY_DAYS = (\d+)$/m)?.[1]);
+ expect(max).toBeGreaterThan(0);
+ for (const days of TOKEN_EXPIRY_DAYS) if (days !== null) expect(days).toBeLessThanOrEqual(max);
+ });
+});
diff --git a/frontend/src/lib/api/tokens.ts b/frontend/src/lib/api/tokens.ts
new file mode 100644
index 00000000..82ab160d
--- /dev/null
+++ b/frontend/src/lib/api/tokens.ts
@@ -0,0 +1,52 @@
+// A user's API tokens: a machine's key to their Files library, folder by folder (backend
+// `routes/tokens.py`, `apitokens.py`). Made, listed and revoked by the signed-in user only; a new
+// token's value is in the answer that made it and nowhere after.
+import { req } from "./core";
+import type { FolderKind } from "./files";
+
+// What a token may do in a folder. The backend's `Right` enum, in its order (held equal by
+// tokens.contract.test.ts).
+export type TokenRight = "list" | "read" | "write" | "delete";
+export const TOKEN_RIGHTS: readonly TokenRight[] = ["list", "read", "write", "delete"];
+
+// How long a new token lasts, in days; null = never. Within the backend's `_MAX_EXPIRY_DAYS` (the
+// contract test), a year the default.
+export const TOKEN_EXPIRY_DAYS: readonly (number | null)[] = [30, 90, 365, null];
+export const TOKEN_EXPIRY_DEFAULT = 365;
+
+export interface TokenGrant {
+ folder_id: number;
+ kind: FolderKind;
+ source_key: string | null;
+ name: string | null;
+ rights: TokenRight[];
+}
+
+export interface ApiToken {
+ id: number;
+ name: string;
+ prefix: string; // the token's first characters, to tell it apart by
+ created_at: string;
+ expires_at: string | null; // null = never
+ expired: boolean;
+ expiring: boolean; // runs out within the span its user is warned in (the server's)
+ last_used_at: string | null;
+ grants: TokenGrant[];
+}
+
+export interface NewToken {
+ name: string;
+ expires_days: number | null; // null = never
+ // `hub`: the one Hub folder, every right (the server makes the folder on first use). Else `grants`.
+ preset?: "hub";
+ grants?: { folder_id: number; rights: TokenRight[] }[];
+}
+
+export const tokensApi = {
+ apiTokens: (): Promise => req("/api/tokens"),
+ // The answer carries the token's value, once.
+ createApiToken: (body: NewToken): Promise =>
+ req("/api/tokens", { method: "POST", body: JSON.stringify(body) }),
+ revokeApiToken: (id: number): Promise<{ ok: true; deleted: number }> =>
+ req(`/api/tokens/${id}`, { method: "DELETE" }),
+};
diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts
index 709b1ad2..7fefc143 100644
--- a/frontend/src/lib/queryKeys.ts
+++ b/frontend/src/lib/queryKeys.ts
@@ -70,6 +70,7 @@ export const qk = {
fileFolders: () => ["file-folders"] as const,
filePreview: (id: number) => ["file-preview", id] as const,
storageUsage: () => ["storage-usage"] as const,
+ apiTokens: () => ["api-tokens"] as const,
// --- downloads ---
downloads: () => ["downloads"] as const,
downloadIndex: () => ["download-index"] as const,
diff --git a/frontend/src/lib/settingsTab.ts b/frontend/src/lib/settingsTab.ts
new file mode 100644
index 00000000..00bb801d
--- /dev/null
+++ b/frontend/src/lib/settingsTab.ts
@@ -0,0 +1,16 @@
+import { LS, setTabRaw } from "./storage";
+
+// The Settings page's tabs, and a helper to pre-select one before navigating there (a notice's link,
+// a return from Google). useTabPersistedState reads the key when the page mounts, and the page mounts
+// fresh on navigation. Kept out of SettingsPanel.tsx so a caller does not pull the page in with it.
+export type SettingsTabId =
+ | "appearance"
+ | "notifications"
+ | "downloads"
+ | "tokens"
+ | "account"
+ | "diagnostics";
+
+export function focusSettingsTab(tab: SettingsTabId): void {
+ setTabRaw(LS.settingsTab, tab);
+}
From c31d7f9d65c322e60cd1f2345a44cc0c063cbf5c Mon Sep 17 00:00:00 2001
From: npeter83
Date: Wed, 14 Oct 2026 23:54:19 +0200
Subject: [PATCH 03/11] fix(files): an upload is announced once, as an upload,
and a finish shows in Files at once
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
An upload that came back done was announced twice: by the upload dialog ("File uploaded") and
again by the tray, which saw a job turn done ("Downloaded: …"). The dialog now marks its job as
announced, so the queue keeps it for the tray but does not say it again. An upload the worker
converted first is still the tray's to announce, now as "Uploaded: …".
A finished job's notice offered "Open" while Files still listed the folder without it (its own
poll runs every 15 s): the tray now refreshes the library the moment it sees a finish.
---
.../src/components/DownloadTray.dom.test.tsx | 23 +++++++++++++++++++
frontend/src/components/DownloadTray.tsx | 15 ++++++++----
.../src/components/UploadDialog.dom.test.tsx | 10 +++++++-
frontend/src/components/UploadDialog.tsx | 5 +++-
frontend/src/i18n/locales/en/tray.json | 1 +
frontend/src/i18n/locales/hu/tray.json | 1 +
frontend/src/lib/downloadQueue.dom.test.ts | 9 ++++++++
frontend/src/lib/downloadQueue.ts | 13 ++++++++++-
8 files changed, 70 insertions(+), 7 deletions(-)
diff --git a/frontend/src/components/DownloadTray.dom.test.tsx b/frontend/src/components/DownloadTray.dom.test.tsx
index 162ffc54..b9775866 100644
--- a/frontend/src/components/DownloadTray.dom.test.tsx
+++ b/frontend/src/components/DownloadTray.dom.test.tsx
@@ -70,6 +70,7 @@ vi.mock("../lib/notifications", async (importOriginal) => ({
import DownloadTray from "./DownloadTray";
import { noteQueue, resetQueueWatch, showInTray } from "../lib/downloadQueue";
import { pushLayer, removeLayer } from "../lib/layerStack";
+import { qk } from "../lib/queryKeys";
beforeEach(() => {
resetQueueWatch();
@@ -237,6 +238,28 @@ describe("DownloadTray", () => {
);
});
+ it("a finish refreshes Files at once, so the notice's Open finds the file listed", async () => {
+ const invalidate = vi.spyOn(QueryClient.prototype, "invalidateQueries");
+ poll([job(1, "running")]);
+ const view = mount();
+ expect(invalidate).not.toHaveBeenCalledWith({ queryKey: qk.files() });
+ poll([job(1, "done")], view);
+ await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1));
+ expect(invalidate).toHaveBeenCalledWith({ queryKey: qk.files() });
+ expect(invalidate).toHaveBeenCalledWith({ queryKey: qk.fileFolders() });
+ });
+
+ it("an upload the worker converted first is announced as uploaded, not downloaded", async () => {
+ poll([job(1, "running", { source_key: "upload" })]);
+ const view = mount();
+ poll([job(1, "done", { source_key: "upload" })], view);
+ await waitFor(() =>
+ expect(h.notify).toHaveBeenCalledWith(
+ expect.objectContaining({ title: "tray.done.uploadedTitle:Clip 1" }),
+ ),
+ );
+ });
+
it("closed, it forgets what finished and stays away until the work changes", () => {
poll([job(1, "running"), job(2, "running")]);
const view = mount();
diff --git a/frontend/src/components/DownloadTray.tsx b/frontend/src/components/DownloadTray.tsx
index 83e46e6b..da1a15e8 100644
--- a/frontend/src/components/DownloadTray.tsx
+++ b/frontend/src/components/DownloadTray.tsx
@@ -14,7 +14,7 @@ import {
X,
} from "lucide-react";
import type { QueueJob } from "../lib/api";
-import { quickShareLink } from "../lib/downloads";
+import { invalidateDownloads, quickShareLink } from "../lib/downloads";
import { failCopyKeys, failTitleKey } from "../lib/downloadFailure";
import {
IN_FLIGHT,
@@ -190,7 +190,10 @@ export default function DownloadTray() {
const title = jobTitle(job);
notify({
level: "success",
- title: t("tray.done.title", { title }),
+ // An upload the worker had to convert first is done here too — it was never downloaded.
+ title: t(job.source_key === "upload" ? "tray.done.uploadedTitle" : "tray.done.title", {
+ title,
+ }),
message: t(job.asset?.media_kind === "file" ? "tray.done.file" : "tray.done.phone"),
action: { label: t("tray.done.copyLink"), onClick: () => void quickShareLink(qc, job, t) },
secondaryAction: {
@@ -202,8 +205,12 @@ export default function DownloadTray() {
const fresh = useUnannounced();
useEffect(() => {
// The take clears them, so a second pass (StrictMode, a re-render) announces nothing again.
- if (fresh.length) takeUnannounced().forEach(announce);
- }, [fresh]);
+ if (!fresh.length) return;
+ // A notice that offers "Open" while Files still lists the folder without the file (its own poll
+ // is slower) sends the reader to something not there yet.
+ invalidateDownloads(qc);
+ takeUnannounced().forEach(announce);
+ }, [fresh, qc]);
// --- Back to top steps up over the tray while it shows -----------------------------------------
useLayoutEffect(() => {
diff --git a/frontend/src/components/UploadDialog.dom.test.tsx b/frontend/src/components/UploadDialog.dom.test.tsx
index b0836ea8..6878f5b6 100644
--- a/frontend/src/components/UploadDialog.dom.test.tsx
+++ b/frontend/src/components/UploadDialog.dom.test.tsx
@@ -16,6 +16,7 @@ const h = vi.hoisted(() => ({
filesUsage: vi.fn(async () => ({ upload_max_bytes: 1_000_000 })),
notify: vi.fn(),
showInTray: vi.fn(),
+ noteAnnounced: vi.fn(),
openFile: vi.fn(),
}));
@@ -35,7 +36,10 @@ vi.mock("../lib/notifications", async (importOriginal) => ({
...(await importOriginal()),
notify: h.notify,
}));
-vi.mock("../lib/downloadQueue", () => ({ showInTray: h.showInTray }));
+vi.mock("../lib/downloadQueue", () => ({
+ showInTray: h.showInTray,
+ noteAnnounced: h.noteAnnounced,
+}));
import UploadDialog from "./UploadDialog";
import { FilesActionsContext } from "./filesContext";
@@ -112,6 +116,8 @@ describe("UploadDialog", () => {
expect(h.notify.mock.calls[0]![0]).toMatchObject({
message: "downloads.upload.addedConverting",
});
+ // Its finish is still to come, and the tray's to announce.
+ expect(h.noteAnnounced).not.toHaveBeenCalledWith(43);
});
it("puts the file in the folder it was opened from, and its notice opens where it now is", async () => {
@@ -125,6 +131,8 @@ describe("UploadDialog", () => {
(h.notify.mock.calls[0]![0] as { action: { onClick: () => void } }).action.onClick();
expect(h.openFile).toHaveBeenLastCalledWith(42, 5);
expect(h.showInTray).not.toHaveBeenCalled();
+ // Done and said so here: the queue's next look must not announce it again, as a download.
+ expect(h.noteAnnounced).toHaveBeenCalledWith(42);
cleanup();
h.notify.mockClear();
diff --git a/frontend/src/components/UploadDialog.tsx b/frontend/src/components/UploadDialog.tsx
index 348b98cb..7eab023d 100644
--- a/frontend/src/components/UploadDialog.tsx
+++ b/frontend/src/components/UploadDialog.tsx
@@ -10,7 +10,7 @@ import { isAborted } from "../lib/api/core";
import { notifyActionError } from "../lib/actionErrors";
import { invalidateDownloads } from "../lib/downloads";
import { notify } from "../lib/notifications";
-import { showInTray } from "../lib/downloadQueue";
+import { noteAnnounced, showInTray } from "../lib/downloadQueue";
import type { DownloadJob } from "../lib/api";
import { useFilesActions } from "./filesContext";
import { formatBytes } from "../lib/format";
@@ -103,6 +103,9 @@ export default function UploadDialog({
onProgress: setPct,
signal: ac.signal,
});
+ // Said here, so the queue does not say it again; one the worker still converts is the tray's
+ // to announce when it is done.
+ if (job.status === "done") noteAnnounced(job.id);
invalidateDownloads(qc);
notify({
level: "success",
diff --git a/frontend/src/i18n/locales/en/tray.json b/frontend/src/i18n/locales/en/tray.json
index 13663b54..f6f10fb1 100644
--- a/frontend/src/i18n/locales/en/tray.json
+++ b/frontend/src/i18n/locales/en/tray.json
@@ -20,6 +20,7 @@
"open": "Open",
"copyLink": "Copy link",
"title": "Downloaded: {{title}}",
+ "uploadedTitle": "Uploaded: {{title}}",
"phone": "Plays on a phone too",
"file": "Saved in Files"
}
diff --git a/frontend/src/i18n/locales/hu/tray.json b/frontend/src/i18n/locales/hu/tray.json
index 5caf6158..fb0d374d 100644
--- a/frontend/src/i18n/locales/hu/tray.json
+++ b/frontend/src/i18n/locales/hu/tray.json
@@ -20,6 +20,7 @@
"open": "Megnyitás",
"copyLink": "Link másolása",
"title": "Letöltve: {{title}}",
+ "uploadedTitle": "Feltöltve: {{title}}",
"phone": "Telefonon is lejátszható",
"file": "Bekerült a Fájlok közé"
}
diff --git a/frontend/src/lib/downloadQueue.dom.test.ts b/frontend/src/lib/downloadQueue.dom.test.ts
index 67a4c69a..fc3f4d3d 100644
--- a/frontend/src/lib/downloadQueue.dom.test.ts
+++ b/frontend/src/lib/downloadQueue.dom.test.ts
@@ -7,6 +7,7 @@ import type { QueueJob } from "./api";
import {
clearFinished,
forgetFinished,
+ noteAnnounced,
noteQueue,
resetQueueWatch,
takeUnannounced,
@@ -41,6 +42,14 @@ describe("downloadQueue · a finish", () => {
expect(takeUnannounced().map((j) => j.id)).toEqual([5]);
});
+ it("a finish its own action announced is kept for the tray but not announced again", () => {
+ noteQueue([]);
+ noteAnnounced(7); // an upload, done in the answer that made it
+ noteQueue([job(7, "done"), job(8, "done")]);
+ expect(takeUnannounced().map((j) => j.id)).toEqual([8]);
+ expect(finishedIds()).toEqual([7, 8]);
+ });
+
it("a failure is not a finish; closing the tray forgets what finished", () => {
noteQueue([job(1, "running"), job(2, "running")]);
noteQueue([job(1, "error"), job(2, "done")]);
diff --git a/frontend/src/lib/downloadQueue.ts b/frontend/src/lib/downloadQueue.ts
index 235d1a56..31d57008 100644
--- a/frontend/src/lib/downloadQueue.ts
+++ b/frontend/src/lib/downloadQueue.ts
@@ -33,6 +33,8 @@ let lastSeen: Map | null = null;
const finished = createStore([]);
const unannounced = createStore([]);
const revealing = createStore(null);
+// Finishes this tab already said out loud where they happened: an upload that answered done.
+const announcedHere = new Set();
/** A job to show in the tray, asked for at `at` (ms). */
export interface Reveal {
@@ -50,9 +52,17 @@ export function noteQueue(jobs: QueueJob[]): void {
if (done.length === 0) return;
const ids = new Set(done.map((j) => j.id));
finished.set((list) => [...done, ...list.filter((j) => !ids.has(j.id))]);
- unannounced.set((list) => [...list, ...done]);
+ // `delete` answers whether it was there: each id is spent on the one look that finds it done.
+ const news = done.filter((j) => !announcedHere.delete(j.id));
+ if (news.length) unannounced.set((list) => [...list, ...news]);
}
+/** A finish its own action announced already (an upload done in the answer that made it): the tray
+ * still keeps it, but the done notice would say it a second time — and as a download. */
+export const noteAnnounced = (id: number): void => {
+ announcedHere.add(id);
+};
+
/** The queue, noted (see above). Every reader of `qk.downloads()` fetches with this. */
export async function fetchQueue(): Promise {
const jobs = await api.downloads();
@@ -96,5 +106,6 @@ export function resetQueueWatch(): void {
lastSeen = null;
finished.set([]);
unannounced.set([]);
+ announcedHere.clear();
revealing.set(null);
}
From 90567718b2fa32ed0d20608b27b9f74264345b86 Mon Sep 17 00:00:00 2001
From: npeter83
Date: Thu, 15 Oct 2026 01:02:29 +0200
Subject: [PATCH 04/11] feat(files): share-link tokens are kept only hashed and
encrypted, and every link change is audited
A /watch link's token is the credential, and it sat in the database as written. Migration 0107
replaces the column with the token's SHA-256 (the lookup) and a Fernet-encrypted copy (so the owner
can copy the same link again); every existing address keeps working, and the downgrade decrypts
them back. Without a copy this key can read, a link still opens but its address cannot be shown:
"Copy link" then takes the newest link that has one or makes a new one, and the Share dialog says
so in that row instead of a broken URL. Making, changing and revoking a link are audited, never
with its token or password.
---
.../versions/0107_link_token_at_rest.py | 66 +++++++
backend/app/audit.py | 5 +
backend/app/downloads/links.py | 46 ++++-
backend/app/downloads/og.py | 5 +-
backend/app/models.py | 9 +-
backend/app/routes/downloads.py | 46 ++++-
backend/app/routes/public.py | 3 +-
backend/app/security.py | 6 +
backend/tests/test_link_token_at_rest.py | 187 ++++++++++++++++++
.../src/components/ShareDialog.dom.test.tsx | 81 +++++---
frontend/src/components/ShareDialog.tsx | 22 ++-
.../src/i18n/locales/en/auditActions.json | 5 +-
frontend/src/i18n/locales/en/downloads.json | 1 +
.../src/i18n/locales/hu/auditActions.json | 5 +-
frontend/src/i18n/locales/hu/downloads.json | 1 +
frontend/src/lib/api/downloads.ts | 8 +-
frontend/src/lib/downloads.test.ts | 29 +++
frontend/src/lib/downloads.ts | 15 +-
18 files changed, 480 insertions(+), 60 deletions(-)
create mode 100644 backend/alembic/versions/0107_link_token_at_rest.py
create mode 100644 backend/tests/test_link_token_at_rest.py
diff --git a/backend/alembic/versions/0107_link_token_at_rest.py b/backend/alembic/versions/0107_link_token_at_rest.py
new file mode 100644
index 00000000..30554da0
--- /dev/null
+++ b/backend/alembic/versions/0107_link_token_at_rest.py
@@ -0,0 +1,66 @@
+"""Share-link tokens no longer stored in the clear
+
+Revision ID: 0107_link_token_at_rest
+Revises: 0106_api_tokens
+Create Date: 2026-10-15
+
+A `/watch` link's token is the credential itself, and it sat in `download_links.token` as written,
+so a copy of the database was every shared file. Now a link is found by the token's SHA-256
+(`token_hash`), and the token is kept only Fernet-encrypted (`token_enc`, TOKEN_ENCRYPTION_KEY) so its
+owner can copy the same link again. Without a key the copy is not kept: the link still opens, its
+address just cannot be shown again (`app/downloads/links.py`).
+
+Every existing link is converted here, so the addresses already sent keep working. The downgrade
+writes the tokens back from their encrypted copies; a link with none left (no key, or a key since
+changed) gets a fresh random token, which is the same as revoking it.
+"""
+
+import hashlib
+import secrets
+
+import sqlalchemy as sa
+from alembic import op
+
+from app import security
+
+revision: str = "0107_link_token_at_rest"
+down_revision: str | None = "0106_api_tokens"
+branch_labels: str | None = None
+depends_on: str | None = None
+
+
+def upgrade() -> None:
+ op.add_column("download_links", sa.Column("token_hash", sa.String(64)))
+ op.add_column("download_links", sa.Column("token_enc", sa.Text()))
+ conn = op.get_bind()
+ sealing = security.can_encrypt()
+ for link_id, token in conn.execute(sa.text("SELECT id, token FROM download_links")).fetchall():
+ conn.execute(
+ sa.text("UPDATE download_links SET token_hash = :h, token_enc = :e WHERE id = :id"),
+ {
+ "id": link_id,
+ "h": hashlib.sha256(token.encode()).hexdigest(),
+ "e": security.encrypt(token) if sealing else None,
+ },
+ )
+ op.alter_column("download_links", "token_hash", nullable=False)
+ op.create_unique_constraint("uq_download_link_token_hash", "download_links", ["token_hash"])
+ op.drop_constraint("uq_download_link_token", "download_links", type_="unique")
+ op.drop_column("download_links", "token")
+
+
+def downgrade() -> None:
+ op.add_column("download_links", sa.Column("token", sa.String(64)))
+ conn = op.get_bind()
+ rows = conn.execute(sa.text("SELECT id, token_enc FROM download_links")).fetchall()
+ for link_id, enc in rows:
+ token = security.decrypt_optional(enc) or secrets.token_urlsafe(24)
+ conn.execute(
+ sa.text("UPDATE download_links SET token = :t WHERE id = :id"),
+ {"id": link_id, "t": token},
+ )
+ op.alter_column("download_links", "token", nullable=False)
+ op.create_unique_constraint("uq_download_link_token", "download_links", ["token"])
+ op.drop_constraint("uq_download_link_token_hash", "download_links", type_="unique")
+ op.drop_column("download_links", "token_enc")
+ op.drop_column("download_links", "token_hash")
diff --git a/backend/app/audit.py b/backend/app/audit.py
index 6a5f5170..d44606ba 100644
--- a/backend/app/audit.py
+++ b/backend/app/audit.py
@@ -73,6 +73,11 @@ class AuditAction:
# 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"
+ # A public `/watch` link to a file (`routes.downloads`): made, its settings changed, revoked.
+ # Never its token or password — only whether it has one.
+ DOWNLOAD_LINK_CREATE = "download_link_create"
+ DOWNLOAD_LINK_UPDATE = "download_link_update"
+ DOWNLOAD_LINK_REVOKE = "download_link_revoke"
def record(
diff --git a/backend/app/downloads/links.py b/backend/app/downloads/links.py
index 0f023774..f4a2c3cf 100644
--- a/backend/app/downloads/links.py
+++ b/backend/app/downloads/links.py
@@ -1,10 +1,14 @@
"""Public "share by link" helpers for the download center.
-A DownloadLink is a capability URL: the unguessable `token` grants anyone read access to one
-download's file on the login-free `/watch/{token}` page. Password-protected links additionally
-require a short-lived signed **grant** on the file request — because a `