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 ( +
+ +
+ + {token.name} + {token.prefix}… + {token.expired ? ( + {t("settings.tokens.expired")} + ) : ( + token.expiring && {t("settings.tokens.expiring")} + )} + + + {token.grants.map((g) => ( + + ))} + + + {token.last_used_at ? ( + <> + {t("settings.tokens.lastUsed")} + + ) : ( + t("settings.tokens.neverUsed") + )} + {" · "} + {token.expires_at === null + ? t("settings.tokens.neverExpires") + : token.expired + ? t("settings.tokens.expiredOn", { date: expires }) + : t("settings.tokens.expiresOn", { date: expires })} + +
+ {more} +
+ ); +} + +export default function ApiTokens() { + const { t } = useTranslation(); + const qc = useQueryClient(); + const confirm = useConfirm(); + const [making, setMaking] = useState(false); + const tokensQ = useQuery({ queryKey: qk.apiTokens(), queryFn: api.apiTokens }); + const revoke = useMutation({ + mutationFn: (id: number) => api.revokeApiToken(id), + onSuccess: () => qc.invalidateQueries({ queryKey: qk.apiTokens() }), + meta: { failTitle: "errors.writeFailed.apiToken.revoke" }, + }); + const onRevoke = async (token: ApiToken) => { + const ok = await confirm({ + title: t("settings.tokens.revokeTitle", { name: token.name }), + message: t("settings.tokens.revokeBody"), + confirmLabel: t("settings.tokens.revokeAction"), + danger: true, + }); + if (ok) revoke.mutate(token.id); + }; + const menu = useContextMenu({ keyOf: (tk) => tk.id }); + const items = (token: ApiToken): ActionMenuItem[] => [ + { + id: "revoke", + label: t("settings.tokens.revoke"), + icon: , + danger: true, + onSelect: () => void onRevoke(token), + }, + ]; + const tokens = tokensQ.data; + + return ( +
+
+

{t("settings.tokens.intro")}

+ +
+ {tokensQ.isError && !tokens ? ( + void tokensQ.refetch()}> + {t("common.loadError")} + + ) : !tokens ? ( + + ) : tokens.length === 0 ? ( +

{t("settings.tokens.empty")}

+ ) : ( + + {tokens.map((token, i) => ( + + } + /> + ))} + + )} + + {making && setMaking(false)} />} +
+ ); +} + +// --- making one ----------------------------------------------------------------------------------- + +type Kind = "hub" | "custom"; +type Grants = Record; + +/** The folders a custom token may be given, as the Files rail shows them: the sources that hold + * something, then the Hub folder, then the user's own. A right a folder cannot take (writing into a + * site's folder) is not offered. */ +function grantable(folders: readonly FileFolder[], t: TFunction, lang: string): FileFolder[] { + const byName = (a: FileFolder, b: FileFolder) => + compareText(folderLabel(a, t), folderLabel(b, t), lang); + return [ + ...folders.filter((f) => f.kind === "source" && f.count > 0).sort(byName), + ...folders.filter((f) => f.kind === "hub"), + ...folders.filter((f) => f.kind === "own").sort(byName), + ]; +} + +function RightsGrid({ + foldersQ, + grants, + onChange, +}: { + foldersQ: UseQueryResult; + grants: Grants; + onChange: (g: Grants) => void; +}) { + const { t, i18n } = useTranslation(); + if (foldersQ.isError && !foldersQ.data) + return ( + void foldersQ.refetch()}> + {t("common.loadError")} + + ); + if (!foldersQ.data) return ; + const rows = grantable(foldersQ.data, t, i18n.language); + if (rows.length === 0) + return

{t("settings.tokens.noFolders")}

