security: harden data auth and deployment
This commit is contained in:
@@ -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
@@ -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"}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user