<?php
/**
* 單檔案上傳處理類別
* 使用方式:
* $upload = new Upload();
* $fileName = $upload->uploadFile($_FILES['myfile'], 2 * 1024 * 1024, 'uploads/');
* if ($fileName === false) { echo Upload::$error; }
*/
class Upload {
// 公開靜態錯誤訊息
public static string $error = '';
// 允許的 MIME 類型 (可擴充)
private array $allowedMimes = [
'image/jpeg', 'image/jpg', 'image/png', 'image/gif',
'image/webp', 'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
];
/**
* 執行上傳
* @param array $file $_FILES['field'] 陣列
* @param int $maxSize 最大允許位元組數
* @param string $destDir 目標目錄 (相對或絕對路徑)
* @return string|false 成功回傳儲存檔名,失敗回傳 false
*/
public function uploadFile(array $file, int $maxSize, string $destDir): string|false {
// 1. 重置錯誤
self::$error = '';
// 2. 基本結構驗證
if (!isset($file['error'], $file['tmp_name'], $file['name'], $file['type'], $file['size'])) {
self::$error = '無效的上傳檔案結構';
return false;
}
// 3. 上傳錯誤碼檢查
if ($file['error'] !== UPLOAD_ERR_OK) {
self::$error = $this->getUploadErrorMessage($file['error']);
return false;
}
// 4. MIME 類型驗證
if (!in_array($file['type'], $this->allowedMimes, true)) {
self::$error = '不允許的檔案類型: ' . $file['type'];
return false;
}
// 5. 檔案大小驗證
if ($file['size'] > $maxSize) {
self::$error = '檔案大小超過限制 (' . $this->formatBytes($maxSize) . ')';
return false;
}
// 6. 確保目標目錄存在
if (!$this->ensureDir($destDir)) {
self::$error = '無法建立目標目錄: ' . $destDir;
return false;
}
// 7. 產生唯一檔名
$destName = $this->generateUniqueName($file['name']);
$destPath = rtrim($destDir, '/\\') . DIRECTORY_SEPARATOR . $destName;
// 8. 移動檔案
if (!move_uploaded_file($file['tmp_name'], $destPath)) {
self::$error = '檔案移動失敗 (權限不足或磁碟空間不足)';
return false;
}
// 9. 可選:設定檔案權限
@chmod($destPath, 0644);
return $destName;
}
// --- 私有輔助方法 ---
/** 取得上傳錯誤碼對應訊息 */
private function getUploadErrorMessage(int $code): string {
return match ($code) {
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 擴充套件中止上傳',
default => '未知上傳錯誤 (代碼: ' . $code . ')',
};
}
/** 確保目錄存在,遞迴建立 */
private function ensureDir(string $dir): bool {
if (is_dir($dir)) return true;
$parent = dirname($dir);
if ($parent !== $dir && !$this->ensureDir($parent)) return false;
return @mkdir($dir, 0755, true);
}
/** 產生唯一檔名:時間戳(微秒) + 隨機字串 + 原副檔名 */
private function generateUniqueName(string $originalName): string {
$ext = pathinfo($originalName, PATHINFO_EXTENSION);
$ext = $ext ? '.' . $ext : '';
$base = pathinfo($originalName, PATHINFO_FILENAME);
// 移除危險字元
$base = preg_replace('/[^a-zA-Z0-9_\-\.]/', '_', $base);
$base = mb_substr($base, 0, 50); // 限制長度
return sprintf('%s_%s_%s%s',
date('YmdHis'),
substr(md5(microtime(true)), 0, 6),
$base,
$ext
);
}
/** 格式化位元組為人類可讀 */
private function formatBytes(int $bytes): string {
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
for ($i = 0; $bytes >= 1024 && $i < count($units) - 1; $i++) {
$bytes /= 1024;
}
return round($bytes, 2) . ' ' . $units[$i];
}
/** 設定允許的 MIME 類型 (流暢介面) */
public function setAllowedMimes(array $mimes): self {
$this->allowedMimes = $mimes;
return $this;
}
/** 取得目前允許的 MIME 類型 */
public function getAllowedMimes(): array {
return $this->allowedMimes;
}
}
<form action="upload.php" method="post" enctype="multipart/form-data"> <input type="hidden" name="MAX_FILE_SIZE" value="2097152"> <!-- 2MB 客戶端提示 --> <input type="file" name="avatar" accept="image/*" required> <button type="submit">上傳頭像</button> </form>
<?php
require_once 'Upload.php';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$upload = new Upload()
->setAllowedMimes(['image/jpeg', 'image/png', 'image/gif', 'image/webp']);
$fileName = $upload->uploadFile($_FILES['avatar'] ?? [], 2 * 1024 * 1024, 'uploads/avatars/');
if ($fileName) {
// 成功:記錄資料庫、回傳 JSON
echo json_encode([
'success' => true,
'file' => $fileName,
'url' => 'uploads/avatars/' . $fileName
]);
} else {
// 失敗
http_response_code(400);
echo json_encode(['success' => false, 'error' => Upload::$error]);
}
exit;
}
?>
finfo_file() 或 getimagesize() 驗證實際內容。
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$realMime = finfo_file($finfo, $file['tmp_name']);
if (!in_array($realMime, $this->allowedMimes)) { ... }
php_flag engine off (Apache) 或 Nginx location ~ \.php$ { deny all; } 禁止執行。generateUniqueName 已移除路徑遍歷字元,但仍建議資料庫儲存時分離儲存路徑與檔名。| 現象 | 原因 | 解決 |
|---|---|---|
| $_FILES 為空 | 表單缺少 enctype="multipart/form-data" | 檢查 form 標籤屬性 |
| UPLOAD_ERR_INI_SIZE | php.ini upload_max_filesize/post_max_size 過小 | 調整 php.ini 或 .user.ini,重啟 PHP-FPM |
| move_uploaded_file 失敗 | 目標目錄權限不足、磁碟滿、open_basedir 限制 | 檢查目錄擁有者、磁碟空間、open_basedir 設定 |
| 上傳大檔案中斷 | Nginx client_max_body_size、PHP max_execution_time、memory_limit | 調整 Nginx/PHP 相關設定 |
| 圖片上傳後無法顯示 | 輸出圖片前有 BOM/空白/錯誤訊息 | 檔案儲存為 UTF-8 無 BOM;圖片輸出腳本純淨 |
public function uploadMultiple(array $files, int $maxSize, string $destDir): array {
$results = ['success' => [], 'failed' => []];
foreach ($files['name'] as $i => $name) {
if ($files['error'][$i] === UPLOAD_ERR_NO_FILE) continue;
$single = [
'name' => $files['name'][$i],
'type' => $files['type'][$i],
'tmp_name' => $files['tmp_name'][$i],
'error' => $files['error'][$i],
'size' => $files['size'][$i],
];
$saved = $this->uploadFile($single, $maxSize, $destDir);
if ($saved) {
$results['success'][] = $saved;
} else {
$results['failed'][] = ['name' => $name, 'error' => self::$error];
}
}
return $results;
}
此 Upload 類別結構清晰、錯誤處理完善、易於擴充多檔案/雲端儲存/安全掃描,適合直接整合至現代 PHP 專案 (Laravel, Symfony, Slim 等) 或原生專案使用。