Files
siftlode/backend/app/routes/setup.py
T
peter 3f047bbad1 refactor(config): config/state mutators flush, callers commit (E3 S3, B-core-10)
Restores CLAUDE.md's canonical transaction rule ("the handler commits; helpers
flush; only quota owns its session") on the config path: sysconfig.set_value /
sysconfig.reset and state.set_sync_paused now flush instead of committing, so a
change lands in ONE transaction with the audit row the route records for it —
previously the helper's internal commit split them, and a crash between left the
mutation persisted with no audit trail.

All callers already commit (the config PATCH/DELETE + audit, the sync pause/resume
+ audit, the scheduler maintenance PATCH); the setup wizard, which relied on the
internal commit, gets an explicit db.commit() after its loop. apply_interval is
deliberately left committing — it performs a live APScheduler reschedule that must
follow the DB persist, so it owns its own unit of work (a legitimate exception,
like the download service transitions). New test locks the flush-not-commit
contract (a rollback before the caller commits discards the change). 368 pass.
2026-08-18 23:46:48 +02:00

127 lines
5.1 KiB
Python

"""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).
"""
from typing import Any
from fastapi import APIRouter, Depends, Header, HTTPException
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.orm import Session
from app import email as email_mod
from app import state, sysconfig
from app.auth import PASSWORD_MIN_LEN
from app.db import get_db
from app.models import User
from app.security import hash_password
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)}
@router.get("/info")
def setup_info(db: Session = Depends(require_setup)) -> dict:
"""Capabilities the wizard adapts to: whether secrets (Google creds, SMTP password) can be
stored — they need TOKEN_ENCRYPTION_KEY (set in env), so on a box without it the wizard hides
those steps and the operator stays on email+password only."""
return {"secrets_manageable": sysconfig.secrets_manageable()}
class SetupAdminIn(BaseModel):
# Both default to "" so a missing field stays the wizard's own 400 ("Enter a valid email" /
# password too short) rather than a 422. `password: str` also stops a non-string value from
# reaching argon2 (which would 500 in len()/hash_password) — it's now a clean 422.
email: str = ""
password: str = ""
@router.post("/admin")
def setup_admin(body: SetupAdminIn, db: Session = Depends(require_setup)) -> dict:
"""Create (or update) the first administrator. Active + verified immediately — the operator is
configuring the instance, so there's no email-verification / approval gate here."""
email = body.email.strip().lower()
password = body.password
if "@" not in email:
raise HTTPException(status_code=400, detail="Enter a valid email address.")
if len(password) < PASSWORD_MIN_LEN:
raise HTTPException(
status_code=400, detail=f"Password must be at least {PASSWORD_MIN_LEN} characters."
)
user = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
if user is None:
user = User(email=email, role="admin")
db.add(user)
user.password_hash = hash_password(password)
user.role = "admin"
user.is_active = True
user.email_verified = True
db.commit()
return {"ok": True, "email": email}
@router.post("/config")
def setup_config(settings_map: dict[str, Any], db: Session = Depends(require_setup)) -> dict:
"""Persist provided DB-backed settings (Google creds, SMTP, quota…). Keys must be in the
sysconfig registry; empty values are skipped (left at the default). Genuinely dynamic
key→value map, so it stays a typed dict rather than a fixed model."""
saved: list[str] = []
for key, value in settings_map.items():
if sysconfig.spec(key) is None:
raise HTTPException(status_code=400, detail=f"Unknown setting: {key}")
if value is None or value == "":
continue
try:
sysconfig.set_value(db, key, value)
except (ValueError, RuntimeError) as exc:
raise HTTPException(status_code=400, detail=str(exc))
saved.append(key)
db.commit() # sysconfig.set_value only flushes now — persist the wizard's config in one commit
return {"saved": saved}
class SetupTestEmailIn(BaseModel):
to: str = ""
@router.post("/test-email")
def setup_test_email(body: SetupTestEmailIn, db: Session = Depends(require_setup)) -> dict:
"""Send a test email to confirm the SMTP settings the wizard just saved."""
to = body.to.strip()
if "@" not in to:
raise HTTPException(status_code=400, detail="Enter a valid email address.")
if not email_mod.send_test(to):
raise HTTPException(status_code=400, detail="Couldn't send — check the SMTP settings.")
return {"ok": True}
@router.post("/finish")
def setup_finish(db: Session = Depends(require_setup)) -> dict:
"""Complete setup: require that an admin exists, then mark the instance configured (which
invalidates the setup token and disables the wizard)."""
has_admin = db.execute(select(User).where(User.role == "admin")).first() is not None
if not has_admin:
raise HTTPException(status_code=400, detail="Create the admin account first.")
state.mark_configured(db)
return {"ok": True}