from fastapi import FastAPI, Request, Response, HTTPException from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from contextlib import asynccontextmanager import os from pathlib import Path import mimetypes from config.settings import settings from config.database import engine, Base from api.routers import auth, users, events, albums, posts, vlogs, stats, admin, notifications, settings as settings_router, information, tickets, push from utils.init_db import init_database from utils.settings_service import SettingsService from config.database import SessionLocal # Create uploads directory if it doesn't exist Path(settings.UPLOAD_PATH).mkdir(parents=True, exist_ok=True) def init_default_settings(): """Initialize default system settings.""" db = SessionLocal() try: # Check if settings already exist from models.settings import SystemSettings existing_settings = db.query(SystemSettings).count() if existing_settings == 0: print("Initializing default system settings...") default_settings = [ { "key": "max_album_size_mb", "value": "100", "description": "Taille maximale des albums en MB", "category": "uploads" }, { "key": "max_vlog_size_mb", "value": "500", "description": "Taille maximale des vlogs en MB", "category": "uploads" }, { "key": "max_image_size_mb", "value": "10", "description": "Taille maximale des images en MB", "category": "uploads" }, { "key": "max_video_size_mb", "value": "100", "description": "Taille maximale des vidéos en MB", "category": "uploads" }, { "key": "max_media_per_album", "value": "50", "description": "Nombre maximum de médias par album", "category": "uploads" }, { "key": "allowed_image_types", "value": "image/jpeg,image/png,image/gif,image/webp", "description": "Types d'images autorisés (séparés par des virgules)", "category": "uploads" }, { "key": "allowed_video_types", "value": "video/mp4,video/mpeg,video/quicktime,video/webm", "description": "Types de vidéos autorisés (séparés par des virgules)", "category": "uploads" }, { "key": "max_users", "value": "50", "description": "Nombre maximum d'utilisateurs", "category": "general" }, { "key": "enable_registration", "value": "true", "description": "Autoriser les nouvelles inscriptions", "category": "general" } ] for setting_data in default_settings: setting = SystemSettings(**setting_data) db.add(setting) db.commit() print(f"Created {len(default_settings)} default settings") else: print("System settings already exist, checking for missing settings...") # Check for missing settings and add them all_settings = [ { "key": "max_album_size_mb", "value": "100", "description": "Taille maximale des albums en MB", "category": "uploads" }, { "key": "max_vlog_size_mb", "value": "500", "description": "Taille maximale des vlogs en MB", "category": "uploads" }, { "key": "max_image_size_mb", "value": "10", "description": "Taille maximale des images en MB", "category": "uploads" }, { "key": "max_video_size_mb", "value": "100", "description": "Taille maximale des vidéos en MB", "category": "uploads" }, { "key": "max_media_per_album", "value": "50", "description": "Nombre maximum de médias par album", "category": "uploads" }, { "key": "allowed_image_types", "value": "image/jpeg,image/png,image/gif,image/webp", "description": "Types d'images autorisés (séparés par des virgules)", "category": "uploads" }, { "key": "allowed_video_types", "value": "video/mp4,video/mpeg,video/quicktime,video/webm", "description": "Types de vidéos autorisés (séparés par des virgules)", "category": "uploads" }, { "key": "max_users", "value": "50", "description": "Nombre maximum d'utilisateurs", "category": "general" }, { "key": "enable_registration", "value": "true", "description": "Autoriser les nouvelles inscriptions", "category": "general" } ] added_count = 0 for setting_data in all_settings: existing = db.query(SystemSettings).filter(SystemSettings.key == setting_data["key"]).first() if not existing: setting = SystemSettings(**setting_data) db.add(setting) added_count += 1 if added_count > 0: db.commit() print(f"Added {added_count} missing settings") else: print("All settings are already present") except Exception as e: print(f"Error initializing settings: {e}") db.rollback() finally: db.close() @asynccontextmanager async def lifespan(app: FastAPI): # Startup print("Starting LeDiscord backend...") # Note: Database migrations are handled by Alembic # Run migrations manually with: alembic upgrade head # Base.metadata.create_all(bind=engine) # Disabled in favor of Alembic migrations # Initialize database with admin user init_database() # Initialize default settings init_default_settings() yield # Shutdown print("Shutting down LeDiscord backend...") app = FastAPI( title="LeDiscord API", description="API pour la plateforme communautaire LeDiscord", version="1.0.0", lifespan=lifespan ) # Configure CORS app.add_middleware( CORSMiddleware, allow_origins=settings.CORS_ORIGINS_LIST, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], expose_headers=["Content-Range", "Accept-Ranges"], ) # Endpoint personnalisé pour servir les vidéos avec support Range @app.get("/uploads/{file_path:path}") async def serve_media_with_range(request: Request, file_path: str): """ Serve media files with proper Range request support for video scrubbing. """ file_full_path = Path(settings.UPLOAD_PATH) / file_path # Vérifier que le fichier existe if not file_full_path.exists() or not file_full_path.is_file(): raise HTTPException(status_code=404, detail="File not found") # Vérifier que le fichier est dans le répertoire uploads (sécurité) try: file_full_path.resolve().relative_to(Path(settings.UPLOAD_PATH).resolve()) except ValueError: raise HTTPException(status_code=403, detail="Access denied") # Obtenir la taille du fichier file_size = file_full_path.stat().st_size # Déterminer le content type content_type, _ = mimetypes.guess_type(str(file_full_path)) if not content_type: content_type = "application/octet-stream" # Gérer les requêtes Range range_header = request.headers.get("Range") if range_header: # Parser le header Range (format: bytes=start-end) range_match = range_header.replace("bytes=", "").split("-") start = int(range_match[0]) if range_match[0] else 0 end = int(range_match[1]) if range_match[1] and range_match[1] else file_size - 1 # Valider la plage if start >= file_size or end >= file_size or start > end: return Response( status_code=416, headers={ "Content-Range": f"bytes */{file_size}", "Accept-Ranges": "bytes" } ) # Lire la plage demandée chunk_size = end - start + 1 with open(file_full_path, "rb") as f: f.seek(start) chunk = f.read(chunk_size) # Retourner la réponse 206 Partial Content return Response( content=chunk, status_code=206, headers={ "Content-Range": f"bytes {start}-{end}/{file_size}", "Accept-Ranges": "bytes", "Content-Length": str(chunk_size), "Content-Type": content_type, }, media_type=content_type ) else: # Pas de Range header, retourner le fichier complet with open(file_full_path, "rb") as f: content = f.read() return Response( content=content, headers={ "Accept-Ranges": "bytes", "Content-Length": str(file_size), "Content-Type": content_type, }, media_type=content_type ) # Note: StaticFiles mount retiré car notre endpoint personnalisé gère tous les fichiers # avec support Range pour permettre le scrubbing vidéo # Include routers app.include_router(auth.router, prefix="/api/auth", tags=["Authentication"]) app.include_router(users.router, prefix="/api/users", tags=["Users"]) app.include_router(events.router, prefix="/api/events", tags=["Events"]) app.include_router(albums.router, prefix="/api/albums", tags=["Albums"]) app.include_router(posts.router, prefix="/api/posts", tags=["Posts"]) app.include_router(vlogs.router, prefix="/api/vlogs", tags=["Vlogs"]) app.include_router(stats.router, prefix="/api/stats", tags=["Statistics"]) app.include_router(admin.router, prefix="/api/admin", tags=["Admin"]) app.include_router(notifications.router, prefix="/api/notifications", tags=["Notifications"]) app.include_router(push.router, prefix="/api/push", tags=["Push Notifications"]) app.include_router(settings_router.router, prefix="/api/settings", tags=["Settings"]) app.include_router(information.router, prefix="/api/information", tags=["Information"]) app.include_router(tickets.router, prefix="/api/tickets", tags=["Tickets"]) @app.get("/") async def root(): return { "message": "Bienvenue sur LeDiscord API", "version": "1.0.0", "docs": "/docs" } @app.get("/health") async def health_check(): return {"status": "healthy"}