文件上傳是 Web 應用高危功能點,必須解決:**多文件批量上傳、MIME/擴展名雙重校驗、大小限制、隨機文件名防衝突、目錄權限、移動存儲 (本地/對象存儲)、PSR-7 標準輸入兼容、錯誤碼統一**。
<?php
namespace App\Http\Upload;
use Psr\Http\Message\UploadedFileInterface;
use Psr\Http\Message\ServerRequestInterface;
final class UploadHandler {
private array $allowedMimes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
private array $allowedExts = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
private int $maxSize = 2 * 1024 * 1024; // 2MB
private string $storageRoot;
private string $publicPrefix; // 公開訪問 URL 前綴
public function __construct(string $storageRoot, string $publicPrefix = '/uploads') {
$this->storageRoot = rtrim($storageRoot, '/');
$this->publicPrefix = rtrim($publicPrefix, '/');
if (!is_dir($this->storageRoot)) {
mkdir($this->storageRoot, 0755, true);
}
}
/** 處理單個/多個上傳文件
* @param UploadedFileInterface|UploadedFileInterface[] $files
* @return array ['success' => [...], 'errors' => [...]] */
public function handle($files): array {
$normalized = is_array($files) ? $files : [$files];
$results = ['success' => [], 'errors' => []];
foreach ($normalized as $index => $file) {
if (!$file instanceof UploadedFileInterface) {
$results['errors'][] = ['index' => $index, 'code' => 'INVALID_FILE_OBJECT', 'message' => '非法文件對象'];
continue;
}
$error = $file->getError();
if ($error !== UPLOAD_ERR_OK) {
$results['errors'][] = ['index' => $index, 'code' => 'UPLOAD_ERROR_' . $error, 'message' => $this->uploadErrorMsg($error)];
continue;
}
// 1. 大小校驗
if ($file->getSize() > $this->maxSize) {
$results['errors'][] = ['index' => $index, 'code' => 'EXCEED_MAX_SIZE', 'message' => "文件大小超過限制 ({$this->maxSize} bytes)"];
continue;
}
// 2. MIME 類型校驗 (finfo 更可靠)
$finfo = new \finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($file->getStream()->getMetadata('uri') ?? $file->getStream()->__toString()); // 臨時文件路徑
// 注意:getStream() 返回 StreamInterface,需獲取實際路徑。Swoole/Workerman 環境可能無臨時文件,需先移動到臨時路徑。
// 簡化:假設 $file->getStream()->getMetadata('uri') 可用
if (!in_array($mime, $this->allowedMimes, true)) {
$results['errors'][] = ['index' => $index, 'code' => 'INVALID_MIME', 'message' => "不支持的文件類型: $mime"];
continue;
}
// 3. 擴展名校驗 (客戶端原始文件名)
$clientFilename = $file->getClientFilename() ?? 'unknown';
$ext = strtolower(pathinfo($clientFilename, PATHINFO_EXTENSION));
if (!in_array($ext, $this->allowedExts, true)) {
$results['errors'][] = ['index' => $index, 'code' => 'INVALID_EXT', 'message' => "不支持的擴展名: $ext"];
continue;
}
// 4. 生成唯一存儲路徑
$dateDir = date('Y/m/d');
$saveDir = $this->storageRoot . '/' . $dateDir;
if (!is_dir($saveDir)) mkdir($saveDir, 0755, true);
$filename = bin2hex(random_bytes(16)) . '.' . $ext; // 32 字符十六進制
$savePath = $saveDir . '/' . $filename;
// 5. 移動文件 (PSR-7 標準方法)
try {
$file->moveTo($savePath);
} catch (Throwable $e) {
$results['errors'][] = ['index' => $index, 'code' => 'MOVE_FAILED', 'message' => '文件保存失敗: ' . $e->getMessage()];
continue;
}
// 6. 記錄成功信息
$results['success'][] = [
'original_name' => $clientFilename,
'mime' => $mime,
'size' => $file->getSize(),
'path' => $dateDir . '/' . $filename, // 相對存儲根目錄
'url' => $this->publicPrefix . '/' . $dateDir . '/' . $filename, // 公開訪問 URL
'saved_at' => date('c'),
];
}
return $results;
}
private function uploadErrorMsg(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
// routes.php
$app->post('/upload', function (ServerRequestInterface $request, ResponseInterface $response) {
$uploadedFiles = $request->getUploadedFiles(); // PSR-7 標準:返回 ['field_name' => UploadedFileInterface|array]
$handler = new UploadHandler('/var/www/storage', '/storage');
$allResults = ['success' => [], 'errors' => []];
foreach ($uploadedFiles as $fieldName => $files) {
$results = $handler->handle($files);
// 可選:按字段分類或合並
$allResults['success'] = array_merge($allResults['success'], $results['success']);
$allResults['errors'] = array_merge($allResults['errors'], $results['errors']);
}
$response->getBody()->write(json_encode([
'code' => empty($allResults['errors']) ? 0 : 1,
'data' => $allResults,
], JSON_UNESCAPED_UNICODE));
return $response->withHeader('Content-Type', 'application/json');
});
<form action="/upload" method="post" enctype="multipart/form-data" id="uploadForm">
<input type="file" name="files[]" multiple accept="image/jpeg,image/png,image/gif,image/webp" required>
<button type="submit">上傳</button>
</form>
<script>
document.getElementById('uploadForm').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const resp = await fetch('/upload', {method: 'POST', body: formData, credentials: 'same-origin'});
const json = await resp.json();
if (json.code === 0) {
json.data.success.forEach(f => console.log('成功:', f.url));
} else {
json.data.errors.forEach(err => console.error('失敗:', err));
}
});
</script>
| 風險 | 對策 |
|---|---|
| 腳本文件上傳繞過 (雙重擴展名、解析漏洞) | 1. 存儲目錄**禁用腳本執行** (Nginx: location ~ \.php$ { deny all; } / Apache: SetHandler default-handler)2. 不信任原始文件名,強制隨機命名 3. MIME 檢測用 finfo 而非 $_FILES['type'] |
| 大文件 DoS | Nginx client_max_body_size 10M、PHP upload_max_filesize/post_max_size、組件層 $maxSize 三層限制 |
| 路徑遍歷 | 隨機文件名 + 固定日期目錄,不使用用戶輸入構建路徑 |
| 文件內容惡意代碼 (圖片馬) | 1. 重新壓縮/轉換圖片 (GD/Imagick imagecreatefromstring → imagejpeg) 剝離非圖片數據2. 掃描引擎 (ClamAV) 掃描上傳文件 |
| CSRF | 表單攜帶 Token、API 驗證 Header/Double-Submit Cookie |
| 未授權訪問 | 上傳接口需認證中間件,私有文件簽名 URL 下載 |
實現 StorageAdapterInterface,LocalAdapter 用 moveTo,S3Adapter 用 PutObject,上傳組件依賴接口而非具體實現,滿足開閉原則。
現代 PHP 文件上傳應遵循:**PSR-7 標準輸入、MIME+擴展名雙重校驗、隨機命名、分層大小限制、存儲目錄禁執行、圖片重壓縮剝離、統一錯誤碼、對象存儲適配**。封裝為可復用組件,控制器僅負責協調請求/響應,業務邏輯零耦合,安全可審計。