阿鹏云盘 · 功能演进图鉴

worker.js —— 从「上游原版」到最新版,新增了什么、修了什么、每个坑是谁埋的。
时间跨度 2026-08-28 → 2026-09-27(31 天)· 53 份版本副本 · 最近更新 2026-09-27

代码行数
12,598 行
原版 7,187 · +75.3%
源文件体积
716 KB
原版 280 KB · +155%
API 路由
61 个
原版 38 · +23
页面路由
12 个
原版 5 · +7
函数声明
465 个
原版 286 · +179
部署产物
725 KiB
gzip 253.3 KiB ≈ 8.3% 额度
原版 96.7% 的代码一字未改。改动形态是「在原有骨架上逐层叠加」,不是重写 —— 所以上游出修复时相对容易手工合回来; 代价是新增代码必须顺着原版的写法(比如原版不用模板字符串的地方新增代码也别乱用),否则风格分裂、更容易踩转义坑。
读这份图鉴的诀窍:每个「坑」都有主人 —— 🔴 原版自带(上游代码本来就有的缺陷)、 🟠 本项目引入(我们改出来的)、🟢 设计取舍(有意为之的代价)、🔵 外部环境(系统 / 库 / 浏览器行为)。 时间线条目点「查看详情」才展开,根因单独一条,想学习时再翻。先看根因分布 →

一、功能总表

原版 → 2026-09-27 · 共 0 项
引入类型功能 / 改动说明
08-28新增回收站(软删除)trash: 前缀原版删除=永久消失。删前把整棵子树快照到 trash:,R2 实体不立刻删;恢复=反向搬回
08-28新增访客协作 /shared 可收件把只读分享变成「可收作业」:口令验证 + 直传 + 分片 + 建文件夹 + 配额
08-28加固节点 API 密钥回退去掉「没配 token 就用管理员密码」这条密码泄漏通道,改为直接关闭节点 API
08-28加固SSRF 防护存储节点 URL 原可填内网地址(如 169.254.169.254)→ 只允许公网 http/https
08-28加固登录爆破限流登录接口无限速 → 可跑字典。加失败计数 + 锁定时长
08-28修复孤儿分片清理误删清理任务会把正在上传中的分片当垃圾删掉 → 加「进行中」判定
08-28移除全局页脚原作者写在全局外壳里 → 登录/主页/分享页全带着。只保留 /shared 页底部一行免责声明
08-30新增PWA 支持manifest + Service Worker + 多尺寸图标,可「添加到主屏幕」全屏打开
09-01修复分片上传断点续传原版断网=从头再来。resumeKey 找回会话,新增 /api/multipart/status
09-05修复401 守卫白名单原版 bug:/s/xxx 与 /shared 也被踢去登录页 → 分享功能等于失效
09-06新增版本历史覆盖前把旧实体快照成新 R2 key,一文件一条版本索引。与回收站构成「双保险」
09-08新增在线预览五件套PDF / Word / Excel / PPT / 音视频。单版体量从 388 KB 跳到 608 KB,提升最大的一块
09-08新增可折叠侧边栏原版只有顶部应用栏,窄屏/手机内容挤成一团;移动端默认收起
09-08界面页面内弹窗替代原生15 处 alert/confirm/prompt 换成 Material 风格弹窗
09-09优化回收站列表并发读Promise.allSettled 并发化,治「回收站点开要等很久」
09-10优化目录改名 / 移动批量化原版最严重缺陷:O(N²) 子请求超限被强杀 → 留下新旧两份重复索引。子请求 ≈500 → ≈7
09-10修复云端改名一条龙把「三步操作」压成双击一次;兼容「云端已改过名」的识别 + 跳过 + 只补本地
09-11修复list-all 的 modified 取值用错字段 → 改名会把整棵树判「云端较新」→ 同步重下约 500 MB。改为内容上传时间优先
09-12修复孤儿清理漏扫(严重)只扫文件前缀 → 点「清理孤儿」会把回收站 + 所有历史版本判为孤儿,勾选即永久丢失
09-12优化回收站 N+1 → 集合查询listRaw() 一条 SQL 取回全部键值:89 条实测 0.95 ms vs 旧写法 ≈18 ms
09-12新增回收站「清理 N 天前」手动按钮而非 cron:量级小、可见可控,避免定时任务误删
09-12修复PWA 图标溢出真根因是 manifest 的 purpose:'maskable' 一行 → 删掉即好
09-14修复全局搜索(三轮大修)修 formatDate is not defined、修匹配数翻倍、结果含文件夹、输入即搜、面板可滚动
09-14新增搜索只按名称匹配刻意取舍:结果更精准(「必修一」不再被整棵子树淹没),代价是命中文件夹名的文件搜不到
09-15新增存储用量口径改造口径换成「存储桶物理占用」+ 占用构成 + 月均快照,一眼看出会不会超 R2 免费额度
09-17加固预览库同源托管5 个库原全走 jsdelivr(可用性 + 供应链双风险)→ 挪到 R2 system/lib/,CSP 得以收紧为纯 'self'
09-17加固Office 预览 XSS 清洗全站唯一真正的「数据派生 XSS」入口(Office 解析结果直接 innerHTML,输入是外来文件)→ 白名单清洗
09-17加固长期会话(删掉存密码)原来把密码 base64 存 localStorage = 明文。改为服务端签 30 天令牌 session:v3
09-17加固安全响应头 + CSPhtmlResponse() 收口全部 9 处 HTML 出口;不能被 iframe 嵌套 / 不能被 object 借道
09-17加固分享令牌密钥收紧原来拿公开的 share.id 当签名密钥 → 可伪造任意分享访问令牌
09-17修复大文件没有版本历史>512 KB 的文件优先走分布式,而分布式路径没有快照调用点 → 版本列表恒为空(教学 PPT 全在这区间)
09-17修复手机换图「读取失败」三条路(FileReader / arrayBuffer / objectURL)→ 两条路兜底 + 按字节魔法数判型 + 失败三分诊
09-17修复用量分类瑕疵system/lib/ 3.4 MB 原被算进「云盘文件」→ 并入站点资源(总量不变,只是分类修正)
09-18新增「从云盘选图」安卓安装版 PWA 读不到选图器的 content:// 引用 → 绕开选图器,直接从云盘取字节
09-18界面失败提示改「先给结论」不再摆出两个猜想让用户挑,直接给唯一动作「请改用『从云盘选图』」
09-19界面手机列表时间列可见旧表格自动布局超宽 → 时间列与操作列被挤到屏外。窄屏改固定布局 + 时间双格式
09-19界面共享文件夹改名 shared → 共享存储名跟着改,公开网址 /shared 永不改(发给学生的入口)
09-20界面表头「时间」竖排图标字体 flex-shrink:0 逼文字让步 → 文字锁死、让箭头让步
09-22界面回收站窄屏对齐主界面错位套用了主界面的 nth-child 列序规则 → 改用更高特异性选择器逐列纠正
09-22新增回收站「时间 ⇄ 大小」切换360px 放不下四列 → 表头加 ↔ 小按钮,箭头永远指向「点了会变成什么」
09-22界面641–720px 固定布局真凶是共享的 .file-row-name{max-width:400px} 把名称列 min-content 钉在 458px
09-22界面主列表 641–1110px 固定布局主网盘/共享/分享三张表同被钉住 → 溢出又被 overflow-x:hidden 静默裁掉
09-22界面侧栏抽屉断点 768 → 1000px治「769–1000px 名称列只剩 4 个字」;顺手修掉抽屉遮罩残留挡住整页的老 bug
09-22界面回收站 721–1110px 固定布局auto 布局把整页撑宽,右端被视口裁掉(时间显示成 2026/09/2)
09-26新增上传进度显示实时速度EMA 0.65/0.35 平滑,接入 5 处上传;顺手合并下载端内联的同一算法
09-26新增分享链接自定义后缀可起一个好记的后缀;没照抄上游——本项目 KV 是 upsert,没有原子性,另写了 putIfAbsent()
09-26新增复制下载直链免密文件直接给出直链(/api/share-download 路由早就存在,只是一直没暴露给用户)
09-26新增文本文件在线编辑新增 /api/text-file:ETag + If-Match 乐观锁(428 / 409 / 413),写回前留版本回滚点
09-27修复版本历史弹窗打不开浏览器端调用了服务端专属函数 → 红字「加载失败」。从 09-06 上线起坏了 21 天,第一次真点开才暴露
09-17移除版本更新检查页面加载时去 GitHub 拉 version.json → 国内常被墙,拖慢首屏。删掉后每页少一个外部请求
09-17移除下载 inline 死参数全项目搜不到任何 inline=1 生成点 → 删除后下载恒 attachment
09-26放弃上游 Hollow 毛玻璃皮肤 + 自定义壁纸上游把接口 GET 放在鉴权之前(猜到路径就能拉到壁纸);皮肤还会覆盖已调好的四套布局
09-26暂缓分享管理面板 / 分享页预览 / 全选勾选不是不做,是排在三件高感知功能之后。其中的「请求竞态序列号」值得单独抄

