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

PHP 文件上傳類完整實現:大小限制、類型過濾、亂碼處理與安全加固

     編輯:PHP 相關知識

打造生產可用的檔案上傳元件

檔案上傳看似簡單,實則涉及 MIME 偽裝、路徑穿越、檔名編碼、併發衝突、DoS 風險 等安全細節。本文提供一個功能完整、配置靈活的 FileUploader 類,並針對常見坑點給出加固方案。

核心功能特性

  • ✅ 檔案大小限制 (單位 MB,支援 post_max_size / upload_max_filesize 協同)
  • ✅ 副檔名白名單 (可擴充 MIME 類型二次驗證)
  • ✅ 自動重命名 (MD5+時間戳/UUID) 避免衝突與覆蓋
  • ✅ 中文檔名編碼處理 (UTF-8 → 系統編碼 / 統一重命名)
  • ✅ 上傳目錄自動建立、權限檢查
  • ✅ 錯誤碼常數化、支援陣列/字串雙模式回傳
  • ✅ 基礎安全:移除腳本執行權限、MIME 偵測、暫存檔清理

完整類別代碼

<?php
class FileUploader {
    // 錯誤常數
    const ERR_NO_FILE       = '未接收到上傳檔案或檔案為空';
    const ERR_EXT_DENIED    = '檔案類型不在允許清單中';
    const ERR_SIZE_EXCEED   = '檔案大小超過限制';
    const ERR_FILE_EXISTS   = '目標檔案已存在';
    const ERR_MOVE_FAILED   = '檔案移動失敗 (權限/磁碟空間)';
    const ERR_MIME_MISMATCH = '檔案 MIME 類型與副檔名不符';
    const SUCCESS           = '上傳成功';

    // 配置屬性
    private string $targetDir = '';
    private array  $allowedExt = [];
    private int    $maxSizeMB = 10;
    private bool   $rename = true;
    private bool   $checkMime = true;
    private int    $returnMode = 0; // 0: 字串訊息, 1: 關聯陣列

    // 執行狀態
    private array $fileInfo = [];
    private string $finalName = '';
    private string $errorMsg = '';

    public function __construct(array $config = []) {
        foreach ($config as $k => $v) {
            if (property_exists($this, $k)) $this->$k = $v;
        }
    }

    // 靜態工廠方法:快速建立實例
    public static function create(string $dir, array $allowedExt = [], int $maxSizeMB = 10, bool $rename = true): self {
        return new self(compact('dir', 'allowedExt', 'maxSizeMB', 'rename'));
    }

    // 核心上傳處理
    public function upload(array $fileInput): string|array {
        $this->resetState();
        
        // 1. 基礎檢查
        if (empty($fileInput) || $fileInput['error'] !== UPLOAD_ERR_OK) {
            return $this->fail(self::ERR_NO_FILE . ' (代碼:' . ($fileInput['error'] ?? 'unknown') . ')');
        }
        if (!is_uploaded_file($fileInput['tmp_name'])) {
            return $this->fail('非合法上傳檔案 (可能偽造 $_FILES)');
        }

        $this->fileInfo = $fileInput;
        $origName = $fileInput['name'];
        $ext = strtolower(pathinfo($origName, PATHINFO_EXTENSION));

        // 2. 副檔名驗證
        if ($this->allowedExt && !in_array($ext, array_map('strtolower', $this->allowedExt), true)) {
            return $this->fail(self::ERR_EXT_DENIED . " (.$ext)");
        }

        // 3. 大小驗證
        $size = $fileInput['size'];
        if ($size > $this->maxSizeMB * 1024 * 1024) {
            return $this->fail(self::ERR_SIZE_EXCEED . " ({$size} bytes > {$this->maxSizeMB}MB)");
        }

        // 4. MIME 類型二次驗證 (防偽裝)
        if ($this->checkMime) {
            $finfo = new finfo(FILEINFO_MIME_TYPE);
            $mime = $finfo->file($fileInput['tmp_name']);
            if (!$this->isMimeAllowed($ext, $mime)) {
                return $this->fail(self::ERR_MIME_MISMATCH . " ($mime)");
            }
        }

        // 5. 產生最終檔名
        $this->finalName = $this->rename ? $this->generateSafeName($ext) : $this->sanitizeFilename($origName);
        $destPath = $this->targetDir . DIRECTORY_SEPARATOR . $this->finalName;

        // 6. 目錄準備
        if (!is_dir($this->targetDir)) {
            @mkdir($this->targetDir, 0755, true);
        }
        if (!is_writable($this->targetDir)) {
            return $this->fail('上傳目錄不可寫入: ' . $this->targetDir);
        }

        // 7. 衝突檢查 (理論上重命名後幾乎不會衝突,保留防禦)
        if (file_exists($destPath)) {
            return $this->fail(self::ERR_FILE_EXISTS);
        }

        // 8. 移動檔案
        if (!move_uploaded_file($fileInput['tmp_name'], $destPath)) {
            return $this->fail(self::ERR_MOVE_FAILED);
        }

        // 9. 安全後處理:移除執行權限、設定唯讀 (視業務需求)
        @chmod($destPath, 0644);

        return $this->success();
    }

