Files
sbnews/server/app/common/service/article/ArticleImageProxyService.php
2026-06-11 12:15:29 +08:00

242 lines
8.0 KiB
PHP

<?php
namespace app\common\service\article;
use think\Response;
class ArticleImageProxyService
{
private const SOURCE_HOSTS = [
'hkjc_racingnews' => [
'res.hkjc.com',
'racingnews.hkjc.com',
],
'taiwan_lottery' => [
'cdn.taiwanlottery.com.tw',
'www.taiwanlottery.com',
'taiwanlottery.com',
],
];
private const SOURCE_REFERERS = [
'hkjc_racingnews' => 'https://racingnews.hkjc.com/',
'taiwan_lottery' => 'https://www.taiwanlottery.com/',
];
public static function rewriteArticlePayload(array $article): array
{
$ext = self::decodeExt($article['ext'] ?? []);
if (!self::shouldRewrite($ext)) {
return $article;
}
if (!empty($article['image'])) {
$article['image'] = self::buildProxyUrl((string) $article['image'], $ext);
}
if (!empty($article['content'])) {
$article['content'] = preg_replace_callback(
'/(<img\b[^>]*\bsrc=["\'])([^"\']+)(["\'][^>]*>)/i',
static function (array $matches) use ($ext) {
$proxyUrl = self::buildProxyUrl($matches[2] ?? '', $ext);
if ($proxyUrl === '' || $proxyUrl === ($matches[2] ?? '')) {
return $matches[0];
}
return $matches[1]
. htmlspecialchars($proxyUrl, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
. $matches[3];
},
(string) $article['content']
);
}
return $article;
}
public static function proxyRemoteImage(string $url): Response
{
$normalizedUrl = self::normalizeUrl($url);
if ($normalizedUrl === '' || !self::isAllowedRemoteUrl($normalizedUrl)) {
return response('image not found', 404)->header([
'Content-Type' => 'text/plain; charset=utf-8',
'Cache-Control' => 'public, max-age=300',
]);
}
$source = self::detectSourceByUrl($normalizedUrl);
$headers = [
'Accept: image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
'User-Agent: Mozilla/5.0 (compatible; SportEra/1.0)',
];
$referer = self::SOURCE_REFERERS[$source] ?? '';
if ($referer !== '') {
$headers[] = 'Referer: ' . $referer;
}
$ch = curl_init($normalizedUrl);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 3,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_TIMEOUT => 20,
CURLOPT_ENCODING => '',
CURLOPT_HTTPHEADER => $headers,
]);
$body = curl_exec($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$contentType = (string) curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
$effectiveUrl = (string) curl_getinfo($ch, CURLINFO_EFFECTIVE_URL);
$curlError = curl_error($ch);
curl_close($ch);
if ($body === false || $curlError !== '' || $httpCode < 200 || $httpCode >= 300) {
return response('image not found', 404)->header([
'Content-Type' => 'text/plain; charset=utf-8',
'Cache-Control' => 'public, max-age=300',
]);
}
$effectiveUrl = $effectiveUrl !== '' ? $effectiveUrl : $normalizedUrl;
if (!self::isAllowedRemoteUrl($effectiveUrl)) {
return response('image not found', 404)->header([
'Content-Type' => 'text/plain; charset=utf-8',
'Cache-Control' => 'public, max-age=300',
]);
}
$contentType = self::normalizeContentType($contentType, $effectiveUrl);
if (!str_starts_with($contentType, 'image/')) {
return response('image not found', 404)->header([
'Content-Type' => 'text/plain; charset=utf-8',
'Cache-Control' => 'public, max-age=300',
]);
}
return response($body, 200)->header([
'Content-Type' => $contentType,
'Cache-Control' => 'public, max-age=86400',
'Access-Control-Allow-Origin' => '*',
]);
}
private static function shouldRewrite(array $ext): bool
{
$source = (string) ($ext['source'] ?? '');
return isset(self::SOURCE_HOSTS[$source]);
}
private static function buildProxyUrl(string $url, array $ext = []): string
{
$normalizedUrl = self::normalizeUrl($url);
if ($normalizedUrl === '' || self::isLocalProxyUrl($normalizedUrl)) {
return $url;
}
if (!self::isAllowedRemoteUrl($normalizedUrl, $ext)) {
return $url;
}
$path = '/api/article/imageProxy?url=' . rawurlencode($normalizedUrl);
$domain = rtrim((string) request()->domain(), '/');
return $domain !== '' ? $domain . $path : $path;
}
private static function normalizeUrl(string $url): string
{
$url = trim(html_entity_decode($url, ENT_QUOTES | ENT_HTML5, 'UTF-8'));
if ($url === '') {
return '';
}
if (str_starts_with($url, '//')) {
return 'https:' . $url;
}
return $url;
}
private static function isAllowedRemoteUrl(string $url, array $ext = []): bool
{
$parts = parse_url($url);
$scheme = strtolower((string) ($parts['scheme'] ?? ''));
$host = strtolower((string) ($parts['host'] ?? ''));
if ($host === '' || !in_array($scheme, ['http', 'https'], true)) {
return false;
}
$allowedHosts = self::getAllowedHosts($ext);
foreach ($allowedHosts as $allowedHost) {
$allowedHost = strtolower($allowedHost);
if ($host === $allowedHost || str_ends_with($host, '.' . $allowedHost)) {
return true;
}
}
return false;
}
private static function getAllowedHosts(array $ext = []): array
{
$source = (string) ($ext['source'] ?? '');
if ($source !== '' && isset(self::SOURCE_HOSTS[$source])) {
return self::SOURCE_HOSTS[$source];
}
$allHosts = [];
foreach (self::SOURCE_HOSTS as $hosts) {
$allHosts = array_merge($allHosts, $hosts);
}
return array_values(array_unique($allHosts));
}
private static function detectSourceByUrl(string $url): string
{
$parts = parse_url($url);
$host = strtolower((string) ($parts['host'] ?? ''));
foreach (self::SOURCE_HOSTS as $source => $hosts) {
foreach ($hosts as $allowedHost) {
$allowedHost = strtolower($allowedHost);
if ($host === $allowedHost || str_ends_with($host, '.' . $allowedHost)) {
return $source;
}
}
}
return '';
}
private static function isLocalProxyUrl(string $url): bool
{
return str_contains($url, '/api/article/imageProxy?');
}
private static function normalizeContentType(string $contentType, string $url): string
{
$contentType = strtolower(trim(explode(';', $contentType)[0] ?? ''));
if (str_starts_with($contentType, 'image/')) {
return $contentType;
}
$path = strtolower((string) parse_url($url, PHP_URL_PATH));
return match (pathinfo($path, PATHINFO_EXTENSION)) {
'jpg', 'jpeg' => 'image/jpeg',
'png' => 'image/png',
'gif' => 'image/gif',
'webp' => 'image/webp',
'svg' => 'image/svg+xml',
default => 'application/octet-stream',
};
}
private static function decodeExt($ext): array
{
if (is_array($ext)) {
return $ext;
}
if (is_string($ext) && $ext !== '') {
$decoded = json_decode($ext, true);
return is_array($decoded) ? $decoded : [];
}
return [];
}
}