Files
siftlode/backend/app/routes/setup.py
T
peter 99df44d7fb fix(api): mutations answer ok; route helpers flush and leave the commit to the handler
Two rows of the canonical-patterns table had drifted, found while closing the roadmap audit:

- 63 mutation responses carried no `ok` — `{"unsubscribed": true}`, `{"status": "ok"}`,
  `{"added": 3, …}` and so on. Each now leads with `"ok": true` (additive: every field a client
  reads is still there).
- Twelve route helpers committed on their own. Six were just the end of their handler's work and now
  flush: the playlist item add/remove and Watch later get-or-create, the Plex series/season bulk
  mark, and the invite decision + account activation — which an approval used to land in separate
  transactions. The other six commit on purpose (a token durable before its link is mailed, a
  single-use token burned as its own unit, the Google callback's tail, a background registration
  with its own session, the staged search ingest around YouTube calls), each listed with its reason.

`tests/test_route_conventions.py` holds both rules in code; CLAUDE.md's two rows point at it.
2026-09-19 18:21:06 +02:00

139 lines
5.6 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
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.orm import Session
from app import email as email_mod
from app import mailtext
from app import state, sysconfig
from app.utils import canonical_email
from app.auth import PASSWORD_MIN_LEN
from app.db import get_db
from app.models import User
from app.security import hash_password
from app.refusal import refused
from app.refusal import setting_refused
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 refused(409, "already_set_up")
if not state.check_setup_token(db, x_setup_token):
raise refused(403, "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 refused(400, "invalid_email")
if len(password) < PASSWORD_MIN_LEN:
raise refused(400, "weak_password", min=PASSWORD_MIN_LEN)
# Resolve canonically, not by the literal string (DX-17). The wizard can be re-run, and E9.5's
# whole point is that two spellings of one Gmail mailbox are one person — matching literally here
# would let a second run under the other spelling create the DUPLICATE admin row the epic exists
# to prevent. Every other lookup went canonical in S3; this one was outside that diff.
user = db.execute(
select(User).where(User.email_canonical == canonical_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 refused(400, "unknown_setting", key=key)
if value is None or value == "":
continue
try:
sysconfig.set_value(db, key, value)
except sysconfig.InvalidSetting as exc:
raise setting_refused(exc) from None
saved.append(key)
db.commit() # sysconfig.set_value only flushes now — persist the wizard's config in one commit
return {"ok": True, "saved": saved}
class SetupTestEmailIn(BaseModel):
to: str = ""
# The wizard's page language. No account exists yet on a fresh instance, so this is the only
# thing that can say what language to write in; anything unsupported falls back to English.
lang: str | None = None
@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 refused(400, "invalid_email")
if not email_mod.send_test(to, mailtext.normalize(body.lang)):
raise refused(400, "smtp_failed")
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 refused(400, "admin_first")
state.mark_configured(db)
return {"ok": True}