security: harden data auth and deployment

This commit is contained in:
2026-09-17 18:31:35 +12:00
parent a6d1c73837
commit 5639dbcb83
32 changed files with 1401 additions and 378 deletions
+43 -15
View File
@@ -39,8 +39,7 @@ from ..db import (
set_user_jellyseerr_id,
set_setting,
set_user_blocked,
delete_user_by_username,
delete_user_activity_by_username,
delete_user_data_by_username,
set_user_auto_search_enabled,
set_auto_search_enabled_for_non_admin_users,
set_user_email,
@@ -49,6 +48,7 @@ from ..db import (
set_user_profile_id,
set_user_expires_at,
set_user_password,
increment_user_auth_version,
sync_jellyfin_password_state,
set_user_role,
run_integrity_check,
@@ -69,6 +69,7 @@ from ..db import (
get_signup_invite_by_id,
create_signup_invite,
update_signup_invite,
rotate_signup_invite_code,
delete_signup_invite,
get_signup_invite_by_code,
disable_signup_invites_by_creator,
@@ -779,7 +780,7 @@ async def test_email_settings(request: Request) -> Dict[str, Any]:
result = await send_test_email(recipient_email=recipient_email)
except RuntimeError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
logger.info("Admin triggered SMTP test: recipient=%s", result.get("recipient_email"))
logger.info("Admin triggered SMTP test")
return {"status": "ok", **result}
@@ -1307,12 +1308,12 @@ async def user_system_action(username: str, payload: Dict[str, Any]) -> Dict[str
result["jellyseerr"] = {"status": "error", "detail": _http_error_detail(exc)}
if action == "remove":
deleted = delete_user_by_username(username)
activity_deleted = delete_user_activity_by_username(username)
deletion = delete_user_data_by_username(username)
deleted = bool(deletion.get("deleted"))
result["local"] = {
"status": "ok" if deleted else "not_found",
"deleted": bool(deleted),
"activity_deleted": activity_deleted,
"data_cleanup": deletion,
}
if any(
@@ -1574,6 +1575,7 @@ async def update_user_password(username: str, payload: Dict[str, Any]) -> Dict[s
except Exception as exc:
raise HTTPException(status_code=502, detail=f"Jellyfin password update failed: {exc}") from exc
sync_jellyfin_password_state(username, new_password_clean)
increment_user_auth_version(username)
return {"status": "ok", "username": username, "provider": "jellyfin"}
raise HTTPException(
status_code=400,
@@ -1917,6 +1919,11 @@ async def send_invite_email(payload: Dict[str, Any]) -> Dict[str, Any]:
role=invite.get('role'), max_uses=1, enabled=bool(invite.get('enabled')),
expires_at=invite.get('expires_at'), recipient_email=recipient_email,
)
if not invite:
raise HTTPException(status_code=404, detail='Invite not found')
invite = rotate_signup_invite_code(int(invite['id']), _generate_invite_code())
if not invite:
raise HTTPException(status_code=409, detail='Invite is unavailable')
try:
result = await send_templated_email(
@@ -1930,9 +1937,8 @@ async def send_invite_email(payload: Dict[str, Any]) -> Dict[str, Any]:
except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
logger.info(
"Admin sent invite email template: template=%s recipient=%s invite_id=%s username=%s",
"Admin sent invite email template: template=%s invite_id=%s username=%s",
template_key,
result.get("recipient_email"),
invite.get("id") if invite else None,
user.get("username") if user else None,
)
@@ -1998,15 +2004,14 @@ async def create_invite(payload: Dict[str, Any], current_user: Dict[str, Any] =
except Exception as exc:
email_error = str(exc)
logger.info(
"Admin created invite: invite_id=%s code=%s label=%s profile_id=%s role=%s max_uses=%s enabled=%s recipient_email=%s send_email=%s",
"Admin created invite: invite_id=%s label=%s profile_id=%s role=%s max_uses=%s enabled=%s has_recipient=%s send_email=%s",
invite.get("id"),
invite.get("code"),
invite.get("label"),
invite.get("profile_id"),
invite.get("role"),
invite.get("max_uses"),
invite.get("enabled"),
invite.get("recipient_email"),
bool(invite.get("recipient_email")),
send_email,
)
return {
@@ -2029,7 +2034,11 @@ async def edit_invite(invite_id: int, payload: Dict[str, Any]) -> Dict[str, Any]
existing = get_signup_invite_by_id(invite_id)
if not existing:
raise HTTPException(status_code=404, detail="Invite not found")
code = _normalize_invite_code(_normalize_optional_text(payload.get("code")) or existing["code"])
requested_code = _normalize_optional_text(payload.get("code"))
if requested_code and not requested_code.startswith("••••") and requested_code != "Protected invite":
code = _normalize_invite_code(requested_code)
else:
code = str(existing.get("code") or "")
profile_id = _parse_optional_profile_id(payload.get("profile_id"))
enabled = payload.get("enabled")
if not isinstance(enabled, bool):
@@ -2063,6 +2072,10 @@ async def edit_invite(invite_id: int, payload: Dict[str, Any]) -> Dict[str, Any]
email_error = None
if send_email:
try:
rotated = rotate_signup_invite_code(invite_id, _generate_invite_code())
if not rotated:
raise ValueError("Invite is unavailable")
invite = rotated
email_result = await send_templated_email(
"invited",
invite=invite,
@@ -2072,15 +2085,14 @@ async def edit_invite(invite_id: int, payload: Dict[str, Any]) -> Dict[str, Any]
except Exception as exc:
email_error = str(exc)
logger.info(
"Admin updated invite: invite_id=%s code=%s label=%s profile_id=%s role=%s max_uses=%s enabled=%s recipient_email=%s send_email=%s",
"Admin updated invite: invite_id=%s label=%s profile_id=%s role=%s max_uses=%s enabled=%s has_recipient=%s send_email=%s",
invite.get("id"),
invite.get("code"),
invite.get("label"),
invite.get("profile_id"),
invite.get("role"),
invite.get("max_uses"),
invite.get("enabled"),
invite.get("recipient_email"),
bool(invite.get("recipient_email")),
send_email,
)
return {
@@ -2096,6 +2108,22 @@ async def edit_invite(invite_id: int, payload: Dict[str, Any]) -> Dict[str, Any]
}
@router.post("/invites/{invite_id}/rotate")
async def rotate_invite(
invite_id: int,
current_user: Dict[str, Any] = Depends(require_admin),
) -> Dict[str, Any]:
invite = rotate_signup_invite_code(invite_id, _generate_invite_code())
if not invite:
raise HTTPException(status_code=409, detail="Invite is unavailable")
logger.info(
"Admin rotated invite: invite_id=%s actor=%s",
invite_id,
current_user.get("username"),
)
return {"status": "ok", "invite": invite}
@router.delete("/invites/{invite_id}")
async def remove_invite(invite_id: int) -> Dict[str, Any]:
deleted = delete_signup_invite(invite_id)
+83 -108
View File
@@ -1,11 +1,8 @@
from ..feature_guards import require_invites
from datetime import datetime, timedelta, timezone
from collections import defaultdict, deque
import logging
import secrets
import string
import time
from threading import Lock
import httpx
from fastapi import APIRouter, HTTPException, status, Depends, Request, Response
@@ -28,6 +25,7 @@ from ..db import (
list_signup_invites,
create_signup_invite,
update_signup_invite,
rotate_signup_invite_code,
delete_signup_invite,
reserve_signup_invite_use,
release_signup_invite_use,
@@ -39,6 +37,10 @@ from ..db import (
get_global_request_total,
get_setting,
sync_jellyfin_password_state,
increment_user_auth_version,
get_rate_limit_status,
record_rate_limit_event,
clear_rate_limit_events,
)
from ..runtime import get_runtime_settings
from ..clients.jellyfin import JellyfinClient
@@ -87,14 +89,6 @@ PASSWORD_RESET_GENERIC_MESSAGE = (
"If an account exists for that username or email, a password reset link has been sent."
)
_LOGIN_RATE_LOCK = Lock()
_LOGIN_ATTEMPTS_BY_IP: dict[str, deque[float]] = defaultdict(deque)
_LOGIN_ATTEMPTS_BY_USER: dict[str, deque[float]] = defaultdict(deque)
_RESET_RATE_LOCK = Lock()
_RESET_ATTEMPTS_BY_IP: dict[str, deque[float]] = defaultdict(deque)
_RESET_ATTEMPTS_BY_IDENTIFIER: dict[str, deque[float]] = defaultdict(deque)
def _require_recipient_email(value: object) -> str:
normalized = normalize_delivery_email(value)
if normalized:
@@ -145,12 +139,6 @@ def _password_reset_rate_key_identifier(identifier: str) -> str:
return (identifier or "").strip().lower()[:256] or "<empty>"
def _prune_attempts(bucket: deque[float], now: float, window_seconds: int) -> None:
cutoff = now - window_seconds
while bucket and bucket[0] < cutoff:
bucket.popleft()
def _pick_preferred_ci_user_match(users: list[dict], requested_username: str) -> dict | None:
if not users:
return None
@@ -172,56 +160,33 @@ def _pick_preferred_ci_user_match(users: list[dict], requested_username: str) ->
def _record_login_failure(request: Request, username: str) -> None:
now = time.monotonic()
window = max(int(settings.auth_rate_limit_window_seconds or 60), 1)
ip_key = _auth_client_ip(request)
user_key = _login_rate_key_user(username)
with _LOGIN_RATE_LOCK:
ip_bucket = _LOGIN_ATTEMPTS_BY_IP[ip_key]
user_bucket = _LOGIN_ATTEMPTS_BY_USER[user_key]
_prune_attempts(ip_bucket, now, window)
_prune_attempts(user_bucket, now, window)
ip_bucket.append(now)
user_bucket.append(now)
logger.warning("login failure recorded username=%s client=%s", user_key, ip_key)
record_rate_limit_event("login-ip", ip_key)
record_rate_limit_event("login-user", user_key)
logger.warning("login failure recorded")
def _clear_login_failures(request: Request, username: str) -> None:
ip_key = _auth_client_ip(request)
user_key = _login_rate_key_user(username)
with _LOGIN_RATE_LOCK:
_LOGIN_ATTEMPTS_BY_IP.pop(ip_key, None)
_LOGIN_ATTEMPTS_BY_USER.pop(user_key, None)
clear_rate_limit_events("login-ip", ip_key)
clear_rate_limit_events("login-user", user_key)
def _enforce_login_rate_limit(request: Request, username: str) -> None:
now = time.monotonic()
window = max(int(settings.auth_rate_limit_window_seconds or 60), 1)
max_ip = max(int(settings.auth_rate_limit_max_attempts_ip or 20), 1)
max_user = max(int(settings.auth_rate_limit_max_attempts_user or 10), 1)
ip_key = _auth_client_ip(request)
user_key = _login_rate_key_user(username)
with _LOGIN_RATE_LOCK:
ip_bucket = _LOGIN_ATTEMPTS_BY_IP[ip_key]
user_bucket = _LOGIN_ATTEMPTS_BY_USER[user_key]
_prune_attempts(ip_bucket, now, window)
_prune_attempts(user_bucket, now, window)
exceeded = len(ip_bucket) >= max_ip or len(user_bucket) >= max_user
retry_after = 1
if exceeded:
retry_candidates = []
if ip_bucket:
retry_candidates.append(max(1, int(window - (now - ip_bucket[0]))))
if user_bucket:
retry_candidates.append(max(1, int(window - (now - user_bucket[0]))))
if retry_candidates:
retry_after = max(retry_candidates)
ip_exceeded, ip_retry = get_rate_limit_status("login-ip", ip_key, window, max_ip)
user_exceeded, user_retry = get_rate_limit_status("login-user", user_key, window, max_user)
exceeded = ip_exceeded or user_exceeded
retry_after = max(ip_retry if ip_exceeded else 1, user_retry if user_exceeded else 1)
if exceeded:
logger.warning(
"login rate limit exceeded username=%s client=%s retry_after=%s",
user_key,
ip_key,
retry_after,
"login rate limit exceeded retry_after=%s", retry_after,
)
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
@@ -231,48 +196,28 @@ def _enforce_login_rate_limit(request: Request, username: str) -> None:
def _record_password_reset_attempt(request: Request, identifier: str) -> None:
now = time.monotonic()
window = max(int(settings.password_reset_rate_limit_window_seconds or 300), 1)
ip_key = _auth_client_ip(request)
identifier_key = _password_reset_rate_key_identifier(identifier)
with _RESET_RATE_LOCK:
ip_bucket = _RESET_ATTEMPTS_BY_IP[ip_key]
identifier_bucket = _RESET_ATTEMPTS_BY_IDENTIFIER[identifier_key]
_prune_attempts(ip_bucket, now, window)
_prune_attempts(identifier_bucket, now, window)
ip_bucket.append(now)
identifier_bucket.append(now)
logger.info("password reset rate event recorded identifier=%s client=%s", identifier_key, ip_key)
record_rate_limit_event("reset-ip", ip_key)
record_rate_limit_event("reset-identifier", identifier_key)
logger.info("password reset rate event recorded")
def _enforce_password_reset_rate_limit(request: Request, identifier: str) -> None:
now = time.monotonic()
window = max(int(settings.password_reset_rate_limit_window_seconds or 300), 1)
max_ip = max(int(settings.password_reset_rate_limit_max_attempts_ip or 6), 1)
max_identifier = max(int(settings.password_reset_rate_limit_max_attempts_identifier or 3), 1)
ip_key = _auth_client_ip(request)
identifier_key = _password_reset_rate_key_identifier(identifier)
with _RESET_RATE_LOCK:
ip_bucket = _RESET_ATTEMPTS_BY_IP[ip_key]
identifier_bucket = _RESET_ATTEMPTS_BY_IDENTIFIER[identifier_key]
_prune_attempts(ip_bucket, now, window)
_prune_attempts(identifier_bucket, now, window)
exceeded = len(ip_bucket) >= max_ip or len(identifier_bucket) >= max_identifier
retry_after = 1
if exceeded:
retry_candidates = []
if ip_bucket:
retry_candidates.append(max(1, int(window - (now - ip_bucket[0]))))
if identifier_bucket:
retry_candidates.append(max(1, int(window - (now - identifier_bucket[0]))))
if retry_candidates:
retry_after = max(retry_candidates)
ip_exceeded, ip_retry = get_rate_limit_status("reset-ip", ip_key, window, max_ip)
identifier_exceeded, identifier_retry = get_rate_limit_status(
"reset-identifier", identifier_key, window, max_identifier
)
exceeded = ip_exceeded or identifier_exceeded
retry_after = max(ip_retry if ip_exceeded else 1, identifier_retry if identifier_exceeded else 1)
if exceeded:
logger.warning(
"password reset rate limit exceeded identifier=%s client=%s retry_after=%s",
identifier_key,
ip_key,
retry_after,
"password reset rate limit exceeded retry_after=%s", retry_after,
)
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
@@ -400,6 +345,7 @@ def _auth_success_response(response: Response, token: str, user_payload: dict) -
def _public_invite_payload(invite: dict, profile: dict | None = None) -> dict:
return {
"code": invite.get("code"),
"code_available": bool(invite.get("code_available")),
"email_bound": bool(invite.get("recipient_email")),
"label": invite.get("label"),
"description": invite.get("description"),
@@ -493,6 +439,7 @@ def _serialize_self_invite(invite: dict) -> dict:
return {
"id": invite.get("id"),
"code": invite.get("code"),
"code_available": bool(invite.get("code_available")),
"label": invite.get("label"),
"description": invite.get("description"),
"profile_id": invite.get("profile_id"),
@@ -576,6 +523,7 @@ def _serialize_self_service_master_invite(invite: dict | None) -> dict | None:
return {
"id": invite.get("id"),
"code": invite.get("code"),
"code_available": bool(invite.get("code_available")),
"label": invite.get("label"),
"description": invite.get("description"),
"profile_id": invite.get("profile_id"),
@@ -664,7 +612,9 @@ async def login(
detail="This account uses external sign-in. Use the external sign-in option.",
)
_assert_user_can_login(user)
token = create_access_token(user["username"], user["role"])
token = create_access_token(
user["username"], user["role"], auth_version=int(user.get("auth_version") or 1)
)
_clear_login_failures(request, form_data.username)
set_last_login(user["username"])
logger.info(
@@ -708,7 +658,9 @@ async def jellyfin_login(
matched_email = extract_jellyseerr_user_email(matched_seerr_user)
_assert_user_can_login(user)
if user and _has_valid_jellyfin_cache(user, password):
token = create_access_token(canonical_username, "user")
token = create_access_token(
canonical_username, "user", auth_version=int(user.get("auth_version") or 1)
)
_clear_login_failures(request, username)
set_last_login(canonical_username)
logger.info(
@@ -775,7 +727,10 @@ async def jellyfin_login(
matched_id = match_jellyseerr_user_id(canonical_username, candidate_map)
if matched_id is not None:
set_user_jellyseerr_id(canonical_username, matched_id)
token = create_access_token(canonical_username, "user")
refreshed_user = get_user_by_username(canonical_username) or user or {}
token = create_access_token(
canonical_username, "user", auth_version=int(refreshed_user.get("auth_version") or 1)
)
_clear_login_failures(request, username)
set_last_login(canonical_username)
logger.info(
@@ -851,7 +806,10 @@ async def jellyseerr_login(
set_user_jellyseerr_id(canonical_username, jellyseerr_user_id)
if jellyseerr_email:
set_user_email(canonical_username, jellyseerr_email)
token = create_access_token(canonical_username, "user")
refreshed_user = get_user_by_username(canonical_username) or user or {}
token = create_access_token(
canonical_username, "user", auth_version=int(refreshed_user.get("auth_version") or 1)
)
_clear_login_failures(request, form_data.username)
set_last_login(canonical_username)
logger.info(
@@ -873,7 +831,10 @@ async def me(current_user: dict = Depends(get_current_user)) -> dict:
@router.post("/logout")
async def logout(response: Response) -> dict:
async def logout(
response: Response, current_user: dict = Depends(get_current_user)
) -> dict:
increment_user_auth_version(str(current_user.get("username") or ""))
clear_auth_cookies(response)
return {"status": "ok"}
@@ -884,6 +845,7 @@ async def stream_token(current_user: dict = Depends(get_current_user)) -> dict:
current_user["username"],
current_user["role"],
expires_seconds=STREAM_TOKEN_TTL_SECONDS,
auth_version=int(current_user.get("auth_version") or 1),
)
return {
"stream_token": token,
@@ -923,11 +885,7 @@ async def signup(payload: dict, response: Response) -> dict:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
if get_user_by_username(username):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="User already exists")
logger.info(
"signup attempt username=%s invite_code=%s",
username,
invite_code,
)
logger.info("signup attempt username=%s", username)
invite = get_signup_invite_by_code(invite_code)
if not invite:
@@ -1039,7 +997,7 @@ async def signup(payload: dict, response: Response) -> dict:
auto_search_enabled=auto_search_enabled,
profile_id=int(profile_id) if profile_id is not None else None,
expires_at=expires_at,
invited_by_code=invite.get("code"),
invited_by_code=f"invite:{invite.get('id')}",
)
except Exception as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
@@ -1066,15 +1024,18 @@ async def signup(payload: dict, response: Response) -> dict:
# Welcome email delivery is best-effort and must not break signup.
logger.warning("Welcome email send skipped for %s: %s", username, exc)
_assert_user_can_login(created_user)
token = create_access_token(username, role)
refreshed_user = get_user_by_username(username) or created_user or {}
token = create_access_token(
username, role, auth_version=int(refreshed_user.get("auth_version") or 1)
)
set_last_login(username)
logger.info(
"signup success username=%s role=%s auth_provider=%s profile_id=%s invite_code=%s",
"signup success username=%s role=%s auth_provider=%s profile_id=%s invite_id=%s",
username,
role,
created_user.get("auth_provider") if created_user else auth_provider,
created_user.get("profile_id") if created_user else None,
invite.get("code"),
invite.get("id"),
)
return _auth_success_response(
response,
@@ -1110,8 +1071,7 @@ async def forgot_password(payload: dict, request: Request) -> dict:
)
client_ip = _auth_client_ip(request)
safe_identifier = identifier.strip().lower()[:256]
logger.info("password reset requested identifier=%s client=%s", safe_identifier, client_ip)
logger.info("password reset requested")
try:
reset_result = await request_password_reset(
identifier,
@@ -1120,24 +1080,17 @@ async def forgot_password(payload: dict, request: Request) -> dict:
)
if reset_result.get("issued"):
logger.info(
"password reset issued username=%s provider=%s recipient=%s client=%s",
"password reset issued username=%s provider=%s",
reset_result.get("username"),
reset_result.get("auth_provider"),
reset_result.get("recipient_email"),
client_ip,
)
else:
logger.info(
"password reset request completed with no eligible account identifier=%s client=%s",
safe_identifier,
client_ip,
"password reset request completed with no eligible account",
)
except Exception as exc:
logger.warning(
"password reset email dispatch failed identifier=%s client=%s detail=%s",
safe_identifier,
client_ip,
str(exc),
"password reset email dispatch failed detail=%s", type(exc).__name__,
)
return {"status": "ok", "message": PASSWORD_RESET_GENERIC_MESSAGE}
@@ -1371,8 +1324,13 @@ async def update_profile_invite(
_require_self_service_invite_access(current_user)
existing = _get_owned_invite(invite_id, current_user)
requested_code = payload.get("code", existing.get("code"))
if isinstance(requested_code, str) and requested_code.strip():
requested_code = payload.get("code")
if (
isinstance(requested_code, str)
and requested_code.strip()
and not requested_code.strip().startswith("••••")
and requested_code.strip() != "Protected invite"
):
code = _normalize_invite_code(requested_code)
else:
code = str(existing.get("code") or "").strip()
@@ -1427,6 +1385,10 @@ async def update_profile_invite(
email_error = None
if send_email:
try:
rotated = rotate_signup_invite_code(invite_id, _generate_invite_code())
if not rotated:
raise ValueError("Invite is unavailable")
invite = rotated
email_result = await send_templated_email(
"invited",
invite=invite,
@@ -1450,6 +1412,18 @@ async def update_profile_invite(
}
@router.post("/profile/invites/{invite_id}/rotate")
async def rotate_profile_invite(
invite_id: int, current_user: dict = Depends(get_current_user)
) -> dict:
_require_self_service_invite_access(current_user)
_get_owned_invite(invite_id, current_user)
invite = rotate_signup_invite_code(invite_id, _generate_invite_code())
if not invite:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Invite is unavailable")
return {"status": "ok", "invite": _serialize_self_invite(invite)}
@router.delete("/profile/invites/{invite_id}")
async def delete_profile_invite(invite_id: int, current_user: dict = Depends(get_current_user)) -> dict:
_require_self_service_invite_access(current_user)
@@ -1531,6 +1505,7 @@ async def change_password(payload: dict, current_user: dict = Depends(get_curren
# Keep Magent's password hash and Jellyfin auth cache aligned with Jellyfin.
sync_jellyfin_password_state(username, new_password_clean)
increment_user_auth_version(username)
logger.info("password change completed username=%s provider=jellyfin", username)
return {"status": "ok", "provider": "jellyfin"}
+23 -5
View File
@@ -1,4 +1,5 @@
import os
import warnings
from io import BytesIO
from typing import Any, Dict
@@ -15,6 +16,10 @@ _BUNDLED_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "as
_BUNDLED_LOGO_PATH = os.path.join(_BUNDLED_DIR, "logo.png")
_BUNDLED_FAVICON_PATH = os.path.join(_BUNDLED_DIR, "favicon.ico")
_BRANDING_SOURCE = os.getenv("BRANDING_SOURCE", "bundled").lower()
_MAX_UPLOAD_BYTES = 5 * 1024 * 1024
_MAX_IMAGE_PIXELS = 25_000_000
_ALLOWED_IMAGE_TYPES = {"image/png", "image/jpeg", "image/webp"}
_ALLOWED_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp"}
def _ensure_branding_dir() -> None:
@@ -110,14 +115,27 @@ async def branding_favicon() -> FileResponse:
async def save_branding_image(file: UploadFile) -> Dict[str, Any]:
if not file.content_type or not file.content_type.startswith("image/"):
raise HTTPException(status_code=400, detail="Please upload an image file.")
content = await file.read()
content_type = str(file.content_type or "").lower()
extension = os.path.splitext(str(file.filename or ""))[1].lower()
if content_type not in _ALLOWED_IMAGE_TYPES or extension not in _ALLOWED_IMAGE_EXTENSIONS:
raise HTTPException(status_code=400, detail="Upload a PNG, JPEG, or WebP image.")
content = await file.read(_MAX_UPLOAD_BYTES + 1)
if not content:
raise HTTPException(status_code=400, detail="Uploaded file is empty.")
if len(content) > _MAX_UPLOAD_BYTES:
raise HTTPException(status_code=413, detail="Image is too large (maximum 5 MB).")
try:
image = Image.open(BytesIO(content))
except OSError as exc:
with warnings.catch_warnings():
warnings.simplefilter("error", Image.DecompressionBombWarning)
candidate = Image.open(BytesIO(content))
if candidate.format not in {"PNG", "JPEG", "WEBP"}:
raise ValueError("Unsupported image format")
if candidate.width * candidate.height > _MAX_IMAGE_PIXELS:
raise Image.DecompressionBombError("Image pixel limit exceeded")
candidate.verify()
image = Image.open(BytesIO(content))
image.load()
except (OSError, ValueError, Image.DecompressionBombError, Image.DecompressionBombWarning) as exc:
raise HTTPException(status_code=400, detail="Image file could not be read.") from exc
_ensure_branding_dir()
+95 -59
View File
@@ -1593,11 +1593,54 @@ def get_requests_sync_state() -> Dict[str, Any]:
async def _ensure_request_access(
client: JellyseerrClient, request_id: int, user: Dict[str, str]
) -> None:
if user.get("role") == "admin" or user.get("username"):
return
raise HTTPException(status_code=403, detail="Request not accessible for this user")
client: JellyseerrClient,
request_id: int,
user: Dict[str, Any],
*,
require_owner: bool = False,
) -> Optional[Dict[str, Any]]:
if user.get("role") == "admin":
return None
if not user.get("username"):
raise HTTPException(status_code=403, detail="Request not accessible for this user")
if not require_owner:
return None
request_data = await client.get_request(str(request_id))
if not isinstance(request_data, dict):
raise HTTPException(status_code=404, detail="Request not found")
requester_id = _extract_requested_by_id(request_data)
current_seerr_id = user.get("jellyseerr_user_id")
if isinstance(current_seerr_id, int) and requester_id == current_seerr_id:
return request_data
if _request_matches_user(request_data, str(user.get("username") or "")):
return request_data
email = str(user.get("email") or "").strip()
if email and _request_matches_user(request_data, email):
return request_data
raise HTTPException(
status_code=403,
detail="Only the original requester or an administrator can change this request",
)
async def _ensure_request_mutation_access(
runtime: Any, request_id: int, user: Dict[str, Any]
) -> Optional[Dict[str, Any]]:
"""Fail closed when a non-admin request owner cannot be verified."""
if user.get("role") == "admin":
return None
client = JellyseerrClient(
getattr(runtime, "jellyseerr_base_url", None),
getattr(runtime, "jellyseerr_api_key", None),
)
if not client.configured():
raise HTTPException(
status_code=403,
detail="Request ownership cannot be verified while Seerr is unavailable",
)
return await _ensure_request_access(
client, request_id, user, require_owner=True
)
def _build_recent_map(response: Dict[str, Any]) -> Dict[int, Dict[str, Any]]:
@@ -1948,9 +1991,6 @@ async def issue_target_options(
if not request_id.isdigit():
raise HTTPException(status_code=400, detail="Invalid request id")
runtime = get_runtime_settings()
seerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
if seerr.configured():
await _ensure_request_access(seerr, int(request_id), user)
snapshot = await build_snapshot(request_id)
arr_item = snapshot.raw.get("arr", {}).get("item")
if not isinstance(arr_item, dict):
@@ -2136,9 +2176,7 @@ async def action_replace_media(
)
runtime = get_runtime_settings()
seerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
if seerr.configured():
await _ensure_request_access(seerr, int(request_id), user)
await _ensure_request_mutation_access(runtime, int(request_id), user)
snapshot = await build_snapshot(request_id)
arr_item = snapshot.raw.get("arr", {}).get("item")
if not isinstance(arr_item, dict):
@@ -2360,6 +2398,7 @@ async def action_search_missing_media(
payload.get("season_numbers"), field="season_numbers", maximum=100, minimum=0
)
runtime = get_runtime_settings()
await _ensure_request_mutation_access(runtime, int(request_id), user)
snapshot = await build_snapshot(request_id)
arr_item = snapshot.raw.get("arr", {}).get("item")
if not isinstance(arr_item, dict) or not isinstance(arr_item.get("id"), int):
@@ -2497,9 +2536,7 @@ async def action_add_seasons(
raise HTTPException(status_code=400, detail="Choose at least one season")
runtime = get_runtime_settings()
seerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
if seerr.configured():
await _ensure_request_access(seerr, int(request_id), user)
await _ensure_request_mutation_access(runtime, int(request_id), user)
snapshot = await build_snapshot(request_id)
if snapshot.request_type != RequestType.tv:
raise HTTPException(status_code=400, detail="Additional seasons are only available for TV requests")
@@ -2627,6 +2664,7 @@ async def action_repair_subtitles(
episode_ids = _positive_id_list(payload.get("episode_ids"), field="episode_ids", maximum=100)
forced = payload.get("forced") is True
runtime = get_runtime_settings()
await _ensure_request_mutation_access(runtime, int(request_id), user)
bazarr = BazarrClient(runtime.bazarr_base_url, runtime.bazarr_api_key)
if not bazarr.configured() or not runtime.bazarr_api_key:
raise HTTPException(status_code=400, detail="Bazarr is not configured")
@@ -2772,33 +2810,36 @@ async def action_recheck(request_id: str, user: Dict[str, str] = Depends(get_cur
seerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
if not seerr.configured():
raise HTTPException(status_code=400, detail="Seerr is not configured")
await _ensure_request_access(seerr, int(request_id), user)
fresh_request = await _ensure_request_access(
seerr, int(request_id), user, require_owner=True
)
try:
fresh_request = await seerr.get_request(request_id)
except httpx.HTTPStatusError as exc:
detail = _format_upstream_error("Seerr", exc)
await asyncio.to_thread(
save_action,
request_id,
"recheck_pipeline",
"Recheck request status",
"failed",
detail,
)
raise HTTPException(status_code=502, detail=detail) from exc
except Exception as exc:
logger.warning("Request recheck failed while reading Seerr: request_id=%s error=%s", request_id, exc)
detail = "Magent could not reach Seerr to recheck this request."
await asyncio.to_thread(
save_action,
request_id,
"recheck_pipeline",
"Recheck request status",
"failed",
detail,
)
raise HTTPException(status_code=502, detail=detail) from exc
if fresh_request is None:
try:
fresh_request = await seerr.get_request(request_id)
except httpx.HTTPStatusError as exc:
detail = _format_upstream_error("Seerr", exc)
await asyncio.to_thread(
save_action,
request_id,
"recheck_pipeline",
"Recheck request status",
"failed",
detail,
)
raise HTTPException(status_code=502, detail=detail) from exc
except Exception as exc:
logger.warning("Request recheck failed while reading Seerr: request_id=%s error=%s", request_id, exc)
detail = "Magent could not reach Seerr to recheck this request."
await asyncio.to_thread(
save_action,
request_id,
"recheck_pipeline",
"Recheck request status",
"failed",
detail,
)
raise HTTPException(status_code=502, detail=detail) from exc
if not isinstance(fresh_request, dict):
raise HTTPException(status_code=404, detail="Request not found in Seerr")
@@ -3444,11 +3485,14 @@ async def ai_triage(request_id: str, user: Dict[str, str] = Depends(get_current_
return triage_snapshot(snapshot)
async def _request_language_context(request_id, user):
async def _request_language_context(request_id, user, *, require_owner: bool = False):
runtime = get_runtime_settings()
seerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
await _ensure_request_access(seerr, int(request_id), user)
request = await seerr.get_request(request_id)
request = await _ensure_request_access(
seerr, int(request_id), user, require_owner=require_owner
)
if request is None:
request = await seerr.get_request(request_id)
if not isinstance(request, dict) or request.get('type') != 'movie':
return runtime, None, None
tmdb_id = (request.get('media') or {}).get('tmdbId')
@@ -3479,7 +3523,9 @@ async def accept_request_language(request_id: str, payload: dict, user: dict = D
raise HTTPException(403, 'Search and download changes are disabled for this account.')
if payload.get('acceptOriginalLanguage') is not True:
raise HTTPException(400, 'Explicitly accept original-language audio before continuing.')
runtime, tmdb_id, language = await _request_language_context(request_id, user)
runtime, tmdb_id, language = await _request_language_context(
request_id, user, require_owner=True
)
if not language:
raise HTTPException(409, 'This request has no verified non-English original language.')
if payload.get('languageCode') != language['code']:
@@ -3502,9 +3548,7 @@ async def action_search(request_id: str, user: Dict[str, str] = Depends(get_curr
total_missing = 0
next_offset = None
runtime = get_runtime_settings()
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
if client.configured():
await _ensure_request_access(client, int(request_id), user)
await _ensure_request_mutation_access(runtime, int(request_id), user)
snapshot = await build_snapshot(request_id)
arr_item = snapshot.raw.get("arr", {}).get("item")
if not isinstance(arr_item, dict) or not isinstance(arr_item.get("id"), int):
@@ -3612,9 +3656,7 @@ async def action_search_auto(request_id: str, user: Dict[str, str] = Depends(get
if not _user_can_use_search_auto(user):
raise HTTPException(status_code=403, detail="Auto search and download is disabled for this user")
runtime = get_runtime_settings()
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
if client.configured():
await _ensure_request_access(client, int(request_id), user)
await _ensure_request_mutation_access(runtime, int(request_id), user)
snapshot = await build_snapshot(request_id)
arr_item = snapshot.raw.get("arr", {}).get("item")
if not isinstance(arr_item, dict):
@@ -3664,9 +3706,7 @@ async def action_search_auto(request_id: str, user: Dict[str, str] = Depends(get
@router.post("/{request_id}/actions/qbit/resume")
async def action_resume(request_id: str, user: Dict[str, str] = Depends(get_current_user)) -> dict:
runtime = get_runtime_settings()
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
if client.configured():
await _ensure_request_access(client, int(request_id), user)
await _ensure_request_mutation_access(runtime, int(request_id), user)
snapshot = await build_snapshot(request_id)
queue = snapshot.raw.get("arr", {}).get("queue")
download_ids = _download_ids(_queue_records(queue))
@@ -3711,9 +3751,7 @@ async def action_resume(request_id: str, user: Dict[str, str] = Depends(get_curr
@router.post("/{request_id}/actions/readd")
async def action_readd(request_id: str, user: Dict[str, str] = Depends(get_current_user)) -> dict:
runtime = get_runtime_settings()
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
if client.configured():
await _ensure_request_access(client, int(request_id), user)
await _ensure_request_mutation_access(runtime, int(request_id), user)
snapshot = await build_snapshot(request_id)
jelly = snapshot.raw.get("jellyseerr") or {}
media = jelly.get("media") or {}
@@ -3870,9 +3908,7 @@ async def action_grab(
request_id: str, payload: Dict[str, Any], user: Dict[str, str] = Depends(get_current_user)
) -> dict:
runtime = get_runtime_settings()
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
if client.configured():
await _ensure_request_access(client, int(request_id), user)
await _ensure_request_mutation_access(runtime, int(request_id), user)
snapshot = await build_snapshot(request_id)
guid = payload.get("guid")
indexer_id = payload.get("indexerId")