CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / TypeScript typecheck (push) Successful in 22s
CI & Build / integration (push) Successful in 22s
CI & Build / Python tests (push) Successful in 53s
CI & Build / Build & push image (push) Successful in 39s
Second adapter consumer. POST /api/webhooks/forge validates Gitea's
X-Gitea-Signature (HMAC-SHA256, constant-time; no secret configured =
the endpoint 404s out of existence), extracts changed/removed paths,
and flags matched snippets by writing verification.invalidated_by
{commit_sha, at, path, removed} — the existing attention vocabulary
extended, not a new flag: needs_attention includes it, both filter
dialects (Python + jsonpath SQL) include it in 'attention' and exclude
it from 'ok', and recording ANY fresh verdict clears it by construction
because compose_verification builds a new dict. Unverified snippets are
skipped (already in their own bucket); replayed deliveries at the same
head commit are no-ops; processing failures return 200 with a WARNING +
AppLog canary so the forge never marks deliveries failed and operators
never disable the hook over a transient (#2663's lesson).
Matching goes through repo BINDINGS: recorded location repos are
free-form names ('Scribe') that cannot address a forge, so a snippet
reaches its forge repo through its project's binding — which also fixes
step 5's pull-time resolution for every real record via the same
fallback. O(bindings + snippets-in-project + changed files).
Settings: webhook secret beside the forge config (masked, sentinel-
skipped, Docker-secret env channel, endpoint documented in the UI).
Tests: signature gate, payload parsing, path semantics, both filter
dialects extended in the drift-check guard file, and real-Postgres
end-to-end (flag lands, attention lists it, replay quiet, re-verify
clears, unbound repo untouched).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
424 lines
14 KiB
Python
424 lines
14 KiB
Python
import asyncio
|
||
import json
|
||
|
||
from quart import Blueprint, Response, g, jsonify, request
|
||
|
||
from scribe.auth import admin_required, login_required, get_current_user_id
|
||
from scribe.services.auth import (
|
||
create_invitation,
|
||
delete_user,
|
||
is_registration_open,
|
||
list_pending_invitations,
|
||
list_users,
|
||
revoke_invitation,
|
||
set_registration_open,
|
||
)
|
||
from scribe.services.backup import (
|
||
export_full_backup,
|
||
export_user_backup,
|
||
restore_full_backup,
|
||
)
|
||
from scribe.services.email import SMTP_SETTING_KEYS, get_base_url, get_smtp_config, is_smtp_configured, send_test_email
|
||
from scribe.services.forge import (
|
||
FORGE_BASE_URL_KEY,
|
||
FORGE_KIND_KEY,
|
||
FORGE_KINDS,
|
||
FORGE_TOKEN_KEY,
|
||
ForgeError,
|
||
forge_config,
|
||
get_forge,
|
||
)
|
||
from scribe.services.logging import get_logs, get_log_stats, log_audit
|
||
from scribe.services.notifications import send_invitation_email
|
||
from scribe.services.settings import (
|
||
get_admin_setting,
|
||
set_admin_setting,
|
||
set_setting,
|
||
set_settings_batch,
|
||
)
|
||
|
||
admin_bp = Blueprint("admin", __name__, url_prefix="/api/admin")
|
||
|
||
|
||
@admin_bp.route("/backup", methods=["GET"])
|
||
@login_required
|
||
async def backup():
|
||
uid = get_current_user_id()
|
||
scope = request.args.get("scope", "user")
|
||
|
||
if scope == "full":
|
||
# Full backup requires admin
|
||
if g.user.role != "admin":
|
||
return jsonify({"error": "Admin access required for full backup"}), 403
|
||
data = await export_full_backup()
|
||
else:
|
||
data = await export_user_backup(uid)
|
||
|
||
await log_audit("backup", user_id=uid, username=g.user.username, ip_address=request.remote_addr, details={"scope": scope})
|
||
return Response(
|
||
json.dumps(data, indent=2, default=str),
|
||
content_type="application/json",
|
||
headers={
|
||
"Content-Disposition": f'attachment; filename="fabled-backup-{scope}.json"',
|
||
},
|
||
)
|
||
|
||
|
||
@admin_bp.route("/restore", methods=["POST"])
|
||
@admin_required
|
||
async def restore():
|
||
data = await request.get_json()
|
||
if not data:
|
||
return jsonify({"error": "No backup data provided"}), 400
|
||
|
||
stats = await restore_full_backup(data)
|
||
uid = get_current_user_id()
|
||
await log_audit("restore", user_id=uid, username=g.user.username, ip_address=request.remote_addr, details={"stats": stats})
|
||
return jsonify({"status": "ok", "stats": stats})
|
||
|
||
|
||
@admin_bp.route("/users", methods=["GET"])
|
||
@admin_required
|
||
async def get_users():
|
||
users = await list_users()
|
||
return jsonify({"users": [u.to_dict() for u in users]})
|
||
|
||
|
||
@admin_bp.route("/users/<int:user_id>", methods=["DELETE"])
|
||
@admin_required
|
||
async def remove_user(user_id: int):
|
||
current_uid = get_current_user_id()
|
||
if user_id == current_uid:
|
||
return jsonify({"error": "Cannot delete your own account"}), 400
|
||
|
||
deleted = await delete_user(user_id)
|
||
if not deleted:
|
||
return jsonify({"error": "User not found"}), 404
|
||
await log_audit("user_delete", user_id=current_uid, username=g.user.username, ip_address=request.remote_addr, details={"deleted_user_id": user_id})
|
||
return jsonify({"status": "ok"})
|
||
|
||
|
||
@admin_bp.route("/registration", methods=["GET"])
|
||
@admin_required
|
||
async def get_registration():
|
||
open_status = await is_registration_open()
|
||
return jsonify({"open": open_status})
|
||
|
||
|
||
@admin_bp.route("/registration", methods=["PUT"])
|
||
@admin_required
|
||
async def toggle_registration():
|
||
data = await request.get_json()
|
||
open_val = data.get("open")
|
||
if open_val is None:
|
||
return jsonify({"error": "Missing 'open' field"}), 400
|
||
|
||
uid = get_current_user_id()
|
||
await set_registration_open(uid, bool(open_val))
|
||
await log_audit("registration_toggle", user_id=uid, username=g.user.username, ip_address=request.remote_addr, details={"open": bool(open_val)})
|
||
return jsonify({"status": "ok", "open": bool(open_val)})
|
||
|
||
|
||
@admin_bp.route("/smtp", methods=["GET"])
|
||
@admin_required
|
||
async def get_smtp():
|
||
config = await get_smtp_config()
|
||
# Mask password
|
||
if config.get("smtp_password"):
|
||
config["smtp_password"] = "********"
|
||
return jsonify(config)
|
||
|
||
|
||
@admin_bp.route("/smtp", methods=["PUT"])
|
||
@admin_required
|
||
async def update_smtp():
|
||
data = await request.get_json()
|
||
uid = get_current_user_id()
|
||
|
||
settings_to_save = {}
|
||
for key in SMTP_SETTING_KEYS:
|
||
if key in data:
|
||
# Skip password if it's the mask placeholder
|
||
if key == "smtp_password" and data[key] == "********":
|
||
continue
|
||
settings_to_save[key] = str(data[key])
|
||
|
||
if settings_to_save:
|
||
await set_settings_batch(uid, settings_to_save)
|
||
await log_audit("smtp_config", user_id=uid, username=g.user.username, ip_address=request.remote_addr)
|
||
return jsonify({"status": "ok"})
|
||
|
||
|
||
@admin_bp.route("/smtp/test", methods=["POST"])
|
||
@admin_required
|
||
async def test_smtp():
|
||
data = await request.get_json()
|
||
recipient = (data.get("recipient") or "").strip()
|
||
if not recipient:
|
||
return jsonify({"error": "Recipient email is required"}), 400
|
||
|
||
uid = get_current_user_id()
|
||
try:
|
||
await send_test_email(recipient)
|
||
await log_audit("smtp_test", user_id=uid, username=g.user.username, ip_address=request.remote_addr, details={"recipient": recipient})
|
||
return jsonify({"status": "ok"})
|
||
except Exception as e:
|
||
return jsonify({"error": str(e)}), 500
|
||
|
||
|
||
_TOKEN_MASK = "********"
|
||
|
||
|
||
@admin_bp.route("/forge", methods=["GET"])
|
||
@admin_required
|
||
async def get_forge_settings():
|
||
from scribe.config import Config
|
||
from scribe.routes.webhooks import FORGE_WEBHOOK_SECRET_KEY
|
||
|
||
cfg = await forge_config()
|
||
webhook_secret = (
|
||
await get_admin_setting(FORGE_WEBHOOK_SECRET_KEY, "")
|
||
or Config.FORGE_WEBHOOK_SECRET
|
||
)
|
||
return jsonify({
|
||
"kind": cfg["kind"],
|
||
"base_url": cfg["base_url"],
|
||
# Secrets never leave the server — the smtp_password convention:
|
||
# masked when set, empty when not.
|
||
"token": _TOKEN_MASK if cfg["token"] else "",
|
||
"webhook_secret": _TOKEN_MASK if webhook_secret else "",
|
||
"configured": bool(await get_forge()),
|
||
"kinds": list(FORGE_KINDS),
|
||
})
|
||
|
||
|
||
@admin_bp.route("/forge", methods=["PUT"])
|
||
@admin_required
|
||
async def update_forge_settings():
|
||
data = await request.get_json() or {}
|
||
uid = get_current_user_id()
|
||
|
||
kind = str(data.get("kind", "")).strip().lower()
|
||
if kind and kind not in FORGE_KINDS:
|
||
return jsonify({"error": f"Unknown forge kind {kind!r}"}), 400
|
||
base_url = str(data.get("base_url", "")).strip().rstrip("/")
|
||
if base_url and not base_url.startswith(("http://", "https://")):
|
||
return jsonify({"error": "Forge base URL must use http or https"}), 400
|
||
|
||
await set_admin_setting(FORGE_KIND_KEY, kind)
|
||
await set_admin_setting(FORGE_BASE_URL_KEY, base_url)
|
||
token = data.get("token")
|
||
# The mask coming back means "unchanged" — the form round-trips what GET
|
||
# showed it, and storing the mask would silently break the integration.
|
||
if token is not None and token != _TOKEN_MASK:
|
||
await set_admin_setting(FORGE_TOKEN_KEY, str(token))
|
||
from scribe.routes.webhooks import FORGE_WEBHOOK_SECRET_KEY
|
||
|
||
webhook_secret = data.get("webhook_secret")
|
||
if webhook_secret is not None and webhook_secret != _TOKEN_MASK:
|
||
await set_admin_setting(FORGE_WEBHOOK_SECRET_KEY, str(webhook_secret))
|
||
# The token is deliberately absent from the audit detail.
|
||
await log_audit(
|
||
"forge_config", user_id=uid, username=g.user.username,
|
||
ip_address=request.remote_addr,
|
||
details={"kind": kind, "base_url": base_url},
|
||
)
|
||
return jsonify({"status": "ok"})
|
||
|
||
|
||
@admin_bp.route("/forge/test", methods=["POST"])
|
||
@admin_required
|
||
async def test_forge():
|
||
"""Probe the SAVED forge config: reachability and token acceptance in one
|
||
press, so a misconfiguration is visible now rather than as silent
|
||
fallbacks later (#2663's lesson, applied to integrations)."""
|
||
uid = get_current_user_id()
|
||
forge = await get_forge()
|
||
if forge is None:
|
||
return jsonify({"error": "Forge is not configured — save kind, base URL and token first"}), 400
|
||
try:
|
||
result = await forge.check()
|
||
except ForgeError as e:
|
||
return jsonify({"error": str(e)}), 502
|
||
await log_audit(
|
||
"forge_test", user_id=uid, username=g.user.username,
|
||
ip_address=request.remote_addr,
|
||
details={"ok": True, "username": result.get("username", "")},
|
||
)
|
||
return jsonify(result)
|
||
|
||
|
||
@admin_bp.route("/logs", methods=["GET"])
|
||
@admin_required
|
||
async def list_logs():
|
||
category = request.args.get("category")
|
||
user_id = request.args.get("user_id", type=int)
|
||
search = request.args.get("search")
|
||
date_from = request.args.get("date_from")
|
||
date_to = request.args.get("date_to")
|
||
limit = request.args.get("limit", 50, type=int)
|
||
offset = request.args.get("offset", 0, type=int)
|
||
|
||
logs, total = await get_logs(
|
||
category=category,
|
||
user_id=user_id,
|
||
search=search,
|
||
date_from=date_from,
|
||
date_to=date_to,
|
||
limit=min(limit, 200),
|
||
offset=offset,
|
||
)
|
||
return jsonify({"logs": logs, "total": total})
|
||
|
||
|
||
@admin_bp.route("/logs/stats", methods=["GET"])
|
||
@admin_required
|
||
async def log_stats():
|
||
stats = await get_log_stats()
|
||
return jsonify(stats)
|
||
|
||
|
||
@admin_bp.route("/base-url", methods=["GET"])
|
||
@admin_required
|
||
async def get_base_url_setting():
|
||
base_url = await get_base_url()
|
||
return jsonify({"base_url": base_url})
|
||
|
||
|
||
@admin_bp.route("/base-url", methods=["PUT"])
|
||
@admin_required
|
||
async def update_base_url():
|
||
data = await request.get_json()
|
||
url = (data.get("base_url") or "").strip().rstrip("/")
|
||
if url:
|
||
scheme = url.split("://")[0].lower() if "://" in url else ""
|
||
if scheme not in ("http", "https"):
|
||
return jsonify({"error": "Base URL must use http or https"}), 400
|
||
uid = get_current_user_id()
|
||
await set_setting(uid, "base_url", url)
|
||
await log_audit("base_url_config", user_id=uid, username=g.user.username, ip_address=request.remote_addr, details={"base_url": url})
|
||
return jsonify({"status": "ok"})
|
||
|
||
|
||
@admin_bp.route("/db-maintenance", methods=["GET"])
|
||
@admin_required
|
||
async def get_db_maintenance():
|
||
"""Current DB-maintenance config + the last run's summary."""
|
||
from scribe.services.db_maintenance import get_last_run
|
||
from scribe.services.db_maintenance_scheduler import (
|
||
get_maintenance_hour,
|
||
is_maintenance_enabled,
|
||
)
|
||
return jsonify({
|
||
"enabled": await is_maintenance_enabled(),
|
||
"hour": await get_maintenance_hour(),
|
||
"last_run": await get_last_run(),
|
||
})
|
||
|
||
|
||
@admin_bp.route("/db-maintenance/health", methods=["GET"])
|
||
@admin_required
|
||
async def get_db_maintenance_health():
|
||
"""Read-only per-table bloat/health stats from Postgres + total DB size."""
|
||
from scribe.services.db_maintenance import get_table_health
|
||
return jsonify(await get_table_health())
|
||
|
||
|
||
@admin_bp.route("/db-maintenance", methods=["PUT"])
|
||
@admin_required
|
||
async def update_db_maintenance():
|
||
"""Set whether scheduled maintenance runs and at what UTC hour."""
|
||
from scribe.services.db_maintenance_scheduler import reschedule_db_maintenance
|
||
data = await request.get_json() or {}
|
||
enabled = bool(data.get("enabled", True))
|
||
try:
|
||
hour = int(data.get("hour", 4))
|
||
except (TypeError, ValueError):
|
||
return jsonify({"error": "hour must be an integer 0–23"}), 400
|
||
if not 0 <= hour <= 23:
|
||
return jsonify({"error": "hour must be between 0 and 23"}), 400
|
||
|
||
await set_admin_setting("db_maintenance_enabled", "true" if enabled else "false")
|
||
await set_admin_setting("db_maintenance_hour", str(hour))
|
||
reschedule_db_maintenance(hour)
|
||
uid = get_current_user_id()
|
||
await log_audit(
|
||
"db_maintenance_config", user_id=uid, username=g.user.username,
|
||
ip_address=request.remote_addr, details={"enabled": enabled, "hour": hour},
|
||
)
|
||
return jsonify({"status": "ok", "enabled": enabled, "hour": hour})
|
||
|
||
|
||
@admin_bp.route("/db-maintenance/run", methods=["POST"])
|
||
@admin_required
|
||
async def run_db_maintenance_now():
|
||
"""Run a VACUUM (ANALYZE) sweep immediately and return its summary."""
|
||
from scribe.services.db_maintenance import run_maintenance
|
||
uid = get_current_user_id()
|
||
await log_audit(
|
||
"db_maintenance_run", user_id=uid, username=g.user.username,
|
||
ip_address=request.remote_addr,
|
||
)
|
||
summary = await run_maintenance()
|
||
return jsonify(summary)
|
||
|
||
|
||
@admin_bp.route("/invitations", methods=["POST"])
|
||
@admin_required
|
||
async def create_invite():
|
||
data = await request.get_json()
|
||
email = (data.get("email") or "").strip().lower()
|
||
if not email:
|
||
return jsonify({"error": "Email is required"}), 400
|
||
|
||
if not await is_smtp_configured():
|
||
return jsonify({"error": "SMTP is not configured. Configure email settings first."}), 400
|
||
|
||
uid = get_current_user_id()
|
||
raw_token = await create_invitation(email, uid)
|
||
base_url = await get_base_url()
|
||
invite_url = f"{base_url}/register-invite?token={raw_token}"
|
||
asyncio.create_task(send_invitation_email(email, invite_url, g.user.username))
|
||
await log_audit(
|
||
"invitation_created",
|
||
user_id=uid,
|
||
username=g.user.username,
|
||
ip_address=request.remote_addr,
|
||
details={"invited_email": email},
|
||
)
|
||
return jsonify({"status": "ok"}), 201
|
||
|
||
|
||
@admin_bp.route("/invitations", methods=["GET"])
|
||
@admin_required
|
||
async def get_invitations():
|
||
invitations = await list_pending_invitations()
|
||
return jsonify({
|
||
"invitations": [
|
||
{
|
||
"id": inv.id,
|
||
"email": inv.email,
|
||
"created_at": inv.created_at.isoformat(),
|
||
"expires_at": inv.expires_at.isoformat(),
|
||
}
|
||
for inv in invitations
|
||
]
|
||
})
|
||
|
||
|
||
@admin_bp.route("/invitations/<int:invitation_id>", methods=["DELETE"])
|
||
@admin_required
|
||
async def delete_invitation(invitation_id: int):
|
||
uid = get_current_user_id()
|
||
revoked = await revoke_invitation(invitation_id)
|
||
if not revoked:
|
||
return jsonify({"error": "Invitation not found or already used"}), 404
|
||
await log_audit(
|
||
"invitation_revoked",
|
||
user_id=uid,
|
||
username=g.user.username,
|
||
ip_address=request.remote_addr,
|
||
details={"invitation_id": invitation_id},
|
||
)
|
||
return jsonify({"status": "ok"})
|