二、重要更新(按时间倒序,最新在上)

概述先看 · 详情与根因点开再看
2026-09-27 修复Version f96b08e8 本项目引入

版本历史弹窗永远打不开(jsAttr is not defined)

点网页端「历史版本」→ 弹红字「加载失败:jsAttr is not defined」。而这条路从上线起就一直是坏的。

藏了21 天 同类与 09-14 搜索 bug 同一个模式 验证jsdom 真点按钮 15/15 顺带中文路径恢复 404 一并修
实现细节 & 踩过的坑6 条
  • 为什么藏了 21 天:没有历史版本时函数会提前 return,只有真存在历史版本才会走到那一行。「一直没用过」→ 第一次点开才暴露。而 node --check、部署前自检、产物校验全都抓不到(语法合法、特征字符串也还在)。
  • 顺带揪出第二个 bug:原来传 encodeURIComponent(path),而服务端 /api/versions/restore 拿到的是原样字符串(不 decode)→ 即使不报错,中文路径恢复必然 404。
  • 修法:浏览器端自备 attrJs()(只做 HTML 实体转义,别在它里面再包引号——包了点击只求值不调用);参数改用 JSON.stringify() 生成。两处一起修。
  • 加防线:worker部署前自检.mjs 新增第 4 项「内联脚本误用服务端函数」,判据自动推导,可反向对照(临时副本退回旧写法 → 精准报出 jsAttr();正常源码 0 误报)。
  • 验证:jsdom 真构建 → 真跑页面 → 真点一次「恢复」 → 断言真的发出 /api/versions/restore,15/15 全过。
  • 教训:只验 new Function(onclick) 语法会假绿 —— 多包一层引号也能过。是真点击那条断言把问题揪出来的。
根因溯源:谁埋的这个坑
本项目引入
loadVersions() 是 09-06 我们自己写版本历史时创建的前端函数,它跑在浏览器里,却直接调用了只存在于服务端的 jsAttr() / jsString()。 原版没有版本历史功能(实测原版 grep snapshotVersion / r2drive:version 命中数均为 0),所以这个坑 100% 是我们自己埋的。
模式警告
这不是孤例 —— 09-14 全局搜索的 formatDate is not defined 是同一个病:浏览器端代码引用了服务端函数。 本项目里 escapeHtml / formatDate / formatSize 各有两份(服务端 + 浏览器端),删掉任何一份都会出事。现在自检里那条新检查就是为了拦这一类。
2026-09-26 新增Version 678aec3c 上游方案不适用

上游 v1.2.1 三项移植:上传网速 · 分享后缀 · 文本在线编辑

上游 09-15 发布 v1.2.1(跳过 1.2.0,共 102 个差异块 / +689 −61 行)。挑了三件用户可感知度最高的落地。

接入5 处上传路径 顺带下载 EMA 合并 −6 行 没照抄上游的原子占位写法 比上游多写回前留版本回滚点
实现细节 & 踩过的坑8 条
  • ① 上传进度显示实时速度 —— 新增 formatTransferStats() + trackTransferSpeed()(EMA 0.65 / 0.35,与本项目下载端系数一模一样)。接入 5 处:直传 / R2 原生分片 / 分布式 / 共享文件夹直传 / 共享分片(后两处是本项目自有的,上游只有前三处)。顺手把下载里内联的手算 EMA 也合并进同一函数,−6 行。
  • 合并下载时的两条纪律(别踩):下载端原本阈值是 0.4s、完成时还有一次强制刷新 → ①不要把阈值统一成 0.35(数值会更跳);②不要丢掉「完成强制刷新」。两条都保住 = 行为完全等价。
  • ② 分享链接自定义后缀 —— 正则从 32 位 hex 放宽到 1–64 位小写字母/数字/下划线/短横线,老链接全部仍合法。规则:创建留空 = 随机后缀;编辑留空 = 保持原后缀(不会因为清空输入框被悄悄改成随机)。
  • 🔴 没照抄上游:上游用 DB.prepare(INSERT) 靠主键冲突做原子占位;但本项目写 KV 走 ON CONFLICT DO UPDATE 的 upsert,根本没有原子性,照抄就是「看起来有锁、实际没锁」。改为新增 putIfAbsent() —— 不带 ON CONFLICT,只把 UNIQUE / 主键冲突翻译成 false,其余错误照抛(不吞真错误)。
  • 复制下载直链 —— publicShare 加 downloadUrl(仅「文件 + 无密码」时给)。该路由本来就存在,只是从没暴露给用户。
  • ③ 文本文件在线编辑 —— 新增 /api/text-file(鉴权之后):30+ 扩展名白名单;>2 MB → 413;分布式存储直接拒。ETag + If-Match 乐观锁:缺锁 428、锁不匹配 409「文件已被其他操作修改,请重新打开后再保存」。
  • 比上游多一步:写回前先 snapshotVersion() 留版本回滚点 —— 上游是直接覆盖,等于网页上改的文件没有版本记录。前端零第三方库:底层 <pre> 画高亮 + 上层透明 <textarea> 接管输入,滚动同步;Ctrl+S 保存。
  • 客户端同步:client/src/server.mjs 的 share-create 是逐字段白名单转发(只转 path/ttlSeconds/maxAccesses/password),新字段必须登记,否则被静默丢掉 → 补 suffix,打包 6.9.27。
根因溯源:上游参考,但不能照抄
上游方案不适用
原子占位:上游靠 D1 主键冲突保证「并发抢同一后缀不会双双成功」。本项目的历史包袱是 —— KV 层用 ON CONFLICT DO UPDATE(upsert),天然没有原子性。 照抄的结果不是「多此一举」,而是「看起来有锁、实际没锁」,比没有还危险(会让人以为并发已经处理好了)。
折中做法
自建 putIfAbsent():不带 ON CONFLICT,把 UNIQUE / 主键冲突翻译成 false 返回,其余错误照抛(不吞真错误)。要点是只吞「键已存在」这一种错。
外部环境
renderHTML 是模板字面量 → 在它内部写正则必须双写反斜杠。其中 \/ 被吃掉会报语法错(能被自检抓到),但 \b 被吃掉是完全静默的(变成字母 b,语法合法、功能失效)。这个坑后续每次往模板里加正则都会重现。
2026-09-22 布局4 次部署 本项目引入

中间档布局四连修:从「表格溢出」到「整页被撑宽」

用户拖动窗口逐个发现:回收站很乱 → 时间列被裁成 2026/09/2 → 名称列只剩 4 个字。四轮排查都在治同一个根因家族。

