instruction2

This commit is contained in:
2025-10-12 22:39:51 +02:00
parent e48cb29131
commit 7481a33c15
40 changed files with 639 additions and 14993 deletions

View File

@@ -1,24 +0,0 @@
# AniWorld FastAPI Server Configuration
# Authentication Configuration
JWT_SECRET_KEY=your-super-secure-jwt-secret-key-change-this-in-production
PASSWORD_SALT=c3149a46648b4394410b415ea654c31731b988ee59fc91b8fb8366a0b32ef0c1
MASTER_PASSWORD=admin123
# MASTER_PASSWORD_HASH=bb202031f646922388567de96a784074272efbbba9eb5d2259e23af04686d2a5
SESSION_TIMEOUT_HOURS=24
# Application Configuration
ANIME_DIRECTORY=\\\\sshfs.r\\ubuntu@192.168.178.43\\media\\serien\\Serien
LOG_LEVEL=INFO
# Database Configuration (if needed)
DATABASE_URL=sqlite:///./aniworld.db
# Security Configuration
CORS_ORIGINS=*
API_RATE_LIMIT=100
# Provider Configuration
DEFAULT_PROVIDER=aniworld.to
PROVIDER_TIMEOUT=30
RETRY_ATTEMPTS=3

View File

@@ -1,257 +0,0 @@
# AniWorld FastAPI Server
A comprehensive FastAPI-based server implementation for AniWorld following the project instructions.
## 🚀 Features
### ✅ Authentication System (Completed)
- **Simple Master Password Authentication**: Single master password for the entire application
- **JWT Token Management**: Stateless authentication using JWT tokens
- **Environment Configuration**: Secure password hash stored in environment variables
- **Session Management**: Configurable token expiry (default: 24 hours)
- **Security Features**: SHA-256 password hashing with salt
### ✅ API Endpoints (Implemented)
#### Authentication Endpoints
- `POST /auth/login` - Login with master password and receive JWT token
- `GET /auth/verify` - Verify JWT token validity (protected)
- `POST /auth/logout` - Logout endpoint (stateless - client removes token)
#### System Endpoints
- `GET /` - Root endpoint with API information
- `GET /health` - Health check endpoint
- `GET /api/system/config` - System configuration (protected)
- `GET /api/system/database/health` - Database health check (protected)
#### Anime & Episode Endpoints (Protected)
- `GET /api/anime/search` - Search anime by title with pagination
- `GET /api/anime/{anime_id}` - Get specific anime details
- `GET /api/anime/{anime_id}/episodes` - Get all episodes for anime
- `GET /api/episodes/{episode_id}` - Get specific episode details
### 🔧 Technical Features
- **FastAPI Framework**: Modern, fast (high-performance) web framework
- **OpenAPI Documentation**: Automatic API documentation at `/docs`
- **CORS Support**: Configurable cross-origin resource sharing
- **Request Validation**: Pydantic models for request/response validation
- **Error Handling**: Centralized error handling with proper HTTP status codes
- **Logging**: Comprehensive logging system with file and console output
- **Environment Configuration**: Secure configuration via environment variables
## 🛠️ Installation & Setup
### Prerequisites
- Python 3.11+ (AniWorld conda environment)
- Conda package manager
### 1. Activate AniWorld Environment
```bash
conda activate AniWorld
```
### 2. Install Dependencies
```bash
cd src/server
pip install -r requirements_fastapi.txt
```
### 3. Configure Environment
Create or update `.env` file:
```env
# Authentication
JWT_SECRET_KEY=your-super-secure-jwt-secret-key
PASSWORD_SALT=your-secure-salt
MASTER_PASSWORD=admin123
SESSION_TIMEOUT_HOURS=24
# Application
ANIME_DIRECTORY=your-anime-directory-path
LOG_LEVEL=INFO
# Optional
DATABASE_URL=sqlite:///./aniworld.db
CORS_ORIGINS=*
```
### 4. Start the Server
#### Option 1: Direct Python Execution
```bash
cd src/server
C:\Users\lukas\anaconda3\envs\AniWorld\python.exe fastapi_app.py
```
#### Option 2: Using Batch Script (Windows)
```cmd
cd src/server
run_and_test.bat
```
#### Option 3: Using Shell Script (Linux/Mac)
```bash
cd src/server
chmod +x start_fastapi_server.sh
./start_fastapi_server.sh
```
## 📖 API Usage
### 1. Access Documentation
Visit: http://localhost:8000/docs
### 2. Authentication Flow
#### Step 1: Login
```bash
curl -X POST "http://localhost:8000/auth/login" \
-H "Content-Type: application/json" \
-d '{"password": "admin123"}'
```
Response:
```json
{
"success": true,
"message": "Authentication successful",
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"expires_at": "2025-10-06T18:19:24.710065"
}
```
#### Step 2: Use Token for Protected Endpoints
```bash
curl -X GET "http://localhost:8000/api/anime/search?query=naruto&limit=5" \
-H "Authorization: Bearer YOUR_JWT_TOKEN"
```
### 3. Example API Calls
#### Health Check
```bash
curl "http://localhost:8000/health"
```
#### Search Anime
```bash
curl -H "Authorization: Bearer YOUR_TOKEN" \
"http://localhost:8000/api/anime/search?query=naruto&limit=10"
```
#### Get Anime Details
```bash
curl -H "Authorization: Bearer YOUR_TOKEN" \
"http://localhost:8000/api/anime/anime_123"
```
## 🧪 Testing
### Automated Testing
```bash
cd src/server
C:\Users\lukas\anaconda3\envs\AniWorld\python.exe test_fastapi.py
```
### Manual Testing
1. Start the server
2. Visit http://localhost:8000/docs
3. Use the interactive API documentation
4. Test authentication with password: `admin123`
## 📁 Project Structure
```
src/server/
├── fastapi_app.py # Main FastAPI application
├── .env # Environment configuration
├── requirements_fastapi.txt # Python dependencies
├── test_fastapi.py # Test script
├── start_fastapi_server.bat # Windows startup script
├── start_fastapi_server.sh # Linux/Mac startup script
├── run_and_test.bat # Windows test runner
└── logs/ # Log files
```
## 🔐 Security
### Authentication
- Master password authentication (no user registration required)
- JWT tokens with configurable expiry
- Secure password hashing (SHA-256 + salt)
- Environment-based secret management
### API Security
- All anime/episode endpoints require authentication
- CORS protection
- Input validation using Pydantic
- Error handling without sensitive data exposure
## 🔧 Configuration
### Environment Variables
- `JWT_SECRET_KEY`: Secret key for JWT token signing
- `PASSWORD_SALT`: Salt for password hashing
- `MASTER_PASSWORD`: Master password (development only)
- `MASTER_PASSWORD_HASH`: Hashed master password (production)
- `SESSION_TIMEOUT_HOURS`: JWT token expiry time
- `ANIME_DIRECTORY`: Path to anime files
- `LOG_LEVEL`: Logging level (DEBUG, INFO, WARNING, ERROR)
### Production Configuration
1. Set `MASTER_PASSWORD_HASH` instead of `MASTER_PASSWORD`
2. Use a strong `JWT_SECRET_KEY`
3. Set appropriate `CORS_ORIGINS`
4. Configure proper logging levels
## 📊 API Status
| Endpoint Category | Status | Coverage |
|------------------|--------|----------|
| Authentication | ✅ Complete | 100% |
| Health/System | ✅ Complete | 100% |
| Anime Search | ✅ Implemented | Mock data |
| Episode Management | ✅ Implemented | Mock data |
| Database Integration | 🔄 Placeholder | Todo |
| Real Data Provider | 🔄 Placeholder | Todo |
## 🚧 Future Enhancements
### High Priority
- [ ] Connect to actual anime database/provider
- [ ] Implement real anime search functionality
- [ ] Add episode streaming capabilities
- [ ] Database connection pooling
### Medium Priority
- [ ] Redis caching layer
- [ ] Rate limiting middleware
- [ ] Background task processing
- [ ] WebSocket support
### Low Priority
- [ ] Advanced search filters
- [ ] User preferences (multi-user support)
- [ ] Download progress tracking
- [ ] Statistics and analytics
## 📝 License
This project follows the AniWorld project licensing terms.
## 🤝 Contributing
1. Follow the coding standards in `.github/copilot-instructions.md`
2. Use type hints and Pydantic models
3. Add comprehensive logging
4. Include tests for new features
5. Update documentation
## 📞 Support
- API Documentation: http://localhost:8000/docs
- Health Check: http://localhost:8000/health
- Logs: Check `logs/aniworld.log` for detailed information
---
**Note**: This FastAPI implementation provides a solid foundation following the project instructions. The authentication system is complete and production-ready, while anime/episode endpoints currently return mock data pending integration with the actual data providers.

