237 lines
8.8 KiB
PHP
237 lines
8.8 KiB
PHP
<?php
|
|
/**
|
|
* GridTV — proxy.php
|
|
*
|
|
* HTTP->HTTPS proxy restricted to hosts explicitly configured in config.json.
|
|
* Private and LAN IPs are allowed only when the administrator configured them.
|
|
* Redirects are followed manually and the host is revalidated at every hop.
|
|
*/
|
|
|
|
// ── Allowlist: authorized hosts extracted from 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 = strtolower(parse_url($src[$key], PHP_URL_HOST) ?? '');
|
|
if ($host !== '') $allowed_hosts[] = $host;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Helpers ────────────────────────────────────────────────────────────────────
|
|
|
|
function is_allowed_url(string $url, array $allowed_hosts): bool {
|
|
if (!preg_match('#^https?://#i', $url)) return false;
|
|
$host = strtolower(parse_url($url, PHP_URL_HOST) ?? '');
|
|
return $host !== '' && in_array($host, $allowed_hosts, true);
|
|
}
|
|
|
|
function normalize_path(string $path): string {
|
|
$segments = explode('/', $path);
|
|
$normalized = [];
|
|
|
|
foreach ($segments as $segment) {
|
|
if ($segment === '' || $segment === '.') {
|
|
continue;
|
|
}
|
|
if ($segment === '..') {
|
|
array_pop($normalized);
|
|
continue;
|
|
}
|
|
$normalized[] = $segment;
|
|
}
|
|
|
|
return '/' . implode('/', $normalized);
|
|
}
|
|
|
|
function resolve_url(string $base, string $location): string {
|
|
if (preg_match('#^https?://#i', $location)) return $location;
|
|
|
|
$parts = parse_url($base);
|
|
$origin = $parts['scheme'] . '://' . $parts['host'];
|
|
if (!empty($parts['port'])) $origin .= ':' . $parts['port'];
|
|
|
|
if (strpos($location, '//') === 0) {
|
|
return $parts['scheme'] . ':' . $location;
|
|
}
|
|
|
|
if ($location === '') {
|
|
return $base;
|
|
}
|
|
|
|
if ($location[0] === '#') {
|
|
$without_fragment = preg_replace('/#.*$/', '', $base);
|
|
return $without_fragment . $location;
|
|
}
|
|
|
|
if ($location[0] === '?') {
|
|
$path = $parts['path'] ?? '/';
|
|
return $origin . $path . $location;
|
|
}
|
|
|
|
$location_parts = parse_url($location);
|
|
$path = $location_parts['path'] ?? '';
|
|
$query = isset($location_parts['query']) ? '?' . $location_parts['query'] : '';
|
|
$fragment = isset($location_parts['fragment']) ? '#' . $location_parts['fragment'] : '';
|
|
|
|
if ($path !== '' && $path[0] === '/') {
|
|
return $origin . normalize_path($path) . $query . $fragment;
|
|
}
|
|
|
|
$base_dir = dirname($parts['path'] ?? '/');
|
|
if ($base_dir === '\\' || $base_dir === '.') {
|
|
$base_dir = '/';
|
|
}
|
|
$combined = rtrim($base_dir, '/') . '/' . $path;
|
|
|
|
return $origin . normalize_path($combined) . $query . $fragment;
|
|
}
|
|
|
|
/**
|
|
* Fetch with manual redirect handling so every Location header is checked
|
|
* against the allowlist before following it.
|
|
*/
|
|
function fetch_with_checked_redirects(string $url, array $allowed_hosts, bool $head_only = false): array {
|
|
$max_redirects = 5;
|
|
|
|
for ($i = 0; $i <= $max_redirects; $i++) {
|
|
if (!is_allowed_url($url, $allowed_hosts)) {
|
|
http_response_code(403); die('Host not allowed after redirect');
|
|
}
|
|
|
|
$ua = $_SERVER['HTTP_USER_AGENT'] ?? 'Mozilla/5.0';
|
|
$ch = curl_init($url);
|
|
curl_setopt_array($ch, [
|
|
CURLOPT_FOLLOWLOCATION => false,
|
|
CURLOPT_HEADER => true,
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_NOBODY => $head_only,
|
|
CURLOPT_TIMEOUT => 30,
|
|
CURLOPT_USERAGENT => $ua,
|
|
CURLOPT_HTTPHEADER => ['Accept: */*'],
|
|
]);
|
|
|
|
$response = curl_exec($ch);
|
|
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
$header_size = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
|
|
curl_close($ch);
|
|
|
|
if ($response === false) {
|
|
http_response_code(502); die('Upstream error');
|
|
}
|
|
|
|
$raw_headers = substr($response, 0, $header_size);
|
|
$body = substr($response, $header_size);
|
|
|
|
// Follow redirects manually so each hop is rechecked against the allowlist.
|
|
if ($code >= 300 && $code < 400) {
|
|
if (!preg_match('/^Location:\s*(.+)$/mi', $raw_headers, $m)) {
|
|
http_response_code(502); die('Invalid redirect');
|
|
}
|
|
$url = resolve_url($url, trim($m[1]));
|
|
continue;
|
|
}
|
|
|
|
return [$code, $raw_headers, $body, $url];
|
|
}
|
|
|
|
http_response_code(508); die('Too many redirects');
|
|
}
|
|
|
|
// ── Validate the initial URL ───────────────────────────────────────────────────
|
|
$url = $_GET['url'] ?? '';
|
|
|
|
if (!is_allowed_url($url, $allowed_hosts)) {
|
|
http_response_code(empty($allowed_hosts) ? 503 : 403);
|
|
die(empty($allowed_hosts) ? 'No sources configured' : 'Host not allowed');
|
|
}
|
|
|
|
// ── Detect the resource type ───────────────────────────────────────────────────
|
|
$path = parse_url($url, PHP_URL_PATH) ?? '';
|
|
$is_segment = preg_match('#\.(ts|aac|mp4|m4s|fmp4)(\?|$)#i', $path);
|
|
|
|
header('Access-Control-Allow-Origin: *');
|
|
header('Cache-Control: no-cache');
|
|
|
|
// ── Binary segment: stream chunk by chunk ──────────────────────────────────────
|
|
if ($is_segment) {
|
|
header('Content-Type: video/MP2T');
|
|
header('X-Content-Type-Options: nosniff');
|
|
|
|
if (ob_get_level()) ob_end_clean();
|
|
|
|
// Resolve redirects with HEAD first so we do not download the segment twice.
|
|
// Some HLS/CDN servers reject HEAD on media objects even when GET works.
|
|
[$code, , , $final_url] = fetch_with_checked_redirects($url, $allowed_hosts, true);
|
|
if ($code === 403 || $code === 405) {
|
|
[, , , $final_url] = fetch_with_checked_redirects($url, $allowed_hosts, false);
|
|
$code = 200;
|
|
}
|
|
|
|
if ($code >= 400) { http_response_code($code); die(); }
|
|
|
|
// Stream the validated final URL.
|
|
$ua = $_SERVER['HTTP_USER_AGENT'] ?? 'Mozilla/5.0';
|
|
$ch = curl_init($final_url);
|
|
curl_setopt_array($ch, [
|
|
CURLOPT_FOLLOWLOCATION => false,
|
|
CURLOPT_TIMEOUT => 30,
|
|
CURLOPT_USERAGENT => $ua,
|
|
CURLOPT_HTTPHEADER => ['Accept: */*'],
|
|
CURLOPT_RETURNTRANSFER => false,
|
|
CURLOPT_WRITEFUNCTION => function($ch, $data) {
|
|
echo $data; flush(); return strlen($data);
|
|
},
|
|
CURLOPT_HEADERFUNCTION => function($ch, $header) {
|
|
$h = trim($header);
|
|
if (preg_match('/^Content-Type:/i', $h)) header($h);
|
|
return strlen($header);
|
|
},
|
|
]);
|
|
$ok = curl_exec($ch);
|
|
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
if (!$ok || $code >= 400) http_response_code($code ?: 502);
|
|
curl_close($ch);
|
|
|
|
// ── m3u8 playlist: fetch and rewrite URLs ──────────────────────────────────────
|
|
} else {
|
|
[$code, , $body, $final_url] = fetch_with_checked_redirects($url, $allowed_hosts, false);
|
|
|
|
if ($code >= 400) { http_response_code($code); die("Upstream error $code"); }
|
|
|
|
header('Content-Type: application/vnd.apple.mpegurl');
|
|
|
|
$final_parts = parse_url($final_url);
|
|
$origin = $final_parts['scheme'] . '://' . $final_parts['host'];
|
|
if (!empty($final_parts['port'])) $origin .= ':' . $final_parts['port'];
|
|
$base = preg_replace('#[^/]*(\?.*)?$#', '', $final_url);
|
|
|
|
$proxy_base = (isset($_SERVER['HTTPS']) ? 'https' : 'http')
|
|
. '://' . $_SERVER['HTTP_HOST']
|
|
. strtok($_SERVER['REQUEST_URI'], '?')
|
|
. '?url=';
|
|
|
|
$out = [];
|
|
foreach (explode("\n", $body) as $line) {
|
|
$line = rtrim($line);
|
|
if ($line === '' || $line[0] === '#') {
|
|
$line = preg_replace_callback('/URI="([^"]+)"/', function($m) use ($base, $origin, $proxy_base) {
|
|
$seg = strpos($m[1], 'http') === 0 ? $m[1]
|
|
: ($m[1][0] === '/' ? $origin . $m[1] : $base . $m[1]);
|
|
return 'URI="' . $proxy_base . urlencode($seg) . '"';
|
|
}, $line);
|
|
$out[] = $line;
|
|
} else {
|
|
$seg = strpos($line, 'http') === 0 ? $line
|
|
: ($line[0] === '/' ? $origin . $line : $base . $line);
|
|
$out[] = $proxy_base . urlencode($seg);
|
|
}
|
|
}
|
|
echo implode("\n", $out);
|
|
}
|