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

PHP 檔案上傳完整實作:安全驗證、多檔支援、目錄管理與錯誤處理

     編輯:PHP 相關知識

檔案上傳是 Web 應用常見功能,但也是安全風險高發區。本文提供生產級的 PHP 檔案上傳實作,涵蓋前端表單、後端驗證、安全存儲、錯誤處理與日誌記錄的完整流程。

前端表單設計

<form action="upload.php" method="post" enctype="multipart/form-data" id="upload-form">
    <input type="hidden" name="MAX_FILE_SIZE" value="2097152"> <!-- 2MB,瀏覽器端提示 -->
    <div class="form-group">
        <label>選擇檔案(支援多選)</label>
        <input type="file" name="files[]" id="file-input" multiple accept="image/*,.pdf,.doc,.docx">
    </div>
    <button type="submit" class="btn btn-primary">上傳</button>
    <div id="progress-bar" class="d-none"><div class="progress-bar" role="progressbar" style="width: 0%"></div></div>
</form>
<div id="upload-results"></div>

後端核心處理類

<?php
class FileUploader {
    private string $uploadDir;
    private array $allowedMimes;
    private array $allowedExts;
    private int $maxFileSize;
    private int $maxTotalSize;
    private string $logFile;

    public function __construct(array $config = []) {
        $this->uploadDir = $config['upload_dir'] ?? __DIR__ . '/uploads';
        $this->allowedMimes = $config['allowed_mimes'] ?? [
            'image/jpeg', 'image/png', 'image/gif', 'image/webp',
            'application/pdf',
            'application/msword',
            'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
        ];
        $this->allowedExts = $config['allowed_exts'] ?? ['jpg', 'jpeg', 'png', 'gif', 'webp', 'pdf', 'doc', 'docx'];
        $this->maxFileSize = $config['max_file_size'] ?? 2 * 1024 * 1024; // 2MB
        $this->maxTotalSize = $config['max_total_size'] ?? 10 * 1024 * 1024; // 10MB
        $this->logFile = $config['log_file'] ?? __DIR__ . '/upload.log';

        // 確保上傳目錄存在
        if (!is_dir($this->uploadDir)) {
            mkdir($this->uploadDir, 0755, true);
        }
    }

    /**
     * 處理多檔上傳
     * @return array ['success' => [], 'errors' => []]
     */
    public function handleUpload(): array {
        $results = ['success' => [], 'errors' => []];
        $totalSize = 0;

        // 整理 $_FILES 陣列結構
        $files = $this->normalizeFilesArray($_FILES['files'] ?? []);

        foreach ($files as $index => $file) {
            // 累計總大小
            $totalSize += $file['size'];
            if ($totalSize > $this->maxTotalSize) {
                $results['errors'][] = [
                    'index' => $index,
                    'name' => $file['name'],
                    'error' => '總上傳大小超過限制(' . $this->formatBytes($this->maxTotalSize) . ')'
                ];
                continue;
            }

            $validation = $this->validateFile($file);
            if ($validation !== true) {
                $results['errors'][] = ['index' => $index, 'name' => $file['name'], 'error' => $validation];
                continue;
            }

            $saved = $this->saveFile($file);
            if ($saved) {
                $results['success'][] = $saved;
                $this->log("UPLOAD SUCCESS: {$saved['original_name']} -> {$saved['stored_name']} ({$saved['size']} bytes) by {$_SERVER['REMOTE_ADDR']}");
            } else {
                $results['errors'][] = ['index' => $index, 'name' => $file['name'], 'error' => '儲存失敗'];
            }
        }

        return $results;
    }

    /** 將 $_FILES 扁平化為單檔陣列 */
    private function normalizeFilesArray(array $files): array {
        $normalized = [];
        if (isset($files['name']) && is_array($files['name'])) {
            $count = count($files['name']);
            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]
                ];
            }
        } elseif (isset($files['name'])) {
            $normalized[] = $files;
        }
        return $normalized;
    }

    /** 驗證單一檔案 */
    private function validateFile(array $file): bool|string {
        if ($file['error'] !== UPLOAD_ERR_OK) {
            return $this->getUploadErrorMessage($file['error']);
        }

        if ($file['size'] === 0) {
            return '檔案大小為 0';
        }

        if ($file['size'] > $this->maxFileSize) {
            return '檔案大小超過限制(' . $this->formatBytes($this->maxFileSize) . ')';
        }

        // MIME 類型驗證(使用 finfo 避免瀏覽器誤報)
        $finfo = new finfo(FILEINFO_MIME_TYPE);
        $realMime = $finfo->file($file['tmp_name']);
        if (!in_array($realMime, $this->allowedMimes, true)) {
            return "不允許的檔案類型:$realMime";
        }

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

        // 圖片額外驗證(檢查是否為真實圖片)
        if (str_starts_with($realMime, 'image/')) {
            $imgInfo = @getimagesize($file['tmp_name']);
            if ($imgInfo === false) {
                return '無效的圖片檔案';
            }
            // 可選:限制圖片尺寸
            // if ($imgInfo[0] > 4096 || $imgInfo[1] > 4096) return '圖片尺寸過大';
        }

        return true;
    }

    /** 儲存檔案並回傳資訊 */
    private function saveFile(array $file): ?array {
        $ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
        $storedName = bin2hex(random_bytes(16)) . ($ext ? '.' . $ext : '');
        
        // 按日期分目錄
        $dateDir = $this->uploadDir . '/' . date('Y/m/d');
        if (!is_dir($dateDir)) {
            mkdir($dateDir, 0755, true);
        }

        $targetPath = $dateDir . '/' . $storedName;

        if (!move_uploaded_file($file['tmp_name'], $targetPath)) {
            return null;
        }

        chmod($targetPath, 0644);

        return [
            'original_name' => $file['name'],
            'stored_name' => $storedName,
            'path' => $targetPath,
            'relative_path' => substr($targetPath, strlen(__DIR__)),
            'size' => $file['size'],
            'mime' => (new finfo(FILEINFO_MIME_TYPE))->file($targetPath),
            'upload_time' => date('c')
        ];
    }

    private function getUploadErrorMessage(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 => "未知上傳錯誤(代碼:$code)"
        };
    }

    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 log(string $message): void {
        $entry = date('Y-m-d H:i:s') . " $message\n";
        file_put_contents($this->logFile, $entry, FILE_APPEND | LOCK_EX);
    }
}
?>

