HTML 原生 <input type="file" multiple> 可一次選擇多個檔案,但後端需正確解析 $_FILES 多維陣列結構、執行安全驗證、防止檔名衝突。本文提供一個生產級 Upload 類別,涵蓋驗證、重命名、目錄管理、錯誤處理全流程。
單檔:$_FILES['upfile']['name'] 為字串
多檔:$_FILES['upfile']['name'] 為陣列,結構為:
$_FILES['upfile'] = [
'name' => ['a.jpg', 'b.png'],
'type' => ['image/jpeg', 'image/png'],
'tmp_name' => ['/tmp/phpX1', '/tmp/phpX2'],
'error' => [0, 0],
'size' => [12345, 67890],
];
<?php
/**
* 多檔案上傳處理類
* 支援:副檔名/MIME/大小驗證、自動重命名、目錄自建、權限檢查
*/
class Upload
{
// 公開配置
public array $allowedExt = []; // 允許副檔名,空陣列=用預設圖片 MIME
public int $maxSizeKb = 5120; // 最大 5MB
public string $saveDir = ''; // 儲存目錄 (絕對路徑)
// 內部狀態
private string $fieldName;
private bool $rename = true;
private array $files = [];
private array $results = [];
private int $fileCount = 0;
public function __construct(string $fieldName, array $allowedExt = [], bool $rename = true)
{
if (!$fieldName) throw new InvalidArgumentException('檔案欄位名稱不可為空');
$this->fieldName = $fieldName;
$this->allowedExt = $allowedExt;
$this->rename = $rename;
$this->saveDir = defined('WEBSITE_DIRROOT') ? WEBSITE_DIRROOT . ($GLOBALS['cfg_upload_path'] ?? '/uploads/') : __DIR__ . '/uploads/';
$this->initFiles();
}
/** 解析 $_FILES 統一為單檔陣列格式 */
private function initFiles(): void
{
if (!isset($_FILES[$this->fieldName])) {
throw new RuntimeException("找不到上傳欄位: {$this->fieldName}");
}
$src = $_FILES[$this->fieldName];
// 判斷是否多檔:name 為陣列且 error 非 4 (UPLOAD_ERR_NO_FILE)
if (is_array($src['name'])) {
$count = count($src['name']);
for ($i = 0; $i < $count; $i++) {
if (($src['error'][$i] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE) {
$this->files[] = [
'tmp_name' => $src['tmp_name'][$i],
'name' => $src['name'][$i],
'type' => $src['type'][$i],
'size' => $src['size'][$i],
'error' => $src['error'][$i],
];
}
}
} elseif ($src['error'] !== UPLOAD_ERR_NO_FILE) {
$this->files[] = [
'tmp_name' => $src['tmp_name'],
'name' => $src['name'],
'type' => $src['type'],
'size' => $src['size'],
'error' => $src['error'],
];
}
$this->fileCount = count($this->files);
if ($this->fileCount === 0) {
throw new RuntimeException('沒有有效的上傳檔案');
}
$this->validate();
}
/** 驗證副檔名/MIME/大小 */
private function validate(): void
{
$allowedMimes = ['image/wbmp', 'image/bmp', 'image/gif', 'image/jpeg', 'image/pjpeg', 'image/png', 'image/x-png'];
foreach ($this->files as $f) {
// 錯誤碼檢查
if ($f['error'] !== UPLOAD_ERR_OK) {
throw new RuntimeException("檔案 {$f['name']} 上傳錯誤碼: {$f['error']}");
}
// 大小檢查
if ($f['size'] > $this->maxSizeKb * 1024) {
throw new RuntimeException("檔案 {$f['name']} 超過限制 {$this->maxSizeKb}KB");
}
// 格式檢查
if ($this->allowedExt) {
$ext = strtolower(pathinfo($f['name'], PATHINFO_EXTENSION));
if (!in_array($ext, array_map('strtolower', $this->allowedExt), true)) {
throw new RuntimeException("不允許的副檔名: {$ext}");
}
} else {
if (!in_array($f['type'], $allowedMimes, true)) {
throw new RuntimeException("不允許的 MIME 類型: {$f['type']}");
}
}
}
}
/** 執行上傳 */
public function upload(): array
{
// 確保目錄存在且可寫
if (!is_dir($this->saveDir)) {
if (!mkdir($this->saveDir, 0777, true)) {
throw new RuntimeException("建立目錄失敗: {$this->saveDir}");
}
}
if (!is_writable($this->saveDir)) {
throw new RuntimeException("目錄不可寫入: {$this->saveDir}");
}
foreach ($this->files as $f) {
if (!is_uploaded_file($f['tmp_name'])) {
throw new RuntimeException("非合法上傳檔案: {$f['name']}");
}
$fileName = $f['name'];
if ($this->rename) {
$ext = strtolower(pathinfo($fileName, PATHINFO_EXTENSION));
$rand = substr(md5(uniqid('', true)), mt_rand(0, 26), 6);
$fileName = date('YmdHis') . '_' . $rand . '.' . $ext;
}
$dest = $this->saveDir . $fileName;
if (!move_uploaded_file($f['tmp_name'], $dest)) {
throw new RuntimeException("移動檔案失敗: {$fileName}");
}
// 回傳相對路徑 (相對網站根目錄)
$this->results[] = str_replace(defined('WEBSITE_DIRROOT') ? WEBSITE_DIRROOT : '', '', $dest);
}
return $this->results;
}
/** 取得上傳結果 */
public function getResults(): array
{
return $this->results;
}
}
<?php
require_once 'Upload.php';
try {
// 允許 jpg/png/gif/pdf,最大 2MB,自動重命名
$uploader = new Upload('files', ['jpg', 'jpeg', 'png', 'gif', 'pdf'], true);
$uploader->maxSizeKb = 2048;
$uploader->saveDir = '/var/www/html/uploads/';
$paths = $uploader->upload();
echo json_encode(['success' => true, 'files' => $paths]);
} catch (Throwable $e) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
}
<form action="upload.php" method="post" enctype="multipart/form-data">
<input type="file" name="files[]" multiple accept="image/*,.pdf">
<button type="submit">上傳</button>
</form>
| 檢查點 | 實作位置 | 說明 |
|---|---|---|
| 檔案數量限制 | 前端 max-files / 後端 count($files) | 防止 DoS |
| 副檔名白名單 | $allowedExt | 阻擋 .php/.exe 等可執行檔 |
| MIME 類型二次確認 | finfo_file() 或 getimagesize() | 防止偽裝副檔名 |
| 檔案大小限制 | $maxSizeKb + php.ini upload_max_filesize | 雙層防護 |
| 儲存目錄非 Web 可執行 | Nginx/Apache 設定 location ~ \.php$ { deny all; } | 防止上傳木馬執行 |
| 隨機檔名 | $rename = true | 避免覆蓋、路徑遍歷、猜測 |
| 病毒掃描 | 整合 ClamAV clamscan | 企業級需求 |
此 Upload 類別封裝了多檔案上傳的完整生命週期:解析 $_FILES、驗證、重命名、儲存、回傳路徑。實務專案可依需求擴充:圖片壓縮、水印、雲端儲存 (S3/OSS)、資料庫記錄、非同步處理佇列。核心原則:**永不信任客戶端輸入,伺服器端全方位驗證與清理**。