Files
Magent/backend/app/routers/admin.py

606 lines
21 KiB
Python

from typing import Any, Dict, List
from datetime import datetime, timedelta, timezone
import secrets
import os
from fastapi import APIRouter, HTTPException, Depends, UploadFile, File
from ..auth import require_admin
from ..config import settings as env_settings
from ..db import (
delete_setting,
get_all_users,
get_invite_profile,
list_invite_profiles,
create_invite_profile,
create_invite,
list_invites,
disable_invite,
delete_invite,
delete_user,
get_all_contacts,
get_request_cache_overview,
save_announcement,
get_settings_overrides,
get_user_by_username,
set_setting,
set_user_blocked,
set_user_password,
set_user_role,
run_integrity_check,
vacuum_db,
clear_requests_cache,
clear_history,
cleanup_history,
)
from ..runtime import get_runtime_settings
from ..clients.sonarr import SonarrClient
from ..clients.radarr import RadarrClient
from ..clients.jellyfin import JellyfinClient
from ..clients.jellyseerr import JellyseerrClient
from ..services.jellyfin_sync import sync_jellyfin_users
from ..services.jellyseerr_sync import sync_jellyseerr_users
import logging
from ..logging_config import configure_logging
from ..routers import requests as requests_router
from ..routers.branding import save_branding_image
from ..services.notifications import send_notification
router = APIRouter(prefix="/admin", tags=["admin"], dependencies=[Depends(require_admin)])
logger = logging.getLogger(__name__)
SENSITIVE_KEYS = {
"jellyseerr_api_key",
"jellyfin_api_key",
"sonarr_api_key",
"radarr_api_key",
"prowlarr_api_key",
"qbittorrent_password",
"smtp_password",
"hcaptcha_secret_key",
"recaptcha_secret_key",
"turnstile_secret_key",
"telegram_bot_token",
"matrix_password",
"matrix_access_token",
"pushover_token",
"pushover_user_key",
"pushbullet_token",
"gotify_token",
}
SETTING_KEYS: List[str] = [
"jellyseerr_base_url",
"jellyseerr_api_key",
"jellyfin_base_url",
"jellyfin_api_key",
"jellyfin_public_url",
"jellyfin_sync_to_arr",
"artwork_cache_mode",
"sonarr_base_url",
"sonarr_api_key",
"sonarr_quality_profile_id",
"sonarr_root_folder",
"radarr_base_url",
"radarr_api_key",
"radarr_quality_profile_id",
"radarr_root_folder",
"prowlarr_base_url",
"prowlarr_api_key",
"qbittorrent_base_url",
"qbittorrent_username",
"qbittorrent_password",
"log_level",
"log_file",
"requests_sync_ttl_minutes",
"requests_poll_interval_seconds",
"requests_delta_sync_interval_minutes",
"requests_full_sync_time",
"requests_cleanup_time",
"requests_cleanup_days",
"requests_data_source",
"invites_enabled",
"invites_require_captcha",
"invite_default_profile_id",
"signup_allow_referrals",
"referral_default_uses",
"password_min_length",
"password_require_upper",
"password_require_lower",
"password_require_number",
"password_require_symbol",
"password_reset_enabled",
"captcha_provider",
"hcaptcha_site_key",
"hcaptcha_secret_key",
"recaptcha_site_key",
"recaptcha_secret_key",
"turnstile_site_key",
"turnstile_secret_key",
"smtp_host",
"smtp_port",
"smtp_user",
"smtp_password",
"smtp_from",
"smtp_tls",
"smtp_starttls",
"notify_email_enabled",
"notify_discord_enabled",
"notify_telegram_enabled",
"notify_matrix_enabled",
"notify_pushover_enabled",
"notify_pushbullet_enabled",
"notify_gotify_enabled",
"notify_ntfy_enabled",
"telegram_bot_token",
"telegram_chat_id",
"matrix_homeserver",
"matrix_user",
"matrix_password",
"matrix_access_token",
"matrix_room_id",
"pushover_token",
"pushover_user_key",
"pushbullet_token",
"gotify_url",
"gotify_token",
"ntfy_url",
"ntfy_topic",
"expiry_default_days",
"expiry_default_action",
"expiry_warning_days",
"expiry_check_interval_minutes",
"jellyseerr_sync_users",
"jellyseerr_sync_interval_minutes",
]
def _normalize_root_folders(folders: Any) -> List[Dict[str, Any]]:
if not isinstance(folders, list):
return []
results = []
for folder in folders:
if not isinstance(folder, dict):
continue
folder_id = folder.get("id")
path = folder.get("path")
if folder_id is None or path is None:
continue
results.append({"id": folder_id, "path": path, "label": path})
return results
def _normalize_quality_profiles(profiles: Any) -> List[Dict[str, Any]]:
if not isinstance(profiles, list):
return []
results = []
for profile in profiles:
if not isinstance(profile, dict):
continue
profile_id = profile.get("id")
name = profile.get("name")
if profile_id is None or name is None:
continue
results.append({"id": profile_id, "name": name, "label": name})
return results
@router.get("/settings")
async def list_settings() -> Dict[str, Any]:
overrides = get_settings_overrides()
results = []
for key in SETTING_KEYS:
override_present = key in overrides
value = overrides.get(key) if override_present else getattr(env_settings, key)
is_set = value is not None and str(value).strip() != ""
sensitive = key in SENSITIVE_KEYS
results.append(
{
"key": key,
"value": None if sensitive else value,
"isSet": is_set,
"source": "db" if override_present else ("env" if is_set else "unset"),
"sensitive": sensitive,
}
)
return {"settings": results}
@router.put("/settings")
async def update_settings(payload: Dict[str, Any]) -> Dict[str, Any]:
updates = 0
touched_logging = False
for key, value in payload.items():
if key not in SETTING_KEYS:
raise HTTPException(status_code=400, detail=f"Unknown setting: {key}")
if value is None:
continue
if isinstance(value, str) and value.strip() == "":
delete_setting(key)
updates += 1
continue
set_setting(key, str(value))
updates += 1
if key in {"log_level", "log_file"}:
touched_logging = True
if touched_logging:
runtime = get_runtime_settings()
configure_logging(runtime.log_level, runtime.log_file)
return {"status": "ok", "updated": updates}
@router.get("/sonarr/options")
async def sonarr_options() -> Dict[str, Any]:
runtime = get_runtime_settings()
client = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
if not client.configured():
raise HTTPException(status_code=400, detail="Sonarr not configured")
root_folders = await client.get_root_folders()
profiles = await client.get_quality_profiles()
return {
"rootFolders": _normalize_root_folders(root_folders),
"qualityProfiles": _normalize_quality_profiles(profiles),
}
@router.get("/radarr/options")
async def radarr_options() -> Dict[str, Any]:
runtime = get_runtime_settings()
client = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
if not client.configured():
raise HTTPException(status_code=400, detail="Radarr not configured")
root_folders = await client.get_root_folders()
profiles = await client.get_quality_profiles()
return {
"rootFolders": _normalize_root_folders(root_folders),
"qualityProfiles": _normalize_quality_profiles(profiles),
}
@router.get("/jellyfin/users")
async def jellyfin_users() -> Dict[str, Any]:
runtime = get_runtime_settings()
client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
if not client.configured():
raise HTTPException(status_code=400, detail="Jellyfin not configured")
users = await client.get_users()
if not isinstance(users, list):
return {"users": []}
results = []
for user in users:
if not isinstance(user, dict):
continue
results.append(
{
"id": user.get("Id"),
"name": user.get("Name"),
"hasPassword": user.get("HasPassword"),
"lastLoginDate": user.get("LastLoginDate"),
}
)
return {"users": results}
@router.post("/jellyfin/users/sync")
async def jellyfin_users_sync() -> Dict[str, Any]:
imported = await sync_jellyfin_users()
return {"status": "ok", "imported": imported}
@router.post("/jellyseerr/users/sync")
async def jellyseerr_users_sync() -> Dict[str, Any]:
imported = await sync_jellyseerr_users()
return {"status": "ok", "imported": imported}
@router.post("/requests/sync")
async def requests_sync() -> Dict[str, Any]:
runtime = get_runtime_settings()
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
if not client.configured():
raise HTTPException(status_code=400, detail="Jellyseerr not configured")
state = await requests_router.start_requests_sync(
runtime.jellyseerr_base_url, runtime.jellyseerr_api_key
)
logger.info("Admin triggered requests sync: status=%s", state.get("status"))
return {"status": "ok", "sync": state}
@router.post("/requests/sync/delta")
async def requests_sync_delta() -> Dict[str, Any]:
runtime = get_runtime_settings()
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
if not client.configured():
raise HTTPException(status_code=400, detail="Jellyseerr not configured")
state = await requests_router.start_requests_delta_sync(
runtime.jellyseerr_base_url, runtime.jellyseerr_api_key
)
logger.info("Admin triggered delta requests sync: status=%s", state.get("status"))
return {"status": "ok", "sync": state}
@router.post("/requests/artwork/prefetch")
async def requests_artwork_prefetch() -> Dict[str, Any]:
runtime = get_runtime_settings()
state = await requests_router.start_artwork_prefetch(
runtime.jellyseerr_base_url, runtime.jellyseerr_api_key
)
logger.info("Admin triggered artwork prefetch: status=%s", state.get("status"))
return {"status": "ok", "prefetch": state}
@router.get("/requests/artwork/status")
async def requests_artwork_status() -> Dict[str, Any]:
return {"status": "ok", "prefetch": requests_router.get_artwork_prefetch_state()}
@router.get("/requests/sync/status")
async def requests_sync_status() -> Dict[str, Any]:
return {"status": "ok", "sync": requests_router.get_requests_sync_state()}
@router.get("/logs")
async def read_logs(lines: int = 200) -> Dict[str, Any]:
runtime = get_runtime_settings()
log_file = runtime.log_file
if not log_file:
raise HTTPException(status_code=400, detail="Log file not configured")
if not os.path.isabs(log_file):
log_file = os.path.join(os.getcwd(), log_file)
if not os.path.exists(log_file):
raise HTTPException(status_code=404, detail="Log file not found")
lines = max(1, min(lines, 1000))
from collections import deque
with open(log_file, "r", encoding="utf-8", errors="replace") as handle:
tail = deque(handle, maxlen=lines)
return {"lines": list(tail)}
@router.get("/requests/cache")
async def requests_cache(limit: int = 50) -> Dict[str, Any]:
return {"rows": get_request_cache_overview(limit)}
@router.post("/branding/logo")
async def upload_branding_logo(file: UploadFile = File(...)) -> Dict[str, Any]:
return await save_branding_image(file)
@router.post("/maintenance/repair")
async def repair_database() -> Dict[str, Any]:
result = run_integrity_check()
vacuum_db()
logger.info("Database repair executed: integrity_check=%s", result)
return {"status": "ok", "integrity": result}
@router.post("/maintenance/flush")
async def flush_database() -> Dict[str, Any]:
cleared = clear_requests_cache()
history = clear_history()
delete_setting("requests_sync_last_at")
logger.warning("Database flush executed: requests_cache=%s history=%s", cleared, history)
return {"status": "ok", "requestsCleared": cleared, "historyCleared": history}
@router.post("/maintenance/cleanup")
async def cleanup_database(days: int = 90) -> Dict[str, Any]:
result = cleanup_history(days)
logger.info("Database cleanup executed: days=%s result=%s", days, result)
return {"status": "ok", "days": days, "cleared": result}
@router.post("/maintenance/logs/clear")
async def clear_logs() -> Dict[str, Any]:
runtime = get_runtime_settings()
log_file = runtime.log_file
if not log_file:
raise HTTPException(status_code=400, detail="Log file not configured")
if not os.path.isabs(log_file):
log_file = os.path.join(os.getcwd(), log_file)
try:
os.makedirs(os.path.dirname(log_file), exist_ok=True)
with open(log_file, "w", encoding="utf-8"):
pass
except OSError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
logger.info("Log file cleared")
return {"status": "ok"}
@router.get("/users")
async def list_users() -> Dict[str, Any]:
users = get_all_users()
return {"users": users}
@router.post("/users/{username}/block")
async def block_user(username: str) -> Dict[str, Any]:
set_user_blocked(username, True)
return {"status": "ok", "username": username, "blocked": True}
@router.post("/users/{username}/unblock")
async def unblock_user(username: str) -> Dict[str, Any]:
set_user_blocked(username, False)
return {"status": "ok", "username": username, "blocked": False}
@router.post("/users/{username}/role")
async def update_user_role(username: str, payload: Dict[str, Any]) -> Dict[str, Any]:
role = payload.get("role")
if role not in {"admin", "user"}:
raise HTTPException(status_code=400, detail="Invalid role")
set_user_role(username, role)
return {"status": "ok", "username": username, "role": role}
@router.post("/users/{username}/password")
async def update_user_password(username: str, payload: Dict[str, Any]) -> Dict[str, Any]:
new_password = payload.get("password") if isinstance(payload, dict) else None
if not isinstance(new_password, str) or len(new_password.strip()) < 8:
raise HTTPException(status_code=400, detail="Password must be at least 8 characters.")
user = get_user_by_username(username)
if not user:
raise HTTPException(status_code=404, detail="User not found")
if user.get("auth_provider") != "local":
raise HTTPException(
status_code=400, detail="Password changes are only available for local users."
)
set_user_password(username, new_password.strip())
return {"status": "ok", "username": username}
@router.post("/users/bulk")
async def bulk_user_action(payload: Dict[str, Any]) -> Dict[str, Any]:
action = str(payload.get("action") or "").strip().lower()
usernames = payload.get("usernames")
if not isinstance(usernames, list) or not usernames:
raise HTTPException(status_code=400, detail="User list required")
if action not in {"block", "unblock", "delete", "role"}:
raise HTTPException(status_code=400, detail="Invalid action")
updated = 0
for username in usernames:
if not isinstance(username, str) or not username.strip():
continue
name = username.strip()
if action == "block":
set_user_blocked(name, True)
elif action == "unblock":
set_user_blocked(name, False)
elif action == "delete":
delete_user(name)
elif action == "role":
role = str(payload.get("role") or "").strip().lower()
if role not in {"admin", "user"}:
raise HTTPException(status_code=400, detail="Invalid role")
set_user_role(name, role)
updated += 1
return {"status": "ok", "updated": updated}
@router.get("/invite-profiles")
async def invite_profiles() -> Dict[str, Any]:
return {"profiles": list_invite_profiles()}
@router.post("/invite-profiles")
async def create_profile(
payload: Dict[str, Any], user: Dict[str, Any] = Depends(require_admin)
) -> Dict[str, Any]:
name = str(payload.get("name") or "").strip()
if not name:
raise HTTPException(status_code=400, detail="Profile name required")
profile_id = create_invite_profile(
name=name,
description=str(payload.get("description") or "").strip() or None,
max_uses=payload.get("max_uses"),
expires_in_days=payload.get("expires_in_days"),
require_captcha=bool(payload.get("require_captcha")),
password_rules=payload.get("password_rules") if isinstance(payload.get("password_rules"), dict) else None,
allow_referrals=bool(payload.get("allow_referrals")),
referral_uses=payload.get("referral_uses"),
user_expiry_days=payload.get("user_expiry_days"),
user_expiry_action=str(payload.get("user_expiry_action") or "").strip() or None,
)
return {"status": "ok", "id": profile_id}
@router.get("/invites")
async def list_invites_endpoint(limit: int = 200) -> Dict[str, Any]:
return {"invites": list_invites(limit)}
@router.post("/invites")
async def create_invite_endpoint(
payload: Dict[str, Any], user: Dict[str, Any] = Depends(require_admin)
) -> Dict[str, Any]:
runtime = get_runtime_settings()
profile_id = payload.get("profile_id")
profile = None
if profile_id is not None:
try:
profile = get_invite_profile(int(profile_id))
except (TypeError, ValueError):
profile = None
expires_in_days = payload.get("expires_in_days") or (profile.get("expires_in_days") if profile else None)
expires_at = None
if expires_in_days:
try:
expires_at = (
datetime.now(timezone.utc) + timedelta(days=float(expires_in_days))
).isoformat()
except (TypeError, ValueError):
expires_at = None
require_captcha = bool(payload.get("require_captcha"))
if not require_captcha and profile:
require_captcha = bool(profile.get("require_captcha"))
if not require_captcha:
require_captcha = runtime.invites_require_captcha
password_rules = payload.get("password_rules")
if not isinstance(password_rules, dict):
password_rules = profile.get("password_rules") if profile else None
allow_referrals = bool(payload.get("allow_referrals"))
if not allow_referrals and profile:
allow_referrals = bool(profile.get("allow_referrals"))
user_expiry_days = payload.get("user_expiry_days") or (profile.get("user_expiry_days") if profile else None)
user_expiry_action = payload.get("user_expiry_action") or (profile.get("user_expiry_action") if profile else None)
code = secrets.token_urlsafe(8)
create_invite(
code=code,
created_by=user.get("username"),
profile_id=int(profile_id) if profile_id is not None else None,
expires_at=expires_at,
max_uses=payload.get("max_uses") or (profile.get("max_uses") if profile else None),
require_captcha=require_captcha,
password_rules=password_rules if isinstance(password_rules, dict) else None,
allow_referrals=allow_referrals,
referral_uses=payload.get("referral_uses") or (profile.get("referral_uses") if profile else None),
user_expiry_days=user_expiry_days,
user_expiry_action=str(user_expiry_action) if user_expiry_action else None,
is_referral=bool(payload.get("is_referral")),
)
return {"status": "ok", "code": code}
@router.post("/invites/{code}/disable")
async def disable_invite_endpoint(code: str) -> Dict[str, Any]:
disable_invite(code)
return {"status": "ok", "code": code, "disabled": True}
@router.delete("/invites/{code}")
async def delete_invite_endpoint(code: str) -> Dict[str, Any]:
delete_invite(code)
return {"status": "ok", "code": code, "deleted": True}
@router.post("/announcements")
async def send_announcement(
payload: Dict[str, Any], user: Dict[str, Any] = Depends(require_admin)
) -> Dict[str, Any]:
subject = str(payload.get("subject") or "").strip()
body = str(payload.get("body") or "").strip()
channels = payload.get("channels") if isinstance(payload.get("channels"), list) else []
if not subject or not body:
raise HTTPException(status_code=400, detail="Subject and message required")
results: Dict[str, Any] = {}
email_count = 0
email_failed = 0
if "email" in [str(c).lower() for c in channels]:
for contact in get_all_contacts():
email = contact.get("email")
if not email:
continue
outcome = await send_notification(subject, body, channels=["email"], email=email)
if outcome.get("email") == "sent":
email_count += 1
else:
email_failed += 1
results["email"] = {"sent": email_count, "failed": email_failed}
other_channels = [c for c in channels if str(c).lower() != "email"]
if other_channels:
results.update(await send_notification(subject, body, channels=other_channels))
save_announcement(user.get("username"), subject, body, ",".join(channels))
return {"status": "ok", "results": results}