檔案上傳下載是 Web 系統的基礎功能,但也是安全漏洞高發區。本文提供符合現代標準的完整實作,涵蓋單檔/多檔上傳、檔案類型驗證、安全命名、斷點下載與權限控制。
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>檔案上傳</title>
</head>
<body>
<form action="upload.php" method="post" enctype="multipart/form-data">
<label>用戶名:<input type="text" name="username" required></label><br>
<label>檔案說明:<br><textarea name="fileintro" rows="4" cols="50"></textarea></label><br>
<label>選擇檔案:<input type="file" name="myfile" required></label><br>
<button type="submit">上傳檔案</button>
</form>
</body>
</html>
<?php
declare(strict_types=1);
// 設定上傳根目錄 (建議置於 Web 根目錄外)
define('UPLOAD_ROOT', __DIR__ . '/../data/uploads');
// 允許的 MIME 類型
const ALLOWED_TYPES = [
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
];
// 最大檔案大小 (5MB)
const MAX_FILE_SIZE = 5 * 1024 * 1024;
session_start();
header('Content-Type: application/json; charset=utf-8');
$response = ['success' => false, 'message' => '', 'data' => null];
// 驗證登入狀態 (示例)
if (empty($_SESSION['user_id'])) {
$response['message'] = '請先登入';
echo json_encode($response);
exit;
}
$username = trim($_POST['username'] ?? '');
$intro = trim($_POST['fileintro'] ?? '');
if ($username === '') {
$response['message'] = '用戶名不可為空';
echo json_encode($response);
exit;
}
// 檢查檔案上傳狀態
if (!isset($_FILES['myfile']) || $_FILES['myfile']['error'] !== UPLOAD_ERR_OK) {
$response['message'] = '檔案上傳失敗,錯誤代碼: ' . ($_FILES['myfile']['error'] ?? 'unknown');
echo json_encode($response);
exit;
}
$file = $_FILES['myfile'];
// 檔案大小驗證
if ($file['size'] > MAX_FILE_SIZE) {
$response['message'] = '檔案過大,限制 ' . (MAX_FILE_SIZE / 1024 / 1024) . 'MB';
echo json_encode($response);
exit;
}
// MIME 類型驗證 (需安裝 fileinfo 擴展)
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($file['tmp_name']);
if (!in_array($mime, ALLOWED_TYPES, true)) {
$response['message'] = '不允許的檔案類型: ' . $mime;
echo json_encode($response);
exit;
}
// 建立用戶專屬目錄
$userDir = UPLOAD_ROOT . '/' . $username;
if (!is_dir($userDir)) {
if (!mkdir($userDir, 0755, true)) {
$response['message'] = '無法建立上傳目錄';
echo json_encode($response);
exit;
}
}
// 產生安全檔名:時間戳 + 隨機字串 + 原始副檔名
$ext = pathinfo($file['name'], PATHINFO_EXTENSION);
$safeName = date('YmdHis') . '_' . bin2hex(random_bytes(8)) . ($ext ? '.' . $ext : '');
$destPath = $userDir . '/' . $safeName;
// 移動檔案
if (!move_uploaded_file($file['tmp_name'], $destPath)) {
$response['message'] = '檔案儲存失敗';
echo json_encode($response);
exit;
}
// 記錄檔案資訊至資料庫 (略)
// $db->insert('uploads', [...]);
$response['success'] = true;
$response['message'] = '上傳成功';
$response['data'] = [
'original_name' => $file['name'],
'saved_name' => $safeName,
'size' => $file['size'],
'mime' => $mime,
'path' => $destPath,
];
echo json_encode($response);
<?php
// download.php
declare(strict_types=1);
$filePath = __DIR__ . '/../data/uploads/' . ($_GET['user'] ?? '') . '/' . ($_GET['file'] ?? '');
// 安全性檢查:防止目錄遍歷
$realBase = realpath(__DIR__ . '/../data/uploads');
$realFile = realpath($filePath);
if (!$realFile || strpos($realFile, $realBase) !== 0 || !is_file($realFile)) {
http_response_code(404);
exit('檔案不存在或存取被拒絕');
}
// 取得檔案資訊
$fileSize = filesize($realFile);
$fileName = basename($realFile);
$mimeType = mime_content_type($realFile);
// 處理 Range 標頭 (斷點續傳)
$rangeHeader = $_SERVER['HTTP_RANGE'] ?? '';
$start = 0;
$end = $fileSize - 1;
$statusCode = 200;
if (preg_match('/bytes=(\d+)-(\d*)/', $rangeHeader, $matches)) {
$start = (int)$matches[1];
$end = $matches[2] !== '' ? min((int)$matches[2], $fileSize - 1) : $fileSize - 1;
if ($start > $end || $start >= $fileSize) {
header('Content-Range: bytes */' . $fileSize);
http_response_code(416);
exit;
}
$statusCode = 206;
header('Content-Range: bytes ' . $start . '-' . $end . '/' . $fileSize);
}
// 設定回應標頭
header('Content-Type: ' . $mimeType);
header('Content-Length: ' . ($end - $start + 1));
header('Accept-Ranges: bytes');
header('Content-Disposition: attachment; filename="' . rawurlencode($fileName) . '"');
http_response_code($statusCode);
// 串流輸出 (節省記憶體)
$fp = fopen($realFile, 'rb');
fseek($fp, $start);
$bufferSize = 8192;
while (!feof($fp) && ($pos = ftell($fp)) <= $end) {
$readSize = min($bufferSize, $end - $pos + 1);
echo fread($fp, $readSize);
flush(); // 即時發送給客戶端
}
fclose($fp);
<?php
// list.php
$uploadDir = __DIR__ . '/../data/uploads';
$users = array_filter(glob($uploadDir . '/*'), 'is_dir');
?>
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><title>檔案列表</title></head>
<body>
<h1>用戶檔案列表</h1>
<?php foreach ($users as $userDir): ?>
<h2><?= basename($userDir) ?></h2>
<ul>
<?php foreach (glob($userDir . '/*') as $file): ?>
<li>
<a href="download.php?user=<?= urlencode(basename($userDir)) ?>&file=<?= urlencode(basename($file)) ?>">
<?= htmlspecialchars(basename($file)) ?> (<?= round(filesize($file)/1024, 1) ?> KB)
</a>
</li>
<?php endforeach; ?>
</ul>
<?php endforeach; ?>
</body>
</html>
finfo 偵測真實類型,不要信任 $_FILES['type']。upload_max_filesize)、Nginx/Apache (client_max_body_size) 設定。