PHP 下载文件:从设置响应头到输出文件内容

PHP 下载文件:从设置响应头到输出文件内容

PHP 下载文件:从设置响应头到输出文件内容

在 PHP 中实现文件下载,核心并不是“把文件读出来”,而是让服务器在发送响应正文之前设置好正确的响应头,再将文件内容按预期写入响应。一个可靠的下载接口还需要处理文件定位、访问权限、文件名、输出缓冲和异常情况。处理不当时,用户可能下载到损坏的文件、看到乱码文件名,或者借助参数访问不该公开的文件。

本文从最基础的响应头讲起,给出可以落地使用的示例,并说明单文件下载与断点续传等能力的边界。示例以 PHP 7.4 及以上版本为参考,不依赖第三方库。

一、文件下载响应由什么组成

一个普通的 HTTP 文件下载响应主要包括两部分:

  • 响应头:告诉客户端内容类型、建议保存的文件名,以及是否可以缓存等信息。
  • 响应正文:实际的文件字节。

例如,下面这个响应头表示内容按二进制文件处理,并建议浏览器将其保存为 report.pdf:

Content-Type: application/octet-stream
Content-Disposition: attachment; filename="report.pdf"

Content-Disposition: attachment通常会提示浏览器下载,而不是直接尝试在页面中展示内容。它是“建议”,具体表现仍可能受浏览器、用户设置和文件类型影响。若使用 inline,浏览器可能会直接打开可识别的内容,例如 PDF 或图片。

二、先确认下载文件是否应该由 PHP 输出

PHP 下载适合需要在发送文件前执行应用逻辑的场景,例如:

  • 验证登录状态、角色权限或文件归属关系。
  • 记录下载日志、扣减次数或生成审计记录。
  • 下载文件存放在 Web 根目录之外,不允许通过静态 URL 直接访问。
  • 根据业务规则选择文件,或在输出前动态生成内容。

如果文件是公开静态资源,通常让 Web 服务器或对象存储直接提供文件更合适。这样可以减少 PHP 工作进程长时间占用,也更容易利用缓存、范围请求和高效的文件传输机制。对较大的私有文件,可以由 PHP 完成鉴权,再通过内部重定向或受控的临时链接交给 Web 服务器或对象存储传输;具体做法取决于部署环境。

三、设置响应头前,不能先输出内容

HTTP 响应头必须在响应正文开始前发送。因此,调用 header() 之前,不能输出 HTML、空格、调试信息,也不能让 PHP 文件在 <?php 之前包含 BOM。否则可能遇到“headers already sent”警告,导致下载响应头没有生效。

下载处理脚本最好是独立入口:完成鉴权和文件检查后设置响应头,再输出文件内容。不要在下载动作之后继续渲染页面。若项目启用了输出缓冲,缓冲区中的已有内容也可能混进文件正文;清理缓冲时应确认缓冲内容不是有意保留的数据。

四、一个可运行的基本示例

下面的示例假设文件保存在项目目录下的 private-files 文件夹,并通过固定映射表按编号选择文件。将代码保存为 download.php,在同目录创建 private-files 文件夹和 example.pdf,然后访问 download.php?id=report。生产环境还应在标注处接入真实的登录与权限检查。

<?php
declare(strict_types=1);

// 生产环境应在这里验证登录状态、用户权限和文件归属关系。
// 示例仅演示下载流程,不提供实际身份认证。

$files = [
    'report' => [
        'path' => __DIR__ . '/private-files/example.pdf',
        'name' => '年度报告.pdf',
        'type' => 'application/pdf',
    ],
];

$id = $_GET['id'] ?? '';
if (!is_string($id) || !isset($files[$id])) {
    http_response_code(404);
    exit('File not found');
}

$item = $files[$id];
$storageRoot = realpath(__DIR__ . '/private-files');
$filePath = realpath($item['path']);

// 检查目录和文件是否存在,并确保解析后的文件仍在存储目录内。
if ($storageRoot === false || $filePath === false) {
    http_response_code(404);
    exit('File not found');
}

