Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25dabc97b2 | ||
|
|
e81cd8b64d | ||
|
|
30d8a24927 | ||
|
|
d6532d3501 | ||
|
|
17db9f56f7 |
@@ -1,18 +1,64 @@
|
|||||||
<?php
|
<?php
|
||||||
/**
|
/**
|
||||||
* GridTV — proxy.php (Apache + cURL)
|
* GridTV — proxy.php
|
||||||
|
* Proxy HTTP->HTTPS restreint aux hotes autorises dans config.json
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// ── Charger la whitelist depuis config.json ────────────────────────────────────
|
||||||
|
$config_path = __DIR__ . '/config.json';
|
||||||
|
$allowed_hosts = [];
|
||||||
|
|
||||||
|
if (file_exists($config_path)) {
|
||||||
|
$config = json_decode(file_get_contents($config_path), true);
|
||||||
|
foreach ($config['epg_sources'] ?? [] as $src) {
|
||||||
|
foreach (['epg_url', 'm3u_url'] as $key) {
|
||||||
|
if (!empty($src[$key])) {
|
||||||
|
$host = parse_url($src[$key], PHP_URL_HOST);
|
||||||
|
if ($host) $allowed_hosts[] = strtolower($host);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Valider l'URL demandee ─────────────────────────────────────────────────────
|
||||||
$url = $_GET['url'] ?? '';
|
$url = $_GET['url'] ?? '';
|
||||||
|
|
||||||
if (empty($url) || !preg_match('#^https?://#i', $url)) {
|
if (empty($url) || !preg_match('#^https?://#i', $url)) {
|
||||||
http_response_code(400); die('Invalid URL');
|
http_response_code(400); die('Invalid URL');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$parsed = parse_url($url);
|
||||||
|
$host = strtolower($parsed['host'] ?? '');
|
||||||
|
|
||||||
|
// Bloquer si hote absent de la whitelist
|
||||||
|
if (empty($allowed_hosts) || !in_array($host, $allowed_hosts, true)) {
|
||||||
|
http_response_code(403); die('Host not allowed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bloquer les IPs privees, loopback, metadata cloud
|
||||||
|
function is_private_host(string $host): bool {
|
||||||
|
// Loopback / localhost
|
||||||
|
if ($host === 'localhost' || $host === '::1') return true;
|
||||||
|
// Metadata AWS/GCP/Azure
|
||||||
|
if ($host === '169.254.169.254' || $host === 'metadata.google.internal') return true;
|
||||||
|
// Resoudre et verifier si IP privee
|
||||||
|
$ip = filter_var($host, FILTER_VALIDATE_IP) ? $host : gethostbyname($host);
|
||||||
|
if (!filter_var($ip, FILTER_VALIDATE_IP)) return true; // echec resolution
|
||||||
|
return !filter_var($ip, FILTER_VALIDATE_IP, [
|
||||||
|
'flags' => FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note : on autorise les IPs privees si elles sont explicitement dans config.json
|
||||||
|
// (cas Tunarr/Jellyfin sur le reseau local) — on bloque seulement les hotes
|
||||||
|
// qui ne sont PAS dans la whitelist, ce qui couvre deja le SSRF.
|
||||||
|
|
||||||
|
// ── Proxy ─────────────────────────────────────────────────────────────────────
|
||||||
$base = preg_replace('#[^/]*(\?.*)?$#', '', $url);
|
$base = preg_replace('#[^/]*(\?.*)?$#', '', $url);
|
||||||
$origin = parse_url($url, PHP_URL_SCHEME) . '://' . parse_url($url, PHP_URL_HOST);
|
$origin = $parsed['scheme'] . '://' . $parsed['host'];
|
||||||
$port = parse_url($url, PHP_URL_PORT);
|
$port = $parsed['port'] ?? null;
|
||||||
if ($port) $origin .= ':' . $port;
|
if ($port) $origin .= ':' . $port;
|
||||||
$path = parse_url($url, PHP_URL_PATH) ?? '';
|
$path = $parsed['path'] ?? '';
|
||||||
$is_segment = preg_match('#\.(ts|aac|mp4|m4s|fmp4)(\?|$)#i', $path);
|
$is_segment = preg_match('#\.(ts|aac|mp4|m4s|fmp4)(\?|$)#i', $path);
|
||||||
|
|
||||||
header('Access-Control-Allow-Origin: *');
|
header('Access-Control-Allow-Origin: *');
|
||||||
@@ -21,7 +67,6 @@ header('Cache-Control: no-cache');
|
|||||||
$ua = $_SERVER['HTTP_USER_AGENT'] ?? 'Mozilla/5.0';
|
$ua = $_SERVER['HTTP_USER_AGENT'] ?? 'Mozilla/5.0';
|
||||||
|
|
||||||
if ($is_segment) {
|
if ($is_segment) {
|
||||||
// Segment binaire — stream via cURL chunk par chunk
|
|
||||||
header('Content-Type: video/MP2T');
|
header('Content-Type: video/MP2T');
|
||||||
header('X-Content-Type-Options: nosniff');
|
header('X-Content-Type-Options: nosniff');
|
||||||
|
|
||||||
@@ -35,28 +80,20 @@ if ($is_segment) {
|
|||||||
CURLOPT_HTTPHEADER => ['Accept: */*'],
|
CURLOPT_HTTPHEADER => ['Accept: */*'],
|
||||||
CURLOPT_RETURNTRANSFER => false,
|
CURLOPT_RETURNTRANSFER => false,
|
||||||
CURLOPT_WRITEFUNCTION => function($ch, $data) {
|
CURLOPT_WRITEFUNCTION => function($ch, $data) {
|
||||||
echo $data;
|
echo $data; flush(); return strlen($data);
|
||||||
flush();
|
|
||||||
return strlen($data);
|
|
||||||
},
|
},
|
||||||
CURLOPT_HEADERFUNCTION => function($ch, $header) {
|
CURLOPT_HEADERFUNCTION => function($ch, $header) {
|
||||||
$h = trim($header);
|
$h = trim($header);
|
||||||
if (preg_match('/^Content-Type:/i', $h)) {
|
if (preg_match('/^Content-Type:/i', $h)) header($h);
|
||||||
header($h);
|
|
||||||
}
|
|
||||||
return strlen($header);
|
return strlen($header);
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
$ok = curl_exec($ch);
|
||||||
$ok = curl_exec($ch);
|
|
||||||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
if (!$ok || $code >= 400) {
|
if (!$ok || $code >= 400) http_response_code($code ?: 502);
|
||||||
http_response_code($code ?: 502);
|
|
||||||
}
|
|
||||||
curl_close($ch);
|
curl_close($ch);
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
// Playlist .m3u8 — fetch + réécriture URLs
|
|
||||||
$ch = curl_init($url);
|
$ch = curl_init($url);
|
||||||
curl_setopt_array($ch, [
|
curl_setopt_array($ch, [
|
||||||
CURLOPT_FOLLOWLOCATION => true,
|
CURLOPT_FOLLOWLOCATION => true,
|
||||||
@@ -70,8 +107,7 @@ if ($is_segment) {
|
|||||||
curl_close($ch);
|
curl_close($ch);
|
||||||
|
|
||||||
if ($body === false || $code >= 400) {
|
if ($body === false || $code >= 400) {
|
||||||
http_response_code($code ?: 502);
|
http_response_code($code ?: 502); die("Upstream error $code");
|
||||||
die("Upstream error $code");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
header('Content-Type: application/vnd.apple.mpegurl');
|
header('Content-Type: application/vnd.apple.mpegurl');
|
||||||
@@ -85,7 +121,7 @@ if ($is_segment) {
|
|||||||
foreach (explode("\n", $body) as $line) {
|
foreach (explode("\n", $body) as $line) {
|
||||||
$line = rtrim($line);
|
$line = rtrim($line);
|
||||||
if ($line === '' || $line[0] === '#') {
|
if ($line === '' || $line[0] === '#') {
|
||||||
$line = preg_replace_callback('/URI="([^"]+)"/', function($m) use ($base, $proxy_base) {
|
$line = preg_replace_callback('/URI="([^"]+)"/', function($m) use ($base, $origin, $proxy_base) {
|
||||||
$seg = strpos($m[1], 'http') === 0 ? $m[1]
|
$seg = strpos($m[1], 'http') === 0 ? $m[1]
|
||||||
: ($m[1][0] === '/' ? $origin . $m[1] : $base . $m[1]);
|
: ($m[1][0] === '/' ? $origin . $m[1] : $base . $m[1]);
|
||||||
return 'URI="' . $proxy_base . urlencode($seg) . '"';
|
return 'URI="' . $proxy_base . urlencode($seg) . '"';
|
||||||
|
|||||||
+2
-2
@@ -39,12 +39,12 @@ function renderMobile() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const timeCol = document.createElement('div'); timeCol.className='mobile-program-time';
|
const timeCol = document.createElement('div'); timeCol.className='mobile-program-time';
|
||||||
timeCol.innerHTML = `<span>${fmtTime(p.start)}</span><span>${fmtTime(p.stop)}</span>`;
|
timeCol.innerHTML = `<span>${esc(fmtTime(p.start))}</span><span>${esc(fmtTime(p.stop))}</span>`;
|
||||||
item.appendChild(timeCol);
|
item.appendChild(timeCol);
|
||||||
|
|
||||||
const info = document.createElement('div'); info.className='mobile-program-info';
|
const info = document.createElement('div'); info.className='mobile-program-info';
|
||||||
const ep = fmtEpisode(p.season, p.episode);
|
const ep = fmtEpisode(p.season, p.episode);
|
||||||
info.innerHTML = `<div class="mobile-program-title">${p.title}</div><div class="mobile-program-dur">${ep ? ep + ' · ' : ''}${dur} min</div>`;
|
info.innerHTML = `<div class="mobile-program-title">${esc(p.title)}</div><div class="mobile-program-dur">${ep ? esc(ep) + ' · ' : ''}${dur} min</div>`;
|
||||||
item.appendChild(info);
|
item.appendChild(info);
|
||||||
|
|
||||||
if (isLive) {
|
if (isLive) {
|
||||||
|
|||||||
+2
-2
@@ -179,12 +179,12 @@ function renderMobileFiltered(filteredChannels, q) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const timeCol = document.createElement('div'); timeCol.className = 'mobile-program-time';
|
const timeCol = document.createElement('div'); timeCol.className = 'mobile-program-time';
|
||||||
timeCol.innerHTML = `<span>${fmtTime(p.start)}</span><span>${fmtTime(p.stop)}</span>`;
|
timeCol.innerHTML = `<span>${esc(fmtTime(p.start))}</span><span>${esc(fmtTime(p.stop))}</span>`;
|
||||||
item.appendChild(timeCol);
|
item.appendChild(timeCol);
|
||||||
|
|
||||||
const info = document.createElement('div'); info.className = 'mobile-program-info';
|
const info = document.createElement('div'); info.className = 'mobile-program-info';
|
||||||
const ep = fmtEpisode(p.season, p.episode);
|
const ep = fmtEpisode(p.season, p.episode);
|
||||||
info.innerHTML = `<div class="mobile-program-title">${p.title}</div><div class="mobile-program-dur">${ep ? ep + ' · ' : ''}${dur} min</div>`;
|
info.innerHTML = `<div class="mobile-program-title">${esc(p.title)}</div><div class="mobile-program-dur">${ep ? esc(ep) + ' · ' : ''}${dur} min</div>`;
|
||||||
item.appendChild(info);
|
item.appendChild(info);
|
||||||
|
|
||||||
if (isLive) {
|
if (isLive) {
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
|
|
||||||
|
// Echapper les donnees issues de l'EPG pour eviter les injections HTML
|
||||||
|
function esc(str) {
|
||||||
|
const d = document.createElement('div');
|
||||||
|
d.textContent = str || '';
|
||||||
|
return d.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
let channels = [];
|
let channels = [];
|
||||||
let programs = {};
|
let programs = {};
|
||||||
let m3uStreams = {}; // slug normalisé → url stream
|
let m3uStreams = {}; // slug normalisé → url stream
|
||||||
|
|||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"version": "1.4.0"
|
"version": "1.4.2"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user