from datetime import datetime, timedelta, timezone from fastapi import APIRouter, HTTPException, status, Depends from fastapi.security import OAuth2PasswordRequestForm from ..db import ( verify_user_password, create_user, create_user_if_missing, set_last_login, get_user_by_username, set_user_password, set_jellyfin_auth_cache, set_user_jellyseerr_id, get_signup_invite_by_code, 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, ) 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 ..auth import get_current_user 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"]) 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 _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 ), } @router.post("/login") async def login(form_data: OAuth2PasswordRequestForm = Depends()) -> dict: user = verify_user_password(form_data.username, form_data.password) if not user: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials") _assert_user_can_login(user) token = create_access_token(user["username"], user["role"]) 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(form_data: OAuth2PasswordRequestForm = Depends()) -> dict: 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 user = get_user_by_username(username) _assert_user_can_login(user) if user and _has_valid_jellyfin_cache(user, password): token = create_access_token(username, "user") set_last_login(username) return {"access_token": token, "token_type": "bearer", "user": {"username": 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"): raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Jellyfin credentials") create_user_if_missing(username, "jellyfin-user", role="user", auth_provider="jellyfin") user = get_user_by_username(username) _assert_user_can_login(user) try: users = await client.get_users() if isinstance(users, list): save_jellyfin_users_cache(users) for jellyfin_user in users: if not isinstance(jellyfin_user, dict): continue name = jellyfin_user.get("Name") if isinstance(name, str) and name: create_user_if_missing(name, "jellyfin-user", role="user", auth_provider="jellyfin") except Exception: pass set_jellyfin_auth_cache(username, password) if user and user.get("jellyseerr_user_id") is None and candidate_map: matched_id = match_jellyseerr_user_id(username, candidate_map) if matched_id is not None: set_user_jellyseerr_id(username, matched_id) token = create_access_token(username, "user") set_last_login(username) return {"access_token": token, "token_type": "bearer", "user": {"username": username, "role": "user"}} @router.post("/jellyseerr/login") async def jellyseerr_login(form_data: OAuth2PasswordRequestForm = Depends()) -> dict: 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): raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Jellyseerr credentials") jellyseerr_user_id = _extract_jellyseerr_user_id(response) create_user_if_missing( form_data.username, "jellyseerr-user", role="user", auth_provider="jellyseerr", jellyseerr_user_id=jellyseerr_user_id, ) user = get_user_by_username(form_data.username) _assert_user_can_login(user) if jellyseerr_user_id is not None: set_user_jellyseerr_id(form_data.username, jellyseerr_user_id) token = create_access_token(form_data.username, "user") set_last_login(form_data.username) return {"access_token": token, "token_type": "bearer", "user": {"username": form_data.username, "role": "user"}} @router.get("/me") async def me(current_user: dict = Depends(get_current_user)) -> dict: return current_user @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() try: create_user( username, password.strip(), role=role, auth_provider="local", 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) _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, "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.post("/password") async def change_password(payload: dict, current_user: dict = Depends(get_current_user)) -> dict: if current_user.get("auth_provider") != "local": raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Password changes are only available for local users.", ) 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." ) user = verify_user_password(current_user["username"], current_password) if not user: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Current password is incorrect") set_user_password(current_user["username"], new_password.strip()) return {"status": "ok"}