成为全栈·Node 后端篇·文件上传:R2 / 本地磁盘双实现与签名直传
上传功能看着就三步:前端选文件、后端收下、存到某个地方。可真做起来你会发现,问题全在"收下之后"------同一个文件被传十次要不要存十份?用户把文件名改成 ../../etc/passwd 怎么办?传个 SVG 上来,别人点开就中招怎么办?文章删了,图还留在桶里算谁的?

这一篇把文件上传的完整链路拆开讲,重点是我们怎么用一层存储适配层同时伺候 Cloudflare R2 和本地磁盘:内容寻址怎么用 SHA-256 去重、路径遍历怎么挡、SVG 的 XSS 怎么防,以及删除时那道引用计数护栏。
一、为什么双存储:一层适配层伺候两个后端
我们的部署目标是"一套后端、双环境跑":生产上 Cloudflare(R2 对象存储 + D1 数据库),开发/测试/兜底跑普通 Linux(本地磁盘 + SQLite)。文件存在哪,自然也跟着环境走。如果业务代码里到处写 fs.writeFile 又写 r2.put,那双部署就别想干净了。
解法是抽一层存储适配层 StorageProvider:
ts
// storage.ts
export interface StorageProvider {
put(buffer: Buffer, ext: string): Promise<{ key: string; url: string }>;
get(key: string): Promise<Buffer | null>;
delete(key: string): Promise<void>;
}
export const createStorage = (env: AppEnv): StorageProvider => {
if (env.STORAGE_DRIVER === 'r2') {
throw new Error('R2 storage requires Cloudflare binding; use STORAGE_DRIVER=local in dev/test.');
}
return new LocalStorage('./uploads', '/files');
};

业务代码(比如上传逻辑)只依赖 StorageProvider 接口,完全不关心底层是 R2 还是磁盘。STORAGE_DRIVER 这个环境变量决定用哪个实现。这里要诚实说一句:R2 分支目前是 deferred 的 ------没有 Cloudflare 凭证就无法实测接线,所以 createStorage 在 r2 下暂时 throw,本地实现已经完整可用。适配器结构已经就位,等凭证到位、在 worker.ts 那条 CF 路径上把 R2 绑定接进来即可(M1-24 专门讲双部署适配层)。这正是"先抽象、后接线"的好处:业务不阻塞,生产切换只是补一个分支。
为什么生产非上对象存储不可?本地磁盘的痛点很清楚:单机容量有限、文件和应用耦合在同一台机器、横向扩容时文件没法跟着请求走、备份也要单独操心。对象存储(R2/S3 一类)把"文件"变成"带 URL 的全球可访问资源",天然配合 CDN 做边缘缓存,容量近乎无限、持久性由厂商保证。所以"本地用于开发、对象存储用于生产"几乎是行业默认姿势,我们的适配层只是把这个姿势规范成了代码。
二、内容寻址去重(P-39)
一个很现实的问题:同一张图,用户在不同文章里传了五次,磁盘上要存几份? 朴素做法按"时间戳+随机名"命名,五份全存,浪费空间和对象存储的 PUT 费用。我们用内容寻址从根上解决:
ts
// storage.ts --- LocalStorage.put
const key = `${createHash('sha256').update(buffer).digest('hex')}${ext}`; // 同字节 → 同 key
const existing = await this.get(key);
if (existing) return { key, url: `${this.baseUrl}/${key}` }; // 命中复用,跳过写盘
await mkdir(this.root, { recursive: true });
await writeFile(join(this.root, key), buffer);

