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
This commit is contained in:
parent
4e08d81bb0
commit
a3651e0e47
@ -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"
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
"""
|
||||
@ -130,6 +152,8 @@ class AuthService:
|
||||
# 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:
|
||||
return self._failed.setdefault(
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user