ImageProxy/ImageProxy.php
Snowz 39d21d3a6a feat(ImageProxy): 添加域名白名单和日志管理功能
- 在ImageProxy中添加了域名白名单配置,支持精确和子域名匹配,增强安全性。
- 实现了日志文件的自动清理和轮转功能,限制日志文件大小、保留时间和数量,优化日志管理。
- 更新README文档,详细说明了新功能和配置方法。
2025-05-28 02:04:34 +08:00

526 lines
18 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',
'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, '<?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;
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}");
}
}
}