Files
IM-web-new/dist/api/media_proxy.php
T
2026-10-10 21:20:27 +07:00

253 lines
8.1 KiB
PHP

<?php
declare(strict_types=1);
require __DIR__ . '/im_web_bootstrap.php';
const WEB_MEDIA_PROXY_MAX_BYTES = 15 * 1024 * 1024;
const WEB_MEDIA_PROXY_MAX_SECONDS = 25;
function mediaProxyEnv(string $name, string $default = ''): string
{
if (function_exists('webImEnv')) {
return (string)webImEnv($name, $default);
}
$value = getenv($name);
if ($value === false || trim((string)$value) === '') {
return $default;
}
return trim((string)$value);
}
function failMediaProxy(int $status, string $message): never
{
http_response_code($status);
header('Content-Type: text/plain; charset=utf-8');
header('Cache-Control: no-store');
header('X-Content-Type-Options: nosniff');
echo $message;
exit;
}
function mediaProxyAllowedHosts(): array
{
$configured = mediaProxyEnv('JC_WEB_IM_MEDIA_PROXY_HOSTS');
$hosts = array_map(
static fn(string $host): string => strtolower(rtrim(trim($host), '.')),
preg_split('/[,\s]+/', $configured) ?: []
);
$hosts[] = 'zhiyanim.dfpnm.cn';
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(mediaProxyEnv('JC_WEB_IM_MEDIA_CACHE_ROOT', '/var/cache/jc-web-im-media'), '/');
if ($root === '' || !is_dir($root) || !is_writable($root)) {
$root = rtrim(sys_get_temp_dir(), '/') . '/jc-web-im-media';
if (!is_dir($root) && !@mkdir($root, 0755, true) && !is_dir($root)) {
failMediaProxy(503, '媒体缓存目录不可用');
}
}
return $root;
}
function mediaProxyPreferredIps(): array
{
$configured = preg_split('/[,\s]+/', mediaProxyEnv('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);