Files
FabledGrimoire/src/grimoire/routes/admin.py
T
bvandeusenandClaude Opus 5.5 1f5e963c35
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / Python lint (push) Successful in 1s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 1m42s
CI & Build / integration (push) Successful in 1m48s
CI & Build / Build & push image (push) Canceled after 22s
feat(6848): Settings restore rolls back, from the destination or a file, after a preview
The web restore no longer copies a backup in beside the live data.

- GET /api/admin/restore/sources lists the backups at the backup destination,
  newest first.
- POST /api/admin/restore/preview says what a restore would do, changing
  nothing: fresh or rollback, when the backup was taken, and counts now and
  after per kind. A backup that cannot apply here gets a 409 with the reason.
- POST /api/admin/restore?confirm=rollback runs backup_job.rollback_to_backup:
  safety backup, replace, then search rebuilt in the background.

Each takes ?source=<name> (a backup at the destination) or an upload. The
destination path exists because uploads are held to the request-size limit,
and a real instance's backup is far over it. backup_s3.get_file downloads
from a bucket. backup_destination.list_backups/open_backup only accept names
the scheduled backup writes, so a request cannot reach any other file.

Settings -> Data -> Restore from Backup opens RestoreBackupDialog. The dialog:
1. Pick a backup at the destination or a file.
2. Read the preview.
3. Type "roll back".
4. Run it. The dialog then says where the replaced state was saved, and sends
   the user to sign in again.

Closes #6840. Milestone 582, step 4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 20:06:07 -05:00

