文件存储

默认用 R2 保存上传,Worker 经 /uploaded 代理读取。改存储配置后,在业务 API 里调用统一上传函数即可。

Saavo 模板已经提供统一的文件上传和访问能力,头像、工单图片、通知配图以及 OAuth 客户端 Logo 等文件都通过同一套文件服务进行存储和读取。

开发自己的产品时,通常不需要直接操作 R2 或 KV。完成文件存储配置后,即可在需要上传文件的业务接口中调用 uploadFileOrThrow。大文件直传或给私有对象发短时下载地址时,再用 createR2TemporaryUploadUrl 和 createR2TemporaryDownloadUrl。

已有功能

初始化模板后,文件存储的以下功能可直接使用,流程如下:

功能默认值说明
默认后端R2在 config/deploy.ts 的 upload.storage.default 配置
文件读取代理/uploadedWorker 通过该路径读取对象并返回文件
大小上限5 MiB配置路径为 upload.maxSize
应用生成 R2 直连地址关闭默认通过 Worker 的 /uploaded 读取
MIME 白名单图片、常用文档、压缩包、音视频不包含 HTML、JS、SVG
临时上传链接无现成 HTTP 路由createR2TemporaryUploadUrl,需 Access Key
临时下载链接无现成 HTTP 路由createR2TemporaryDownloadUrl,需 Access Key

文件存储依赖于在 wrangler.jsonc 里声明的 MAIN_R2 和 MAIN_KV 运行时绑定。常规表单上传使用 Worker Binding,无需将 R2 的 Access Key 传入上传接口。

虽然项目中声明了 R2_ACCOUNT_ID、R2_ACCESS_KEY_ID 与 R2_SECRET_ACCESS_KEY,但 src/libs/storage 里的上传和 /uploaded 读取不会用到它们。头像、工单图等现成能力只依赖 Worker Binding。这三个凭证只在签发临时上传 / 下载链接时才需要。

先体验文件上传

修改配置之前,可以先用现成的功能确认存储可用:

  • 登录后在账号中心上传头像
  • 提交一张带图片的工单
  • 浏览器打开返回的 /uploaded/... 地址,确认能访问

账号头像上传

本地开发时,R2 会通过 Wrangler 中的 MAIN_R2 Binding 提供给应用。遇到上传失败时,先确认绑定和开发服务器日志是否正常,再检查业务代码。

配置文件存储

文件上传相关配置集中在 config/deploy.ts 的 upload 配置中,可以根据实际存储方式和上传需求进行调整。

upload: {
    maxSize: 5 * 1024 * 1024,
    delivery: {
        proxyPath: '/uploaded',
        cache: {
            public: 'public, max-age=31536000, immutable',
        },
    },
    storage: {
        default: 'r2',
        kv: {
            defaultTTL: false,
        },
        r2: {
            publicBaseUrl: false,
        },
    },
}

上面这些字段改 config/deploy.ts。Bucket、KV 的 ID 改 wrangler.jsonc,见下一节。

上线前需要处理的事项:

  • maxSize:这是 uploadFileOrThrow 的全局上限,默认 5 MiB。按产品允许的最大文件调整。工单、通知配图还有各自更小的限制,把这里调大不会自动放宽那些业务上限。
  • proxyPath:没有特殊需求就保持 /uploaded。Worker 会按这个值自动挂读取路由,不必再改 src/pages/routes.tsx。一旦改了前缀,数据库和富文本里已经存下来的旧 URL 不会自动更新。
  • publicBaseUrl:默认是 false,表示应用只生成 Worker 地址。只有绑定并启用 R2 Custom Domain 后才填写 URL。它不会关闭 Cloudflare 上已有的 r2.dev 或自定义域。

storage.default 决定所有普通上传使用 KV 还是 R2,上传接口不能单独覆盖。默认值是 r2,现成的头像、工单图片、通知配图都会写入 R2。

选择 KV 后,可以通过 storage.kv.defaultTTL 设置默认 TTL,也可以在单次上传时通过 expiresIn 设置文件自己的有效期。R2 没有对象级自动删除:传入 expiresIn 后,Worker 会在到期后拒绝代理读取,但对象仍会留在 Bucket 里,需要另行安排清理任务。

准备存储服务

生产环境必须把 wrangler.jsonc 里的 MAIN_R2、MAIN_KV 换成你自己账户下的 Bucket 和 KV Namespace。仓库里预填的 ID 是模板示例,部署到你的账户时无效或会指到别人的资源。

默认文件上传写入 MAIN_R2。MAIN_KV 仍然要换成自己的,因为工单正文、日志等内容也用它;将 storage.default 改成 kv 后,普通上传的文件才会写入 MAIN_KV。

生产若要让应用返回 R2 公开地址:先绑定自定义域并确认 HTTPS 正常,再填写 storage.r2.publicBaseUrl。不需要生成公开地址时保持 false;若要让 Bucket 真正不再公开,还要在 Cloudflare R2 中关闭 r2.dev 并移除自定义域。

本地开发通过 Worker 的 MAIN_R2 Binding 上传即可,普通表单上传不需要 R2 Access Key。只有签发临时上传 / 下载链接时,才需要配置 R2_ACCOUNT_ID、R2_ACCESS_KEY_ID 和 R2_SECRET_ACCESS_KEY。

MAIN_R2 配错时,头像、工单图片、通知配图会上传失败或打不开。MAIN_KV 配错时,默认走 R2 的文件上传不受影响,但工单正文、日志等 KV 数据会出问题。

在业务中存取文件

业务上传写在已认证、已授权的 API 里,使用统一函数:

