From a3651e0e475f1dbad601cdd829cefc09ba95179d Mon Sep 17 00:00:00 2001 From: Lukas Date: Fri, 24 Oct 2025 20:55:10 +0200 Subject: [PATCH] fix: load configuration from config.json and fix authentication - Load anime_directory and master_password_hash from config.json on startup - Sync configuration from config.json to settings object in fastapi_app.py - Update dependencies.py to load config from JSON if not in environment - Fix app.js to use makeAuthenticatedRequest() for all authenticated API calls - Fix API endpoint paths from /api/v1/anime to /api/anime - Update auth_service.py to load master_password_hash from config.json - Update auth.py setup endpoint to save master_password_hash to config - Fix rate limiting code to satisfy type checker - Update config.json with test master password hash Fixes: - 401 Unauthorized errors on /api/anime endpoint - 503 Service Unavailable errors on /api/anime/process/locks - Configuration not being loaded from config.json file - Authentication flow now works end-to-end with JWT tokens --- data/config.json | 3 +- src/server/api/auth.py | 18 +++++--- src/server/fastapi_app.py | 20 +++++++++ src/server/services/auth_service.py | 32 ++++++++++++-- src/server/utils/dependencies.py | 45 ++++++++++++++++---- src/server/web/static/js/app.js | 66 +++++++++++++++++++++-------- 6 files changed, 148 insertions(+), 36 deletions(-) diff --git a/data/config.json b/data/config.json index bdef19b..2ed9177 100644 --- a/data/config.json +++ b/data/config.json @@ -17,7 +17,8 @@ "keep_days": 30 }, "other": { - "anime_directory": "/home/lukas/Volume/serien/" + "anime_directory": "/home/lukas/Volume/serien/", + "master_password_hash": "$pbkdf2-sha256$29000$ZWwtJaQ0ZkxpLUWolRJijA$QcfgTBqgM3ABu9N93/w8naBLdfCKmKFc65Cn/f4fP84" }, "version": "1.0.0" } \ No newline at end of file diff --git a/src/server/api/auth.py b/src/server/api/auth.py index e5abdf9..199fd41 100644 --- a/src/server/api/auth.py +++ b/src/server/api/auth.py @@ -30,7 +30,7 @@ def setup_auth(req: SetupRequest): """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. + and saves the anime directory and master password hash. """ if auth_service.is_configured(): raise HTTPException( @@ -39,10 +39,13 @@ def setup_auth(req: SetupRequest): ) try: - # Set up master password - auth_service.setup_master_password(req.master_password) + # Set up master password (this validates and hashes it) + password_hash = auth_service.setup_master_password( + req.master_password + ) - # Initialize or update config with anime directory if provided + # Initialize or update config with master password hash + # and anime directory config_service = get_config_service() try: config = config_service.load_config() @@ -50,10 +53,15 @@ def setup_auth(req: SetupRequest): # If config doesn't exist, create default config = AppConfig() + # 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 if hasattr(req, 'anime_directory') and req.anime_directory: config.other['anime_directory'] = req.anime_directory - config_service.save_config(config, create_backup=False) + + # Save the config with the password hash and anime directory + config_service.save_config(config, create_backup=False) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) from e diff --git a/src/server/fastapi_app.py b/src/server/fastapi_app.py index a0f736a..bc0a759 100644 --- a/src/server/fastapi_app.py +++ b/src/server/fastapi_app.py @@ -53,10 +53,30 @@ async def lifespan(app: FastAPI): """Manage application lifespan (startup and shutdown).""" # Startup try: + # Load configuration from config.json and sync with settings + try: + from src.server.services.config_service import get_config_service + config_service = get_config_service() + config = config_service.load_config() + + # Sync anime_directory from config.json to settings + if config.other and config.other.get("anime_directory"): + settings.anime_directory = str(config.other["anime_directory"]) + print( + f"Loaded anime_directory from config: " + f"{settings.anime_directory}" + ) + except Exception as e: + print(f"Warning: Failed to load config from config.json: {e}") + # Initialize SeriesApp with configured directory and store it on # application state so it can be injected via dependencies. if settings.anime_directory: app.state.series_app = SeriesApp(settings.anime_directory) + print( + f"SeriesApp initialized with directory: " + f"{settings.anime_directory}" + ) else: # Log warning when anime directory is not configured print( diff --git a/src/server/services/auth_service.py b/src/server/services/auth_service.py index bab9b37..bc47edf 100644 --- a/src/server/services/auth_service.py +++ b/src/server/services/auth_service.py @@ -45,7 +45,26 @@ class AuthService: """ def __init__(self) -> None: - self._hash: Optional[str] = settings.master_password_hash + # Try to load master password hash from config file first + # If not found, fallback to environment variable + self._hash: Optional[str] = None + + # Try loading from config file + try: + from src.server.services.config_service import get_config_service + config_service = get_config_service() + config = config_service.load_config() + hash_val = config.other.get('master_password_hash') + if isinstance(hash_val, str): + self._hash = hash_val + except Exception: + # Config doesn't exist or can't be loaded - that's OK + pass + + # If not in config, try environment variable + if not self._hash: + self._hash = settings.master_password_hash + # In-memory failed attempts per identifier. Values are dicts with # keys: count, last, locked_until # WARNING: In-memory storage resets on process restart. @@ -81,7 +100,7 @@ class AuthService: def is_configured(self) -> bool: return bool(self._hash) - def setup_master_password(self, password: str) -> None: + def setup_master_password(self, password: str) -> str: """Set the master password (hash and store in memory/settings). Enforces strong password requirements: @@ -91,12 +110,15 @@ class AuthService: - At least one special character For now we update only the in-memory value and - settings.master_password_hash. A future task should persist this - to a config file. + settings.master_password_hash. Caller should persist the returned + hash to a config file. Args: password: The password to set + Returns: + str: The hashed password + Raises: ValueError: If password doesn't meet requirements """ @@ -129,6 +151,8 @@ class AuthService: except Exception: # Settings may be frozen or not persisted - that's okay for now pass + + return h # --- failed attempts and lockout --- def _get_fail_record(self, identifier: str) -> Dict: diff --git a/src/server/utils/dependencies.py b/src/server/utils/dependencies.py index 5e632e7..be04116 100644 --- a/src/server/utils/dependencies.py +++ b/src/server/utils/dependencies.py @@ -68,6 +68,17 @@ def get_series_app() -> SeriesApp: """ global _series_app + # Try to load anime_directory from config.json if not in settings + if not settings.anime_directory: + try: + from src.server.services.config_service import get_config_service + config_service = get_config_service() + config = config_service.load_config() + if config.other and config.other.get("anime_directory"): + settings.anime_directory = str(config.other["anime_directory"]) + except Exception: + pass # Will raise 503 below if still not configured + if not settings.anime_directory: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, @@ -104,6 +115,17 @@ def get_optional_series_app() -> Optional[SeriesApp]: """ global _series_app + # Try to load anime_directory from config.json if not in settings + if not settings.anime_directory: + try: + from src.server.services.config_service import get_config_service + config_service = get_config_service() + config = config_service.load_config() + if config.other and config.other.get("anime_directory"): + settings.anime_directory = str(config.other["anime_directory"]) + except Exception: + return None + if not settings.anime_directory: return None @@ -283,20 +305,27 @@ async def rate_limit_dependency(request: Request) -> None: async with _rate_limit_lock: record = _RATE_LIMIT_BUCKETS.get(client_id) - if not record or now - record.window_start >= _RATE_LIMIT_WINDOW_SECONDS: + window_expired = ( + not record + or now - record.window_start >= _RATE_LIMIT_WINDOW_SECONDS + ) + if window_expired: _RATE_LIMIT_BUCKETS[client_id] = RateLimitRecord( count=1, window_start=now, ) return - record.count += 1 - if record.count > max_requests: - logger.warning("Rate limit exceeded", extra={"client": client_id}) - raise HTTPException( - status_code=status.HTTP_429_TOO_MANY_REQUESTS, - detail="Too many requests. Please slow down.", - ) + if record: # Type guard to satisfy mypy + record.count += 1 + if record.count > max_requests: + logger.warning( + "Rate limit exceeded", extra={"client": client_id} + ) + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="Too many requests. Please slow down.", + ) # Dependency for request logging (placeholder) diff --git a/src/server/web/static/js/app.js b/src/server/web/static/js/app.js index cbd7c76..711585d 100644 --- a/src/server/web/static/js/app.js +++ b/src/server/web/static/js/app.js @@ -42,24 +42,40 @@ class AniWorldApp { try { // First check if we have a token const token = localStorage.getItem('access_token'); + console.log('checkAuthentication: token exists =', !!token); - // Build request with token if available - const headers = {}; - if (token) { - headers['Authorization'] = `Bearer ${token}`; + if (!token) { + console.log('checkAuthentication: No token found, redirecting to /login'); + window.location.href = '/login'; + return; } + // Build request with token + const headers = { + 'Authorization': `Bearer ${token}` + }; + const response = await fetch('/api/auth/status', { headers }); + console.log('checkAuthentication: response status =', response.status); + + if (!response.ok) { + console.log('checkAuthentication: Response not OK, status =', response.status); + throw new Error(`HTTP ${response.status}`); + } + const data = await response.json(); + console.log('checkAuthentication: data =', data); if (!data.configured) { // No master password set, redirect to setup + console.log('checkAuthentication: Not configured, redirecting to /setup'); window.location.href = '/setup'; return; } if (!data.authenticated) { // Not authenticated, redirect to login + console.log('checkAuthentication: Not authenticated, redirecting to /login'); localStorage.removeItem('access_token'); localStorage.removeItem('token_expires_at'); window.location.href = '/login'; @@ -67,6 +83,7 @@ class AniWorldApp { } // User is authenticated, show logout button + console.log('checkAuthentication: Authenticated successfully'); const logoutBtn = document.getElementById('logout-btn'); if (logoutBtn) { logoutBtn.style.display = 'block'; @@ -539,22 +556,35 @@ class AniWorldApp { try { this.showLoading(); - const response = await fetch('/api/v1/anime'); + const response = await this.makeAuthenticatedRequest('/api/anime'); - if (response.status === 401) { - window.location.href = '/login'; + if (!response) { + // makeAuthenticatedRequest returns null and handles redirect on auth failure return; } const data = await response.json(); - if (data.status === 'success') { + // Check if response has the expected format + if (Array.isArray(data)) { + // API returns array of AnimeSummary objects directly + this.seriesData = data.map(anime => ({ + id: anime.id, + name: anime.title, + title: anime.title, + missing_episodes: anime.missing_episodes || 0, + episodeDict: {} // Will be populated when needed + })); + } else if (data.status === 'success') { + // Legacy format support this.seriesData = data.series; - this.applyFiltersAndSort(); - this.renderSeries(); } else { - this.showToast(`Error loading series: ${data.message}`, 'error'); + this.showToast(`Error loading series: ${data.message || 'Unknown error'}`, 'error'); + return; } + + this.applyFiltersAndSort(); + this.renderSeries(); } catch (error) { console.error('Error loading series:', error); this.showToast('Failed to load series', 'error'); @@ -783,7 +813,7 @@ class AniWorldApp { try { this.showLoading(); - const response = await this.makeAuthenticatedRequest('/api/v1/anime/search', { + const response = await this.makeAuthenticatedRequest('/api/anime/search', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -836,7 +866,7 @@ class AniWorldApp { async addSeries(link, name) { try { - const response = await this.makeAuthenticatedRequest('/api/v1/anime/add', { + const response = await this.makeAuthenticatedRequest('/api/anime/add', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -870,7 +900,7 @@ class AniWorldApp { try { const folders = Array.from(this.selectedSeries); - const response = await this.makeAuthenticatedRequest('/api/v1/anime/download', { + const response = await this.makeAuthenticatedRequest('/api/anime/download', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -894,7 +924,7 @@ class AniWorldApp { async rescanSeries() { try { - const response = await this.makeAuthenticatedRequest('/api/v1/anime/rescan', { + const response = await this.makeAuthenticatedRequest('/api/anime/rescan', { method: 'POST' }); @@ -1030,7 +1060,7 @@ class AniWorldApp { async checkProcessLocks() { try { - const response = await this.makeAuthenticatedRequest('/api/v1/anime/process/locks'); + const response = await this.makeAuthenticatedRequest('/api/anime/process/locks'); if (!response) { // If no response, set status as idle this.updateProcessStatus('rescan', false); @@ -1101,7 +1131,7 @@ class AniWorldApp { try { // Load current status - const response = await this.makeAuthenticatedRequest('/api/v1/anime/status'); + const response = await this.makeAuthenticatedRequest('/api/anime/status'); if (!response) return; const data = await response.json(); @@ -1600,7 +1630,7 @@ class AniWorldApp { async refreshStatus() { try { - const response = await this.makeAuthenticatedRequest('/api/v1/anime/status'); + const response = await this.makeAuthenticatedRequest('/api/anime/status'); if (!response) return; const data = await response.json();