584 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import asyncio
import json
from quart import Blueprint, Response, g, jsonify, request
from grimoire.auth import admin_required, login_required, get_current_user_id
from grimoire.proxy import client_ip
from grimoire.services.auth import (
create_invitation,
delete_user,
is_registration_open,
list_pending_invitations,
list_users,
revoke_invitation,
set_registration_open,
)
from grimoire.services.backup import (
export_full_backup,
export_user_backup,
)
from grimoire.services.email import SMTP_SETTING_KEYS, get_base_url, get_smtp_config, is_smtp_configured, send_test_email
from grimoire.services.logging import get_logs, get_log_stats, log_audit
from grimoire.services.notifications import send_invitation_email
from grimoire.services.settings import (
SECRET_MASK,
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=client_ip(), 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"',
},
)
async def _restore_payload() -> tuple[dict | None, tuple | None]:
"""The backup a restore request names: `?source=<file>` at the backup
destination, or the uploaded file (the JSON download as
application/json, or the tar as the raw body; held to the request-size
limit, which is why the destination is the way in for a large one).
Returns (payload, None) or (None, an error response)."""
import io
import tempfile
from pathlib import Path
from grimoire.services import backup_destination as dest
from grimoire.services.backup_file import ArchiveError, read_archive
source = request.args.get("source")
try:
if source:
config = await dest.load_config()
secret, _ = await dest.load_secret()
with tempfile.TemporaryDirectory(prefix="grimoire-restore-") as staging:
path = await dest.open_backup(config, secret, source, Path(staging))
def load() -> dict:
with open(path, "rb") as f:
return read_archive(f)
data = await asyncio.to_thread(load)
elif request.mimetype == "application/json":
data = await request.get_json()
else:
data = read_archive(io.BytesIO(await request.get_data()))
except dest.DestinationError as exc:
return None, (jsonify({"error": str(exc)}), 400)
except ArchiveError as exc:
return None, (jsonify({"error": f"That file isn't a Grimoire backup: {exc}"}), 400)
if not isinstance(data, dict) or not data:
return None, (jsonify({"error": "No backup data provided"}), 400)
return data, None
@admin_bp.route("/restore/sources", methods=["GET"])
@admin_required
async def restore_sources():
"""The backups at the backup destination, newest first, to restore from
without uploading one."""
from grimoire.services import backup_destination as dest
config = await dest.load_config()
secret, _ = await dest.load_secret()
try:
backups = await asyncio.wait_for(dest.list_backups(config, secret), _BACKUP_CHECK_TIMEOUT)
except dest.DestinationError as exc:
return jsonify({"backups": [], "error": str(exc)})
except (TimeoutError, asyncio.TimeoutError):
return jsonify({"backups": [], "error": (
f"The destination didn't answer within {int(_BACKUP_CHECK_TIMEOUT)} seconds."
)})
return jsonify({"backups": [{"name": b.name, "size_bytes": b.size_bytes} for b in backups]})
@admin_bp.route("/restore/preview", methods=["POST"])
@admin_required
async def restore_preview():
"""What restoring this backup would do, changing nothing: fresh or
rollback, when it was taken, and the counts now and after. 409 with the
reason when it can't be restored here."""
from grimoire.services.backup import RestoreRefused, replace_preview
data, error = await _restore_payload()
if error:
return error
try:
return jsonify(await replace_preview(data))
except RestoreRefused as exc:
return jsonify({"error": str(exc)}), 409
@admin_bp.route("/restore", methods=["POST"])
@admin_required
async def restore():
"""Put the whole instance back to a full backup (services/backup.py
replace_from_backup), after backing up what it replaces. `?confirm=rollback`
is required: this removes everything made since the backup, for every
user. Every session ends, the caller's included; search is rebuilt in the
background."""
from grimoire.services import backup_job as job
from grimoire.services.backup import RestoreRefused
if request.args.get("confirm") != "rollback":
return jsonify({"error": "Restoring replaces the whole instance; confirm it first."}), 400
data, error = await _restore_payload()
if error:
return error
try:
result = await job.rollback_to_backup(data)
except RestoreRefused as exc:
return jsonify({"error": str(exc)}), 409
except job.BackupBusy:
return jsonify({"error": "A backup is running. Restore once it has finished."}), 409
job.start_reindex_after_restore()
uid = get_current_user_id()
await log_audit(
"restore", user_id=uid, username=g.user.username, ip_address=client_ip(),
details={"mode": result["mode"], "exported_at": result.get("exported_at"),
"safety_backup": result["safety_backup"], "stats": result["restored"]},
)
return jsonify({"status": "ok", **result})
@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=client_ip(), 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=client_ip(), 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"] = SECRET_MASK
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] == SECRET_MASK:
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=client_ip())
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=client_ip(), details={"recipient": recipient})
return jsonify({"status": "ok"})
except Exception as e:
return jsonify({"error": str(e)}), 500
# The forge CONFIG is per-user keyring rows (Settings → Git forges); what
# stays admin is the webhook secret, because the push endpoint
# is one URL per instance and authenticates deliveries, not users.
@admin_bp.route("/forge-webhook", methods=["GET"])
@admin_required
async def get_forge_webhook_settings():
from grimoire.config import Config
from grimoire.routes.webhooks import FORGE_WEBHOOK_SECRET_KEY
webhook_secret = (
await get_admin_setting(FORGE_WEBHOOK_SECRET_KEY, "")
or Config.FORGE_WEBHOOK_SECRET
)
return jsonify({
# Secrets never leave the server — the smtp_password convention:
# masked when set, empty when not.
"webhook_secret": SECRET_MASK if webhook_secret else "",
})
@admin_bp.route("/forge-webhook", methods=["PUT"])
@admin_required
async def update_forge_webhook_settings():
from grimoire.routes.webhooks import FORGE_WEBHOOK_SECRET_KEY
data = await request.get_json() or {}
uid = get_current_user_id()
webhook_secret = data.get("webhook_secret")
# The mask coming back means "unchanged" — the form round-trips what GET
# showed it, and storing the mask would silently break the integration.
if webhook_secret is not None and webhook_secret != SECRET_MASK:
await set_admin_setting(FORGE_WEBHOOK_SECRET_KEY, str(webhook_secret))
# The secret is deliberately absent from the audit detail.
await log_audit(
"forge_webhook_config", user_id=uid, username=g.user.username,
ip_address=client_ip(), details={},
)
return jsonify({"status": "ok"})
@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=client_ip(), 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 grimoire.services.db_maintenance import get_last_run
from grimoire.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 grimoire.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 grimoire.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=client_ip(), 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 grimoire.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=client_ip(),
)
summary = await run_maintenance()
return jsonify(summary)
# ── Scheduled backups ────────────────────────────────────────────────────────
# One dated, uncompressed tar per run to a folder or an S3 bucket — see
# services/backup_file.py for why it is shaped that way, backup_destination.py
# for where it goes, backup_job.py for when.
# How long "Test" waits on the destination. A folder on a hung NFS mount has
# no timeout of its own, and the request must come back either way.
_BACKUP_CHECK_TIMEOUT = 45.0
async def _backups_payload() -> dict:
from datetime import datetime, timezone
from grimoire.services import backup_destination as dest
from grimoire.services import backup_job as job
config = await dest.load_config()
secret, stored = await dest.load_secret()
runs = await job.list_runs()
return {
"config": config.to_dict(),
# Never the secret itself: whether one is kept, and whether this
# instance can still open it (it can't after a restore onto another
# SECRET_KEY, and then it has to be entered again).
"secret": {"set": stored, "readable": secret is not None},
"problems": config.problems(has_secret=secret is not None),
"running": job.is_running(),
"runs": runs,
**job.summarise(runs, enabled=config.enabled, now=datetime.now(timezone.utc)),
}
@admin_bp.route("/backups", methods=["GET"])
@admin_required
async def get_backups():
"""The backup destination, its schedule, and the recent runs."""
return jsonify(await _backups_payload())
@admin_bp.route("/backups", methods=["PUT"])
@admin_required
async def update_backups():
"""Change the destination or schedule. `s3_secret` replaces the stored
secret when it is sent and isn't the mask; `clear_s3_secret` removes it."""
from grimoire.services import backup_destination as dest
data = await request.get_json() or {}
try:
config = dest.apply_changes(await dest.load_config(), data)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
await dest.save_config(config)
secret = data.get("s3_secret")
if data.get("clear_s3_secret"):
await dest.save_secret("")
elif isinstance(secret, str) and secret and secret != SECRET_MASK:
await dest.save_secret(secret)
uid = get_current_user_id()
await log_audit(
"backup_config", user_id=uid, username=g.user.username,
ip_address=client_ip(),
details={**config.to_dict(), "secret_changed": bool(
data.get("clear_s3_secret") or (isinstance(secret, str) and secret and secret != SECRET_MASK)
)},
)
return jsonify(await _backups_payload())
@admin_bp.route("/backups/check", methods=["POST"])
@admin_required
async def check_backups_destination():
"""Ask the destination whether it would take a backup, without sending
one. The body may carry unsaved form values, so a destination can be
tested before it is saved; the stored secret is used unless one is sent."""
from grimoire.services import backup_destination as dest
data = await request.get_json() or {}
try:
config = dest.apply_changes(await dest.load_config(), data)
except ValueError as exc:
return jsonify({"ok": False, "message": str(exc)})
secret = data.get("s3_secret")
if not (isinstance(secret, str) and secret and secret != SECRET_MASK):
secret, _ = await dest.load_secret()
try:
message = await asyncio.wait_for(
dest.check_destination(config, secret), _BACKUP_CHECK_TIMEOUT,
)
except dest.DestinationError as exc:
return jsonify({"ok": False, "message": str(exc)})
except (TimeoutError, asyncio.TimeoutError):
return jsonify({"ok": False, "message": (
f"The destination didn't answer within {int(_BACKUP_CHECK_TIMEOUT)} seconds."
)})
return jsonify({"ok": True, "message": message})
@admin_bp.route("/backups/run", methods=["POST"])
@admin_required
async def run_backup_now():
"""Start a backup now. It runs in the background; the page follows it
through GET /backups (`running`, then the newest run)."""
from grimoire.services import backup_job as job
try:
job.start_backup_now()
except job.BackupBusy:
return jsonify({"error": "A backup is already running."}), 409
uid = get_current_user_id()
await log_audit("backup_run", user_id=uid, username=g.user.username,
ip_address=client_ip(), details={})
return jsonify({"status": "started"}), 202
@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=client_ip(),
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=client_ip(),
details={"invitation_id": invitation_id},
)
return jsonify({"status": "ok"})