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:
@@ -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
|
||||
),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user