import { resolveFetchWorkerCtx } from '@/ctx';
import { uploadFileOrThrow, purgeFileOrThrow } from '@/libs/storage';

const ctx = resolveFetchWorkerCtx(c);
const uploaded = await uploadFileOrThrow(ctx, {
    file,
    path: `user/${userId}`,
    name: 'photo.png',
});

默认返回相对地址 /uploaded/{key}。业务需要绝对地址时可以传入 urlMode: 'absolute'。

永久保存的 R2 对象在配置了 publicBaseUrl 后,生产环境会返回该公开地址。临时对象必须经过 Worker 检查有效期,因此仍返回当前网站的 /uploaded/{key} 绝对地址。调用方不能自行开启 R2 公开访问。

删除对象:

await purgeFileOrThrow(ctx, {
    store: uploaded.store,
    key: uploaded.key,
});

生成临时上传链接和下载链接

uploadFileOrThrow 会把文件先传到 Worker,再写入存储。文件较大、或对象不应经 /uploaded 公开读取时,可以签发短时有效的 R2 预签名 URL,让浏览器或调用方直接对 Bucket 读写。

这两个函数在 src/libs/utils/r2-presigned-url.ts:

  • createR2TemporaryUploadUrl:签发 PUT 上传链接
  • createR2TemporaryDownloadUrl:签发 GET 下载链接

它们不是现成的 HTTP 路由,要在你自己的已认证 API 里调用,把结果返回给前端。链接指向 *.r2.cloudflarestorage.com,不走 /uploaded,也不走 publicBaseUrl。

签发需要 R2 S3 API 凭证,从 Worker 环境读取,不要返回给前端:

const credentials = {
    accountId: workerCtx.env.R2_ACCOUNT_ID,
    bucketName: workerCtx.env.R2_BUCKET_NAME,
    accessKeyId: workerCtx.env.R2_ACCESS_KEY_ID,
    secretAccessKey: workerCtx.env.R2_SECRET_ACCESS_KEY,
};

R2_BUCKET_NAME 在 wrangler.jsonc 的 vars 里。另外三个写在 .env / Cloudflare Secret,字段名与 example.vars 一致。凭证缺失或格式不对时,签名函数会抛错。expiresInSeconds 必须是 1~604800 之间的整数,单位为秒,即最短 1 秒、最长 7 天。

签名函数不会套用 upload.maxSize 和 MIME 白名单。签发前要在自己的 API 里做完登录、授权、路径、类型和大小校验。key 不能以 / 开头,也不能包含 ..。

临时上传链接

PUT 链接会把文件大小、Content-Type 和 SHA-256 签进请求头,并带上 if-none-match: *,已有同名对象时不能覆盖。sha256 必须是 64 位小写十六进制。

import { createR2TemporaryUploadUrl } from '@/libs/utils/r2-presigned-url';

const upload = await createR2TemporaryUploadUrl({
    ...credentials,
    key: `user/${userId}/photo.png`,
    expiresInSeconds: 600,
    contentType: 'image/png',
    sizeBytes: fileSize,
    sha256: fileSha256Hex,
});

把 method、url、headers 返回给前端。客户端必须用返回的请求头原样 PUT,请求体就是文件本身:

await fetch(upload.url, {
    method: upload.method,
    headers: upload.headers,
    body: file,
});

浏览器直传还要在 R2 Bucket 上配置 CORS,允许你的站点 Origin 发送 PUT,并放行签名用到的请求头。服务端代为 PUT 则不需要 CORS。

临时下载链接

GET 链接只绑定对象 key 和过期时间,适合私有对象的短时下载。拿到链接的人在过期前都能读取,TTL 尽量短。

import { createR2TemporaryDownloadUrl } from '@/libs/utils/r2-presigned-url';

const download = await createR2TemporaryDownloadUrl({
    ...credentials,
    key: `user/${userId}/photo.png`,
    expiresInSeconds: 60,
});

客户端对 download.url 发 GET 即可,不必再带额外请求头。

文件路径中禁止包含 ..。返回给前端的应当是文件代理路径、配置好的 R2 公开地址,或上述短时链接;不要直接暴露内部 Object Key。账户级的存储凭证也只能留在服务端,不能返回给前端或写入公开文档。

工单图片上传已经提供了完整的实现示例,可以直接参考现有流程。新增文件上传功能时,应继续复用现有的认证和权限控制,不要另外创建一个绕过这些检查的通用上传接口。

上线检查

存储会直接影响用户内容,上线前建议至少确认:

  • MAIN_R2 / MAIN_KV 已换成自己的 Cloudflare 资源。
  • 不公开 R2 时,storage.r2.publicBaseUrl 保持 false,Cloudflare 中的 r2.dev 和自定义域也已关闭。
  • 头像或业务上传可以成功,并通过 /uploaded 或配置好的 R2 公开地址访问。
  • 超过 maxSize 或使用 HTML / JS / SVG 的文件会被拒绝。
  • 删除路径会真正清除对象。
  • 上传接口要求登录,且只能写当前用户自己的路径。
  • 若使用临时链接:三个 R2 Secret 已配置,签发接口已鉴权,返回给前端的只有 url 和必要请求头。

建议用普通账号实际上传一次,不要只在本地 mock File 对象。

常见问题

接下来

根据接下来要开发的功能,可以继续阅读:

大多数产品走完本章后,只需要换成自己的 Bucket;确实需要公开直连 R2 时,再配置独立文件域名。

真正接收业务文件时,在受保护的 API 里调用 uploadFileOrThrow 即可。需要直传或私有下载时,再包一层临时链接签发接口。