文件下載看似簡單,實則涉及權限控制、大文件傳輸、斷點續傳、安全防護等複雜問題。本文系統梳理從簡單的 `readfile()` 到生產級下載服務的完整演進路徑。
<?php
// 簡單但危險的實現
$file = '/var/www/files/report.pdf';
if (file_exists($file)) {
header('Content-Type: application/pdf');
header('Content-Disposition: attachment; filename="report.pdf"');
header('Content-Length: ' . filesize($file));
readfile($file);
exit;
}
echo '文件不存在';
?>
<?php
class FileDownloader {
private $rootPath; // 文件存儲根目錄(不可直接 Web 訪問)
private $maxSize; // 最大文件限制
private $allowedMimes; // 允許的 MIME 類型
public function __construct(string $rootPath, array $options = []) {
$this->rootPath = rtrim(realpath($rootPath), DIRECTORY_SEPARATOR);
$this->maxSize = $options['max_size'] ?? 2 * 1024 * 1024 * 1024; // 2GB
$this->allowedMimes = $options['allowed_mimes'] ?? [
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'image/jpeg',
'image/png',
'image/gif',
'text/csv',
'application/zip',
'application/x-rar-compressed'
];
}
// 核心下載方法
public function download(string $relativePath, array $options = []): bool {
// 1. 路徑安全檢查(防止目錄遍歷)
$fullPath = $this->resolvePath($relativePath);
if ($fullPath === false) {
$this->error(404, '文件不存在');
return false;
}
// 2. 文件存在性與可讀性
if (!is_file($fullPath) || !is_readable($fullPath)) {
$this->error(404, '文件不存在或不可讀');
return false;
}
// 3. 文件大小檢查
$fileSize = filesize($fullPath);
if ($fileSize > $this->maxSize) {
$this->error(413, '文件過大');
return false;
}
// 4. MIME 類型檢測與驗證
$mime = $this->detectMime($fullPath);
if (!in_array($mime, $this->allowedMimes)) {
$this->error(403, '文件類型不允許下載');
return false;
}
// 5. 權限檢查(業務邏輯:用戶是否有權下載)
if (isset($options['permission_check']) && !$options['permission_check']($fullPath)) {
$this->error(403, '無下載權限');
return false;
}
// 6. 發送響應頭
$this->sendHeaders($fullPath, $fileSize, $mime, $options);
// 6. 輸出文件內容(支持斷點續傳)
return $this->outputFile($fullPath, $fileSize);
}
// 路徑解析與安全檢查
private function resolvePath(string $relativePath) {
// 規範化路徑
$relativePath = ltrim($relativePath, '/\\');
$fullPath = $this->rootPath . DIRECTORY_SEPARATOR . $relativePath;
$realFullPath = realpath($fullPath);
$realRootPath = realpath($this->rootPath);
// 關鍵:確保解析後路徑在根目錄內(防止 ../ 遍歷)
if ($realFullPath === false || strpos($realFullPath, $realRootPath) !== 0) {
return false;
}
return $realFullPath;
}
// MIME 類型檢測
private function detectMime(string $filePath): string {
// 優先使用 finfo(基於魔數,比擴展名可靠)
if (function_exists('finfo_open')) {
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_file($finfo, $filePath);
finfo_close($finfo);
if ($mime) return $mime;
}
// 回退:mime_content_type
if (function_exists('mime_content_type')) {
return mime_content_type($filePath);
}
// 最後:擴展名猜測
$ext = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
$map = [
'pdf' => 'application/pdf',
'doc' => 'application/msword',
'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'xls' => 'application/vnd.ms-excel',
'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'jpg' => 'image/jpeg',
'jpeg' => 'image/jpeg',
'png' => 'image/png',
'gif' => 'image/gif',
'csv' => 'text/csv',
'zip' => 'application/zip',
'rar' => 'application/x-rar-compressed',
];
return $map[$ext] ?? 'application/octet-stream';
}
// 發送響應頭
private function sendHeaders(string $filePath, int $fileSize, string $mime, array $options) {
$filename = $options['filename'] ?? basename($filePath);
$disposition = $options['inline'] ?? false ? 'inline' : 'attachment';
// RFC 5987 編碼(支持中文文件名)
$encodedFilename = rawurlencode($filename);
$dispositionHeader = "{$disposition}; filename=\"{$filename}\"; filename*=UTF-8''{$encodedFilename}";
header('Content-Type: ' . $mime);
header('Content-Disposition: ' . $dispositionHeader);
header('Content-Length: ' . $fileSize);
header('Accept-Ranges: bytes'); // 聲明支持範圍請求
header('Cache-Control: private, must-revalidate');
header('Pragma: private');
header('Expires: 0');
// 自定義頭部
if (isset($options['headers'])) {
foreach ($options['headers'] as $k => $v) {
header("$k: $v");
}
}
}
// 文件輸出(支持 Range 斷點續傳)
private function outputFile(string $filePath, int $fileSize): bool {
$rangeStart = 0;
$rangeEnd = $fileSize - 1;
$statusCode = 200;
// 解析 Range 頭
if (isset($_SERVER['HTTP_RANGE'])) {
$range = $_SERVER['HTTP_RANGE'];
if (preg_match('/bytes=(\d+)-(\d*)/', $range, $matches)) {
$rangeStart = (int)$matches[1];
$rangeEnd = isset($matches[2]) && $matches[2] !== '' ? (int)$matches[2] : $fileSize - 1;
// 範圍有效性檢查
if ($rangeStart >= $fileSize || $rangeEnd >= $fileSize || $rangeStart > $rangeEnd) {
header('Content-Range: bytes */' . $fileSize);
$this->error(416, '範圍不滿足');
return false;
}
$statusCode = 206; // Partial Content
header('HTTP/1.1 206 Partial Content');
header('Content-Range: bytes ' . $rangeStart . '-' . $rangeEnd . '/' . $fileSize);
header('Content-Length: ' . ($rangeEnd - $rangeStart + 1));
}
}
// 分塊輸出(避免大文件內存溢出)
$chunkSize = 1024 * 1024; // 1MB
$handle = fopen($filePath, 'rb');
if (!$handle) {
$this->error(500, '文件打開失敗');
return false;
}
// 定位到起始位置
if ($rangeStart > 0) {
fseek($handle, $rangeStart);
}
$remaining = $rangeEnd - $rangeStart + 1;
try {
while ($remaining > 0 && !feof($handle) && connection_status() === CONNECTION_NORMAL) {
$readSize = min($chunkSize, $remaining);
$data = fread($handle, $readSize);
if ($data === false) break;
echo $data;
@flush(); // 發送到客戶端
$remaining -= strlen($data);
}
} finally {
fclose($handle);
}
return connection_status() === CONNECTION_NORMAL;
}
private function error(int $code, string $message) {
http_response_code($code);
header('Content-Type: application/json; charset=UTF-8');
echo json_encode(['error' => $message, 'code' => $code]);
}
}
// 使用示例
$downloader = new FileDownloader('/var/www/private_files', [
'max_size' => 500 * 1024 * 1024, // 500MB
'allowed_mimes' => ['application/pdf', 'image/*', 'application/zip']
]);
// 業務邏輯:權限檢查
$canDownload = function($filePath) use ($userId, $pdo) {
// 檢查用戶是否購買、是否會員、是否作者等
return true; // 簡化
};
$downloader->download('user_uploads/report_2024.pdf', [
'permission_check' => $canDownload,
'filename' => '年度報告.pdf' // 自定義下載文件名
]);
?>
讓 Web 服務器(Nginx/Apache)直接發送文件,完全釋放 PHP 進程:
<?php
// PHP 僅做權限驗證,不輸出文件內容
function downloadWithXSendfile(string $filePath, string $filename) {
// 權限檢查...
// Nginx
header('X-Accel-Redirect: /protected_files/' . basename($filePath));
// Apache (mod_xsendfile)
// header('X-Sendfile: ' . $filePath);
header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename="' . rawurlencode($filename) . '"');
// 不需要 Content-Length,Nginx 會自動添加
exit;
}
?>
# Nginx 配置
location /protected_files/ {
internal; # 關鍵:只允許內部重定向
alias /var/www/private_files/;
# 支持斷點續傳
add_header Accept-Ranges bytes;
}
| HTTP 頭 | 請求/響應 | 說明 |
|---|---|---|
| Range | 請求 | bytes=0-1023 (請求前 1KB) |
| If-Range | 請求 | ETag 或 Last-Modified,確保文件未修改 |
| Accept-Ranges | 響應 | bytes (聲明支持) |
| Content-Range | 響應 | bytes 0-1023/2048 (響應範圍) |
| 206 Partial Content | 響應狀態 | 範圍請求成功 |
| 416 Range Not Satisfiable | 響應狀態 | 範圍無效 |
<?php
class SignedUrlGenerator {
private $secretKey;
private $baseUrl;
public function __construct(string $secretKey, string $baseUrl) {
$this->secretKey = $secretKey;
$this->baseUrl = rtrim($baseUrl, '/');
}
// 生成帶簽名的下載鏈接
public function generate(string $filePath, int $expireMinutes = 60, array $params = []): string {
$expire = time() + $expireMinutes * 60;
$data = [
'path' => $filePath,
'expire' => $expire,
'params' => $params
];
$payload = base64_encode(json_encode($data));
$signature = hash_hmac('sha256', $payload, $this->secretKey);
return $this->baseUrl . '/download_signed.php?token=' . rawurlencode($payload . '.' . $signature);
}
// 驗證簽名
public static function verify(string $token, string $secretKey): ?array {
$parts = explode('.', $token, 2);
if (count($parts) !== 2) return null;
[$payload, $signature] = $parts;
$expectedSig = hash_hmac('sha256', $payload, $secretKey);
if (!hash_equals($expectedSig, $signature)) return null;
$data = json_decode(base64_decode($payload), true);
if (!$data || $data['expire'] < time()) return null;
return $data;
}
}
// 下載入口 download_signed.php
$token = $_GET['token'] ?? '';
$data = SignedUrlGenerator::verify($token, $secretKey);
if (!$data) {
http_response_code(403);
exit('鏈接無效或已過期');
}
// 直接下載(已驗證簽名,無需再次權限檢查)
$downloader->download($data['path'], [
'filename' => $data['params']['filename'] ?? null
]);
?>
架構建議:
1. 私有文件:PHP 權限驗證 + Nginx X-Accel-Redirect 發送
2. 公開文件:CDN 直連 + 簽名 URL 過期控制
3. 超大文件:分片上傳/下載、對象存儲(S3/OSS)預簽名 URL
4. 合規要求:下載水印、DRM 加密、審計追蹤、地理位置限制
5. 監控指標:下載成功率、平均速度、斷點續傳比例、異常流量告警