mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-11 17:30:00 +02:00
Le guide du fournisseur est figé sur beaucoup de revendeurs : sur le compte de test, get_simple_data_table répond correctement mais le dernier programme français date de deux jours, et le xmltv.php du panneau ne couvre aucune chaîne française. Aucun correctif côté appel Xtream ne pouvait donc remplir la grille. Nouveau service XmltvEpgService : téléchargement de dumps XMLTV publics, décompression gzip détectée sur le nombre magique, parsing en flux via XmlEventReader — un dump national fait 46 Mo décompressés, en charger l'arbre DOM coûterait plusieurs centaines de mégaoctets dans le conteneur. Mesuré à environ 1 s pour 973 chaînes. Index rafraîchi toutes les 6 h, programmes terminés depuis plus de 6 h écartés à l'indexation. L'appel sortant reste un dernier recours. L'ordre est inchangé — get_simple_data_table, puis get_short_epg — et le dump n'est consulté que si le panneau ne rend aucun programme couvrant l'instant présent : compter les entrées ne suffisait pas, un guide périmé en renvoie des centaines. La source se configure par EPG_XMLTV_URLS ; vider la variable supprime tout appel sortant. La réponse porte un en-tête X-Epg-Source pour savoir qui a répondu. Les identifiants sont rapprochés après normalisation : le dump écrit France.2.fr là où le panneau annonce France2.fr. Repli sur le nom affiché de la chaîne, et table stream_id vers identifiant EPG mise en cache 6 h. Le client passe désormais par /api/epg au lieu d'appeler player_api.php en direct. Il dupliquait le choix de l'action, la conversion des fuseaux et le décodage base64 ; le backend le fait une fois, met en cache pour tous les utilisateurs, et sait basculer sur le repli — bascule impossible depuis le navigateur. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
407 lines
13 KiB
Dart
407 lines
13 KiB
Dart
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 'services/xmltv_epg_service.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<String> 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<void> 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<PlaylistConfig?> 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);
|
|
// Source XMLTV de repli. Vider EPG_XMLTV_URLS désactive tout appel sortant :
|
|
// l'EPG se limite alors au panneau de l'abonné.
|
|
final xmltvUrls = (Platform.environment['EPG_XMLTV_URLS'] ??
|
|
'https://epgshare01.online/epgshare01/epg_ripper_FR1.xml.gz')
|
|
.split(',')
|
|
.map((u) => u.trim())
|
|
.where((u) => u.isNotEmpty)
|
|
.toList();
|
|
if (xmltvUrls.isEmpty) {
|
|
print('[EPG] Repli XMLTV désactivé (EPG_XMLTV_URLS vide)');
|
|
} else {
|
|
print('[EPG] Repli XMLTV : ${xmltvUrls.length} source(s)');
|
|
}
|
|
final epgApi = EpgApi(
|
|
getPlaylist,
|
|
xmltv: xmltvUrls.isEmpty ? null : XmltvEpgService(sourceUrls: xmltvUrls),
|
|
);
|
|
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('/<id>', recordingsApi.handleDelete)
|
|
..post('/stop/<id>', recordingsApi.handleStop)
|
|
..get('/logs/<id>', recordingsApi.getLogHandler);
|
|
|
|
final epgRouter = Router()..get('/<channelId>', epgApi.handleGetEpg);
|
|
|
|
final seasonPassesRouter = Router()
|
|
..get('/', seasonPassesApi.handleGetAll)
|
|
..post('/', seasonPassesApi.handlePost)
|
|
..delete('/<id>', 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<Response> 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 = <String, String>{
|
|
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);
|
|
};
|
|
};
|
|
}
|