; + const toggle = (folder: FileFolder, right: TokenRight) => { + const had = grants[folder.id] ?? []; + const next = had.includes(right) ? had.filter((r) => r !== right) : [...had, right]; + onChange({ ...grants, [folder.id]: next }); + }; + return ( +
+ {rows.map((f) => { + const label = folderLabel(f, t); + return ( +
+ + + {label} + + + {TOKEN_RIGHTS.map((r) => + r === "write" && !takesUploads(f) ? null : ( + toggle(f, r)} + > + {t(`settings.tokens.rights.${r}`)} + + ), + )} + +
+ ); + })} +
+ ); +} + +function KindTile({ + on, + icon, + title, + text, + onPick, +}: { + on: boolean; + icon: ReactNode; + title: string; + text: string; + onPick: () => void; +}) { + const textId = useId(); + return ( + + + {icon} + {title} + + {/* Read after the name, not into it ("Custom" — then what it means). */} + + {text} + + + ); +} + +const HubIcon = folderIcon({ kind: "hub", source_key: null, name: null }); + +const expiryValue = (days: number | null) => (days === null ? "never" : String(days)); + +function NewTokenDialog({ onClose }: { onClose: () => void }) { + const { t } = useTranslation(); + const qc = useQueryClient(); + const nameId = useId(); + const [kind, setKind] = useState("hub"); + // The name as typed; untouched, the Hub exchange is named for what it is and a custom one is blank. + const [nameDraft, setNameDraft] = useState(null); + const name = nameDraft ?? (kind === "hub" ? t("settings.tokens.hubName") : ""); + const [grants, setGrants] = useState({}); + const [expiry, setExpiry] = useState(TOKEN_EXPIRY_DEFAULT); + // Read as the dialog opens, not when Custom is picked: the grid is there by the time it is wanted. + const foldersQ = useQuery({ queryKey: qk.fileFolders(), queryFn: api.fileFolders }); + const picked = Object.entries(grants) + .filter(([, rights]) => rights.length > 0) + .map(([id, rights]) => ({ folder_id: Number(id), rights: [...rights] })); + const create = useMutation({ + mutationFn: () => + api.createApiToken( + kind === "hub" + ? { name: name.trim(), expires_days: expiry, preset: "hub" } + : { name: name.trim(), expires_days: expiry, grants: picked }, + ), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: qk.apiTokens() }); + // The Hub exchange makes the Hub folder the first time. + void qc.invalidateQueries({ queryKey: qk.fileFolders() }); + }, + meta: { failTitle: "errors.writeFailed.apiToken.create" }, + }); + const made = create.data; + const ready = name.trim() !== "" && (kind === "hub" || picked.length > 0); + + if (made) + return ( + +
+ {t("settings.tokens.once")} + + +
+ + + +
+ ); + + return ( + +
{ + e.preventDefault(); + if (ready && !create.isPending) create.mutate(); + }} + > +
+ } + title={t("settings.tokens.hubTitle")} + text={t("settings.tokens.hubText")} + onPick={() => setKind("hub")} + /> + } + title={t("settings.tokens.customTitle")} + text={t("settings.tokens.customText")} + onPick={() => setKind("custom")} + /> +
+ + {kind === "custom" && ( +
+ {t("settings.tokens.folders")} + +
+ )} +
+ + {t("settings.tokens.expiry")} + + ({ + value: expiryValue(d), + label: t(`settings.tokens.expiryOption.${expiryValue(d)}`), + }))} + value={expiryValue(expiry)} + onChange={(v) => setExpiry(v === "never" ? null : Number(v))} + /> + {expiry === null && {t("settings.tokens.neverWarning")}} +
+
+ + + + +
+ ); +} + +/** A value to copy, shown whole (it wraps), with its Copy button. */ +function CopyField({ + label, + value, + autoFocus = false, +}: { + label: string; + value: string; + autoFocus?: boolean; +}) { + const { t } = useTranslation(); + const [copied, setCopied] = useState(false); + const copy = async () => { + if (await copyText(value)) { + setCopied(true); + window.setTimeout(() => setCopied(false), 2000); + return; + } + // The value is on screen and selectable: a failed copy is a convenience that failed. + notify({ level: "warning", message: t("common.copy.failed") }); + }; + return ( +
+ {label} +
+ + {value} + + +
+
+ ); +} 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 && ( + + )} {isDownloadDone && ( diff --git a/frontend/src/components/ShareDialog.dom.test.tsx b/frontend/src/components/ShareDialog.dom.test.tsx index 48d82111..793a9e7c 100644 --- a/frontend/src/components/ShareDialog.dom.test.tsx +++ b/frontend/src/components/ShareDialog.dom.test.tsx @@ -177,6 +177,27 @@ describe("ShareDialog · a folder", () => { expect((allow() as HTMLInputElement).checked).toBe(true); }); + it("a list that did not load says so with Retry, and offers no New link beside it", async () => { + h.folderLinks + .mockReset() + .mockRejectedValueOnce(new Error("503")) + .mockResolvedValue([link(4)]); + h.createFolderLink.mockReset(); + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + {}} /> + , + ); + expect(await screen.findByText("downloads.share.linksFailed")).toBeTruthy(); + expect(screen.queryByText("downloads.share.noLinks")).toBeNull(); + expect(screen.queryByRole("button", { name: "downloads.share.newLink" })).toBeNull(); + expect(h.createFolderLink).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "common.retry" })); + expect(await screen.findByDisplayValue(`${window.location.origin}/watch/f4`)).toBeTruthy(); + expect(screen.getByRole("button", { name: "downloads.share.newLink" })).toBeTruthy(); + }); + it("makes a link on opening with none, but never right after the last one was revoked", async () => { h.folderLinks.mockReset().mockResolvedValue([]); h.createFolderLink.mockReset().mockResolvedValue(link(6)); diff --git a/frontend/src/components/ShareDialog.tsx b/frontend/src/components/ShareDialog.tsx index 3b0fd461..5b4cf0f7 100644 --- a/frontend/src/components/ShareDialog.tsx +++ b/frontend/src/components/ShareDialog.tsx @@ -7,7 +7,6 @@ import Modal from "./ui/Modal"; import { useConfirm } from "./confirmContext"; import { notify } from "../lib/notifications"; import { notifyActionError } from "../lib/actionErrors"; -import { copyText } from "../lib/clipboard"; import { formatDate } from "../lib/format"; import { api, type DownloadJob, type ShareLink, type ShareRecipient } from "../lib/api"; import { fetchLinks, invalidateLinks, linksKey, makeLink, type LinkTarget } from "../lib/downloads"; @@ -17,6 +16,8 @@ import { Input, InnerInput } from "./ui/Input"; import { CheckRow } from "./ui/NativeControls"; import { SURFACE } from "./ui/surfaces"; import UserPicker from "./UserPicker"; +import { useCopyFeedback } from "./useCopyFeedback"; +import { LoadingState, StateMessage } from "./ui/QueryState"; const EXPIRY_DAYS = [null, 1, 7, 30] as const; // --- share with a registered user (ACL) ----------------------------------------------------- @@ -68,18 +69,13 @@ function UserShare({ job }: { job: DownloadJob }) { function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }) { const { t, i18n } = useTranslation(); const confirm = useConfirm(); - const [copied, setCopied] = useState(false); + const { copied, copy: copyValue } = useCopyFeedback(); const [editPass, setEditPass] = useState(false); const [newPass, setNewPass] = useState(""); const fullUrl = link.url && window.location.origin + link.url; - const copy = async () => { - if (fullUrl && (await copyText(fullUrl))) { - setCopied(true); - setTimeout(() => setCopied(false), 1500); - } else { - notify({ level: "error", message: t("downloads.share.copyFailed") }); - } + const copy = () => { + if (fullUrl) void copyValue(fullUrl); }; const toggle = useMutation({ mutationFn: () => api.updateDownloadLink(link.id, { allow_download: !link.allow_download }), @@ -299,16 +295,28 @@ function LinkShare({ target }: { target: LinkTarget }) { )}

-
- {(links.data ?? []).map((l) => ( - - ))} - {links.data && links.data.length === 0 && !creating && !create.isPending && ( -

{t("downloads.share.noLinks")}

- )} -
+ {/* A list that did not load is said so, with Retry — never shown as "no links", and with no + "New link" beside it, which would add a second public link next to ones not seen. */} + {!links.data ? ( + links.isError ? ( + void links.refetch()}> + {t("downloads.share.linksFailed")} + + ) : ( + + ) + ) : ( +
+ {links.data.map((l) => ( + + ))} + {links.data.length === 0 && !creating && !create.isPending && ( +

{t("downloads.share.noLinks")}

+ )} +
+ )} - {creating ? ( + {!links.data ? null : creating ? (
{t("downloads.share.allowDownload")} diff --git a/frontend/src/components/UploadDialog.dom.test.tsx b/frontend/src/components/UploadDialog.dom.test.tsx index 6878f5b6..61b94203 100644 --- a/frontend/src/components/UploadDialog.dom.test.tsx +++ b/frontend/src/components/UploadDialog.dom.test.tsx @@ -16,7 +16,8 @@ const h = vi.hoisted(() => ({ filesUsage: vi.fn(async () => ({ upload_max_bytes: 1_000_000 })), notify: vi.fn(), showInTray: vi.fn(), - noteAnnounced: vi.fn(), + uploadStarted: vi.fn(), + uploadEnded: vi.fn(), openFile: vi.fn(), })); @@ -38,7 +39,8 @@ vi.mock("../lib/notifications", async (importOriginal) => ({ })); vi.mock("../lib/downloadQueue", () => ({ showInTray: h.showInTray, - noteAnnounced: h.noteAnnounced, + uploadStarted: h.uploadStarted, + uploadEnded: h.uploadEnded, })); import UploadDialog from "./UploadDialog"; @@ -117,7 +119,7 @@ describe("UploadDialog", () => { message: "downloads.upload.addedConverting", }); // Its finish is still to come, and the tray's to announce. - expect(h.noteAnnounced).not.toHaveBeenCalledWith(43); + expect(h.uploadEnded).toHaveBeenCalledWith(null); }); it("puts the file in the folder it was opened from, and its notice opens where it now is", async () => { @@ -132,7 +134,8 @@ describe("UploadDialog", () => { 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); + expect(h.uploadStarted).toHaveBeenCalled(); + expect(h.uploadEnded).toHaveBeenCalledWith(42); cleanup(); h.notify.mockClear(); diff --git a/frontend/src/components/UploadDialog.tsx b/frontend/src/components/UploadDialog.tsx index 7eab023d..a9ce514d 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 { noteAnnounced, showInTray } from "../lib/downloadQueue"; +import { showInTray, uploadEnded, uploadStarted } from "../lib/downloadQueue"; import type { DownloadJob } from "../lib/api"; import { useFilesActions } from "./filesContext"; import { formatBytes } from "../lib/format"; @@ -28,6 +28,8 @@ import { Input } from "./ui/Input"; // skip the whole dialog. const uploadedKey = (status: string) => status === "done" ? "downloads.upload.added" : "downloads.upload.addedConverting"; +// The job the upload's own notice announces (a done one), for the queue not to say it again. +const announces = (job: { id: number; status: string }) => (job.status === "done" ? job.id : null); export default function UploadDialog({ folderId = null, initialFile = null, @@ -96,6 +98,7 @@ export default function UploadDialog({ abortRef.current = ac; setPct(0); const displayName = name.trim() || undefined; + uploadStarted(); try { const job = await api.uploadFile(file, { displayName, @@ -105,7 +108,7 @@ export default function UploadDialog({ }); // 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); + uploadEnded(announces(job)); invalidateDownloads(qc); notify({ level: "success", @@ -123,6 +126,7 @@ export default function UploadDialog({ // a long upload, or an nginx 413, snapped the dialog back to its pre-upload state with nothing // named and no job in the list. The comment here claimed the global error modal had it; it // had not since the shared `reportFailure` landed. + uploadEnded(null); if (!isAborted(e)) notifyActionError(e, t("downloads.upload.failed")); // Re-arm the form so the user can retry or pick again. setPct(null); diff --git a/frontend/src/components/useCopyFeedback.dom.test.ts b/frontend/src/components/useCopyFeedback.dom.test.ts new file mode 100644 index 00000000..62bc475b --- /dev/null +++ b/frontend/src/components/useCopyFeedback.dom.test.ts @@ -0,0 +1,42 @@ +// @vitest-environment jsdom +// +// A Copy button beside its value: "Copied" for a moment and a notice a screen reader hears, or a +// warning when the clipboard refused — the same at every such button. +import { afterEach, describe, expect, it, vi } from "vitest"; +import { act, renderHook } from "@testing-library/react"; + +const h = vi.hoisted(() => ({ copyText: vi.fn(async (_t: string) => true), notify: vi.fn() })); +vi.mock("react-i18next", async (importOriginal) => ({ + ...(await importOriginal()), + useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }), +})); +vi.mock("../lib/clipboard", () => ({ copyText: h.copyText })); +vi.mock("../lib/notifications", () => ({ notify: h.notify })); + +import { useCopyFeedback } from "./useCopyFeedback"; + +afterEach(() => { + vi.useRealTimers(); + h.notify.mockClear(); +}); + +describe("useCopyFeedback", () => { + it("a copy that worked reads Copied for a moment and is said out loud", async () => { + vi.useFakeTimers(); + const { result } = renderHook(() => useCopyFeedback()); + await act(() => result.current.copy("sfl_x")); + expect(h.copyText).toHaveBeenCalledWith("sfl_x"); + expect(result.current.copied).toBe(true); + expect(h.notify).toHaveBeenCalledWith({ level: "success", message: "common.copy.done" }); + act(() => void vi.advanceTimersByTime(2000)); + expect(result.current.copied).toBe(false); + }); + + it("a copy the clipboard refused is a warning, and nothing reads Copied", async () => { + h.copyText.mockResolvedValueOnce(false); + const { result } = renderHook(() => useCopyFeedback()); + await act(() => result.current.copy("sfl_x")); + expect(result.current.copied).toBe(false); + expect(h.notify).toHaveBeenCalledWith({ level: "warning", message: "common.copy.failed" }); + }); +}); diff --git a/frontend/src/components/useCopyFeedback.ts b/frontend/src/components/useCopyFeedback.ts new file mode 100644 index 00000000..161ca6ce --- /dev/null +++ b/frontend/src/components/useCopyFeedback.ts @@ -0,0 +1,30 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { copyText } from "../lib/clipboard"; +import { notify } from "../lib/notifications"; + +// How long a Copy button reads "Copied" after it worked. +const COPIED_MS = 2000; + +/** The feedback of a Copy button that sits beside the value it copies (a token, a share link, a + * set-password link), the same at every such button: it reads "Copied" for a moment, and a notice + * says it too — the notices are a live region, so a screen reader hears what a swapped label would + * not tell it. A copy that failed is a warning, not an error: the value is on screen and + * selectable, so it is a convenience that failed, not the action. + * + * A feature file, not `ui/`: it speaks the app's notifications (as `copyItems` does). */ +export function useCopyFeedback(): { copied: boolean; copy: (value: string) => Promise } { + const { t } = useTranslation(); + const [copied, setCopied] = useState(false); + const copy = async (value: string) => { + if (await copyText(value)) { + setCopied(true); + notify({ level: "success", message: t("common.copy.done") }); + window.setTimeout(() => setCopied(false), COPIED_MS); + return; + } + notify({ level: "warning", message: t("common.copy.failed") }); + }; + return { copied, copy }; +} diff --git a/frontend/src/design-lab/DesignLab.tsx b/frontend/src/design-lab/DesignLab.tsx index 3b106b62..25e1abc7 100644 --- a/frontend/src/design-lab/DesignLab.tsx +++ b/frontend/src/design-lab/DesignLab.tsx @@ -77,11 +77,14 @@ export default function DesignLab() { />
); } -// ── F1: the list and the grid ──────────────────────────────────────────────────────────────────── +// ── F1: the list ───────────────────────────────────────────────────────────────────────────────── // One grid for the whole list, each row a subgrid: every kind of value sits in its own column, so the -// badges, the sizes and the action icons line up down the page. An action that does not apply keeps -// its slot empty (a file has no "copy the play link"). +// badges, the sizes and the dates line up down the page. const LIST_COLUMNS = - "grid grid-cols-[4rem_minmax(0,1fr)_7rem_6.5rem_7.5rem_4.5rem_4.5rem_auto] items-center gap-x-3"; -const ROW = "col-span-full grid grid-cols-subgrid items-center rounded-lg px-2 py-1.5"; + "grid grid-cols-[4rem_minmax(0,1fr)_8rem_6.5rem_6.75rem_5.5rem_6.5rem_3rem] items-center gap-x-3"; +const ROW = "group col-span-full grid grid-cols-subgrid items-center rounded-lg px-2 py-1.5"; function ListHead() { const th = "text-2xs uppercase tracking-wider text-muted"; @@ -447,7 +413,7 @@ function ListRows() { {item.size} {item.when} - + ))} @@ -496,32 +462,6 @@ function RowMenu() { ); } -function GridCards() { - return ( -
- {ITEMS.map((item, i) => ( -
- - {item.name} - - - {item.by && {item.by}} - - - - -
- ))} -
- ); -} - // ── F2: the download tray ──────────────────────────────────────────────────────────────────────── function TrayRow({ title, site, children }: { title: string; site: string; children: ReactNode }) { @@ -536,13 +476,15 @@ function TrayRow({ title, site, children }: { title: string; site: string; child ); } -function Progress({ pct }: { pct: number }) { +function Progress({ step, pct }: { step: string; pct: number }) { return ( - {pct}% + + {step} · {pct}% + ); } @@ -561,7 +503,7 @@ function TrayPanel() { - + Sorban áll… @@ -603,20 +545,6 @@ function TrayPill() { ); } -function RailBell() { - return ( -
- - - - 3 - - - -
- ); -} - // ── F3: the done notification ──────────────────────────────────────────────────────────────────── function DoneToast() { @@ -647,84 +575,44 @@ function DoneToast() { export function FilesRound() { return (
- - - - - - - - - - - - - - - + + + + + + + - -
- - - - - - - - - - - +
+ + + + + +
- + - - - - - + +
); } diff --git a/frontend/src/design-lab/labKit.tsx b/frontend/src/design-lab/labKit.tsx index 1bad5c48..8a2ce46e 100644 --- a/frontend/src/design-lab/labKit.tsx +++ b/frontend/src/design-lab/labKit.tsx @@ -1,12 +1,12 @@ import { useSyncExternalStore, type ReactNode } from "react"; import clsx from "clsx"; -import { Badge } from "../components/ui/Badge"; import { SURFACE } from "../components/ui/surfaces"; -// Building blocks of the design lab. A taken decision is a : the chosen rendering with the -// rule it became. An open design round shows its questions as s with their s side -// by side, until the owner picks. Everything -// is static markup on purpose: a rendering that only exists here must not need app state. +// Building blocks of the design lab. Every decision in it is taken, so the lab is the reference half +// only: each is the chosen rendering with the rule it became. A new design round brings +// back a side-by-side / pair (see git history) for its open questions, and deletes +// it again once the owner picks. Everything is static markup on purpose: a rendering that only exists +// here must not need app state. /** A decision already taken: the chosen rendering only, with the rule it became. The lab's * reference half — the living style guide. */ @@ -37,76 +37,6 @@ export function Reference({ ); } -/** An open question of a design round: what the app does today, then the variants side by side. */ -export function Decision({ - id, - code, - title, - question, - now, - children, -}: { - id: string; - code: string; - title: string; - question: string; - /** What the app does today. */ - now: ReactNode; - children: ReactNode; -}) { - return ( -
-
-
- {code} -

