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(); }