View File

@@ -1,573 +0,0 @@
import os
import json
import hashlib
import secrets
from typing import Dict, Any, Optional
from datetime import datetime, timedelta
class Config:
"""Configuration management for AniWorld Flask app."""
def __init__(self, config_file: str = "data/config.json"):
self.config_file = config_file
self.default_config = {
"security": {
"master_password_hash": None,
"salt": None,
"session_timeout_hours": 24,
"max_failed_attempts": 5,
"lockout_duration_minutes": 30
},
"anime": {
"directory": os.getenv("ANIME_DIRECTORY", "\\\\sshfs.r\\ubuntu@192.168.178.43\\media\\serien\\Serien"),
"download_threads": 3,
"download_speed_limit": None,
"auto_rescan_time": "03:00",
"auto_download_after_rescan": False
},
"logging": {
"level": "INFO",
"enable_console_logging": True,
"enable_console_progress": False,
"enable_fail2ban_logging": True,
"log_file": "./logs/aniworld.log",
"max_log_size_mb": 10,
"log_backup_count": 5
},
"providers": {
"default_provider": "aniworld.to",
"preferred_language": "German Dub",
"fallback_providers": ["aniworld.to"],
"provider_timeout": 30,
"retry_attempts": 3,
"provider_settings": {
"aniworld.to": {
"enabled": True,
"priority": 1,
"quality_preference": "720p"
}
}
},
"advanced": {
"max_concurrent_downloads": 3,
"download_buffer_size": 8192,
"connection_timeout": 30,
"read_timeout": 300,
"enable_debug_mode": False,
"cache_duration_minutes": 60
}
}
self._config = self._load_config()
def _load_config(self) -> Dict[str, Any]:
"""Load configuration from file or create default."""
try:
if os.path.exists(self.config_file):
with open(self.config_file, 'r', encoding='utf-8') as f:
config = json.load(f)
# Merge with defaults to ensure all keys exist
return self._merge_configs(self.default_config, config)
else:
return self.default_config.copy()
except Exception as e:
print(f"Error loading config: {e}")
return self.default_config.copy()
def _merge_configs(self, default: Dict[str, Any], user: Dict[str, Any]) -> Dict[str, Any]:
"""Recursively merge user config with defaults."""
result = default.copy()
for key, value in user.items():
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
result[key] = self._merge_configs(result[key], value)
else:
result[key] = value
return result
def save_config(self) -> bool:
"""Save current configuration to file."""
try:
with open(self.config_file, 'w', encoding='utf-8') as f:
json.dump(self._config, f, indent=4)
return True
except Exception as e:
print(f"Error saving config: {e}")
return False
def get(self, key_path: str, default: Any = None) -> Any:
"""Get config value using dot notation (e.g., 'security.master_password_hash')."""
keys = key_path.split('.')
value = self._config
for key in keys:
if isinstance(value, dict) and key in value:
value = value[key]
else:
return default
return value
def set(self, key_path: str, value: Any) -> bool:
"""Set config value using dot notation."""
keys = key_path.split('.')
config = self._config
# Navigate to parent
for key in keys[:-1]:
if key not in config:
config[key] = {}
config = config[key]
# Set final value
config[keys[-1]] = value
return self.save_config()
def set_master_password(self, password: str) -> bool:
"""Set master password with secure hashing."""
try:
# Generate salt
salt = secrets.token_hex(32)
# Hash password with salt
password_hash = hashlib.sha256((password + salt).encode()).hexdigest()
# Save to config
self.set("security.salt", salt)
self.set("security.master_password_hash", password_hash)
return True
except Exception as e:
print(f"Error setting master password: {e}")
return False
def verify_password(self, password: str) -> bool:
"""Verify password against stored hash."""
try:
stored_hash = self.get("security.master_password_hash")
salt = self.get("security.salt")
if not stored_hash or not salt:
return False
# Hash provided password with stored salt
password_hash = hashlib.sha256((password + salt).encode()).hexdigest()
return password_hash == stored_hash
except Exception as e:
print(f"Error verifying password: {e}")
return False
def has_master_password(self) -> bool:
"""Check if master password is configured."""
return bool(self.get("security.master_password_hash"))
def backup_config(self, backup_path: Optional[str] = None) -> str:
"""Create backup of current configuration."""
if not backup_path:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
backup_path = f"config_backup_{timestamp}.json"
try:
with open(backup_path, 'w', encoding='utf-8') as f:
json.dump(self._config, f, indent=4)
return backup_path
except Exception as e:
raise Exception(f"Failed to create backup: {e}")
def restore_config(self, backup_path: str) -> bool:
"""Restore configuration from backup."""
try:
with open(backup_path, 'r', encoding='utf-8') as f:
config = json.load(f)
# Validate config before restoring
validation_result = self.validate_config(config)
if not validation_result['valid']:
raise Exception(f"Invalid configuration: {validation_result['errors']}")
self._config = self._merge_configs(self.default_config, config)
return self.save_config()
except Exception as e:
print(f"Error restoring config: {e}")
return False
def validate_config(self, config: Dict[str, Any] = None) -> Dict[str, Any]:
"""Validate configuration structure and values."""
if config is None:
config = self._config
errors = []
warnings = []
# Validate security settings
security = config.get('security', {})
if security.get('session_timeout_hours', 0) < 1 or security.get('session_timeout_hours', 0) > 168:
errors.append("Session timeout must be between 1 and 168 hours")
if security.get('max_failed_attempts', 0) < 1 or security.get('max_failed_attempts', 0) > 50:
errors.append("Max failed attempts must be between 1 and 50")
if security.get('lockout_duration_minutes', 0) < 1 or security.get('lockout_duration_minutes', 0) > 1440:
errors.append("Lockout duration must be between 1 and 1440 minutes")
# Validate anime settings
anime = config.get('anime', {})
directory = anime.get('directory', '')
if directory and not os.path.exists(directory) and not directory.startswith('\\\\'):
warnings.append(f"Anime directory does not exist: {directory}")
download_threads = anime.get('download_threads', 1)
if download_threads < 1 or download_threads > 10:
errors.append("Download threads must be between 1 and 10")
# Validate logging settings
logging_config = config.get('logging', {})
log_level = logging_config.get('level', 'INFO')
if log_level not in ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']:
errors.append(f"Invalid log level: {log_level}")
# Validate provider settings
providers = config.get('providers', {})
provider_timeout = providers.get('provider_timeout', 30)
if provider_timeout < 5 or provider_timeout > 300:
errors.append("Provider timeout must be between 5 and 300 seconds")
retry_attempts = providers.get('retry_attempts', 3)
if retry_attempts < 0 or retry_attempts > 10:
errors.append("Retry attempts must be between 0 and 10")
# Validate advanced settings
advanced = config.get('advanced', {})
max_concurrent = advanced.get('max_concurrent_downloads', 3)
if max_concurrent < 1 or max_concurrent > 20:
errors.append("Max concurrent downloads must be between 1 and 20")
connection_timeout = advanced.get('connection_timeout', 30)
if connection_timeout < 5 or connection_timeout > 300:
errors.append("Connection timeout must be between 5 and 300 seconds")
return {
'valid': len(errors) == 0,
'errors': errors,
'warnings': warnings
}
def get_config_schema(self) -> Dict[str, Any]:
"""Get configuration schema for UI generation."""
return {
"security": {
"title": "Security Settings",
"fields": {
"session_timeout_hours": {
"type": "number",
"title": "Session Timeout (hours)",
"description": "How long sessions remain active",
"min": 1,
"max": 168,
"default": 24
},
"max_failed_attempts": {
"type": "number",
"title": "Max Failed Login Attempts",
"description": "Number of failed attempts before lockout",
"min": 1,
"max": 50,
"default": 5
},
"lockout_duration_minutes": {
"type": "number",
"title": "Lockout Duration (minutes)",
"description": "How long to lock account after failed attempts",
"min": 1,
"max": 1440,
"default": 30
}
}
},
"anime": {
"title": "Anime Settings",
"fields": {
"directory": {
"type": "text",
"title": "Anime Directory",
"description": "Base directory for anime storage",
"required": True
},
"download_threads": {
"type": "number",
"title": "Download Threads",
"description": "Number of concurrent download threads",
"min": 1,
"max": 10,
"default": 3
},
"download_speed_limit": {
"type": "number",
"title": "Speed Limit (KB/s)",
"description": "Download speed limit (0 = unlimited)",
"min": 0,
"max": 102400,
"default": 0
}
}
},
"providers": {
"title": "Provider Settings",
"fields": {
"default_provider": {
"type": "select",
"title": "Default Provider",
"description": "Primary anime provider",
"options": ["aniworld.to"],
"default": "aniworld.to"
},
"preferred_language": {
"type": "select",
"title": "Preferred Language",
"description": "Default language preference",
"options": ["German Dub", "German Sub", "English Dub", "English Sub", "Japanese"],
"default": "German Dub"
},
"provider_timeout": {
"type": "number",
"title": "Provider Timeout (seconds)",
"description": "Timeout for provider requests",
"min": 5,
"max": 300,
"default": 30
},
"retry_attempts": {
"type": "number",
"title": "Retry Attempts",
"description": "Number of retry attempts for failed requests",
"min": 0,
"max": 10,
"default": 3
}
}
},
"advanced": {
"title": "Advanced Settings",
"fields": {
"max_concurrent_downloads": {
"type": "number",
"title": "Max Concurrent Downloads",
"description": "Maximum simultaneous downloads",
"min": 1,
"max": 20,
"default": 3
},
"connection_timeout": {
"type": "number",
"title": "Connection Timeout (seconds)",
"description": "Network connection timeout",
"min": 5,
"max": 300,
"default": 30
},
"enable_debug_mode": {
"type": "boolean",
"title": "Debug Mode",
"description": "Enable detailed debug logging",
"default": False
}
}
}
}
def export_config(self, include_sensitive: bool = False) -> Dict[str, Any]:
"""Export configuration, optionally excluding sensitive data."""
config_copy = json.loads(json.dumps(self._config)) # Deep copy
if not include_sensitive:
# Remove sensitive data
if 'security' in config_copy:
config_copy['security'].pop('master_password_hash', None)
config_copy['security'].pop('salt', None)
return config_copy
def import_config(self, config_data: Dict[str, Any], validate: bool = True) -> Dict[str, Any]:
"""Import configuration with validation."""
if validate:
validation_result = self.validate_config(config_data)
if not validation_result['valid']:
return {
'success': False,
'errors': validation_result['errors'],
'warnings': validation_result['warnings']
}
# Merge with existing config (don't overwrite security settings)
current_security = self._config.get('security', {})
merged_config = self._merge_configs(self.default_config, config_data)
# Preserve current security settings if not provided
if not config_data.get('security', {}).get('master_password_hash'):
merged_config['security'] = current_security
self._config = merged_config
success = self.save_config()
return {
'success': success,
'errors': [] if success else ['Failed to save configuration'],
'warnings': validation_result.get('warnings', []) if validate else []
}
@property
def anime_directory(self) -> str:
"""Get anime directory path."""
# Always check environment variable first
env_dir = os.getenv("ANIME_DIRECTORY")
if env_dir:
# Remove quotes if they exist
env_dir = env_dir.strip('"\'')
return env_dir
return self.get("anime.directory", "\\\\sshfs.r\\ubuntu@192.168.178.43\\media\\serien\\Serien")
@anime_directory.setter
def anime_directory(self, value: str):
"""Set anime directory path."""
self.set("anime.directory", value)
@property
def session_timeout_hours(self) -> int:
"""Get session timeout in hours."""
return self.get("security.session_timeout_hours", 24)
@property
def max_failed_attempts(self) -> int:
"""Get maximum failed login attempts."""
return self.get("security.max_failed_attempts", 5)
@property
def lockout_duration_minutes(self) -> int:
"""Get lockout duration in minutes."""
return self.get("security.lockout_duration_minutes", 30)
@property
def scheduled_rescan_enabled(self) -> bool:
"""Get whether scheduled rescan is enabled."""
return self.get("scheduler.rescan_enabled", False)
@scheduled_rescan_enabled.setter
def scheduled_rescan_enabled(self, value: bool):
"""Set whether scheduled rescan is enabled."""
self.set("scheduler.rescan_enabled", value)
@property
def scheduled_rescan_time(self) -> str:
"""Get scheduled rescan time in HH:MM format."""
return self.get("scheduler.rescan_time", "03:00")
@scheduled_rescan_time.setter
def scheduled_rescan_time(self, value: str):
"""Set scheduled rescan time in HH:MM format."""
self.set("scheduler.rescan_time", value)
@property
def auto_download_after_rescan(self) -> bool:
"""Get whether to auto-download after scheduled rescan."""
return self.get("scheduler.auto_download_after_rescan", False)
@auto_download_after_rescan.setter
def auto_download_after_rescan(self, value: bool):
"""Set whether to auto-download after scheduled rescan."""
self.set("scheduler.auto_download_after_rescan", value)
@property
def log_level(self) -> str:
"""Get current log level."""
return self.get("logging.level", "INFO")
@log_level.setter
def log_level(self, value: str):
"""Set log level."""
self.set("logging.level", value.upper())
@property
def enable_console_logging(self) -> bool:
"""Get whether console logging is enabled."""
return self.get("logging.enable_console_logging", True)
@enable_console_logging.setter
def enable_console_logging(self, value: bool):
"""Set whether console logging is enabled."""
self.set("logging.enable_console_logging", value)
@property
def enable_console_progress(self) -> bool:
"""Get whether console progress bars are enabled."""
return self.get("logging.enable_console_progress", False)
@enable_console_progress.setter
def enable_console_progress(self, value: bool):
"""Set whether console progress bars are enabled."""
self.set("logging.enable_console_progress", value)
@property
def enable_fail2ban_logging(self) -> bool:
"""Get whether fail2ban logging is enabled."""
return self.get("logging.enable_fail2ban_logging", True)
@enable_fail2ban_logging.setter
def enable_fail2ban_logging(self, value: bool):
"""Set whether fail2ban logging is enabled."""
self.set("logging.enable_fail2ban_logging", value)
# Provider configuration properties
@property
def default_provider(self) -> str:
"""Get default provider."""
return self.get("providers.default_provider", "aniworld.to")
@default_provider.setter
def default_provider(self, value: str):
"""Set default provider."""
self.set("providers.default_provider", value)
@property
def preferred_language(self) -> str:
"""Get preferred language."""
return self.get("providers.preferred_language", "German Dub")
@preferred_language.setter
def preferred_language(self, value: str):
"""Set preferred language."""
self.set("providers.preferred_language", value)
@property
def provider_timeout(self) -> int:
"""Get provider timeout in seconds."""
return self.get("providers.provider_timeout", 30)
@provider_timeout.setter
def provider_timeout(self, value: int):
"""Set provider timeout in seconds."""
self.set("providers.provider_timeout", value)
# Advanced configuration properties
@property
def max_concurrent_downloads(self) -> int:
"""Get maximum concurrent downloads."""
return self.get("advanced.max_concurrent_downloads", 3)
@max_concurrent_downloads.setter
def max_concurrent_downloads(self, value: int):
"""Set maximum concurrent downloads."""
self.set("advanced.max_concurrent_downloads", value)
@property
def enable_debug_mode(self) -> bool:
"""Get whether debug mode is enabled."""
return self.get("advanced.enable_debug_mode", False)
@enable_debug_mode.setter
def enable_debug_mode(self, value: bool):
"""Set whether debug mode is enabled."""
self.set("advanced.enable_debug_mode", value)
# Global config instance
config = Config()

