文件上傳是 Web 開發的基礎功能,但安全性、驗證邏輯、擴展性往往被忽視。本文提供一個生產級的 PHP 多文件上傳類,包含 MIME 類型驗證、文件頭魔數檢測、大小限制、隨機命名、單位轉換等完整功能,並採用面向對象設計,易於擴展支持任意文件類型。
$_FILES 數組遍歷)<?php
/**
* 多文件上傳處理類
* 支持圖片/文檔/壓縮包等任意類型,核心驗證邏輯可擴展
*/
class MultiFileUpload
{
// ===== 可配置屬性 =====
public string $uploadDir = '/uploadfile/'; // 上傳根目錄(需可寫權限)
public array $allowedMimeTypes = [ // 允許的 MIME 類型
'image/gif', 'image/jpeg', 'image/png', 'image/webp',
'application/pdf', 'text/plain',
'application/zip', 'application/x-rar-compressed',
];
public array $allowedExtensions = [ // 允許的副檔名(二次驗證)
'gif', 'jpg', 'jpeg', 'png', 'webp',
'pdf', 'txt', 'zip', 'rar',
];
public int $maxFileSize = 10 * 1024 * 1024; // 10MB 單文件限制
public int $maxTotalSize = 50 * 1024 * 1024; // 50MB 總上傳限制
// ===== 內部狀態 =====
private array $files = []; // $_FILES 規範化數據
private array $results = []; // 處理結果
private int $totalSize = 0; // 本次上傳總大小
// ===== 文件頭魔數特徵庫(十六進制簽名)=====
private array $magicSignatures = [
'jpeg' => ['ffd8ff'], // JPEG: FF D8 FF
'png' => ['89504e47'], // PNG: 89 50 4E 47
'gif' => ['47494638'], // GIF: 47 49 46 38 (GIF8)
'pdf' => ['25504446'], // PDF: 25 50 44 46 (%PDF)
'zip' => ['504b0304', '504b0506', '504b0708'], // ZIP 多種簽名
'rar' => ['52617221'], // RAR: 52 61 72 21 (Rar!)
];
/**
* 構造函數:接收 $_FILES 並規範化
*/
public function __construct(array $files = [])
{
$this->files = $this->normalizeFiles($files ?: $_FILES);
}
/**
* 執行上傳處理
* @return array ['success' => [...], 'failed' => [...], 'total_size' => int]
*/
public function upload(): array
{
$this->results = ['success' => [], 'failed' => []];
$this->totalSize = 0;
foreach ($this->files as $index => $file) {
$result = $this->processSingleFile($file, $index);
if ($result['success']) {
$this->results['success'][] = $result;
$this->totalSize += $file['size'];
} else {
$this->results['failed'][] = $result;
}
}
$this->results['total_size'] = $this->totalSize;
return $this->results;
}
/**
* 規範化 $_FILES 結構,支持單文件、多文件、多字段
*/
private function normalizeFiles(array $rawFiles): array
{
$normalized = [];
foreach ($rawFiles as $fieldName => $fileData) {
if (isset($fileData['name']) && is_array($fileData['name'])) {
// 多文件字段:name="files[]"
$count = count($fileData['name']);
for ($i = 0; $i < $count; $i++) {
$normalized[] = [
'field' => $fieldName,
'name' => $fileData['name'][$i],
'type' => $fileData['type'][$i],
'tmp_name' => $fileData['tmp_name'][$i],
'error' => $fileData['error'][$i],
'size' => $fileData['size'][$i],
];
}
} else {
// 單文件字段
$normalized[] = [
'field' => $fieldName,
'name' => $fileData['name'],
'type' => $fileData['type'],
'tmp_name' => $fileData['tmp_name'],
'error' => $fileData['error'],
'size' => $fileData['size'],
];
}
}
return $normalized;
}
/**
* 處理單個文件完整流程
*/
private function processSingleFile(array $file, int $index): array
{
$baseResult = [
'index' => $index,
'field' => $file['field'],
'original_name' => $file['name'],
'size' => $file['size'],
'size_human' => $this->formatSize($file['size']),
'mime' => $file['type'],
];
// 1. 上傳錯誤碼檢查
if ($file['error'] !== UPLOAD_ERR_OK) {
return array_merge($baseResult, [
'success' => false,
'error_code' => $file['error'],
'message' => $this->getUploadErrorMessage($file['error']),
]);
}
// 2. 文件大小檢查
if ($file['size'] > $this->maxFileSize) {
return array_merge($baseResult, [
'success' => false,
'message' => "文件大小超過限制 ({$this->formatSize($this->maxFileSize)})",
]);
}
// 3. 總大小檢查
if (($this->totalSize + $file['size']) > $this->maxTotalSize) {
return array_merge($baseResult, [
'success' => false,
'message' => "總上傳大小超過限制 ({$this->formatSize($this->maxTotalSize)})",
]);
}
// 4. MIME 類型檢查
if (!in_array($file['type'], $this->allowedMimeTypes, true)) {
return array_merge($baseResult, [
'success' => false,
'message' => "不支持的文件類型: {$file['type']}",
]);
}
// 5. 副檔名檢查
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
if (!in_array($ext, $this->allowedExtensions, true)) {
return array_merge($baseResult, [
'success' => false,
'message' => "不支持的文件副檔名: .{$ext}",
]);
}
// 6. 文件頭魔數驗證(核心安全防禦)
if (!$this->verifyMagicNumber($file['tmp_name'], $ext)) {
return array_merge($baseResult, [
'success' => false,
'message' => "文件內容與聲明類型不符,疑似偽造上傳",
]);
}
// 7. 生成唯一文件名並移動
$newName = $this->generateUniqueName($ext);
$destPath = rtrim($this->uploadDir, '/') . '/' . $newName;
// 確保目錄存在
$dir = dirname($destPath);
if (!is_dir($dir) && !mkdir($dir, 0755, true)) {
return array_merge($baseResult, [
'success' => false,
'message' => "無法創建上傳目錄: {$dir}",
]);
}
if (!move_uploaded_file($file['tmp_name'], $destPath)) {
return array_merge($baseResult, [
'success' => false,
'message' => "文件移動失敗,請檢查目錄權限",
]);
}
// 8. 成功返回
return array_merge($baseResult, [
'success' => true,
'saved_name' => $newName,
'saved_path' => $destPath,
'url' => $this->getPublicUrl($newName), // 需根據實際部署實現
'message' => '上傳成功',
]);
}
/**
* 文件頭魔數驗證:讀取前 8-12 字節匹配特徵碼
*/
private function verifyMagicNumber(string $tmpPath, string $ext): bool
{
if (!isset($this->magicSignatures[$ext])) {
return true; // 未定義特徵碼的類型跳過驗證(或可選擇拒絕)
}
$handle = fopen($tmpPath, 'rb');
if (!$handle) return false;
$header = fread($handle, 12); // 讀取足夠字節覆蓋所有特徵碼
fclose($handle);
if ($header === false || strlen($header) < 4) return false;
$hexHeader = bin2hex($header);
foreach ($this->magicSignatures[$ext] as $signature) {
if (str_starts_with($hexHeader, $signature)) {
return true;
}
}
return false;
}
/**
* 生成唯一文件名:md5(uniqid + microtime) + 副檔名
*/
private function generateUniqueName(string $ext): string
{
return md5(uniqid((string)microtime(true), true)) . '.' . $ext;
}
/**
* 文件大小人性化格式化
*/
private function formatSize(int $bytes): string
{
if ($bytes < 1024) return "{$bytes} B";
if ($bytes < 1024 * 1024) return round($bytes / 1024, 1) . " KB";
if ($bytes < 1024 * 1024 * 1024) return round($bytes / (1024 * 1024), 1) . " MB";
return round($bytes / (1024 * 1024 * 1024), 1) . " GB";
}
/**
* 上傳錯誤碼轉中文提示
*/
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})",
};
}
/**
* 獲取公開訪問 URL(需根據實際部署覆寫)
*/
protected function getPublicUrl(string $filename): string
{
// 示例:return "https://cdn.example.com/uploads/{$filename}";
return "/uploads/{$filename}";
}
}
<form action="upload.php" method="post" enctype="multipart/form-data">
<input type="file" name="images[]" multiple accept="image/*,.pdf,.zip">
<input type="file" name="documents[]" multiple accept=".pdf,.doc,.txt">
<button type="submit">上傳</button>
</form>
<?php
// upload.php
require_once 'MultiFileUpload.php';
$uploader = new MultiFileUpload();
// 可選:自定義配置
$uploader->uploadDir = __DIR__ . '/storage/uploads/';
$uploader->maxFileSize = 5 * 1024 * 1024; // 5MB
$uploader->allowedMimeTypes = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf'];
$uploader->allowedExtensions = ['jpg', 'jpeg', 'png', 'webp', 'pdf'];
$results = $uploader->upload();
// 輸出結果
header('Content-Type: application/json');
echo json_encode([
'success_count' => count($results['success']),
'failed_count' => count($results['failed']),
'total_size' => $results['total_size'],
'total_size_human' => (new MultiFileUpload())->formatSize($results['total_size']),
'details' => $results,
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
// 繼承並擴展魔數特徵庫
class ExtendedUpload extends MultiFileUpload
{
protected array $magicSignatures = [
// 父類簽名...
'docx' => ['504b0304'], // Office Open XML (實際是 ZIP 結構)
'xlsx' => ['504b0304'],
'pptx' => ['504b0304'],
'mp4' => ['6674797069736f6d'], // ftypisom
];
}
// 在 processSingleFile 成功移動後調用
if ($result['success'] && str_starts_with($file['type'], 'image/')) {
$this->generateThumbnail($destPath);
$this->compressImage($destPath, 85);
}
前端使用 XMLHttpRequest 或 fetch 配合 FormData,後端返回 JSON,前端輪詢或 WebSocket 接收進度。
php_flag engine off (Apache) 或 location ~ \.php$ { deny all; } (Nginx)這個 MultiFileUpload 類採用單一職責原則,將驗證、存儲、命名、統計分離,核心邏輯集中在 processSingleFile(),易於測試、擴展、維護。魔數驗證是防止偽造上傳的關鍵防線,生產環境務必啟用。根據業務需求調整允許類型、大小限制、存儲路徑即可直接投入使用。