<?php
namespace App\Utils;
class FileUploader {
// 錯誤碼常數
public const ERR_OK = 0;
public const ERR_NO_FILE = 1;
public const ERR_UPLOAD_FAILED = 2;
public const ERR_INVALID_TYPE = 3;
public const ERR_EXCEEDS_SIZE = 4;
public const ERR_MOVE_FAILED = 5;
public const ERR_DIR_NOT_WRITABLE = 6;
private string $uploadDir;
private array $allowedMimes = [];
private array $allowedExts = [];
private int $maxSize; // bytes
private bool $rename = true;
private int $permissions = 0644;
public function __construct(string $uploadDir, array $options = []) {
$this->uploadDir = rtrim($uploadDir, '/');
$this->allowedMimes = $options['mimes'] ?? ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf'];
$this->allowedExts = $options['exts'] ?? ['jpg', 'jpeg', 'png', 'gif', 'webp', 'pdf'];
$this->maxSize = ($options['max_size_mb'] ?? 10) * 1024 * 1024;
$this->rename = $options['rename'] ?? true;
$this->permissions = $options['permissions'] ?? 0644;
if (!is_dir($this->uploadDir)) {
mkdir($this->uploadDir, 0755, true);
}
}
/**
* 處理單檔上傳
* @param array $_FILES['field'] 陣列
* @return array ['code' => int, 'message' => string, 'data' => array|null]
*/
public function upload(array $file): array {
// 1. 基礎檢查
if (!isset($file['error']) || $file['error'] !== UPLOAD_ERR_OK) {
return $this->error(self::ERR_NO_FILE, $this->getUploadErrorMsg($file['error'] ?? UPLOAD_ERR_NO_FILE));
}
if (!is_uploaded_file($file['tmp_name'])) {
return $this->error(self::ERR_UPLOAD_FAILED, '非合法上傳檔案');
}
// 2. 大小檢查
if ($file['size'] > $this->maxSize) {
return $this->error(self::ERR_EXCEEDS_SIZE, "檔案大小超過限制 ({$this->formatBytes($this->maxSize)})");
}
// 3. 類型檢查(MIME + 副檔名)
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_file($finfo, $file['tmp_name']);
finfo_close($finfo);
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
if (!in_array($mime, $this->allowedMimes, true) || !in_array($ext, $this->allowedExts, true)) {
return $this->error(self::ERR_INVALID_TYPE, "不支援的檔案類型: $mime / .$ext");
}
// 4. 產生儲存檔名
$filename = $this->rename
? $this->generateSafeName($ext)
: $this->sanitizeFilename($file['name']);
$destPath = $this->uploadDir . '/' . $filename;
// 5. 避免覆蓋(理論上 UUID 不會衝突,雙重保險)
if (file_exists($destPath)) {
$filename = $this->generateSafeName($ext);
$destPath = $this->uploadDir . '/' . $filename;
}
// 6. 移動檔案
if (!move_uploaded_file($file['tmp_name'], $destPath)) {
return $this->error(self::ERR_MOVE_FAILED, '檔案移動失敗,請檢查目錄權限');
}
// 7. 設定權限
@chmod($destPath, $this->permissions);
return [
'code' => self::ERR_OK,
'message' => '上傳成功',
'data' => [
'filename' => $filename,
'original_name' => $file['name'],
'path' => $destPath,
'url' => $this->pathToUrl($destPath),
'size' => $file['size'],
'mime' => $mime,
'ext' => $ext,
]
];
}
/**
* 批次上傳
* @param array $_FILES['field'] (多檔結構)
* @return array 每個檔案的結果陣列
*/
public function uploadMultiple(array $files): array {
// 正規化多檔陣列結構
$normalized = $this->normalizeFilesArray($files);
$results = [];
foreach ($normalized as $file) {
$results[] = $this->upload($file);
}
return $results;
}
// ===== 私有輔助方法 =====
private function generateSafeName(string $ext): string {
return bin2hex(random_bytes(16)) . '.' . $ext; // 32 字元 hex
}
private function sanitizeFilename(string $name): string {
// 移除路徑資訊、特殊字元,保留中文
$name = basename($name);
$name = preg_replace('/[^\w\-\.\p{Han}]/u', '_', $name);
return $name;
}
private function normalizeFilesArray(array $files): array {
// $_FILES 多檔結構:['name' => [...], 'tmp_name' => [...], ...]
// 轉為單檔陣列列表
if (!isset($files['name']) || !is_array($files['name'])) {
return [$files]; // 單檔
}
$count = count($files['name']);
$normalized = [];
for ($i = 0; $i < $count; $i++) {
$normalized[] = [
'name' => $files['name'][$i],
'type' => $files['type'][$i],
'tmp_name' => $files['tmp_name'][$i],
'error' => $files['error'][$i],
'size' => $files['size'][$i],
];
}
return $normalized;
}
private function getUploadErrorMsg(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 => '未知上傳錯誤',
};
}
private function formatBytes(int $bytes): string {
$units = ['B', 'KB', 'MB', 'GB'];
$i = 0;
while ($bytes >= 1024 && $i < count($units) - 1) {
$bytes /= 1024;
$i++;
}
return round($bytes, 2) . ' ' . $units[$i];
}
private function pathToUrl(string $path): string {
// 根據專案實際情況實現:返回公開可訪問的 URL
return '/uploads/' . str_replace($this->uploadDir . '/', '', $path);
}
private function error(int $code, string $msg): array {
return ['code' => $code, 'message' => $msg, 'data' => null];
}
}
?>
<?php
// Laravel / ThinkPHP / 原生 PHP 通用
$uploader = new \App\Utils\FileUploader(
__DIR__ . '/../public/uploads',
[
'mimes' => ['image/jpeg', 'image/png', 'image/webp'],
'exts' => ['jpg', 'jpeg', 'png', 'webp'],
'max_size_mb' => 5,
'rename' => true,
]
);
// 單檔
if (isset($_FILES['avatar'])) {
$result = $uploader->upload($_FILES['avatar']);
if ($result['code'] === 0) {
// 存入資料庫
$user->avatar = $result['data']['url'];
$user->save();
echo json_encode(['success' => true, 'url' => $result['data']['url']]);
} else {
http_response_code(400);
echo json_encode(['success' => false, 'msg' => $result['message']]);
}
}
// 多檔
if (isset($_FILES['gallery'])) {
$results = $uploader->uploadMultiple($_FILES['gallery']);
$success = array_filter($results, fn($r) => $r['code'] === 0);
echo json_encode(['success' => true, 'files' => $success]);
}
?>
<form action="/upload" method="post" enctype="multipart/form-data">
<!-- 單檔 -->
<input type="file" name="avatar" accept="image/jpeg,image/png,image/webp" required>
<!-- 多檔 -->
<input type="file" name="gallery[]" accept="image/*" multiple>
<button type="submit">上傳</button>
</form>
| 風險 | 防護措施 |
|---|---|
| 惡意腳本上傳 | MIME 檢測 + 副檔名白名單 + 重命名無副檔名執行權限 |
| 大檔案 DoS | php.ini upload_max_filesize, post_max_size + 類內 maxSize |
| 路徑遍歷 | basename() + 重命名策略 |
| 檔案覆蓋 | UUID 命名 + 存在性檢查 |
| 中文檔名亂碼 | 資料庫存原始名,檔案系統用 UUID;或 sanitizeFilename 保留 Unicode |
| 圖片處理漏洞 | 上傳後用 GD/Imagick 重新編碼(imagecreatefromstring -> imagejpeg) |
<?php
// 上傳成功後自動生成縮圖
function generateThumbnails(string $sourcePath, array $sizes = [[200,200], [400,400]]): array {
$results = [];
foreach ($sizes as [$w, $h]) {
$dest = preg_replace('/\.(\w+)$/', "_${w}x${h}.\$1", $sourcePath);
$img = imagecreatefromstring(file_get_contents($sourcePath));
if ($img) {
$thumb = imagescale($img, $w, $h, IMG_BICUBIC);
imagejpeg($thumb, $dest, 85);
imagedestroy($thumb);
imagedestroy($img);
$results[] = $dest;
}
}
return $results;
}
// WebP 轉換(現代瀏覽器優先)
function convertToWebp(string $sourcePath, int $quality = 80): ?string {
$img = imagecreatefromstring(file_get_contents($sourcePath));
if (!$img) return null;
$dest = preg_replace('/\.\w+$/', '.webp', $sourcePath);
$ok = imagewebp($img, $dest, $quality);
imagedestroy($img);
return $ok ? $dest : null;
}
?>
檔案上傳看似簡單,實則涉及安全、編碼、效能、使用者體驗多個維度。本文提供的 FileUploader 類採用「白名單驗證 + UUID 命名 + 權限最小化 + 失敗快速返回」原則,已在多個生產專案驗證。建議根據業務擴充:整合雲存儲、病毒掃描、CDN 刷新等後續流程。