strtolower(rtrim(trim($host), '.')), preg_split('/[,\s]+/', $configured) ?: [] ); return array_values(array_unique(array_filter($hosts))); } function validatedMediaProxyUrl(string $url): array { if ($url === '' || strlen($url) > 2048 || filter_var($url, FILTER_VALIDATE_URL) === false) { failMediaProxy(400, '媒体地址不合法'); } $parts = parse_url($url); $scheme = strtolower((string)($parts['scheme'] ?? '')); $host = strtolower(rtrim((string)($parts['host'] ?? ''), '.')); $port = isset($parts['port']) ? (int)$parts['port'] : 443; if ( $scheme !== 'https' || $host === '' || $port !== 443 || isset($parts['user']) || isset($parts['pass']) || !in_array($host, mediaProxyAllowedHosts(), true) ) { failMediaProxy(403, '媒体来源不允许代理'); } return [$url, $host]; } function mediaProxyCacheRoot(): string { $root = rtrim(webImEnv('JC_WEB_IM_MEDIA_CACHE_ROOT', '/var/cache/jc-web-im-media'), '/'); if ($root === '' || !is_dir($root) || !is_writable($root)) { failMediaProxy(503, '媒体缓存目录不可用'); } return $root; } function mediaProxyPreferredIps(): array { $configured = preg_split('/[,\s]+/', webImEnv('JC_WEB_IM_MEDIA_PROXY_IPS')) ?: []; $ips = []; foreach ($configured as $ip) { $ip = trim($ip); if ( $ip !== '' && filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) !== false ) { $ips[$ip] = $ip; } } return array_values($ips); } function mediaProxyMime(string $path): string { $finfo = finfo_open(FILEINFO_MIME_TYPE); $mime = $finfo !== false ? (string)(finfo_file($finfo, $path) ?: '') : ''; if ($finfo !== false) { finfo_close($finfo); } return $mime; } function fetchMediaProxyImage(string $url, string $host, string $cacheFile): void { $deadline = microtime(true) + WEB_MEDIA_PROXY_MAX_SECONDS; $lastError = '媒体源无响应'; $preferredIps = mediaProxyPreferredIps(); $targets = array_merge($preferredIps, array_fill(0, max(2, 6 - count($preferredIps)), null)); foreach ($targets as $index => $preferredIp) { if (microtime(true) >= $deadline) { break; } $attempt = $index + 1; $temporaryFile = $cacheFile . '.tmp.' . getmypid() . '.' . $attempt; $stream = @fopen($temporaryFile, 'x+b'); if ($stream === false) { $lastError = '无法创建媒体缓存文件'; break; } $bytes = 0; $tooLarge = false; $curl = curl_init($url); if ($curl === false) { fclose($stream); @unlink($temporaryFile); $lastError = '无法初始化媒体请求'; break; } $remainingMs = max(1000, (int)floor(($deadline - microtime(true)) * 1000)); $curlOptions = [ CURLOPT_RETURNTRANSFER => false, CURLOPT_FOLLOWLOCATION => false, CURLOPT_CONNECTTIMEOUT_MS => min(2500, $remainingMs), CURLOPT_TIMEOUT_MS => min(12000, $remainingMs), CURLOPT_NOSIGNAL => true, CURLOPT_DNS_CACHE_TIMEOUT => 0, CURLOPT_FRESH_CONNECT => true, CURLOPT_FORBID_REUSE => true, CURLOPT_PROTOCOLS => CURLPROTO_HTTPS, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_USERAGENT => 'JC-Web-IM-Media-Proxy/1.0', CURLOPT_HTTPHEADER => ['Accept: image/avif,image/webp,image/*,*/*;q=0.8'], CURLOPT_WRITEFUNCTION => static function ($handle, string $chunk) use ($stream, &$bytes, &$tooLarge): int { $length = strlen($chunk); if ($bytes + $length > WEB_MEDIA_PROXY_MAX_BYTES) { $tooLarge = true; return 0; } $written = fwrite($stream, $chunk); if ($written === false) { return 0; } $bytes += $written; return $written; }, ]; if (is_string($preferredIp) && $preferredIp !== '') { $curlOptions[CURLOPT_RESOLVE] = [$host . ':443:' . $preferredIp]; } curl_setopt_array($curl, $curlOptions); $result = curl_exec($curl); $status = (int)curl_getinfo($curl, CURLINFO_RESPONSE_CODE); $curlError = curl_error($curl); curl_close($curl); fflush($stream); fclose($stream); $mime = $bytes > 0 && is_file($temporaryFile) ? mediaProxyMime($temporaryFile) : ''; if ($result !== false && $status >= 200 && $status < 300 && !$tooLarge && str_starts_with($mime, 'image/')) { if (!@rename($temporaryFile, $cacheFile)) { @unlink($temporaryFile); $lastError = '媒体缓存保存失败'; break; } @chmod($cacheFile, 0644); return; } @unlink($temporaryFile); $lastError = $tooLarge ? '图片超过代理大小限制' : ($curlError !== '' ? $curlError : ($status > 0 ? '媒体源返回 HTTP ' . $status : '媒体源无响应')); if ($attempt < count($targets) && microtime(true) < $deadline) { usleep(150000); } } error_log(sprintf('media_proxy fetch failed host=%s error=%s', $host, $lastError)); failMediaProxy(502, '图片源暂时不可用,请稍后重试'); } function serveMediaProxyImage(string $cacheFile, string $etag): never { if (!is_file($cacheFile)) { failMediaProxy(404, '媒体缓存不存在'); } $mime = mediaProxyMime($cacheFile); if (!str_starts_with($mime, 'image/')) { failMediaProxy(415, '媒体内容不是图片'); } header('ETag: "' . $etag . '"'); header('Cache-Control: public, max-age=86400, immutable'); header('Content-Type: ' . $mime); header('Content-Length: ' . (string)filesize($cacheFile)); header('Content-Disposition: inline'); header('X-Content-Type-Options: nosniff'); if (trim((string)($_SERVER['HTTP_IF_NONE_MATCH'] ?? ''), ' W/') === '"' . $etag . '"') { http_response_code(304); exit; } readfile($cacheFile); exit; } [$url, $host] = validatedMediaProxyUrl(trim((string)($_GET['url'] ?? ''))); $cacheRoot = mediaProxyCacheRoot(); $cacheKey = hash('sha256', $url); $cacheFile = $cacheRoot . '/' . $cacheKey . '.image'; $lockFile = $cacheRoot . '/' . $cacheKey . '.lock'; if (!is_file($cacheFile)) { $lock = @fopen($lockFile, 'c'); if ($lock === false || !flock($lock, LOCK_EX)) { if (is_resource($lock)) { fclose($lock); } failMediaProxy(503, '媒体缓存暂时忙碌'); } if (!is_file($cacheFile)) { fetchMediaProxyImage($url, $host, $cacheFile); } flock($lock, LOCK_UN); fclose($lock); } serveMediaProxyImage($cacheFile, $cacheKey);