diff --git a/proxy.php b/proxy.php index a87bebb..583bb55 100644 --- a/proxy.php +++ b/proxy.php @@ -31,22 +31,72 @@ function is_allowed_url(string $url, array $allowed_hosts): bool { 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 ($location[0] === '/') return $origin . $location; - return $origin . rtrim(dirname($parts['path'] ?? '/'), '/') . '/' . $location; + + 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. - * $stream = true: stream the response chunk by chunk (video segments) - * $stream = false: return the full body (m3u8 playlists) */ -function fetch_with_checked_redirects(string $url, array $allowed_hosts, bool $stream = false): array { +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++) { @@ -60,6 +110,7 @@ function fetch_with_checked_redirects(string $url, array $allowed_hosts, bool $s CURLOPT_FOLLOWLOCATION => false, CURLOPT_HEADER => true, CURLOPT_RETURNTRANSFER => true, + CURLOPT_NOBODY => $head_only, CURLOPT_TIMEOUT => 30, CURLOPT_USERAGENT => $ua, CURLOPT_HTTPHEADER => ['Accept: */*'], @@ -114,9 +165,8 @@ if ($is_segment) { if (ob_get_level()) ob_end_clean(); - // For segments, resolve redirects first and only stream once the final URL - // has already been validated. - [$code, , , $final_url] = fetch_with_checked_redirects($url, $allowed_hosts, false); + // Resolve redirects with HEAD first so we do not download the segment twice. + [$code, , , $final_url] = fetch_with_checked_redirects($url, $allowed_hosts, true); if ($code >= 400) { http_response_code($code); die(); } diff --git a/sw.js b/sw.js index d0dc69b..c312579 100644 --- a/sw.js +++ b/sw.js @@ -33,12 +33,13 @@ self.addEventListener('activate', e => { self.addEventListener('fetch', e => { const url = new URL(e.request.url); - // EPG/M3U feeds and the GitHub API stay network-only. + // EPG/M3U feeds, the GitHub API, and version.json stay network-only. if ( url.pathname.includes('xmltv') || url.pathname.includes('.m3u') || url.hostname === 'api.github.com' || - url.pathname.includes('proxy.php') + url.pathname.includes('proxy.php') || + url.pathname.endsWith('version.json') ) { return; }