真凶名称列 min-content 恒 458px 改后769px 档名称列 134 → 437px 手段table-layout:fixed 顺手修掉抽屉遮罩残留老 bug
实现细节 & 踩过的坑10 条
  • 共同真凶:主界面共享的 .file-row-name{max-width:400px} —— auto 布局下表格宽 = 各列 min-content 之和,而名称列的 min-content 恰是「400 文字 + 图标 + 间距 + 内边距 = 458px」(实测各档都是 458,纹丝不动)。合计 ≈822px > 容器 → 溢出。
  • 修法:table-layout: fixed 让内容 intrinsic 宽度彻底退出计算,表格恒等于 100% 容器宽、与文件名长短无关。
  • 回收站窄屏:错位套用了主界面的 nth-child 列序规则(主界面是「名称/大小/时间/操作」,回收站是「勾选/名称/大小/时间」)→ 不换类,改用更高特异性的 .file-list.trash-list ... 逐列纠正。
  • 让哪一列:360px 下四列放不下 → 让「大小」不让「名称」(回收站里「多大」最不重要,名称宽度是上一轮刚调好的)。改完名称文字区反而从 212 涨到 232–284px。
  • 🔴 坑 1:切换按钮不能放在会被隐藏的列里 —— 第一版放 th4,切到「大小」后按钮跟着被隐藏,再也切不回来。改成「换第 4 列自己的内容和宽度」。
  • 🔴 坑 2:.material-icons-round 图标字体没就绪时会退化成英文单词,一口吃掉 163px(名称区只剩 85px)→ 补 width:1em。
  • 🔴 坑 3:量 flex 的视觉顺序一律看坐标(getBoundingClientRect().left),别看 DOM 顺序 —— 按 DOM 顺序判会得到假阴性。
  • 侧栏断点 768 → 1000px:769px 档名称列只有 134px(4 个字),根因是常驻侧栏吃掉 256px。断点值是算出来的:256+64+300+名称列 330 ≈ 950,取整 1000。改后 769 档名称列 134 → 437px。
  • 顺手修掉一个老 bug:抽屉遮罩 .sidebar-backdrop.open 是全局规则 → 在窄屏打开抽屉、再把窗口拉宽,侧栏回归常驻但遮罩留着,整页点不动。加 @media(min-width:1001px){display:none!important} 兜底。
  • 验证方式:真跑 worker.fetch()(内存 KV stub + HMAC 自签 session:v3 令牌)拿真实 HTML → iframe 精确模拟各档 CSS 宽度量列宽,并出真渲染截图目视(列宽达标 ≠ 不丑)。
根因溯源:谁埋的这个坑
本项目引入
.file-row-name{max-width:400px} 是我们自己早期写的 CSS;.sidebar-backdrop.open 也是我们加可折叠侧边栏时写的全局规则(原版只有顶部应用栏,没有侧边栏)。两条都是原版没有的东西。
本项目引入
回收站错位复用主界面 nth-child 列序 —— 属于「复用 class 时没核对列语义」。同类问题在 09-19 手机端布局已经踩过一次(切换控件放进了会被隐藏的列)。
外部环境
.material-icons-round 的「字体没就绪时退化成英文单词」是图标字体的固有行为(Google Material Icons 用连字,字体未加载时 keyboard_arrow_down 就是一个 18 字符的英文单词)。代码层面只能靠 width:1em 限制它别撑开布局,不能不加载。
回收站 641px 改前
回收站 641px · 改前:大小列只有 57px,「8.42 MB」折两行
回收站 641px 改后
回收站 641px · 改后:固定布局,大小单行,零溢出
回收站 721px 改前
回收站 721px · 改前:时间被裁成 2026/09/2,整页被撑宽 42px
回收站 721px 改后
回收站 721px · 改后:时间列 170px 单行,容器宽 = 视口
主列表 769px 改前
主列表 769px · 改前:侧栏常驻 256px,名称列仅 134px(「变更日志....」)
主列表 769px 改后
主列表 769px · 改后:侧栏变抽屉,名称列 437px,文件名完整
主列表 641px 改前
主列表 641px · 改前:日期列被裁,右侧静默消失
主列表 900px 改后
主列表 900px · 改后:时间列 96px 单行、行高整齐
2026-09-22 新增 设计取舍

回收站窄屏「删除时间 ⇄ 大小」一键切换

「左侧加了复选框,右边的时间没有了……既然没办法同时显示两种,能不能点一下『时间』就变成『大小』?」

约束360px 放不下四列 选型表头加图标,热区留给排序 有意接受切换态不持久
实现细节 & 已知代价4 条
  • 交互选型:三个方案(点表头文字切 / 表头加 ⇄ 图标切 / 不做切换),用户选加图标 —— 把「表头文字」这块热区留给将来的排序。
  • 箭头方向:切换态把 ↔ 用 order:-1 挪到文字左边 —— 默认「时间 ↔」、点后「↔ 大小」,箭头永远指向「点了会变成什么」。
  • 独立性(用户最关心的三点):电脑端 / 手机横屏不受影响(规则全在 @media(max-width:640px) 内,宽屏 CSS 没有任何规则引用切换 class);与排序独立;与主界面、共享页独立(class 只加在回收站这一个表格上)。
  • 已知代价:切换状态不持久 —— 刷新页面或恢复/删除文件后会回到默认「时间」列。
根因溯源:不是 bug,是有意的取舍
设计取舍
不持久是有意为之:要让切换态记住,就得往 config / localStorage 里写一个新状态键,并处理「刚恢复完文件、列表重渲染」时该不该保留。收益(少点一次)远小于成本(多一个状态源、多一类「状态不一致」的 bug)。 代价已明确告诉用户,这就够了 —— 判断标准是「用户知道边界在哪」,而不是「功能更完整」。
默认时间态
默认态:表头「时间 ↔」,箭头在文字右侧
切换到大小态
切换态:表头「↔ 大小」,箭头移到左侧,同一列换成文件大小
2026-09-20 修复Version 1237314d 本项目引入

手机表头「时间」两个字变成竖排

「1080P 显示刚刚好,只是『时间』两个字变成竖排」→ 第三版把箭头隐藏了 → 用户:「箭头看不见就没人知道能点它排序」。

根因图标字体自带 flex-shrink:0 为什么竖排CJK min-content 只有 1 个字宽 修法谁能让步分清楚,别只加宽列
实现细节 & 验证方式3 条
  • 根因:表头是 flex,而 .material-icons-round 自带 flex-shrink:0(图标压不动)→ 空间不足时被压缩的必然是文字;48px 列宽内容盒只剩 40px,而 CJK 的 min-content 只有 1 个汉字宽 → 折成一半一个字 = 竖排。
  • 修法不是「把列加宽到装得下」,而是先分清谁能让步:文字绝不能收缩(否则就竖排)→ 文字 flex:0 0 auto; white-space:nowrap 锁死,要压就压箭头(flex-shrink:1; min-width:0)。
  • 验证:把表头字号放大到 16 / 18px 模拟手机系统字体缩放、把箭头放大到 40px 模拟极端情况 —— 三档 × 三情形「时间」全部单行 24×16px、箭头可见。
根因溯源:谁埋的这个坑
外部环境
病灶链的起点是图标字体自带 flex-shrink:0 —— 这是 Google Material Icons 的设计(图标不该被压扁)。在 flex 布局里它意味着「文字永远是先被牺牲的一方」,这是一个跨项目的通用陷阱。
本项目引入
而「文字被压到竖排」之所以发生,是因为我们早期替换图标时只加了 class、没加 width:1em,也没给文字加 flex:0 0 auto。 原版用的是同一套图标字体(material-icons 走 jsdelivr),但原版窄屏布局更简单(没有我们加的多列固定布局),所以没触发。
2026-09-19 新增Version 5f0c0256 原版自带

手机端文件列表:时间列看得见、能排序

手机上文件列表只见名称/大小两列 —— 旧表格自动布局下「文件名不换行 + 全格式时间 + 操作按钮」总宽超出视口,时间列和操作列被挤到屏幕右边界外。要看时间只能切浏览器「电脑模式」。

