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

PHP 多檔案上傳完整實作:從表單構建到安全存儲的最佳實踐

     編輯:PHP 綜合知識

需求場景

使用者單次提交多個檔案(如圖片庫、文件批量匯入),伺服端需驗證、重命名、分類存儲並回傳結果。本文提供**現代化、安全優先**的完整實作,修正原始碼的變數注入、路徑穿越、MIME 偽裝等風險。

前端:HTML5 多檔案輸入

<!DOCTYPE html>
<html lang="zh-Hant">
<head>
  <meta charset="UTF-8">
  <title>多檔案上傳示範</title>
  <style>
    .drop-zone { border:2px dashed #ccc; padding:2rem; text-align:center; transition:border-color .2s; }
    .drop-zone.drag-over { border-color:#007bff; background:#e7f1ff; }
    .file-list { margin-top:1rem; }
    .file-item { display:flex; align-items:center; gap:.5rem; padding:.5rem; background:#f8f9fa; margin:.25rem 0; }
    .progress { flex:1; height:.5rem; background:#dee2e6; border-radius:.25rem; overflow:hidden; }
    .progress-bar { height:100%; background:#007bff; width:0; transition:width .2s; }
    .btn { padding:.5rem 1rem; border:none; border-radius:.25rem; cursor:pointer; }
    .btn-primary { background:#007bff; color:#fff; }
    .btn:disabled { opacity:.6; cursor:not-allowed; }
  </style>
</head>
<body>
  <h1>多檔案上傳</h1>
  <form id="uploadForm" enctype="multipart/form-data">
    <div class="drop-zone" id="dropZone">
      <input type="file" id="fileInput" name="files[]" multiple accept="image/*,.pdf,.doc,.docx" hidden>
      <label for="fileInput" class="btn btn-primary">選擇檔案</label>
      <p class="hint">或拖曳檔案到此區域</p>
    </div>
    <div class="file-list" id="fileList"></div>
    <button type="submit" class="btn btn-primary" id="submitBtn" disabled>開始上傳</button>
  </form>

  <script>
  const dropZone = document.getElementById('dropZone');
  const fileInput = document.getElementById('fileInput');
  const fileList = document.getElementById('fileList');
  const submitBtn = document.getElementById('submitBtn');
  const filesMap = new Map(); // name -> {file, elem, xhr}

  // 拖曳視覺
  ['dragenter','dragover'].forEach(e => dropZone.addEventListener(e, e => { e.preventDefault(); dropZone.classList.add('drag-over'); }));
  ['dragleave','drop'].forEach(e => dropZone.addEventListener(e, e => { e.preventDefault(); dropZone.classList.remove('drag-over'); }));
  dropZone.addEventListener('drop', e => fileInput.files = e.dataTransfer.files);

  // 檔案選擇後建立列表
  fileInput.addEventListener('change', () => {
    fileList.innerHTML = '';
    filesMap.clear();
    Array.from(fileInput.files).forEach((file, i) => {
      const id = 'f' + i + '_' + Date.now();
      filesMap.set(id, {file, elem: null, xhr: null});
      const div = document.createElement('div');
      div.className = 'file-item';
      div.dataset.id = id;
      div.innerHTML = `
        <span class="fname" title="${escapeHtml(file.name)}">${escapeHtml(file.name)}</span>
        <span class="fsize">${formatSize(file.size)}</span>
        <div class="progress"><div class="progress-bar"></div></div>
        <span class="fstatus">待上傳</span>
        <button type="button" class="btn btn-sm btn-danger remove-btn">移除</button>
      `;
      fileList.appendChild(div);
      filesMap.get(id).elem = div;
      div.querySelector('.remove-btn').onclick = () => removeFile(id);
    });
    submitBtn.disabled = filesMap.size === 0;
  });

  function removeFile(id) {
    const entry = filesMap.get(id);
    if (entry?.xhr) entry.xhr.abort();
    entry?.elem?.remove();
    filesMap.delete(id);
    submitBtn.disabled = filesMap.size === 0;
  }

  // 表單提交:逐檔上傳並顯示進度
  document.getElementById('uploadForm').addEventListener('submit', async e => {
    e.preventDefault();
    submitBtn.disabled = true;
    for (const [id, entry] of filesMap) {
      await uploadFile(id, entry);
    }
    submitBtn.disabled = false;
    alert('所有檔案處理完成');
  });

  async function uploadFile(id, entry) {
    const {file, elem} = entry;
    const formData = new FormData();
    formData.append('file', file);
    // 可加入 CSRF token 等額外欄位
    // formData.append('_token', '{{ csrf_token() }}');

    entry.xhr = new XMLHttpRequest();
    entry.xhr.upload.onprogress = e => {
      if (e.lengthComputable) {
        const pct = Math.round(e.loaded / e.total * 100);
        elem.querySelector('.progress-bar').style.width = pct + '%';
      }
    };
    entry.xhr.onload = () => {
      elem.querySelector('.fstatus').textContent = entry.xhr.status === 200 ? '成功' : '失敗: ' + entry.xhr.status;
      try {
        const res = JSON.parse(entry.xhr.responseText);
        if (res.path) elem.dataset.serverPath = res.path;
      } catch {}
    };
    entry.xhr.onerror = () => elem.querySelector('.fstatus').textContent = '網路錯誤';
    entry.xhr.open('POST', 'upload_handler.php', true);
    entry.xhr.send(formData);

    return new Promise(r => entry.xhr.onload = () => { entry.xhr.onload(); r(); });
  }

  function escapeHtml(s) { return s.replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
  function formatSize(b) { return b<1024?b+'B':b<1048576?(b/1024).toFixed(1)+'KB':(b/1048576).toFixed(1)+'MB'; }
  </script>
</body>
</html>

後端:upload_handler.php — 安全處理核心

<?php
declare(strict_types=1);

// ─── 配置區 ───
const UPLOAD_ROOT   = __DIR__ . '/storage/uploads';   // 非 Web 可直接訪問目錄
const MAX_FILE_SIZE = 10 * 1024 * 1024;               // 10 MB
const ALLOWED_MIME  = [
    'image/jpeg', 'image/png', 'image/gif', 'image/webp',
    'application/pdf',
    'application/msword',
    'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
];
const ALLOWED_EXT   = ['jpg','jpeg','png','gif','webp','pdf','doc','docx'];

// ─── 工具函數 ───
function jsonResponse(array $data, int $code = 200): never {
    http_response_code($code);
    header('Content-Type: application/json; charset=UTF-8');
    echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
    exit;
}

function secureFilename(string $original): string {
    $ext = strtolower(pathinfo($original, PATHINFO_EXTENSION));
    $ext = in_array($ext, ALLOWED_EXT, true) ? $ext : 'bin';
    return bin2hex(random_bytes(16)) . '.' . $ext;
}

function verifyMime(string $tmpPath): ?string {
    $finfo = new finfo(FILEINFO_MIME_TYPE);
    $mime  = $finfo->file($tmpPath);
    return in_array($mime, ALLOWED_MIME, true) ? $mime : null;
}

// ─── 主流程 ───
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    jsonResponse(['error' => 'Method not allowed'], 405);
}

if (!isset($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) {
    jsonResponse(['error' => 'No file or upload error: ' . ($_FILES['file']['error'] ?? 'unknown')], 400);
}

$file = $_FILES['file'];

// 1. 大小檢查
if ($file['size'] > MAX_FILE_SIZE) {
    jsonResponse(['error' => 'File too large'], 413);
}

// 2. MIME 驗證(防偽裝)
$mime = verifyMime($file['tmp_name']);
if (!$mime) {
    jsonResponse(['error' => 'File type not allowed'], 415);
}

// 3. 目標路徑:按日期分目錄,避免單目錄檔案過多
$dateDir = date('Y/m/d');
$targetDir = UPLOAD_ROOT . '/' . $dateDir;
if (!is_dir($targetDir) && !mkdir($targetDir, 0750, true)) {
    jsonResponse(['error' => 'Failed to create target directory'], 500);
}

$filename = secureFilename($file['name']);
$targetPath = $targetDir . '/' . $filename;

// 4. 移動檔案(原子操作)
if (!move_uploaded_file($file['tmp_name'], $targetPath)) {
    jsonResponse(['error' => 'Failed to move uploaded file'], 500);
}

// 5. 設定權限(非執行)
chmod($targetPath, 0640);

// 6. 回傳相對路徑供前端記錄/資料庫存儲
$relativePath = 'storage/uploads/' . $dateDir . '/' . $filename;
jsonResponse([
    'success' => true,
    'original_name' => $file['name'],
    'stored_name'   => $filename,
    'path'          => $relativePath,
    'size'          => $file['size'],
    'mime'          => $mime,
]);
?>

進階擴展建議

  • 斷點續傳:前端使用 spark-md5 計算分片雜湊,後端支援 Content-Range 合併分片(參考 tus.io 協議)。
  • 病毒掃描:整合 ClamAV(clamscan 或 clamd)非同步掃描,通過才移至正式區。
  • 圖片處理:上傳後觸發隊列任務(Redis + Worker),使用 Intervention/Image 或 Imagick 生成縮圖、WebP 轉換、EXIF 剝離。
  • 物件儲存:將 move_uploaded_file 替換為 S3 SDK putObject,配合預簽名 URL 直傳,減輕應用伺服器頻寬。
  • 資料庫記錄:建立 uploads 表(id, original_name, stored_path, mime, size, uploader_id, created_at, status, scan_result)供管理與審計。

原始碼常見漏洞對照修正

原始碼問題風險本文修正
$$upfile 變數變數變數注入,可覆蓋任意全域變數固定 $_FILES['file'] 單檔處理,前端多檔案逐個 AJAX
copy($$upfile, $filename)不檢查 is_uploaded_file(),可讀取伺服器任意檔案move_uploaded_file() 強制驗證上傳來源
直接使用 $file['name'] 存檔路徑穿越 (../../etc/passwd)、覆蓋系統檔、XSS隨機命名 + 白名單副檔名 + 非 Web 目錄
無 MIME 驗證上傳 .php.jpg 繞過副檔名檢查執行腳本finfo 實際內容檢測 + 擴展名白名單雙重驗證
無大小限制DoS 佔滿磁碟MAX_FILE_SIZE + upload_max_filesize + post_max_size 三層防護
檔案上傳是 Web 應用最高風險入口之一。**「不信任客戶端任何資訊,伺服端全量驗證」** 是唯一原則。
  1. 上一篇文章:
  2. 下一篇文章:
Copyright © 程式碼之家 All Rights Reserved