instruction2
This commit is contained in:
@@ -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
|
||||
@@ -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.
|
||||
@@ -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()
|
||||
@@ -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']
|
||||
@@ -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.")
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user