Supabase Storage 签名可续传上传(Signed Resumable Upload):基于 Uppy + TUS 与 createSignedUploadUrl 的完整实战
【免费下载链接】supabaseThe Postgres development platform. Supabase gives you a dedicated Postgres database to build your web, mobile, and AI applications.项目地址: https://gitcode.com/GitHub_Trending/supa/supabase
导读
对于大文件上传,网络中断、弱网环境与并发限制都容易导致整包重传。Supabase Storage 内置了基于 TUS 协议的可续传上传能力,本仓库examples/storage/resumable-upload-signed-uppy提供了一份完整的参考实现:它在浏览器端使用 Uppy 作为上传 UI 与 TUS 客户端,通过边缘函数为每一个文件调用createSignedUploadUrl()申请签名令牌,并经由x-signature请求头随 TUS 分片请求提交,从而在不暴露服务端密钥的前提下完成安全、可续传的大文件上传。读完本文,你将掌握这套“按文件签名 + Uppy/TUS 续传”方案的前后端完整接线方式、本地运行步骤,以及其中每一个关键配置与调用在仓库源码中的真实依据。
原文入口:README.md,本文所有路径均以仓库根目录为基准。
一、签名可续传上传要解决什么问题
Supabase Storage 实现并对外暴露了 TUS 协议(The Upload Server)用于可续传上传,相关的能力说明与建议可以参阅文档 resumable-uploads.mdx。它推荐在大文件(可能超过 6MB)、弱网环境、需要上传进度事件的场景下使用可续传上传。
在本仓库的examples/storage目录下并存着两个高度相似但鉴权方式截然不同的示例:
| 对比维度 | resumable-upload-signed-uppy(本文主角) | resumable-upload-uppy |
|---|---|---|
| TUS 端点 | …/storage/v1/upload/resumable/sign | …/storage/v1/upload/resumable |
| 携带凭据 | 每个文件独立签发的x-signature令牌 | 固定的authorization: Bearer <token> |
| 凭据来源 | 服务端createSignedUploadUrl()按需签发 | 客户端静态配置(如用户会话令牌) |
| 安全性 | 令牌按文件路径签发、可限时,无需暴露长期密钥 | 需要把令牌直接配置在页面里 |
可以看出,签名方案的核心价值在于:不再需要把服务端或用户的长期凭据写死在页面 / 暴露给最终上传者,而是由可信的服务端代码(本示例中的 Supabase Edge Function)在文件入队时动态签发“仅能写该文件的受限令牌”,浏览器端只持有这张短时令牌即可通过 TUS 完成大文件上传。与此对应的实现背景可参考兄弟示例 resumable-upload-uppy/README.md。
二、示例文件结构与职责划分
整个示例只有一张单页应用和一个小小的边缘函数,却能完整覆盖“前端界面 + TUS 续传 + 服务端签名 + 存储授权”整条链路:
examples/storage/resumable-upload-signed-uppy/ ├── index.html # 前端单页:Uppy Dashboard + TUS 上传 └── supabase/ ├── config.toml # 本地项目配置:bucket、函数开关 ├── functions/ │ └── create-upload-token/ │ ├── deno.json # 函数 import map │ └── index.ts # 边缘函数:签发 x-signature 令牌 └── migrations/ └── 20241128121139_storage_rls.sql # 允许匿名用户写入 uploads bucket 的 RLS 策略各文件的详细职责如下:
- index.html:引入 Uppy v3.6.1 的 Dashboard 与 Tus 插件;在
file-added事件里为每个文件申请令牌并写入该文件的 TUS header。 - create-upload-token/index.ts:接收
{ filename },用服务端管理员上下文调用createSignedUploadUrl(),把token返回给前端。 - config.toml:本地开发配置,声明了 bucket
uploads、函数create-upload-token(含verify_jwt = false)。 - 20241128121139_storage_rls.sql:一条 RLS 策略,放行匿名用户对
uploadsbucket 的 INSERT。
三、本地运行步骤(附完整命令)
依据 README 的说明,本地完整跑通这套签名上传只需三步:
第 1 步:启动本地 Supabase 项目
supabase startCLI 会按项目内 config.toml 启动本地 API、Storage、Functions 等组件,并在启动结束后在终端输出本地服务的各类密钥。
第 2 步:配置发布密钥并指向本地服务
打开 index.html,在文件顶部的常量区把SUPABASE_PUBLISHABLE_KEY替换为supabase start输出的发布密钥(anon key):
const SUPABASE_PUBLISHABLE_KEY = '' // your project's publishable (anon) key const PROJECT_URL = 'http://127.0.0.1:54321' const STORAGE_BUCKET = 'uploads'PROJECT_URL固定为本地 CLI 默认端口54321(config.toml 中并未修改默认端口)。STORAGE_BUCKET对应配置里声明的 bucket 名uploads,见下方 config 说明。
第 3 步:用静态服务器打开页面开始上传
README 给出了两种等价方式:
# python http server python3 -m http.server # npm http-server npx http-server由于 index.html 使用<script type="module">从 CDN import Uppy 的 ES Module,直接用浏览器打开本地文件可能受 CORS / module 加载限制,因此需要一个本地静态服务器来承载页面。
四、核心原理一:前端如何把一个文件变成“可签名续传上传”
4.1 组装 TUS 客户端与上传端点
前端的关键在于 Uppy 的 Tus 插件配置,见 index.html:
import { Uppy, Dashboard, Tus } from 'https://releases.transloadit.com/uppy/v3.6.1/uppy.min.mjs' var uppy = new Uppy() .use(Dashboard, { inline: true, limit: 10, target: '#drag-drop-area', showProgressDetails: true, }) .use(Tus, { endpoint: `${PROJECT_URL}/storage/v1/upload/resumable/sign`, headers: { apikey: SUPABASE_PUBLISHABLE_KEY, }, uploadDataDuringCreation: true, chunkSize: 6 * 1024 * 1024, allowedMetaFields: ['bucketName', 'objectName', 'contentType', 'cacheControl'], onError: function (error) { console.log('Failed because: ' + error) }, })逐个解读这些参数在签名场景下的意义:
endpoint:指向/storage/v1/upload/resumable/sign。注意与普通(Bearer 令牌)示例 resumable-upload-uppy/index.html 中使用的/storage/v1/upload/resumable相比,这里多了/sign后缀,这正是签名可续传上传的专用入口。headers.apikey:固定带上本地 anon key,作为基础请求身份。uploadDataDuringCreation: true:在创建 TUS 上传时即携带元数据,让服务端在创建阶段就能拿到 bucket / 对象路径做签名校验。chunkSize: 6 * 1024 * 1024:TUS 分片大小取 6MB,与文档 resumable-uploads.mdx 中的示例保持一致;断点续传正是以分片为单位记录进度,中断后可只重传未完成分片。allowedMetaFields:白名单之外的元数据不会随请求发出。签名上传必须的bucketName / objectName / contentType都在其中,cacheControl则用于按文件设置缓存策略。- Dashboard 的
limit: 10限制并发上传数量;showProgressDetails: true在界面上展示分片级进度详情。
4.2 file-added 钩子:每个文件独立申请签名并注入 x-signature
签名方案与“一次性配置一个 Bearer token”最大的不同,在于令牌需要逐文件申请、逐文件注入。这体现在 index.html 的file-added事件处理中:
async function getSignedUploadToken(filename) { const res = await fetch(`${PROJECT_URL}/functions/v1/create-upload-token`, { method: 'POST', headers: { 'Content-Type': 'application/json', apikey: SUPABASE_PUBLISHABLE_KEY }, body: JSON.stringify({ filename }), }) if (!res.ok) throw new Error('Failed to get upload token') const data = await res.json() return data.token } uppy.on('file-added', async (file) => { const supabaseMetadata = { bucketName: STORAGE_BUCKET, objectName: file.name, contentType: file.type, } file.meta = { ...file.meta, ...supabaseMetadata } // Important - add signing token header const token = await getSignedUploadToken(file.name) uppy.setFileState(file.id, { tus: { headers: { 'x-signature': token, }, }, }) }) uppy.on('complete', (result) => { console.log('Upload complete! We've uploaded these files:', result.successful) })这里有四个关键动作:
- 填充上传元数据:把
bucketName、objectName、contentType合并进file.meta,供 TUS 创建上传时按allowedMetaFields提交。 - 按文件回调签名函数:
getSignedUploadToken(file.name)以文件名为入参 POST 到本地 Functions 的/functions/v1/create-upload-token。 - 把令牌写进该文件的 TUS header:通过
uppy.setFileState(file.id, { tus: { headers: { 'x-signature': token } } })实现。这是本示例最核心的一行代码——它保证了签名令牌只会被用于与其绑定的那一个文件的上传请求,而不是全局共享一个令牌。 - 完成回调:
uppy.on('complete')在全部上传结束后打印result.successful,可在此接入服务端回调、二次校验等后续逻辑。
代码注释中的// Important - add signing token header也提示了:缺少这一步,服务端将无法校验签名,上传会被拒绝。
五、核心原理二:边缘函数如何签发令牌
5.1 函数入口与鉴权模式
签名必须由“持有服务端权限”的代码完成,本示例把它放在 Edge Function 中,见 create-upload-token/index.ts:
import 'jsr:@supabase/functions-js/edge-runtime.d.ts' import { withSupabase } from 'npm:@supabase/server@^1' // Deploy with verify_jwt = false. export default { fetch: withSupabase({ auth: 'secret' }, async (req, ctx) => { try { const { filename } = await req.json() if (!filename) { return Response.json({ error: 'Missing filename' }, { status: 400 }) } const { data, error } = await ctx.supabaseAdmin.storage .from('uploads') .createSignedUploadUrl(filename) if (error) { return Response.json({ error: error.message }, { status: 500 }) } return Response.json({ token: data.token }) } catch (error) { return Response.json({ error: (error as Error).message }, { status: 500 }) } }), }要点拆解:
withSupabase({ auth: 'secret' }, ...):声明函数内部使用服务端密钥上下文(auth: 'secret'),因此ctx.supabaseAdmin拥有绕过 RLS 的管理员能力——它需要替“匿名”的浏览器上传者签发令牌。withSupabase与supabaseAdmin均来自npm:@supabase/server@^1。- 参数校验:
req.json()解析出的filename缺失时直接返回400 Missing filename,避免对空路径签发令牌。 - 核心调用:
ctx.supabaseAdmin.storage.from('uploads').createSignedUploadUrl(filename)在 Storage SDK 中以“bucket + 对象路径”为输入签发受限上传令牌。仓库中对该 SDK 方法的接口定义可参见规范文件 supabase_js_v2.yml。 - 令牌返回:只把
data.token回传给前端,函数内部持有的密钥永远不会离开服务端。 - 签名与路径强绑定:因为令牌是对
uploads/{filename}这个对象路径签发的,前端在 TUS 元数据里提交的objectName必须与该路径一致(本示例中objectName: file.name),签名校验才能通过——这也是为什么令牌要逐文件申请。
5.2 与官方文档的行为对照
在官方文档 resumable-uploads.mdx 的“Presigned uploads”一节中,对同一机制有明确描述:可续传上传支持通过调用 SDK 的createSignedUploadUrl方法生成限时的共享 URL,并把返回的 token 放进x-signature头参与可续传上传。本文示例 index.html 正是该机制的完整落地形态:服务端负责签发、浏览器负责把令牌作为x-signature请求头随 TUS 请求发出。
六、配套基建:config.toml、迁移与 bucket 授权
6.1 本地项目配置 config.toml
config.toml 做了四项与本示例强相关的声明:
project_id = "resumable-upload-uppy" [api] # Disable data API since we are not using the PostgREST client in this example. enabled = false [storage] file_size_limit = "50MiB" [storage.image_transformation] enabled = false [storage.buckets.uploads] public = true # file_size_limit = "50MiB" # allowed_mime_types = ["image/png", "image/jpeg"] # objects_path = "./buckets/uploads" [functions.create-upload-token] enabled = true verify_jwt = false import_map = "./functions/create-upload-token/deno.json" entrypoint = "./functions/create-upload-token/index.ts"逐项说明:
project_id = "resumable-upload-uppy":本地项目标识,用于在同一机器上区分不同 Supabase 项目。[api] enabled = false:本示例只走 Storage 与 Functions,不依赖 PostgREST 数据 API,因此显式关闭,可减少本地启动的组件。[storage] file_size_limit = "50MiB":整个项目所有 bucket 的默认单文件上限为 50MiB。如果想上传更大的文件,需要同步调大此处,同时把前端 TUS 的chunkSize保持在低于该上限的合理值。[storage.buckets.uploads] public = true:声明一个名为uploads的公开 bucket(public只影响对象是否可公开读取,不影响写入授权——写入仍由 RLS 策略决定)。被注释掉的file_size_limit、allowed_mime_types、objects_path分别用于对单 bucket 覆盖大小限制、限定 MIME 类型(如["image/png", "image/jpeg"])以及映射本地目录预置对象。[functions.create-upload-token]:verify_jwt = false:允许不带用户 JWT 调用该函数。这与源码注释// Deploy with verify_jwt = false相互印证——因为本示例走“匿名 + 按文件签名”而非“登录用户”模式,函数依赖自身的auth: 'secret'服务端上下文来代签。import_map/entrypoint:分别指向函数目录内的 deno.json 与index.ts。deno.json 目前为空导入映射({"imports": {}}),函数所需依赖(如@supabase/server)直接以npm:/jsr:前缀在源码中声明。- 被注释掉的
static_files展示了如需随函数托管静态资源时可使用的 glob 配置方式。
6.2 RLS 迁移:放行匿名写入
20241128121139_storage_rls.sql 只有一条策略,但它是整条链路“最后一公里”的授权保障:
CREATE POLICY "allow uploads" ON storage.objects FOR INSERT TO public WITH CHECK (bucket_id = 'uploads');FOR INSERT TO public:允许public(含匿名)角色向storage.objects插入记录;WITH CHECK (bucket_id = 'uploads'):插入对象的 bucket 必须被限定为uploads,把写入范围收窄到单一 bucket。
配合uploadsbucket 声明为public = true(读公开)与上面这条 INSERT 策略(匿名可写),浏览器端才能仅凭“anon key + x-signature 令牌”把分片写入该 bucket。如果去掉该策略,即使拿到了签名令牌,匿名上传仍会被 RLS 拦截。
七、端到端请求链与安全边界
综合前文,一次成功的签名可续传上传其完整调用链如下:
- 用户在 Uppy Dashboard 中添加文件,触发
file-added事件; - 前端为该文件填充
bucketName / objectName / contentType元数据; - 前端 POST
{ filename }到边缘函数create-upload-token; - 函数在
auth: 'secret'的服务端上下文中调用createSignedUploadUrl(filename),得到受限令牌; - 前端通过
uppy.setFileState把令牌写入该文件 TUS 请求的x-signature头; - Uppy Tus 插件把文件按 6MB 分片 POST 到
/storage/v1/upload/resumable/sign,服务端校验签名与元数据后进行 TUS 续传写入; - 中断后 Tus 插件自动按 TUS 协议续传剩余分片;全部完成后触发
uppy.on('complete')。
这套设计的几条安全边界值得在生产中沿用:
- 最小权限:浏览器端只持有“单 bucket、单对象路径”的受限签名令牌,而不是长期有效的管理员密钥或用户访问令牌;
- 逐文件隔离:令牌在
file-added内按文件名申请并通过setFileState注入,某一文件令牌泄露不会波及其他文件路径(签名与该文件对象路径强绑定); - 函数收敛:唯一能代签的入口是部署时以
verify_jwt = false暴露的边缘函数,需确保该函数自身逻辑最小化——本示例仅做参数校验与转发,不做任何文件内容处理; - 上传终点收紧:即使 RLS 放行了匿名 INSERT,也通过
bucket_id = 'uploads'的WITH CHECK把可写入的 bucket 锁定。
从源码结构可以推断,若要在生产环境使用这套模板,通常还应在签名函数中加入用户态校验(如改用auth: 'user'鉴权后校验登录用户是否有权上传)、对文件名做规范化处理,以及把PROJECT_URL由本地http://127.0.0.1:54321替换为线上项目域名(大文件场景下建议使用直连存储域名)。
八、与普通可续传上传示例的取舍
仓库中还提供了不签名、直接携带Bearer令牌的版本 resumable-upload-uppy,两者仅在“如何表达上传者身份”上分叉:
- 携带用户会话令牌(Bearer):适合“登录用户才能上传”的业务,令牌本身即代表用户身份,上传与用户账号天然关联,实现最简单;
- 按文件签发 x-signature:适合“把上传能力开放给匿名或第三方、但不想扩散长期密钥”的业务,例如分享式网盘、问卷附件、票据系统等;缺点是每次文件入队都需要一次额外的函数调用。
两个示例共用同一套前端骨架(Uppy v3.6.1、Dashboard、Tus 插件、6MB 分片),差异仅在 TUS 端点(/resumable还是/resumable/sign)与令牌注入方式上,非常适合对照阅读:先读懂 resumable-upload-uppy/index.html 的基线行为,再看本文示例中多出的“申请令牌 → 注入 x-signature”两步,即可快速迁移出自己的签名上传方案。
总结
examples/storage/resumable-upload-signed-uppy用“一个 Uppy 单页 + 一个 30 行边缘函数 + 一条 RLS 策略”演示了 Supabase Storage 签名可续传上传的最小可用闭环。其设计要点可以概括为一句话:上传能力与密钥彻底分离——服务端用createSignedUploadUrl()按对象路径签发受限令牌,浏览器仅凭x-signature请求头驱动 TUS 分片续传。无论是做移动端 / Web 端的大文件直传,还是构建对匿名用户开放的分享式上传,这套模板连同 config.toml、迁移脚本 与 官方文档 都是可以直接对照落地的参考基线。
【免费下载链接】supabaseThe Postgres development platform. Supabase gives you a dedicated Postgres database to build your web, mobile, and AI applications.项目地址: https://gitcode.com/GitHub_Trending/supa/supabase
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考