多檔案上傳是常見需求,需處理驗證、命名衝突、安全掃描、進度回報。本文提供生產級 UploadHandler 類別,支援單/多檔、分片、非同步處理。
<?php
class UploadHandler {
private array $config = [
'maxFileSize' => 10 * 1024 * 1024, // 10MB
'maxTotalSize' => 100 * 1024 * 1024, // 100MB
'allowedMimes' => [
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
],
'allowedExtensions' => ['jpg', 'jpeg', 'png', 'gif', 'webp', 'pdf', 'doc', 'docx'],
'uploadDir' => '/var/www/uploads',
'chunkDir' => '/var/www/uploads/chunks',
'overwrite' => false,
'scanVirus' => false, // 需整合 ClamAV
];
private array $results = [];
private int $totalSize = 0;
public function __construct(array $options = []) {
$this->config = array_merge($this->config, $options);
foreach ([$this->config['uploadDir'], $this->config['chunkDir']] as $dir) {
if (!is_dir($dir)) mkdir($dir, 0755, true);
}
}
// 主處理入口
public function handle(array $files = null): array {
$files = $files ?? $_FILES;
$this->normalizeFiles($files);
$this->validateTotalSize($files);
foreach ($files as $index => $file) {
$this->processFile($file, $index);
}
return $this->results;
}
// 正規化 $_FILES 結構 (支援單檔/多檔/多欄位)
private function normalizeFiles(array &$files): void {
$normalized = [];
foreach ($files as $fieldName => $file) {
if (isset($file['tmp_name'])) {
// 結構判斷:單檔 vs 多檔
if (is_array($file['tmp_name'])) {
// 多檔:['name' => [...], 'tmp_name' => [...], ...]
$count = count($file['tmp_name']);
for ($i = 0; $i < $count; $i++) {
$normalized[] = [
'field' => $fieldName,
'name' => $file['name'][$i],
'type' => $file['type'][$i],
'tmp_name' => $file['tmp_name'][$i],
'error' => $file['error'][$i],
'size' => $file['size'][$i],
];
}
} else {
// 單檔
$normalized[] = ['field' => $fieldName] + $file;
}
}
}
$files = $normalized;
}
private function validateTotalSize(array $files): void {
$this->totalSize = array_sum(array_column($files, 'size'));
if ($this->totalSize > $this->config['maxTotalSize']) {
throw new Exception("總大小超過限制: {$this->config['maxTotalSize']} bytes");
}
}
private function processFile(array $file, int $index): void {
$result = [
'index' => $index,
'field' => $file['field'],
'original_name' => $file['name'],
'success' => false,
];
// 1. 上傳錯誤檢查
if ($file['error'] !== UPLOAD_ERR_OK) {
$result['error'] = $this->getErrorMessage($file['error']);
$this->results[] = $result;
return;
}
// 2. 檔案大小
if ($file['size'] > $this->config['maxFileSize']) {
$result['error'] = '檔案過大';
$this->results[] = $result;
return;
}
// 3. MIME 類型驗證 (finfo 比 $_FILES['type'] 可靠)
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$realMime = finfo_file($finfo, $file['tmp_name']);
finfo_close($finfo);
if (!in_array($realMime, $this->config['allowedMimes'])) {
$result['error'] = "不允許的檔案類型: $realMime";
$this->results[] = $result;
return;
}
// 4. 副檔名驗證
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
if (!in_array($ext, $this->config['allowedExtensions'])) {
$result['error'] = "不允許的副檔名: $ext";
$this->results[] = $result;
return;
}
// 5. 病毒掃描 (可選)
if ($this->config['scanVirus']) {
if (!$this->scanForVirus($file['tmp_name'])) {
$result['error'] = '病毒掃描失敗';
$this->results[] = $result;
return;
}
}
// 6. 生成安全檔名
$safeName = $this->generateSafeName($ext);
$destPath = $this->config['uploadDir'] . '/' . $safeName;
// 7. 處理重名
if (file_exists($destPath) && !$this->config['overwrite']) {
$result['error'] = '檔案已存在';
$this->results[] = $result;
return;
}
// 8. 移動檔案
if (!move_uploaded_file($file['tmp_name'], $destPath)) {
$result['error'] = '移動檔案失敗';
$this->results[] = $result;
return;
}
// 9. 設定權限
chmod($destPath, 0644);
$result['success'] = true;
$result['saved_name'] = $safeName;
$result['saved_path'] = $destPath;
$result['size'] = $file['size'];
$result['mime'] = $realMime;
$result['url'] = $this->pathToUrl($destPath);
$this->results[] = $result;
}
private function generateSafeName(string $ext): string {
// 格式: 年月日_隨機字串.副檔名
return date('Ymd') . '_' . bin2hex(random_bytes(16)) . '.' . $ext;
}
private function pathToUrl(string $path): string {
// 依專案實際路徑轉換
$base = '/uploads/';
return $base . basename($path);
}
private function scanForVirus(string $filePath): bool {
// 整合 ClamAV: clamscan --no-summary $filePath
// 回傳 0 = 乾淨, 1 = 發現病毒
$cmd = escapeshellcmd("clamscan --no-summary " . escapeshellarg($filePath));
exec($cmd, $output, $returnCode);
return $returnCode === 0;
}
private function getErrorMessage(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
// upload.php
$handler = new UploadHandler([
'maxFileSize' => 20 * 1024 * 1024,
'allowedMimes' => ['image/jpeg', 'image/png', 'image/webp'],
'allowedExtensions' => ['jpg', 'jpeg', 'png', 'webp'],
'uploadDir' => __DIR__ . '/storage/uploads',
]);
try {
$results = $handler->handle();
$success = array_filter($results, fn($r) => $r['success']);
$failed = array_filter($results, fn($r) => !$r['success']);
echo json_encode([
'success' => count($success) > 0,
'uploaded' => array_values($success),
'failed' => array_values($failed),
'total' => count($results),
], JSON_UNESCAPED_UNICODE);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['error' => $e->getMessage()]);
}
?>
<script>
class FileUploader {
constructor(options = {}) {
this.endpoint = options.endpoint || '/upload.php';
this.maxConcurrent = options.maxConcurrent || 3;
this.queue = [];
this.uploading = 0;
}
addFiles(fileList) {
for (const file of fileList) {
this.queue.push({
file,
status: 'pending',
progress: 0,
xhr: null,
});
}
this.processQueue();
}
async processQueue() {
while (this.queue.length > 0 && this.uploading < this.maxConcurrent) {
const item = this.queue.find(i => i.status === 'pending');
if (!item) break;
this.uploadFile(item);
}
}
uploadFile(item) {
item.status = 'uploading';
this.uploading++;
const formData = new FormData();
formData.append('files[]', item.file); // 陣列格式
item.xhr = new XMLHttpRequest();
item.xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
item.progress = e.loaded / e.total;
this.onProgress(item);
}
};
item.xhr.onload = () => {
this.uploading--;
if (item.xhr.status === 200) {
item.status = 'done';
item.response = JSON.parse(item.xhr.responseText);
} else {
item.status = 'error';
item.error = 'HTTP ' + item.xhr.status;
}
this.onComplete(item);
this.processQueue();
};
item.xhr.onerror = () => {
this.uploading--;
item.status = 'error';
item.error = 'Network error';
this.onComplete(item);
this.processQueue();
};
item.xhr.open('POST', this.endpoint);
item.xhr.send(formData);
}
onProgress(item) { /* 更新 UI 進度條 */ }
onComplete(item) { /* 完成處理 */ }
}
// 使用
const uploader = new FileUploader({endpoint: '/upload.php'});
document.getElementById('fileInput').addEventListener('change', (e) => {
uploader.addFiles(Array.from(e.target.files));
});
參考「PHP 大檔案上傳完整實作」一文,核心差異在於:
chunkDir 儲存分片mergeChunks() 方法file_id, chunk_index, status| 風險 | 防護 |
|---|---|
| 惡意檔案上傳 | finfo MIME 驗證 + 副檔名白名單 + 病毒掃描 |
| 檔名注入 | 隨機檔名 + 副檔名白名單 |
| 目錄遍歷 | basename() + 固定上傳目錄 |
| DoS (大量/大檔) | maxFileSize + maxTotalSize + 速率限制 |
| 腳本執行 | 上傳目錄禁用 PHP 執行 (nginx: location ~ \.php$ { deny all; }) |
多檔上傳核心在於「正規化 $_FILES → 逐檔驗證 → 安全命名 → 移動儲存 → 結構化回報」,配合前端佇列與進度回報可構建優質上傳體驗。