企業級圖片上傳需解決:文件驗證、安全存儲、縮略圖生成、水印保護、錯誤處理、性能優化。本文提供生產就緒的完整實現。
<?php
class ImageUploader {
// 配置屬性
private string $uploadDir = 'uploads/';
private int $maxSize = 5 * 1024 * 1024; // 5MB
private array $allowedTypes = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
private array $allowedMimes = [
'image/jpeg', 'image/png', 'image/gif', 'image/webp'
];
// 縮略圖配置
private bool $createThumb = true;
private int $thumbWidth = 400;
private int $thumbHeight = 300;
private string $thumbPrefix = 'thumb_';
private string $thumbDir = ''; // 空則同目錄
// 水印配置
private bool $watermark = true;
private string $wmText = '© MySite.com';
private string $wmFont = '';
private int $wmSize = 18;
private array $wmColor = [255, 255, 255, 60];
private string $wmPosition = 'bottom-right';
private string $wmImage = ''; // 圖片水印路徑
// 狀態
private array $errors = [];
private string $savedPath = '';
private string $thumbPath = '';
public function __construct(array $config = []) {
foreach ($config as $key => $value) {
if (property_exists($this, $key)) {
$this->$key = $value;
}
}
// 確保目錄存在
$this->ensureDir($this->uploadDir);
if ($this->thumbDir) $this->ensureDir($this->thumbDir);
}
public function upload(array $file): bool {
$this->errors = [];
// 1. 基礎驗證
if (!$this->validateUpload($file)) return false;
// 2. 生成安全文件名
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
$filename = $this->generateFilename($ext);
$this->savedPath = $this->uploadDir . $filename;
// 3. 移動文件
if (!move_uploaded_file($file['tmp_name'], $this->savedPath)) {
$this->errors[] = '文件移動失敗,請檢查目錄權限';
return false;
}
// 4. 二次驗證:檢查文件內容是否真為圖片
if (!$this->verifyImageContent($this->savedPath)) {
unlink($this->savedPath);
$this->errors[] = '文件內容非有效圖片';
return false;
}
// 5. 添加水印(先水印再縮略圖,保證縮略圖也有水印)
if ($this->watermark) {
$this->applyWatermark($this->savedPath);
}
// 6. 生成縮略圖
if ($this->createThumb) {
$this->createThumbnail($this->savedPath);
}
return true;
}
// ========== 驗證方法 ==========
private function validateUpload(array $file): bool {
if ($file['error'] !== UPLOAD_ERR_OK) {
$this->errors[] = $this->getUploadErrorMsg($file['error']);
return false;
}
if ($file['size'] > $this->maxSize) {
$this->errors[] = "文件大小超過限制 ({$this->formatBytes($this->maxSize)})";
return false;
}
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
if (!in_array($ext, $this->allowedTypes, true)) {
$this->errors[] = "不支持的文件類型: $ext";
return false;
}
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_file($finfo, $file['tmp_name']);
finfo_close($finfo);
if (!in_array($mime, $this->allowedMimes, true)) {
$this->errors[] = "MIME 類型不匹配: $mime";
return false;
}
// 檢查圖片尺寸(防止解壓炸彈)
[$w, $h] = getimagesize($file['tmp_name']);
if ($w > 8000 || $h > 8000) {
$this->errors[] = '圖片尺寸過大';
return false;
}
return true;
}
private function verifyImageContent(string $path): bool {
$im = @imagecreatefromstring(file_get_contents($path));
if (!$im) return false;
imagedestroy($im);
return true;
}
// ========== 文件名生成 ==========
private function generateFilename(string $ext): string {
// 格式:年月日時分秒_隨機字符.擴展名
$date = date('YmdHis');
$rand = bin2hex(random_bytes(4));
return "{$date}_{$rand}.{$ext}";
}
// ========== 水印處理 ==========
private function applyWatermark(string $path): void {
[$w, $h, $type] = getimagesize($path);
$createFn = $this->getCreateFn($type);
$saveFn = $this->getSaveFn($type);
if (!$createFn || !$saveFn) return;
$im = $createFn($path);
$this->prepareAlpha($im, $type);
// 文字水印
if ($this->wmText && $this->wmFont && file_exists($this->wmFont)) {
$color = imagecolorallocatealpha($im, ...$this->wmColor);
$box = imagettfbbox($this->wmSize, 0, $this->wmFont, $this->wmText);
$tw = $box[2] - $box[0];
$th = $box[1] - $box[7];
[$x, $y] = $this->calcPosition($w, $h, $tw, $th, $this->wmPosition);
imagettftext($im, $this->wmSize, 0, $x, $y, $color, $this->wmFont, $this->wmText);
}
// 圖片水印
if ($this->wmImage && file_exists($this->wmImage)) {
[$wmW, $wmH, $wmType] = getimagesize($this->wmImage);
$wmCreateFn = $this->getCreateFn($wmType);
if ($wmCreateFn) {
$wm = $wmCreateFn($this->wmImage);
$this->prepareAlpha($wm, $wmType);
[$x, $y] = $this->calcPosition($w, $h, $wmW, $wmH, $this->wmPosition);
imagealphablending($im, true);
imagecopymerge($im, $wm, $x, $y, 0, 0, $wmW, $wmH, 50);
imagedestroy($wm);
}
}
$saveFn($im, $path);
imagedestroy($im);
}
// ========== 縮略圖生成 ==========
private function createThumbnail(string $srcPath): void {
[$srcW, $srcH, $type] = getimagesize($srcPath);
$createFn = $this->getCreateFn($type);
$saveFn = $this->getSaveFn($type);
if (!$createFn || !$saveFn) return;
// 計算目標尺寸(等比)
$ratio = min($this->thumbWidth / $srcW, $this->thumbHeight / $srcH);
if ($ratio >= 1) return; // 源圖已小於目標
$dstW = (int)round($srcW * $ratio);
$dstH = (int)round($srcH * $ratio);
$srcIm = $createFn($srcPath);
$dstIm = imagecreatetruecolor($dstW, $dstH);
$this->prepareAlpha($dstIm, $type);
imagecopyresampled($dstIm, $srcIm, 0, 0, 0, 0, $dstW, $dstH, $srcW, $srcH);
// 保存路徑
$filename = basename($srcPath);
$thumbDir = $this->thumbDir ?: $this->uploadDir;
$this->thumbPath = $thumbDir . $this->thumbPrefix . $filename;
$saveFn($dstIm, $this->thumbPath);
imagedestroy($srcIm);
imagedestroy($dstIm);
}
// ========== 工具方法 ==========
private function getCreateFn(int $type): ?callable {
return match($type) {
IMAGETYPE_JPEG => 'imagecreatefromjpeg',
IMAGETYPE_PNG => 'imagecreatefrompng',
IMAGETYPE_GIF => 'imagecreatefromgif',
IMAGETYPE_WEBP => 'imagecreatefromwebp',
default => null
};
}
private function getSaveFn(int $type): ?callable {
return match($type) {
IMAGETYPE_JPEG => fn($im, $p) => imagejpeg($im, $p, 90),
IMAGETYPE_PNG => fn($im, $p) => imagepng($im, $p, 9),
IMAGETYPE_GIF => fn($im, $p) => imagegif($im, $p),
IMAGETYPE_WEBP => fn($im, $p) => imagewebp($im, $p, 90),
default => null
};
}
private function prepareAlpha($im, int $type): void {
if (in_array($type, [IMAGETYPE_PNG, IMAGETYPE_GIF, IMAGETYPE_WEBP])) {
imagealphablending($im, false);
imagesavealpha($im, true);
}
}
private function calcPosition(int $cw, int $ch, int $ow, int $oh, string $pos): array {
$margin = 15;
return match($pos) {
'top-left' => [$margin, $margin + $oh],
'top-center' => [($cw - $ow)/2, $margin + $oh],
'top-right' => [$cw - $ow - $margin, $margin + $oh],
'center-left' => [$margin, ($ch + $oh)/2],
'center' => [($cw - $ow)/2, ($ch + $oh)/2],
'center-right' => [$cw - $ow - $margin, ($ch + $oh)/2],
'bottom-left' => [$margin, $ch - $oh - $margin],
'bottom-center' => [($cw - $ow)/2, $ch - $oh - $margin],
'bottom-right' => [$cw - $ow - $margin, $ch - $oh - $margin],
default => [$cw - $ow - $margin, $ch - $oh - $margin],
};
}
private function ensureDir(string $dir): void {
if (!is_dir($dir)) @mkdir($dir, 0755, true);
}
private function getUploadErrorMsg(int $code): string {
return match($code) {
UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => '文件超過大小限制',
UPLOAD_ERR_PARTIAL => '文件上傳不完整',
UPLOAD_ERR_NO_FILE => '未選擇文件',
UPLOAD_ERR_NO_TMP_DIR => '臨時目錄缺失',
UPLOAD_ERR_CANT_WRITE => '寫入失敗',
UPLOAD_ERR_EXTENSION => '擴展阻止上傳',
default => '未知上傳錯誤'
};
}
private function formatBytes(int $bytes): string {
$units = ['B', 'KB', 'MB', 'GB'];
$i = 0;
while ($bytes >= 1024 && $i < count($units)-1) {
$bytes /= 1024;
$i++;
}
return round($bytes, 2) . ' ' . $units[$i];
}
// ========== 獲取器 ==========
public function getErrors(): array { return $this->errors; }
public function getSavedPath(): string { return $this->savedPath; }
public function getThumbPath(): string { return $this->thumbPath; }
public function getPublicUrl(string $path): string {
// 轉換為公開 URL,需根據項目配置調整
return '/'. ltrim($path, '/');
}
}
<!-- HTML 表單 -->
<?php
// upload.php
require 'ImageUploader.php';
header('Content-Type: application/json');
$uploader = new ImageUploader([
'uploadDir' => __DIR__ . '/uploads/',
'thumbDir' => __DIR__ . '/uploads/thumbs/',
'wmFont' => __DIR__ . '/fonts/SourceHanSansCN-Regular.otf',
'wmText' => $_POST['wm_text'] ?? '© MySite.com',
'maxSize' => 10 * 1024 * 1024, // 10MB
]);
if ($uploader->upload($_FILES['image'])) {
echo json_encode([
'success' => true,
'url' => $uploader->getPublicUrl($uploader->getSavedPath()),
'thumb_url' => $uploader->getPublicUrl($uploader->getThumbPath()),
'message' => '上傳成功'
]);
} else {
http_response_code(400);
echo json_encode([
'success' => false,
'errors' => $uploader->getErrors()
]);
}
| 風險 | 防護措施 | 實現位置 |
|---|---|---|
| 惡意腳本上傳 | MIME + 擴展名雙重驗證、imagecreatefromstring 內容驗證 | validateUpload(), verifyImageContent() |
| 文件名注入/遍歷 | 隨機重命名、basename()、路徑標準化 | generateFilename() |
| 圖片解壓炸彈 | 限制最大像素尺寸 (8000x8000) | getimagesize() 檢查 |
| 目錄執行權限 | 上傳目錄配置 php_flag engine off / SetHandler default-handler | Nginx/Apache 配置 |
| 大文件 DoS | MAX_FILE_SIZE、post_max_size、upload_max_filesize、memory_limit | php.ini + 表單隱藏域 |
| 並發衝突 | 隨機文件名 + 微秒時間戳 | generateFilename() |
location /uploads/ {
# 禁止 PHP 腳本執行
location ~ \.php$ {
deny all;
return 403;
}
# 禁止訪問隱藏文件
location ~ /\. {
deny all;
}
# 緩存靜態資源
expires 30d;
add_header Cache-Control "public";
}
對於 >100MB 文件,需前端分片 + 後端合併:
<?php
// chunk_upload.php
$chunkDir = 'uploads/chunks/' . $_POST['fileHash'] . '/';
@mkdir($chunkDir, 0755, true);
$chunkFile = $chunkDir . $_POST['chunkIndex'];
move_uploaded_file($_FILES['chunk']['tmp_name'], $chunkFile);
// 合併請求
if ($_POST['isLastChunk']) {
$finalPath = 'uploads/' . $_POST['fileName'];
$fp = fopen($finalPath, 'wb');
for ($i = 0; $i <= $_POST['totalChunks']; $i++) {
fwrite($fp, file_get_contents($chunkDir . $i));
}
fclose($fp);
// 清理分片
array_map('unlink', glob($chunkDir . '*'));
@rmdir($chunkDir);
// 後續處理:驗證、縮略圖、水印...
}
ImageUploader 類封裝了企業級圖片上傳的完整鏈路:驗證 → 重命名 → 存儲 → 內容驗證 → 水印 → 縮略圖 → 安全輸出。關鍵設計原則:
imagedestroy()生產部署時務必配合 Web 服務器禁用上傳目錄腳本執行權限,並設置合理的 PHP 上傳限制參數。