Sync dev changes into release-1.0
This commit is contained in:
43
backend/app/services/captcha.py
Normal file
43
backend/app/services/captcha.py
Normal file
@@ -0,0 +1,43 @@
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from ..runtime import get_runtime_settings
|
||||
|
||||
|
||||
async def verify_captcha(token: Optional[str], remote_ip: Optional[str] = None) -> bool:
|
||||
runtime = get_runtime_settings()
|
||||
provider = (runtime.captcha_provider or "none").strip().lower()
|
||||
if provider in {"", "none", "off", "disabled"}:
|
||||
return True
|
||||
if not token:
|
||||
return False
|
||||
|
||||
if provider == "hcaptcha":
|
||||
secret = runtime.hcaptcha_secret_key
|
||||
url = "https://hcaptcha.com/siteverify"
|
||||
payload = {"secret": secret, "response": token}
|
||||
elif provider == "recaptcha":
|
||||
secret = runtime.recaptcha_secret_key
|
||||
url = "https://www.google.com/recaptcha/api/siteverify"
|
||||
payload = {"secret": secret, "response": token}
|
||||
elif provider == "turnstile":
|
||||
secret = runtime.turnstile_secret_key
|
||||
url = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
|
||||
payload = {"secret": secret, "response": token}
|
||||
else:
|
||||
return False
|
||||
|
||||
if not secret:
|
||||
return False
|
||||
if remote_ip:
|
||||
payload["remoteip"] = remote_ip
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(url, data=payload)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return bool(data.get("success"))
|
||||
except httpx.HTTPError:
|
||||
return False
|
||||
47
backend/app/services/expiry.py
Normal file
47
backend/app/services/expiry.py
Normal file
@@ -0,0 +1,47 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from ..db import (
|
||||
get_users_expiring_by,
|
||||
get_expired_users,
|
||||
get_user_contact,
|
||||
mark_expiry_warning_sent,
|
||||
mark_expiry_disabled,
|
||||
mark_expiry_deleted,
|
||||
set_user_blocked,
|
||||
delete_user,
|
||||
)
|
||||
from ..runtime import get_runtime_settings
|
||||
from .notifications import send_notification
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def run_expiry_loop() -> None:
|
||||
while True:
|
||||
runtime = get_runtime_settings()
|
||||
now = datetime.now(timezone.utc)
|
||||
warn_days = int(runtime.expiry_warning_days or 0)
|
||||
if warn_days > 0:
|
||||
cutoff = (now + timedelta(days=warn_days)).isoformat()
|
||||
for user in get_users_expiring_by(cutoff):
|
||||
contact = get_user_contact(user["username"]) or {}
|
||||
email = contact.get("email")
|
||||
await send_notification(
|
||||
"Account expiring soon",
|
||||
f"Your account expires on {user['expires_at']}.",
|
||||
channels=["email"] if email else [],
|
||||
email=email,
|
||||
)
|
||||
mark_expiry_warning_sent(user["username"])
|
||||
for expired in get_expired_users(now.isoformat()):
|
||||
action = (expired.get("action") or "disable").lower()
|
||||
if action in {"disable", "disable_then_delete"}:
|
||||
set_user_blocked(expired["username"], True)
|
||||
mark_expiry_disabled(expired["username"])
|
||||
if action in {"delete", "disable_then_delete"}:
|
||||
delete_user(expired["username"])
|
||||
mark_expiry_deleted(expired["username"])
|
||||
delay = max(60, int(runtime.expiry_check_interval_minutes or 60) * 60)
|
||||
await asyncio.sleep(delay)
|
||||
56
backend/app/services/jellyseerr_sync.py
Normal file
56
backend/app/services/jellyseerr_sync.py
Normal file
@@ -0,0 +1,56 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from ..clients.jellyseerr import JellyseerrClient
|
||||
from ..db import create_user_if_missing, upsert_user_contact
|
||||
from ..runtime import get_runtime_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def sync_jellyseerr_users() -> int:
|
||||
runtime = get_runtime_settings()
|
||||
if not runtime.jellyseerr_base_url or not runtime.jellyseerr_api_key:
|
||||
raise HTTPException(status_code=400, detail="Jellyseerr not configured")
|
||||
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
||||
take = 50
|
||||
skip = 0
|
||||
imported = 0
|
||||
while True:
|
||||
data = await client.get_users(take=take, skip=skip)
|
||||
if not isinstance(data, dict):
|
||||
break
|
||||
results = data.get("results")
|
||||
if not isinstance(results, list) or not results:
|
||||
break
|
||||
for user in results:
|
||||
if not isinstance(user, dict):
|
||||
continue
|
||||
username = user.get("username") or user.get("email") or user.get("displayName")
|
||||
if not isinstance(username, str) or not username.strip():
|
||||
continue
|
||||
email = user.get("email") if isinstance(user.get("email"), str) else None
|
||||
if create_user_if_missing(username.strip(), "jellyseerr-user", role="user", auth_provider="jellyseerr"):
|
||||
imported += 1
|
||||
if email:
|
||||
upsert_user_contact(username.strip(), email=email.strip())
|
||||
skip += take
|
||||
return imported
|
||||
|
||||
|
||||
async def run_jellyseerr_sync_loop() -> None:
|
||||
while True:
|
||||
runtime = get_runtime_settings()
|
||||
if runtime.jellyseerr_sync_users:
|
||||
try:
|
||||
imported = await sync_jellyseerr_users()
|
||||
logger.info("Jellyseerr sync complete: imported=%s", imported)
|
||||
except HTTPException as exc:
|
||||
logger.warning("Jellyseerr sync skipped: %s", exc.detail)
|
||||
except Exception:
|
||||
logger.exception("Jellyseerr sync failed")
|
||||
delay = max(60, int(runtime.jellyseerr_sync_interval_minutes or 1440) * 60)
|
||||
await asyncio.sleep(delay)
|
||||
216
backend/app/services/notifications.py
Normal file
216
backend/app/services/notifications.py
Normal file
@@ -0,0 +1,216 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Iterable, Optional
|
||||
import asyncio
|
||||
import logging
|
||||
import smtplib
|
||||
from email.message import EmailMessage
|
||||
|
||||
import httpx
|
||||
|
||||
from ..db import log_notification
|
||||
from ..runtime import get_runtime_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _normalize_channels(channels: Optional[Iterable[str]]) -> list[str]:
|
||||
if not channels:
|
||||
return []
|
||||
return [str(channel).strip().lower() for channel in channels if str(channel).strip()]
|
||||
|
||||
|
||||
def _send_email_sync(
|
||||
smtp_host: str,
|
||||
smtp_port: int,
|
||||
smtp_user: Optional[str],
|
||||
smtp_password: Optional[str],
|
||||
smtp_from: str,
|
||||
to_address: str,
|
||||
subject: str,
|
||||
body: str,
|
||||
use_tls: bool,
|
||||
use_starttls: bool,
|
||||
) -> None:
|
||||
message = EmailMessage()
|
||||
message["From"] = smtp_from
|
||||
message["To"] = to_address
|
||||
message["Subject"] = subject
|
||||
message.set_content(body)
|
||||
|
||||
if use_tls:
|
||||
with smtplib.SMTP_SSL(smtp_host, smtp_port, timeout=10) as server:
|
||||
if smtp_user and smtp_password:
|
||||
server.login(smtp_user, smtp_password)
|
||||
server.send_message(message)
|
||||
else:
|
||||
with smtplib.SMTP(smtp_host, smtp_port, timeout=10) as server:
|
||||
if use_starttls:
|
||||
server.starttls()
|
||||
if smtp_user and smtp_password:
|
||||
server.login(smtp_user, smtp_password)
|
||||
server.send_message(message)
|
||||
|
||||
|
||||
async def _send_email(to_address: str, subject: str, body: str) -> None:
|
||||
runtime = get_runtime_settings()
|
||||
if not runtime.notify_email_enabled:
|
||||
raise RuntimeError("Email notifications disabled")
|
||||
if not runtime.smtp_host or not runtime.smtp_from:
|
||||
raise RuntimeError("SMTP not configured")
|
||||
await asyncio.to_thread(
|
||||
_send_email_sync,
|
||||
runtime.smtp_host,
|
||||
int(runtime.smtp_port or 587),
|
||||
runtime.smtp_user,
|
||||
runtime.smtp_password,
|
||||
runtime.smtp_from,
|
||||
to_address,
|
||||
subject,
|
||||
body,
|
||||
bool(runtime.smtp_tls),
|
||||
bool(runtime.smtp_starttls),
|
||||
)
|
||||
|
||||
|
||||
async def _send_discord(subject: str, body: str) -> None:
|
||||
runtime = get_runtime_settings()
|
||||
if not runtime.notify_discord_enabled:
|
||||
raise RuntimeError("Discord notifications disabled")
|
||||
if not runtime.discord_webhook_url:
|
||||
raise RuntimeError("Discord webhook not configured")
|
||||
payload = {"content": f"**{subject}**\n{body}"}
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(runtime.discord_webhook_url, json=payload)
|
||||
response.raise_for_status()
|
||||
|
||||
|
||||
async def _send_telegram(subject: str, body: str, chat_id: Optional[str]) -> None:
|
||||
runtime = get_runtime_settings()
|
||||
if not runtime.notify_telegram_enabled:
|
||||
raise RuntimeError("Telegram notifications disabled")
|
||||
if not runtime.telegram_bot_token:
|
||||
raise RuntimeError("Telegram bot token not configured")
|
||||
target_chat = chat_id or runtime.telegram_chat_id
|
||||
if not target_chat:
|
||||
raise RuntimeError("Telegram chat ID not configured")
|
||||
url = f"https://api.telegram.org/bot{runtime.telegram_bot_token}/sendMessage"
|
||||
payload = {"chat_id": target_chat, "text": f"{subject}\n{body}"}
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(url, json=payload)
|
||||
response.raise_for_status()
|
||||
|
||||
|
||||
async def _send_matrix(subject: str, body: str) -> None:
|
||||
runtime = get_runtime_settings()
|
||||
if not runtime.notify_matrix_enabled:
|
||||
raise RuntimeError("Matrix notifications disabled")
|
||||
if not runtime.matrix_homeserver or not runtime.matrix_access_token or not runtime.matrix_room_id:
|
||||
raise RuntimeError("Matrix not configured")
|
||||
url = (
|
||||
f"{runtime.matrix_homeserver}/_matrix/client/v3/rooms/"
|
||||
f"{runtime.matrix_room_id}/send/m.room.message"
|
||||
)
|
||||
payload = {"msgtype": "m.text", "body": f"{subject}\n{body}"}
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(url, json=payload, params={"access_token": runtime.matrix_access_token})
|
||||
response.raise_for_status()
|
||||
|
||||
|
||||
async def _send_pushover(subject: str, body: str) -> None:
|
||||
runtime = get_runtime_settings()
|
||||
if not runtime.notify_pushover_enabled:
|
||||
raise RuntimeError("Pushover notifications disabled")
|
||||
if not runtime.pushover_token or not runtime.pushover_user_key:
|
||||
raise RuntimeError("Pushover not configured")
|
||||
payload = {
|
||||
"token": runtime.pushover_token,
|
||||
"user": runtime.pushover_user_key,
|
||||
"title": subject,
|
||||
"message": body,
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post("https://api.pushover.net/1/messages.json", data=payload)
|
||||
response.raise_for_status()
|
||||
|
||||
|
||||
async def _send_pushbullet(subject: str, body: str) -> None:
|
||||
runtime = get_runtime_settings()
|
||||
if not runtime.notify_pushbullet_enabled:
|
||||
raise RuntimeError("Pushbullet notifications disabled")
|
||||
if not runtime.pushbullet_token:
|
||||
raise RuntimeError("Pushbullet not configured")
|
||||
payload = {"type": "note", "title": subject, "body": body}
|
||||
headers = {"Access-Token": runtime.pushbullet_token}
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post("https://api.pushbullet.com/v2/pushes", json=payload, headers=headers)
|
||||
response.raise_for_status()
|
||||
|
||||
|
||||
async def _send_gotify(subject: str, body: str) -> None:
|
||||
runtime = get_runtime_settings()
|
||||
if not runtime.notify_gotify_enabled:
|
||||
raise RuntimeError("Gotify notifications disabled")
|
||||
if not runtime.gotify_url or not runtime.gotify_token:
|
||||
raise RuntimeError("Gotify not configured")
|
||||
payload = {"title": subject, "message": body, "priority": 5}
|
||||
url = f"{runtime.gotify_url.rstrip('/')}/message"
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(url, params={"token": runtime.gotify_token}, json=payload)
|
||||
response.raise_for_status()
|
||||
|
||||
|
||||
async def _send_ntfy(subject: str, body: str) -> None:
|
||||
runtime = get_runtime_settings()
|
||||
if not runtime.notify_ntfy_enabled:
|
||||
raise RuntimeError("ntfy notifications disabled")
|
||||
if not runtime.ntfy_url or not runtime.ntfy_topic:
|
||||
raise RuntimeError("ntfy not configured")
|
||||
url = f"{runtime.ntfy_url.rstrip('/')}/{runtime.ntfy_topic}"
|
||||
headers = {"Title": subject}
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(url, content=body.encode("utf-8"), headers=headers)
|
||||
response.raise_for_status()
|
||||
|
||||
|
||||
async def send_notification(
|
||||
subject: str,
|
||||
body: str,
|
||||
channels: Optional[Iterable[str]] = None,
|
||||
email: Optional[str] = None,
|
||||
telegram_chat_id: Optional[str] = None,
|
||||
) -> dict[str, str]:
|
||||
requested = _normalize_channels(channels)
|
||||
results: dict[str, str] = {}
|
||||
if not requested:
|
||||
return results
|
||||
for channel in requested:
|
||||
try:
|
||||
if channel == "email":
|
||||
if not email:
|
||||
raise RuntimeError("Email address not provided")
|
||||
await _send_email(email, subject, body)
|
||||
elif channel == "discord":
|
||||
await _send_discord(subject, body)
|
||||
elif channel == "telegram":
|
||||
await _send_telegram(subject, body, telegram_chat_id)
|
||||
elif channel == "matrix":
|
||||
await _send_matrix(subject, body)
|
||||
elif channel == "pushover":
|
||||
await _send_pushover(subject, body)
|
||||
elif channel == "pushbullet":
|
||||
await _send_pushbullet(subject, body)
|
||||
elif channel == "gotify":
|
||||
await _send_gotify(subject, body)
|
||||
elif channel == "ntfy":
|
||||
await _send_ntfy(subject, body)
|
||||
else:
|
||||
results[channel] = "unsupported"
|
||||
continue
|
||||
results[channel] = "sent"
|
||||
log_notification(channel, email or telegram_chat_id, "sent", None)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("Notification failed: channel=%s error=%s", channel, exc)
|
||||
results[channel] = "failed"
|
||||
log_notification(channel, email or telegram_chat_id, "failed", str(exc))
|
||||
return results
|
||||
Reference in New Issue
Block a user