文件导入与资源访问
文件导入与资源访问 · 阅读后完成下方实验与检查题,再用「标记完成」记录进度。
学习目标
你想把本地的 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 },
});
}
索引结果随导入落库(文件名、大小、类型),列表页查库而不扫目录——数据库是唯一事实,磁盘只是仓库。
示例拆解
本机验证三件事:
curl -F "file=@notes.md" localhost:3000/api/documents/import——返回 201 和 doc_ 开头的 ID;再传超大文件与伪装类型,分别得到 413 和 415。- 用返回的 ID 请求取回接口,字节数与原文件一致。
- 构造
id=../../db/client之类的攻击串——数据库查无此 ID,404;路径前缀检查是第二道保险。
关键点:两层防御各管一头——ID 查库让「路径」根本进不了系统;前缀检查防未来有人手滑把用户输入又拼回路径。
动手任务
在本机工程里完成:
- 跑通导入接口:正常 .md、超大文件、伪装类型各打一次,核对状态码。
- 给 document 表补列表索引查询(按名称、大小、类型过滤),验证导入后的记录能被检索。
- 尝试用
../组合的伪 ID 访问,确认只得到 404/403 而非文件内容。
常见错误
- 信任 file.type 或扩展名:两者都可伪造;白名单 + 大小 + 服务端探测(读文件头)三件套才稳。
- 用原始文件名落盘:同名覆盖、中文乱码、路径注入全来了;磁盘文件名用生成的 ID。
- 把上传目录放在 public/ 下:绕过所有访问控制,任何人拼 URL 就能拿文件;私有资源必须走服务端中转。
- 落盘成功但入库失败不回滚:磁盘有文件、库里没记录,成了孤儿文件;两步要放进一个补偿流程。
检查与复盘
自查清单:
- 超限与非法类型都在入库前被拒,状态码语义正确
- 文件只能通过 ID + 数据库记录取到,磁盘路径从不来自用户输入
- 导入记录可按名称、大小、类型检索
参考资料
- Next.js 文档:Route Handlers —— FormData 处理与响应构造
- MDN:File —— File 对象的 size 与 type 语义
- Node.js 文档:node:path —— 路径拼接与解析
读完这一课了?
标记完成后,工作台会安排接下来的学习。