feat(ImageProxy): 增加WebP转换功能并优化缓存处理

- 增加对WebP格式的转换支持,提升图片加载性能
- 优化缓存路径生成逻辑,增加对URL的兼容性处理
- 改进图片验证逻辑,支持更多图片格式
- 增加缓存元数据文件,确保缓存内容的正确性
- 改进错误日志记录,提供更详细的错误信息
This commit is contained in:
Snowz 2025-05-23 20:24:49 +08:00
parent 309b0eddf1
commit 5bbadf3671

View File

@ -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->saveToCache($cachePath, $dataToProcess, $mimeTypeToUse);
$this->outputImage($dataToProcess, $mimeTypeToUse);
// 输出图片
$this->outputImage($imageData, $this->getImageType($url));
} 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 {
$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/gif' => "GIF", // Covers GIF87a and GIF89a
'image/bmp' => "BM",
'image/svg+xml' => '<?xml'
];
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, '<?xml') === 0 || stripos($trimmedDataStart, '<svg') === 0) {
if (stripos($data, '<svg') !== false && stripos($data, '</svg>') !== 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() {
// Check if headers already sent to avoid warning
if (!headers_sent()) {
header("HTTP/1.1 500 Internal Server Error");
header("Content-type: image/jpeg");
// 输出一个1x1的透明图片
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) {
try {
$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);
// 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}");
}
}
}