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
d7b7acb637
commit
283c4c9a1e
5 changed files with 179 additions and 3 deletions
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue