"""Admin Configuration page API: read/edit the DB-overridable operational settings defined in app.sysconfig. Secret values (e.g. SMTP password) are write-only — never returned.""" import re from typing import Any from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from sqlalchemy.orm import Session from app import audit from app import email as email_mod from app import mailtext from app import sysconfig from app.audit import AuditAction from app.auth import admin_user from app.db import get_db from app.models import User router = APIRouter(prefix="/api/admin/config", tags=["admin"]) # Strip URL userinfo (user:pass@) so a non-secret setting that happens to embed credentials — # e.g. an authenticated egress proxy http://user:pass@host:port — never lands plaintext in the # audit trail. Non-secret values are shown live in the UI, but the log shouldn't retain creds. _URL_USERINFO = re.compile(r"(://)[^/@\s]+@") def _redact(v): return _URL_USERINFO.sub(r"\1***@", v) if isinstance(v, str) else v @router.get("") def get_config(_: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict: return sysconfig.describe(db) class ConfigSetIn(BaseModel): # A config value is arbitrarily typed (str/int/bool/…), validated downstream by # sysconfig.set_value. `value` defaults to a sentinel so a MISSING value stays a 400 # ("Missing 'value'") while an explicit `null` is a real, settable value. value: Any = None @router.patch("/{key}") def set_config( key: str, body: ConfigSetIn, admin: User = Depends(admin_user), db: Session = Depends(get_db), ) -> dict: s = sysconfig.spec(key) if s is None: raise HTTPException(status_code=404, detail="Unknown config key") if "value" not in body.model_fields_set: raise HTTPException(status_code=400, detail="Missing 'value'") if s.secret and not sysconfig.secrets_manageable(): raise HTTPException( status_code=400, detail="Set TOKEN_ENCRYPTION_KEY to store secrets in the database.", ) old = None if s.secret else sysconfig.get(db, key) try: sysconfig.set_value(db, key, body.value) except (TypeError, ValueError) as exc: raise HTTPException(status_code=400, detail=str(exc) or "Invalid value") # Never log a secret's value — only that it was changed, by whom. Non-secret values are logged # but with URL credentials redacted (a proxy/URL setting can embed user:pass). Skip the row when # a non-secret value didn't actually change (a no-op re-save shouldn't add trail noise); secrets # are write-only so we can't compare — always log. new = None if s.secret else sysconfig.get(db, key) if s.secret or new != old: audit.record( db, admin.id, AuditAction.CONFIG_SET, target_type="config", target_id=key, summary=f"{key} = (secret updated)" if s.secret else f"{key} = {_redact(new)}", before=None if s.secret else {"value": _redact(old)}, after=None if s.secret else {"value": _redact(new)}, ) db.commit() return sysconfig.describe(db) @router.delete("/{key}") def reset_config( key: str, admin: User = Depends(admin_user), db: Session = Depends(get_db), ) -> dict: s = sysconfig.spec(key) if s is None: raise HTTPException(status_code=404, detail="Unknown config key") old = None if s.secret else sysconfig.get(db, key) sysconfig.reset(db, key) audit.record( db, admin.id, AuditAction.CONFIG_RESET, target_type="config", target_id=key, summary=f"{key} → default", before=None if s.secret else {"value": _redact(old)}, ) db.commit() return sysconfig.describe(db) @router.post("/test-email") def send_test_email( user: User = Depends(admin_user), db: Session = Depends(get_db), ) -> dict: """Send a test email to the requesting admin using the current (DB or env) SMTP config — lets the admin verify the settings actually work.""" if not email_mod.email_enabled(): raise HTTPException(status_code=400, detail="SMTP is not configured.") sent = email_mod.send_test(user.email, mailtext.lang_for_user(user)) if not sent: raise HTTPException(status_code=422, detail="SMTP send failed — check the settings.") return {"sent": True, "to": user.email}