Skip to content
lujihPublic

About

Cloudflare Workers Free + R2 上的 WebDAV 电子书库:设计取舍全部围绕每天 10 万请求的免费额度

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

webooks

Cloudflare Workers 免费版 + R2 上的 WebDAV 电子书库。

Phase 0 骨架:验证「WebDAV 能不能在 10 万请求/天以内跑起来」。 设计取舍全部围绕这个预算,而不是围绕功能完整度。


快速开始

npm install

# 本地跑起来(D1 / R2 由 wrangler 自动创建,无需预先建资源)
npm run db:migrate:local
npm run dev            # http://127.0.0.1:8788/dav/

部署到线上:

npx wrangler login
npm run db:migrate                        # 建表(远端 D1,首次会自动创建)
npx wrangler secret put ADMIN_TOKEN
npm run deploy

wrangler.jsonc 里只写语义化名字(webooks-db / webooks-bucket), 不带任何账号相关的 ID —— 用的是 wrangler 的 自动资源创建 (需 ≥ 4.45)。好处是仓库不含任何账号信息,别人 fork 后一条命令就能部署。

配置文件里只有一个必填项(R2_PUBLIC_BASE)。挂载前缀、单目录上限、 缓存 TTL、上传体积、写配额这些都走 src/config.ts 里的默认值 —— 理由和实测数据都写在那个文件的注释里。想临时覆盖用 wrangler deploy --var KEY=VALUE,不必改文件。

⚠️ 还要做一件事:给 R2 桶绑一个公开自定义域,填进 wrangler.jsonc 的 R2_PUBLIC_BASE。这个地址有两个用途 —— WebDAV 的 GET 302 目标,以及浏览页书卡 的直链。不做的症状是「能列目录,一点下载就 530」(直链会 404/530,Worker 也会在 第一次请求时就因 R2_PUBLIC_BASE 为空而报错)。 不要用 r2.dev(官方明示不适用于生产)。

完整部署说明(三条路径、GitHub 集成、CI/CD、故障排查)见 docs/DEPLOY.md。

部署后立刻验证:

node scripts/dav-probe.mjs --url https://你的域名/dav/
# 写路径(LOCK / DELETE / MOVE / 覆盖)需要写口令,不带口令时这些动词只会看到 401
node scripts/dav-probe.mjs --url https://你的域名/dav/ --user x --pass "$WRITE_TOKEN"

目录结构

src/
  index.ts              路由:/dav、/api/admin、/health
  config.ts             所有影响请求预算的参数集中在此,并对上限做硬 clamp
  webdav/
    index.ts            方法分发(Class 1:无 LOCK/UNLOCK)
    propfind.ts         ★ 预算核心:D1 目录树 + Cache API + 目录上限 + Depth 防护
  lib/
    collections.ts      ★ 虚拟目录树定义与路径解析
    db.ts               D1 访问(全部走索引)
    cache.ts            条目缓存的键与「写入即失效」逻辑
    metadata.ts         从文件名推导元数据(Phase 1 换成解析 EPUB OPF)
    xml.ts / http.ts    XML 与 HTTP 工具
  api/admin.ts          reindex / stats / purge / backfill-disposition
migrations/0001_init.sql
public/                 ★ 静态资源:唯一「不计入请求配额」的一类请求
  favicon.ico           浏览器每次浏览都会猜它;没有它就是每次白花一次请求
  robots.txt            挡住爬虫枚举 /dav/ 那棵可无限展开的目录树
  _headers              静态资源的响应头(对 Worker 响应无效,见下)
scripts/
  budget.mjs            ★ 请求预算模拟器
  gen-favicon.mjs       生成 public/favicon.ico(开发期跑,产物提交进仓库)
  gen-pinyin-table.mjs  生成 src/lib/pinyin-table.ts(同上,运行时零依赖)
  dav-probe.mjs         ★ 线上 DAV 一致性 / 边缘动词放行 / 100-continue 探针
  size-probe.mjs        ★ 请求体 100 MB 边界探针
test/                   单元 + 端到端 + 回归(`npm test` 会报当前条数)

虚拟目录树(为什么长这样)

请求成本 ≈ 每个目录一次 PROPFIND + 每个文件一次 GET(浏览页书卡直连 R2, 那一侧不花请求;WebDAV 客户端的 GET 仍算一次)。 所以目录数量直接乘进请求数 —— 目录树必须「宽而浅」。

