feat(setup): first-boot detection + setup-mode gating + one-time token (epic 6a)

- app_state gains configured + setup_token_hash (migration 0025); existing installs (any users)
  are marked configured so they never see the wizard, a fresh DB starts in setup mode.
- On first boot the app mints a one-time setup token and logs the wizard URL (only the hash is
  stored); the token gates the setup steps (added in 6c).
- A middleware locks all /api + /auth routes (except /api/setup, /api/version, /healthz, static)
  to 503 until configured, with a DB-free cached fast-path once setup completes.
- GET /api/setup/status tells the SPA whether to show the wizard.
This commit is contained in:
npeter83 2026-06-21 00:38:47 +02:00
parent d7b7acb637
commit 283c4c9a1e
5 changed files with 179 additions and 3 deletions

View file

@ -0,0 +1,39 @@
"""first-run install wizard state
Revision ID: 0025_install_wizard
Revises: 0024_google_email_verified
Create Date: 2026-06-19
Adds app_state.configured + setup_token_hash for the first-run install wizard. Existing installs
(any users present) are marked configured so they never see the wizard; a genuinely fresh DB (no
users) starts unconfigured and boots into setup mode.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0025_install_wizard"
down_revision: Union[str, None] = "0024_google_email_verified"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"app_state",
sa.Column("configured", sa.Boolean(), nullable=False, server_default="false"),
)
op.add_column("app_state", sa.Column("setup_token_hash", sa.String(length=64), nullable=True))
# Ensure the singleton row exists and mark it configured, then revert to unconfigured only if
# this is a genuinely fresh DB (no users yet). Net: configured = (the instance already has users).
op.execute(
"INSERT INTO app_state (id, sync_paused, configured) VALUES (1, false, true) "
"ON CONFLICT (id) DO UPDATE SET configured = true"
)
op.execute("UPDATE app_state SET configured = false WHERE NOT EXISTS (SELECT 1 FROM users)")
def downgrade() -> None:
op.drop_column("app_state", "setup_token_hash")
op.drop_column("app_state", "configured")

View file

@ -20,12 +20,13 @@ if not _subfeed_logger.handlers:
log = logging.getLogger("subfeed.app")
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from starlette.middleware.sessions import SessionMiddleware
from app import auth
from app import auth, state
from app.config import settings
from app.db import SessionLocal
from app.routes import (
admin,
channels,
@ -37,6 +38,7 @@ from app.routes import (
playlists,
quota,
scheduler as scheduler_routes,
setup as setup_routes,
sync,
tags,
version,
@ -47,6 +49,17 @@ from app.scheduler import shutdown_scheduler, start_scheduler
@asynccontextmanager
async def lifespan(app: FastAPI):
log.info("Siftlode starting up")
# First-run: if the instance isn't configured yet, mint a fresh one-time setup token and print
# the wizard URL so the operator can open it (the app runs in setup mode until they finish).
with SessionLocal() as db:
if not state.is_configured(db):
token = state.rotate_setup_token(db)
url = f"{settings.app_base}/setup?token={token}"
log.warning(
"FIRST-RUN SETUP REQUIRED — this instance isn't configured yet.\n"
" Open the install wizard (internal access only):\n %s",
url,
)
start_scheduler()
try:
yield
@ -73,6 +86,27 @@ if settings.frontend_origin:
allow_headers=["*"],
)
# Paths reachable while the instance is unconfigured (setup mode). Everything else under /api or
# /auth is locked until the wizard finishes; static/SPA always loads so the wizard page can render.
_SETUP_OPEN_PREFIXES = ("/api/setup", "/api/version")
@app.middleware("http")
async def setup_gate(request, call_next):
"""Lock the app to the install wizard until it's configured. Skips the DB entirely once setup
completed in this process (the cached fast-path), so there's no steady-state overhead."""
if not state.setup_complete():
path = request.url.path
if path.startswith(("/api/", "/auth/")) and not path.startswith(_SETUP_OPEN_PREFIXES):
with SessionLocal() as db:
configured = state.is_configured_cached(db)
if not configured:
return JSONResponse(
{"detail": "This instance isn't set up yet."}, status_code=503
)
return await call_next(request)
app.include_router(health.router)
app.include_router(auth.router)
app.include_router(sync.router)
@ -87,6 +121,7 @@ app.include_router(config_routes.router)
app.include_router(scheduler_routes.router)
app.include_router(quota.router)
app.include_router(version.router)
app.include_router(setup_routes.router)
# The built SPA (populated by the Docker frontend build stage).
STATIC_DIR = Path(__file__).parent / "static_spa"

