Sync dev changes into release-1.0

This commit is contained in:
2026-01-24 18:51:15 +13:00
parent 52e3d680f7
commit d2ff2b3e41
17 changed files with 2227 additions and 5 deletions

View 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

View 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)

View 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)

View 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