2025-04-14 17:13:00 +08:00
|
|
|
|
<?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,
|
2025-05-28 02:04:34 +08:00
|
|
|
|
'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' => [] // 添加白名单域名配置
|
2025-04-14 17:13:00 +08:00
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
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)) {
|
2025-05-23 20:24:49 +08:00
|
|
|
|
mkdir($logDir, 0777, true);
|
2025-04-14 17:13:00 +08:00
|
|
|
|
}
|
|
|
|
|
$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);
|
|
|
|
|
|
2025-05-23 20:24:49 +08:00
|
|
|
|
$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');
|
2025-04-14 17:13:00 +08:00
|
|
|
|
}
|
|
|
|
|
|
2025-05-23 20:24:49 +08:00
|
|
|
|
$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");
|
|
|
|
|
}
|
2025-04-14 17:13:00 +08:00
|
|
|
|
|
2025-05-23 20:24:49 +08:00
|
|
|
|
$this->saveToCache($cachePath, $dataToProcess, $mimeTypeToUse);
|
|
|
|
|
$this->outputImage($dataToProcess, $mimeTypeToUse);
|
|
|
|
|
|
2025-04-14 17:13:00 +08:00
|
|
|
|
} catch (Exception $e) {
|
2025-05-23 20:24:49 +08:00
|
|
|
|
$this->logger->error('Image processing error for URL ' . $url . ': ' . $e->getMessage() . ' Trace: ' . $e->getTraceAsString());
|
2025-04-14 17:13:00 +08:00
|
|
|
|
$this->outputError();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 验证URL格式
|
|
|
|
|
*
|
|
|
|
|
* @param string $url URL地址
|
|
|
|
|
* @return bool
|
|
|
|
|
*/
|
|
|
|
|
private function validateUrl(string $url): bool {
|
2025-05-28 02:04:34 +08:00
|
|
|
|
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;
|
2025-04-14 17:13:00 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 获取缓存路径
|
|
|
|
|
*
|
|
|
|
|
* @param string $url 图片URL
|
|
|
|
|
* @return string
|
|
|
|
|
*/
|
|
|
|
|
private function getCachePath(string $url): string {
|
|
|
|
|
$parsedUrl = parse_url($url);
|
2025-05-23 20:24:49 +08:00
|
|
|
|
$domain = $parsedUrl['host'] ?? 'unknown_host'; // Add fallback for host
|
|
|
|
|
$path = $parsedUrl['path'] ?? '/';
|
2025-04-14 17:13:00 +08:00
|
|
|
|
|
2025-05-23 20:24:49 +08:00
|
|
|
|
$cacheSubDir = $this->config['cache_dir'] . '/' . preg_replace('/[^a-zA-Z0-9_\-\.]/', '_', $domain); // Sanitize domain for directory name
|
2025-04-14 17:13:00 +08:00
|
|
|
|
if (!file_exists($cacheSubDir)) {
|
|
|
|
|
mkdir($cacheSubDir, 0755, true);
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-23 20:24:49 +08:00
|
|
|
|
$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;
|
2025-04-14 17:13:00 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 检查图片是否已缓存
|
|
|
|
|
*
|
|
|
|
|
* @param string $cachePath 缓存路径
|
|
|
|
|
* @return bool
|
|
|
|
|
*/
|
|
|
|
|
private function isCached(string $cachePath): bool {
|
2025-05-23 20:24:49 +08:00
|
|
|
|
$metaPath = $cachePath . '.meta';
|
|
|
|
|
return file_exists($cachePath) &&
|
|
|
|
|
file_exists($metaPath) && // Also check for meta file
|
2025-04-14 17:13:00 +08:00
|
|
|
|
(time() - filemtime($cachePath)) < 86400; // 缓存24小时
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 从缓存输出图片
|
|
|
|
|
*
|
|
|
|
|
* @param string $cachePath 缓存路径
|
|
|
|
|
*/
|
|
|
|
|
private function serveFromCache(string $cachePath) {
|
2025-05-23 20:24:49 +08:00
|
|
|
|
$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
|
|
|
|
|
}
|
|
|
|
|
|
2025-04-14 17:13:00 +08:00
|
|
|
|
header("Content-type: " . $type);
|
2025-05-23 20:24:49 +08:00
|
|
|
|
header("Cache-Control: public, max-age=86400");
|
2025-04-14 17:13:00 +08:00
|
|
|
|
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'],
|
2025-05-23 20:24:49 +08:00
|
|
|
|
CURLOPT_SSL_VERIFYPEER => true, // Consider making this configurable for dev environments
|
2025-04-14 17:13:00 +08:00
|
|
|
|
CURLOPT_SSL_VERIFYHOST => 2,
|
|
|
|
|
CURLOPT_USERAGENT => $this->config['user_agent'],
|
2025-05-23 20:24:49 +08:00
|
|
|
|
CURLOPT_REFERER => parse_url($url, PHP_URL_SCHEME) . '://' . (parse_url($url, PHP_URL_HOST) ?? ''),
|
|
|
|
|
CURLOPT_HTTPHEADER => ['Accept: image/*,application/octet-stream'] // Accept more types
|
2025-04-14 17:13:00 +08:00
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$data = curl_exec($ch);
|
|
|
|
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
2025-05-23 20:24:49 +08:00
|
|
|
|
$curlError = curl_error($ch);
|
2025-04-14 17:13:00 +08:00
|
|
|
|
curl_close($ch);
|
|
|
|
|
|
2025-05-23 20:24:49 +08:00
|
|
|
|
if ($curlError) {
|
|
|
|
|
throw new Exception('cURL error fetching image: ' . $curlError . ' from URL: ' . $url);
|
|
|
|
|
}
|
2025-04-14 17:13:00 +08:00
|
|
|
|
if ($httpCode !== 200 || empty($data)) {
|
2025-05-23 20:24:49 +08:00
|
|
|
|
throw new Exception('Failed to fetch image: HTTP ' . $httpCode . ' from URL: ' . $url);
|
2025-04-14 17:13:00 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $data;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 验证图片数据
|
|
|
|
|
*
|
|
|
|
|
* @param string $data 图片数据
|
2025-05-23 20:24:49 +08:00
|
|
|
|
* @param string|null &$outMimeType 输出参数,检测到的MIME类型
|
2025-04-14 17:13:00 +08:00
|
|
|
|
* @return bool
|
|
|
|
|
*/
|
2025-05-23 20:24:49 +08:00
|
|
|
|
private function validateImageData(string $data, ?string &$outMimeType = null): bool {
|
2025-04-14 17:13:00 +08:00
|
|
|
|
if (empty($data)) {
|
2025-05-23 20:24:49 +08:00
|
|
|
|
$outMimeType = null;
|
2025-04-14 17:13:00 +08:00
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-23 20:24:49 +08:00
|
|
|
|
// 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 = [
|
2025-04-14 17:13:00 +08:00
|
|
|
|
'image/jpeg' => "\xFF\xD8\xFF",
|
2025-05-23 20:24:49 +08:00
|
|
|
|
'image/png' => "\x89PNG\r\n\x1a\n",
|
|
|
|
|
'image/gif' => "GIF", // Covers GIF87a and GIF89a
|
|
|
|
|
'image/bmp' => "BM",
|
2025-04-14 17:13:00 +08:00
|
|
|
|
];
|
|
|
|
|
|
2025-05-23 20:24:49 +08:00
|
|
|
|
foreach ($signatures as $mime => $sig) {
|
|
|
|
|
if (substr($data, 0, strlen($sig)) === $sig) {
|
|
|
|
|
$outMimeType = $mime;
|
2025-04-14 17:13:00 +08:00
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-05-23 20:24:49 +08:00
|
|
|
|
|
|
|
|
|
// 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;
|
2025-04-14 17:13:00 +08:00
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-23 20:24:49 +08:00
|
|
|
|
/**
|
|
|
|
|
* 转换图片为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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-04-14 17:13:00 +08:00
|
|
|
|
/**
|
|
|
|
|
* 保存到缓存
|
|
|
|
|
*
|
|
|
|
|
* @param string $cachePath 缓存路径
|
|
|
|
|
* @param string $data 图片数据
|
2025-05-23 20:24:49 +08:00
|
|
|
|
* @param string $actualMimeType 图片的实际MIME类型
|
2025-04-14 17:13:00 +08:00
|
|
|
|
*/
|
2025-05-23 20:24:49 +08:00
|
|
|
|
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");
|
2025-04-14 17:13:00 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 输出图片
|
|
|
|
|
*
|
|
|
|
|
* @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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2025-05-23 20:24:49 +08:00
|
|
|
|
* 获取图片类型 (基于文件扩展名)
|
2025-04-14 17:13:00 +08:00
|
|
|
|
*
|
2025-05-23 20:24:49 +08:00
|
|
|
|
* @param string $urlOrPath 图片URL或路径
|
2025-04-14 17:13:00 +08:00
|
|
|
|
* @return string
|
|
|
|
|
*/
|
2025-05-23 20:24:49 +08:00
|
|
|
|
private function getImageType(string $urlOrPath): string {
|
|
|
|
|
$ext = strtolower(pathinfo($urlOrPath, PATHINFO_EXTENSION));
|
|
|
|
|
return self::SUPPORTED_TYPES[$ext] ?? 'application/octet-stream'; // Fallback type
|
2025-04-14 17:13:00 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 输出错误信息
|
|
|
|
|
*/
|
|
|
|
|
private function outputError() {
|
2025-05-23 20:24:49 +08:00
|
|
|
|
// 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
|
2025-04-14 17:13:00 +08:00
|
|
|
|
echo base64_decode('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 简单的日志记录类
|
|
|
|
|
*/
|
|
|
|
|
class Logger {
|
|
|
|
|
private $logDir;
|
2025-05-28 02:04:34 +08:00
|
|
|
|
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个日志文件
|
2025-04-14 17:13:00 +08:00
|
|
|
|
|
|
|
|
|
public function __construct(string $logDir) {
|
|
|
|
|
$this->logDir = $logDir;
|
2025-05-23 20:24:49 +08:00
|
|
|
|
|
|
|
|
|
if (!file_exists($this->logDir)) {
|
|
|
|
|
mkdir($this->logDir, 0777, true);
|
|
|
|
|
}
|
2025-05-28 02:04:34 +08:00
|
|
|
|
|
|
|
|
|
// 清理旧日志
|
|
|
|
|
$this->cleanupOldLogs();
|
2025-04-14 17:13:00 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function error(string $message) {
|
|
|
|
|
$this->log('ERROR', $message);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function info(string $message) {
|
|
|
|
|
$this->log('INFO', $message);
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-28 02:04:34 +08:00
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-04-14 17:13:00 +08:00
|
|
|
|
private function log(string $level, string $message) {
|
2025-05-23 20:24:49 +08:00
|
|
|
|
try {
|
|
|
|
|
$date = date('Y-m-d');
|
|
|
|
|
$time = date('Y-m-d H:i:s');
|
|
|
|
|
$logFile = $this->logDir . "/{$date}.log";
|
|
|
|
|
|
2025-05-28 02:04:34 +08:00
|
|
|
|
// 检查并轮转日志
|
|
|
|
|
$this->rotateLogIfNeeded($logFile);
|
|
|
|
|
|
2025-05-23 20:24:49 +08:00
|
|
|
|
$logMessage = "[{$time}] [{$level}] {$message}\n";
|
2025-05-28 02:04:34 +08:00
|
|
|
|
|
|
|
|
|
// 确保日志目录可写
|
2025-05-23 20:24:49 +08:00
|
|
|
|
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;
|
|
|
|
|
}
|
2025-05-28 02:04:34 +08:00
|
|
|
|
|
|
|
|
|
// 写入日志
|
2025-05-23 20:24:49 +08:00
|
|
|
|
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}");
|
|
|
|
|
}
|
2025-04-14 17:13:00 +08:00
|
|
|
|
}
|
2025-05-23 20:24:49 +08:00
|
|
|
|
}
|