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
|
"keep_days": 30
|
||||||
},
|
},
|
||||||
"other": {
|
"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"
|
"version": "1.0.0"
|
||||||
}
|
}
|
||||||
@ -30,7 +30,7 @@ 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
|
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():
|
if auth_service.is_configured():
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@ -39,10 +39,13 @@ def setup_auth(req: SetupRequest):
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Set up master password
|
# Set up master password (this validates and hashes it)
|
||||||
auth_service.setup_master_password(req.master_password)
|
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()
|
config_service = get_config_service()
|
||||||
try:
|
try:
|
||||||
config = config_service.load_config()
|
config = config_service.load_config()
|
||||||
@ -50,10 +53,15 @@ def setup_auth(req: SetupRequest):
|
|||||||
# If config doesn't exist, create default
|
# If config doesn't exist, create default
|
||||||
config = AppConfig()
|
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
|
# Store anime directory in config's other field if provided
|
||||||
if hasattr(req, 'anime_directory') and req.anime_directory:
|
if hasattr(req, 'anime_directory') and req.anime_directory:
|
||||||
config.other['anime_directory'] = 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:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e)) from 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)."""
|
"""Manage application lifespan (startup and shutdown)."""
|
||||||
# Startup
|
# Startup
|
||||||
try:
|
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
|
# Initialize SeriesApp with configured directory and store it on
|
||||||
# application state so it can be injected via dependencies.
|
# application state so it can be injected via dependencies.
|
||||||
if settings.anime_directory:
|
if settings.anime_directory:
|
||||||
app.state.series_app = SeriesApp(settings.anime_directory)
|
app.state.series_app = SeriesApp(settings.anime_directory)
|
||||||
|
print(
|
||||||
|
f"SeriesApp initialized with directory: "
|
||||||
|
f"{settings.anime_directory}"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
# Log warning when anime directory is not configured
|
# Log warning when anime directory is not configured
|
||||||
print(
|
print(
|
||||||
|
|||||||
@ -45,7 +45,26 @@ class AuthService:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
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
|
# In-memory failed attempts per identifier. Values are dicts with
|
||||||
# keys: count, last, locked_until
|
# keys: count, last, locked_until
|
||||||
# WARNING: In-memory storage resets on process restart.
|
# WARNING: In-memory storage resets on process restart.
|
||||||
@ -81,7 +100,7 @@ class AuthService:
|
|||||||
def is_configured(self) -> bool:
|
def is_configured(self) -> bool:
|
||||||
return bool(self._hash)
|
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).
|
"""Set the master password (hash and store in memory/settings).
|
||||||
|
|
||||||
Enforces strong password requirements:
|
Enforces strong password requirements:
|
||||||
@ -91,12 +110,15 @@ class AuthService:
|
|||||||
- At least one special character
|
- At least one special character
|
||||||
|
|
||||||
For now we update only the in-memory value and
|
For now we update only the in-memory value and
|
||||||
settings.master_password_hash. A future task should persist this
|
settings.master_password_hash. Caller should persist the returned
|
||||||
to a config file.
|
hash to a config file.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
password: The password to set
|
password: The password to set
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: The hashed password
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: If password doesn't meet requirements
|
ValueError: If password doesn't meet requirements
|
||||||
"""
|
"""
|
||||||
@ -129,6 +151,8 @@ class AuthService:
|
|||||||
except Exception:
|
except Exception:
|
||||||
# Settings may be frozen or not persisted - that's okay for now
|
# Settings may be frozen or not persisted - that's okay for now
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
return h
|
||||||
|
|
||||||
# --- failed attempts and lockout ---
|
# --- failed attempts and lockout ---
|
||||||
def _get_fail_record(self, identifier: str) -> Dict:
|
def _get_fail_record(self, identifier: str) -> Dict:
|
||||||
|
|||||||
@ -68,6 +68,17 @@ def get_series_app() -> SeriesApp:
|
|||||||
"""
|
"""
|
||||||
global _series_app
|
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:
|
if not settings.anime_directory:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
@ -104,6 +115,17 @@ def get_optional_series_app() -> Optional[SeriesApp]:
|
|||||||
"""
|
"""
|
||||||
global _series_app
|
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:
|
if not settings.anime_directory:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@ -283,20 +305,27 @@ async def rate_limit_dependency(request: Request) -> None:
|
|||||||
|
|
||||||
async with _rate_limit_lock:
|
async with _rate_limit_lock:
|
||||||
record = _RATE_LIMIT_BUCKETS.get(client_id)
|
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(
|
_RATE_LIMIT_BUCKETS[client_id] = RateLimitRecord(
|
||||||
count=1,
|
count=1,
|
||||||
window_start=now,
|
window_start=now,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
record.count += 1
|
if record: # Type guard to satisfy mypy
|
||||||
if record.count > max_requests:
|
record.count += 1
|
||||||
logger.warning("Rate limit exceeded", extra={"client": client_id})
|
if record.count > max_requests:
|
||||||
raise HTTPException(
|
logger.warning(
|
||||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
"Rate limit exceeded", extra={"client": client_id}
|
||||||
detail="Too many requests. Please slow down.",
|
)
|
||||||
)
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
detail="Too many requests. Please slow down.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Dependency for request logging (placeholder)
|
# Dependency for request logging (placeholder)
|
||||||
|
|||||||
@ -42,24 +42,40 @@ class AniWorldApp {
|
|||||||
try {
|
try {
|
||||||
// First check if we have a token
|
// First check if we have a token
|
||||||
const token = localStorage.getItem('access_token');
|
const token = localStorage.getItem('access_token');
|
||||||
|
console.log('checkAuthentication: token exists =', !!token);
|
||||||
|
|
||||||
// Build request with token if available
|
if (!token) {
|
||||||
const headers = {};
|
console.log('checkAuthentication: No token found, redirecting to /login');
|
||||||
if (token) {
|
window.location.href = '/login';
|
||||||
headers['Authorization'] = `Bearer ${token}`;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build request with token
|
||||||
|
const headers = {
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
};
|
||||||
|
|
||||||
const response = await fetch('/api/auth/status', { headers });
|
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();
|
const data = await response.json();
|
||||||
|
console.log('checkAuthentication: data =', data);
|
||||||
|
|
||||||
if (!data.configured) {
|
if (!data.configured) {
|
||||||
// No master password set, redirect to setup
|
// No master password set, redirect to setup
|
||||||
|
console.log('checkAuthentication: Not configured, redirecting to /setup');
|
||||||
window.location.href = '/setup';
|
window.location.href = '/setup';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data.authenticated) {
|
if (!data.authenticated) {
|
||||||
// Not authenticated, redirect to login
|
// Not authenticated, redirect to login
|
||||||
|
console.log('checkAuthentication: Not authenticated, redirecting to /login');
|
||||||
localStorage.removeItem('access_token');
|
localStorage.removeItem('access_token');
|
||||||
localStorage.removeItem('token_expires_at');
|
localStorage.removeItem('token_expires_at');
|
||||||
window.location.href = '/login';
|
window.location.href = '/login';
|
||||||
@ -67,6 +83,7 @@ class AniWorldApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// User is authenticated, show logout button
|
// User is authenticated, show logout button
|
||||||
|
console.log('checkAuthentication: Authenticated successfully');
|
||||||
const logoutBtn = document.getElementById('logout-btn');
|
const logoutBtn = document.getElementById('logout-btn');
|
||||||
if (logoutBtn) {
|
if (logoutBtn) {
|
||||||
logoutBtn.style.display = 'block';
|
logoutBtn.style.display = 'block';
|
||||||
@ -539,22 +556,35 @@ class AniWorldApp {
|
|||||||
try {
|
try {
|
||||||
this.showLoading();
|
this.showLoading();
|
||||||
|
|
||||||
const response = await fetch('/api/v1/anime');
|
const response = await this.makeAuthenticatedRequest('/api/anime');
|
||||||
|
|
||||||
if (response.status === 401) {
|
if (!response) {
|
||||||
window.location.href = '/login';
|
// makeAuthenticatedRequest returns null and handles redirect on auth failure
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
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.seriesData = data.series;
|
||||||
this.applyFiltersAndSort();
|
|
||||||
this.renderSeries();
|
|
||||||
} else {
|
} 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) {
|
} catch (error) {
|
||||||
console.error('Error loading series:', error);
|
console.error('Error loading series:', error);
|
||||||
this.showToast('Failed to load series', 'error');
|
this.showToast('Failed to load series', 'error');
|
||||||
@ -783,7 +813,7 @@ class AniWorldApp {
|
|||||||
try {
|
try {
|
||||||
this.showLoading();
|
this.showLoading();
|
||||||
|
|
||||||
const response = await this.makeAuthenticatedRequest('/api/v1/anime/search', {
|
const response = await this.makeAuthenticatedRequest('/api/anime/search', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@ -836,7 +866,7 @@ class AniWorldApp {
|
|||||||
|
|
||||||
async addSeries(link, name) {
|
async addSeries(link, name) {
|
||||||
try {
|
try {
|
||||||
const response = await this.makeAuthenticatedRequest('/api/v1/anime/add', {
|
const response = await this.makeAuthenticatedRequest('/api/anime/add', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@ -870,7 +900,7 @@ class AniWorldApp {
|
|||||||
try {
|
try {
|
||||||
const folders = Array.from(this.selectedSeries);
|
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',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@ -894,7 +924,7 @@ class AniWorldApp {
|
|||||||
|
|
||||||
async rescanSeries() {
|
async rescanSeries() {
|
||||||
try {
|
try {
|
||||||
const response = await this.makeAuthenticatedRequest('/api/v1/anime/rescan', {
|
const response = await this.makeAuthenticatedRequest('/api/anime/rescan', {
|
||||||
method: 'POST'
|
method: 'POST'
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -1030,7 +1060,7 @@ class AniWorldApp {
|
|||||||
|
|
||||||
async checkProcessLocks() {
|
async checkProcessLocks() {
|
||||||
try {
|
try {
|
||||||
const response = await this.makeAuthenticatedRequest('/api/v1/anime/process/locks');
|
const response = await this.makeAuthenticatedRequest('/api/anime/process/locks');
|
||||||
if (!response) {
|
if (!response) {
|
||||||
// If no response, set status as idle
|
// If no response, set status as idle
|
||||||
this.updateProcessStatus('rescan', false);
|
this.updateProcessStatus('rescan', false);
|
||||||
@ -1101,7 +1131,7 @@ class AniWorldApp {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Load current status
|
// Load current status
|
||||||
const response = await this.makeAuthenticatedRequest('/api/v1/anime/status');
|
const response = await this.makeAuthenticatedRequest('/api/anime/status');
|
||||||
if (!response) return;
|
if (!response) return;
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
@ -1600,7 +1630,7 @@ class AniWorldApp {
|
|||||||
|
|
||||||
async refreshStatus() {
|
async refreshStatus() {
|
||||||
try {
|
try {
|
||||||
const response = await this.makeAuthenticatedRequest('/api/v1/anime/status');
|
const response = await this.makeAuthenticatedRequest('/api/anime/status');
|
||||||
if (!response) return;
|
if (!response) return;
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user