PHP 安全檔案下載完整實作指南
本文提供生產級的 PHP 檔案下載實作,解決中文檔名編碼、大檔案記憶體溢出、斷點續傳、安全性驗證等核心問題。
基礎下載函數
<?php
header('Content-Type: text/html; charset=utf-8');
define('ROOT_PATH', dirname(__FILE__));
/**
* 安全下載檔案
* @param string $file_path 絕對路徑
* @param string $download_name 自訂下載檔名(可選)
* @param bool $enable_resume 支援斷點續傳
*/
function downFile($file_path, $download_name = '', $enable_resume = true) {
// 1. 路徑安全檢查:防止目錄遍歷攻擊
$file_path = realpath($file_path);
$root_path = realpath(ROOT_PATH);
if ($file_path === false || strpos($file_path, $root_path) !== 0) {
exit('非法檔案路徑!');
}
// 2. 檔案存在性檢查
if (!file_exists($file_path) || !is_file($file_path)) {
exit('檔案不存在!');
}
// 3. 讀取權限檢查
if (!is_readable($file_path)) {
exit('檔案不可讀!');
}
// 4. 取得檔案資訊
$file_size = filesize($file_path);
$file_name = $download_name ?: basename($file_path);
// 5. 中文檔名編碼處理(RFC 5987 相容)
$encoded_name = rawurlencode($file_name);
$ascii_name = preg_replace('/[^\x20-\x7E]/', '', $file_name); // 移除非 ASCII
$content_disposition = "attachment; filename=\"$ascii_name\"; filename*=UTF-8''$encoded_name";
// 6. MIME 類型偵測
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime_type = finfo_file($finfo, $file_path);
finfo_close($finfo);
// 預設二進位串流
if (!$mime_type) $mime_type = 'application/octet-stream';
// 7. 發送標頭
header("Content-Type: $mime_type");
header("Content-Disposition: $content_disposition");
header("Content-Length: $file_size");
header("Accept-Ranges: bytes");
header("Cache-Control: private, must-revalidate");
header("Pragma: no-cache");
// 8. 斷點續傳支援
if ($enable_resume && isset($_SERVER['HTTP_RANGE'])) {
$range = $_SERVER['HTTP_RANGE'];
if (preg_match('/bytes=(\d+)-(\d*)/', $range, $matches)) {
$start = intval($matches[1]);
$end = $matches[2] ? intval($matches[2]) : $file_size - 1;
if ($start < $file_size && $end < $file_size && $start <= $end) {
header('HTTP/1.1 206 Partial Content');
header("Content-Range: bytes $start-$end/$file_size");
header("Content-Length: " . ($end - $start + 1));
$file_size = $end - $start + 1;
}
}
}
// 9. 分塊輸出(避免記憶體溢出)
$buffer = 1024 * 8; // 8KB 緩衝區
$fp = fopen($file_path, 'rb');
if (isset($start)) fseek($fp, $start);
while (!feof($fp) && $file_size > 0) {
$read_size = min($buffer, $file_size);
$data = fread($fp, $read_size);
echo $data;
$file_size -= $read_size;
// 定期 flush 避免緩衝區堆積
if (ob_get_level()) ob_flush();
flush();
}
fclose($fp);
exit;
}
// 使用範例
downFile(ROOT_PATH . '/down/Sunset.jpg');
?>
進階:下載權限控制與日誌記錄
<?php
function secureDownload($file_id, $user_id) {
// 1. 資料庫驗證下載權限
$pdo = Db::getDB();
$stmt = $pdo->prepare("
SELECT f.path, f.name, f.size, f.mime, p.permission
FROM files f
JOIN permissions p ON f.id = p.file_id
WHERE f.id = ? AND p.user_id = ? AND p.can_download = 1
");
$stmt->execute([$file_id, $user_id]);
$file = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$file) {
// 記錄未授權嘗試
logDownloadAttempt($user_id, $file_id, 'DENIED');
exit('無下載權限或檔案不存在');
}
// 2. 記錄下載日誌
logDownloadAttempt($user_id, $file_id, 'ALLOWED', $file['name']);
// 3. 執行下載
downFile($file['path'], $file['name']);
}
function logDownloadAttempt($user_id, $file_id, $status, $filename = '') {
$pdo = Db::getDB();
$stmt = $pdo->prepare("
INSERT INTO download_logs (user_id, file_id, filename, status, ip, user_agent, created_at)
VALUES (?, ?, ?, ?, ?, ?, NOW())
");
$stmt->execute([
$user_id, $file_id, $filename, $status,
$_SERVER['REMOTE_ADDR'],
$_SERVER['HTTP_USER_AGENT']
]);
}
?>
大檔案優化策略
| 問題 | 解決方案 |
| 記憶體耗盡 | 分塊讀取(8KB-64KB)、readfile() 替代 file_get_contents() |
| 執行逾時 | set_time_limit(0)、Nginx proxy_read_timeout 調大 |
| 網路中斷 | HTTP Range 斷點續傳、前端使用 resumable.js |
| 併發限制 | 限流(Token Bucket)、CDN 卸載靜態檔案 |
| 磁碟 I/O 瓶頸 | SSD、RAID、檔案預讀、sendfile 系統呼叫 |
Nginx X-Accel-Redirect 高效下載(推薦生產環境)
# nginx.conf
location /protected/ {
internal; # 僅允許內部重導向
alias /var/www/files/; # 實際檔案目錄
sendfile on; # 核心層傳輸(零拷貝)
tcp_nopush on;
aio on; # 非同步 I/O
output_buffers 16 32k; # 輸出緩衝區
}
# PHP 端
function nginxDownload($file_path, $filename) {
$relative_path = str_replace('/var/www/files/', '', $file_path);
header("X-Accel-Redirect: /protected/$relative_path");
header("Content-Disposition: attachment; filename*=UTF-8''" . rawurlencode($filename));
header("Content-Type: application/octet-stream");
exit;
}
安全性檢查清單
- 路徑遍歷防護:使用
realpath() 驗證路徑在允許目錄內
- 檔案類型驗證:MIME 偵測 + 副檔名白名單
- 權限控制:資料庫/ACL 驗證使用者下載授權
- 下載日誌:記錄使用者、IP、時間、檔案、狀態
- 頻率限制:防止惡意爬蟲/濫用頻寬
- 敏感資訊過濾:錯誤訊息不洩露伺服器路徑結構
常見中文檔名問題對照
| 瀏覽器 | 支援標準 | 建議寫法 |
| Chrome/Firefox/Edge 新版 | RFC 5987 (filename*) | filename*=UTF-8''encoded_name |
| IE/舊版瀏覽器 | RFC 2231 部分支援 | 雙重標頭:filename="ascii" + filename* |
| Safari | RFC 5987 | 同 Chrome |