Compare commits

..
23 Commits
Author SHA1 Message Date
peter 2b6d968833 release: 0.107.1 (improvement/gate-timing) 2026-10-16 00:45:45 +02:00
peter 95e6b2c3d9 chore(release): 0.107.1 — a failed download's explanation says press Retry 2026-10-16 00:33:59 +02:00
peter 80c55fbf1b merge bug/download-retry-copy into improvement/gate-timing 2026-10-16 00:33:38 +02:00
peter 011f269152 chore(gate): every lane reports how long it took, and a run can log the times for comparison 2026-10-16 00:13:47 +02:00
peter dcb244dd04 fix(downloads): a failed download's explanation says press Retry, not retry it from a list that is gone 2026-10-15 22:56:40 +02:00
peter 08a9f4e255 release: 0.107.0 (feature/e39b-machine-access) 2026-10-15 06:20:35 +02:00
peter 1673134c36 chore(release): 0.107.0 — API tokens for your machines, share a whole folder by link 2026-10-15 06:09:23 +02:00
peter 27427eef32 fix(files): ending every session and making a token lock the user without holding up rows that refer to it 2026-10-15 06:05:51 +02:00
peter a248ca6c13 fix(files): the token list is fresh after the sessions end, a copy again reads Copied again, the Copied button says so 2026-10-15 05:47:00 +02:00
peter 620f84dba4 fix(files): a token made as every session ends goes with them, and a file being shared holds its job 2026-10-15 05:47:00 +02:00
peter 2efa7d5a75 fix(files): an upload is said once in any order, copy buttons speak, a link list that failed says so
- The queue holds an upload it sees land done while this tab's upload still waits for its answer, so
  the dialog's notice is the only one whichever comes first; another device's upload follows after.
- One `useCopyFeedback` for every Copy beside a value (token, share link, set-password link): Copied
  for a moment, a notice a screen reader hears, a warning when the clipboard refuses; the token
  dialog's two Copy buttons are named by what they copy.
- The share dialog's link list shows loading, and a load error with Retry, never "no links" — and no
  New link beside a list it has not seen.
- The settings texts say that signing out elsewhere or a password change revokes the API tokens.
- DESIGN.md logs the folder-link decisions; the design lab keeps only the chosen renderings of the
  Links, Tokens and Files rounds.
2026-10-15 04:58:17 +02:00
peter 827df7494c fix(files): ending every session ends every API token, and the share links hold their folder
- A password reset or change, "log out everywhere" and a revoked untrusted password revoke all of the
  account's API tokens too (audited): one minted from a stolen session no longer outlives the answer.
- A bearer token or link grant with a non-ASCII character is refused (401/403), not a 500.
- Making a token's grants or a folder link holds the folder, so a racing delete cannot fail the
  foreign key; a link's view count is one atomic UPDATE.
- A folder link's card counts exactly the files its page lists (one `links.folder_files`).
- A file with no extension is served without a trailing dot.
- 0106's downgrade names a Hub folder so it never collides with one of the user's folders.
- The speed baseline reads share tokens through the app (the database keeps them hashed/encrypted).
- Tests: the token scope on the real session stack, the folder poster, the Hub refused for a link,
  0106's downgrade data step, 0107 without a key; docs/comments follow (three folder kinds, folder
  links, key change, the cleanup job's token warnings).
2026-10-15 04:58:16 +02:00
peter 86712b6b0e feat(files): share a folder by link — its page lists what is in it, plays a video above the list
A /watch link may now open one of its owner's own folders (migration 0108: folder_id beside job_id,
exactly one set; a folder's links go with it). The page shows what is in the folder when it is
opened, newest first: a video or a song plays in the same player a file link has, above the list,
each keeping its own resume point; a document only downloads, never opens as a page. A file moved
out stops opening through the link at its next request. Password, expiry and stream-only work as on
a file link; the unfurl names the folder and counts its files, never their names. Source folders
and the Hub cannot be shared.

The folder menu gets "Copy link" and "Share with a link…" (the Share dialog's link half); the rail
marks a folder a live link opens.

Two fixes in the Share dialog on the way: revoking the last link no longer mints a new one at once,
and the new-link form goes back to downloadable after making a link.
2026-10-15 02:23:24 +02:00
peter 90567718b2 feat(files): share-link tokens are kept only hashed and encrypted, and every link change is audited
A /watch link's token is the credential, and it sat in the database as written. Migration 0107
replaces the column with the token's SHA-256 (the lookup) and a Fernet-encrypted copy (so the owner
can copy the same link again); every existing address keeps working, and the downgrade decrypts
them back. Without a copy this key can read, a link still opens but its address cannot be shown:
"Copy link" then takes the newest link that has one or makes a new one, and the Share dialog says
so in that row instead of a broken URL. Making, changing and revoking a link are audited, never
with its token or password.
2026-10-15 01:02:29 +02:00
peter c31d7f9d65 fix(files): an upload is announced once, as an upload, and a finish shows in Files at once
An upload that came back done was announced twice: by the upload dialog ("File uploaded") and
again by the tray, which saw a job turn done ("Downloaded: …"). The dialog now marks its job as
announced, so the queue keeps it for the tray but does not say it again. An upload the worker
converted first is still the tray's to announce, now as "Uploaded: …".

