Add lightweight PHP media proxy for radio streams

This commit is contained in:
Jage9
2026-02-22 02:18:02 -05:00
parent f05d017307
commit 05722e3fe2
3 changed files with 275 additions and 2 deletions

View File

@@ -130,7 +130,40 @@ sudo /usr/local/cpanel/scripts/restartsrv_httpd
Usage example in Chat Grid:
- `https://bestmidi.com/listen/8000/stream`
## 8) GitHub-based update flow (`bestmidi`)
## 8) PHP media proxy (Dropbox + HTTP stream passthrough)
`deploy/php/media_proxy.php` is a lightweight same-origin proxy for stream URLs.
It is auto-copied to your publish dir by `deploy_client.sh` (and `up.sh`), so after deploy it should be available at:
- `https://bestmidi.com/chgrid/media_proxy.php`
Use in Chat Grid `streamUrl`:
```text
https://bestmidi.com/chgrid/media_proxy.php?url=<urlencoded-upstream-url>
```
Examples:
- Dropbox:
`https://bestmidi.com/chgrid/media_proxy.php?url=https%3A%2F%2Fwww.dropbox.com%2Fscl%2Ffi%2Fa7s3n15bgj043rr54k3n9%2FMario-Hold-Music.mp3%3Frlkey%3Ddfr3dybr7s7nndudag0k8xflc%26dl%3D1`
- HTTP stream:
`https://bestmidi.com/chgrid/media_proxy.php?url=http%3A%2F%2Fstream.rpgamers.net%3A8000%2Frpgn`
Troubleshooting checks:
```bash
curl -I "https://bestmidi.com/chgrid/media_proxy.php?url=https%3A%2F%2Fwww.dropbox.com%2Fscl%2Ffi%2Fa7s3n15bgj043rr54k3n9%2FMario-Hold-Music.mp3%3Frlkey%3Ddfr3dybr7s7nndudag0k8xflc%26dl%3D1"
curl -I "https://bestmidi.com/chgrid/media_proxy.php?url=http%3A%2F%2Fstream.rpgamers.net%3A8000%2Frpgn"
```
Optional hardening:
- Set env var `CHGRID_MEDIA_PROXY_ALLOWLIST` (comma-separated hosts/suffixes) in Apache/PHP-FPM.
- Example: `dropbox.com,dropboxusercontent.com,stream.rpgamers.net`
## 9) GitHub-based update flow (`bestmidi`)
Initial clone (one time):
@@ -171,7 +204,7 @@ Notes:
- For HTTPS GitHub auth, use your GitHub username plus a Personal Access Token (PAT) as the password.
- SSH key passphrases are only used for `git@github.com:` remotes, not `https://` remotes.
## 9) Save GitHub PAT for HTTPS pulls/pushes
## 10) Save GitHub PAT for HTTPS pulls/pushes
Persistent storage (simple, plaintext in `~/.git-credentials`):

235
deploy/php/media_proxy.php Normal file
View File

@@ -0,0 +1,235 @@
<?php
declare(strict_types=1);
/**
* Lightweight audio/media proxy for Chat Grid radio streams.
*
* Usage:
* /chgrid/media_proxy.php?url=<urlencoded-remote-stream-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.
*/
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, HEAD, OPTIONS');
header('Access-Control-Allow-Headers: Range');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(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;
}
$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;
}
$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;
}
$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;
}
$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;
}
/**
* Optional host allowlist, comma-separated suffixes.
* Example:
* CHGRID_MEDIA_PROXY_ALLOWLIST=dropbox.com,dropboxusercontent.com,stream0.wfmu.org
*/
$allowlistEnv = getenv('CHGRID_MEDIA_PROXY_ALLOWLIST');
if ($allowlistEnv !== false && trim($allowlistEnv) !== '') {
$allowlist = array_values(array_filter(array_map(
static fn(string $v): string => strtolower(trim($v)),
explode(',', (string) $allowlistEnv)
)));
$allowed = false;
foreach ($allowlist as $suffix) {
if ($suffix === '') {
continue;
}
if ($host === $suffix || str_ends_with($host, '.' . $suffix)) {
$allowed = true;
break;
}
}
if (!$allowed) {
http_response_code(403);
header('Content-Type: text/plain; charset=utf-8');
echo "host not allowed\n";
exit;
}
}
// Resolve and block private/reserved targets (basic SSRF guard).
$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;
}
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;
}
}
$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;
}
$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 => static function ($curl, $headerLine) use (&$upstreamHeaders, &$statusCode, &$sentContentType): int {
$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_NOBODY, true);
}
/**
* Emit downstream headers exactly once (before body bytes).
*/
$emitHeaders = static function () use (&$headersSent, &$statusCode, &$upstreamHeaders, &$sentContentType, $ch): void {
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, static function ($curl, $chunk) use ($emitHeaders): int {
$emitHeaders();
$len = strlen($chunk);
if ($len > 0) {
echo $chunk;
flush();
}
return $len;
});
$ok = curl_exec($ch);
if ($ok === 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;
}
$emitHeaders();
curl_close($ch);

View File

@@ -5,6 +5,7 @@ REPO_ROOT="${1:-/home/bestmidi/chgrid}"
PUBLISH_DIR="${2:-/home/bestmidi/public_html/chgrid}"
BASE_PATH="${3:-/chgrid/}"
CLIENT_DIR="$REPO_ROOT/client"
PHP_PROXY_DIR="$REPO_ROOT/deploy/php"
if [[ ! -d "$CLIENT_DIR" ]]; then
echo "error: client directory not found: $CLIENT_DIR" >&2
@@ -23,5 +24,9 @@ VITE_BASE_PATH="$BASE_PATH" npm run build
mkdir -p "$PUBLISH_DIR"
rsync -a --delete dist/ "$PUBLISH_DIR/"
if [[ -d "$PHP_PROXY_DIR" ]]; then
rsync -a "$PHP_PROXY_DIR/" "$PUBLISH_DIR/"
fi
echo "client deploy complete: $PUBLISH_DIR"
echo "client base path: $BASE_PATH"