253 lines
8.1 KiB
PHP
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);
|