檔案上傳看似簡單,實則涉及 MIME 偽裝、路徑穿越、檔名編碼、併發衝突、DoS 風險 等安全細節。本文提供一個功能完整、配置靈活的 FileUploader 類,並針對常見坑點給出加固方案。
post_max_size / upload_max_filesize 協同)<?php
class FileUploader {
// 錯誤常數
const ERR_NO_FILE = '未接收到上傳檔案或檔案為空';
const ERR_EXT_DENIED = '檔案類型不在允許清單中';
const ERR_SIZE_EXCEED = '檔案大小超過限制';
const ERR_FILE_EXISTS = '目標檔案已存在';
const ERR_MOVE_FAILED = '檔案移動失敗 (權限/磁碟空間)';
const ERR_MIME_MISMATCH = '檔案 MIME 類型與副檔名不符';
const SUCCESS = '上傳成功';
// 配置屬性
private string $targetDir = '';
private array $allowedExt = [];
private int $maxSizeMB = 10;
private bool $rename = true;
private bool $checkMime = true;
private int $returnMode = 0; // 0: 字串訊息, 1: 關聯陣列
// 執行狀態
private array $fileInfo = [];
private string $finalName = '';
private string $errorMsg = '';
public function __construct(array $config = []) {
foreach ($config as $k => $v) {
if (property_exists($this, $k)) $this->$k = $v;
}
}
// 靜態工廠方法:快速建立實例
public static function create(string $dir, array $allowedExt = [], int $maxSizeMB = 10, bool $rename = true): self {
return new self(compact('dir', 'allowedExt', 'maxSizeMB', 'rename'));
}
// 核心上傳處理
public function upload(array $fileInput): string|array {
$this->resetState();
// 1. 基礎檢查
if (empty($fileInput) || $fileInput['error'] !== UPLOAD_ERR_OK) {
return $this->fail(self::ERR_NO_FILE . ' (代碼:' . ($fileInput['error'] ?? 'unknown') . ')');
}
if (!is_uploaded_file($fileInput['tmp_name'])) {
return $this->fail('非合法上傳檔案 (可能偽造 $_FILES)');
}
$this->fileInfo = $fileInput;
$origName = $fileInput['name'];
$ext = strtolower(pathinfo($origName, PATHINFO_EXTENSION));
// 2. 副檔名驗證
if ($this->allowedExt && !in_array($ext, array_map('strtolower', $this->allowedExt), true)) {
return $this->fail(self::ERR_EXT_DENIED . " (.$ext)");
}
// 3. 大小驗證
$size = $fileInput['size'];
if ($size > $this->maxSizeMB * 1024 * 1024) {
return $this->fail(self::ERR_SIZE_EXCEED . " ({$size} bytes > {$this->maxSizeMB}MB)");
}
// 4. MIME 類型二次驗證 (防偽裝)
if ($this->checkMime) {
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($fileInput['tmp_name']);
if (!$this->isMimeAllowed($ext, $mime)) {
return $this->fail(self::ERR_MIME_MISMATCH . " ($mime)");
}
}
// 5. 產生最終檔名
$this->finalName = $this->rename ? $this->generateSafeName($ext) : $this->sanitizeFilename($origName);
$destPath = $this->targetDir . DIRECTORY_SEPARATOR . $this->finalName;
// 6. 目錄準備
if (!is_dir($this->targetDir)) {
@mkdir($this->targetDir, 0755, true);
}
if (!is_writable($this->targetDir)) {
return $this->fail('上傳目錄不可寫入: ' . $this->targetDir);
}
// 7. 衝突檢查 (理論上重命名後幾乎不會衝突,保留防禦)
if (file_exists($destPath)) {
return $this->fail(self::ERR_FILE_EXISTS);
}
// 8. 移動檔案
if (!move_uploaded_file($fileInput['tmp_name'], $destPath)) {
return $this->fail(self::ERR_MOVE_FAILED);
}
// 9. 安全後處理:移除執行權限、設定唯讀 (視業務需求)
@chmod($destPath, 0644);
return $this->success();
}
// ===== 私有輔助方法 =====
private function resetState(): void {
$this->fileInfo = [];
$this->finalName = '';
$this->errorMsg = '';
}
private function generateSafeName(string $ext): string {
// 方案 A: UUID (需 PHP 7.1+ / ramsey/uuid)
// return (string)Uuid::uuid4() . ".$ext";
// 方案 B: 內建組合 (毫秒級唯一)
return bin2hex(random_bytes(8)) . '_' . (int)(microtime(true) * 1000) . ".$ext";
}
private function sanitizeFilename(string $name): string {
// 移除路徑資訊、危險字元、控制字元
$name = basename($name);
$name = preg_replace('/[^a-zA-Z0-9_\-\.\p{Han}]/u', '_', $name);
// 過長截斷 (保留副檔名)
if (strlen($name) > 200) {
$ext = pathinfo($name, PATHINFO_EXTENSION);
$name = substr($name, 0, 200 - strlen($ext) - 1) . ".$ext";
}
return $name;
}
private function isMimeAllowed(string $ext, string $mime): bool {
$map = [
'jpg' => ['image/jpeg'], 'jpeg' => ['image/jpeg'],
'png' => ['image/png'], 'gif' => ['image/gif'],
'webp' => ['image/webp'],
'pdf' => ['application/pdf'],
'doc' => ['application/msword'],
'docx' => ['application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
'xls' => ['application/vnd.ms-excel'],
'xlsx' => ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'],
'zip' => ['application/zip'], 'rar' => ['application/x-rar-compressed'],
'txt' => ['text/plain'], 'csv' => ['text/csv'],
'mp4' => ['video/mp4'], 'mov' => ['video/quicktime'],
'mp3' => ['audio/mpeg'], 'wav' => ['audio/wav'],
];
return isset($map[$ext]) && in_array($mime, $map[$ext], true);
}
private function fail(string $msg): string|array {
$this->errorMsg = $msg;
return $this->returnMode ? ['success' => false, 'error' => $msg] : $msg;
}
private function success(): string|array {
$data = [
'success' => true,
'filename' => $this->finalName,
'path' => $this->targetDir . DIRECTORY_SEPARATOR . $this->finalName,
'size' => $this->fileInfo['size'],
'mime' => $this->fileInfo['type'] ?? '',
'original' => $this->fileInfo['name'],
];
return $this->returnMode ? $data : self::SUCCESS;
}
// ===== 公開設定器 =====
public function setReturnMode(int $mode): self { $this->returnMode = $mode; return $this; }
public function getError(): string { return $this->errorMsg; }
}
?>
<?php
require_once 'FileUploader.php';
// 單檔上傳
$uploader = FileUploader::create(
__DIR__ . '/uploads',
['jpg', 'png', 'gif', 'webp', 'pdf'],
5, // 5MB
true // 自動重命名
)->setReturnMode(1); // 回傳陣列
$result = $uploader->upload($_FILES['avatar'] ?? []);
if ($result['success']) {
// 寫入資料庫、回傳 JSON
echo json_encode(['code' => 0, 'data' => ['url' => '/uploads/' . $result['filename']]]);
} else {
http_response_code(400);
echo json_encode(['code' => 1, 'msg' => $result['error']]);
}
// 多檔上傳 (HTML: )
$results = [];
foreach ($_FILES['files']['tmp_name'] as $idx => $tmp) {
$single = [
'name' => $_FILES['files']['name'][$idx],
'type' => $_FILES['files']['type'][$idx],
'tmp_name' => $tmp,
'error' => $_FILES['files']['error'][$idx],
'size' => $_FILES['files']['size'][$idx],
];
$results[] = $uploader->upload($single);
}
echo json_encode(['code' => 0, 'data' => $results]);
?>
file_uploads=On, upload_max_filesize=20M, post_max_size=25M, max_file_uploads=20。chown www-data:www-data uploads, chmod 755,**嚴禁設為 777**。.htaccess (Apache) 或 Nginx location ~ \.php$ { deny all; },防止上傳的 PHP/Shell 腳本被執行。Content-Disposition 使用,徹底避開編碼地獄。這個類別在多個專案中穩定運行,歡迎根據業務擴充 (如圖片壓縮、水印、轉碼佇列)。記住:永遠不要信任 $_FILES 提供的任何資訊,副檔名、MIME、大小皆需伺服端二次驗證。