[ '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( '/(]*\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 []; } }