mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-19 05:06:21 +02:00
Serveur réécrit en Rust
Le serveur Dart (bin/) est remplacé par un serveur Rust (server/) : tokio + axum, reqwest/rustls, SQLite embarqué via rusqlite. Même API, mêmes routes et mêmes réponses : le front Flutter ne change pas. La base de production est reprise telle quelle : schéma, migrations et formats de dates (DateTime.toIso8601String de Dart) sont reproduits à l'identique, les sessions et hachages existants restent valides. Comportement conservé : cascade de routage, anti-SSRF, logs masqués, rate limits, transcodage HLS/turbo.ts avec repli GPU, quotas fournisseur, enregistrements (reprise, relances, fusion), season passes, EPG, logos de repli, arrêt gracieux sur SIGTERM. Tests Dart portés (132 tests Rust) ; la CI exécute fmt, test et clippy -D warnings, l'image Docker compile le serveur dans un étage rust:1.98.1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f82c70ae05
commit
3ea6fa0ae3
109 files changed
+14008
-11047
No files matched your search
@@ -1,81 +0,0 @@
|
||||
import 'package:shelf/shelf.dart';
|
||||
|
||||
/// Réponse pour une image indisponible.
|
||||
///
|
||||
/// Statut 410 et non 404 : la `Cascade` du serveur retombe sur le handler
|
||||
/// suivant pour tout 404 et renvoie finalement celui du handler statique, sans
|
||||
/// notre `cache-control`. Un 410 s'arrête ici.
|
||||
///
|
||||
/// Un statut d'erreur plutôt qu'une image vide : le client affiche alors son
|
||||
/// propre repli (icône, initiale) au lieu d'une tuile blanche. Le `max-age`
|
||||
/// est essentiel : sans lui le navigateur redemande le logo à chaque
|
||||
/// affichage de la grille et le flot de requêtes mortes reprend aussitôt.
|
||||
Response missingImageResponse() => Response(
|
||||
410,
|
||||
headers: {
|
||||
'cache-control': 'public, max-age=600',
|
||||
'access-control-allow-origin': '*',
|
||||
},
|
||||
);
|
||||
|
||||
/// Mémoire courte des hôtes d'images injoignables.
|
||||
///
|
||||
/// POURQUOI : les URL de picons pointent chez l'hébergeur du revendeur, pas
|
||||
/// sur le panneau. Quand cette machine tombe (maintenance, DNS, quota), elle
|
||||
/// répond 5xx en quelques millisecondes, le navigateur réessaie à chaque
|
||||
/// rendu de la grille et le proxy relaie des centaines de requêtes mortes par
|
||||
/// seconde. Le serveur tournant sur un unique isolate Dart, ce flot passe
|
||||
/// devant les paquets vidéo dans la boucle d'événements : l'image saccade
|
||||
/// pendant qu'on s'acharne sur un hôte qu'on sait hors service.
|
||||
///
|
||||
/// On coupe donc court : au bout de [threshold] échecs consécutifs, l'hôte
|
||||
/// est considéré mort pendant [ttl] et les requêtes suivantes sont servies
|
||||
/// localement, sans appel sortant.
|
||||
class AssetFailureCache {
|
||||
AssetFailureCache({
|
||||
this.threshold = 3,
|
||||
this.ttl = const Duration(minutes: 5),
|
||||
DateTime Function()? clock,
|
||||
}) : _clock = clock ?? DateTime.now;
|
||||
|
||||
/// Nombre d'échecs consécutifs avant de couper un hôte. Un 5xx passager ne
|
||||
/// doit pas priver l'utilisateur de ses logos.
|
||||
final int threshold;
|
||||
|
||||
/// Durée de la coupure. Assez courte pour qu'un hôte réparé revienne seul.
|
||||
final Duration ttl;
|
||||
|
||||
final DateTime Function() _clock;
|
||||
|
||||
final Map<String, int> _failures = {};
|
||||
final Map<String, DateTime> _downUntil = {};
|
||||
|
||||
/// L'hôte est-il réputé hors service en ce moment ?
|
||||
bool isDown(String host) {
|
||||
final key = host.toLowerCase();
|
||||
final until = _downUntil[key];
|
||||
if (until == null) return false;
|
||||
if (_clock().isBefore(until)) return true;
|
||||
|
||||
// Coupure expirée : on repart d'une ardoise vierge pour laisser une
|
||||
// vraie chance au prochain appel.
|
||||
_downUntil.remove(key);
|
||||
_failures.remove(key);
|
||||
return false;
|
||||
}
|
||||
|
||||
void recordFailure(String host) {
|
||||
final key = host.toLowerCase();
|
||||
final count = (_failures[key] ?? 0) + 1;
|
||||
_failures[key] = count;
|
||||
if (count >= threshold) {
|
||||
_downUntil[key] = _clock().add(ttl);
|
||||
}
|
||||
}
|
||||
|
||||
void recordSuccess(String host) {
|
||||
final key = host.toLowerCase();
|
||||
_failures.remove(key);
|
||||
_downUntil.remove(key);
|
||||
}
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
import 'dart:convert';
|
||||
import 'package:shelf/shelf.dart';
|
||||
import 'package:shelf_router/shelf_router.dart';
|
||||
import '../database/database.dart';
|
||||
|
||||
class AuthHandler {
|
||||
final AppDatabase db;
|
||||
|
||||
AuthHandler(this.db);
|
||||
|
||||
Router get router {
|
||||
final router = Router();
|
||||
|
||||
router.post('/login', _login);
|
||||
router.post('/logout', _logout);
|
||||
router.get('/me', _getCurrentUser);
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
/// POST /api/auth/login
|
||||
Future<Response> _login(Request request) async {
|
||||
try {
|
||||
print('[Auth] Login attempt received');
|
||||
final bodyStr = await request.readAsString();
|
||||
|
||||
final payload = jsonDecode(bodyStr) as Map<String, dynamic>;
|
||||
final username = payload['username'] as String?;
|
||||
final password = payload['password'] as String?;
|
||||
|
||||
if (username == null || password == null) {
|
||||
print('[Auth] Missing username or password');
|
||||
return Response(400, body: jsonEncode({
|
||||
'success': false,
|
||||
'error': 'Username and password are required',
|
||||
}), headers: {'Content-Type': 'application/json'},);
|
||||
}
|
||||
|
||||
print('[Auth] Verifying credentials for user: $username');
|
||||
// Verify credentials
|
||||
final user = db.verifyCredentials(username, password);
|
||||
if (user == null) {
|
||||
print('[Auth] Invalid credentials for user: $username');
|
||||
return Response(401, body: jsonEncode({
|
||||
'success': false,
|
||||
'error': 'Invalid credentials',
|
||||
}), headers: {'Content-Type': 'application/json'},);
|
||||
}
|
||||
|
||||
print('[Auth] User verified, creating session for userId: ${user.id}');
|
||||
// Create session
|
||||
final session = db.createSession(user.id);
|
||||
|
||||
// HttpOnly session cookie so same-origin media requests (hls.js inside
|
||||
// the player iframe cannot send Authorization headers) are authenticated.
|
||||
final maxAge = session.expiresAt.difference(DateTime.now()).inSeconds;
|
||||
return Response.ok(jsonEncode({
|
||||
'success': true,
|
||||
'user': user.toJson(),
|
||||
'token': session.token,
|
||||
}), headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Set-Cookie':
|
||||
'session=${session.token}; HttpOnly; SameSite=Lax; Path=/; Max-Age=$maxAge',
|
||||
},);
|
||||
} catch (e, stackTrace) {
|
||||
print('[Auth] ERROR during login: $e');
|
||||
print('[Auth] Stack trace: $stackTrace');
|
||||
return Response.internalServerError(
|
||||
body: jsonEncode({'success': false, 'error': e.toString()}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /api/auth/logout
|
||||
Future<Response> _logout(Request request) async {
|
||||
try {
|
||||
final token = _extractToken(request);
|
||||
if (token == null) {
|
||||
return Response(401, body: jsonEncode({
|
||||
'success': false,
|
||||
'error': 'Unauthorized',
|
||||
}), headers: {'Content-Type': 'application/json'},);
|
||||
}
|
||||
|
||||
db.deleteSession(token);
|
||||
|
||||
return Response.ok(jsonEncode({
|
||||
'success': true,
|
||||
}), headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Set-Cookie': 'session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0',
|
||||
},);
|
||||
} catch (e) {
|
||||
return Response.internalServerError(
|
||||
body: jsonEncode({'success': false, 'error': e.toString()}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /api/auth/me
|
||||
Future<Response> _getCurrentUser(Request request) async {
|
||||
try {
|
||||
final token = _extractToken(request);
|
||||
if (token == null) {
|
||||
return Response(401, body: jsonEncode({
|
||||
'success': false,
|
||||
'error': 'Unauthorized',
|
||||
}), headers: {'Content-Type': 'application/json'},);
|
||||
}
|
||||
|
||||
final session = db.findSessionByToken(token);
|
||||
if (session == null) {
|
||||
return Response(401, body: jsonEncode({
|
||||
'success': false,
|
||||
'error': 'Invalid or expired session',
|
||||
}), headers: {'Content-Type': 'application/json'},);
|
||||
}
|
||||
|
||||
final user = db.findUserById(session.userId);
|
||||
if (user == null) {
|
||||
return Response(404, body: jsonEncode({
|
||||
'success': false,
|
||||
'error': 'User not found',
|
||||
}), headers: {'Content-Type': 'application/json'},);
|
||||
}
|
||||
|
||||
return Response.ok(jsonEncode({
|
||||
'user': user.toJson(),
|
||||
}), headers: {'Content-Type': 'application/json'},);
|
||||
} catch (e) {
|
||||
return Response.internalServerError(
|
||||
body: jsonEncode({'success': false, 'error': e.toString()}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract token from Authorization header or cookie
|
||||
String? _extractToken(Request request) {
|
||||
// Try Authorization header first
|
||||
final authHeader = request.headers['authorization'];
|
||||
if (authHeader != null && authHeader.startsWith('Bearer ')) {
|
||||
return authHeader.substring(7);
|
||||
}
|
||||
|
||||
// Try cookie
|
||||
final cookie = request.headers['cookie'];
|
||||
if (cookie != null) {
|
||||
final parts = cookie.split(';');
|
||||
for (final part in parts) {
|
||||
final trimmed = part.trim();
|
||||
if (trimmed.startsWith('session=')) {
|
||||
return trimmed.substring(8);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,473 +0,0 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:isolate';
|
||||
import 'package:shelf/shelf.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../models/playlist_config.dart';
|
||||
import '../services/xmltv_epg_service.dart';
|
||||
import '../utils/log_redactor.dart';
|
||||
|
||||
/// API EPG — proxy vers Xtream avec cache 30 minutes
|
||||
/// GET /api/epg/<channel_id>?days=1
|
||||
///
|
||||
/// Le panneau de l'abonné reste la source de référence, mais par son dump
|
||||
/// `xmltv.php` d'abord : une requête couvre toutes les chaînes, là où
|
||||
/// `player_api` en réclame une par chaîne à plusieurs secondes pièce. Vient
|
||||
/// ensuite l'interrogation chaîne par chaîne, puis une source XMLTV externe
|
||||
/// en dernier recours — cas des revendeurs dont le guide est figé depuis
|
||||
/// plusieurs jours.
|
||||
class EpgApi {
|
||||
final Future<PlaylistConfig?> Function(Request) _getPlaylist;
|
||||
|
||||
/// Repli XMLTV. `null` quand aucune source n'est configurée : l'API se
|
||||
/// comporte alors exactement comme avant, sans le moindre appel sortant.
|
||||
final XmltvEpgService? _xmltv;
|
||||
|
||||
/// Cache mémoire : « dns|username|channelId » → {data, expiresAt}.
|
||||
///
|
||||
/// La clé inclut le compte : les `stream_id` sont propres à chaque panneau,
|
||||
/// deux playlists distinctes peuvent parfaitement partager le même
|
||||
/// identifiant et se servaient alors le guide l'une de l'autre.
|
||||
final Map<String, _CacheEntry> _cache = {};
|
||||
|
||||
/// Correspondance `stream_id` → identifiant EPG et nom, par compte.
|
||||
/// Nécessaire au repli : le dump XMLTV indexe par identifiant de chaîne,
|
||||
/// pas par `stream_id` propre au panneau.
|
||||
final Map<String, _ChannelMap> _channelMaps = {};
|
||||
|
||||
/// Client HTTP des appels `player_api`. Injectable pour les tests.
|
||||
final http.Client _http;
|
||||
|
||||
/// Fabrique de la source XMLTV du panneau, une par compte.
|
||||
final XmltvEpgService Function(PlaylistConfig) _panelXmltvBuilder;
|
||||
|
||||
/// Index XMLTV du panneau, par compte.
|
||||
final Map<String, XmltvEpgService> _panelXmltv = {};
|
||||
|
||||
EpgApi(
|
||||
this._getPlaylist, {
|
||||
XmltvEpgService? xmltv,
|
||||
http.Client? httpClient,
|
||||
XmltvEpgService Function(PlaylistConfig)? panelXmltvBuilder,
|
||||
}) : _xmltv = xmltv,
|
||||
_http = httpClient ?? http.Client(),
|
||||
_panelXmltvBuilder = panelXmltvBuilder ?? _defaultPanelXmltv;
|
||||
|
||||
/// Dump XMLTV servi par le panneau lui-même.
|
||||
///
|
||||
/// POURQUOI : `player_api.php?action=get_simple_data_table` répond en
|
||||
/// plusieurs secondes chez beaucoup de revendeurs, et il faut un appel par
|
||||
/// chaîne — afficher une grille de trente chaînes demandait donc plus d'une
|
||||
/// minute. `xmltv.php` renvoie le guide de toutes les chaînes en une seule
|
||||
/// requête, réutilisée ensuite pendant des heures. C'est ce que font les
|
||||
/// clients IPTV rapides.
|
||||
static XmltvEpgService _defaultPanelXmltv(PlaylistConfig playlist) {
|
||||
final user = Uri.encodeQueryComponent(playlist.username);
|
||||
final password = Uri.encodeQueryComponent(playlist.password);
|
||||
return XmltvEpgService(
|
||||
sourceUrls: ['${playlist.dns}/xmltv.php?username=$user&password=$password'],
|
||||
refreshInterval: const Duration(hours: 3),
|
||||
// Le guide est facultatif : on ne fait pas patienter l'utilisateur
|
||||
// plusieurs minutes sur un panneau qui traîne.
|
||||
downloadTimeout: const Duration(seconds: 90),
|
||||
);
|
||||
}
|
||||
|
||||
Future<Response> handleGetEpg(Request request, String channelId) async {
|
||||
final playlist = await _getPlaylist(request);
|
||||
if (playlist == null) {
|
||||
return Response.forbidden(
|
||||
json.encode({'error': 'Playlist non trouvée'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
|
||||
final cacheKey = '${playlist.dns}|${playlist.username}|$channelId';
|
||||
final cached = _cache[cacheKey];
|
||||
if (cached != null && DateTime.now().isBefore(cached.expiresAt)) {
|
||||
return Response.ok(
|
||||
cached.data,
|
||||
headers: {'Content-Type': 'application/json', 'X-Cache': 'HIT'},
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Sources par ordre de préférence. On s'arrête à la première qui
|
||||
// contient un programme en cours ou à venir ; à défaut, la première
|
||||
// non vide sert de repli.
|
||||
//
|
||||
// Le dump du panneau passe devant : une requête couvre toutes les
|
||||
// chaînes, là où `player_api` en demande une par chaîne, à plusieurs
|
||||
// secondes pièce.
|
||||
//
|
||||
// Le dump externe passe ensuite AVANT l'interrogation chaîne par
|
||||
// chaîne : il est déjà indexé en mémoire (réponse instantanée), alors
|
||||
// que `player_api` coûte plusieurs secondes par chaîne et échouait en
|
||||
// prod — ce qui laissait toute la grille sans guide.
|
||||
final sources = <(String, Future<List<Map<String, dynamic>>> Function())>[
|
||||
('panel-xmltv', () => _panelProgrammes(playlist, channelId)),
|
||||
('xmltv', () => _xmltvProgrammes(playlist, channelId)),
|
||||
('xtream', () => _xtreamProgrammes(playlist, channelId)),
|
||||
];
|
||||
|
||||
var epgData = <String, dynamic>{
|
||||
'channel_id': channelId,
|
||||
'programmes': <Map<String, dynamic>>[],
|
||||
};
|
||||
var source = 'xtream';
|
||||
var found = false;
|
||||
|
||||
for (final (name, fetch) in sources) {
|
||||
final programmes = await fetch();
|
||||
if (programmes.isEmpty) continue;
|
||||
|
||||
final candidate = <String, dynamic>{
|
||||
'channel_id': channelId,
|
||||
'programmes': programmes,
|
||||
};
|
||||
if (!found) {
|
||||
// Meilleur repli connu à ce stade, même si le guide est périmé.
|
||||
epgData = candidate;
|
||||
source = name;
|
||||
found = true;
|
||||
}
|
||||
if (_hasCurrentProgramme(candidate)) {
|
||||
epgData = candidate;
|
||||
source = name;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
final jsonStr = json.encode(epgData);
|
||||
|
||||
// 30 min pour un guide trouvé ; 5 min seulement pour un guide vide,
|
||||
// qui vient souvent d'une source momentanément indisponible et ne
|
||||
// doit pas priver la chaîne de guide pendant une demi-heure.
|
||||
_cache[cacheKey] = _CacheEntry(
|
||||
data: jsonStr,
|
||||
expiresAt: DateTime.now()
|
||||
.add(Duration(minutes: found ? 30 : 5)),
|
||||
);
|
||||
|
||||
return Response.ok(
|
||||
jsonStr,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Cache': 'MISS',
|
||||
'X-Epg-Source': source,
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
// Détail redacté en log uniquement : une ClientException Dart contient
|
||||
// l'URI amont, credentials Xtream inclus.
|
||||
print('[EpgApi] Erreur EPG: ${LogRedactor.redactUrl('$e')}');
|
||||
return Response.internalServerError(
|
||||
body: json.encode({'error': 'Erreur lors de la récupération EPG'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Programmes tirés du dump XMLTV du panneau, ou liste vide.
|
||||
Future<List<Map<String, dynamic>>> _panelProgrammes(
|
||||
PlaylistConfig playlist,
|
||||
String channelId,
|
||||
) async {
|
||||
final key = '${playlist.dns}|${playlist.username}';
|
||||
final service =
|
||||
_panelXmltv.putIfAbsent(key, () => _panelXmltvBuilder(playlist));
|
||||
return _fromXmltv(service, playlist, channelId);
|
||||
}
|
||||
|
||||
/// Programmes obtenus en interrogeant le panneau chaîne par chaîne.
|
||||
///
|
||||
/// Chemin lent : deux actions possibles, plusieurs secondes chacune chez la
|
||||
/// plupart des revendeurs. Il ne sert que lorsque le dump XMLTV du panneau
|
||||
/// est absent ou ne couvre pas la chaîne.
|
||||
Future<List<Map<String, dynamic>>> _xtreamProgrammes(
|
||||
PlaylistConfig playlist,
|
||||
String channelId,
|
||||
) async {
|
||||
// `get_simple_data_table` d'abord : c'est la seule action réellement
|
||||
// universelle. `get_epg` n'existe pas sur beaucoup de panneaux — au lieu
|
||||
// d'une erreur, ils renvoient poliment le payload d'authentification,
|
||||
// sans champ `epg_listings`, ce qui produisait un guide vide impossible
|
||||
// à distinguer d'une chaîne sans programme.
|
||||
const actions = ['get_simple_data_table', 'get_short_epg'];
|
||||
|
||||
for (final action in actions) {
|
||||
final url =
|
||||
'${playlist.dns}/player_api.php?username=${playlist.username}'
|
||||
'&password=${playlist.password}'
|
||||
'&action=$action&stream_id=$channelId';
|
||||
|
||||
final http.Response response;
|
||||
try {
|
||||
response = await _http
|
||||
.get(Uri.parse(url))
|
||||
.timeout(const Duration(seconds: 20));
|
||||
} catch (e) {
|
||||
// Délai dépassé, connexion coupée… : une source parmi d'autres, pas
|
||||
// une raison de répondre 500 (ce qui privait la chaîne des sources
|
||||
// suivantes). L'exception recopie l'URL, identifiants compris.
|
||||
print('[EpgApi] player_api $action indisponible pour $channelId : '
|
||||
'${LogRedactor.redactUrl('$e')}');
|
||||
continue;
|
||||
}
|
||||
if (response.statusCode != 200) continue;
|
||||
|
||||
Map<String, dynamic> parsed;
|
||||
try {
|
||||
parsed = transformEpgData(json.decode(response.body), channelId);
|
||||
} catch (_) {
|
||||
continue;
|
||||
}
|
||||
final programmes = (parsed['programmes'] as List)
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.toList();
|
||||
if (programmes.isNotEmpty) return programmes;
|
||||
}
|
||||
return const [];
|
||||
}
|
||||
|
||||
/// Le guide contient-il un programme en cours ou à venir ?
|
||||
///
|
||||
/// Un panneau dont l'EPG est figé répond avec des centaines de programmes,
|
||||
/// tous terminés depuis plusieurs jours. Compter les entrées ne suffit donc
|
||||
/// pas à décider si le guide est exploitable.
|
||||
static bool _hasCurrentProgramme(Map<String, dynamic> epgData) {
|
||||
final now = DateTime.now().toUtc();
|
||||
for (final programme in epgData['programmes'] as List) {
|
||||
if (programme is! Map) continue;
|
||||
final end = DateTime.tryParse('${programme['end']}');
|
||||
if (end != null && end.isAfter(now)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Programmes issus du dump XMLTV, ou liste vide si indisponible.
|
||||
Future<List<Map<String, dynamic>>> _xmltvProgrammes(
|
||||
PlaylistConfig playlist,
|
||||
String channelId,
|
||||
) =>
|
||||
_fromXmltv(_xmltv, playlist, channelId);
|
||||
|
||||
/// Programmes d'une chaîne dans un index XMLTV quelconque (panneau ou
|
||||
/// source externe), ou liste vide si l'index ne la connaît pas.
|
||||
Future<List<Map<String, dynamic>>> _fromXmltv(
|
||||
XmltvEpgService? xmltv,
|
||||
PlaylistConfig playlist,
|
||||
String channelId,
|
||||
) async {
|
||||
if (xmltv == null) return const [];
|
||||
|
||||
try {
|
||||
final channels = await _channelMapFor(playlist);
|
||||
final epgId = channels.epgIds[channelId];
|
||||
final name = channels.names[channelId];
|
||||
if (epgId == null && name == null) return const [];
|
||||
|
||||
final programmes =
|
||||
await xmltv.programmesFor(epgId, displayName: name);
|
||||
if (programmes.isEmpty) return const [];
|
||||
|
||||
final now = DateTime.now().toUtc();
|
||||
return programmes
|
||||
.where((p) => p.stop.isAfter(now))
|
||||
.take(64)
|
||||
.map((p) => p.toJson(channelId))
|
||||
.toList();
|
||||
} catch (e) {
|
||||
// L'exception peut recopier l'URL `player_api`/`xmltv.php`, identifiants
|
||||
// compris.
|
||||
print(
|
||||
'[EpgApi] source XMLTV indisponible pour $channelId : '
|
||||
'${LogRedactor.redactUrl('$e')}',
|
||||
);
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
/// `stream_id` → (`epg_channel_id`, nom) depuis la réponse
|
||||
/// `get_live_streams`.
|
||||
static (Map<String, String>, Map<String, String>) _parseChannelTable(
|
||||
String body,
|
||||
) {
|
||||
final epgIds = <String, String>{};
|
||||
final names = <String, String>{};
|
||||
final decoded = json.decode(body);
|
||||
if (decoded is List) {
|
||||
for (final item in decoded) {
|
||||
if (item is! Map) continue;
|
||||
final streamId = item['stream_id']?.toString();
|
||||
if (streamId == null || streamId.isEmpty) continue;
|
||||
final epgId = item['epg_channel_id']?.toString();
|
||||
if (epgId != null && epgId.isNotEmpty) epgIds[streamId] = epgId;
|
||||
final name = item['name']?.toString();
|
||||
if (name != null && name.isNotEmpty) names[streamId] = name;
|
||||
}
|
||||
}
|
||||
return (epgIds, names);
|
||||
}
|
||||
|
||||
/// Téléchargements de la table des chaînes en cours, par compte.
|
||||
final Map<String, Future<_ChannelMap>> _channelMapsInFlight = {};
|
||||
|
||||
/// Table des chaînes du compte, rafraîchie toutes les 6 heures.
|
||||
///
|
||||
/// Les requêtes simultanées partagent un seul téléchargement : la grille
|
||||
/// demande le guide de 30 chaînes d'un coup, et chacune déclenchait sinon
|
||||
/// son propre `get_live_streams` (7 Mo en prod).
|
||||
Future<_ChannelMap> _channelMapFor(PlaylistConfig playlist) {
|
||||
final key = '${playlist.dns}|${playlist.username}';
|
||||
final cached = _channelMaps[key];
|
||||
if (cached != null && DateTime.now().isBefore(cached.expiresAt)) {
|
||||
return Future.value(cached);
|
||||
}
|
||||
return _channelMapsInFlight[key] ??=
|
||||
_loadChannelMap(playlist, key).whenComplete(() {
|
||||
_channelMapsInFlight.remove(key);
|
||||
});
|
||||
}
|
||||
|
||||
Future<_ChannelMap> _loadChannelMap(
|
||||
PlaylistConfig playlist,
|
||||
String key,
|
||||
) async {
|
||||
final url = '${playlist.dns}/player_api.php'
|
||||
'?username=${playlist.username}&password=${playlist.password}'
|
||||
'&action=get_live_streams';
|
||||
http.Response? response;
|
||||
try {
|
||||
response =
|
||||
await _http.get(Uri.parse(url)).timeout(const Duration(seconds: 90));
|
||||
} catch (e) {
|
||||
print('[EpgApi] table des chaînes indisponible : '
|
||||
'${LogRedactor.redactUrl('$e')}');
|
||||
}
|
||||
|
||||
var epgIds = <String, String>{};
|
||||
var names = <String, String>{};
|
||||
if (response != null && response.statusCode == 200) {
|
||||
// Décodage dans un isolate : 7 Mo de JSON sur la boucle principale la
|
||||
// gelaient, et le relais turbo.ts cessait d'émettre pendant ce temps.
|
||||
// UTF-8 explicite : sans charset annoncé, `response.body` peut décoder
|
||||
// en latin-1 et abîmer les noms (« Chérie », « ◉ »), donc leur
|
||||
// correspondance avec le guide.
|
||||
final body = utf8.decode(response.bodyBytes, allowMalformed: true);
|
||||
try {
|
||||
(epgIds, names) = await Isolate.run(() => _parseChannelTable(body));
|
||||
} catch (e) {
|
||||
print('[EpgApi] table des chaînes illisible : ${e.runtimeType}');
|
||||
}
|
||||
}
|
||||
|
||||
// Une table vide (panneau en échec) n'est gardée que 2 minutes : la
|
||||
// garder 6 h privait TOUTES les chaînes du repli XMLTV pendant 6 h.
|
||||
final map = _ChannelMap(
|
||||
epgIds: epgIds,
|
||||
names: names,
|
||||
expiresAt: DateTime.now().add(
|
||||
names.isEmpty ? const Duration(minutes: 2) : const Duration(hours: 6),
|
||||
),
|
||||
);
|
||||
_channelMaps[key] = map;
|
||||
return map;
|
||||
}
|
||||
|
||||
/// Normalise une réponse Xtream (`epg_listings`) vers le format servi au
|
||||
/// client. Public pour être couvert par `bin/test/epg_api_test.dart`.
|
||||
Map<String, dynamic> transformEpgData(dynamic raw, String channelId) {
|
||||
try {
|
||||
List<dynamic> listings = [];
|
||||
|
||||
if (raw is Map && raw.containsKey('epg_listings')) {
|
||||
listings = raw['epg_listings'] as List<dynamic>? ?? [];
|
||||
} else if (raw is List) {
|
||||
listings = raw;
|
||||
}
|
||||
|
||||
final programmes = listings.map((item) {
|
||||
// Les champs texte `start`/`end` sont exprimés dans le fuseau du
|
||||
// panneau (souvent Europe/Amsterdam), sans indicateur de zone : le
|
||||
// client les relisait comme de l'heure locale et décalait tout le
|
||||
// guide. Les `*_timestamp` sont de l'epoch UTC — on s'en sert dès
|
||||
// qu'ils sont présents et on émet de l'ISO-8601 UTC explicite.
|
||||
final start = _isoUtc(item['start_timestamp']) ??
|
||||
_normalizeDate(item['start'] as String? ?? '');
|
||||
final end = _isoUtc(item['stop_timestamp']) ??
|
||||
_isoUtc(item['end_timestamp']) ??
|
||||
_normalizeDate(
|
||||
(item['stop'] as String?) ?? (item['end'] as String?) ?? '',
|
||||
);
|
||||
|
||||
// Décoder le titre (base64 si nécessaire)
|
||||
String title = item['title'] as String? ?? '';
|
||||
try {
|
||||
if (title.isNotEmpty) {
|
||||
final decoded = utf8.decode(base64Decode(title));
|
||||
if (decoded.isNotEmpty) title = decoded;
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
String description = item['description'] as String? ?? '';
|
||||
try {
|
||||
if (description.isNotEmpty) {
|
||||
final decoded = utf8.decode(base64Decode(description));
|
||||
if (decoded.isNotEmpty) description = decoded;
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
return {
|
||||
'title': title,
|
||||
'description': description,
|
||||
'start': start,
|
||||
'end': end,
|
||||
'channel_id': channelId,
|
||||
};
|
||||
}).toList();
|
||||
|
||||
return {'channel_id': channelId, 'programmes': programmes};
|
||||
} catch (e) {
|
||||
print('[EpgApi] Erreur panneau pour $channelId: ${LogRedactor.redactUrl('$e')}');
|
||||
return {
|
||||
'channel_id': channelId,
|
||||
'programmes': [],
|
||||
'error': 'EPG indisponible',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Epoch (secondes, String ou int) → ISO-8601 UTC, ou `null` si absent.
|
||||
static String? _isoUtc(dynamic timestamp) {
|
||||
if (timestamp == null) return null;
|
||||
final seconds = timestamp is int ? timestamp : int.tryParse('$timestamp');
|
||||
if (seconds == null || seconds <= 0) return null;
|
||||
return DateTime.fromMillisecondsSinceEpoch(seconds * 1000, isUtc: true)
|
||||
.toIso8601String();
|
||||
}
|
||||
|
||||
/// Repli : « YYYY-MM-DD HH:MM:SS » → « YYYY-MM-DDTHH:MM:SS ».
|
||||
static String _normalizeDate(String raw) {
|
||||
if (raw.contains(' ') && !raw.contains('T')) {
|
||||
return raw.replaceFirst(' ', 'T');
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
class _ChannelMap {
|
||||
final Map<String, String> epgIds;
|
||||
final Map<String, String> names;
|
||||
final DateTime expiresAt;
|
||||
_ChannelMap({
|
||||
required this.epgIds,
|
||||
required this.names,
|
||||
required this.expiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
class _CacheEntry {
|
||||
final String data;
|
||||
final DateTime expiresAt;
|
||||
_CacheEntry({required this.data, required this.expiresAt});
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import 'package:shelf/shelf.dart';
|
||||
|
||||
import '../services/logo_catalog.dart';
|
||||
import 'asset_failure_cache.dart';
|
||||
|
||||
/// GET /api/logo?src=<stream_icon>&name=<nom de chaîne>
|
||||
///
|
||||
/// Logo d'une chaîne, avec repli. L'URL fournie par le panneau est tentée
|
||||
/// d'abord, via le proxy Xtream (mêmes protections anti-SSRF, même mémoire
|
||||
/// des hôtes morts). En cas d'échec, le logo est cherché par nom dans le
|
||||
/// dépôt public `tv-logos`.
|
||||
///
|
||||
/// POURQUOI : quand l'hébergeur de picons du revendeur tombe, toute la grille
|
||||
/// perd ses logos d'un coup, alors que les autres applications IPTV les
|
||||
/// affichent encore grâce à leurs propres sources de repli.
|
||||
class LogoApi {
|
||||
LogoApi(this._proxy, this._catalog);
|
||||
|
||||
/// Proxy Xtream (`ProxyHandler.handler`), qui attend `/api/xtream/<url>`.
|
||||
final Handler _proxy;
|
||||
|
||||
final LogoCatalog _catalog;
|
||||
|
||||
/// Un logo trouvé change rarement : un jour de cache navigateur.
|
||||
static const _found = 'public, max-age=86400';
|
||||
|
||||
Future<Response> handle(Request request) async {
|
||||
final src = request.url.queryParameters['src'] ?? '';
|
||||
final name = request.url.queryParameters['name'] ?? '';
|
||||
|
||||
// Une URL panneau malformée ne doit pas priver la chaîne du repli.
|
||||
final proxied = src.startsWith('http://') || src.startsWith('https://')
|
||||
? Uri.tryParse('${request.requestedUri.origin}/api/xtream/$src')
|
||||
: null;
|
||||
|
||||
if (proxied != null) {
|
||||
final upstream = await _proxy(
|
||||
Request(
|
||||
'GET',
|
||||
proxied,
|
||||
headers: {
|
||||
for (final header in const ['cookie', 'authorization'])
|
||||
if (request.headers[header] != null)
|
||||
header: request.headers[header]!,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final type = upstream.headers['content-type'] ?? '';
|
||||
if (upstream.statusCode == 200 && type.startsWith('image/')) {
|
||||
return Response.ok(
|
||||
upstream.read(),
|
||||
headers: {'content-type': type, 'cache-control': _found},
|
||||
);
|
||||
}
|
||||
// Corps d'erreur consommé : sinon la connexion amont reste ouverte.
|
||||
await upstream.read().drain<void>();
|
||||
}
|
||||
|
||||
if (name.isNotEmpty) {
|
||||
final bytes = await _catalog.logoFor(name);
|
||||
if (bytes != null) {
|
||||
return Response.ok(
|
||||
bytes,
|
||||
headers: {'content-type': 'image/png', 'cache-control': _found},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return missingImageResponse();
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:shelf/shelf.dart';
|
||||
|
||||
/// Journal des incidents de lecture remontés par le lecteur web.
|
||||
///
|
||||
/// POURQUOI : les freezes (image et son figés puis reprise) se produisent
|
||||
/// chez l'utilisateur, dans son navigateur ; le serveur n'en voit rien. Le
|
||||
/// lecteur envoie ici chaque coupure, erreur, recréation et saut, et le
|
||||
/// serveur les écrit dans `docker logs` (`[Player] …`) : on sait alors si un
|
||||
/// freeze vient d'un buffer à sec, d'une erreur du démuxeur ou d'une
|
||||
/// relance du flux.
|
||||
///
|
||||
/// Entrée non fiable : seuls des champs connus, courts et typés passent.
|
||||
|
||||
const _events = {
|
||||
'start', 'first_frame', 'stall', 'seek', 'mpegts_error', 'recreate',
|
||||
'fallback_hls', 'hls_error', 'profile', 'error_shown', 'heartbeat',
|
||||
};
|
||||
|
||||
const _numericFields = {
|
||||
't', 'ms', 'buffer', 'pos', 'from', 'to', 'stalls', 'stallMs', 'seeks',
|
||||
'errors', 'rate',
|
||||
};
|
||||
|
||||
const _textFields = {'session', 'ch', 'mode', 'profile', 'detail'};
|
||||
|
||||
/// Ligne de journal pour l'événement [raw], ou null s'il est invalide.
|
||||
String? formatPlayerEvent(Object? raw) {
|
||||
if (raw is! Map) return null;
|
||||
final event = raw['event'];
|
||||
if (event is! String || !_events.contains(event)) return null;
|
||||
|
||||
final parts = <String>[event];
|
||||
for (final key in _textFields) {
|
||||
final value = raw[key];
|
||||
if (value is String && value.isNotEmpty) {
|
||||
// Ni espace, ni retour ligne, ni URL : jamais d'injection de faux
|
||||
// journaux, jamais d'identifiants recopiés depuis une URL d'erreur.
|
||||
final safe = value
|
||||
.replaceAll(RegExp(r'https?://\S+'), '<url>')
|
||||
.replaceAll(RegExp(r'[^\w.\-:/<>]'), '_');
|
||||
parts.add('$key=${safe.length > 60 ? safe.substring(0, 60) : safe}');
|
||||
}
|
||||
}
|
||||
for (final key in _numericFields) {
|
||||
final value = raw[key];
|
||||
if (value is num && value.isFinite) {
|
||||
parts.add('$key=${value is int ? value : value.toStringAsFixed(1)}');
|
||||
}
|
||||
}
|
||||
return '[Player] ${parts.join(' ')}';
|
||||
}
|
||||
|
||||
Future<Response> handlePlayerLog(Request request) async {
|
||||
final body = await request.readAsString();
|
||||
if (body.length > 4096) return Response(413);
|
||||
Object? decoded;
|
||||
try {
|
||||
decoded = jsonDecode(body);
|
||||
} catch (_) {
|
||||
return Response.badRequest();
|
||||
}
|
||||
final events = decoded is List ? decoded.take(20) : [decoded];
|
||||
for (final event in events) {
|
||||
final line = formatPlayerEvent(event);
|
||||
if (line != null) print(line);
|
||||
}
|
||||
return Response(204);
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
import 'dart:convert';
|
||||
import 'package:shelf/shelf.dart';
|
||||
import 'package:shelf_router/shelf_router.dart';
|
||||
import '../database/database.dart';
|
||||
|
||||
class PlaylistsHandler {
|
||||
final AppDatabase db;
|
||||
|
||||
PlaylistsHandler(this.db);
|
||||
|
||||
Router get router {
|
||||
final router = Router();
|
||||
|
||||
router.get('/', _getPlaylists);
|
||||
router.post('/', _createPlaylist);
|
||||
router.put('/<id>', _updatePlaylist);
|
||||
router.delete('/<id>', _deletePlaylist);
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
/// GET /api/playlists
|
||||
Future<Response> _getPlaylists(Request request) async {
|
||||
try {
|
||||
final userId = request.context['userId'] as String?;
|
||||
if (userId == null) {
|
||||
return Response(401, body: jsonEncode({
|
||||
'success': false,
|
||||
'error': 'Unauthorized',
|
||||
}), headers: {'Content-Type': 'application/json'},);
|
||||
}
|
||||
|
||||
final playlists = db.getPlaylists(userId);
|
||||
|
||||
return Response.ok(jsonEncode({
|
||||
'playlists': playlists.map((p) => p.toJson()).toList(),
|
||||
}), headers: {'Content-Type': 'application/json'},);
|
||||
} catch (e) {
|
||||
return Response.internalServerError(
|
||||
body: jsonEncode({'success': false, 'error': e.toString()}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /api/playlists
|
||||
Future<Response> _createPlaylist(Request request) async {
|
||||
try {
|
||||
final userId = request.context['userId'] as String?;
|
||||
if (userId == null) {
|
||||
return Response(401, body: jsonEncode({
|
||||
'success': false,
|
||||
'error': 'Unauthorized',
|
||||
}), headers: {'Content-Type': 'application/json'},);
|
||||
}
|
||||
|
||||
final payload = jsonDecode(await request.readAsString()) as Map<String, dynamic>;
|
||||
final name = payload['name'] as String?;
|
||||
final serverUrl = payload['serverUrl'] as String?;
|
||||
final username = payload['username'] as String?;
|
||||
final password = payload['password'] as String?;
|
||||
final dns = payload['dns'] as String?;
|
||||
|
||||
if (name == null || serverUrl == null || username == null || password == null) {
|
||||
return Response(400, body: jsonEncode({
|
||||
'success': false,
|
||||
'error': 'Missing required fields',
|
||||
}), headers: {'Content-Type': 'application/json'},);
|
||||
}
|
||||
|
||||
final playlist = db.createPlaylist(
|
||||
userId: userId,
|
||||
name: name,
|
||||
serverUrl: serverUrl,
|
||||
username: username,
|
||||
password: password,
|
||||
dns: dns ?? serverUrl,
|
||||
);
|
||||
|
||||
return Response.ok(jsonEncode({
|
||||
'success': true,
|
||||
'playlist': playlist.toJson(),
|
||||
}), headers: {'Content-Type': 'application/json'},);
|
||||
} catch (e) {
|
||||
return Response.internalServerError(
|
||||
body: jsonEncode({'success': false, 'error': e.toString()}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// PUT /api/playlists/:id
|
||||
Future<Response> _updatePlaylist(Request request, String id) async {
|
||||
try {
|
||||
final userId = request.context['userId'] as String?;
|
||||
if (userId == null) {
|
||||
return Response(401, body: jsonEncode({
|
||||
'success': false,
|
||||
'error': 'Unauthorized',
|
||||
}), headers: {'Content-Type': 'application/json'},);
|
||||
}
|
||||
|
||||
// Verify playlist belongs to user
|
||||
final existing = db.getPlaylistById(id);
|
||||
if (existing == null) {
|
||||
return Response(404, body: jsonEncode({
|
||||
'success': false,
|
||||
'error': 'Playlist not found',
|
||||
}), headers: {'Content-Type': 'application/json'},);
|
||||
}
|
||||
|
||||
if (existing.userId != userId) {
|
||||
return Response(403, body: jsonEncode({
|
||||
'success': false,
|
||||
'error': 'Forbidden',
|
||||
}), headers: {'Content-Type': 'application/json'},);
|
||||
}
|
||||
|
||||
final payload = jsonDecode(await request.readAsString()) as Map<String, dynamic>;
|
||||
final name = payload['name'] as String? ?? existing.name;
|
||||
final serverUrl = payload['serverUrl'] as String? ?? existing.serverUrl;
|
||||
final username = payload['username'] as String? ?? existing.username;
|
||||
// Empty password means "keep current": clients never receive the real
|
||||
// password, so edit forms send it back blank.
|
||||
final incomingPassword = payload['password'] as String?;
|
||||
final password = (incomingPassword == null || incomingPassword.isEmpty)
|
||||
? existing.password
|
||||
: incomingPassword;
|
||||
final dns = payload['dns'] as String? ?? existing.dns;
|
||||
|
||||
final playlist = db.updatePlaylist(
|
||||
playlistId: id,
|
||||
name: name,
|
||||
serverUrl: serverUrl,
|
||||
username: username,
|
||||
password: password,
|
||||
dns: dns,
|
||||
);
|
||||
|
||||
return Response.ok(jsonEncode({
|
||||
'success': true,
|
||||
'playlist': playlist.toJson(),
|
||||
}), headers: {'Content-Type': 'application/json'},);
|
||||
} catch (e) {
|
||||
return Response.internalServerError(
|
||||
body: jsonEncode({'success': false, 'error': e.toString()}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// DELETE /api/playlists/:id
|
||||
Future<Response> _deletePlaylist(Request request, String id) async {
|
||||
try {
|
||||
final userId = request.context['userId'] as String?;
|
||||
if (userId == null) {
|
||||
return Response(401, body: jsonEncode({
|
||||
'success': false,
|
||||
'error': 'Unauthorized',
|
||||
}), headers: {'Content-Type': 'application/json'},);
|
||||
}
|
||||
|
||||
// Verify playlist belongs to user
|
||||
final existing = db.getPlaylistById(id);
|
||||
if (existing == null) {
|
||||
return Response(404,body: jsonEncode({
|
||||
'success': false,
|
||||
'error': 'Playlist not found',
|
||||
}), headers: {'Content-Type': 'application/json'},);
|
||||
}
|
||||
|
||||
if (existing.userId != userId) {
|
||||
return Response(403, body: jsonEncode({
|
||||
'success': false,
|
||||
'error': 'Forbidden',
|
||||
}), headers: {'Content-Type': 'application/json'},);
|
||||
}
|
||||
|
||||
db.deletePlaylist(id);
|
||||
|
||||
return Response.ok(jsonEncode({
|
||||
'success': true,
|
||||
}), headers: {'Content-Type': 'application/json'},);
|
||||
} catch (e) {
|
||||
return Response.internalServerError(
|
||||
body: jsonEncode({'success': false, 'error': e.toString()}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,334 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:shelf/shelf.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../database/database.dart';
|
||||
import '../middleware/auth_middleware.dart';
|
||||
import '../models/playlist_config.dart';
|
||||
import '../utils/log_redactor.dart';
|
||||
import 'asset_failure_cache.dart';
|
||||
|
||||
/// L'URL désigne-t-elle une image ou un logo ?
|
||||
///
|
||||
/// Ces URL échappent à l'allowlist de domaine (les revendeurs hébergent leurs
|
||||
/// picons ailleurs que le panneau) et méritent un repli visuel plutôt qu'une
|
||||
/// erreur propagée au navigateur.
|
||||
bool _isStaticAssetUrl(Uri url) =>
|
||||
url.path.endsWith('.png') ||
|
||||
url.path.endsWith('.jpg') ||
|
||||
url.path.endsWith('.jpeg') ||
|
||||
url.path.endsWith('.gif') ||
|
||||
url.path.endsWith('.webp') ||
|
||||
url.path.endsWith('.ico') ||
|
||||
url.path.contains('/picons/') ||
|
||||
url.path.contains('/logos/');
|
||||
|
||||
/// Returns true when [host] must never be proxied (loopback, private LAN,
|
||||
/// link-local/cloud-metadata ranges) — SSRF protection for asset URLs that
|
||||
/// bypass the playlist-domain allowlist.
|
||||
bool isForbiddenProxyHost(String host) {
|
||||
final lower = host.toLowerCase();
|
||||
if (lower == 'localhost' || lower == '::1') return true;
|
||||
|
||||
final ip = InternetAddress.tryParse(lower);
|
||||
if (ip == null) return false; // Hostname: validated by domain allowlist path
|
||||
|
||||
if (ip.isLoopback || ip.isLinkLocal) return true;
|
||||
if (ip.type == InternetAddressType.IPv4) {
|
||||
final parts = ip.address.split('.').map(int.parse).toList();
|
||||
if (parts[0] == 10) return true; // 10.0.0.0/8
|
||||
if (parts[0] == 172 && parts[1] >= 16 && parts[1] <= 31) return true;
|
||||
if (parts[0] == 192 && parts[1] == 168) return true; // 192.168.0.0/16
|
||||
if (parts[0] == 169 && parts[1] == 254) return true; // metadata/link-local
|
||||
if (parts[0] == 0) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Handler for the Xtream Proxy
|
||||
class ProxyHandler {
|
||||
final Future<PlaylistConfig?> Function(Request) _getPlaylist;
|
||||
final AppDatabase _db;
|
||||
final http.Client _client = http.Client();
|
||||
|
||||
final Map<String, (PlaylistConfig, DateTime)> _playlistCache = {};
|
||||
static const _cacheDuration = Duration(minutes: 5);
|
||||
|
||||
/// Hôtes d'images réputés hors service (voir [AssetFailureCache]).
|
||||
final AssetFailureCache _assetFailures = AssetFailureCache();
|
||||
|
||||
static const _allowedHeaders = [
|
||||
'content-type',
|
||||
'content-range',
|
||||
'accept-ranges',
|
||||
'cache-control',
|
||||
'server',
|
||||
'date',
|
||||
];
|
||||
|
||||
static const _allowedRequestHeaders = [
|
||||
'user-agent',
|
||||
'accept',
|
||||
'range',
|
||||
'referer',
|
||||
];
|
||||
|
||||
Future<PlaylistConfig?> _getCachedPlaylist(Request request) async {
|
||||
// Basic caching to avoid DB overhead on every video segment
|
||||
final now = DateTime.now();
|
||||
const cacheKey =
|
||||
'global_playlist'; // Currently app has one primary playlist per user/global
|
||||
|
||||
if (_playlistCache.containsKey(cacheKey)) {
|
||||
final (cached, expiry) = _playlistCache[cacheKey]!;
|
||||
if (now.isBefore(expiry)) return cached;
|
||||
}
|
||||
|
||||
final playlist = await _getPlaylist(request);
|
||||
if (playlist != null) {
|
||||
_playlistCache[cacheKey] = (playlist, now.add(_cacheDuration));
|
||||
}
|
||||
return playlist;
|
||||
}
|
||||
|
||||
ProxyHandler(this._getPlaylist, this._db);
|
||||
|
||||
/// Create Xtream proxy handler with M3U8 URL rewriting support
|
||||
Handler get handler {
|
||||
return (Request request) async {
|
||||
final path = request.url.path;
|
||||
|
||||
// Only handle /api/xtream/* requests
|
||||
// CRITICAL: Check path BEFORE anything else so non-proxy requests fall through to static handler
|
||||
if (!path.startsWith('api/xtream/')) {
|
||||
// Return 404 so Cascade tries next handler (streamingRouter)
|
||||
return Response.notFound(null);
|
||||
}
|
||||
|
||||
// Authentification par session. Les requêtes initiées par le navigateur
|
||||
// (img src, hls.js) ne portent pas d'en-tête Authorization mais envoient
|
||||
// le cookie HttpOnly `session` (SameSite=Lax, même origine) posé au
|
||||
// login : extractAuthToken accepte les deux. Un proxy ouvert offrait un
|
||||
// rebond SSRF non authentifié vers n'importe quel hôte public via les
|
||||
// extensions d'image.
|
||||
final token = extractAuthToken(request);
|
||||
if (token == null || _db.findSessionByToken(token) == null) {
|
||||
return Response(401, body: 'Unauthorized');
|
||||
}
|
||||
|
||||
Uri? targetUrl;
|
||||
|
||||
// === PROXY LOGIC ===
|
||||
try {
|
||||
// Extract target URL from request
|
||||
// Format: /api/xtream/http://server:port/path
|
||||
String apiPath = path.substring('api/xtream/'.length);
|
||||
|
||||
// Decode URL if it's encoded
|
||||
if (apiPath.startsWith('http%3A') || apiPath.startsWith('https%3A')) {
|
||||
apiPath = Uri.decodeComponent(apiPath);
|
||||
}
|
||||
|
||||
if (!apiPath.startsWith('http://') && !apiPath.startsWith('https://')) {
|
||||
return Response.badRequest(
|
||||
body: 'Invalid API URL. Expected format: /api/xtream/http://...',
|
||||
);
|
||||
}
|
||||
|
||||
String fullUrl = apiPath;
|
||||
if (request.url.query.isNotEmpty) {
|
||||
if (fullUrl.contains('?')) {
|
||||
fullUrl = '$fullUrl&${request.url.query}';
|
||||
} else {
|
||||
fullUrl = '$fullUrl?${request.url.query}';
|
||||
}
|
||||
}
|
||||
|
||||
targetUrl = Uri.parse(fullUrl);
|
||||
|
||||
// Only plain http(s) may be proxied
|
||||
if (targetUrl.scheme != 'http' && targetUrl.scheme != 'https') {
|
||||
return Response.forbidden('Unsupported URL scheme');
|
||||
}
|
||||
|
||||
// Never proxy to loopback/private/link-local targets (SSRF)
|
||||
if (isForbiddenProxyHost(targetUrl.host)) {
|
||||
print('[Proxy] Blocked SSRF attempt to private host: ${targetUrl.host}');
|
||||
return Response.forbidden('Access to this host is forbidden');
|
||||
}
|
||||
|
||||
// SSRF Protection - but allow images/static assets from any host
|
||||
// Xtream providers often use separate CDN servers for picons/images
|
||||
final isStaticAsset = _isStaticAssetUrl(targetUrl);
|
||||
|
||||
// Hôte d'images déjà constaté mort : on répond tout de suite.
|
||||
// Aucun appel sortant, aucune entrée de log — c'est précisément le
|
||||
// flot qu'on cherche à éteindre.
|
||||
if (isStaticAsset && _assetFailures.isDown(targetUrl.host)) {
|
||||
return missingImageResponse();
|
||||
}
|
||||
|
||||
String? allowedHost;
|
||||
if (!isStaticAsset) {
|
||||
// For API calls, enforce domain allowlist
|
||||
final playlist = await _getCachedPlaylist(request);
|
||||
if (playlist == null) {
|
||||
return Response.forbidden(
|
||||
'No active playlist configuration found to validate request',
|
||||
);
|
||||
}
|
||||
|
||||
final targetHost = targetUrl.host.toLowerCase();
|
||||
allowedHost = Uri.parse(playlist.dns).host.toLowerCase();
|
||||
|
||||
if (targetHost != allowedHost) {
|
||||
print(
|
||||
'[Proxy] Blocked SSRF attempt to $targetHost (Allowed: $allowedHost)',
|
||||
);
|
||||
return Response.forbidden(
|
||||
'Access to this domain is forbidden by policy',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final proxyHeaders = <String, String>{
|
||||
'User-Agent': 'VLC/3.0.18 LibVLC/3.0.18',
|
||||
'Accept': '*/*',
|
||||
'Accept-Encoding': 'identity',
|
||||
'Keep-Alive': 'timeout=30, max=100', // Request persistent connection
|
||||
};
|
||||
|
||||
// Forward Range header if present
|
||||
if (request.headers['range'] != null) {
|
||||
proxyHeaders['range'] = request.headers['range']!;
|
||||
}
|
||||
|
||||
try {
|
||||
print('[Proxy] Forwarding to: ${LogRedactor.redactUrl(targetUrl.toString())}');
|
||||
|
||||
// Forward safe request headers
|
||||
for (final header in _allowedRequestHeaders) {
|
||||
if (request.headers.containsKey(header)) {
|
||||
proxyHeaders[header] = request.headers[header]!;
|
||||
}
|
||||
}
|
||||
|
||||
List<int>? postBody;
|
||||
if (request.method == 'POST') {
|
||||
final bodyBytes = await request.read().toList();
|
||||
postBody = bodyBytes.expand((i) => i).toList();
|
||||
}
|
||||
|
||||
// Redirections suivies MANUELLEMENT : chaque destination est
|
||||
// revalidée (hôte privé, allowlist de domaine). Avec
|
||||
// followRedirects, la validation ne portait que sur l'URL
|
||||
// initiale — une 302 du serveur amont suffisait pour atteindre
|
||||
// un hôte interne malgré l'anti-SSRF.
|
||||
http.StreamedResponse response;
|
||||
var currentUrl = targetUrl;
|
||||
var redirects = 0;
|
||||
while (true) {
|
||||
final proxyRequest = http.Request(request.method, currentUrl);
|
||||
proxyRequest.headers.addAll(proxyHeaders);
|
||||
proxyRequest.followRedirects = false;
|
||||
if (postBody != null) proxyRequest.bodyBytes = postBody;
|
||||
|
||||
// Added 90s timeout to allow frontend (60s) to time out gracefully first
|
||||
response = await _client
|
||||
.send(proxyRequest)
|
||||
.timeout(const Duration(seconds: 90));
|
||||
|
||||
final location = response.headers['location'];
|
||||
final isRedirect = response.statusCode >= 300 &&
|
||||
response.statusCode < 400 &&
|
||||
location != null;
|
||||
if (!isRedirect) break;
|
||||
|
||||
if (++redirects > 3) {
|
||||
return Response.forbidden('Too many redirects');
|
||||
}
|
||||
final next = Uri.parse(location);
|
||||
currentUrl = next.isAbsolute ? next : currentUrl.resolve(location);
|
||||
if (currentUrl.scheme != 'http' && currentUrl.scheme != 'https') {
|
||||
return Response.forbidden('Unsupported redirect scheme');
|
||||
}
|
||||
if (isForbiddenProxyHost(currentUrl.host)) {
|
||||
print(
|
||||
'[Proxy] Blocked SSRF redirect to private host: ${currentUrl.host}',
|
||||
);
|
||||
return Response.forbidden('Access to this host is forbidden');
|
||||
}
|
||||
if (allowedHost != null &&
|
||||
currentUrl.host.toLowerCase() != allowedHost) {
|
||||
print(
|
||||
'[Proxy] Blocked redirect to ${currentUrl.host} (Allowed: $allowedHost)',
|
||||
);
|
||||
return Response.forbidden(
|
||||
'Access to this domain is forbidden by policy',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Une image en erreur n'a rien à transmettre au navigateur : le
|
||||
// relais du 503 déclenchait un nouveau cycle de requêtes à chaque
|
||||
// rendu. On répond un 404 mis en cache, et on compte l'échec.
|
||||
if (isStaticAsset) {
|
||||
if (response.statusCode >= 400) {
|
||||
// Compté sur l'hôte demandé, pas sur la cible finale d'une
|
||||
// redirection : c'est cette clé-là que consulte le garde-fou
|
||||
// en tête de requête.
|
||||
_assetFailures.recordFailure(targetUrl.host);
|
||||
// Le corps d'erreur doit être consommé, sinon la connexion
|
||||
// reste ouverte jusqu'au timeout.
|
||||
unawaited(response.stream.drain<void>().catchError((_) {}));
|
||||
return missingImageResponse();
|
||||
}
|
||||
_assetFailures.recordSuccess(targetUrl.host);
|
||||
}
|
||||
|
||||
// Build response headers from source response
|
||||
final responseHeaders = <String, String>{
|
||||
'access-control-allow-origin': '*',
|
||||
'connection': 'keep-alive',
|
||||
'keep-alive': 'timeout=30',
|
||||
};
|
||||
|
||||
// Forward specific safe headers
|
||||
for (final header in _allowedHeaders) {
|
||||
if (response.headers.containsKey(header)) {
|
||||
responseHeaders[header] = response.headers[header]!;
|
||||
}
|
||||
}
|
||||
|
||||
// ALWAYS stream the response for maximum performance and to avoid memory issues with large JSONs
|
||||
return Response(
|
||||
response.statusCode,
|
||||
body: response.stream,
|
||||
headers: responseHeaders,
|
||||
);
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
} catch (e) {
|
||||
// Redaction : une ClientException porte l'URL amont, credentials
|
||||
// Xtream inclus. Jamais de détail d'exception vers le client.
|
||||
print(
|
||||
'[ProxyHandler] error on ${LogRedactor.redactUrl(path)}: '
|
||||
'${LogRedactor.redactUrl('$e')}',
|
||||
);
|
||||
|
||||
// Image injoignable : 404 mis en cache plutôt qu'une erreur 500
|
||||
if (targetUrl != null && _isStaticAssetUrl(targetUrl)) {
|
||||
_assetFailures.recordFailure(targetUrl.host);
|
||||
return missingImageResponse();
|
||||
}
|
||||
|
||||
return Response.internalServerError(
|
||||
body: jsonEncode({'error': 'Proxy error'}),
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
/// Adressage des sessions de lecture d'un enregistrement.
|
||||
///
|
||||
/// Un enregistrement est transcodé à la volée par FFmpeg, séquentiellement.
|
||||
/// Reprendre à 45 min sans le lui dire imposerait d'attendre que l'encodeur
|
||||
/// y arrive : le lecteur demande donc une playlist qui *commence* à cette
|
||||
/// position (`?start=2700`), servie par une session FFmpeg dédiée.
|
||||
///
|
||||
/// Ces fonctions sont isolées du handler HTTP pour rester testables.
|
||||
library;
|
||||
|
||||
/// Position de départ demandée (`?start=<secondes>`), bornée au positif.
|
||||
/// Toute valeur absente, négative ou non numérique vaut « depuis le début ».
|
||||
int parseRecordingStart(String? raw) {
|
||||
final value = double.tryParse(raw ?? '');
|
||||
if (value == null || !value.isFinite || value <= 0) return 0;
|
||||
return value.floor();
|
||||
}
|
||||
|
||||
/// Segment de chemin qui rattache un segment `.ts` à SA session.
|
||||
///
|
||||
/// Les segments sont référencés en relatif dans la playlist et la
|
||||
/// query string n'y survit pas : sans ce répertoire virtuel, la playlist
|
||||
/// démarrée à 0 s et celle démarrée à 45 min demanderaient toutes deux
|
||||
/// `segment_000.ts` à la même URL.
|
||||
String recordingOffsetKey(int start) => 't$start';
|
||||
|
||||
/// Inverse de [recordingOffsetKey] ; `null` si la clé est mal formée.
|
||||
int? parseRecordingOffsetKey(String key) {
|
||||
if (!key.startsWith('t') || key.length < 2) return null;
|
||||
final value = int.tryParse(key.substring(1));
|
||||
return (value == null || value < 0) ? null : value;
|
||||
}
|
||||
|
||||
/// Préfixe les segments de [playlist] par [offsetKey].
|
||||
///
|
||||
/// Seules les lignes de segment sont touchées : les balises `#EXT-X-…` et
|
||||
/// les lignes vides passent telles quelles.
|
||||
String rewriteRecordingPlaylist(String playlist, String offsetKey) {
|
||||
return playlist.replaceAllMapped(
|
||||
RegExp(r'^(segment_\d+\.ts)\s*$', multiLine: true),
|
||||
(m) => '$offsetKey/${m[1]}',
|
||||
);
|
||||
}
|
||||
@@ -1,290 +0,0 @@
|
||||
import 'dart:io';
|
||||
import 'dart:convert';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:shelf/shelf.dart';
|
||||
import '../database/database.dart';
|
||||
import '../models/recording.dart';
|
||||
import '../models/user.dart';
|
||||
import '../services/recording_scheduler.dart';
|
||||
import '../utils/media_probe.dart';
|
||||
import '../utils/safe_path.dart';
|
||||
|
||||
class RecordingsApi {
|
||||
final AppDatabase _db;
|
||||
final RecordingScheduler _scheduler;
|
||||
|
||||
/// Durée maximale d'un enregistrement (env MAX_RECORDING_HOURS).
|
||||
final int maxRecordingHours = int.tryParse(
|
||||
Platform.environment['MAX_RECORDING_HOURS'] ?? '',
|
||||
) ??
|
||||
12;
|
||||
|
||||
RecordingsApi(this._db, this._scheduler);
|
||||
|
||||
Response _json(int status, Map<String, dynamic> body) => Response(
|
||||
status,
|
||||
body: json.encode(body),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
|
||||
/// L'utilisateur courant peut-il agir sur cet enregistrement ?
|
||||
/// (même patron de contrôle de propriété que playlists_handler)
|
||||
bool _canAccess(User? user, Recording recording) {
|
||||
if (user == null) return false;
|
||||
return user.isAdmin || recording.userId == user.id;
|
||||
}
|
||||
|
||||
/// Handler pour GET /api/recordings/logs/<id>
|
||||
/// Exposé séparément car shelf_router a un conflit entre DELETE /<id> et GET /logs/<id>
|
||||
Future<Response> getLogHandler(Request request, String id) async {
|
||||
final recording = _db.getRecordingById(id);
|
||||
|
||||
if (recording == null) {
|
||||
return _json(404, {'error': 'Enregistrement non trouvé'});
|
||||
}
|
||||
|
||||
final user = request.context['user'] as User?;
|
||||
if (!_canAccess(user, recording)) {
|
||||
return _json(403, {'error': 'Accès refusé'});
|
||||
}
|
||||
|
||||
if (recording.filePath == null) {
|
||||
return _json(404, {'error': 'Aucun fichier ni log associé pour le moment.'});
|
||||
}
|
||||
|
||||
// Les enregistrements sont écrits en .mkv avec un .log à côté
|
||||
// (l'ancien replaceAll('.mp4', '.log') ne trouvait jamais le fichier).
|
||||
final logFilePath = p.setExtension(recording.filePath!, '.log');
|
||||
|
||||
// Anti path-traversal : le log doit rester dans le dossier des enregistrements
|
||||
final safeLogPath = SafePath.resolveWithin(recordingsDirPath, logFilePath);
|
||||
if (safeLogPath == null) {
|
||||
return _json(403, {'error': 'Chemin de log invalide'});
|
||||
}
|
||||
|
||||
final logFile = File(safeLogPath);
|
||||
|
||||
if (!await logFile.exists()) {
|
||||
return _json(404, {'error': 'Le fichier de log est introuvable.'});
|
||||
}
|
||||
|
||||
final logs = await logFile.readAsString();
|
||||
return _json(200, {'logs': logs});
|
||||
}
|
||||
|
||||
/// GET /api/recordings — Liste les enregistrements de l'utilisateur
|
||||
/// (tous les enregistrements pour un admin), enrichis des informations de
|
||||
/// suivi : taille du fichier, progression, relances FFmpeg.
|
||||
Future<Response> handleGetAll(Request request) async {
|
||||
final user = request.context['user'] as User?;
|
||||
final recordings = (user != null && !user.isAdmin)
|
||||
? _db.getUserRecordings(user.id)
|
||||
: _db.getAllRecordings();
|
||||
final now = DateTime.now().toUtc();
|
||||
final enriched = <Map<String, dynamic>>[];
|
||||
for (final r in recordings) {
|
||||
final map = _enrich(r, now);
|
||||
// Durée réelle du média : `end_time - start_time` n'est que la durée
|
||||
// *programmée*. Un enregistrement arrêté en avance ou tronqué par une
|
||||
// coupure amont donnait une barre de progression fausse et un « reprendre »
|
||||
// hors des clous.
|
||||
final duration = await _probeDuration(r);
|
||||
if (duration != null) map['duration_seconds'] = duration;
|
||||
enriched.add(map);
|
||||
}
|
||||
return Response.ok(
|
||||
json.encode(enriched),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
|
||||
/// Durée du média en secondes, ou `null` si elle n'est pas mesurable.
|
||||
///
|
||||
/// Un enregistrement en cours grossit en permanence : le cache de
|
||||
/// [MediaProbe] serait invalidé à chaque appel et ffprobe tournerait en
|
||||
/// boucle. La durée programmée suffit tant que la capture n'est pas
|
||||
/// terminée.
|
||||
Future<double?> _probeDuration(Recording r) async {
|
||||
final path = r.filePath;
|
||||
if (path == null || r.status != 'completed') return null;
|
||||
|
||||
// Anti path-traversal : la mesure doit rester dans le dossier des
|
||||
// enregistrements, comme la lecture des logs.
|
||||
final safePath = SafePath.resolveWithin(recordingsDirPath, path);
|
||||
if (safePath == null) return null;
|
||||
|
||||
return MediaProbe.duration(safePath);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _enrich(Recording r, DateTime now) {
|
||||
final map = r.toMap();
|
||||
|
||||
if (r.status == 'recording') {
|
||||
final start = r.startTime.toUtc();
|
||||
final end = r.endTime.toUtc();
|
||||
final total = end.difference(start).inSeconds;
|
||||
if (total > 0) {
|
||||
final elapsed = now.difference(start).inSeconds;
|
||||
map['progress_pct'] =
|
||||
(elapsed * 100 / total).clamp(0, 100).round();
|
||||
}
|
||||
map['is_active'] = _scheduler.isCapturing(r.id);
|
||||
final retries = _scheduler.retryCountOf(r.id);
|
||||
if (retries != null) map['retry_count'] = retries;
|
||||
}
|
||||
|
||||
final path = r.filePath;
|
||||
if (path != null) {
|
||||
try {
|
||||
final file = File(path);
|
||||
if (file.existsSync()) map['file_size_bytes'] = file.lengthSync();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
/// POST /api/recordings — Planifie un nouvel enregistrement
|
||||
Future<Response> handlePost(Request request) async {
|
||||
final user = request.context['user'] as User?;
|
||||
final userId = user?.id ?? request.context['userId'] as String?;
|
||||
if (userId == null) {
|
||||
return _json(401, {'error': 'Authentification requise'});
|
||||
}
|
||||
|
||||
Map<String, dynamic> data;
|
||||
try {
|
||||
data = json.decode(await request.readAsString()) as Map<String, dynamic>;
|
||||
} catch (_) {
|
||||
return _json(400, {'error': 'Corps JSON invalide'});
|
||||
}
|
||||
|
||||
final channelId = data['channel_id']?.toString() ?? '';
|
||||
final streamUrl = data['stream_url']?.toString() ?? '';
|
||||
if (channelId.isEmpty) {
|
||||
return _json(400, {'error': 'channel_id est requis'});
|
||||
}
|
||||
if (streamUrl.isEmpty) {
|
||||
return _json(400, {'error': 'stream_url est requis'});
|
||||
}
|
||||
|
||||
final startTime = _parseZonedDate(data['start_time']);
|
||||
final endTime = _parseZonedDate(data['end_time']);
|
||||
if (startTime == null || endTime == null) {
|
||||
// Une date sans indicateur de fuseau ('Z' ou ±hh:mm) est ambiguë :
|
||||
// l'interpréter dans le fuseau du serveur décale l'enregistrement
|
||||
// de plusieurs heures selon le TZ du conteneur.
|
||||
return _json(400, {
|
||||
'error':
|
||||
'start_time et end_time doivent être des dates ISO-8601 avec fuseau '
|
||||
'(ex: 2026-08-27T21:00:00Z)',
|
||||
});
|
||||
}
|
||||
if (!endTime.isAfter(startTime)) {
|
||||
return _json(400, {'error': 'end_time doit être après start_time'});
|
||||
}
|
||||
if (endTime.difference(startTime) > Duration(hours: maxRecordingHours)) {
|
||||
return _json(400, {
|
||||
'error': 'Durée maximale dépassée ($maxRecordingHours h)',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
final recording = _db.createRecording(
|
||||
userId: userId,
|
||||
channelId: channelId,
|
||||
streamUrl: streamUrl,
|
||||
title: data['title']?.toString() ?? 'Sans Titre',
|
||||
startTime: startTime,
|
||||
endTime: endTime,
|
||||
);
|
||||
|
||||
return Response.ok(
|
||||
recording.toJson(),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
} catch (e) {
|
||||
print('[RecordingsApi] Erreur à la création: $e');
|
||||
return _json(500, {'error': 'Erreur lors de la programmation'});
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse une date ISO-8601 en exigeant un indicateur de fuseau, et la
|
||||
/// normalise en UTC. Retourne null si absente, invalide ou naïve.
|
||||
DateTime? _parseZonedDate(dynamic raw) {
|
||||
final str = raw?.toString() ?? '';
|
||||
if (str.isEmpty) return null;
|
||||
// 'Z' final ou offset ±hh[:mm] après l'heure
|
||||
final hasZone =
|
||||
str.endsWith('Z') || RegExp(r'[+-]\d{2}:?\d{2}$').hasMatch(str);
|
||||
if (!hasZone) return null;
|
||||
return DateTime.tryParse(str)?.toUtc();
|
||||
}
|
||||
|
||||
/// DELETE /api/recordings/<id> — Annule ou supprime un enregistrement
|
||||
/// Si un enregistrement FFmpeg est actif, il est arrêté avant la suppression.
|
||||
/// Les fichiers associés (.mkv, .log, parties) sont supprimés avec la ligne.
|
||||
Future<Response> handleDelete(Request request, String id) async {
|
||||
final recording = _db.getRecordingById(id);
|
||||
|
||||
if (recording == null) {
|
||||
return _json(404, {'error': 'Enregistrement non trouvé'});
|
||||
}
|
||||
|
||||
final user = request.context['user'] as User?;
|
||||
if (!_canAccess(user, recording)) {
|
||||
return _json(403, {'error': 'Accès refusé'});
|
||||
}
|
||||
|
||||
// Tuer FFmpeg si cet enregistrement est en cours AVANT de supprimer de la DB
|
||||
await _scheduler.stopRecording(id);
|
||||
|
||||
// Supprimer les fichiers pour ne pas laisser d'orphelins sur le volume,
|
||||
// en restant confiné au dossier des enregistrements.
|
||||
final path = recording.filePath;
|
||||
if (path != null) {
|
||||
final safePath = SafePath.resolveWithin(recordingsDirPath, path);
|
||||
if (safePath != null) {
|
||||
await _scheduler.deleteRecordingFiles(safePath);
|
||||
}
|
||||
}
|
||||
|
||||
_db.deleteRecording(id);
|
||||
|
||||
return _json(200, {'message': 'Enregistrement supprimé avec succès'});
|
||||
}
|
||||
|
||||
/// POST /api/recordings/stop/<id> — Arrête un enregistrement FFmpeg en cours
|
||||
Future<Response> handleStop(Request request, String id) async {
|
||||
final recording = _db.getRecordingById(id);
|
||||
if (recording == null) {
|
||||
return _json(404, {'error': 'Enregistrement non trouvé'});
|
||||
}
|
||||
|
||||
final user = request.context['user'] as User?;
|
||||
if (!_canAccess(user, recording)) {
|
||||
return _json(403, {'error': 'Accès refusé'});
|
||||
}
|
||||
|
||||
final stopped = await _scheduler.stopRecording(id);
|
||||
if (stopped) {
|
||||
return _json(200, {'message': 'Enregistrement arrêté'});
|
||||
}
|
||||
|
||||
if (recording.status == 'scheduled') {
|
||||
// Rien n'a encore été capturé : annulé, pas « terminé ». Marquer
|
||||
// completed sans fichier faisait ensuite échouer la lecture.
|
||||
_db.updateRecordingStatus(id, 'cancelled');
|
||||
return _json(200, {'message': 'Enregistrement annulé'});
|
||||
}
|
||||
|
||||
if (recording.status == 'recording') {
|
||||
// Statut « recording » sans processus actif (orphelin) : clôturer.
|
||||
_db.updateRecordingStatus(id, 'completed');
|
||||
return _json(200, {'message': 'Enregistrement marqué comme terminé'});
|
||||
}
|
||||
|
||||
// Déjà completed/failed/cancelled : ne pas écraser le statut final.
|
||||
return _json(200, {'message': 'Enregistrement déjà clôturé'});
|
||||
}
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
import 'dart:convert';
|
||||
import 'package:shelf/shelf.dart';
|
||||
import '../database/database.dart';
|
||||
import '../models/user.dart';
|
||||
|
||||
/// API Season Passes — enregistrements répétés par titre d'émission
|
||||
class SeasonPassesApi {
|
||||
final AppDatabase _db;
|
||||
|
||||
SeasonPassesApi(this._db);
|
||||
|
||||
/// GET /api/season-passes — liste les season passes de l'utilisateur
|
||||
/// (tous les passes pour un admin)
|
||||
Response handleGetAll(Request request) {
|
||||
try {
|
||||
final user = request.context['user'] as User?;
|
||||
if (user == null) {
|
||||
return Response(
|
||||
401,
|
||||
body: json.encode({'error': 'Authentification requise'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
final passes = user.isAdmin
|
||||
? _db.getAllSeasonPasses()
|
||||
: _db.getSeasonPassesForUser(user.id);
|
||||
return Response.ok(
|
||||
json.encode(passes),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
} catch (e) {
|
||||
print('[SeasonPass] Erreur au listage: $e');
|
||||
return Response.internalServerError(
|
||||
body: json.encode({'error': 'Erreur interne'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /api/season-passes — créer un season pass
|
||||
Future<Response> handlePost(Request request) async {
|
||||
try {
|
||||
final body = await request.readAsString();
|
||||
final data = json.decode(body) as Map<String, dynamic>;
|
||||
|
||||
final showTitle = data['show_title'] as String?;
|
||||
final channelId = data['channel_id'] as String?;
|
||||
final streamUrl = data['stream_url'] as String?;
|
||||
|
||||
if (showTitle == null || showTitle.isEmpty) {
|
||||
return Response(
|
||||
400,
|
||||
body: json.encode({'error': 'show_title est requis'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
if (channelId == null || channelId.isEmpty) {
|
||||
return Response(
|
||||
400,
|
||||
body: json.encode({'error': 'channel_id est requis'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
if (streamUrl == null || streamUrl.isEmpty) {
|
||||
return Response(
|
||||
400,
|
||||
body: json.encode({'error': 'stream_url est requis'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
|
||||
// Récupérer l'utilisateur depuis le contexte
|
||||
final user = request.context['user'] as User?;
|
||||
if (user == null) {
|
||||
return Response(
|
||||
401,
|
||||
body: json.encode({'error': 'Authentification requise'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
|
||||
// 'exact' par défaut : « Journal » ne doit pas capturer tous les
|
||||
// programmes qui contiennent le mot. 'contains' reste disponible.
|
||||
final matchMode = data['match_mode'] == 'contains' ? 'contains' : 'exact';
|
||||
|
||||
// Vérifier si un season pass identique existe déjà pour cet utilisateur
|
||||
final existing = _db.getSeasonPassesForUser(user.id);
|
||||
final duplicate = existing.any(
|
||||
(p) =>
|
||||
(p['show_title'] as String).toLowerCase() ==
|
||||
showTitle.toLowerCase() &&
|
||||
p['channel_id'] == channelId,
|
||||
);
|
||||
if (duplicate) {
|
||||
return Response(
|
||||
409,
|
||||
body: json.encode({'error': 'Un Season Pass identique existe déjà'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
|
||||
final pass = _db.createSeasonPass(
|
||||
userId: user.id,
|
||||
showTitle: showTitle,
|
||||
channelId: channelId,
|
||||
streamUrl: streamUrl,
|
||||
matchMode: matchMode,
|
||||
);
|
||||
|
||||
print('[SeasonPass] Créé: "$showTitle" sur chaîne $channelId');
|
||||
return Response(
|
||||
201,
|
||||
body: json.encode(pass),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
} catch (e) {
|
||||
return Response.internalServerError(
|
||||
body: json.encode({'error': 'Erreur: $e'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// DELETE /api/season-passes/<id> — supprimer un season pass
|
||||
Response handleDelete(Request request, String id) {
|
||||
try {
|
||||
final user = request.context['user'] as User?;
|
||||
final pass = _db.getSeasonPassById(id);
|
||||
if (pass == null) {
|
||||
return Response.notFound(
|
||||
json.encode({'error': 'Season Pass non trouvé'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
// Contrôle de propriété : seul le propriétaire ou un admin supprime.
|
||||
if (user == null || (!user.isAdmin && pass['user_id'] != user.id)) {
|
||||
return Response.forbidden(
|
||||
json.encode({'error': 'Accès refusé'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
_db.deleteSeasonPass(id);
|
||||
return Response.ok(
|
||||
json.encode({'message': 'Season Pass supprimé'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
} catch (e) {
|
||||
print('[SeasonPass] Erreur à la suppression: $e');
|
||||
return Response.internalServerError(
|
||||
body: json.encode({'error': 'Erreur interne'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import 'dart:convert';
|
||||
import 'package:shelf/shelf.dart';
|
||||
import 'package:shelf_router/shelf_router.dart';
|
||||
import '../database/database.dart';
|
||||
|
||||
class SettingsHandler {
|
||||
final AppDatabase db;
|
||||
|
||||
SettingsHandler(this.db);
|
||||
|
||||
Router get router {
|
||||
final router = Router();
|
||||
|
||||
// GET /api/settings
|
||||
router.get('/', (Request req) {
|
||||
// Get userId from context (set by auth middleware)
|
||||
final userId = req.context['userId'] as String?;
|
||||
if (userId == null) {
|
||||
return Response.forbidden(jsonEncode({'error': 'Authentication required'}));
|
||||
}
|
||||
|
||||
final settingsJson = db.getUserSettings(userId);
|
||||
|
||||
// If no settings exist, return empty object or default
|
||||
return Response.ok(
|
||||
settingsJson ?? '{}',
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
});
|
||||
|
||||
// POST /api/settings
|
||||
router.post('/', (Request req) async {
|
||||
// Get userId from context (set by auth middleware)
|
||||
final userId = req.context['userId'] as String?;
|
||||
if (userId == null) {
|
||||
return Response.forbidden(jsonEncode({'error': 'Authentication required'}));
|
||||
}
|
||||
|
||||
try {
|
||||
final body = await req.readAsString();
|
||||
// Validate JSON
|
||||
try {
|
||||
jsonDecode(body);
|
||||
} catch (e) {
|
||||
return Response.badRequest(body: jsonEncode({'error': 'Invalid JSON'}));
|
||||
}
|
||||
|
||||
db.updateUserSettings(userId, body);
|
||||
|
||||
return Response.ok(
|
||||
body,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
} catch (e) {
|
||||
return Response.internalServerError(
|
||||
body: jsonEncode({'error': 'Failed to save settings: $e'}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
import 'dart:convert';
|
||||
import 'package:shelf/shelf.dart';
|
||||
import 'package:shelf_router/shelf_router.dart';
|
||||
import '../database/database.dart';
|
||||
import '../services/simple_recorder.dart';
|
||||
|
||||
/// 🎯 Simple Recording API - 3 endpoints only!
|
||||
class SimpleRecordingApi {
|
||||
final AppDatabase db;
|
||||
final SimpleRecorder recorder;
|
||||
|
||||
SimpleRecordingApi(this.db, this.recorder);
|
||||
|
||||
/// Setup routes
|
||||
Router get router {
|
||||
final router = Router();
|
||||
|
||||
router.post('/record/now', _recordNow);
|
||||
router.post('/record/schedule', _scheduleRecord);
|
||||
router.post('/record/stop/<channelId>', _stopRecord);
|
||||
router.get('/record/list', _listRecordings);
|
||||
router.get('/record/active', _getActive);
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
/// 🟢 POST /record/now
|
||||
/// Start recording RIGHT NOW
|
||||
/// Body: { channel_id, stream_url, title, duration_minutes }
|
||||
Future<Response> _recordNow(Request request) async {
|
||||
try {
|
||||
final data = jsonDecode(await request.readAsString());
|
||||
|
||||
final id = await recorder.startRecording(
|
||||
channelId: data['channel_id'],
|
||||
streamUrl: data['stream_url'],
|
||||
title: data['title'] ?? 'Recording',
|
||||
duration: Duration(minutes: data['duration_minutes'] ?? 60),
|
||||
);
|
||||
|
||||
return Response.ok(
|
||||
jsonEncode({
|
||||
'status': 'recording',
|
||||
'id': id,
|
||||
'message': 'Recording started!',
|
||||
}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
} catch (e) {
|
||||
return Response.badRequest(
|
||||
body: jsonEncode({'error': '$e'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 🔵 POST /record/schedule
|
||||
/// Schedule recording for later
|
||||
/// Body: { channel_id, stream_url, title, start_time (ISO), end_time (ISO) }
|
||||
Future<Response> _scheduleRecord(Request request) async {
|
||||
try {
|
||||
final data = jsonDecode(await request.readAsString());
|
||||
|
||||
final id = await recorder.scheduleRecording(
|
||||
channelId: data['channel_id'],
|
||||
streamUrl: data['stream_url'],
|
||||
title: data['title'] ?? 'Recording',
|
||||
startTime: DateTime.parse(data['start_time']),
|
||||
endTime: DateTime.parse(data['end_time']),
|
||||
);
|
||||
|
||||
return Response.ok(
|
||||
jsonEncode({
|
||||
'status': 'scheduled',
|
||||
'id': id,
|
||||
'message': 'Recording scheduled!',
|
||||
}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
} catch (e) {
|
||||
return Response.badRequest(
|
||||
body: jsonEncode({'error': '$e'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 🔴 POST /record/stop/<channelId>
|
||||
/// Stop recording immediately
|
||||
Future<Response> _stopRecord(Request request, String channelId) async {
|
||||
try {
|
||||
await recorder.stopRecording(channelId);
|
||||
return Response.ok(
|
||||
jsonEncode({'status': 'stopped', 'message': 'Recording stopped!'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
} catch (e) {
|
||||
return Response.badRequest(
|
||||
body: jsonEncode({'error': '$e'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 📋 GET /record/list
|
||||
/// List all recordings
|
||||
Response _listRecordings(Request request) {
|
||||
final recordings = db.getAllRecordings();
|
||||
return Response.ok(
|
||||
jsonEncode({
|
||||
'total': recordings.length,
|
||||
'recordings': recordings.map((r) => r.toMap()).toList(),
|
||||
}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
|
||||
/// 🟢 GET /record/active
|
||||
/// List currently recording
|
||||
Response _getActive(Request request) {
|
||||
return Response.ok(
|
||||
jsonEncode({
|
||||
'active': recorder.getActive(),
|
||||
'count': recorder.getActive().length,
|
||||
}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,133 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../models/playlist_config.dart';
|
||||
|
||||
/// Diagnostic admin : chronomètre l'ouverture d'une chaîne chez le
|
||||
/// fournisseur, en `.ts` et en `.m3u8`.
|
||||
///
|
||||
/// POURQUOI : ~4,3 s des ~4,6 s d'un zap se passent avant le premier octet
|
||||
/// du panneau (mesuré en prod). Le panneau autorise aussi le HLS ; reste à
|
||||
/// savoir lequel des deux formats démarre le plus vite, et où part le temps
|
||||
/// (redirection, en-têtes, premier octet). Les identifiants n'apparaissent
|
||||
/// jamais dans la réponse : seuls l'hôte et les durées sont rendus.
|
||||
Future<Map<String, Object?>> probeUpstream(
|
||||
PlaylistConfig playlist,
|
||||
String streamId, {
|
||||
http.Client? client,
|
||||
}) async {
|
||||
final c = client ?? http.Client();
|
||||
try {
|
||||
final base = '${playlist.dns}/live/${playlist.username}/'
|
||||
'${playlist.password}/$streamId';
|
||||
final ts = await _probeStream(c, Uri.parse('$base.ts'));
|
||||
// Laisser le panneau libérer la connexion (comptes à une connexion).
|
||||
await Future<void>.delayed(const Duration(seconds: 2));
|
||||
final hls = await _probeHls(c, Uri.parse('$base.m3u8'));
|
||||
return {'stream': streamId, 'ts': ts, 'm3u8': hls};
|
||||
} finally {
|
||||
if (client == null) c.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Suit les redirections à la main pour chronométrer chaque saut.
|
||||
Future<({http.StreamedResponse? response, List<Map<String, Object?>> hops, Uri url})>
|
||||
_open(http.Client c, Uri url, Stopwatch sw) async {
|
||||
final hops = <Map<String, Object?>>[];
|
||||
var current = url;
|
||||
for (var i = 0; i < 5; i++) {
|
||||
final request = http.Request('GET', current)
|
||||
..followRedirects = false
|
||||
..headers['User-Agent'] = 'VLC/3.0.18 LibVLC/3.0.18';
|
||||
final response =
|
||||
await c.send(request).timeout(const Duration(seconds: 20));
|
||||
hops.add({
|
||||
'host': current.host,
|
||||
'status': response.statusCode,
|
||||
'headersMs': sw.elapsedMilliseconds,
|
||||
});
|
||||
final location = response.headers['location'];
|
||||
if (const {301, 302, 303, 307, 308}.contains(response.statusCode) &&
|
||||
location != null) {
|
||||
await response.stream.drain<void>().catchError((_) {});
|
||||
current = current.resolve(location);
|
||||
continue;
|
||||
}
|
||||
return (response: response, hops: hops, url: current);
|
||||
}
|
||||
return (response: null, hops: hops, url: current);
|
||||
}
|
||||
|
||||
Future<Map<String, Object?>> _probeStream(http.Client c, Uri url) async {
|
||||
final sw = Stopwatch()..start();
|
||||
try {
|
||||
final opened = await _open(c, url, sw);
|
||||
final response = opened.response;
|
||||
if (response == null || response.statusCode != 200) {
|
||||
return {'hops': opened.hops, 'error': 'status ${response?.statusCode}'};
|
||||
}
|
||||
var bytes = 0;
|
||||
int? firstByteMs;
|
||||
final sub = response.stream.listen(null);
|
||||
final done = Completer<void>();
|
||||
sub.onData((chunk) {
|
||||
firstByteMs ??= sw.elapsedMilliseconds;
|
||||
bytes += chunk.length;
|
||||
// 1 Mo suffit à voir le premier envoi (préchargement du panneau).
|
||||
if (bytes > 1000000 && !done.isCompleted) done.complete();
|
||||
});
|
||||
sub.onDone(() {
|
||||
if (!done.isCompleted) done.complete();
|
||||
});
|
||||
await done.future.timeout(const Duration(seconds: 15), onTimeout: () {});
|
||||
await sub.cancel();
|
||||
return {
|
||||
'hops': opened.hops,
|
||||
'firstByteMs': firstByteMs,
|
||||
'firstMegabyteMs': bytes > 1000000 ? sw.elapsedMilliseconds : null,
|
||||
};
|
||||
} catch (e) {
|
||||
return {'error': e.runtimeType.toString(), 'afterMs': sw.elapsedMilliseconds};
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, Object?>> _probeHls(http.Client c, Uri url) async {
|
||||
final sw = Stopwatch()..start();
|
||||
try {
|
||||
final opened = await _open(c, url, sw);
|
||||
final response = opened.response;
|
||||
if (response == null || response.statusCode != 200) {
|
||||
return {'hops': opened.hops, 'error': 'status ${response?.statusCode}'};
|
||||
}
|
||||
final body = await response.stream.bytesToString();
|
||||
final playlistMs = sw.elapsedMilliseconds;
|
||||
final lines = const LineSplitter().convert(body);
|
||||
final segments =
|
||||
lines.where((l) => l.isNotEmpty && !l.startsWith('#')).toList();
|
||||
final target = lines
|
||||
.firstWhere((l) => l.startsWith('#EXT-X-TARGETDURATION'),
|
||||
orElse: () => '')
|
||||
.split(':')
|
||||
.last;
|
||||
if (segments.isEmpty) {
|
||||
return {'hops': opened.hops, 'playlistMs': playlistMs, 'segments': 0};
|
||||
}
|
||||
// Un lecteur démarre près du direct : chronométrer l'avant-dernier.
|
||||
final pick = segments.length >= 2
|
||||
? segments[segments.length - 2]
|
||||
: segments.last;
|
||||
final segment = await _probeStream(c, opened.url.resolve(pick));
|
||||
return {
|
||||
'hops': opened.hops,
|
||||
'playlistMs': playlistMs,
|
||||
'segments': segments.length,
|
||||
'targetDuration': target,
|
||||
'segment': segment,
|
||||
'segmentStartedAtMs': playlistMs,
|
||||
};
|
||||
} catch (e) {
|
||||
return {'error': e.runtimeType.toString(), 'afterMs': sw.elapsedMilliseconds};
|
||||
}
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
import 'dart:convert';
|
||||
import 'package:shelf/shelf.dart';
|
||||
import 'package:shelf_router/shelf_router.dart';
|
||||
import '../database/database.dart';
|
||||
|
||||
class UsersHandler {
|
||||
final AppDatabase db;
|
||||
|
||||
UsersHandler(this.db);
|
||||
|
||||
Router get router {
|
||||
final router = Router();
|
||||
router.get('/', _getAllUsers);
|
||||
router.post('/', _createUser);
|
||||
router.put('/<id>/password', _updatePassword);
|
||||
router.put('/<id>', _updateUser);
|
||||
router.delete('/<id>', _deleteUser);
|
||||
return router;
|
||||
}
|
||||
|
||||
/// Check if requester is admin
|
||||
bool _isAdmin(Request request) {
|
||||
final userId = request.context['userId'] as String?;
|
||||
if (userId == null) return false;
|
||||
final user = db.findUserById(userId);
|
||||
return user?.isAdmin ?? false;
|
||||
}
|
||||
|
||||
/// GET /api/users
|
||||
Future<Response> _getAllUsers(Request request) async {
|
||||
try {
|
||||
if (!_isAdmin(request)) {
|
||||
return Response.forbidden(jsonEncode({'success': false, 'error': 'Admin required'}));
|
||||
}
|
||||
|
||||
final users = db.getAllUsers();
|
||||
return Response.ok(jsonEncode({
|
||||
'success': true,
|
||||
'users': users.map((u) => u.toJson()).toList(),
|
||||
}), headers: {'Content-Type': 'application/json'},);
|
||||
} catch (e) {
|
||||
return Response.internalServerError(
|
||||
body: jsonEncode({'success': false, 'error': e.toString()}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /api/users
|
||||
Future<Response> _createUser(Request request) async {
|
||||
try {
|
||||
if (!_isAdmin(request)) {
|
||||
return Response.forbidden(jsonEncode({'success': false, 'error': 'Admin required'}));
|
||||
}
|
||||
|
||||
final payload = jsonDecode(await request.readAsString()) as Map<String, dynamic>;
|
||||
final username = payload['username'] as String?;
|
||||
final password = payload['password'] as String?;
|
||||
final isAdmin = payload['isAdmin'] as bool? ?? false;
|
||||
|
||||
if (username == null || password == null) {
|
||||
return Response.badRequest(body: jsonEncode({
|
||||
'success': false,
|
||||
'error': 'Missing required fields',
|
||||
}), headers: {'Content-Type': 'application/json'},);
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
return Response.badRequest(body: jsonEncode({
|
||||
'success': false,
|
||||
'error': 'Le mot de passe doit faire au moins 8 caractères',
|
||||
}), headers: {'Content-Type': 'application/json'},);
|
||||
}
|
||||
|
||||
if (db.findUserByUsername(username) != null) {
|
||||
return Response.badRequest(body: jsonEncode({
|
||||
'success': false,
|
||||
'error': 'Username already exists',
|
||||
}), headers: {'Content-Type': 'application/json'},);
|
||||
}
|
||||
|
||||
final user = db.createUser(username, password, isAdmin: isAdmin);
|
||||
return Response.ok(jsonEncode({
|
||||
'success': true,
|
||||
'user': user.toJson(),
|
||||
}), headers: {'Content-Type': 'application/json'},);
|
||||
|
||||
} catch (e) {
|
||||
return Response.internalServerError(
|
||||
body: jsonEncode({'success': false, 'error': e.toString()}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// PUT /api/users/:id/password
|
||||
Future<Response> _updatePassword(Request request, String id) async {
|
||||
try {
|
||||
if (!_isAdmin(request)) {
|
||||
return Response.forbidden(jsonEncode({'success': false, 'error': 'Admin required'}));
|
||||
}
|
||||
|
||||
final payload = jsonDecode(await request.readAsString()) as Map<String, dynamic>;
|
||||
final password = payload['password'] as String?;
|
||||
|
||||
if (password == null) {
|
||||
return Response.badRequest(body: jsonEncode({
|
||||
'success': false,
|
||||
'error': 'Missing password',
|
||||
}), headers: {'Content-Type': 'application/json'},);
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
return Response.badRequest(body: jsonEncode({
|
||||
'success': false,
|
||||
'error': 'Le mot de passe doit faire au moins 8 caractères',
|
||||
}), headers: {'Content-Type': 'application/json'},);
|
||||
}
|
||||
|
||||
db.updateUserPassword(id, password);
|
||||
return Response.ok(jsonEncode({'success': true}), headers: {'Content-Type': 'application/json'});
|
||||
|
||||
} catch (e) {
|
||||
return Response.internalServerError(
|
||||
body: jsonEncode({'success': false, 'error': e.toString()}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// PUT /api/users/:id (Toggle Admin)
|
||||
Future<Response> _updateUser(Request request, String id) async {
|
||||
try {
|
||||
if (!_isAdmin(request)) {
|
||||
return Response.forbidden(jsonEncode({'success': false, 'error': 'Admin required'}));
|
||||
}
|
||||
|
||||
final payload = jsonDecode(await request.readAsString()) as Map<String, dynamic>;
|
||||
final isAdmin = payload['isAdmin'] as bool?;
|
||||
|
||||
if (isAdmin != null) {
|
||||
db.updateUserAdminStatus(id, isAdmin);
|
||||
}
|
||||
|
||||
return Response.ok(jsonEncode({'success': true}), headers: {'Content-Type': 'application/json'});
|
||||
|
||||
} catch (e) {
|
||||
return Response.internalServerError(
|
||||
body: jsonEncode({'success': false, 'error': e.toString()}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// DELETE /api/users/:id
|
||||
Future<Response> _deleteUser(Request request, String id) async {
|
||||
try {
|
||||
if (!_isAdmin(request)) {
|
||||
return Response.forbidden(jsonEncode({'success': false, 'error': 'Admin required'}));
|
||||
}
|
||||
|
||||
// Prevent deleting self
|
||||
final currentUserId = request.context['userId'] as String;
|
||||
if (currentUserId == id) {
|
||||
return Response.badRequest(body: jsonEncode({
|
||||
'success': false,
|
||||
'error': 'Cannot delete yourself',
|
||||
}), headers: {'Content-Type': 'application/json'},);
|
||||
}
|
||||
|
||||
db.deleteUser(id);
|
||||
return Response.ok(jsonEncode({'success': true}), headers: {'Content-Type': 'application/json'});
|
||||
} catch (e) {
|
||||
return Response.internalServerError(
|
||||
body: jsonEncode({'success': false, 'error': e.toString()}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import 'dart:convert';
|
||||
import 'package:shelf/shelf.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../models/playlist_config.dart';
|
||||
import '../utils/log_redactor.dart';
|
||||
|
||||
/// Authenticated gateway to the Xtream `player_api.php` endpoint.
|
||||
///
|
||||
/// The frontend never sees the Xtream credentials: it calls
|
||||
/// `GET /api/xtream-api?action=...` with its session token and the server
|
||||
/// injects the username/password of the user's playlist before forwarding.
|
||||
class XtreamApiHandler {
|
||||
final Future<PlaylistConfig?> Function(Request) _getPlaylist;
|
||||
final http.Client _client = http.Client();
|
||||
|
||||
XtreamApiHandler(this._getPlaylist);
|
||||
|
||||
/// Query parameters the client is allowed to pass through to Xtream.
|
||||
static const _allowedParams = {
|
||||
'action',
|
||||
'category_id',
|
||||
'stream_id',
|
||||
'series_id',
|
||||
'vod_id',
|
||||
'limit',
|
||||
'type',
|
||||
};
|
||||
|
||||
Future<Response> handle(Request request) async {
|
||||
final playlist = await _getPlaylist(request);
|
||||
if (playlist == null) {
|
||||
return Response.forbidden(
|
||||
jsonEncode({'error': 'No playlist configured'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
|
||||
final params = <String, String>{
|
||||
'username': playlist.username,
|
||||
'password': playlist.password,
|
||||
};
|
||||
request.url.queryParameters.forEach((key, value) {
|
||||
if (_allowedParams.contains(key)) params[key] = value;
|
||||
});
|
||||
|
||||
final base = Uri.parse('${playlist.dns}/player_api.php');
|
||||
final targetUrl = base.replace(queryParameters: params);
|
||||
|
||||
try {
|
||||
final proxyRequest = http.Request('GET', targetUrl)
|
||||
..headers['User-Agent'] = 'VLC/3.0.18 LibVLC/3.0.18'
|
||||
..headers['Accept'] = '*/*'
|
||||
..followRedirects = true;
|
||||
|
||||
final response = await _client
|
||||
.send(proxyRequest)
|
||||
.timeout(const Duration(seconds: 90));
|
||||
|
||||
return Response(
|
||||
response.statusCode,
|
||||
body: response.stream,
|
||||
headers: {
|
||||
'Content-Type':
|
||||
response.headers['content-type'] ?? 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
print(
|
||||
'[XtreamApi] Error forwarding to ${LogRedactor.redactUrl(targetUrl.toString())}: $e',
|
||||
);
|
||||
return Response.internalServerError(
|
||||
body: jsonEncode({'error': 'Upstream Xtream API error'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user