'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', 'allowed_domains' => [] // 添加白名单域名配置 ]; 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 { if (!filter_var($url, FILTER_VALIDATE_URL) || !preg_match('/^https?:\/\//i', $url)) { return false; } // 检查域名是否在白名单中 $parsedUrl = parse_url($url); $host = $parsedUrl['host'] ?? ''; // 如果白名单为空,则允许所有域名 if (empty($this->config['allowed_domains'])) { return true; } // 检查域名是否在白名单中 foreach ($this->config['allowed_domains'] as $allowedDomain) { if (strcasecmp($host, $allowedDomain) === 0 || preg_match('/\.' . preg_quote($allowedDomain, '/') . '$/', $host)) { return true; } } $this->logger->error("Domain not in whitelist: $host"); return false; } /** * 获取缓存路径 * * @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, '') !== 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; private const MAX_LOG_SIZE = 10 * 1024 * 1024; // 10MB private const MAX_LOG_AGE = 30 * 24 * 60 * 60; // 30天 private const MAX_LOGS = 10; // 最多保留10个日志文件 public function __construct(string $logDir) { $this->logDir = $logDir; if (!file_exists($this->logDir)) { mkdir($this->logDir, 0777, true); } // 清理旧日志 $this->cleanupOldLogs(); } public function error(string $message) { $this->log('ERROR', $message); } public function info(string $message) { $this->log('INFO', $message); } private function cleanupOldLogs() { try { $files = glob($this->logDir . '/*.log'); if (!$files) { return; } // 按修改时间排序 usort($files, function($a, $b) { return filemtime($b) - filemtime($a); }); // 删除超过保留时间的日志 foreach ($files as $file) { if (time() - filemtime($file) > self::MAX_LOG_AGE) { unlink($file); } } // 如果日志文件数量超过限制,删除最旧的 $files = glob($this->logDir . '/*.log'); if (count($files) > self::MAX_LOGS) { usort($files, function($a, $b) { return filemtime($a) - filemtime($b); }); $filesToDelete = array_slice($files, 0, count($files) - self::MAX_LOGS); foreach ($filesToDelete as $file) { unlink($file); } } } catch (Exception $e) { error_log("Logger cleanup error: " . $e->getMessage()); } } private function rotateLogIfNeeded(string $logFile) { if (!file_exists($logFile)) { return; } if (filesize($logFile) >= self::MAX_LOG_SIZE) { $timestamp = date('Y-m-d_H-i-s'); $newName = $logFile . '.' . $timestamp; rename($logFile, $newName); } } 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"; // 检查并轮转日志 $this->rotateLogIfNeeded($logFile); $logMessage = "[{$time}] [{$level}] {$message}\n"; // 确保日志目录可写 if (!is_writable($this->logDir) && !mkdir($this->logDir, 0777, true) && !is_dir($this->logDir)) { 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}"); } } }