diff --git a/backend/app/config.py b/backend/app/config.py index 743f79c..f11ce77 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -50,6 +50,12 @@ class Settings(BaseSettings): default="info", validation_alias=AliasChoices("SITE_BANNER_TONE") ) site_changelog: Optional[str] = Field(default=CHANGELOG) + apprise_base_url: Optional[str] = Field( + default=None, validation_alias=AliasChoices("APPRISE_URL", "APPRISE_BASE_URL") + ) + apprise_api_key: Optional[str] = Field( + default=None, validation_alias=AliasChoices("APPRISE_API_KEY") + ) jellyseerr_base_url: Optional[str] = Field( default=None, validation_alias=AliasChoices("JELLYSEERR_URL", "JELLYSEERR_BASE_URL") diff --git a/backend/app/db.py b/backend/app/db.py index 1f4a13b..2a1504a 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -150,7 +150,10 @@ def init_db() -> None: last_login_at TEXT, is_blocked INTEGER NOT NULL DEFAULT 0, jellyfin_password_hash TEXT, - last_jellyfin_auth_at TEXT + last_jellyfin_auth_at TEXT, + notify_enabled INTEGER NOT NULL DEFAULT 0, + notify_urls TEXT, + notify_updated_at TEXT ) """ ) @@ -264,6 +267,20 @@ def init_db() -> None: conn.execute("ALTER TABLE users ADD COLUMN jellyseerr_user_id INTEGER") except sqlite3.OperationalError: pass + try: + conn.execute( + "ALTER TABLE users ADD COLUMN notify_enabled INTEGER NOT NULL DEFAULT 0" + ) + except sqlite3.OperationalError: + pass + try: + conn.execute("ALTER TABLE users ADD COLUMN notify_urls TEXT") + except sqlite3.OperationalError: + pass + try: + conn.execute("ALTER TABLE users ADD COLUMN notify_updated_at TEXT") + except sqlite3.OperationalError: + pass try: conn.execute("ALTER TABLE requests_cache ADD COLUMN requested_by_id INTEGER") except sqlite3.OperationalError: @@ -474,6 +491,75 @@ def get_user_by_id(user_id: int) -> Optional[Dict[str, Any]]: "last_jellyfin_auth_at": row[10], } + +def get_user_notification_settings(username: str) -> Dict[str, Any]: + if not username: + return {"enabled": False, "urls": []} + with _connect() as conn: + row = conn.execute( + """ + SELECT notify_enabled, notify_urls + FROM users + WHERE username = ? COLLATE NOCASE + """, + (username,), + ).fetchone() + if not row: + return {"enabled": False, "urls": []} + enabled = bool(row[0]) + urls_raw = row[1] + urls: list[str] = [] + if isinstance(urls_raw, str) and urls_raw.strip(): + try: + parsed = json.loads(urls_raw) + if isinstance(parsed, list): + urls = [str(item).strip() for item in parsed if str(item).strip()] + except json.JSONDecodeError: + urls = [urls_raw.strip()] + return {"enabled": enabled, "urls": urls} + + +def set_user_notification_settings(username: str, enabled: bool, urls: list[str]) -> None: + if not username: + return + urls_payload = json.dumps(urls, ensure_ascii=True) + updated_at = datetime.now(timezone.utc).isoformat() + with _connect() as conn: + conn.execute( + """ + UPDATE users + SET notify_enabled = ?, notify_urls = ?, notify_updated_at = ? + WHERE username = ? COLLATE NOCASE + """, + (1 if enabled else 0, urls_payload, updated_at, username), + ) + + +def get_admin_notification_targets() -> list[Dict[str, Any]]: + with _connect() as conn: + rows = conn.execute( + """ + SELECT username, notify_urls + FROM users + WHERE role = 'admin' AND notify_enabled = 1 + ORDER BY username COLLATE NOCASE + """ + ).fetchall() + results: list[Dict[str, Any]] = [] + for row in rows: + username, urls_raw = row + urls: list[str] = [] + if isinstance(urls_raw, str) and urls_raw.strip(): + try: + parsed = json.loads(urls_raw) + if isinstance(parsed, list): + urls = [str(item).strip() for item in parsed if str(item).strip()] + except json.JSONDecodeError: + urls = [urls_raw.strip()] + if urls: + results.append({"username": username, "urls": urls}) + return results + def get_all_users() -> list[Dict[str, Any]]: with _connect() as conn: rows = conn.execute( @@ -499,7 +585,6 @@ def get_all_users() -> list[Dict[str, Any]]: ) return results - def delete_non_admin_users() -> int: with _connect() as conn: cursor = conn.execute( diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 2d5ebee..10c96ab 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -34,6 +34,7 @@ from ..db import ( update_request_cache_title, repair_request_cache_titles, delete_non_admin_users, + get_user_notification_settings, ) from ..runtime import get_runtime_settings from ..clients.sonarr import SonarrClient @@ -49,6 +50,7 @@ from ..services.user_cache import ( save_jellyfin_users_cache, save_jellyseerr_users_cache, ) +from ..services.notifications import send_apprise_notification import logging from ..logging_config import configure_logging from ..routers import requests as requests_router @@ -64,6 +66,7 @@ SENSITIVE_KEYS = { "radarr_api_key", "prowlarr_api_key", "qbittorrent_password", + "apprise_api_key", } URL_SETTING_KEYS = { @@ -74,6 +77,7 @@ URL_SETTING_KEYS = { "radarr_base_url", "prowlarr_base_url", "qbittorrent_base_url", + "apprise_base_url", } SETTING_KEYS: List[str] = [ @@ -101,6 +105,8 @@ SETTING_KEYS: List[str] = [ "qbittorrent_password", "log_level", "log_file", + "apprise_base_url", + "apprise_api_key", "requests_sync_ttl_minutes", "requests_poll_interval_seconds", "requests_delta_sync_interval_minutes", @@ -608,6 +614,72 @@ async def list_users() -> Dict[str, Any]: users = [user for user in get_all_users() if user.get("role") == "admin" or user.get("auth_provider") == "jellyseerr"] return {"users": users} +@router.get("/notifications/users") +async def list_notification_users() -> Dict[str, Any]: + users = get_all_users() + results: list[Dict[str, Any]] = [] + for user in users: + username = user.get("username") or "" + settings = get_user_notification_settings(username) + results.append( + { + "username": username, + "role": user.get("role"), + "authProvider": user.get("auth_provider"), + "jellyseerrUserId": user.get("jellyseerr_user_id"), + "isBlocked": bool(user.get("is_blocked")), + "notifyEnabled": bool(settings.get("enabled")), + "notifyCount": len(settings.get("urls") or []), + } + ) + return {"users": results} + +@router.post("/notifications/send") +async def send_notifications(payload: Dict[str, Any]) -> Dict[str, Any]: + usernames = payload.get("usernames") + message = payload.get("message") + title = payload.get("title") or "Magent admin message" + if not isinstance(usernames, list) or not usernames: + raise HTTPException(status_code=400, detail="Select at least one user.") + if not isinstance(message, str) or not message.strip(): + raise HTTPException(status_code=400, detail="Message cannot be empty.") + + results: list[Dict[str, Any]] = [] + counts = {"sent": 0, "skipped": 0, "failed": 0} + for raw_username in usernames: + if not isinstance(raw_username, str) or not raw_username.strip(): + results.append({"username": str(raw_username), "status": "invalid"}) + counts["failed"] += 1 + continue + username = raw_username.strip() + user = get_user_by_username(username) + if not user: + results.append({"username": username, "status": "not_found"}) + counts["failed"] += 1 + continue + if user.get("is_blocked"): + results.append({"username": username, "status": "blocked"}) + counts["skipped"] += 1 + continue + settings = get_user_notification_settings(username) + if not settings.get("enabled"): + results.append({"username": username, "status": "disabled"}) + counts["skipped"] += 1 + continue + urls = settings.get("urls") or [] + if not urls: + results.append({"username": username, "status": "no_targets"}) + counts["skipped"] += 1 + continue + ok = send_apprise_notification(urls, str(title).strip() or "Magent admin message", message.strip()) + if ok: + results.append({"username": username, "status": "sent"}) + counts["sent"] += 1 + else: + results.append({"username": username, "status": "failed"}) + counts["failed"] += 1 + return {"results": results, **counts} + @router.get("/users/summary") async def list_users_summary() -> Dict[str, Any]: users = [user for user in get_all_users() if user.get("role") == "admin" or user.get("auth_provider") == "jellyseerr"] diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index f6fe65e..b9b9b9b 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -1,4 +1,5 @@ from datetime import datetime, timedelta, timezone +import asyncio from fastapi import APIRouter, HTTPException, status, Depends from fastapi.security import OAuth2PasswordRequestForm @@ -16,6 +17,8 @@ from ..db import ( get_user_request_stats, get_global_request_leader, get_global_request_total, + get_user_notification_settings, + set_user_notification_settings, ) from ..runtime import get_runtime_settings from ..clients.jellyfin import JellyfinClient @@ -28,6 +31,11 @@ from ..services.user_cache import ( match_jellyseerr_user_id, save_jellyfin_users_cache, ) +from ..services.notifications import ( + notify_admins_new_signup, + send_apprise_notification, + validate_apprise_urls, +) router = APIRouter(prefix="/auth", tags=["auth"]) @@ -119,10 +127,14 @@ async def jellyfin_login(form_data: OAuth2PasswordRequestForm = Depends()) -> di raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc if not isinstance(response, dict) or not response.get("User"): raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Jellyfin credentials") - create_user_if_missing(username, "jellyfin-user", role="user", auth_provider="jellyfin") + created = create_user_if_missing(username, "jellyfin-user", role="user", auth_provider="jellyfin") user = get_user_by_username(username) if user and user.get("is_blocked"): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked") + if created: + asyncio.create_task( + asyncio.to_thread(notify_admins_new_signup, username, "jellyfin") + ) try: users = await client.get_users() if isinstance(users, list): @@ -159,7 +171,7 @@ async def jellyseerr_login(form_data: OAuth2PasswordRequestForm = Depends()) -> if not isinstance(response, dict): raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Jellyseerr credentials") jellyseerr_user_id = _extract_jellyseerr_user_id(response) - create_user_if_missing( + created = create_user_if_missing( form_data.username, "jellyseerr-user", role="user", @@ -171,6 +183,10 @@ async def jellyseerr_login(form_data: OAuth2PasswordRequestForm = Depends()) -> raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked") if jellyseerr_user_id is not None: set_user_jellyseerr_id(form_data.username, jellyseerr_user_id) + if created: + asyncio.create_task( + asyncio.to_thread(notify_admins_new_signup, form_data.username, "jellyseerr") + ) token = create_access_token(form_data.username, "user") set_last_login(form_data.username) return {"access_token": token, "token_type": "bearer", "user": {"username": form_data.username, "role": "user"}} @@ -207,6 +223,48 @@ async def profile(current_user: dict = Depends(get_current_user)) -> dict: } +@router.get("/notifications") +async def get_notifications(current_user: dict = Depends(get_current_user)) -> dict: + settings = get_user_notification_settings(current_user.get("username") or "") + return settings + + +@router.put("/notifications") +async def update_notifications(payload: dict, current_user: dict = Depends(get_current_user)) -> dict: + if not isinstance(payload, dict): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload") + enabled = bool(payload.get("enabled")) + urls_raw = payload.get("urls") or [] + if isinstance(urls_raw, str): + urls = [line.strip() for line in urls_raw.splitlines() if line.strip()] + elif isinstance(urls_raw, list): + urls = [str(item).strip() for item in urls_raw if str(item).strip()] + else: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid URLs") + try: + validated = validate_apprise_urls(urls) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + set_user_notification_settings(current_user.get("username") or "", enabled, validated) + return {"status": "ok", "enabled": enabled, "urls": validated} + + +@router.post("/notifications/test") +async def test_notifications(current_user: dict = Depends(get_current_user)) -> dict: + settings = get_user_notification_settings(current_user.get("username") or "") + if not settings.get("enabled"): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Notifications are disabled") + urls = settings.get("urls") or [] + if not urls: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="No Apprise URLs configured") + title = "Magent notification test" + body = f"Hello {current_user.get('username')}, your Apprise notifications are working." + sent = await asyncio.to_thread(send_apprise_notification, urls, title, body) + if not sent: + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="Notification failed") + return {"status": "ok"} + + @router.post("/password") async def change_password(payload: dict, current_user: dict = Depends(get_current_user)) -> dict: if current_user.get("auth_provider") != "local": diff --git a/backend/app/services/notifications.py b/backend/app/services/notifications.py new file mode 100644 index 0000000..f487c1c --- /dev/null +++ b/backend/app/services/notifications.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +import logging +from datetime import datetime, timezone +from typing import Iterable, List + +import httpx + +from ..db import get_admin_notification_targets +from ..runtime import get_runtime_settings + +logger = logging.getLogger(__name__) + + +def _normalize_urls(urls: Iterable[str]) -> List[str]: + normalized: list[str] = [] + seen: set[str] = set() + for entry in urls: + if not isinstance(entry, str): + continue + value = entry.strip() + if value and value not in seen: + normalized.append(value) + seen.add(value) + return normalized + + +def validate_apprise_urls(urls: Iterable[str]) -> List[str]: + normalized = _normalize_urls(urls) + if not normalized: + return [] + invalid: list[str] = [] + for url in normalized: + if "://" not in url: + invalid.append(url) + if invalid: + raise ValueError( + "Invalid Apprise URL(s): " + + ", ".join(invalid) + + " (each URL must include a scheme like discord:// or mailto://)" + ) + return normalized + + +def _get_apprise_notify_url() -> str | None: + runtime = get_runtime_settings() + base_url = (runtime.apprise_base_url or "").strip() + if not base_url: + return None + if "://" not in base_url: + base_url = f"http://{base_url}" + base_url = base_url.rstrip("/") + if base_url.endswith("/notify"): + return base_url + return f"{base_url}/notify" + + +def _get_apprise_headers() -> dict[str, str]: + runtime = get_runtime_settings() + headers = {"Content-Type": "application/json"} + api_key = (runtime.apprise_api_key or "").strip() + if api_key: + headers["X-API-Key"] = api_key + headers["Authorization"] = f"Bearer {api_key}" + return headers + + +def send_apprise_notification(urls: Iterable[str], title: str, body: str) -> bool: + try: + normalized = validate_apprise_urls(urls) + except ValueError as exc: + logger.warning("Apprise notification skipped due to invalid URL(s): %s", exc) + return False + if not normalized: + return False + + notify_url = _get_apprise_notify_url() + if not notify_url: + logger.warning("Apprise notification skipped: APPRISE_BASE_URL is not configured.") + return False + + payload = { + "urls": normalized, + "title": str(title or "Magent notification").strip() or "Magent notification", + "body": str(body or "").strip(), + } + if not payload["body"]: + return False + + try: + with httpx.Client(timeout=10.0) as client: + response = client.post(notify_url, headers=_get_apprise_headers(), json=payload) + response.raise_for_status() + except httpx.HTTPError as exc: + logger.warning("Apprise sidecar notify failed: %s", exc) + return False + + try: + data = response.json() + except ValueError: + return True + + if isinstance(data, dict): + if data.get("status") in {"error", "failed"}: + return False + if "sent" in data: + return bool(data.get("sent")) + return True + + +def notify_admins_new_signup(username: str, provider: str) -> int: + targets = get_admin_notification_targets() + if not targets: + return 0 + timestamp = datetime.now(timezone.utc).isoformat() + title = "New Magent user signup" + body = f"User {username} signed in via {provider} at {timestamp}." + sent = 0 + for target in targets: + urls = target.get("urls") or [] + if send_apprise_notification(urls, title, body): + sent += 1 + if sent == 0: + logger.info("Apprise signup notification skipped (no valid admin targets).") + return sent diff --git a/frontend/app/admin/SettingsPage.tsx b/frontend/app/admin/SettingsPage.tsx index 319e61e..736a287 100644 --- a/frontend/app/admin/SettingsPage.tsx +++ b/frontend/app/admin/SettingsPage.tsx @@ -27,6 +27,7 @@ const SECTION_LABELS: Record = { radarr: 'Radarr', prowlarr: 'Prowlarr', qbittorrent: 'qBittorrent', + apprise: 'Apprise', log: 'Activity log', requests: 'Request sync', site: 'Site', @@ -42,6 +43,7 @@ const URL_SETTINGS = new Set([ 'radarr_base_url', 'prowlarr_base_url', 'qbittorrent_base_url', + 'apprise_base_url', ]) const BANNER_TONES = ['info', 'warning', 'error', 'maintenance'] @@ -54,6 +56,7 @@ const SECTION_DESCRIPTIONS: Record = { radarr: 'Movie automation settings.', prowlarr: 'Indexer search settings.', qbittorrent: 'Downloader connection settings.', + apprise: 'Configure the external Apprise sidecar used for notifications.', requests: 'Control how often requests are refreshed and cleaned up.', log: 'Activity log for troubleshooting.', site: 'Sitewide banner, version, and changelog details.', @@ -67,6 +70,7 @@ const SETTINGS_SECTION_MAP: Record = { radarr: 'radarr', prowlarr: 'prowlarr', qbittorrent: 'qbittorrent', + apprise: 'apprise', requests: 'requests', cache: null, logs: 'log', @@ -366,6 +370,10 @@ export default function SettingsPage({ section }: SettingsPageProps) { 'qBittorrent server URL for download status (FQDN or IP). Scheme is optional.', qbittorrent_username: 'qBittorrent login username.', qbittorrent_password: 'qBittorrent login password.', + apprise_base_url: + 'External Apprise API base URL for notifications (for example http://apprise:8000).', + apprise_api_key: + 'Optional API key Magent uses when calling your external Apprise service.', requests_sync_ttl_minutes: 'How long saved requests stay fresh before a refresh is needed.', requests_poll_interval_seconds: 'How often Magent checks if a full refresh should run.', @@ -393,6 +401,7 @@ export default function SettingsPage({ section }: SettingsPageProps) { radarr_base_url: 'https://radarr.example.com or 10.30.1.81:7878', prowlarr_base_url: 'https://prowlarr.example.com or 10.30.1.81:9696', qbittorrent_base_url: 'https://qb.example.com or 10.30.1.81:8080', + apprise_base_url: 'http://apprise:8000 or https://notify.example.com', } const buildSelectOptions = ( diff --git a/frontend/app/admin/notifications/page.tsx b/frontend/app/admin/notifications/page.tsx new file mode 100644 index 0000000..e09381c --- /dev/null +++ b/frontend/app/admin/notifications/page.tsx @@ -0,0 +1,281 @@ +'use client' + +import { useEffect, useMemo, useState } from 'react' +import { useRouter } from 'next/navigation' +import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth' +import AdminShell from '../../ui/AdminShell' + +type NotificationUser = { + username: string + role?: string | null + authProvider?: string | null + jellyseerrUserId?: number | null + isBlocked?: boolean + notifyEnabled?: boolean + notifyCount?: number +} + +type SendResult = { + username: string + status: string +} + +const formatStatus = (user: NotificationUser) => { + if (user.isBlocked) return 'Blocked' + if (!user.notifyEnabled) return 'Disabled' + if (user.notifyCount && user.notifyCount > 0) return `Enabled (${user.notifyCount})` + return 'No targets' +} + +export default function AdminNotificationsPage() { + const router = useRouter() + const [users, setUsers] = useState([]) + const [selected, setSelected] = useState>(new Set()) + const [title, setTitle] = useState('') + const [message, setMessage] = useState('') + const [loading, setLoading] = useState(false) + const [sending, setSending] = useState(false) + const [status, setStatus] = useState(null) + const [sendResults, setSendResults] = useState([]) + + const selectedCount = selected.size + const selectableUsers = useMemo( + () => users.filter((user) => user.username && !user.isBlocked), + [users] + ) + + const load = async () => { + if (!getToken()) { + router.push('/login') + return + } + setLoading(true) + setStatus(null) + try { + const baseUrl = getApiBase() + const response = await authFetch(`${baseUrl}/admin/notifications/users`) + if (!response.ok) { + if (response.status === 401) { + clearToken() + router.push('/login') + return + } + if (response.status === 403) { + router.push('/') + return + } + throw new Error('Load failed') + } + const data = await response.json() + const fetched = Array.isArray(data?.users) ? data.users : [] + setUsers(fetched) + setSelected(new Set()) + } catch (err) { + console.error(err) + setStatus('Unable to load notification targets.') + } finally { + setLoading(false) + } + } + + useEffect(() => { + void load() + }, []) + + const toggleUser = (username: string) => { + setSelected((current) => { + const next = new Set(current) + if (next.has(username)) { + next.delete(username) + } else { + next.add(username) + } + return next + }) + } + + const selectAll = () => { + const next = new Set() + for (const user of selectableUsers) { + if (user.username) { + next.add(user.username) + } + } + setSelected(next) + } + + const selectEnabled = () => { + const next = new Set() + for (const user of selectableUsers) { + if (user.username && user.notifyEnabled && (user.notifyCount ?? 0) > 0) { + next.add(user.username) + } + } + setSelected(next) + } + + const clearSelection = () => { + setSelected(new Set()) + } + + const send = async () => { + setStatus(null) + setSendResults([]) + if (selectedCount === 0) { + setStatus('Select at least one user.') + return + } + if (!message.trim()) { + setStatus('Message cannot be empty.') + return + } + setSending(true) + try { + const baseUrl = getApiBase() + const response = await authFetch(`${baseUrl}/admin/notifications/send`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + usernames: Array.from(selected), + title: title.trim() || 'Magent admin message', + message: message.trim(), + }), + }) + if (!response.ok) { + const text = await response.text() + throw new Error(text || 'Send failed') + } + const data = await response.json() + const results = Array.isArray(data?.results) ? data.results : [] + setSendResults(results) + setStatus( + `Sent ${data?.sent ?? 0}. Skipped ${data?.skipped ?? 0}. Failed ${data?.failed ?? 0}.` + ) + } catch (err) { + console.error(err) + const message = + err instanceof Error && err.message + ? err.message.replace(/^\\{\"detail\":\"|\"\\}$/g, '') + : 'Send failed.' + setStatus(message) + } finally { + setSending(false) + } + } + + return ( + router.push('/admin')}> + Back to settings + + } + > +
+
+
+ {users.length.toLocaleString()} users + {selectedCount.toLocaleString()} selected +
+
+ + + +
+
+ {loading ? ( +
Loading notification targets…
+ ) : users.length === 0 ? ( +
No users found.
+ ) : ( +
+
+ Select + User + Role + Status +
+ {users.map((user) => { + const username = user.username || 'Unknown' + const isChecked = selected.has(username) + return ( +
+ + toggleUser(username)} + disabled={!username || user.isBlocked} + /> + + {username} + {user.role || 'user'} + {formatStatus(user)} +
+ ) + })} +
+ )} +
+
+
+

Message

+
+
+ +