文件存储
ShipKit 如何通过 Worker binding 把文件存进 Cloudflare R2:上传、私有下载、头像、配额,以及可选的文件模块。
所有文件都存在同一个 Cloudflare R2 bucket 里,通过名为 BUCKET 的 Worker binding 访问。没有 S3 访问密钥,也没有预签名 URL:浏览器把文件 POST 到一个server route,Worker 把它以流的方式写进 R2;下载时再原路经 Worker 返回。这样每一次读取都在你自己的会话校验之后,代价是每个字节都要经过 Worker。
用到这个 bucket 的有三处:
| 用途 | 上传路由 | 读取路由 | R2 key | 谁能读 |
|---|---|---|---|---|
| 文件模块 | POST /api/files | GET /api/files/$fileId | <userId>/<uuid>/<filename> | 仅本人 |
| 头像(core) | POST /api/avatar | GET /api/avatar/$userId | avatars/<userId> | 所有人 |
| 反馈截图 | POST /api/feedback-image | GET /api/feedback-image/$userId/$id | feedback/<userId>/<id> | 上传者,以及拥有 feedback.read 的角色 |
Binding
bucket 在 wrangler.jsonc 里声明:
"r2_buckets": [
{
"binding": "BUCKET",
"bucket_name": "shipkit-files"
}
]
开发时,bun run dev 在 workerd 里运行应用,带本地 R2 binding,所以不需要Cloudflare 账号就能上传;对象保存在 .wrangler/ 下。上生产前要先创建一次bucket(/deploy skill 会替你做):
bunx wrangler r2 bucket create shipkit-files
服务端代码通过 src/core/server/cf.ts 的 getBindings().BUCKET 拿到 bucket——这是唯一允许 import cloudflare:workers 的模块(见架构)。
上传和下载用的是 API route 而不是 server function,因为它们传的是 multipart请求体和二进制流。这是模板里少数几处用 server.handlers、不走服务端函数 那条常规数据通道的地方。
上传文件
文件模块的上传路由是 src/routes/api/files.ts。它接收一个只含 file 字段的multipart POST,/files 页面发的就是这个:
const form = new FormData()
form.append('file', file)
const res = await fetch('/api/files', { method: 'POST', body: form })
路由依次做这些事:
- 要求已登录(否则
401)。 - 按用户 id 限流,计入
PUBLIC_RATE_LIMITbinding(wrangler.jsonc默认每分钟5 次),超出返回429。 - 拒绝超过 25 MB 的文件:先看声明的
Content-Length,再看实际大小(413)。 - 检查该账号的总用量是否会超过 1 GB 配额(超出返回
507)。 - 把文件连同 content type 流式写入 R2,在
files表插入一行,并记一条files.uploaded审计日志。
两个上限是路由文件顶部的常量:
const MAX_SIZE = 25 * 1024 * 1024 // 25 MB
const USER_QUOTA = 1024 * 1024 * 1024 // 1 GB
在那里改。大幅调高之前要知道两点限制:路由用 request.formData() 读请求体,会把整个文件缓冲在 Worker 内存里;Cloudflare 也按套餐限制请求体大小。真要处理很大的文件,更合适的是用预签名 URL 直传 R2,那等于替换掉这一个路由。另外配额检查是先读后写,两个并发上传可能一起超出一个文件的量。
下载文件
GET /api/files/$fileId(src/routes/api/files.$fileId.ts)按 id 加上调用者的用户 id 查行,所以文件 id 对本人以外的任何人都没用。没有公开链接,也没有分享链接。
响应的 Content-Disposition 取决于存储的类型:图片(PNG、JPEG、GIF、WebP、AVIF)、视频、音频、纯文本和 PDF 在浏览器里直接打开,其余一律作为附件下载。这很重要,因为存储的类型是上传者自己声明的:一个 HTML 或 SVG 文件如果内联显示,就会带着查看者的会话在你的域名下执行脚本。以后加分享或后台预览时,请保留这条规则。
删除
src/features/files/server/fns.ts 里的 deleteFileFn 是一个普通的 server function。它先删 R2 对象,再删数据库行。D1 和 R2 没法共用一个事务,所以第二步失败时会留下一行指向空处的记录——无害,而且可以再删一次。选这个顺序而不是反过来,是因为反过来留下的会是一个谁也找不到的对象。
头像
头像属于 core,不属于文件模块。账号对话框把图片 POST 到 /api/avatar,接受2 MB 以内的 PNG、JPEG、WebP 或 GIF(src/features/auth/avatar.ts),并替换该用户在 avatars/<userId> 下唯一的那个对象。只能替换,不能删除。
路由把一个带版本号的 URL 存进 user.image:
/api/avatar/<userId>?v=<timestamp>
头像是公开的,而每次上传都会改变 ?v= 的值,所以读取路由用Cache-Control: public, max-age=31536000, immutable 返回它。从没上传过头像的用户,用的是 Google 账号提供的图片地址。
注销与数据导出
files 表的行会随用户级联删除,R2 对象不会。每个往 bucket 写东西的功能都在src/core/account/events.ts 注册了清理逻辑(注销流程见登录):
- 文件模块在删行之前,删掉它有记录的所有对象;
- core 的 auth 删除头像;
- 反馈模块删除该用户
feedback/前缀下的全部对象,连从未提交的截图也一并清掉。
这些在账号被彻底清除(purge)时执行,而不是在关闭账号时,所以被恢复的账号文件还在。下载我的数据里包含文件的元数据(名称、大小、类型、日期),不含文件本身。
后台
文件模块在用户详情的抽屉和页面里加了一个区块,受 files.read 权限控制:显示该用户的文件数、总大小和最近的上传。只有元数据——管理员在后台拿不到下载链接。见后台和权限。
删掉文件模块
文件模块是可选的。它的删除配方会移除 src/features/files/、/files 页面、两个/api/files 路由、侧边栏那一行、仪表盘卡片以及各个注册表里的那一行。R2 binding会留在 wrangler.jsonc 里,因为头像要用。见删除功能。