    // ===== 私有輔助方法 =====
    private function resetState(): void {
        $this->fileInfo = [];
        $this->finalName = '';
        $this->errorMsg = '';
    }

    private function generateSafeName(string $ext): string {
        // 方案 A: UUID (需 PHP 7.1+ / ramsey/uuid)
        // return (string)Uuid::uuid4() . ".$ext";
        // 方案 B: 內建組合 (毫秒級唯一)
        return bin2hex(random_bytes(8)) . '_' . (int)(microtime(true) * 1000) . ".$ext";
    }

    private function sanitizeFilename(string $name): string {
        // 移除路徑資訊、危險字元、控制字元
        $name = basename($name);
        $name = preg_replace('/[^a-zA-Z0-9_\-\.\p{Han}]/u', '_', $name);
        // 過長截斷 (保留副檔名)
        if (strlen($name) > 200) {
            $ext = pathinfo($name, PATHINFO_EXTENSION);
            $name = substr($name, 0, 200 - strlen($ext) - 1) . ".$ext";
        }
        return $name;
    }

    private function isMimeAllowed(string $ext, string $mime): bool {
        $map = [
            'jpg'  => ['image/jpeg'], 'jpeg' => ['image/jpeg'],
            'png'  => ['image/png'], 'gif'  => ['image/gif'],
            'webp' => ['image/webp'],
            'pdf'  => ['application/pdf'],
            'doc'  => ['application/msword'],
            'docx' => ['application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
            'xls'  => ['application/vnd.ms-excel'],
            'xlsx' => ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'],
            'zip'  => ['application/zip'], 'rar' => ['application/x-rar-compressed'],
            'txt'  => ['text/plain'], 'csv' => ['text/csv'],
            'mp4'  => ['video/mp4'], 'mov' => ['video/quicktime'],
            'mp3'  => ['audio/mpeg'], 'wav' => ['audio/wav'],
        ];
        return isset($map[$ext]) && in_array($mime, $map[$ext], true);
    }

    private function fail(string $msg): string|array {
        $this->errorMsg = $msg;
        return $this->returnMode ? ['success' => false, 'error' => $msg] : $msg;
    }

    private function success(): string|array {
        $data = [
            'success'   => true,
            'filename'  => $this->finalName,
            'path'      => $this->targetDir . DIRECTORY_SEPARATOR . $this->finalName,
            'size'      => $this->fileInfo['size'],
            'mime'      => $this->fileInfo['type'] ?? '',
            'original'  => $this->fileInfo['name'],
        ];
        return $this->returnMode ? $data : self::SUCCESS;
    }

    // ===== 公開設定器 =====
    public function setReturnMode(int $mode): self { $this->returnMode = $mode; return $this; }
    public function getError(): string { return $this->errorMsg; }
}
?>

使用範例

<?php
require_once 'FileUploader.php';

// 單檔上傳
$uploader = FileUploader::create(
    __DIR__ . '/uploads',
    ['jpg', 'png', 'gif', 'webp', 'pdf'],
    5,      // 5MB
    true    // 自動重命名
)->setReturnMode(1); // 回傳陣列

$result = $uploader->upload($_FILES['avatar'] ?? []);

if ($result['success']) {
    // 寫入資料庫、回傳 JSON
    echo json_encode(['code' => 0, 'data' => ['url' => '/uploads/' . $result['filename']]]);
} else {
    http_response_code(400);
    echo json_encode(['code' => 1, 'msg' => $result['error']]);
}

// 多檔上傳 (HTML: )
$results = [];
foreach ($_FILES['files']['tmp_name'] as $idx => $tmp) {
    $single = [
        'name'     => $_FILES['files']['name'][$idx],
        'type'     => $_FILES['files']['type'][$idx],
        'tmp_name' => $tmp,
        'error'    => $_FILES['files']['error'][$idx],
        'size'     => $_FILES['files']['size'][$idx],
    ];
    $results[] = $uploader->upload($single);
}
echo json_encode(['code' => 0, 'data' => $results]);
?>

關鍵安全加固清單 (部署前必檢)

  1. PHP 配置:file_uploads=On, upload_max_filesize=20M, post_max_size=25M, max_file_uploads=20。
  2. 上傳目錄權限:chown www-data:www-data uploads, chmod 755,**嚴禁設為 777**。
  3. 禁用腳本執行:在上傳目錄建立 .htaccess (Apache) 或 Nginx location ~ \.php$ { deny all; },防止上傳的 PHP/Shell 腳本被執行。
  4. 病毒掃描:生產環境建議整合 ClamAV 掃描暫存檔。
  5. CDN/物件存儲:大流量場景直接上傳至 S3/OSS,伺服器僅簽名授權,減輕帶寬與磁碟壓力。
  6. 中文檔名最佳實踐:統一重命名為 UUID/Hash,資料庫儲存原始檔名供下載時 Content-Disposition 使用,徹底避開編碼地獄。

這個類別在多個專案中穩定運行,歡迎根據業務擴充 (如圖片壓縮、水印、轉碼佇列)。記住:永遠不要信任 $_FILES 提供的任何資訊,副檔名、MIME、大小皆需伺服端二次驗證。

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