Files
xtremflow/bin/api/proxy_handler.dart
MichaelandClaude Opus 5 9843999809 Logos des chaînes : repli par nom quand l'hébergeur de picons tombe
L'hébergeur des picons du fournisseur (51.158.145.100) répond toujours
503 sur toutes ses URL. Couper le flot de requêtes ne suffisait pas : la
grille restait sans logo. Pire, le pixel transparent servi en 200 était
pris pour une image valide par Flutter, qui n'affichait plus son icône
de repli — d'où des tuiles entièrement vides.

- Nouvelle route `/api/logo?src=…&name=…` : URL du panneau d'abord (via
  le proxy, mêmes protections anti-SSRF et même mémoire des hôtes
  morts), puis recherche par nom dans le dépôt public tv-logo/tv-logos
- Appariement par nom : préfixe pays et suffixes de qualité ignorés,
  `+` → `plus`, variante plus longue (`bein-sports-1-french`), puis
  marque sans numéro pour les canaux événementiels. Aucune approximation
  sur un mot seul
- Liste de logos en cache 24 h (API GitHub : 60 requêtes/h non
  authentifiées), 15 min de recul après échec, images en mémoire
- Image indisponible : 410 mis en cache au lieu du pixel. 410 et non
  404 : la Cascade du serveur rattrape les 404 et renvoyait celui du
  handler statique, sans cache-control
- Client (grille bureau, mobile, enregistrements) : logo toujours
  demandé au backend, nom compris, même sans stream_icon. L'onglet
  Enregistrements chargeait l'URL http brute, bloquée en HTTPS

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 13:39:01 +02:00

335 lines
12 KiB
Dart

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'},
);
}
};
}
}