diff --git a/deploy/README.md b/deploy/README.md index 07c6fdc..d2d6a6a 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -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= +``` + +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`): diff --git a/deploy/php/media_proxy.php b/deploy/php/media_proxy.php new file mode 100644 index 0000000..70f3f32 --- /dev/null +++ b/deploy/php/media_proxy.php @@ -0,0 +1,235 @@ + + * + * 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); diff --git a/deploy/scripts/deploy_client.sh b/deploy/scripts/deploy_client.sh index f5b36bc..519ce54 100755 --- a/deploy/scripts/deploy_client.sh +++ b/deploy/scripts/deploy_client.sh @@ -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"