认知坑1080p 屏 CSS 视口只有 360px 范围三张表改 fixed 手段时间单元格渲染双格式 span 结论切换控件不能放会被隐藏的列
实现细节 & 第一版为什么翻车3 条
  • 怎么改:窄屏三个表格(主网盘 / 共享访客页 / 分享目录页)改 table-layout:fixed(名称自适应 / 大小 58px / 时间 54px / 操作列隐藏);时间单元格渲染双格式 span(全格式 + 短格式),窄屏切短格式并允许折两行;表头「修改时间」双 span,排序箭头保留。
  • 🔴 第一版翻车:没按真实手机 CSS 宽度算 —— 1080p 屏 DPR 3 → CSS 视口只有 360px,不是 1080px。首版固定列宽后用户反馈「文件名只剩 6 个字、右边一片空白」(空白=被隐藏的操作列仍在占位)。
  • 结论:切换控件绝不能放在会被隐藏的列;量行数要用 Range(td.getClientRects() 恒为 1,会假绿)。
根因溯源:谁埋的这个坑
原版自带
表格没有任何 table-layout 声明 —— 原版三张表全部走 auto 布局,宽度由内容决定,内容一多就必然溢出。原版没有移动端列宽策略,因为它本来就不考虑窄屏(也没有可折叠侧边栏)。 这是「能力缺失」而非「写错的代码」,但后果一样:时间列在手机上根本不存在。
本项目引入
第一版翻车是我们自己的算错:把「1080p 屏」直接当成了 1080 CSS px。DPR 3 时真实视口只有 360px。 这类错误没有语法提示、不会报错,只有出真机截图才能发现。
2026-09-19 界面Version e31c8665 设计取舍

共享文件夹改名 shared → 共享

用户根目录是「工作 / 麦芽糖 / 软件 / 生活 / 探索 / 同步」,就它一个英文,不协调。

红线公开网址 /shared 永不改 关键存储名与网址是两个变量 顺手发现浏览器端不能引用服务端 const
实现细节 & 关联陷阱3 条
  • 🔴 公开网址永不跟着改:页面路由、401 白名单、sharedBase = '/shared' 全部保持 /shared —— 发给学生的那个入口地址不能变。存储名(SHARED_PREFIX)与公开网址本来就是两个变量,这次只动前者。
  • 顺手发现:浏览器端脚本不能引用服务端 const(静默 ReferenceError)→ 必须在模板里用 ${jsString(SHARED_PREFIX)} 注入字面量。
  • 收尾:常量改完,云端那个空的 shared 文件夹就成了普通文件夹 → 用 /api/rename 把它改名(实测该文件夹当时完全为空,影响为 0)。
根因溯源:这不是 bug,是必须守住的边界
设计取舍
「显示名」与「路径名」必须解耦。存储前缀(SHARED_PREFIX)可以随中文习惯改,但对外的 URL 前缀 /shared 一旦发出去就是承诺 —— 学生收藏夹里、群里发过的链接都指向它。 这条约束后来写进了项目记忆,属于不可回退的红线。
本项目引入
「浏览器端不能引用服务端 const」这个坑和 09-27 的 jsAttr is not defined、09-14 的 formatDate is not defined 是同一家族:模板里写的东西,服务端求值 ⟺ 浏览器求值,边界很容易混。
2026-09-18 新增Version 46aa979e 外部环境

「从云盘选图」—— 绕开手机选图器

前两轮修复都失败后,用户给出决定性线索:电脑能设、手机浏览器能设、单单装了 PWA 的不能设。

系统级不是代码能绕的 解法服务端零改动 决定线索「只有装 PWA 的不能」
实现细节 & 为什么绕不过去4 条
  • 真根因:安卓安装版 PWA 跑在独立进程里,<input type=file> 选完图得到的是 content:// 引用,PWA 进程没有读它的权限 → size=0、arrayBuffer() 抛 NotFoundError。FileReader / arrayBuffer / createObjectURL 三条路全断在同一个权限上 → 不是代码能绕的,只能绕开选图器本身。
  • 怎么改:新增「从云盘选图」按钮(图标行、背景行各一个)—— 弹窗用 /api/list 列当前目录(可进文件夹、可回上一级、有面包屑),只列图片;点图后走同源 fetch('/api/download') 拿字节(PWA 里完全正常)→ 复用 canvas 缩图 → 上传。服务端零改动。
  • 收尾(同日):失败提示语改「先给结论、先给出口」—— 不再摆出「①云相册云端图 / ②PWA 权限」两个猜想让用户挑,直接给唯一动作「请改用『从云盘选图』」;catch 前缀由「读取失败:」改为「出错:」。
  • 说明:不是 PWA 的 bug 修好了,而是给了绕行方案 —— 系统级权限问题前端改不动。
根因溯源:代码层面绕不过去的那一类
外部环境
安卓对 content:// URI 的跨进程授权是系统行为。PWA 安装后运行在独立的安全上下文里,选图器(另一个进程)返回的 URI 授权不会传递给它 —— 这是设计如此,不是 bug。 三条读取路径失败在同一个权限检查上,所以「换一种读法再试」注定无效。
排障方法论
真正解决问题的是用户那句对照实验:「电脑能 / 手机浏览器能 / 装了 PWA 的不能」。三个场景里唯一的变量是「进程模型」→ 直接锁定系统权限。 比读十遍代码都有效。这类「对照组」信息值得在提 bug 时主动给出。
2026-09-17 晚 修复Version a6f58672 本项目引入

手机 PWA 换背景图「读取失败」(两轮)

手机里「设置 → 外观 → 更换登录页背景图」,选完照片提示「读取失败」;电脑上同样操作正常。

真根因信 file.type 标签、不信真实字节 修法按字节魔法数认格式 附带失败从 1 句话改成三分诊 验证CDP 驱动真 Chrome 20/20
实现细节 & 两轮排查4 条
  • 第一轮:uploadSiteAsset() 改成两条路 —— ① 原样读(≤3 MB 且 MIME 在白名单,桌面一直走这条,保真、保留 PNG 透明);② URL.createObjectURL → <img> → canvas 重编码(<img> 走浏览器自己的加载器,云相册的图也能拿到),顺带等比缩到 1920 / 512。
  • 第二轮真根因:旧代码信 file.type 标签、不信真实字节 —— 手机选图器经常给空标签/怪标签,好好的 JPG 被当成「不认识的格式」赶去走重编码路;而 img.onerror 把三种完全不同的病因全塞进同一句「可能是 HEIC」,没法排障。
  • 定稿修法:sniffImageType() 按字节魔法数认格式(JPEG/PNG/GIF/WebP + ftyp 盒品牌判 HEIC/AVIF),file.type 只做兜底;diagnoseAssetFailure() 三分诊:字节读不出 → 「云相册图没下载到本机,先点开原图/存本地」;真 HEIC/AVIF → 「导出 JPEG」;字节正常也认得 → 「渲染失败,换 Chrome/Edge」。
  • 验证:CDP 驱动真实 Chrome 20/20(空标签 JPG 走原样路、怪标签 PNG 按字节判型、HEIC 给精准提示且不发起上传、引用图给「点开原图」提示、11.6 MB 噪声大图走压缩路)。
根因溯源:谁埋的这个坑
本项目引入
uploadSiteAsset() 是我们自己写的站点资源上传(原版没有「登录页背景图 / 站点图标上传」这套功能)。旧实现里那个「白名单判型」是我们写的,也是我们把三种病因塞进同一句提示的。
外部环境
手机选图器给空 MIME 或错误 MIME 是常态(不同厂商实现不一)。file.type 在 Web 规范里本来就只是「浏览器猜测」,从来不是可靠信息。要判类型只能读字节。
方法论教训
第一轮「加一条兜底路」是治标 —— 真正解决靠的是不再相信可能错的输入(改读字节)+ 把失败原因拆开报。这两条后来都成了本项目的习惯写法。
2026-09-17 安全加固Version e01d5550 原版 + 本项目

外部审计 7 项落地:库同源 + CSP 收紧 + XSS 清洗 + 长期会话

外部审计给出问题清单,复核后落地 7 项。其中一半是原版历史遗留,另一半是我们自己加功能时带进来的。