文件的 key 不再是"时间戳+随机数",而是文件内容的 SHA-256 哈希 。这带来一个漂亮的性质:相同的字节,永远算出相同的 key。上传前先按 key 查一下,命中就直接复用、跳过写盘------既省了本地 I/O,也省了 R2 的 PUT 请求费(对象存储按请求数计费)。这跟 S3 的 ETag(也是内容哈希)是同一个思路。有人会担心哈希碰撞------两份不同内容算出同一个 SHA-256 key?SHA-256 的输出空间是 2^256,碰撞概率比"地球毁灭于陨石"还低几个数量级,工程上完全可以当不可能事件处理。相比它带来的去重收益,这点理论风险不值得为它加一层"哈希冲突回退"的复杂逻辑。附带好处:哈希 key 是 hex 字符串,天然满足下面要讲的"安全 key 约束",不会混入路径穿越字符。
三、信任边界:上传校验在最外层(呼应 M1-10)
M1-10 讲过"所有外部输入不可信,校验必须最外层做"。上传文件这个场景尤其典型------它是 multipart/form-data,没有 JSON schema 可挂靠,所以校验得手动在信任边界里完成:
ts
// upload.ts --- parseUpload
const MAX_BYTES = 10 * 1024 * 1024;
const ACCEPTED = new Set(['image/png','image/jpeg','image/gif','image/webp','image/svg+xml','application/pdf']);
// ...
if (!ACCEPTED.has(file.type)) throw new AppError(ErrCode.VALIDATION, 400, ..., { errors:[{field:'file',message:'文件类型不合法'}] });
const buf = Buffer.from(await file.arrayBuffer());
if (buf.byteLength > MAX_BYTES) throw new AppError(ErrCode.VALIDATION, 400, ..., { errors:[{field:'file',message:'文件大小超过 10MB'}] });
两个硬护栏:类型白名单(只收图片和 PDF)+ 大小上限 10MB。不合法返回契约 4001 并在 data.errors 里带字段级信息(呼应 M1-10 的 P-20)。这是"攻击者能塞任意文件"和"系统只认安全的文件"之间的那道墙------挡不住,就可能有人传个 .exe 或几 GB 的垃圾把你撑爆。顺便说尺寸和类型的选择不是拍脑袋:10MB 是兼顾"能传高清图"和"不被大文件拖垮"的折中;PDF 单独放行是因为投稿常带参考资料,但可执行文件(exe/脚本)一律不在白名单------宁可少收,不能收危险的。
四、路径遍历防御(P-38)
文件服务的另一个经典漏洞是路径遍历 :攻击者传 ?key=../../etc/passwd,如果你的代码直接 readFile(join(root, key)),就能读到系统任意文件。我们的防御是白名单校验 key 的字符集:
ts
// storage.ts / files.ts 同源
const SAFE_KEY = /^[A-Za-z0-9._-]+$/;
private resolveKey(key: string): string {
if (!SAFE_KEY.test(key)) throw new Error(`invalid storage key: ${key}`);
return join(this.root, key);
}
key 只允许字母、数字、点、下划线、连字符------/ 和 .. 根本进不来,路径遍历在字符层面就被拒。本地直出路由 files.ts 用同样的 SAFE_KEY 守一遍(防御同源、两处落点一致)。记住一条原则:凡是把外部输入拼进文件路径,必须先过字符白名单 ,不要指望 join 帮你挡------join 只会规整路径,不会拒绝恶意片段。这也解释了为什么 key 必须用内容哈希生成(第二节)------哈希出来的 key 天然是白名单字符,不可能携带 ../,内容寻址和路径安全在这里是互相加强的。
五、SVG 的 XSS 隐患(P-42)
SVG 不是普通图片,它是带脚本的 XML 。如果以 image/svg+xml 直接内联到页面(比如 <img src="/files/x.svg"> 在某些上下文会被当 HTML 解析),里面的 <script> 就能在你站点域下执行,造成 XSS。本地直出路由专门堵了这个口子:
ts
// files.ts
c.header('X-Content-Type-Options', 'nosniff');
c.header('Content-Disposition', mime === 'image/svg+xml' ? 'attachment' : 'inline');
c.header('Content-Type', mime);
对 SVG 强制 Content-Disposition: attachment(浏览器下载而非内联渲染)+ 统一 X-Content-Type-Options: nosniff(禁止 MIME 嗅探)。两个响应头双保险,SVG 绝不会被当可执行内容塞进页面。这是"能收 SVG"和"不被 SVG 反咬一口"之间的平衡------我们允许上传 SVG(设计稿常需要),但绝不允许它内联执行。
六、删除的引用计数护栏(P-40 / P-41 / P-56 / P-57)

