多檔案上傳是常見需求,但原始碼常存在安全漏洞(任意檔案上傳、路徑遍歷、型別繞過)。本文提供一個結構清晰、安全強化的多檔案上傳類別,涵蓋驗證、重命名、儲存、錯誤處理與前端整合。
<?php
class MultiFileUploader {
private $uploadDir;
private $allowedMimes = [];
private $maxFileSize; // bytes
private $maxFiles;
private $overwrite = false;
private $errors = [];
private $uploaded = [];
public function __construct(string $uploadDir, array $options = []) {
$this->uploadDir = rtrim($uploadDir, '/') . '/';
$this->allowedMimes = $options['allowed_mimes'] ?? [
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
'application/pdf', 'text/plain'
];
$this->maxFileSize = $options['max_file_size'] ?? 5 * 1024 * 1024; // 5MB
$this->maxFiles = $options['max_files'] ?? 10;
$this->overwrite = $options['overwrite'] ?? false;
if (!is_dir($this->uploadDir)) {
mkdir($this->uploadDir, 0755, true);
}
}
public function upload(array $files = null): array {
$files = $files ?? $_FILES;
$this->errors = [];
$this->uploaded = [];
// 正規化 $_FILES 結構
$normalized = $this->normalizeFiles($files);
if (count($normalized) > $this->maxFiles) {
$this->errors[] = "超過最大檔案數限制 ({$this->maxFiles})";
return $this->getResult();
}
foreach ($normalized as $file) {
$this->processFile($file);
}
return $this->getResult();
}
private function normalizeFiles(array $files): array {
$result = [];
foreach ($files as $name => $data) {
if (isset($data['tmp_name'])) {
// 單檔案結構
if (is_array($data['tmp_name'])) {
// 多檔案同名輸入
foreach ($data['tmp_name'] as $i => $tmp) {
if ($tmp !== '' && $data['error'][$i] === UPLOAD_ERR_OK) {
$result[] = [
'name' => $data['name'][$i],
'type' => $data['type'][$i],
'tmp_name' => $tmp,
'size' => $data['size'][$i],
'error' => $data['error'][$i]
];
}
}
} else {
// 單檔案
if ($data['tmp_name'] !== '' && $data['error'] === UPLOAD_ERR_OK) {
$result[] = $data;
}
}
}
}
return $result;
}
private function processFile(array $file) {
// 1. MIME 驗證(建議配合 finfo 二次驗證)
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$realMime = finfo_file($finfo, $file['tmp_name']);
finfo_close($finfo);
if (!in_array($realMime, $this->allowedMimes, true)) {
$this->errors[] = "檔案 {$file['name']} 類型不允許: $realMime";
return;
}
// 2. 大小檢查
if ($file['size'] > $this->maxFileSize) {
$this->errors[] = "檔案 {$file['name']} 超過大小限制 (" . $this->formatBytes($this->maxFileSize) . ")";
return;
}
// 3. 產生安全檔名
$ext = $this->getSafeExtension($realMime, $file['name']);
$newName = $this->generateUniqueName($ext);
$destPath = $this->uploadDir . $newName;
// 4. 覆蓋檢查
if (file_exists($destPath) && !$this->overwrite) {
$this->errors[] = "檔案 {$newName} 已存在,略過";
return;
}
// 5. 移動檔案
if (!move_uploaded_file($file['tmp_name'], $destPath)) {
$this->errors[] = "檔案 {$file['name']} 移動失敗";
return;
}
// 6. 設定權限
chmod($destPath, 0644);
$this->uploaded[] = [
'original_name' => $file['name'],
'saved_name' => $newName,
'path' => $destPath,
'size' => $file['size'],
'mime' => $realMime,
'url' => $this->getPublicUrl($newName)
];
}
private function getSafeExtension(string $mime, string $originalName): string {
$map = [
'image/jpeg' => 'jpg', 'image/png' => 'png',
'image/gif' => 'gif', 'image/webp' => 'webp',
'application/pdf' => 'pdf', 'text/plain' => 'txt'
];
return $map[$mime] ?? pathinfo($originalName, PATHINFO_EXTENSION) ?? 'bin';
}
private function generateUniqueName(string $ext): string {
do {
$name = bin2hex(random_bytes(16)) . '.' . $ext;
} while (file_exists($this->uploadDir . $name));
return $name;
}
private function formatBytes(int $bytes): string {
$units = ['B','KB','MB','GB'];
for ($i = 0; $bytes >= 1024 && $i < 3; $i++) $bytes /= 1024;
return round($bytes, 2) . ' ' . $units[$i];
}
private function getPublicUrl(string $filename): string {
// 根據實際部署調整
return '/uploads/' . $filename;
}
public function getResult(): array {
return [
'success' => empty($this->errors),
'uploaded' => $this->uploaded,
'errors' => $this->errors
];
}
}
<form action="upload.php" method="post" enctype="multipart/form-data">
<input type="hidden" name="MAX_FILE_SIZE" value="5242880">
<input type="file" name="files[]" multiple accept="image/*,.pdf,.txt">
<label><input type="checkbox" name="overwrite"> 覆蓋同名檔案</label>
<button type="submit">上傳</button>
</form>
<?php
require 'MultiFileUploader.php';
$uploader = new MultiFileUploader(__DIR__ . '/uploads', [
'allowed_mimes' => ['image/jpeg','image/png','image/gif','image/webp','application/pdf'],
'max_file_size' => 10 * 1024 * 1024,
'max_files' => 20,
'overwrite' => isset($_POST['overwrite'])
]);
$result = $uploader->upload();
header('Content-Type: application/json');
echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
| 風險 | 防護措施 |
|---|---|
| 惡意腳本上傳 | MIME 白名單 + finfo 實際檢測 + 副檔名映射 + 儲存目錄禁用執行權限 |
| 檔名路徑遍歷 | 忽略原始檔名,使用隨機雜湊檔名 |
| 大檔案阻塞服務 | MAX_FILE_SIZE、upload_max_filesize、post_max_size、max_file_size 多層限制 |
| 併發檔名衝突 | random_bytes(16) + 存在性檢查迴圈 |
| 上傳目錄洩漏 | 儲存路徑置於 Web Root 外,或 Nginx location /uploads { internal; } |
| CSRF 攻擊 | 表單加入 CSRF Token 驗證 |
| 殭屍暫存檔 | 定期清理 sys_get_temp_dir() 超過 24 小時的 php* 暫存檔 |
plupload 或 uppy 前端分片,後端合併uploads 表,關聯業務實體(文章、用戶、訂單)採用物件導向封裝上傳邏輯,能集中管理安全策略、便於單元測試、支援多種儲存後端替換。