b255a0f90e
Renames src/fabledassistant -> src/scribe and all imports, plus the default DB name and DB user/password (fabled -> scribe) in config + compose. 952 refs / 154 files. Reverses the old 'internal name stays fabledassistant' convention. Code-only: live databases are still physically named 'fabledassistant'. Deployed environments must set POSTGRES_DB / POSTGRES_USER (or rename the DB) since the defaults now resolve to 'scribe'. Repo (FabledScribe), git host (fabledsword), MCP (fabled-git) and the image name (fabledscribe) are intentionally unchanged. ruff check src/ clean locally; CI (typecheck + pytest) is the gate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
265 lines
8.5 KiB
Python
265 lines
8.5 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.logging import get_logs, get_log_stats, log_audit
|
|
from scribe.services.notifications import send_invitation_email
|
|
from scribe.services.settings import 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
|
|
|
|
|
|
@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("/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"})
|