"""Authentication API endpoints for Aniworld.""" from typing import Optional from fastapi import APIRouter, Depends, HTTPException from fastapi import status as http_status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from src.server.models.auth import ( AuthStatus, LoginRequest, LoginResponse, RegisterRequest, SetupRequest, ) from src.server.models.config import AppConfig from src.server.services.auth_service import AuthError, LockedOutError, auth_service from src.server.services.config_service import get_config_service # NOTE: import dependencies (optional_auth, security) lazily inside handlers # to avoid importing heavyweight modules (e.g. sqlalchemy) at import time. router = APIRouter(prefix="/api/auth", tags=["auth"]) # HTTPBearer for optional authentication optional_bearer = HTTPBearer(auto_error=False) @router.post("/setup", status_code=http_status.HTTP_201_CREATED) async def setup_auth(req: SetupRequest): """Initial setup endpoint to configure the master password. This endpoint also initializes the configuration with all provided values and saves them to config.json. It triggers background initialization and redirects to a loading page that shows real-time progress. """ if auth_service.is_configured(): raise HTTPException( status_code=http_status.HTTP_400_BAD_REQUEST, detail="Master password already configured", ) try: # Set up master password (this validates and hashes it) password_hash = auth_service.setup_master_password( req.master_password ) # Initialize or update config with all provided values config_service = get_config_service() try: config = config_service.load_config() except Exception: # If config doesn't exist, create default from src.server.models.config import ( BackupConfig, LoggingConfig, NFOConfig, SchedulerConfig, ) config = AppConfig() # Update basic settings if req.name: config.name = req.name if req.data_dir: config.data_dir = req.data_dir # Update scheduler configuration if req.scheduler_enabled is not None: config.scheduler.enabled = req.scheduler_enabled if req.scheduler_interval_minutes is not None: config.scheduler.interval_minutes = req.scheduler_interval_minutes # Update logging configuration if req.logging_level: config.logging.level = req.logging_level.upper() if req.logging_file is not None: config.logging.file = req.logging_file if req.logging_max_bytes is not None: config.logging.max_bytes = req.logging_max_bytes if req.logging_backup_count is not None: config.logging.backup_count = req.logging_backup_count # Update backup configuration if req.backup_enabled is not None: config.backup.enabled = req.backup_enabled if req.backup_path: config.backup.path = req.backup_path if req.backup_keep_days is not None: config.backup.keep_days = req.backup_keep_days # Update NFO configuration if req.nfo_tmdb_api_key is not None: config.nfo.tmdb_api_key = req.nfo_tmdb_api_key if req.nfo_auto_create is not None: config.nfo.auto_create = req.nfo_auto_create if req.nfo_update_on_scan is not None: config.nfo.update_on_scan = req.nfo_update_on_scan if req.nfo_download_poster is not None: config.nfo.download_poster = req.nfo_download_poster if req.nfo_download_logo is not None: config.nfo.download_logo = req.nfo_download_logo if req.nfo_download_fanart is not None: config.nfo.download_fanart = req.nfo_download_fanart if req.nfo_image_size: config.nfo.image_size = req.nfo_image_size.lower() # Store master password hash in config's other field config.other['master_password_hash'] = password_hash # Store anime directory in config's other field if provided anime_directory = None if req.anime_directory: anime_directory = req.anime_directory.strip() if anime_directory: config.other['anime_directory'] = anime_directory # Save the config with all updates config_service.save_config(config, create_backup=False) # Sync config.json values to settings object # (mirroring the logic in fastapi_app.py lifespan) from src.config.settings import settings other_settings = dict(config.other) if config.other else {} if other_settings.get("anime_directory"): settings.anime_directory = str(other_settings["anime_directory"]) if config.nfo: if config.nfo.tmdb_api_key: settings.tmdb_api_key = config.nfo.tmdb_api_key settings.nfo_auto_create = config.nfo.auto_create settings.nfo_update_on_scan = config.nfo.update_on_scan settings.nfo_download_poster = config.nfo.download_poster settings.nfo_download_logo = config.nfo.download_logo settings.nfo_download_fanart = config.nfo.download_fanart settings.nfo_image_size = config.nfo.image_size # Trigger initialization in background task import asyncio from src.server.services.initialization_service import ( perform_initial_setup, perform_nfo_scan_if_needed, ) from src.server.services.progress_service import get_progress_service progress_service = get_progress_service() async def run_initialization(): """Run initialization steps with progress updates.""" try: # Perform the initial series sync and mark as completed await perform_initial_setup(progress_service) # Perform NFO scan if configured await perform_nfo_scan_if_needed(progress_service) # Send completion event from src.server.services.progress_service import ProgressType await progress_service.start_progress( progress_id="initialization_complete", progress_type=ProgressType.SYSTEM, title="Initialization Complete", total=100, message="All initialization tasks completed successfully", metadata={"initialization_complete": True} ) await progress_service.complete_progress( progress_id="initialization_complete", message="All initialization tasks completed successfully", metadata={"initialization_complete": True} ) except Exception as e: # Send error event from src.server.services.progress_service import ProgressType await progress_service.start_progress( progress_id="initialization_error", progress_type=ProgressType.ERROR, title="Initialization Failed", total=100, message=str(e), metadata={"initialization_complete": True, "error": str(e)} ) await progress_service.fail_progress( progress_id="initialization_error", error_message=str(e), metadata={"initialization_complete": True, "error": str(e)} ) # Start initialization in background asyncio.create_task(run_initialization()) # Return redirect to loading page return {"status": "ok", "redirect": "/loading"} # Note: Media scan is skipped during setup as it requires # background_loader service which is only available during # application lifespan. It will run on first application startup. return {"status": "ok"} except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) from e @router.post("/login", response_model=LoginResponse) def login(req: LoginRequest): """Validate master password and return JWT token.""" # Use a simple identifier for failed attempts; prefer IP in real app identifier = "global" try: valid = auth_service.validate_master_password( req.password, identifier=identifier ) except LockedOutError as e: raise HTTPException( status_code=http_status.HTTP_429_TOO_MANY_REQUESTS, detail=str(e), ) from e except AuthError as e: # Return 401 for authentication errors (including not configured) # This prevents information leakage about system configuration raise HTTPException( status_code=http_status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials" ) from e if not valid: raise HTTPException( status_code=http_status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials" ) token = auth_service.create_access_token( subject="master", remember=bool(req.remember) ) return token @router.post("/logout") def logout_endpoint( credentials: Optional[HTTPAuthorizationCredentials] = Depends( optional_bearer ), ): """Logout by revoking token (no-op for stateless JWT).""" # If a plain credentials object was provided, extract token token = getattr(credentials, "credentials", None) # Placeholder; auth_service.revoke_token can be expanded to persist # revocations if token: auth_service.revoke_token(token) return {"status": "ok", "message": "Logged out successfully"} async def get_optional_auth( credentials: Optional[HTTPAuthorizationCredentials] = Depends( optional_bearer ), ) -> Optional[dict]: """Get optional authentication from bearer token.""" if credentials is None: return None token = credentials.credentials try: # Validate and decode token using the auth service session = auth_service.create_session_model(token) return session.model_dump() except AuthError: return None @router.get("/status", response_model=AuthStatus) async def auth_status(auth: Optional[dict] = Depends(get_optional_auth)): """Return whether master password is configured and authenticated.""" return AuthStatus( configured=auth_service.is_configured(), authenticated=bool(auth) ) @router.post("/register", status_code=http_status.HTTP_201_CREATED) def register(req: RegisterRequest): """Register a new user (for testing/validation purposes). Note: This is primarily for input validation testing. The actual Aniworld app uses a single master password. """ # This endpoint is primarily for input validation testing # In a real multi-user system, you'd create the user here return { "status": "ok", "message": "User registration successful", "username": req.username, }