Files
xtremflow/bin/api/epg_api.dart
T
MichaelandClaude Fable 5 60d3f42901 feat: security hardening, streaming overhaul, design polish, tests
Security:
- Replace unsalted SHA-256 password hashing with bcrypt (lazy rehash on login)
- Add authenticated /api/xtream-api gateway: Xtream credentials are injected
  server-side and never sent to the frontend; /api/playlists no longer
  returns passwords
- Redact credentials from all logs (login body, proxy/FFmpeg/scheduler URLs)
- Add auth to recordings, EPG, season-passes and streaming routes
  (HttpOnly session cookie for hls.js; loopback bypass for local FFmpeg)
- Lock player postMessage to same-origin in both directions
- Vendor and pin hls.js 1.6.7 / mpegts.js 1.7.3 (drop CDN @latest)
- Fix rate limiter (client IP was never resolved), add login rate limit,
  restrict CORS, add CSP Report-Only, block private-IP SSRF targets,
  fix path traversal in recording log retrieval, chmod 777 -> 770
- Remove dead HiveService (seeded admin/admin into IndexedDB with SHA-256)
- Fix authMiddleware not populating 'user' context (getPlaylist ignored the
  logged-in user; admin purge always returned 403)

Streaming:
- New FfmpegSessionManager: process registry, idle reaper (4 min live /
  15 min VOD), orphan cleanup at startup, clean SIGTERM shutdown,
  fast-fail with stderr instead of 30 s timeout
- Quality selection (source/high/medium/low) for live and VOD; source mode
  streams with -c:v copy (zero transcoding); selector wired into the player
- Concurrent recordings (MAX_CONCURRENT_RECORDINGS, default 2); conflicts
  retry on the next tick instead of silently failing
- Lower live latency (HLS window 20 -> 10 segments, liveSync 10 -> 3)
- Fix recording log lookup (.mp4 vs .mkv mismatch)

Design:
- Replace hardcoded colors with AppColors tokens (12 files)
- web/theme.css syncs HTML players with the Flutter palette
- DPAD/keyboard navigation (arrow-key focus, player shortcuts)
- Tooltips on player icon buttons, Semantics on content cards
- Remove 7 dead widgets broken since the Stitch merge

Quality:
- bin/test/: 21 unit tests (bcrypt, redaction, traversal, SSRF, recording
  conflicts) plus a quality-selector widget test
- GitHub Actions CI (analyze + test + build web)
- Archive stale status docs into docs/archive/

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 10:07:18 +02:00

146 lines
4.6 KiB
Dart

import 'dart:convert';
import 'package:shelf/shelf.dart';
import 'package:http/http.dart' as http;
import '../models/playlist_config.dart';
/// API EPG — proxy vers Xtream avec cache 30 minutes
/// GET /api/epg/<channel_id>?days=1
class EpgApi {
final Future<PlaylistConfig?> Function(Request) _getPlaylist;
// Cache simple en mémoire : channelId → {data, expiresAt}
final Map<String, _CacheEntry> _cache = {};
EpgApi(this._getPlaylist);
Future<Response> handleGetEpg(Request request, String channelId) async {
// Vérifier le cache
final cached = _cache[channelId];
if (cached != null && DateTime.now().isBefore(cached.expiresAt)) {
return Response.ok(
cached.data,
headers: {'Content-Type': 'application/json', 'X-Cache': 'HIT'},
);
}
try {
final playlist = await _getPlaylist(request);
if (playlist == null) {
return Response.forbidden(
json.encode({'error': 'Playlist non trouvée'}),
headers: {'Content-Type': 'application/json'},
);
}
final dns = playlist.dns;
// 1. Tenter d'abord l'EPG complet (48h)
var url =
'$dns/player_api.php?username=${playlist.username}&password=${playlist.password}'
'&action=get_epg&stream_id=$channelId&limit=48';
var response =
await http.get(Uri.parse(url)).timeout(const Duration(seconds: 60));
Map<String, dynamic> epgData = {
'channel_id': channelId,
'programmes': [],
};
if (response.statusCode == 200) {
final raw = json.decode(response.body);
epgData = _transformEpgData(raw, channelId);
}
// 2. Fallback EPG court si le complet est vide
if ((epgData['programmes'] as List).isEmpty) {
url =
'$dns/player_api.php?username=${playlist.username}&password=${playlist.password}'
'&action=get_short_epg&stream_id=$channelId';
response =
await http.get(Uri.parse(url)).timeout(const Duration(seconds: 60));
if (response.statusCode == 200) {
final raw = json.decode(response.body);
epgData = _transformEpgData(raw, channelId);
}
}
final jsonStr = json.encode(epgData);
// Mettre en cache 30 minutes
_cache[channelId] = _CacheEntry(
data: jsonStr,
expiresAt: DateTime.now().add(const Duration(minutes: 30)),
);
return Response.ok(
jsonStr,
headers: {'Content-Type': 'application/json', 'X-Cache': 'MISS'},
);
} catch (e) {
return Response.internalServerError(
body: json.encode({'error': 'Erreur lors de la récupération EPG: $e'}),
headers: {'Content-Type': 'application/json'},
);
}
}
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) {
final startRaw = item['start'] as String? ?? '';
final endRaw = item['stop'] as String? ?? item['end'] as String? ?? '';
// Normaliser les dates pour le frontend (Xtream format support)
final start = startRaw.contains(' ') && !startRaw.contains('T')
? startRaw.replaceFirst(' ', 'T')
: startRaw;
final end = endRaw.contains(' ') && !endRaw.contains('T')
? endRaw.replaceFirst(' ', 'T')
: endRaw;
// 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) {
return {'channel_id': channelId, 'programmes': [], 'error': e.toString()};
}
}
}
class _CacheEntry {
final String data;
final DateTime expiresAt;
_CacheEntry({required this.data, required this.expiresAt});
}