From 5bbadf3671d21a9504d540b9d1c300b85f0098b4 Mon Sep 17 00:00:00 2001 From: Snowz <372492339@qq.com> Date: Fri, 23 May 2025 20:24:49 +0800 Subject: [PATCH] =?UTF-8?q?feat(ImageProxy):=20=E5=A2=9E=E5=8A=A0WebP?= =?UTF-8?q?=E8=BD=AC=E6=8D=A2=E5=8A=9F=E8=83=BD=E5=B9=B6=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E7=BC=93=E5=AD=98=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 增加对WebP格式的转换支持,提升图片加载性能 - 优化缓存路径生成逻辑,增加对URL的兼容性处理 - 改进图片验证逻辑,支持更多图片格式 - 增加缓存元数据文件,确保缓存内容的正确性 - 改进错误日志记录,提供更详细的错误信息 --- ImageProxy.php | 258 +++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 209 insertions(+), 49 deletions(-) diff --git a/ImageProxy.php b/ImageProxy.php index 7dd4572..9995ea3 100644 --- a/ImageProxy.php +++ b/ImageProxy.php @@ -55,7 +55,7 @@ class ImageProxy { private function initLogger() { $logDir = $this->config['cache_dir'] . '/logs'; if (!file_exists($logDir)) { - mkdir($logDir, 0755, true); + mkdir($logDir, 0777, true); } $this->logger = new Logger($logDir); } @@ -74,27 +74,36 @@ class ImageProxy { $cachePath = $this->getCachePath($url); - // 检查缓存 if ($this->isCached($cachePath)) { $this->serveFromCache($cachePath); return; } - // 获取远程图片 $imageData = $this->fetchRemoteImage($url); - // 验证图片数据 - if (!$this->validateImageData($imageData)) { - throw new Exception('Invalid image data'); + $actualOriginalMimeType = null; + if (!$this->validateImageData($imageData, $actualOriginalMimeType) || $actualOriginalMimeType === null) { + $extensionBasedType = $this->getImageType($url); // For logging + $this->logger->error("Validation failed for image data from URL: $url. Detected extension type: $extensionBasedType"); + throw new Exception('Invalid or unsupported image data from URL'); } - // 保存到缓存 - $this->saveToCache($cachePath, $imageData); + $dataToProcess = $imageData; + $mimeTypeToUse = $actualOriginalMimeType; + + // Attempt conversion to WebP if applicable + $convertedWebPData = $this->convertToWebP($dataToProcess, $actualOriginalMimeType); + if ($convertedWebPData) { + $dataToProcess = $convertedWebPData; + $mimeTypeToUse = 'image/webp'; + $this->logger->info("Successfully converted to WebP: $url"); + } - // 输出图片 - $this->outputImage($imageData, $this->getImageType($url)); + $this->saveToCache($cachePath, $dataToProcess, $mimeTypeToUse); + $this->outputImage($dataToProcess, $mimeTypeToUse); + } catch (Exception $e) { - $this->logger->error('Image processing error: ' . $e->getMessage()); + $this->logger->error('Image processing error for URL ' . $url . ': ' . $e->getMessage() . ' Trace: ' . $e->getTraceAsString()); $this->outputError(); } } @@ -118,15 +127,27 @@ class ImageProxy { */ private function getCachePath(string $url): string { $parsedUrl = parse_url($url); - $domain = $parsedUrl['host']; - $path = $parsedUrl['path']; + $domain = $parsedUrl['host'] ?? 'unknown_host'; // Add fallback for host + $path = $parsedUrl['path'] ?? '/'; - $cacheSubDir = $this->config['cache_dir'] . '/' . $domain; + $cacheSubDir = $this->config['cache_dir'] . '/' . preg_replace('/[^a-zA-Z0-9_\-\.]/', '_', $domain); // Sanitize domain for directory name if (!file_exists($cacheSubDir)) { mkdir($cacheSubDir, 0755, true); } - return $cacheSubDir . '/' . md5($url) . '.' . pathinfo($path, PATHINFO_EXTENSION); + $extension = pathinfo($path, PATHINFO_EXTENSION); + if (empty($extension) && isset($parsedUrl['query'])) { // Try to get extension from query if path has none + parse_str($parsedUrl['query'], $queryParams); + foreach ($queryParams as $val) { + $queryExt = pathinfo($val, PATHINFO_EXTENSION); + if (!empty($queryExt) && isset(self::SUPPORTED_TYPES[strtolower($queryExt)])) { + $extension = $queryExt; + break; + } + } + } + $fileName = md5($url) . '.' . (!empty($extension) ? $extension : 'img'); // Fallback extension + return $cacheSubDir . '/' . $fileName; } /** @@ -136,7 +157,9 @@ class ImageProxy { * @return bool */ private function isCached(string $cachePath): bool { - return file_exists($cachePath) && + $metaPath = $cachePath . '.meta'; + return file_exists($cachePath) && + file_exists($metaPath) && // Also check for meta file (time() - filemtime($cachePath)) < 86400; // 缓存24小时 } @@ -146,8 +169,36 @@ class ImageProxy { * @param string $cachePath 缓存路径 */ private function serveFromCache(string $cachePath) { - $type = $this->getImageType($cachePath); + $metaPath = $cachePath . '.meta'; + $type = null; + + if (file_exists($metaPath)) { + $type = trim(file_get_contents($metaPath)); + } else { + $this->logger->error("Cache meta file missing for: $cachePath. Attempting to determine type from data."); + if (file_exists($cachePath)) { + $imageData = file_get_contents($cachePath); + if ($imageData !== false && $this->validateImageData($imageData, $type) && $type !== null) { + file_put_contents($metaPath, $type); // Recreate meta + $this->logger->info("Recreated meta file for: $cachePath with type: $type"); + } else { + $this->logger->error("Could not determine type for cached file (meta missing): $cachePath"); + $type = $this->getImageType($cachePath); // Fallback to extension of cache path itself + } + } else { + $this->logger->error("Cache file missing during serveFromCache (isCached check failed?): $cachePath"); + $this->outputError(); + return; + } + } + + if (empty($type)) { + $this->logger->error("Unknown content type for cache file: $cachePath"); + $type = 'application/octet-stream'; // Generic fallback if type determination failed + } + header("Content-type: " . $type); + header("Cache-Control: public, max-age=86400"); readfile($cachePath); } @@ -165,19 +216,23 @@ class ImageProxy { CURLOPT_MAXREDIRS => $this->config['max_redirects'], CURLOPT_CONNECTTIMEOUT => $this->config['connect_timeout'], CURLOPT_TIMEOUT => $this->config['timeout'], - CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_SSL_VERIFYPEER => true, // Consider making this configurable for dev environments CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_USERAGENT => $this->config['user_agent'], - CURLOPT_REFERER => parse_url($url, PHP_URL_SCHEME) . '://' . parse_url($url, PHP_URL_HOST) . '/', - CURLOPT_HTTPHEADER => ['Accept: image/*'] + CURLOPT_REFERER => parse_url($url, PHP_URL_SCHEME) . '://' . (parse_url($url, PHP_URL_HOST) ?? ''), + CURLOPT_HTTPHEADER => ['Accept: image/*,application/octet-stream'] // Accept more types ]); $data = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $curlError = curl_error($ch); curl_close($ch); + if ($curlError) { + throw new Exception('cURL error fetching image: ' . $curlError . ' from URL: ' . $url); + } if ($httpCode !== 200 || empty($data)) { - throw new Exception('Failed to fetch image: HTTP ' . $httpCode); + throw new Exception('Failed to fetch image: HTTP ' . $httpCode . ' from URL: ' . $url); } return $data; @@ -187,40 +242,126 @@ class ImageProxy { * 验证图片数据 * * @param string $data 图片数据 + * @param string|null &$outMimeType 输出参数,检测到的MIME类型 * @return bool */ - private function validateImageData(string $data): bool { + private function validateImageData(string $data, ?string &$outMimeType = null): bool { if (empty($data)) { + $outMimeType = null; return false; } - // 检查文件头 - $headers = [ + // Order of checks can matter. WebP is quite specific. + if (strlen($data) >= 12 && substr($data, 0, 4) === 'RIFF' && substr($data, 8, 4) === 'WEBP') { + $outMimeType = 'image/webp'; + return true; + } + + $signatures = [ 'image/jpeg' => "\xFF\xD8\xFF", - 'image/png' => "\x89PNG\r\n\x1a\n", - 'image/gif' => "GIF", - 'image/webp' => "RIFF....WEBP", - 'image/bmp' => "BM", - 'image/svg+xml' => ' "\x89PNG\r\n\x1a\n", + 'image/gif' => "GIF", // Covers GIF87a and GIF89a + 'image/bmp' => "BM", ]; - foreach ($headers as $type => $header) { - if (strpos($data, $header) === 0) { + foreach ($signatures as $mime => $sig) { + if (substr($data, 0, strlen($sig)) === $sig) { + $outMimeType = $mime; return true; } } - + + // SVG check: more complex as it's XML-based. + $trimmedDataStart = substr($data, 0, 256); // Check a small portion from the start for performance + if (stripos($trimmedDataStart, '') !== false) { // Basic check for opening and closing svg tag + $outMimeType = 'image/svg+xml'; + return true; + } + } + + $outMimeType = null; return false; } + /** + * 转换图片为WebP格式 + * + * @param string $imageData 原始图片数据 + * @param string $originalMimeType 原始MIME类型 + * @return string|null WebP图片数据或null(如果转换失败或不支持) + */ + private function convertToWebP(string $imageData, string $originalMimeType): ?string { + if (!function_exists('imagewebp') || !function_exists('imagecreatefromstring')) { + $this->logger->info('GD WebP support (imagewebp or imagecreatefromstring) not available.'); + return null; + } + + // Do not convert these types or if original is already WebP + if (in_array($originalMimeType, ['image/webp', 'image/gif', 'image/svg+xml'])) { + return null; + } + + $sourceImage = @imagecreatefromstring($imageData); + if (!$sourceImage) { + $this->logger->error('imagecreatefromstring failed for ' . $originalMimeType . ' while attempting WebP conversion.'); + return null; + } + + // Preserve alpha transparency for PNGs + if ($originalMimeType === 'image/png') { + imagealphablending($sourceImage, false); + imagesavealpha($sourceImage, true); + } + + ob_start(); + $success = imagewebp($sourceImage, null, 80); // Quality 80 + $webpData = ob_get_clean(); + imagedestroy($sourceImage); + + if (!$success || empty($webpData)) { + $this->logger->error('imagewebp conversion failed for ' . $originalMimeType); + return null; + } + + // Minimal validation of the converted WebP data + $validatedConvertedMimeType = null; + if ($this->validateImageData($webpData, $validatedConvertedMimeType) && $validatedConvertedMimeType === 'image/webp') { + return $webpData; + } else { + $this->logger->error('Converted WebP data failed validation. Original type: ' . $originalMimeType); + return null; + } + } + /** * 保存到缓存 * * @param string $cachePath 缓存路径 * @param string $data 图片数据 + * @param string $actualMimeType 图片的实际MIME类型 */ - private function saveToCache(string $cachePath, string $data) { - file_put_contents($cachePath, $data); + private function saveToCache(string $cachePath, string $data, string $actualMimeType) { + $parentDir = dirname($cachePath); + if (!file_exists($parentDir)) { + // This should ideally be handled by getCachePath, but as a safeguard: + if (!mkdir($parentDir, 0755, true) && !is_dir($parentDir)) { + $this->logger->error("Failed to create cache subdirectory: $parentDir"); + throw new Exception("Failed to create cache subdirectory: $parentDir"); + } + } + + if (file_put_contents($cachePath, $data) === false) { + $this->logger->error("Failed to write cache file: $cachePath"); + throw new Exception("Failed to write cache file: $cachePath"); + } + if (file_put_contents($cachePath . '.meta', $actualMimeType) === false) { + $this->logger->error("Failed to write meta file for: $cachePath . Mime: $actualMimeType"); + // If meta fails, cache is inconsistent. Clean up. + unlink($cachePath); + throw new Exception("Failed to write cache meta file: $cachePath.meta"); + } + $this->logger->info("Saved to cache: $cachePath, Type: $actualMimeType"); } /** @@ -236,23 +377,26 @@ class ImageProxy { } /** - * 获取图片类型 + * 获取图片类型 (基于文件扩展名) * - * @param string $url 图片URL + * @param string $urlOrPath 图片URL或路径 * @return string */ - private function getImageType(string $url): string { - $ext = strtolower(pathinfo($url, PATHINFO_EXTENSION)); - return self::SUPPORTED_TYPES[$ext] ?? 'image/jpeg'; + private function getImageType(string $urlOrPath): string { + $ext = strtolower(pathinfo($urlOrPath, PATHINFO_EXTENSION)); + return self::SUPPORTED_TYPES[$ext] ?? 'application/octet-stream'; // Fallback type } /** * 输出错误信息 */ private function outputError() { - header("HTTP/1.1 500 Internal Server Error"); - header("Content-type: image/jpeg"); - // 输出一个1x1的透明图片 + // Check if headers already sent to avoid warning + if (!headers_sent()) { + header("HTTP/1.1 500 Internal Server Error"); + header("Content-type: image/jpeg"); // Keep consistent error image type + } + // Output a 1x1 transparent GIF as error image echo base64_decode('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'); } } @@ -265,6 +409,10 @@ class Logger { public function __construct(string $logDir) { $this->logDir = $logDir; + + if (!file_exists($this->logDir)) { + mkdir($this->logDir, 0777, true); + } } public function error(string $message) { @@ -276,11 +424,23 @@ class Logger { } private function log(string $level, string $message) { - $date = date('Y-m-d'); - $time = date('Y-m-d H:i:s'); - $logFile = $this->logDir . "/{$date}.log"; - - $logMessage = "[{$time}] [{$level}] {$message}\n"; - file_put_contents($logFile, $logMessage, FILE_APPEND); + try { + $date = date('Y-m-d'); + $time = date('Y-m-d H:i:s'); + $logFile = $this->logDir . "/{$date}.log"; + + $logMessage = "[{$time}] [{$level}] {$message}\n"; + // Ensure log directory is writable, though constructor should handle creation + if (!is_writable($this->logDir) && !mkdir($this->logDir, 0777, true) && !is_dir($this->logDir)) { + // Cannot write to log, perhaps output to error_log as fallback + error_log("Logger Error: Directory {$this->logDir} is not writable. Message: {$logMessage}"); + return; + } + if (file_put_contents($logFile, $logMessage, FILE_APPEND) === false) { + error_log("Logger Error: Failed to write to log file {$logFile}. Message: {$logMessage}"); + } + } catch (Exception $e) { + error_log("Logger Exception: " . $e->getMessage() . " Original log message: {$message}"); + } } -} \ No newline at end of file +} \ No newline at end of file