上傳類設計目標
- 封裝
$_FILES 處理邏輯
- 支援多種檔案型別驗證 (MIME + 副檔名雙重檢查)
- 可配置大小限制、儲存目錄、命名規則
- 防止惡意檔案上傳、路徑遍歷攻擊
- 提供流暢介面 (Fluent Interface) 鏈式呼叫
完整上傳類實作
<?php
class FileUploader {
private $file; // $_FILES 項目
private $allowedTypes = []; // 允許的 MIME 型別
private $allowedExts = []; // 允許的副檔名
private $maxSize = 2 * 1024 * 1024; // 預設 2MB
private $saveDir = 'uploads/';
private $nameRule = 'hash'; // 'hash' | 'original' | 'timestamp' | callable
private $overwrite = false;
private $errors = [];
public function __construct(array $file = null) {
if ($file) $this->setFile($file);
}
// 流暢介面設定器
public function setFile(array $file) { $this->file = $file; return $this; }
public function allowTypes(array $types) { $this->allowedTypes = $types; return $this; }
public function allowExts(array $exts) { $this->allowedExts = array_map('strtolower', $exts); return $this; }
public function maxSize(int $bytes) { $this->maxSize = $bytes; return $this; }
public function saveTo(string $dir) { $this->saveDir = rtrim($dir, '/') . '/'; return $this; }
public function nameRule($rule) { $this->nameRule = $rule; return $this; }
public function allowOverwrite(bool $flag) { $this->overwrite = $flag; return $this; }
// 核心驗證
private function validate() {
if (!$this->file || $this->file['error'] !== UPLOAD_ERR_OK) {
$this->errors[] = '上傳失敗或無檔案: ' . $this->getUploadErrorMsg();
return false;
}
// 檔案大小
if ($this->file['size'] > $this->maxSize) {
$this->errors[] = sprintf('檔案過大 (%.2f MB),限制 %.2f MB',
$this->file['size'] / 1048576, $this->maxSize / 1048576);
}
// MIME 型別 (需啟用 fileinfo 擴展)
if ($this->allowedTypes) {
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($this->file['tmp_name']);
if (!in_array($mime, $this->allowedTypes, true)) {
$this->errors[] = "檔案類型不允許: $mime";
}
}
// 副檔名
if ($this->allowedExts) {
$ext = strtolower(pathinfo($this->file['name'], PATHINFO_EXTENSION));
if (!in_array($ext, $this->allowedExts, true)) {
$this->errors[] = "副檔名不允許: $ext";
}
}
// 目錄可寫
if (!is_dir($this->saveDir)) {
if (!mkdir($this->saveDir, 0755, true) || !is_writable($this->saveDir)) {
$this->errors[] = "儲存目錄不可寫: {$this->saveDir}";
}
}
return empty($this->errors);
}
// 產生儲存檔名
private function generateFilename() {
$ext = strtolower(pathinfo($this->file['name'], PATHINFO_EXTENSION));
$ext = $ext ? ".$ext" : '';
switch (true) {
case $this->nameRule === 'hash':
return hash_file('sha256', $this->file['tmp_name']) . $ext;
case $this->nameRule === 'timestamp':
return date('YmdHis') . '_' . mt_rand(1000,9999) . $ext;
case $this->nameRule === 'original':
return basename($this->file['name']);
case is_callable($this->nameRule):
return call_user_func($this->nameRule, $this->file) . $ext;
default:
return uniqid('up_', true) . $ext;
}
}
// 執行上傳
public function upload() {
if (!$this->validate()) return false;
$filename = $this->generateFilename();
$dest = $this->saveDir . $filename;
// 防止覆蓋
if (!$this->overwrite && file_exists($dest)) {
$this->errors[] = "檔案已存在: $filename";
return false;
}
// 移動檔案 (安全:驗證是真正上傳檔案)
if (!move_uploaded_file($this->file['tmp_name'], $dest)) {
$this->errors[] = '檔案移動失敗,請檢查權限';
return false;
}
// 設定合理權限
@chmod($dest, 0644);
return [
'filename' => $filename,
'path' => $dest,
'url' => $this->getPublicUrl($filename),
'size' => $this->file['size'],
'mime' => $this->getMimeType(),
'ext' => pathinfo($filename, PATHINFO_EXTENSION)
];
}
public function getErrors() { return $this->errors; }
public function hasErrors() { return !empty($this->errors); }
private function getUploadErrorMsg() {
$msgs = [
UPLOAD_ERR_INI_SIZE => '超過 php.ini upload_max_filesize',
UPLOAD_ERR_FORM_SIZE => '超過表單 MAX_FILE_SIZE',
UPLOAD_ERR_PARTIAL => '檔案上傳不完整',
UPLOAD_ERR_NO_FILE => '未選擇檔案',
UPLOAD_ERR_NO_TMP_DIR => '缺少暫存目錄',
UPLOAD_ERR_CANT_WRITE => '寫入磁碟失敗',
UPLOAD_ERR_EXTENSION => 'PHP 擴展中斷上傳'
];
return $msgs[$this->file['error']] ?? '未知錯誤';
}
private function getMimeType() {
if (function_exists('finfo_open')) {
$finfo = new finfo(FILEINFO_MIME_TYPE);
return $finfo->file($this->file['tmp_name']);
}
return $this->file['type']; // 瀏覽器提供,不可信
}
private function getPublicUrl($filename) {
// 根據實際 Web 根目錄對應調整
return '/uploads/' . $filename;
}
}
使用範例
<?php
// 圖片上傳
$uploader = new FileUploader($_FILES['avatar'])
->allowTypes(['image/jpeg', 'image/png', 'image/gif', 'image/webp'])
->allowExts(['jpg', 'jpeg', 'png', 'gif', 'webp'])
->maxSize(5 * 1024 * 1024) // 5MB
->saveTo(__DIR__ . '/public/uploads/avatars')
->nameRule('hash'); // 內容哈希命名,自動去重
$result = $uploader->upload();
if ($result) {
echo "上傳成功: {$result['url']}";
// 儲存 $result['filename'] 到資料庫
} else {
foreach ($uploader->getErrors() as $err) {
echo "錯誤: $err\n";
}
}
// 文件上傳 (自訂命名)
$docUploader = new FileUploader($_FILES['document'])
->allowTypes(['application/pdf', 'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document'])
->nameRule(function($file) {
return 'doc_' . date('Ymd') . '_' . pathinfo($file['name'], PATHINFO_FILENAME);
});
?>
安全性關鍵點
- MIME 型別用 finfo 驗證,不信任
$_FILES['type']
- 副檔名白名單,雙重保險
- 儲存目錄非 Web 可執行:Nginx/Apache 設定
location ~ \.php$ { deny all; }
- 檔名重命名,防止路徑遍歷、覆蓋、執行惡意腳本
- 大小限制雙層:PHP
upload_max_filesize + 程式碼邏輯