View File

@@ -1,10 +0,0 @@
"""
Configuration package for the Aniworld server.
This package provides configuration management and environment
variable handling for secure application deployment.
"""
from .env_config import EnvironmentConfig, env_config
__all__ = ['EnvironmentConfig', 'env_config']

View File

@@ -1,217 +0,0 @@
"""
Environment configuration for secure handling of sensitive data.
This module provides secure environment variable handling and configuration
management for the Aniworld server application.
"""
import os
import secrets
from typing import Optional, Dict, Any
from dotenv import load_dotenv
import logging
logger = logging.getLogger(__name__)
# Load environment variables from .env file
load_dotenv()
class EnvironmentConfig:
"""Manages environment variables and secure configuration."""
# Security
SECRET_KEY: str = os.getenv('SECRET_KEY', secrets.token_urlsafe(32))
JWT_SECRET_KEY: str = os.getenv('JWT_SECRET_KEY', secrets.token_urlsafe(32))
PASSWORD_SALT: str = os.getenv('PASSWORD_SALT', secrets.token_hex(32))
# Database
DATABASE_URL: str = os.getenv('DATABASE_URL', 'sqlite:///data/aniworld.db')
DATABASE_PASSWORD: Optional[str] = os.getenv('DATABASE_PASSWORD')
# Redis (for caching and sessions)
REDIS_URL: str = os.getenv('REDIS_URL', 'redis://localhost:6379/0')
REDIS_PASSWORD: Optional[str] = os.getenv('REDIS_PASSWORD')
# API Keys and External Services
ANIME_PROVIDER_API_KEY: Optional[str] = os.getenv('ANIME_PROVIDER_API_KEY')
TMDB_API_KEY: Optional[str] = os.getenv('TMDB_API_KEY')
# Email Configuration (for password reset)
SMTP_SERVER: str = os.getenv('SMTP_SERVER', 'localhost')
SMTP_PORT: int = int(os.getenv('SMTP_PORT', '587'))
SMTP_USERNAME: Optional[str] = os.getenv('SMTP_USERNAME')
SMTP_PASSWORD: Optional[str] = os.getenv('SMTP_PASSWORD')
SMTP_USE_TLS: bool = os.getenv('SMTP_USE_TLS', 'true').lower() == 'true'
FROM_EMAIL: str = os.getenv('FROM_EMAIL', 'noreply@aniworld.local')
# Security Settings
SESSION_TIMEOUT_HOURS: int = int(os.getenv('SESSION_TIMEOUT_HOURS', '24'))
MAX_FAILED_LOGIN_ATTEMPTS: int = int(os.getenv('MAX_FAILED_LOGIN_ATTEMPTS', '5'))
LOCKOUT_DURATION_MINUTES: int = int(os.getenv('LOCKOUT_DURATION_MINUTES', '30'))
# Rate Limiting
RATE_LIMIT_PER_MINUTE: int = int(os.getenv('RATE_LIMIT_PER_MINUTE', '60'))
API_RATE_LIMIT_PER_MINUTE: int = int(os.getenv('API_RATE_LIMIT_PER_MINUTE', '100'))
# Application Settings
DEBUG: bool = os.getenv('DEBUG', 'false').lower() == 'true'
HOST: str = os.getenv('HOST', '127.0.0.1')
PORT: int = int(os.getenv('PORT', '5000'))
# Anime Directory and Download Settings
ANIME_DIRECTORY: str = os.getenv('ANIME_DIRECTORY', './downloads')
MAX_CONCURRENT_DOWNLOADS: int = int(os.getenv('MAX_CONCURRENT_DOWNLOADS', '3'))
DOWNLOAD_SPEED_LIMIT: Optional[int] = int(os.getenv('DOWNLOAD_SPEED_LIMIT', '0')) or None
# Logging
LOG_LEVEL: str = os.getenv('LOG_LEVEL', 'INFO')
LOG_FILE: str = os.getenv('LOG_FILE', './logs/aniworld.log')
@classmethod
def get_database_config(cls) -> Dict[str, Any]:
"""Get database configuration."""
return {
'url': cls.DATABASE_URL,
'password': cls.DATABASE_PASSWORD,
'pool_size': int(os.getenv('DATABASE_POOL_SIZE', '10')),
'max_overflow': int(os.getenv('DATABASE_MAX_OVERFLOW', '20')),
'pool_timeout': int(os.getenv('DATABASE_POOL_TIMEOUT', '30')),
'pool_recycle': int(os.getenv('DATABASE_POOL_RECYCLE', '3600'))
}
@classmethod
def get_redis_config(cls) -> Dict[str, Any]:
"""Get Redis configuration."""
return {
'url': cls.REDIS_URL,
'password': cls.REDIS_PASSWORD,
'max_connections': int(os.getenv('REDIS_MAX_CONNECTIONS', '10')),
'retry_on_timeout': True,
'socket_timeout': int(os.getenv('REDIS_SOCKET_TIMEOUT', '5'))
}
@classmethod
def get_email_config(cls) -> Dict[str, Any]:
"""Get email configuration."""
return {
'server': cls.SMTP_SERVER,
'port': cls.SMTP_PORT,
'username': cls.SMTP_USERNAME,
'password': cls.SMTP_PASSWORD,
'use_tls': cls.SMTP_USE_TLS,
'from_email': cls.FROM_EMAIL
}
@classmethod
def get_security_config(cls) -> Dict[str, Any]:
"""Get security configuration."""
return {
'secret_key': cls.SECRET_KEY,
'jwt_secret_key': cls.JWT_SECRET_KEY,
'password_salt': cls.PASSWORD_SALT,
'session_timeout_hours': cls.SESSION_TIMEOUT_HOURS,
'max_failed_attempts': cls.MAX_FAILED_LOGIN_ATTEMPTS,
'lockout_duration_minutes': cls.LOCKOUT_DURATION_MINUTES,
'rate_limit_per_minute': cls.RATE_LIMIT_PER_MINUTE,
'api_rate_limit_per_minute': cls.API_RATE_LIMIT_PER_MINUTE
}
@classmethod
def validate_config(cls) -> bool:
"""Validate that required configuration is present."""
required_vars = [
'SECRET_KEY',
'JWT_SECRET_KEY',
'PASSWORD_SALT'
]
missing_vars = []
for var in required_vars:
if not getattr(cls, var):
missing_vars.append(var)
if missing_vars:
logger.error(f"Missing required environment variables: {missing_vars}")
return False
return True
@classmethod
def generate_env_template(cls, file_path: str = '.env.template') -> bool:
"""Generate a template .env file with all available configuration options."""
try:
template_content = """# Aniworld Server Environment Configuration
# Copy this file to .env and fill in your values
# Security (REQUIRED - Generate secure random values)
SECRET_KEY=your_secret_key_here
JWT_SECRET_KEY=your_jwt_secret_here
PASSWORD_SALT=your_password_salt_here
# Database Configuration
DATABASE_URL=sqlite:///data/aniworld.db
# DATABASE_PASSWORD=your_db_password_here
DATABASE_POOL_SIZE=10
DATABASE_MAX_OVERFLOW=20
DATABASE_POOL_TIMEOUT=30
DATABASE_POOL_RECYCLE=3600
# Redis Configuration (for caching and sessions)
REDIS_URL=redis://localhost:6379/0
# REDIS_PASSWORD=your_redis_password_here
REDIS_MAX_CONNECTIONS=10
REDIS_SOCKET_TIMEOUT=5
# Email Configuration (for password reset emails)
SMTP_SERVER=localhost
SMTP_PORT=587
# SMTP_USERNAME=your_smtp_username
# SMTP_PASSWORD=your_smtp_password
SMTP_USE_TLS=true
FROM_EMAIL=noreply@aniworld.local
# External API Keys
# ANIME_PROVIDER_API_KEY=your_anime_provider_api_key
# TMDB_API_KEY=your_tmdb_api_key
# Security Settings
SESSION_TIMEOUT_HOURS=24
MAX_FAILED_LOGIN_ATTEMPTS=5
LOCKOUT_DURATION_MINUTES=30
# Rate Limiting
RATE_LIMIT_PER_MINUTE=60
API_RATE_LIMIT_PER_MINUTE=100
# Application Settings
DEBUG=false
HOST=127.0.0.1
PORT=5000
# Anime and Download Settings
ANIME_DIRECTORY=./downloads
MAX_CONCURRENT_DOWNLOADS=3
# DOWNLOAD_SPEED_LIMIT=1000000 # bytes per second
# Logging
LOG_LEVEL=INFO
LOG_FILE=./logs/aniworld.log
"""
with open(file_path, 'w', encoding='utf-8') as f:
f.write(template_content)
logger.info(f"Environment template created at {file_path}")
return True
except Exception as e:
logger.error(f"Error creating environment template: {e}")
return False
# Create global instance
env_config = EnvironmentConfig()
# Validate configuration on import
if not env_config.validate_config():
logger.warning("Invalid environment configuration detected. Please check your .env file.")

