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

PHP 文件上傳組件實戰:多文件、類型校驗、隨機命名、PSR-7 兼容封裝

     編輯:PHP 綜合知識

需求分析

文件上傳是 Web 應用高危功能點,必須解決:**多文件批量上傳、MIME/擴展名雙重校驗、大小限制、隨機文件名防衝突、目錄權限、移動存儲 (本地/對象存儲)、PSR-7 標準輸入兼容、錯誤碼統一**。

現代上傳組件設計 (PHP 8.1+、PSR-7/17/18)

<?php
namespace App\Http\Upload;

use Psr\Http\Message\UploadedFileInterface;
use Psr\Http\Message\ServerRequestInterface;

final class UploadHandler {
    private array $allowedMimes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
    private array $allowedExts  = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
    private int $maxSize = 2 * 1024 * 1024; // 2MB
    private string $storageRoot;
    private string $publicPrefix; // 公開訪問 URL 前綴

    public function __construct(string $storageRoot, string $publicPrefix = '/uploads') {
        $this->storageRoot = rtrim($storageRoot, '/');
        $this->publicPrefix = rtrim($publicPrefix, '/');
        if (!is_dir($this->storageRoot)) {
            mkdir($this->storageRoot, 0755, true);
        }
    }

    /** 處理單個/多個上傳文件
     * @param UploadedFileInterface|UploadedFileInterface[] $files
     * @return array ['success' => [...], 'errors' => [...]] */
    public function handle($files): array {
        $normalized = is_array($files) ? $files : [$files];
        $results = ['success' => [], 'errors' => []];

        foreach ($normalized as $index => $file) {
            if (!$file instanceof UploadedFileInterface) {
                $results['errors'][] = ['index' => $index, 'code' => 'INVALID_FILE_OBJECT', 'message' => '非法文件對象'];
                continue;
            }
            $error = $file->getError();
            if ($error !== UPLOAD_ERR_OK) {
                $results['errors'][] = ['index' => $index, 'code' => 'UPLOAD_ERROR_' . $error, 'message' => $this->uploadErrorMsg($error)];
                continue;
            }

            // 1. 大小校驗
            if ($file->getSize() > $this->maxSize) {
                $results['errors'][] = ['index' => $index, 'code' => 'EXCEED_MAX_SIZE', 'message' => "文件大小超過限制 ({$this->maxSize} bytes)"];
                continue;
            }

            // 2. MIME 類型校驗 (finfo 更可靠)
            $finfo = new \finfo(FILEINFO_MIME_TYPE);
            $mime = $finfo->file($file->getStream()->getMetadata('uri') ?? $file->getStream()->__toString()); // 臨時文件路徑
            // 注意:getStream() 返回 StreamInterface,需獲取實際路徑。Swoole/Workerman 環境可能無臨時文件,需先移動到臨時路徑。
            // 簡化:假設 $file->getStream()->getMetadata('uri') 可用
            if (!in_array($mime, $this->allowedMimes, true)) {
                $results['errors'][] = ['index' => $index, 'code' => 'INVALID_MIME', 'message' => "不支持的文件類型: $mime"];
                continue;
            }

            // 3. 擴展名校驗 (客戶端原始文件名)
            $clientFilename = $file->getClientFilename() ?? 'unknown';
            $ext = strtolower(pathinfo($clientFilename, PATHINFO_EXTENSION));
            if (!in_array($ext, $this->allowedExts, true)) {
                $results['errors'][] = ['index' => $index, 'code' => 'INVALID_EXT', 'message' => "不支持的擴展名: $ext"];
                continue;
            }

            // 4. 生成唯一存儲路徑
            $dateDir = date('Y/m/d');
            $saveDir = $this->storageRoot . '/' . $dateDir;
            if (!is_dir($saveDir)) mkdir($saveDir, 0755, true);
            $filename = bin2hex(random_bytes(16)) . '.' . $ext; // 32 字符十六進制
            $savePath = $saveDir . '/' . $filename;

            // 5. 移動文件 (PSR-7 標準方法)
            try {
                $file->moveTo($savePath);
            } catch (Throwable $e) {
                $results['errors'][] = ['index' => $index, 'code' => 'MOVE_FAILED', 'message' => '文件保存失敗: ' . $e->getMessage()];
                continue;
            }

            // 6. 記錄成功信息
            $results['success'][] = [
                'original_name' => $clientFilename,
                'mime'          => $mime,
                'size'          => $file->getSize(),
                'path'          => $dateDir . '/' . $filename, // 相對存儲根目錄
                'url'           => $this->publicPrefix . '/' . $dateDir . '/' . $filename, // 公開訪問 URL
                'saved_at'      => date('c'),
            ];
        }
        return $results;
    }

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

控制器中使用 (以 Slim/Laravel/原生 PSR-7 為例)

<?php
// routes.php
$app->post('/upload', function (ServerRequestInterface $request, ResponseInterface $response) {
    $uploadedFiles = $request->getUploadedFiles(); // PSR-7 標準:返回 ['field_name' => UploadedFileInterface|array]
    $handler = new UploadHandler('/var/www/storage', '/storage');

    $allResults = ['success' => [], 'errors' => []];
    foreach ($uploadedFiles as $fieldName => $files) {
        $results = $handler->handle($files);
        // 可選:按字段分類或合並
        $allResults['success'] = array_merge($allResults['success'], $results['success']);
        $allResults['errors'] = array_merge($allResults['errors'], $results['errors']);
    }

    $response->getBody()->write(json_encode([
        'code' => empty($allResults['errors']) ? 0 : 1,
        'data' => $allResults,
    ], JSON_UNESCAPED_UNICODE));
    return $response->withHeader('Content-Type', 'application/json');
});

前端表單 (支持多文件)

<form action="/upload" method="post" enctype="multipart/form-data" id="uploadForm">
    <input type="file" name="files[]" multiple accept="image/jpeg,image/png,image/gif,image/webp" required>
    <button type="submit">上傳</button>
</form>
<script>
document.getElementById('uploadForm').addEventListener('submit', async (e) => {
    e.preventDefault();
    const formData = new FormData(e.target);
    const resp = await fetch('/upload', {method: 'POST', body: formData, credentials: 'same-origin'});
    const json = await resp.json();
    if (json.code === 0) {
        json.data.success.forEach(f => console.log('成功:', f.url));
    } else {
        json.data.errors.forEach(err => console.error('失敗:', err));
    }
});
</script>

安全加固清單 (必須項)

風險對策
腳本文件上傳繞過 (雙重擴展名、解析漏洞)1. 存儲目錄**禁用腳本執行** (Nginx: location ~ \.php$ { deny all; } / Apache: SetHandler default-handler)
2. 不信任原始文件名,強制隨機命名
3. MIME 檢測用 finfo 而非 $_FILES['type']
大文件 DoSNginx client_max_body_size 10M、PHP upload_max_filesize/post_max_size、組件層 $maxSize 三層限制
路徑遍歷隨機文件名 + 固定日期目錄,不使用用戶輸入構建路徑
文件內容惡意代碼 (圖片馬)1. 重新壓縮/轉換圖片 (GD/Imagick imagecreatefromstring → imagejpeg) 剝離非圖片數據
2. 掃描引擎 (ClamAV) 掃描上傳文件
CSRF表單攜帶 Token、API 驗證 Header/Double-Submit Cookie
未授權訪問上傳接口需認證中間件,私有文件簽名 URL 下載

擴展:對象存儲 (S3/OSS/COS) 適配

實現 StorageAdapterInterface,LocalAdapter 用 moveTo,S3Adapter 用 PutObject,上傳組件依賴接口而非具體實現,滿足開閉原則。

總結

現代 PHP 文件上傳應遵循:**PSR-7 標準輸入、MIME+擴展名雙重校驗、隨機命名、分層大小限制、存儲目錄禁執行、圖片重壓縮剝離、統一錯誤碼、對象存儲適配**。封裝為可復用組件,控制器僅負責協調請求/響應,業務邏輯零耦合,安全可審計。

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