View file

@ -383,6 +383,14 @@ class AppState(Base):
# Admin override for the maintenance job's rolling re-validation batch (videos re-checked
# per run). NULL = use the config/env default (settings.maintenance_revalidate_batch).
maintenance_revalidate_batch: Mapped[int | None] = mapped_column(Integer)
# First-run install wizard. `configured` is False on a fresh instance (the app runs in
# setup mode: only the wizard works) and flipped True when the wizard finishes. While
# unconfigured, a one-time setup token (only its SHA-256 hash stored) gates the wizard;
# the raw token + URL are printed to the container logs on each startup.
configured: Mapped[bool] = mapped_column(
Boolean, default=False, server_default="false"
)
setup_token_hash: Mapped[str | None] = mapped_column(String(64))
class SchedulerSetting(Base):

View file

@ -0,0 +1,32 @@
"""First-run install wizard.
`GET /api/setup/status` is public (the SPA uses it to decide whether to show the wizard). Every
mutating step goes through `require_setup`: it rejects once the instance is configured AND requires
the one-time setup token (printed to the container logs at first boot, carried by the wizard as the
X-Setup-Token header). The wizard steps themselves are added in epic 6c.
"""
from fastapi import APIRouter, Depends, Header, HTTPException
from sqlalchemy.orm import Session
from app import state
from app.db import get_db
router = APIRouter(prefix="/api/setup", tags=["setup"])
def require_setup(
x_setup_token: str | None = Header(default=None),
db: Session = Depends(get_db),
) -> Session:
"""Gate for setup steps. Returns the db session for the handler to reuse."""
if state.is_configured(db):
raise HTTPException(status_code=409, detail="This instance is already set up.")
if not state.check_setup_token(db, x_setup_token):
raise HTTPException(status_code=403, detail="Invalid or missing setup token.")
return db
@router.get("/status")
def setup_status(db: Session = Depends(get_db)) -> dict:
"""Public: whether the instance still needs first-run setup."""
return {"configured": state.is_configured(db)}

View file

@ -1,5 +1,7 @@
"""Global admin-controlled app state (e.g. pausing background sync)."""
"""Global admin-controlled app state (e.g. pausing background sync, first-run setup)."""
import hashlib
import logging
import secrets
from sqlalchemy.orm import Session
@ -12,6 +14,10 @@ log = logging.getLogger("subfeed.state")
MAINTENANCE_BATCH_MIN = 100
MAINTENANCE_BATCH_MAX = 100_000
# Once configuration completes in this process it never reverts, so cache it to skip the per-request
# DB hit in the setup-mode gate (see main.setup_gate). Starts False; refreshed from the DB.
_configured_cache = False
def _row(db: Session) -> AppState:
row = db.get(AppState, 1)
@ -34,6 +40,62 @@ def set_sync_paused(db: Session, paused: bool) -> None:
log.info("Background sync %s", "paused" if paused else "resumed")
# --- First-run install wizard ------------------------------------------------------------
def is_configured(db: Session) -> bool:
return _row(db).configured
def setup_complete() -> bool:
"""Fast, DB-free check for the request gate: True once configuration finished this process."""
return _configured_cache
def is_configured_cached(db: Session) -> bool:
"""Configured check that promotes the module cache once true (configuration is one-way)."""
global _configured_cache
if _configured_cache:
return True
if is_configured(db):
_configured_cache = True
return _configured_cache
def mark_configured(db: Session) -> None:
"""Finish setup: flip the instance to configured and invalidate the setup token."""
global _configured_cache
row = _row(db)
row.configured = True
row.setup_token_hash = None
db.add(row)
db.commit()
_configured_cache = True
log.info("Instance marked configured; setup wizard disabled.")
def _hash_token(raw: str) -> str:
return hashlib.sha256(raw.encode()).hexdigest()
def rotate_setup_token(db: Session) -> str:
"""Generate a fresh one-time setup token, persist only its hash, and return the raw token
(logged at startup so the admin can open the wizard)."""
raw = secrets.token_urlsafe(24)
row = _row(db)
row.setup_token_hash = _hash_token(raw)
db.add(row)
db.commit()
return raw
def check_setup_token(db: Session, raw: str | None) -> bool:
"""Whether `raw` matches the stored setup-token hash (constant-time)."""
if not raw:
return False
stored = _row(db).setup_token_hash
return stored is not None and secrets.compare_digest(stored, _hash_token(raw))
def get_maintenance_batch(db: Session) -> int:
"""Effective maintenance re-validation batch: the admin override if set, else the
env/config default."""