上傳處理端點 upload.php

<?php
require 'FileUploader.php';

header('Content-Type: application/json; charset=utf-8');

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    http_response_code(405);
    echo json_encode(['error' => '僅支援 POST 方法']);
    exit;
}

try {
    $uploader = new FileUploader([
        'upload_dir' => __DIR__ . '/uploads',
        'max_file_size' => 5 * 1024 * 1024, // 5MB
        'max_total_size' => 20 * 1024 * 1024, // 20MB
        'allowed_mimes' => ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf'],
        'allowed_exts' => ['jpg', 'jpeg', 'png', 'gif', 'webp', 'pdf']
    ]);

    $result = $uploader->handleUpload();
    echo json_encode($result, JSON_UNESCAPED_UNICODE);
} catch (Throwable $e) {
    http_response_code(500);
    echo json_encode(['error' => '伺服器錯誤:' . $e->getMessage()]);
}
?>

前端 AJAX 整合(使用 Fetch API)

<script>
document.getElementById('upload-form').addEventListener('submit', async (e) => {
    e.preventDefault();
    const formData = new FormData(e.target);
    const progressBar = document.getElementById('progress-bar');
    const resultsDiv = document.getElementById('upload-results');
    
    progressBar.classList.remove('d-none');
    resultsDiv.innerHTML = '';

    try {
        const response = await fetch('upload.php', {
            method: 'POST',
            body: formData
            // 進度顯示需使用 XMLHttpRequest 或第三方庫
        });

        const result = await response.json();
        renderResults(result);
    } catch (err) {
        resultsDiv.innerHTML = '<div class="alert alert-danger">上傳失敗:' + err.message + '</div>';
    } finally {
        progressBar.classList.add('d-none');
    }
});

function renderResults(result) {
    const container = document.getElementById('upload-results');
    let html = '';

    if (result.success.length) {
        html += '<h5>上傳成功</h5><ul class="list-group">';
        result.success.forEach(file => {
            html += `<li class="list-group-item d-flex justify-content-between">
                <span>${escapeHtml(file.original_name)} (${formatBytes(file.size)})</span>
                <a href="/download.php?file=${encodeURIComponent(file.relative_path)}" class="btn btn-sm btn-outline-primary">下載</a>
            </li>`;
        });
        html += '</ul>';
    }

    if (result.errors.length) {
        html += '<h5 class="text-danger">上傳失敗</h5><ul class="list-group">';
        result.errors.forEach(err => {
            html += `<li class="list-group-item text-danger">${escapeHtml(err.name)}:${escapeHtml(err.error)}</li>`;
        });
        html += '</ul>';
    }

    container.innerHTML = html;
}

function escapeHtml(str) {
    return str.replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
}

function formatBytes(bytes) {
    if (bytes === 0) return '0 B';
    const k = 1024, sizes = ['B', 'KB', 'MB', 'GB'];
    const i = Math.floor(Math.log(bytes) / Math.log(k));
    return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
</script>

安全性關鍵點總結

  1. 檔案類型雙重驗證:MIME 偵測(finfo)+ 副檔名白名單
  2. 檔名隨機化:使用 `random_bytes()` 產生不可預測檔名
  3. 儲存路徑隔離:上傳目錄置於 Web 根目錄外,或設定 Nginx/Apache 禁止執行腳本
  4. 大小限制:單檔與總量雙重限制,防止 DoS
  5. 權限控制:檔案 0644、目錄 0755,嚴禁 777
  6. 圖片二次驗證:`getimagesize()` 確認圖片完整性
  7. 操作審計日誌:記錄 IP、檔名、時間、結果
  8. 錯誤訊息不洩露路徑:前端顯示友善訊息,詳細錯誤寫入伺服器日誌

此架構易於擴充:加入病毒掃描、圖片壓縮、水印、縮圖生成、雲端儲存同步等功能,只需在 `saveFile()` 後擴充處理流程即可。

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