fix: proxy unstable native image media
This commit is contained in:
Vendored
+205
@@ -0,0 +1,205 @@
|
||||
<?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 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 = webImEnv('JC_WEB_IM_MEDIA_PROXY_HOSTS');
|
||||
$hosts = array_map(
|
||||
static fn(string $host): string => 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 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 = '媒体源无响应';
|
||||
|
||||
for ($attempt = 1; $attempt <= 6 && microtime(true) < $deadline; $attempt++) {
|
||||
$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));
|
||||
curl_setopt_array($curl, [
|
||||
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;
|
||||
},
|
||||
]);
|
||||
|
||||
$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 < 6 && 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);
|
||||
Reference in New Issue
Block a user