0%

用 GitHub Release 和 Cloudflare Worker 搭一个临时文件箱

前几天用 Cloudflare Worker 给一个开源软件的 GitHub Release 做了下载中转。做完之后我想到,既然 Worker 能代理 Release 里资产的下载,那么反过来让它往一个私有仓库的 Release 里上传文件,再配上账号和索引,应当也能拼出一个自用的临时网盘。

此篇记录这个文件箱(下称 filebox)的背景、设计和实现中的一些细节。源码在 Liam0205/filebox-source。

需求与约束

需求本身很简单:

  • 存任意类型的文件,不限于图片。
  • 自己和少数几个人用,账号由管理员建,不开放注册。
  • 下载公开,拿到链接就能下,不需要登录;但文件列表不公开,每个人只能看到自己名下的文件。
  • 临时存储,没有持久化的要求,满了可以删旧文件。
  • 既有网页,也有命令行,命令行要能直接塞进脚本里用。

真正决定设计的是另一条约束:不能产生任何费用。Cloudflare 的对象存储 R2 有免费额度,但超出部分按量计费;对于一个公开下载的服务来说,用量不完全在自己手里,我不想承担这个风险。于是所有组件都要选「超额即报错」的免费资源:Workers 免费版、Workers KV 免费版,以及 GitHub Release。前两者在免费计划下超出额度时,对应操作直接失败,不会扣费;GitHub Release 本身不收费。

这样一来,免费额度就成了硬约束,后面几乎每一个设计选择都能在下表里找到原因。

资源 免费额度 对设计的影响
Worker CPU 每次请求 10 ms I/O 等待不计入,但下载时拼接数据流的方式受它限制
Worker 入口请求体 100 MB 一次请求最多传一段,段长定为 95 MiB
Worker 外部子请求 每次调用 50 个 决定了大文件最多分几段、一次清理最多删几个文件
KV 写、删、list 各 1,000 次/天 上传过程中尽量不写 KV,列文件尽量少 list
Release 资产数 每个 Release 1,000 个 必须自动清理
单个资产 小于 2 GiB 一段一个资产,用不到这么大

由此大致能估出它适合的用量:几百个文件、每天几百次列目录、上千次下载,完全够用;拿它当大流量图床或者几百 GB 的网盘,会先撞上 KV list 和 Release 资产数的上限。

整体结构

整个系统分三层。

1
2
3
浏览器 ─┐
├─ Cloudflare Worker ── KV(用户、token、文件索引)
脚本 ───┘ └─ GitHub 私有仓库的 Release 资产(文件字节)
  • 文件字节存在一个私有仓库的 Release 里,一个文件对应一个资产;大文件切成几段,每段一个资产。
  • 路径与归属存在 KV 里:用户、token、文件索引、目录各占一类键。
  • Worker 负责页面、鉴权、上传转发和下载代理。

下载要经过 Worker 代理,不能直接 302 到 GitHub。这是因为私有仓库的资产没有固定的公开地址:用 token 请求资产接口,GitHub 返回一个 302,指向 release-assets.githubusercontent.com 上约一小时过期的签名地址。把这个地址交给用户的话,链接一小时后就失效了;而且这个域名在部分网络下也不稳定。

Worker 跟随这次跳转时,有一个细节需要注意:签名地址自带凭据,发过去的请求不能再带 Authorization 头,否则 GitHub token 会被发到另一个域名上。所以这里不用 fetch 的自动跟随,而是手工处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 私有仓库的资产要先拿一个 302 签名地址,手工跟随,避免把 token 发给另一个域名。
export async function assetDownload(cfg, assetId, range) {
const h = headers(cfg, { accept: "application/octet-stream" });
if (range) h.range = range;

const first = await call(cfg, `${API}/repos/${repoOf(cfg)}/releases/assets/${assetId}`, {
headers: h,
redirect: "manual",
});

if (first.status >= 300 && first.status < 400) {
const location = first.headers.get("location");
if (!location) throw new Error(`上游重定向缺少 location(HTTP ${first.status})`);
// 签名 URL 自带凭据,不能再带 Authorization。
const plain = { "user-agent": cfg.userAgent };
if (range) plain.range = range;
return call(cfg, location, { headers: plain, redirect: "follow" });
}

return first;
}