/                        根:5 个集合
/按书名/                  导航:列出有书的首字母桶
/按书名/A/                列表第 1 页(≤ 200 本)
/按书名/A/2/              列表第 2 页(仅当超过 200 本)
/按书名/A/Dune.epub       书(叶子)
/按作者/  /按类型/  /按格式/   同构
/最近收录/                扁平集合:/最近收录/、/最近收录/2/、/最近收录/Dune.epub

目录总数上界 ≈ 120(远低于 200 的设计上限)。

两条刻意的设计决定:

  • 绝不「每本书一个目录」 —— 那会让目录数等于书数,请求数直接爆炸。
  • 叶子按 leaf_name 全局唯一查找,所以路径里的桶与书的实际分桶不要求一致。 客户端即使 PUT 到了「错误的」桶,之后也一定取得到文件。

路径段用中文(不是 displayname)

绝大多数 WebDAV 客户端(Windows 资源管理器、rclone、Cyberduck…)直接用 href 的 最后一段当文件夹名,只有少数会读 displayname 属性。所以想让客户端显示「按书名」 而不是「title」,路径本身就必须是中文。

英文旧路径(/dav/title/ 等)保留为兼容别名,中英文指向同一本书。 两者通过 canonicalDavPath() 归一 —— 否则缓存键与锁资源会分裂(用英文路径缓存的目录, 按中文路径失效时打不中,客户端会一直看到旧列表)。

分类规则

集合 分桶依据
按书名 / 按作者 拉丁字母 A–Z、数字 0-9、汉字取拼音首字母、没有作者 → 「未知作者」、其余进「其他」
按类型 电子书 / 漫画 / 有声书(按内容嗅探,不只是扩展名)
按格式 epub / pdf / cbz / … 归一化后的格式
最近收录 不分组,按收录时间倒序

中文按拼音分桶是中文书库的关键:早期实现把所有中文书都塞进 other, 按书名浏览等于没有分类。「三体」现在进 S、「末世降临」进 M、「活着」进 H。

拼音表由 scripts/gen-pinyin-table.mjs 在开发期用 pinyin-pro 烘成静态表 (src/lib/pinyin-table.ts,63 KiB,20935 个汉字),运行时不引入任何依赖 —— 项目仍然是零运行时依赖。重新生成:

node scripts/gen-pinyin-table.mjs

作者识别

三层,从最可靠到最猜测:

  1. EPUB 的 dc:creator —— 文件内的权威元数据,优先于一切猜测
  2. 文件名显式标记 —— 作者:X、X 著、[X]书名、书名 (X)
  3. 分隔符 + 姓氏表 —— A - B 的方向靠「哪边以常见中文姓氏开头」判断

第 3 条是关键:书名 - 作者 与 作者 - 书名 两种命名约定都真实存在,写死任何一边 都会让另一半错得离谱。而姓氏是极强的判据:萧冬连 的「萧」是姓、 筚路维艰 的「筚」不是 —— 于是 筚路维艰 - 萧冬连 能判对, Frank Herbert - Dune 靠「多个首字母大写的单词更像人名」也能判对。

TXT 会读正文找作者:很多中文小说开头就写着 作者:XXX,没有元数据也能捞回来 (实测把「神秘复苏」的作者补成了「佛前献花」)。

没有作者就进「未知作者」桶,而不是拿书名首字母顶替 —— 后者会让一本书看起来像「作者姓某字」,是错误信息而不是缺信息。

电子书 / 漫画:按内容识别

只看扩展名是不准的:.pdf 既可能是文字书,也可能是扫描漫画(用户书库里的 敖丙传(17-24).pdf 就是)。所以读内容判断:

格式 判据
cbz / cbr / cb7 / cbt / cba 容器格式本身就是漫画,无需读内容
pdf 零字体 + 每页一图 → 漫画;有字体资源 → 文字书
epub 固定版式声明(rendition:layout: pre-paginated)或图片项远多于文档项 → 漫画

PDF 的判据来自实测:用户那本 86MB 的扫描漫画,采样 2.86MB 得到 /Type/Page 17 个、/Subtype/Image 17 个、/DCTDecode 17 个、/Type/Font 0 个。 文字书即使正文全是图,页眉/书签/目录也要字体资源,所以「零字体」非常硬。

两个工程要点:

  • 用 ranged GET 只读前 2MB,不把整个文件读进 128MB 的 isolate(那本漫画有 86MB)
  • 判不出就返回 null,回退到按扩展名分类。猜错的代价比不判更大

手动修正(启发式的兜底)

再准的启发式也会判错(PDF 用对象流压缩把字典藏起来、作者是笔名、文件名格式古怪)。 管理后台 /admin 里每本书都能直接改作者与类型,改完立刻重算分桶并失效缓存。