原版share.id 当签名密钥(公开字符串) 本项目预览 5 库全走 CDN 唯一数据派生 XSS 面 边界CSP 必须留 unsafe-inline
实现细节 & 复核更正8 条
  • 预览库同源托管:原来 5 个库 6 个 URL 全走 cdn.jsdelivr.net —— 可用性风险(被墙/抽风 → 预览全废)+ 供应链风险(第三方脚本可在你的页面上执行)。改为 R2 同源托管 system/lib/ + 白名单只读路由 /assets/lib/,CSP 得以收紧为纯 'self'。
  • 为什么不能「内嵌进 worker.js」(审计的原建议):5 库 ≈3.0 MB、base64 后 ≈4.0 MB,加上当时的 629 KB → ≈4.7 MB 源码,而 Cloudflare 免费版按压缩后 3 MB 限制;更硬的理由是部署方式是「浏览器里粘贴文本」,4.7 MB 没法维护。
  • Office 预览 XSS 清洗:sanitizeDocHtml() 标签/属性白名单 + 删所有 on* + 去 url()/expression() + 协议白名单挡 javascript:。这是全站唯一真正的「数据派生 XSS」入口 —— 站内其余 ~58 处 innerHTML 要么写死字符串、要么已转义。
  • 「记住密码」→ 30 天长期会话:原来把密码 btoa(encodeURIComponent(pwd)) 存 localStorage —— base64 不是加密,等于明文。用户要的是「少输密码」不是「把密码存下来」→ 服务端签 session:v3 令牌,密码永不落地;兼容 v2 避免已登录用户被踢。
  • 安全响应头 + CSP:htmlResponse() 收口全部 9 处 HTML 出口(避免「加了 8 个漏了 1 个」);HSTS 不带 includeSubDomains;下载恒 attachment + nosniff。
  • 分享令牌密钥收紧:原来 shareTokenSecret() 会回退用 passwordHash 甚至 share.id —— 而 share.id 是公开字符串(就出现在分享链接里)→ 可伪造任意分享访问令牌。改为只从真正的密钥类环境变量取,取不到就不签(fail-closed)。
  • 🔴 边界必须理解:script-src 必须保留 'unsafe-inline'(页面到处是 onclick)→ CSP 拦不住「已注入的内联脚本」。CSP 的价值在 frame-ancestors / object-src / base-uri / 限外域;防 XSS 只能靠清洗。任何「加了 CSP 就安全了」的想法都是错的。
  • 复核更正:审计说「inline=1 让访客上传的 HTML 被内联渲染」→ 复核结论是死参数(全项目搜不到任何 inline=1 生成点),严重性被高估,但顺手删掉仍是正确清理。
根因溯源:一半是上游的,一半是我们的
原版自带
分享令牌密钥回退:原版源码原文就是 return env.ACCESS_PASSWORD || env.STORAGE_NODE_TOKEN || share.passwordHash || share.id; —— 最后那个兜底用的是公开字符串 share.id(它本身就在分享链接 /s/<id> 里)。任何人都能拿到它,于是任何人都能伪造分享访问令牌。这是上游的设计缺陷。
本项目引入
预览库走 CDN:实测原版只有 1 处外部 CDN(cdn.jsdelivr.net/npm/material-icons 字体),那 5 个预览库(pdf.js / mammoth / SheetJS / JSZip / 播放器)是我们 09-08 加预览功能时自己引进来的。
「记住密码」存 base64:原版 localStorage 只存 viewMode 和 theme,不存任何凭据 —— 把密码写进 localStorage 是我们的实现选择。
本项目引入
唯一的数据派生 XSS 面:Office 预览是 09-08 我们加的功能,「把解析结果直接 innerHTML」的写法也是我们写的。 反过来说:这也解释了为什么全站只有这么一个真 XSS 入口 —— 因为其余 ~58 处 innerHTML 都在原版代码里写死了字符串。
2026-09-17 修复 本项目引入

大文件(>512 KB)根本没有版本历史

核实「大文件无版本历史」缺口后如实记账,当天修掉。教学 PPT 基本都在这个区间 —— 该功能对主要使用场景是空转的(面板能开、能列,但永远没内容)。

空转范围>512 KB 全部 不是加一行修了 4 处联动 档位20 → 60 MB · 10 → 5 版 线上实测10/10(含 sha256 逐字节比对)
实现细节 & 病灶链5 条
  • 病灶链:snapshotVersion() 原来只有两个调用点(/api/upload 与 /api/multipart/complete),没有 /api/distributed/complete;而前端 >512 KB 的文件优先走分布式,且 init 里主节点恒在 → 即使一个外部节点都没配也照样走分布式 → 永远走不到有快照的那条路。
  • ⚠️ 别把两套「分片」看混:r2multipart_session_(R2 原生,有快照)/ multipart_session_(分布式,本次补上)/ shared_multipart_session_(访客上传,有意不做)。
  • 修了 4 处联动,不是「加一行」:① 分布式 complete 补快照调用;② snapshotVersion() 学会认 manifest(用真实大小做上限判定,否则任何上限都拦不住;contentType 存清单里声明的原始类型);③ /api/versions/restore 加分布式分支;④ 🔴 新增分片保护 VERSION_SRC_PREFIX —— 快照只存清单,而覆盖上传时旧分片会被清理,分片一删历史版本就变成「能列出、下载不了」的坏文件。
  • 档位一并上调:VERSION_MAX_MB 20 → 60、VERSION_KEEP_MAX 10 → 5(客户端/脚本版默认值同步改 60)。
  • 线上实测 10/10:1.5 MB 文件覆盖 → 版本出现且 size=1572864B(修前 0 条)→ 再覆盖累计 2 版 → 恢复最旧版 → 下载内容 sha256 与旧版本逐字节一致 → 删完版本后标记计数 0(无 KV 泄漏)。
根因溯源:谁埋的这个坑
本项目引入
版本历史是 09-06 我们新增的功能(原版 grep snapshotVersion / r2drive:version 命中数 0)。 当时只把快照挂在两个上传出口上,漏掉了第三条(分布式 complete)—— 而这条恰好是大文件走的那条。
典型的「加功能时枚举不全」:同一件事有几个入口,得先数清楚再动手。
同一病灶的连锁
这次修完才发现,同一条链上还有两个连带缺陷: ① VERSION_SRC_PREFIX 缺失 → 覆盖上传清掉旧分片后,历史版本变成「能列出、下不了」; ② 09-27 的弹窗 jsAttr bug 也在同一个功能里。 一个新功能上线后,它的失败模式往往要过几天才从不同角度暴露出来。
2026-09-15 新增 设计取舍

存储用量改造:口径 + 占用构成 + 月均快照

原口径是「云盘内文件总大小 / 配额」,但桶里还躺着历史版本 / 回收站 / 站点资源 → 看不出会不会超 R2 免费额度。

零额外开销不增加任何一次 R2 请求 月均一天最多一条快照,保留 180 天 保守样本 <3 条不给结论 陷阱/api/storage 有 60s 缓存
实现细节 & 已知代价4 条
  • 口径换成「存储桶物理占用」;calculateR2Usage() 本来就整桶遍历一遍,顺手按 key 前缀分类累加(历史版本 / 站点资源 / 其他对象 / 云盘文件),不增加任何一次 R2 请求。
  • 月均靠去重快照:只在真正全量扫描时写一条「当天」快照,按北京时间记日期,一天最多一条、保留 180 天,整数组存一个 KV 键。
  • 回收站单列「其中回收站 X」解释「删了怎么还占着」。
  • 已知代价:样本 <3 条或跨度 <3 天不给增长结论(保守,避免抖动误判);/api/storage 有 60 s 缓存,改完不要立刻断言分类没生效。
根因溯源:口径问题 ≠ 代码问题
设计取舍
原口径「云盘内文件总大小」在原版是准确的 —— 因为原版桶里只有文件对象。是我们后面加进「回收站 / 历史版本 / 站点资源」之后,桶里的东西变得比「云盘文件」多,口径才失真。 不是谁写错了,是功能增长让旧定义过期了。这类问题只能靠回头审视已有指标发现。
保守取值
「样本 <3 条或跨度 <3 天不给增长结论」是故意压低敏感度:宁可说「数据不足」,也不给一个会随抖动变化的「月均增长 X MB」。教学场景里这个数字是拿来做决策的,误导比沉默更糟。
2026-09-14 修复新增 本项目引入