资产名与 label

直觉上最省事的做法,是把文件路径直接当作资产名,这样 Release 本身就是一个文件列表。实测之后发现行不通,GitHub 会改写资产名:

上传时的名字 GitHub 存成
dir_test/a.txt dir_test.a.txt
中文 name.txt name.txt
a%b#c?d.txt a.b.c.d.txt

改写既丢信息,也会撞名,所以资产名不能承载路径。现在的做法是把两者分开:资产名用不透明的 id,形如 fb_<用户>_<24 位十六进制随机数>,在 Release 内唯一;可读的路径写进资产的 label 字段,实测 label 能原样保留中文和 /。真正的路径以 KV 索引为准,label 只是一份备份,KV 里的索引丢了的话,可以凭 label 和资产名前缀重建。

KV 索引

KV 里的键分四类:user:<名>、token:<摘要>、file:<用户>:<路径>、dir:<用户>:<路径>。其中路径经过 encodeURIComponent,/ 变成 %2F,于是「目录 X 下的全部子项」就是前缀 file:<用户>:<X 编码>%2F,用一次带前缀的 list 就能找出来。

列文件时有一个容易忽略的开销。KV list 只返回键,若要拿到每个文件的大小和创建时间,就要逐条 get;每人最多 300 个文件,刷新一次列表就是 300 次 KV 读。好在 KV 允许每个键带一份最多 1024 字节的 metadata,list 时会一并返回。我们把资产 id、长度、创建时间、类型、长期标记和各段资产 id 压进 metadata,于是列一个用户的文件、统计全站的长期文件、挑出最旧的文件,都只需要 list,不用逐条 get。

分段上传

Worker 免费版的入口请求体上限是 100 MB,所以大文件必须由客户端切段,一段一个请求。段长取 95 MiB(PART_BYTES),留出一点余量。每段在 GitHub 上是一个独立的资产,label 写成 <路径> (n/N)。

段数的上限由下载决定。下载一个分段文件时,Worker 对每一段都要先请求资产接口、再跟随签名跳转,花 2 个外部子请求;免费计划每次调用最多 50 个子请求,扣掉余量后,段数上限定为 22。22 段乘以 95 MiB 略多于 2 GiB,单文件上限便定为 2 GiB。

接下来的问题是,各段传完之后,服务端怎么知道它们属于同一个文件。最直接的办法是每传一段就写一条 KV 记录,但 KV 每天只有 1,000 次写,一个 2 GiB 的文件就要写 22 次,几十个大文件就能把一天的额度用完。

我们的做法是让服务端不保存中间状态。每段传完,服务端返回一张回执,里面写着用户、路径摘要、上传 id、段号、段数、文件总长、这一段的资产 id 和长度,以及过期时间,用 SESSION_SECRET 做 HMAC 签名。客户端把全部回执攒起来,最后一次性交给 /api/upload/complete。服务端只需要验签和核对,就能写索引:

1
2
3
4
5
6
7
8
// 所有回执必须来自同一次上传,段号 1..of 各一张,总长对得上。
const first = parts[0];
const same = parts.every((r) => r.i === first.i && r.of === first.of && r.z === first.z);
if (!same || parts.length !== first.of) return json({ error: "回执不是同一次上传的,或者缺段" }, 400);
parts.sort((a, b) => a.n - b.n);
if (parts.some((r, k) => r.n !== k + 1)) return json({ error: "段号不连续" }, 400);
const total = parts.reduce((s, r) => s + r.s, 0);
if (total !== first.z) return json({ error: `各段加起来 ${total} 字节,应为 ${first.z}` }, 400);