也可以批量按内容重新提取存量书:

# 只重算字母桶(不动类型)
curl -H "Authorization: Bearer $ADMIN_TOKEN" https://你的域名/api/admin/rebucket

# 按内容重新嗅探类型 + 补齐缺失作者(会读 R2,较慢)
curl -H "Authorization: Bearer $ADMIN_TOKEN" "https://你的域名/api/admin/rebucket?sniff=1"

rebucket 默认不动 kind_bucket:它现在来自内容嗅探或人工修正,比「按扩展名猜」 更权威。按扩展名重算会把扫描漫画打回「电子书」(真实踩过)。

?sniff=1 也一样 —— 迁移 0006 起每本书都记了 kind_source (sniffed / derived / manual),manual 的值任何自动化都不会覆盖 (rebucket 会跳过并报告 skippedManual,覆盖上传也会保留)。 这不是多余的约束:内容嗅探在两种真实情况下必然失效 —— ① PDF 1.5+ 用对象流把字典压起来,标记根本看不到 ② 带 OCR 层的扫描漫画引入字体资源,让「零字体」判据直接崩 这两类书只能靠人判断,而人判断恰恰是最容易被脚本覆盖掉的数据。

分类规则一旦变化,存量书不会自动跟上(title_letter 等是入库时算好存起来的), 所以改规则后要跑一次 rebucket。

上传格式白名单

只接受电子书 + 漫画格式(src/lib/collections.ts 里的 ALLOWED_UPLOAD_FORMATS,是唯一定义处):

电子书: epub pdf mobi azw3 fb2 djvu txt rtf doc docx odt md html lit chm prc pdb
漫画:   cbz cbr cb7 cbt cba

被排除:有声书(m4a/m4b/mp3)、通用压缩包(zip/rar/7z)、以及一切可执行文件 和脚本。想调整就改那一个数组,网页上传与 WebDAV PUT 会同时生效。

早先网页上传有一份白名单,但 WebDAV PUT 完全没有检查,任何文件都能绕过网页 限制塞进书库。现在两条入口共用 isAllowedUploadExt(),不存在只改一边的可能。

批量上传:一批只验证一次

网页上传支持多选与多文件拖入,串行上传并逐项显示进度。

以下描述的是开启人机验证时的行为;默认已关闭(见下方取舍说明)。

这里有个容易做错的点:Turnstile 令牌是一次性的。最初按「每个文件领一个新令牌」 实现,结果是传 10 本书要点 10 次验证 —— 批量上传等于没用。

正确做法是把「验证码」与「上传次数」解耦:

第 1 个文件  → 带一次性 turnstileToken 调 /api/upload/init
              服务端 siteverify 通过 → 回一张通行证(HMAC 签名,10 分钟,绑定 IP)
第 2..N 个   → 带 ticket 调用,服务端只验签名,不再打扰用户

通行证是无状态的(<过期时间戳>.<HMAC(密钥, 过期时间|IP)>),不占 D1 写入, 不需要新表;IP 不写进通行证里,验证时用当前请求的 IP 重算签名比对 —— 所以通行证只能由签发它的那台机器使用。

⚠️ 这条保证依赖 IP 取自 CF-Connecting-IP(Cloudflare 边缘强制设置)。 实现上只信任这个头、不回落到 X-Forwarded-For:后者完全由客户端提供, 一旦回退生效,攻击者只要把 XFF 填成签发时的 IP 就能在别的机器上复用通行证。 早期实现有那个回落,承诺实际上是失效的。

密钥默认复用 TURNSTILE_SECRET(想独立轮换再设 UPLOAD_TICKET_SECRET), 不引入新的必填配置。

同时 widget 设成 appearance: 'interaction-only':正常用户根本看不到验证框, 只有 Cloudflare 判定可疑时才弹出来要求交互。

权衡:一张通行证在有效期内可多次使用,即「一次验证码换 10 分钟上传窗口」。 匿名上传本来就有每日写入次数节流兜底,验证码的边际价值有限, 用这一点换掉「每本弹一次验证」是划算的。


预算机制(这部分决定项目能不能免费跑)

1. PROPFIND 只读 D1,从不调用 R2.list()

这是最反直觉的一条。如果按常见做法用 R2.list() 实现 PROPFIND:

10 万次/天 × 30 天 = 300 万次 Class A 操作/月, 超出免费额度 100 万 → 200 万 × $4.50/M = $9/月 —— 比直接买 $5 的 Workers Paid 还贵。

本实现改为读 D1(索引查询),R2 Class A 只用于上传。

2. PROPFIND 结果缓存 300 秒

