From 283c4c9a1e733be65a38368213170ce07998affd Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 21 Jun 2026 00:38:47 +0200 Subject: [PATCH] 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. --- .../alembic/versions/0025_install_wizard.py | 39 +++++++++++ backend/app/main.py | 39 ++++++++++- backend/app/models.py | 8 +++ backend/app/routes/setup.py | 32 ++++++++++ backend/app/state.py | 64 ++++++++++++++++++- 5 files changed, 179 insertions(+), 3 deletions(-) create mode 100644 backend/alembic/versions/0025_install_wizard.py create mode 100644 backend/app/routes/setup.py diff --git a/backend/alembic/versions/0025_install_wizard.py b/backend/alembic/versions/0025_install_wizard.py new file mode 100644 index 0000000..a3026d8 --- /dev/null +++ b/backend/alembic/versions/0025_install_wizard.py @@ -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") diff --git a/backend/app/main.py b/backend/app/main.py index 220b1a5..d000c16 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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" diff --git a/backend/app/models.py b/backend/app/models.py index 388ba36..623da7b 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -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): diff --git a/backend/app/routes/setup.py b/backend/app/routes/setup.py new file mode 100644 index 0000000..a622486 --- /dev/null +++ b/backend/app/routes/setup.py @@ -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)} diff --git a/backend/app/state.py b/backend/app/state.py index 34f56fa..80466c5 100644 --- a/backend/app/state.py +++ b/backend/app/state.py @@ -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."""