如此一来,一次分段上传只写一次 KV。这里还有几个细节。

  • 回执签名前加了 receipt: 前缀,与会话 cookie 的签名区分开,两者不能互相冒用。
  • 回执里存的是路径的 SHA-256 摘要,回执的长度因而与路径长短无关。
  • 服务端在每段上传时就核对段长:除最后一段外,每段必须正好是 PART_BYTES,段数必须等于总长除以段长向上取整。客户端算错了当场拒绝,免得传完 2 GiB 才发现拼不起来。
  • 同一组回执提交两次时,第二次直接返回已有的记录。否则覆盖上传的逻辑会把刚写进索引的段当成旧资产删掉。

中途放弃的上传会在 Release 里留下没有索引的段,这些段交给后面要讲的孤儿对账去删。为此,回执的有效期必须短于孤儿判定的时长(默认分别是 5 小时和 6 小时)。若顺序反过来,对账任务可能先删掉某一段,随后才有人拿着回执来提交,索引里就会出现一个缺段的文件。

分段下载

下载分段文件时,Worker 按顺序向 GitHub 取各段,接成一个响应流返回给客户端。Range 请求先换算到具体的段,只取用得到的那几段。

第一版用 TransformStream 接流:把各段的 body 依次 pipe 进它的 writable 端,把 readable 端作为响应体返回。上线后我传了一个 200 MiB、3 段的文件,下载传到 75 MB 到 145 MB 时就中断了。用 wrangler tail 看日志,请求的结局是 exceededCpu,CPU 时间约 2 秒。

原因在于 TransformStream 是 JS 实现的,每个数据块都要经过 JS,实测大约每 MB 花 14 ms CPU,请求还没传完,就因超出 CPU 限制被掐断了。更麻烦的是,这种响应没有 Content-Length 头,流中途断掉时 curl 照样返回 0,用户拿到的是半个文件而不自知。

换成 FixedLengthStream 之后问题就解决了。它是 Workers 运行时原生的流,原生流之间 pipeTo 不经过 JS;同一个 200 MiB 的文件,完整下载只用了 8 ms CPU,sha256 与原文件一致。它还要求预先声明总长,于是响应自然带上了 Content-Length,流不足长时连接会报错,客户端不会把半个文件当成完整的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const { readable, writable } = new FixedLengthStream(end - start + 1);
const pump = async () => {
try {
for (let k = 0; k < slices.length; k += 1) {
// 第一段在返回响应头之前就已经取好,后面的段读完一段再取下一段。
const res = k === 0 ? first : await fetchSlice(slices[k]);
await res.body.pipeTo(writable, { preventClose: true });
}
await writable.close();
} catch (err) {
console.log(JSON.stringify({ partsError: err.message, path: rec.path }));
await writable.abort(err).catch(() => {});
}
};
// 响应体还在被客户端读的时候,运行时会让 Worker 一直活着;waitUntil 是保险。
ctx.waitUntil(pump());

第一段要在返回响应头之前取好。这样 GitHub 不可用时,Worker 还能返回 502;若等到响应头发出之后才发现上游出错,就只能把流断掉了。I/O 等待不计入 CPU 时间,客户端连着就没有墙钟时间的限制,所以大文件下载可以持续很久。

单段文件会进边缘缓存。下载地址带 ?v=<资产 id>,每次上传都会换一个新的资产 id,于是带版本号的地址内容永远不变,可以缓存一年并标记为 immutable;不带版本号的地址返回当前内容,缓存 7 天。下载时先查 KV 索引,再查缓存,顺序反过来的话,已删除的文件会继续从缓存里被返回。分段文件不进缓存,因为 Cache API 的单个对象最大 512 MB,而且整段写缓存也要额外花子请求。

自动清理

一个 Release 最多放 1,000 个资产,filebox 又是临时存储,所以 Worker 每小时跑一次定时任务,在满之前删掉最旧的文件。计数单位是「名额」,也就是 Release 里的资产:普通文件占 1 个,分段文件有几段占几个。若按文件计数,一个人传几个 2 GiB 的文件,就能占掉 Release 的大半。

删除顺序有三条规则:

  1. 标记为长期的文件永远不删。
  2. 某个用户占的名额超过 300 个,先删他自己最旧的文件,删到 300 以内。这样一个人批量上传,挤掉的是他自己的旧文件。
  3. 全站名额仍然超过 950 个,再删全站最旧的文件,删到 900 以内。