缓存的是条目 JSON(不是最终 XML),所以不同客户端请求不同属性集时共享同一份缓存, 命中率更高,purge 也能做对。

  • 命中:0 次 D1 读、0 次 R2 操作、CPU 接近于 0
  • 未命中:约 200 行 D1 读取

D1 免费额度是 500 万行读/天。10 万次全部未命中 = 2000 万行,会超; 90% 命中率则降到约 200 万行,安全。

3. 下载完全绕开 Worker:书卡直连 R2,WebDAV 的 GET 走 302

两种客户端各走一条路,但字节都不过 Worker:

谁 怎么下载 消耗 Worker 请求?
浏览页(浏览器点书卡) 书卡 href 直接是 R2_PUBLIC_BASE 上的对象地址 ❌ 0 次
WebDAV 客户端(rclone / Finder / 资源管理器) GET /dav/<路径> → 302 到同一个公开域 1 次(读目录时已经在花的那些)

浏览页那条是后改的:更早的时候书卡 href 是 /dav/<路径>,所以「点开一本书」 必须先撞一次 Worker 拿 302;而那个 302 带 no-store,CDN 缓存不上,于是每次点击 都实打实消耗一次配额。现在书卡直接指向 R2,这一次彻底没了(test/download-direct.test.ts 有守卫,防止改回去)。

WebDAV 那条 302 有意保留:它带着鉴权与写配额判定,rclone / Finder 也依赖它 (含 Range 透传),删掉等于把 WebDAV 客户端挡在门外。

两条路的共同点:下载的字节不占 CPU/内存,不受 100 MB 请求体上限影响,还能被 CDN 缓存。R2 出网本来就免费。

⚠️ 下载名只能靠写入时的 Content-Disposition。 对象键是 books/<随机 id>, 既没有扩展名也没有书名;而 R2 不支持 S3 的 response-content-disposition 之类 response-* 覆盖参数(实测无效)。所以三条写入路径(WebDAV PUT 的两个分支、COPY/MOVE、 网页上传 complete)统一用 src/lib/r2.ts 里的同一个 helper 写入下载名。 在它落地之前就存在的对象需要跑一次回填,否则直链下载下来叫 95e860d26bd874cf —— 命令见下方「数据导入」一节的 backfill-disposition。

4. 单目录硬上限 200 条

同时满足两件事:控制 PROPFIND 响应体积,以及把 XML 生成留在 Workers Free 的 10 ms CPU 之内(200 条约 1–3 ms,1000 条就会触发 Error 1102)。

5. rclone / 脚本用户应该走 S3,不走 WebDAV

这是省预算最狠的一招:rclone 原生支持 S3,直连 R2 端点是 0 次 Worker 请求。 WebDAV 只留给只会讲 WebDAV 的客户端(Finder、Windows 资源管理器、KOReader)。

# 推荐:rclone 直连 R2 的 S3 端点(0 次 Worker 请求)
rclone config create r2 s3 provider=Cloudflare \
  access_key_id=<R2_ACCESS_KEY> secret_access_key=<R2_SECRET> \
  endpoint=https://<ACCOUNT_ID>.r2.cloudflarestorage.com

# 只有必须用 WebDAV 时才走 Worker,并且把目录缓存拉长
rclone mount webooks: /mnt/books \
  --dir-cache-time 24h --poll-interval 0 --vfs-cache-mode off

6. 静态资源(public/)—— 唯一真正不计费的请求类型

先说三个反直觉的事实,因为这里最容易走错路:

机制 省请求数? 说明
Cache API(caches.default,本项目在用) ❌ 一次都不省 官方原文:「every request still executes your Worker on the way in」。它只省上游(D1/R2)。
Workers Caching(cache.enabled) ❌ 而且更贵 命中仍按标准费率计费(「Cache hits count as requests but do not consume CPU time」),且开启后连本来免费的静态资源请求也变成计费。我们的瓶颈是请求数不是 CPU → 故不开。
静态资源 ✅ free and unlimited 官方原文:「Requests to static assets are free and unlimited.」且完全不经过 Worker 代码。

所以 public/ 里放的是「每次访问都会发生、但内容恒定」的东西:

  • favicon.ico —— 页面不声明 <link rel="icon"> 时,浏览器会自己去请求 /favicon.ico。 在这个文件存在之前,那个路径是 404:每打开一个页面就白花一次 Worker 请求。 现在每个页面都显式声明它(test/pages-syntax.test.ts 有守卫)。
  • robots.txt —— 首页有个「进入书柜」链接指向 /dav/,而那是棵可无限枚举的目录树。 没有 robots.txt 时爬虫会顺着目录把整个书库翻一遍。注意 HTML 页上的 <meta name="robots" content="noindex"> 只阻止收录、不阻止抓取 —— 抓取照样花请求。
  • 404.html —— 见下,它承担的是最大的一块:所有不存在的路径。

