Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b6d968833 | ||
|
|
95e6b2c3d9 | ||
|
|
80c55fbf1b | ||
|
|
011f269152 | ||
|
|
dcb244dd04 | ||
|
|
08a9f4e255 | ||
|
|
1673134c36 | ||
|
|
27427eef32 | ||
|
|
a248ca6c13 | ||
|
|
620f84dba4 | ||
|
|
2efa7d5a75 | ||
|
|
827df7494c | ||
|
|
86712b6b0e | ||
|
|
90567718b2 | ||
|
|
c31d7f9d65 | ||
|
|
db56f25387 | ||
|
|
4f58d15d3f | ||
|
|
2c66ce1cf4 | ||
|
|
ce45445d17 | ||
|
|
383d394e32 | ||
|
|
06e8291896 | ||
|
|
db6b60029f | ||
|
|
b2d34e6e6c |
@@ -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
|
||||
|
||||
@@ -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
@@ -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 \
|
||||
|
||||
@@ -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")
|
||||
@@ -20,7 +20,8 @@ Three layers, by *who* runs the code — not by feature:
|
||||
run by both the admin route and the scheduler; `downloads/pipeline.py` is the worker's child pipeline;
|
||||
`issues/bundle.py` is the one definition of a bug report's files, for the zip export and the tracker.
|
||||
|
||||
3. **Flat `app/*.py` core modules — cross-cutting primitives.** `auth`, `sysconfig`, `quota`, `audit`,
|
||||
3. **Flat `app/*.py` core modules — cross-cutting primitives.** `auth` (with `apitokens`, a machine's
|
||||
folder-scoped way into the Files library), `sysconfig`, `quota`, `audit`,
|
||||
`notifications`, `userscope`, `ratelimit`, `progress`, `logs` (the one logging
|
||||
configuration), `logsink` (its `log_events` table), `diagnostics` (a user's consented log
|
||||
level), `debugsession` (a recording that tags its user's lines, E16) and `collation` (A→Z in the
|
||||
|
||||
@@ -0,0 +1,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)
|
||||
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -57,12 +57,14 @@ def ingest_upload(
|
||||
display_name: str | None = None,
|
||||
sha256: str | None = None,
|
||||
folder_id: int | None = None,
|
||||
via: str | None = None,
|
||||
) -> DownloadJob:
|
||||
"""Ingest an already-uploaded file at `src_path` into `user_id`'s library and return its job —
|
||||
`done`, or `queued` for the worker's phone-safe conversion (see the module docstring). `src_path`
|
||||
is MOVED into place on success (the caller stages it to a temp file first). `sha256` is the
|
||||
hash of those bytes, when the caller took it on the way in; `folder_id` the folder it asked for
|
||||
(`folders.upload_target`), else its Uploads folder.
|
||||
(`folders.upload_target`), else its Uploads folder; `via` what the audit row names as the doer
|
||||
when it was not the user themselves (an API token).
|
||||
|
||||
Raises folders.FolderError when that folder is not one an upload may land in (or is gone).
|
||||
|
||||
@@ -145,7 +147,14 @@ def ingest_upload(
|
||||
folders.stamp_done(db, job)
|
||||
service.incr_ref_count(db, asset.id)
|
||||
db.flush()
|
||||
audit.record(db, user_id, AuditAction.FILE_UPLOAD, target_type="file", target_id=job.id)
|
||||
audit.record(
|
||||
db,
|
||||
user_id,
|
||||
AuditAction.FILE_UPLOAD,
|
||||
target_type="file",
|
||||
target_id=job.id,
|
||||
summary=via,
|
||||
)
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
|
||||
@@ -58,6 +58,7 @@ from app.routes import ( # noqa: E402 — logging is configured first
|
||||
setup as setup_routes,
|
||||
sync,
|
||||
tags,
|
||||
tokens as tokens_routes,
|
||||
version,
|
||||
)
|
||||
from app.scheduler import shutdown_scheduler, start_scheduler # noqa: E402 — logging is configured first
|
||||
@@ -337,6 +338,7 @@ app.include_router(plex_routes.router)
|
||||
app.include_router(downloads.router)
|
||||
app.include_router(downloads.admin_router)
|
||||
app.include_router(files_routes.router)
|
||||
app.include_router(tokens_routes.router)
|
||||
app.include_router(public_routes.router)
|
||||
app.include_router(admin.router)
|
||||
app.include_router(config_routes.router)
|
||||
|
||||
+67
-10
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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])
|
||||
|
||||
@@ -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}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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"),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -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() == []
|
||||
@@ -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
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 })),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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=""
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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). */}
|
||||
|
||||
@@ -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. */}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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'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 />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 "Authorization: Bearer {RAW.slice(0, 12)}…"
|
||||
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
Reference in New Issue
Block a user