| 副檔名 | MIME 類型 | PHP 獲取方式 |
| .pdf | application/pdf | mime_content_type($path) 或 finfo_file(finfo_open(), $path) |
| .jpg .jpeg | image/jpeg | 同上 |
| .png | image/png | 同上 |
| .zip | application/zip | 同上 |
| .rar | application/x-rar-compressed | 同上 |
| .docx | application/vnd.openxmlformats-officedocument.wordprocessingml.document | 同上 |
| .xlsx | application/vnd.openxmlformats-officedocument.spreadsheetml.sheet | 同上 |
| .mp4 | video/mp4 | 同上 |
| .apk | application/vnd.android.package-archive | 同上 |
最佳實踐:使用 `finfo` 自動檢測,回退到 `application/octet-stream`:
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_file($finfo, $filepath) ?: 'application/octet-stream';
finfo_close($finfo);
header("Content-Type: {$mime}");
大文件下載:內存安全與斷點續傳
1. readfile() vs fread() 循環
readfile($path):PHP 內部使用流式讀取,**不會將整個文件載入內存**,適合 100MB-1GB 文件。但無法處理 Range 請求。
fread() 循環:可精確控制輸出位置,支持 **HTTP Range 斷點續傳**,必須手動發送 `206 Partial Content`。
2. 斷點續傳完整實現(支持多線程下載器)
<?php
function sendFileWithRangeSupport(string $filepath, string $filename): void {
if (!is_file($filepath)) { http_response_code(404); exit; }
$size = filesize($filepath);
$mime = mime_content_type($filepath) ?: 'application/octet-stream';
// 解析 Range 頭
$range = $_SERVER['HTTP_RANGE'] ?? '';
$start = 0;
$end = $size - 1;
$statusCode = 200;
if (preg_match('/bytes=(\d+)-(\d*)/', $range, $matches)) {
$start = (int)$matches[1];
$end = $matches[2] !== '' ? (int)$matches[2] : $size - 1;
if ($start > $end || $start >= $size) {
header('HTTP/1.1 416 Range Not Satisfiable');
header("Content-Range: bytes */{$size}");
exit;
}
$statusCode = 206; // Partial Content
}
$length = $end - $start + 1;
// 發送 Header
header("HTTP/1.1 {$statusCode} " . ($statusCode === 206 ? 'Partial Content' : 'OK'));
header("Content-Type: {$mime}");
header('Content-Disposition: attachment; filename="' . rawurlencode($filename) . '"');
header("Content-Length: {$length}");
header("Content-Range: bytes {$start}-{$end}/{$size}");
header('Accept-Ranges: bytes');
header('Cache-Control: private, must-revalidate');
// 流式輸出
$fp = fopen($filepath, 'rb');
fseek($fp, $start);
$buffer = 1024 * 8; // 8KB 緩衝
while (!feof($fp) && $length > 0) {
$read = min($buffer, $length);
echo fread($fp, $read);
$length -= $read;
// 可選:ob_flush(); flush(); // 實時推送到瀏覽器
}
fclose($fp);
}
// 調用
sendFileWithRangeSupport('/data/large_video.mp4', 'video.mp4');
安全防範清單
| 風險 | 攻擊向量 | 防禦代碼 |
|---|---|---|
| **目錄遍歷** | `download.php?file=../../etc/passwd` | `basename()` + `realpath()` 對比允許根目錄 |
| **任意文件讀取** | 直接訪問 `.env`、`config.php` | 文件存放在 **Web 根目錄之外**,PHP 讀取後輸出 |
| **中文文件名亂碼** | `filename=中文.pdf` | `rawurlencode($filename)` + `filename*=UTF-8''` (RFC 5987) |
| **內存耗盡** | 10GB 文件 `readfile` | 流式輸出、Nginx `X-Accel-Redirect` 卸載 |
| **盜鏈/未授權下載** | 直接訪問下載鏈接 | 驗證 Session/JWT、簽名 URL 過期時間、Referer 檢查 |
企業級最佳實踐:Nginx X-Accel-Redirect (X-Sendfile)
**核心思想**:PHP 只負責權限驗證、生成簽名 URL,實際文件傳輸交給 **Nginx/Apache** 高性能靜態文件服務,PHP 進程瞬間釋放,支持萬級併發下載。
<?php
// PHP 控制器
public function download(Request $request) {
$file = File::findOrFail($request->id);
// 1. 權限檢查
if (!$this->canDownload($file)) abort(403);
// 2. 記錄下載日志
DownloadLog::create(['file_id' => $file->id, 'user_id' => auth()->id()]);
// 3. 返回特殊 Header,Nginx 攔截並發送文件
return response('', 200, [
'X-Accel-Redirect' => "/protected-files/{$file->stored_path}", // 內部路徑
'Content-Disposition' => 'attachment; filename="' . rawurlencode($file->original_name) . '"',
'Content-Type' => $file->mime_type,
]);
}
# Nginx 配置
location /protected-files/ {
internal; # 只允許內部重定向,外部直接訪問 404
alias /data/uploads/; # 真實文件存儲路徑
# 可選:限速、斷點續傳、Token 驗證模塊
}
總結
PHP 文件下載看似簡單,實則包含 MIME 類型識別、Content-Disposition 編碼、Range 斷點續傳、內存安全流式輸出、權限防盜鏈、Nginx 卸載傳輸 等工程細節。按需選型:小文件用 `readfile()`,大文件/斷點續傳用 `fread` 流式 + Range,高併發生產環境必上 `X-Accel-Redirect`。記住:**文件存 Web 目錄外,權限在 PHP 層驗,傳輸交 Web Server 層跑**,這是現代架構的鐵律。
- 上一篇文章: PHP「Maximum execution time of 30 seconds exceeded」錯誤徹底解決方案
- 下一篇文章: PHP PDO 完全指南:從連接到預處理、事務、錯誤處理的現代數據庫開發
Copyright ©
程式碼之家 All Rights Reserved