隱藏真實檔案路徑、實作下載授權、支援大檔案傳輸,常用「PHP 代理下載」:設定 HTTP Header 強制瀏覽器下載,再用 readfile() 或分塊輸出檔案內容。本文提供完整、安全、可擴充的下載實作,並針對大檔案記憶體優化、斷點續傳、速度限制進行進階說明。
<?php
function downloadFile(string $filePath, string $downloadName = ''): void {
if (!is_file($filePath) || !is_readable($filePath)) {
http_response_code(404);
exit('File not found');
}
$downloadName = $downloadName ?: basename($filePath);
$mime = mime_content_type($filePath) ?: 'application/octet-stream';
$size = filesize($filePath);
header('Content-Type: ' . $mime);
header('Content-Length: ' . $size);
header('Content-Disposition: attachment; filename="' . rawurlencode($downloadName) . '"');
header('Content-Transfer-Encoding: binary');
header('Accept-Ranges: bytes'); // 聲明支援 Range (實際未處理)
header('Cache-Control: private, must-revalidate');
header('Pragma: private');
readfile($filePath); // 直接輸出至瀏覽器
exit;
}
// 使用
downloadFile('/var/www/secret/report.pdf', '2024年報.pdf');
?>
Range 標頭,瀏覽器/下載工具無法暫停續傳。readfile() 內部使用 8KB 緩衝區,記憶體尚可,但若前有 ob_start() 會緩衝整個檔案導致 OOM。304。<?php
/**
* 進階下載器:支援 Range、速度限制、恆定記憶體
*/
function advancedDownload(string $filePath, string $downloadName = '', int $speedLimit = 0): void {
if (!is_file($filePath) || !is_readable($filePath)) {
http_response_code(404);
exit('File not found');
}
$downloadName = $downloadName ?: basename($filePath);
$mime = mime_content_type($filePath) ?: 'application/octet-stream';
$fileSize = filesize($filePath);
$lastModified = filemtime($filePath);
$etag = md5($fileSize . '-' . $lastModified);
// 1. 快取驗證
if (isset($_SERVER['HTTP_IF_NONE_MATCH']) && $_SERVER['HTTP_IF_NONE_MATCH'] === $etag) {
http_response_code(304);
exit;
}
if (isset($_SERVER['HTTP_IF_MODIFIED_SINCE']) && strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE']) >= $lastModified) {
http_response_code(304);
exit;
}
// 2. 解析 Range
$start = 0;
$end = $fileSize - 1;
$isPartial = false;
if (isset($_SERVER['HTTP_RANGE'])) {
if (preg_match('/^bytes=(\d*)-(\d*)$/i', $_SERVER['HTTP_RANGE'], $m)) {
$start = $m[1] !== '' ? (int)$m[1] : 0;
$end = $m[2] !== '' ? (int)$m[2] : $fileSize - 1;
if ($m[1] === '' && $m[2] !== '') { // bytes=-500
$start = $fileSize - $end;
$end = $fileSize - 1;
}
$start = max(0, min($start, $fileSize - 1));
$end = max($start, min($end, $fileSize - 1));
if ($start <= $end) {
$isPartial = true;
}
}
}
$contentLength = $end - $start + 1;
// 3. 發送標頭
if ($isPartial) {
header('HTTP/1.1 206 Partial Content');
header("Content-Range: bytes $start-$end/$fileSize");
} else {
header('HTTP/1.1 200 OK');
}
header('Content-Type: ' . $mime);
header('Content-Length: ' . $contentLength);
header('Accept-Ranges: bytes');
header('Last-Modified: ' . gmdate('D, d M Y H:i:s', $lastModified) . ' GMT');
header('ETag: ' . $etag);
header('Cache-Control: public, max-age=31536000');
header('Pragma: public');
header('Content-Disposition: attachment; filename*=UTF-8\'\'' . rawurlencode($downloadName) . '; filename="' . rawurlencode($downloadName) . '"');
header('Content-Transfer-Encoding: binary');
// 4. 關閉輸出緩衝 (防止 OOM)
while (ob_get_level() > 0) {
ob_end_clean();
}
// 5. 流式輸出
$handle = fopen($filePath, 'rb');
fseek($handle, $start);
$bufferSize = 8192; // 8KB
$sent = 0;
$packetTime = $speedLimit > 0 ? ($bufferSize * 1_000_000 / $speedLimit) : 0;
while ($sent < $contentLength && !feof($handle) && connection_status() === CONNECTION_NORMAL) {
if ($packetTime > 0) $t0 = microtime(true);
$readLen = min($bufferSize, $contentLength - $sent);
$buffer = fread($handle, $readLen);
if ($buffer === false) break;
echo $buffer;
$sent += strlen($buffer);
if (ob_get_level() > 0) ob_flush();
flush();
if ($packetTime > 0) {
$t1 = microtime(true);
$elapsed = ($t1 - $t0) * 1_000_000;
$sleep = $packetTime - $elapsed;
if ($sleep > 0) usleep((int)$sleep);
}
}
fclose($handle);
exit;
}
// 使用:免費用戶限速 200 KB/s
advancedDownload('/data/files/video.mp4', '課程影片.mp4', 200 * 1024);
?>
| 問題 | 解決方案 |
|---|---|
| PHP 記憶體耗盡 | 關閉 output_buffering (ob_end_clean()),使用 fread 分塊輸出,不使用 readfile、file_get_contents |
| 執行逾時 | set_time_limit(0) 或 Nginx proxy_read_timeout 調大 |
| Nginx 代理緩衝導致延遲/記憶體暴增 | proxy_buffering off; proxy_request_buffering off; |
| 斷點續傳不工作 | 正確解析 Range、回應 206 + Content-Range + Accept-Ranges: bytes |
| 中文檔名亂碼 | RFC 5987 filename*=UTF-8''... + 相容 filename="..." |
| 客戶端斷線腳本繼續跑 | 檢查 connection_status() === CONNECTION_NORMAL,異常即 break |
若檔案儲存在伺服器本地,最佳方案是 PHP 僅驗證權限,Nginx 直接傳檔:
# PHP 端
function downloadWithXAccel(string $internalPath, string $downloadName): void {
// $internalPath 為 Nginx internal location 對應的內部路徑,如 /protected/files/report.pdf
header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename*=UTF-8\'\'' . rawurlencode($downloadName));
header('X-Accel-Redirect: ' . $internalPath); // 告訴 Nginx 傳送此路徑
header('X-Accel-Buffering: no'); // 禁用 Nginx 緩衝
exit;
}
# Nginx 設定
location /protected/ {
internal; # 僅允許內部重導向 (X-Accel-Redirect)
alias /var/www/secret/; # 實際檔案根目錄
# 可在此層加入 limit_rate、limit_rate_after 限速
}
sendfile() 系統調用 (零拷貝),CPU 佔用極低。根據專案規模選型:
readfile()。mod_xsendfile),PHP 僅做權限驗證。所有方案務必實作:路徑安全檢查 (realpath + 允許目錄前綴)、中文檔名 RFC 5987、關閉輸出緩衝、連線狀態檢測。