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:
parent
31046f905e
commit
eda9bbbc57
5 changed files with 179 additions and 3 deletions
39
backend/alembic/versions/0025_install_wizard.py
Normal file
39
backend/alembic/versions/0025_install_wizard.py
Normal 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")
|
||||||
|
|
@ -20,12 +20,13 @@ if not _subfeed_logger.handlers:
|
||||||
|
|
||||||
log = logging.getLogger("subfeed.app")
|
log = logging.getLogger("subfeed.app")
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse, JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from starlette.middleware.sessions import SessionMiddleware
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
|
|
||||||
from app import auth
|
from app import auth, state
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
from app.db import SessionLocal
|
||||||
from app.routes import (
|
from app.routes import (
|
||||||
admin,
|
admin,
|
||||||
channels,
|
channels,
|
||||||
|
|
@ -37,6 +38,7 @@ from app.routes import (
|
||||||
playlists,
|
playlists,
|
||||||
quota,
|
quota,
|
||||||
scheduler as scheduler_routes,
|
scheduler as scheduler_routes,
|
||||||
|
setup as setup_routes,
|
||||||
sync,
|
sync,
|
||||||
tags,
|
tags,
|
||||||
version,
|
version,
|
||||||
|
|
@ -47,6 +49,17 @@ from app.scheduler import shutdown_scheduler, start_scheduler
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
log.info("Siftlode starting up")
|
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()
|
start_scheduler()
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
|
|
@ -73,6 +86,27 @@ if settings.frontend_origin:
|
||||||
allow_headers=["*"],
|
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(health.router)
|
||||||
app.include_router(auth.router)
|
app.include_router(auth.router)
|
||||||
app.include_router(sync.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(scheduler_routes.router)
|
||||||
app.include_router(quota.router)
|
app.include_router(quota.router)
|
||||||
app.include_router(version.router)
|
app.include_router(version.router)
|
||||||
|
app.include_router(setup_routes.router)
|
||||||
|
|
||||||
# The built SPA (populated by the Docker frontend build stage).
|
# The built SPA (populated by the Docker frontend build stage).
|
||||||
STATIC_DIR = Path(__file__).parent / "static_spa"
|
STATIC_DIR = Path(__file__).parent / "static_spa"
|
||||||
|
|
|
||||||
|
|
@ -383,6 +383,14 @@ class AppState(Base):
|
||||||
# Admin override for the maintenance job's rolling re-validation batch (videos re-checked
|
# 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).
|
# per run). NULL = use the config/env default (settings.maintenance_revalidate_batch).
|
||||||
maintenance_revalidate_batch: Mapped[int | None] = mapped_column(Integer)
|
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):
|
class SchedulerSetting(Base):
|
||||||
|
|
|
||||||
32
backend/app/routes/setup.py
Normal file
32
backend/app/routes/setup.py
Normal 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)}
|
||||||
|
|
@ -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 logging
|
||||||
|
import secrets
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
|
@ -12,6 +14,10 @@ log = logging.getLogger("subfeed.state")
|
||||||
MAINTENANCE_BATCH_MIN = 100
|
MAINTENANCE_BATCH_MIN = 100
|
||||||
MAINTENANCE_BATCH_MAX = 100_000
|
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:
|
def _row(db: Session) -> AppState:
|
||||||
row = db.get(AppState, 1)
|
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")
|
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:
|
def get_maintenance_batch(db: Session) -> int:
|
||||||
"""Effective maintenance re-validation batch: the admin override if set, else the
|
"""Effective maintenance re-validation batch: the admin override if set, else the
|
||||||
env/config default."""
|
env/config default."""
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue