首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
您现在的位置: 程式碼之家 >> 編程語言 >  >> 網頁程式設計 >> PHP 程式設計 >> PHP 相關知識

PHP 多檔案上傳類別實作:驗證、安全、進度與錯誤處理

     編輯:PHP 相關知識

多檔案上傳是常見需求,需處理驗證、命名衝突、安全掃描、進度回報。本文提供生產級 UploadHandler 類別,支援單/多檔、分片、非同步處理。

核心類別:UploadHandler

<?php
class UploadHandler {
    private array $config = [
        'maxFileSize' => 10 * 1024 * 1024,     // 10MB
        'maxTotalSize' => 100 * 1024 * 1024,   // 100MB
        'allowedMimes' => [
            'image/jpeg', 'image/png', 'image/gif', 'image/webp',
            'application/pdf',
            'application/msword',
            'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
        ],
        'allowedExtensions' => ['jpg', 'jpeg', 'png', 'gif', 'webp', 'pdf', 'doc', 'docx'],
        'uploadDir' => '/var/www/uploads',
        'chunkDir' => '/var/www/uploads/chunks',
        'overwrite' => false,
        'scanVirus' => false, // 需整合 ClamAV
    ];

    private array $results = [];
    private int $totalSize = 0;

    public function __construct(array $options = []) {
        $this->config = array_merge($this->config, $options);
        foreach ([$this->config['uploadDir'], $this->config['chunkDir']] as $dir) {
            if (!is_dir($dir)) mkdir($dir, 0755, true);
        }
    }

    // 主處理入口
    public function handle(array $files = null): array {
        $files = $files ?? $_FILES;
        $this->normalizeFiles($files);
        
        $this->validateTotalSize($files);
        
        foreach ($files as $index => $file) {
            $this->processFile($file, $index);
        }
        
        return $this->results;
    }

    // 正規化 $_FILES 結構 (支援單檔/多檔/多欄位)
    private function normalizeFiles(array &$files): void {
        $normalized = [];
        foreach ($files as $fieldName => $file) {
            if (isset($file['tmp_name'])) {
                // 結構判斷:單檔 vs 多檔
                if (is_array($file['tmp_name'])) {
                    // 多檔:['name' => [...], 'tmp_name' => [...], ...]
                    $count = count($file['tmp_name']);
                    for ($i = 0; $i < $count; $i++) {
                        $normalized[] = [
                            'field' => $fieldName,
                            'name' => $file['name'][$i],
                            'type' => $file['type'][$i],
                            'tmp_name' => $file['tmp_name'][$i],
                            'error' => $file['error'][$i],
                            'size' => $file['size'][$i],
                        ];
                    }
                } else {
                    // 單檔
                    $normalized[] = ['field' => $fieldName] + $file;
                }
            }
        }
        $files = $normalized;
    }

    private function validateTotalSize(array $files): void {
        $this->totalSize = array_sum(array_column($files, 'size'));
        if ($this->totalSize > $this->config['maxTotalSize']) {
            throw new Exception("總大小超過限制: {$this->config['maxTotalSize']} bytes");
        }
    }

    private function processFile(array $file, int $index): void {
        $result = [
            'index' => $index,
            'field' => $file['field'],
            'original_name' => $file['name'],
            'success' => false,
        ];

        // 1. 上傳錯誤檢查
        if ($file['error'] !== UPLOAD_ERR_OK) {
            $result['error'] = $this->getErrorMessage($file['error']);
            $this->results[] = $result;
            return;
        }

        // 2. 檔案大小
        if ($file['size'] > $this->config['maxFileSize']) {
            $result['error'] = '檔案過大';
            $this->results[] = $result;
            return;
        }

        // 3. MIME 類型驗證 (finfo 比 $_FILES['type'] 可靠)
        $finfo = finfo_open(FILEINFO_MIME_TYPE);
        $realMime = finfo_file($finfo, $file['tmp_name']);
        finfo_close($finfo);
        
        if (!in_array($realMime, $this->config['allowedMimes'])) {
            $result['error'] = "不允許的檔案類型: $realMime";
            $this->results[] = $result;
            return;
        }

        // 4. 副檔名驗證
        $ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
        if (!in_array($ext, $this->config['allowedExtensions'])) {
            $result['error'] = "不允許的副檔名: $ext";
            $this->results[] = $result;
            return;
        }

        // 5. 病毒掃描 (可選)
        if ($this->config['scanVirus']) {
            if (!$this->scanForVirus($file['tmp_name'])) {
                $result['error'] = '病毒掃描失敗';
                $this->results[] = $result;
                return;
            }
        }

        // 6. 生成安全檔名
        $safeName = $this->generateSafeName($ext);
        $destPath = $this->config['uploadDir'] . '/' . $safeName;

        // 7. 處理重名
        if (file_exists($destPath) && !$this->config['overwrite']) {
            $result['error'] = '檔案已存在';
            $this->results[] = $result;
            return;
        }

        // 8. 移動檔案
        if (!move_uploaded_file($file['tmp_name'], $destPath)) {
            $result['error'] = '移動檔案失敗';
            $this->results[] = $result;
            return;
        }

        // 9. 設定權限
        chmod($destPath, 0644);

        $result['success'] = true;
        $result['saved_name'] = $safeName;
        $result['saved_path'] = $destPath;
        $result['size'] = $file['size'];
        $result['mime'] = $realMime;
        $result['url'] = $this->pathToUrl($destPath);
        
        $this->results[] = $result;
    }

