使用者單次提交多個檔案(如圖片庫、文件批量匯入),伺服端需驗證、重命名、分類存儲並回傳結果。本文提供**現代化、安全優先**的完整實作,修正原始碼的變數注入、路徑穿越、MIME 偽裝等風險。
<!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>
<?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)非同步掃描,通過才移至正式區。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 應用最高風險入口之一。**「不信任客戶端任何資訊,伺服端全量驗證」** 是唯一原則。