$rootPrefix = rtrim($storageRoot, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
if (strncmp($filePath, $rootPrefix, strlen($rootPrefix)) !== 0) {
    http_response_code(404);
    exit('File not found');
}

if (!is_file($filePath) || !is_readable($filePath)) {
    http_response_code(404);
    exit('File not found');
}

$fileSize = filesize($filePath);
if ($fileSize === false) {
    http_response_code(500);
    exit('Unable to read file');
}

// 准备一个仅含 ASCII 字符的兼容性文件名。
$fallbackName = preg_replace('/[^A-Za-z0-9._-]/', '_', $item['name']);
if ($fallbackName === null || $fallbackName === '') {
    $fallbackName = 'download';
}

// filename* 使用 UTF-8 百分号编码,支持较新的浏览器正确显示中文文件名。
$encodedName = rawurlencode($item['name']);

// 丢弃应用输出缓冲区中的内容,避免混入文件正文。
// 不要在未经评估的情况下清理其他组件有意设置的缓冲内容。
while (ob_get_level() > 0) {
    ob_end_clean();
}

header('Content-Type: ' . $item['type']);
header(
    'Content-Disposition: attachment; filename="' . $fallbackName .
    '"; filename*=UTF-8\'\'' . $encodedName
);
header('Content-Length: ' . (string) $fileSize);
header('X-Content-Type-Options: nosniff');
header('Cache-Control: private, no-store');

readfile($filePath);
exit;

示例中使用固定映射表,而不是直接把请求参数拼接到文件路径上。这一点比对用户输入做简单替换更重要:请求参数只用于选择应用明确允许下载的文件,不能让客户端任意指定服务器路径。

五、文件路径与权限:下载接口的安全边界

下面这种写法风险很高:

<?php
$path = '/var/app/files/' . $_GET['file'];
readfile($path);

攻击者可能构造路径穿越输入,尝试读取存储目录之外的文件。即使使用 basename() 或删除 ../,也不能将其视为完整的授权和路径安全方案。路径编码、符号链接、平台路径规则以及业务权限都需要分别考虑。

更可靠的做法是用数据库记录或服务端映射表根据资源 ID 查找文件,并在读取前验证:

  • 当前用户是否有权下载该资源,而不只是是否登录。
  • 文件是否存在、是否为普通文件,以及当前 PHP 进程是否可读。
  • 解析后的绝对路径是否仍位于允许的存储目录内。
  • 文件记录和磁盘文件是否仍然对应,是否需要防止用户下载已删除或已替换的文件。

示例用 realpath() 解析目录和文件,并检查文件路径是否以存储目录路径加目录分隔符开头。这样可以识别常见的路径穿越和指向目录外的符号链接。但它不是所有场景下的完整防护:如果攻击者能在检查和打开文件之间修改文件系统,仍可能出现竞态;文件存储权限、部署方式和并发行为也需要一并评估。最重要的基础是让用户不能直接控制任意服务器路径。

对受保护文件,存储目录通常应放在 Web 根目录之外。否则,即便下载脚本本身做了鉴权,服务器仍可能允许访问者绕过脚本,通过静态 URL 直接读取文件。

六、Content-Type、文件名和长度怎么设置

Content-Type

Content-Type说明响应正文的媒体类型。若文件类型确定,可以设置为对应的 MIME 类型,例如 PDF 使用 application/pdf。若文件来自用户上传,不能只凭扩展名就认定内容类型;需要基于可信的文件记录或实际检测结果决定。无法确定类型时,常见选择是 application/octet-stream,表示按通用二进制数据处理,但它不保证浏览器一定下载,也不代替访问控制。

示例同时设置了 X-Content-Type-Options: nosniff,用于减少部分浏览器对内容类型进行嗅探的情况。它不能修复错误的 MIME 类型,也不能代替正确的文件处理策略。

Content-Disposition 与中文文件名

filename参数使用兼容性较好的 ASCII 文件名作为回退,filename*参数则按 UTF-8 百分号编码提供原始文件名。许多较新的浏览器能据此显示中文名称。不同客户端对文件名参数的支持可能有差异,因此通常同时提供两者。

不要把未验证的用户输入直接拼接进响应头。换行符等字符可能造成响应头注入问题。示例中的文件名来自服务端映射,并经过 ASCII 回退处理;rawurlencode()负责编码 filename*中的 UTF-8 文件名。业务系统仍应限制文件名长度,并决定是否保留斜杠、控制字符等不适合作为下载名称的字符。

Content-Length

对于可确定大小的普通文件,可以通过 filesize() 设置 Content-Length。它表示响应正文的字节数,应与实际输出完全一致。不要把格式化后的数字、字符数或压缩前后的错误长度填进去。若文件在检查大小后可能被并发替换或修改,预先取得的长度也可能与最终输出不一致;需要更严格保证时,应采用不会被并发改写的文件或其他传输方案。

大小未知、正文动态生成,或者应用无法保证长度准确时,可以不设置该头。不要为了“让下载更完整”而随意估算长度。实际传输采用何种 HTTP 分帧方式由协议和服务器环境处理。

七、大文件输出:readfile 与分块读取

readfile()会输出文件内容,并返回实际读取的字节数或 false。对常规文件下载,它比先用 file_get_contents() 将整个文件读入 PHP 字符串,再一次性输出更适合,因为后者通常需要把整个文件内容放入内存。

不过,“使用 readfile() 就一定不占用大量资源”并不成立。PHP 输出缓冲、PHP-FPM、Web 服务器代理缓冲和操作系统缓存都可能影响内存占用及数据传输过程。大文件服务需要结合实际部署环境测试,并设置合适的超时、并发和存储策略。

如果希望明确按块读取,可以使用如下方式替代 readfile()。不要同时调用两种输出方式,否则会重复发送文件内容。

<?php
$handle = fopen($filePath, 'rb');
if ($handle === false) {
    http_response_code(500);
    exit('Unable to open file');
}

while (!feof($handle)) {
    $chunk = fread($handle, 1024 * 1024);
    if ($chunk === false) {
        break;
    }

    echo $chunk;
    flush();

    if (connection_aborted()) {
        break;
    }
}

fclose($handle);
exit;

这里的 $filePath应当是前面已经完成授权和路径校验的文件路径。flush()会尝试刷新 PHP 输出,但不保证代理服务器或浏览器立即收到数据,也不能保证关闭了所有层级的缓冲。若启用了压缩或代理缓冲,流式传输表现可能不同。代码还可以根据项目需求记录读取失败、客户端断开和下载完成等事件。

八、缓存控制应该按文件用途选择

示例设置了 Cache-Control: private, no-store,适用于希望浏览器和共享缓存不要存储这份响应的敏感文件场景。它并不是所有下载接口都必须使用的固定配置。

  • private表示响应不应由共享缓存存储,但私人浏览器缓存仍可能按其他指令处理。
  • no-store用于要求缓存不要存储响应。它不等于加密,也不能保证终端用户无法保存文件。
  • 公开、可长期缓存的静态文件通常可以采用适合其更新策略的缓存头,例如基于版本号的 URL 配合较长缓存时间。

是否允许缓存应由文件的敏感程度、访问模型和产品需求决定。即使设置了禁止缓存,接口仍必须执行鉴权,不能把缓存头当作权限控制。

九、常见错误与排查方法

1. 下载文件开头出现 HTML 或调试文字

常见原因是页面模板、警告信息、调试输出或文件 BOM 混入响应正文。下载文件与普通页面不同,额外输出一个字节都可能让压缩包、图片或文档无法正常解析。关闭下载接口中的调试输出,检查 PHP 错误日志,并确认响应体没有错误页面内容。

2. 报 “headers already sent”

检查所有被包含文件是否提前输出内容,留意 PHP 标签前的空白和 UTF-8 BOM。将下载逻辑从会输出页面的控制器或模板中分离出来,通常比在发送响应头后再尝试补救更稳妥。

3. 文件名乱码或浏览器忽略文件名

文件名参数的客户端兼容性不完全一致。可以同时设置 ASCII filename和 UTF-8 编码的 filename*,并在真实目标浏览器中测试。不要将原始中文或未经处理的用户输入直接拼进响应头。

4. 下载到的文件长度不对

检查 Content-Length是否与实际正文一致,是否有 PHP 警告、空格、模板输出或压缩层改变了传输内容。还要确认文件没有在取得大小后被修改。排查时可在浏览器开发者工具或命令行中查看响应头和响应正文。

5. 大文件导致超时或连接中断

除了调整 PHP 和 Web 服务器的超时设置,还应考虑把文件交给 Web 服务器、CDN 或对象存储传输。盲目延长脚本执行时间无法解决网络中断、并发量过高和后端资源占用问题。对于敏感文件,交接传输前仍需保证访问控制没有被绕过。

6. 试图通过拼接参数支持任意文件路径

这是访问越权和路径穿越风险的常见来源。优先使用数据库 ID 或服务端允许列表定位资源,再检查用户权限和文件真实路径。验证扩展名、替换 ../或调用 basename()都不能单独构成完整的安全方案。

十、断点续传与 Range 请求的边界

普通的 readfile()下载示例通常只实现完整文件响应,并没有实现断点续传。客户端可能发送 Range请求,请求文件的一部分;服务器若支持,需要正确处理范围校验、部分内容的字节偏移、206 Partial Content状态、Content-Range等响应头,以及不支持范围时的处理方式。仅仅增加一个 Accept-Ranges: bytes响应头,并不会自动让 PHP 脚本正确支持断点续传。

文件内容可以稳定读取、部署组件适合处理范围请求时,优先让 Web 服务器或对象存储提供这项能力。若必须由应用处理,应使用经过验证的实现并覆盖单范围、多范围、非法范围、文件变化和并发访问等情况;不要把未经处理的客户端范围值直接用于文件偏移和读取长度。断点续传实现复杂度远高于一次性下载,不能用示例中的完整文件输出代码冒充。

十一、上线前检查清单

  • 下载前完成身份验证、资源级授权和必要的业务校验。
  • 客户端不能指定任意服务器路径;文件存放位置和映射关系由服务端控制。
  • 下载脚本在设置响应头前没有输出内容,错误信息写入日志而不是混进文件。
  • Content-Type、下载文件名及其编码符合文件和客户端需求。
  • Content-Length只在能够准确确定时设置。
  • 根据文件敏感程度选择缓存策略,不能用缓存头代替权限控制。
  • 大文件在生产环境中测试内存、超时、缓冲和并发,并评估是否应交由专用文件服务传输。
  • 需要断点续传时明确实现并测试 Range 支持,不假设普通文件输出会自动具备该能力。

PHP 文件下载可以简化为“验证资源、设置响应头、输出字节”三个步骤,但每一步都有明确的边界。应用只提供用户被授权的文件,响应头在正文之前一次性设置,输出过程不混入其他内容;当文件体积、并发或传输能力超出 PHP 请求进程的合适范围时,再把实际传输交给更适合的服务器或存储服务。

© 版权声明

相关文章