用 Claude Code 安全实现文件上传:FormData、校验、预览与 S3
面向 SaaS 的 Claude Code 文件上传实战:File API、FormData、fetch、服务端校验、预览、进度和 S3 取舍。
文件上传看起来只是一个按钮,但在 SaaS 产品里,它很快会变成一个安全边界。头像、发票 PDF、CSV 导入、合同、聊天附件,每一种场景都需要考虑浏览器 API、请求格式、服务端校验、存储权限、公开范围、预览、进度、成本和审计日志。
Claude Code 很适合生成这类功能,因为它可以同时写 React 组件、API 路由、校验函数、存储适配层和 README。不过提示词必须足够具体。如果只说“做一个文件上传”,你可能会得到能跑的 demo,却把原始文件名直接保存、只相信浏览器传来的 MIME 类型、没有大小限制,或者把上传后的 URL 直接公开。
本文按可审查的顺序实现。先理解浏览器侧的 File API、FormData 和 Fetch API,再做最小上传、React 预览和真实进度、服务端校验,最后讨论什么时候迁移到 S3、Cloud Storage、Azure Blob Storage 或 Cloudflare R2。
如果你已经要接 S3,可以配合阅读 Claude Code 和 AWS S3。安全审查的整体思路可以看 Claude Code 安全实践。
先把浏览器侧职责拆开
第一层是 File API。用户通过<input type="file">选择文件,或者把文件拖到页面上时,浏览器会给你一个File对象。你可以读到file.name、file.size、file.type和file.lastModified。如果是图片,还可以用URL.createObjectURL(file)生成临时预览地址。
第二层是 FormData。它是把普通字段和文件一起按multipart/form-data发送的容器。常见写法是formData.append("file", file)。这里有一个很实用的坑:用 FormData 配合 fetch 时,不要手动设置Content-Type。浏览器需要自动追加 multipart boundary,手写反而容易破坏请求。
第三层是 fetch。它负责发送 HTTP 请求。小文件上传可以直接这样写:
const formData = new FormData();
formData.append("file", file);
await fetch("/api/upload", {
method: "POST",
body: formData
});
但如果产品需要真实进度条,fetch 并不是最顺手的选择。上传进度事件通常还是用XMLHttpRequest.upload.onprogress更直接。所以我会让 Claude Code 明确区分:简单上传用 fetch,需要精确进度时用 XMLHttpRequest。
最小 HTML 与 fetch 实现
先不要急着上 React 和 S3。下面这个版本完成选择文件、浏览器侧类型/大小检查、图片预览、FormData 发送。
<form id="upload-form">
<input id="file-input" name="file" type="file" accept="image/png,image/jpeg,application/pdf" />
<button type="submit">上传</button>
</form>
<img id="preview" alt="" style="max-width: 240px; display: none;" />
<p id="message"></p>
<script type="module">
const MAX_BYTES = 5 * 1024 * 1024;
const allowedTypes = new Set(["image/png", "image/jpeg", "application/pdf"]);
const form = document.querySelector("#upload-form");
const input = document.querySelector("#file-input");
const preview = document.querySelector("#preview");
const message = document.querySelector("#message");
input.addEventListener("change", () => {
const file = input.files?.[0];
preview.style.display = "none";
preview.removeAttribute("src");
message.textContent = "";
if (!file) return;
if (!allowedTypes.has(file.type)) {
message.textContent = "只允许 PNG、JPEG、PDF。";
input.value = "";
return;
}
if (file.size > MAX_BYTES) {
message.textContent = "文件大小必须在 5MB 以内。";
input.value = "";
return;
}
if (file.type.startsWith("image/")) {
preview.src = URL.createObjectURL(file);
preview.style.display = "block";
preview.onload = () => URL.revokeObjectURL(preview.src);
}
});
form.addEventListener("submit", async (event) => {
event.preventDefault();
const file = input.files?.[0];
if (!file) {
message.textContent = "请先选择文件。";
return;
}
const formData = new FormData();
formData.append("file", file);
const response = await fetch("/api/upload", {
method: "POST",
body: formData
});
const result = await response.json();
message.textContent = response.ok ? `已保存: ${result.name}` : result.error;
});
</script>
浏览器侧检查是为了体验,不是安全边界。攻击者可以绕过这些 JavaScript,所以服务端必须重新检查类型、扩展名、大小、保存名和保存位置。
React 预览与真实上传进度
React 版建议把状态拆清楚:选中的文件、预览 URL、进度、错误、保存后的文件名。以后 Claude Code 要加拖拽、取消上传或多文件时,也更容易改。
import { ChangeEvent, FormEvent, useEffect, useMemo, useState } from "react";
const MAX_BYTES = 5 * 1024 * 1024;
const ALLOWED_TYPES = new Set(["image/png", "image/jpeg", "application/pdf"]);
type UploadResult = { ok: true; name: string; size: number; type: string };
export function FileUploadBox() {
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [progress, setProgress] = useState(0);
const [error, setError] = useState<string | null>(null);
const [uploadedName, setUploadedName] = useState<string | null>(null);
const canUpload = useMemo(() => selectedFile && !error, [selectedFile, error]);
useEffect(() => {
return () => {
if (previewUrl) URL.revokeObjectURL(previewUrl);
};
}, [previewUrl]);
function handleFileChange(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0] ?? null;
setUploadedName(null);
setProgress(0);
setError(null);
if (previewUrl) URL.revokeObjectURL(previewUrl);
setPreviewUrl(null);
if (!file) return setSelectedFile(null);
if (!ALLOWED_TYPES.has(file.type)) {
setSelectedFile(null);
return setError("只允许 PNG、JPEG、PDF。");
}
if (file.size > MAX_BYTES) {
setSelectedFile(null);
return setError("文件大小必须在 5MB 以内。");
}
setSelectedFile(file);
if (file.type.startsWith("image/")) setPreviewUrl(URL.createObjectURL(file));
}
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!selectedFile) return;
const formData = new FormData();
formData.append("file", selectedFile);
const result = await uploadWithProgress(formData, setProgress);
setUploadedName(result.name);
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<input type="file" accept="image/png,image/jpeg,application/pdf" onChange={handleFileChange} />
{previewUrl && <img src={previewUrl} alt="文件预览" width={240} />}
{selectedFile && <p>{selectedFile.name} / {Math.round(selectedFile.size / 1024)}KB</p>}
{error && <p role="alert">{error}</p>}
<progress value={progress} max={100}>{progress}%</progress>
<button type="submit" disabled={!canUpload}>上传</button>
{uploadedName && <p>已保存: {uploadedName}</p>}
</form>
);
}
function uploadWithProgress(formData: FormData, onProgress: (progress: number) => void) {
return new Promise<UploadResult>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", "/api/upload");
xhr.upload.addEventListener("progress", (event) => {
if (event.lengthComputable) onProgress(Math.round((event.loaded / event.total) * 100));
});
xhr.addEventListener("load", () => {
const body = JSON.parse(xhr.responseText || "{}");
if (xhr.status >= 200 && xhr.status < 300) resolve(body);
else reject(new Error(body.error ?? "Upload failed"));
});
xhr.addEventListener("error", () => reject(new Error("Network error")));
xhr.send(formData);
});
}
这里的原则是诚实。用 fetch 就显示“上传中”,不要假装知道百分比;需要真实百分比就用能拿到上传进度的方式。
服务端校验必须重新做
下面是 Next.js Route Handler。它保存到.local-uploads,适合第一版验证。生产环境可以把保存逻辑替换成对象存储。
// app/api/upload/route.ts
import { randomUUID } from "node:crypto";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { NextRequest, NextResponse } from "next/server";
export const runtime = "nodejs";
const MAX_BYTES = 5 * 1024 * 1024;
const ALLOWED_TYPES = new Map([
["image/png", ".png"],
["image/jpeg", ".jpg"],
["application/pdf", ".pdf"]
]);
export async function POST(request: NextRequest) {
const formData = await request.formData();
const value = formData.get("file");
if (!(value instanceof File)) {
return NextResponse.json({ error: "缺少文件。" }, { status: 400 });
}
const expectedExt = ALLOWED_TYPES.get(value.type);
const originalExt = path.extname(value.name).toLowerCase();
if (!expectedExt) return NextResponse.json({ error: "不允许的 MIME 类型。" }, { status: 400 });
if (value.size === 0 || value.size > MAX_BYTES) {
return NextResponse.json({ error: "文件必须大于 0 且不超过 5MB。" }, { status: 400 });
}
if (expectedExt === ".jpg" && ![".jpg", ".jpeg"].includes(originalExt)) {
return NextResponse.json({ error: "JPEG 扩展名不正确。" }, { status: 400 });
}
if (expectedExt !== ".jpg" && originalExt !== expectedExt) {
return NextResponse.json({ error: "MIME 类型和扩展名不一致。" }, { status: 400 });
}
const bytes = Buffer.from(await value.arrayBuffer());
const storedName = `${randomUUID()}${expectedExt === ".jpg" ? ".jpg" : expectedExt}`;
const uploadDir = path.join(process.cwd(), ".local-uploads");
await mkdir(uploadDir, { recursive: true });
await writeFile(path.join(uploadDir, storedName), bytes, { flag: "wx" });
return NextResponse.json({ ok: true, name: storedName, size: value.size, type: value.type });
}
最少要检查 MIME、扩展名、大小、保存名、保存位置。更严格的场景还要检查文件魔数、实际解码、病毒扫描、登录用户、配额和审计日志。不要在代码注释里写“扩展名检查即可保证安全”,这会误导后续维护者。
什么时候迁移到 S3 或 Cloud Storage
本地保存适合学习、原型和内部临时工具。如果文件是客户资产,或者服务会扩展到多台机器,或者需要生命周期规则、异步缩略图、长期备份,就应该迁移到对象存储。
一开始不一定要做浏览器直传 S3。小文件可以先经过应用服务器:服务器接收、校验、生成安全 key,然后写入 S3。等文件变大或流量变高,再引入 presigned URL。这样学习曲线更平滑,也更容易审查权限。
请把现有 Next.js 本地文件上传改为 S3 保存。
现状: /api/upload 已经用 FormData 接收文件,并检查 MIME、扩展名、大小。
约束: 不使用原始文件名作为 S3 key,保存为 uploads/yyyy/mm/dd/{uuid}.ext。
约束: 只允许 image/png、image/jpeg、application/pdf,最大 5MB。
约束: bucket 保持 private,API 返回文件 ID,不返回公开 URL。
成果物: app/api/upload/route.ts、lib/storage/s3.ts、失败用例测试、README 环境变量。
确认: 说明超大文件、扩展名不一致、未登录、正常上传的测试方法。
三个实际场景
头像上传重视预览、裁剪和重试。建议先只允许 PNG、JPEG、WebP,不要轻易开放 SVG。
CSV 导入重视业务校验。上传之后要预览列名、行数、编码、错误行和回滚策略。提示 Claude Code 时要提供 CSV 样例和失败时的报告格式。
合同或发票 PDF 重视权限。文件应保存在 private 存储里,下载前检查登录和组织权限,再发短时间有效的签名 URL,并记录访问日志。
聊天附件则重视进度、取消、成本和过期规则。不要让附件 bucket 变成无限期归档。
常见失败与 Claude Code 提示词
常见失败包括:把accept当安全机制、直接保存原始文件名、上传后立即返回公开 URL、用定时器伪造进度、S3 权限过宽。提示词要把这些禁止项写进去。
为这个 Next.js 应用添加安全文件上传。
目标: SaaS 管理画面一次上传一个 PNG/JPEG/PDF。
客户端: React 组件使用 File API,显示文件名、大小、图片预览、错误和上传状态。
传输: 用 FormData POST 到 /api/upload。需要真实进度时用 XMLHttpRequest,并解释为什么不用 fetch 做进度。
服务端: 在 app/api/upload/route.ts 接收 FormData,校验 MIME、扩展名、5MB 上限和空文件。
保存: 不把原始文件名当保存名,使用 UUID + 扩展名保存到 .local-uploads。
禁止: 不直接保存到 public/。不声称扩展名检查就是完整安全。不把 S3 bucket 设为 public。
验证: 说明超大文件、扩展名不一致、缺少文件、正常上传的测试。
参考: MDN File API、FormData、Fetch API。
Masa 的验证笔记
我测试这个流程时,最明显的质量差异来自“fetch 简单上传”和“真实进度上传”的拆分。只说“做进度条”时,代码有时会生成看起来很漂亮但不是真进度的 UI。明确写出“需要真实进度时使用 XMLHttpRequest”后,结果更容易审查。
另一个有效做法是先本地保存,再迁移到 S3。File API、FormData、校验、预览都稳定后,再替换存储层。这样比一开始同时处理 IAM、CORS、签名 URL、React 状态和数据库记录更适合初学者。
总结
安全的文件上传不是一个输入框,而是用户设备、应用服务器、存储系统和权限模型之间的边界。Claude Code 可以很快实现它,但提示词必须写清楚约束:客户端用 File API,传输用 FormData,服务端重新校验 MIME、扩展名和大小,保存名由服务器生成,进度显示要诚实,生产文件默认放在 private 对象存储。
如果你想把这个模式落到真实仓库里,Claude Code 培训与咨询可以一起整理上传流程、S3 保存、签名 URL、测试和审查清单。也可以先从免费 PDF 和教材开始试用。
免费 PDF: Claude Code 速查表
输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。
我们会妥善保护你的信息,不发送垃圾邮件。
把 Claude Code 变成真正能带来结果的工作流
先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。
关于作者
Masa
专注 Claude Code 实务流程、团队导入和内容转化的工程师。
相关文章
从Obsidian到CLAUDE.md的Claude Code流程:不再反复解释上下文
把 Obsidian 工作笔记整理成 CLAUDE.md 运行说明,让 Claude Code 每次都带着正确上下文开始。
Claude Code 收入 CTA 路由:从文章分流到 PDF、Gumroad 与咨询
用 Claude Code 按读者意图把文章流量分到免费 PDF、Gumroad 教材或咨询入口。
Claude Code 团队交接规则: 把审查证据、权限、回滚和收入路径一起交付
面向团队的 Claude Code 交接格式: 证据、权限、回滚、免费 PDF、Gumroad 与咨询路径都要可审查。