圖片上傳是 Web 開發高頻需求,涉及安全驗證、文件命名、存儲路徑、數據庫關聯。本文提供一個生產就緒的 ImageUpload 類,包含類型/大小驗證、安全重命名、自動建目錄、錯誤碼規範、配合資料庫記錄的完整流程。
<?php
declare(strict_types=1);
class ImageUpload {
private array $allowedMimes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
private int $maxSize = 2 * 1024 * 1024; // 2MB
private string $saveRoot;
private string $urlPrefix;
public function __construct(string $saveRoot, string $urlPrefix = '/uploads/') {
$this->saveRoot = rtrim($saveRoot, '/');
$this->urlPrefix = rtrim($urlPrefix, '/');
}
public function setMaxSize(int $bytes): self { $this->maxSize = $bytes; return $this; }
public function setAllowedMimes(array $mimes): self { $this->allowedMimes = $mimes; return $this; }
/**
* 上傳單個文件
* @param array $file $_FILES['field'] 子數組
* @return array ['success'=>bool, 'url'=>string, 'path'=>string, 'error'=>string, 'code'=>int]
*/
public function upload(array $file): array {
// 1. 錯誤碼檢查
if ($file['error'] !== UPLOAD_ERR_OK) {
return $this->fail($file['error'], $this->getUploadErrorMsg($file['error']));
}
// 2. 類型驗證(MIME + 副檢查擴展名)
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_file($finfo, $file['tmp_name']);
finfo_close($finfo);
if (!in_array($mime, $this->allowedMimes, true)) {
return $this->fail(1001, "禁止的文件類型: $mime");
}
// 3. 大小驗證
if ($file['size'] > $this->maxSize) {
return $this->fail(1002, "文件過大,限制 {$this->maxSize} 字節");
}
// 4. 安全重命名:時間戳 + 隨機數 + 原擴展名
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
$ext = in_array($ext, ['jpg','jpeg','png','gif','webp']) ? $ext : 'jpg';
$filename = date('YmdHis') . '_' . mt_rand(10000, 99999) . '.' . $ext;
// 5. 日期分目錄:uploads/2024/01/15/
$dateDir = date('Y/m/d');
$saveDir = $this->saveRoot . '/' . $dateDir;
if (!is_dir($saveDir) && !mkdir($saveDir, 0755, true)) {
return $this->fail(1003, "無法創建目錄: $saveDir");
}
$savePath = $saveDir . '/' . $filename;
$relativePath = $dateDir . '/' . $filename;
$url = $this->urlPrefix . '/' . $relativePath;
// 6. 移動文件
if (!move_uploaded_file($file['tmp_name'], $savePath)) {
return $this->fail(1004, "文件移動失敗");
}
// 7. 可選:二次驗證是否真實圖片
if (@getimagesize($savePath) === false) {
unlink($savePath);
return $this->fail(1005, "非有效圖片文件");
}
return [
'success' => true,
'url' => $url,
'path' => $savePath,
'relative'=> $relativePath,
'mime' => $mime,
'size' => $file['size'],
];
}
private function fail(int $code, string $msg): array {
return ['success' => false, 'error' => $msg, 'code' => $code];
}
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 => 'PHP 擴展阻止上傳',
default => '未知上傳錯誤',
};
}
}
?>
<?php
// 1. 初始化上傳類
$uploader = new ImageUpload(
saveRoot: __DIR__ . '/public/uploads',
urlPrefix: '/uploads'
);
// 2. 處理上傳
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$result = $uploader->upload($_FILES['avatar'] ?? []);
if ($result['success']) {
// 3. 入庫(使用 PDO 預處理)
$pdo = new PDO('mysql:host=localhost;dbname=app', 'user', 'pass');
$stmt = $pdo->prepare('INSERT INTO user_avatars (user_id, url, path, mime, size) VALUES (?, ?, ?, ?, ?)');
$stmt->execute([$_SESSION['user_id'], $result['url'], $result['relative'], $result['mime'], $result['size']]);
echo json_encode(['code' => 0, 'msg' => '上傳成功', 'url' => $result['url']]);
} else {
http_response_code(400);
echo json_encode(['code' => $result['code'], 'msg' => $result['error']]);
}
exit;
}
?>
<!DOCTYPE html>
<html>
<body>
<form method="post" enctype="multipart/form-data">
<input type="file" name="avatar" accept="image/*" required>
<button type="submit">上傳頭像</button>
</form>
</body>
</html>
| 風險點 | 防護措施 |
|---|---|
| 腳本偽裝成圖片 | finfo_file() 檢測真實 MIME + getimagesize() 二次驗證 |
| 文件名遍歷攻擊 | 完全忽略原文件名,生成隨機名,路徑自行構造 |
| 上傳目錄可執行 | Nginx/Apache 配置 location ~ \.php$ { deny all; } 禁止執行 |
| 大文件填滿磁盤 | setMaxSize() + PHP upload_max_filesize/post_max_size 雙重限制 |
| 並發衝突 | 時間戳+隨機數極低衝突率,極端可加 while(file_exists) rename |
uploadMultiple(array $files): array 返回結果集Intervention/Image 自動縮略、水印、WebP 轉換StorageAdapter 接口,支持本地/OSS/S3 切換