圖片上傳是 Web 開發高頻需求。本文提供一個生產級 ImageUploader 類,整合**文件校驗(類型/大小/偽造)、目錄自建、唯一文件名、等比縮略圖生成、異常處理**,代碼結構清晰,可直接納入項目。
<?php
class ImageUploader {
// 配置屬性
private $allowedMimes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
private $allowedExts = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
private $maxSize = 5 * 1024 * 1024; // 5MB
private $uploadDir = '';
private $thumbWidth = 200;
private $thumbHeight = 200;
private $thumbPrefix = 'thumb_';
private $quality = 85;
// 運行時屬性
private $fileInfo = [];
private $srcPath = '';
private $thumbPath = '';
private $error = '';
public function __construct(array $config = []) {
foreach ($config as $k => $v) {
if (property_exists($this, $k)) $this->$k = $v;
}
}
/** 主入口:處理 $_FILES['field'] 上傳
* @return array|false 成功返回 ['src' => '', 'thumb' => ''],失敗返回 false
*/
public function upload(string $fieldName): array|false {
if (empty($_FILES[$fieldName])) {
$this->error = '未收到文件';
return false;
}
$this->fileInfo = $_FILES[$fieldName];
if (!$this->validate()) return false;
if (!$this->prepareDir()) return false;
if (!$this->moveFile()) return false;
if (!$this->createThumb()) return false;
return [
'src' => $this->srcPath,
'thumb' => $this->thumbPath,
];
}
/** 校驗:錯誤碼、MIME、擴展名、大小、偽造文件 */
private function validate(): bool {
$err = $this->fileInfo['error'];
if ($err !== UPLOAD_ERR_OK) {
$this->error = match ($err) {
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 => 'PHP 擴展阻止上傳',
default => '未知上傳錯誤',
};
return false;
}
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_file($finfo, $this->fileInfo['tmp_name']);
finfo_close($finfo);
if (!in_array($mime, $this->allowedMimes, true)) {
$this->error = "文件類型不允許: $mime";
return false;
}
$ext = strtolower(pathinfo($this->fileInfo['name'], PATHINFO_EXTENSION));
if (!in_array($ext, $this->allowedExts, true)) {
$this->error = "擴展名不允許: $ext";
return false;
}
if ($this->fileInfo['size'] > $this->maxSize) {
$this->error = '文件過大';
return false;
}
if (!is_uploaded_file($this->fileInfo['tmp_name'])) {
$this->error = '偽造上傳文件';
return false;
}
return true;
}
/** 確保上傳目錄存在 */
private function prepareDir(): bool {
if (!is_dir($this->uploadDir)) {
if (!mkdir($this->uploadDir, 0755, true)) {
$this->error = '無法創建上傳目錄';
return false;
}
}
return is_writable($this->uploadDir);
}
/** 移動文件,生成唯一文件名 */
private function moveFile(): bool {
$ext = strtolower(pathinfo($this->fileInfo['name'], PATHINFO_EXTENSION));
$filename = bin2hex(random_bytes(16)) . '.' . $ext; // 32位隨機十六進制
$this->srcPath = $this->uploadDir . '/' . $filename;
if (!move_uploaded_file($this->fileInfo['tmp_name'], $this->srcPath)) {
$this->error = '文件移動失敗';
return false;
}
// 修正權限
chmod($this->srcPath, 0644);
return true;
}
/** 生成等比縮略圖 */
private function createThumb(): bool {
list($srcW, $srcH, $type) = getimagesize($this->srcPath);
if (!$srcW || !$srcH) {
$this->error = '無法讀取圖片尺寸';
return false;
}
// 計算目標尺寸(等比縮放,限制在矩形框內)
$ratio = min($this->thumbWidth / $srcW, $this->thumbHeight / $srcH);
$dstW = (int)($srcW * $ratio);
$dstH = (int)($srcH * $ratio);
$srcImg = match ($type) {
IMAGETYPE_JPEG => imagecreatefromjpeg($this->srcPath),
IMAGETYPE_PNG => imagecreatefrompng($this->srcPath),
IMAGETYPE_GIF => imagecreatefromgif($this->srcPath),
IMAGETYPE_WEBP => imagecreatefromwebp($this->srcPath),
default => null,
};
if (!$srcImg) {
$this->error = '不支持的圖片格式';
return false;
}
$thumbImg = imagecreatetruecolor($dstW, $dstH);
// PNG/GIF/WEBP 保持透明度
if (in_array($type, [IMAGETYPE_PNG, IMAGETYPE_GIF, IMAGETYPE_WEBP])) {
imagealphablending($thumbImg, false);
imagesavealpha($thumbImg, true);
$transparent = imagecolorallocatealpha($thumbImg, 255, 255, 255, 127);
imagefilledrectangle($thumbImg, 0, 0, $dstW, $dstH, $transparent);
}
imagecopyresampled($thumbImg, $srcImg, 0, 0, 0, 0, $dstW, $dstH, $srcW, $srcH);
$thumbFilename = $this->thumbPrefix . basename($this->srcPath);
$this->thumbPath = $this->uploadDir . '/' . $thumbFilename;
$success = match ($type) {
IMAGETYPE_JPEG => imagejpeg($thumbImg, $this->thumbPath, $this->quality),
IMAGETYPE_PNG => imagepng($thumbImg, $this->thumbPath, 9),
IMAGETYPE_GIF => imagegif($thumbImg, $this->thumbPath),
IMAGETYPE_WEBP => imagewebp($thumbImg, $this->thumbPath, $this->quality),
default => false,
};
imagedestroy($srcImg);
imagedestroy($thumbImg);
if (!$success) {
$this->error = '縮略圖生成失敗';
return false;
}
chmod($this->thumbPath, 0644);
return true;
}
public function getError(): string { return $this->error; }
}
?>
<?php
require 'ImageUploader.php';
$uploader = new ImageUploader([
'uploadDir' => __DIR__ . '/uploads',
'maxSize' => 10 * 1024 * 1024, // 10MB
'thumbWidth' => 300,
'thumbHeight' => 300,
]);
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$result = $uploader->upload('avatar'); //
if ($result) {
echo json_encode(['code' => 0, 'data' => $result]);
} else {
echo json_encode(['code' => 1, 'msg' => $uploader->getError()]);
}
exit;
}
?>
<form method="post" enctype="multipart/form-data">
<input type="file" name="avatar" accept="image/*" required>
<button type="submit">上傳</button>
</form>
| 措施 | 實現位置 | 說明 |
|---|---|---|
| MIME 類型白名單 | finfo_file() | 防止偽造擴展名繞過檢查 |
| 文件大小限制 | $maxSize + UPLOAD_ERR_INI_SIZE | 雙重保障(php.ini + 代碼) |
| 隨機文件名 | bin2hex(random_bytes(16)) | 防止覆蓋、遍歷、執行腳本 |
| 上傳目錄非 Web 可執行 | Nginx: location ~ \.php$ { deny all; } | 即使上傳 .php 也無法執行 |
| 禁用腳本執行權限 | chmod 0644 | 去除可執行位 |
| 圖片重繪驗證 | imagecreatefrom* + imagecopyresampled | 破壞隱藏在圖片中的惡意代碼 |
| 病毒掃描 | ClamAV 集成 | 高安全場景額外增加 |
imagealphablending/imagesavealpha 設置,PNG/GIF/WEBP 需開啟透明保護寬×高×4 字節 內存,5000×5000 約 100MB。大圖建議用 Imagick 流式處理或分塊縮放uploadDir 屬於 PHP-FPM 用戶(chown www:www uploads)ImageUploader 類將「校驗、存儲、縮略、安全」四大核心職責封裝於單一類中,配置靈活、錯誤信息友好、擴展性強。生產環境建議配合 雲存儲 SDK(OSS/COS/S3) 替換本地存儲邏輯,並接入 異步隊列 處理耗時的縮略圖生成、水印添加、病毒掃描等任務。