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

@@ -1,4 +1,6 @@
from typing import Any, Dict, List
from datetime import datetime, timedelta, timezone
import secrets
import os
from fastapi import APIRouter, HTTPException, Depends, UploadFile, File
@@ -8,7 +10,17 @@ 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,
@@ -27,10 +39,12 @@ 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__)
@@ -42,6 +56,17 @@ SENSITIVE_KEYS = {
"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] = [
@@ -74,6 +99,59 @@ SETTING_KEYS: List[str] = [
"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]]:
@@ -208,6 +286,12 @@ async def jellyfin_users_sync() -> Dict[str, Any]:
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()
@@ -365,3 +449,157 @@ async def update_user_password(username: str, payload: Dict[str, Any]) -> Dict[s
)
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}

View File

@@ -1,21 +1,60 @@
from fastapi import APIRouter, HTTPException, status, Depends
from datetime import datetime, timedelta, timezone
from typing import Optional
from fastapi import APIRouter, HTTPException, status, Depends, Request
from fastapi.security import OAuth2PasswordRequestForm
import secrets
from ..db import (
verify_user_password,
create_user_if_missing,
create_user,
set_last_login,
get_user_by_username,
get_user_by_email,
set_user_password,
get_invite_by_code,
get_invite_profile,
increment_invite_use,
list_invites_by_creator,
create_invite,
upsert_user_contact,
get_user_contact,
set_user_expiry,
create_password_reset,
get_password_reset,
mark_password_reset_used,
)
from ..runtime import get_runtime_settings
from ..clients.jellyfin import JellyfinClient
from ..clients.jellyseerr import JellyseerrClient
from ..security import create_access_token
from ..auth import get_current_user
from ..services.captcha import verify_captcha
from ..services.notifications import send_notification
router = APIRouter(prefix="/auth", tags=["auth"])
def _validate_password(password: str, rules: dict | None = None) -> Optional[str]:
runtime = get_runtime_settings()
rules = rules or {}
min_length = int(rules.get("min_length") or runtime.password_min_length or 8)
require_upper = bool(rules.get("require_upper", runtime.password_require_upper))
require_lower = bool(rules.get("require_lower", runtime.password_require_lower))
require_number = bool(rules.get("require_number", runtime.password_require_number))
require_symbol = bool(rules.get("require_symbol", runtime.password_require_symbol))
if len(password) < min_length:
return f"Password must be at least {min_length} characters."
if require_upper and password.lower() == password:
return "Password must include an uppercase letter."
if require_lower and password.upper() == password:
return "Password must include a lowercase letter."
if require_number and not any(char.isdigit() for char in password):
return "Password must include a number."
if require_symbol and password.isalnum():
return "Password must include a symbol."
return None
@router.post("/login")
async def login(form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
@@ -103,12 +142,216 @@ async def change_password(payload: dict, current_user: dict = Depends(get_curren
new_password = payload.get("new_password") if isinstance(payload, dict) else None
if not isinstance(current_password, str) or not isinstance(new_password, str):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
if len(new_password.strip()) < 8:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Password must be at least 8 characters."
)
error = _validate_password(new_password.strip())
if error:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error)
user = verify_user_password(current_user["username"], current_password)
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Current password is incorrect")
set_user_password(current_user["username"], new_password.strip())
return {"status": "ok"}
@router.get("/contact")
async def get_contact(current_user: dict = Depends(get_current_user)) -> dict:
contact = get_user_contact(current_user["username"])
return {"contact": contact or {}}
@router.post("/contact")
async def update_contact(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")
upsert_user_contact(
current_user["username"],
email=str(payload.get("email") or "").strip() or None,
discord=str(payload.get("discord") or "").strip() or None,
telegram=str(payload.get("telegram") or "").strip() or None,
matrix=str(payload.get("matrix") or "").strip() or None,
)
return {"status": "ok"}
@router.post("/register")
async def register(payload: dict, request: Request) -> dict:
runtime = get_runtime_settings()
if not runtime.invites_enabled:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invites are disabled")
invite_code = str(payload.get("invite_code") or "").strip()
username = str(payload.get("username") or "").strip()
password = str(payload.get("password") or "").strip()
contact = payload.get("contact") if isinstance(payload, dict) else None
captcha_token = str(payload.get("captcha_token") or "").strip()
if not invite_code or not username or not password:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invite, username, and password required")
if get_user_by_username(username):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Username already exists")
invite = get_invite_by_code(invite_code)
if not invite or invite.get("disabled"):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invite not found or disabled")
profile = None
if invite.get("profile_id"):
profile = get_invite_profile(int(invite["profile_id"]))
max_uses = invite.get("max_uses")
if max_uses is not None and invite.get("uses_count", 0) >= max_uses:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invite has been fully used")
expires_at = invite.get("expires_at")
if expires_at:
try:
if datetime.fromisoformat(expires_at) <= datetime.now(timezone.utc):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invite has expired")
except ValueError:
pass
require_captcha = (
bool(invite.get("require_captcha"))
or (bool(profile.get("require_captcha")) if profile else False)
or runtime.invites_require_captcha
)
if require_captcha:
ok = await verify_captcha(captcha_token, request.client.host if request.client else None)
if not ok:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Captcha failed")
rules = invite.get("password_rules") or (profile.get("password_rules") if profile else None)
error = _validate_password(password, rules)
if error:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error)
try:
create_user(username, password, role="user", auth_provider="local")
except Exception as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
if isinstance(contact, dict):
upsert_user_contact(
username,
email=str(contact.get("email") or "").strip() or None,
discord=str(contact.get("discord") or "").strip() or None,
telegram=str(contact.get("telegram") or "").strip() or None,
matrix=str(contact.get("matrix") or "").strip() or None,
)
expiry_days = (
invite.get("user_expiry_days")
or (profile.get("user_expiry_days") if profile else None)
or runtime.expiry_default_days
)
expiry_action = (
invite.get("user_expiry_action")
or (profile.get("user_expiry_action") if profile else None)
or runtime.expiry_default_action
)
if expiry_days and expiry_action:
try:
expiry_days_float = float(expiry_days)
except (TypeError, ValueError):
expiry_days_float = 0
if expiry_days_float > 0:
expires_at = (
datetime.now(timezone.utc) + timedelta(days=expiry_days_float)
).isoformat()
set_user_expiry(username, expires_at, str(expiry_action))
increment_invite_use(invite_code)
token = create_access_token(username, "user")
return {"access_token": token, "token_type": "bearer", "user": {"username": username, "role": "user"}}
@router.post("/password/reset")
async def request_password_reset(payload: dict) -> dict:
runtime = get_runtime_settings()
if not runtime.password_reset_enabled:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Password reset disabled")
identifier = str(payload.get("identifier") or "").strip()
if not identifier:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Username or email required")
user = get_user_by_username(identifier)
if not user:
user = get_user_by_email(identifier)
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
if user.get("auth_provider") != "local":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Password reset for local users only")
token = secrets.token_urlsafe(32)
expires_at = (datetime.now(timezone.utc) + timedelta(hours=2)).isoformat()
create_password_reset(token, user["username"], expires_at)
contact = get_user_contact(user["username"])
email = contact.get("email") if isinstance(contact, dict) else None
if not runtime.notify_email_enabled or not email:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email notifications are not configured for password resets.",
)
await send_notification(
"Password reset request",
f"Your reset token is: {token}",
channels=["email"],
email=email,
)
return {"status": "ok"}
@router.post("/password/reset/confirm")
async def confirm_password_reset(payload: dict) -> dict:
token = str(payload.get("token") or "").strip()
new_password = str(payload.get("new_password") or "").strip()
if not token or not new_password:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Token and new password required")
reset = get_password_reset(token)
if not reset:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Reset token not found")
if reset.get("used_at"):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Reset token already used")
try:
expires_at = datetime.fromisoformat(reset["expires_at"])
if expires_at <= datetime.now(timezone.utc):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Reset token expired")
except ValueError:
pass
error = _validate_password(new_password)
if error:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error)
set_user_password(reset["username"], new_password)
mark_password_reset_used(token)
return {"status": "ok"}
@router.get("/signup/config")
async def signup_config() -> dict:
runtime = get_runtime_settings()
return {
"invites_enabled": runtime.invites_enabled,
"captcha_provider": runtime.captcha_provider,
"hcaptcha_site_key": runtime.hcaptcha_site_key,
"recaptcha_site_key": runtime.recaptcha_site_key,
"turnstile_site_key": runtime.turnstile_site_key,
"password_min_length": runtime.password_min_length,
"password_require_upper": runtime.password_require_upper,
"password_require_lower": runtime.password_require_lower,
"password_require_number": runtime.password_require_number,
"password_require_symbol": runtime.password_require_symbol,
}
@router.get("/referrals")
async def list_referrals(current_user: dict = Depends(get_current_user)) -> dict:
invites = list_invites_by_creator(current_user["username"], is_referral=True)
return {"invites": invites}
@router.post("/referrals")
async def create_referral(current_user: dict = Depends(get_current_user)) -> dict:
runtime = get_runtime_settings()
if not runtime.signup_allow_referrals:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Referrals are disabled")
code = secrets.token_urlsafe(8)
create_invite(
code=code,
created_by=current_user["username"],
profile_id=runtime.invite_default_profile_id,
expires_at=None,
max_uses=int(runtime.referral_default_uses or 1),
require_captcha=runtime.invites_require_captcha,
password_rules=None,
allow_referrals=False,
referral_uses=None,
user_expiry_days=None,
user_expiry_action=None,
is_referral=True,
)
return {"status": "ok", "code": code}