admin.py 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141
  1. """Admin panel — detached page at /admin plus /api/admin/* endpoints.
  2. All routes require the admin role. Settings are read/written through the ConfigStore
  3. (atomic write, comment-preserving, secrets never returned). Users and the denied message
  4. are ordinary config keys, so they go through the same single PUT /api/admin/config write
  5. path — one atomic write, no interleaving between "save users" and "save folders".
  6. """
  7. from __future__ import annotations
  8. import logging
  9. from flask import Blueprint, jsonify, render_template, request
  10. import logs
  11. import pipeline
  12. from auth import admin_required, current_user
  13. from config_store import ADMIN_EDITABLE, RESTART_KEYS, ConfigError
  14. from pps_client import PPSClient, PPSError
  15. log = logging.getLogger("pps.admin")
  16. def init_admin(store, queue) -> Blueprint:
  17. bp = Blueprint("admin", __name__)
  18. @bp.route("/admin")
  19. @admin_required
  20. def admin_page():
  21. return render_template("admin.html")
  22. @bp.route("/api/admin/config", methods=["GET", "PUT"])
  23. @admin_required
  24. def admin_config():
  25. if request.method == "GET":
  26. snap = store.snapshot()
  27. return jsonify(
  28. {
  29. "config": store.redacted(),
  30. "schema": _schema(snap),
  31. "version": snap.version,
  32. }
  33. )
  34. # PUT
  35. patch = request.get_json(silent=True) or {}
  36. try:
  37. result = store.apply(patch, actor=current_user()["email"])
  38. except ConfigError as exc:
  39. return jsonify({"errors": exc.errors}), 400
  40. return jsonify(
  41. {
  42. "version": result.version,
  43. "applied": result.applied,
  44. "restart_required": result.restart_required,
  45. "config": store.redacted(),
  46. }
  47. )
  48. @bp.route("/api/admin/pps-test", methods=["POST"])
  49. @admin_required
  50. def admin_pps_test():
  51. """Build a throwaway client from the PENDING form values and probe PPS.
  52. Never persists, never touches the live client. Blank password falls back to the
  53. stored one so the admin can test other fields without re-typing the secret.
  54. """
  55. body = request.get_json(silent=True) or {}
  56. snap = store.snapshot()
  57. p = snap.pps
  58. password = body.get("password") or p.get("password")
  59. try:
  60. client = PPSClient(
  61. base_url=body.get("base_url") or p["base_url"],
  62. username=body.get("username") or p["username"],
  63. password=password,
  64. verify_tls=body.get("verify_tls", p.get("verify_tls", False)),
  65. timeout=int(body.get("timeout") or p.get("timeout", 120)),
  66. client_cert=(body.get("client_cert") or p.get("client_cert")) or None,
  67. client_key=(body.get("client_key") or p.get("client_key")) or None,
  68. )
  69. q = snap.quarantine
  70. records = client.search(
  71. q.get("default_folder", "Quarantine"),
  72. q.get("list_query", "from=*"),
  73. limit=1,
  74. days_back=int(q.get("default_days_back", 7)),
  75. )
  76. return jsonify({"ok": True, "detail": f"Connected. {len(records)} record(s) sampled."})
  77. except PPSError as exc:
  78. return jsonify({"ok": False, "detail": str(exc)})
  79. except Exception as exc: # noqa: BLE001 - surface any client/build error to the admin
  80. return jsonify({"ok": False, "detail": f"{type(exc).__name__}: {exc}"})
  81. @bp.route("/api/admin/jobs")
  82. @admin_required
  83. def admin_jobs():
  84. limit = _int(request.args.get("limit"), 50, 1, 500)
  85. return jsonify({"jobs": queue.recent_jobs(limit=limit), "active": queue.has_active_jobs()})
  86. @bp.route("/api/admin/jobs/<int:job_id>/retry", methods=["POST"])
  87. @admin_required
  88. def admin_retry(job_id: int):
  89. ok = queue.retry(job_id)
  90. if not ok:
  91. return jsonify({"error": "job not found or not failed"}), 404
  92. return jsonify({"job_id": job_id, "status": "pending"})
  93. @bp.route("/api/admin/logs")
  94. @admin_required
  95. def admin_logs():
  96. source = request.args.get("source", "ops")
  97. if source not in logs.LOG_SOURCES:
  98. return jsonify({"error": f"unknown log source: {source}"}), 400
  99. lines = _int(request.args.get("lines"), 200, 1, logs.MAX_LINES)
  100. query = request.args.get("q") or None
  101. app_cfg = store.snapshot().app
  102. return jsonify(logs.read_log(source, app_cfg, lines=lines, query=query))
  103. return bp
  104. def _schema(snap) -> dict:
  105. """Static choices the admin UI needs to render generically."""
  106. return {
  107. "folders": list(snap.quarantine.get("folders", [])),
  108. "steps": list(pipeline.ALLOWED_STEPS),
  109. "roles": ["admin", "user"],
  110. "sort_fields": ["subject", "date", "from", "rcpt"],
  111. "sort_dirs": ["asc", "desc"],
  112. "auth_mode": snap.auth.get("mode", "static"),
  113. "editable_keys": sorted(ADMIN_EDITABLE),
  114. "restart_keys": sorted(RESTART_KEYS),
  115. "pipeline_sentence": pipeline.describe_pipeline(snap.quarantine),
  116. }
  117. def _int(raw, default: int, lo: int, hi: int) -> int:
  118. try:
  119. return max(lo, min(int(raw), hi))
  120. except (TypeError, ValueError):
  121. return default