PHP下载系统从零搭建:文件上传、存储与下载流程详解

PHP下载系统从零搭建:文件上传、存储与下载流程详解

PHP下载系统从零搭建:文件上传、存储与下载流程详解

文件下载功能看起来只是“把文件传上去,再给一个链接”,实际涉及请求限制、文件校验、存储路径、访问授权和响应头等多个环节。只要其中一处处理不当,就可能出现文件覆盖、目录穿越、任意文件下载或磁盘被占满等问题。

本文从一个可运行的 PHP 示例出发,搭建简单的文件上传与下载系统。示例使用 SQLite 保存文件元数据,使用服务器本地目录保存文件内容,并通过随机 ID 提供下载地址。代码适合内部工具、受控用户上传的资料库和小型应用原型;公开服务或高并发场景还需要增加身份验证、限流、配额、恶意文件扫描等措施。

一、先明确系统的工作流程

一个最小可用的文件系统通常分成两部分:

  1. 文件内容:实际文件保存在磁盘或对象存储中。
  2. 文件元数据:保存随机标识、原始文件名、文件大小、媒体类型和存储路径等信息。

上传时,PHP 接收 multipart/form-data 请求,检查 PHP 返回的上传状态,再校验大小、扩展名和 MIME 类型。通过检查后,系统为文件生成不可预测的存储名,将文件移入存储目录,并把下载所需信息写入数据库。

下载时,客户端只提供文件 ID。服务端查询元数据、确认请求者有权访问文件,再从存储目录读取文件并返回响应。下载 URL 不应该直接包含服务器文件路径,也不应该让用户提交任意路径。

二、适用场景与不适用场景

这种实现适用于文件数量和访问量适中、单机部署的小型系统,例如团队资料下载、课程附件、产品文档或内部归档。它把文件名和存储路径隔离开,结构简单,容易本地运行和调试。

以下情况不能只依赖本文的基础代码:

  • 用户隐私或商业敏感文件:必须接入登录和逐文件授权,不能把“知道链接”当作权限控制。
  • 面向公众的上传入口:需要限流、配额、滥用监测、恶意文件检测以及明确的内容处置策略。
  • 多台应用服务器:本地磁盘通常不能自动共享,应改用共享存储或对象存储,并处理数据库与文件存储的一致性。
  • 大文件或高并发下载:可使用对象存储的签名 URL,或由 Web 服务器通过 X-Sendfile、X-Accel-Redirect 等机制发送文件,避免 PHP 长时间占用工作进程。

三、运行环境与目录准备

示例要求 PHP 8.1 或更高版本,并启用 PDO SQLite、Fileinfo 扩展。可以先检查扩展:

php -m

确认输出中包含 PDO、pdo_sqlite 和 fileinfo。目录结构如下,storage 和 SQLite 数据库应放在 Web 根目录之外:

project/
├── public/
│   ├── upload.php
│   └── download.php
├── storage/
└── data/

在项目目录中创建 storage 和 data。运行 PHP 的用户必须能够写入这两个目录。不要为了省事把目录权限设成可供所有用户读写;应按操作系统和部署方式,只授予 PHP 服务进程所需权限。

开发环境可以在项目目录启动内置服务器:

php -S 127.0.0.1:8000 -t public

内置服务器只适合开发和测试,不是生产环境 Web 服务器。

四、公共配置与数据库初始化

在项目根目录创建 common.php。它负责建立目录、初始化 SQLite 表,并提供统一的文件类型白名单。示例只允许 PDF、PNG、JPEG 和纯文本;需要其他格式时,应按业务需求增加扩展名与 MIME 类型的对应关系,而不是放开任意文件类型。

<?php
declare(strict_types=1);

const STORAGE_DIR = __DIR__ . '/storage';
const DATABASE_FILE = __DIR__ . '/data/files.sqlite';
const MAX_UPLOAD_BYTES = 10 * 1024 * 1024; // 10 MiB

const ALLOWED_TYPES = [
    'pdf'  => ['application/pdf'],
    'png'  => ['image/png'],
    'jpg'  => ['image/jpeg'],
    'jpeg' => ['image/jpeg'],
    'txt'  => ['text/plain'],
];

function db(): PDO
{
    static $pdo = null;

    if ($pdo instanceof PDO) {
        return $pdo;
    }

    if (!is_dir(STORAGE_DIR) && !mkdir(STORAGE_DIR, 0700, true) && !is_dir(STORAGE_DIR)) {
        throw new RuntimeException('无法创建存储目录');
    }

    $dataDir = dirname(DATABASE_FILE);
    if (!is_dir($dataDir) && !mkdir($dataDir, 0700, true) && !is_dir($dataDir)) {
        throw new RuntimeException('无法创建数据库目录');
    }

    $pdo = new PDO('sqlite:' . DATABASE_FILE, null, null, [
        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
    ]);

    $pdo->exec(
        'CREATE TABLE IF NOT EXISTS files (
            id TEXT PRIMARY KEY,
            stored_name TEXT NOT NULL UNIQUE,
            original_name TEXT NOT NULL,
            extension TEXT NOT NULL,
            mime_type TEXT NOT NULL,
            size_bytes INTEGER NOT NULL,
            created_at TEXT NOT NULL
        )'
    );

    return $pdo;
}

