← PHP文件上传:从表单构建到安全存储的完整流程 没有下一篇了 →

PHP文件下载:用readfile()安全可控地输出文件

原创 2026-05-11 PHP 已有人查阅

在Web开发中,文件下载是一项常见需求——软件安装包、PDF文档、用户导出的数据报表,都涉及将服务器上的文件发送到用户浏览器端。PHP提供了readfile()函数来完成这一任务,它能把文件内容读取后直接推送到输出缓冲区,配合合适的HTTP响应头,就能触发浏览器的下载行为。比起直接用HTML超链接指向文件,通过PHP脚本控制下载可以实现权限校验、下载日志记录、文件路径隐藏等实用功能。

一、为什么要用PHP处理文件下载

如果把文件直接放在Web可访问目录下,用户通过一个<a>链接就能下载。但这种方式有几个明显的局限:

  • 无法控制访问权限:任何知道链接的人都能下载,没法限制只有登录用户才能获取。

  • 无法记录下载行为:不知道谁下载了什么、下载了多少次。

  • 暴露文件真实路径:用户可以看到文件的服务器目录结构,存在信息泄露风险。

  • 浏览器行为不可控:对于TXT、JPG、PDF等浏览器能直接渲染的文件类型,点击链接可能会在浏览器中直接打开而不是触发下载。

通过PHP脚本来控制下载过程,上面这些问题都能得到解决。

个人经验分享:早些年做一个小型文档管理系统时,我直接把PDF文件放在/docs/目录下,用HTML的<a>标签链接过去。结果发现Google爬虫把整个文档库都索引了,有些内部资料直接出现在搜索结果里。后来改成用PHP脚本统一处理下载,在输出文件前先做登录态检查,同时给所有文档路径加上了一层映射(数据库里记录的真实路径不会暴露到前端),安全性和可控性都提升了不少。

二、readfile()函数详解

readfile()是PHP处理文件下载的核心函数,它读取一个文件的内容并直接写入输出缓冲区,省去了手动fopen()fread()echo的繁琐步骤。

语法结构

readfile(string $filename, bool $use_include_path = false, ?resource $context = null): int|false
  • $filename:要读取并输出的文件路径,可以是本地路径,也可以是一个URL(前提是PHP的allow_url_fopen配置开启)。

  • $use_include_path:可选,设为true时会在php.iniinclude_path指定的目录中搜索文件。

  • $context:可选,流上下文资源,用于更复杂的HTTP请求场景。

  • 返回值:成功时返回读取的字节数,失败时返回false

readfile()不用把整个文件内容加载到内存中再输出,而是按块读取直接推送,在处理较大文件时比file_get_contents()echo的组合对内存的消耗更低。

三、基础下载示例:触发浏览器下载文本文件

以下是一个简单的PHP下载脚本,让浏览器把服务器上的一个文本文件以附件形式下载到用户本地。

<?php
$file_path = 'resources/readme_2026.txt';   // 待下载的文件路径

// 检查文件是否存在
if (!file_exists($file_path)) {
    http_response_code(404);
    echo "文件未找到,请联系管理员。";
    exit;
}

// 设置HTTP响应头,触发下载行为
header('Content-Type: application/octet-stream');
header('Content-Transfer-Encoding: Binary');
header('Content-Disposition: attachment; filename="' . basename($file_path) . '"');
header('Content-Length: ' . filesize($file_path));

// 读取文件并输出
readfile($file_path);
exit;
?>

执行效果:用户访问这个PHP脚本时,浏览器不会显示文件内容,而是弹出“另存为”对话框,提示保存一个名为readme_2026.txt的文件。

关键响应头说明

  • Content-Type: application/octet-stream:告诉浏览器这是一个二进制流数据,浏览器不会尝试直接渲染它,而是触发下载。对于文本文件如果设为text/plain,某些浏览器仍可能直接打开预览。

  • Content-Disposition: attachment:指示浏览器以附件方式处理内容,filename参数指定了保存时的默认文件名。

  • Content-Length:告知文件大小,浏览器可以据此显示下载进度条。

四、下载二进制文件的处理方式

对于ZIP包、EXE可执行文件、图片等二进制文件,下载处理方式类似,只需调整MIME类型或者统一使用application/octet-stream

<?php
$file_url = 'https://example.com/files/project_backup.zip';

header('Content-Type: application/zip');
header('Content-Transfer-Encoding: Binary');
header('Content-Disposition: attachment; filename="project_backup_2026.zip"');
header('Content-Length: ' . filesize($file_url));

readfile($file_url);
exit;
?>

执行效果:浏览器弹出下载对话框,文件保存为project_backup_2026.zip

个人建议:如果下载来源是远程URL,readfile()可以接受URL作为参数,但每次请求都会从远程服务器重新拉取数据。对于频繁下载的热门文件,建议先把远程文件缓存到本地服务器,再用本地路径调用readfile(),既能减轻远程服务器的压力,也减少了网络延迟的影响。远程读取时filesize()可能无确获取文件大小,此时可以省略Content-Length头。

五、使用file_get_contents()配合实现带日志的下载

在某些场景下,需要在文件下载前后做一些额外的处理——比如记录下载日志、更新下载计数器、校验用户权限等。这时可以在readfile()的前后插入相应的业务逻辑。