全局搜索三轮大修

原版只能盯着当前目录手翻,文件一多就是瞎。三轮用户实测反馈又暴露三个体验问题。

效果「必修一」8+50 → 2+1 同类病formatDate is not defined 手段不新增路由,加 ?folders=1 形状陷阱两个接口的 folders 类型不同
三轮改动 & 数据形状陷阱5 条
  • 第一轮:修 formatDate is not defined(结果行整个渲染失败)+ 修匹配数翻倍(网格与列表装的是同一批文件,两个容器一起统计了)。
  • 第二轮:/api/list-all 加可选 ?folders=1(不新增路由,同步端不传 → 调用开销与返回体零变化)+ 结果含文件夹 + 输入即搜 + 空态文案按上下文分流。
  • 第三轮:筛选从 hit(name) || hit(path) 收紧为只看 f.name + 搜索面板可滚动(480 → 760px)。
  • 效果(88 文件 / 18 文件夹真实数据):搜「必修一」从 文件夹 8 + 文件 50 收敛到 文件夹 2 + 文件 1。
  • ⚠️ 形状陷阱:带 folders=1 时 folders 是 {name,path} 对象数组;而 /api/list 的 folders 是字符串数组。写测试 stub 时必须同形,否则「断言全绿但功能是坏的」。
根因溯源:又一个「服务端函数在浏览器里」
本项目引入
全局搜索是我们新增的功能(原版只能翻当前目录)。formatDate is not defined 与 09-27 的 jsAttr is not defined 是同一个病的两次发作: 浏览器端代码引用了只存在于服务端的函数,抛 ReferenceError 被 catch 吞掉,表现为「结果区一片空白」。
设计取舍
「只按名称匹配」是有意的:改成 hit(name) || hit(path) 后搜「必修一」会把整棵子树(路径里含这三个字的都算)全捞进来,结果爆炸。 改为只看文件名后精准得多,代价是「文件名里没有、但所在文件夹名里有」的文件搜不到 —— 用户明确接受。
本项目引入
匹配数翻倍:网格视图和列表视图渲染的是同一批数据,统计时两个容器各算了一遍 —— 典型的「同一份数据多个视图」问题。
2026-09-12 修复数据安全 本项目引入

孤儿清理会删光回收站与全部历史版本

复核代码时发现 findAllReferencedStorageKeys() 只扫文件前缀,漏了回收站与版本索引。

后果勾选删除即永久丢失 修法抽出 collectStorageKeysByPrefix 消掉两份实现 实测与 D1 在引用者完全一致 = 0 释放536.2 MB(对象 283 → 128)
实现细节 & 这条为什么最危险4 条
  • 后果:点「清理孤儿文件」会把回收站对象和所有历史版本对象判为孤儿 → 勾选删除即永久丢失(D1 条目还在,所以列表照常显示,点「恢复」才报「对象不存在」)。
  • 修法:抽出 collectStorageKeysByPrefix() 消掉「同一件事两份实现」这个根因;补扫回收站 + 版本索引前缀;deleteOrphanStorageKeys() 加双层兜底(前缀白名单物理拒删 + 删除前再反查一遍)。
  • 效果:扫描出 155 个孤儿,与 D1 在引用的 key 完全一致者 = 0 → 安全清理释放 536.2 MB(R2 对象 283 → 128)。
  • 🔴 这是全项目最容易埋雷的地方:以后往 R2 放任何非文件系统资源(新的库、缩略图缓存、字体、UI 壁纸……),必须同时做两件事 —— ① 加进引用集合;② 扩兜底前缀。漏任何一半,跑一次清理就永久丢。
根因溯源:原版的函数,我们的前缀
原版函数
findAllReferencedStorageKeys() 是原版自带的,它内部写死了 const prefix = FS_FILE_PREFIX;,只遍历文件前缀那一批 key。 在原版语境里这是完全正确的 —— 因为原版桶里只有文件对象(原版 grep trash / r2drive:version 命中数均为 0,它根本没有回收站和版本历史)。
本项目引入
真正的坑是:我们 08-28 加了 trash: 前缀、09-06 加了版本索引前缀,但两次都没有回头更新这个「引用集合」函数。 于是「清理逻辑」和「实际存储布局」之间出现了裂缝 —— 而且裂缝的另一端是用户的数据。
为什么会漏
新增功能时,注意力在「功能能不能跑通」;而「孤儿清理」是个角落里的运维功能,半年不点一次。这类「低频 + 破坏性 + 依赖全局知识」的功能,是最需要加机械防线的 —— 所以修法里加了双层兜底而不是只补前缀。
2026-09-12 优化 本项目引入

回收站 N+1 → 集合查询 + 清理 30 天前 + PWA 图标真根因

实测89 条 0.95 ms vs 旧写法 ≈18 ms 调用方返回结构不变 → 零改动 PWA 图标maskable 一行 教训双图架构当天回退
实现细节 & 当天回退记录4 条
  • N+1 根治:原实现 = 1 次 list 只取 key + 每条再单独 SELECT。新增 listRaw() 一条 SQL 取回全部键值,返回结构与排序不变 → 调用方零改动。实测 89 条:新写法 0.95 ms vs 旧写法累计 ≈18 ms(还没算跨机房往返)。
  • 「清理 N 天前」用手动按钮而不是 cron:量级小(当时 78 文件 / 513 MB),手动「可见可控」,且避免定时任务误删。
  • PWA 图标溢出的真根因:只是 manifest 里 purpose:'maskable' 一行(安卓按中心 80% 安全区遮罩裁切满幅图)。
  • 教训:当天试过「双图架构」(PWA 用带底图、页头用透明图)→ 用户实测「连页头 logo 也变了」→ 当天回退。默认走最小改动,别上纲成系统工程。
根因溯源:谁埋的这个坑
本项目引入
回收站是 08-28 我们新增的功能(原版 grep trash 命中 0)。所以「回收站列表点开要等很久」这个 N+1 是我们自己写的第一版实现留下的,不存在「上游也这样」的说法。
本项目引入
purpose:'maskable' 也是我们 08-30 加 PWA 时写进 manifest 的 —— 照抄了常见教程的写法,没意识到它会让安卓把整张图当满幅图标裁切。
「照抄教程」是这个项目里最常见的引入方式,也是最容易埋隐形坑的一种 —— 09-26 没照抄上游的原子占位写法,就是同一枚硬币的另一面。
方法教训
「双图架构」当天回退这件事值得记:一个看起来只是「换张图」的小问题,很容易被升级成「引入两套资源体系」的系统工程。真正的根因是一行配置。 判断标准:改动规模是否显著大于问题规模。
2026-09-11 修复事故级 本项目引入

/api/list-all 的 modified 取值错误

曾写成 updatedAt || uploaded —— 而 updatedAt 是条目写入时刻,改名会被刷成 now。

后果同步重下约 500 MB 等级全项目第二严重事故 修法uploaded 优先(内容上传时间)
后果与修法2 条
  • 后果:整棵树被判「云端较新」→ 同步把约 500 MB 重下到「云端更新」。
  • 修法:modified 必须是内容上传时间优先 —— uploaded || updatedAt。
根因溯源:原版没有这个字段,是我们加的
本项目引入
实测原版 worker备份/worker原版.js 全文 grep modified 命中数 0 —— 原版的 /api/list-all 根本不返回 modified 字段。 这个字段是我们为了让客户端同步能判断「云端是否更新」而新增的,取值逻辑也是我们自己定的。
为什么会错
updatedAt 语义是「这条索引记录什么时候被写过」,改名 / 移动都会刷新它 —— 它跟「文件内容有没有变」不是一回事。
这是一个字段语义被误用的典型:名字看着像「修改时间」,实际是「元数据写入时间」。 给同步逻辑选时间字段时,必须找「内容时间」而不是「任何看着像时间的时间」。
放大因素
为什么一个小取值错会变成 500 MB 事故 —— 因为同一天 09-10 刚上线了「云端改名」功能,改名会批量刷新 updatedAt,两个改动叠在一起才引爆。 相邻上线的功能之间会互相放大对方的 bug,这也是为什么这类问题往往「上线当天没事、第二天才炸」。
2026-09-10 优化修复 原版自带

