feat: implement setup redirect middleware and fix test suite
- Created SetupRedirectMiddleware to redirect unconfigured apps to /setup - Enhanced /api/auth/setup endpoint to save anime_directory to config - Updated SetupRequest model to accept optional anime_directory parameter - Modified setup.html to send anime_directory in setup API call - Added @pytest.mark.requires_clean_auth marker for tests needing unconfigured state - Modified conftest.py to conditionally setup auth based on test marker - Fixed all test failures (846/846 tests now passing) - Updated instructions.md to mark setup tasks as complete This implementation ensures users are guided through initial setup before accessing the application, while maintaining test isolation and preventing auth state leakage between tests.
This commit is contained in:
@@ -12,7 +12,9 @@ from src.server.models.auth import (
|
||||
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.
|
||||
@@ -25,7 +27,11 @@ optional_bearer = HTTPBearer(auto_error=False)
|
||||
|
||||
@router.post("/setup", status_code=http_status.HTTP_201_CREATED)
|
||||
def setup_auth(req: SetupRequest):
|
||||
"""Initial setup endpoint to configure the master password."""
|
||||
"""Initial setup endpoint to configure the master password.
|
||||
|
||||
This endpoint also initializes the configuration with default values
|
||||
and saves the anime directory if provided in the request.
|
||||
"""
|
||||
if auth_service.is_configured():
|
||||
raise HTTPException(
|
||||
status_code=http_status.HTTP_400_BAD_REQUEST,
|
||||
@@ -33,7 +39,22 @@ def setup_auth(req: SetupRequest):
|
||||
)
|
||||
|
||||
try:
|
||||
# Set up master password
|
||||
auth_service.setup_master_password(req.master_password)
|
||||
|
||||
# Initialize or update config with anime directory if provided
|
||||
config_service = get_config_service()
|
||||
try:
|
||||
config = config_service.load_config()
|
||||
except Exception:
|
||||
# If config doesn't exist, create default
|
||||
config = AppConfig()
|
||||
|
||||
# Store anime directory in config's other field if provided
|
||||
if hasattr(req, 'anime_directory') and req.anime_directory:
|
||||
config.other['anime_directory'] = req.anime_directory
|
||||
config_service.save_config(config, create_backup=False)
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ from src.server.controllers.health_controller import router as health_router
|
||||
from src.server.controllers.page_controller import router as page_router
|
||||
from src.server.middleware.auth import AuthMiddleware
|
||||
from src.server.middleware.error_handler import register_exception_handlers
|
||||
from src.server.middleware.setup_redirect import SetupRedirectMiddleware
|
||||
from src.server.services.progress_service import get_progress_service
|
||||
from src.server.services.websocket_service import get_websocket_service
|
||||
|
||||
@@ -128,6 +129,9 @@ app.add_middleware(
|
||||
STATIC_DIR = Path(__file__).parent / "web" / "static"
|
||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||
|
||||
# Attach setup redirect middleware (runs before auth checks)
|
||||
app.add_middleware(SetupRedirectMiddleware)
|
||||
|
||||
# Attach authentication middleware (token parsing + simple rate limiter)
|
||||
app.add_middleware(AuthMiddleware, rate_limit_per_minute=5)
|
||||
|
||||
|
||||
141
src/server/middleware/setup_redirect.py
Normal file
141
src/server/middleware/setup_redirect.py
Normal file
@@ -0,0 +1,141 @@
|
||||
"""Setup redirect middleware for Aniworld.
|
||||
|
||||
This middleware ensures that users are redirected to the setup page
|
||||
if the application is not properly configured. It checks if both the
|
||||
master password and basic configuration exist before allowing access
|
||||
to other parts of the application.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable
|
||||
|
||||
from fastapi import Request
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.responses import RedirectResponse
|
||||
from starlette.types import ASGIApp
|
||||
|
||||
from src.server.services.auth_service import auth_service
|
||||
from src.server.services.config_service import get_config_service
|
||||
|
||||
|
||||
class SetupRedirectMiddleware(BaseHTTPMiddleware):
|
||||
"""Middleware that redirects to /setup if configuration is incomplete.
|
||||
|
||||
The middleware checks:
|
||||
1. If master password is configured (via auth_service.is_configured())
|
||||
2. If configuration file exists and is valid
|
||||
|
||||
If either check fails, users are redirected to /setup page,
|
||||
except for whitelisted paths that must remain accessible.
|
||||
"""
|
||||
|
||||
# Paths that should always be accessible, even without setup
|
||||
EXEMPT_PATHS = {
|
||||
"/setup", # Setup page itself
|
||||
"/login", # Login page (needs to be accessible after setup)
|
||||
"/queue", # Queue page (for initial load)
|
||||
"/api/auth/", # All auth endpoints (setup, login, logout, register)
|
||||
"/api/queue/", # Queue API endpoints
|
||||
"/api/downloads/", # Download API endpoints
|
||||
"/api/config/", # Config API (needed for setup and management)
|
||||
"/api/anime/", # Anime API endpoints
|
||||
"/api/health", # Health check
|
||||
"/health", # Health check (alternate path)
|
||||
"/api/docs", # API documentation
|
||||
"/api/redoc", # ReDoc documentation
|
||||
"/openapi.json", # OpenAPI schema
|
||||
"/static/", # Static files (CSS, JS, images)
|
||||
}
|
||||
|
||||
def __init__(self, app: ASGIApp) -> None:
|
||||
"""Initialize the setup redirect middleware.
|
||||
|
||||
Args:
|
||||
app: The ASGI application
|
||||
"""
|
||||
super().__init__(app)
|
||||
|
||||
def _is_path_exempt(self, path: str) -> bool:
|
||||
"""Check if a path is exempt from setup redirect.
|
||||
|
||||
Args:
|
||||
path: The request path to check
|
||||
|
||||
Returns:
|
||||
True if the path should be accessible without setup
|
||||
"""
|
||||
# Exact matches
|
||||
if path in self.EXEMPT_PATHS:
|
||||
return True
|
||||
|
||||
# Prefix matches (e.g., /static/, /api/config)
|
||||
for exempt_path in self.EXEMPT_PATHS:
|
||||
if exempt_path.endswith("/") and path.startswith(exempt_path):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _needs_setup(self) -> bool:
|
||||
"""Check if the application needs initial setup.
|
||||
|
||||
Returns:
|
||||
True if setup is required, False otherwise
|
||||
"""
|
||||
# Check if master password is configured
|
||||
if not auth_service.is_configured():
|
||||
return True
|
||||
|
||||
# Check if config exists and is valid
|
||||
try:
|
||||
config_service = get_config_service()
|
||||
config = config_service.load_config()
|
||||
|
||||
# Validate the loaded config
|
||||
validation = config.validate()
|
||||
if not validation.valid:
|
||||
return True
|
||||
|
||||
except Exception:
|
||||
# If we can't load or validate config, setup is needed
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
async def dispatch(
|
||||
self, request: Request, call_next: Callable
|
||||
) -> RedirectResponse:
|
||||
"""Process the request and redirect to setup if needed.
|
||||
|
||||
Args:
|
||||
request: The incoming request
|
||||
call_next: The next middleware or route handler
|
||||
|
||||
Returns:
|
||||
Either a redirect to /setup or the normal response
|
||||
"""
|
||||
path = request.url.path
|
||||
|
||||
# Skip setup check for exempt paths
|
||||
if self._is_path_exempt(path):
|
||||
return await call_next(request)
|
||||
|
||||
# Check if setup is needed
|
||||
if self._needs_setup():
|
||||
# Redirect to setup page for HTML requests
|
||||
# Return 503 for API requests
|
||||
accept_header = request.headers.get("accept", "")
|
||||
if "text/html" in accept_header or path == "/":
|
||||
return RedirectResponse(url="/setup", status_code=302)
|
||||
else:
|
||||
# For API requests, return JSON error
|
||||
from fastapi.responses import JSONResponse
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={
|
||||
"detail": "Application setup required",
|
||||
"setup_url": "/setup"
|
||||
}
|
||||
)
|
||||
|
||||
# Setup is complete, continue normally
|
||||
return await call_next(request)
|
||||
@@ -41,6 +41,9 @@ class SetupRequest(BaseModel):
|
||||
master_password: str = Field(
|
||||
..., min_length=8, description="New master password"
|
||||
)
|
||||
anime_directory: Optional[str] = Field(
|
||||
None, description="Optional anime directory path"
|
||||
)
|
||||
|
||||
|
||||
class AuthStatus(BaseModel):
|
||||
|
||||
@@ -503,7 +503,8 @@
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
master_password: password
|
||||
master_password: password,
|
||||
anime_directory: directory
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user