    private function generateSafeName(string $ext): string {
        // 格式: 年月日_隨機字串.副檔名
        return date('Ymd') . '_' . bin2hex(random_bytes(16)) . '.' . $ext;
    }

    private function pathToUrl(string $path): string {
        // 依專案實際路徑轉換
        $base = '/uploads/';
        return $base . basename($path);
    }

    private function scanForVirus(string $filePath): bool {
        // 整合 ClamAV: clamscan --no-summary $filePath
        // 回傳 0 = 乾淨, 1 = 發現病毒
        $cmd = escapeshellcmd("clamscan --no-summary " . escapeshellarg($filePath));
        exec($cmd, $output, $returnCode);
        return $returnCode === 0;
    }

    private function getErrorMessage(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 => '未知上傳錯誤',
        };
    }
}
?>

使用範例

<?php
// upload.php
$handler = new UploadHandler([
    'maxFileSize' => 20 * 1024 * 1024,
    'allowedMimes' => ['image/jpeg', 'image/png', 'image/webp'],
    'allowedExtensions' => ['jpg', 'jpeg', 'png', 'webp'],
    'uploadDir' => __DIR__ . '/storage/uploads',
]);

try {
    $results = $handler->handle();
    
    $success = array_filter($results, fn($r) => $r['success']);
    $failed = array_filter($results, fn($r) => !$r['success']);
    
    echo json_encode([
        'success' => count($success) > 0,
        'uploaded' => array_values($success),
        'failed' => array_values($failed),
        'total' => count($results),
    ], JSON_UNESCAPED_UNICODE);
    
} catch (Exception $e) {
    http_response_code(500);
    echo json_encode(['error' => $e->getMessage()]);
}
?>

前端整合:進度條與佇列

<script>
class FileUploader {
    constructor(options = {}) {
        this.endpoint = options.endpoint || '/upload.php';
        this.maxConcurrent = options.maxConcurrent || 3;
        this.queue = [];
        this.uploading = 0;
    }

    addFiles(fileList) {
        for (const file of fileList) {
            this.queue.push({
                file,
                status: 'pending',
                progress: 0,
                xhr: null,
            });
        }
        this.processQueue();
    }

    async processQueue() {
        while (this.queue.length > 0 && this.uploading < this.maxConcurrent) {
            const item = this.queue.find(i => i.status === 'pending');
            if (!item) break;
            this.uploadFile(item);
        }
    }

    uploadFile(item) {
        item.status = 'uploading';
        this.uploading++;
        
        const formData = new FormData();
        formData.append('files[]', item.file); // 陣列格式
        
        item.xhr = new XMLHttpRequest();
        item.xhr.upload.onprogress = (e) => {
            if (e.lengthComputable) {
                item.progress = e.loaded / e.total;
                this.onProgress(item);
            }
        };
        
        item.xhr.onload = () => {
            this.uploading--;
            if (item.xhr.status === 200) {
                item.status = 'done';
                item.response = JSON.parse(item.xhr.responseText);
            } else {
                item.status = 'error';
                item.error = 'HTTP ' + item.xhr.status;
            }
            this.onComplete(item);
            this.processQueue();
        };
        
        item.xhr.onerror = () => {
            this.uploading--;
            item.status = 'error';
            item.error = 'Network error';
            this.onComplete(item);
            this.processQueue();
        };
        
        item.xhr.open('POST', this.endpoint);
        item.xhr.send(formData);
    }

    onProgress(item) { /* 更新 UI 進度條 */ }
    onComplete(item) { /* 完成處理 */ }
}

// 使用
const uploader = new FileUploader({endpoint: '/upload.php'});
document.getElementById('fileInput').addEventListener('change', (e) => {
    uploader.addFiles(Array.from(e.target.files));
});

分片上傳擴展 (大檔案)

參考「PHP 大檔案上傳完整實作」一文,核心差異在於:

  • 前端切片 + 計算雜湊
  • 後端 chunkDir 儲存分片
  • 新增 mergeChunks() 方法
  • 資料庫記錄 file_id, chunk_index, status

安全清單

風險防護
惡意檔案上傳finfo MIME 驗證 + 副檔名白名單 + 病毒掃描
檔名注入隨機檔名 + 副檔名白名單
目錄遍歷basename() + 固定上傳目錄
DoS (大量/大檔)maxFileSize + maxTotalSize + 速率限制
腳本執行上傳目錄禁用 PHP 執行 (nginx: location ~ \.php$ { deny all; })

多檔上傳核心在於「正規化 $_FILES → 逐檔驗證 → 安全命名 → 移動儲存 → 結構化回報」,配合前端佇列與進度回報可構建優質上傳體驗。

  1. 上一篇文章:
  2. 下一篇文章:
Copyright © 程式碼之家 All Rights Reserved