目录改名 / 移动批量化(原版最严重缺陷)

原版O(N²) 子请求超限被强杀 改后子请求 ≈500 → ≈7 实测2.3 秒完成 纪律改名唯一入口是 UI,补做前严禁跑同步
缺陷细节 & 配套改动4 条
  • 原版缺陷:改名 = 逐文件搬 + 每个文件都重建整个父目录索引 = O(N²) 子请求,超过 Worker 免费版 50 子请求上限被强杀 → 留下新旧两份重复索引(云端出现「幽灵目录」)。
  • 修法:把「逐条读改写」改成「一次批量取源 + 全部塞进 DB.batch()」—— 子请求 ≈500 → ≈7,实测 2.3 秒完成。
  • 配套:改名一条龙(把三步操作压成双击一次);09-11 控制台改名按钮「没生效」三层排查(根因是前端引用未定义变量导致 onclick 静默中断)。
  • 纪律:改云端目录名的唯一正确入口是 UI 的「改名…」;脚本版与客户端互不联动,一侧改名后另一侧必须补做,且补做前严禁跑同步。
根因溯源:原版代码的具体病灶
原版自带
原版 moveVirtualPath() 处理文件夹时的结构是逐条 await 循环,没有任何批量提交:
① 每个文件一次 putFileEntry();② 每个文件还要 removeFileFromDirectoryIndex()(重建父目录索引);③ 每个文件夹再 deleteDirectoryIndex() + removeFolderFromDirectoryIndex()。
全部是独立子请求,没有一处进 DB.batch()。
为什么会写这样
「逐条读改写」是最直白的写法,本地环境或小目录下完全看不出问题(10 个文件也就 30 次子请求)。 只有在大目录 + 平台子请求上限同时存在时才会暴露 —— 而 Cloudflare Workers 免费版上限是 50,教学目录轻松超过。
这是「平台限制 × 代码写法」的乘积型缺陷,读上游代码时最该警惕这一类。
最阴的地方
被强杀时不是回滚,而是留下一半:旧索引还在、新索引也建了一半 → 云端出现新旧两份重复目录(「幽灵目录」)。 破坏性操作做到一半失败,永远比一开始就失败更糟。
2026-09-09 优化 本项目引入

回收站列表并发读

症状清空后就秒开=典型 N+1 第一轮Promise.allSettled 根治09-12 换 listRaw 集合查询
怎么改 & 演进2 条
  • 怎么改:Promise.allSettled 并发化,治「回收站点开要等很久」(清空后就秒开,这是典型 N+1 症状)。
  • 演进:这是第一轮;09-12 用 listRaw() 集合查询根治。
根因溯源:症状本身就是最好的诊断
本项目引入
回收站是 08-28 我们新增的功能,第一版实现写成了 N+1(1 次 list 拿 key + 每条再单独 SELECT)。
诊断方法
「清空后就秒开」这一条观察,等于直接指认 N+1 —— 延迟与「条目数」成正比、与「数据量」无关,就是逐条查询的特征。
比读代码快得多的排障路径:先找「症状随什么变化」。这条观察是这个 bug 从「感觉慢」变成「确诊」的关键。
2026-09-08 新增跳跃最大 本项目引入

在线预览五件套 + 侧边栏 + 页面内弹窗(单版 +220 KB)

原版这些格式全都只能下载。教师场景里 PDF / pptx 是最高频的预览需求 —— 这一块是提升最大的。

体量388 KB → 608 KB 当时的选择5 个库全走 jsdelivr 有意规避PDF 不用 iframe(Electron 白屏) 已知待办网页版 PPT 还原器不完整
五件套实现要点 & 已知缺口7 条
  • PDF:pdf.js canvas 自绘。特意没用 <iframe> —— iframe 方案在 PWA / Electron 里会挂(Electron 无内置 PDF 插件,白屏)。带翻页 / 缩放 / 适应窗口 / 左右键。
  • Word:mammoth 转 HTML,保留标题、表格、图片结构。旧版 .doc(二进制格式)无法解析。
  • Excel:SheetJS 解析 → 自绘表格,多 sheet 用标签页切换。
  • PPT:自研 OOXML 解析器(JSZip 解包 + 按 a:xfrm 坐标还原版式)。⚠️ 网页端至今仍是自研还原器,补不起「主题/母版继承 + 表格 + 背景图」,属已知待办。
  • 音视频:关键是代理层透传 Range —— 否则 mp4 要整包下完才能播、不能拖进度条。
  • 可折叠侧边栏 + 遮罩层,移动端默认收起。
  • 15 处原生 alert/confirm/prompt 换成 Material 风格弹窗(PWA / 移动端尤其难看)。
根因溯源:这一版埋了三个后来的坑
本项目引入
这一版是「埋坑密度最高」的一次,三个后来的问题都源于此刻:
① 5 个预览库全走 jsdelivr → 09-17 才收回到 R2 同源托管;
② Office 解析结果直接 innerHTML → 全站唯一的数据派生 XSS 面,09-17 补 sanitizeDocHtml();
③ 可折叠侧边栏的遮罩用了全局规则 → 09-22 才发现「拉宽窗口后整页点不动」。
设计取舍
PDF 不用 iframe 是有意规避:iframe 内嵌 PDF 在浏览器里最省事,但 Electron 无内置 PDF 插件会白屏。改用 pdf.js canvas 自绘,代价是要自己实现翻页 / 缩放(后来这些交互都补上了)。
「将来要跑在三种壳里」这个前提,决定了当时的技术选型。
已知缺口
网页版 PPT 用自研还原器不完整:主题 / 母版继承、表格、背景图都还原不了。这是如实记账未解决,不是忘了 —— 完整还原需要一整套 OOXML 渲染引擎,超出自建范围。
2026-09-06 新增

版本历史

同名文件覆盖上传 = 旧内容直接消失。云盘最常见的「手滑」。

价值与回收站构成双保险 后来才知道>512KB 是空转的 后来才知道网页端弹窗从上线起就坏着
怎么改 & 两处后续缺陷3 条
  • 怎么改:覆盖前把旧实体快照成一个新 R2 key(r2drive:version:<hash>/<id>/<name>);每个文件的版本索引存一条 D1 记录(value 是数组);新增 /api/versions / restore / delete + 网页端面板。
  • 价值:与回收站配合构成双保险(删错、覆盖错都能救)。
  • 后来才知道:这一版对 >512 KB 的文件是空转的(见 09-17 那条);网页端弹窗也从上线起就坏着(见 09-27 那条)。
根因溯源:一个功能的两处缺陷,21 天后才全部暴露
本项目引入
版本历史整个功能是我们新增的(原版 0 命中)。这一版同时埋了两个坑,而且两个都是「没走到的分支」:
· 快照只挂在 /api/upload 和 /api/multipart/complete,「分布式 complete」这条分支没挂 → 大文件永远没版本;
· 前端 loadVersions() 只在真的存在历史版本时才走到出错那一行 → 没有版本时一切正常。
为什么能藏这么久
两个坑的共同点:都藏在「功能看起来正常」的表面之下。面板能打开、能列出(空列表),所以「能用」;没版本时函数提前 return,所以「不报错」。
教训:新功能上线时要专门走一遍「有数据」的路径,而不是只看它「能不能打开」。空数据下的正常,是假正常。
2026-09-05 修复 原版自带

401 守卫白名单(修了原版的 bug)

原版分享功能等于失效 修法白名单三个前缀 回归风险新增免登录路由都要过它
原版 bug 的表现与修法3 条
  • 原版 bug:/s/xxx(分享链接)与 /shared(访客页)也被踢去登录页 → 分享功能等于失效。
  • 修法:PUBLIC_PREFIXES = ['/login', '/s/', '/shared'] 白名单式放行;install401Guard() 被所有页面继承,漏一个就把访客全踢走。
  • 回归风险:以后加任何免登录路由都要考虑它会不会被拦 —— /assets/lib/ 就是放在鉴权之前处理来避开的。