长期文件每人最多 20 个,全站最多占 200 个名额,否则长期文件可以把 Release 占满。挑哪些文件删由一个纯函数 planEviction 决定,方便单独写单元测试。

真正执行删除时,有两处设计与子请求额度有关。首先,删一个资产要调一次 GitHub 接口,而每次调用最多发 50 个子请求,所以一次定时任务最多删 40 个资产,剩下的留给下一个小时。其次,删除不清边缘缓存,因为下载总是先查 KV,记录没了就直接 404,缓存里的旧内容不会再被返回。这样每删一个资产只花一个子请求。

删除之前还要重读一次记录。list 拿到的是快照,期间文件可能被覆盖上传(换了资产 id),或者被设为长期,这两种情况都要跳过,否则会删掉新文件。

两次定时任务之间,Release 仍有可能被传满。GitHub 的文档只写了每个 Release 最多 1,000 个资产,没写满了以后返回什么,代码假定是 422:上传收到 422 时,当场清理一批,返回 507,由客户端自动重传一次。这种情况没有实测过,要测得往一个测试 Release 里传满 1,000 个文件。

孤儿对账

孤儿指 Release 里有、索引里没有的 fb_ 资产。它们有三个来源:中断的分段上传、上传后写索引失败,以及删除时没删干净的段。定时任务清理完之后,会把创建超过 6 小时的孤儿删掉。

对账删错了就是丢数据,所以它的保护比清理多。

  • 只碰 fb_ 开头的资产,别人手工放进 Release 的东西不动。
  • 索引里有记录没能还原出来时,不对账,那条记录引用的资产会被误判成孤儿。
  • 一半以上的 fb_ 资产看起来都像孤儿时,一个都不删。这种情况通常说明 KV 和 Release 对不上,比如本地开发用的 KV 连着线上的 Release。
  • 对账默认关闭,只在线上配置里打开。

对账还复用了自动清理 list 出来的索引快照,每小时少一次全量 KV list。这样做不会误删:快照里还留着刚被清理掉的文件,它们的资产只会被当成已知,这次不删;快照比列资产早几秒,这期间新传的资产创建时间远不到 6 小时,不会被判成孤儿。

身份与安全

身份有两种凭据,最终都回到 KV 里的用户记录。

  • 会话 cookie:无状态的 HMAC 签名,载荷是用户名、角色、账号版本和过期时间,HttpOnly; Secure; SameSite=Strict,有效期 7 天。
  • API token:192 位随机数,KV 里只存 SHA-256 摘要,明文只在创建时返回一次。

每次请求都回查用户记录,所以停用账号、改角色立即生效。改口令时账号版本加一,旧会话因版本对不上而全部失效,同时吊销该账号的全部 token。口令用 PBKDF2-SHA256,10 万次迭代,线上实测登录耗时约 0.8 秒到 0.9 秒,没有触发 CPU 限制;用户不存在时也用假盐算一遍,避免靠响应时间判断账号是否存在。

登录限流放在 zone 上的一条速率限制规则里,Worker 自己不记失败次数。若每次失败都写 KV,猜不中口令的爆破也能把每天 1,000 次的写额度耗光,当天就没法上传了。

下载是公开的,所以要防止用户上传的主动内容以本站的 origin 执行:HTML、SVG、JS、XML 这类文件一律以附件形式返回,并带 nosniff 和 Content-Security-Policy: default-src 'none'; sandbox;只有图片、音视频、纯文本和 PDF 内联展示。

命令行客户端

命令行客户端 filebox 是一个 Bash 脚本,由 Worker 在 /filebox 下发,下发时把默认站点地址填进去。它要在 macOS 自带的 Bash 3.2 上运行,不能依赖 python3 和 jq,因为 macOS 默认不带它们。于是需要解析的响应,都让服务端加 ?format=text 直接返回纯文本:put 和 url 只返回一行下载地址,ls 返回逐行的文件列表。