数据库中的 stored_name 是服务器生成的存储名,不采用用户提供的文件名。数据库路径也在 Web 根目录之外,避免通过静态 URL 直接下载数据库或存储文件。

五、实现上传接口

在 public/upload.php 中接收文件。客户端需要使用 multipart/form-data,文件字段名为 file。示例返回 JSON,便于网页、脚本或其他服务调用。

<?php
declare(strict_types=1);

require dirname(__DIR__) . '/common.php';

header('Content-Type: application/json; charset=utf-8');

function fail(int $status, string $message): never
{
    http_response_code($status);
    echo json_encode(['error' => $message], JSON_UNESCAPED_UNICODE);
    exit;
}

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    header('Allow: POST');
    fail(405, '仅支持 POST 请求');
}

if (!isset($_FILES['file']) || !is_array($_FILES['file'])) {
    fail(400, '缺少文件字段 file');
}

$upload = $_FILES['file'];

if (!isset($upload['error']) || is_array($upload['error'])) {
    fail(400, '无效的上传数据');
}

if ($upload['error'] !== UPLOAD_ERR_OK) {
    $messages = [
        UPLOAD_ERR_INI_SIZE   => '文件超过服务器配置的上传大小限制',
        UPLOAD_ERR_FORM_SIZE  => '文件超过表单声明的大小限制',
        UPLOAD_ERR_PARTIAL    => '文件只上传了部分内容',
        UPLOAD_ERR_NO_FILE    => '没有收到文件',
        UPLOAD_ERR_NO_TMP_DIR => '服务器缺少临时目录',
        UPLOAD_ERR_CANT_WRITE => '服务器无法写入临时文件',
        UPLOAD_ERR_EXTENSION  => '上传被 PHP 扩展中止',
    ];
    fail(400, $messages[$upload['error']] ?? '上传失败');
}

$tmpPath = $upload['tmp_name'] ?? '';
$size = $upload['size'] ?? -1;

if (!is_string($tmpPath) || !is_uploaded_file($tmpPath)) {
    fail(400, '上传临时文件无效');
}

if (!is_int($size) || $size <= 0 || $size > MAX_UPLOAD_BYTES) {
    fail(413, '文件大小不符合要求');
}

$clientName = $upload['name'] ?? '';
if (!is_string($clientName)) {
    fail(400, '文件名无效');
}

// 兼容不同操作系统的路径分隔符,只保留末尾名称。
$clientName = str_replace('\\', '/', $clientName);
$originalName = basename($clientName);
$originalName = preg_replace('/[\x00-\x1F\x7F]/u', '', $originalName) ?? '';
$originalName = trim($originalName);

if ($originalName === '' || strlen($originalName) > 240) {
    fail(400, '文件名为空或过长');
}

$extension = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
if (!isset(ALLOWED_TYPES[$extension])) {
    fail(415, '不支持该文件扩展名');
}

$finfo = new finfo(FILEINFO_MIME_TYPE);
$mimeType = $finfo->file($tmpPath);

if (!is_string($mimeType) || !in_array($mimeType, ALLOWED_TYPES[$extension], true)) {
    fail(415, '文件内容类型与允许的类型不匹配');
}

$id = bin2hex(random_bytes(16));
$storedName = bin2hex(random_bytes(24)) . '.' . $extension;
$destination = STORAGE_DIR . '/' . $storedName;

if (!move_uploaded_file($tmpPath, $destination)) {
    fail(500, '无法保存上传文件');
}

try {
    $statement = db()->prepare(
        'INSERT INTO files
         (id, stored_name, original_name, extension, mime_type, size_bytes, created_at)
         VALUES (:id, :stored_name, :original_name, :extension, :mime_type, :size_bytes, :created_at)'
    );

    $statement->execute([
        ':id'            => $id,
        ':stored_name'   => $storedName,
        ':original_name' => $originalName,
        ':extension'     => $extension,
        ':mime_type'     => $mimeType,
        ':size_bytes'    => $size,
        ':created_at'    => gmdate('Y-m-d\TH:i:s\Z'),
    ]);
} catch (Throwable $e) {
    @unlink($destination);
    error_log((string) $e);
    fail(500, '保存文件信息失败');
}