根因溯源:原版代码的具体病灶
原版自带
原版的鉴权守卫只有两类出口:path === '/login' 直接放行、/api/download 对 SHARED_PREFIX 下的文件开个特例; 其余一律 return new Response('Unauthorized', {status:401}) 或 Response.redirect('/login')。
也就是说,原版从未把「分享链接页」和「访客页」列入公开路径。
为什么会错
原版代码里确实有分享功能(/s/<id> 路由、publicShare、分享令牌),但那套东西是**在鉴权之前处理的另一条分支**还是别处,与守卫的放行列表对不上 —— 典型的「同一个项目里两个子系统对不上账」:一个负责鉴权,一个负责分享,中间没有共同的白名单来源。
为什么值得抄
修法用的是白名单(列出允许公开的,其余全拦)而不是黑名单 —— 这是安全上更稳的方向:漏加一个公开路径 = 功能不可用(立刻被发现);漏加一个黑名单 = 漏洞(长期没人发现)。
代价是每加一个新免登录路由都要记得登记 —— 于是才有了「/assets/lib/ 放在鉴权之前」这种绕法。
2026-09-01 修复 原版能力缺失

分片上传断点续传

原版断网 = 从头再来 机制resumeKey = sha256(路径|大小|mtime) TLL 红线7 天,不要改小
怎么改 & 为什么 TTL 不能动3 条
  • 为什么:原版断网 = 从头再来。大文件(课件、录课视频)重传代价极高。
  • 怎么改:resumeKey = sha256(路径|大小|mtime) 找回分片会话;/api/multipart/init 支持复用;新增 /api/multipart/status 查已收哪些片;会话 TTL 7 天(对齐 R2 的分片回收策略)。
  • 🔴 TTL 不要改小:改小会让 7 天内可能续传的文件变成「必须重传」。
根因溯源:这是能力缺失,不是写错了
原版能力缺失
原版实现了 R2 分片上传(r2multipart_session_),但只做了「上传」没做「续传」:一旦中断,会话键就找不回来,只能重传。 这不算 bug —— 是一个没做完的功能(在「上传能用」的目标下,它确实能用)。
为什么 7 天是红线
TTL 不是随便取的,它对齐了 R2 自身对未完成分片的回收策略。改小 → 本地还想续传、云端分片已经没了 → 必然重传;改大 → 本地记着一个云端已失效的会话,白白多一次失败往返。 跨系统协作的时间参数必须以「更短的那一方」为准,并写进文档别再动。
2026-08-30 新增 本项目引入

PWA 支持

形态manifest + SW + 多尺寸图标 图标优先用户上传,没上传用内置 base64 反复踩SW 缓存旧 UI
怎么改 & 反复踩的坑2 条
  • 怎么改:manifest + Service Worker + 多尺寸图标;图标优先读用户上传的站点图标,没上传就用内置 base64 回落。可安装到桌面 / 手机主屏,全屏无地址栏。
  • 反复踩的坑:Service Worker 会缓存旧 UI —— 改前端后用户不 Ctrl+F5 看不到新版。改 UI 后要提示一次强刷。
根因溯源:PWA 带来的三个连带坑
本项目引入
PWA 是原版完全没有的能力(原版无 manifest / Service Worker)。加它之后接连带来三个坑,全都是这个功能的固有副作用:
· SW 缓存旧 UI —— 前端更新后用户看不到新版(后来成了「改 UI 要提示强刷」的固定动作);
· maskable 图标溢出 —— 08-30 写进 manifest,09-12 才追到根因;
· 安卓 PWA 独立进程读不到 content:// —— 09-18 才解决,而且是绕行方案。
外部环境
PWA 装到桌面后运行模型与浏览器标签页完全不同(独立进程、独立存储、独立权限上下文)。这意味着「浏览器里测过没问题」不能推广到 PWA —— 09-18 那个 bug 就是活例子。
2026-08-28 基线原版 + 首轮加固 原版自带

初稳定版:首轮 6 处安全修复 + 访客协作 + 回收站

从上游原版(HandsomeMJZ / R2-Cloud-Drive,7,187 行 / 280 KB)跑通并做第一次加固 —— 这一轮同时定下了整个项目的走向:从「能用」到「敢用」。

起点7,187 行 / 280 KB 安全6 处原版缺陷 功能回收站 + 访客收件 清理删掉原版全局页脚
首轮 6 处安全修复 & 两项新功能4 条
  • 安全六处:节点 API 密钥回退(密码泄漏通道)/ 孤儿分片清理误删(会把正在上传的分片当垃圾删)/ 分片 complete 校验 / SSRF 防护(节点 URL 可填内网地址)/ CSRF 覆盖到全部写接口 / 登录爆破限流。
  • 访客协作:/shared 从只读变成可收件 —— 口令验证 + 直传 + 分片 + 建文件夹 + 配额;新增两个环境变量。
  • 回收站(软删除):原版删除=永久消失,误删无法挽救。删前把整棵子树快照到 trash: 前缀,R2 实体不立刻删。
  • 删掉全局页脚:原作者写在全局外壳里 → 登录 / 主页 / 分享页都带着。
根因溯源:六处安全缺陷全是原版的
原版自带
六处全部是上游代码本来的写法,不是我们改出来的。特征也一致 —— 都属于「默认信任、缺少校验」这一类:
· 密钥回退:没配 token 时退回用管理员密码(把高权限凭据降级成一个普通 API 密钥);
· SSRF:节点 URL 不校验目标,可指向内网 / 云元数据地址(实测原版全文 grep 169.254 命中 0,即没有任何内网地址防护);
· 无限速:登录接口没有失败计数,可以跑字典;
· 无 CSRF 覆盖:写接口没全上同源校验。
项目走向
这一轮定下了本项目的方法论:先补防护,再谈功能。后面 30 天里「原版自带」的缺陷越来越少(09-05 / 09-10 各一条后基本清零), 而「本项目引入」的坑变多 —— 这本身就是一条演进曲线:上游的坑会挖完,自己的坑会越挖越多。

三、根因分布:这些坑都是谁埋的

点卡片 → 跳到上方时间线并只看这一类

「修 bug」这件事的学习价值,取决于坑是谁埋的 —— 读上游代码时要知道该防什么,改自己代码时要知道哪类改动最容易出事。 数字由页面自动统计,不会随手改而漂移。

四、「不做」也是决策

项目处理为什么
版本更新检查checkVersionUpdate删除 页面加载时去 raw.githubusercontent.com 拉 version.json —— 运行时依赖 GitHub,国内常被墙/超时,拖慢首屏。代价:上游出新版不会自动知道。
下载 inline 参数删除 全项目搜不到任何 inline=1 生成点 → 死参数。删除后下载恒 attachment,「上传 HTML 再骗人点开」的攻击链彻底不存在。
双图架构SITE_FAVICON_KEY当天回退 用户实测「连页头 logo 也变了」—— 本意只是「不让 PWA 图标主体溢出」。真正的根因就是 manifest 的 maskable 一行。教训:默认走最小改动。
favicon 清理临时按钮用完即撤 一次性运维操作,不应长期留在界面上。
上游 Hollow 毛玻璃皮肤
+ 自定义壁纸
放弃 ① 上游把两个接口的 GET 放在鉴权之前(为了让登录页也套主题)→ 任何人猜到路径就能拉到壁纸;② 皮肤会覆盖 .file-card / .sidebar / .file-list,与本项目已调好的四套布局正面打架 —— 成本最高、收益最低。
分享管理独立面板
分享页在线预览
全选圆形勾选
暂缓 不是不做,是排在后面。⚠️ 其中的请求竞态序列号值得单独抄 —— 本项目分享面板现在是裸 fetch,同样有「旧响应覆盖新渲染」的毛病。

五、这份文档怎么维护

定位:worker改动全史.md 是纵向总账(按功能域回答「这个功能经历了什么」);这份 HTML 是横向 + 可视化(按时间回答「哪一天改了什么、长什么样、坑是谁埋的」),两者互补,不互相替代。