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:
MichaelandClaude Opus 5.5 committed 2026-10-13 00:29:57 +02:00
1 parent f82c70ae05
commit 3ea6fa0ae3
109 files changed
+14008 -11047

No files matched your search

-81
View File
@@ -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);
}
}
-163
View File
@@ -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;
}
}
-473
View File
@@ -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});
}
-72
View File
@@ -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();
}
}
-70
View File
@@ -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);
}
-191
View File
@@ -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'},
);
}
}
}
-334
View File
@@ -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'},
);
}
};
}
}
-43
View File
@@ -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]}',
);
}
-290
View File
@@ -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é'});
}
}
-155
View File
@@ -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'},
);
}
}
}
-63
View File
@@ -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;
}
}
-129
View File
@@ -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
-133
View File
@@ -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};
}
}
-180
View File
@@ -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'},
);
}
}
}
-78
View File
@@ -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'},
);
}
}
}