echo json_encode([
    'id' => $id,
    'download_url' => '/download.php?id=' . $id,
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);

上传校验到底能保证什么

is_uploaded_file() 和 move_uploaded_file() 用于确认文件来自 PHP 的 HTTP 上传流程,并安全地移动 PHP 创建的上传临时文件。它们不是恶意内容检测器。

$_FILES['size'] 来自 PHP 解析后的上传信息,应用层还检查它是否为正数以及是否超过业务上限。MIME 检测使用 Fileinfo 检查文件内容特征,比直接相信客户端提交的 Content-Type 更可靠,但仍不能证明文件无害。文件格式可能包含脚本、宏或利用解析器漏洞的内容;如果业务风险较高,还要采用杀毒或内容扫描,并考虑在隔离环境中处理。

扩展名和 MIME 类型需要同时匹配,是一种格式筛选,不是安全认证。某些合法文件可能因服务器 Fileinfo 数据库或文件内容差异而被识别成其他 MIME 类型;上线前应使用实际业务文件测试,并谨慎调整白名单。

六、实现下载接口

在 public/download.php 中,客户端只提交随机 ID。接口先校验 ID 格式,再查询数据库并确认最终路径仍位于存储目录中。文件名用于下载提示,不参与服务器路径拼接。

<?php
declare(strict_types=1);

require dirname(__DIR__) . '/common.php';

if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
    header('Allow: GET');
    http_response_code(405);
    exit('Method Not Allowed');
}

$id = $_GET['id'] ?? '';
if (!is_string($id) || !preg_match('/\A[a-f0-9]{32}\z/', $id)) {
    http_response_code(400);
    exit('Invalid file id');
}

$statement = db()->prepare(
    'SELECT id, stored_name, original_name, extension, mime_type, size_bytes
     FROM files WHERE id = :id'
);
$statement->execute([':id' => $id]);
$file = $statement->fetch();

if (!$file) {
    http_response_code(404);
    exit('File not found');
}

$storedName = $file['stored_name'];
if (!is_string($storedName) || !preg_match('/\A[a-f0-9]{48}\.(pdf|png|jpg|jpeg|txt)\z/', $storedName)) {
    http_response_code(404);
    exit('File not found');
}

$path = STORAGE_DIR . '/' . $storedName;
$realStorage = realpath(STORAGE_DIR);
$realPath = realpath($path);

if ($realStorage === false || $realPath === false
    || !str_starts_with($realPath, $realStorage . DIRECTORY_SEPARATOR)
    || !is_file($realPath) || !is_readable($realPath)) {
    http_response_code(404);
    exit('File not found');
}

$allowedMimes = [];
foreach (ALLOWED_TYPES as $types) {
    $allowedMimes = array_merge($allowedMimes, $types);
}
$mimeType = $file['mime_type'];
if (!is_string($mimeType) || !in_array($mimeType, $allowedMimes, true)) {
    http_response_code(500);
    exit('Invalid stored metadata');
}

$name = $file['original_name'];
if (!is_string($name) || $name === '') {
    $name = 'download.' . $file['extension'];
}

// ASCII 回退名称;filename* 提供 UTF-8 文件名。
$fallback = preg_replace('/[^A-Za-z0-9._-]/', '_', $name) ?? 'download';
$fallback = trim($fallback, '._-');
if ($fallback === '') {
    $fallback = 'download';
}

while (ob_get_level() > 0) {
    ob_end_clean();
}

header('Content-Type: ' . $mimeType);
header('Content-Disposition: attachment; filename="' . $fallback . '"; filename*=UTF-8\'\'' . rawurlencode($name));
header('Content-Length: ' . (string) filesize($realPath));
header('X-Content-Type-Options: nosniff');
header('Cache-Control: private, no-store');

readfile($realPath);
exit;

随机 ID 降低了顺序枚举的可能性,但它本身不是完整的授权方案。链接一旦泄露,持有链接的人仍可能下载文件。若文件属于用户或组织,应在查询后检查当前登录用户是否拥有该文件或具备相应权限,再发送响应。

下载响应使用 Content-Disposition: attachment,让浏览器优先按附件处理。filename 提供兼容性较好的 ASCII 回退名称,filename* 按 RFC 5987 的方式编码 UTF-8 文件名。原始文件名已经从响应头的控制字符中清理,并经过 URL 编码,避免把用户输入直接拼进响应头。

X-Content-Type-Options: nosniff 用于提示浏览器不要自行猜测内容类型。它不能替代文件校验,也不能取代授权。若文件应在浏览器内展示,可以按业务决定是否使用 inline,但对 HTML、SVG 等可执行或可嵌入内容应格外谨慎。

七、提交文件与测试

可以用 curl 上传一个 PDF 文件:

