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 · 改前 :大小列只有 57px,「8.42 MB」折两行
回收站 641px · 改后 :固定布局,大小单行,零溢出
回收站 721px · 改前 :时间被裁成 2026/09/2,整页被撑宽 42px
回收站 721px · 改后 :时间列 170px 单行,容器宽 = 视口
主列表 769px · 改前 :侧栏常驻 256px,名称列仅 134px(「变更日志....」)
主列表 769px · 改后 :侧栏变抽屉,名称列 437px,文件名完整
主列表 641px · 改前 :日期列被裁,右侧静默消失
主列表 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 各一条后基本清零),
而「本项目引入」的坑变多 —— 这本身就是一条演进曲线:上游的坑会挖完,自己的坑会越挖越多。