View File

@@ -1,28 +0,0 @@
from typing import Optional
from pydantic import BaseSettings, Field
class Settings(BaseSettings):
"""Application settings from environment variables."""
jwt_secret_key: str = Field(default="your-secret-key-here", env="JWT_SECRET_KEY")
password_salt: str = Field(default="default-salt", env="PASSWORD_SALT")
master_password_hash: Optional[str] = Field(default=None, env="MASTER_PASSWORD_HASH")
master_password: Optional[str] = Field(default=None, env="MASTER_PASSWORD") # For development
token_expiry_hours: int = Field(default=24, env="SESSION_TIMEOUT_HOURS")
anime_directory: str = Field(default="", env="ANIME_DIRECTORY")
log_level: str = Field(default="INFO", env="LOG_LEVEL")
# Additional settings from .env
database_url: str = Field(default="sqlite:///./data/aniworld.db", env="DATABASE_URL")
cors_origins: str = Field(default="*", env="CORS_ORIGINS")
api_rate_limit: int = Field(default=100, env="API_RATE_LIMIT")
default_provider: str = Field(default="aniworld.to", env="DEFAULT_PROVIDER")
provider_timeout: int = Field(default=30, env="PROVIDER_TIMEOUT")
retry_attempts: int = Field(default=3, env="RETRY_ATTEMPTS")
class Config:
env_file = ".env"
extra = "ignore"
settings = Settings()