1
2
3
4
5
6
filebox login                            # 粘贴在网页上生成的 token,存进 ~/.config/filebox/token
filebox put ./photo.jpg album/2026.jpg # 成功后打印下载地址;同名默认拒绝,-f 覆盖
filebox ls -r album # 列出 album 下的全部文件
filebox mv album/2026.jpg album/新年.jpg # 重命名
filebox keep album/2026.jpg # 设为长期,不参与自动清理
filebox update # 从站点下载新版,替换脚本自己

超过 95 MiB 的文件,put 用 dd 按服务端给的段长切出临时文件,逐段上传换回执,最后提交全部回执。

update 先找到脚本自己在磁盘上的真实位置(经 PATH 里的软链接调用也能找到),把新版下载到同一个目录下的临时文件,检查过开头的 #! 和版本号,并用 bash -n 做过语法检查之后,再用 mv 原子替换。脚本发出的请求都带 User-Agent: filebox-cli/<版本号>,在访问统计里能据此区分命令行和浏览器,也能看出还有哪些旧版本在用。

变量后紧跟中文

脚本在 Linux 上自己编译的 Bash 3.2.57 里跑通了全部命令,到了 macOS 上,update 却报了一个 new?: unbound variable。出问题的是这样一行:

1
echo "已更新:$VERSION → $new($self)"