未知路径:404-page + run_worker_first

扫描器打过来的不存在的路径(/wp-login.php、/.env、/administrator…)数量不受本站控制, 而它们在 not_found_handling: "none" 下每一条都要花一次 Worker 配额去回一个十来个字节的文本 404。 这正是上次一天打满 10 万的形态,所以现在配的是:

"assets": {
  "directory": "./public",
  "not_found_handling": "404-page",
  "run_worker_first": [ /* 每一条 Worker 路由,缺一条那条路由就对真人 404 */ ]
}

这两个设置必须成对出现,且是一条线的两端:

  • 只要 404-page,不加 run_worker_first → 真人浏览器的每一次顶层导航都被静态 404 页吃掉。 触发条件只有 Sec-Fetch-Mode: navigate 一个头(curl 不带),所以 curl 冒烟测试全绿而线上整站 404。
  • 只要 run_worker_first 的语义有一条漏掉 → 那条路径静默 404。实测三条容易漏的规则: /x/* 不匹配 /x 本身;字面量精确匹配、大小写敏感;run_worker_first 优先于命中的静态资源。
  • run_worker_first: true(布尔)不是「全部走 Worker」的安全写法 —— 实测它让任何静态资源都不被服务, 优化归零。必须用数组。

test/static-assets.test.ts 把这条线钉住了:它从 src/index.ts 里抠出全部路由字面量, 断言每一条都被某个 run_worker_first 模式覆盖 —— 新加一条路由却忘了同步列表,测试会红。 scripts/verify-deploy.mjs 另有两端哨兵:一条带 navigate 头确认 Worker 路由仍可达, 一条确认未知路径返回的是 public/404.html(而不是 Worker 的文本 Not Found)。 两处都读真实文件,改动会被逼着重新读一遍上面这段论证。

HTML 页面故意不放进 public/:它们是动态渲染的(首页插 DAV_MOUNT/R2_PUBLIC_BASE, 上传页插 Turnstile 公钥),一旦落成静态文件,部署时的 --var 覆盖就再也反映不到页面上 —— 那是静默失效。

⚠️ public/_headers 只对静态资源生效,对 Worker 生成的响应完全无效;反之 Worker 里的 withSecurityHeaders() 也管不到静态资源。两边各管各的,漏一边就是静默缺失, 所以每个 public/ 文件都必须在 _headers 里有对应规则(test/static-assets.test.ts 守卫这条)。


用预算模拟器回答「10 万/天够不够」

npm run budget
npm run budget -- --books 20000 --browse 2000 --mounts 200 --syncs 20

它会区分两类额度,因为行为完全不同:

类型 超额后果
硬停类(Worker 请求、D1 行读写) 站点停摆到次日 UTC 0 点(北京时间 08:00)
计费类(R2 存储 / Class A / Class B) 照常服务,产生小额账单(R2 不是硬停)

默认场景(5,000 本 / 12 GB / 300 次浏览 / 20 次挂载 / 2 次全库同步)的结果:

硬停瓶颈        Worker 请求/天(16.6% 已用)
忙日承受力      忙日系数 3× → 49.7%(✅ 忙日也在额度内)
💳 R2 超额账单 ≈ $0.03/月(存储超 2.21 GB)

「忙日系数」而不是「峰值 QPS」:100,000/天 是日总量额度,不是速率上限 (官方 limits 页:「There is no general limit on requests per second.」)。 所以这里乘的是「某天流量是平时的几倍」,与日额度同量纲,可以直接比。 相乘出来的百分比用的就是同一个判据 —— 上一行说「忙日会打满」、下一行说 「站点不会停摆」这种自相矛盾曾经真的出现过(只判均值不判忙日),现已修掉。

⚠️ 这个百分比只算「可预期的使用量」,不含爬虫 / 扫描器 / 客户端重试。 这不是谨慎,是踩过的坑:这个模型曾在显示 16% 的当天,站点真的打满了 10 万次并停摆到次日 08:00。

差距来自模型本身的三个错误,都已修掉(并写进代码注释,防止有人「优化」回去):

  1. 把 Cache API 的命中当成了省请求。它一次都不省(见上一节表格)。 模型里那个 cdPageViewHitRate: 0.8 参数已删除 —— 留着就会有人继续传一个 「假装能省请求」的数字,现在传它会直接报 未知参数 然后退出。
  2. 没算每次浏览附带的隐式请求。/favicon.ico 那条 404 是其中最普遍的一个 (现已由静态资源层消掉,所以 implicitPerPageView 现在是 0, 但参数保留着 —— 加任何「浏览器自动请求且必须过 Worker」的东西时都要加回它)。
  3. 压根没建模爬虫流量。这个无法猜(本机没有 CF 凭据,拿不到线上计数), 所以默认 0,并且在结论里显式标注「不含未建模流量」—— 宁可让人一眼看见这个空白,也不要再给一个虚假的安全感。

想估算真实占用,用 Cloudflare 控制台的请求量反推:--bots <次数/天>。


数据导入

把你的书放进 R2 的 library/ 前缀下,然后重建索引:

curl -X POST "https://你的域名/api/admin/reindex?prefix=library/" \
  -H "Authorization: Bearer $ADMIN_TOKEN"

返回 nextCursor 时,带上同一个 cursor 再调一次(R2 列表分页)。 扫完最后一个前缀才会重建分桶计数,所以中途查询目录树可能是空的。

但如果你是从 R2 直接导入的书,还要补一次下载名

对象键 books/<随机 id> 里没有书名也没有扩展名,浏览器只能靠对象上的 Content-Disposition 决定「下载下来叫什么」。这个头只在写入时生效, 而 R2 也不支持用查询参数临时覆盖它。所以在下载名功能上线之前就已经躺在桶里的书 需要重写一次元数据:

# 先预演:只统计不动对象(建议第一次都这么跑)
curl -X POST "https://你的域名/api/admin/backfill-disposition?dryRun=1" \
  -H "Authorization: Bearer $ADMIN_TOKEN"

# 确认数字对得上再真跑,每页 10 本
curl -X POST "https://你的域名/api/admin/backfill-disposition" \
  -H "Authorization: Bearer $ADMIN_TOKEN"

返回 nextAfter 时,用 after=<那个值> 继续调,直到 nextAfter 为 null。 逐本处理,所以可以反复跑(已经带对的会进 skipped,不会重复搬运字节)。 返回的 backfilled / skipped / missing / failed 四类数量分开计数: backfilled 是这次重写的本数,bytesRewritten 是这些对象的字节数之和 (每本都是一次 get + 一次 put,所以实际出入流量约为它的两倍 —— R2 没有「只改元数据」的接口,只能整对象重写,这是如实报告,不是估算)。 missing 是「D1 有记录、R2 里没对象」的数据不一致,值得单独看一眼。

每页上限是 16 本(limit 参数调大也会被 clamp):Workers Free 的子请求上限是 50/次,而一本最坏要 head + get + put 三个子请求。

其他管理接口:

curl -H "Authorization: Bearer $ADMIN_TOKEN" https://你的域名/api/admin/stats
curl -X POST "https://你的域名/api/admin/purge?path=/title/A/" -H "Authorization: Bearer $ADMIN_TOKEN"

stats 会返回 directoryCount —— 这个数字必须远低于 200,否则挂载一次的请求数会失控。


Phase 0 必须实测的事

r2-webdav 的 litmus 一致性测试只跑过 wrangler dev --local,没有证明线上边缘行为。 本仓库的测试跑在 workerd(miniflare)里,同样不是 Cloudflare 边缘。

所以部署到真实域名后,第一件事是跑这两个探针:

# ① DAV 一致性 + 边缘动词放行 + Expect: 100-continue
node scripts/dav-probe.mjs --url https://你的域名/dav/
#    再带口令跑一遍,才能把写路径也验证到(否则 LOCK/DELETE/MOVE 只会看到 401)
node scripts/dav-probe.mjs --url https://你的域名/dav/ --user x --pass "$WRITE_TOKEN"

# ② 请求体体积边界(100 MB 天花板在哪、超限时什么表现)
node scripts/size-probe.mjs --url https://你的域名/dav/ --dry-run   # 先看计划
node scripts/size-probe.mjs --url https://你的域名/dav/             # 再真跑

dav-probe.mjs 覆盖什么

分组 检查内容
动词放行 PROPFIND / PROPPATCH / MKCOL / COPY / MOVE / LOCK / UNLOCK / DELETE / PUT 是否真的到达 Worker。它会区分「源站按设计拒绝」和「边缘拦截」 —— 后者意味着整套方案要改
凭据路径 写动词现在需要写口令(见下)。不带口令时它们返回的 401 是自家 Worker 的凭据闸门(www-authenticate: Basic realm="webooks"),探针记为 INFO 并在结论区写「N 个写动词的凭据路径未验证」;带了口令仍 401 才算 FAIL(口令错)
Depth 语义 0 / 1 / 缺失 / infinity 四种情况的实际返回
协议声明 OPTIONS 的 DAV 头,以及「声明 Class 2 但没实现 LOCK」这类谎言
PROPFIND allprop / propname / 未知属性进 404 propstat / 空 body(对应 litmus props 套件)
写入读取 PUT 201、覆盖 204、HEAD 长度、GET 302 目标可下载、DELETE 后 404
编码 中文文件名与 href 的百分号编码
Expect: 100-continue 用原始套接字手写 HTTP/1.1 —— fetch 会自己处理掉这个握手。这正是 r2-webdav 的 litmus http 套件挂掉的点

退出码:有 FAIL 时为 1。「按设计拒绝」(如未带口令时 LOCK 返回 401)记为 INFO,不算失败。

不带 --pass 跑出来的「0 失败」不等于写路径已验证。 写动词(LOCK / DELETE / MOVE / 覆盖已有书)需要写口令 —— 不带凭据时它们只会返回 401,探针记 INFO 而不是 FAIL。所以结论区会额外打印一条 ⚠ N 个写动词的凭据路径未验证,并提醒:不带凭据 时探针清理也删不掉自己写进去的文件,__probe_* 会永久留在书库里(「清理」分组会 逐条列出残留文件名)。要真正验证写路径,带上写口令重跑;若带了口令仍 401,那才是 真失败(口令错),探针会报 ✘ 已提供凭据但被拒。

关于真正的 litmus

litmus 是 C 程序,Windows 上要 autotools + gcc。想跑权威套件的话用 Docker:

docker run --rm -it alpine:latest sh -c '
  apk add --no-cache build-base git curl-dev libxml2-dev neon-dev openssl-dev zlib-dev &&
  git clone --depth 1 https://github.com/notroj/litmus /litmus && cd /litmus &&
  ./autogen.sh && ./configure && make &&
  TESTS="basic props copymove" ./litmus https://你的域名/dav/ 用户名 密码'

✅ 本实现是 WebDAV Class 2(DAV: 1, 2),提供完整的 LOCK/UNLOCK —— 锁表存在 D1 里,不依赖 Durable Objects。locks 套件应当能通过大部分用例。 (曾有一处真实缺陷:LOCK 响应体输出了不成对的 <D:locktoken> 标签, 所有严格客户端都会解析失败。已修复,并由 test/xml-wellformed.test.ts 持续兜底。)

http 套件预计在 Expect: 100-continue 上超时(dav-probe 会先告诉你这一点)。

待核实清单

详见 docs/cloudflare-ebook-library-feasibility.md 第 8 节。 *.workers.dev 与自定义域的行为是否一致,目前只能靠你自己在两个域名上各跑一遍探针对比。


已知取舍(有意为之,不是遗漏)

项 现状 原因
MKCOL 一律拒绝(已映射的集合回 405,未映射回 403 + valid-resourcetype) 目录树是虚拟的,由 D1 推导,建了也不会出现在树里 假装 201 再让目录消失,比直接拒绝更糟:客户端会缓存「创建成功」这个事实,之后再也收不到正确的状态。收到 405 的客户端能正确理解为「已存在」并继续挂载
Depth 缺失按 1 处理 有意偏离 RFC 4918 规范要求按 infinity,那会在大目录上炸掉 CPU
PROPFIND 缓存有最长 300 秒延迟 新增的书最多 5 分钟后才出现在列表里 换取命中率。/api/admin/purge 或 /api/admin/rebucket 可手动失效
重命名书名会改变所在桶 书会从 /按书名/A/ 移到 /按书名/B/ 目录树是派生视图
PUT 不计算 SHA-256 sha256 存 null 流式计算哈希要缓冲整个 body,会撞 128 MB isolate 上限
分桶计数用全表重建 每次 PUT/DELETE 跑一次聚合 写入频率低时可接受。写量大时应改成 Queue + 增量计数
单桶超过 20,000 本 分页子目录被 PAGINATION_MAX=100 截断 Phase 1 应对超大桶做二级分桶(如按书名前两个字母)
PDF 用对象流压缩时判不出类型 退回按扩展名分类 PDF 1.5+ 可以把对象字典压进对象流,标记就看不到了。这类文件请在管理后台手动指定类型
服务端不扫描文件内容 只按扩展名放行 免费层没有可用的杀毒/沙箱。伪装成 .epub 的可执行文件会被接受,但浏览器只会下载不会执行
两个上传通道都没有人机验证 原先网页上传有 Turnstile,现已默认关闭 见下方「为什么关掉人机验证」。用「每日写入次数」节流缓解(减速带,非硬限制)

为什么关掉人机验证

网页上传原本有 Turnstile,现在默认关闭。三个理由:

1. 它防不住真正的匿名写入口。 整个 R2 写入路径(WebDAV PUT、COPY/MOVE、 删除)从来就没有人机验证 —— WebDAV 协议里根本没有验证码的位置。所以挡住的 只是网页上传,脚本用 rclone 直接 PUT 照样能灌。真正的防线一直是 guardWrite 节流 + 每日写配额(见 src/lib/throttle.ts), 两个入口共用。

2. 它的实际故障率很高,且症状难查。 Turnstile 要求站点域名在控制台逐一 登记(不支持通配符、不能带协议),漏登记就是 widget 一直转圈,报 Error 1101。浏览器控制台只有一条 [Report Only] 的 CSP 报告, 而真正的错误在 Cloudflare 那边 —— 表现为「网站上传坏了」却查不到原因。

3. 对开架书库是纯粹的负收益。 这个站点的定位是「免注册、开架、免审」, 拦住真实上传者的代价远大于防住脚本的收益。

想临时打开(担心被刷):

npx wrangler secret put UPLOAD_REQUIRE_TURNSTILE   # 值填 1

⚠️ 打开后 必须同时配好 TURNSTILE_SECRET,否则会 fail-closed (上传完全不可用)—— 这正是当初设计它的本意。 TURNSTILE_SITE_KEY 则要放在 vars 里(公钥,可以公开)。


测试

npm test          # 见末尾的「Test Files / Tests」两行
npm run typecheck
npm run check:dav  -- --url https://你的域名/dav/   # 线上一致性探针
npm run check:size -- --url https://你的域名/dav/   # 请求体体积边界

覆盖:路径解析与分桶(含中文拼音首字母)、分类体系与 rebucket 重算、 格式白名单(网页上传与 WebDAV 两条入口)、元数据推导、content-type 取舍、 XML 转义与编码、PROPFIND 请求体解析、Depth 语义、207 响应结构、 200 条硬上限与分页、缓存命中与写入即失效、PUT/GET/HEAD/DELETE 全流程、 未实现方法的明确拒绝、admin 鉴权、页面内联脚本的语法自检, 以及缓存安全(每个响应都必须显式声明 cache-control,敏感接口不许可缓存) 与静态资源层(public/ 的产物字节、_headers 规则、404-page + run_worker_first 必须覆盖全部 Worker 路由),还有一组下载路径不许倒退的守卫 (书卡必须直连 R2、必须保留 /dav 回退、三处写入路径 —— WebDAV PUT / COPY-MOVE / 网页上传 complete —— 与回填动作必须共用同一个 helper 带上 Content-Disposition, 详见 test/download-direct.test.ts)。

后两组值得一提:静态资源层跑在 Worker 之外,SELF.fetch() 完全看不到它 —— 所以它们不测运行时行为,而是在 Node 侧把 public/ 的真实文件内容读进来当夹具 (workerd 的 node:fs 是个空壳,读不到仓库文件),把配置与产物本身钉死。 理由很实际:这一层配错的后果是「线上整站 404,而 400+ 个测试全绿」。 下载路径那组同理:它断言的是源码文本本身(用 import ... ?raw 把 src/ 读进来), 因为「书卡改回 /dav」或「某条写入路径漏掉下载名」都不会让任何行为测试变红 —— 前者只是每次下载多花一次配额,后者只是下载下来文件名难看,两个都很难在运行时发现。

线上另有几套探针(见 scripts/):

node scripts/webdav-litmus.mjs  --url https://你的域名/dav/ --token $WRITE_TOKEN  # 16/16
node scripts/rfc-probe.mjs      --url https://你的域名/dav/ --token $WRITE_TOKEN  # 14/14
node scripts/security-probe.mjs --url https://你的域名                            # 33/33
node scripts/abuse-probe.mjs    --url https://你的域名/dav --token $WRITE_TOKEN   # 22 项
node scripts/verify-upload-chain.mjs --site https://你的域名                       # 上传链路 4 项

探针会创建临时文件并自行删除。破坏性操作现在需要口令,所以必须带 --token, 否则临时文件删不掉,会在书库里堆积。


相关文档

About

Cloudflare Workers Free + R2 上的 WebDAV 电子书库:设计取舍全部围绕每天 10 万请求的免费额度

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages