現代批量上傳採用 HTML5 <input type="file" multiple>,前端可預覽、顯示進度;後端遍歷 $_FILES['files']['tmp_name'] 陣列,執行類型/大小/惡意代碼檢測、安全重命名、原子移動。本文提供完整前後端代碼、安全清單、進度條實現方向。
<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>批量上傳</title>
<style>
.preview {display:flex; flex-wrap:wrap; gap:10px; margin-top:10px;}
.preview img {width:100px; height:100px; object-fit:cover; border:1px solid #ddd;}
.progress {width:100%; height:20px; background:#eee; margin-top:10px; display:none;}
.progress-bar {height:100%; background:#007bff; width:0%; transition:width .3s;}
</style></head><body>
<h2>圖片批量上傳</h2>
<form id="uploadForm" enctype="multipart/form-data">
<input type="file" name="files[]" id="fileInput" multiple accept="image/*" required>
<div class="preview" id="preview"></div>
<div class="progress" id="progress"><div class="progress-bar" id="progressBar"></div></div>
<button type="submit">開始上傳</button>
</form>
<script>
const input = document.getElementById('fileInput');
const preview = document.getElementById('preview');
const form = document.getElementById('uploadForm');
const progress = document.getElementById('progress');
const bar = document.getElementById('progressBar');
// 文件選擇後本地預覽
input.addEventListener('change', () => {
preview.innerHTML = '';
[...input.files].forEach(file => {
if (!file.type.startsWith('image/')) return;
const url = URL.createObjectURL(file);
const img = document.createElement('img');
img.src = url;
img.title = file.name + ' (' + (file.size/1024).toFixed(1) + ' KB)';
preview.appendChild(img);
});
});
// AJAX 提交(支持進度條)
form.addEventListener('submit', async (e) => {
e.preventDefault();
if (!input.files.length) return alert('請選擇文件');
const fd = new FormData();
[...input.files].forEach(f => fd.append('files[]', f));
progress.style.display = 'block';
bar.style.width = '0%';
try {
const res = await fetch('upload.php', {
method: 'POST',
body: fd,
// 進度監聽需使用 XMLHttpRequest
});
const data = await res.json();
alert(data.message);
if (data.success) { input.value = ''; preview.innerHTML = ''; }
} catch (err) {
alert('上傳失敗: ' + err.message);
}
progress.style.display = 'none';
});
// 真正的進度條需用 XMLHttpRequest
function uploadWithProgress(fd) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) bar.style.width = (e.loaded / e.total * 100) + '%';
};
xhr.onload = () => resolve(JSON.parse(xhr.responseText));
xhr.onerror = () => reject(new Error('Network error'));
xhr.open('POST', 'upload.php');
xhr.send(fd);
});
}
</script>
</body></html>
<?php
// upload.php
header('Content-Type: application/json');
$config = [
'max_size' => 5 * 1024 * 1024, // 5MB
'allowed_types' => ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
'allowed_exts' => ['jpg', 'jpeg', 'png', 'gif', 'webp'],
'save_dir' => __DIR__ . '/uploads/',
'max_files' => 20, // 單次最多文件數
];
// 確保目錄存在且不可執行腳本
if (!is_dir($config['save_dir'])) {
mkdir($config['save_dir'], 0755, true);
// .htaccess 禁止執行(Apache)
file_put_contents($config['save_dir'] . '.htaccess', "SetHandler default-handler\nOptions -ExecCGI\nphp_flag engine off");
}
$files = $_FILES['files'] ?? null;
if (!$files || !is_array($files['name'])) {
echo json_encode(['success' => false, 'message' => '無文件上傳']);
exit;
}
$count = count($files['name']);
if ($count > $config['max_files']) {
echo json_encode(['success' => false, 'message' => "單次最多上傳 {$config['max_files']} 個文件"]);
exit;
}
$uploaded = [];
$errors = [];
for ($i = 0; $i < $count; $i++) {
$name = $files['name'][$i];
$tmpName = $files['tmp_name'][$i];
$size = $files['size'][$i];
$error = $files['error'][$i];
$type = $files['type'][$i]; // 瀏覽器提供,不可信
// 1. 上傳錯誤碼檢查
if ($error !== UPLOAD_ERR_OK) {
$errors[] = "$name: 上傳錯誤代碼 $error";
continue;
}
// 2. 文件大小
if ($size > $config['max_size']) {
$errors[] = "$name: 文件過大 (限制 {$config['max_size']} bytes)";
continue;
}
// 3. MIME 類型驗證(finfo 基於魔數,可靠)
$finfo = new finfo(FILEINFO_MIME_TYPE);
$realMime = $finfo->file($tmpName);
if (!in_array($realMime, $config['allowed_types'], true)) {
$errors[] = "$name: 類型不允許 ($realMime)";
continue;
}
// 4. 副檔名驗證(雙重保險)
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
if (!in_array($ext, $config['allowed_exts'], true)) {
$errors[] = "$name: 副檔名不允許 ($ext)";
continue;
}
// 5. 圖片內容二次驗證(getimagesize 解析頭部)
$imgInfo = @getimagesize($tmpName);
if (!$imgInfo) {
$errors[] = "$name: 非有效圖片文件";
continue;
}
// 6. 生成安全文件名:隨機 + 時間戳 + 正確副檔名
$safeName = bin2hex(random_bytes(8)) . '_' . time() . '.' . $ext;
$destPath = $config['save_dir'] . $safeName;
// 7. 原子移動(上傳文件已在臨時目錄,move_uploaded_file 檢查是否真為上傳文件)
if (!move_uploaded_file($tmpName, $destPath)) {
$errors[] = "$name: 移動失敗";
continue;
}
// 8. 可選:生成縮略圖、記錄數據庫、掃描病毒...
$uploaded[] = [
'original' => $name,
'saved' => $safeName,
'path' => $destPath,
'size' => $size,
'mime' => $realMime,
'width' => $imgInfo[0],
'height' => $imgInfo[1],
];
}
echo json_encode([
'success' => empty($errors),
'message' => empty($errors) ? "成功上傳 $count 個文件" : "部分失敗: " . implode('; ', $errors),
'data' => $uploaded,
'errors' => $errors,
]);
| 風險 | 防禦措施 |
|---|---|
| 腳本上傳執行 | 上傳目錄禁用 PHP/腳本執行、副檔名白名單、MIME 驗證、重命名無執行副檔名 |
| 文件名注入/遍歷 | 隨機文件名、pathinfo 取副檔名、不信任原文件名 |
| 大文件 DoS | 限制單文件/總大小、Nginx client_max_body_size、PHP post_max_size/upload_max_filesize |
| 圖片炸彈 (解壓巨大) | getimagesize 讀取尺寸、限制像素總數 (如 4000x4000)、GD 解碼前檢查 |
| 惡意內容隱寫 | 重新壓縮/轉碼、Exif 剝離、ClamAV 掃描 |
| 併發競爭 | 隨機文件名幂無衝突、move_uploaded_file 原子性 |
前端使用 Plupload、Uppy 或自研 Blob.slice() 分片,後端合併分片(需臨時目錄、分片索引、完成標記)。適合 >100MB 視頻、大文件場景。