Files
Magent/backend/app/routers/auth.py

1038 lines
41 KiB
Python

from datetime import datetime, timedelta, timezone
from collections import defaultdict, deque
import secrets
import string
import time
from threading import Lock
import httpx
from fastapi import APIRouter, HTTPException, status, Depends, Request
from fastapi.security import OAuth2PasswordRequestForm
from ..db import (
verify_user_password,
create_user,
create_user_if_missing,
set_last_login,
get_user_by_username,
get_users_by_username_ci,
set_user_password,
set_jellyfin_auth_cache,
set_user_jellyseerr_id,
set_user_auth_provider,
get_signup_invite_by_code,
get_signup_invite_by_id,
list_signup_invites,
create_signup_invite,
update_signup_invite,
delete_signup_invite,
increment_signup_invite_use,
get_user_profile,
get_user_activity,
get_user_activity_summary,
get_user_request_stats,
get_global_request_leader,
get_global_request_total,
get_setting,
)
from ..runtime import get_runtime_settings
from ..clients.jellyfin import JellyfinClient
from ..clients.jellyseerr import JellyseerrClient
from ..security import create_access_token, verify_password
from ..security import create_stream_token
from ..auth import get_current_user
from ..config import settings
from ..services.user_cache import (
build_jellyseerr_candidate_map,
get_cached_jellyseerr_users,
match_jellyseerr_user_id,
save_jellyfin_users_cache,
)
router = APIRouter(prefix="/auth", tags=["auth"])
SELF_SERVICE_INVITE_MASTER_ID_KEY = "self_service_invite_master_id"
STREAM_TOKEN_TTL_SECONDS = 120
_LOGIN_RATE_LOCK = Lock()
_LOGIN_ATTEMPTS_BY_IP: dict[str, deque[float]] = defaultdict(deque)
_LOGIN_ATTEMPTS_BY_USER: dict[str, deque[float]] = defaultdict(deque)
def _auth_client_ip(request: Request) -> str:
forwarded = request.headers.get("x-forwarded-for")
if isinstance(forwarded, str) and forwarded.strip():
return forwarded.split(",", 1)[0].strip()
real = request.headers.get("x-real-ip")
if isinstance(real, str) and real.strip():
return real.strip()
if request.client and request.client.host:
return str(request.client.host)
return "unknown"
def _login_rate_key_user(username: str) -> str:
return (username 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
requested = (requested_username or "").strip()
requested_lower = requested.lower()
def _rank(user: dict) -> tuple[int, int, int, int]:
provider = str(user.get("auth_provider") or "local").strip().lower()
role = str(user.get("role") or "user").strip().lower()
username = str(user.get("username") or "")
return (
0 if role == "admin" else 1,
0 if isinstance(user.get("jellyseerr_user_id"), int) else 1,
0 if provider == "jellyfin" else (1 if provider == "local" else (2 if provider == "jellyseerr" else 3)),
0 if username.lower() == requested_lower else 1,
)
return sorted(users, key=_rank)[0]
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)
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)
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)
if exceeded:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Too many login attempts. Try again shortly.",
headers={"Retry-After": str(retry_after)},
)
def _normalize_username(value: str) -> str:
normalized = value.strip().lower()
if "@" in normalized:
normalized = normalized.split("@", 1)[0]
return normalized
def _is_recent_jellyfin_auth(last_auth_at: str) -> bool:
if not last_auth_at:
return False
try:
parsed = datetime.fromisoformat(last_auth_at)
except ValueError:
return False
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
age = datetime.now(timezone.utc) - parsed
return age <= timedelta(days=7)
def _has_valid_jellyfin_cache(user: dict, password: str) -> bool:
if not user or not password:
return False
cached_hash = user.get("jellyfin_password_hash")
last_auth_at = user.get("last_jellyfin_auth_at")
if not cached_hash or not last_auth_at:
return False
if not verify_password(password, cached_hash):
return False
return _is_recent_jellyfin_auth(last_auth_at)
def _extract_jellyseerr_user_id(response: dict) -> int | None:
if not isinstance(response, dict):
return None
candidate = response
if isinstance(response.get("user"), dict):
candidate = response.get("user")
for key in ("id", "userId", "Id"):
value = candidate.get(key) if isinstance(candidate, dict) else None
if value is None:
continue
try:
return int(value)
except (TypeError, ValueError):
continue
return None
def _extract_http_error_detail(exc: Exception) -> str:
if isinstance(exc, httpx.HTTPStatusError):
response = exc.response
try:
text = response.text.strip()
except Exception:
text = ""
if text:
return text
return f"HTTP {response.status_code}"
return str(exc)
async def _refresh_jellyfin_user_cache(client: JellyfinClient) -> None:
try:
users = await client.get_users()
if isinstance(users, list):
save_jellyfin_users_cache(users)
except Exception:
# Cache refresh is best-effort and should not block auth/signup.
return
def _is_user_expired(user: dict | None) -> bool:
if not user:
return False
expires_at = user.get("expires_at")
if not expires_at:
return False
try:
parsed = datetime.fromisoformat(str(expires_at).replace("Z", "+00:00"))
except ValueError:
return False
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed <= datetime.now(timezone.utc)
def _assert_user_can_login(user: dict | None) -> None:
if not user:
return
if user.get("is_blocked"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked")
if _is_user_expired(user):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User access has expired")
def _public_invite_payload(invite: dict, profile: dict | None = None) -> dict:
return {
"code": invite.get("code"),
"label": invite.get("label"),
"description": invite.get("description"),
"enabled": bool(invite.get("enabled")),
"expires_at": invite.get("expires_at"),
"max_uses": invite.get("max_uses"),
"use_count": invite.get("use_count", 0),
"remaining_uses": invite.get("remaining_uses"),
"is_expired": bool(invite.get("is_expired")),
"is_usable": bool(invite.get("is_usable")),
"profile": (
{
"id": profile.get("id"),
"name": profile.get("name"),
"description": profile.get("description"),
}
if profile
else None
),
}
def _parse_optional_positive_int(value: object, field_name: str) -> int | None:
if value is None or value == "":
return None
try:
parsed = int(value)
except (TypeError, ValueError) as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"{field_name} must be a number") from exc
if parsed <= 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"{field_name} must be greater than 0",
)
return parsed
def _parse_optional_expires_at(value: object) -> str | None:
if value is None or value == "":
return None
if not isinstance(value, str):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="expires_at must be an ISO datetime string",
)
candidate = value.strip()
if not candidate:
return None
try:
parsed = datetime.fromisoformat(candidate.replace("Z", "+00:00"))
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="expires_at must be a valid ISO datetime",
) from exc
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.isoformat()
def _normalize_invite_code(value: str | None) -> str:
raw = (value or "").strip().upper()
filtered = "".join(ch for ch in raw if ch.isalnum())
if len(filtered) < 6:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invite code must be at least 6 letters/numbers.",
)
return filtered
def _generate_invite_code(length: int = 12) -> str:
alphabet = string.ascii_uppercase + string.digits
return "".join(secrets.choice(alphabet) for _ in range(length))
def _same_username(a: object, b: object) -> bool:
if not isinstance(a, str) or not isinstance(b, str):
return False
return a.strip().lower() == b.strip().lower()
def _serialize_self_invite(invite: dict) -> dict:
profile = None
profile_id = invite.get("profile_id")
if profile_id is not None:
try:
profile = get_user_profile(int(profile_id))
except Exception:
profile = None
return {
"id": invite.get("id"),
"code": invite.get("code"),
"label": invite.get("label"),
"description": invite.get("description"),
"profile_id": invite.get("profile_id"),
"profile": (
{"id": profile.get("id"), "name": profile.get("name")}
if isinstance(profile, dict)
else None
),
"role": invite.get("role"),
"max_uses": invite.get("max_uses"),
"use_count": invite.get("use_count", 0),
"remaining_uses": invite.get("remaining_uses"),
"enabled": bool(invite.get("enabled")),
"expires_at": invite.get("expires_at"),
"is_expired": bool(invite.get("is_expired")),
"is_usable": bool(invite.get("is_usable")),
"created_at": invite.get("created_at"),
"updated_at": invite.get("updated_at"),
"created_by": invite.get("created_by"),
}
def _current_user_invites(username: str) -> list[dict]:
owned = [
invite
for invite in list_signup_invites()
if _same_username(invite.get("created_by"), username)
]
owned.sort(key=lambda item: (str(item.get("created_at") or ""), int(item.get("id") or 0)), reverse=True)
return owned
def _get_owned_invite(invite_id: int, current_user: dict) -> dict:
invite = get_signup_invite_by_id(invite_id)
if not invite:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invite not found")
if not _same_username(invite.get("created_by"), current_user.get("username")):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You can only manage your own invites")
return invite
def _self_service_invite_access_enabled(current_user: dict) -> bool:
if str(current_user.get("role") or "").lower() == "admin":
return True
return bool(current_user.get("invite_management_enabled", False))
def _require_self_service_invite_access(current_user: dict) -> None:
if _self_service_invite_access_enabled(current_user):
return
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invite management is not enabled for your account.",
)
def _get_self_service_master_invite() -> dict | None:
raw_value = get_setting(SELF_SERVICE_INVITE_MASTER_ID_KEY)
if raw_value is None:
return None
candidate = str(raw_value).strip()
if not candidate:
return None
try:
invite_id = int(candidate)
except (TypeError, ValueError):
return None
if invite_id <= 0:
return None
return get_signup_invite_by_id(invite_id)
def _serialize_self_service_master_invite(invite: dict | None) -> dict | None:
if not isinstance(invite, dict):
return None
profile = None
profile_id = invite.get("profile_id")
if isinstance(profile_id, int):
profile = get_user_profile(profile_id)
return {
"id": invite.get("id"),
"code": invite.get("code"),
"label": invite.get("label"),
"description": invite.get("description"),
"profile_id": invite.get("profile_id"),
"profile": (
{"id": profile.get("id"), "name": profile.get("name")}
if isinstance(profile, dict)
else None
),
"role": invite.get("role"),
"max_uses": invite.get("max_uses"),
"enabled": bool(invite.get("enabled")),
"expires_at": invite.get("expires_at"),
"is_expired": bool(invite.get("is_expired")),
"is_usable": bool(invite.get("is_usable")),
"created_at": invite.get("created_at"),
"updated_at": invite.get("updated_at"),
}
def _master_invite_controlled_values(master_invite: dict) -> tuple[int | None, str, int | None, bool, str | None]:
profile_id_raw = master_invite.get("profile_id")
profile_id: int | None = None
if isinstance(profile_id_raw, int):
profile_id = profile_id_raw
elif profile_id_raw not in (None, ""):
try:
profile_id = int(profile_id_raw)
except (TypeError, ValueError):
profile_id = None
role_value = str(master_invite.get("role") or "").strip().lower()
role = role_value if role_value in {"user", "admin"} else "user"
max_uses_raw = master_invite.get("max_uses")
try:
max_uses = int(max_uses_raw) if max_uses_raw is not None else None
except (TypeError, ValueError):
max_uses = None
enabled = bool(master_invite.get("enabled", True))
expires_at_value = master_invite.get("expires_at")
expires_at = str(expires_at_value).strip() if isinstance(expires_at_value, str) and str(expires_at_value).strip() else None
return profile_id, role, max_uses, enabled, expires_at
@router.post("/login")
async def login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
_enforce_login_rate_limit(request, form_data.username)
# Provider placeholder passwords must never be accepted by the local-login endpoint.
if form_data.password in {"jellyfin-user", "jellyseerr-user"}:
_record_login_failure(request, form_data.username)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
matching_users = get_users_by_username_ci(form_data.username)
has_external_match = any(
str(user.get("auth_provider") or "local").lower() != "local" for user in matching_users
)
if has_external_match:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="This account uses external sign-in. Use the external sign-in option.",
)
user = verify_user_password(form_data.username, form_data.password)
if not user:
_record_login_failure(request, form_data.username)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
if user.get("auth_provider") != "local":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
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"])
_clear_login_failures(request, form_data.username)
set_last_login(user["username"])
return {
"access_token": token,
"token_type": "bearer",
"user": {"username": user["username"], "role": user["role"]},
}
@router.post("/jellyfin/login")
async def jellyfin_login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
_enforce_login_rate_limit(request, form_data.username)
runtime = get_runtime_settings()
client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
if not client.configured():
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Jellyfin not configured")
jellyseerr_users = get_cached_jellyseerr_users()
candidate_map = build_jellyseerr_candidate_map(jellyseerr_users or [])
username = form_data.username
password = form_data.password
ci_matches = get_users_by_username_ci(username)
preferred_match = _pick_preferred_ci_user_match(ci_matches, username)
canonical_username = str(preferred_match.get("username") or username) if preferred_match else username
user = preferred_match or get_user_by_username(username)
_assert_user_can_login(user)
if user and _has_valid_jellyfin_cache(user, password):
token = create_access_token(canonical_username, "user")
_clear_login_failures(request, username)
set_last_login(canonical_username)
return {
"access_token": token,
"token_type": "bearer",
"user": {"username": canonical_username, "role": "user"},
}
try:
response = await client.authenticate_by_name(username, password)
except Exception as exc:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
if not isinstance(response, dict) or not response.get("User"):
_record_login_failure(request, username)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Jellyfin credentials")
if not preferred_match:
create_user_if_missing(canonical_username, "jellyfin-user", role="user", auth_provider="jellyfin")
elif (
user
and str(user.get("role") or "user").strip().lower() != "admin"
and str(user.get("auth_provider") or "local").strip().lower() != "jellyfin"
):
set_user_auth_provider(canonical_username, "jellyfin")
user = get_user_by_username(canonical_username)
user = get_user_by_username(canonical_username)
_assert_user_can_login(user)
try:
users = await client.get_users()
if isinstance(users, list):
save_jellyfin_users_cache(users)
except Exception:
pass
set_jellyfin_auth_cache(canonical_username, password)
if user and user.get("jellyseerr_user_id") is None and candidate_map:
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")
_clear_login_failures(request, username)
set_last_login(canonical_username)
return {
"access_token": token,
"token_type": "bearer",
"user": {"username": canonical_username, "role": "user"},
}
@router.post("/jellyseerr/login")
async def jellyseerr_login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
_enforce_login_rate_limit(request, form_data.username)
runtime = get_runtime_settings()
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
if not client.configured():
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Jellyseerr not configured")
payload = {"email": form_data.username, "password": form_data.password}
try:
response = await client.post("/api/v1/auth/login", payload=payload)
except Exception as exc:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
if not isinstance(response, dict):
_record_login_failure(request, form_data.username)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Jellyseerr credentials")
jellyseerr_user_id = _extract_jellyseerr_user_id(response)
ci_matches = get_users_by_username_ci(form_data.username)
preferred_match = _pick_preferred_ci_user_match(ci_matches, form_data.username)
canonical_username = str(preferred_match.get("username") or form_data.username) if preferred_match else form_data.username
if not preferred_match:
create_user_if_missing(
canonical_username,
"jellyseerr-user",
role="user",
auth_provider="jellyseerr",
jellyseerr_user_id=jellyseerr_user_id,
)
user = get_user_by_username(canonical_username)
_assert_user_can_login(user)
if jellyseerr_user_id is not None:
set_user_jellyseerr_id(canonical_username, jellyseerr_user_id)
token = create_access_token(canonical_username, "user")
_clear_login_failures(request, form_data.username)
set_last_login(canonical_username)
return {
"access_token": token,
"token_type": "bearer",
"user": {"username": canonical_username, "role": "user"},
}
@router.get("/me")
async def me(current_user: dict = Depends(get_current_user)) -> dict:
return current_user
@router.get("/stream-token")
async def stream_token(current_user: dict = Depends(get_current_user)) -> dict:
token = create_stream_token(
current_user["username"],
current_user["role"],
expires_seconds=STREAM_TOKEN_TTL_SECONDS,
)
return {
"stream_token": token,
"token_type": "bearer",
"expires_in": STREAM_TOKEN_TTL_SECONDS,
}
@router.get("/invites/{code}")
async def invite_details(code: str) -> dict:
invite = get_signup_invite_by_code(code.strip())
if not invite:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invite not found")
profile = None
profile_id = invite.get("profile_id")
if profile_id is not None:
profile = get_user_profile(int(profile_id))
if profile and not profile.get("is_active", True):
invite = {**invite, "is_usable": False}
return {"invite": _public_invite_payload(invite, profile)}
@router.post("/signup")
async def signup(payload: dict) -> dict:
if not isinstance(payload, dict):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
invite_code = str(payload.get("invite_code") or "").strip()
username = str(payload.get("username") or "").strip()
password = str(payload.get("password") or "")
if not invite_code:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invite code is required")
if not username:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Username is required")
if len(password.strip()) < 8:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Password must be at least 8 characters.",
)
if get_user_by_username(username):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="User already exists")
invite = get_signup_invite_by_code(invite_code)
if not invite:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invite not found")
if not invite.get("enabled"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invite is disabled")
if invite.get("is_expired"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invite has expired")
remaining_uses = invite.get("remaining_uses")
if remaining_uses is not None and int(remaining_uses) <= 0:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invite has no remaining uses")
profile = None
profile_id = invite.get("profile_id")
if profile_id is not None:
profile = get_user_profile(int(profile_id))
if not profile:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invite profile not found")
if not profile.get("is_active", True):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invite profile is disabled")
invite_role = invite.get("role")
profile_role = profile.get("role") if profile else None
role = invite_role if invite_role in {"user", "admin"} else profile_role
if role not in {"user", "admin"}:
role = "user"
auto_search_enabled = (
bool(profile.get("auto_search_enabled", True))
if profile is not None
else True
)
expires_at = None
account_expires_days = profile.get("account_expires_days") if profile else None
if isinstance(account_expires_days, int) and account_expires_days > 0:
expires_at = (datetime.now(timezone.utc) + timedelta(days=account_expires_days)).isoformat()
runtime = get_runtime_settings()
password_value = password.strip()
auth_provider = "local"
local_password_value = password_value
matched_jellyseerr_user_id: int | None = None
jellyfin_client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
if jellyfin_client.configured():
auth_provider = "jellyfin"
local_password_value = "jellyfin-user"
try:
await jellyfin_client.create_user_with_password(username, password_value)
except httpx.HTTPStatusError as exc:
status_code = exc.response.status_code if exc.response is not None else None
duplicate_like = status_code in {400, 409}
if duplicate_like:
try:
response = await jellyfin_client.authenticate_by_name(username, password_value)
except Exception as auth_exc:
detail = _extract_http_error_detail(auth_exc) or _extract_http_error_detail(exc)
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Jellyfin account already exists and could not be authenticated: {detail}",
) from exc
if not isinstance(response, dict) or not response.get("User"):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Jellyfin account already exists for that username.",
) from exc
else:
detail = _extract_http_error_detail(exc)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Jellyfin account provisioning failed: {detail}",
) from exc
except Exception as exc:
detail = _extract_http_error_detail(exc)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Jellyfin account provisioning failed: {detail}",
) from exc
await _refresh_jellyfin_user_cache(jellyfin_client)
jellyseerr_users = get_cached_jellyseerr_users()
candidate_map = build_jellyseerr_candidate_map(jellyseerr_users or [])
if candidate_map:
matched_jellyseerr_user_id = match_jellyseerr_user_id(username, candidate_map)
try:
create_user(
username,
local_password_value,
role=role,
auth_provider=auth_provider,
jellyseerr_user_id=matched_jellyseerr_user_id,
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"),
)
except Exception as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
increment_signup_invite_use(int(invite["id"]))
created_user = get_user_by_username(username)
if auth_provider == "jellyfin":
set_jellyfin_auth_cache(username, password_value)
if (
created_user
and created_user.get("jellyseerr_user_id") is None
and matched_jellyseerr_user_id is not None
):
set_user_jellyseerr_id(username, matched_jellyseerr_user_id)
created_user = get_user_by_username(username)
_assert_user_can_login(created_user)
token = create_access_token(username, role)
set_last_login(username)
return {
"access_token": token,
"token_type": "bearer",
"user": {
"username": username,
"role": role,
"auth_provider": created_user.get("auth_provider") if created_user else auth_provider,
"profile_id": created_user.get("profile_id") if created_user else None,
"expires_at": created_user.get("expires_at") if created_user else None,
},
}
@router.get("/profile")
async def profile(current_user: dict = Depends(get_current_user)) -> dict:
username = current_user.get("username") or ""
username_norm = _normalize_username(username) if username else ""
stats = get_user_request_stats(username_norm, current_user.get("jellyseerr_user_id"))
global_total = get_global_request_total()
share = (stats.get("total", 0) / global_total) if global_total else 0
activity_summary = get_user_activity_summary(username) if username else {}
activity_recent = get_user_activity(username, limit=5) if username else []
stats_payload = {
**stats,
"share": share,
"global_total": global_total,
}
if current_user.get("role") == "admin":
stats_payload["most_active_user"] = get_global_request_leader()
return {
"user": current_user,
"stats": stats_payload,
"activity": {
**activity_summary,
"recent": activity_recent,
},
}
@router.get("/profile/invites")
async def profile_invites(current_user: dict = Depends(get_current_user)) -> dict:
username = str(current_user.get("username") or "").strip()
if not username:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid user")
master_invite = _get_self_service_master_invite()
invite_access_enabled = _self_service_invite_access_enabled(current_user)
invites = [_serialize_self_invite(invite) for invite in _current_user_invites(username)]
return {
"invites": invites,
"count": len(invites),
"invite_access": {
"enabled": invite_access_enabled,
"managed_by_master": bool(master_invite),
},
"master_invite": _serialize_self_service_master_invite(master_invite),
}
@router.post("/profile/invites")
async def create_profile_invite(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")
_require_self_service_invite_access(current_user)
username = str(current_user.get("username") or "").strip()
if not username:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid user")
requested_code = payload.get("code")
if isinstance(requested_code, str) and requested_code.strip():
code = _normalize_invite_code(requested_code)
existing = get_signup_invite_by_code(code)
if existing:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Invite code already exists")
else:
code = ""
for _ in range(20):
candidate = _generate_invite_code()
if not get_signup_invite_by_code(candidate):
code = candidate
break
if not code:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Could not generate invite code")
label = payload.get("label")
description = payload.get("description")
if label is not None:
label = str(label).strip() or None
if description is not None:
description = str(description).strip() or None
master_invite = _get_self_service_master_invite()
if master_invite:
if not bool(master_invite.get("enabled")) or bool(master_invite.get("is_expired")) or master_invite.get("is_usable") is False:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Self-service invites are temporarily unavailable (master invite template is disabled or expired).",
)
profile_id, _master_role, max_uses, enabled, expires_at = _master_invite_controlled_values(master_invite)
if profile_id is not None and not get_user_profile(profile_id):
profile_id = None
role = "user"
else:
max_uses = _parse_optional_positive_int(payload.get("max_uses"), "max_uses")
expires_at = _parse_optional_expires_at(payload.get("expires_at"))
enabled = bool(payload.get("enabled", True))
profile_id = current_user.get("profile_id")
if not isinstance(profile_id, int) or profile_id <= 0:
profile_id = None
role = "user"
invite = create_signup_invite(
code=code,
label=label,
description=description,
profile_id=profile_id,
role=role,
max_uses=max_uses,
enabled=enabled,
expires_at=expires_at,
created_by=username,
)
return {"status": "ok", "invite": _serialize_self_invite(invite)}
@router.put("/profile/invites/{invite_id}")
async def update_profile_invite(
invite_id: int, 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")
_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():
code = _normalize_invite_code(requested_code)
else:
code = str(existing.get("code") or "").strip()
if not code:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invite code is required")
duplicate = get_signup_invite_by_code(code)
if duplicate and int(duplicate.get("id") or 0) != int(existing.get("id") or 0):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Invite code already exists")
label = payload.get("label", existing.get("label"))
description = payload.get("description", existing.get("description"))
if label is not None:
label = str(label).strip() or None
if description is not None:
description = str(description).strip() or None
master_invite = _get_self_service_master_invite()
if master_invite:
profile_id, _master_role, max_uses, enabled, expires_at = _master_invite_controlled_values(master_invite)
if profile_id is not None and not get_user_profile(profile_id):
profile_id = None
role = "user"
else:
max_uses = _parse_optional_positive_int(payload.get("max_uses", existing.get("max_uses")), "max_uses")
expires_at = _parse_optional_expires_at(payload.get("expires_at", existing.get("expires_at")))
enabled_raw = payload.get("enabled", existing.get("enabled"))
enabled = bool(enabled_raw)
profile_id = existing.get("profile_id")
role = existing.get("role")
invite = update_signup_invite(
invite_id,
code=code,
label=label,
description=description,
profile_id=profile_id,
role=role,
max_uses=max_uses,
enabled=enabled,
expires_at=expires_at,
)
if not invite:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invite not found")
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)
_get_owned_invite(invite_id, current_user)
deleted = delete_signup_invite(invite_id)
if not deleted:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invite not found")
return {"status": "ok"}
@router.post("/password")
async def change_password(payload: dict, current_user: dict = Depends(get_current_user)) -> dict:
current_password = payload.get("current_password") if isinstance(payload, dict) else None
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."
)
username = str(current_user.get("username") or "").strip()
auth_provider = str(current_user.get("auth_provider") or "local").lower()
if not username:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid user")
new_password_clean = new_password.strip()
if auth_provider == "local":
user = verify_user_password(username, current_password)
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Current password is incorrect")
set_user_password(username, new_password_clean)
return {"status": "ok", "provider": "local"}
if auth_provider == "jellyfin":
runtime = get_runtime_settings()
client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
if not client.configured():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Jellyfin is not configured for password passthrough.",
)
try:
auth_result = await client.authenticate_by_name(username, current_password)
if not isinstance(auth_result, dict) or not auth_result.get("User"):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Current password is incorrect"
)
except HTTPException:
raise
except Exception as exc:
detail = _extract_http_error_detail(exc)
if isinstance(exc, httpx.HTTPStatusError) and exc.response is not None and exc.response.status_code in {401, 403}:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Current password is incorrect"
) from exc
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Jellyfin password validation failed: {detail}",
) from exc
try:
jf_user = await client.find_user_by_name(username)
user_id = client._extract_user_id(jf_user)
if not user_id:
raise RuntimeError("Jellyfin user ID not found")
await client.set_user_password(user_id, new_password_clean)
except Exception as exc:
detail = _extract_http_error_detail(exc)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Jellyfin password update failed: {detail}",
) from exc
# Keep Magent's Jellyfin auth cache in sync for faster subsequent sign-ins.
set_jellyfin_auth_cache(username, new_password_clean)
return {"status": "ok", "provider": "jellyfin"}
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Password changes are not available for this sign-in provider.",
)