Replace non-existent emit_progress calls with proper ProgressService methods: - start_progress for starting operations - update_progress for progress updates - complete_progress for successful completion - fail_progress for failures Convert percentage-based updates to current/total based on ProgressService API
297 lines
11 KiB
Python
297 lines
11 KiB
Python
"""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,
|
|
}
|
|
|