import 'dart:io'; import 'dart:async'; import 'dart:convert'; import 'package:shelf/shelf.dart'; import 'package:shelf/shelf_io.dart' as shelf_io; import 'package:shelf_static/shelf_static.dart'; import 'package:shelf_router/shelf_router.dart'; import 'package:args/args.dart'; import 'database/database.dart'; import 'models/user.dart'; import 'models/playlist.dart'; import 'models/playlist_config.dart'; import 'api/auth_handler.dart'; import 'api/users_handler.dart'; import 'api/playlists_handler.dart'; import 'api/settings_handler.dart'; import 'api/streaming_handler.dart'; import 'api/proxy_handler.dart'; import 'api/recordings_api.dart'; import 'api/epg_api.dart'; import 'api/season_passes_api.dart'; import 'api/xtream_api_handler.dart'; import 'middleware/auth_middleware.dart'; import 'middleware/security_middleware.dart'; import 'services/cleanup_service.dart'; import 'services/recording_scheduler.dart'; void main(List args) async { // Parse command line arguments final parser = ArgParser() ..addOption('port', abbr: 'p', defaultsTo: '8089') ..addOption('path', defaultsTo: '/app/web'); final result = parser.parse(args); final port = int.parse(result['port']); final webPath = result['path']; // Initialize database final db = AppDatabase(); await db.init(); await db.seedAdmin(); // Initialize and start Recording Scheduler final recordingScheduler = RecordingScheduler(db); recordingScheduler.start(); // Injecter la config playlist dans le scheduler pour les Season Passes // (on prend la playlist du premier utilisateur disponible) Future injectPlaylistToScheduler() async { final users = db.getAllUsers(); if (users.isNotEmpty) { final playlists = db.getPlaylists(users[0].id); if (playlists.isNotEmpty) { final p = playlists.first; recordingScheduler.playlistDns = p.serverUrl; recordingScheduler.playlistUsername = p.username; recordingScheduler.playlistPassword = p.password; print('[Server] Playlist injectée dans le scheduler: ${p.name}'); } } } // Injecter après 5s pour attendre l'initialisation complète Future.delayed(const Duration(seconds: 5), injectPlaylistToScheduler); // Initialize Streaming Subsystem await initStreaming(); // Helper to get playlist from request Future getPlaylist(Request request) async { Playlist? playlist; final user = request.context['user'] as User?; // If we have a user from auth middleware, prefer their playlists if (user != null) { print('[getPlaylist] User from context: ${user.username}'); final playlists = db.getPlaylists(user.id); if (playlists.isNotEmpty) playlist = playlists.first; } else { // Fallback for proxy: get first user's first playlist print('[getPlaylist] No user in context, using fallback'); final users = db.getAllUsers(); print('[getPlaylist] Total users in DB: ${users.length}'); if (users.isNotEmpty) { final playlists = db.getPlaylists(users[0].id); print( '[getPlaylist] User ${users[0].username} has ${playlists.length} playlists', ); if (playlists.isNotEmpty) playlist = playlists.first; } } if (playlist != null) { print( '[getPlaylist] Returning playlist: ${playlist.name} (DNS: ${playlist.serverUrl})', ); return PlaylistConfig( id: playlist.id, name: playlist.name, dns: playlist.serverUrl, username: playlist.username, password: playlist.password, createdAt: playlist.createdAt, isActive: true, ); } print('[getPlaylist] WARNING: No playlist found, returning null'); return null; } // Create API handlers final authHandler = AuthHandler(db); final playlistsHandler = PlaylistsHandler(db); final usersHandler = UsersHandler(db); final settingsHandler = SettingsHandler(db); final proxyHandler = ProxyHandler(getPlaylist); final recordingsApi = RecordingsApi(db, recordingScheduler); final epgApi = EpgApi(getPlaylist); final seasonPassesApi = SeasonPassesApi(db); final xtreamApiHandler = XtreamApiHandler(getPlaylist); // TV Recordings sub-router (wrapped with auth below). // NOTE: /api/recordings/stream/* falls through this router (404) and is // handled by streamingRouter further down the Cascade. final recordingsRouter = Router() ..get('/', recordingsApi.handleGetAll) ..post('/', recordingsApi.handlePost) ..delete('/', recordingsApi.handleDelete) ..post('/stop/', recordingsApi.handleStop) ..get('/logs/', recordingsApi.getLogHandler); final epgRouter = Router()..get('/', epgApi.handleGetEpg); final seasonPassesRouter = Router() ..get('/', seasonPassesApi.handleGetAll) ..post('/', seasonPassesApi.handlePost) ..delete('/', seasonPassesApi.handleDelete); // Setup router final apiRouter = Router() // Auth endpoints (with per-IP brute-force protection on login) ..mount( '/api/auth', const Pipeline() .addMiddleware(loginRateLimitMiddleware()) .addHandler(authHandler.router.call), ) // Xtream API gateway: injects credentials server-side so the // frontend never sees them ..mount( '/api/xtream-api', const Pipeline() .addMiddleware(authMiddleware(db)) .addHandler(xtreamApiHandler.handle), ) // Playlists endpoints ..mount( '/api/playlists', const Pipeline() .addMiddleware(authMiddleware(db)) .addHandler(playlistsHandler.router.call), ) // Users endpoints ..mount( '/api/users', const Pipeline() .addMiddleware(authMiddleware(db)) .addHandler(usersHandler.router.call), ) // Settings endpoints ..mount( '/api/settings', const Pipeline() .addMiddleware(authMiddleware(db)) .addHandler(settingsHandler.router.call), ) // TV Recordings (auth required) ..mount( '/api/recordings', const Pipeline() .addMiddleware(authMiddleware(db)) .addHandler(recordingsRouter.call), ) // EPG - guide TV (auth required) ..mount( '/api/epg', const Pipeline() .addMiddleware(authMiddleware(db)) .addHandler(epgRouter.call), ) // Season Passes - enregistrements répétés (auth required) ..mount( '/api/season-passes', const Pipeline() .addMiddleware(authMiddleware(db)) .addHandler(seasonPassesRouter.call), ); // NOTE: /api/xtream is handled by proxyHandler in the Cascade below // Do NOT mount here as it would intercept and block the actual proxy // Initialize Cleanup Service final cleanupService = CleanupService(); cleanupService.addTarget(Directory.systemTemp); cleanupService.addTarget(Directory('/app/data/logs')); cleanupService.addTarget(Directory('/app/data/tmp')); cleanupService.start(); // Admin Routes (protected) apiRouter.mount( '/api/admin', const Pipeline() .addMiddleware(authMiddleware(db)) .addHandler((Request request) { final router = Router(); // POST /api/admin/purge router.post('/purge', (Request req) async { final user = req.context['user'] as User?; if (user == null || !user.isAdmin) { return Response.forbidden( jsonEncode({'error': 'Admin access required'}), ); } final result = await cleanupService.runCleanup(); return Response.ok( jsonEncode(result), headers: {'content-type': 'application/json'}, ); }); return router(request); }), ); // Create static handler final baseStaticHandler = createStaticHandler( webPath, defaultDocument: 'index.html', listDirectories: false, ); // Wrap static handler to enforce cache policies FutureOr staticHandler(Request request) async { final response = await baseStaticHandler(request); // Disable cache for entry points to ensure updates are seen immediately. // Vendored player libraries (hls.js/mpegts.js, ~750 KB) are pinned // versions: keep them cacheable or every player open re-downloads them. final path = request.url.path; final isVendored = path.startsWith('vendor/'); if (!isVendored && (path.isEmpty || path == 'index.html' || path.endsWith('.js') || path.endsWith('.json'))) { return response.change( headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0', 'Pragma': 'no-cache', 'Expires': '0', }, ); } // Allow aggressive caching for hashed assets return response.change( headers: { 'Cache-Control': 'public, max-age=86400', }, ); } // Create Streaming Router (Mount handlers on correct paths) final streamingRouter = Router() ..mount( '/api/live', createLiveStreamHandler( getPlaylist, isGpuEnabled: db.isNvidiaGpuEnabled, ), ) ..mount( '/api/vod', createVodStreamHandler( getPlaylist, isGpuEnabled: db.isNvidiaGpuEnabled, ), ) ..mount( '/api/recordings/stream', createRecordingStreamHandler( db, isGpuEnabled: db.isNvidiaGpuEnabled, ), ); // Proxy Handler (auth is now handled INSIDE the handler, after path check) // This allows non-/api/xtream requests to fall through to static handler final proxyPipeline = proxyHandler.handler; // Streaming routes accept the HttpOnly session cookie (hls.js cannot send // Authorization headers); local FFmpeg loopback fetches bypass auth. final protectedStreaming = const Pipeline() .addMiddleware(streamAuthMiddleware(db)) .addHandler(streamingRouter.call); // Main handler final handler = Cascade() .add(apiRouter.call) /* Standard API endpoints */ .add(proxyPipeline) /* Xtream Proxy (auth inside handler) */ .add(protectedStreaming) /* Streaming endpoints */ .add(staticHandler) .handler; // Add middleware final pipeline = const Pipeline() .addMiddleware(logRequests()) .addMiddleware(securityHeadersMiddleware()) .addMiddleware(honeypotMiddleware()) .addMiddleware(rateLimitMiddleware()) .addMiddleware(_corsMiddleware()) .addHandler(handler); // Start server final server = await shelf_io.serve( pipeline, InternetAddress.anyIPv4, port, ); print('Server started on port ${server.port}'); print('Serving static files from: $webPath'); print('REST API available at: /api/auth/* and /api/playlists/*'); print('Xtream proxy available at: /api/xtream/* (SSRF Protected)'); // Clean expired sessions periodically (every hour) Timer.periodic(const Duration(hours: 1), (_) { db.cleanExpiredSessions(); print('Cleaned expired sessions'); }); } /// CORS middleware. /// /// The app is served same-origin by this server, so CORS headers are only /// needed for development (Flutter dev server on another port) or an /// explicitly configured external origin via the ALLOWED_ORIGIN env var. /// The request Origin is echoed back only when it matches the allowlist — /// never a wildcard. Middleware _corsMiddleware() { final extraOrigin = Platform.environment['ALLOWED_ORIGIN']; bool isAllowed(String origin) { if (extraOrigin != null && extraOrigin.isNotEmpty && origin == extraOrigin) { return true; } // Local development origins (flutter run -d chrome, etc.) final uri = Uri.tryParse(origin); return uri != null && (uri.host == 'localhost' || uri.host == '127.0.0.1'); } return (Handler handler) { return (Request request) async { final origin = request.headers['origin']; final headers = { if (origin != null && isAllowed(origin)) ...{ 'Access-Control-Allow-Origin': origin, 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', 'Access-Control-Allow-Headers': 'Origin, Content-Type, Accept, Authorization, Range', 'Access-Control-Expose-Headers': 'Content-Length, Content-Range', 'Access-Control-Allow-Credentials': 'true', 'Vary': 'Origin', }, }; // Handle preflight requests if (request.method == 'OPTIONS') { return Response.ok('', headers: headers); } // Process request and add CORS headers to response final response = await handler(request); return headers.isEmpty ? response : response.change(headers: headers); }; }; }