View File

@@ -1,612 +0,0 @@
"""
FastAPI-based AniWorld Server Application.
This module implements a comprehensive FastAPI application following the instructions:
- Simple master password authentication using JWT
- Repository pattern with dependency injection
- Proper error handling and validation
- OpenAPI documentation
- Security best practices
"""
import hashlib
import logging
import os
import sys
from contextlib import asynccontextmanager
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional
import jwt
# Add parent directory to path for imports
current_dir = os.path.dirname(__file__)
parent_dir = os.path.join(current_dir, '..')
project_root = os.path.join(parent_dir, '..') # Go up two levels to reach project root
sys.path.insert(0, os.path.abspath(project_root))
import uvicorn
from fastapi import Depends, FastAPI, HTTPException, Request, Security, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel, Field
from src.config.settings import settings
# Application flow services will be imported lazily where needed to avoid
# import-time circular dependencies during tests.
SetupService = None
ApplicationFlowMiddleware = None
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler('./logs/aniworld.log'),
logging.StreamHandler()
]
)
logger = logging.getLogger(__name__)
# Security
security = HTTPBearer()
# Settings are loaded from `src.config.settings.settings`
# Pydantic Models
class LoginRequest(BaseModel):
"""Login request model."""
password: str = Field(..., min_length=1, description="Master password")
class LoginResponse(BaseModel):
"""Login response model."""
success: bool
message: str
token: Optional[str] = None
expires_at: Optional[datetime] = None
class TokenVerifyResponse(BaseModel):
"""Token verification response model."""
valid: bool
message: str
user: Optional[str] = None
expires_at: Optional[datetime] = None
class HealthResponse(BaseModel):
"""Health check response model."""
status: str
timestamp: datetime
version: str = "1.0.0"
services: Dict[str, str]
class AnimeSearchRequest(BaseModel):
"""Anime search request model."""
query: str = Field(..., min_length=1, max_length=100)
limit: int = Field(default=20, ge=1, le=100)
offset: int = Field(default=0, ge=0)
class AnimeResponse(BaseModel):
"""Anime response model."""
id: str
title: str
description: Optional[str] = None
episodes: int = 0
status: str = "Unknown"
poster_url: Optional[str] = None
class EpisodeResponse(BaseModel):
"""Episode response model."""
id: str
anime_id: str
episode_number: int
title: Optional[str] = None
description: Optional[str] = None
duration: Optional[int] = None
stream_url: Optional[str] = None
class ErrorResponse(BaseModel):
"""Error response model."""
success: bool = False
error: str
code: Optional[str] = None
details: Optional[Dict[str, Any]] = None
class SetupRequest(BaseModel):
"""Setup request model."""
password: str = Field(..., min_length=8, description="Master password (min 8 characters)")
directory: str = Field(..., min_length=1, description="Anime directory path")
class SetupResponse(BaseModel):
"""Setup response model."""
status: str
message: str
redirect_url: Optional[str] = None
class SetupStatusResponse(BaseModel):
"""Setup status response model."""
setup_complete: bool
requirements: Dict[str, bool]
missing_requirements: List[str]
# Authentication utilities
def hash_password(password: str) -> str:
"""Hash password with salt using SHA-256."""
salted_password = password + settings.password_salt
return hashlib.sha256(salted_password.encode()).hexdigest()
def verify_master_password(password: str) -> bool:
"""Verify password against master password hash."""
if not settings.master_password_hash:
# If no hash is set, check against plain password (development only)
if settings.master_password:
return password == settings.master_password
return False
password_hash = hash_password(password)
return password_hash == settings.master_password_hash
def generate_jwt_token() -> Dict[str, Any]:
"""Generate JWT token for authentication."""
expires_at = datetime.utcnow() + timedelta(hours=settings.token_expiry_hours)
payload = {
'user': 'master',
'exp': expires_at,
'iat': datetime.utcnow(),
'iss': 'aniworld-fastapi-server'
}
token = jwt.encode(payload, settings.jwt_secret_key, algorithm='HS256')
return {
'token': token,
'expires_at': expires_at
}
def verify_jwt_token(token: str) -> Optional[Dict[str, Any]]:
"""Verify and decode JWT token."""
try:
payload = jwt.decode(token, settings.jwt_secret_key, algorithms=['HS256'])
return payload
except jwt.ExpiredSignatureError:
logger.warning("Token has expired")
return None
except jwt.InvalidTokenError as e:
logger.warning(f"Invalid token: {str(e)}")
return None
async def get_current_user(credentials: HTTPAuthorizationCredentials = Security(security)):
"""Dependency to get current authenticated user."""
token = credentials.credentials
payload = verify_jwt_token(token)
if not payload:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
headers={"WWW-Authenticate": "Bearer"},
)
return payload
# Global exception handler
async def global_exception_handler(request, exc):
"""Global exception handler for unhandled errors."""
logger.error(f"Unhandled exception: {exc}", exc_info=True)
return JSONResponse(
status_code=500,
content={
"success": False,
"error": "Internal Server Error",
"code": "INTERNAL_ERROR"
}
)
# Application lifespan
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Manage application lifespan events."""
# Startup
logger.info("Starting AniWorld FastAPI server...")
logger.info(f"Anime directory: {settings.anime_directory}")
logger.info(f"Log level: {settings.log_level}")
# Verify configuration
if not settings.master_password_hash and not settings.master_password:
logger.warning("No master password configured! Set MASTER_PASSWORD_HASH or MASTER_PASSWORD environment variable.")
yield
# Shutdown
logger.info("Shutting down AniWorld FastAPI server...")
# Create FastAPI application
app = FastAPI(
title="AniWorld API",
description="""
## AniWorld Management System
A comprehensive FastAPI-based application for managing anime series and episodes.
### Features
* **Series Management**: Search, track, and manage anime series
* **Episode Tracking**: Monitor missing episodes and download progress
* **Authentication**: Secure master password authentication with JWT tokens
* **Real-time Updates**: WebSocket support for live progress tracking
* **File Management**: Automatic file scanning and organization
* **Download Queue**: Queue-based download management system
### Authentication
Most endpoints require authentication using a master password.
Use the `/auth/login` endpoint to obtain a JWT token, then include it
in the `Authorization` header as `Bearer <token>`.
### API Versioning
This API follows semantic versioning. Current version: **1.0.0**
""",
version="1.0.0",
docs_url="/docs",
redoc_url="/redoc",
lifespan=lifespan,
contact={
"name": "AniWorld API Support",
"url": "https://github.com/your-repo/aniworld",
"email": "support@aniworld.com",
},
license_info={
"name": "MIT",
"url": "https://opensource.org/licenses/MIT",
},
tags_metadata=[
{
"name": "Authentication",
"description": "Operations related to user authentication and session management",
},
{
"name": "Anime",
"description": "Operations for searching and managing anime series",
},
{
"name": "Episodes",
"description": "Operations for managing individual episodes",
},
{
"name": "Downloads",
"description": "Operations for managing the download queue and progress",
},
{
"name": "System",
"description": "System health, configuration, and maintenance operations",
},
{
"name": "Files",
"description": "File system operations and scanning functionality",
},
]
)
# Configure templates
templates = Jinja2Templates(directory="src/server/web/templates")
# Mount static files
app.mount("/static", StaticFiles(directory="src/server/web/static"), name="static")
# Add CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # Configure appropriately for production
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Add application flow middleware (import lazily to avoid circular imports during tests)
try:
if SetupService is None:
from src.server.middleware.application_flow_middleware import (
ApplicationFlowMiddleware as _AppFlow,
)
from src.server.services.setup_service import SetupService as _SetupService
setup_service = _SetupService()
app.add_middleware(_AppFlow, setup_service=setup_service)
except Exception:
# In test environments or minimal setups, middleware may be skipped
pass
# Add global exception handler
app.add_exception_handler(Exception, global_exception_handler)
from src.server.controllers.v2.anime_controller import router as anime_router
# Include controller routers (use v2 controllers to avoid circular imports)
from src.server.controllers.v2.auth_controller import router as auth_router
from src.server.controllers.v2.episode_controller import router as episode_router
from src.server.controllers.v2.setup_controller import router as setup_router
from src.server.controllers.v2.system_controller import router as system_router
app.include_router(auth_router)
app.include_router(setup_router)
app.include_router(anime_router)
app.include_router(episode_router)
app.include_router(system_router)
# Legacy API compatibility endpoints (TODO: migrate JavaScript to use v1 endpoints)
@app.post("/api/add_series")
async def legacy_add_series(
request_data: Dict[str, Any],
current_user: Dict = Depends(get_current_user)
):
"""Legacy endpoint for adding series - basic implementation."""
try:
link = request_data.get('link', '')
name = request_data.get('name', '')
if not link or not name:
return {"status": "error", "message": "Link and name are required"}
return {"status": "success", "message": f"Series '{name}' added successfully"}
except Exception as e:
return {"status": "error", "message": f"Failed to add series: {str(e)}"}
@app.post("/api/download")
async def legacy_download(
request_data: Dict[str, Any],
current_user: Dict = Depends(get_current_user)
):
"""Legacy endpoint for downloading series - basic implementation."""
try:
folders = request_data.get('folders', [])
if not folders:
return {"status": "error", "message": "No folders specified"}
folder_count = len(folders)
return {"status": "success", "message": f"Download started for {folder_count} series"}
except Exception as e:
return {"status": "error", "message": f"Failed to start download: {str(e)}"}
# Setup endpoints moved to controllers: src/server/controllers/setup_controller.py
# Health check endpoint (kept in main app)
@app.get("/health", response_model=HealthResponse, tags=["System"])
async def health_check() -> HealthResponse:
"""
Application health check endpoint.
"""
return HealthResponse(
status="healthy",
timestamp=datetime.utcnow(),
services={
"authentication": "online",
"anime_service": "online",
"episode_service": "online"
}
)
# Common browser requests that might cause "Invalid HTTP request received" warnings
@app.get("/favicon.ico")
async def favicon():
"""Handle favicon requests from browsers."""
return JSONResponse(status_code=404, content={"detail": "Favicon not found"})
@app.get("/robots.txt")
async def robots():
"""Handle robots.txt requests."""
return JSONResponse(status_code=404, content={"detail": "Robots.txt not found"})
@app.get("/")
async def root():
"""Root endpoint redirect to docs."""
return {"message": "AniWorld API", "documentation": "/docs", "health": "/health"}
# Web interface routes
@app.get("/app", response_class=HTMLResponse)
async def web_app(request: Request):
"""Serve the main web application."""
return templates.TemplateResponse("base/index.html", {"request": request})
@app.get("/login", response_class=HTMLResponse)
async def login_page(request: Request):
"""Serve the login page."""
return templates.TemplateResponse("base/login.html", {"request": request})
@app.get("/setup", response_class=HTMLResponse)
async def setup_page(request: Request):
"""Serve the setup page."""
return templates.TemplateResponse("base/setup.html", {"request": request})
@app.get("/queue", response_class=HTMLResponse)
async def queue_page(request: Request):
"""Serve the queue page."""
return templates.TemplateResponse("base/queue.html", {"request": request})
# Anime endpoints (protected)
@app.get("/api/anime/search", response_model=List[AnimeResponse], tags=["Anime"])
async def search_anime(
query: str,
limit: int = 20,
offset: int = 0,
current_user: Dict = Depends(get_current_user)
) -> List[AnimeResponse]:
"""
Search for anime by title.
Requires: Bearer token in Authorization header
- **query**: Search query string
- **limit**: Maximum number of results (1-100)
- **offset**: Number of results to skip for pagination
"""
# TODO: Implement actual anime search logic
# This is a placeholder implementation
logger.info(f"Searching anime with query: {query}")
# Mock data for now
mock_results = [
AnimeResponse(
id=f"anime_{i}",
title=f"Sample Anime {i}",
description=f"Description for anime {i}",
episodes=24,
status="Completed"
)
for i in range(offset + 1, min(offset + limit + 1, 100))
if query.lower() in f"sample anime {i}".lower()
]
return mock_results
@app.get("/api/anime/{anime_id}", response_model=AnimeResponse, tags=["Anime"])
async def get_anime(
anime_id: str,
current_user: Dict = Depends(get_current_user)
) -> AnimeResponse:
"""
Get detailed information about a specific anime.
Requires: Bearer token in Authorization header
- **anime_id**: Unique identifier for the anime
"""
# TODO: Implement actual anime retrieval logic
logger.info(f"Fetching anime details for ID: {anime_id}")
# Mock data for now
return AnimeResponse(
id=anime_id,
title=f"Anime {anime_id}",
description=f"Detailed description for anime {anime_id}",
episodes=24,
status="Completed"
)
@app.get("/api/anime/{anime_id}/episodes", response_model=List[EpisodeResponse], tags=["Episodes"])
async def get_anime_episodes(
anime_id: str,
current_user: Dict = Depends(get_current_user)
) -> List[EpisodeResponse]:
"""
Get all episodes for a specific anime.
Requires: Bearer token in Authorization header
- **anime_id**: Unique identifier for the anime
"""
# TODO: Implement actual episode retrieval logic
logger.info(f"Fetching episodes for anime ID: {anime_id}")
# Mock data for now
return [
EpisodeResponse(
id=f"{anime_id}_ep_{i}",
anime_id=anime_id,
episode_number=i,
title=f"Episode {i}",
description=f"Description for episode {i}",
duration=1440 # 24 minutes in seconds
)
for i in range(1, 25) # 24 episodes
]
@app.get("/api/episodes/{episode_id}", response_model=EpisodeResponse, tags=["Episodes"])
async def get_episode(
episode_id: str,
current_user: Dict = Depends(get_current_user)
) -> EpisodeResponse:
"""
Get detailed information about a specific episode.
Requires: Bearer token in Authorization header
- **episode_id**: Unique identifier for the episode
"""
# TODO: Implement actual episode retrieval logic
logger.info(f"Fetching episode details for ID: {episode_id}")
# Mock data for now
return EpisodeResponse(
id=episode_id,
anime_id="sample_anime",
episode_number=1,
title=f"Episode {episode_id}",
description=f"Detailed description for episode {episode_id}",
duration=1440
)
# Database health check endpoint
@app.get("/api/system/database/health", response_model=Dict[str, Any], tags=["System"])
async def database_health(current_user: Dict = Depends(get_current_user)) -> Dict[str, Any]:
"""
Check database connectivity and health.
Requires: Bearer token in Authorization header
"""
# TODO: Implement actual database health check
return {
"status": "healthy",
"connection_pool": "active",
"response_time_ms": 15,
"last_check": datetime.utcnow().isoformat()
}
# Configuration endpoint
@app.get("/api/system/config", response_model=Dict[str, Any], tags=["System"])
async def get_system_config(current_user: Dict = Depends(get_current_user)) -> Dict[str, Any]:
"""
Get system configuration information.
Requires: Bearer token in Authorization header
"""
return {
"anime_directory": settings.anime_directory,
"log_level": settings.log_level,
"token_expiry_hours": settings.token_expiry_hours,
"version": "1.0.0"
}
if __name__ == "__main__":
import socket
# Configure enhanced logging
log_level = getattr(logging, settings.log_level.upper(), logging.INFO)
logging.getLogger().setLevel(log_level)
# Check if port is available
def is_port_available(host: str, port: int) -> bool:
"""Check if a port is available on the given host."""
try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind((host, port))
return True
except OSError:
return False
host = "127.0.0.1"
port = 8000
if not is_port_available(host, port):
logger.error(f"Port {port} is already in use on {host}. Please stop other services or choose a different port.")
logger.info("You can check which process is using the port with: netstat -ano | findstr :8000")
sys.exit(1)
logger.info("Starting AniWorld FastAPI server with uvicorn...")
logger.info(f"Anime directory: {settings.anime_directory}")
logger.info(f"Log level: {settings.log_level}")
logger.info(f"Server will be available at http://{host}:{port}")
logger.info(f"API documentation at http://{host}:{port}/docs")
try:
# Run the application
uvicorn.run(
"fastapi_app:app",
host=host,
port=port,
reload=False, # Disable reload to prevent constant restarting
log_level=settings.log_level.lower()
)
except Exception as e:
logger.error(f"Failed to start server: {e}")
sys.exit(1)

View File

@@ -1,248 +0,0 @@
"""
Application Flow Middleware for FastAPI.
This middleware enforces the application flow priorities:
1. Setup page (if setup is not complete)
2. Authentication page (if user is not authenticated)
3. Main application (for authenticated users with completed setup)
The middleware redirects users to the appropriate page based on their current state
and the state of the application setup.
"""
import logging
from typing import Optional
from fastapi import Request
from fastapi.responses import RedirectResponse
from starlette.middleware.base import BaseHTTPMiddleware
# Import the setup service
try:
from ...core.application.services.setup_service import SetupService
except ImportError:
# Handle case where service is not available
class SetupService:
def is_setup_complete(self):
return True
logger = logging.getLogger(__name__)
class ApplicationFlowMiddleware(BaseHTTPMiddleware):
"""
Middleware to enforce application flow: setup → auth → main application.
This middleware:
1. Checks if setup is complete
2. Validates authentication status
3. Redirects to appropriate page based on state
4. Allows API endpoints and static files to pass through
"""
def __init__(self, app, setup_service: Optional[SetupService] = None):
"""
Initialize the application flow middleware.
Args:
app: FastAPI application instance
setup_service: Setup service instance (optional, will create if not provided)
"""
super().__init__(app)
self.setup_service = setup_service or SetupService()
# Define paths that should bypass flow enforcement
self.bypass_paths = {
"/static", # Static files
"/favicon.ico", # Browser favicon requests
"/robots.txt", # Robots.txt
"/health", # Health check endpoints
"/docs", # OpenAPI documentation
"/redoc", # ReDoc documentation
"/openapi.json" # OpenAPI spec
}
# API paths that should bypass flow but may require auth
self.api_paths = {
"/api",
"/auth"
}
# Pages that are part of the flow and should be accessible
self.flow_pages = {
"/setup",
"/login",
"/app"
}
async def dispatch(self, request: Request, call_next):
"""
Process the request and enforce application flow.
Args:
request: Incoming HTTP request
call_next: Next middleware/handler in chain
Returns:
Response: Either a redirect response or the result of call_next
"""
try:
# Get the request path
path = request.url.path
# Skip flow enforcement for certain paths
if self._should_bypass_flow(path):
return await call_next(request)
# Check application setup status
setup_complete = self.setup_service.is_setup_complete()
# Check authentication status
is_authenticated = await self._is_user_authenticated(request)
# Determine the appropriate action
redirect_response = self._determine_redirect(path, setup_complete, is_authenticated)
if redirect_response:
logger.info(f"Redirecting {path} to {redirect_response.headers.get('location')}")
return redirect_response
# Continue with the request
return await call_next(request)
except Exception as e:
logger.error(f"Error in ApplicationFlowMiddleware: {e}", exc_info=True)
# In case of error, allow the request to continue
return await call_next(request)
def _should_bypass_flow(self, path: str) -> bool:
"""
Check if the given path should bypass flow enforcement.
Args:
path: Request path
Returns:
bool: True if path should bypass flow enforcement
"""
# Check exact bypass paths
for bypass_path in self.bypass_paths:
if path.startswith(bypass_path):
return True
# API paths bypass flow enforcement (but may have their own auth)
for api_path in self.api_paths:
if path.startswith(api_path):
return True
return False
async def _is_user_authenticated(self, request: Request) -> bool:
"""
Check if the user is authenticated by validating JWT token.
Args:
request: HTTP request object
Returns:
bool: True if user is authenticated, False otherwise
"""
try:
# Check for Authorization header
auth_header = request.headers.get("authorization")
if not auth_header or not auth_header.startswith("Bearer "):
return False
# Extract and validate token
token = auth_header.split(" ")[1]
# Import JWT validation function (avoid circular imports)
try:
from ..fastapi_app import verify_jwt_token
payload = verify_jwt_token(token)
return payload is not None
except ImportError:
# Fallback if import fails
logger.warning("Could not import JWT verification function")
return False
except Exception as e:
logger.error(f"Error checking authentication: {e}")
return False
def _determine_redirect(self, path: str, setup_complete: bool, is_authenticated: bool) -> Optional[RedirectResponse]:
"""
Determine if a redirect is needed based on current state.
Args:
path: Current request path
setup_complete: Whether application setup is complete
is_authenticated: Whether user is authenticated
Returns:
Optional[RedirectResponse]: Redirect response if needed, None otherwise
"""
# If setup is not complete
if not setup_complete:
# Allow access to setup page
if path == "/setup":
return None
# Redirect everything else to setup
return RedirectResponse(url="/setup", status_code=302)
# Setup is complete, check authentication
if not is_authenticated:
# Allow access to login page
if path == "/login":
return None
# Redirect unauthenticated users to login (except for specific pages)
if path in self.flow_pages or path == "/":
return RedirectResponse(url="/login", status_code=302)
# User is authenticated and setup is complete
else:
# Redirect from setup/login pages to main app
if path in ["/setup", "/login", "/"]:
return RedirectResponse(url="/app", status_code=302)
# No redirect needed
return None
def get_flow_status(self, request: Request) -> dict:
"""
Get current flow status for debugging/monitoring.
Args:
request: HTTP request object
Returns:
dict: Current flow status information
"""
try:
setup_complete = self.setup_service.is_setup_complete()
is_authenticated = self._is_user_authenticated(request)
return {
"setup_complete": setup_complete,
"authenticated": is_authenticated,
"path": request.url.path,
"should_bypass": self._should_bypass_flow(request.url.path)
}
except Exception as e:
return {
"error": str(e),
"path": request.url.path
}
def create_application_flow_middleware(setup_service: Optional[SetupService] = None) -> ApplicationFlowMiddleware:
"""
Factory function to create application flow middleware.
Args:
setup_service: Setup service instance (optional)
Returns:
ApplicationFlowMiddleware: Configured middleware instance
"""
return ApplicationFlowMiddleware(app=None, setup_service=setup_service)

View File

@@ -1,41 +0,0 @@
# FastAPI and ASGI server
fastapi==0.118.0
uvicorn[standard]==0.37.0
python-multipart==0.0.12
# Authentication and security
pyjwt==2.10.1
passlib[bcrypt]==1.7.4
python-jose[cryptography]==3.3.0
# Configuration and environment
pydantic==2.11.10
pydantic-settings==2.11.0
python-dotenv==1.1.1
# Database (if needed)
sqlalchemy==2.0.43
alembic==1.16.5
# HTTP client
httpx==0.28.1
aiofiles==24.1.0
# Utilities
python-dateutil==2.9.0.post0
pytz==2024.2
# Development and testing
pytest==8.4.2
pytest-asyncio==1.2.0
pytest-cov==7.0.0
pytest-mock==3.15.1
# Code quality
black==25.9.0
isort==6.1.0
flake8==7.3.0
mypy==1.18.2
# Logging
structlog==25.1.0

View File

@@ -1,34 +0,0 @@
from fastapi import FastAPI
from src.server.controllers import (
anime_controller,
auth_controller,
episode_controller,
setup_controller,
system_controller,
)
# Avoid TestClient/httpx incompatibilities in some envs; we'll check route registration instead
def test_controllers_expose_router_objects():
# Routers should exist
assert hasattr(auth_controller, "router")
assert hasattr(anime_controller, "router")
assert hasattr(episode_controller, "router")
assert hasattr(setup_controller, "router")
assert hasattr(system_controller, "router")
def test_include_routers_in_app():
app = FastAPI()
app.include_router(auth_controller.router)
app.include_router(anime_controller.router)
app.include_router(episode_controller.router)
app.include_router(setup_controller.router)
app.include_router(system_controller.router)
# Basic sanity: the system config route should be registered on the app
paths = [r.path for r in app.routes if hasattr(r, 'path')]
assert "/api/system/config" in paths

View File

@@ -1,24 +0,0 @@
import os
from src.config.settings import settings
def test_settings_has_fields(monkeypatch):
# Ensure settings object has expected attributes and env vars affect values
monkeypatch.setenv("JWT_SECRET_KEY", "test-secret")
monkeypatch.setenv("ANIME_DIRECTORY", "/tmp/anime")
# Reload settings by creating a new instance
from src.config.settings import Settings
s = Settings()
assert s.jwt_secret_key == "test-secret"
assert s.anime_directory == "/tmp/anime"
def test_settings_defaults():
# When env not set, defaults are used
s = settings
assert hasattr(s, "jwt_secret_key")
assert hasattr(s, "anime_directory")
assert hasattr(s, "token_expiry_hours")