Files
xtremflow/bin/api/epg_api.dart
T
Claude ea63314ba7 Durcit la sécurité du backend et du player
Proxy /api/xtream :
- Authentification de session rétablie (Authorization ou cookie HttpOnly
  session — les requêtes navigateur même-origine le portent) ; le proxy
  était volontairement ouvert, offrant un rebond SSRF non authentifié
- Redirections suivies manuellement avec revalidation à chaque saut
  (hôte privé interdit + allowlist de domaine) : avec followRedirects,
  seule l'URL initiale était validée, une 302 amont suffisait pour
  atteindre un hôte interne
- Erreurs proxy sans détail d'exception (ClientException porte l'URL
  amont, credentials Xtream inclus), logs redactés

Logs :
- redactedLogRequests remplace logRequests() de shelf : l'URI de
  /api/xtream/<url> écrivait username/password Xtream en clair à chaque
  requête, annulant l'effort de LogRedactor partout ailleurs
- Les 500 d'epg_api ne renvoient plus e.toString() au client (même
  risque ClientException) ; détail redacté en log serveur

Middleware :
- X-Forwarded-For honoré uniquement depuis un proxy de confiance
  (loopback + RFC1918 par défaut, surchargables via TRUSTED_PROXIES) :
  un client direct forgeait l'en-tête et contournait le rate limit
  global comme la limite de tentatives de login
- Honeypot comparé sur chemin exact/préfixe : l'ancien
  contains(trap.replaceAll('/','')) bloquait toute URL contenant
  console, env ou wpadmin, y compris des URLs proxifiées légitimes

Comptes :
- Mot de passe admin initial aléatoire (Random.secure, affiché une fois
  au démarrage) ou ADMIN_INITIAL_PASSWORD ; fini le admin/admin persistant
- Longueur minimale de 8 caractères à la création et au changement

Divers :
- CleanupService ne cible plus Directory.systemTemp en récursif (il
  supprimait les temporaires de la VM Dart et le parent des sessions HLS)
- web/xf-player-core.js : postMessage vers location.origin au lieu de
  '*', et filtrage d'event.origin à la réception (le côté Flutter le
  faisait déjà)

Validé : dart analyze (0 issue) et dart test (48/48) sur bin/.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015oEu9QayWsw7hCKhenxgVa
2026-08-27 12:19:58 +00:00

308 lines
11 KiB
Dart

import 'dart:convert';
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. Une source XMLTV
/// externe n'est interrogée qu'en dernier recours, quand le panneau ne rend
/// aucun programme couvrant l'instant présent ou à venir — cas fréquent 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 = {};
EpgApi(this._getPlaylist, {XmltvEpgService? xmltv}) : _xmltv = xmltv;
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 {
final dns = playlist.dns;
Map<String, dynamic> epgData = {
'channel_id': channelId,
'programmes': [],
};
// Ordre d'essai des actions Xtream.
//
// `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 =
'$dns/player_api.php?username=${playlist.username}&password=${playlist.password}'
'&action=$action&stream_id=$channelId';
final response =
await http.get(Uri.parse(url)).timeout(const Duration(seconds: 60));
if (response.statusCode != 200) continue;
try {
epgData = transformEpgData(json.decode(response.body), channelId);
} catch (_) {
continue;
}
if ((epgData['programmes'] as List).isNotEmpty) break;
}
var source = 'xtream';
if (!_hasCurrentProgramme(epgData)) {
final fallback = await _xmltvProgrammes(playlist, channelId);
if (fallback.isNotEmpty) {
epgData = {'channel_id': channelId, 'programmes': fallback};
source = 'xmltv';
}
}
final jsonStr = json.encode(epgData);
// Mettre en cache 30 minutes
_cache[cacheKey] = _CacheEntry(
data: jsonStr,
expiresAt: DateTime.now().add(const Duration(minutes: 30)),
);
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'},
);
}
}
/// 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,
) async {
final xmltv = _xmltv;
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) {
print('[EpgApi] repli XMLTV indisponible pour $channelId : $e');
return const [];
}
}
/// Table des chaînes du compte, rafraîchie toutes les 6 heures.
Future<_ChannelMap> _channelMapFor(PlaylistConfig playlist) async {
final key = '${playlist.dns}|${playlist.username}';
final cached = _channelMaps[key];
if (cached != null && DateTime.now().isBefore(cached.expiresAt)) {
return cached;
}
final url = '${playlist.dns}/player_api.php'
'?username=${playlist.username}&password=${playlist.password}'
'&action=get_live_streams';
final response =
await http.get(Uri.parse(url)).timeout(const Duration(seconds: 90));
final epgIds = <String, String>{};
final names = <String, String>{};
if (response.statusCode == 200) {
final decoded = json.decode(response.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;
}
}
}
final map = _ChannelMap(
epgIds: epgIds,
names: names,
expiresAt: DateTime.now().add(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});
}