{title}

-
-

{question}

-

- Today: - {now} -

-
-
{children}
-
- ); -} - -/** One answer to a Decision; the recommended one carries the accent edge. */ -export function Variant({ - letter, - name, - recommended = false, - why, - children, -}: { - letter: string; - name: string; - recommended?: boolean; - why: string; - children: ReactNode; -}) { - return ( -
-
- - {letter} - -

{name}

- {recommended && Recommended} -
-
{children}
-

{why}

-
- ); -} - /** A small caption above a sample inside a variant. */ export function Sample({ label, children }: { label: string; children: ReactNode }) { return ( diff --git a/frontend/src/design-lab/linksRound.tsx b/frontend/src/design-lab/linksRound.tsx index 50f05704..ad27182e 100644 --- a/frontend/src/design-lab/linksRound.tsx +++ b/frontend/src/design-lab/linksRound.tsx @@ -16,13 +16,13 @@ import { Users, X, } from "lucide-react"; -import { Decision, Sample, Variant } from "./labKit"; +import { Reference, Sample } from "./labKit"; import { SURFACE } from "../components/ui/surfaces"; import { buttonClass, iconButtonClass } from "../components/ui/buttonStyles"; import { MEDIA_BADGE } from "../components/ui/mediaCardStyles"; -// The folder links round: a /watch link that opens a whole folder, not one file. Static markup on -// purpose (the lab's rule); the folder and its files are the owner's own kind of content. +// Folder links, DECIDED (owner A/A/A): a /watch link that opens a whole folder, not one file. Static +// markup on purpose (the lab's rule); the folder and its files are the owner's own kind of content. const ICON = iconButtonClass({ variant: "ghost", size: "sm" }); @@ -91,15 +91,13 @@ function Rail({ marked }: { marked: boolean }) { ); } -function FolderMenu({ quickCopy }: { quickCopy: boolean }) { +function FolderMenu() { const row = "flex items-center gap-2.5 rounded-lg px-2 py-1.5 text-sm"; const groups: [typeof Folder, string, boolean?][][] = [ - quickCopy - ? [ - [Link2, "Link másolása"], - [Users, "Megosztás linkkel…"], - ] - : [[Users, "Megosztás linkkel…"]], + [ + [Link2, "Link másolása"], + [Users, "Megosztás linkkel…"], + ], [ [Clock, "Megőrzés"], [Pencil, "Átnevezés"], @@ -266,155 +264,54 @@ function ListPage({ phone, playing }: { phone?: boolean; playing?: boolean }) { ); } -function GridPage() { - return ( - -
-
- {FILES.map((f) => ( -
- - {f.name} - - {f.size} - - -
- ))} -
- - ); -} - -function DownloadOnlyPage() { - return ( - -
-
- {FILES.map((f) => ( -
- - {f.name} - {f.size} - - Letöltés - -
- ))} -
- - ); -} - // ── The round ─────────────────────────────────────────────────────────────────────────────────── export function LinksRound() { return ( -
- + -
- - -
- - -
-
-
- - - - - - +
+ +
+ + +
+
+ - +
- + - -
- - - - - - - - - - - - - - +
+ + + + + +
- + - -
- - - - - - -
-
+ +
); } diff --git a/frontend/src/design-lab/tokensRound.tsx b/frontend/src/design-lab/tokensRound.tsx index b879b845..f1a5d252 100644 --- a/frontend/src/design-lab/tokensRound.tsx +++ b/frontend/src/design-lab/tokensRound.tsx @@ -2,7 +2,6 @@ import clsx from "clsx"; import type { ReactNode } from "react"; import { AlertTriangle, - Check, Copy, Folder, KeyRound, @@ -12,15 +11,16 @@ import { SlidersHorizontal, X, } from "lucide-react"; -import { Decision, Sample, Variant } from "./labKit"; +import { Reference, Sample } 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). +// API tokens, DECIDED (owner A/A/A): 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 = () => {}; @@ -143,49 +143,6 @@ function ListRows() { ); } -function ListTable() { - return ( -
- - - - - - - - - - - {TOKENS.map((tk) => ( - - - - - - - - ))} - -
NévMappákHasználvaLejár -
-
{tk.name}
- {tk.prefix}… -
{tk.grants.map((g) => g.folder).join(", ")}{tk.used} - {tk.expires} - - - - -
-
- ); -} - // ── T2 · the new-token dialog ─────────────────────────────────────────────────────────────────── function Dialog({ @@ -220,22 +177,16 @@ function Field({ label, children }: { label: string; children: ReactNode }) { ); } -function Expiry({ never = false }: { never?: boolean }) { +function Expiry() { return ( {["30 nap", "90 nap", "1 év", "Soha"].map((l) => ( - + {l} ))} - {never && ( - - - Egy soha le nem járó token addig nyitja a mappát, amíg vissza nem vonod. - - )} ); } @@ -325,30 +276,6 @@ function PresetFirst({ custom }: { custom: boolean }) { ); } -function OneForm() { - return ( - - Mégse - Létrehozás - - } - > - - Hub csere - - - Mappák és jogok - Hub csere kitöltése - - - - - ); -} - // ── T3 · the token, shown once ────────────────────────────────────────────────────────────────── const RAW = "sfl_7Qm2xK9aVt3pLc8RzN0eWq5uYb1HdJ6gFs4kMx2oTi9a3F7kQe"; @@ -396,120 +323,48 @@ function ShownInDialog() { ); } -function ShownAsBanner() { - 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/downloads.json b/frontend/src/i18n/locales/en/downloads.json index b1c92551..66d00946 100644 --- a/frontend/src/i18n/locales/en/downloads.json +++ b/frontend/src/i18n/locales/en/downloads.json @@ -94,6 +94,7 @@ "linkCopied": "Share link copied to clipboard", "openNewTab": "Open in new tab", "linkFailed": "Couldn’t create the share link", + "linksFailed": "Couldn’t load the links", "noLinks": "No links yet.", "newLink": "Create a link", "createLink": "Create link", diff --git a/frontend/src/i18n/locales/en/settings.json b/frontend/src/i18n/locales/en/settings.json index f20bcedf..8aee5fda 100644 --- a/frontend/src/i18n/locales/en/settings.json +++ b/frontend/src/i18n/locales/en/settings.json @@ -99,16 +99,16 @@ "new": "New password (min. 10 characters)", "save": "Save password", "saving": "Saving…", - "saved": "Password updated for {{email}}", + "saved": "Password updated for {{email}} — other sessions were signed out and your API tokens revoked", "failed": "Couldn't update the password" }, "sessions": { "title": "Active sessions", - "hint": "Sign this account out everywhere else — handy if you left it signed in on a shared or lost device. You stay signed in here.", + "hint": "Sign this account out everywhere else, and revoke its API tokens — handy if you left it signed in on a shared or lost device. You stay signed in here.", "action": "Log out other sessions", "confirmTitle": "Log out other sessions?", - "confirmBody": "This signs you out of every other browser and device. You'll stay signed in here.", - "done": "Signed out of all other sessions", + "confirmBody": "This signs you out of every other browser and device, and revokes every API token of yours (make new ones for your machines). You'll stay signed in here.", + "done": "Signed out of all other sessions; API tokens revoked", "failed": "Couldn't sign out other sessions. Please try again." } }, @@ -284,6 +284,7 @@ "tryIt": "Try it (curl)", "copy": "Copy", "copied": "Copied", + "copyField": "Copy: {{field}}", "done": "Done" } } diff --git a/frontend/src/i18n/locales/en/users.json b/frontend/src/i18n/locales/en/users.json index 78e8edd2..f50b5ddf 100644 --- a/frontend/src/i18n/locales/en/users.json +++ b/frontend/src/i18n/locales/en/users.json @@ -71,7 +71,6 @@ "approvalWaitingOnYou": "waiting on you", "copied": "Copied", "copy": "Copy", - "copyFailed": "Couldn't copy — select the link and copy it by hand", "email": "Email", "emailConfirmed": "confirmed", "emailUnconfirmed": "not confirmed", diff --git a/frontend/src/i18n/locales/hu/downloads.json b/frontend/src/i18n/locales/hu/downloads.json index 72c308d0..3dcccbe0 100644 --- a/frontend/src/i18n/locales/hu/downloads.json +++ b/frontend/src/i18n/locales/hu/downloads.json @@ -94,6 +94,7 @@ "linkCopied": "Megosztási link a vágólapra másolva", "openNewTab": "Megnyitás új lapon", "linkFailed": "Nem sikerült a megosztási link létrehozása", + "linksFailed": "Nem sikerült betölteni a linkeket", "noLinks": "Még nincs link.", "newLink": "Link létrehozása", "createLink": "Létrehozás", diff --git a/frontend/src/i18n/locales/hu/settings.json b/frontend/src/i18n/locales/hu/settings.json index 57b2073e..d9623852 100644 --- a/frontend/src/i18n/locales/hu/settings.json +++ b/frontend/src/i18n/locales/hu/settings.json @@ -99,16 +99,16 @@ "new": "Új jelszó (min. 10 karakter)", "save": "Jelszó mentése", "saving": "Mentés…", - "saved": "Jelszó frissítve ehhez: {{email}}", + "saved": "Jelszó frissítve ehhez: {{email}} — a többi munkamenet kijelentkezett, az API-tokenjeid visszavonva", "failed": "Nem sikerült frissíteni a jelszót" }, "sessions": { "title": "Aktív munkamenetek", - "hint": "Jelentkeztesd ki ezt a fiókot minden más helyen — hasznos, ha megosztott vagy elveszett eszközön maradt bejelentkezve. Itt bejelentkezve maradsz.", + "hint": "Jelentkeztesd ki ezt a fiókot minden más helyen, és vond vissza az API-tokenjeit — hasznos, ha megosztott vagy elveszett eszközön maradt bejelentkezve. Itt bejelentkezve maradsz.", "action": "Kijelentkezés máshonnan", "confirmTitle": "Kijelentkezés a többi munkamenetből?", - "confirmBody": "Ez kijelentkeztet minden más böngészőből és eszközről. Itt bejelentkezve maradsz.", - "done": "Kijelentkeztetve minden más munkamenetből", + "confirmBody": "Ez kijelentkeztet minden más böngészőből és eszközről, és visszavonja minden API-tokenedet (a gépeidnek újakat kell készítened). Itt bejelentkezve maradsz.", + "done": "Kijelentkeztetve minden más munkamenetből; API-tokenek visszavonva", "failed": "Nem sikerült kijelentkeztetni a többi munkamenetet. Próbáld újra." } }, @@ -284,6 +284,7 @@ "tryIt": "Kipróbálás (curl)", "copy": "Másolás", "copied": "Másolva", + "copyField": "Másolás: {{field}}", "done": "Kész" } } diff --git a/frontend/src/i18n/locales/hu/users.json b/frontend/src/i18n/locales/hu/users.json index 57b6666e..3211fb31 100644 --- a/frontend/src/i18n/locales/hu/users.json +++ b/frontend/src/i18n/locales/hu/users.json @@ -71,7 +71,6 @@ "approvalWaitingOnYou": "rád vár", "copied": "Másolva", "copy": "Másolás", - "copyFailed": "Nem sikerült másolni — jelöld ki a linket, és másold kézzel", "email": "E-mail", "emailConfirmed": "megerősítve", "emailUnconfirmed": "nincs megerősítve", diff --git a/frontend/src/lib/downloadQueue.dom.test.ts b/frontend/src/lib/downloadQueue.dom.test.ts index fc3f4d3d..00d8db38 100644 --- a/frontend/src/lib/downloadQueue.dom.test.ts +++ b/frontend/src/lib/downloadQueue.dom.test.ts @@ -7,15 +7,18 @@ import type { QueueJob } from "./api"; import { clearFinished, forgetFinished, - noteAnnounced, noteQueue, resetQueueWatch, takeUnannounced, + uploadEnded, + uploadStarted, useFinished, } from "./downloadQueue"; import { renderHook } from "@testing-library/react"; const job = (id: number, status: string) => ({ id, status }) as unknown as QueueJob; +const upload = (id: number) => + ({ id, status: "done", source_kind: "upload" }) as unknown as QueueJob; const finishedIds = () => renderHook(() => useFinished()).result.current.map((j) => j.id); beforeEach(resetQueueWatch); @@ -44,12 +47,39 @@ describe("downloadQueue · a finish", () => { 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")]); + uploadStarted(); + uploadEnded(7); // an upload, done in the answer that made it + noteQueue([upload(7), job(8, "done")]); expect(takeUnannounced().map((j) => j.id)).toEqual([8]); expect(finishedIds()).toEqual([7, 8]); }); + it("an upload the queue sees done before its own answer arrives is still said once, by it", () => { + noteQueue([]); + uploadStarted(); + noteQueue([upload(7)]); // the server committed it; the answer is still on its way + expect(takeUnannounced()).toEqual([]); + uploadEnded(7); + expect(takeUnannounced()).toEqual([]); + expect(finishedIds()).toEqual([7]); + }); + + it("another device's upload landing meanwhile is announced once this tab's upload answers", () => { + noteQueue([]); + uploadStarted(); + noteQueue([upload(7), upload(9)]); + uploadEnded(7); + expect(takeUnannounced().map((j) => j.id)).toEqual([9]); + }); + + it("an upload that failed announced nothing: what landed meanwhile is announced", () => { + noteQueue([]); + uploadStarted(); + noteQueue([upload(9)]); + uploadEnded(null); + expect(takeUnannounced().map((j) => j.id)).toEqual([9]); + }); + 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 31d57008..7e7a61d6 100644 --- a/frontend/src/lib/downloadQueue.ts +++ b/frontend/src/lib/downloadQueue.ts @@ -35,6 +35,11 @@ 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(); +// This tab's uploads still waiting for their answer, and the uploads the queue saw land done +// meanwhile: one of them may be the answer's own job, done before the answer arrived. Whose it is, +// only the answer can tell (`uploadEnded`), so until then none of them is announced. +let uploading = 0; +let held: QueueJob[] = []; /** A job to show in the tray, asked for at `at` (ms). */ export interface Reveal { @@ -54,15 +59,35 @@ export function noteQueue(jobs: QueueJob[]): void { finished.set((list) => [...done, ...list.filter((j) => !ids.has(j.id))]); // `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]); + const landed = (j: QueueJob) => + uploading > 0 && j.source_kind === "upload" && before.get(j.id) === undefined; + held = [...held, ...news.filter(landed)]; + const now = news.filter((j) => !landed(j)); + if (now.length) unannounced.set((list) => [...list, ...now]); } -/** 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); +/** An upload from this tab is on its way: what the queue sees land done may be its own job. */ +export const uploadStarted = (): void => { + uploading += 1; }; +/** An upload's answer came: `said` is the job it announced itself (done in that answer — the tray + * still keeps it, but its done notice would say it a second time, and as a download), null when it + * announced none (still converting, failed, cancelled). In whichever order the queue saw it. The + * rest held meanwhile (another device's uploads) are announced once no upload is waiting. */ +export function uploadEnded(said: number | null): void { + uploading = Math.max(0, uploading - 1); + if (said !== null) { + const before = held.length; + held = held.filter((j) => j.id !== said); + if (held.length === before) announcedHere.add(said); + } + if (uploading > 0 || held.length === 0) return; + const release = held; + held = []; + unannounced.set((list) => [...list, ...release]); +} + /** The queue, noted (see above). Every reader of `qk.downloads()` fetches with this. */ export async function fetchQueue(): Promise { const jobs = await api.downloads(); @@ -107,5 +132,7 @@ export function resetQueueWatch(): void { finished.set([]); unannounced.set([]); announcedHere.clear(); + uploading = 0; + held = []; revealing.set(null); } From 620f84dba4ca532540ceaabe08ad19dbb47b0162 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 15 Oct 2026 05:47:00 +0200 Subject: [PATCH 08/11] fix(files): a token made as every session ends goes with them, and a file being shared holds its job --- backend/app/routes/auth.py | 16 ++++-- backend/app/routes/downloads.py | 18 +++++- backend/app/routes/tokens.py | 10 +++- backend/tests/test_api_tokens.py | 92 ++++++++++++++++++++++++++++++ backend/tests/test_folder_links.py | 70 +++++++++++++++++++++++ 5 files changed, 199 insertions(+), 7 deletions(-) diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py index 7ff41634..65edaf05 100644 --- a/backend/app/routes/auth.py +++ b/backend/app/routes/auth.py @@ -1147,10 +1147,18 @@ def bump_session_epoch(db: Session, user: User) -> None: """Invalidate every EXISTING signed cookie for this account by advancing its epoch (SA4). Cookies minted before the bump carry a now-stale epoch and are rejected by `current_user`. The account's API tokens go with them (`apitokens.revoke_all`): a token minted from a stolen session must not - outlive the answer to that theft. Not committed — the caller commits it with its change.""" - user.session_epoch = (user.session_epoch or 0) + 1 - if user.id is not None: - apitokens.revoke_all(db, user.id) + outlive the answer to that theft. Not committed — the caller commits it with its change. + + The user's row is locked before the tokens go, the lock `create_token` takes too: a token made at + the same moment either commits first and is deleted here, or waits and sees the new epoch.""" + if user.id is None: + user.session_epoch = (user.session_epoch or 0) + 1 + return + epoch = db.execute( + select(User.session_epoch).where(User.id == user.id).with_for_update() + ).scalar_one() + user.session_epoch = (epoch or 0) + 1 + apitokens.revoke_all(db, user.id) def revoke_untrusted_preset_password(db: Session, user: User) -> None: diff --git a/backend/app/routes/downloads.py b/backend/app/routes/downloads.py index 873b6cf7..2c897fe1 100644 --- a/backend/app/routes/downloads.py +++ b/backend/app/routes/downloads.py @@ -797,7 +797,7 @@ def create_link( user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: - job = owned_or_404(db, DownloadJob, job_id, user, what="download") + job = _shareable_job(db, user, job_id) if job.status != JobStatus.DONE or job.asset_id is None: raise refused(409, "share_unfinished") link, token = _new_link(db, user, body, job_id=job_id) @@ -805,6 +805,22 @@ def create_link( return linksmod.owner_view(link, token=token) +def _shareable_job(db: Session, user: User, job_id: int) -> DownloadJob: + """One of the user's files, held (FOR SHARE) for the rest of the transaction: a delete of it at + the same moment (the user's, or the retention sweep's) waits and takes the new link with it, + rather than failing the link's foreign key — or, done first, leaves a 404.""" + owned_or_404(db, DownloadJob, job_id, user, what="download") + job = db.execute( + select(DownloadJob) + .where(DownloadJob.id == job_id) + .with_for_update(read=True) + .execution_options(populate_existing=True) + ).scalar_one_or_none() + if job is None: + raise not_found("download") + return job + + def _shareable_folder(db: Session, user: User, folder_id: int) -> FileFolder: """One of the user's own folders. A source folder lets its files go on its own clock, and the Hub is the user's channel to a machine of theirs: neither is a shelf to hand out. Held (FOR SHARE) diff --git a/backend/app/routes/tokens.py b/backend/app/routes/tokens.py index adfbcbbd..d98f570d 100644 --- a/backend/app/routes/tokens.py +++ b/backend/app/routes/tokens.py @@ -120,8 +120,14 @@ def create_token( 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()) + # The user's row, locked: two requests at once cannot both slip under the ceiling, and a + # session ended since `current_user` read the epoch (unlocked) cannot mint a token that outlives + # it — `bump_session_epoch` takes this lock before it revokes them all. + epoch = db.execute( + select(User.session_epoch).where(User.id == user.id).with_for_update() + ).scalar_one() + if (epoch or 0) != (user.session_epoch or 0): + raise refused(401, "not_authenticated") count = db.execute( select(func.count(ApiToken.id)).where(ApiToken.user_id == user.id) ).scalar_one() diff --git a/backend/tests/test_api_tokens.py b/backend/tests/test_api_tokens.py index 211e0e98..64023c70 100644 --- a/backend/tests/test_api_tokens.py +++ b/backend/tests/test_api_tokens.py @@ -10,16 +10,19 @@ holds nothing, and a client trying tokens is slowed down. from datetime import datetime, timedelta, timezone import pytest +from fastapi import Depends, Request from fastapi.testclient import TestClient from sqlalchemy import select from app import apitokens from app.apitokens import files_caller +from app.auth import current_user 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.db import SessionLocal, get_db from app.main import app from app.models import ( ApiToken, @@ -29,9 +32,11 @@ from app.models import ( FileFolder, MediaAsset, Notification, + User, ) from app.ratelimit import RateLimiter from app.routes import tokens as tokens_routes +from app.routes.auth import bump_session_epoch from app.security import hash_token @@ -173,6 +178,39 @@ def test_a_token_is_granted_only_the_users_own_folders_and_writes_only_where_upl assert db.execute(select(ApiToken)).all() == [] +@pytest.mark.parametrize("right", ["read", "write"]) +def test_a_folder_granted_is_held_until_the_token_is_in(db, make_user, right): + """A delete of the folder at the same moment waits and takes the new grant with it, rather than + failing the grant's foreign key.""" + import threading + + alice = make_user() + fid = _own(db, alice.id, "Mine").id + body = tokens_routes.TokenIn( + name="x", grants=[tokens_routes.GrantIn(folder_id=fid, rights=[right])] + ) + tokens_routes._wanted_grants(db, alice, body) + outcome: list[object] = [] + + def delete_it(): + other = SessionLocal() + try: + outcome.append(folders.delete_own(other, other.get(FileFolder, fid))) + other.commit() + except Exception as e: # noqa: BLE001 — the assertion below names what went wrong + outcome.append(e) + finally: + other.close() + + t = threading.Thread(target=delete_it) + t.start() + t.join(timeout=1) + assert t.is_alive() # the delete waits for the token on its way in + db.commit() + t.join(timeout=10) + assert not isinstance(outcome[0], Exception), outcome + + 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()) @@ -368,6 +406,60 @@ def test_whatever_ends_every_session_ends_every_token_too(db, make_user, client, assert revoked.actor_id == alice.id and "every session" in (revoked.summary or "") +def test_a_token_asked_for_as_every_session_ends_is_refused(db, make_user, client, override_dep): + """The session was still good when its request came in, and ended before the token was made.""" + alice = make_user(email="alice@example.com") + c = _signed_in(client) + + def ended_meanwhile(request: Request, session=Depends(get_db)): + user = current_user(request, session) + other = SessionLocal() + try: + bump_session_epoch(other, other.get(User, alice.id)) + other.commit() + finally: + other.close() + return user + + override_dep(current_user, ended_meanwhile) + r = c.post("/api/tokens", json={"name": "late", "preset": "hub"}) + assert r.status_code == 401 + assert db.execute(select(ApiToken.id)).scalars().all() == [] + + +def test_a_token_made_as_every_session_ends_goes_with_them(db, make_user): + """The other order: the token is on its way in when the sessions end.""" + import threading + + alice = make_user(email="alice@example.com") + db.execute(select(User.id).where(User.id == alice.id).with_for_update()) # as create_token + raw = apitokens.mint() + db.add(ApiToken(user_id=alice.id, name="t", prefix=raw[:12], token_hash=hash_token(raw))) + db.flush() + outcome: list[object] = [] + + def end_sessions(): + other = SessionLocal() + try: + bump_session_epoch(other, other.get(User, alice.id)) + other.commit() + outcome.append("done") + except Exception as e: # noqa: BLE001 — the assertion below names what went wrong + outcome.append(e) + finally: + other.close() + + t = threading.Thread(target=end_sessions) + t.start() + t.join(timeout=1) + assert t.is_alive() # it waits for the token being made + db.commit() + t.join(timeout=10) + assert outcome == ["done"] + db.expire_all() + assert db.execute(select(ApiToken.id)).scalars().all() == [] + + # --- a token that does not hold ------------------------------------------------------------------ diff --git a/backend/tests/test_folder_links.py b/backend/tests/test_folder_links.py index 87e9f082..2bbcf4a5 100644 --- a/backend/tests/test_folder_links.py +++ b/backend/tests/test_folder_links.py @@ -325,3 +325,73 @@ def test_a_file_whose_bytes_are_gone_is_not_offered(shelf, client, db): (Path(settings.files_root) / asset.rel_path).unlink() assert _ids(client, token) == [shelf["inside"][1]] assert client.get(f"/api/public/watch/{token}/files/{gone}/file").status_code == 404 + + +def _meanwhile(work): + """Run `work(other_session)` in another thread and session, committed; (thread, outcome).""" + import threading + + from app.db import SessionLocal + + outcome: list[object] = [] + + def run(): + other = SessionLocal() + try: + outcome.append(work(other)) + other.commit() + except Exception as e: # noqa: BLE001 — the assertion below names what went wrong + outcome.append(e) + finally: + other.close() + + t = threading.Thread(target=run) + t.start() + return t, outcome + + +def test_two_opens_at_once_count_as_two_views(shelf, db): + from app.routes import public + + made = _link(shelf) + link = db.get(DownloadLink, made["id"]) + public._opened(db, link, made["token"]) # this open's count is not committed yet + t, outcome = _meanwhile( + lambda other: public._opened(other, other.get(DownloadLink, made["id"]), made["token"]) + ) + t.join(timeout=1) + db.commit() + t.join(timeout=10) + assert not isinstance(outcome[0], Exception), outcome + db.expire_all() + assert db.get(DownloadLink, made["id"]).view_count == 2 + + +def test_a_file_shared_as_it_is_deleted_takes_its_new_link_with_it(shelf, db): + from app.routes import downloads as routes + + job_id = shelf["outside"] + routes._shareable_job(db, shelf["user"], job_id) # held from the check until this commits + t, outcome = _meanwhile(lambda other: other.delete(other.get(DownloadJob, job_id))) + t.join(timeout=1) + assert t.is_alive() # the delete waits for the link on its way in + db.add(DownloadLink(job_id=job_id, token_hash="h" * 64, created_by=shelf["user"].id)) + db.commit() + t.join(timeout=10) + assert outcome == [None] + assert db.execute(select(DownloadLink.id)).scalars().all() == [] + + +def test_a_folder_shared_as_it_is_deleted_takes_its_new_link_with_it(shelf, db): + from app.routes import downloads as routes + + fid = shelf["folder"] + routes._shareable_folder(db, shelf["user"], fid) # held from the check until this commits + t, outcome = _meanwhile(lambda other: folders.delete_own(other, other.get(FileFolder, fid))) + t.join(timeout=1) + assert t.is_alive() # the delete waits for the link on its way in + db.add(DownloadLink(folder_id=fid, token_hash="h" * 64, created_by=shelf["user"].id)) + db.commit() + t.join(timeout=10) + assert not isinstance(outcome[0], Exception), outcome + assert db.execute(select(DownloadLink.id)).scalars().all() == [] From a248ca6c13f9781dacd5317b87787e13a0d94ecc Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 15 Oct 2026 05:47:00 +0200 Subject: [PATCH 09/11] fix(files): the token list is fresh after the sessions end, a copy again reads Copied again, the Copied button says so --- .../src/components/ApiTokens.dom.test.tsx | 6 ++ frontend/src/components/ApiTokens.tsx | 7 +- .../SettingsPanel.signIn.dom.test.tsx | 74 +++++++++++++++++++ frontend/src/components/SettingsPanel.tsx | 8 +- .../src/components/UploadDialog.dom.test.tsx | 14 ++++ .../components/useCopyFeedback.dom.test.ts | 12 +++ frontend/src/components/useCopyFeedback.ts | 8 +- frontend/src/i18n/locales/en/settings.json | 1 + frontend/src/i18n/locales/hu/settings.json | 1 + frontend/src/lib/downloadQueue.dom.test.ts | 8 ++ 10 files changed, 133 insertions(+), 6 deletions(-) create mode 100644 frontend/src/components/SettingsPanel.signIn.dom.test.tsx diff --git a/frontend/src/components/ApiTokens.dom.test.tsx b/frontend/src/components/ApiTokens.dom.test.tsx index 984f6763..566fac04 100644 --- a/frontend/src/components/ApiTokens.dom.test.tsx +++ b/frontend/src/components/ApiTokens.dom.test.tsx @@ -20,6 +20,7 @@ vi.mock("react-i18next", async (importOriginal) => ({ }), })); vi.mock("./confirmContext", () => ({ useConfirm: () => mocks.confirm })); +vi.mock("../lib/clipboard", () => ({ copyText: vi.fn(async () => true) })); import ApiTokens from "./ApiTokens"; @@ -192,6 +193,11 @@ describe("API tokens · making one", () => { screen.getByRole("button", { name: "settings.tokens.copyField(settings.tokens.token)" }), ).toBe(document.activeElement); screen.getByRole("button", { name: "settings.tokens.copyField(settings.tokens.tryIt)" }); + // Once copied, its name says what it shows, so a voice command saying "Copied" finds it. + fireEvent.click(document.activeElement!); + await screen.findByRole("button", { + name: "settings.tokens.copiedField(settings.tokens.token)", + }); fireEvent.click(screen.getByRole("button", { name: "settings.tokens.done" })); expect(screen.queryByRole("dialog")).toBeNull(); }); diff --git a/frontend/src/components/ApiTokens.tsx b/frontend/src/components/ApiTokens.tsx index 528567ff..628a4344 100644 --- a/frontend/src/components/ApiTokens.tsx +++ b/frontend/src/components/ApiTokens.tsx @@ -489,8 +489,11 @@ function CopyField({ size="sm" autoFocus={autoFocus} icon={copied ? : } - // Two of these sit in one dialog: each is named by the value it copies. - aria-label={t("settings.tokens.copyField", { field: label })} + // Two of these sit in one dialog: each is named by the value it copies, and by the word + // it shows, so a voice command saying what is on screen finds it. + aria-label={t(copied ? "settings.tokens.copiedField" : "settings.tokens.copyField", { + field: label, + })} onClick={() => void copy(value)} > {copied ? t("settings.tokens.copied") : t("settings.tokens.copy")} diff --git a/frontend/src/components/SettingsPanel.signIn.dom.test.tsx b/frontend/src/components/SettingsPanel.signIn.dom.test.tsx new file mode 100644 index 00000000..fa9fc99f --- /dev/null +++ b/frontend/src/components/SettingsPanel.signIn.dom.test.tsx @@ -0,0 +1,74 @@ +// @vitest-environment jsdom +// +// Ending every other session, or changing the password, revokes every API token on the server — so +// the Tokens tab must not then show them from its cache as if they still worked. +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import type { Me } from "../lib/api"; +import { qk } from "../lib/queryKeys"; + +const h = vi.hoisted(() => ({ + logoutOthers: vi.fn(async () => ({ ok: true })), + setPassword: vi.fn(async (_p: string, _c?: string) => ({ ok: true })), +})); + +vi.mock("react-i18next", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }), + }; +}); +vi.mock("../lib/api", async (importOriginal) => ({ + ...(await importOriginal()), + api: h, +})); +vi.mock("../lib/notifications", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, notify: vi.fn() }; +}); +vi.mock("./confirmContext", () => ({ useConfirm: () => vi.fn(async () => true) })); + +import { SignInMethods } from "./SettingsPanel"; + +afterEach(cleanup); + +const me = { email: "a@example.com", has_password: true, google_enabled: false } as Me; + +function renderIt() { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + qc.setQueryData(qk.apiTokens(), [{ id: 1 }]); + render( + + + , + ); + return qc; +} + +const tokensStale = (qc: QueryClient) => qc.getQueryState(qk.apiTokens())?.isInvalidated; + +describe("SignInMethods", () => { + it("signing the other sessions out marks the token list stale", async () => { + const qc = renderIt(); + fireEvent.click(screen.getByText("settings.account.sessions.action")); + await waitFor(() => expect(h.logoutOthers).toHaveBeenCalled()); + await waitFor(() => expect(tokensStale(qc)).toBe(true)); + }); + + it("a password changed marks the token list stale", async () => { + const qc = renderIt(); + fireEvent.click(screen.getByText("settings.account.password.change")); + fireEvent.change(screen.getByPlaceholderText("settings.account.password.current"), { + target: { value: "old one" }, + }); + fireEvent.change(screen.getByPlaceholderText("settings.account.password.new"), { + target: { value: "a brand new one" }, + }); + fireEvent.click(screen.getByText("settings.account.password.save")); + await waitFor(() => expect(h.setPassword).toHaveBeenCalled()); + await waitFor(() => expect(tokensStale(qc)).toBe(true)); + }); +}); diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx index ec72bbc7..a67c1b0e 100644 --- a/frontend/src/components/SettingsPanel.tsx +++ b/frontend/src/components/SettingsPanel.tsx @@ -361,7 +361,7 @@ function AccessRow({ // so either method can reach the same account. Google connect is a full-page OAuth round-trip // (auth.py attaches the identity to the current session via /auth/link); the password form posts // directly with inline errors. -function SignInMethods({ me }: { me: Me }) { +export function SignInMethods({ me }: { me: Me }) { const { t } = useTranslation(); const qc = useQueryClient(); const confirm = useConfirm(); @@ -387,6 +387,8 @@ function SignInMethods({ me }: { me: Me }) { setSessBusy(true); try { await api.logoutOthers(); + // Every API token went with the other sessions: the Tokens tab must not show them from cache. + void qc.invalidateQueries({ queryKey: qk.apiTokens() }); notify({ level: "success", message: t("settings.account.sessions.done") }); } catch (e) { notifyActionError(e, t("settings.account.sessions.failed")); @@ -404,7 +406,9 @@ function SignInMethods({ me }: { me: Me }) { setOpen(false); setCurrent(""); setNext(""); - // Refresh `me` so has_password flips and the section switches to "Change password". + // Refresh `me` so has_password flips and the section switches to "Change password". Every API + // token went with the old password, so the Tokens tab must not show them from cache either. + void qc.invalidateQueries({ queryKey: qk.apiTokens() }); await qc.invalidateQueries({ queryKey: qk.me() }); notify({ level: "success", diff --git a/frontend/src/components/UploadDialog.dom.test.tsx b/frontend/src/components/UploadDialog.dom.test.tsx index 61b94203..ef1762fa 100644 --- a/frontend/src/components/UploadDialog.dom.test.tsx +++ b/frontend/src/components/UploadDialog.dom.test.tsx @@ -122,6 +122,20 @@ describe("UploadDialog", () => { expect(h.uploadEnded).toHaveBeenCalledWith(null); }); + it("an upload that failed or was cut off still says it ended, so later finishes are announced", async () => { + h.uploadFile.mockRejectedValueOnce(new Error("network")); + h.uploadStarted.mockClear(); + h.uploadEnded.mockClear(); + wrap(); + fireEvent.change(fileInput(), { + target: { files: [new File(["x"], "clip.mp4", { type: "video/mp4" })] }, + }); + fireEvent.click(screen.getByText("downloads.upload.submit")); + await waitFor(() => expect(h.uploadEnded).toHaveBeenCalledTimes(1)); + expect(h.uploadEnded).toHaveBeenCalledWith(null); + expect(h.uploadStarted).toHaveBeenCalledTimes(1); + }); + it("puts the file in the folder it was opened from, and its notice opens where it now is", async () => { // Done: Files opens on the file itself, in its folder. Still converting: the tray opens on it. h.uploadFile.mockResolvedValueOnce({ id: 42, status: "done", folder_id: 5 } as never); diff --git a/frontend/src/components/useCopyFeedback.dom.test.ts b/frontend/src/components/useCopyFeedback.dom.test.ts index 62bc475b..c2be1ee7 100644 --- a/frontend/src/components/useCopyFeedback.dom.test.ts +++ b/frontend/src/components/useCopyFeedback.dom.test.ts @@ -32,6 +32,18 @@ describe("useCopyFeedback", () => { expect(result.current.copied).toBe(false); }); + it("a copy again within the moment reads Copied for a whole moment again", async () => { + vi.useFakeTimers(); + const { result } = renderHook(() => useCopyFeedback()); + await act(() => result.current.copy("sfl_x")); + act(() => void vi.advanceTimersByTime(1500)); + await act(() => result.current.copy("sfl_x")); + act(() => void vi.advanceTimersByTime(1000)); // past the first copy's moment + expect(result.current.copied).toBe(true); + act(() => void vi.advanceTimersByTime(1000)); + expect(result.current.copied).toBe(false); + }); + it("a copy the clipboard refused is a warning, and nothing reads Copied", async () => { h.copyText.mockResolvedValueOnce(false); const { result } = renderHook(() => useCopyFeedback()); diff --git a/frontend/src/components/useCopyFeedback.ts b/frontend/src/components/useCopyFeedback.ts index 161ca6ce..6e05f60e 100644 --- a/frontend/src/components/useCopyFeedback.ts +++ b/frontend/src/components/useCopyFeedback.ts @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { copyText } from "../lib/clipboard"; @@ -17,11 +17,15 @@ const COPIED_MS = 2000; export function useCopyFeedback(): { copied: boolean; copy: (value: string) => Promise } { const { t } = useTranslation(); const [copied, setCopied] = useState(false); + // One reset at a time: a copy again within the moment starts it over, not cut short by the last. + const timer = useRef(undefined); + useEffect(() => () => window.clearTimeout(timer.current), []); const copy = async (value: string) => { if (await copyText(value)) { setCopied(true); notify({ level: "success", message: t("common.copy.done") }); - window.setTimeout(() => setCopied(false), COPIED_MS); + window.clearTimeout(timer.current); + timer.current = window.setTimeout(() => setCopied(false), COPIED_MS); return; } notify({ level: "warning", message: t("common.copy.failed") }); diff --git a/frontend/src/i18n/locales/en/settings.json b/frontend/src/i18n/locales/en/settings.json index 8aee5fda..0475ae82 100644 --- a/frontend/src/i18n/locales/en/settings.json +++ b/frontend/src/i18n/locales/en/settings.json @@ -285,6 +285,7 @@ "copy": "Copy", "copied": "Copied", "copyField": "Copy: {{field}}", + "copiedField": "Copied: {{field}}", "done": "Done" } } diff --git a/frontend/src/i18n/locales/hu/settings.json b/frontend/src/i18n/locales/hu/settings.json index d9623852..767c6174 100644 --- a/frontend/src/i18n/locales/hu/settings.json +++ b/frontend/src/i18n/locales/hu/settings.json @@ -285,6 +285,7 @@ "copy": "Másolás", "copied": "Másolva", "copyField": "Másolás: {{field}}", + "copiedField": "Másolva: {{field}}", "done": "Kész" } } diff --git a/frontend/src/lib/downloadQueue.dom.test.ts b/frontend/src/lib/downloadQueue.dom.test.ts index 00d8db38..ea491c73 100644 --- a/frontend/src/lib/downloadQueue.dom.test.ts +++ b/frontend/src/lib/downloadQueue.dom.test.ts @@ -72,6 +72,14 @@ describe("downloadQueue · a finish", () => { expect(takeUnannounced().map((j) => j.id)).toEqual([9]); }); + it("a download finishing during an upload is announced at once, not held for the upload", () => { + noteQueue([]); + uploadStarted(); + noteQueue([job(8, "done")]); + expect(takeUnannounced().map((j) => j.id)).toEqual([8]); + uploadEnded(null); + }); + it("an upload that failed announced nothing: what landed meanwhile is announced", () => { noteQueue([]); uploadStarted(); From 27427eef32f1db260adefea5b936ded73d990e04 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 15 Oct 2026 06:05:51 +0200 Subject: [PATCH 10/11] fix(files): ending every session and making a token lock the user without holding up rows that refer to it --- backend/app/routes/auth.py | 6 +- backend/app/routes/tokens.py | 5 +- backend/tests/test_api_tokens.py | 73 ++++++++++++++++++++++- frontend/src/components/SettingsPanel.tsx | 5 +- 4 files changed, 81 insertions(+), 8 deletions(-) diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py index 65edaf05..f9b40dfe 100644 --- a/backend/app/routes/auth.py +++ b/backend/app/routes/auth.py @@ -1150,12 +1150,14 @@ def bump_session_epoch(db: Session, user: User) -> None: outlive the answer to that theft. Not committed — the caller commits it with its change. The user's row is locked before the tokens go, the lock `create_token` takes too: a token made at - the same moment either commits first and is deleted here, or waits and sees the new epoch.""" + the same moment either commits first and is deleted here, or waits and sees the new epoch. FOR NO + KEY UPDATE (`key_share`): the two still exclude each other, but a row that only refers to the user + (a notice, an audit line) is not held up — a token's expiry notice would deadlock with it.""" if user.id is None: user.session_epoch = (user.session_epoch or 0) + 1 return epoch = db.execute( - select(User.session_epoch).where(User.id == user.id).with_for_update() + select(User.session_epoch).where(User.id == user.id).with_for_update(key_share=True) ).scalar_one() user.session_epoch = (epoch or 0) + 1 apitokens.revoke_all(db, user.id) diff --git a/backend/app/routes/tokens.py b/backend/app/routes/tokens.py index d98f570d..40e3edc2 100644 --- a/backend/app/routes/tokens.py +++ b/backend/app/routes/tokens.py @@ -122,9 +122,10 @@ def create_token( raise refused(400, "api_token_name_required") # The user's row, locked: two requests at once cannot both slip under the ceiling, and a # session ended since `current_user` read the epoch (unlocked) cannot mint a token that outlives - # it — `bump_session_epoch` takes this lock before it revokes them all. + # it — `bump_session_epoch` takes this lock before it revokes them all. No key update: a row that + # only refers to the user (a folder delete's audit line) must not wait on it, or the two deadlock. epoch = db.execute( - select(User.session_epoch).where(User.id == user.id).with_for_update() + select(User.session_epoch).where(User.id == user.id).with_for_update(key_share=True) ).scalar_one() if (epoch or 0) != (user.session_epoch or 0): raise refused(401, "not_authenticated") diff --git a/backend/tests/test_api_tokens.py b/backend/tests/test_api_tokens.py index 64023c70..aa7e64c9 100644 --- a/backend/tests/test_api_tokens.py +++ b/backend/tests/test_api_tokens.py @@ -12,9 +12,9 @@ from datetime import datetime, timedelta, timezone import pytest from fastapi import Depends, Request from fastapi.testclient import TestClient -from sqlalchemy import select +from sqlalchemy import select, update -from app import apitokens +from app import apitokens, audit from app.apitokens import files_caller from app.auth import current_user from app.downloads import folders, service @@ -24,6 +24,7 @@ from app.downloads.status import JobStatus from app.config import settings from app.db import SessionLocal, get_db from app.main import app +from app.notifications import create_notification from app.models import ( ApiToken, ApiTokenGrant, @@ -427,6 +428,74 @@ def test_a_token_asked_for_as_every_session_ends_is_refused(db, make_user, clien assert db.execute(select(ApiToken.id)).scalars().all() == [] +def _meanwhile(work): + """Run `work(other_session)` in another thread and session, committed; (thread, outcome).""" + import threading + + outcome: list[object] = [] + + def run(): + other = SessionLocal() + try: + outcome.append(work(other)) + other.commit() + except Exception as e: # noqa: BLE001 — the assertion below names what went wrong + outcome.append(e) + finally: + other.close() + + t = threading.Thread(target=run) + t.start() + return t, outcome + + +def test_ending_every_session_never_deadlocks_with_a_tokens_expiry_notice(db, make_user): + """The notice's sweep holds the token's row and then writes a row naming the user; the sessions' + end holds the user and then deletes the token. A lock on the user that a mere reference to it + waits on would make that a deadlock — one of the two failing.""" + alice = make_user() + raw = apitokens.mint() + soon = datetime.now(timezone.utc) + timedelta(days=1) + tok = ApiToken( + user_id=alice.id, name="t", prefix=raw[:12], token_hash=hash_token(raw), expires_at=soon + ) + db.add(tok) + db.commit() + # `warn_expiring`, its two halves held apart: the token row taken… + db.execute(update(ApiToken).where(ApiToken.id == tok.id).values(expiry_warned=True)) + t, outcome = _meanwhile(lambda other: bump_session_epoch(other, other.get(User, alice.id))) + t.join(timeout=1) + assert t.is_alive() # the sessions' end waits for the token row + # …and then its notice, naming the user. + create_notification(db, alice.id, "api_token_expiring", title="t", commit=False) + db.commit() + t.join(timeout=10) + assert outcome == [None] + db.expire_all() + assert db.execute(select(ApiToken.id)).scalars().all() == [] + + +def test_a_token_granted_a_folder_being_deleted_never_deadlocks(db, make_user): + """The delete holds the folder and then writes its audit line naming the user; the token holds + the user and then waits for the folder.""" + alice = make_user() + fid = _own(db, alice.id, "Mine").id + folders.delete_own(db, db.get(FileFolder, fid)) # the folder held, as the delete route does + body = tokens_routes.TokenIn( + name="x", grants=[tokens_routes.GrantIn(folder_id=fid, rights=["read"])] + ) + t, outcome = _meanwhile( + lambda other: tokens_routes.create_token(body, other.get(User, alice.id), other) + ) + t.join(timeout=1) + assert t.is_alive() # the token waits for the folder + audit.record(db, alice.id, "file_folder_delete", target_type="file_folder", target_id=fid) + db.commit() + t.join(timeout=10) + [refusal] = outcome + assert getattr(refusal, "status_code", None) == 409, refusal # the folder went first + + def test_a_token_made_as_every_session_ends_goes_with_them(db, make_user): """The other order: the token is on its way in when the sessions end.""" import threading diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx index a67c1b0e..e2995d8d 100644 --- a/frontend/src/components/SettingsPanel.tsx +++ b/frontend/src/components/SettingsPanel.tsx @@ -387,7 +387,8 @@ export function SignInMethods({ me }: { me: Me }) { setSessBusy(true); try { await api.logoutOthers(); - // Every API token went with the other sessions: the Tokens tab must not show them from cache. + // Every API token went with the other sessions: the Tokens tab asks again when it next opens, + // rather than trusting its cached list for another half minute. void qc.invalidateQueries({ queryKey: qk.apiTokens() }); notify({ level: "success", message: t("settings.account.sessions.done") }); } catch (e) { @@ -407,7 +408,7 @@ export function SignInMethods({ me }: { me: Me }) { setCurrent(""); setNext(""); // Refresh `me` so has_password flips and the section switches to "Change password". Every API - // token went with the old password, so the Tokens tab must not show them from cache either. + // token went with the old password, so the Tokens tab asks again when it next opens, too. void qc.invalidateQueries({ queryKey: qk.apiTokens() }); await qc.invalidateQueries({ queryKey: qk.me() }); notify({ From 1673134c36b7e97498ab6495e81d113f2398765d Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 15 Oct 2026 06:09:23 +0200 Subject: [PATCH 11/11] =?UTF-8?q?chore(release):=200.107.0=20=E2=80=94=20A?= =?UTF-8?q?PI=20tokens=20for=20your=20machines,=20share=20a=20whole=20fold?= =?UTF-8?q?er=20by=20link?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 11 +++++++++++ VERSION | 2 +- frontend/src/content/release-notes/en.json | 9 +++++++++ frontend/src/content/release-notes/hu.json | 11 +++++++++++ 4 files changed, 32 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29c53783..40c02ee2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ edit the release notes and re-run the generator. Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading). +## 0.107.0 — 2026-10-15 + +### New + +- API tokens: Settings › API tokens gives one machine of yours a key to the Files folders you pick — or, in one click, to a Hub folder for swapping files with it. Signing out your other sessions or changing your password revokes every token. +- Share by link: a link can now open a whole folder of yours — its page lists what is in it and plays a video above the list. + +### Fixed + +- Uploads: a finished upload is announced once, and shows in Files at once. + ## 0.106.2 — 2026-10-14 ### Fixed diff --git a/VERSION b/VERSION index f18da326..a9a7f3fe 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.106.2 +0.107.0 diff --git a/frontend/src/content/release-notes/en.json b/frontend/src/content/release-notes/en.json index ecb55922..34f2ab8e 100644 --- a/frontend/src/content/release-notes/en.json +++ b/frontend/src/content/release-notes/en.json @@ -1,4 +1,13 @@ [ + { + "version": "0.107.0", + "date": "2026-10-15", + "new": [ + "API tokens: Settings › API tokens gives one machine of yours a key to the Files folders you pick — or, in one click, to a Hub folder for swapping files with it. Signing out your other sessions or changing your password revokes every token.", + "Share by link: a link can now open a whole folder of yours — its page lists what is in it and plays a video above the list." + ], + "fixed": ["Uploads: a finished upload is announced once, and shows in Files at once."] + }, { "version": "0.106.2", "date": "2026-10-14", diff --git a/frontend/src/content/release-notes/hu.json b/frontend/src/content/release-notes/hu.json index d138551d..31388b1b 100644 --- a/frontend/src/content/release-notes/hu.json +++ b/frontend/src/content/release-notes/hu.json @@ -1,4 +1,15 @@ [ + { + "version": "0.107.0", + "date": "2026-10-15", + "new": [ + "API-tokenek: a Beállítások › API-tokenek alatt egy gépednek kulcsot adhatsz a kiválasztott Fájlok-mappáidhoz — vagy egy kattintással egy Hub mappához, amelyen át fájlokat cserélhettek. A többi munkamenet kiléptetése vagy jelszócsere minden tokent visszavon.", + "Megosztás linkkel: egy link mostantól egy egész saját mappát is megnyithat — az oldala felsorolja, mi van benne, és a lista fölött lejátssza a videót." + ], + "fixed": [ + "Feltöltés: egy kész feltöltésről egyszer jön értesítés, és azonnal megjelenik a Fájlokban." + ] + }, { "version": "0.106.2", "date": "2026-10-14",