feat(files): API tokens in Settings, the Hub folder in Files, a notice before a token runs out

Settings gains an API tokens tab: a row per token (name and prefix; each
folder with its rights; last use and expiry, "expires soon" marked),
revoked from its menu behind a confirm. A new token is the Hub exchange in
one click, or folders and rights picked per folder (no Write where an
upload cannot land); expiry 30/90 days, 1 year or never (warned). The
token shows once in the same dialog, with Copy and a ready curl line.

The Hub folder closes the Sources in the Files rail, takes uploads and
moved-in files. A token about to run out warns its user once, a week
before (the Files GC's sixth pass); the notice opens the tokens tab.
This commit is contained in:
2026-10-14 23:33:34 +02:00
parent 4f58d15d3f
commit db56f25387
39 changed files with 1759 additions and 26 deletions
+2 -1
View File
@@ -5,7 +5,7 @@ Revises: 0105_job_source_key
Create Date: 2026-10-14
`api_tokens` is a user's token for a machine (a script, the hub): only its SHA-256 is kept, plus the
first characters to recognise it by. `api_token_grants` says what it may do in which folder — list,
first characters to recognise it by, and whether its user was told it is about to run out. `api_token_grants` says what it may do in which folder — list,
read, write, delete — so a token reaches nothing it was not given, folder by folder.
A third folder kind, `hub`: the one folder a user exchanges files with their hub machine through.
@@ -53,6 +53,7 @@ def upgrade() -> None:
sa.Column("token_hash", sa.String(64), nullable=False, unique=True),
sa.Column("expires_at", sa.DateTime(timezone=True)),
sa.Column("last_used_at", sa.DateTime(timezone=True)),
sa.Column("expiry_warned", sa.Boolean(), nullable=False, server_default=sa.text("false")),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
+41 -1
View File
@@ -11,7 +11,8 @@ The token is `sfl_` + 32 random bytes in base62 + a CRC32 of them: a mistyped or
refused without a database read, and a secret scanner can tell one by its shape. Only its SHA-256 is
kept (`security.hash_token`) — the user sees it once, when it is made — with its first characters to
tell tokens apart by. It is looked up by that hash on every request: no signed claims, so a deleted
token stops working at once.
token stops working at once. A token about to run out warns its user once, a week before
(`warn_expiring`), so a machine does not lose its key unannounced.
"""
import secrets
@@ -29,9 +30,11 @@ from app import debugsession, logs
from app.auth import current_user
from app.db import get_db
from app.models import ApiToken, ApiTokenGrant, User
from app.notifications import create_notification
from app.ratelimit import RateLimiter, client_key
from app.refusal import refused
from app.security import hash_token
from app.utils import iso
log = logs.get_logger(__name__)
@@ -52,6 +55,9 @@ _bad = RateLimiter(20, 300)
# How stale `last_used_at` may get before a request writes it again: a busy script costs one write a
# minute, not one per request.
_TOUCH_EVERY = timedelta(minutes=1)
# How long before a token runs out its user is told (`warn_expiring`); the token list marks it
# "expires soon" for the same span.
EXPIRY_WARNING = timedelta(days=7)
class Right(StrEnum):
@@ -207,3 +213,37 @@ def _token_caller(request: Request, db: Session, raw: str) -> FilesCaller:
logs.set_user(user.id, *debugsession.level_for(user, None))
request.state.user_id = user.id
return caller
def expiring(token: ApiToken, now: datetime) -> bool:
"""Whether `token` runs out within the warning span (and has not yet)."""
return token.expires_at is not None and now < token.expires_at <= now + EXPIRY_WARNING
def warn_expiring(db: Session) -> int:
"""Tell each user whose token runs out within `EXPIRY_WARNING` — once per token: the mark and the
notice commit together, and a token made again (a new row) warns afresh. Returns how many."""
now = datetime.now(timezone.utc)
hits = db.execute(
update(ApiToken)
.where(
ApiToken.expiry_warned.is_(False),
ApiToken.expires_at.is_not(None),
ApiToken.expires_at > now,
ApiToken.expires_at <= now + EXPIRY_WARNING,
)
.values(expiry_warned=True)
.returning(ApiToken.id, ApiToken.user_id, ApiToken.name, ApiToken.expires_at)
.execution_options(synchronize_session=False)
).all()
for token_id, user_id, name, expires_at in hits:
create_notification(
db,
user_id,
"api_token_expiring",
title=f"API token {name} expires soon", # English fallback; the client names it from data
data={"token_id": token_id, "name": name, "expires_at": iso(expires_at)},
commit=False,
)
db.commit()
return len(hits)
+8 -2
View File
@@ -1,6 +1,6 @@
"""Download retention garbage collector (a scheduler job).
Five passes each run:
Six passes each run:
1. Pre-expiry warning — a done file whose `expires_at` (its folder's retention, `folders`) falls
within the grace window, and that its user did not keep, warns its user once (`expiry_warned`).
2. Expiry — such a file past its `expires_at` leaves the library: the job is deleted and its hold
@@ -14,6 +14,8 @@ Five passes each run:
re-download renames an asset, e.g. old accented channel dirs, or an upload's stranded file).
It skips the dot-prefixed system trees (all but `.uploads`) and anything newer than an hour,
so an in-flight download is never touched.
6. Token expiry warning — a machine's token for the library about to run out warns its user once
(`apitokens.warn_expiring`): the library's other clock.
Runs in the API process (which mounts FILES_ROOT). Pure disk/DB work — no YouTube quota.
"""
@@ -25,7 +27,7 @@ from pathlib import Path
from sqlalchemy import func, or_, select, update
from sqlalchemy.orm import Session
from app import logs, sysconfig
from app import apitokens, logs, sysconfig
from app.config import settings
from app.downloads import quota, service, storage
from app.downloads.status import JobStatus
@@ -366,12 +368,16 @@ def run_download_gc(db: Session) -> dict:
# posters). Runs last so the keep-set reflects the assets that survived passes 2-4.
swept = _sweep_orphan_files(db, settings.files_root)
# 6. A machine's token about to run out.
tokens_warned = apitokens.warn_expiring(db)
result = {
"warned": warned,
"expired": expired,
"evicted": evicted,
"reaped": reaped,
"swept": swept,
"tokens_warned": tokens_warned,
}
log.info("download_gc %s", result)
return result
+2
View File
@@ -968,6 +968,8 @@ class ApiToken(Base, TimestampMixin):
token_hash: Mapped[str] = mapped_column(String(64), unique=True)
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) # NULL = never
last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# The one notice its user gets before it runs out (`apitokens.warn_expiring`).
expiry_warned: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
class ApiTokenGrant(Base):
+1
View File
@@ -64,6 +64,7 @@ def _serialize(db: Session, token: ApiToken) -> dict:
"created_at": iso(token.created_at),
"expires_at": iso(token.expires_at),
"expired": token.expires_at is not None and token.expires_at <= now,
"expiring": apitokens.expiring(token, now),
"last_used_at": iso(token.last_used_at),
"grants": [
{
+38 -1
View File
@@ -16,11 +16,20 @@ from sqlalchemy import select
from app import apitokens
from app.apitokens import files_caller
from app.downloads import folders, service
from app.downloads import gc as gcmod
from app.downloads import upload as uploadmod
from app.downloads.status import JobStatus
from app.config import settings
from app.main import app
from app.models import ApiToken, ApiTokenGrant, AuditLog, DownloadJob, FileFolder, MediaAsset
from app.models import (
ApiToken,
ApiTokenGrant,
AuditLog,
DownloadJob,
FileFolder,
MediaAsset,
Notification,
)
from app.ratelimit import RateLimiter
from app.routes import tokens as tokens_routes
@@ -329,6 +338,34 @@ def test_a_malformed_unknown_expired_revoked_or_suspended_token_holds_nothing(
assert _refused(_bearer(raw)) == 401
def test_a_token_about_to_run_out_warns_its_user_once_and_is_marked_in_the_list(
db, make_user, as_user
):
alice, bob = make_user(), make_user()
c = as_user(alice)
soon = _make(c, preset="hub", expires_days=3)
_make(c, preset="hub", expires_days=30) # outside the span
_make(c, preset="hub") # never runs out
gone = _make(c, preset="hub", expires_days=1)
db.get(ApiToken, gone["id"]).expires_at = datetime.now(timezone.utc) - timedelta(seconds=1)
_make(as_user(bob), preset="hub", expires_days=2)
db.commit()
assert [t["expiring"] for t in c.get("/api/tokens").json()] == [True, False, False, False]
assert apitokens.warn_expiring(db) == 2 # alice's and bob's; the run-out one is not "soon"
notes = db.execute(select(Notification).where(Notification.user_id == alice.id)).scalars()
[note] = notes.all()
assert note.type == "api_token_expiring"
assert note.data["token_id"] == soon["id"] and note.data["name"] == "a machine"
assert note.data["expires_at"] == soon["expires_at"]
assert apitokens.warn_expiring(db) == 0 # once per token
def test_the_files_gc_runs_the_token_warning(db, make_user, as_user, _root):
_make(as_user(make_user()), preset="hub", expires_days=3)
assert gcmod.run_download_gc(db)["tokens_warned"] == 1
def test_another_users_token_cannot_be_revoked(db, make_user, as_user):
alice, bob = make_user(), make_user()
made = _make(as_user(alice), preset="hub")