<?php
$requested_file = 'downloads/user_manual_v3.pdf';
$log_file = 'logs/download_records.txt';

// 记录下载尝试
$user_ip = $_SERVER['REMOTE_ADDR'];
$timestamp = date('Y-m-d H:i:s');
file_put_contents($log_file, "[$timestamp] IP:$user_ip 请求下载 $requested_file\n", FILE_APPEND);

// 检查目标文件
if (!file_exists($requested_file)) {
    file_put_contents($log_file, "[$timestamp] IP:$user_ip 下载失败,文件不存在\n", FILE_APPEND);
    http_response_code(404);
    echo "下载失败:文件不存在,可能已被移除。";
    exit;
}

// 清理输出缓冲区,防止之前的内容干扰文件输出
if (ob_get_level()) {
    ob_end_clean();
}

// 设置下载头
header('Content-Description: File Transfer');
header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename="' . basename($requested_file) . '"');
header('Expires: 0');
header('Cache-Control: must-revalidate');
header('Pragma: public');
header('Content-Length: ' . filesize($requested_file));

// 输出文件
readfile($requested_file);

// 记录成功
file_put_contents($log_file, "[$timestamp] IP:$user_ip 成功下载 $requested_file\n", FILE_APPEND);
exit;
?>

执行效果

  • user_manual_v3.pdf存在:浏览器触发下载,日志文件追加一条成功记录。

  • 若文件不存在:页面显示404提示,日志记录失败信息。

日志文件内容示例

[2026-05-11 14:32:10] IP:192.168.1.108 请求下载 downloads/user_manual_v3.pdf
[2026-05-11 14:32:10] IP:192.168.1.108 成功下载 downloads/user_manual_v3.pdf

为什么要用ob_end_clean()清理输出缓冲区:PHP的脚本文件中可能在<?php标签之前存在一些空白字符或BOM头,或者之前有echo输出,这些内容如果留在缓冲区里,会被混入下载文件中,导致文件损坏。在设置响应头之前清理一次输出缓冲区,是比较稳妥的做法。

六、下载安全实践与访问控制

下载功能处理不当,可能引发路径遍历攻击、未授权访问、带宽滥用等问题。以下是几个值得纳入编码习惯的安全措施。

1. 文件路径校验

不要把用户输入的参数直接拼接成文件路径。攻击者可以通过../../../etc/passwd这样的路径遍历字符串读取服务器上的任意文件。

<?php
// 用户传入的文件名参数
$user_file = $_GET['file'] ?? '';

// 用basename()剥离路径信息,只保留文件名部分
$safe_filename = basename($user_file);

// 限定下载目录,拼接出安全路径
$base_dir = 'downloads/public/';
$file_path = $base_dir . $safe_filename;

if (!file_exists($file_path)) {
    http_response_code(404);
    echo "文件不存在";
    exit;
}

// 二次校验:确保解析后的真实路径在允许的目录范围内
$real_path = realpath($file_path);
if (strpos($real_path, realpath($base_dir)) !== 0) {
    http_response_code(403);
    echo "禁止访问";
    exit;
}

// 通过校验后执行下载
header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename="' . $safe_filename . '"');
readfile($file_path);
exit;
?>

realpath()会解析出文件的真实绝对路径,再和允许的基础目录做前缀匹配,能有效防止路径穿越攻击。

2. 登录态校验

对敏感文件执行下载前,先检查用户的登录状态和权限等级。

<?php
session_start();
if (!isset($_SESSION['user_id'])) {
    http_response_code(403);
    echo "请先登录后再下载文件。";
    exit;
}
// 继续执行下载逻辑...
?>

3. 隐藏真实文件路径

文件的实际存储路径不直接暴露给前端。可以利用数据库或键值对建立映射关系,用户只知道一个下载ID,后端根据ID查询出真实路径后再调用readfile()

本节课程知识要点

  • readfile()能将文件内容直接推送到输出缓冲区,配合HTTP响应头触发浏览器下载,适合各类文件类型。

  • Content-Type: application/octet-stream让浏览器把响应视为二进制流,是触发下载的通用做法;Content-Disposition: attachment配合filename参数控制下载弹窗和默认文件名。

  • 通过PHP脚本处理下载,可以实现权限检查、下载计数、日志记录等HTML直链方式做不到的功能。

  • 使用ob_end_clean()清理输出缓冲区,防止脚本中的多余输出混入下载文件导致文件损坏。

  • 对用户输入的下载文件名参数,务必用basename()剥离路径、用realpath()校验真实路径范围,防范路径遍历漏洞。

  • 涉及大文件下载时,readfile()比先file_get_contents()echo更节省内存,它按块读取直接输出而不会将整个文件加载到内存。

  • 生产环境中,敏感文件的下载必须加上登录态和权限校验,文件真实存储路径不应直接暴露给客户端。

PHP实现文件下载,技术层面的核心是readfile()函数配合恰当的HTTP头设置。但在项目中,围绕下载安全的路径校验、权限控制和日志审计,往往比写出下载代码本身更需要花心思。把这些防护措施融入到下载逻辑中形成固定的编码模式,能够让你的文件下载功能在可用和安全之间取得平衡。

← PHP文件上传:从表单构建到安全存储的完整流程 没有下一篇了 →
分享笔记 (共有 篇笔记)
验证码:
微信公众号