Replace media proxy with cPanel-compatible PHP implementation

This commit is contained in:
Jage9
2026-02-22 02:43:43 -05:00
parent ef72635b6b
commit 1ec38d19cf

View File

@@ -1,25 +1,40 @@
<?php <?php
/** /*
* Lightweight audio/media proxy for Chat Grid radio streams. * Chat Grid media proxy (compat-focused).
* *
* Usage: * Usage:
* /chgrid/media_proxy.php?url=<urlencoded-remote-stream-url> * /chgrid/media_proxy.php?url=<urlencoded-remote-url>
* *
* Notes: * Goals:
* - Supports upstream http/https URLs. * - Works on older cPanel PHP web handlers.
* - Intended for same-origin browser playback to avoid client-side CORS limits. * - Supports http/https upstreams.
* - Includes simple SSRF protections (scheme checks + private/reserved IP blocking). * - Provides same-origin endpoint for browser playback.
* - Optional host allowlist via env CHGRID_MEDIA_PROXY_ALLOWLIST, comma-separated.
*/ */
header('Access-Control-Allow-Origin: *'); function set_status($code)
header('Access-Control-Allow-Methods: GET, HEAD, OPTIONS'); {
header('Access-Control-Allow-Headers: Range'); $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) function host_matches_suffix($host, $suffix)
{ {
if ($suffix === '') { if ($suffix === '') {
@@ -35,67 +50,46 @@ function host_matches_suffix($host, $suffix)
return substr($host, -strlen($needle)) === $needle; return substr($host, -strlen($needle)) === $needle;
} }
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { header('Access-Control-Allow-Origin: *');
http_response_code(204); 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; exit;
} }
if ($method !== 'GET' && $method !== 'HEAD') {
if (!in_array($_SERVER['REQUEST_METHOD'], ['GET', 'HEAD'], true)) { send_text(405, 'method not allowed');
http_response_code(405);
header('Content-Type: text/plain; charset=utf-8');
echo "method not allowed\n";
exit;
} }
$rawUrl = isset($_GET['url']) ? trim((string) $_GET['url']) : ''; $rawUrl = isset($_GET['url']) ? trim((string) $_GET['url']) : '';
if ($rawUrl === '') { if ($rawUrl === '') {
http_response_code(400); send_text(400, 'missing url query param');
header('Content-Type: text/plain; charset=utf-8');
echo "missing url query param\n";
exit;
} }
$parsed = parse_url($rawUrl); $parsed = parse_url($rawUrl);
if ($parsed === false || !isset($parsed['scheme'], $parsed['host'])) { if ($parsed === false || !isset($parsed['scheme']) || !isset($parsed['host'])) {
http_response_code(400); send_text(400, 'invalid url');
header('Content-Type: text/plain; charset=utf-8');
echo "invalid url\n";
exit;
} }
$scheme = strtolower((string) $parsed['scheme']); $scheme = strtolower((string) $parsed['scheme']);
if ($scheme !== 'http' && $scheme !== 'https') { if ($scheme !== 'http' && $scheme !== 'https') {
http_response_code(400); send_text(400, 'unsupported scheme');
header('Content-Type: text/plain; charset=utf-8');
echo "unsupported scheme\n";
exit;
} }
$host = strtolower((string) $parsed['host']); $host = strtolower((string) $parsed['host']);
if ($host === 'localhost' || $host === '127.0.0.1' || $host === '::1') { if ($host === 'localhost' || $host === '127.0.0.1' || $host === '::1') {
http_response_code(403); send_text(403, 'forbidden host');
header('Content-Type: text/plain; charset=utf-8');
echo "forbidden host\n";
exit;
} }
/** // Optional allowlist env var: CHGRID_MEDIA_PROXY_ALLOWLIST=dropbox.com,example.com
* Optional host allowlist, comma-separated suffixes.
* Example:
* CHGRID_MEDIA_PROXY_ALLOWLIST=dropbox.com,dropboxusercontent.com,stream0.wfmu.org
*/
$allowlistEnv = getenv('CHGRID_MEDIA_PROXY_ALLOWLIST'); $allowlistEnv = getenv('CHGRID_MEDIA_PROXY_ALLOWLIST');
if ($allowlistEnv !== false && trim($allowlistEnv) !== '') { 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; $allowed = false;
foreach ($allowlist as $suffix) { $parts = explode(',', (string) $allowlistEnv);
foreach ($parts as $part) {
$suffix = strtolower(trim((string) $part));
if ($suffix === '') { if ($suffix === '') {
continue; continue;
} }
@@ -105,159 +99,117 @@ if ($allowlistEnv !== false && trim($allowlistEnv) !== '') {
} }
} }
if (!$allowed) { if (!$allowed) {
http_response_code(403); send_text(403, 'host not allowed');
header('Content-Type: text/plain; charset=utf-8');
echo "host not allowed\n";
exit;
} }
} }
// Resolve and block private/reserved targets (basic SSRF guard). // Basic SSRF guard for IPv4.
$resolved = @gethostbynamel($host); $resolved = @gethostbynamel($host);
if ($resolved === false || count($resolved) === 0) { if ($resolved === false || count($resolved) === 0) {
http_response_code(502); send_text(502, 'dns resolution failed');
header('Content-Type: text/plain; charset=utf-8');
echo "dns resolution failed\n";
exit;
} }
foreach ($resolved as $ip) { foreach ($resolved as $ip) {
$ok = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE); $ok = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE);
if ($ok === false) { if ($ok === false) {
http_response_code(403); send_text(403, 'resolved ip not allowed');
header('Content-Type: text/plain; charset=utf-8');
echo "resolved ip not allowed\n";
exit;
} }
} }
if (!function_exists('curl_init')) { if (!function_exists('curl_init')) {
http_response_code(500); send_text(500, 'curl extension is required');
header('Content-Type: text/plain; charset=utf-8');
echo "curl extension is required\n";
exit;
} }
$ch = curl_init($rawUrl); $ch = curl_init();
if ($ch === false) { if (!$ch) {
http_response_code(500); send_text(500, 'proxy init failed');
header('Content-Type: text/plain; charset=utf-8');
echo "proxy init failed\n";
exit;
} }
$upstreamHeaders = []; $requestHeaders = array('Accept: */*', 'Connection: keep-alive');
$statusCode = 200; $range = isset($_SERVER['HTTP_RANGE']) ? trim((string) $_SERVER['HTTP_RANGE']) : '';
$sentContentType = false; if ($range !== '') {
$headersSent = false; $requestHeaders[] = 'Range: ' . $range;
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']);
} }
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); curl_setopt($ch, CURLOPT_NOBODY, true);
} }
/** $response = curl_exec($ch);
* Emit downstream headers exactly once (before body bytes). if ($response === false) {
*/
$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) {
$err = curl_error($ch); $err = curl_error($ch);
curl_close($ch); curl_close($ch);
http_response_code(502); send_text(502, 'upstream fetch failed: ' . $err);
header('Content-Type: text/plain; charset=utf-8');
echo "upstream fetch failed: " . $err . "\n";
exit;
} }
$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); 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;
}