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

446 lines
16 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
/**
* 图片代理与缓存系统
*
* 提供安全的图片代理和缓存服务,支持多种图片格式,具有完善的错误处理和日志记录功能
*/
class ImageProxy {
// 支持的图片类型
private const SUPPORTED_TYPES = [
'gif' => 'image/gif',
'jpeg' => 'image/jpeg',
'jpg' => 'image/jpeg',
'jpe' => 'image/jpeg',
'png' => 'image/png',
'webp' => 'image/webp',
'bmp' => 'image/bmp',
'svg' => 'image/svg+xml'
];
// 默认配置
private const DEFAULT_CONFIG = [
'cache_dir' => 'cache',
'timeout' => 30,
'connect_timeout' => 15,
'max_redirects' => 5,
'user_agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
];
private $config;
private $logger;
/**
* 构造函数
*
* @param array $config 配置参数
*/
public function __construct(array $config = []) {
$this->config = array_merge(self::DEFAULT_CONFIG, $config);
$this->initCacheDir();
$this->initLogger();
}
/**
* 初始化缓存目录
*/
private function initCacheDir() {
if (!file_exists($this->config['cache_dir'])) {
mkdir($this->config['cache_dir'], 0755, true);
}
}
/**
* 初始化日志记录器
*/
private function initLogger() {
$logDir = $this->config['cache_dir'] . '/logs';
if (!file_exists($logDir)) {
mkdir($logDir, 0777, true);
}
$this->logger = new Logger($logDir);
}
/**
* 处理图片请求
*
* @param string $url 图片URL
* @return void
*/
public function processRequest(string $url) {
try {
if (!$this->validateUrl($url)) {
throw new Exception('Invalid URL format');
}
$cachePath = $this->getCachePath($url);
if ($this->isCached($cachePath)) {
$this->serveFromCache($cachePath);
return;
}
$imageData = $this->fetchRemoteImage($url);
$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');
}
$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);
} catch (Exception $e) {
$this->logger->error('Image processing error for URL ' . $url . ': ' . $e->getMessage() . ' Trace: ' . $e->getTraceAsString());
$this->outputError();
}
}
/**
* 验证URL格式
*
* @param string $url URL地址
* @return bool
*/
private function validateUrl(string $url): bool {
return filter_var($url, FILTER_VALIDATE_URL) !== false &&
preg_match('/^https?:\/\//i', $url);
}
/**
* 获取缓存路径
*
* @param string $url 图片URL
* @return string
*/
private function getCachePath(string $url): string {
$parsedUrl = parse_url($url);
$domain = $parsedUrl['host'] ?? 'unknown_host'; // Add fallback for host
$path = $parsedUrl['path'] ?? '/';
$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);
}
$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;
}
/**
* 检查图片是否已缓存
*
* @param string $cachePath 缓存路径
* @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小时
}
/**
* 从缓存输出图片
*
* @param string $cachePath 缓存路径
*/
private function serveFromCache(string $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);
}
/**
* 获取远程图片
*
* @param string $url 图片URL
* @return string
*/
private function fetchRemoteImage(string $url): string {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => $this->config['max_redirects'],
CURLOPT_CONNECTTIMEOUT => $this->config['connect_timeout'],
CURLOPT_TIMEOUT => $this->config['timeout'],
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/*,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 . ' from URL: ' . $url);
}
return $data;
}
/**
* 验证图片数据
*
* @param string $data 图片数据
* @param string|null &$outMimeType 输出参数检测到的MIME类型
* @return bool
*/
private function validateImageData(string $data, ?string &$outMimeType = null): bool {
if (empty($data)) {
$outMimeType = null;
return false;
}
// 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", // Covers GIF87a and GIF89a
'image/bmp' => "BM",
];
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, 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");
}
/**
* 输出图片
*
* @param string $data 图片数据
* @param string $type 图片类型
*/
private function outputImage(string $data, string $type) {
header("Content-type: " . $type);
header("Cache-Control: public, max-age=86400");
echo $data;
}
/**
* 获取图片类型 (基于文件扩展名)
*
* @param string $urlOrPath 图片URL或路径
* @return string
*/
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"); // Keep consistent error image type
}
// Output a 1x1 transparent GIF as error image
echo base64_decode('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7');
}
}
/**
* 简单的日志记录类
*/
class Logger {
private $logDir;
public function __construct(string $logDir) {
$this->logDir = $logDir;
if (!file_exists($this->logDir)) {
mkdir($this->logDir, 0777, true);
}
}
public function error(string $message) {
$this->log('ERROR', $message);
}
public function info(string $message) {
$this->log('INFO', $message);
}
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";
// 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}");
}
}
}