| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141 |
- """Admin panel — detached page at /admin plus /api/admin/* endpoints.
- All routes require the admin role. Settings are read/written through the ConfigStore
- (atomic write, comment-preserving, secrets never returned). Users and the denied message
- are ordinary config keys, so they go through the same single PUT /api/admin/config write
- path — one atomic write, no interleaving between "save users" and "save folders".
- """
- from __future__ import annotations
- import logging
- from flask import Blueprint, jsonify, render_template, request
- import logs
- import pipeline
- from auth import admin_required, current_user
- from config_store import ADMIN_EDITABLE, RESTART_KEYS, ConfigError
- from pps_client import PPSClient, PPSError
- log = logging.getLogger("pps.admin")
- def init_admin(store, queue) -> Blueprint:
- bp = Blueprint("admin", __name__)
- @bp.route("/admin")
- @admin_required
- def admin_page():
- return render_template("admin.html")
- @bp.route("/api/admin/config", methods=["GET", "PUT"])
- @admin_required
- def admin_config():
- if request.method == "GET":
- snap = store.snapshot()
- return jsonify(
- {
- "config": store.redacted(),
- "schema": _schema(snap),
- "version": snap.version,
- }
- )
- # PUT
- patch = request.get_json(silent=True) or {}
- try:
- result = store.apply(patch, actor=current_user()["email"])
- except ConfigError as exc:
- return jsonify({"errors": exc.errors}), 400
- return jsonify(
- {
- "version": result.version,
- "applied": result.applied,
- "restart_required": result.restart_required,
- "config": store.redacted(),
- }
- )
- @bp.route("/api/admin/pps-test", methods=["POST"])
- @admin_required
- def admin_pps_test():
- """Build a throwaway client from the PENDING form values and probe PPS.
- Never persists, never touches the live client. Blank password falls back to the
- stored one so the admin can test other fields without re-typing the secret.
- """
- body = request.get_json(silent=True) or {}
- snap = store.snapshot()
- p = snap.pps
- password = body.get("password") or p.get("password")
- try:
- client = PPSClient(
- base_url=body.get("base_url") or p["base_url"],
- username=body.get("username") or p["username"],
- password=password,
- verify_tls=body.get("verify_tls", p.get("verify_tls", False)),
- timeout=int(body.get("timeout") or p.get("timeout", 120)),
- client_cert=(body.get("client_cert") or p.get("client_cert")) or None,
- client_key=(body.get("client_key") or p.get("client_key")) or None,
- )
- q = snap.quarantine
- records = client.search(
- q.get("default_folder", "Quarantine"),
- q.get("list_query", "from=*"),
- limit=1,
- days_back=int(q.get("default_days_back", 7)),
- )
- return jsonify({"ok": True, "detail": f"Connected. {len(records)} record(s) sampled."})
- except PPSError as exc:
- return jsonify({"ok": False, "detail": str(exc)})
- except Exception as exc: # noqa: BLE001 - surface any client/build error to the admin
- return jsonify({"ok": False, "detail": f"{type(exc).__name__}: {exc}"})
- @bp.route("/api/admin/jobs")
- @admin_required
- def admin_jobs():
- limit = _int(request.args.get("limit"), 50, 1, 500)
- return jsonify({"jobs": queue.recent_jobs(limit=limit), "active": queue.has_active_jobs()})
- @bp.route("/api/admin/jobs/<int:job_id>/retry", methods=["POST"])
- @admin_required
- def admin_retry(job_id: int):
- ok = queue.retry(job_id)
- if not ok:
- return jsonify({"error": "job not found or not failed"}), 404
- return jsonify({"job_id": job_id, "status": "pending"})
- @bp.route("/api/admin/logs")
- @admin_required
- def admin_logs():
- source = request.args.get("source", "ops")
- if source not in logs.LOG_SOURCES:
- return jsonify({"error": f"unknown log source: {source}"}), 400
- lines = _int(request.args.get("lines"), 200, 1, logs.MAX_LINES)
- query = request.args.get("q") or None
- app_cfg = store.snapshot().app
- return jsonify(logs.read_log(source, app_cfg, lines=lines, query=query))
- return bp
- def _schema(snap) -> dict:
- """Static choices the admin UI needs to render generically."""
- return {
- "folders": list(snap.quarantine.get("folders", [])),
- "steps": list(pipeline.ALLOWED_STEPS),
- "roles": ["admin", "user"],
- "sort_fields": ["subject", "date", "from", "rcpt"],
- "sort_dirs": ["asc", "desc"],
- "auth_mode": snap.auth.get("mode", "static"),
- "editable_keys": sorted(ADMIN_EDITABLE),
- "restart_keys": sorted(RESTART_KEYS),
- "pipeline_sentence": pipeline.describe_pipeline(snap.quarantine),
- }
- def _int(raw, default: int, lo: int, hi: int) -> int:
- try:
- return max(lo, min(int(raw), hi))
- except (TypeError, ValueError):
- return default
|