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:http/http.dart' as http; 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 '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, db); final recordingsApi = RecordingsApi(db, recordingScheduler); final epgApi = EpgApi(db, getPlaylist); final seasonPassesApi = SeasonPassesApi(db); // Setup router final apiRouter = Router() // Auth endpoints ..mount('/api/auth', authHandler.router) // 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 - routes explicites ..get('/api/recordings', recordingsApi.handleGetAll) ..post('/api/recordings', recordingsApi.handlePost) ..delete('/api/recordings/', recordingsApi.handleDelete) ..post('/api/recordings/stop/', recordingsApi.handleStop) ..get('/api/recordings/logs/', recordingsApi.getLogHandler) // EPG - guide TV ..get('/api/epg/', epgApi.handleGetEpg) // Season Passes - enregistrements répétés ..get('/api/season-passes', seasonPassesApi.handleGetAll) ..post('/api/season-passes', seasonPassesApi.handlePost) ..delete('/api/season-passes/', seasonPassesApi.handleDelete); // 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 final path = request.url.path; if (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, ), ); // 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; // Main handler final handler = Cascade() .add(apiRouter.call) /* Standard API endpoints */ .add(proxyPipeline) /* Xtream Proxy (auth inside handler) */ .add(streamingRouter.call) /* 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 to allow cross-origin requests Middleware _corsMiddleware() { return (Handler handler) { return (Request request) async { // Handle preflight requests if (request.method == 'OPTIONS') { return Response.ok('', headers: _corsHeaders); } // Process request and add CORS headers to response final response = await handler(request); return response.change(headers: _corsHeaders); }; }; } final _corsHeaders = { 'Access-Control-Allow-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', };