feat: add Apprise sidecar user/admin notifications
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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":
|
||||
|
||||
125
backend/app/services/notifications.py
Normal file
125
backend/app/services/notifications.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user