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/", 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/", 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"})