curl -F "file=@./example.pdf" http://127.0.0.1:8000/upload.php

成功时返回类似下面的 JSON:

{
  "id": "0123456789abcdef0123456789abcdef",
  "download_url": "/download.php?id=0123456789abcdef0123456789abcdef"
}

在浏览器中访问服务器地址加返回的下载路径,或用 curl 下载:

curl -OJ "http://127.0.0.1:8000/download.php?id=0123456789abcdef0123456789abcdef"

测试时还应覆盖以下情况:上传空文件、超过 10 MiB 的文件、不在白名单中的扩展名、扩展名与内容不匹配的文件、手动修改的无效 ID,以及磁盘文件被删除但数据库记录仍存在的情况。接口对这些情形应返回明确错误,而不是输出 PHP 警告或服务器路径。

八、PHP 配置限制与应用限制

示例中的 MAX_UPLOAD_BYTES 是应用层业务限制,并不代表 PHP 一定会收到这么大的文件。PHP 还有独立配置项,例如:

  • upload_max_filesize:单个上传文件的 PHP 限制。
  • post_max_size:整个 POST 请求体的限制,通常需要大于单文件限制,因为请求还包含 multipart 边界和其他字段。
  • max_file_uploads:单个请求可上传的文件数量限制。
  • max_input_time、Web 服务器超时及代理限制:可能影响大文件或慢速网络上传。

这些配置的具体可设置范围取决于 PHP 版本、SAPI、系统资源以及 Web 服务器和代理配置,不能仅通过应用代码保证。实际接入前,应检查当前环境的有效配置,并让 PHP、Web 服务器、反向代理和应用层限制彼此协调。客户端超过 PHP 限制时,$_FILES 可能带有对应错误码;整个请求超过 post_max_size 时,PHP 也可能无法按预期填充表单字段。

示例的 10 MiB 是人为选取的业务值,不是 PHP 固定上限。应根据文件用途、服务器带宽、磁盘容量和请求超时确定合适值,并限制单个用户的总存储量与上传频率。

九、常见错误与修正

1. 把原始文件名直接作为磁盘路径

用户文件名可能包含路径分隔符、特殊字符或与现有文件重名。直接拼路径可能造成覆盖或路径穿越。应使用随机存储名,原始文件名只用于展示和下载提示。

2. 只检查扩展名

重命名文件即可伪造扩展名。扩展名检查应与服务端 MIME 检测结合,但两者都不是恶意软件扫描。高风险场景还应扫描内容,并将文件放在不能被 Web 服务器直接执行的位置。

3. 把存储目录放在公开 Web 目录下

如果 Web 服务器可以直接按路径访问文件,用户可能绕过下载接口的登录、授权和审计逻辑。优先将存储目录放到 Web 根目录之外;如果做不到,必须在 Web 服务器配置中禁止该目录的直接访问与脚本执行。

4. 认为随机链接等同于访问控制

随机 ID 具有不可预测性,但可能通过聊天记录、浏览器历史、日志或页面引用泄露。需要权限控制时,应在每次下载时校验登录身份和文件归属,并避免将敏感链接暴露给不可信第三方。

5. 只返回下载地址,不考虑文件生命周期

文件存储会持续占用磁盘。生产系统应定义保留期限、用户配额和删除流程,并处理“删记录但文件仍在”或“删文件但记录仍在”的不一致。可通过定期任务清理过期文件和孤立文件,删除前要有明确的权限和审计策略。

6. 忽略并发和容量问题

本地磁盘可能耗尽,SQLite 也不适合所有高并发写入场景。应监控磁盘使用量、上传失败率和下载耗时。多实例部署时,需把文件放到共享存储或对象存储,并评估元数据数据库的并发能力与备份恢复方式。

十、上线前的安全检查清单

  • 上传和下载接口是否要求适当的身份验证与逐文件授权?
  • 上传文件大小、类型、用户配额和请求频率是否有限制?
  • 存储目录是否无法通过静态 URL 直接访问或执行文件?
  • 数据库和存储文件是否纳入备份、监控与恢复演练?
  • 文件下载名、响应头和错误信息是否避免回显未经处理的输入?
  • 是否根据风险对上传内容进行恶意文件检测,或明确限制可上传类型?
  • 是否处理过期文件、孤立文件及数据库和磁盘之间的不一致?

这套示例将“用户看到的文件名”“数据库记录”和“磁盘上的存储名”分开,避免把用户输入当成可信路径,并通过随机 ID 把下载请求映射到服务端记录。它适合作为小型 PHP 文件系统的起点;真正上线时,应把认证授权、配额、清理、扫描、备份和运行监控作为系统的一部分,而不是只关注上传与下载代码本身。

© 版权声明

相关文章