All checks were successful
Deploy / deploy (push) Successful in 30s
- Full FastAPI sync engine: master→replica document sync via paperless REST API - Web UI: dashboard, replicas, logs, settings (Jinja2 + HTMX + Pico CSS) - APScheduler background sync, SSE live log stream, Prometheus metrics - Fernet encryption for API tokens at rest - pngx.env credential file: written on save, pre-fills forms on load - Dockerfile with layer-cached uv build, Python healthcheck - docker-compose with host networking for Tailscale access - Gitea Actions workflow: version bump, secret injection, docker compose deploy Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
181 lines
6.0 KiB
Python
181 lines
6.0 KiB
Python
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()
|