diff --git a/deploy/php/media_proxy.php b/deploy/php/media_proxy.php index fef9e8b..44ba49c 100644 --- a/deploy/php/media_proxy.php +++ b/deploy/php/media_proxy.php @@ -1,25 +1,40 @@ + * /chgrid/media_proxy.php?url= * - * Notes: - * - Supports upstream http/https URLs. - * - Intended for same-origin browser playback to avoid client-side CORS limits. - * - Includes simple SSRF protections (scheme checks + private/reserved IP blocking). - * - Optional host allowlist via env CHGRID_MEDIA_PROXY_ALLOWLIST, comma-separated. + * Goals: + * - Works on older cPanel PHP web handlers. + * - Supports http/https upstreams. + * - Provides same-origin endpoint for browser playback. */ -header('Access-Control-Allow-Origin: *'); -header('Access-Control-Allow-Methods: GET, HEAD, OPTIONS'); -header('Access-Control-Allow-Headers: Range'); +function set_status($code) +{ + $map = array( + 200 => 'OK', + 204 => 'No Content', + 400 => 'Bad Request', + 403 => 'Forbidden', + 405 => 'Method Not Allowed', + 500 => 'Internal Server Error', + 502 => 'Bad Gateway' + ); + $text = isset($map[$code]) ? $map[$code] : 'OK'; + header('HTTP/1.1 ' . (int) $code . ' ' . $text); +} + +function send_text($code, $message) +{ + set_status($code); + header('Content-Type: text/plain; charset=utf-8'); + echo $message . "\n"; + exit; +} -/** - * PHP-version-safe suffix check (avoid str_ends_with dependency). - */ function host_matches_suffix($host, $suffix) { if ($suffix === '') { @@ -35,67 +50,46 @@ function host_matches_suffix($host, $suffix) return substr($host, -strlen($needle)) === $needle; } -if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { - http_response_code(204); +header('Access-Control-Allow-Origin: *'); +header('Access-Control-Allow-Methods: GET, HEAD, OPTIONS'); +header('Access-Control-Allow-Headers: Range'); + +$method = isset($_SERVER['REQUEST_METHOD']) ? $_SERVER['REQUEST_METHOD'] : 'GET'; +if ($method === 'OPTIONS') { + set_status(204); exit; } - -if (!in_array($_SERVER['REQUEST_METHOD'], ['GET', 'HEAD'], true)) { - http_response_code(405); - header('Content-Type: text/plain; charset=utf-8'); - echo "method not allowed\n"; - exit; +if ($method !== 'GET' && $method !== 'HEAD') { + send_text(405, 'method not allowed'); } $rawUrl = isset($_GET['url']) ? trim((string) $_GET['url']) : ''; if ($rawUrl === '') { - http_response_code(400); - header('Content-Type: text/plain; charset=utf-8'); - echo "missing url query param\n"; - exit; + send_text(400, 'missing url query param'); } $parsed = parse_url($rawUrl); -if ($parsed === false || !isset($parsed['scheme'], $parsed['host'])) { - http_response_code(400); - header('Content-Type: text/plain; charset=utf-8'); - echo "invalid url\n"; - exit; +if ($parsed === false || !isset($parsed['scheme']) || !isset($parsed['host'])) { + send_text(400, 'invalid url'); } $scheme = strtolower((string) $parsed['scheme']); if ($scheme !== 'http' && $scheme !== 'https') { - http_response_code(400); - header('Content-Type: text/plain; charset=utf-8'); - echo "unsupported scheme\n"; - exit; + send_text(400, 'unsupported scheme'); } $host = strtolower((string) $parsed['host']); if ($host === 'localhost' || $host === '127.0.0.1' || $host === '::1') { - http_response_code(403); - header('Content-Type: text/plain; charset=utf-8'); - echo "forbidden host\n"; - exit; + send_text(403, 'forbidden host'); } -/** - * Optional host allowlist, comma-separated suffixes. - * Example: - * CHGRID_MEDIA_PROXY_ALLOWLIST=dropbox.com,dropboxusercontent.com,stream0.wfmu.org - */ +// Optional allowlist env var: CHGRID_MEDIA_PROXY_ALLOWLIST=dropbox.com,example.com $allowlistEnv = getenv('CHGRID_MEDIA_PROXY_ALLOWLIST'); if ($allowlistEnv !== false && trim($allowlistEnv) !== '') { - $allowlist = array(); - $rawAllowlist = explode(',', (string) $allowlistEnv); - foreach ($rawAllowlist as $entry) { - $normalized = strtolower(trim((string) $entry)); - if ($normalized !== '') { - $allowlist[] = $normalized; - } - } $allowed = false; - foreach ($allowlist as $suffix) { + $parts = explode(',', (string) $allowlistEnv); + foreach ($parts as $part) { + $suffix = strtolower(trim((string) $part)); if ($suffix === '') { continue; } @@ -105,159 +99,117 @@ if ($allowlistEnv !== false && trim($allowlistEnv) !== '') { } } if (!$allowed) { - http_response_code(403); - header('Content-Type: text/plain; charset=utf-8'); - echo "host not allowed\n"; - exit; + send_text(403, 'host not allowed'); } } -// Resolve and block private/reserved targets (basic SSRF guard). +// Basic SSRF guard for IPv4. $resolved = @gethostbynamel($host); if ($resolved === false || count($resolved) === 0) { - http_response_code(502); - header('Content-Type: text/plain; charset=utf-8'); - echo "dns resolution failed\n"; - exit; + send_text(502, 'dns resolution failed'); } foreach ($resolved as $ip) { $ok = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE); if ($ok === false) { - http_response_code(403); - header('Content-Type: text/plain; charset=utf-8'); - echo "resolved ip not allowed\n"; - exit; + send_text(403, 'resolved ip not allowed'); } } if (!function_exists('curl_init')) { - http_response_code(500); - header('Content-Type: text/plain; charset=utf-8'); - echo "curl extension is required\n"; - exit; + send_text(500, 'curl extension is required'); } -$ch = curl_init($rawUrl); -if ($ch === false) { - http_response_code(500); - header('Content-Type: text/plain; charset=utf-8'); - echo "proxy init failed\n"; - exit; +$ch = curl_init(); +if (!$ch) { + send_text(500, 'proxy init failed'); } -$upstreamHeaders = []; -$statusCode = 200; -$sentContentType = false; -$headersSent = false; - -curl_setopt_array($ch, [ - CURLOPT_FOLLOWLOCATION => true, - CURLOPT_MAXREDIRS => 5, - CURLOPT_CONNECTTIMEOUT => 10, - CURLOPT_TIMEOUT => 30, - CURLOPT_HTTPGET => true, - CURLOPT_RETURNTRANSFER => false, - CURLOPT_HEADER => false, - CURLOPT_NOSIGNAL => true, - CURLOPT_USERAGENT => 'ChatGridMediaProxy/1.0', - CURLOPT_HTTPHEADER => [ - 'Accept: */*', - 'Connection: keep-alive', - ], - CURLOPT_HEADERFUNCTION => function ($curl, $headerLine) use (&$upstreamHeaders, &$statusCode, &$sentContentType) { - $trimmed = trim($headerLine); - $length = strlen($headerLine); - if ($trimmed === '') { - return $length; - } - if (stripos($trimmed, 'HTTP/') === 0) { - $parts = explode(' ', $trimmed); - if (isset($parts[1]) && ctype_digit($parts[1])) { - $statusCode = (int) $parts[1]; - } - return $length; - } - $split = explode(':', $trimmed, 2); - if (count($split) !== 2) { - return $length; - } - $name = strtolower(trim($split[0])); - $value = trim($split[1]); - $upstreamHeaders[$name] = $value; - if ($name === 'content-type') { - $sentContentType = true; - } - return $length; - }, -]); - -if (isset($_SERVER['HTTP_RANGE']) && $_SERVER['HTTP_RANGE'] !== '') { - curl_setopt($ch, CURLOPT_RANGE, (string) $_SERVER['HTTP_RANGE']); +$requestHeaders = array('Accept: */*', 'Connection: keep-alive'); +$range = isset($_SERVER['HTTP_RANGE']) ? trim((string) $_SERVER['HTTP_RANGE']) : ''; +if ($range !== '') { + $requestHeaders[] = 'Range: ' . $range; } -if ($_SERVER['REQUEST_METHOD'] === 'HEAD') { +curl_setopt($ch, CURLOPT_URL, $rawUrl); +curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); +curl_setopt($ch, CURLOPT_MAXREDIRS, 5); +curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10); +curl_setopt($ch, CURLOPT_TIMEOUT, 45); +curl_setopt($ch, CURLOPT_HEADER, true); +curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); +curl_setopt($ch, CURLOPT_NOSIGNAL, true); +curl_setopt($ch, CURLOPT_USERAGENT, 'ChatGridMediaProxy/1.0'); +curl_setopt($ch, CURLOPT_HTTPHEADER, $requestHeaders); +if ($method === 'HEAD') { curl_setopt($ch, CURLOPT_NOBODY, true); } -/** - * Emit downstream headers exactly once (before body bytes). - */ -$emitHeaders = function () use (&$headersSent, &$statusCode, &$upstreamHeaders, &$sentContentType, $ch) { - if ($headersSent) { - return; - } - if ($statusCode < 200 || $statusCode >= 600) { - $statusCode = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); - if ($statusCode < 200 || $statusCode >= 600) { - $statusCode = 200; - } - } - http_response_code($statusCode); - - if ($sentContentType && isset($upstreamHeaders['content-type'])) { - header('Content-Type: ' . $upstreamHeaders['content-type']); - } else { - header('Content-Type: application/octet-stream'); - } - - if (isset($upstreamHeaders['content-length'])) { - header('Content-Length: ' . $upstreamHeaders['content-length']); - } - if (isset($upstreamHeaders['accept-ranges'])) { - header('Accept-Ranges: ' . $upstreamHeaders['accept-ranges']); - } - if (isset($upstreamHeaders['content-range'])) { - header('Content-Range: ' . $upstreamHeaders['content-range']); - } - if (isset($upstreamHeaders['cache-control'])) { - header('Cache-Control: ' . $upstreamHeaders['cache-control']); - } else { - header('Cache-Control: no-store'); - } - $headersSent = true; -}; - -// Stream output incrementally. -curl_setopt($ch, CURLOPT_WRITEFUNCTION, function ($curl, $chunk) use ($emitHeaders) { - $emitHeaders(); - $len = strlen($chunk); - if ($len > 0) { - echo $chunk; - flush(); - } - return $len; -}); - -$ok = curl_exec($ch); -if ($ok === false) { +$response = curl_exec($ch); +if ($response === false) { $err = curl_error($ch); curl_close($ch); - http_response_code(502); - header('Content-Type: text/plain; charset=utf-8'); - echo "upstream fetch failed: " . $err . "\n"; - exit; + send_text(502, 'upstream fetch failed: ' . $err); } -$emitHeaders(); - +$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); +if ($status < 100 || $status > 599) { + $status = 200; +} +$headerSize = (int) curl_getinfo($ch, CURLINFO_HEADER_SIZE); curl_close($ch); + +$rawHeaders = substr($response, 0, $headerSize); +$body = substr($response, $headerSize); +$headerBlocks = preg_split("/\r\n\r\n|\n\n/", trim($rawHeaders)); +$lastHeaderBlock = ''; +if (is_array($headerBlocks) && count($headerBlocks) > 0) { + $lastHeaderBlock = $headerBlocks[count($headerBlocks) - 1]; +} + +set_status($status); + +$contentType = ''; +$contentLength = ''; +$acceptRanges = ''; +$contentRange = ''; +$cacheControl = ''; + +$lines = preg_split("/\r\n|\n/", $lastHeaderBlock); +if (is_array($lines)) { + foreach ($lines as $line) { + $line = trim($line); + if ($line === '' || stripos($line, 'HTTP/') === 0) { + continue; + } + $split = explode(':', $line, 2); + if (count($split) !== 2) { + continue; + } + $name = strtolower(trim($split[0])); + $value = trim($split[1]); + if ($name === 'content-type') $contentType = $value; + if ($name === 'content-length') $contentLength = $value; + if ($name === 'accept-ranges') $acceptRanges = $value; + if ($name === 'content-range') $contentRange = $value; + if ($name === 'cache-control') $cacheControl = $value; + } +} + +if ($contentType !== '') { + header('Content-Type: ' . $contentType); +} else { + header('Content-Type: application/octet-stream'); +} +if ($contentLength !== '') header('Content-Length: ' . $contentLength); +if ($acceptRanges !== '') header('Accept-Ranges: ' . $acceptRanges); +if ($contentRange !== '') header('Content-Range: ' . $contentRange); +if ($cacheControl !== '') { + header('Cache-Control: ' . $cacheControl); +} else { + header('Cache-Control: no-store'); +} + +if ($method !== 'HEAD') { + echo $body; +} +