A finished job's notice offered "Open" while Files still listed the folder without it (its own
poll runs every 15 s): the tray now refreshes the library the moment it sees a finish.
2026-10-14 23:54:19 +02:00
peter db56f25387 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.
2026-10-14 23:33:34 +02:00
peter 4f58d15d3f Merge branch 'main' into feature/e39b-machine-access 2026-10-14 21:19:14 +02:00
peter 2c66ce1cf4 release: 0.106.2 (improvement/thumbs-and-apt-refresh) 2026-10-14 21:17:39 +02:00
peter ce45445d17 chore(release): 0.106.2 — pictures that fail to load retry instead of breaking 2026-10-14 21:06:18 +02:00
peter 383d394e32 fix(ui): a picture that fails to load shows the card's own frame, then retries
A bare <img> sent a Referer that Google's image CDNs refuse and had no retry and no fallback, so a
Home shelf card showed the browser's broken-image mark. ui/RemoteImage gathers what Avatar, the
channel banner and the download rows had each hand-rolled: no Referer, up to three retries past the
browser's negative cache, the caller's fallback (by default the card's empty frame) instead of the
broken mark, the failure remembered by URL. Every picture now goes through it; a lint rule refuses
a bare <img>.
2026-10-14 20:51:08 +02:00
peter 06e8291896 build(image): rebuild the apt-get upgrade layer when APT_REFRESH changes
A build cache kept the upgrade layer for as long as nothing above it changed, so a publish shipped
curl and librsvg a release behind fixes already in the Debian archive (the CVE gate refused it).
The release build passes the day; empty, the default for a self-hosted build, leaves the cache alone.
2026-10-14 20:51:07 +02:00
peter db6b60029f Merge branch 'main' into feature/e39b-machine-access 2026-10-14 20:17:05 +02:00
peter b2d34e6e6c feat(files): API tokens — a machine reaches the library folder by folder
A user can make a token for a machine of theirs (`/api/tokens`, signed-in only): `sfl_` + 32
random bytes + a checksum, only its SHA-256 kept, shown once. Sent as a Bearer header it is
honoured on six `/api/files` routes only — folders, list, file, hash, upload, delete — and there
only in the folders its grants name, with the rights they give (list / read / write / delete).
One check, `FilesCaller.may`, in every route; a folder or file out of reach answers like a
missing one; the files shared with the user are never in reach. A token beats a cookie on the
same request.

The Hub preset makes the user's one Hub folder (a new folder kind) and grants it every right.
Refused tokens are slowed down per client, a runaway machine per token; last use is stamped at
most once a minute; making and revoking a token is audited, and a token's uploads and deletes
name it in their audit rows.
2026-10-14 18:40:23 +02:00
132 changed files with 6733 additions and 1169 deletions
+23
View File
@@ -7,6 +7,29 @@ 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.1 — 2026-10-16
### Fixed
- Downloads: a failed download's explanation now says to press Retry, instead of pointing to a download list that no longer exists.
## 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
- Pictures: a thumbnail, poster or channel picture that fails to load is retried, and meanwhile the card shows its empty frame instead of a broken-image mark.
## 0.106.1 — 2026-10-14
### Fixed
+2 -2
View File
@@ -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
@@ -114,7 +114,7 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory
Not repeated here, because a lint rule refuses the wrong one and its message names the right one:
buttons (`ui/Button`, `IconButton`, `Pressable`, `Chip`, `DragHandle`), text fields and native
controls (`ui/Input`, `Checkbox`, `Slider`, …), glass surfaces (`SURFACE.*`), a native `<select>`
(`SegmentedToggle` / `FilterSelect`), and network calls (`req` / `uploadReq` / `beacon`, `lib/http.ts`).
(`SegmentedToggle` / `FilterSelect`), a picture (`ui/RemoteImage`, never a bare `<img>`), and network calls (`req` / `uploadReq` / `beacon`, `lib/http.ts`).
## House rules that bite (learned from real bugs)
+7 -1
View File
@@ -55,8 +55,14 @@ RUN adduser --disabled-password --gecos "" appuser \
# token, else only storyboards are offered). yt-dlp auto-detects deno on PATH.
# `apt-get upgrade`: the base image is a snapshot, and the Debian security fixes released since it
# was cut are only in the archive — the first Trivy scan found one (pcre2) fixed upstream but not in it.
# APT_REFRESH: the upgrade only runs when this layer is rebuilt, and a build cache keeps it for as long
# as nothing above it changes — a publish shipped an image whose curl and librsvg were a release behind
# fixes already in the archive. The release build passes the day here, so the layer is rebuilt at
# least daily; empty (the default, a self-hosted build) leaves the cache alone.
ARG DENO_VERSION=v2.9.1
RUN apt-get update \
ARG APT_REFRESH=
RUN echo "apt refresh: ${APT_REFRESH:-cached}" \
&& apt-get update \
&& apt-get upgrade -y --no-install-recommends \
&& apt-get install -y --no-install-recommends ffmpeg intel-media-va-driver unzip curl ca-certificates \
&& curl -fsSL "https://github.com/denoland/deno/releases/download/${DENO_VERSION}/deno-x86_64-unknown-linux-gnu.zip" -o /tmp/deno.zip \
+1 -1
View File
@@ -1 +1 @@
0.106.1
0.107.1
+110
View File
@@ -0,0 +1,110 @@
"""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, 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.
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("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
),
)
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,
# named "Hub <id>" — or "Hub <id> (2)", … when its user already has a folder of that name,
# which `uq_file_folder_own_name` (per user, case-blind) would refuse.
conn = op.get_bind()
hubs = conn.execute(sa.text("SELECT id, user_id FROM file_folders WHERE kind = 'hub'")).all()
for folder_id, user_id in hubs:
name, n = f"Hub {folder_id}", 1
while conn.execute(
sa.text("SELECT 1 FROM file_folders WHERE user_id = :u AND lower(name) = lower(:n)"),
{"u": user_id, "n": name},
).first():
n += 1
name = f"Hub {folder_id} ({n})"
conn.execute(
sa.text("UPDATE file_folders SET kind = 'own', name = :n WHERE id = :i"),
{"n": name, "i": folder_id},
)
op.drop_constraint("ck_file_folder_kind", "file_folders", type_="check")
op.create_check_constraint("ck_file_folder_kind", "file_folders", _KIND_OLD)
@@ -0,0 +1,66 @@
"""Share-link tokens no longer stored in the clear
Revision ID: 0107_link_token_at_rest
Revises: 0106_api_tokens
Create Date: 2026-10-15
A `/watch` link's token is the credential itself, and it sat in `download_links.token` as written,
so a copy of the database was every shared file. Now a link is found by the token's SHA-256
(`token_hash`), and the token is kept only Fernet-encrypted (`token_enc`, TOKEN_ENCRYPTION_KEY) so its
owner can copy the same link again. Without a key the copy is not kept: the link still opens, its
address just cannot be shown again (`app/downloads/links.py`).
Every existing link is converted here, so the addresses already sent keep working. The downgrade
writes the tokens back from their encrypted copies; a link with none left (no key, or a key since
changed) gets a fresh random token, which is the same as revoking it.
"""
import hashlib
import secrets
import sqlalchemy as sa
from alembic import op
from app import security
revision: str = "0107_link_token_at_rest"
down_revision: str | None = "0106_api_tokens"
branch_labels: str | None = None
depends_on: str | None = None
def upgrade() -> None:
op.add_column("download_links", sa.Column("token_hash", sa.String(64)))
op.add_column("download_links", sa.Column("token_enc", sa.Text()))
conn = op.get_bind()
sealing = security.can_encrypt()
for link_id, token in conn.execute(sa.text("SELECT id, token FROM download_links")).fetchall():
conn.execute(
sa.text("UPDATE download_links SET token_hash = :h, token_enc = :e WHERE id = :id"),
{
"id": link_id,
"h": hashlib.sha256(token.encode()).hexdigest(),
"e": security.encrypt(token) if sealing else None,
},
)
op.alter_column("download_links", "token_hash", nullable=False)
op.create_unique_constraint("uq_download_link_token_hash", "download_links", ["token_hash"])
op.drop_constraint("uq_download_link_token", "download_links", type_="unique")
op.drop_column("download_links", "token")
def downgrade() -> None:
op.add_column("download_links", sa.Column("token", sa.String(64)))
conn = op.get_bind()
rows = conn.execute(sa.text("SELECT id, token_enc FROM download_links")).fetchall()
for link_id, enc in rows:
token = security.decrypt_optional(enc) or secrets.token_urlsafe(24)
conn.execute(
sa.text("UPDATE download_links SET token = :t WHERE id = :id"),
{"id": link_id, "t": token},
)
op.alter_column("download_links", "token", nullable=False)
op.create_unique_constraint("uq_download_link_token", "download_links", ["token"])
op.drop_constraint("uq_download_link_token_hash", "download_links", type_="unique")
op.drop_column("download_links", "token_enc")
op.drop_column("download_links", "token_hash")
@@ -0,0 +1,40 @@
"""A share link may open a folder
Revision ID: 0108_folder_links
Revises: 0107_link_token_at_rest
Create Date: 2026-10-15
A `/watch` link opened one file. Now it may open one of its owner's own folders instead: the link's
page lists what is in the folder when it is opened. `folder_id` sits beside `job_id`, exactly one of
the two set; a folder's links go with the folder.
The downgrade has nowhere to keep a folder link, so it deletes them: they stop opening.
"""
import sqlalchemy as sa
from alembic import op
revision: str = "0108_folder_links"
down_revision: str | None = "0107_link_token_at_rest"
branch_labels: str | None = None
depends_on: str | None = None
def upgrade() -> None:
op.add_column(
"download_links",
sa.Column("folder_id", sa.Integer(), sa.ForeignKey("file_folders.id", ondelete="CASCADE")),
)
op.create_index("ix_download_links_folder_id", "download_links", ["folder_id"])
op.alter_column("download_links", "job_id", nullable=True)
op.create_check_constraint(
"ck_download_link_target", "download_links", "(job_id IS NULL) <> (folder_id IS NULL)"
)
def downgrade() -> None:
op.execute("DELETE FROM download_links WHERE folder_id IS NOT NULL")
op.drop_constraint("ck_download_link_target", "download_links", type_="check")
op.alter_column("download_links", "job_id", nullable=False)
op.drop_index("ix_download_links_folder_id", table_name="download_links")
op.drop_column("download_links", "folder_id")
+2 -1
View File
@@ -20,7 +20,8 @@ Three layers, by *who* runs the code — not by feature:
run by both the admin route and the scheduler; `downloads/pipeline.py` is the worker's child pipeline;
`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
+273
View File
@@ -0,0 +1,273 @@
"""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. 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
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 delete, or_, select, update
from sqlalchemy.orm import Session
from app import audit, debugsession, logs
from app.audit import AuditAction
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__)
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)
# 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):
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:]
# The check too: compare_digest refuses (TypeError, a 500) a str with anything non-ASCII in it.
if any(c not in _ALPHABET for c in body + check):
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
def revoke_all(db: Session, user_id: int) -> int:
"""Revoke every token of `user_id`, each with its audit row (NOT committed). A token is a
credential like a session, so whatever ends every session of an account — a password reset or
change, "log out everywhere", a revoked untrusted password — ends its tokens too: one minted
from a stolen session must not outlive the owner's answer to that theft. Returns how many."""
gone = (
db.execute(delete(ApiToken).where(ApiToken.user_id == user_id).returning(ApiToken.id))
.scalars()
.all()
)
for token_id in gone:
audit.record(
db,
user_id,
AuditAction.API_TOKEN_REVOKE,
target_type="api_token",
target_id=token_id,
summary="revoked with every session of the account",
)
return len(gone)
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)
+10
View File
@@ -69,6 +69,16 @@ 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"
# A public `/watch` link to a file or a folder (`routes.downloads`): made, its settings changed,
# revoked.
# Never its token or password — only whether it has one.
DOWNLOAD_LINK_CREATE = "download_link_create"
DOWNLOAD_LINK_UPDATE = "download_link_update"
DOWNLOAD_LINK_REVOKE = "download_link_revoke"
def record(
+33 -7
View File
@@ -1,20 +1,23 @@
"""The folders of the Files library — where each of a user's files sits.
A folder is a row, never a directory: on disk a file stays where the download layout put it (one
shared asset can be held by several users), so a rename or a move is a column update. Two kinds:
shared asset can be held by several users), so a rename or a move is a column update. Three kinds:
- a SOURCE folder is made automatically, one per origin a user has files from (YouTube, Plex clips,
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
moves only into one of the user's own folders or back to its own source folder (`move`): a YouTube
file in the TikTok folder would be a lie the folder's name tells.
moves only into one of the user's own folders, their Hub folder, or back to its own source folder
(`move`): a YouTube file in the TikTok folder would be a lie the folder's name tells.
Retention is the folder's: a source folder keeps a file `download_retention_days` (the value when the
folder was made; the user can change it), Uploads and own folders keep it until it is deleted. A
folder was made; the user can change it), Uploads, own folders and the Hub keep it until it is deleted. A
done file carries its own `expires_at`, stamped by `done_expiry` when it finishes — unless the user
kept it — and its `ready_at`, the moment it finished (`stamp_done`).
"""
@@ -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."""
@@ -178,8 +202,9 @@ def own_folder(db: Session, user_id: int, folder_id: int, *, hold: bool = True)
def upload_target(db: Session, user_id: int, folder_id: int, *, hold: bool = True) -> FileFolder:
"""The folder an upload asked to land in, if it may: one of the user's own folders, or their
Uploads folder. A file put in a site's folder would claim an origin it does not have."""
"""The folder an upload asked to land in, if it may: one of the user's own folders, their Hub
folder, or their Uploads folder. A file put in a site's folder would claim an origin it does not
have."""
folder = own_folder(db, user_id, folder_id, hold=hold)
if folder.kind == FolderKind.SOURCE and folder.source_key != SourceKey.UPLOAD:
raise FolderError("file_folder_not_its_source")
@@ -201,7 +226,8 @@ def set_kept(db: Session, job: DownloadJob, kept: bool) -> None:
def move(db: Session, job: DownloadJob, folder: FileFolder) -> None:
"""Put a file in `folder` — one of its user's own folders, or the source folder it came from.
"""Put a file in `folder` — one of its user's own folders, their Hub folder, or the source folder
it came from.
The caller has checked that `folder` is the job's user's."""
if folder.kind == FolderKind.SOURCE and folder.source_key != source_key_for(db, job):
raise FolderError("file_folder_not_its_source")
+8 -2
View File
@@ -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
+75 -11
View File
@@ -1,10 +1,15 @@
"""Public "share by link" helpers for the download center.
A DownloadLink is a capability URL: the unguessable `token` grants anyone read access to one
download's file on the login-free `/watch/{token}` page. Password-protected links additionally
require a short-lived signed **grant** on the file request — because a `<video src>` can't send a
header, the password is exchanged once at `/unlock` for an HMAC grant appended to the file URL,
so the raw password never rides in the media URL (or server logs).
A DownloadLink is a capability URL: the unguessable token grants anyone read access to one
download's file, or to the files in one of its owner's own folders (`folder_files`), on the
login-free `/watch/{token}` page. So the token is never stored as written:
the link is found by its SHA-256, and the owner's copy is Fernet-encrypted — a database copy alone
opens nothing. Without that copy (no TOKEN_ENCRYPTION_KEY, or a key since changed) the link still
opens; only its address cannot be shown again, and the owner makes a new link to share.
Password-protected links additionally require a short-lived signed **grant** on the file request —
because a `<video src>` can't send a header, the password is exchanged once at `/unlock` for an HMAC
grant appended to the file URL, so the raw password never rides in the media URL (or server logs).
"""
import hashlib
@@ -12,16 +17,71 @@ import hmac
import secrets
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.orm import Session
from app import security
from app.config import settings
from app.models import DownloadLink
from app.downloads import storage
from app.downloads.status import JobStatus
from app.models import DownloadJob, DownloadLink, FileFolder, MediaAsset
_GRANT_TTL = 6 * 3600 # a watch session's grant is good for 6h, then the viewer re-unlocks
# The most files a folder link lists: as many as the owner's own Files list shows (`routes.files.
# MAX_LIST`; a test holds the two equal — this layer may not import that one).
FOLDER_MAX = 500
def new_token() -> str:
return secrets.token_urlsafe(24) # ~32 chars, 192 bits
def seal(link: DownloadLink, token: str) -> None:
"""Store `token` on `link` the only ways it is kept: its hash, and its encrypted copy."""
link.token_hash = security.hash_token(token)
link.token_enc = security.encrypt(token) if security.can_encrypt() else None
def find(db: Session, token: str) -> DownloadLink | None:
"""The link a `/watch` token names, by its hash."""
return db.execute(
select(DownloadLink).where(DownloadLink.token_hash == security.hash_token(token))
).scalar_one_or_none()
def recover(link: DownloadLink) -> str | None:
"""The link's token from its encrypted copy, or None when there is none this key can read."""
return security.decrypt_optional(link.token_enc)
def in_folder(folder: FileFolder) -> tuple:
"""Which jobs a link to `folder` reaches: the done files in it NOW, the folder owner's. A file
moved out stops opening through the link at its next request; one moved in starts."""
return (
DownloadJob.folder_id == folder.id,
DownloadJob.user_id == folder.user_id,
DownloadJob.status == JobStatus.DONE,
)
def folder_files(db: Session, folder: FileFolder) -> list[tuple[DownloadJob, MediaAsset]]:
"""What a link to `folder` shows — its page lists exactly these, and its link card counts them:
each file `in_folder` whose asset is ready and whose bytes are on disk (so the list never offers
a dead row), newest first, at most `FOLDER_MAX`."""
rows = db.execute(
select(DownloadJob, MediaAsset)
.join(MediaAsset, MediaAsset.id == DownloadJob.asset_id)
.where(*in_folder(folder), MediaAsset.status == "ready")
.order_by(DownloadJob.ready_at.desc().nulls_last(), DownloadJob.id.desc())
.limit(FOLDER_MAX)
).all()
return [
(job, asset)
for job, asset in rows
if storage.safe_abs_path(settings.files_root, asset.rel_path) is not None
]
def is_expired(link: DownloadLink) -> bool:
return link.expires_at is not None and link.expires_at <= datetime.now(timezone.utc)
@@ -55,18 +115,22 @@ def check_grant(token: str, grant: str | None, password_version: int) -> bool:
return False
if exp < datetime.now(timezone.utc).timestamp():
return False
return hmac.compare_digest(sig, _sign(f"{token}.{exp}.{password_version}"))
# As bytes: compare_digest refuses (TypeError, a 500) a str with anything non-ASCII in it, and
# `sig` is whatever the URL carried.
return hmac.compare_digest(sig.encode(), _sign(f"{token}.{exp}.{password_version}").encode())
# --- serialization -------------------------------------------------------------------------
def owner_view(link: DownloadLink, base_url: str = "") -> dict:
"""What the file owner sees when managing their links."""
def owner_view(link: DownloadLink, base_url: str = "", token: str | None = None) -> dict:
"""What the file owner sees when managing their links. `token` is the one just made (it is in
hand even where no copy is kept); else it is read back, and `url` is None when it cannot be."""
token = token or recover(link)
return {
"id": link.id,
"token": link.token,
"url": f"{base_url}/watch/{link.token}",
"token": token,
"url": f"{base_url}/watch/{token}" if token else None,
"allow_download": link.allow_download,
"has_password": link.password_hash is not None,
"expires_at": link.expires_at.isoformat() if link.expires_at else None,
+31 -5
View File
@@ -11,13 +11,12 @@ import html
import re
from pathlib import Path
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.config import settings
from app.downloads import links as linksmod
from app.downloads import storage
from app.models import DownloadJob, DownloadLink, MediaAsset
from app.models import DownloadJob, DownloadLink, FileFolder, MediaAsset
_OG_BLOCK = re.compile(r"<!--OG:START-->.*?<!--OG:END-->", re.DOTALL)
@@ -74,22 +73,25 @@ def _jpeg_size(path: Path) -> tuple[int, int] | None:
def _watch_tags(token: str, db: Session) -> str | None:
"""Build the OG/Twitter meta block for a watch token, or None to keep the generic card."""
link = db.execute(select(DownloadLink).where(DownloadLink.token == token)).scalar_one_or_none()
link = linksmod.find(db, token)
if link is None or linksmod.is_expired(link):
return None
url = f"{settings.app_base}/watch/{token}"
folder = link.folder_id is not None
if link.password_hash:
# Password-gated: don't leak the title/thumbnail in the unfurl.
return "\n ".join(
[
"<title>Siftlode</title>",
_tag("og:title", "Password-protected video"),
_tag("og:title", "Password-protected " + ("folder" if folder else "video")),
_tag("og:description", "Shared via Siftlode"),
_tag("og:type", "video.other"),
_tag("og:type", "website" if folder else "video.other"),
_tag("og:url", url),
_tag("og:site_name", "Siftlode"),
]
)
if folder:
return _folder_tags(db, link, url)
job = db.get(DownloadJob, link.job_id)
asset = db.get(MediaAsset, job.asset_id) if job and job.asset_id else None
if job is None or asset is None or asset.status != "ready":
@@ -147,6 +149,30 @@ def _watch_tags(token: str, db: Session) -> str | None:
return "\n ".join(tags)
def _folder_tags(db: Session, link: DownloadLink, url: str) -> str | None:
"""A folder link's card: the folder's name and how many files it holds — never their names,
which the page shows only to whoever opens it."""
folder = db.get(FileFolder, link.folder_id)
if folder is None or not folder.name:
return None
count = len(linksmod.folder_files(db, folder)) # the page's own list, so the two agree
title = folder.name.strip()
about = f"{count} file{'' if count == 1 else 's'} · Shared via Siftlode"
return "\n ".join(
[
f"<title>{html.escape(title)}</title>",
_tag("og:title", title),
_tag("og:description", about),
_tag("og:type", "website"),
_tag("og:url", url),
_tag("og:site_name", "Siftlode"),
_tag("twitter:title", title, attr="name"),
_tag("twitter:description", about, attr="name"),
_tag("twitter:card", "summary", attr="name"),
]
)
def render_watch_html(token: str, page_html: Path, db: Session) -> str | None:
"""Return the watch page (watch.html) with the OG block replaced for this token, or None to
serve the generic page unchanged (invalid/expired/not-ready link, or a template without the
+5 -1
View File
@@ -294,9 +294,13 @@ def served_media_type(media_kind: str | None, container: str | None) -> str | No
def download_filename(display_name: str, container: str | None, path: Path) -> str:
"""The Content-Disposition filename for a served download: the cleaned display name carrying a single
correct extension — the asset container, else the file's own suffix — without doubling it when the
name already ends in that extension. Shared by the private download and the public watch endpoints."""
name already ends in that extension. A file with no extension at all is served without one —
never as `name.` (Windows drops a trailing dot, so the saved file would not be the one named).
Shared by the private download and the public watch endpoints."""
ext = container or path.suffix.lstrip(".")
base = display_filename(display_name)
if not ext:
return base
if base.lower().endswith(f".{ext.lower()}"):
base = base[: -(len(ext) + 1)]
return f"{base}.{ext}"
+11 -2
View File
@@ -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()
+2
View File
@@ -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)
+67 -10
View File
@@ -919,7 +919,8 @@ class FileFolder(Base, TimestampMixin):
"""A folder of the Files library (`app.downloads.folders`): a row, never a directory.
A SOURCE folder (`source_key` set: youtube / plex / upload / site:<host>) is made the first time a
user gets a file from that origin; an OWN folder (`name` set) is one the user made. Every file —
user gets a file from that origin; an OWN folder (`name` set) is one the user made; the HUB folder
(neither set, one per user) is where they swap files with a machine of theirs. Every file —
a `DownloadJob` — sits in exactly one (`DownloadJob.folder_id`). `retention_days` is how long a
file stays once done (NULL = until deleted); a file's own `expires_at` is stamped from it."""
@@ -933,9 +934,12 @@ class FileFolder(Base, TimestampMixin):
unique=True,
postgresql_where=text("name IS NOT NULL"),
),
# One Hub folder per user.
Index("uq_file_folder_hub", "user_id", unique=True, postgresql_where=text("kind = 'hub'")),
CheckConstraint(
"(kind = 'source' AND source_key IS NOT NULL AND name IS NULL)"
" OR (kind = 'own' AND name IS NOT NULL AND source_key IS NULL)",
" OR (kind = 'own' AND name IS NOT NULL AND source_key IS NULL)"
" OR (kind = 'hub' AND name IS NULL AND source_key IS NULL)",
name="ck_file_folder_kind",
),
)
@@ -949,6 +953,47 @@ 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))
# 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):
"""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`)."""
@@ -1084,21 +1129,33 @@ class DownloadQuota(Base, UpdatedAtMixin):
class DownloadLink(Base, TimestampMixin):
"""A public capability link to one download — the "share by link" surface.
"""A public capability link to one download, or to one of its owner's own folders — the "share
by link" surface.
Anyone holding the unguessable `token` can watch the file on the login-free `/watch/{token}`
player page (Google-Drive style). Optional per-link controls: `expires_at`, `allow_download`
(stream-only vs downloadable), and a `password_hash` (argon2, like a user password). Revoke =
delete the row. Distinct from `DownloadShare`, which grants a *registered* user access via the
in-app "Shared with me" list."""
Anyone holding the unguessable token can watch the file on the login-free `/watch/{token}`
player page (Google-Drive style); a folder link opens the folder's list there, with what is in
the folder at the time it is opened. The token itself is never stored as written: a link is
found by its SHA-256 (`token_hash`), and `token_enc` is the Fernet-encrypted copy its owner
copies the link from again — absent without a key (`downloads/links.py`). Optional per-link
controls: `expires_at`, `allow_download` (stream-only vs downloadable), and a `password_hash`
(argon2, like a user password). Revoke = delete the row. Distinct from `DownloadShare`, which
grants a *registered* user access via the in-app "Shared with me" list."""
__tablename__ = "download_links"
__table_args__ = (
# A link opens exactly one thing: a file or a folder.
CheckConstraint("(job_id IS NULL) <> (folder_id IS NULL)", name="ck_download_link_target"),
)
id: Mapped[int] = mapped_column(primary_key=True)
job_id: Mapped[int] = mapped_column(
job_id: Mapped[int | None] = mapped_column(
ForeignKey("download_jobs.id", ondelete="CASCADE"), index=True
)
token: Mapped[str] = mapped_column(String(64), unique=True)
folder_id: Mapped[int | None] = mapped_column(
ForeignKey("file_folders.id", ondelete="CASCADE"), index=True
)
token_hash: Mapped[str] = mapped_column(String(64), unique=True)
token_enc: Mapped[str | None] = mapped_column(Text) # Fernet ciphertext — never returned/logged
created_by: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
allow_download: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
password_hash: Mapped[str | None] = mapped_column(Text)
+28 -14
View File
@@ -16,6 +16,7 @@ from sqlalchemy import select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app import apitokens
from app import email as email_mod
from app import logs
from app import mailtext
@@ -446,7 +447,7 @@ async def callback(
# SEC-10: drop any password nobody proved the mailbox for, now that Google attests
# ownership (bumps the epoch to revoke any session it minted). Runs before the
# activation below, which is what it reads.
revoke_untrusted_preset_password(user)
revoke_untrusted_preset_password(db, user)
# Google verified the email and is_allowed granted access, so finish activating a
# previously pending password registration (otherwise current_user would bounce it).
user.email_verified = True
@@ -486,7 +487,7 @@ async def callback(
# real owner, signed in through Google right now, sets a new password in Settings.
if canonical_email(email) in env_admin_canonicals():
if user.role != "admin" and user.password_hash is not None:
_revoke_password(user)
_revoke_password(db, user)
user.role = "admin"
elif not user.role:
user.role = "user"
@@ -1118,9 +1119,8 @@ def password_reset_confirm(body: TokenPasswordIn, db: Session = Depends(get_db))
# about. The person still confirms their address the normal way.
if kind == "reset":
user.email_verified = True
bump_session_epoch(
user
) # SA4: a reset (often a compromise response) kills all existing sessions.
# SA4: a reset (often a compromise response) kills all existing sessions — and API tokens.
bump_session_epoch(db, user)
# Burn any other outstanding reset tokens for this user.
for row in db.execute(
select(AuthToken).where(
@@ -1143,13 +1143,27 @@ def _remember_epoch(request: Request, user: User) -> None:
request.session["epochs"] = epochs
def bump_session_epoch(user: User) -> None:
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`."""
user.session_epoch = (user.session_epoch or 0) + 1
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.
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. 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(key_share=True)
).scalar_one()
user.session_epoch = (epoch or 0) + 1
apitokens.revoke_all(db, user.id)
def revoke_untrusted_preset_password(user: User) -> None:
def revoke_untrusted_preset_password(db: Session, user: User) -> None:
"""SEC-10 (account pre-emption): a password set on an account whose email was NEVER verified is
untrusted — whoever registered the address (to pre-empt the real owner) could have chosen it. When
the real owner attests ownership through Google, drop that password and bump the epoch to revoke any
@@ -1183,15 +1197,15 @@ def revoke_untrusted_preset_password(user: User) -> None:
_send_reset_if_eligible keyed off exactly that, so a revoked account silently got no reset link and
was locked out for good (it could not sign in either). The stamp separates the two cases."""
if not user.email_verified or (not user.is_active and user.password_hash is not None):
_revoke_password(user)
_revoke_password(db, user)
def _revoke_password(user: User) -> None:
def _revoke_password(db: Session, user: User) -> None:
"""Drop the account's password, stamp why (so password-reset still accepts it) and end every
session it signed in."""
user.password_hash = None
user.password_revoked_at = datetime.now(timezone.utc)
bump_session_epoch(user)
bump_session_epoch(db, user)
@router.post("/logout-others")
@@ -1203,7 +1217,7 @@ def logout_others(
in somewhere" / a suspected session compromise."""
# Commit the bump BEFORE re-stamping this cookie, so a failed commit can't strand the current
# session at a newer epoch than the DB (see set_password).
bump_session_epoch(user)
bump_session_epoch(db, user)
db.commit()
_remember_epoch(request, user)
log.info("Logout-others: uid=%s (epoch=%s)", user.id, user.session_epoch)
@@ -1255,7 +1269,7 @@ def set_password(
# SA4: a password change logs out every OTHER session; re-stamp this cookie at the new epoch so
# the person making the change stays signed in here. Commit BEFORE re-stamping so a failed commit
# can't leave the cookie at a newer epoch than the DB (which would wrongly 401 THIS session).
bump_session_epoch(user)
bump_session_epoch(db, user)
db.commit()
_remember_epoch(request, user)
log.info("Password set/changed: uid=%s", user.id)
+129 -26
View File
@@ -27,13 +27,16 @@ from app.db import get_db
from app.downloads import clip as clipmod
from app.downloads import credentials as dlcreds
from app.downloads import edit as editmod
from app.downloads import folders
from app.downloads import links as linksmod
from app.downloads import quota, service, storage
from app.downloads import urlguard
from app.downloads.folders import FolderKind
from app.downloads.status import FINISHED, NOT_REFETCHABLE, JobStatus
from app.models import (
DownloadJob,
DownloadLink,
FileFolder,
DownloadProfile,
DownloadQuota,
DownloadShare,
@@ -62,6 +65,7 @@ from app.routes.files import (
)
from app.security import hash_password
from app.userscope import is_messageable_user, messageable_clauses
from app.utils import iso
from app.refusal import refused
router = APIRouter(prefix="/api/downloads", tags=["downloads"])
@@ -716,8 +720,12 @@ def _own_link(db: Session, user: User, link_id: int) -> DownloadLink:
link = db.get(DownloadLink, link_id)
if link is None:
raise not_found("link")
job = db.get(DownloadJob, link.job_id)
if job is None or job.user_id != user.id:
target = (
db.get(DownloadJob, link.job_id)
if link.job_id is not None
else db.get(FileFolder, link.folder_id)
)
if target is None or target.user_id != user.id:
raise not_found("link")
return link
@@ -730,21 +738,17 @@ def _link_expiry(days: int | None) -> datetime | None:
return datetime.now(timezone.utc) + timedelta(days=min(days, 3650))
def _links_of(db: Session, *where) -> list[dict]:
rows = db.execute(select(DownloadLink).where(*where).order_by(DownloadLink.id.desc()))
return [linksmod.owner_view(row) for row in rows.scalars()]
@router.get("/{job_id}/links")
def list_links(
job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> list[dict]:
owned_or_404(db, DownloadJob, job_id, user, what="download")
rows = (
db.execute(
select(DownloadLink)
.where(DownloadLink.job_id == job_id)
.order_by(DownloadLink.id.desc())
)
.scalars()
.all()
)
return [linksmod.owner_view(row) for row in rows]
return _links_of(db, DownloadLink.job_id == job_id)
class CreateLinkIn(BaseModel):
@@ -758,6 +762,34 @@ class CreateLinkIn(BaseModel):
expires_days: int | None = None
def _new_link(
db: Session, user: User, body: CreateLinkIn, **target: int
) -> tuple[DownloadLink, str]:
"""Make a link to `target` (`job_id=` or `folder_id=`, already checked to be the user's), and
the token it is opened by — which is in hand only now."""
pw = body.password.strip()
token = linksmod.new_token()
link = DownloadLink(
**target,
created_by=user.id,
allow_download=body.allow_download,
password_hash=hash_password(pw) if pw else None,
expires_at=_link_expiry(body.expires_days),
)
linksmod.seal(link, token)
db.add(link)
db.flush()
audit.record(
db,
user.id,
audit.AuditAction.DOWNLOAD_LINK_CREATE,
target_type="download_link",
target_id=link.id,
after={**target, **_link_audit(link)},
)
return link, token
@router.post("/{job_id}/links")
def create_link(
job_id: int,
@@ -765,22 +797,73 @@ 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")
pw = body.password.strip()
link = DownloadLink(
job_id=job_id,
token=linksmod.new_token(),
created_by=user.id,
allow_download=body.allow_download,
password_hash=hash_password(pw) if pw else None,
expires_at=_link_expiry(body.expires_days),
)
db.add(link)
link, token = _new_link(db, user, body, job_id=job_id)
db.commit()
db.refresh(link)
return linksmod.owner_view(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)
for the rest of the transaction, so a delete of it at the same moment waits and takes the new
link with it, rather than failing the link's foreign key."""
owned_or_404(db, FileFolder, folder_id, user, what="folder")
try:
folder = folders.own_folder(db, user.id, folder_id)
except folders.FolderError as e:
raise refused(409, e.reason) from e
if folder.kind != FolderKind.OWN:
raise refused(409, "share_folder_kind")
return folder
@router.get("/folders/{folder_id}/links")
def list_folder_links(
folder_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> list[dict]:
owned_or_404(db, FileFolder, folder_id, user, what="folder")
return _links_of(db, DownloadLink.folder_id == folder_id)
@router.post("/folders/{folder_id}/links")
def create_folder_link(
folder_id: int,
body: CreateLinkIn,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
_shareable_folder(db, user, folder_id)
link, token = _new_link(db, user, body, folder_id=folder_id)
db.commit()
return linksmod.owner_view(link, token=token)
def _link_audit(link: DownloadLink) -> dict:
"""A link's settings as the audit records them: whether it has a password, never which."""
return {
"allow_download": link.allow_download,
"has_password": link.password_hash is not None,
"expires_at": iso(link.expires_at),
}
class UpdateLinkIn(BaseModel):
@@ -797,6 +880,7 @@ def update_link(
db: Session = Depends(get_db),
) -> dict:
link = _own_link(db, user, link_id)
before = _link_audit(link)
sent = body.model_fields_set
if "allow_download" in sent:
link.allow_download = bool(body.allow_download)
@@ -808,6 +892,17 @@ def update_link(
# Bump the version so any grant issued under the previous password stops working now, rather
# than living out its 6h TTL (setting/clearing/changing the password all count as a rotation).
link.password_version += 1
after = _link_audit(link)
if after != before or "password" in sent:
audit.record(
db,
user.id,
audit.AuditAction.DOWNLOAD_LINK_UPDATE,
target_type="download_link",
target_id=link_id,
before=before,
after=after,
)
db.commit()
return linksmod.owner_view(link)
@@ -818,8 +913,16 @@ def revoke_link(
) -> dict:
link = _own_link(db, user, link_id)
db.delete(link)
audit.record(
db,
user.id,
audit.AuditAction.DOWNLOAD_LINK_REVOKE,
target_type="download_link",
target_id=link_id,
after={"job_id": link.job_id} if link.job_id is not None else {"folder_id": link.folder_id},
)
db.commit()
return {"deleted": link_id}
return {"ok": True, "deleted": link_id}
# --- admin ---------------------------------------------------------------------------------
+111 -38
View File
@@ -7,6 +7,11 @@ bytes. Folders are rows (`downloads.folders`), so a rename or a move never touch
In-flight jobs are not here: they are the download tray's (`routes.downloads`). The helpers above
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
@@ -41,6 +47,7 @@ from app.mailtext import lang_for_user
from app.models import (
DISPLAY_TEXT_MAX,
DownloadJob,
DownloadLink,
DownloadShare,
FileFolder,
LibraryState,
@@ -208,7 +215,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 +224,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 +241,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 +274,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 +316,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,10 +339,21 @@ 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 -------------------------------------------------------------------------------
def _serialize_folder(folder: FileFolder, count: int, size: int) -> dict:
def _serialize_folder(folder: FileFolder, count: int, size: int, linked: bool = False) -> dict:
return {
"id": folder.id,
"kind": folder.kind,
@@ -336,27 +364,47 @@ def _serialize_folder(folder: FileFolder, count: int, size: int) -> dict:
"count": count,
"size_bytes": size,
"created_at": iso(folder.created_at),
# A live public link opens it: whatever is moved in is shown to whoever holds the link.
"has_link": linked,
}
@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."""
done = (DownloadJob.folder_id == FileFolder.id) & (DownloadJob.status == JobStatus.DONE)
def _linked(db: Session, folder_ids: list[int]) -> set[int]:
"""Which of these folders a live public link opens (one not past its expiry)."""
now = datetime.now(timezone.utc)
rows = db.execute(
select(
FileFolder,
func.count(DownloadJob.id),
func.coalesce(func.sum(MediaAsset.size_bytes), 0),
select(DownloadLink.folder_id).where(
DownloadLink.folder_id.in_(folder_ids),
or_(DownloadLink.expires_at.is_(None), DownloadLink.expires_at > now),
)
.outerjoin(DownloadJob, done)
).scalars()
return {folder_id for folder_id in rows if folder_id is not None}
@router.get("/folders")
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(
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()
return [_serialize_folder(f, count, int(size)) for f, count, size in rows]
linked = _linked(db, [f.id for f, _, _ in rows])
return [_serialize_folder(f, count, int(size), f.id in linked) for f, count, size in rows]
class FolderIn(BaseModel):
@@ -439,7 +487,7 @@ def update_folder(
)
db.commit()
count, size = _folder_totals(db, folder.id)
return _serialize_folder(folder, count, size)
return _serialize_folder(folder, count, size, folder.id in _linked(db, [folder.id]))
def _folder_totals(db: Session, folder_id: int) -> tuple[int, int]:
@@ -520,12 +568,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 +596,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 +751,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 +802,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 +853,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 +913,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
+149 -53
View File
@@ -9,7 +9,7 @@ a short-lived signed grant that the media URL carries (a `<video src>` can't sen
from fastapi import APIRouter, Depends, Request
from fastapi.responses import FileResponse
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy import select, update
from sqlalchemy.orm import Session
from app.config import settings
@@ -17,7 +17,7 @@ from app.db import get_db
from app.downloads import links as linksmod
from app.downloads import storage
from app.downloads.status import JobStatus
from app.models import DownloadJob, DownloadLink, MediaAsset, User
from app.models import DownloadJob, DownloadLink, FileFolder, MediaAsset, User
from app.ratelimit import RateLimiter, client_key
from app.security import verify_password
from app.refusal import refused
@@ -38,7 +38,7 @@ _unlock_failures = RateLimiter(max_events=50, window_seconds=300)
def _link_or_404(db: Session, token: str) -> DownloadLink:
link = db.execute(select(DownloadLink).where(DownloadLink.token == token)).scalar_one_or_none()
link = linksmod.find(db, token)
if link is None or linksmod.is_expired(link):
raise refused(404, "link_gone")
return link
@@ -55,8 +55,7 @@ def _being_made(db: Session, job: DownloadJob) -> bool:
return owner is not None and not owner.is_suspended
def _ready_target(db: Session, link: DownloadLink) -> tuple[DownloadJob, MediaAsset]:
job = db.get(DownloadJob, link.job_id)
def _ready_job(db: Session, job: DownloadJob | None) -> tuple[DownloadJob, MediaAsset]:
asset = db.get(MediaAsset, job.asset_id) if job and job.asset_id else None
if (
job is not None
@@ -79,36 +78,114 @@ def _ready_target(db: Session, link: DownloadLink) -> tuple[DownloadJob, MediaAs
return job, asset
def _ready_asset(db: Session, link: DownloadLink) -> MediaAsset:
return _ready_target(db, link)[1]
def _ready_target(db: Session, link: DownloadLink) -> tuple[DownloadJob, MediaAsset]:
"""A file link's file. A folder link names no one file: its files are asked for by id."""
if link.job_id is None:
raise refused(404, "video_gone")
return _ready_job(db, db.get(DownloadJob, link.job_id))
def _file_url(token: str, grant: str | None = None) -> str:
base = f"/api/public/watch/{token}/file"
return f"{base}?g={grant}" if grant else base
def _link_folder(db: Session, link: DownloadLink) -> FileFolder:
folder = db.get(FileFolder, link.folder_id) if link.folder_id is not None else None
if folder is None:
raise refused(404, "link_gone")
return folder
def _poster_url(token: str, asset, grant: str | None = None) -> str | None:
def _folder_job(db: Session, link: DownloadLink, job_id: int) -> tuple[DownloadJob, MediaAsset]:
folder = _link_folder(db, link)
job = db.execute(
select(DownloadJob).where(DownloadJob.id == job_id, *linksmod.in_folder(folder))
).scalar_one_or_none()
if job is None:
raise refused(404, "video_gone")
return _ready_job(db, job)
def _base(token: str, job_id: int | None) -> str:
"""A file link's file lives under its token; a folder link's files, each under its id."""
return f"/api/public/watch/{token}" + (f"/files/{job_id}" if job_id is not None else "")
def _file_url(token: str, grant: str | None = None, job_id: int | None = None) -> str:
url = f"{_base(token, job_id)}/file"
return f"{url}?g={grant}" if grant else url
def _poster_url(
token: str, asset: MediaAsset, grant: str | None = None, job_id: int | None = None
) -> str | None:
if not asset.poster_path:
return None
base = f"/api/public/watch/{token}/poster.jpg"
return f"{base}?g={grant}" if grant else base
url = f"{_base(token, job_id)}/poster.jpg"
return f"{url}?g={grant}" if grant else url
def _folder_meta(db: Session, link: DownloadLink, token: str, grant: str | None) -> dict:
"""A folder link's page: the folder's name and each file's own watch metadata
(`linksmod.folder_files`: ready, on disk, newest first)."""
folder = _link_folder(db, link)
files = [
{
"id": job.id,
**linksmod.public_meta(
link,
job,
asset,
_file_url(token, grant, job.id),
_poster_url(token, asset, grant, job.id),
),
}
for job, asset in linksmod.folder_files(db, folder)
]
return {
"needs_password": False,
"kind": "folder",
"name": folder.name,
"allow_download": link.allow_download,
"files": files,
}
def _file_meta(db: Session, link: DownloadLink, token: str, grant: str | None) -> dict:
job, asset = _ready_target(db, link)
return {
"needs_password": False,
"kind": "file",
**linksmod.public_meta(
link, job, asset, _file_url(token, grant), _poster_url(token, asset, grant)
),
}
def _opened(db: Session, link: DownloadLink, token: str, grant: str | None = None) -> dict:
"""The page an open link shows, counted as one view (the handler commits the count)."""
meta = (
_folder_meta(db, link, token, grant)
if link.folder_id is not None
else _file_meta(db, link, token, grant)
)
# In SQL, not `+= 1` on the loaded row: two opens at once would both write the same count.
db.execute(
update(DownloadLink)
.where(DownloadLink.id == link.id)
.values(view_count=DownloadLink.view_count + 1)
.execution_options(synchronize_session=False)
)
return meta
@router.get("/watch/{token}")
def watch_meta(token: str, db: Session = Depends(get_db)) -> dict:
"""Public metadata for the watch page. For a password link, returns only `needs_password`
until the viewer unlocks it (no title/thumbnail leak)."""
"""Public metadata for the watch page: one file's, or a folder's list. For a password link,
returns only `needs_password` until the viewer unlocks it (no title/thumbnail leak)."""
link = _link_or_404(db, token)
if link.password_hash:
return {"needs_password": True}
job, asset = _ready_target(db, link)
link.view_count += 1
# Which kind it opens, for the password prompt's title — never what is in it.
return {"needs_password": True, "kind": "folder" if link.folder_id is not None else "file"}
meta = _opened(db, link, token)
db.commit()
return {
"needs_password": False,
**linksmod.public_meta(link, job, asset, _file_url(token), _poster_url(token, asset)),
}
return meta
class WatchUnlockIn(BaseModel):
@@ -119,7 +196,7 @@ class WatchUnlockIn(BaseModel):
def watch_unlock(
token: str, body: WatchUnlockIn, request: Request, db: Session = Depends(get_db)
) -> dict:
"""Exchange the link password for a signed grant carried by the media URL."""
"""Exchange the link password for a signed grant carried by the media URLs."""
# The link first: a token naming none must leave no limiter entry behind, or posting made-up tokens
# would grow the limiter without bound.
link = _link_or_404(db, token)
@@ -128,36 +205,27 @@ def watch_unlock(
raise refused(429, "too_many_attempts")
if not link.password_hash:
# Not a password link — nothing to unlock; just hand back the plain meta.
job, asset = _ready_target(db, link)
return {
"ok": True,
"needs_password": False,
**linksmod.public_meta(link, job, asset, _file_url(token), _poster_url(token, asset)),
}
meta = _opened(db, link, token)
db.commit()
return {"ok": True, **meta}
if not verify_password(body.password, link.password_hash):
_unlock_failures.allow(token)
raise refused(403, "wrong_share_password")
job, asset = _ready_target(db, link)
link.view_count += 1
db.commit()
grant = linksmod.make_grant(token, link.password_version)
return {
"ok": True,
"needs_password": False,
**linksmod.public_meta(
link, job, asset, _file_url(token, grant), _poster_url(token, asset, grant)
),
}
meta = _opened(db, link, token, grant)
db.commit()
return {"ok": True, **meta}
@router.get("/watch/{token}/file")
def watch_file(token: str, g: str | None = None, db: Session = Depends(get_db)):
"""Range-aware stream of the shared file. Inline by default (plays in the page); an
`allow_download` link serves it as an attachment. Password links require a valid grant."""
link = _link_or_404(db, token)
def _granted(link: DownloadLink, token: str, g: str | None) -> None:
"""A password link's files want the grant its unlock handed out."""
if link.password_hash and not linksmod.check_grant(token, g, link.password_version):
raise refused(403, "share_password_required")
asset = _ready_asset(db, link)
def _serve(link: DownloadLink, asset: MediaAsset) -> FileResponse:
"""Range-aware stream of a shared file. Inline by default (plays in the page); an
`allow_download` link serves it as an attachment."""
path = storage.safe_abs_path(settings.files_root, asset.rel_path)
if path is None:
raise refused(404, "video_gone")
@@ -184,17 +252,45 @@ def watch_file(token: str, g: str | None = None, db: Session = Depends(get_db)):
)
@router.get("/watch/{token}/poster.jpg")
def watch_poster(token: str, g: str | None = None, db: Session = Depends(get_db)):
"""The generated poster frame for a shared thumbnail-less video (used as the page/video poster
and the link-preview image). Password links require a valid grant, like the file."""
link = _link_or_404(db, token)
if link.password_hash and not linksmod.check_grant(token, g, link.password_version):
raise refused(403, "share_password_required")
job, asset = _ready_target(db, link)
def _poster(asset: MediaAsset) -> FileResponse:
if not asset.poster_path:
raise not_found("poster")
path = storage.safe_abs_path(settings.files_root, asset.poster_path)
if path is None:
raise not_found("poster")
return FileResponse(path, media_type="image/jpeg")
@router.get("/watch/{token}/file")
def watch_file(token: str, g: str | None = None, db: Session = Depends(get_db)):
"""A file link's file. Password links require a valid grant."""
link = _link_or_404(db, token)
_granted(link, token, g)
return _serve(link, _ready_target(db, link)[1])
@router.get("/watch/{token}/poster.jpg")
def watch_poster(token: str, g: str | None = None, db: Session = Depends(get_db)):
"""The generated poster frame for a shared thumbnail-less video (used as the page/video poster
and the link-preview image). Password links require a valid grant, like the file."""
link = _link_or_404(db, token)
_granted(link, token, g)
return _poster(_ready_target(db, link)[1])
@router.get("/watch/{token}/files/{job_id}/file")
def watch_folder_file(token: str, job_id: int, g: str | None = None, db: Session = Depends(get_db)):
"""One file of a folder link's folder, while it is in it. Password links require a grant."""
link = _link_or_404(db, token)
_granted(link, token, g)
return _serve(link, _folder_job(db, link, job_id)[1])
@router.get("/watch/{token}/files/{job_id}/poster.jpg")
def watch_folder_poster(
token: str, job_id: int, g: str | None = None, db: Session = Depends(get_db)
):
"""The poster frame of one file of a folder link's folder, under the same terms as the file."""
link = _link_or_404(db, token)
_granted(link, token, g)
return _poster(_folder_job(db, link, job_id)[1])
+185
View File
@@ -0,0 +1,185 @@
"""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,
"expiring": apitokens.expiring(token, 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)
else:
folders.own_folder(db, user.id, folder_id)
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, 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. 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(key_share=True)
).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()
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}
+2 -1
View File
@@ -370,7 +370,8 @@ def _explore_cleanup_job() -> None:
def _download_gc_job() -> None:
# Retention GC for the download center: pre-expiry warnings, TTL deletion, LRU eviction.
# Retention GC for the download center: pre-expiry warnings, TTL deletion, LRU eviction — and
# the week-ahead warning before an API token runs out (`apitokens.warn_expiring`).
# Pure disk/DB work (no YouTube quota).
_job("download_gc", run_download_gc)
+6
View File
@@ -46,6 +46,12 @@ _fernet: Fernet | None = (
)
def can_encrypt() -> bool:
"""Whether a value can be stored encrypted here: TOKEN_ENCRYPTION_KEY is set. Production
refuses to start without it (`config`); a local box may run without one."""
return _fernet is not None
def _require_fernet() -> Fernet:
if _fernet is None:
raise RuntimeError("TOKEN_ENCRYPTION_KEY is not configured")
+9 -1
View File
@@ -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
+648
View File
@@ -0,0 +1,648 @@
"""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 import Depends, Request
from fastapi.testclient import TestClient
from sqlalchemy import select, update
from app import apitokens, audit
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.notifications import create_notification
from app.models import (
ApiToken,
ApiTokenGrant,
AuditLog,
DownloadJob,
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
@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:])
# A header byte past ASCII arrives as one latin-1 character: refused, never a 500.
assert not apitokens.well_formed(raw[:-1] + "é")
def test_a_non_ascii_token_is_refused_as_any_bad_one(db, client):
# The test client re-sends a high byte as two UTF-8 bytes, which arrive as two latin-1
# characters: one "A" fewer keeps the header a token's length, so it is the checksum that meets
# them, not the length check.
raw = ("Bearer sfl_" + "A" * 47 + "é").encode("latin-1")
r = TestClient(app, headers={"authorization": raw}).get("/api/files/folders")
assert r.status_code == 401 and r.json()["detail"]["reason"] == "not_authenticated"
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() == []
@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())
_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 _signed_in(client, email="alice@example.com") -> TestClient:
r = client.post(
"/auth/password-login", json={"email": email, "password": "correct horse battery"}
)
assert r.status_code == 200, r.text
return client
def test_a_token_works_on_the_files_routes_only(db, make_user, client):
"""Through the real session stack: under `as_user` every route without its header answers 401
whatever `current_user` does with a token, so these refusals would prove nothing there."""
make_user(email="alice@example.com")
raw = _make(_signed_in(client), 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/me").json() == {"authenticated": False} # the token is no one here
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}"),
}
def _reset_password(db, client, alice):
from app.routes.auth import _issue_token
link = _issue_token(db, alice, "reset", timedelta(hours=1))
r = client.post(
"/auth/password-reset/confirm", json={"token": link, "password": "a brand new password"}
)
assert r.status_code == 200, r.text
def _change_password(db, client, alice):
r = client.post(
"/auth/set-password",
json={"password": "a brand new password", "current_password": "correct horse battery"},
)
assert r.status_code == 200, r.text
def _log_out_others(db, client, alice):
assert client.post("/auth/logout-others").status_code == 200
def _revoke_untrusted_password(db, client, alice):
from app.routes.auth import _revoke_password
_revoke_password(db, alice)
db.commit()
@pytest.mark.parametrize(
"end_sessions",
[_reset_password, _change_password, _log_out_others, _revoke_untrusted_password],
)
def test_whatever_ends_every_session_ends_every_token_too(db, make_user, client, end_sessions):
"""A token minted from a stolen session must not outlive the owner's answer to the theft."""
alice = make_user(email="alice@example.com")
bob = make_user(email="bob@example.com")
raw = _make(_signed_in(client), preset="hub")["token"]
bobs = apitokens.mint()
db.add(ApiToken(user_id=bob.id, name="b", prefix=bobs[:12], token_hash=hash_token(bobs)))
db.commit()
assert _bearer(raw).get("/api/files/folders").status_code == 200
end_sessions(db, client, alice)
assert _bearer(raw).get("/api/files/folders").status_code == 401
assert db.execute(select(ApiToken.user_id)).scalars().all() == [bob.id] # only hers went
[revoked] = _audits(db, "api_token_revoke")
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 _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
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 ------------------------------------------------------------------
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_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")
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]
+1 -1
View File
@@ -493,7 +493,7 @@ def _revoke_as_google_adoption(db, user) -> None:
Google callback adopting an account whose email was never verified."""
from app.routes.auth import revoke_untrusted_preset_password
revoke_untrusted_preset_password(user)
revoke_untrusted_preset_password(db, user)
db.commit()
+7 -1
View File
@@ -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"),
],
)
+397
View File
@@ -0,0 +1,397 @@
"""A `/watch` link to one of its owner's own folders (routes/downloads.py, routes/public.py), DB-lane.
The link shows what is in the folder when it is opened: a file moved in appears, one moved out stops
opening at its next request. Only an own folder can be shared — a source folder lets its files go on
its own clock, the Hub is the channel to a machine. A password, an expiry and stream-only work as on
a file link; a document is never served to open as a page.
"""
from datetime import datetime, timedelta, timezone
from pathlib import Path
import pytest
from alembic import command
from alembic.config import Config
from sqlalchemy import select, text
from app.config import settings
from app.db import engine
from app.downloads import folders, og
from app.downloads import probe as probemod
from app.models import AuditLog, DownloadJob, DownloadLink, FileFolder, MediaAsset
_VIDEO = {
"streams": [
{"codec_type": "video", "codec_name": "h264", "pix_fmt": "yuv420p"},
{"codec_type": "audio", "codec_name": "aac"},
],
"format": {"format_name": "mov,mp4", "duration": "5"},
}
@pytest.fixture
def probe(monkeypatch):
"""What ffprobe says the next upload is: a phone-safe video, or (None) a file it cannot read."""
said = {"data": _VIDEO}
monkeypatch.setattr(probemod, "probe_json", lambda _p: said["data"])
return said
@pytest.fixture
def shelf(as_user, make_user, tmp_path, monkeypatch, upload_headers, probe):
"""An owner with an own folder "Friends" holding two videos, a third video in their Uploads, and
a function to upload more."""
monkeypatch.setattr(settings, "files_root", str(tmp_path))
user = make_user()
owner = as_user(user)
folder = owner.post("/api/files/folders", json={"name": "Friends"}).json()
def upload(name: str, folder_id: int | None = None, body: bytes = b"fake-video") -> int:
r = owner.post(
"/api/files/upload", content=body, headers=upload_headers(name, folder=folder_id)
)
assert r.status_code == 200, r.text
return r.json()["id"]
inside = [upload("a.mp4", folder["id"]), upload("b.mp4", folder["id"])]
outside = upload("c.mp4")
return {
"user": user,
"owner": owner,
"folder": folder["id"],
"inside": inside,
"outside": outside,
"upload": upload,
}
def _link(shelf, **body) -> dict:
r = shelf["owner"].post(f"/api/downloads/folders/{shelf['folder']}/links", json=body)
assert r.status_code == 200, r.text
return r.json()
def _ids(client, token: str) -> list[int]:
meta = client.get(f"/api/public/watch/{token}").json()
return [f["id"] for f in meta["files"]]
def test_a_folder_link_lists_what_is_in_the_folder(shelf, client):
token = _link(shelf)["token"]
meta = client.get(f"/api/public/watch/{token}").json()
assert meta["kind"] == "folder"
assert meta["name"] == "Friends"
assert sorted(f["id"] for f in meta["files"]) == sorted(shelf["inside"])
# Each one plays and downloads through the link, under its own id.
for f in meta["files"]:
assert f["file_url"] == f"/api/public/watch/{token}/files/{f['id']}/file"
served = client.get(f["file_url"])
assert served.status_code == 200
assert served.content == b"fake-video"
def test_a_file_outside_the_folder_does_not_open_through_it(shelf, client):
token = _link(shelf)["token"]
r = client.get(f"/api/public/watch/{token}/files/{shelf['outside']}/file")
assert r.status_code == 404
# Nor does the folder link open as a file link.
assert client.get(f"/api/public/watch/{token}/file").status_code == 404
def test_the_link_follows_the_folder_as_files_move(shelf, client):
token = _link(shelf)["token"]
owner, moved_out = shelf["owner"], shelf["inside"][0]
other = owner.post("/api/files/folders", json={"name": "Other"}).json()["id"]
def move(job_id: int, folder_id: int) -> None:
r = owner.patch(f"/api/files/{job_id}", json={"folder_id": folder_id})
assert r.status_code == 200, r.text
move(shelf["outside"], shelf["folder"])
move(moved_out, other)
assert sorted(_ids(client, token)) == sorted([shelf["inside"][1], shelf["outside"]])
assert client.get(f"/api/public/watch/{token}/files/{moved_out}/file").status_code == 404
url = f"/api/public/watch/{token}/files/{shelf['outside']}/file"
assert client.get(url).status_code == 200
def test_only_a_folder_of_ones_own_can_be_shared(shelf, db, as_user, make_user):
owner = shelf["owner"]
source = db.execute(
select(FileFolder.id).where(
FileFolder.user_id == shelf["user"].id, FileFolder.kind == "source"
)
).scalar_one()
r = owner.post(f"/api/downloads/folders/{source}/links", json={})
assert r.status_code == 409
assert r.json()["detail"]["reason"] == "share_folder_kind"
# Nor the Hub: whatever a machine drops there would be public the moment it lands.
hub = folders.hub_folder(db, shelf["user"].id).id
db.commit()
r = owner.post(f"/api/downloads/folders/{hub}/links", json={})
assert r.status_code == 409
assert r.json()["detail"]["reason"] == "share_folder_kind"
stranger = as_user(make_user())
assert (
stranger.post(f"/api/downloads/folders/{shelf['folder']}/links", json={}).status_code == 404
)
assert stranger.get(f"/api/downloads/folders/{shelf['folder']}/links").status_code == 404
link_id = _link(shelf)["id"]
patch = stranger.patch(f"/api/downloads/links/{link_id}", json={"allow_download": False})
assert patch.status_code == 404
assert stranger.delete(f"/api/downloads/links/{link_id}").status_code == 404
def test_a_password_folder_link_shows_nothing_until_unlocked(shelf, client):
token = _link(shelf, password="open sesame")["token"]
assert client.get(f"/api/public/watch/{token}").json() == {
"needs_password": True,
"kind": "folder",
}
bare = f"/api/public/watch/{token}/files/{shelf['inside'][0]}/file"
assert client.get(bare).status_code == 403
unlocked = client.post(f"/api/public/watch/{token}/unlock", json={"password": "open sesame"})
assert unlocked.status_code == 200
files = unlocked.json()["files"]
assert len(files) == 2
assert all("?g=" in f["file_url"] for f in files)
assert client.get(files[0]["file_url"]).status_code == 200
def test_a_files_poster_opens_by_the_links_own_rules(shelf, db, client):
"""The poster route on its own: a password link's poster needs the unlock grant, and a file
outside the folder has none through it, grant or not."""
for job_id in (shelf["inside"][0], shelf["outside"]):
asset = db.get(MediaAsset, db.get(DownloadJob, job_id).asset_id)
asset.poster_path = f"poster-{job_id}.jpg"
(Path(settings.files_root) / asset.poster_path).write_bytes(b"a poster frame")
db.commit()
token = _link(shelf, password="open sesame")["token"]
inside = f"/api/public/watch/{token}/files/{shelf['inside'][0]}/poster.jpg"
assert client.get(inside).status_code == 403
unlocked = client.post(f"/api/public/watch/{token}/unlock", json={"password": "open sesame"})
[entry] = [f for f in unlocked.json()["files"] if f["id"] == shelf["inside"][0]]
assert entry["poster_url"].startswith(inside + "?g=")
got = client.get(entry["poster_url"])
assert got.status_code == 200 and got.content == b"a poster frame"
grant = entry["poster_url"].split("?g=", 1)[1]
outside = f"/api/public/watch/{token}/files/{shelf['outside']}/poster.jpg?g={grant}"
assert client.get(outside).status_code == 404
def test_every_open_counts_as_a_view(shelf, client):
made = _link(shelf)
for _ in range(3):
assert client.get(f"/api/public/watch/{made['token']}").status_code == 200
[listed] = shelf["owner"].get(f"/api/downloads/folders/{shelf['folder']}/links").json()
assert listed["view_count"] == 3
def test_the_folder_list_is_capped_like_the_owners_files_list():
from app.downloads import links as linksmod
from app.routes.files import MAX_LIST
assert linksmod.FOLDER_MAX == MAX_LIST
def test_a_document_in_a_shared_folder_only_downloads(shelf, client, probe):
probe["data"] = None
doc = shelf["upload"]("notes.html", shelf["folder"], b"<script>1</script>")
token = _link(shelf, allow_download=False)["token"]
assert doc in _ids(client, token)
served = client.get(f"/api/public/watch/{token}/files/{doc}/file")
assert served.status_code == 200
assert served.headers["content-type"].startswith("application/octet-stream")
assert "attachment" in served.headers["content-disposition"].lower()
def test_a_stream_only_folder_link_plays_inline(shelf, client):
token = _link(shelf, allow_download=False)["token"]
meta = client.get(f"/api/public/watch/{token}").json()
assert meta["allow_download"] is False
served = client.get(meta["files"][0]["file_url"])
assert "inline" in served.headers["content-disposition"].lower()
def test_an_expired_or_deleted_folder_link_is_gone(shelf, client, db):
made = _link(shelf)
db.get(DownloadLink, made["id"]).expires_at = datetime.now(timezone.utc) - timedelta(minutes=1)
db.commit()
assert client.get(f"/api/public/watch/{made['token']}").status_code == 404
token = _link(shelf)["token"]
assert shelf["owner"].delete(f"/api/files/folders/{shelf['folder']}").status_code == 200
assert client.get(f"/api/public/watch/{token}").status_code == 404
db.expire_all()
assert db.execute(select(DownloadLink)).scalars().all() == []
def test_the_folder_list_marks_a_folder_a_live_link_opens(shelf, db):
owner = shelf["owner"]
def marked() -> dict[int, bool]:
return {f["id"]: f["has_link"] for f in owner.get("/api/files/folders").json()}
assert marked()[shelf["folder"]] is False
made = _link(shelf)
assert marked()[shelf["folder"]] is True
# A rename answers with the mark too, so the rail does not lose it.
renamed = owner.patch(f"/api/files/folders/{shelf['folder']}", json={"name": "Pals"}).json()
assert renamed["has_link"] is True
db.get(DownloadLink, made["id"]).expires_at = datetime.now(timezone.utc) - timedelta(minutes=1)
db.commit()
assert marked()[shelf["folder"]] is False
live = _link(shelf)
assert owner.delete(f"/api/downloads/links/{live['id']}").status_code == 200
assert marked()[shelf["folder"]] is False
assert not any(marked()[f] for f in marked() if f != shelf["folder"])
def test_making_and_revoking_a_folder_link_is_audited(shelf, db):
made = _link(shelf)
assert shelf["owner"].delete(f"/api/downloads/links/{made['id']}").status_code == 200
db.expire_all()
rows = {
a.action: a.after
for a in db.execute(
select(AuditLog).where(AuditLog.action.like("download_link_%"))
).scalars()
}
assert rows["download_link_create"]["folder_id"] == shelf["folder"]
assert "job_id" not in rows["download_link_create"]
assert rows["download_link_revoke"] == {"folder_id": shelf["folder"]}
def test_the_unfurl_names_the_folder_and_counts_its_files_only(shelf, db):
token = _link(shelf)["token"]
tags = og._watch_tags(token, db)
assert "Friends" in tags
assert "2 files" in tags
names = db.execute(
select(DownloadJob.display_name).where(DownloadJob.id.in_(shelf["inside"]))
).scalars()
for name in names:
assert not name or name not in tags
# A file whose bytes are gone is off the page, and so off the card's count.
gone = db.get(MediaAsset, db.get(DownloadJob, shelf["inside"][0]).asset_id)
(Path(settings.files_root) / gone.rel_path).unlink()
assert "1 file " in og._watch_tags(token, db)
locked = _link(shelf, password="pw")["token"]
hidden = og._watch_tags(locked, db)
assert "Friends" not in hidden and "Password-protected folder" in hidden
assert 'content="website"' in hidden and "video.other" not in hidden
def test_the_downgrade_drops_folder_links_and_keeps_file_links(shelf, db, client):
_link(shelf)
file_link = shelf["owner"].post(f"/api/downloads/{shelf['outside']}/links", json={}).json()
db.commit() # an open transaction's locks would hold the downgrade's ALTER TABLE forever
cfg = Config("alembic.ini")
command.downgrade(cfg, "0107_link_token_at_rest")
try:
with engine.connect() as conn:
left = conn.execute(text("SELECT job_id FROM download_links")).scalars().all()
finally:
command.upgrade(cfg, "head")
assert left == [shelf["outside"]]
assert client.get(f"/api/public/watch/{file_link['token']}").status_code == 200
def test_a_password_file_link_says_it_opens_a_file(shelf, client):
made = (
shelf["owner"]
.post(f"/api/downloads/{shelf['outside']}/links", json={"password": "pw"})
.json()
)
meta = client.get(f"/api/public/watch/{made['token']}").json()
assert meta == {"needs_password": True, "kind": "file"}
def test_a_file_whose_bytes_are_gone_is_not_offered(shelf, client, db):
from pathlib import Path
from app.models import MediaAsset
token = _link(shelf)["token"]
gone = shelf["inside"][0]
asset = db.get(MediaAsset, db.get(DownloadJob, gone).asset_id)
(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() == []
+227
View File
@@ -0,0 +1,227 @@
"""A `/watch` link's token at rest (downloads/links.py, migration 0107), DB-lane.
The token is the credential, so the database keeps only its SHA-256 (to find the link by) and a
Fernet-encrypted copy (for its owner to copy the same link again). Without a copy this key can read,
the link still opens and only its address cannot be shown again. Making, changing and revoking a
link is audited, never with its token or password.
"""
import json
import pytest
from alembic import command
from alembic.config import Config
from cryptography.fernet import Fernet
from sqlalchemy import select, text
from app import security
from app.config import settings
from app.db import engine
from app.downloads import probe as probemod
from app.models import AuditLog, DownloadLink
_VIDEO = {
"streams": [
{"codec_type": "video", "codec_name": "h264", "pix_fmt": "yuv420p"},
{"codec_type": "audio", "codec_name": "aac"},
],
"format": {"format_name": "mov,mp4", "duration": "5"},
}
@pytest.fixture
def key(monkeypatch):
monkeypatch.setattr(security, "_fernet", Fernet(Fernet.generate_key()))
@pytest.fixture
def owner_job(as_user, make_user, tmp_path, monkeypatch, upload_headers):
monkeypatch.setattr(settings, "files_root", str(tmp_path))
monkeypatch.setattr(probemod, "probe_json", lambda _p: _VIDEO)
owner = as_user(make_user())
job = owner.post("/api/files/upload", content=b"fake-video", headers=upload_headers("clip.mp4"))
assert job.status_code == 200, job.text
return owner, job.json()["id"]
def _row(db, link_id: int) -> dict:
db.expire_all()
row = db.execute(
text("SELECT * FROM download_links WHERE id = :id"), {"id": link_id}
).mappings()
return dict(row.one())
def _audits(db, action: str) -> list[AuditLog]:
db.expire_all()
return list(db.execute(select(AuditLog).where(AuditLog.action == action)).scalars())
def test_the_token_is_kept_only_hashed_and_encrypted(db, key, owner_job, client):
owner, job_id = owner_job
made = owner.post(f"/api/downloads/{job_id}/links", json={}).json()
token = made["token"]
assert made["url"] == f"/watch/{token}"
row = _row(db, made["id"])
assert "token" not in row
assert token not in json.dumps(row, default=str)
assert row["token_hash"] == security.hash_token(token)
assert security.decrypt(row["token_enc"]) == token
# The address it was given opens it, and its owner can copy the same one again.
assert client.get(f"/api/public/watch/{token}").status_code == 200
listed = owner.get(f"/api/downloads/{job_id}/links").json()
assert [entry["url"] for entry in listed] == [f"/watch/{token}"]
def test_a_token_naming_no_link_finds_none(db, key, owner_job, client):
owner, job_id = owner_job
token = owner.post(f"/api/downloads/{job_id}/links", json={}).json()["token"]
other = token[:-1] + ("A" if token[-1] != "A" else "B")
assert client.get(f"/api/public/watch/{other}").status_code == 404
# Its hash is no key either: the lookup hashes what it is given.
hashed = security.hash_token(token)
assert client.get(f"/api/public/watch/{hashed}").status_code == 404
def test_without_a_key_the_link_opens_but_its_address_is_not_kept(db, owner_job, client):
assert not security.can_encrypt()
owner, job_id = owner_job
made = owner.post(f"/api/downloads/{job_id}/links", json={}).json()
# The one who made it has it in hand…
assert made["url"] == f"/watch/{made['token']}"
assert _row(db, made["id"])["token_enc"] is None
assert client.get(f"/api/public/watch/{made['token']}").status_code == 200
# …but it cannot be shown again.
listed = owner.get(f"/api/downloads/{job_id}/links").json()
assert [(entry["token"], entry["url"]) for entry in listed] == [(None, None)]
def test_a_changed_key_hides_the_address_and_the_link_still_opens(
db, key, owner_job, client, monkeypatch
):
owner, job_id = owner_job
token = owner.post(f"/api/downloads/{job_id}/links", json={}).json()["token"]
monkeypatch.setattr(security, "_fernet", Fernet(Fernet.generate_key()))
listed = owner.get(f"/api/downloads/{job_id}/links").json()
assert [entry["url"] for entry in listed] == [None]
assert client.get(f"/api/public/watch/{token}").status_code == 200
def test_making_changing_and_revoking_a_link_is_audited_without_its_secrets(db, key, owner_job):
owner, job_id = owner_job
pw = "a secret word"
made = owner.post(
f"/api/downloads/{job_id}/links", json={"password": pw, "allow_download": False}
).json()
link_id = made["id"]
patch = f"/api/downloads/links/{link_id}"
assert owner.patch(patch, json={"allow_download": True}).status_code == 200
assert owner.patch(patch, json={"password": "another one"}).status_code == 200
# Nothing changed: nothing to record.
assert owner.patch(patch, json={"allow_download": True}).status_code == 200
gone = owner.delete(f"/api/downloads/links/{link_id}")
assert gone.json() == {"ok": True, "deleted": link_id}
(create,) = _audits(db, "download_link_create")
assert create.target_id == str(link_id)
assert create.after == {
"job_id": job_id,
"allow_download": False,
"has_password": True,
"expires_at": None,
}
updates = _audits(db, "download_link_update")
assert [(u.before["allow_download"], u.after["allow_download"]) for u in updates] == [
(False, True),
(True, True), # the password changed: a rotation is recorded though the flags are equal
]
(revoke,) = _audits(db, "download_link_revoke")
assert revoke.target_id == str(link_id)
assert revoke.after == {"job_id": job_id}
trail = json.dumps(
[[a.summary, a.before, a.after] for a in [create, *updates, revoke]], default=str
)
for secret in (made["token"], pw, "another one"):
assert secret not in trail
def test_the_migration_keeps_every_address_already_sent(db, key, owner_job, client):
owner, job_id = owner_job
db.commit() # an open transaction's locks would hold the downgrade's ALTER TABLE forever
cfg = Config("alembic.ini")
command.downgrade(cfg, "0106_api_tokens")
try:
with engine.begin() as conn:
conn.execute(
text("INSERT INTO download_links (job_id, token) VALUES (:j, 'an-old-token-sent')"),
{"j": job_id},
)
finally:
command.upgrade(cfg, "head")
(row,) = db.execute(text("SELECT token_hash, token_enc FROM download_links")).mappings().all()
assert row["token_hash"] == security.hash_token("an-old-token-sent")
assert security.decrypt(row["token_enc"]) == "an-old-token-sent"
assert client.get("/api/public/watch/an-old-token-sent").status_code == 200
listed = owner.get(f"/api/downloads/{job_id}/links").json()
assert [entry["url"] for entry in listed] == ["/watch/an-old-token-sent"]
def test_the_downgrade_writes_the_tokens_back(db, key, owner_job):
owner, job_id = owner_job
token = owner.post(f"/api/downloads/{job_id}/links", json={}).json()["token"]
db.commit() # an open transaction's locks would hold the downgrade's ALTER TABLE forever
cfg = Config("alembic.ini")
command.downgrade(cfg, "0106_api_tokens")
try:
with engine.connect() as conn:
back = conn.execute(text("SELECT token FROM download_links")).scalars().all()
finally:
command.upgrade(cfg, "head")
assert back == [token]
assert db.execute(select(DownloadLink.token_hash)).scalars().all() == [
security.hash_token(token)
]
def test_without_a_key_the_migration_keeps_the_address_opening_but_not_shown(db, owner_job, client):
"""No key to seal with: the hash alone is kept, the link still opens, its address is gone."""
assert not security.can_encrypt()
owner, job_id = owner_job
db.commit() # an open transaction's locks would hold the downgrade's ALTER TABLE forever
cfg = Config("alembic.ini")
command.downgrade(cfg, "0106_api_tokens")
try:
with engine.begin() as conn:
conn.execute(
text("INSERT INTO download_links (job_id, token) VALUES (:j, 'an-old-token-sent')"),
{"j": job_id},
)
finally:
command.upgrade(cfg, "head")
(row,) = db.execute(text("SELECT token_hash, token_enc FROM download_links")).mappings().all()
assert row["token_hash"] == security.hash_token("an-old-token-sent")
assert row["token_enc"] is None
assert client.get("/api/public/watch/an-old-token-sent").status_code == 200
assert [e["url"] for e in owner.get(f"/api/downloads/{job_id}/links").json()] == [None]
def test_a_downgrade_with_no_readable_copy_writes_a_fresh_token(db, key, owner_job, monkeypatch):
"""A key since changed reads no copy: the old column gets a random token, so the link is
revoked rather than left with no token (the column is NOT NULL there)."""
owner, job_id = owner_job
token = owner.post(f"/api/downloads/{job_id}/links", json={}).json()["token"]
db.commit() # an open transaction's locks would hold the downgrade's ALTER TABLE forever
monkeypatch.setattr(security, "_fernet", Fernet(Fernet.generate_key()))
cfg = Config("alembic.ini")
command.downgrade(cfg, "0106_api_tokens")
try:
with engine.connect() as conn:
back = conn.execute(text("SELECT token FROM download_links")).scalars().all()
finally:
command.upgrade(cfg, "head")
assert len(back) == 1 and back[0] and back[0] != token
+3
View File
@@ -53,6 +53,9 @@ class TestGrant:
def test_malformed_grants_are_rejected_not_crashed(self):
for bad in [None, "", "no-dot", "notanumber.sig"]:
assert check_grant("tok", bad, 0) is False
# The query string decodes as UTF-8: a signature past ASCII is just a wrong one.
future = int(datetime.now(timezone.utc).timestamp()) + _GRANT_TTL
assert check_grant("tok", f"{future}.é", 0) is False
def _asset(**over):
+50
View File
@@ -0,0 +1,50 @@
"""Migration 0106's downgrade data step, DB-lane: a Hub folder holding a file becomes an own folder
that keeps the file — under a name its user's own folders do not already have (their names are
unique per user, case-blind), and with its token gone with the tokens table."""
from alembic import command
from alembic.config import Config
from sqlalchemy import text
from app.db import engine
def test_a_hub_folder_with_a_file_comes_back_as_an_own_folder_beside_a_namesake(db, make_user):
uid = make_user().id
with engine.begin() as conn:
hub = conn.execute(
text("INSERT INTO file_folders (user_id, kind) VALUES (:u, 'hub') RETURNING id"),
{"u": uid},
).scalar_one()
conn.execute(
text("INSERT INTO file_folders (user_id, kind, name) VALUES (:u, 'own', :n)"),
{"u": uid, "n": f"hub {hub}"}, # the name the downgrade would pick, in another case
)
held = conn.execute(
text(
"INSERT INTO download_jobs (user_id, source_kind, source_ref, profile_snapshot,"
" status, folder_id) VALUES (:u, 'upload', 'x', '{}', 'queued', :f) RETURNING id"
),
{"u": uid, "f": hub},
).scalar_one()
conn.execute(
text(
"INSERT INTO api_tokens (user_id, name, prefix, token_hash) VALUES"
" (:u, 'hub', 'sfl_abcd1234', 'h') "
),
{"u": uid},
)
cfg = Config("alembic.ini")
command.downgrade(cfg, "0105_job_source_key")
try:
with engine.connect() as conn:
kind, name = conn.execute(
text("SELECT kind, name FROM file_folders WHERE id = :i"), {"i": hub}
).one()
folder_of_job = conn.execute(
text("SELECT folder_id FROM download_jobs WHERE id = :j"), {"j": held}
).scalar_one()
finally:
command.upgrade(cfg, "head")
assert (kind, name) == ("own", f"Hub {hub} (2)")
assert folder_of_job == hub
+3
View File
@@ -96,6 +96,9 @@ class TestDownloadFilename:
def test_does_not_double_an_extension_already_present(self):
assert download_filename("clip.mp4", "mp4", Path("/x/f.mp4")) == "clip.mp4"
def test_no_extension_is_served_without_a_trailing_dot(self):
assert download_filename("Makefile", None, Path("/x/upload-7")) == "Makefile"
def test_falls_back_to_the_file_suffix_when_no_container(self):
assert download_filename("song", None, Path("/x/f.m4a")) == "song.m4a"
+122 -120
View File
@@ -661,123 +661,125 @@ of a full-width panel and nothing on the right.
## Decision log
| Date | Decision | Chosen | Over | Why |
| ---------- | ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 2026-09-24 | Floating button | Back to top: a round 48 glass FAB; "round" = floats over something (media or the page) | A Material 3 rounded square; a labelled pill; the old 64 circle | The common web convention for back to top; round tells it apart from every toolbar button; 64 was oversized |
| 2026-09-24 | Device contract | Tablet portrait + landscape and up; phones out | A 1024px floor with a "too small" message | Portrait tablet use is real, and the app already renders at 820 portrait |
| 2026-09-24 | Card actions on touch | Fewer visible, the rest in a "…" menu | 44px buttons that no longer fit the card | Six 44px targets need more width than a card has |
| 2026-09-24 | Action placement | One menu with several triggers, never gesture-only, a desktop background menu that mirrors the toolbar | Visible buttons for everything | Fewer non-critical buttons on screen, without hiding any function |
| 2026-09-24 | Text selection | Text stays selectable; a click that ends a selection does nothing | Click-to-play on titles | The user must be able to copy any text the app shows |
| 2026-09-24 | A1 Button shape | r8 + a rung per context; glass is a Button variant | r12; a pill | Nested corners (r8 in r12 in r16); the pill already means "a filter or a choice" |
| 2026-09-24 | A2 Icon buttons | Two sizes, r8; a round button only on media | One size; all round | A lone toolbar icon at the row size looks lost; round rows read as tags |
| 2026-09-24 | A3 Touch size | 44 for every control primitive | 36 + gaps; Button only | One rule (HIG 44) instead of two sizes nobody remembers |
| 2026-09-24 | A4 Input | Bordered, a 55% fg-into-bg edge (≥ 3:1) | Filled; today's look as a primitive | The only option that clears WCAG 1.4.11 without a heavy fill on glass |
| 2026-09-24 | A5 Dropdown trigger | Pill in a toolbar, r8 in a form | Pill everywhere; r8 everywhere | Matches the neighbours in each context |
| 2026-09-24 | A6 Checkbox, switch, slider | Native, accent-tinted, the row is the target | Drawn primitives | Free keyboard / screen-reader / forced-colours support; only 3 sites |
| 2026-09-24 | B1 Density | Compact 32 / 28 / 24 by explicit height | Standard 38 / 32 / 26 | The owner's read of the lab; touch is covered separately (A3), and one toolbar row lines up at 28 |
| 2026-09-24 | B2 Scheme set | Midnight, Forest→cyan, Slate→fuchsia, Matrix→lime | Today's six | The accent collided with danger (YouTube), warn (Slate) and success (Forest, Matrix); Midnight and Starship were one scheme twice; 4 of 6 light accents failed 4.5:1 |
| 2026-09-24 | B3 State layers | Text colour at 10% / 16% over anything | Material's 8 / 12%; today's opacity + bg-card | Visible in every scheme and mode (≥ 1.2:1); today's row hover was 1.00:1 on a card |
| 2026-09-24 | B4 Page chrome | One sticky band + fade; filters fold when short | A floating glass bar; everything scrolls | Tabs and filters always in reach, in one place; a keyboard-up tablet keeps its content |
| 2026-09-24 | B5 Surfaces | One class + radius per role | Today's per-site mix | Nested corners and no per-site alpha drift |
| 2026-09-24 | B6 /watch | The app's surfaces, native video controls | Flat and minimal | The page strangers see should look like the product; native controls work on any device |
| 2026-09-24 | B7 Nav rail | Soft tint + edge bar, optional labels (a user setting), slim | Solid accent (recommended); a Material rail | The owner's combination: calmer than a solid fill, labels for who wants them, no width cost |
| 2026-09-24 | B8 Loading | Skeleton by shape, spinner in a control | A spinner everywhere | Nothing jumps when data lands; a long wait doesn't look stuck |
| 2026-09-24 | B9 Fade | Toolbars / headers, panel tabs, dialog edges, chip rows | Thumbnails; buttons | The north-star depth where content scrolls under chrome; not on content or small controls |
| 2026-09-24 | C1 Action classification | The table (lab C1): state + primary at rest, everyday toggles on hover, "…" on touch, destructive into the menu | Visible buttons per site, as today | One action list per object, nothing gesture-only, fewer buttons without losing a function |
| 2026-09-24 | C2 Side panels | A tap opens an overlay drawer; pin is a separate button ≥ 1024 | A tap pins and the content reflows | Below 768 the filters were unreachable; on touch a tap pinned and pushed the grid |
| 2026-09-24 | C3 Players on touch | Tap = show the controls; 44px seek hit area; 44 buttons; the YT description behind ⓘ | Today (tap also pauses; 7.5px seek bar) | Measured live: you couldn't reach the seek bar without pausing, and the hover-only description never opened on a tap |
| 2026-09-24 | C4 Dialogs | Focus by purpose; a pinned footer | Focus on Close; the footer scrolls | A form starts in its first field, and Save stays on screen with the keyboard up |
| 2026-09-24 | C5 Plex player controls | The owner's lean set: no Stop / Restart / ±10 s buttons, Back by position, Next only with a next item, Resume + From the start on the card, touch double-tap ±10 s | Every control in one row (it ran off-screen below 1024); Back by a press counter; ±10 s keyboard-only | The row fits a portrait tablet; the decision to restart is made on the card; a tablet keeps the 10 s skip |
| 2026-09-24 | Glass tuner | Retired; the schemes are the only colour choice | Keep it with ranges clamped to the rules | Free tuning is the drift the constitution exists to stop (owner) |
| 2026-09-24 | Matrix toned down | Moss #86a05a at Midnight's lightness, near-neutral greys; every white-on-accent part → `--accent-fg` | The B2 lime #a3e635 | The owner found the lime neon and tiring; it was the loudest accent (L 0.85, C 0.21), and fixed white parts sat on it at 1.5:1 |
| 2026-09-24 | Field primitives | `Input` / `Textarea` / `InnerInput` + `fieldClass`, `SelectTrigger`, the native controls; the lint rule "no raw `<input>`" | `inputCls` and ~40 hand-styled boxes | Six edges, four radii and two focus looks for one control; the ≥ 3:1 edge held nowhere |
| 2026-09-25 | Surface roles | `SURFACE` roles in `ui/surfaces.ts`; the lint rule "no bare glass class"; menus p-1 with r8 rows | Three tiers paired ad hoc with five radii and three menu paddings | ~140 sites drifted; nested corners only hold if the radius comes with the role |
| 2026-09-25 | Static labels | Flat tonal `Badge`, no edge; a filtering tag is an edged chip | A glass pill with an edge, like the chips | Edged = pressable at a glance; a static tag that looks like a chip gets clicked and does nothing |
| 2026-09-25 | Header search | A 40 glass pill with the field edge (`lg`); what sits inside it is round | 44, r16, a glass box with a faint edge | The one rounded box among pills, and its edge under 3:1; the hero search is the YouTube/Gmail pill |
| 2026-09-25 | Switch row (F12) | The whole settings row toggles (`SwitchRow`; a hand-laid row points a label at the switch) | Only the 36×24 switch toggles | The iOS / Material settings row; the text was a dead click beside the control it names |
| 2026-09-25 | Edges and hovers (F14) | The ≥ 3:1 edge on the field family (fields, the form trigger); labelled pills keep the faint edge and the accent-edge hover | The ≥ 3:1 edge and a state-layer hover on every control | 1.4.11 asks an edge only where nothing else identifies the control; a heavy edge on every chip and tab weighs the whole UI down |
| 2026-09-25 | Rating and live (F16) | Own tokens: `--rating` (theme-scoped gold), `--live`/`--live-fg`, `--rating-media`, `--media-chrome`; palette classes lint-refused | Map them to `--warn` / `--danger` | A gold star and a red LIVE are what the eye expects; as warn/danger they would read as a status. The old amber score was 2:1 on light |
| 2026-09-25 | Fjord trial (B2 exception) | A fifth scheme, temporary: Midnight's neutrals, Playnite's steel accent (#337599 lifted to #5aa1cb) and steel rules; exit = fold into Midnight or remove | Retune Midnight straight away; borrow only the qualities | The owner wants to live with the calmer palette app-wide before it replaces the default; it breaks "own hue family" on purpose, and only until that call |
| 2026-09-25 | Whole-card row (owner) | A card row's B3 layer is the card's `::before` under its content; the open button's hit layer spans the card, the actions sit above it | The name button's own hover, ending at the first badge or button | A row that half-answers the pointer reads as broken; a badge click did nothing |
| 2026-09-25 | Badge tones (F29) | `Badge tone` neutral / accent / warn at 6%; light `--warn` #b45309 → #a14a07; edged label spans lint-refused | Keep edged status pills (a private Badge in Users, Settings "Granted", the suggestions count) | Edged = pressable at a glance; amber-700 was 4.58:1 as text and fell under 4.5 on its own tint |
| 2026-09-26 | Page band built (B4) | Controls only in the band (intros scroll); the fold shows the same row in place; one `h1` per page (`PageHeading` steps the hidden one aside); `SectionLabel` headings; Skip to content | A popover of the folded filters; the page title kept visible | A folded row is still the same controls, nothing duplicated; the rail and the tab title already name the page |
| 2026-09-26 | Fade built (B9) | `fade-below` on sticky toolbars and table headers; the scroller mask where the band sits on the backdrop | A painted band over the backdrop | The backdrop is the colour under the band, so fading the content into it is the same edge |
| 2026-09-26 | Viewport units | `dvh` everywhere (the Plex player stage with S3f) | `vh` / `h-screen` | `100vh` hides the last rows under a tablet browser's toolbar |
| 2026-09-26 | Video actions built (C1) | One list per video (menu, right-click, long-press, "…"); the card keeps watched / save / add to playlist on its picture on hover; download, details, copy, hide, reset in the menu | The six-button row under the card and the hover ⓘ | The menu is the one door every device has; the card loses a line of height and the touch card its row of 28px buttons |
| 2026-09-26 | Plex actions built (C1) | One list per Plex title (play/open, watched, add to playlist, media info, copy title, pin); the poster keeps watched / add / "…" on its picture on hover; seasons get open / watched / add / copy | The hover ⓘ, the pin-only menu, an episode's add button at rest, a collection's "Remove" X on the card | The same door as a video's; the collection grid goes to two columns on a phone so the touch "…" leaves the title its line |
| 2026-09-26 | Playlist actions built (C1) | A playlist row's "…" (play, pin, copy title / link, move, order, remove), Delete + an undo toast; one list per playlist (rename, pin, YouTube open / push / revert, delete) from its rail row and its header's "…" | The row's X column and its confirm; the header's pencil, YouTube link, push, revert and trash buttons | Remove is reversible, so it needs no confirm; the header keeps Play all; a narrow table drops "#" and Channel so the title keeps a width |
| 2026-09-26 | Channel + Plex list actions (C1) | A channel row's "…" (open, pin, YouTube, copy, full history, hide + undo, unsubscribe); a Plex playlist's title / show / season "…", Delete + undo; its header's "…" (rename, delete); an action toast lasts ≥ 8 s | The channel row's full-history, hide and unsubscribe buttons; the Plex list's red X on every row, show and season; the header's pencil and trash | Hiding and removing are reversible, so they need no confirm and the undo needs time; unsubscribe and deleting a playlist keep theirs |
| 2026-09-26 | Download / notification / Scheduler actions (C1) | A download row's "…" (play, share, save, edit, details, re-download, manage sharing, copies, cancel / delete / remove); a library row's Share stays in sight, ahead of save; a notification row's "…" and hover dismiss; the inbox band's and the Scheduler strip's "…" | The download row's five rare buttons and its cancel / delete / remove; the source link's copy button; an activity notice's Find / Undo links; the band's Clear everything; the strip's Purge discovery | The destructive rows keep their confirm, and Clear everything gains one; a row link keeps the browser's own menu; Find in feed drops the tag / search / age filters that could hide its video |
| 2026-09-26 | Channel menu: Tags + Priority (C1) | Tags A–Z as in-place checks; Priority as fixed steps 10 · 5 · 3 · 2 · 1 · 0, the current one ticked; a pick re-sorts at once and the page + focus follow the row; one drawn `MenuCheck` for every checkable row that cannot be an `<input>` | Relative steps (+1 / +5); straight to the top; the stepper only; seven hand-drawn check marks (three box sizes, a tinted box, a × for "on", a trailing tick) | Bubbling a channel up is one pick; fixed levels keep the scale the reader already uses; the list moving where the reader can see it is the feedback |
| 2026-09-26 | Reorder without a drag (WCAG 2.5.7) | Row menus: Top / Up / Down / Bottom (YouTube + Plex playlists); saved views: four buttons with the place beside the selected view (owner: A); side panels in edit mode: an Up / Down pair per group (owner: A), the name wrapping there | A "…" on every saved-view row; a "…" on every panel group; drag only | A visible, one-click path where the list is edited; the edge moves shown off so the buttons never shift |
| 2026-09-26 | Titles in click surfaces | The surface stays a button, flagged `selectable`: its text selects, and the click that ends a selection does nothing; every plain `Pressable` pinned with its reason | Rebuilding each card and row so the title sits outside the button; exceptions for master-detail lists | Chrome selects inside a button once it is `user-select: text` (measured), so one flag on the primitive keeps the keyboard, focus and list walking every surface already has |
| 2026-09-26 | Right-click on a selection | The browser's own menu opens on selected words; beside them the object's menu | A "Copy selection" row in the app menu | The reader selected the words to do something with them; the native menu copies, searches and translates, and changes nothing else |
| 2026-09-26 | Keyboard ring on a flush row | Inside the row (a table row by its tag, a log line, a show header in its clipping card, with the card's corners) | The ring outside, as on a card | Outside it ran under the table head and past the table, and a clipping card cut it away entirely (invisible focus) |
| 2026-09-26 | Fade at rest | The sticky header's fade appears with the first 1rem of scroll (a scroll-driven animation; no fade where unsupported) | A fade that is always drawn | At rest it dimmed the first row and hid its ring's top edge; nothing is under the header yet |
| 2026-09-26 | Narrow playlist table | "#" and Channel hide by the table's own width (under 42rem), not the window's | Hiding below the `sm` window width | Beside the playlist rail a desktop window left the table 432 px and the title 0 px |
| 2026-09-26 | Background menu built | Five list pages (feed, Plex grid, channels, playlist, notifications); the toolbar's sort / view / filters and the band's actions; no destructive row | Every page with a toolbar; the "…" menus copied whole, delete included | The tab-only pages have nothing a menu would add; a blank-area right-click must not be one click from delete |
| 2026-09-26 | Media card anatomy (C) | One frame; the meta line pinned to its bottom; the length a badge on the picture; the Plex card on the same primitive as the video card | A: the year as an overline over the title; B: the year inline after it; a fixed two-line title slot | A one-line title left a hole the eye reads as a missing line; C puts nothing under a variable-height title |
| 2026-09-26 | One Play | `MediaPlayGlyph` (the media button variant) and only where the click plays | The Plex card's hand-painted accent circle; a playable dot on every card | One Play for every card; a dot that only a colour and a tooltip explain says nothing |
| 2026-09-26 | Filtering values on the info page | A value that filters (year, certificate, studio, director) is an edged chip; a fact is flat text | Everything flat, the clickable ones only hover-tinted | The S3d rule: edged = pressable, so the reader sees which values do something |
| 2026-09-26 | Shelf density | On any strip: one title line over one meta line | The grid's two lines and full meta on a strip | The tallest card set the strip's height and left holes in the cards with less to say |
| 2026-09-26 | Compact list row (B) | The title leads; a faint "…" at the end; the toggles float over the date on hover | A leading 128px action column | That column sat empty at rest, a hole at the start of every row |
| 2026-09-26 | Media-info layout (B) | A main column (collections first, crew, cast) beside a facts sidebar; an empty column is not drawn | Every section full width, stacked | The collection is the most used; the keywords' column left a void beside a short details box |
| 2026-09-26 | Mixed Home shelf (A) | Plex titles as wide art beside videos | Posters, with the videos not stretched | Stretching a video card to a poster's height left an ~80px gap |
| 2026-09-27 | Home hero | A card (hover row + Play on the picture, the whole menu, a touch "…"; owner: A); three quarters wide and centred on a wide window; one fact line; the summary at the measure | The hero's pin row alone; full width; an 18rem picture column | The one answer stands in focus, and it offers what the same card offers anywhere |
| 2026-09-27 | Home shelf tiles | A channel / playlist tile: Play, ↗ open, "…" with Open; a show acts on its next episode, ⓘ opens it; the shelf slot 232px | The tile's pin row alone; 200px | A tile without its object's actions read as broken; at 200 the Play touched the hover row |
| 2026-09-27 | Synopsis preview (C) | A ~0.9 s mouse rest opens a passive panel beside the card | The synopsis as the picture's `title` tooltip; an ⓘ to hover; none | One long tooltip line fired anywhere on the art and clashed with the cut title's own tooltip |
| 2026-09-27 | Reading measure | Prose capped at ~65 characters a line (`measure`) | The container's full width | At 110–130 characters a line the eye loses the next line's start |
| 2026-09-24 | Chip family | One `Chip` (filter / token / tag / suggest / ghost) + `DismissibleChip`; nothing under 24; the lint rule "no raw `<button>` outside ui/" | Five chip geometries in five files | The applied-filter row, the tag cells, the year picker and the saved-view editor each drew their own chip; a one-letter tag was 21px wide |
| 2026-09-24 | Button families | `Button` / `IconButton` / `Pressable` / chips / `DragHandle`; `lg` 40 for a hero CTA; pills and a 48 Play on media (from C5) | Every button hand-painted | 268 hand-rolled buttons in 79 files drifted into as many looks; the ladder only holds if every button stands on it |
| 2026-09-27 | C4 as built | `initialFocus` required on every `Modal`; the commit row in `ModalFooter`, portalled under the body from any depth; the fades driven by the body's scroll timeline; `interactive-widget=resizes-content` | An optional prop defaulting to the ✕; a `footer` prop (the issue composer's buttons live three components down); always-on fades | A default is how 21 dialogs came to open on the ✕; lifting the composer's state to reach a prop is the larger change; a resting fade dimmed the first line |
| 2026-09-27 | C3 + C5 as built | A tap toggles the bar through `pointerType` from `pointerdown`; a double-tap takes back its first tap's toggle; the card's pair is the video card's `MediaResumeButtons` (Continue / Restart), a sibling over the picture; the YT description is an ⓘ click popover | A delayed single tap (wait out the double-tap window); new "Resume / From the start" words for Plex; the pair inside the picture's button | A delayed tap feels dead; one media card says one pair of words; a button inside a button is invalid and the hover row already sits beside the picture |
| 2026-09-27 | C2 as built | The side panel's drawer is `ui/Modal` `drawer-start`; drawer = a coarse pointer or below 768; the pin offered in its header ≥ 1024 | A drawer of its own inside `SidePanel` | Esc, Back, the scrim, the trap and the scroll lock are the dialog's; a second implementation is how two shells drift |
| 2026-09-30 | Composition and the reader (E30 S6) | A chapter and a Conformance step: the space is used, one focal point, worst real data, where am I, a click answers at once, zoom and text size first-class; the person page as two columns (facts and bio left, "Known for" right) | Rules for pieces only; the bio in a full-width panel; a narrower panel | The person page passed every piece rule with half its panel empty; the owner reads zoomed in, so zoom is a requirement, not a nicety |
| 2026-09-30 | Hungarian words (E30 S5) | The D3 list: Feed, Shorts, Admin, Token, Port stay; stream, Prio, Job, Audit, Demo, Auto, Info, Frontend/Backend, Backfill, transcode get the Hungarian word; one name per thing (kérelem, behúzás, one "Test connection") | Keep every loanword; translate Feed/Shorts too | The owner chose the list (D3); the final sweep found the same feature under two names in three places |
| 2026-09-29 | Numbers and order in the UI language (E30 S3) | Every number through `lib/format.ts` in the UI language (decimal mark, grouping, compact, units); A→Z by the shown name in the UI language's alphabet, client and server alike; the language comes off `useTranslation`, never the singleton | `toFixed`, hand-built "1.2K", `localeCompare` in the browser's locale, byte-order server sorts | A Hungarian reader saw "8.3", "1.2K", "Életrajzi" after "Z"; the singleton read also left values in the old language after a switch (React Compiler) |
| 2026-09-27 | Narrow rows by container | The download row, the playlist table and the Channels tag row stack by a container query; a fact line never wraps | Breakpoints on the window | The iPad zoomed in, split screen and a column beside a rail are narrow while the window is not; the zoomed download row broke "18.9 / MB" apart |
| 2026-09-27 | Phones deferred | A phone-only layout (the hamburger nav, a folded feed band) waits for its own epic; E28 stays desktop + tablet | Fix the phone views inside E28 | Principle 2; the first phone attempts made the feed worse, and the epic's review grows with every sprint |
| 2026-09-27 | Conformance check (S3g) | A four-step check before a UI change is done: primitive at a named rung, the chapter re-read, the rendered page measured by kind and look, every state seen | A reviewer's eye per sprint | The S1 probe found 69 button looks; after S3 every control kind is drawn by its primitive, and the check keeps a new look from arriving unnoticed |
| 2026-09-27 | Segmented rung | `SegmentedToggle` defaults to the toolbar rung (28, `text-xs`); 24 only for a panel's captioned match toggle | The small default | Two toolbars had inherited 10px type from the default beside 28px buttons |
| 2026-09-27 | Copy rule (S3g) | One name per feature (the rail may shorten it); four verbs for four meanings; sentence case with the app's places capitalised; "…" when a command asks first; one-sentence messages without a stop; curly quotes; dates relative in lists with the exact minute on hover, three absolute forms; units from Intl, short | Word-for-word names everywhere; the verbs as they were; all lower case; no "…"; always a stop; always absolute dates; long or compact units | The copy survey found 7 EN and 6 HU double names, 22 Delete against 27 Remove, five date shapes and three spellings of a minute; the owner picked every recommended option |
| 2026-09-27 | One glyph, one meaning (S4) | Play only for playback or resuming a paused transfer; Retry = `RefreshCw`; reset / revert = `RotateCcw` | Retry drawn with Play | The first UI-review run (Downloads) found a failed row whose Retry showed ▷ in the row and its menu |
| 2026-09-27 | Fjord kept | Fjord stays a fifth scheme beside Midnight; the trial wording goes | Fold it into Midnight; remove it | The owner lived with it and likes it; the vivid Midnight stays for those who want it. B2's own-hue rule still binds any new scheme |
| 2026-09-27 | Tablet columns | A table hides `tabletHidden` columns below 64rem of its own width; Channels drops Prio, About, Length | Hide About only; give the name a bigger share | At 1024 the channel name had 60 px; the owner chose to drop all three there |
| 2026-09-27 | Glyph pager | First / previous / next / last as glyph buttons round the page box, names as tooltips | Words beside the arrows (hidden only on a phone) | The owner's call: the words widened the row; four arrows are the shared convention |
| 2026-09-27 | Match switch on the panel | `ui/MatchToggle` above the picker from two picks, everywhere; one wording (Any / All) | The picker's popover footer (Feed, Plex collection) | A closed picker hid an active "all"; six pickers had drawn it two ways |
| 2026-09-27 | Missing picture, bare link | A glyph of the kind for no picture; a download with no name is titled by its short link, once | An empty box; the URL as title, channel and link | The owner's picks 4A and 3A: an empty box read as loading, the URL showed three times |
| 2026-09-27 | Copy on a selection | Right-click on selected words keeps the browser's menu; no app Copy row | An app menu with Copy first | A real browser's menu has copy, search and translate; only the desktop pane's is thin |
| 2026-10-01 | Zoom target 125–133% | Screens are held at 125% and 133% browser zoom (1536 × 729, 1444 × 685 on a 1080p window) and the largest text size | 200% browser zoom | The owner: 200% was a guess; 125–133% is what they read Siftlode and most sites at, so 200% would be an upper bound nobody uses |
| 2026-10-01 | E31 Nav rail at short heights | One "Admin" rail item opening a panel of the admin pages | A denser rail when short; a visible "+4" hint | The rail fits at 133–150% without a hidden scroll; the admin pages are rare and admin-only |
| 2026-10-01 | E31 Configuration sections | A vertical, grouped section list, as Settings has | Group tabs + sub-tabs; one long page with a contents list | Sixteen tabs wrapped to three rows at 133%; one pattern for both settings pages |
| 2026-10-01 | E31 Statistics overview | Number tiles, and the per-operation counts as horizontal bars | Three narrow cards; a 30-day chart | The label sits by its value; what spends the quota is seen at a glance |
| 2026-10-01 | E31 Channel page header | The full banner, folding to one row once the grid scrolls | Always compact; no banner | The header is fixed and took 40% of a 685px window; the channel keeps its face at first sight |
| 2026-10-01 | E31 Channel page watch state | A fresh channel page shows the unwatched videos; a picked state is remembered | Show all, forgotten on reload | The owner: open a channel to see what is new; a choice made there should stick |
| 2026-10-01 | E31 Audit log runs | A job's successful runs fold into one row that opens, whatever ran between; only that job's failure (never folded, danger colour) breaks it | A filter defaulting to admin actions; both | Nothing hidden, the noise collapsed, the failure seen |
| 2026-10-01 | E31 Users row | Role and state as badges, the actions in the row's one menu | An inline role toggle; icon-only buttons | Action placement: the row shows state, rare and destructive actions live in the menu |
| 2026-10-01 | E31 YouTube player | Video beside an info panel at 1280px and wider (the video as large as the height allows); stacked below | Today's stack with a one-line bar; full-window like Plex | The owner's eyesight: about 1460px of video on 1080p instead of 1008, the actions still visible |
| 2026-10-01 | E31 Plex info and show pages | A second column for the facts, keywords and crew, as the person page; a collection's hero sized to its content | Full-width text; a larger poster and fanart | The right ~40% was empty; one composition for every media page |
| 2026-10-01 | E31 Nav rail rows are links | Each page row is a real link: a middle-click or Ctrl-click opens the page in a new tab | A button | The owner: open a page in a tab of its own, as on any site |
| 2026-10-02 | E31 Plex detail second column (refined) | The facts over the key crew beside the text; the keywords, chips that filter like the genres, under the text; the show page gets all three too | All three in the column, as the mockup drew it | At 20 keywords and 12 crew the column ran to twice the text's height (849 vs ~400 px at 133%): a hole under the text instead of beside it |
| 2026-10-02 | E31 Plex detail poster and crew | The movie and show poster keeps its 2:3 and grows a rung in a wide hero (w-56 at a 64rem panel); the crew shows its first 6 names and "Show N more" (a list of 8 or fewer shows whole) | Poster stretched to the row; a "More" on the keywords | The row is as tall as the crew column, so the poster drew ~175×590 and cropped; the crew, not the keywords, set the hero's height |
| 2026-10-04 | E32 `field` focus skips a mode switch | A form opens on its first field; a control above it that is not a field (the composer's Edit / Preview) is passed over by marking the field `data-initial-focus` | The first text field everywhere | That moved the focus in the download dialog off its profile choice and in the upload dialog off its drop zone; the mark changes only the form that needs it |
| 2026-10-04 | E35 Lint for z-levels and edged labels | Both rules stay; the two eslint selectors (`Z_RAW`, `EDGED_LABEL`) that refused them go | Keep the selectors | 0 hits since the E28 sweep that fixed the sites: a check that never fires costs a probe test and a snapshot each, and the UI review holds both rules |
| 2026-10-06 | `player` focus | A dialog that is a player opens focused on its panel (the YouTube player's rule, now `Modal`'s too); on the media element itself, Space and the arrows stay the element's own | `close`, or the media element | On the ✕ Space closed the player; on the element the browser's own controls acted on the same key as the player's (one Space played and paused, measured in Chrome) |
| 2026-10-08 | Captions on touch | A `Tooltip` opens for a mouse and a key's focus only, never for a finger | A caption on tap; a long-press caption | On an iPad the tap that showed the caption was taken as a hover and lost its click (every rail button took two taps), and no finger sends the mouseleave that hides it; the rail's labels setting names the buttons on touch |
| 2026-10-08 | Header search on touch | No key cap on a touch screen; the pill grows to 52 px so a ring shows round the 44 px Go; Go drops to its icon by the column's width | Keep the key cap; a 44 px Go overhanging a 40 px pill | On a tablet the 44 px key cap and Go sat on the pill's edge and cut the placeholder; an overhanging Go breaks the nested radii |
| 2026-10-08 | Plex subtitles and the control bar | The player draws the cues itself; while the bar shows they rise above it (200 ms), then settle back | The browser's own cues, moved by `line` | On an iPad the bar covered the text; a native cue cannot be animated or measured, and our layer behaves the same on every engine |
| 2026-10-08 | Hover on a button over a picture | The `media` variant's hover is a 2px accent ring over a darker body (Plex + YT cards, rail arrows; option B) | An accent fill (reads as "selected"); a white wash (weak on a bright poster) | The 60 → 75% black step was invisible, and over a card a rail arrow could not be told from the card's own Play under it |
| 2026-10-08 | Plex "Add to collection" | A movie or show card's menu row opens a panel beside the card: its library's manual collections tick in place, a name + Enter creates one with the title in it (admin; option A) | An Add submenu with a "New…" dialog; the per-title editor modal from the menu | A collection took a trip to the Collections page, a name, a title search, then a second search per title; the panel is the video card's "Add to playlist" |
| 2026-10-08 | YouTube modal chrome | No prev/next strips beside the card; Close an icon (the panel's top right; stacked, the title row's end); the description clamped to three lines + More; the panel shuts to a 2.75rem strip of close, expand, prev/next (option D1) | Keep the strips; a "Close" text button; the full description in a scroller; a panel that hides outright | The strips cost the video 7rem of width for a second copy of the panel's buttons; a long description pushed the channel and the actions out of sight; a hidden panel left no way to step the queue without the keyboard |
| 2026-10-08 | The wheel over the YouTube modal | Over the video: volume. Over a panel with something to scroll: scroll only, never volume, not even at its end. Over a panel with nothing to scroll: volume (owner) | Volume everywhere; a scroll that hands over to the volume at its end | Reading a long description turned the volume down once it hit the end |
| 2026-10-09 | A Plex show card plays | Its picture plays the show's next episode everywhere (an episode with a resume point → Continue + Restart, else the first unwatched, else S1·E1), resolved on the click; the preview names it once up; title and ⓘ open the show (option A + hover ask) | The picture opens the show page (as before); every card carrying `next` | A show card on Discover had no Play while Home's had one; carrying `next` costs a grid page up to 180 queries for the few cards anyone plays |
| 2026-10-10 | Clip mode in the players (E38) | Scissors in the control row + `C`, `[` `]`; the panel in the player with the watched video as preview; overview + zoomed strip on `ui/RangeTrack` (ends are sliders); a toast + the Ready notification after Cut | A ⋯ menu entry; permanent In / Out buttons; a big dialog with its own preview (a second Plex transcode / YouTube iframe) | Owner's picks A, P1, a; a film is hours and a clip seconds, so only a zoomed strip gives a hand the precision. The Downloads editor's cuts moved onto the same track (keyboard-steppable now) |
| 2026-10-10 | Crop in clip mode (E38) | A Crop toggle in the clip panel: a frame over the playing picture (`ui/CropFrame`, the editor's crop too), Free / 16:9 / 9:16 / 1:1, Whole picture; maximised YouTube keeps the panel in the bar's place; the wide side panel shuts while clipping | A frame always on the picture in clip mode (K2); a free shape only | Owner's K1 + a: a crop is rare, so it never covers the picture unasked; a phone or square clip needs a held shape. The iframe hides its picture's size: drawn on the 16:9 box, the cut fits it to the real picture |
| 2026-10-10 | Marks on the player chrome | `--media-mark` (amber) and `--media-danger` (light red), the same in both themes; `media-marks` points the focus ring (`--focus`) and the chosen mark (`--chosen`: a track's tick, a tab's underline; the label stays white) at the amber on the player and every chrome surface; `media-scope` (a form) also the errors at the red | The theme's accent ring, chosen text, warn and danger | On the chrome (#171717) a light theme's accent ring (Slate 2.84:1), warn (2.99:1) and danger (2.77:1) all fall under 3:1; the accent as chosen text under 4.5:1 |
| 2026-10-11 | Files page (one library) | List rows by default, the media grid a toggle remembered per folder; a sidebar of automatic source folders, own folders, Shared with me and the storage meter; the paste-a-link box in the toolbar | A media grid by default | The library holds videos AND plain files: rows read both, show more per screen and scan by size and date |
| 2026-10-11 | Library list columns | Every kind of value in its own column (name, source, phone-safe, retention, size, added, actions), one grid with a subgrid per row so they line up; an action that does not apply keeps its slot empty; a right-click menu on every row (the ⋯ opens the same menu) | Badges flowing after the title; icons that shift when one is missing | Owner: values of one kind must fall in one vertical line; a row's every action reachable by right click |
| 2026-10-11 | Running downloads | An app-wide tray, bottom right, shown only while something runs or failed (reason + retry), foldable to a pill | A rail icon with a count and a popover | Downloading became an action you start anywhere; a running one must be visible without going to look |
| 2026-10-11 | Download done | The notification says whether it plays on a phone and carries Link másolása + Megnyitás (the folder, the item focused); never an automatic jump | A plain "ready" notice; auto-navigation to the item | The daily path is paste → done → copy the link; a jump would pull the user out of what they were doing |
| 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 |
| Date | Decision | Chosen | Over | Why |
| ---------- | ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 2026-09-24 | Floating button | Back to top: a round 48 glass FAB; "round" = floats over something (media or the page) | A Material 3 rounded square; a labelled pill; the old 64 circle | The common web convention for back to top; round tells it apart from every toolbar button; 64 was oversized |
| 2026-09-24 | Device contract | Tablet portrait + landscape and up; phones out | A 1024px floor with a "too small" message | Portrait tablet use is real, and the app already renders at 820 portrait |
| 2026-09-24 | Card actions on touch | Fewer visible, the rest in a "…" menu | 44px buttons that no longer fit the card | Six 44px targets need more width than a card has |
| 2026-09-24 | Action placement | One menu with several triggers, never gesture-only, a desktop background menu that mirrors the toolbar | Visible buttons for everything | Fewer non-critical buttons on screen, without hiding any function |
| 2026-09-24 | Text selection | Text stays selectable; a click that ends a selection does nothing | Click-to-play on titles | The user must be able to copy any text the app shows |
| 2026-09-24 | A1 Button shape | r8 + a rung per context; glass is a Button variant | r12; a pill | Nested corners (r8 in r12 in r16); the pill already means "a filter or a choice" |
| 2026-09-24 | A2 Icon buttons | Two sizes, r8; a round button only on media | One size; all round | A lone toolbar icon at the row size looks lost; round rows read as tags |
| 2026-09-24 | A3 Touch size | 44 for every control primitive | 36 + gaps; Button only | One rule (HIG 44) instead of two sizes nobody remembers |
| 2026-09-24 | A4 Input | Bordered, a 55% fg-into-bg edge (≥ 3:1) | Filled; today's look as a primitive | The only option that clears WCAG 1.4.11 without a heavy fill on glass |
| 2026-09-24 | A5 Dropdown trigger | Pill in a toolbar, r8 in a form | Pill everywhere; r8 everywhere | Matches the neighbours in each context |
| 2026-09-24 | A6 Checkbox, switch, slider | Native, accent-tinted, the row is the target | Drawn primitives | Free keyboard / screen-reader / forced-colours support; only 3 sites |
| 2026-09-24 | B1 Density | Compact 32 / 28 / 24 by explicit height | Standard 38 / 32 / 26 | The owner's read of the lab; touch is covered separately (A3), and one toolbar row lines up at 28 |
| 2026-09-24 | B2 Scheme set | Midnight, Forest→cyan, Slate→fuchsia, Matrix→lime | Today's six | The accent collided with danger (YouTube), warn (Slate) and success (Forest, Matrix); Midnight and Starship were one scheme twice; 4 of 6 light accents failed 4.5:1 |
| 2026-09-24 | B3 State layers | Text colour at 10% / 16% over anything | Material's 8 / 12%; today's opacity + bg-card | Visible in every scheme and mode (≥ 1.2:1); today's row hover was 1.00:1 on a card |
| 2026-09-24 | B4 Page chrome | One sticky band + fade; filters fold when short | A floating glass bar; everything scrolls | Tabs and filters always in reach, in one place; a keyboard-up tablet keeps its content |
| 2026-09-24 | B5 Surfaces | One class + radius per role | Today's per-site mix | Nested corners and no per-site alpha drift |
| 2026-09-24 | B6 /watch | The app's surfaces, native video controls | Flat and minimal | The page strangers see should look like the product; native controls work on any device |
| 2026-09-24 | B7 Nav rail | Soft tint + edge bar, optional labels (a user setting), slim | Solid accent (recommended); a Material rail | The owner's combination: calmer than a solid fill, labels for who wants them, no width cost |
| 2026-09-24 | B8 Loading | Skeleton by shape, spinner in a control | A spinner everywhere | Nothing jumps when data lands; a long wait doesn't look stuck |
| 2026-09-24 | B9 Fade | Toolbars / headers, panel tabs, dialog edges, chip rows | Thumbnails; buttons | The north-star depth where content scrolls under chrome; not on content or small controls |
| 2026-09-24 | C1 Action classification | The table (lab C1): state + primary at rest, everyday toggles on hover, "…" on touch, destructive into the menu | Visible buttons per site, as today | One action list per object, nothing gesture-only, fewer buttons without losing a function |
| 2026-09-24 | C2 Side panels | A tap opens an overlay drawer; pin is a separate button ≥ 1024 | A tap pins and the content reflows | Below 768 the filters were unreachable; on touch a tap pinned and pushed the grid |
| 2026-09-24 | C3 Players on touch | Tap = show the controls; 44px seek hit area; 44 buttons; the YT description behind ⓘ | Today (tap also pauses; 7.5px seek bar) | Measured live: you couldn't reach the seek bar without pausing, and the hover-only description never opened on a tap |
| 2026-09-24 | C4 Dialogs | Focus by purpose; a pinned footer | Focus on Close; the footer scrolls | A form starts in its first field, and Save stays on screen with the keyboard up |
| 2026-09-24 | C5 Plex player controls | The owner's lean set: no Stop / Restart / ±10 s buttons, Back by position, Next only with a next item, Resume + From the start on the card, touch double-tap ±10 s | Every control in one row (it ran off-screen below 1024); Back by a press counter; ±10 s keyboard-only | The row fits a portrait tablet; the decision to restart is made on the card; a tablet keeps the 10 s skip |
| 2026-09-24 | Glass tuner | Retired; the schemes are the only colour choice | Keep it with ranges clamped to the rules | Free tuning is the drift the constitution exists to stop (owner) |
| 2026-09-24 | Matrix toned down | Moss #86a05a at Midnight's lightness, near-neutral greys; every white-on-accent part → `--accent-fg` | The B2 lime #a3e635 | The owner found the lime neon and tiring; it was the loudest accent (L 0.85, C 0.21), and fixed white parts sat on it at 1.5:1 |
| 2026-09-24 | Field primitives | `Input` / `Textarea` / `InnerInput` + `fieldClass`, `SelectTrigger`, the native controls; the lint rule "no raw `<input>`" | `inputCls` and ~40 hand-styled boxes | Six edges, four radii and two focus looks for one control; the ≥ 3:1 edge held nowhere |
| 2026-09-25 | Surface roles | `SURFACE` roles in `ui/surfaces.ts`; the lint rule "no bare glass class"; menus p-1 with r8 rows | Three tiers paired ad hoc with five radii and three menu paddings | ~140 sites drifted; nested corners only hold if the radius comes with the role |
| 2026-09-25 | Static labels | Flat tonal `Badge`, no edge; a filtering tag is an edged chip | A glass pill with an edge, like the chips | Edged = pressable at a glance; a static tag that looks like a chip gets clicked and does nothing |
| 2026-09-25 | Header search | A 40 glass pill with the field edge (`lg`); what sits inside it is round | 44, r16, a glass box with a faint edge | The one rounded box among pills, and its edge under 3:1; the hero search is the YouTube/Gmail pill |
| 2026-09-25 | Switch row (F12) | The whole settings row toggles (`SwitchRow`; a hand-laid row points a label at the switch) | Only the 36×24 switch toggles | The iOS / Material settings row; the text was a dead click beside the control it names |
| 2026-09-25 | Edges and hovers (F14) | The ≥ 3:1 edge on the field family (fields, the form trigger); labelled pills keep the faint edge and the accent-edge hover | The ≥ 3:1 edge and a state-layer hover on every control | 1.4.11 asks an edge only where nothing else identifies the control; a heavy edge on every chip and tab weighs the whole UI down |
| 2026-09-25 | Rating and live (F16) | Own tokens: `--rating` (theme-scoped gold), `--live`/`--live-fg`, `--rating-media`, `--media-chrome`; palette classes lint-refused | Map them to `--warn` / `--danger` | A gold star and a red LIVE are what the eye expects; as warn/danger they would read as a status. The old amber score was 2:1 on light |
| 2026-09-25 | Fjord trial (B2 exception) | A fifth scheme, temporary: Midnight's neutrals, Playnite's steel accent (#337599 lifted to #5aa1cb) and steel rules; exit = fold into Midnight or remove | Retune Midnight straight away; borrow only the qualities | The owner wants to live with the calmer palette app-wide before it replaces the default; it breaks "own hue family" on purpose, and only until that call |
| 2026-09-25 | Whole-card row (owner) | A card row's B3 layer is the card's `::before` under its content; the open button's hit layer spans the card, the actions sit above it | The name button's own hover, ending at the first badge or button | A row that half-answers the pointer reads as broken; a badge click did nothing |
| 2026-09-25 | Badge tones (F29) | `Badge tone` neutral / accent / warn at 6%; light `--warn` #b45309 → #a14a07; edged label spans lint-refused | Keep edged status pills (a private Badge in Users, Settings "Granted", the suggestions count) | Edged = pressable at a glance; amber-700 was 4.58:1 as text and fell under 4.5 on its own tint |
| 2026-09-26 | Page band built (B4) | Controls only in the band (intros scroll); the fold shows the same row in place; one `h1` per page (`PageHeading` steps the hidden one aside); `SectionLabel` headings; Skip to content | A popover of the folded filters; the page title kept visible | A folded row is still the same controls, nothing duplicated; the rail and the tab title already name the page |
| 2026-09-26 | Fade built (B9) | `fade-below` on sticky toolbars and table headers; the scroller mask where the band sits on the backdrop | A painted band over the backdrop | The backdrop is the colour under the band, so fading the content into it is the same edge |
| 2026-09-26 | Viewport units | `dvh` everywhere (the Plex player stage with S3f) | `vh` / `h-screen` | `100vh` hides the last rows under a tablet browser's toolbar |
| 2026-09-26 | Video actions built (C1) | One list per video (menu, right-click, long-press, "…"); the card keeps watched / save / add to playlist on its picture on hover; download, details, copy, hide, reset in the menu | The six-button row under the card and the hover ⓘ | The menu is the one door every device has; the card loses a line of height and the touch card its row of 28px buttons |
| 2026-09-26 | Plex actions built (C1) | One list per Plex title (play/open, watched, add to playlist, media info, copy title, pin); the poster keeps watched / add / "…" on its picture on hover; seasons get open / watched / add / copy | The hover ⓘ, the pin-only menu, an episode's add button at rest, a collection's "Remove" X on the card | The same door as a video's; the collection grid goes to two columns on a phone so the touch "…" leaves the title its line |
| 2026-09-26 | Playlist actions built (C1) | A playlist row's "…" (play, pin, copy title / link, move, order, remove), Delete + an undo toast; one list per playlist (rename, pin, YouTube open / push / revert, delete) from its rail row and its header's "…" | The row's X column and its confirm; the header's pencil, YouTube link, push, revert and trash buttons | Remove is reversible, so it needs no confirm; the header keeps Play all; a narrow table drops "#" and Channel so the title keeps a width |
| 2026-09-26 | Channel + Plex list actions (C1) | A channel row's "…" (open, pin, YouTube, copy, full history, hide + undo, unsubscribe); a Plex playlist's title / show / season "…", Delete + undo; its header's "…" (rename, delete); an action toast lasts ≥ 8 s | The channel row's full-history, hide and unsubscribe buttons; the Plex list's red X on every row, show and season; the header's pencil and trash | Hiding and removing are reversible, so they need no confirm and the undo needs time; unsubscribe and deleting a playlist keep theirs |
| 2026-09-26 | Download / notification / Scheduler actions (C1) | A download row's "…" (play, share, save, edit, details, re-download, manage sharing, copies, cancel / delete / remove); a library row's Share stays in sight, ahead of save; a notification row's "…" and hover dismiss; the inbox band's and the Scheduler strip's "…" | The download row's five rare buttons and its cancel / delete / remove; the source link's copy button; an activity notice's Find / Undo links; the band's Clear everything; the strip's Purge discovery | The destructive rows keep their confirm, and Clear everything gains one; a row link keeps the browser's own menu; Find in feed drops the tag / search / age filters that could hide its video |
| 2026-09-26 | Channel menu: Tags + Priority (C1) | Tags A–Z as in-place checks; Priority as fixed steps 10 · 5 · 3 · 2 · 1 · 0, the current one ticked; a pick re-sorts at once and the page + focus follow the row; one drawn `MenuCheck` for every checkable row that cannot be an `<input>` | Relative steps (+1 / +5); straight to the top; the stepper only; seven hand-drawn check marks (three box sizes, a tinted box, a × for "on", a trailing tick) | Bubbling a channel up is one pick; fixed levels keep the scale the reader already uses; the list moving where the reader can see it is the feedback |
| 2026-09-26 | Reorder without a drag (WCAG 2.5.7) | Row menus: Top / Up / Down / Bottom (YouTube + Plex playlists); saved views: four buttons with the place beside the selected view (owner: A); side panels in edit mode: an Up / Down pair per group (owner: A), the name wrapping there | A "…" on every saved-view row; a "…" on every panel group; drag only | A visible, one-click path where the list is edited; the edge moves shown off so the buttons never shift |
| 2026-09-26 | Titles in click surfaces | The surface stays a button, flagged `selectable`: its text selects, and the click that ends a selection does nothing; every plain `Pressable` pinned with its reason | Rebuilding each card and row so the title sits outside the button; exceptions for master-detail lists | Chrome selects inside a button once it is `user-select: text` (measured), so one flag on the primitive keeps the keyboard, focus and list walking every surface already has |
| 2026-09-26 | Right-click on a selection | The browser's own menu opens on selected words; beside them the object's menu | A "Copy selection" row in the app menu | The reader selected the words to do something with them; the native menu copies, searches and translates, and changes nothing else |
| 2026-09-26 | Keyboard ring on a flush row | Inside the row (a table row by its tag, a log line, a show header in its clipping card, with the card's corners) | The ring outside, as on a card | Outside it ran under the table head and past the table, and a clipping card cut it away entirely (invisible focus) |
| 2026-09-26 | Fade at rest | The sticky header's fade appears with the first 1rem of scroll (a scroll-driven animation; no fade where unsupported) | A fade that is always drawn | At rest it dimmed the first row and hid its ring's top edge; nothing is under the header yet |
| 2026-09-26 | Narrow playlist table | "#" and Channel hide by the table's own width (under 42rem), not the window's | Hiding below the `sm` window width | Beside the playlist rail a desktop window left the table 432 px and the title 0 px |
| 2026-09-26 | Background menu built | Five list pages (feed, Plex grid, channels, playlist, notifications); the toolbar's sort / view / filters and the band's actions; no destructive row | Every page with a toolbar; the "…" menus copied whole, delete included | The tab-only pages have nothing a menu would add; a blank-area right-click must not be one click from delete |
| 2026-09-26 | Media card anatomy (C) | One frame; the meta line pinned to its bottom; the length a badge on the picture; the Plex card on the same primitive as the video card | A: the year as an overline over the title; B: the year inline after it; a fixed two-line title slot | A one-line title left a hole the eye reads as a missing line; C puts nothing under a variable-height title |
| 2026-09-26 | One Play | `MediaPlayGlyph` (the media button variant) and only where the click plays | The Plex card's hand-painted accent circle; a playable dot on every card | One Play for every card; a dot that only a colour and a tooltip explain says nothing |
| 2026-09-26 | Filtering values on the info page | A value that filters (year, certificate, studio, director) is an edged chip; a fact is flat text | Everything flat, the clickable ones only hover-tinted | The S3d rule: edged = pressable, so the reader sees which values do something |
| 2026-09-26 | Shelf density | On any strip: one title line over one meta line | The grid's two lines and full meta on a strip | The tallest card set the strip's height and left holes in the cards with less to say |
| 2026-09-26 | Compact list row (B) | The title leads; a faint "…" at the end; the toggles float over the date on hover | A leading 128px action column | That column sat empty at rest, a hole at the start of every row |
| 2026-09-26 | Media-info layout (B) | A main column (collections first, crew, cast) beside a facts sidebar; an empty column is not drawn | Every section full width, stacked | The collection is the most used; the keywords' column left a void beside a short details box |
| 2026-09-26 | Mixed Home shelf (A) | Plex titles as wide art beside videos | Posters, with the videos not stretched | Stretching a video card to a poster's height left an ~80px gap |
| 2026-09-27 | Home hero | A card (hover row + Play on the picture, the whole menu, a touch "…"; owner: A); three quarters wide and centred on a wide window; one fact line; the summary at the measure | The hero's pin row alone; full width; an 18rem picture column | The one answer stands in focus, and it offers what the same card offers anywhere |
| 2026-09-27 | Home shelf tiles | A channel / playlist tile: Play, ↗ open, "…" with Open; a show acts on its next episode, ⓘ opens it; the shelf slot 232px | The tile's pin row alone; 200px | A tile without its object's actions read as broken; at 200 the Play touched the hover row |
| 2026-09-27 | Synopsis preview (C) | A ~0.9 s mouse rest opens a passive panel beside the card | The synopsis as the picture's `title` tooltip; an ⓘ to hover; none | One long tooltip line fired anywhere on the art and clashed with the cut title's own tooltip |
| 2026-09-27 | Reading measure | Prose capped at ~65 characters a line (`measure`) | The container's full width | At 110–130 characters a line the eye loses the next line's start |
| 2026-09-24 | Chip family | One `Chip` (filter / token / tag / suggest / ghost) + `DismissibleChip`; nothing under 24; the lint rule "no raw `<button>` outside ui/" | Five chip geometries in five files | The applied-filter row, the tag cells, the year picker and the saved-view editor each drew their own chip; a one-letter tag was 21px wide |
| 2026-09-24 | Button families | `Button` / `IconButton` / `Pressable` / chips / `DragHandle`; `lg` 40 for a hero CTA; pills and a 48 Play on media (from C5) | Every button hand-painted | 268 hand-rolled buttons in 79 files drifted into as many looks; the ladder only holds if every button stands on it |
| 2026-09-27 | C4 as built | `initialFocus` required on every `Modal`; the commit row in `ModalFooter`, portalled under the body from any depth; the fades driven by the body's scroll timeline; `interactive-widget=resizes-content` | An optional prop defaulting to the ✕; a `footer` prop (the issue composer's buttons live three components down); always-on fades | A default is how 21 dialogs came to open on the ✕; lifting the composer's state to reach a prop is the larger change; a resting fade dimmed the first line |
| 2026-09-27 | C3 + C5 as built | A tap toggles the bar through `pointerType` from `pointerdown`; a double-tap takes back its first tap's toggle; the card's pair is the video card's `MediaResumeButtons` (Continue / Restart), a sibling over the picture; the YT description is an ⓘ click popover | A delayed single tap (wait out the double-tap window); new "Resume / From the start" words for Plex; the pair inside the picture's button | A delayed tap feels dead; one media card says one pair of words; a button inside a button is invalid and the hover row already sits beside the picture |
| 2026-09-27 | C2 as built | The side panel's drawer is `ui/Modal` `drawer-start`; drawer = a coarse pointer or below 768; the pin offered in its header ≥ 1024 | A drawer of its own inside `SidePanel` | Esc, Back, the scrim, the trap and the scroll lock are the dialog's; a second implementation is how two shells drift |
| 2026-09-30 | Composition and the reader (E30 S6) | A chapter and a Conformance step: the space is used, one focal point, worst real data, where am I, a click answers at once, zoom and text size first-class; the person page as two columns (facts and bio left, "Known for" right) | Rules for pieces only; the bio in a full-width panel; a narrower panel | The person page passed every piece rule with half its panel empty; the owner reads zoomed in, so zoom is a requirement, not a nicety |
| 2026-09-30 | Hungarian words (E30 S5) | The D3 list: Feed, Shorts, Admin, Token, Port stay; stream, Prio, Job, Audit, Demo, Auto, Info, Frontend/Backend, Backfill, transcode get the Hungarian word; one name per thing (kérelem, behúzás, one "Test connection") | Keep every loanword; translate Feed/Shorts too | The owner chose the list (D3); the final sweep found the same feature under two names in three places |
| 2026-09-29 | Numbers and order in the UI language (E30 S3) | Every number through `lib/format.ts` in the UI language (decimal mark, grouping, compact, units); A→Z by the shown name in the UI language's alphabet, client and server alike; the language comes off `useTranslation`, never the singleton | `toFixed`, hand-built "1.2K", `localeCompare` in the browser's locale, byte-order server sorts | A Hungarian reader saw "8.3", "1.2K", "Életrajzi" after "Z"; the singleton read also left values in the old language after a switch (React Compiler) |
| 2026-09-27 | Narrow rows by container | The download row, the playlist table and the Channels tag row stack by a container query; a fact line never wraps | Breakpoints on the window | The iPad zoomed in, split screen and a column beside a rail are narrow while the window is not; the zoomed download row broke "18.9 / MB" apart |
| 2026-09-27 | Phones deferred | A phone-only layout (the hamburger nav, a folded feed band) waits for its own epic; E28 stays desktop + tablet | Fix the phone views inside E28 | Principle 2; the first phone attempts made the feed worse, and the epic's review grows with every sprint |
| 2026-09-27 | Conformance check (S3g) | A four-step check before a UI change is done: primitive at a named rung, the chapter re-read, the rendered page measured by kind and look, every state seen | A reviewer's eye per sprint | The S1 probe found 69 button looks; after S3 every control kind is drawn by its primitive, and the check keeps a new look from arriving unnoticed |
| 2026-09-27 | Segmented rung | `SegmentedToggle` defaults to the toolbar rung (28, `text-xs`); 24 only for a panel's captioned match toggle | The small default | Two toolbars had inherited 10px type from the default beside 28px buttons |
| 2026-09-27 | Copy rule (S3g) | One name per feature (the rail may shorten it); four verbs for four meanings; sentence case with the app's places capitalised; "…" when a command asks first; one-sentence messages without a stop; curly quotes; dates relative in lists with the exact minute on hover, three absolute forms; units from Intl, short | Word-for-word names everywhere; the verbs as they were; all lower case; no "…"; always a stop; always absolute dates; long or compact units | The copy survey found 7 EN and 6 HU double names, 22 Delete against 27 Remove, five date shapes and three spellings of a minute; the owner picked every recommended option |
| 2026-09-27 | One glyph, one meaning (S4) | Play only for playback or resuming a paused transfer; Retry = `RefreshCw`; reset / revert = `RotateCcw` | Retry drawn with Play | The first UI-review run (Downloads) found a failed row whose Retry showed ▷ in the row and its menu |
| 2026-09-27 | Fjord kept | Fjord stays a fifth scheme beside Midnight; the trial wording goes | Fold it into Midnight; remove it | The owner lived with it and likes it; the vivid Midnight stays for those who want it. B2's own-hue rule still binds any new scheme |
| 2026-09-27 | Tablet columns | A table hides `tabletHidden` columns below 64rem of its own width; Channels drops Prio, About, Length | Hide About only; give the name a bigger share | At 1024 the channel name had 60 px; the owner chose to drop all three there |
| 2026-09-27 | Glyph pager | First / previous / next / last as glyph buttons round the page box, names as tooltips | Words beside the arrows (hidden only on a phone) | The owner's call: the words widened the row; four arrows are the shared convention |
| 2026-09-27 | Match switch on the panel | `ui/MatchToggle` above the picker from two picks, everywhere; one wording (Any / All) | The picker's popover footer (Feed, Plex collection) | A closed picker hid an active "all"; six pickers had drawn it two ways |
| 2026-09-27 | Missing picture, bare link | A glyph of the kind for no picture; a download with no name is titled by its short link, once | An empty box; the URL as title, channel and link | The owner's picks 4A and 3A: an empty box read as loading, the URL showed three times |
| 2026-09-27 | Copy on a selection | Right-click on selected words keeps the browser's menu; no app Copy row | An app menu with Copy first | A real browser's menu has copy, search and translate; only the desktop pane's is thin |
| 2026-10-01 | Zoom target 125–133% | Screens are held at 125% and 133% browser zoom (1536 × 729, 1444 × 685 on a 1080p window) and the largest text size | 200% browser zoom | The owner: 200% was a guess; 125–133% is what they read Siftlode and most sites at, so 200% would be an upper bound nobody uses |
| 2026-10-01 | E31 Nav rail at short heights | One "Admin" rail item opening a panel of the admin pages | A denser rail when short; a visible "+4" hint | The rail fits at 133–150% without a hidden scroll; the admin pages are rare and admin-only |
| 2026-10-01 | E31 Configuration sections | A vertical, grouped section list, as Settings has | Group tabs + sub-tabs; one long page with a contents list | Sixteen tabs wrapped to three rows at 133%; one pattern for both settings pages |
| 2026-10-01 | E31 Statistics overview | Number tiles, and the per-operation counts as horizontal bars | Three narrow cards; a 30-day chart | The label sits by its value; what spends the quota is seen at a glance |
| 2026-10-01 | E31 Channel page header | The full banner, folding to one row once the grid scrolls | Always compact; no banner | The header is fixed and took 40% of a 685px window; the channel keeps its face at first sight |
| 2026-10-01 | E31 Channel page watch state | A fresh channel page shows the unwatched videos; a picked state is remembered | Show all, forgotten on reload | The owner: open a channel to see what is new; a choice made there should stick |
| 2026-10-01 | E31 Audit log runs | A job's successful runs fold into one row that opens, whatever ran between; only that job's failure (never folded, danger colour) breaks it | A filter defaulting to admin actions; both | Nothing hidden, the noise collapsed, the failure seen |
| 2026-10-01 | E31 Users row | Role and state as badges, the actions in the row's one menu | An inline role toggle; icon-only buttons | Action placement: the row shows state, rare and destructive actions live in the menu |
| 2026-10-01 | E31 YouTube player | Video beside an info panel at 1280px and wider (the video as large as the height allows); stacked below | Today's stack with a one-line bar; full-window like Plex | The owner's eyesight: about 1460px of video on 1080p instead of 1008, the actions still visible |
| 2026-10-01 | E31 Plex info and show pages | A second column for the facts, keywords and crew, as the person page; a collection's hero sized to its content | Full-width text; a larger poster and fanart | The right ~40% was empty; one composition for every media page |
| 2026-10-01 | E31 Nav rail rows are links | Each page row is a real link: a middle-click or Ctrl-click opens the page in a new tab | A button | The owner: open a page in a tab of its own, as on any site |
| 2026-10-02 | E31 Plex detail second column (refined) | The facts over the key crew beside the text; the keywords, chips that filter like the genres, under the text; the show page gets all three too | All three in the column, as the mockup drew it | At 20 keywords and 12 crew the column ran to twice the text's height (849 vs ~400 px at 133%): a hole under the text instead of beside it |
| 2026-10-02 | E31 Plex detail poster and crew | The movie and show poster keeps its 2:3 and grows a rung in a wide hero (w-56 at a 64rem panel); the crew shows its first 6 names and "Show N more" (a list of 8 or fewer shows whole) | Poster stretched to the row; a "More" on the keywords | The row is as tall as the crew column, so the poster drew ~175×590 and cropped; the crew, not the keywords, set the hero's height |
| 2026-10-04 | E32 `field` focus skips a mode switch | A form opens on its first field; a control above it that is not a field (the composer's Edit / Preview) is passed over by marking the field `data-initial-focus` | The first text field everywhere | That moved the focus in the download dialog off its profile choice and in the upload dialog off its drop zone; the mark changes only the form that needs it |
| 2026-10-04 | E35 Lint for z-levels and edged labels | Both rules stay; the two eslint selectors (`Z_RAW`, `EDGED_LABEL`) that refused them go | Keep the selectors | 0 hits since the E28 sweep that fixed the sites: a check that never fires costs a probe test and a snapshot each, and the UI review holds both rules |
| 2026-10-06 | `player` focus | A dialog that is a player opens focused on its panel (the YouTube player's rule, now `Modal`'s too); on the media element itself, Space and the arrows stay the element's own | `close`, or the media element | On the ✕ Space closed the player; on the element the browser's own controls acted on the same key as the player's (one Space played and paused, measured in Chrome) |
| 2026-10-08 | Captions on touch | A `Tooltip` opens for a mouse and a key's focus only, never for a finger | A caption on tap; a long-press caption | On an iPad the tap that showed the caption was taken as a hover and lost its click (every rail button took two taps), and no finger sends the mouseleave that hides it; the rail's labels setting names the buttons on touch |
| 2026-10-08 | Header search on touch | No key cap on a touch screen; the pill grows to 52 px so a ring shows round the 44 px Go; Go drops to its icon by the column's width | Keep the key cap; a 44 px Go overhanging a 40 px pill | On a tablet the 44 px key cap and Go sat on the pill's edge and cut the placeholder; an overhanging Go breaks the nested radii |
| 2026-10-08 | Plex subtitles and the control bar | The player draws the cues itself; while the bar shows they rise above it (200 ms), then settle back | The browser's own cues, moved by `line` | On an iPad the bar covered the text; a native cue cannot be animated or measured, and our layer behaves the same on every engine |
| 2026-10-08 | Hover on a button over a picture | The `media` variant's hover is a 2px accent ring over a darker body (Plex + YT cards, rail arrows; option B) | An accent fill (reads as "selected"); a white wash (weak on a bright poster) | The 60 → 75% black step was invisible, and over a card a rail arrow could not be told from the card's own Play under it |
| 2026-10-08 | Plex "Add to collection" | A movie or show card's menu row opens a panel beside the card: its library's manual collections tick in place, a name + Enter creates one with the title in it (admin; option A) | An Add submenu with a "New…" dialog; the per-title editor modal from the menu | A collection took a trip to the Collections page, a name, a title search, then a second search per title; the panel is the video card's "Add to playlist" |
| 2026-10-08 | YouTube modal chrome | No prev/next strips beside the card; Close an icon (the panel's top right; stacked, the title row's end); the description clamped to three lines + More; the panel shuts to a 2.75rem strip of close, expand, prev/next (option D1) | Keep the strips; a "Close" text button; the full description in a scroller; a panel that hides outright | The strips cost the video 7rem of width for a second copy of the panel's buttons; a long description pushed the channel and the actions out of sight; a hidden panel left no way to step the queue without the keyboard |
| 2026-10-08 | The wheel over the YouTube modal | Over the video: volume. Over a panel with something to scroll: scroll only, never volume, not even at its end. Over a panel with nothing to scroll: volume (owner) | Volume everywhere; a scroll that hands over to the volume at its end | Reading a long description turned the volume down once it hit the end |
| 2026-10-09 | A Plex show card plays | Its picture plays the show's next episode everywhere (an episode with a resume point → Continue + Restart, else the first unwatched, else S1·E1), resolved on the click; the preview names it once up; title and ⓘ open the show (option A + hover ask) | The picture opens the show page (as before); every card carrying `next` | A show card on Discover had no Play while Home's had one; carrying `next` costs a grid page up to 180 queries for the few cards anyone plays |
| 2026-10-10 | Clip mode in the players (E38) | Scissors in the control row + `C`, `[` `]`; the panel in the player with the watched video as preview; overview + zoomed strip on `ui/RangeTrack` (ends are sliders); a toast + the Ready notification after Cut | A ⋯ menu entry; permanent In / Out buttons; a big dialog with its own preview (a second Plex transcode / YouTube iframe) | Owner's picks A, P1, a; a film is hours and a clip seconds, so only a zoomed strip gives a hand the precision. The Downloads editor's cuts moved onto the same track (keyboard-steppable now) |
| 2026-10-10 | Crop in clip mode (E38) | A Crop toggle in the clip panel: a frame over the playing picture (`ui/CropFrame`, the editor's crop too), Free / 16:9 / 9:16 / 1:1, Whole picture; maximised YouTube keeps the panel in the bar's place; the wide side panel shuts while clipping | A frame always on the picture in clip mode (K2); a free shape only | Owner's K1 + a: a crop is rare, so it never covers the picture unasked; a phone or square clip needs a held shape. The iframe hides its picture's size: drawn on the 16:9 box, the cut fits it to the real picture |
| 2026-10-10 | Marks on the player chrome | `--media-mark` (amber) and `--media-danger` (light red), the same in both themes; `media-marks` points the focus ring (`--focus`) and the chosen mark (`--chosen`: a track's tick, a tab's underline; the label stays white) at the amber on the player and every chrome surface; `media-scope` (a form) also the errors at the red | The theme's accent ring, chosen text, warn and danger | On the chrome (#171717) a light theme's accent ring (Slate 2.84:1), warn (2.99:1) and danger (2.77:1) all fall under 3:1; the accent as chosen text under 4.5:1 |
| 2026-10-11 | Files page (one library) | List rows by default, the media grid a toggle remembered per folder; a sidebar of automatic source folders, own folders, Shared with me and the storage meter; the paste-a-link box in the toolbar | A media grid by default | The library holds videos AND plain files: rows read both, show more per screen and scan by size and date |
| 2026-10-11 | Library list columns | Every kind of value in its own column (name, source, phone-safe, retention, size, added, actions), one grid with a subgrid per row so they line up; an action that does not apply keeps its slot empty; a right-click menu on every row (the ⋯ opens the same menu) | Badges flowing after the title; icons that shift when one is missing | Owner: values of one kind must fall in one vertical line; a row's every action reachable by right click |
| 2026-10-11 | Running downloads | An app-wide tray, bottom right, shown only while something runs or failed (reason + retry), foldable to a pill | A rail icon with a count and a popover | Downloading became an action you start anywhere; a running one must be visible without going to look |
| 2026-10-11 | Download done | The notification says whether it plays on a phone and carries Link másolása + Megnyitás (the folder, the item focused); never an automatic jump | A plain "ready" notice; auto-navigation to the item | The daily path is paste → done → copy the link; a jump would pull the user out of what they were doing |
| 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 |
| 2026-10-15 | Folder links (owner A/A/A) | An own folder's menu has Copy link and Share with link… as a file row does (the dialog is the link half of the file's); the link opens a list of the folder's files as it is now, Play opening the watch player above it with the playing row lit, a document only downloading; the rail marks a folder with a live link beside its count | Share with link… alone; cards; a download-only list; no mark | Mostly videos from a phone, read on a phone: a list gives a long name its line and the next one is a tap away; the folder link is the one place where moving a file in publishes it, so where it is must show |
+6 -2
View File
@@ -44,7 +44,9 @@ The installer asks for the **public URL** where the instance will be reached (ju
local `.env` file — keep that file private. **Do not change `TOKEN_ENCRYPTION_KEY` once the app has
stored any secret** (Google credentials, API keys, SMTP password, Plex token): those values are
encrypted with it, and a changed key makes them unreadable. If it is ever lost or rotated, clear the
affected rows from the `system_config` table and re-enter the secrets in the admin Configuration page,
affected rows from the `system_config` table and re-enter the secrets in the admin Configuration page.
Share links are kept with it too: after a key change every existing link still opens, but its
address can no longer be shown — its owner makes a new link to share it again,
- pulls the image and starts the app + database,
- prints the **setup wizard URL**, which looks like `…/setup?token=…`.
@@ -163,7 +165,9 @@ then cannot read its own secrets.
2. **`.env`** — in particular `TOKEN_ENCRYPTION_KEY`. Your Google client secret, SMTP password, Plex
token and every stored refresh token are encrypted with it *inside* the database. Restore a dump
against a different key and those values are lost — you would have to clear the affected rows from
`system_config` and re-enter every secret by hand. Keep `.env` beside the dump, and just as private.
`system_config` and re-enter every secret by hand — and no existing share link's address can be
shown again (the links still open; their owners make new ones to share). Keep `.env` beside the
dump, and just as private.
3. **Downloaded media** *(only if you use Files)* — the `siftlode_files` volume, or
whatever `FILES_HOST_PATH` points at. Not needed to bring the instance back; the jobs and their
metadata are in the database, the files themselves are not.
+26 -13
View File
@@ -17,7 +17,7 @@ import { retryConnect } from "./retryConnect";
const RUNS = 5;
const OUT = "e2e/.speed/latest.json";
const CONTAINER = process.env.E2E_DB_CONTAINER || "siftlode-db-1";
const CONTAINER = process.env.E2E_API_CONTAINER || "siftlode-api-1";
const median = (xs: number[]) => [...xs].sort((a, b) => a - b)[Math.floor(xs.length / 2)] ?? NaN;
@@ -32,20 +32,33 @@ const scriptBytes = (page: Page) =>
/** Public share links on the local stack that could play: no password, not expired, asset ready.
* The page itself is the judge of the last step (its file must be on THIS disk — a localdev DB
* seeded from prod lists assets whose files never came along), so the caller probes them. */
* seeded from prod lists assets whose files never came along), so the caller probes them. Read
* through the API container: the database keeps a link's token only hashed and encrypted, and only
* the app holds the key to read it back (`links.recover`). */
const SHARE_TOKENS_PY = `
from sqlalchemy import select
from app.db import SessionLocal
from app.downloads import links
from app.models import DownloadJob, DownloadLink, MediaAsset
with SessionLocal() as db:
for link in db.execute(
select(DownloadLink)
.join(DownloadJob, DownloadJob.id == DownloadLink.job_id)
.join(MediaAsset, MediaAsset.id == DownloadJob.asset_id)
.where(DownloadLink.password_hash.is_(None), MediaAsset.status == "ready")
.order_by(DownloadLink.id)
).scalars():
token = None if links.is_expired(link) else links.recover(link)
if token:
print(token)
`;
function shareTokens(): string[] {
const sql =
"select l.token from download_links l join download_jobs j on j.id = l.job_id" +
" join media_assets a on a.id = j.asset_id" +
" where l.password_hash is null and (l.expires_at is null or l.expires_at > now())" +
" and a.status = 'ready' order by l.id";
// Not caught: a database this cannot reach (no docker, a renamed container) is a failed
// Not caught: an API container this cannot reach (no docker, a renamed container) is a failed
// measurement, not "no playable share link on the local stack".
const out = execFileSync(
"docker",
["exec", CONTAINER, "psql", "-U", "siftlode", "-d", "siftlode", "-tAc", sql],
{ encoding: "utf8" },
);
const out = execFileSync("docker", ["exec", CONTAINER, "python", "-c", SHARE_TOKENS_PY], {
encoding: "utf8",
});
return out
.split("\n")
.map((t) => t.trim())
+7
View File
@@ -627,6 +627,13 @@ export default tseslint.config(
message:
"No native <select>: use ui/SegmentedToggle for a short fixed set, ui/FilterSelect for a longer one or a user's own list.",
},
// A picture that failed to load left the browser's broken-image mark on a card: a bare <img>
// sends a Referer Google's image CDNs refuse, and has no retry and no fallback.
{
selector: "JSXOpeningElement[name.name='img']",
message:
"No bare <img>: use ui/RemoteImage — no Referer, a retry past the browser's negative cache, and a fallback instead of the broken-image mark.",
},
// Keep the rem system from rotting. A px-frozen `text-[Npx]` font size does NOT
// scale with the Settings --font-scale slider while its rem neighbours do; use
// `text-2xs`/`text-3xs` or a `text-[Nrem]` arbitrary instead. Matches both plain strings and
+3 -2
View File
@@ -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";
+7 -15
View File
@@ -9,13 +9,13 @@ import { displayName } from "../lib/api/account";
import { qk } from "../lib/queryKeys";
import { notify } from "../lib/notifications";
import { notifyActionError } from "../lib/actionErrors";
import { copyText } from "../lib/clipboard";
import { formatDateTime } from "../lib/format";
import { invalidateUserAccess } from "../lib/adminUsers";
import type { AdminUserAccess, AdminUserRow } from "../lib/api/admin";
import { Button } from "./ui/Button";
import { SURFACE } from "./ui/surfaces";
import { SectionLabel } from "./ui/Headings";
import { useCopyFeedback } from "./useCopyFeedback";
// The admin's answer to "why can this person not get in?", and the place every recovery action for
// one account lives.
@@ -59,7 +59,7 @@ export default function AccountPanel({
const lang = i18n.language;
const qc = useQueryClient();
const [link, setLink] = useState<{ url: string; expires_at: string | null } | null>(null);
const [copied, setCopied] = useState(false);
const { copied, copy: copyValue } = useCopyFeedback();
const q = useQuery({
queryKey: qk.adminUserAccess(user.id),
@@ -88,19 +88,11 @@ export default function AccountPanel({
onError: (err) => notifyActionError(err, t("users.pendingAccess.activateFailed")),
});
const copy = async () => {
if (!link) return;
// Via the shared helper, whose synchronous `execCommand` path is what makes copying work on a
// plain-http LAN origin — where `navigator.clipboard` is undefined, and which is exactly the
// minimal self-host this no-email link was built for.
if (await copyText(link.url)) {
setCopied(true);
window.setTimeout(() => setCopied(false), 2000);
return;
}
// The link is on screen and selectable, so a failed copy is a convenience that failed — not the
// action failing. Say so rather than leaving the button looking inert.
notify({ level: "warning", message: t("users.account.copyFailed") });
// Via the shared helper, whose synchronous `execCommand` path is what makes copying work on a
// plain-http LAN origin — where `navigator.clipboard` is undefined, and which is exactly the
// minimal self-host this no-email link was built for.
const copy = () => {
if (link) void copyValue(link.url);
};
const a: AdminUserAccess | undefined = q.data;
@@ -0,0 +1,269 @@
// @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<typeof import("react-i18next")>()),
useTranslation: () => ({
i18n: { language: "en" },
t: (key: string, o?: Record<string, unknown>) =>
o && Object.keys(o).length ? `${key}(${Object.values(o).join(",")})` : key,
}),
}));
vi.mock("./confirmContext", () => ({ useConfirm: () => mocks.confirm }));
vi.mock("../lib/clipboard", () => ({ copyText: vi.fn(async () => true) }));
import ApiTokens from "./ApiTokens";
afterEach(() => {
cleanup();
vi.restoreAllMocks();
mocks.confirm.mockClear();
});
const token = (id: number, extra: Partial<ApiToken> = {}): 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>): FileFolder => ({
id,
kind: "source",
source_key: null,
name: null,
retention_days: 30,
count: 1,
size_bytes: 0,
created_at: null,
has_link: false,
...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(
<QueryClientProvider
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
>
<ApiTokens />
</QueryClientProvider>,
);
}
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<ApiToken> = {}) => ({
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");
// Two Copy buttons in one dialog: each is named by the value it copies.
expect(
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();
});
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 })),
);
});
});
+504
View File
@@ -0,0 +1,504 @@
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 { compareText } from "../lib/collate";
import { formatDate } from "../lib/format";
import { navItemAttrs } from "../lib/listNav";
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";
import { useCopyFeedback } from "./useCopyFeedback";
// 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<typeof folderIcon> }) {
return <Icon className="size-3.5 shrink-0" aria-hidden />;
}
function GrantLine({ grant }: { grant: TokenGrant }) {
const { t, i18n } = useTranslation();
return (
<span className="flex min-w-0 items-center gap-1.5">
<Glyph icon={folderIcon(grant)} />
<span className="text-fg">{folderLabel(grant, t)}</span>
<span>· {rightsText(grant.rights, t, i18n.language)}</span>
</span>
);
}
function TokenRow({
token,
index,
rowProps,
more,
}: {
token: ApiToken;
index: number;
rowProps: Record<string, unknown>;
more: ReactNode;
}) {
const { t, i18n } = useTranslation();
const expires = formatDate(token.expires_at, i18n.language);
return (
<div
{...navItemAttrs(index, token.name, { inset: true })}
{...rowProps}
data-testid="api-token-row"
className="flex items-start gap-3 py-2.5"
>
<KeyRound className="mt-0.5 size-4 shrink-0 text-muted" aria-hidden />
<div className="flex min-w-0 flex-1 flex-col gap-1">
<span className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium break-all">{token.name}</span>
<code className="text-xs text-muted">{token.prefix}…</code>
{token.expired ? (
<Badge tone="warn">{t("settings.tokens.expired")}</Badge>
) : (
token.expiring && <Badge tone="warn">{t("settings.tokens.expiring")}</Badge>
)}
</span>
<span className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted">
{token.grants.map((g) => (
<GrantLine key={g.folder_id} grant={g} />
))}
</span>
<span className="text-xs text-muted">
{token.last_used_at ? (
<>
{t("settings.tokens.lastUsed")} <RelativeTime at={token.last_used_at} />
</>
) : (
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 })}
</span>
</div>
{more}
</div>
);
}
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<ApiToken>({ keyOf: (tk) => tk.id });
const items = (token: ApiToken): ActionMenuItem[] => [
{
id: "revoke",
label: t("settings.tokens.revoke"),
icon: <Trash2 className="w-4 h-4" />,
danger: true,
onSelect: () => void onRevoke(token),
},
];
const tokens = tokensQ.data;
return (
<div className="flex flex-col gap-3">
<div className="flex items-start gap-3">
<p className="flex-1 text-xs leading-relaxed text-muted">{t("settings.tokens.intro")}</p>
<Button
variant="primary"
size="sm"
icon={<Plus className="w-4 h-4" />}
onClick={() => setMaking(true)}
>
{t("settings.tokens.new")}
</Button>
</div>
{tokensQ.isError && !tokens ? (
<StateMessage tone="error" onRetry={() => void tokensQ.refetch()}>
{t("common.loadError")}
</StateMessage>
) : !tokens ? (
<LoadingState shape="list" />
) : tokens.length === 0 ? (
<p className="py-4 text-sm text-muted">{t("settings.tokens.empty")}</p>
) : (
<NavGrid className="flex flex-col divide-y divide-border/60">
{tokens.map((token, i) => (
<TokenRow
key={token.id}
token={token}
index={i}
rowProps={menu.triggerProps(token)}
more={
<MoreButton
label={t("settings.tokens.rowMenu")}
expanded={menu.open && menu.target?.id === token.id}
menuProps={menu.buttonProps(token)}
/>
}
/>
))}
</NavGrid>
)}
<ContextMenu
menu={menu}
ariaLabel={t("settings.tokens.rowMenu")}
items={menu.target ? items(menu.target) : []}
/>
{making && <NewTokenDialog onClose={() => setMaking(false)} />}
</div>
);
}
// --- making one -----------------------------------------------------------------------------------
type Kind = "hub" | "custom";
type Grants = Record<number, readonly TokenRight[]>;
/** 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<FileFolder[]>;
grants: Grants;
onChange: (g: Grants) => void;
}) {
const { t, i18n } = useTranslation();
if (foldersQ.isError && !foldersQ.data)
return (
<StateMessage tone="error" onRetry={() => void foldersQ.refetch()}>
{t("common.loadError")}
</StateMessage>
);
if (!foldersQ.data) return <LoadingState />;
const rows = grantable(foldersQ.data, t, i18n.language);
if (rows.length === 0)
return <p className="text-sm text-muted">{t("settings.tokens.noFolders")}</p>;
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 (
<div className="flex flex-col divide-y divide-border/50 rounded-lg border border-border/60">
{rows.map((f) => {
const label = folderLabel(f, t);
return (
<div
key={f.id}
role="group"
aria-label={label}
className="flex flex-wrap items-center gap-2 px-2.5 py-2"
>
<span className="flex min-w-28 flex-1 items-center gap-1.5 text-sm">
<Glyph icon={folderIcon(f)} />
<span className="break-all">{label}</span>
</span>
<span className="flex flex-wrap gap-1">
{TOKEN_RIGHTS.map((r) =>
r === "write" && !takesUploads(f) ? null : (
<Chip
key={r}
pressed={(grants[f.id] ?? []).includes(r)}
onClick={() => toggle(f, r)}
>
{t(`settings.tokens.rights.${r}`)}
</Chip>
),
)}
</span>
</div>
);
})}
</div>
);
}
function KindTile({
on,
icon,
title,
text,
onPick,
}: {
on: boolean;
icon: ReactNode;
title: string;
text: string;
onPick: () => void;
}) {
const textId = useId();
return (
<Pressable
aria-pressed={on}
aria-describedby={textId}
onClick={onPick}
className={`${SURFACE.card} flex flex-col items-start gap-1 border p-3 text-left ${
on ? "border-accent" : "border-transparent hover:border-border"
}`}
>
<span className="flex items-center gap-1.5 text-sm font-medium">
{icon}
{title}
</span>
{/* Read after the name, not into it ("Custom" — then what it means). */}
<span id={textId} aria-hidden className="text-xs text-muted">
{text}
</span>
</Pressable>
);
}
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<Kind>("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<string | null>(null);
const name = nameDraft ?? (kind === "hub" ? t("settings.tokens.hubName") : "");
const [grants, setGrants] = useState<Grants>({});
const [expiry, setExpiry] = useState<number | null>(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 (
<Modal
initialFocus="action"
title={t("settings.tokens.madeTitle")}
onClose={onClose}
maxWidth="max-w-md"
>
<div className="flex flex-col gap-3">
<InlineNotice>{t("settings.tokens.once")}</InlineNotice>
<CopyField label={t("settings.tokens.token")} value={made.token} autoFocus />
<CopyField
label={t("settings.tokens.tryIt")}
value={`curl -H "Authorization: Bearer ${made.token}" ${window.location.origin}/api/files/folders`}
/>
</div>
<ModalFooter>
<Button variant="primary" onClick={onClose}>
{t("settings.tokens.done")}
</Button>
</ModalFooter>
</Modal>
);
return (
<Modal
initialFocus="field"
title={t("settings.tokens.newTitle")}
onClose={onClose}
maxWidth="max-w-md"
>
<form
id={`${nameId}-form`}
className="flex flex-col gap-3"
onSubmit={(e) => {
e.preventDefault();
if (ready && !create.isPending) create.mutate();
}}
>
<div
role="group"
aria-label={t("settings.tokens.kind")}
className="grid grid-cols-1 gap-2 sm:grid-cols-2"
>
<KindTile
on={kind === "hub"}
icon={<HubIcon className="size-4" aria-hidden />}
title={t("settings.tokens.hubTitle")}
text={t("settings.tokens.hubText")}
onPick={() => setKind("hub")}
/>
<KindTile
on={kind === "custom"}
icon={<SlidersHorizontal className="size-4" aria-hidden />}
title={t("settings.tokens.customTitle")}
text={t("settings.tokens.customText")}
onPick={() => setKind("custom")}
/>
</div>
<label htmlFor={nameId} className="flex flex-col gap-1">
<span className="text-xs text-muted">{t("settings.tokens.name")}</span>
<Input
id={nameId}
data-initial-focus=""
className="w-full"
maxLength={80}
value={name}
onChange={(e) => setNameDraft(e.target.value)}
/>
</label>
{kind === "custom" && (
<div className="flex flex-col gap-1">
<span className="text-xs text-muted">{t("settings.tokens.folders")}</span>
<RightsGrid foldersQ={foldersQ} grants={grants} onChange={setGrants} />
</div>
)}
<div className="flex flex-col gap-1">
<span className="text-xs text-muted" aria-hidden>
{t("settings.tokens.expiry")}
</span>
<SegmentedToggle
size="md"
ariaLabel={t("settings.tokens.expiry")}
className="self-start"
options={TOKEN_EXPIRY_DAYS.map((d) => ({
value: expiryValue(d),
label: t(`settings.tokens.expiryOption.${expiryValue(d)}`),
}))}
value={expiryValue(expiry)}
onChange={(v) => setExpiry(v === "never" ? null : Number(v))}
/>
{expiry === null && <InlineNotice>{t("settings.tokens.neverWarning")}</InlineNotice>}
</div>
</form>
<ModalFooter>
<Button variant="outline" onClick={onClose}>
{t("common.cancel")}
</Button>
<Button
variant="primary"
type="submit"
form={`${nameId}-form`}
disabled={!ready || create.isPending}
>
{t("settings.tokens.create")}
</Button>
</ModalFooter>
</Modal>
);
}
/** 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, copy } = useCopyFeedback();
return (
<div className="flex flex-col gap-1">
<span className="text-xs text-muted">{label}</span>
<div className="flex items-start gap-1.5">
<code className="min-w-0 flex-1 break-all rounded-lg border border-border bg-card px-2.5 py-1.5 font-mono text-xs">
{value}
</code>
<Button
variant="outline"
size="sm"
autoFocus={autoFocus}
icon={copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
// 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")}
</Button>
</div>
</div>
);
}
@@ -99,10 +99,10 @@ describe("ChannelPage — a busy toggle keeps the keyboard", () => {
});
// The banner is retried on error (googleusercontent drops a burst); while an attempt fails and the
// next waits, a visible <img> shows the browser's broken-image glyph (seen live). It stays
// invisible until a source has actually loaded.
// next waits, a visible <img> showed the browser's broken-image glyph (seen live). While a retry
// waits there is no <img> at all — the bar's own ground — and the retry asks past the cache.
describe("ChannelPage — the banner", () => {
it("is invisible until it loads, and again while a failed attempt is retried", async () => {
it("leaves the bar empty while a failed attempt waits, then asks again past the cache", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
onTestFinished(() => {
vi.useRealTimers();
@@ -134,16 +134,14 @@ describe("ChannelPage — the banner", () => {
</QueryClientProvider>,
);
const banner = () =>
container.querySelector<HTMLImageElement>('img[src^="https://yt3.example/banner"]')!;
container.querySelector<HTMLImageElement>('img[src^="https://yt3.example/banner"]');
await waitFor(() => expect(banner()).toBeTruthy());
expect(banner().classList.contains("opacity-0")).toBe(true);
fireEvent.error(banner());
expect(banner()!.getAttribute("referrerpolicy")).toBe("no-referrer");
fireEvent.error(banner()!);
expect(banner()).toBeNull();
await act(async () => {
await vi.advanceTimersByTimeAsync(600);
await vi.advanceTimersByTimeAsync(1000);
});
expect(banner().getAttribute("src")).toContain("?r=1");
expect(banner().classList.contains("opacity-0")).toBe(true);
fireEvent.load(banner());
expect(banner().classList.contains("opacity-0")).toBe(false);
expect(banner()!.getAttribute("src")).toContain("?r=1");
});
});
+16 -58
View File
@@ -47,6 +47,7 @@ import { IconButton } from "./ui/IconButton";
import { IconLink } from "./ui/LinkButton";
import { PageHeading } from "./ui/Headings";
import { useTags } from "../lib/useTags";
import RemoteImage from "./ui/RemoteImage";
// A dedicated channel page: an "About"-style header (banner, avatar, stats, subscribe) over the
// channel's videos (the catalog filtered to this channel). For an un-subscribed channel it
@@ -94,17 +95,6 @@ export default function ChannelPage({
// is fixed, so unfolded it kept ~40% of a 133% screen for good. Folded, the name rides in the tab row.
const foldable = useRef<HTMLDivElement>(null);
const folded = useScrollFold(foldable);
// The banner comes from Google's image CDN, which refuses any request carrying a Referer from our
// origin — every time, not only in a burst (measured 2026-09-26) — so the <img> sends none
// (`referrerPolicy`, as ui/Avatar). A load can still drop, and the browser NEGATIVE-CACHES the miss,
// so on error we retry a few times with a cache-buster (forcing a fresh request), and only fall
// back to the blank bar if it keeps failing (a genuinely dead URL — rare).
const MAX_BANNER_RETRIES = 3;
const [bannerAttempt, setBannerAttempt] = useState(0);
// The banner source that has actually loaded. Until then the <img> is invisible: an attempt that
// fails shows the browser's broken-image glyph for as long as the retry waits (seen live).
const [bannerLoaded, setBannerLoaded] = useState<string | null>(null);
// A LIVE query: while THIS user's full-history backfill is running, poll so the stored count (and
// the N/S/L breakdown) climb on their own — the immediate backfill fills it in seconds, and the
// scheduler tops up any tail on its ~10-minute ticks — then stop polling once the back-catalogue is
@@ -113,13 +103,6 @@ export default function ChannelPage({
intervalMs: channelDetailPollMs,
});
const ch = detail.data;
// A fresh channel/banner gets the retries back, in the render that changes it: as an effect the
// new banner was committed with the previous one's exhausted attempt count still in place.
const [bannerFor, setBannerFor] = useState(ch?.banner_url);
if (bannerFor !== ch?.banner_url) {
setBannerFor(ch?.banner_url);
setBannerAttempt(0);
}
// The poll above is also the ONLY signal that the running backfill has written videos, so it
// drives the video grid's refresh too (the "Get full history" mutation below can't: it fires on
// the PATCH response, before the background job has stored anything).
@@ -348,9 +331,7 @@ export default function ChannelPage({
// The channel page owns no scroller of its own (PageShell provides the one app scroller). Its
// whole chrome is fixed — wrapped in <PageToolbar> it portals into the shell's fixed band, so the
// tabs/toolbar stay put and only the video grid scrolls; the banner/identity fold away meanwhile.
const bannerSrc = ch?.banner_url
? `${ch.banner_url}=w1707${bannerAttempt ? `?r=${bannerAttempt}` : ""}`
: null;
const bannerSrc = ch?.banner_url ? `${ch.banner_url}=w1707` : null;
return (
<>
<PageToolbar>
@@ -359,44 +340,21 @@ export default function ChannelPage({
<div ref={foldable}>
{/* Banner — OPTION C: inset, rounded card (not full-bleed) */}
<div className="px-4 sm:px-6 pt-3">
{bannerSrc && bannerAttempt <= MAX_BANNER_RETRIES ? (
// Match YouTube's banner: the stored bannerExternalUrl is the full 16:9 template at a
// low default size, so request a crisp wide version (=w1707) and object-cover the
// desktop "safe area" — the centre 2560×423 (~6:1) band.
<div
className="w-full overflow-hidden rounded-2xl bg-surface"
style={{ aspectRatio: "2560 / 423", maxHeight: "150px" }}
>
<img
// key forces a fresh <img> per attempt; the ?r= cache-buster on a retry bypasses the
// browser's negative cache (googleusercontent accepts an extra query param).
key={bannerAttempt}
src={bannerSrc}
referrerPolicy="no-referrer"
alt=""
className={`w-full h-full object-cover object-center ${
bannerLoaded === bannerSrc ? "" : "opacity-0"
}`}
onLoad={() => setBannerLoaded(bannerSrc)}
onError={() => {
if (bannerAttempt <= MAX_BANNER_RETRIES) {
window.setTimeout(
() => setBannerAttempt((a) => a + 1),
500 * (bannerAttempt + 1),
);
}
}}
/>
</div>
) : (
// No banner (or a dead one): a neutral bar the SAME height as a real banner, so the
// overlapping avatar + the Back button have the same room and don't collide (a short bar
// let the -mt-8 avatar ride up into the Back button).
<div
className="w-full rounded-2xl bg-surface"
style={{ aspectRatio: "2560 / 423", maxHeight: "150px" }}
{/* Match YouTube's banner: the stored bannerExternalUrl is the full 16:9 template at a
low default size, so request a crisp wide version (=w1707) and object-cover the
desktop "safe area" — the centre 2560×423 (~6:1) band. With no banner (or a dead one)
the bar keeps the SAME height, so the overlapping avatar and the Back button keep
their room (a short bar let the -mt-8 avatar ride up into the Back button). */}
<div
className="w-full overflow-hidden rounded-2xl bg-surface"
style={{ aspectRatio: "2560 / 423", maxHeight: "150px" }}
>
<RemoteImage
src={bannerSrc}
alt=""
className="w-full h-full object-cover object-center"
/>
)}
</div>
</div>
<div className="px-4 sm:px-6">
@@ -70,6 +70,7 @@ vi.mock("../lib/notifications", async (importOriginal) => ({
import DownloadTray from "./DownloadTray";
import { noteQueue, resetQueueWatch, showInTray } from "../lib/downloadQueue";
import { pushLayer, removeLayer } from "../lib/layerStack";
import { qk } from "../lib/queryKeys";
beforeEach(() => {
resetQueueWatch();
@@ -237,6 +238,28 @@ describe("DownloadTray", () => {
);
});
it("a finish refreshes Files at once, so the notice's Open finds the file listed", async () => {
const invalidate = vi.spyOn(QueryClient.prototype, "invalidateQueries");
poll([job(1, "running")]);
const view = mount();
expect(invalidate).not.toHaveBeenCalledWith({ queryKey: qk.files() });
poll([job(1, "done")], view);
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1));
expect(invalidate).toHaveBeenCalledWith({ queryKey: qk.files() });
expect(invalidate).toHaveBeenCalledWith({ queryKey: qk.fileFolders() });
});
it("an upload the worker converted first is announced as uploaded, not downloaded", async () => {
poll([job(1, "running", { source_key: "upload" })]);
const view = mount();
poll([job(1, "done", { source_key: "upload" })], view);
await waitFor(() =>
expect(h.notify).toHaveBeenCalledWith(
expect.objectContaining({ title: "tray.done.uploadedTitle:Clip 1" }),
),
);
});
it("closed, it forgets what finished and stays away until the work changes", () => {
poll([job(1, "running"), job(2, "running")]);
const view = mount();
+16 -6
View File
@@ -14,7 +14,7 @@ import {
X,
} from "lucide-react";
import type { QueueJob } from "../lib/api";
import { quickShareLink } from "../lib/downloads";
import { invalidateDownloads, quickShareLink } from "../lib/downloads";
import { failCopyKeys, failTitleKey } from "../lib/downloadFailure";
import {
IN_FLIGHT,
@@ -190,9 +190,15 @@ export default function DownloadTray() {
const title = jobTitle(job);
notify({
level: "success",
title: t("tray.done.title", { title }),
// An upload the worker had to convert first is done here too — it was never downloaded.
title: t(job.source_key === "upload" ? "tray.done.uploadedTitle" : "tray.done.title", {
title,
}),
message: t(job.asset?.media_kind === "file" ? "tray.done.file" : "tray.done.phone"),
action: { label: t("tray.done.copyLink"), onClick: () => void quickShareLink(qc, job, t) },
action: {
label: t("tray.done.copyLink"),
onClick: () => void quickShareLink(qc, { kind: "file", id: job.id }, t),
},
secondaryAction: {
label: t("tray.done.open"),
onClick: () => openFile(job.id, job.folder_id),
@@ -202,8 +208,12 @@ export default function DownloadTray() {
const fresh = useUnannounced();
useEffect(() => {
// The take clears them, so a second pass (StrictMode, a re-render) announces nothing again.
if (fresh.length) takeUnannounced().forEach(announce);
}, [fresh]);
if (!fresh.length) return;
// A notice that offers "Open" while Files still lists the folder without the file (its own poll
// is slower) sends the reader to something not there yet.
invalidateDownloads(qc);
takeUnannounced().forEach(announce);
}, [fresh, qc]);
// --- Back to top steps up over the tray while it shows -----------------------------------------
useLayoutEffect(() => {
@@ -288,7 +298,7 @@ export default function DownloadTray() {
id: "copyLink",
label: t("tray.done.copyLink"),
icon: <Link className="w-4 h-4" />,
onSelect: () => void quickShareLink(qc, job, t),
onSelect: () => void quickShareLink(qc, { kind: "file", id: job.id }, t),
},
]);
const menuItems = menu.target
+7 -8
View File
@@ -23,6 +23,7 @@ import Modal from "./ui/Modal";
import { MoreButton } from "./ui/MoreButton";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { useContextMenu } from "./ui/useContextMenu";
import RemoteImage from "./ui/RemoteImage";
// The Files viewer (DESIGN.md "Files preview"): the library's files that are not media, one at a time,
// in the shared dialog shell. What a file shows is the server's call (`asset.preview`,
@@ -187,19 +188,17 @@ function Stage({
title: string;
}) {
const { t } = useTranslation();
const [failed, setFailed] = useState(false);
if (kind === "image")
return failed ? (
<StateMessage tone="error" className={`${STAGE} justify-center`}>
{t("common.loadError")}
</StateMessage>
) : (
return (
<div className={`${STAGE} flex items-center justify-center`}>
<img
{/* A preview that fails is an answer (the file is not an image after all), not a hiccup:
said at once, no retries. */}
<RemoteImage
src={api.previewUrl(file.id)}
alt={title}
onError={() => setFailed(true)}
className="max-h-full max-w-full rounded-md object-contain"
retries={0}
fallback={<StateMessage tone="error">{t("common.loadError")}</StateMessage>}
/>
</div>
);
@@ -151,10 +151,12 @@ const folder = (id: number, extra: Partial<FileFolder>): FileFolder => ({
count: 1,
size_bytes: 0,
created_at: null,
has_link: false,
...extra,
});
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 +379,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];
@@ -840,12 +854,14 @@ describe("Files · the grid", () => {
describe("Files · the open folder's menu", () => {
const folderMenu = () => screen.getByRole("button", { name: "files.folder.menu" });
it("an own folder is renamed, kept for a while, or deleted", async () => {
it("an own folder is shared by link, renamed, kept for a while, or deleted", async () => {
mocks.files = [];
mocks.folders = [YT, OWN];
mount({ kind: "folder", id: 2 });
await openMenu(() => fireEvent.click(folderMenu()));
expect(menuRows()).toEqual([
"files.actions.copyLink",
"files.folder.shareLink",
"files.folder.rename",
"files.folder.retention",
"files.folder.delete",
@@ -1043,6 +1059,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];
+5 -4
View File
@@ -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
@@ -751,6 +751,7 @@ export default function Files() {
ariaLabel={t("files.folder.menu")}
items={folderMenu.target ? folderActions.items(folderMenu.target) : []}
/>
{folderActions.dialog}
<Suspense fallback={null}>
{upload && (
<UploadDialog
@@ -62,6 +62,7 @@ const folder = (id: number, extra: Partial<FileFolder>): FileFolder => ({
count: 1,
size_bytes: 0,
created_at: null,
has_link: false,
...extra,
});
const YT = folder(1, { source_key: "youtube" });
@@ -124,6 +125,8 @@ describe("FilesRail · folders", () => {
});
await waitFor(() =>
expect(menuRows()).toEqual([
"files.actions.copyLink",
"files.folder.shareLink",
"files.folder.rename",
"files.folder.retention",
"files.folder.delete",
@@ -136,6 +139,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);
@@ -183,3 +199,62 @@ describe("FilesRail · what it says when a part is empty or missing", () => {
expect(await screen.findByText("files.usage.failed")).toBeTruthy();
});
});
describe("FilesRail · a folder shared by link", () => {
it("marks the folder a live link opens, and only that one", async () => {
const work = folder(5, { kind: "own", name: "Munka", retention_days: null });
await mount([YT, { ...OWN, has_link: true }, work]);
expect(entry("Receptek").textContent).toContain("files.folder.shared");
expect(entry("Munka").textContent).not.toContain("files.folder.shared");
expect(entry("files.source.youtube").textContent).not.toContain("files.folder.shared");
});
it("copies the folder's link in one click, making it the first time", async () => {
await mount();
const list = vi.spyOn(api, "folderLinks").mockResolvedValue([]);
const make = vi
.spyOn(api, "createFolderLink")
.mockResolvedValue({ id: 1, url: "/watch/f" } as never);
act(() => {
fireEvent.contextMenu(entry("Receptek"));
});
await waitFor(() => expect(menuRows()).toContain("files.actions.copyLink"));
fireEvent.click(
screen
.getAllByRole("menuitem", { hidden: true })
.find((b) => b.textContent === "files.actions.copyLink")!,
);
await waitFor(() => expect(make).toHaveBeenCalledWith(2, {}));
expect(list).toHaveBeenCalledWith(2);
});
it("opens the folder's Share dialog", async () => {
await mount();
vi.spyOn(api, "folderLinks").mockResolvedValue([
{
id: 1,
token: "f",
url: "/watch/f",
allow_download: true,
has_password: false,
expires_at: null,
view_count: 0,
created_at: null,
},
]);
act(() => {
fireEvent.contextMenu(entry("Receptek"));
});
await waitFor(() => expect(menuRows()).toContain("files.folder.shareLink"));
fireEvent.click(
screen
.getAllByRole("menuitem", { hidden: true })
.find((b) => b.textContent === "files.folder.shareLink")!,
);
// The dialog is its own chunk, fetched on first use: slower than a re-render under a full run.
expect(
await screen.findByText("downloads.share.folderLinkHint", {}, { timeout: 5000 }),
).toBeTruthy();
expect(await screen.findByDisplayValue(`${window.location.origin}/watch/f`)).toBeTruthy();
});
});
+18 -2
View File
@@ -3,7 +3,7 @@ import { Meter } from "./ui/Meter";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import clsx from "clsx";
import { FolderOpen, FolderPlus, HardDrive, Users } from "lucide-react";
import { FolderOpen, FolderPlus, HardDrive, Link2, Users } from "lucide-react";
import { api, type FileFolder } from "../lib/api";
import { compareText } from "../lib/collate";
import { formatBytes } from "../lib/format";
@@ -36,16 +36,20 @@ function Entry({
view,
trigger,
folderId,
shared = false,
}: {
icon: React.ComponentType<{ className?: string }>;
label: string;
count?: number;
/** A live public link opens the folder: what is moved in is shown to whoever holds the link. */
shared?: boolean;
view: FilesView;
/** The folder's menu triggers (right click, long press, Shift+F10). */
trigger?: Record<string, unknown>;
/** A folder's entry is found by it, to hand the keyboard back after a rename. */
folderId?: number;
}) {
const { t } = useTranslation();
const { view: current } = useFiles();
const { setView } = useFilesActions();
const active = sameView(view, current);
@@ -64,6 +68,12 @@ function Entry({
>
<Icon className={clsx("size-4 shrink-0", !active && "text-muted")} />
<span className="min-w-0 flex-1 truncate text-left">{label}</span>
{shared && (
<span title={t("files.folder.shared")} className="shrink-0">
<Link2 aria-hidden className="size-3.5 text-accent" />
<span className="sr-only">{t("files.folder.shared")}</span>
</span>
)}
{count !== undefined && <span className="text-xs tabular-nums text-muted">{count}</span>}
</Pressable>
);
@@ -124,6 +134,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<number | null>(null);
const [creating, setCreating] = useState(false);
@@ -173,6 +186,7 @@ export default function FilesRail({
icon={folderIcon(f)}
label={folderLabel(f, t)}
count={f.count}
shared={f.has_link}
view={{ kind: "folder", id: f.id }}
trigger={menu.triggerProps(f)}
folderId={f.id}
@@ -202,8 +216,9 @@ export default function FilesRail({
<div className="flex flex-col gap-0.5">
{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 && (
<span className="px-2 py-1 text-xs text-muted">{t("files.rail.noSources")}</span>
)}
</div>
@@ -263,6 +278,7 @@ export default function FilesRail({
ariaLabel={t("files.folder.menu")}
items={menu.target ? folderActions.items(menu.target) : []}
/>
{folderActions.dialog}
</FilterRail>
);
}
+3 -2
View File
@@ -72,6 +72,7 @@ import {
mediaPictureBox,
mediaTitleClass,
} from "./ui/mediaCardStyles";
import RemoteImage from "./ui/RemoteImage";
// The Home surface — "Mit nézzek most?".
//
@@ -450,7 +451,7 @@ function ShelfTile({
className={MEDIA_PICTURE}
>
{card.thumb ? (
<img src={card.thumb} alt="" loading="lazy" className={MEDIA_IMG} />
<RemoteImage src={card.thumb} alt="" loading="lazy" className={MEDIA_IMG} />
) : null}
{/* The Play only when there is something to play: the click plays the channel's newest
unwatched video, the playlist's first. */}
@@ -574,7 +575,7 @@ function Hero({
aria-label={`${started ? t("home.hero.continue") : t("home.hero.play")}: ${card.title}`}
>
{card.thumb ? (
<img src={card.thumb} alt="" className="w-full h-full object-cover" />
<RemoteImage src={card.thumb} alt="" className="w-full h-full object-cover" />
) : null}
{card.duration_seconds != null && (
// It steps aside while the hover row covers its corner, as a card's length does.
+3 -1
View File
@@ -29,6 +29,7 @@ import SegmentedToggle from "./ui/SegmentedToggle";
import { Textarea, Input } from "./ui/Input";
import { Checkbox } from "./ui/NativeControls";
import { SURFACE } from "./ui/surfaces";
import RemoteImage from "./ui/RemoteImage";
// The issue composer: one dialog — the draft on the left (title, description, the
// steps drafted from the session, the environment), what goes with it on the right (the two logs,
@@ -680,10 +681,11 @@ function Attachments({
key={shot.id}
className="flex items-center gap-3 rounded-lg border border-border px-3 py-2"
>
<img
<RemoteImage
src={api.debugScreenshotUrl(session.id, shot.id)}
alt={t("recorder.composer.screenshot", { n: i + 1 })}
className="h-10 w-16 shrink-0 rounded-md object-cover bg-surface"
fallback={<div className="h-10 w-16 shrink-0 rounded-md bg-surface" />}
/>
<div className="flex min-w-0 flex-1 flex-col">
<span className="text-sm">{t("recorder.composer.screenshot", { n: i + 1 })}</span>
+2 -1
View File
@@ -10,6 +10,7 @@ import { useMediaLogRef } from "../lib/mediaLog";
import { useMediaSession } from "../lib/mediaSession";
import { useMediaKeys } from "../lib/mediaKeys";
import { keptPosition, useResume } from "../lib/resume";
import RemoteImage from "./ui/RemoteImage";
// In-app player for a completed library item. We do NOT reuse PlayerModal (a cross-origin
// YouTube IFrame shell) or PlexPlayer (a Plex HLS/session shell): both are bound to their own domain
@@ -94,7 +95,7 @@ export default function LibraryPlayer({
{isAudio ? (
<div className="flex flex-col items-center gap-4 py-2">
{poster ? (
<img
<RemoteImage
src={poster}
alt=""
className="w-40 h-40 rounded-xl object-cover border border-border"
@@ -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 = [];
+39 -3
View File
@@ -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<AppNotification>({ keyOf: byId });
const clientMenu = useContextMenu<ClientNotif>({ 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<string, string | undefined> = {
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")}
</Button>
)}
{isTokenExpiring && (
<Button
variant="link"
className="text-sm mt-1.5"
data-nav-open=""
onClick={onOpenTokens}
icon={<KeyRound className="w-3.5 h-3.5" />}
>
{t("inbox.openTokens")}
</Button>
)}
{isDownloadDone && (
<Button
variant="link"
+6 -1
View File
@@ -67,6 +67,7 @@ import { MoreButton } from "./ui/MoreButton";
import { HiddenFilterCue } from "./ui/HiddenFilterCue";
import { PageHeading } from "./ui/Headings";
import { useCopyItems } from "./copyItems";
import RemoteImage from "./ui/RemoteImage";
// The playlist detail is a HYBRID table: real sortable/filterable column headers over a
// body that is still a drag-reorderable @dnd-kit list.
@@ -847,7 +848,11 @@ export default function Playlists() {
<div className="flex gap-3 items-center">
<div className="w-20 h-11.25 rounded-lg bg-card overflow-hidden shrink-0">
{items[0]?.thumbnail_url && (
<img src={items[0].thumbnail_url} alt="" className="w-full h-full object-cover" />
<RemoteImage
src={items[0].thumbnail_url}
alt=""
className="w-full h-full object-cover"
/>
)}
</div>
<div className="min-w-0 flex-1">
+7 -2
View File
@@ -29,6 +29,7 @@ import { IconButton } from "./ui/IconButton";
import { Pressable } from "./ui/Pressable";
import { Input } from "./ui/Input";
import { MoreButton } from "./ui/MoreButton";
import RemoteImage from "./ui/RemoteImage";
type PlSort = {
key: "custom" | "name" | "count" | "duration" | "recent";
@@ -298,7 +299,11 @@ export default function PlaylistsRail({
<div key={pl.id} className={`${ROW_BASE} bg-accent/10 border-accent`}>
<div className="w-8.5 h-8.5 rounded-md bg-card overflow-hidden shrink-0">
{pl.cover_thumbnail && (
<img src={pl.cover_thumbnail} alt="" className="w-full h-full object-cover" />
<RemoteImage
src={pl.cover_thumbnail}
alt=""
className="w-full h-full object-cover"
/>
)}
</div>
<Input
@@ -349,7 +354,7 @@ export default function PlaylistsRail({
>
<div className="w-8.5 h-8.5 rounded-md bg-card overflow-hidden shrink-0">
{pl.cover_thumbnail && (
<img
<RemoteImage
src={pl.cover_thumbnail}
alt=""
className="w-full h-full object-cover"
+12 -12
View File
@@ -55,6 +55,7 @@ import { Chip } from "./ui/Chip";
import { IconButton } from "./ui/IconButton";
import { Pressable } from "./ui/Pressable";
import { SURFACE } from "./ui/surfaces";
import RemoteImage from "./ui/RemoteImage";
// Lazy: the rich player (pulls in hls.js) loads only when something is first played.
const PlexPlayer = lazy(() => import("./PlexPlayer"));
@@ -672,18 +673,17 @@ export default function PlexBrowse() {
title={t("plex.info.viewPerson", { name: p.name })}
className="group/person flex min-w-0 items-center gap-2.5 rounded-lg"
>
{p.photo ? (
<img
src={p.photo}
alt=""
loading="lazy"
className="w-10 h-10 rounded-full object-cover shrink-0"
/>
) : (
<div className="w-10 h-10 rounded-full grid place-items-center bg-surface text-muted shrink-0">
<User className="w-5 h-5" />
</div>
)}
<RemoteImage
src={p.photo}
alt=""
loading="lazy"
className="w-10 h-10 rounded-full object-cover shrink-0"
fallback={
<div className="w-10 h-10 rounded-full grid place-items-center bg-surface text-muted shrink-0">
<User className="w-5 h-5" />
</div>
}
/>
<div className="min-w-0">
<div className="text-sm font-medium truncate max-w-37.5 group-hover/person:text-accent">
{p.name}
+8 -1
View File
@@ -35,6 +35,7 @@ import { useContextMenu } from "./ui/useContextMenu";
import { usePlexCardActions } from "./usePlexCardActions";
import { toggleWatchState } from "../lib/plexWatch";
import { vocab } from "../lib/vocab";
import RemoteImage from "./ui/RemoteImage";
// The rich "media info" surface for a Plex item — poster/art, IMDb score + link, content rating,
// genres, director, cast (with photos), summary. Reused in TWO places: a full-page view opened from
@@ -403,10 +404,16 @@ export default function PlexInfo({
>
<div className="flex gap-4 sm:gap-6">
{/* Poster */}
<img
<RemoteImage
src={detail.thumb}
alt=""
className={`shrink-0 self-start rounded-xl border border-border object-cover ${overlay ? "w-24 sm:w-28" : DETAIL_POSTER} aspect-2/3`}
// The poster's own box, empty, so the text beside it does not jump into its place.
fallback={
<div
className={`shrink-0 self-start rounded-xl border border-border bg-surface ${overlay ? "w-24 sm:w-28" : DETAIL_POSTER} aspect-2/3`}
/>
}
/>
<div className={`min-w-0 flex-1 ${overlay ? "text-white/90" : ""}`}>
@@ -6,6 +6,7 @@ import { api, type PlexCard } from "../lib/api";
import { useDebounced } from "../lib/useDebounced";
import { LoadingState } from "./ui/QueryState";
import { cardImage } from "../lib/plexImage";
import RemoteImage from "./ui/RemoteImage";
// A debounced library search-and-pick list, shared by the "Add title" panel (PlexCollectionView) and
// the "New collection" first-title panel (plexBrowseLists) — both search the library the same way and
@@ -49,7 +50,7 @@ export default function PlexLibraryPicker({
<ul className="flex flex-col gap-1">
{results.map((c) => (
<li key={c.id} className="flex items-center gap-3">
<img
<RemoteImage
{...cardImage(c.thumb)}
alt=""
className="h-12 w-8 shrink-0 rounded object-cover"
+9 -3
View File
@@ -56,6 +56,7 @@ import { PageHeading } from "./ui/Headings";
import { MEDIA_BADGE } from "./ui/mediaCardStyles";
import { seasonTitle } from "../lib/plexDetail";
import { cardImage } from "../lib/plexImage";
import RemoteImage from "./ui/RemoteImage";
// A playlist's items, grouped for readability: movies stay standalone, while a run of episodes from the
// same show collapses into a season/show group so a whole series doesn't sprawl into an endless flat
@@ -158,7 +159,12 @@ function Poster({ src, className }: { src: string; className: string }) {
<div
className={`relative shrink-0 overflow-hidden rounded-sm border border-border bg-surface ${className}`}
>
<img {...cardImage(src)} alt="" loading="lazy" className="h-full w-full object-cover" />
<RemoteImage
{...cardImage(src)}
alt=""
loading="lazy"
className="h-full w-full object-cover"
/>
</div>
);
}
@@ -201,7 +207,7 @@ function EpisodeRow({ ep, ctx, dense }: { ep: PlexCard; ctx: Ctx; dense: boolean
<div
className={`relative shrink-0 overflow-hidden rounded-sm border border-border bg-surface ${dense ? "aspect-video w-14" : "aspect-video w-20"}`}
>
<img
<RemoteImage
{...cardImage(ep.thumb)}
alt=""
loading="lazy"
@@ -323,7 +329,7 @@ function ShowGroup({ block, ctx }: { block: ShowBlock; ctx: Ctx }) {
aria-hidden
>
{block.items.slice(0, 3).map((ep, i) => (
<img
<RemoteImage
key={ep.id}
{...cardImage(ep.thumb)}
alt=""
+2 -1
View File
@@ -13,6 +13,7 @@ import { Pressable } from "./ui/Pressable";
import { SURFACE } from "./ui/surfaces";
import { ROVING_HIGHLIGHT } from "./ui/AnchoredPopover";
import { cardImage } from "../lib/plexImage";
import RemoteImage from "./ui/RemoteImage";
// The Plex header search box: the shared SearchBar enriched with the search scope-pill (a
// GitHub-style field selector inside the box) and an instant typeahead of the top matches attached
@@ -160,7 +161,7 @@ export default function PlexSearchBox() {
data-active={hi === i ? "true" : undefined}
className={`flex w-full items-center gap-3 rounded-lg p-1.5 transition ${ROVING_HIGHLIGHT}`}
>
<img
<RemoteImage
{...cardImage(c.thumb)}
alt=""
loading="lazy"
+2 -1
View File
@@ -4,6 +4,7 @@ import { navItemAttrs } from "../lib/listNav";
import { MEDIA_IMG } from "./ui/mediaCardStyles";
import { Pressable } from "./ui/Pressable";
import { cardImage } from "../lib/plexImage";
import RemoteImage from "./ui/RemoteImage";
// A browse tile split in two: the TOP half shows up to 2 montage posters (from /api/plex/image/{key}),
// the BOTTOM half is a solid colour-hash band that carries the label — so the text is always readable
@@ -49,7 +50,7 @@ export default function PosterTile({
{posters.length > 0 &&
[0, 1].map((i) =>
posters[i] ? (
<img
<RemoteImage
key={i}
{...cardImage(`/api/plex/image/${posters[i]}`)}
alt=""
@@ -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<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
};
});
vi.mock("../lib/api", async (importOriginal) => ({
...(await importOriginal<typeof import("../lib/api")>()),
api: h,
}));
vi.mock("../lib/notifications", async (importOriginal) => {
const actual = await importOriginal<typeof import("../lib/notifications")>();
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(
<QueryClientProvider client={qc}>
<SignInMethods me={me} />
</QueryClientProvider>,
);
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));
});
});
+13 -3
View File
@@ -9,6 +9,7 @@ import {
Bell,
Check,
Download,
KeyRound,
Loader2,
Monitor,
RefreshCw,
@@ -17,6 +18,8 @@ import {
} from "lucide-react";
import { DownloadFormats } from "./ProfileEditor";
import DiagnosticsSettings from "./DiagnosticsSettings";
import ApiTokens from "./ApiTokens";
import { type SettingsTabId } from "../lib/settingsTab";
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
import { api, HttpError, type Me } from "../lib/api";
import { displayName, roleKey } from "../lib/api/account";
@@ -61,11 +64,12 @@ export interface PrefsController {
saveState: PrefsSaveState;
}
type TabId = "appearance" | "notifications" | "downloads" | "account" | "diagnostics";
type TabId = SettingsTabId;
const TABS: { id: TabId; icon: typeof Monitor }[] = [
{ id: "appearance", icon: Monitor },
{ id: "notifications", icon: Bell },
{ id: "downloads", icon: Download },
{ id: "tokens", icon: KeyRound },
{ id: "account", icon: User },
{ id: "diagnostics", icon: Activity },
];
@@ -114,6 +118,7 @@ export default function SettingsPanel() {
{tab === "appearance" && <Appearance prefs={prefs} />}
{tab === "notifications" && <Notifications prefs={prefs} />}
{tab === "downloads" && <Downloads />}
{tab === "tokens" && <ApiTokens />}
{tab === "account" && <Account me={me} onOpenWizard={openWizard} />}
{tab === "diagnostics" && <DiagnosticsSettings />}
</div>
@@ -356,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();
@@ -382,6 +387,9 @@ function SignInMethods({ me }: { me: Me }) {
setSessBusy(true);
try {
await api.logoutOthers();
// 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) {
notifyActionError(e, t("settings.account.sessions.failed"));
@@ -399,7 +407,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 asks again when it next opens, too.
void qc.invalidateQueries({ queryKey: qk.apiTokens() });
await qc.invalidateQueries({ queryKey: qk.me() });
notify({
level: "success",
+184 -26
View File
@@ -4,8 +4,8 @@
// Revoke is the destructive one. Both colours once sat on the button, where the ghost variant's own
// colour class outranks them (clsx does not merge Tailwind), so a password-protected link looked
// unprotected and Revoke hovered neutral. They live on the ICON now; this pins them there.
import { describe, it, expect, afterEach, vi } from "vitest";
import { render, screen, cleanup } from "@testing-library/react";
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
import { render, screen, cleanup, fireEvent, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
@@ -15,33 +15,53 @@ vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }),
}));
vi.mock("./ui/Modal", () => ({ default: ({ children }: { children: ReactNode }) => children }));
vi.mock("./confirmContext", () => ({ useConfirm: () => async () => false }));
vi.mock("./ui/Modal", () => ({
default: ({ title, children }: { title: string; children: ReactNode }) => (
<div>
<h2>{title}</h2>
{children}
</div>
),
}));
vi.mock("./confirmContext", () => ({ useConfirm: () => async () => h.confirm }));
const h = vi.hoisted(() => ({
confirm: false,
revokeDownloadLink: vi.fn(),
links: null as unknown[] | null,
folderLinks: vi.fn(),
createFolderLink: vi.fn(),
createDownloadLink: vi.fn(),
}));
vi.mock("../lib/api", () => ({
api: {
shareRecipients: async () => [],
downloadLinks: async () => [
{
id: 1,
token: "a",
url: "/watch/a",
allow_download: false,
has_password: true,
expires_at: null,
view_count: 0,
created_at: null,
},
{
id: 2,
token: "b",
url: "/watch/b",
allow_download: false,
has_password: false,
expires_at: null,
view_count: 0,
created_at: null,
},
],
folderLinks: h.folderLinks,
createFolderLink: h.createFolderLink,
createDownloadLink: h.createDownloadLink,
revokeDownloadLink: h.revokeDownloadLink,
downloadLinks: async () =>
h.links ?? [
{
id: 1,
token: "a",
url: "/watch/a",
allow_download: false,
has_password: true,
expires_at: null,
view_count: 0,
created_at: null,
},
{
id: 2,
token: "b",
url: "/watch/b",
allow_download: false,
has_password: false,
expires_at: null,
view_count: 0,
created_at: null,
},
],
},
}));
@@ -49,6 +69,10 @@ import ShareDialog from "./ShareDialog";
import type { DownloadJob } from "../lib/api";
afterEach(cleanup);
beforeEach(() => {
h.links = null;
h.confirm = false;
});
const iconClass = (button: HTMLElement) => button.querySelector("svg")?.getAttribute("class") ?? "";
@@ -71,3 +95,137 @@ describe("ShareDialog · a link row's state colours", () => {
expect(iconClass(revoke)).toContain("text-danger");
});
});
describe("ShareDialog · a link whose address cannot be shown again", () => {
it("says so in place of the address, with nothing to copy", async () => {
const link = (id: number, url: string | null) => ({
id,
token: url && url.slice("/watch/".length),
url,
allow_download: false,
has_password: false,
expires_at: null,
view_count: 0,
created_at: null,
});
h.links = [link(3, null), link(2, "/watch/b")];
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<ShareDialog job={{ id: 9 } as DownloadJob} onClose={() => {}} />
</QueryClientProvider>,
);
expect(await screen.findByText("downloads.share.urlHidden")).toBeTruthy();
// One row has an address to show and copy; the hidden one shows none (not "…/null").
expect(screen.getAllByRole("button", { name: "downloads.share.copy" })).toHaveLength(1);
const shown = screen.getAllByRole("textbox").map((el) => (el as HTMLInputElement).value);
expect(shown).toContain(`${window.location.origin}/watch/b`);
expect(shown.some((v) => v.includes("null"))).toBe(false);
// Its other controls stay: it can still be protected, made downloadable, or revoked.
expect(screen.getAllByRole("button", { name: "downloads.share.revoke" })).toHaveLength(2);
});
});
describe("ShareDialog · a folder", () => {
const link = (id: number) => ({
id,
token: `f${id}`,
url: `/watch/f${id}`,
allow_download: true,
has_password: false,
expires_at: null,
view_count: 0,
created_at: null,
});
it("shares by link only, says what a folder link shows, and makes it for the folder", async () => {
h.folderLinks.mockReset().mockResolvedValue([link(4)]);
h.createFolderLink.mockReset().mockResolvedValue(link(5));
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<ShareDialog folder={{ id: 7, name: "Friends" }} onClose={() => {}} />
</QueryClientProvider>,
);
expect(await screen.findByDisplayValue(`${window.location.origin}/watch/f4`)).toBeTruthy();
expect(h.folderLinks).toHaveBeenCalledWith(7);
expect(screen.getByRole("heading").textContent).toBe("downloads.share.folderTitle");
expect(screen.getByText("downloads.share.folderLinkHint")).toBeTruthy();
// No sharing with a user: "Shared with me" lists files, not folders.
expect(screen.queryByText("downloads.share.userSection")).toBeNull();
// A stream-only link, then the form is back at its default for the next one: downloadable.
fireEvent.click(screen.getByRole("button", { name: "downloads.share.newLink" }));
// The form's own checkbox, under the link rows' (each row has its own).
const allow = () => {
const boxes = screen.getAllByRole("checkbox", { name: "downloads.share.allowDownload" });
return boxes[boxes.length - 1]!;
};
fireEvent.click(allow());
expect((allow() as HTMLInputElement).checked).toBe(false);
fireEvent.click(screen.getByRole("button", { name: "downloads.share.createLink" }));
await waitFor(() =>
expect(h.createFolderLink).toHaveBeenCalledWith(
7,
expect.objectContaining({
allow_download: false,
}),
),
);
expect(h.createDownloadLink).not.toHaveBeenCalled();
fireEvent.click(await screen.findByRole("button", { name: "downloads.share.newLink" }));
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(
<QueryClientProvider client={qc}>
<ShareDialog folder={{ id: 7, name: "Friends" }} onClose={() => {}} />
</QueryClientProvider>,
);
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));
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const view = render(
<QueryClientProvider client={qc}>
<ShareDialog folder={{ id: 7, name: "Friends" }} onClose={() => {}} />
</QueryClientProvider>,
);
await waitFor(() => expect(h.createFolderLink).toHaveBeenCalledTimes(1));
view.unmount();
h.confirm = true;
h.folderLinks
.mockReset()
.mockResolvedValueOnce([link(4)])
.mockResolvedValue([]);
h.createFolderLink.mockReset();
h.revokeDownloadLink.mockReset().mockResolvedValue({ ok: true, deleted: 4 });
const qc2 = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc2}>
<ShareDialog folder={{ id: 7, name: "Friends" }} onClose={() => {}} />
</QueryClientProvider>,
);
fireEvent.click(await screen.findByRole("button", { name: "downloads.share.revoke" }));
await waitFor(() => expect(h.revokeDownloadLink).toHaveBeenCalledWith(4));
await screen.findByText("downloads.share.noLinks");
expect(h.createFolderLink).not.toHaveBeenCalled();
});
});
+81 -39
View File
@@ -7,15 +7,17 @@ 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";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
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) -----------------------------------------------------
@@ -67,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 = window.location.origin + link.url;
const fullUrl = link.url && window.location.origin + link.url;
const copy = async () => {
if (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 }),
@@ -114,10 +111,20 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
<div className={`${SURFACE.card} p-2.5 space-y-2`}>
<div className="flex items-center gap-2 rounded-md focus-within:outline-2 focus-within:outline-accent/60 focus-within:outline-offset-2">
<Link2 className="w-4 h-4 text-muted shrink-0" />
<InnerInput className="text-xs text-muted truncate" readOnly value={fullUrl} />
<IconButton label={t("downloads.share.copy")} size="sm" variant="ghost" onClick={copy}>
{copied ? <Check className="w-4 h-4 text-success" /> : <Copy className="w-4 h-4" />}
</IconButton>
{fullUrl ? (
<>
<InnerInput className="text-xs text-muted truncate" readOnly value={fullUrl} />
<IconButton label={t("downloads.share.copy")} size="sm" variant="ghost" onClick={copy}>
{copied ? <Check className="w-4 h-4 text-success" /> : <Copy className="w-4 h-4" />}
</IconButton>
</>
) : (
// The server keeps no copy of this link's address it can read: the link still opens for
// whoever has it, but it cannot be shared again from here.
<span className="flex-1 min-w-0 text-xs text-muted">
{t("downloads.share.urlHidden")}
</span>
)}
{/* All three on the same rung. Only Copy was migrated at first, which left one 2rem button
between two 1.75rem ones with a different radius and hover fill — and, on a coarse
pointer, `.touch-target` grew ONLY the migrated one to 2.75rem while the destructive
@@ -227,12 +234,12 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
);
}
function LinkShare({ job }: { job: DownloadJob }) {
function LinkShare({ target }: { target: LinkTarget }) {
const { t } = useTranslation();
const qc = useQueryClient();
const links = useQuery({
queryKey: qk.downloadLinks(job.id),
queryFn: () => api.downloadLinks(job.id),
queryKey: linksKey(target),
queryFn: () => fetchLinks(target),
});
const [creating, setCreating] = useState(false);
// Downloadable by default — matches the one-click Share (which relies on the backend
@@ -240,11 +247,11 @@ function LinkShare({ job }: { job: DownloadJob }) {
const [allowDownload, setAllowDownload] = useState(true);
const [expiryDays, setExpiryDays] = useState<number | null>(null);
const [password, setPassword] = useState("");
const invalidate = () => void qc.invalidateQueries({ queryKey: qk.downloadLinks(job.id) });
const invalidate = () => invalidateLinks(qc, target);
const create = useMutation({
mutationFn: () =>
api.createDownloadLink(job.id, {
makeLink(target, {
allow_download: allowDownload,
expires_days: expiryDays,
password: password.trim() || undefined,
@@ -252,7 +259,8 @@ function LinkShare({ job }: { job: DownloadJob }) {
onSuccess: () => {
invalidate();
setCreating(false);
setAllowDownload(false);
// Back to the form's own default for the next link: downloadable.
setAllowDownload(true);
setExpiryDays(null);
setPassword("");
},
@@ -266,32 +274,49 @@ function LinkShare({ job }: { job: DownloadJob }) {
const autoTried = useRef(false);
// The mutation OBJECT is rebuilt every render; `mutate` is not (react-query memoises it), so the
// two halves the effect actually uses can be named while `links.data` stays the only real trigger.
// The autoTried ref is what makes it fire exactly once.
const { mutate: createLink, isPending: createPending } = create;
// The autoTried ref is what makes it fire exactly once — and only on the list the dialog OPENED
// with: revoking the last link empties the list too, and minting a fresh public link right after
// the owner revoked one is the opposite of what they asked for.
const { mutate: createLink } = create;
useEffect(() => {
if (links.data && links.data.length === 0 && !autoTried.current && !createPending) {
autoTried.current = true;
createLink();
}
}, [links.data, createLink, createPending]);
if (!links.data || autoTried.current) return;
autoTried.current = true;
if (links.data.length === 0) createLink();
}, [links.data, createLink]);
return (
<div>
<div className="flex items-center gap-2 text-sm font-medium mb-1">
<Link2 className="w-4 h-4 text-muted" /> {t("downloads.share.linkSection")}
</div>
<p className="text-xs text-muted mb-2">{t("downloads.share.linkHint")}</p>
<div className="space-y-2">
{(links.data ?? []).map((l) => (
<LinkRow key={l.id} link={l} onChanged={invalidate} />
))}
{links.data && links.data.length === 0 && !creating && !create.isPending && (
<p className="text-xs text-muted">{t("downloads.share.noLinks")}</p>
<p className="text-xs text-muted mb-2">
{t(
target.kind === "folder" ? "downloads.share.folderLinkHint" : "downloads.share.linkHint",
)}
</div>
</p>
{creating ? (
{/* 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 ? (
<StateMessage tone="error" compact onRetry={() => void links.refetch()}>
{t("downloads.share.linksFailed")}
</StateMessage>
) : (
<LoadingState label={t("common.loading")} className="py-4" />
)
) : (
<div className="space-y-2">
{links.data.map((l) => (
<LinkRow key={l.id} link={l} onChanged={invalidate} />
))}
{links.data.length === 0 && !creating && !create.isPending && (
<p className="text-xs text-muted">{t("downloads.share.noLinks")}</p>
)}
</div>
)}
{!links.data ? null : creating ? (
<div className="mt-2 rounded-lg border border-border p-3 space-y-3">
<CheckRow checked={allowDownload} onChange={setAllowDownload} className="text-sm">
{t("downloads.share.allowDownload")}
@@ -349,14 +374,31 @@ function LinkShare({ job }: { job: DownloadJob }) {
);
}
export default function ShareDialog({ job, onClose }: { job: DownloadJob; onClose: () => void }) {
// A file is shared with a registered user and by link; a folder by link only — "Shared with me" lists
// files, and a folder link already shows its files to whoever has it.
type ShareDialogProps = { onClose: () => void } & (
| { job: DownloadJob; folder?: never }
| { folder: { id: number; name: string }; job?: never }
);
export default function ShareDialog({ job, folder, onClose }: ShareDialogProps) {
const { t } = useTranslation();
if (folder)
return (
<Modal
initialFocus="close"
title={t("downloads.share.folderTitle", { name: folder.name })}
onClose={onClose}
>
<LinkShare target={{ kind: "folder", id: folder.id }} />
</Modal>
);
return (
<Modal initialFocus="close" title={t("downloads.share.title")} onClose={onClose}>
<div className="space-y-5">
<UserShare job={job} />
<div className="border-t border-border" />
<LinkShare job={job} />
<LinkShare target={{ kind: "file", id: job.id }} />
</div>
</Modal>
);
+2 -1
View File
@@ -9,6 +9,7 @@ import { cardImage } from "../lib/plexImage";
import { episodeNumbers } from "../lib/plexDetail";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import RemoteImage from "./ui/RemoteImage";
// Up-next / end-card. Shown at episode end when a next item exists and the pref is on, INSTEAD
// of the old instant jump — so binge stays under the viewer's control. Counts down ~10s then
@@ -76,7 +77,7 @@ export function UpNextCard({
>
<div className="h-[6.5rem] w-[4.6rem] flex-none overflow-hidden rounded-lg bg-white/10">
{next && (
<img
<RemoteImage
{...cardImage(api.plexImageUrl(nextId, "thumb"))}
alt=""
loading="lazy"
@@ -16,6 +16,8 @@ const h = vi.hoisted(() => ({
filesUsage: vi.fn(async () => ({ upload_max_bytes: 1_000_000 })),
notify: vi.fn(),
showInTray: vi.fn(),
uploadStarted: vi.fn(),
uploadEnded: vi.fn(),
openFile: vi.fn(),
}));
@@ -35,7 +37,11 @@ vi.mock("../lib/notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("../lib/notifications")>()),
notify: h.notify,
}));
vi.mock("../lib/downloadQueue", () => ({ showInTray: h.showInTray }));
vi.mock("../lib/downloadQueue", () => ({
showInTray: h.showInTray,
uploadStarted: h.uploadStarted,
uploadEnded: h.uploadEnded,
}));
import UploadDialog from "./UploadDialog";
import { FilesActionsContext } from "./filesContext";
@@ -112,6 +118,22 @@ describe("UploadDialog", () => {
expect(h.notify.mock.calls[0]![0]).toMatchObject({
message: "downloads.upload.addedConverting",
});
// Its finish is still to come, and the tray's to announce.
expect(h.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 () => {
@@ -125,6 +147,9 @@ describe("UploadDialog", () => {
(h.notify.mock.calls[0]![0] as { action: { onClick: () => void } }).action.onClick();
expect(h.openFile).toHaveBeenLastCalledWith(42, 5);
expect(h.showInTray).not.toHaveBeenCalled();
// Done and said so here: the queue's next look must not announce it again, as a download.
expect(h.uploadStarted).toHaveBeenCalled();
expect(h.uploadEnded).toHaveBeenCalledWith(42);
cleanup();
h.notify.mockClear();
+8 -1
View File
@@ -10,7 +10,7 @@ import { isAborted } from "../lib/api/core";
import { notifyActionError } from "../lib/actionErrors";
import { invalidateDownloads } from "../lib/downloads";
import { notify } from "../lib/notifications";
import { showInTray } from "../lib/downloadQueue";
import { 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,
@@ -103,6 +106,9 @@ export default function UploadDialog({
onProgress: setPct,
signal: ac.signal,
});
// Said here, so the queue does not say it again; one the worker still converts is the tray's
// to announce when it is done.
uploadEnded(announces(job));
invalidateDownloads(qc);
notify({
level: "success",
@@ -120,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);
+2 -1
View File
@@ -43,6 +43,7 @@ import {
import { MediaCard, MediaProgress } from "./ui/MediaCard";
import { MediaResumeButtons } from "./MediaResumeButtons";
import { RelativeTime } from "./ui/RelativeTime";
import RemoteImage from "./ui/RemoteImage";
/** The "…" that opens the video's action list, and whether its menu is the open one. */
type More = { props: Record<string, unknown>; open: boolean };
@@ -252,7 +253,7 @@ function Thumb({
className={MEDIA_PICTURE}
>
{video.thumbnail_url ? (
<img
<RemoteImage
src={video.thumbnail_url}
alt=""
loading="lazy"
@@ -324,3 +324,106 @@ describe("WatchPage resume", () => {
expect(loaded(other.container.querySelector("video")!).currentTime).toBe(0);
});
});
// A folder link opens the folder's list: a video or a song plays in the same player a file link has,
// above the list; a document only downloads; Download follows the link's own setting.
describe("WatchPage folder", () => {
const file = (id: number, extra: Record<string, unknown> = {}) => ({
id,
title: `Clip ${id}`,
audio: false,
media_kind: "video",
size_bytes: 1000,
duration_s: 71,
file_url: `/api/public/watch/tok-folder/files/${id}/file`,
poster_url: null,
...extra,
});
const folder = (extra: Record<string, unknown> = {}) => ({
needs_password: false,
kind: "folder",
name: "Friends",
allow_download: true,
files: [file(1), file(2), file(3, { title: "Plan.pdf", media_kind: "file", duration_s: null })],
...extra,
});
const scrolled = vi.fn();
Element.prototype.scrollIntoView = scrolled;
it("lists the folder's files and plays the one picked above the list", async () => {
window.history.replaceState({}, "", "/watch/tok-folder");
mockMeta(folder());
const page = render(<WatchPage />);
expect((await page.findByRole("heading", { level: 1 })).textContent).toBe("Friends");
expect(page.getByText(/^3 files/)).toBeTruthy();
expect(page.container.querySelector("video")).toBeNull();
fireEvent.click(page.getByRole("button", { name: "Play: Clip 2" }));
const video = page.container.querySelector("video")!;
expect(video.getAttribute("src")).toBe("/api/public/watch/tok-folder/files/2/file");
expect(video.autoplay).toBe(true);
expect(page.getByRole("button", { name: "Play: Clip 2" }).getAttribute("aria-current")).toBe(
"true",
);
// The file's title sits under the folder's, one level down.
expect(page.getByRole("heading", { level: 2 }).textContent).toBe("Clip 2");
expect(scrolled).toHaveBeenCalled();
fireEvent.click(page.getByRole("button", { name: "Play: Clip 1" }));
expect(page.container.querySelector("video")!.getAttribute("src")).toBe(
"/api/public/watch/tok-folder/files/1/file",
);
});
it("offers a document for download only, and Download only where the link allows it", async () => {
window.history.replaceState({}, "", "/watch/tok-folder");
mockMeta(folder());
const page = render(<WatchPage />);
await page.findByText("Plan.pdf");
expect(page.queryByRole("button", { name: "Play: Plan.pdf" })).toBeNull();
const pdf = page.getByRole("link", { name: "Download: Plan.pdf" });
expect(pdf.getAttribute("href")).toBe("/api/public/watch/tok-folder/files/3/file");
cleanup();
mockMeta(folder({ allow_download: false }));
const streamOnly = render(<WatchPage />);
await streamOnly.findByText("Plan.pdf");
expect(streamOnly.queryAllByRole("link", { name: /^Download/ })).toEqual([]);
});
it("each file keeps its own place in a folder", async () => {
window.history.replaceState({}, "", "/watch/tok-folder-resume");
mockMeta(folder());
const page = render(<WatchPage />);
fireEvent.click(await page.findByRole("button", { name: "Play: Clip 1" }));
const video = page.container.querySelector("video")!;
let t = 0;
Object.defineProperties(video, {
currentTime: { get: () => t, set: (v: number) => void (t = v), configurable: true },
duration: { get: () => 600, configurable: true },
readyState: { get: () => 1, configurable: true },
});
video.dispatchEvent(new Event("loadedmetadata"));
video.dispatchEvent(new Event("playing"));
video.currentTime = 250;
video.dispatchEvent(new Event("pause"));
const points = JSON.stringify(localStorage);
expect(points).toContain("tok-folder-resume/1");
expect(points).not.toContain('"tok-folder-resume"');
});
it("says when the folder is empty", async () => {
window.history.replaceState({}, "", "/watch/tok-folder");
mockMeta(folder({ files: [] }));
const page = render(<WatchPage />);
expect(await page.findByText("This folder is empty.")).toBeTruthy();
expect(page.getByText("0 files")).toBeTruthy();
});
it("a password folder link asks for the folder's password", async () => {
window.history.replaceState({}, "", "/watch/tok-folder-locked");
mockMeta({ needs_password: true, kind: "folder" });
const page = render(<WatchPage />);
expect(await page.findByText("This folder is password-protected")).toBeTruthy();
});
});
+329 -167
View File
@@ -1,8 +1,22 @@
import { useEffect, useId, useState } from "react";
import { Download, File as FileIcon, Link2, Lock, PlayCircle, Repeat } from "lucide-react";
import clsx from "clsx";
import {
Download,
File as FileIcon,
Film,
Link2,
Lock,
Music,
Play,
PlayCircle,
Repeat,
} from "lucide-react";
import { Button } from "./ui/Button";
import { Chip } from "./ui/Chip";
import { formatBytes } from "../lib/format";
import { Pressable } from "./ui/Pressable";
import RemoteImage from "./ui/RemoteImage";
import { MEDIA_BADGE } from "./ui/mediaCardStyles";
import { formatBytes, formatDuration } from "../lib/format";
import { exposeVideoToBackgroundPlay } from "../lib/browserBackgroundPlay";
import { useMediaSession } from "../lib/mediaSession";
import { useMediaKeys } from "../lib/mediaKeys";
@@ -11,15 +25,17 @@ import { LS, readJSON, writeJSON } from "../lib/storage";
import { send } from "../lib/http";
import { Input } from "./ui/Input";
import { SURFACE } from "./ui/surfaces";
import { LinkButton } from "./ui/LinkButton";
import { IconLink, LinkButton } from "./ui/LinkButton";
import { T, lang } from "./watchStrings";
// Standalone, login-free video page for a shared /watch/<token> link. Its own entry (watchMain.tsx,
// watch.html): no auth, no providers, none of the app's bundle. Uses plain fetch and a tiny
// self-contained i18n dict (`watchStrings`: the public viewer has no app language preference).
// Standalone, login-free page for a shared /watch/<token> link: one file, or a folder's list of them.
// Its own entry (watchMain.tsx, watch.html): no auth, no providers, none of the app's bundle. Uses
// plain fetch and a tiny self-contained i18n dict (`watchStrings`: the public viewer has no app
// language preference).
type Meta = {
needs_password: boolean;
// One shared file: a file link's page, or one row of a folder link's.
type FileMeta = {
kind?: "file";
title?: string | null;
uploader?: string | null;
uploader_url?: string | null;
@@ -35,6 +51,21 @@ type Meta = {
poster_url?: string | null;
};
// A folder link's page: what is in the folder now, newest first.
type FolderMeta = {
kind: "folder";
name: string;
allow_download: boolean;
files: (FileMeta & { id: number })[];
};
type Meta = FileMeta | FolderMeta;
// What the link answers before it is opened: a password link says only which kind it is.
type Answer =
| (Meta & { needs_password?: false })
| { needs_password: true; kind?: "file" | "folder" };
// Compact display of a URL: drop the protocol + trailing slash so it fits on one line.
function displayUrl(url: string): string {
try {
@@ -60,37 +91,12 @@ export default function WatchPage() {
"loading" | "ready" | "password" | "gone" | "preparing" | "error"
>(token ? "loading" : "gone");
const [meta, setMeta] = useState<Meta | null>(null);
// A password link's kind, for its title, until it is unlocked.
const [locked, setLocked] = useState<"file" | "folder">("file");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const errorId = useId();
const [unlocking, setUnlocking] = useState(false);
// Whichever element renders (video or audio-only), as state: the OS's media controls, the keys and
// the resume bind to it once it exists.
const [media, setMedia] = useState<HTMLMediaElement | null>(null);
useMediaSession(
status === "ready" ? media : null,
meta && {
title: meta.title || "Video",
artist: meta.uploader || "Siftlode",
artwork: meta.poster_url,
},
);
useMediaKeys(media);
// It opens where this browser stopped in this link (the viewer has no account to keep it in).
// Read once per link, at render: the point a later checkpoint writes must not move the start.
const [start] = useState(() => (token ? linkPoint(token) : 0));
useResume(media, token || null, start, (position, duration) =>
saveLinkPoint(token, position, duration),
);
// Repeat the shared media when it ends — on unless the viewer turned it off. Remembered per browser
// (this page has no account); an unreadable storage just means the default.
const [repeat, setRepeat] = useState(() => readJSON<boolean>(LS.watchRepeat, true) !== false);
const toggleRepeat = () => {
const next = !repeat;
setRepeat(next);
writeJSON(LS.watchRepeat, next);
};
// Bumped by Retry, which asks again.
const [attempt, setAttempt] = useState(0);
@@ -101,18 +107,20 @@ export default function WatchPage() {
useEffect(() => {
if (!token) return; // the seed above already says "gone"
send(`/api/public/watch/${encodeURIComponent(token)}`)
.then((r): Promise<Meta | "preparing" | "gone"> => {
.then((r): Promise<Answer | "preparing" | "gone"> => {
// Only a 404 says the link is gone (unknown, expired, revoked, or its file removed) — or that
// its video is being made again (`notFoundState`). Anything else — a 5xx while the server
// restarts, a dropped connection — is a failure worth retrying, not a dead link.
if (r.status === 404) return notFoundState(r);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json() as Promise<Meta>;
return r.json() as Promise<Answer>;
})
.then((m) => {
if (typeof m === "string") setStatus(m);
else if (m.needs_password) setStatus("password");
else {
else if (m.needs_password) {
setLocked(m.kind ?? "file");
setStatus("password");
} else {
setMeta(m);
setStatus("ready");
}
@@ -231,7 +239,8 @@ export default function WatchPage() {
<div className="flex-1 grid place-items-center">
<div className={`${SURFACE.panel} w-full max-w-sm p-6`}>
<div className="flex items-center gap-2 font-medium mb-4">
<Lock className="w-4 h-4 text-accent" /> {T.passwordTitle}
<Lock className="w-4 h-4 text-accent" />{" "}
{locked === "folder" ? T.passwordTitleFolder : T.passwordTitle}
</div>
<Input
className="w-full"
@@ -263,137 +272,290 @@ export default function WatchPage() {
</div>
)}
{status === "ready" && meta && meta.media_kind === "file" && (
/* A generic uploaded file has no player — just an info card (name/type/size) and, if the
link allows it, a Download. The backend serves the file force-download regardless, so
gating the button on allow_download only mirrors the owner's ACL, matching the media
branch's download affordance below. It is the page's one action, so it is the filled
primary, as the unlock card's Watch is. */
<div className="flex-1 grid place-items-center">
<div className={`${SURFACE.panel} w-full max-w-md p-6 text-center`}>
<div className="mx-auto w-16 h-16 rounded-xl bg-surface grid place-items-center mb-4">
<FileIcon className="w-8 h-8 text-accent" />
</div>
<h1 className="text-lg font-semibold leading-snug break-words">
{meta.title || T.file}
</h1>
<div className="text-sm text-muted mt-1">
{[meta.container?.toUpperCase(), formatBytes(meta.size_bytes, lang)]
.filter(Boolean)
.join(" · ")}
</div>
{meta.allow_download && (
<LinkButton
href={meta.file_url}
variant="primary"
icon={<Download />}
className="mt-5"
>
{T.download}
</LinkButton>
)}
</div>
</div>
{status === "ready" && meta?.kind === "folder" && (
<FolderView folder={meta} token={token} />
)}
{status === "ready" && meta && meta.media_kind !== "file" && (
<div>
{meta.audio ? (
/* Audio-only asset (mp3): WebKit won't play it in a <video>, so render a native
<audio>. No poster exists for these, so present it as a deliberate padded bar on
the surface (not the black video box) so it looks intentional without artwork. */
<div className={`${SURFACE.card} p-4`}>
<audio
ref={setMedia}
controls
loop={repeat}
src={meta.file_url}
className="w-full block"
/>
</div>
) : (
/* Shrink-wrap the player to the video so a vertical/short clip renders as a narrow
player instead of a wide box with black bars; a landscape clip fills the width. */
<div className="rounded-xl overflow-hidden bg-black border border-border w-fit max-w-full mx-auto">
<video
ref={(el) => {
setMedia(el);
// So Brave iOS's background play keeps it going when the app is left.
exposeVideoToBackgroundPlay(el);
}}
controls
playsInline
loop={repeat}
src={meta.file_url}
poster={meta.poster_url || undefined}
className="max-h-[80dvh] max-w-full mx-auto block"
/>
</div>
)}
{/* The actions wrap under the title when the row is too narrow for both (a phone), rather
than squeezing the title into a column a word wide. */}
<div className={`${SURFACE.panel} mt-3 flex flex-wrap items-start gap-x-3 gap-y-2 p-3`}>
<div className="min-w-0 flex-1 basis-60">
<h1 className="text-lg font-semibold leading-snug break-words">
{meta.title || "Video"}
</h1>
{meta.uploader &&
(meta.uploader_url ? (
<a
href={meta.uploader_url}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-muted hover:text-accent hover:underline"
>
{meta.uploader}
</a>
) : (
<div className="text-sm text-muted">{meta.uploader}</div>
))}
</div>
<div className="flex shrink-0 items-center gap-2">
{/* An on/off toggle, so the shared pressed-pill paint (a solid fill when on) — not a
Button, whose variants differ only in colour. */}
<Chip
size="md"
pressed={repeat}
icon={<Repeat className="size-3.5" aria-hidden />}
onClick={toggleRepeat}
className="font-medium"
>
{T.repeat}
</Chip>
{/* The toolbar rung, level with the Repeat chip beside it (one row, one height). */}
{meta.allow_download && (
<LinkButton href={meta.file_url} icon={<Download />} size="sm" variant="glass">
{T.download}
</LinkButton>
)}
</div>
</div>
{(meta.source_url || (meta.extra_links && meta.extra_links.length > 0)) && (
<div className="mt-3 flex flex-col gap-1.5">
{[meta.source_url, ...(meta.extra_links ?? [])]
.filter((u): u is string => !!u)
.map((url) => (
<a
key={url}
href={url}
target="_blank"
rel="noopener noreferrer"
title={url}
className="inline-flex min-h-6 items-center gap-1.5 text-sm text-muted hover:text-accent hover:underline min-w-0 touch-target"
>
<Link2 className="w-3.5 h-3.5 shrink-0" />
<span className="truncate">{displayUrl(url)}</span>
</a>
))}
</div>
)}
</div>
{status === "ready" && meta && meta.kind !== "folder" && (
<FileView meta={meta} pointKey={token} />
)}
</div>
<div className="text-xs text-muted py-4">{T.brand}</div>
</div>
);
}
/** One shared file: its player (or, for a document, its card), title and actions. `pointKey` is where
* this browser keeps the point it stopped at: the link's token, or the token and the file's id in a
* folder. `nested` is a file shown inside a folder's page, whose heading is the folder's. */
function FileView({
meta,
pointKey,
nested = false,
}: {
meta: FileMeta;
pointKey: string;
nested?: boolean;
}) {
const Heading = nested ? "h2" : "h1";
// Whichever element renders (video or audio-only), as state: the OS's media controls, the keys and
// the resume bind to it once it exists.
const [media, setMedia] = useState<HTMLMediaElement | null>(null);
useMediaSession(media, {
title: meta.title || "Video",
artist: meta.uploader || "Siftlode",
artwork: meta.poster_url,
});
useMediaKeys(media);
// It opens where this browser stopped in this file (the viewer has no account to keep it in).
// Read once per file, at render: the point a later checkpoint writes must not move the start.
const [start] = useState(() => linkPoint(pointKey));
useResume(media, pointKey, start, (position, duration) =>
saveLinkPoint(pointKey, position, duration),
);
// Repeat the shared media when it ends — on unless the viewer turned it off. Remembered per browser
// (this page has no account); an unreadable storage just means the default.
const [repeat, setRepeat] = useState(() => readJSON<boolean>(LS.watchRepeat, true) !== false);
const toggleRepeat = () => {
const next = !repeat;
setRepeat(next);
writeJSON(LS.watchRepeat, next);
};
if (meta.media_kind === "file")
/* A generic uploaded file has no player — just an info card (name/type/size) and, if the
link allows it, a Download. The backend serves the file force-download regardless, so
gating the button on allow_download only mirrors the owner's ACL, matching the media
branch's download affordance below. It is the page's one action, so it is the filled
primary, as the unlock card's Watch is. */
return (
<div className="flex-1 grid place-items-center">
<div className={`${SURFACE.panel} w-full max-w-md p-6 text-center`}>
<div className="mx-auto w-16 h-16 rounded-xl bg-surface grid place-items-center mb-4">
<FileIcon className="w-8 h-8 text-accent" />
</div>
<Heading className="text-lg font-semibold leading-snug break-words">
{meta.title || T.file}
</Heading>
<div className="text-sm text-muted mt-1">
{[meta.container?.toUpperCase(), formatBytes(meta.size_bytes, lang)]
.filter(Boolean)
.join(" · ")}
</div>
{meta.allow_download && (
<LinkButton href={meta.file_url} variant="primary" icon={<Download />} className="mt-5">
{T.download}
</LinkButton>
)}
</div>
</div>
);
return (
<div>
{meta.audio ? (
/* Audio-only asset (mp3): WebKit won't play it in a <video>, so render a native
<audio>. No poster exists for these, so present it as a deliberate padded bar on
the surface (not the black video box) so it looks intentional without artwork. */
<div className={`${SURFACE.card} p-4`}>
<audio
ref={setMedia}
controls
autoPlay={nested}
loop={repeat}
src={meta.file_url}
className="w-full block"
/>
</div>
) : (
/* Shrink-wrap the player to the video so a vertical/short clip renders as a narrow
player instead of a wide box with black bars; a landscape clip fills the width. */
<div className="rounded-xl overflow-hidden bg-black border border-border w-fit max-w-full mx-auto">
<video
ref={(el) => {
setMedia(el);
// So Brave iOS's background play keeps it going when the app is left.
exposeVideoToBackgroundPlay(el);
}}
controls
playsInline
// In a folder the viewer picked it from the list just now: it starts.
autoPlay={nested}
loop={repeat}
src={meta.file_url}
poster={meta.poster_url || undefined}
className={clsx("max-w-full mx-auto block", nested ? "max-h-[60dvh]" : "max-h-[80dvh]")}
/>
</div>
)}
{/* The actions wrap under the title when the row is too narrow for both (a phone), rather
than squeezing the title into a column a word wide. */}
<div className={`${SURFACE.panel} mt-3 flex flex-wrap items-start gap-x-3 gap-y-2 p-3`}>
<div className="min-w-0 flex-1 basis-60">
<Heading className="text-lg font-semibold leading-snug break-words">
{meta.title || "Video"}
</Heading>
{meta.uploader &&
(meta.uploader_url ? (
<a
href={meta.uploader_url}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-muted hover:text-accent hover:underline"
>
{meta.uploader}
</a>
) : (
<div className="text-sm text-muted">{meta.uploader}</div>
))}
</div>
<div className="flex shrink-0 items-center gap-2">
{/* An on/off toggle, so the shared pressed-pill paint (a solid fill when on) — not a
Button, whose variants differ only in colour. */}
<Chip
size="md"
pressed={repeat}
icon={<Repeat className="size-3.5" aria-hidden />}
onClick={toggleRepeat}
className="font-medium"
>
{T.repeat}
</Chip>
{/* The toolbar rung, level with the Repeat chip beside it (one row, one height). */}
{meta.allow_download && (
<LinkButton href={meta.file_url} icon={<Download />} size="sm" variant="glass">
{T.download}
</LinkButton>
)}
</div>
</div>
{(meta.source_url || (meta.extra_links && meta.extra_links.length > 0)) && (
<div className="mt-3 flex flex-col gap-1.5">
{[meta.source_url, ...(meta.extra_links ?? [])]
.filter((u): u is string => !!u)
.map((url) => (
<a
key={url}
href={url}
target="_blank"
rel="noopener noreferrer"
title={url}
className="inline-flex min-h-6 items-center gap-1.5 text-sm text-muted hover:text-accent hover:underline min-w-0 touch-target"
>
<Link2 className="w-3.5 h-3.5 shrink-0" />
<span className="truncate">{displayUrl(url)}</span>
</a>
))}
</div>
)}
</div>
);
}
/** A file's picture in the folder list: its poster frame, else a glyph of its kind; a video or a
* song carries its length. */
function RowThumb({ file }: { file: FileMeta }) {
const Glyph = file.media_kind === "file" ? FileIcon : file.audio ? Music : Film;
const length = file.media_kind !== "file" && file.duration_s ? file.duration_s : null;
// Above the length badge when there is one, so the badge never covers it.
const glyph = (
<Glyph
aria-hidden
className={clsx(
"absolute inset-x-0 mx-auto text-muted",
length ? "top-1 size-4" : "inset-y-0 my-auto size-5",
)}
/>
);
return (
<div className="relative h-10 w-16 shrink-0 overflow-hidden rounded-lg bg-surface">
<RemoteImage
src={file.poster_url}
alt=""
loading="lazy"
className="h-full w-full object-cover"
fallback={glyph}
/>
{length ? (
<span className={clsx(MEDIA_BADGE, "bottom-0.5 right-0.5")}>{formatDuration(length)}</span>
) : null}
</div>
);
}
/** A folder link's page: the folder's name, its files, and the one playing above them. Play opens the
* same player a file link has; a document only downloads. */
function FolderView({ folder, token }: { folder: FolderMeta; token: string }) {
const [playing, setPlaying] = useState<number | null>(null);
const current = folder.files.find((f) => f.id === playing);
const total = folder.files.reduce((n, f) => n + (f.size_bytes ?? 0), 0);
return (
<div className="flex flex-col gap-4">
<div>
<h1 className="text-xl font-bold leading-snug break-words">{folder.name}</h1>
<div className="text-sm text-muted">
{[T.files(folder.files.length), folder.files.length > 0 && formatBytes(total, lang)]
.filter(Boolean)
.join(" · ")}
</div>
</div>
{current && (
// Keyed by the file, so a new pick is a new player (its own resume point, its own media
// session) and brings the player into view — on a phone it is above the row just tapped.
<div key={current.id} ref={(el) => el?.scrollIntoView({ block: "start" })}>
<FileView meta={current} pointKey={`${token}/${current.id}`} nested />
</div>
)}
{folder.files.length === 0 ? (
<p className="text-sm text-muted">{T.folderEmpty}</p>
) : (
<ul className="flex flex-col">
{folder.files.map((f) => {
const title = f.title || T.file;
const body = (
<>
<RowThumb file={f} />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm">{title}</span>
<span className="block text-xs text-muted">
{formatBytes(f.size_bytes, lang)}
</span>
</span>
</>
);
return (
<li
key={f.id}
className={clsx(
"flex items-center gap-1 rounded-lg",
f.id === playing && "bg-accent/10",
)}
>
{f.media_kind === "file" ? (
<div className="flex min-w-0 flex-1 items-center gap-3 px-1.5 py-1.5">{body}</div>
) : (
<Pressable
layer
selectable
aria-current={f.id === playing ? "true" : undefined}
aria-label={`${T.play}: ${title}`}
onClick={() => setPlaying(f.id)}
className="flex min-w-0 flex-1 items-center gap-3 rounded-lg px-1.5 py-1.5"
>
{body}
<Play aria-hidden className="size-4 shrink-0 text-muted" />
</Pressable>
)}
{folder.allow_download && (
<IconLink href={f.file_url} label={`${T.download}: ${title}`}>
<Download />
</IconLink>
)}
</li>
);
})}
</ul>
)}
</div>
);
}
+13 -13
View File
@@ -39,6 +39,7 @@ import { Pressable } from "./ui/Pressable";
import { Input } from "./ui/Input";
import { SURFACE } from "./ui/surfaces";
import { LinkButton } from "./ui/LinkButton";
import RemoteImage from "./ui/RemoteImage";
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
const PASSWORD_MIN = 10;
@@ -224,28 +225,18 @@ function Preview({
className?: string;
onOpen?: (src: string, label: string) => void;
}) {
const [failed, setFailed] = useState(false);
if (failed) {
return (
<div className={`${SURFACE.card} overflow-hidden ${className ?? ""}`}>
<div className="w-full h-full grid place-items-center bg-linear-to-br from-accent/10 to-card text-muted text-sm p-6 text-center">
{label}
</div>
</div>
);
}
return (
<Pressable
onClick={() => onOpen?.(src, label)}
aria-label={label}
className={`group relative ${SURFACE.card} overflow-hidden block cursor-zoom-in ${className ?? ""}`}
>
<img
<RemoteImage
src={src}
alt={label}
loading="lazy"
onError={() => setFailed(true)}
className="w-full h-full object-cover object-top"
fallback={<ShotLabel label={label} />}
/>
<span className="absolute inset-0 grid place-items-center bg-black/0 opacity-0 transition group-hover:bg-black/30 group-hover:opacity-100">
<Expand className="w-6 h-6 text-white drop-shadow-lg" />
@@ -254,6 +245,15 @@ function Preview({
);
}
// A screenshot that would not load: its caption on the card's own ground, not the broken-image mark.
function ShotLabel({ label }: { label: string }) {
return (
<div className="w-full h-full grid place-items-center bg-linear-to-br from-accent/10 to-card text-muted text-sm p-6 text-center">
{label}
</div>
);
}
// Full-size image viewer: a dimmed, blurred backdrop with the screenshot fit to the viewport
// (aspect ratio preserved via object-contain). Closes on backdrop click, the ✕, or Escape.
function Lightbox({ src, label, onClose }: { src: string; label: string; onClose: () => void }) {
@@ -320,7 +320,7 @@ function Lightbox({ src, label, onClose }: { src: string; label: string; onClose
className="flex max-h-full max-w-full flex-col items-center gap-3"
onClick={(e) => e.stopPropagation()}
>
<img
<RemoteImage
src={src}
alt={label}
className="max-h-[82dvh] max-w-[92vw] w-auto h-auto rounded-xl border border-border object-contain shadow-2xl"
+45 -49
View File
@@ -1,4 +1,4 @@
import { useState, type ReactNode } from "react";
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { File, Film, Music } from "lucide-react";
import clsx from "clsx";
@@ -6,6 +6,7 @@ import { DOWNLOAD_PHASES } from "../lib/downloadQueue";
import { api, type DownloadJob } from "../lib/api";
import { IconButton } from "./ui/IconButton";
import { MediaProgress } from "./ui/MediaCard";
import RemoteImage from "./ui/RemoteImage";
// The small shared primitives of a download (status pill, icon button, the job's picture), all purely
// prop-driven: the admin Storage tab shows the status, the Files list and grid the picture, the
@@ -65,73 +66,68 @@ export function JobThumb({ job, box = THUMB_BOX }: { job: DownloadJob; box?: str
// poster resolves to the session-default account and 404s for a per-tab account's download — which,
// now that the poster is the primary image, reads as a blank Library thumbnail.
const poster = job.poster_url ? api.posterImageUrl(job.id) : null;
const primary = poster || remote;
// On load failure (a genuinely missing poster file) fall back to the remote source thumbnail.
// Remembered BY the URL that failed, not as a value an effect re-syncs: these rows are recycled
// down a long virtualized list, so a boolean-style mirror painted the previous job's fallback
// over the next one's poster for a frame. See ui/Avatar, which had the same defect.
// …and forgotten once `primary` has moved off it, for the same reason ui/Avatar does: a poster
// that 404'd because its file was still being written should be re-requested when the row comes
// back, not written off for the life of the recycled slot. Convergent, so it belongs in render.
const [failedPrimary, setFailedPrimary] = useState<string | null>(null);
if (failedPrimary !== null && failedPrimary !== primary) setFailedPrimary(null);
// The remote picture is the last resort, and it fails too (a dead CDN link): remembered the same
// way, so the row falls to the glyph below instead of the browser's broken-image mark.
const [failedRemote, setFailedRemote] = useState<string | null>(null);
if (failedRemote !== null && failedRemote !== remote) setFailedRemote(null);
// An image file's own picture failed (gone since, or not an image after all): the glyph instead.
const [failedPicture, setFailedPicture] = useState<string | null>(null);
const candidate = failedPrimary && failedPrimary === primary ? remote : primary;
const src = candidate && candidate === failedRemote ? null : candidate;
// A generic uploaded file (media_kind='file') has no video frame to poster — it's a document/
// archive, not playable media. An image is its own picture (its sandboxed preview); the rest show
// a neutral file glyph instead of an (always-empty) thumbnail.
// a neutral file glyph instead of an (always-empty) thumbnail. A preview that fails is an answer
// (gone since, or not an image after all), not a hiccup: the glyph at once, no retries.
if (job.asset?.media_kind === "file") {
const picture = imageThumbOf(job);
const glyph = <File className="w-8 h-8" aria-hidden="true" />;
return (
<div className={`${box} flex items-center justify-center text-muted`}>
{picture && picture !== failedPicture ? (
<img
src={picture}
alt=""
loading="lazy"
decoding="async"
className="h-full w-full object-cover"
onError={() => setFailedPicture(picture)}
/>
) : (
<File className="w-8 h-8" aria-hidden="true" />
)}
<RemoteImage
src={imageThumbOf(job)}
alt=""
loading="lazy"
decoding="async"
className="h-full w-full object-cover"
retries={0}
fallback={glyph}
/>
</div>
);
}
// How far into it the viewer is, the media cards' bar.
const duration = job.asset?.duration_s ?? 0;
const pct = duration > 0 ? ((job.position_seconds ?? 0) / duration) * 100 : 0;
// No picture at all (a link that failed before its details, a poster that never came): a quiet
// glyph of what it is, like the file glyph above — an empty box read as "still loading".
const glyph = (
<div className="flex h-full items-center justify-center text-muted">
{job.asset?.media_kind === "audio" ? (
<Music className="w-7 h-7" aria-hidden="true" />
) : (
<Film className="w-7 h-7" aria-hidden="true" />
)}
</div>
);
// The source's picture, the poster's fallback: retried like any remote picture, then the glyph.
const remotePicture = (
<RemoteImage
src={remote}
alt=""
loading="lazy"
className="w-full h-full object-cover"
fallback={glyph}
/>
);
return (
<div className={`${box} relative`}>
<MediaProgress pct={pct} />
{src ? (
<img
src={src}
{poster ? (
// Our own poster failing is an answer (the file is missing), so straight to the source's
// picture. RemoteImage remembers the failure by URL, so a recycled row never shows one job's
// fallback over the next one's poster, and forgets it once the row moves on — a poster that
// 404'd while its file was still being written is asked for again when the row comes back.
<RemoteImage
src={poster}
alt=""
loading="lazy"
className="w-full h-full object-cover"
onError={() => {
if (src !== remote) setFailedPrimary(primary);
else setFailedRemote(remote);
}}
retries={0}
fallback={remotePicture}
/>
) : (
// No picture at all (a link that failed before its details, a poster that never came): a
// quiet glyph of what it is, like the file glyph above — an empty box read as "still loading".
<div className="flex h-full items-center justify-center text-muted">
{job.asset?.media_kind === "audio" ? (
<Music className="w-7 h-7" aria-hidden="true" />
) : (
<Film className="w-7 h-7" aria-hidden="true" />
)}
</div>
remotePicture
)}
</div>
);
+15 -4
View File
@@ -1,10 +1,11 @@
import { Film, Folder, Globe, Upload, type LucideIcon } from "lucide-react";
import { Film, Folder, Globe, Server, Upload, type LucideIcon } from "lucide-react";
import { YouTubeIcon } from "../lib/brandIcons";
import type { FileFolder } from "../lib/api";
// A library folder's and a file's origin, named and drawn. The backend sends a source folder's KEY
// (`youtube`, `plex`, `upload`, `site:<host>`, see backend downloads/folders.py) and leaves
// its name to the client, in the reader's language; an own folder carries the name its user gave it.
// its name to the client, in the reader's language; an own folder carries the name its user gave it;
// the Hub folder (one per user, made by a hub token) is named by the client too.
const SITE_PREFIX = "site:";
@@ -24,10 +25,20 @@ export function sourceIcon(key: string): LucideIcon | typeof YouTubeIcon {
return Globe;
}
export function folderLabel(folder: FileFolder, t: T): string {
type Folderish = Pick<FileFolder, "kind" | "source_key" | "name">;
export function folderLabel(folder: Folderish, t: T): string {
if (folder.kind === "hub") return t("files.folder.hub");
return folder.kind === "own" ? (folder.name ?? "") : sourceLabel(folder.source_key ?? "", t);
}
export function folderIcon(folder: FileFolder): LucideIcon | typeof YouTubeIcon {
export function folderIcon(folder: Folderish): LucideIcon | typeof YouTubeIcon {
if (folder.kind === "hub") return Server;
return folder.kind === "own" ? Folder : sourceIcon(folder.source_key ?? "");
}
/** Whether an upload may land in `folder` (the backend's `folders.upload_target`): an own folder,
* the Hub folder, or Uploads — a file put in a site's folder would claim an origin it does not have. */
export function takesUploads(folder: Folderish): boolean {
return folder.kind !== "source" || folder.source_key === "upload";
}
+2 -1
View File
@@ -5,6 +5,7 @@ import type { Video } from "../lib/api";
import { formatDuration } from "../lib/format";
import type { Column } from "../lib/tableColumns";
import { Pressable } from "./ui/Pressable";
import RemoteImage from "./ui/RemoteImage";
// Column definitions for the playlist detail table, in their own file like `channelColumns.tsx`.
//
@@ -67,7 +68,7 @@ export function playlistColumns({
className="relative w-15.5 h-8.75 rounded-sm overflow-hidden bg-surface shrink-0 group/thumb touch-target"
>
{v.thumbnail_url && (
<img src={v.thumbnail_url} alt="" className="w-full h-full object-cover" />
<RemoteImage src={v.thumbnail_url} alt="" className="w-full h-full object-cover" />
)}
{/* Narrow, the Length column is gone and its value rides on the picture, as on the
media card (the owner's pick A). */}
+15 -10
View File
@@ -33,6 +33,7 @@ import { MediaPreview } from "./ui/MediaPreview";
import { useMediaPreview } from "./ui/useMediaPreview";
import { SURFACE } from "./ui/surfaces";
import { cardImage } from "../lib/plexImage";
import RemoteImage from "./ui/RemoteImage";
// Presentational Plex cards + strips, split out of PlexBrowse (prop-driven, no shared state; the one
// read is a show card asking which episode it would play, once its preview is up). Rendered by the
@@ -224,7 +225,7 @@ function PlexPicture({
return (
<div className={mediaPictureBox(aspect)}>
<Pressable onClick={onOpen} aria-label={label} data-nav-open="" className={MEDIA_PICTURE}>
<img
<RemoteImage
srcSet={pic.srcSet}
sizes={pic.sizes}
src={pic.src}
@@ -599,15 +600,19 @@ export function CastRow({
overlay ? "border-white/15 bg-white/10" : "border-border bg-surface"
} ${clickable ? "group-hover/cast:border-accent" : ""}`}
>
{c.thumb ? (
<img src={c.thumb} alt="" loading="lazy" className="h-full w-full object-cover" />
) : (
<div
className={`grid h-full w-full place-items-center text-lg opacity-40 ${overlay ? "text-white" : ""}`}
>
{c.name.charAt(0)}
</div>
)}
<RemoteImage
src={c.thumb}
alt=""
loading="lazy"
className="h-full w-full object-cover"
fallback={
<div
className={`grid h-full w-full place-items-center text-lg opacity-40 ${overlay ? "text-white" : ""}`}
>
{c.name.charAt(0)}
</div>
}
/>
</div>
{/* Name + role are each capped to ONE line (truncate → ellipsis) so every tile has the
same text height and the row of circles sits on one baseline regardless of name length. */}
+5 -1
View File
@@ -16,6 +16,7 @@ import { Pressable } from "./ui/Pressable";
import { SURFACE } from "./ui/surfaces";
import { Badge } from "./ui/Badge";
import { Chip } from "./ui/Chip";
import RemoteImage from "./ui/RemoteImage";
// Shared "detail page" UI reused by the movie info page (PlexInfo) and the series show/season pages,
// so they look consistent (the standing glassy/HTPC brief): the faint fixed art backdrop, the two
@@ -263,10 +264,13 @@ export function DetailHero({
className={`group/poster relative ${posterSizeClass} shrink-0 self-start`}
title={posterTitle}
>
<img
<RemoteImage
src={thumb}
alt=""
className="w-full aspect-2/3 object-cover rounded-xl border border-border"
fallback={
<div className="w-full aspect-2/3 rounded-xl border border-border bg-surface" />
}
/>
{showPlayOverlay && (
<div className="absolute inset-0 rounded-xl bg-black/40 opacity-0 group-hover/poster:opacity-100 transition grid place-items-center">
+11 -7
View File
@@ -62,6 +62,7 @@ import { SURFACE } from "./ui/surfaces";
import { PageHeading } from "./ui/Headings";
import { vocab } from "../lib/vocab";
import { formatDate, formatDecimal } from "../lib/format";
import RemoteImage from "./ui/RemoteImage";
// The Plex sub-router's view branches, split out of PlexBrowse. Each is invoked by PlexBrowse with
// shell callbacks as props (onBack/onPlay/onFilter/…) — no view reaches back into the shell's state,
@@ -292,13 +293,16 @@ export function PlexPersonView({
<div className="measure min-w-0 text-sm">
<div className="flex items-start gap-4">
<div className="h-24 w-24 shrink-0 overflow-hidden rounded-full border border-border bg-surface">
{d.photo ? (
<img src={d.photo} alt="" className="h-full w-full object-cover" />
) : (
<div className="grid h-full w-full place-items-center opacity-40">
<User className="h-8 w-8" />
</div>
)}
<RemoteImage
src={d.photo}
alt=""
className="h-full w-full object-cover"
fallback={
<div className="grid h-full w-full place-items-center opacity-40">
<User className="h-8 w-8" />
</div>
}
/>
</div>
<div className="min-w-0 flex-1">
<PageHeading className="text-xl font-semibold">{d.name}</PageHeading>
+19 -47
View File
@@ -1,15 +1,9 @@
import { useState } from "react";
import RemoteImage from "./RemoteImage";
// Avatar image with a graceful fallback.
//
// Uses referrerPolicy="no-referrer" because Google's avatar CDNs
// (yt3.ggpht.com / lh3.googleusercontent.com) refuse referrer-bearing requests
// from our origin — yt3 refuses even a single load, not only a burst (measured
// 2026-09-26) — and the browser shows its broken-image icon. Dropping the
// referrer makes those CDNs serve the images. The channel banner, from the same
// CDN, does the same (ChannelPage).
// If a load still fails we render a neutral placeholder (optionally an initial)
// instead of the broken-image icon.
// Avatar image with a graceful fallback: a neutral disc (optionally the name's initial) while there is
// no picture. The loading itself — no Referer for Google's avatar CDNs, the retry past the browser's
// negative cache, the failure remembered by URL so a recycled feed slot never paints one channel's
// initial over the next one's picture — is ui/RemoteImage's.
export default function Avatar({
src,
alt = "",
@@ -21,43 +15,21 @@ export default function Avatar({
fallback?: string; // a name/title; its first letter is shown when there's no image
className?: string;
}) {
// The failure is remembered BY URL, not as a boolean that an effect resets when `src` changes.
// That distinction is visible: this component is recycled across items in the virtualized feed, so
// one avatar's 429 used to paint the fallback initial over the NEXT item's perfectly good image
// for a frame — React committed the render with the new `src` and the stale `failed`, and only the
// post-paint effect cleared it. Derived state cannot be stale, so there is nothing to reset.
//
// Forgetting the URL once `src` has moved off it is the other half, and it is what keeps the 429
// TRANSIENT: without it the memory outlives the reason for it, and a mounted instance whose `src`
// swaps — the player's channel avatar stepping through a queue — would
// show the initial for a throttled channel forever, never re-requesting even after the CDN
// relented. Written in render because it is convergent (the write falsifies its own condition) and
// because an effect would leave one committed frame in which the memory is stale, which is the
// frame this whole component is about.
const [failedSrc, setFailedSrc] = useState<string | null>(null);
if (failedSrc !== null && failedSrc !== src) setFailedSrc(null);
const failed = !!src && failedSrc === src;
if (src && !failed) {
return (
<img
src={src}
alt={alt}
loading="lazy"
referrerPolicy="no-referrer"
onError={() => setFailedSrc(src)}
className={className}
/>
);
}
const initial = fallback?.trim()?.[0]?.toUpperCase() ?? "";
return (
<div
className={`bg-card grid place-items-center font-semibold text-muted select-none ${className}`}
aria-hidden
>
{initial}
</div>
<RemoteImage
src={src}
alt={alt}
loading="lazy"
className={className}
fallback={
<div
className={`bg-card grid place-items-center font-semibold text-muted select-none ${className}`}
aria-hidden
>
{initial}
</div>
}
/>
);
}
@@ -71,6 +71,7 @@ function Page({ withMenu = true }: { withMenu?: boolean }) {
>
<span data-testid="row-inner" />
</div>
{/* eslint-disable-next-line no-restricted-syntax -- the native element whose browser menu is kept */}
<img data-testid="img" alt="" />
</div>
</PageShell>
+1 -1
View File
@@ -39,7 +39,7 @@ Counter-examples, deliberately NOT here: `PosterTile` (knows about Plex montage
| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `MembershipMenu` (the body of a "which sets is it in" menu: checkbox rows + a field naming a new set), `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens; `PageMenu` for a page's background menu), `Modal` (+ `ModalFooter`; `initialFocus` by purpose, C4), `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp` |
| actions & inputs | `Button`, `IconButton`, `Pressable` (`selectable` when its text is a title or a name), `Chip` + `chipStyles`, `DragHandle` + `OrderButtons` / `useOrderItems` (the non-drag reorder, WCAG 2.5.7) (one per button family, docs/DESIGN.md; a raw `<button>` or a bare `FocusSafeButton` outside the control primitives — eslint `BUTTON_PRIMITIVES` — is a lint error), `Input` + `fieldStyles`, `RenameField` (a name typed in place of the name: Enter or blur saves, Escape cancels; the caller hands the keyboard back), `Meter` (how full something is against a limit — one danger line for every place showing the same measure), `TimecodeField` + `useTimecodeField` / `TimecodeError` (a cut's time typed in: the draft, held by the caller, until Enter or blur takes it, then the value as it landed; unreadable stays once offered, marked invalid, and the caller places the line saying how to type one in its flow, outside a label; a draft goes when the value moves under it), `NativeControls`, `MenuCheck` (the drawn check of a menu row or a whole-row checkbox button; its tick is the `chosen` colour, which `media-marks` turns amber on the player chrome), `mediaChrome` (`MEDIA_CHOSEN`, a chosen tab or toggle on the chrome), `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `surfaces` (the B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7; `NavRailGroup` folds a set of rare pages into one row with a panel), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
| data display | `DataTable` (+ `progressiveRows`: a long page mounts in steps), `tableHeader` + `tableAlign`, `HiddenFilterCue` (names a filter whose column is off screen), `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `MediaCard` + `mediaCardStyles` (the one media card, docs/DESIGN.md "Media card") + `MediaPreview` / `useMediaPreview` (its mouse-rest preview), `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup`, `RelativeTime` ("3d ago" with the exact minute on hover, docs/DESIGN.md "Copy"), `ClampedText` (a long text clamped to a few lines; More / Less only when it is cut), `StatTiles` + `StatTile` / `StatBars` (number tiles, a breakdown as horizontal bars), `RangeTrack` + `RulerTicks` (a time track over a window of a media item: regions, slider ends the arrows step, a playhead, a background slot — the editor's cuts, the players' clip panel), `CropFrame` (a crop frame over a picture, on the picture and not its bars: drag, edges, corners, the arrows; the editor's crop, the players' clip crop) |
| data display | `DataTable` (+ `progressiveRows`: a long page mounts in steps), `tableHeader` + `tableAlign`, `HiddenFilterCue` (names a filter whose column is off screen), `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `MediaCard` + `mediaCardStyles` (the one media card, docs/DESIGN.md "Media card") + `MediaPreview` / `useMediaPreview` (its mouse-rest preview), `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `RemoteImage` (every network picture: no Referer, retries past the negative cache, a fallback instead of the broken-image mark — a bare `<img>` is linted out), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup`, `RelativeTime` ("3d ago" with the exact minute on hover, docs/DESIGN.md "Copy"), `ClampedText` (a long text clamped to a few lines; More / Less only when it is cut), `StatTiles` + `StatTile` / `StatBars` (number tiles, a breakdown as horizontal bars), `RangeTrack` + `RulerTicks` (a time track over a window of a media item: regions, slider ends the arrows step, a playhead, a background slot — the editor's cuts, the players' clip panel), `CropFrame` (a crop frame over a picture, on the picture and not its bars: drag, edges, corners, the arrows; the editor's crop, the players' clip crop) |
| page & panel structure | `PageShell` + `pageToolbarSlot` + `pageHeading`, `PageScroller`, `Headings` (`PageHeading`, `SectionLabel`), `SkipLink` + `landmarks`, `FilterFold` (B4), `scrollFold` (`useScrollFold`: a fixed header that folds once its content scrolls), `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` |
| state & messaging | `QueryState`, `Banner`, `InlineNotice`, `CrashBoundary` |
@@ -0,0 +1,116 @@
// @vitest-environment jsdom
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
import RemoteImage from "./RemoteImage";
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
const img = () => screen.queryByRole("img", { name: "pic" });
const wait = (ms: number) =>
act(() => {
vi.advanceTimersByTime(ms);
});
const fallback = <span>no picture</span>;
describe("RemoteImage", () => {
// Google's image CDNs refuse a request carrying our origin's Referer — and the card then showed
// the browser's broken-image mark (a Home shelf card, 2026-10-14).
it("asks without a Referer", () => {
render(<RemoteImage src="https://cdn.test/a.jpg" alt="pic" />);
expect(img()?.getAttribute("referrerpolicy")).toBe("no-referrer");
});
it("shows the fallback, never the broken image, while a retry waits — then asks again past the cache", async () => {
render(<RemoteImage src="https://cdn.test/a.jpg?s=1" alt="pic" fallback={fallback} />);
fireEvent.error(img()!);
expect(img()).toBeNull();
expect(screen.getByText("no picture")).toBeTruthy();
await wait(999);
expect(img()).toBeNull();
await wait(1);
// A fresh request: the browser negative-caches the failed URL, so the same one would only replay it.
expect(img()?.getAttribute("src")).toBe("https://cdn.test/a.jpg?s=1&r=1");
});
it("waits longer each time, and keeps the fallback once every retry is spent", async () => {
render(<RemoteImage src="https://cdn.test/a.jpg" alt="pic" fallback={fallback} />);
for (const [after, n] of [
[1000, 1],
[2000, 2],
[4000, 3],
] as const) {
fireEvent.error(img()!);
await wait(after - 1);
expect(img()).toBeNull();
await wait(1);
expect(img()?.getAttribute("src")).toBe(`https://cdn.test/a.jpg?r=${n}`);
}
fireEvent.error(img()!);
await wait(60_000);
expect(img()).toBeNull();
expect(screen.getByText("no picture")).toBeTruthy();
});
it("a loaded retry stays", async () => {
render(<RemoteImage src="https://cdn.test/a.jpg" alt="pic" />);
fireEvent.error(img()!);
await wait(1000);
fireEvent.load(img()!);
await wait(60_000);
expect(img()?.getAttribute("src")).toBe("https://cdn.test/a.jpg?r=1");
});
it("busts every candidate of a srcset", async () => {
render(
<RemoteImage
src="/api/plex/image/7"
srcSet="/api/plex/image/7?variant=thumb_sm 200w, /api/plex/image/7 400w"
alt="pic"
/>,
);
fireEvent.error(img()!);
await wait(1000);
expect(img()?.getAttribute("srcset")).toBe(
"/api/plex/image/7?variant=thumb_sm&r=1 200w, /api/plex/image/7?r=1 400w",
);
});
it("with no retries, a failure is the fallback at once", () => {
render(<RemoteImage src="/api/files/1/preview" alt="pic" retries={0} fallback={fallback} />);
fireEvent.error(img()!);
expect(screen.getByText("no picture")).toBeTruthy();
act(() => {
vi.advanceTimersByTime(60_000);
});
expect(img()).toBeNull();
});
it("starts over for a new src, and gives no src the fallback", () => {
const { rerender } = render(
<RemoteImage src="https://cdn.test/a.jpg" alt="pic" retries={0} fallback={fallback} />,
);
fireEvent.error(img()!);
rerender(
<RemoteImage src="https://cdn.test/b.jpg" alt="pic" retries={0} fallback={fallback} />,
);
expect(img()?.getAttribute("src")).toBe("https://cdn.test/b.jpg");
rerender(<RemoteImage src={null} alt="pic" fallback={fallback} />);
expect(img()).toBeNull();
expect(screen.getByText("no picture")).toBeTruthy();
});
it("does not bust an inline picture", async () => {
render(<RemoteImage src="data:image/png;base64,AAAA" alt="pic" />);
fireEvent.error(img()!);
await wait(1000);
expect(img()?.getAttribute("src")).toBe("data:image/png;base64,AAAA");
});
});
@@ -0,0 +1,91 @@
import { useEffect, useState, type ImgHTMLAttributes, type ReactNode } from "react";
// Every picture the app loads over the network goes through here (lint-enforced: a bare <img> is
// refused everywhere else), because a picture that fails left the browser's broken-image mark on a
// card, and each component that noticed had hand-rolled its own cure. What they learned, in one place:
//
// - No Referer. Google's image CDNs (yt3.ggpht.com, lh3.googleusercontent.com) refuse a request that
// carries one from our origin — not only in a burst (measured 2026-09-26). Our own endpoints never
// read it, so it is dropped for every picture rather than guessed per host.
// - A failed load is retried, with a cache-buster: the browser NEGATIVE-CACHES the miss, so asking for
// the same URL again would only replay the failure. Both Google CDNs and our API ignore the extra
// `r` parameter (measured 2026-10-14). A 429 from a burst of cards heals this way.
// - While there is no picture — none given, a retry still waiting, every retry spent — the caller's
// `fallback` stands in (nothing by default: the card's own empty frame, which is what a card with no
// thumbnail has always shown). Never the broken-image mark.
// - The failure is remembered BY URL, in render: these sit in virtualized grids whose slots are recycled,
// and a failure stored as a flag an effect resets painted one item's fallback over the next one's
// picture for a frame (ui/Avatar had that defect).
/** How many times a failed picture is asked for again, and the wait before the first retry (it
* doubles each time): 1 s, 2 s, 4 s, then the fallback for good. */
const IMAGE_RETRIES = 3;
const FIRST_RETRY_MS = 1000;
/** `url` with a cache-buster for retry `attempt` (0 = the URL as given). An inline `data:`/`blob:` URL
* is not fetched, so it has no cache to bust. */
function bustImageUrl(url: string, attempt: number): string {
if (attempt === 0 || /^(data|blob):/.test(url)) return url;
return `${url}${url.includes("?") ? "&" : "?"}r=${attempt}`;
}
/** The same for every candidate of a `srcset` ("url 200w, url2 400w"). */
function bustSrcSet(srcSet: string, attempt: number): string {
if (attempt === 0) return srcSet;
return srcSet
.split(",")
.map((part) => {
const [url = "", ...descriptor] = part.trim().split(/\s+/);
return [bustImageUrl(url, attempt), ...descriptor].join(" ");
})
.join(", ");
}
type Miss = { src: string; attempt: number; waiting: boolean };
export default function RemoteImage({
src,
srcSet,
fallback = null,
retries = IMAGE_RETRIES,
...img
}: Omit<ImgHTMLAttributes<HTMLImageElement>, "src" | "srcSet" | "onError" | "referrerPolicy"> & {
src: string | null | undefined;
srcSet?: string;
/** What stands in while there is no picture. */
fallback?: ReactNode;
/** Fewer retries for a picture whose failure is an answer, not a hiccup — a file that is not there
* yet is better shown as its fallback now than after seven seconds of retries. */
retries?: number;
}) {
const [miss, setMiss] = useState<Miss | null>(null);
// Forgotten once `src` moves off it — convergent, so it belongs in render (see above).
if (miss !== null && miss.src !== src) setMiss(null);
const current = miss !== null && miss.src === src ? miss : null;
const attempt = current?.attempt ?? 0;
const waiting = current?.waiting === true;
useEffect(() => {
if (!waiting) return;
const timer = window.setTimeout(
() => setMiss((m) => (m !== null && m.src === src ? { ...m, waiting: false } : m)),
FIRST_RETRY_MS * 2 ** (attempt - 1),
);
return () => window.clearTimeout(timer);
}, [waiting, attempt, src]);
const spent = current !== null && !current.waiting && current.attempt > retries;
if (!src || waiting || spent) return <>{fallback}</>;
return (
// eslint-disable-next-line no-restricted-syntax -- the one <img> the rest of the app goes through
<img
{...img}
// A fresh element per attempt: the failed one is done, and a new src on it would not refire.
key={attempt}
src={bustImageUrl(src, attempt)}
srcSet={srcSet === undefined ? undefined : bustSrcSet(srcSet, attempt)}
referrerPolicy="no-referrer"
onError={() => setMiss({ src, attempt: attempt + 1, waiting: attempt < retries })}
/>
);
}
@@ -17,6 +17,10 @@ const files = readSources(__dirname, [
/** file → [how many plain Pressables, why they are not titles]. */
const PLAIN: Record<string, [number, string]> = {
"ApiTokens.tsx": [
1,
"a token kind's tile in the new-token dialog (an option: its text is its label)",
],
"DownloadTray.tsx": [
2,
"the folded pill (its summary is its label); a failure's reason, which opens its details",
@@ -0,0 +1,54 @@
// @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<typeof import("react-i18next")>()),
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 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());
await act(() => result.current.copy("sfl_x"));
expect(result.current.copied).toBe(false);
expect(h.notify).toHaveBeenCalledWith({ level: "warning", message: "common.copy.failed" });
});
});
@@ -0,0 +1,34 @@
import { useEffect, useRef, 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<void> } {
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<number | undefined>(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.clearTimeout(timer.current);
timer.current = window.setTimeout(() => setCopied(false), COPIED_MS);
return;
}
notify({ level: "warning", message: t("common.copy.failed") });
};
return { copied, copy };
}
+6 -2
View File
@@ -108,7 +108,7 @@ export function useFileActions({
const quickShare = async (file: LibraryFile) => {
if (sharingId !== null) return;
setSharingId(file.id);
await quickShareLink(qc, file, t).finally(() => setSharingId(null));
await quickShareLink(qc, { kind: "file", id: file.id }, t).finally(() => setSharingId(null));
};
const icon = (I: typeof PlayCircle) => <I className="w-4 h-4" />;
@@ -186,7 +186,11 @@ export function useFileActions({
},
],
};
const own = folders.filter((f) => f.kind === "own");
// The user's own folders, then the Hub folder: the ones a file may be moved into from anywhere.
const own = [
...folders.filter((f) => f.kind === "own"),
...folders.filter((f) => f.kind === "hub"),
];
const home = folders.find((f) => f.kind === "source" && f.source_key === file.source_key);
const to = (f: FileFolder) => () => update.mutate({ id: file.id, patch: { folder_id: f.id } });
const items: ActionMenuItem[] = own.map((f) => ({
+46 -6
View File
@@ -1,18 +1,22 @@
import { lazy, Suspense, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { Clock, Pencil, Trash2 } from "lucide-react";
import { Clock, Link, Pencil, Share2, Trash2 } from "lucide-react";
import { api, type FileFolder, type FolderPatch } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import { invalidateDownloads } from "../lib/downloads";
import { invalidateDownloads, quickShareLink } from "../lib/downloads";
import { useConfirm } from "./confirmContext";
import { folderLabel } from "./fileFolders";
import { type ActionMenuItem } from "./ui/ActionMenu";
import { menuGroups } from "./ui/menuGroups";
// A library folder's ONE menu — the rail entry's right click and the Files page's "…" beside an open
// folder's name. An own folder is renamed (inline, where it is named), kept for a while and deleted; a
// source folder is named by its origin and cannot go, so only how long it keeps its files is its
// user's to set (the server holds the same rules: `routes/files.py`).
// folder's name. An own folder is shared by link (its link opens whatever is in it), renamed (inline,
// where it is named), kept for a while and deleted; a source folder is named by its origin and cannot
// go, so only how long it keeps its files is its user's to set (the server holds the same rules:
// `routes/files.py`, `routes/downloads.py`).
const ShareDialog = lazy(() => import("./ShareDialog"));
/** The choices the Retention submenu offers (days; null = until the user deletes the file). */
const RETENTION_CHOICES: readonly (number | null)[] = [7, 30, 90, null];
@@ -25,6 +29,9 @@ export function useFolderActions({ onRename }: { onRename: (folder: FileFolder)
const qc = useQueryClient();
const { t } = useTranslation();
const confirm = useConfirm();
// The own folder whose Share dialog is open (the hook's caller renders `dialog`).
const [sharing, setSharing] = useState<FileFolder | null>(null);
const [copying, setCopying] = useState(false);
// Every write lands in the listings too: a retention change moves its files' expiry, a delete moves
// the files themselves.
@@ -83,10 +90,34 @@ export function useFolderActions({ onRename }: { onRename: (folder: FileFolder)
if (ok) remove.mutate(folder.id);
};
// The same two a file row has, in its order: one click copies the folder's link (made the first
// time), the dialog is for a password, an expiry or revoking it.
const share = (folder: FileFolder): ActionMenuItem[] => [
{
id: "copy-link",
label: t("files.actions.copyLink"),
icon: icon(Link),
disabled: copying,
onSelect: () => {
setCopying(true);
void quickShareLink(qc, { kind: "folder", id: folder.id }, t).finally(() =>
setCopying(false),
);
},
},
{
id: "share-link",
label: t("files.folder.shareLink"),
icon: icon(Share2),
onSelect: () => setSharing(folder),
},
];
/** The folder's menu rows. */
const items = (folder: FileFolder): ActionMenuItem[] =>
folder.kind === "own"
? menuGroups(
share(folder),
[
{
id: "rename",
@@ -126,5 +157,14 @@ export function useFolderActions({ onRename }: { onRename: (folder: FileFolder)
}
};
return { items, rename, add };
const dialog = sharing && (
<Suspense fallback={null}>
<ShareDialog
folder={{ id: sharing.id, name: folderLabel(sharing, t) }}
onClose={() => setSharing(null)}
/>
</Suspense>
);
return { items, rename, add, dialog };
}
+12
View File
@@ -26,6 +26,10 @@ const STR = {
crashTitle: "Something went wrong.",
crashHint: "Reload the page to try again.",
reload: "Reload",
passwordTitleFolder: "This folder is password-protected",
folderEmpty: "This folder is empty.",
play: "Play",
files: (n: number) => (n === 1 ? "1 file" : `${n} files`),
},
hu: {
loading: "Betöltés…",
@@ -50,6 +54,10 @@ const STR = {
crashTitle: "Valami hiba történt.",
crashHint: "Töltsd újra az oldalt, és próbáld újra.",
reload: "Újratöltés",
passwordTitleFolder: "Ez a mappa jelszóval védett",
folderEmpty: "Ez a mappa üres.",
play: "Lejátszás",
files: (n: number) => `${n} fájl`,
},
de: {
loading: "Wird geladen…",
@@ -74,6 +82,10 @@ const STR = {
crashTitle: "Etwas ist schiefgelaufen.",
crashHint: "Lade die Seite neu und versuche es erneut.",
reload: "Neu laden",
passwordTitleFolder: "Dieser Ordner ist passwortgeschützt",
folderEmpty: "Dieser Ordner ist leer.",
play: "Abspielen",
files: (n: number) => (n === 1 ? "1 Datei" : `${n} Dateien`),
},
} as const;
@@ -1,4 +1,29 @@
[
{
"version": "0.107.1",
"date": "2026-10-16",
"new": [],
"fixed": [
"Downloads: a failed download's explanation now says to press Retry, instead of pointing to a download list that no longer exists."
]
},
{
"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",
"new": [],
"fixed": [
"Pictures: a thumbnail, poster or channel picture that fails to load is retried, and meanwhile the card shows its empty frame instead of a broken-image mark."
]
},
{
"version": "0.106.1",
"date": "2026-10-14",
@@ -1,4 +1,31 @@
[
{
"version": "0.107.1",
"date": "2026-10-16",
"new": [],
"fixed": [
"Letöltés: egy sikertelen letöltés magyarázata most az Újra gombot mondja, nem egy már nem létező letöltési listát."
]
},
{
"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",
"new": [],
"fixed": [
"Képek: a be nem töltődő bélyegképet, posztert vagy csatornaképet az app újra lekéri, addig pedig a kártya üres kerete látszik a törött kép jele helyett."
]
},
{
"version": "0.106.1",
"date": "2026-10-14",
+31 -5
View File
@@ -5,6 +5,8 @@ import { BatchB } from "./batchB";
import { BatchC } from "./batchC";
import { BatchD } from "./batchD";
import { FilesRound } from "./filesRound";
import { TokensRound } from "./tokensRound";
import { LinksRound } from "./linksRound";
import { Chip } from "../components/ui/Chip";
import { Slider } from "../components/ui/NativeControls";
import { SURFACE } from "../components/ui/surfaces";
@@ -75,8 +77,14 @@ export default function DesignLab() {
/>
</label>
<nav className="flex flex-wrap gap-1 text-xs">
<a href="#open-files" className="rounded-md px-1.5 py-0.5 text-accent state-layer">
Files
<a href="#open-links" className="rounded-md px-1.5 py-0.5 text-success state-layer">
Links ✓
</a>
<a href="#open-tokens" className="rounded-md px-1.5 py-0.5 text-success state-layer">
Tokens ✓
</a>
<a href="#open-files" className="rounded-md px-1.5 py-0.5 text-success state-layer">
Files ✓
</a>
<a href="#decided" className="rounded-md px-1.5 py-0.5 text-success state-layer">
A ✓
@@ -101,10 +109,28 @@ export default function DesignLab() {
Resize the window to tablet size to see them there too.
</p>
</header>
<header id="open-files" className="mt-6 scroll-mt-20">
<h2 className="text-xl font-bold">Open · Files, the one library</h2>
<header id="open-links" className="mt-6 scroll-mt-20">
<h2 className="text-xl font-bold">Decided · Folder links</h2>
<p className="text-sm text-muted">
The page that replaces Downloads. The recommended variant has the accent edge.
A /watch link that opens a whole folder. Picked: Copy link beside Share with link… in
the folder menu, a list the link opens with the player above it, a link mark in the
rail.
</p>
</header>
<LinksRound />
<header id="open-tokens" className="mt-6 scroll-mt-20">
<h2 className="text-xl font-bold">Decided · API tokens, a machine&apos;s key to Files</h2>
<p className="text-sm text-muted">
Settings → API tokens. Picked: a row per token in three lines, the kind asked first (Hub
csere or Egyedi, rights as chips per folder), the token shown once in the same dialog.
</p>
</header>
<TokensRound />
<header id="open-files" className="mt-6 scroll-mt-20">
<h2 className="text-xl font-bold">Decided · Files, the one library</h2>
<p className="text-sm text-muted">
The page that replaced Downloads. Picked: list rows (the grid a toggle), an app-wide
download tray, a done notification that copies the link.
</p>
</header>
<FilesRound />
+69 -181
View File
@@ -34,15 +34,16 @@ import {
X,
} from "lucide-react";
import { YouTubeIcon } from "../lib/brandIcons";
import { Decision, Sample, Variant } from "./labKit";
import { Reference, Sample } from "./labKit";
import { Badge } from "../components/ui/Badge";
import { SURFACE } from "../components/ui/surfaces";
import { buttonClass, iconButtonClass } from "../components/ui/buttonStyles";
import { fieldClass } from "../components/ui/fieldStyles";
import { MEDIA_BADGE } from "../components/ui/mediaCardStyles";
// The Files round: the one library that replaces the Downloads page. Static markup on purpose (the
// lab's rule): the sample rows are the owner's own kind of content, nothing here talks to the server.
// Files, the one library, DECIDED (owner, 2026-10-11): the page that replaced Downloads. Static markup
// on purpose (the lab's rule): the sample rows are the owner's own kind of content, nothing here talks
// to the server.
const ICON = iconButtonClass({ variant: "ghost", size: "sm" });
@@ -158,47 +159,7 @@ function SourceBadge({ item }: { item: Item }) {
);
}
function StateBadges({ item }: { item: Item }) {
return (
<>
{item.state === "phone" && (
<Badge title="Telefonon is lejátszható">
<Smartphone />
<span className="text-success">✓</span>
</Badge>
)}
{item.state === "converting" && (
<Badge tone="accent">
<Loader2 className="animate-spin" />
Telefonra alakítás…
</Badge>
)}
{item.retention === "kept" && (
<Badge>
<Pin />
Megtartva
</Badge>
)}
{item.retention && item.retention !== "kept" && item.retention.days <= 7 && (
<Badge tone="warn">
<Clock />
Lejár {item.retention.days} nap múlva
</Badge>
)}
</>
);
}
function Thumb({
item,
className,
clipOnPicture = false,
}: {
item: Item;
className: string;
/** A grid card marks a clip on its picture; a small list thumb has no room beside the length. */
clipOnPicture?: boolean;
}) {
function Thumb({ item, className }: { item: Item; className: string }) {
const Glyph = item.kind === "video" ? null : FileText;
return (
<div
@@ -210,31 +171,37 @@ function Thumb({
>
{Glyph && <Glyph className="absolute inset-0 m-auto size-6 text-muted" />}
{item.length && <span className={clsx(MEDIA_BADGE, "bottom-1 right-1")}>{item.length}</span>}
{clipOnPicture && item.clip && (
<span className={clsx(MEDIA_BADGE, "left-1 top-1 inline-flex items-center gap-0.5")}>
<Scissors className="size-3" />
klip
</span>
)}
</div>
);
}
function RowActions({ item }: { item: Item }) {
// Only the ⋯ holds a place: the quick icons come up over the date while the row is hovered or holds
// the keyboard (the mail-list pattern), so the name gets their room. An action that does not apply
// keeps its slot empty (a file has no "copy the play link").
function RowActions({ item, focused }: { item: Item; focused: boolean }) {
return (
<span className="flex shrink-0 items-center gap-0.5">
{item.kind === "video" ? (
<span className={ICON} title="Link másolása">
<Link />
<span className="relative flex items-center justify-end">
<span
className={clsx(
"absolute right-full top-1/2 flex -translate-y-1/2 items-center gap-0.5 rounded-md bg-bg transition-opacity",
focused
? "opacity-100"
: "pointer-events-none opacity-0 group-hover:pointer-events-auto group-hover:opacity-100",
)}
>
{item.kind === "video" ? (
<span className={ICON} title="Link másolása">
<Link />
</span>
) : (
<span className="size-7" aria-hidden />
)}
<span className={ICON} title="Megosztás">
<Share2 />
</span>
<span className={ICON} title="Letöltés">
<Download />
</span>
) : (
<span className="size-7" aria-hidden />
)}
<span className={ICON} title="Megosztás">
<Share2 />
</span>
<span className={ICON} title="Letöltés">
<Download />
</span>
<span className={ICON} title="Továbbiak">
<MoreHorizontal />
@@ -243,7 +210,7 @@ function RowActions({ item }: { item: Item }) {
);
}
// ── The page frame: sidebar + toolbar, shared by both layouts ────────────────────────────────────
// ── The page frame: sidebar + toolbar ────────────────────────────────────────────────────────────
function SideEntry({
icon: Icon,
@@ -303,7 +270,7 @@ function Sidebar() {
);
}
function Toolbar({ grid }: { grid: boolean }) {
function Toolbar() {
return (
<div className="flex flex-wrap items-center gap-2">
<span className={clsx(fieldClass({ size: "sm" }), "flex min-w-64 flex-1 items-center gap-2")}>
@@ -325,10 +292,10 @@ function Toolbar({ grid }: { grid: boolean }) {
Feltöltés
</span>
<span className="flex">
<span className={iconButtonClass({ variant: "ghost", size: "sm" })} aria-pressed={!grid}>
<span className={iconButtonClass({ variant: "ghost", size: "sm" })} aria-pressed>
<List />
</span>
<span className={iconButtonClass({ variant: "ghost", size: "sm" })} aria-pressed={grid}>
<span className={iconButtonClass({ variant: "ghost", size: "sm" })} aria-pressed={false}>
<LayoutGrid />
</span>
</span>
@@ -336,7 +303,7 @@ function Toolbar({ grid }: { grid: boolean }) {
);
}
function Frame({ grid, children }: { grid: boolean; children: ReactNode }) {
function Frame({ children }: { children: ReactNode }) {
return (
<div className="flex gap-4 overflow-x-auto rounded-xl border border-border/60 p-3">
<Sidebar />
@@ -348,21 +315,20 @@ function Frame({ grid, children }: { grid: boolean; children: ReactNode }) {
Legújabb elöl <ChevronDown className="size-3.5" />
</span>
</div>
<Toolbar grid={grid} />
<Toolbar />
{children}
</div>
</div>
);
}
// ── 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() {
</span>
<span className="text-right text-xs tabular-nums text-muted">{item.size}</span>
<span className="text-right text-xs text-muted">{item.when}</span>
<RowActions item={item} />
<RowActions item={item} focused={i === 3} />
</div>
))}
</div>
@@ -496,32 +462,6 @@ function RowMenu() {
);
}
function GridCards() {
return (
<div className="grid grid-cols-[repeat(auto-fill,minmax(11rem,1fr))] gap-3">
{ITEMS.map((item, i) => (
<div
key={item.name}
className={clsx(
"flex flex-col gap-1.5 rounded-xl p-1.5",
i === 3 && "bg-accent/8 ring-1 ring-accent/40",
)}
>
<Thumb item={item} className="aspect-video w-full" clipOnPicture />
<span className="line-clamp-2 text-sm font-medium leading-5">{item.name}</span>
<span className="flex flex-wrap items-center gap-1 text-xs text-muted">
<SourceBadge item={item} />
{item.by && <span className="truncate">{item.by}</span>}
</span>
<span className="flex flex-wrap items-center gap-1">
<StateBadges item={item} />
</span>
</div>
))}
</div>
);
}
// ── 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 (
<span className="flex items-center gap-2 text-xs text-muted">
<span className="h-1 flex-1 overflow-hidden rounded-full bg-fg/10">
<span className="block h-full rounded-full bg-accent" style={{ width: `${pct}%` }} />
</span>
<span className="w-8 text-right tabular-nums">{pct}%</span>
<span className="tabular-nums">
{step} · {pct}%
</span>
</span>
);
}
@@ -561,7 +503,7 @@ function TrayPanel() {
</span>
</div>
<TrayRow title="Ez a nap is eljött" site="tiktok.com">
<Progress pct={64} />
<Progress step="Videó letöltése" pct={64} />
</TrayRow>
<TrayRow title="Rainbow Six Mobile – teljes meccs" site="YouTube">
<span className="text-xs text-muted">Sorban áll…</span>
@@ -603,20 +545,6 @@ function TrayPill() {
);
}
function RailBell() {
return (
<div className="flex items-start gap-3">
<span className="relative grid size-10 place-items-center rounded-xl bg-accent/12 text-accent">
<Download className="size-5" />
<span className="absolute -right-1 -top-1 grid size-4 place-items-center rounded-full bg-accent text-2xs font-bold text-accent-fg">
3
</span>
</span>
<TrayPanel />
</div>
);
}
// ── F3: the done notification ────────────────────────────────────────────────────────────────────
function DoneToast() {
@@ -647,84 +575,44 @@ function DoneToast() {
export function FilesRound() {
return (
<div className="flex flex-col gap-4">
<Decision
<Reference
id="files-layout"
code="F1"
title="The Files page"
question="One library for everything: how does a folder show its items? Both have the same sidebar (sources, own folders, shared with me, storage), the paste box and the same badges: source + channel, phone-safe ✓, expiry when ≤7 days, kept. The highlighted item is the one just downloaded (focus on arrival)."
now="Downloads › Library: one tall card per item, no source, no folders, a row of three icons; the paste box lives on the Queue tab."
rule="One library for videos and plain files alike. List rows by default, the media grid a toggle remembered per folder; a sidebar of automatic source folders, own folders, Shared with me and the storage meter; the paste-a-link box in the toolbar. Every kind of value in its own column (name, source, phone-safe, retention, size, added), one grid with a subgrid per row so they line up. 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 on a phone keeps them in its own line), and an action that does not apply keeps its slot empty. Every row has a right-click menu, the ⋯ opens the same one. The item just downloaded arrives focused (highlighted)."
>
<Variant
letter="A"
name="List rows (grid one click away)"
recommended
why="A library holds videos AND files (a hub report, a PDF): rows read both, show 12–15 items a screen, and put size and date in columns you can scan. The grid stays a toggle, remembered per folder, for a folder of clips."
>
<Frame grid={false}>
<ListRows />
</Frame>
<Sample label="Right click on a row (the ⋯ opens the same menu)">
<RowMenu />
</Sample>
</Variant>
<Variant
letter="B"
name="Media grid (list one click away)"
why="Pictures first, like the feed: best for a folder of clips, but a text file or a PDF becomes a big grey card, and 6–8 items fit a screen."
>
<Frame grid>
<GridCards />
</Frame>
</Variant>
</Decision>
<Frame>
<ListRows />
</Frame>
<Sample label="Right click on a row (the ⋯ opens the same menu)">
<RowMenu />
</Sample>
</Reference>
<Decision
<Reference
id="files-tray"
code="F2"
title="Running downloads"
question="Downloading is an action now, so where do the running and stuck ones show — on every page, not only in Files?"
now="the Queue tab of the Downloads page: you have to go there to see it."
rule="An app-wide tray, bottom right, shown only while something runs or failed: a failure stays with its reason and a retry, and it goes when all is done. A running row names its step beside its bar. It folds to a pill, so it never covers a player control."
>
<div className="grid grid-cols-[repeat(auto-fit,minmax(22rem,1fr))] gap-4">
<Variant
letter="A"
name="A tray, bottom right (Drive)"
recommended
why="It appears by itself while something runs and goes when all is done; a failure stays with its reason and a retry. Folds to a pill, so it never covers a player control."
>
<Sample label="Open">
<TrayPanel />
</Sample>
<Sample label="Folded">
<TrayPill />
</Sample>
</Variant>
<Variant
letter="B"
name="A rail icon with a count, the list in a popover"
why="Nothing floats over the page, but a running download is invisible until you look, and the rail grows a sixth always-there icon."
>
<RailBell />
</Variant>
<div className="flex flex-wrap items-start gap-4">
<Sample label="Open">
<TrayPanel />
</Sample>
<Sample label="Folded">
<TrayPill />
</Sample>
</div>
</Decision>
</Reference>
<Decision
<Reference
id="files-done"
code="F3"
title="When a download is done"
question="The daily path is paste → done → copy the link → send. Can the notification finish it?"
now="a generic 'ready' notification; you open Library, check it plays, then press the share icon."
rule="The notification says whether it plays on a phone and carries Link másolása and Megnyitás (its folder, the item focused), so paste → done → copy the link ends there. Never an automatic jump: it would pull you out of what you were doing."
>
<Variant
letter="A"
name="The notification carries the link"
recommended
why="It says whether it plays on a phone and copies the link in one click; Megnyitás opens its folder with the item focused. No jump on its own — it would pull you out of what you were doing."
>
<DoneToast />
</Variant>
</Decision>
<DoneToast />
</Reference>
</div>
);
}
+5 -75
View File
@@ -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 <Reference>: the chosen rendering with the
// rule it became. An open design round shows its questions as <Decision>s with their <Variant>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 <Reference> is the chosen rendering with the rule it became. A new design round brings
// back a side-by-side <Decision>/<Variant> 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 (
<section id={id} className={`${SURFACE.panel} p-5 scroll-mt-20`}>
<header className="mb-4">
<div className="flex items-baseline gap-3">
<span className="text-xs font-bold tracking-widest text-accent">{code}</span>
<h3 className="text-lg font-bold">{title}</h3>
</div>
<p className="mt-1 text-sm">{question}</p>
<p className="mt-1 text-xs text-muted">
<span className="font-semibold">Today: </span>
{now}
</p>
</header>
<div className="flex flex-col gap-4">{children}</div>
</section>
);
}
/** 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 (
<article
className={clsx(
SURFACE.card,
"flex flex-col gap-3 border p-4",
recommended ? "border-accent" : "border-transparent",
)}
>
<div className="flex items-center gap-2">
<span className="grid h-6 w-6 place-items-center rounded-full bg-fg/8 text-xs font-bold">
{letter}
</span>
<h4 className="flex-1 text-sm font-semibold">{name}</h4>
{recommended && <Badge tone="accent">Recommended</Badge>}
</div>
<div className="flex flex-col gap-3">{children}</div>
<p className="mt-auto border-t border-border/60 pt-2 text-xs text-muted">{why}</p>
</article>
);
}
/** A small caption above a sample inside a variant. */
export function Sample({ label, children }: { label: string; children: ReactNode }) {
return (
+317
View File
@@ -0,0 +1,317 @@
import clsx from "clsx";
import type { ReactNode } from "react";
import {
Clock,
Copy,
Download,
Eye,
FileText,
Folder,
FolderPlus,
Link2,
Lock,
Pencil,
Play,
Trash2,
Users,
X,
} from "lucide-react";
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";
// 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" });
type File = { name: string; size: string; length?: string; doc?: boolean; hue: string };
const FILES: File[] = [
{ name: "Balaton 2026 — a móló", size: "48,1 MB", length: "2:14", hue: "from-accent/45" },
{ name: "Szülinapi torta, gyertyafújás", size: "19,9 MB", length: "1:11", hue: "from-rating/45" },
{ name: "Útvonal és szállás.pdf", size: "640 KB", doc: true, hue: "from-fg/20" },
{ name: "Esti tábortűz", size: "7,3 MB", length: "0:38", hue: "from-success/40" },
];
function Thumb({ file, className }: { file: File; className: string }) {
return (
<div
className={clsx(
"relative shrink-0 overflow-hidden rounded-lg bg-linear-to-br to-card",
file.hue,
className,
)}
>
{file.doc && <FileText className="absolute inset-0 m-auto size-5 text-muted" />}
{file.length && <span className={clsx(MEDIA_BADGE, "bottom-1 right-1")}>{file.length}</span>}
</div>
);
}
// ── L1: making a folder link ────────────────────────────────────────────────────────────────────
function RailEntry({
label,
count,
shared,
active,
}: {
label: string;
count: number;
shared?: boolean;
active?: boolean;
}) {
return (
<span
className={clsx(
"flex h-8 items-center gap-2 rounded-lg px-2 text-sm",
active ? "bg-accent/12 font-medium text-accent" : "text-muted",
)}
>
<Folder className="size-4 shrink-0" />
<span className="flex-1 truncate">{label}</span>
{shared && <Link2 className="size-3.5 shrink-0 text-accent" aria-label="Linkkel megosztva" />}
<span className="text-xs tabular-nums">{count}</span>
</span>
);
}
function Rail({ marked }: { marked: boolean }) {
return (
<aside className="flex w-52 shrink-0 flex-col gap-0.5">
<span className="flex items-center px-2 text-2xs uppercase tracking-wider text-muted">
<span className="flex-1">Saját mappák</span>
<FolderPlus className="size-3.5" />
</span>
<RailEntry label="Cimboráknak" count={4} shared={marked} active />
<RailEntry label="Receptek" count={1} />
<RailEntry label="Munka" count={7} shared={marked} />
</aside>
);
}
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?][][] = [
[
[Link2, "Link másolása"],
[Users, "Megosztás linkkel…"],
],
[
[Clock, "Megőrzés"],
[Pencil, "Átnevezés"],
],
[[Trash2, "Törlés", true]],
];
return (
<div className={clsx(SURFACE.menu, "w-56 shadow-float")}>
{groups.map((g, gi) => (
<div key={gi}>
{gi > 0 && <div className="my-1 h-px bg-border" />}
{g.map(([Icon, label, danger], i) => (
<span
key={label}
className={clsx(row, danger && "text-danger", gi === 0 && i === 0 && "bg-fg/8")}
>
<Icon className={clsx("size-4 shrink-0", danger ? "text-danger" : "text-muted")} />
{label}
</span>
))}
</div>
))}
</div>
);
}
function LinkRow({ views, pw }: { views: number; pw?: boolean }) {
return (
<div className={clsx(SURFACE.card, "flex flex-col gap-2 p-2.5")}>
<div className="flex items-center gap-2">
<Link2 className="size-4 shrink-0 text-muted" />
<span className="flex-1 truncate text-xs text-muted">
https://siftlode.example/watch/Qk7vN2x…
</span>
<span className={ICON}>
<Copy className="size-4" />
</span>
<span className={ICON}>
<Lock className={clsx("size-4", pw && "text-accent")} />
</span>
<span className={ICON}>
<Trash2 className="size-4 text-danger" />
</span>
</div>
<div className="flex items-center gap-2 text-xs text-muted">
<Eye className="size-3" /> {views} megtekintés
{pw && <span className="rounded-sm bg-surface px-1.5 py-0.5">jelszóval</span>}
<span className="rounded-sm bg-surface px-1.5 py-0.5">letölthető</span>
</div>
</div>
);
}
function FolderShareDialog() {
return (
<div className={clsx(SURFACE.panel, "flex w-full max-w-md flex-col gap-3 p-4")}>
<div className="flex items-center">
<h4 className="flex-1 font-semibold">Mappa megosztása · Cimboráknak</h4>
<X className="size-4 text-muted" />
</div>
<div className="flex items-center gap-2 text-sm font-medium">
<Link2 className="size-4 text-muted" /> Megosztás linkkel
</div>
<p className="-mt-2 text-xs text-muted">
A link birtokában bárki megnézheti a mappát — fiók nélkül is. Mindig a mappa aktuális
tartalmát látja: az ide később tett fájlokat is, a kivetteket már nem.
</p>
<LinkRow views={3} />
<span className={clsx(buttonClass({ variant: "outline", size: "sm" }), "self-start")}>
<Link2 className="size-4" /> Link létrehozása
</span>
</div>
);
}
// ── L2: the page the link opens ─────────────────────────────────────────────────────────────────
function Brand() {
return <span className="text-xs text-muted">Megosztva a Siftlode-dal</span>;
}
function PageFrame({ phone, children }: { phone?: boolean; children: ReactNode }) {
return (
<div
className={clsx(
"flex flex-col gap-3 rounded-xl bg-bg p-3 ring-1 ring-border",
phone ? "w-[23.5rem] max-w-full" : "w-full",
)}
>
{children}
</div>
);
}
function Header() {
return (
<div className="flex items-end gap-2">
<div className="flex-1">
<h4 className="text-lg font-bold">Cimboráknak</h4>
<span className="text-xs text-muted">4 fájl · 76 MB</span>
</div>
<Brand />
</div>
);
}
function Player({ file }: { file: File }) {
return (
<div className="flex flex-col gap-1.5">
<div
className={clsx(
"relative aspect-video w-full overflow-hidden rounded-xl bg-linear-to-br to-card",
file.hue,
)}
>
<span className="absolute inset-x-3 bottom-2 flex items-center gap-2 text-xs text-fg/80">
<Play className="size-3.5" /> 0:41 / {file.length}
<span className="h-1 flex-1 rounded-full bg-fg/20">
<span className="block h-full w-1/3 rounded-full bg-accent" />
</span>
</span>
</div>
<div className="flex items-center gap-2">
<span className="flex-1 truncate text-sm font-semibold">{file.name}</span>
<span className={buttonClass({ variant: "outline", size: "xs" })}>
<Download className="size-3.5" /> Letöltés
</span>
</div>
</div>
);
}
function ListPage({ phone, playing }: { phone?: boolean; playing?: boolean }) {
return (
<PageFrame phone={phone}>
<Header />
{playing && <Player file={FILES[1]!} />}
<div className="flex flex-col">
{FILES.map((f, i) => (
<div
key={f.name}
className={clsx(
"flex items-center gap-3 rounded-lg px-1.5 py-1.5",
playing && i === 1 && "bg-accent/10",
)}
>
<Thumb file={f} className="h-10 w-16" />
<div className="min-w-0 flex-1">
<div className="truncate text-sm">{f.name}</div>
<div className="text-xs text-muted">{f.size}</div>
</div>
{!f.doc && (
<span className={ICON}>
<Play className="size-4" />
</span>
)}
<span className={ICON}>
<Download className="size-4" />
</span>
</div>
))}
</div>
</PageFrame>
);
}
// ── The round ───────────────────────────────────────────────────────────────────────────────────
export function LinksRound() {
return (
<div className="flex flex-col gap-4">
<Reference
id="links-l1"
code="L1"
title="Making a folder link"
rule="An own folder's right-click menu has Link másolása and Megosztás linkkel…, the two entries a file row has, in the same order: one click copies the folder's link (making one the first time), the dialog is for a password, an expiry or revoking it. The dialog is the file's Share dialog with only its link half (sharing with a user stays per file), and it says what a folder link means: whoever has it sees the folder as it is NOW, files put in later included."
>
<div className="flex flex-wrap items-start gap-4">
<Sample label="the folder's right-click menu">
<div className="flex gap-3">
<Rail marked={false} />
<FolderMenu />
</div>
</Sample>
<Sample label="the dialog Megosztás linkkel… opens">
<FolderShareDialog />
</Sample>
</div>
</Reference>
<Reference
id="links-l2"
code="L2"
title="The page a folder link opens"
rule="Signed out, often on a phone: the folder's name, then a list of its files as it is now. Play opens the watch page's player above the list with the playing row lit, the next one a tap away; each file downloads when the link allows it, and a document is never opened as a web page, only downloaded. A row without a picture keeps its glyph above the length."
>
<div className="grid items-start gap-4 xl:grid-cols-[minmax(0,42rem)_auto]">
<Sample label="desktop, a video playing">
<ListPage playing />
</Sample>
<Sample label="phone">
<ListPage phone />
</Sample>
</div>
</Reference>
<Reference
id="links-l3"
code="L3"
title="Seeing which folders are shared"
rule="The rail marks a folder that has a live link with a quiet link icon beside its count: a folder link is the one place where moving a file in publishes it. The mark goes when the last link is revoked or runs out."
>
<Rail marked />
</Reference>
</div>
);
}
+370
View File
@@ -0,0 +1,370 @@
import clsx from "clsx";
import type { ReactNode } from "react";
import {
AlertTriangle,
Copy,
Folder,
KeyRound,
MoreHorizontal,
Plus,
Server,
SlidersHorizontal,
X,
} from "lucide-react";
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";
// 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 = () => {};
type Right = "list" | "read" | "write" | "delete";
const RIGHT_LABEL: Record<Right, string> = {
list: "Lista",
read: "Olvasás",
write: "Írás",
delete: "Törlés",
};
type Token = {
name: string;
prefix: string;
grants: { folder: string; hub?: boolean; rights: Right[] }[];
used: string;
expires: string;
soon?: boolean;
};
const TOKENS: Token[] = [
{
name: "Hub csere",
prefix: "sfl_7Qm2xK9a",
grants: [{ folder: "Hub", hub: true, rights: ["list", "read", "write", "delete"] }],
used: "12 perce",
expires: "2027. okt. 14.",
},
{
name: "Telefon – receptek",
prefix: "sfl_Bw41LdZr",
grants: [
{ folder: "Receptek", rights: ["list", "read"] },
{ folder: "Cimboráknak", rights: ["list", "read", "write"] },
],
used: "soha",
expires: "6 nap múlva",
soon: true,
},
];
/** The Settings page frame the tab lives in: the tab rail on the left, the tab on the right. */
function SettingsFrame({ children }: { children: ReactNode }) {
return (
<div className={clsx(SURFACE.panel, "flex max-w-3xl overflow-hidden")}>
<div className="hidden w-36 shrink-0 flex-col gap-1 border-r border-border/60 p-2 text-sm sm:flex">
{["Megjelenés", "Értesítések", "Letöltések", "API-tokenek", "Fiók"].map((l) => (
<span
key={l}
className={clsx(
"rounded-lg px-2.5 py-1.5",
l === "API-tokenek" ? "bg-accent/15 text-accent font-medium" : "text-muted",
)}
>
{l}
</span>
))}
</div>
<div className="flex min-w-0 flex-1 flex-col gap-3 p-4">
<div className="flex items-start gap-3">
<div className="flex-1">
<h3 className="text-sm font-semibold">API-tokenek</h3>
<p className="text-xs leading-relaxed text-muted">
Egy token egy gépnek ad kulcsot a Fájlok könyvtár kijelölt mappáihoz — semmi máshoz.
</p>
</div>
<span className={buttonClass({ variant: "primary", size: "sm" })}>
<Plus className="size-4" />
Új token
</span>
</div>
{children}
</div>
</div>
);
}
function RightsLine({ grants }: { grants: Token["grants"] }) {
return (
<span className="flex flex-wrap items-center gap-x-3 gap-y-1">
{grants.map((g) => (
<span key={g.folder} className="flex items-center gap-1.5">
{g.hub ? <Server className="size-3.5" /> : <Folder className="size-3.5" />}
<span className="text-fg">{g.folder}</span>
<span>· {g.rights.map((r) => RIGHT_LABEL[r].toLowerCase()).join(", ")}</span>
</span>
))}
</span>
);
}
// ── T1 · the list ─────────────────────────────────────────────────────────────────────────────
function ListRows() {
return (
<div className="flex flex-col divide-y divide-border/60">
{TOKENS.map((tk) => (
<div key={tk.prefix} className="flex items-start gap-3 py-2.5">
<KeyRound className="mt-0.5 size-4 shrink-0 text-muted" />
<div className="flex min-w-0 flex-1 flex-col gap-1">
<span className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium">{tk.name}</span>
<code className="text-xs text-muted">{tk.prefix}…</code>
{tk.soon && <Badge tone="warn">Hamarosan lejár</Badge>}
</span>
<span className="text-xs text-muted">
<RightsLine grants={tk.grants} />
</span>
<span className="text-xs text-muted">
Utoljára használva: {tk.used} · Lejár: {tk.expires}
</span>
</div>
<span className={ICON} aria-label="Műveletek">
<MoreHorizontal className="size-4" />
</span>
</div>
))}
</div>
);
}
// ── T2 · the new-token dialog ───────────────────────────────────────────────────────────────────
function Dialog({
title,
children,
foot,
}: {
title: string;
children: ReactNode;
foot: ReactNode;
}) {
return (
<div className={clsx(SURFACE.panel, "flex w-full max-w-md flex-col shadow-float")}>
<div className="flex items-center gap-2 border-b border-border/60 px-4 py-3">
<h4 className="flex-1 text-sm font-semibold">{title}</h4>
<span className={ICON} aria-label="Bezárás">
<X className="size-4" />
</span>
</div>
<div className="flex flex-col gap-3 p-4 text-sm">{children}</div>
<div className="flex justify-end gap-2 border-t border-border/60 px-4 py-3">{foot}</div>
</div>
);
}
function Field({ label, children }: { label: string; children: ReactNode }) {
return (
<label className="flex flex-col gap-1">
<span className="text-xs text-muted">{label}</span>
{children}
</label>
);
}
function Expiry() {
return (
<Field label="Lejárat">
<span className="flex flex-wrap gap-1">
{["30 nap", "90 nap", "1 év", "Soha"].map((l) => (
<Chip key={l} pressed={l === "1 év"} onClick={noop}>
{l}
</Chip>
))}
</span>
</Field>
);
}
const FOLDERS: { name: string; kind: "hub" | "own" | "source"; on: Right[] }[] = [
{ name: "Hub", kind: "hub", on: [] },
{ name: "Receptek", kind: "own", on: ["list", "read"] },
{ name: "Cimboráknak", kind: "own", on: ["list", "read", "write"] },
{ name: "YouTube", kind: "source", on: [] },
];
function RightsMatrix() {
return (
<div className="flex flex-col divide-y divide-border/50 rounded-lg border border-border/60">
{FOLDERS.map((f) => (
<div key={f.name} className="flex flex-wrap items-center gap-2 px-2.5 py-2">
<span className="flex min-w-28 flex-1 items-center gap-1.5">
{f.kind === "hub" ? <Server className="size-3.5" /> : <Folder className="size-3.5" />}
{f.name}
</span>
<span className="flex gap-1">
{(Object.keys(RIGHT_LABEL) as Right[]).map((r) =>
// A source folder fills itself: nothing can be uploaded into it, so no Write.
f.kind === "source" && r === "write" ? null : (
<Chip key={r} pressed={f.on.includes(r)} onClick={noop}>
{RIGHT_LABEL[r]}
</Chip>
),
)}
</span>
</div>
))}
</div>
);
}
function PresetFirst({ custom }: { custom: boolean }) {
return (
<Dialog
title="Új API-token"
foot={
<>
<span className={buttonClass({ variant: "outline", size: "sm" })}>Mégse</span>
<span className={buttonClass({ variant: "primary", size: "sm" })}>Létrehozás</span>
</>
}
>
<div className="grid grid-cols-2 gap-2">
{[
{
on: !custom,
icon: Server,
title: "Hub csere",
text: "Egy közös Hub mappa; a token mindent tehet benne.",
},
{
on: custom,
icon: SlidersHorizontal,
title: "Egyedi",
text: "Te választod a mappákat és a jogokat.",
},
].map((c) => (
<span
key={c.title}
className={clsx(
SURFACE.card,
"flex flex-col gap-1 border p-3",
c.on ? "border-accent" : "border-transparent",
)}
>
<span className="flex items-center gap-1.5 font-medium">
<c.icon className="size-4" />
{c.title}
</span>
<span className="text-xs text-muted">{c.text}</span>
</span>
))}
</div>
<Field label="Név">
<span className={clsx(fieldClass({ size: "sm" }), "flex items-center")}>
{custom ? "Telefon – receptek" : "Hub csere"}
</span>
</Field>
{custom && <RightsMatrix />}
<Expiry />
</Dialog>
);
}
// ── T3 · the token, shown once ──────────────────────────────────────────────────────────────────
const RAW = "sfl_7Qm2xK9aVt3pLc8RzN0eWq5uYb1HdJ6gFs4kMx2oTi9a3F7kQe";
function CopyRow({ children, mono = true }: { children: ReactNode; mono?: boolean }) {
return (
<span className="flex items-stretch gap-1.5">
<code
className={clsx(
fieldClass({ size: "sm" }),
"flex min-w-0 flex-1 items-center break-all h-auto py-1.5",
mono && "font-mono text-xs",
)}
>
{children}
</code>
<span className={buttonClass({ variant: "outline", size: "sm" })}>
<Copy className="size-4" />
Másolás
</span>
</span>
);
}
function ShownInDialog() {
return (
<Dialog
title="A token elkészült"
foot={<span className={buttonClass({ variant: "primary", size: "sm" })}>Kész</span>}
>
<span className="flex items-start gap-1.5 text-xs text-warn">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
Most látod utoljára: mi csak a lenyomatát őrizzük. Ha elveszik, csinálj újat.
</span>
<Field label="Token">
<CopyRow>{RAW}</CopyRow>
</Field>
<Field label="Kipróbálás (curl)">
<CopyRow>
curl -H &quot;Authorization: Bearer {RAW.slice(0, 12)}…&quot;
https://siftlode.example/api/files/folders
</CopyRow>
</Field>
</Dialog>
);
}
// ── The round ───────────────────────────────────────────────────────────────────────────────────
export function TokensRound() {
return (
<div className="flex flex-col gap-4">
<Reference
id="tokens-t1"
code="T1"
title="The token list (Settings → API tokens)"
rule="A row per token in three lines: name + prefix (the “soon expires” badge beside the name), then every folder with its rights, then last use and expiry — nothing truncated at the ~36rem Settings column, and it fits a phone as is. Revoke is in the row's ⋯ menu, behind a confirm."
>
<SettingsFrame>
<ListRows />
</SettingsFrame>
</Reference>
<Reference
id="tokens-t2"
code="T2"
title="Making a token"
rule="The dialog asks the kind first. Hub csere: one tile, the name filled in, Create — the common token never shows a matrix to get wrong. Egyedi: each folder a row, its rights as chips; a source folder has no Write (nothing can be uploaded into it, the server refuses it too). Expiry is a segmented control (DESIGN.md “A choice between a few values”): 30 days, 90 days, 1 year (the default) or never, which warns that the token opens the folder until revoked."
>
<div className="grid items-start gap-4 xl:grid-cols-2">
<Sample label="Hub csere">
<PresetFirst custom={false} />
</Sample>
<Sample label="Egyedi">
<PresetFirst custom />
</Sample>
</div>
</Reference>
<Reference
id="tokens-t3"
code="T3"
title="The token, shown once"
rule="Only its fingerprint is stored, so the token shows once, as the dialog's last step: the token with a Copy, a curl line to try it, and Kész. Closing the dialog is plainly the moment it is gone."
>
<Sample label="after Létrehozás">
<ShownInDialog />
</Sample>
</Reference>
</div>
);
}
@@ -31,5 +31,10 @@
"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",
"download_link_create": "Share link created",
"download_link_update": "Share link changed",
"download_link_revoke": "Share link revoked"
}

Some files were not shown because too many files have changed in this diff Show More