macOS 的 Bash 3.2 在 UTF-8 locale 下,会把紧跟在变量名后面的中文首字节当成变量名的一部分。全角括号「(」的 UTF-8 编码以 0xEF 开头,于是 $new( 被读成了变量 new\xef,在 set -u 下报错。Linux 上编出来的 Bash 3.2 测不出这个问题,因为 glibc 在 UTF-8 locale 下不把单个高位字节当成字母。

修法是变量后面紧跟中文(包括全角标点)时一律写成 ${name}。为了防止以后再犯,下面这条命令用来检查,正常情况下应当没有输出:

1
perl -ne 'print "$.: $_" if /\$[A-Za-z_][A-Za-z0-9_]*[\x80-\xff]/' scripts/filebox

要在 Linux 上复现,可以用 localedef 生成一个 Latin-1 的 locale,再用它运行 Bash 3.2。Latin-1 下 glibc 会把 0xEF 这类字节当成字母,与 macOS 的表现一致。

Finder 右键上传

在 Mac 上,最顺手的上传方式是在 Finder 里选中文件、右键上传。filebox install-finder 在 ~/Library/Services/ 下生成一个 Automator 快速操作,调用脚本的 finder-upload 子命令;上传完成后,下载链接复制到剪贴板,结果用系统通知显示。快速操作运行时读不到 shell 里的环境变量,所以 token 要先用 filebox login 存进文件。

这一部分在 Mac 上遇到了两个问题。

第一个是菜单不出现。脚本生成的 Info.plist 和 document.wflow 是照着格式手写的,系统也确实登记上了这个服务(pbs -dump 里看得到),Finder 的右键菜单里却没有它。用 Automator 打开再保存一次,菜单就出来了。对比 Automator 保存出来的文件,手写版缺了 Info.plist 的 NSIconName、document.wflow 里的 applicationPaths 和动作的 arguments 等字段,有几个开关字段的值要写成 <integer>0</integer>。现在脚本照着 macOS 15 上 Automator 保存的内容生成这两个文件,装完菜单直接出现。

第二个是运行时报 Operation not permitted。我的脚本放在「文稿」目录下,而快速操作的进程受 macOS 的隐私保护(TCC)限制,读不了「文稿」「桌面」「下载」这些目录里的文件,bash 连脚本本身都打不开。所以安装时把脚本复制一份到 ~/Library/Application Support/filebox/,快速操作调用这份副本,filebox update 顺带更新它。

还有一个体验上的问题。大文件上传要很久,期间没有任何提示,我不知道它是不是还在传。终端里有 curl 的进度条,快速操作没有终端,于是改成开始时发一条通知,分段上传时每传一段再发一条「第 k/N 段」。

重命名

重命名和移动只改索引,不动资产,大文件也是一瞬间完成:写新路径的 file: 键,删旧路径的键,再用 GitHub 的接口把资产的 label 改成新路径。下载地址的路径变了,旧链接直接 404,?v= 后面的资产 id 不变;创建时间保持不变,所以移动不会让文件在自动清理的排队里变新。

写入的顺序是先写新键、再删旧键。中间失败时,两个键指向同一组资产,字节还在。若反过来先删再写,失败时资产就没有了索引,6 小时后会被对账当作孤儿删掉,等于丢了文件。改 label 失败只记日志,不算重命名失败,因为索引才是准的,label 只在重建索引时用到。

目标位置已有文件时直接拒绝(409),要覆盖就先删掉目标。覆盖要删掉目标的资产,还要决定长期标记归谁,不值得为改名引入这些逻辑。同样,只能移动单个文件,不能移动目录:目录下有几百个文件时,一次请求的 KV 写和 GitHub 子请求都不够用。

部署

源码仓库是公开的,所以凡是能定位到线上服务的信息都不能进仓库:域名、存储文件的私有仓库名、KV 命名空间的 id、限流规则的阈值。凭据之外,这些部署标识同样敏感。仓库里只有一份占位的 wrangler.example.jsonc,线上用的 wrangler.jsonc 放在另一个私有仓库里维护,本地源码目录里的 wrangler.jsonc 是指向它的软链接。

部署原先是本地手动跑 wrangler deploy,后来改用 Cloudflare 的 Workers Builds:在面板里把 Worker 连到公开的源码仓库,推送 main 之后大约一分钟自动构建并部署。由于公开仓库里没有 wrangler.jsonc,构建命令要先从私有仓库把它读下来,再跑检查和单元测试,有一步失败就不部署:

1
2
3
4
5
# 用只读 token 从配置仓库取出线上配置,而后跑检查和单元测试
curl -fsSL -H "Authorization: Bearer $CONFIG_TOKEN" \
-H "Accept: application/vnd.github.raw" \
"https://api.github.com/repos/<配置仓库>/contents/wrangler.jsonc?ref=main" \
-o wrangler.jsonc && npm run check && node test/evict.test.mjs

最早的做法是把压缩后的配置整个存进构建密钥,这样改配置时要手动同步两份,容易漏;带注释的配置转成 base64 后约 5 KB,面板保存时还会报「内部错误」。现在配置只有私有仓库里的一份,改配置就是推送私有仓库。

CONFIG_TOKEN 是一个单独的 fine-grained PAT,只授权配置仓库,权限只有 Contents: Read-only,并且设了过期时间。它没有复用 Worker 运行时用来读写 Release 的 GITHUB_TOKEN。构建环境每次都要从 npm 下载并执行 wrangler,环境变量泄露的风险比运行时大;只读的 token 泄露了只能读到配置,可读写的 token 泄露了,存着的文件和部署配置都可能被删改。token 过期时,curl 的 -f 让构建直接失败,不会拿错误的配置去部署,线上服务照常运行。

之所以不用公开仓库自己的 GitHub Actions 部署,是因为 wrangler deploy 的输出里有路由和绑定的变量,公开仓库的 Actions 日志谁都能看;Workers Builds 的日志只在 Cloudflare 面板里。

最后还有一处要注意:可观测性的配置要在 wrangler.jsonc 里整段写全。部署时 wrangler 把 observability 这一段原样交给 Cloudflare,在面板上改的设置会被下一次部署覆盖。我在面板上打开了跟踪和实时问题,下一次自动部署按旧配置把实时问题又关掉了。现在配置里写的就是面板上的全部设置:

1
2
3
4
5
6
"observability": {
"enabled": true, "head_sampling_rate": 1, "redact_query_string": false,
"issues": { "enabled": true },
"logs": { "enabled": true, "head_sampling_rate": 1, "persist": true, "invocation_logs": true },
"traces": { "enabled": true, "head_sampling_rate": 1, "persist": true }
}
俗话说,投资效率是最好的投资。 如果您感觉我的文章质量不错,读后收获很大,预计能为您提高 10% 的工作效率,不妨小额捐助我一下,让我有动力继续写出更多好文章。