N17第 1 / 1 课时未开始内容版本 1.0.0 · 核验于 2026-10-26

文件导入与资源访问

文件导入与资源访问 · 阅读后完成下方实验与检查题,再用「标记完成」记录进度。

学习目标

你想把本地的 PDF、Markdown 资料喂给知识站:导入时检查大小和类型、入库建索引,之后用资源 ID 取回文件。这条链路里最危险的是「直接拿客户端给的本机路径去读文件」——本课把它堵死。

问题场景

第一版导入功能:前端把文件路径发过来,服务端 fs.readFile(path) 读取。两个问题立刻出现:一是 300MB 的视频也能传进来,直接把服务拖死;二是这个接口等于以服务进程权限开放了「读任意本机文件」——传 ../../.env 就能把项目里的密钥读出来。文件访问必须走受控通道。

核心解释

原则一句话:服务端永远不接受「路径」,只接受「文件内容」或「资源 ID」。

导入端:内容走上传,属性走校验:

// app/api/documents/import/route.ts(节选)
import { NextRequest, NextResponse } from "next/server";
import fs from "node:fs/promises";
import path from "node:path";
import { getDb, newId, DATA_DIR } from "@/lib/db/client";

const MAX_SIZE = 20 * 1024 * 1024; // 20MB
const EXT_BY_MIME = new Map([
  ["application/pdf", ".pdf"],
  ["text/markdown", ".md"],
]);

export async function POST(req: NextRequest) {
  const form = await req.formData();
  const file = form.get("file");
  if (!(file instanceof File)) {
    return NextResponse.json({ error: { code: "invalid_param" } }, { status: 400 });
  }
  const ext = EXT_BY_MIME.get(file.type);
  if (!ext) {
    return NextResponse.json(
      { error: { code: "unsupported_type", message: `不支持的类型 ${file.type}` } },
      { status: 415 }
    );
  }
  if (file.size > MAX_SIZE) {
    return NextResponse.json({ error: { code: "too_large" } }, { status: 413 });
  }
  const id = newId("doc_");
  const dir = path.join(DATA_DIR, "uploads");
  await fs.mkdir(dir, { recursive: true });
  await fs.writeFile(path.join(dir, id + ext), Buffer.from(await file.arrayBuffer()));
  getDb()
    .prepare("INSERT INTO document (id, filename, size, mime, created_at) VALUES (?, ?, ?, ?, ?)")
    .run(id, file.name, file.size, file.type, new Date().toISOString());
  return NextResponse.json({ id, size: file.size }, { status: 201 });
}

取回端:ID 是唯一钥匙:

// app/api/documents/[id]/route.ts(节选)
export async function GET(
  _req: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params;
  const row = getDb()
    .prepare("SELECT id, mime FROM document WHERE id = ?")
    .get(id) as { id: string; mime: string } | undefined;
  if (!row) return new NextResponse("Not Found", { status: 404 });

  // 磁盘路径由库里的 mime 推导,扩展名不从请求拼凑
  const filePath = path.join(DATA_DIR, "uploads", row.id + EXT_BY_MIME.get(row.mime));
  if (!filePath.startsWith(path.join(DATA_DIR, "uploads"))) {
    return new NextResponse("Forbidden", { status: 403 });
  }
  return new NextResponse(await fs.readFile(filePath), {
    headers: { "content-type": row.mime },
  });
}

索引结果随导入落库(文件名、大小、类型),列表页查库而不扫目录——数据库是唯一事实,磁盘只是仓库。

示例拆解

本机验证三件事:

  1. curl -F "file=@notes.md" localhost:3000/api/documents/import——返回 201 和 doc_ 开头的 ID;再传超大文件与伪装类型,分别得到 413 和 415。
  2. 用返回的 ID 请求取回接口,字节数与原文件一致。
  3. 构造 id=../../db/client 之类的攻击串——数据库查无此 ID,404;路径前缀检查是第二道保险。

关键点:两层防御各管一头——ID 查库让「路径」根本进不了系统;前缀检查防未来有人手滑把用户输入又拼回路径。

动手任务

在本机工程里完成:

  1. 跑通导入接口:正常 .md、超大文件、伪装类型各打一次,核对状态码。
  2. 给 document 表补列表索引查询(按名称、大小、类型过滤),验证导入后的记录能被检索。
  3. 尝试用 ../ 组合的伪 ID 访问,确认只得到 404/403 而非文件内容。

常见错误

  • 信任 file.type 或扩展名:两者都可伪造;白名单 + 大小 + 服务端探测(读文件头)三件套才稳。
  • 用原始文件名落盘:同名覆盖、中文乱码、路径注入全来了;磁盘文件名用生成的 ID。
  • 把上传目录放在 public/ 下:绕过所有访问控制,任何人拼 URL 就能拿文件;私有资源必须走服务端中转。
  • 落盘成功但入库失败不回滚:磁盘有文件、库里没记录,成了孤儿文件;两步要放进一个补偿流程。

检查与复盘

自查清单:

  • 超限与非法类型都在入库前被拒,状态码语义正确
  • 文件只能通过 ID + 数据库记录取到,磁盘路径从不来自用户输入
  • 导入记录可按名称、大小、类型检索

参考资料

检查题共 2 题,提交后立即显示解析
答案由服务端判定;答错的题会自动进入复习计划。
Q1多选较难(多选,全部选对才算正确)

知识站支持上传 .md 文件导入为笔记,服务端接收后写进 data/imports/ 目录。服务端必须做哪些校验?(多选)

Q2读代码中等

阅读导入功能的存储代码。当 fileName 的值是 '../../.env' 时会发生什么?正确的防御是?

import path from 'node:path';
import fs from 'node:fs/promises';

const baseDir = path.resolve('data/imports');

export async function saveImport(fileName: string, content: string) {
  const target = path.join(baseDir, fileName);
  await fs.writeFile(target, content);
  return target;
}

读完这一课了?

标记完成后,工作台会安排接下来的学习。

Command Palette

Search for a command to run...