删除附件是最容易留"孤儿文件"的地方。因为内容寻址后,多个附件可能指向同一个物理文件 (同一个 storageKey)。如果删一个附件就把物理文件删了,别的附件就悬空了。所以删除要走"引用计数护栏":
ts
// attachment.ts --- deleteAttachment
const result = getDb().transaction((tx): { storageKey: string; remaining: number } => {
const row = (tx.select(...).from(attachments).where(eq(attachments.id, id))...)[0];
if (!row) throw new AppError(ErrCode.NOT_FOUND, 404); // 缺失 → 404
const res = tx.delete(attachments).where(eq(attachments.id, id)).run();
if (res.changes === 0) throw new AppError(ErrCode.NOT_FOUND, 404);
const remaining = (tx.select({c: sql`count(*)`}).from(attachments)
.where(eq(attachments.storageKey, row.storageKey))...)[0]?.c ?? 0;
return { storageKey: row.storageKey, remaining };
});
if (result.remaining !== 0) return; // 仍有引用 → 不删物理文件
try { await createStorage(getActiveEnv()).delete(result.storageKey); } catch { /* 失败不阻塞行删除 */ }
几个要点:
- 先数引用、再决定删不删物理文件 :事务里删行 + 统计同
storageKey的兄弟行;remaining !== 0说明还有别的附件指着这文件,只删行、不碰物理文件。只有最后一个引用消失,才真删底层对象。这避免了"删 A 把 B 的文件也删了"的孤儿灾难。 - P-41 同步事务 :用的是 better-sqlite3 的
transaction------它要求回调是同步的,不能在里面await。所以"数引用"和"删行"必须在同步回调里原子完成;真正异步的"删物理文件"被挪到事务外面 做 best-effort(失败只catchswallow,不阻塞行删除)。这个"同步回调 + 异步收尾"的拆分,是 better-sqlite3 事务最易踩的坑(M1-06 也提过)。 - P-56 删不存在 → 404 :事务里
if (!row) throw 404且res.changes === 0也抛 404------删一个不存在的附件,不会静默返回 200 让调用方误以为成功了。路由层guard('editor', resolveAttachmentOwner)也会在附件不存在时先抛 404,双重保险。 - P-57 孤儿竞态,文档化不锁 :注释里坦白了一个极窄的竞态窗口------"并发上传相同字节、恰逢删除间隙"可能留下一个物理孤儿文件。后果轻微(重传即恢复),所以选择文档化而非上锁 。真要根除得加
ref_count列在事务内裁决,但那需要迁移、owner 当前否决。这种"已知罕见竞态、权衡后不强加跨 DB/FS 锁"的判断,是务实工程的一部分。
七、小结与前瞻
文件上传,是把"收文件"做成"安全、可双存储、可去重、可干净删除"的一整套活:
- 双存储适配层 :
StorageProvider接口 +createStorage(env)按STORAGE_DRIVER选实现;业务不碰具体驱动。R2 分支当前 deferred(无凭证),结构已就位待接线(钩子 M1-24)。 - P-39 内容寻址 :key =
sha256(buffer)+ext,同字节恒同 key,命中复用跳过写盘,省 I/O 与 R2 PUT 费。 - 信任边界 :
parseUpload手动校验类型白名单 + 10MB 上限,不合法返4001+字段错误(呼应 M1-10)。 - P-38 路径遍历 :
SAFE_KEY白名单字符集,resolveKey/files.ts两处同源防御。 - P-42 SVG 安全 :强制
Content-Disposition: attachment+nosniff,杜绝内联脚本 XSS。 - P-40/P-41/P-56/P-57 删除护栏 :事务内同步"删行+数引用",
remaining===0才删物理文件;异步删文件事务外 best-effort;删不存在→404;孤儿竞态文档化不锁。
下一篇({{LINK:M1-19}})我们聊"全文搜索":什么时候 LIKE 够用、中文分词有多现实、以及什么时候该上 Meilisearch / ES 这类专用引擎。
如果这篇文章对你有帮助,欢迎订阅我的 CSDN 专栏 「成为全栈」:
🔗 专栏地址:https://blog.csdn.net/fungleo/category_13204651.html
📦 本系列配套代码仓库:https://github.com/fengcms/become-a-full-stack-developer
