import asyncio from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from sqlmodel import Session, select from ..config import get_config from ..crypto import decrypt, encrypt from ..database import get_session from ..models import Setting from ..scheduler import SETTINGS_DEFAULTS router = APIRouter(prefix="/api/settings", tags=["settings"]) ENCRYPTED_KEYS = {"master_token", "alert_target_token"} def _get_all_settings(session: Session) -> dict: rows = session.exec(select(Setting)).all() result = dict(SETTINGS_DEFAULTS) for row in rows: if row.value is not None: result[row.key] = row.value return result def _safe_settings(settings: dict) -> dict: """Return settings with encrypted values masked.""" out = dict(settings) for k in ENCRYPTED_KEYS: if out.get(k): out[k] = "••••••••" return out @router.get("") def get_settings(session: Session = Depends(get_session)): return _safe_settings(_get_all_settings(session)) class SettingsUpdate(BaseModel): master_url: str | None = None master_token: str | None = None sync_interval_seconds: int | None = None log_retention_days: int | None = None sync_cycle_timeout_seconds: int | None = None task_poll_timeout_seconds: int | None = None replica_suspend_threshold: int | None = None max_concurrent_requests: int | None = None alert_target_type: str | None = None alert_target_url: str | None = None alert_target_token: str | None = None alert_error_threshold: int | None = None alert_cooldown_seconds: int | None = None @router.put("") async def update_settings( body: SettingsUpdate, session: Session = Depends(get_session), ): config = get_config() updates = body.model_dump(exclude_none=True) # Validate master connection if URL or token changed current = _get_all_settings(session) if "master_url" in updates or "master_token" in updates: new_url = updates.get("master_url") or current.get("master_url", "") new_token = updates.get("master_token") if not new_token: enc = current.get("master_token", "") new_token = decrypt(enc, config.secret_key) if enc else "" if new_url and new_token: import httpx as _httpx try: async with _httpx.AsyncClient( headers={"Authorization": f"Token {new_token}"}, timeout=10.0, ) as _client: _r = await _client.get( new_url.rstrip("/") + "/api/documents/", params={"page_size": 1}, ) _r.raise_for_status() except Exception as _e: raise HTTPException( 422, detail=f"Master connection test failed: {_e}", ) # Capture plaintext values for envfile before encryption env_updates: dict[str, str] = {} if "master_url" in updates: env_updates["MASTER_URL"] = str(updates["master_url"]) if "master_token" in updates and updates["master_token"]: env_updates["MASTER_TOKEN"] = str(updates["master_token"]) # Persist updates for key, value in updates.items(): if key in ENCRYPTED_KEYS and value: value = encrypt(str(value), config.secret_key) setting = session.get(Setting, key) if setting: setting.value = str(value) else: setting = Setting(key=key, value=str(value)) session.add(setting) session.commit() if env_updates: from .. import envfile envfile.write(env_updates) # Reschedule if interval changed if "sync_interval_seconds" in updates: from ..scheduler import reschedule reschedule(int(updates["sync_interval_seconds"])) return _safe_settings(_get_all_settings(session)) class ConnectionTestRequest(BaseModel): url: str token: str = "" # blank = use saved master token @router.post("/test") async def test_connection( body: ConnectionTestRequest, session: Session = Depends(get_session), ): """Test a connection using the provided URL and token (does not save). If token is blank, falls back to the saved master_token.""" import httpx import time config = get_config() token = body.token.strip() if not token: settings = _get_all_settings(session) enc = settings.get("master_token", "") token = decrypt(enc, config.secret_key) if enc else "" if not token: return {"ok": False, "error": "No token provided and no saved token found", "latency_ms": 0, "doc_count": 0} t0 = time.monotonic() try: async with httpx.AsyncClient( headers={"Authorization": f"Token {token}"}, timeout=10.0, ) as client: r = await client.get( body.url.rstrip("/") + "/api/documents/", params={"page_size": 1}, ) r.raise_for_status() elapsed = int((time.monotonic() - t0) * 1000) data = r.json() return {"ok": True, "error": None, "latency_ms": elapsed, "doc_count": data.get("count", 0)} except Exception as e: return {"ok": False, "error": str(e), "latency_ms": 0, "doc_count": 0} @router.get("/status") async def master_status(session: Session = Depends(get_session)): """Test the currently saved master connection.""" config = get_config() settings = _get_all_settings(session) master_url = settings.get("master_url", "") master_token_enc = settings.get("master_token", "") if not master_url or not master_token_enc: return {"ok": False, "error": "Not configured", "latency_ms": 0, "doc_count": 0} master_token = decrypt(master_token_enc, config.secret_key) from ..sync.paperless import PaperlessClient sem = asyncio.Semaphore(1) async with PaperlessClient(master_url, master_token, sem) as client: return await client.test_connection()