渡劫code 的工作区要支持用户上传文件:代码片段、图片、打包产物。第一版图省事走 Worker 中转——浏览器 POST 给 Worker,Worker 再 PUT 进 R2。上线两周就撞墙:Cloudflare 账户在 Free/Pro 计划下请求体上限 100MB,大文件直接 413;而且中转意味着流量双倍走、免费额度下的 CPU 时间也白白消耗,传几个大文件就见底了。这篇讲我们切到 R2 S3 API 预签名直传 的完整方案:Worker 只当「签发机构」,数据一个字节都不经过 Worker。
一、方案对比:上传的三条路
| 方案 | 优点 | 硬伤 | 适用 |
|---|---|---|---|
| Worker 中转 | 简单、可校验内容、能直接写元数据 | 请求体 100MB 上限;流量与 CPU 双倍计费 | <10MB 的小文件、内部工具 |
| 预签名 PUT 直传 | 浏览器直连 R2;Worker 只签个名 | 需处理 CORS、签名细节;单次 PUT 上限 5GB | 10MB–5GB,绝大多数场景 |
| Multipart 分片 | 支持超大文件、可断点续传 | 三接口编排(initiate/part/complete)复杂度高 | >5GB 或弱网环境 |
我们的选择:50MB 以下走预签名 PUT,超过走分片。这篇先把主战场——预签名 PUT 讲透。
二、Worker 签发预签名 URL
R2 兼容 S3 API,用 aws4fetch 库(专为 Workers 设计,基于 Web API,不需要 Node 环境)。密钥用 R2 API Token,权限限定「对象读写 + 指定 bucket」,别用主账号密钥:
import { AwsClient } from 'aws4fetch';
const r2 = new AwsClient({
accessKeyId: env.R2_ACCESS_KEY_ID,
secretAccessKey: env.R2_SECRET_ACCESS_KEY,
service: 's3',
region: 'auto', // R2 固定 auto
});
// POST /api/upload/presign
app.post('/api/upload/presign', async (c) => {
const user = await auth(c); // JWT 会话,见边缘安全实践
const { filename, size } = await c.req.json();
if (size > 50 * 1024 * 1024) return c.json({ error: 'too_large' }, 413);
// key 服务端生成:用户前缀隔离 + UUID 防猜测 + 文件名编码
const key = `workspaces/${user.id}/${crypto.randomUUID()}/${encodeURIComponent(filename)}`;
const url = new URL(
`https://${env.CF_ACCOUNT_ID}.r2.cloudflarestorage.com/${env.R2_BUCKET}/${key}`
);
// 过期时间走查询参数,单位秒,SigV4 上限 7 天
url.searchParams.set('X-Amz-Expires', '900'); // 15 分钟
const signed = await r2.sign(new Request(url, { method: 'PUT' }), {
aws: { signQuery: true },
});
return c.json({ url: signed.url, key });
});
注意 X-Amz-Expires 是拼在 URL 上的查询参数再签名,不是 sign() 的选项——aws4fetch 的 README 示例就是这么写的,我第一版找个假 API 参数折腾了半小时。另外 signQuery: true 时只有 host 头进签名(返回的 URL 里 X-Amz-SignedHeaders=host),这一点是后面所有坑的根源。
三、浏览器直传
前端拿到预签名 URL 后直接 PUT。用 XHR 而不是 fetch,是为了拿上传进度条:
function uploadFile(file: File, presignUrl: string, onProgress: (p: number) => void) {
return new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('PUT', presignUrl);
// 注意:不要手动设 Content-Type,让浏览器按 file.type 自动加
xhr.upload.onprogress = (e) => e.lengthComputable && onProgress(e.loaded / e.total);
xhr.onload = () => (xhr.status === 200 ? resolve() : reject(new Error(`HTTP ${xhr.status}`)));
xhr.onerror = () => reject(new Error('network'));
xhr.send(file);
});
}
上传成功后前端再回调我们自己的 API,把 key、大小、类型写进 D1 的 files 表——对象存储和元数据必须分两步,这是后面坑四的伏笔。
四、踩坑实录
坑 1:CORS 没配,一切免谈
浏览器跨域 PUT 到 R2,bucket 不配 CORS 全部报 No 'Access-Control-Allow-Origin'。R2 的 CORS 策略用 wrangler 下发:
npx wrangler r2 bucket cors put dujiecode-files --file cors.json
{
"rules": [{
"allowedOrigins": ["https://dujiecode.com"],
"allowedMethods": ["PUT", "GET"],
"allowedHeaders": ["*"],
"exposeHeaders": ["ETag"],
"maxAgeSeconds": 3600
}]
}
坑 2:CORS 报错和签名报错,长得一模一样
最迷惑的现象:签名错了,浏览器报的也是 CORS 错误。因为 403 响应没带 CORS 头,浏览器就把底层错误吞掉显示成跨域失败。我们的排查口诀:先用 curl 同源直传验证签名,再上浏览器测跨域。同源 curl 能过、浏览器挂,问题在 CORS;curl 都挂,问题在签名。
坑 3:Content-Type 陷阱
因为 signQuery 只签 host,如果你在签名时把 Content-Type 签进去,浏览器 PUT 时又没带(或带了不一样的值),R2 直接 SignatureDoesNotMatch。我们的策略是不签 Content-Type,浏览器按文件类型自动加,R2 存下来的 content-type 仍然正确。代价是无法用签名约束类型,类型校验放在我们自己的 API 层做(按文件扩展名 + magic number 双校验)。
坑 4:中文文件名的编码地狱
用户上传「测试报告.docx」,前端直接把原始文件名拼进 key 签名——SigV4 对路径编码极其敏感,浏览器自动编码一次、签名算一次,两边不一致就 403。我们的规矩:key 一律服务端生成,文件名 encodeURIComponent 后作为 key 一部分,原始文件名另外存进 D1 元数据表。下载时服务端重新签发 GET 预签名 URL,Content-Disposition 里带原始文件名。前端永远碰不到 key 的生成逻辑,这个坑就彻底消失了。
坑 5:预签名 URL 的泄漏面
URL 出现在浏览器网络面板、前端日志、甚至用户截图里。缓解三件套:过期 15 分钟(够慢网传完 50MB)、key 带 UUID 随机段(猜不到相邻文件)、访问日志不记录完整 query string。真正敏感的场景(合同、身份证件),正确做法是 bucket 私有 + 下载也走签名 GET,一秒都不公开。
坑 6:对象和元数据的一致性
PUT 成功但写 D1 失败的瞬间,系统里就有一个「孤儿对象」;反过来先写元数据后传失败,就是「幽灵记录」。我们的取舍:先传对象、再写元数据(用户可感知完成态),配一个每天跑的 Cron 对账任务——扫 R2 里超过 24 小时无对应 D1 记录的对象,删掉。删除走「先删记录、再删对象」,避免记录没了对象还在。
五、数字:直传到底省了多少
渡劫code 工作区当前状态:R2 存储 4.2GB、对象 1.1 万个、日均上传 120 次、日均下载 900 次。切到预签名直传前后的对比:
| 指标 | Worker 中转(前) | 预签名直传(后) |
|---|---|---|
| Workers 日均请求 | 约 3.1 万(含上传下载) | 约 4000(只剩签发和下载代理) |
| 免费额度占用 | 31% | 4% |
| 单文件大小上限 | 100MB(硬顶) | 5GB(R2 单 PUT 上限) |
| 上传成功率 | 96.2%(413 + 超时) | 99.7% |
R2 免费额度是 10GB 存储/月、Class A(写)100 万次/月、Class B(读)1000 万次/月。我们 Class B 月均 27 万次,占 2.7%——直传模式最大的节省其实是 Workers 请求数,从 31% 降到 4%,等于给免费额度续了命。上传成功率从 96.2% 到 99.7% 是意外之喜,413 和 Worker 超时这两类错误直接归零。
小结
预签名直传的核心思想一句话:Worker 当签发机构,不当搬运工。三条铁律记住就能避开九成坑:key 服务端生成并做用户前缀隔离、Content-Type 不签进签名、文件名先编码再进 key。CORS 先配再测、签名用 curl 同源验、元数据与对象分离靠 Cron 对账——做到这些,浏览器直传 R2 就是独立开发者能白嫖到的最香上传方案。