檔案上傳是 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()]);
}
?>
<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>
此架構易於擴充:加入病毒掃描、圖片壓縮、水印、縮圖生成、雲端儲存同步等功能,只需在 `saveFile()` 後擴充處理流程即可。