Skip to content

chore(sync): merge upstream main (49 commits) - #2

Merged
rshaw-mcs merged 50 commits into
mainfrom
claude/project-thread-3g3n2v
Nov 11, 2026
Merged

rshaw-mcs merged 50 commits into
mainfrom
claude/project-thread-3g3n2v

Conversation

@rshaw-mcs

Copy link
Copy Markdown

Requested by Riley · project thread

Summary

Merges OpenListTeam/OpenList-Worker main (49 commits, 2026-10-09 to 2026-11-10) into our fork. It's a merge commit, so main's history is not rewritten.

Conflict resolution:

  • wrangler.jsonc: kept our name: openlist-worker, DB_FORMAT=sql, DB_DRIVER=d1, and the pinned D1 binding. Took upstream's other changes (keep_vars: true, iconv-lite alias).
  • README.md: rebuilt from upstream's updated English README. The translated READMEs stay removed. Kept readmes/assets/esa-deploy.svg because the README uses it.
  • router.ts merged cleanly, and the SSO-callback CSP change is still in place.

Upstream changes to note:

Testing

Relying on Workers Builds: openlist-worker for this branch. Merging to main deploys to production.

AI Disclosure

  • This PR includes AI-assisted content (Claude: merge conflict resolution, documentation).

Generated by Claude Code

wumingzhinu and others added 30 commits September 27, 2026 20:04
Co-authored-by: wumingzhinu <wumingzhinu@users.noreply.github.com>
Co-authored-by: wumingzhinu <wumingzhinu@users.noreply.github.com>
Co-authored-by: wumingzhinu <wumingzhinu@users.noreply.github.com>
Signed-off-by: jyxjjj <16695261+jyxjjj@users.noreply.github.com>
Co-authored-by: Codex <199175422+chatgpt-codex-connector[bot]@users.noreply.github.com>
* fix(s3,share): 修复对象存储删除无效与分享复制链接为空

S3 的 remove/move/copy 按「目录 + name」拼接路径,但 op/storage.ts 传入的
physicalPath 已经是目标项自身的物理路径,结果指向 <file>/<name>:HEAD 404
后退化成按目录递归删除,listObjects 同样查不到对象,最后静默返回成功——
接口报 Success,对象仍在(move/copy 同根因)。新增 s3_remove.test.ts 用内存
S3 锁定删除/移动实际命中的 key。

分享摘要模板 share_summary_content 被 seed 成空串,前端复制链接 =
matchTemplate(getSetting("share_summary_content"), data),模板为空时
writeText("") 会清空剪贴板,却照样弹「已复制」。默认值改为与 Go setting.go
一致的模板,并加迁移把已部署实例 KV 里的空串换成默认值;/api/public/settings
同步补默认值兜底。

Co-Authored-By: opencode <noreply@opencode.ai>

* fix(share): 「复制链接」改为只复制纯分享 URL

管理后台「复制链接」渲染 share_summary_content 模板。此前该模板被 seed
成空串,复制出的是空白(writeText("") 直接清空剪贴板却照样弹「已复制」);
上一版修空值时改用了上游 Go 的整段社交文案,按钮叫 Copy link 却复制出一堆
非链接内容——而文件工具栏「分享」对话框里的同名按钮 copy(link()) 复制的
一直是纯 URL。

默认模板改为 {{base_url}}/@s/{{id}},与按钮文案及另一处「复制链接」行为
保持一致;空串与旧文案两种已落盘的值都由 LEGACY_SETTING_MIGRATIONS 迁移
成纯链接,/api/public/settings 的默认值同步更新。

Co-Authored-By: opencode <noreply@opencode.ai>

* fix(drivers): 修复 remove/move/copy 把 name 重复拼接到已含文件名的物理路径

op/storage.ts 的 removeItems/moveItems/copyItems 逐项调用 driver,传入的
physicalPath / srcPhys / dstPhys 是目标项自身的物理路径(resolvePath 已经把
name 解析进去)。多数 driver 却把它当作目录再拼一次 name,得到 `<item>/<name>`
这种不存在的路径:

- remove:删除不存在的路径。接口静默返回成功,对象仍在——用户报的
  「对象存储删除显示 Success 但文件还在」就是这个根因(s3 已单独修复);
- move/copy:源/目标路径错位,操作静默失败或作用到错误对象。

按同一约定修复 20 个 driver:azure_blob, uss, chunk, terabox, mopan, onedrive,
onedrive_app, pikpak, mediatrack, google_drive, mega, dropbox, wps, yandex,
seafile, ftp, sftp, webdav, alias, 139。

各 API 的语义差异按「physical 即目标项」重新推导:如 terabox filemanager 的
dest 是目标目录取 dstPhys 父级、名字取末段;pikpak/mediatrack 的
resolveParentId 实际返回路径自身 id,故先去掉末段再解析。names.length > 1
时保留「目录 + 多个 name」的展开分支(调用方恒传 1 个,属防御性代码)。

审查后确认本就正确的实现未改动:kodbox, halalcloud_open, url_tree,
netease_music, 189, 189_tv, smb, google_drive/util 等。

Co-Authored-By: opencode <noreply@opencode.ai>

* style(drivers): 按 prettier 格式化本次新增的代码

mediatrack/pikpak 的三元表达式换行与 public_settings 测试断言
折行不符合仓库 prettier 配置,重排为单行/标准折行。

Co-Authored-By: opencode <noreply@opencode.ai>

* fix(local): 修复本地盘驱动 remove/move/copy 路径重复拼接

评审指出 drivers/local.ts 是单文件(非 drivers/local/driver.ts),此前批量
修复时被漏检。remove 把项路径当目录再拼一次 name,对 <item>/<name> 执行
fs.rm({force:true}) 时不存在的路径被 force 忽略,静默成功但文件仍在;
move/copy 则直接 ENOENT。

与其余 21 个驱动按同一约定修复:physicalPath/srcPhys/dstPhys 即目标项
自身,仅 names.length > 1 时按目录展开。新增 local_remove.test.ts,
用临时目录真实 fs 断言 remove/move/copy 命中目标项。

Co-Authored-By: opencode <noreply@opencode.ai>

* refactor(drivers): 删除不可达的 names.length > 1 防御分支

评审指出:各驱动修复时加的 expand 分支在真实调用链里恒不可达
(op/storage.ts 的 removeItems/moveItems/copyItems 恒传单元素 names),
且它把参数语义从「项路径」反转成「目录」,属潜伏陷阱——将来若有
调用方传多 name,行为会与单 name 时不一致。

统一删除:所有 remove/move/copy 一律按「physicalPath/srcPhys/dstPhys
即目标项自身路径」直接处理,names 保留于签名(接口要求)。
行为不变(该分支本就不可达),342 项测试全过。

Co-Authored-By: opencode <noreply@opencode.ai>

* test(drivers): 补 dropbox/yandex/webdav/onedrive/sftp/ftp 的 remove/move 回归测试

评审指出 22 个改动驱动里只有 s3/local 有回归保护。按可 mock 性补齐 6 个:
- dropbox/yandex/webdav/onedrive:mock globalThis.fetch,断言 DELETE/POST/MOVE
  请求的路径参数为目标项自身路径(无 <item>/<name> 二次拼接)
- sftp:注入 fake sftpClient(getSFTP 缓存点),断言 unlink/rename 收到项路径
- ftp:替换私有 client(dropbox 先例),断言 removeRecursive/rename 参数

ftp/sftp 无法 mock.module(Node 无实验标志),故用注入方式;6 文件共 13 例,
均验证通过且不发起真实网络连接。

Co-Authored-By: opencode <noreply@opencode.ai>

---------

Co-authored-by: Astroptis <250784029+Astroptis@users.noreply.github.com>
Co-authored-by: opencode <noreply@opencode.ai>
…ead of committing it (OpenListTeam#93)

- .gitignore 重新忽略 cloud-functions/,并 git rm --cached 移除已提交的 cloud-functions/[[default]].js(1.8 MB 构建产物,每次后端改动都要重建提交)
- 删除 .github/workflows/edgeone-artifact-guard.yml 与 tests/artifact-guard.test.ts:产物不再入库,无需再做与源码的新鲜度比对
- 新增 .github/workflows/build-verify.yml:按平台一致流程 install -> fetch-frontend -> build-edge,校验 dist/index.html、dist-server/api/[...route].js、cloud-functions/[[default]].js 均已生成,并断言该产物未被 git 跟踪
- README 及 13 个语言版本的 Build status badge 指向 build-verify
- scripts/build-edge.mjs、middleware.js 注释同步说明产物由构建生成、不入库

EdgeOne 一键部署的 buildCommand(edgeone.json -> pnpm run build)已在前端 dist/ 上长期验证为部署期实时构建,云函数产物走同一条链路即可。

Co-authored-by: pikachuren <pikachuren@example.com>
同一 import 语句中重复导入了 type DbCipher(第 5 行与第 14 行),
导致 pnpm lint(tsc --noEmit)报 TS2300 Duplicate identifier,
所有依赖类型检查的 CI 均被阻断。删除多余的一处导入即可。

Co-authored-by: GLM <glm@z.ai>
OpenListTeam#111)

修复 Issue OpenListTeam#104:标准 WebDAV 客户端挂载 /dav 后所有目录显示为空。

## 规范依据(RFC 4918)

§14.7 规定 DAV:href 的 Purpose 是「MUST contain a URI or a relative
reference」,Value 为 Simple-ref;§8.3 给出产生式并附加三条约束:

    Simple-ref = absolute-URI | ( path-absolute [ "?" query ] )

- 形态二选一:相对引用(客户端按 Request-URI 解析)或完整 URI;
- 同一个 Multi-Status 响应内所有 href 必须同形;
- MUST NOT 有「与 Request-URI 前缀不匹配」的 href;集合标识符 SHOULD 以 /
  结尾。

§8.3.1 的示例把两种合法形态并列给出:

    'http://example.com/sample/' 和 'http://example.com/sample/a%20test'
    '/sample/'                  和 '/sample/a%20test'

本实现取相对引用,基准是真实请求路径(URL.pathname)而非硬编码挂载前缀。
理由:与 Request-URI 天然一致(满足 MUST NOT)、子路径挂载自动正确、
且不回显 Host 头(避免 Host 注入把攻击者域名写进客户端解析结果)。

## 修的三个缺陷

1. href 缺挂载前缀(issue 报告的)。davPathOf() 已剥掉前缀,PROPFIND 却把
   这个已剥前缀的路径直接当 href 输出,于是请求 /dav/wewe 回 /wewe/,
   客户端判定路径对不上并丢弃全部记录(rclone: Item with unknown path
   received)。表现为「能下载但列不出目录」,因为 GET 走 302 重定向不依赖 href。

2. 自身 href 是裸输出,会产出非法 XML(审计新发现,issue 未提)。
   davPathOf() 已对 pathname 做过 decodeURIComponent,而自身 href 原样塞进
   <d:href>;目录名含 & 时 & 未转义,客户端解析整个 multistatus 失败:

       PROPFIND /dav/R&D  ->  <d:href>/R&D/</d:href>   非法 XML

   §8.3.1 正好提醒过这点:「a legal URI may still contain characters that need
   to be escaped within XML character data, such as the ampersand character.」
   子项名此前已被 encodeURIComponent,自身 href 是唯一的裸输出口。

3. 集合与文件未区分尾斜杠。旧实现对子项一律不追加 /,目录 href 不带尾斜杠,
   Windows 资源管理器等要求目录以 / 结尾(§8.3 的 SHOULD)。

## 实现

- 新增 davRequestPath(c) 取 URL.pathname 作为 href 基准,与 davPathOf(仅用于
  存储层寻址)职责分离。
- generateWebDavXml(requestPath, items):自身 href 补斜杠;子项 href = 自身 +
  逐段 encodeURIComponent(name) + (目录 ? "/" : "")。参考项目 Davflare 的
  getResourceHref 采用同一口径。

编码分两路,不可混用:

| | 来源 | 处理 | 原因 |
| --- | --- | --- | --- |
| 自身 href | URL.pathname,已百分号编码 | 只补编码 & 与 ' | 整条重编码会双重编码(%20 -> %2520) |
| 子项 href | 解码后的原文 | 逐段 encodeURIComponent | 整条编码会把 / 变成 %2F,层级就没了 |

为什么自身 href 用百分号编码而不是 XML 实体(&amp;):本仓库自己的 WebDAV
驱动(src/backend/drivers/webdav/util.ts 的 parseMultistatusXml)用正则取 href
文本且不做 XML 实体反转义,输出 &amp; 会被它当成字面量。百分号编码同时满足两边:
既是合法 URI,又无需反转义,decodeURIComponent 后能还原原始路径。

实测 WHATWG URL 在 pathname 里已经编码掉 < > " ` 与空格,# ? 也不会出现,
裸 % 必然属于既有转义序列(用户输入的 % 会被编码成 %25),因此只补 & 与 ',
不做整条重新编码。

- 非法 UTF-16(孤立代理项)会让 encodeURIComponent 抛 URIError,逐字符兜底,
  不因编码失败把整个 PROPFIND 打成 500。
- davPathOf 与 MOVE/COPY 的 Destination 解析改用前缀守卫(仅当
  p === DAV_MOUNT || p.startsWith(DAV_MOUNT + "/") 才剥离)。不能像
  slice(DAV_MOUNT.length) 那样无条件截断 —— 子路径挂载(/list/dav)下那会
  截出 /t/dav/wewe 这样的垃圾路径,并违反 §8.3 的 MUST NOT。
- internal/webdav/webdav.ts 改为直接从 ../../pkg/xml 导入,不再经 pkg/utils
  barrel(后者会拉入 hono / model/db)。

## 验证

新增 src/backend/pkg/xml_webdav.test.ts,14 个用例全部通过,覆盖根目录、
子目录、集合/文件尾斜杠差异、子路径挂载(/list/dav、/openlist/dav)、子项按段
编码、自身 href 不双重编码(断言无 %2520)、裸 & 与 apostrophe 的百分号编码、
输出不含任何 XML 实体、分隔符不编码成 %2F、孤立代理项不抛错、同一响应内 href
格式一致、XML 标签配平。

其中一项是互操作测试:模拟 parseMultistatusXml 的取名逻辑(正则取 href ->
strip 尾斜杠 -> 取末段 -> decodeURIComponent),断言能从
/R%26D/、/a%26b/、/c%27d/ 正确还原出 R&D、a&b、c'd —— 即输出可被本仓库自己的
客户端解析器直接消费。

端到端对照三个挂载场景(Issue OpenListTeam#104 真实场景 PROPFIND /dav/wewe):

    PROPFIND /dav/wewe
      修复前: /wewe/  /wewe/WESSSDQ  /wewe/70rop ...       前缀不一致
      修复后: /dav/wewe/  /dav/wewe/WESSSDQ/  /dav/wewe/70rop/ ...  一致
    PROPFIND /list/dav/wewe        修复后前缀一致
    PROPFIND /openlist/dav/wewe     修复后前缀一致

运行:tsx --test src/backend/pkg/xml_webdav.test.ts
(src/backend/pkg/ 目前未被任何 test:* 脚本覆盖,已在 OpenListTeam#97 中补 test:pkg)

## 未改动(刻意保持 PR 聚焦)

issue 补充提到的另外两点未混入:
- MOVE/COPY 的 Destination base 推导:现状对绝对路径形式可正常工作,收紧
  校验可能让部分客户端直接失败,需单独评估兼容性。
- OPTIONS 声明 DAV: 1, 2 但 LOCK 返回 405:能力声明与实现不一致,Office 会
  因此拒绝写入;改声明会影响现有客户端的能力协商结果,宜单独提 PR。

Co-authored-by: wumingzhinu <wumingzhinu@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
proxy() 原逻辑为 if (!p || url.startsWith("http")) return url,
而所有传入的 GitHub 下载 URL 均以 http 开头,导致 p + url 分支
永远不可达——配置了 gh_proxy 的用户直链仍直连 GitHub。

改为 if (p && url.startsWith("http")) return p + url,对齐上游
OpenList Go 版 drivers/github_releases 的 GhProxy 语义(代理非空
且 URL 为 http(s) 时加前缀)。附 3 个单元测试覆盖配置/未配置/
非 http 链接三种情况。

Co-authored-by: GLM <glm@z.ai>
Co-authored-by: wumingzhinu <wumingzhinu@users.noreply.github.com>
Co-authored-by: pikachuren <pikachuren@example.com>
…placeholders (OpenListTeam#113)

* fix(static): repair /manifest.json 401 on cookie-gated deployments (EdgeOne preview)

Three stacked issues made /manifest.json the only failing request:

1. Frontend OpenListTeam#162 (2025-08-08) repointed index.html from /static/manifest.json
   to /manifest.json to match the Go backend dynamic route
   (OpenList server/router.go:36), but the dist still only ships
   static/manifest.json - no root manifest.json exists.
2. The TS worker never aligned that route, so /manifest.json fell through to
   the SPA fallback (text/html on Cloudflare).
3. On cookie-gated platforms (EdgeOne Makers preview domains, eo_time), every
   other request carries the auth cookie while the PWA manifest fetch is made
   without any credentials by Chromium - so manifest.json alone got 401.

Fix:
- fetch-frontend.mjs: copy static/manifest.json -> manifest.json at build
  time (covers static-first platforms like EdgeOne Pages) and add
  crossorigin="use-credentials" to the manifest <link> so the browser sends
  cookies; both idempotent, applied to every dist source path.
- assets.ts: new ensureManifestCredentials() applied to all HTML served by
  getIndexHtmlWithCdn (covers CDN-sourced index.html, path B), plus a
  /manifest.json -> /static/manifest.json 302 route aligned with the Go
  router for worker-served traffic.
- assets.test.ts: 5 new cases for the attribute patch and the CDN path.

Verified: server suite 184/184, tsc --noEmit clean, fetch-frontend end-to-end
produces dist/manifest.json and a patched index.html.

* fix(edgeone): restore server-handler detection via committed handler placeholders

EdgeOne Pages decides whether a project has a Node backend by scanning the
repository content, not the post-build filesystem: with the handler artifact
gitignored (PR OpenListTeam#93), detection fails ("No server-handler detected, generating
routes.json for pure project") and /api/* falls into the static rewrites
returning index.html - while functions/kv-* keep working.

- scripts/build-edge.mjs: emit the bundled handler to the officially
  documented node-functions/[[default]].js in addition to the historical
  cloud-functions/[[default]].js; the deploy-time build overwrites both with
  the fresh ~2MB bundle, so runtime always executes the latest code.
- cloud-functions/[[default]].js + node-functions/[[default]].js: track a
  tiny, never-changing placeholder (valid onRequest export returning 503).
  It exists only so platform detection sees a server handler; the deploy
  build overwrites it with the real bundle on the build filesystem. Merge
  conflicts on artifacts are structurally impossible and contributors never
  need to touch these files. This supersedes both PR OpenListTeam#93's
  gitignored-artifact approach and an interim full-artifact-committed one.
- middleware.js: export config ({ matcher: [{ source: "/:path*" }] }) as the
  platform expects, silencing "Could not find config in middleware file,
  using defaults".
- .github/workflows/build-verify.yml: assert the tracked entries are still
  the placeholder so accidentally committing a full build fails CI with a
  clear message.
- .gitignore: stop ignoring the two handler entry files.

Verified on a live EdgeOne deployment: /api/public/settings returns JSON
again after deploying this change.
* feat(139): add PC token refresh

* feat(139): add PC token refresh

* feat(139): add PC token refresh

* feat(139): add PC token refresh tests

* feat(139): persist refreshed authorization

* docs(139): explain cookie-free refresh limitation

* docs(139): shorten omitted-cookie note
…Team#108)

- Normalize driver names to support both "GitHub Releases" and "CNB Releases".
- Add driver configurations for GitHub Releases and CNB Releases, including required fields and settings.
- Add unit tests for both drivers and integration coverage for admin registration and storage creation.
- Preserve compatibility with existing driver info retrieval, supporting exact and Go-style driver names.

Co-authored-by: DeepSeek <noreply@deepseek.com>
Co-authored-by: Pikachu Ren <40362270+PIKACHUIM@users.noreply.github.com>
PROPFIND 返回的 D:href 由 davPath 拼出,而 davPathOf() 已经把 /dav 挂载
前缀剥掉。RFC 4918 要求 D:href 是完整请求 URI,因此按请求路径解析 href 的客户端
(rclone、Windows 资源管理器、RaiDrive 等)会丢弃全部条目,表现为目录为空。

改为从请求 URL 推导前缀,而不是硬编码 /dav —— index.ts 还会把发往任意路径的
WebDAV 方法交给同一个 router,此时前缀为空。

GuangYaPan 的 get_res_center_token 把 OSS 凭据嵌套在 data.creds 下,而类型定义
与校验都按平铺在 data 上读取,于是 accessKeyID / secretAccessKey 恒为 undefined,
所有上传都抛 "upload token is incomplete"。改为优先取 creds 并保留平铺回退,同时
把 OSS 签名与 x-oss-security-token 头也改用取到的局部变量。

Co-authored-by: Pikachu Ren <40362270+PIKACHUIM@users.noreply.github.com>
* feat(singleflight): 并发相同调用去重(进程内合并 + 跨实例表协调)

一个部署会同时存在多个 isolate,进程内 Map 无法跨实例合并重复调用,导致
同一目录列表 / 元信息的并发请求各自打一次上游网盘 API —— 这正是限流、封号、
CPU 超时的主要来源。新增 singleflight 做两级去重:

- L1 进程内:Map<key, Promise> 合并,所有模式生效,零成本
- L2 跨实例:数据库表 x_singleflight 抢锁 + 结果共享(仅 db 模式)
  · 抢锁靠 INSERT OR IGNORE + 回读 owner,不依赖事务
  · 执行者按 lockTtl/3 心跳续租,崩溃后锁到期可被接管
  · 等待方轮询读取结果;超时则自行执行,不会把请求挂死

模式由 SINGLEFLIGHT 控制(auto/db/memory/off),默认 auto:有 SQL 库
(d1 / mysql / do)时用表协调,否则退回进程内合并。

关键设计:

- 不做结果缓存。只有「到达时观察到执行者处于 running」的调用才复用结果,
  新到达者会回收旧记录并重新执行,因此不会出现「删除文件后刷新仍看到被删
  文件」这类脏读(handoffMs 只是等待方读取结果的交接窗口,不是缓存)。
- 业务异常原样抛出且不重试;协调层异常(表缺失 / DB 超时 / 结果不可序列化 /
  执行者崩溃)一律降级为「各自执行一次」,业务不会失败。
- x_singleflight 属运行时数据,刻意不加入 TABLE_NAMES —— 否则
  sqlFormat.save() 的整表 DELETE 会在每次保存配置时清空在途记录。

接入:internal/op/storage.ts 的 listItems / getItem,覆盖 /fs/list、/fs/get、
/fs/dir、搜索、WebDAV、MCP。运行统计见 /api/debug/info 的 singleflight 字段。

配置:SINGLEFLIGHT / _HANDOFF_MS / _LOCK_TTL_MS / _POLL_MS / _WAIT_MS,
已同步到 .env.example、.dev.vars.example、wrangler.jsonc 与 README。

新增单测 17 例(含 mock SQL 驱动),并纳入 test:all 的 test:pkg。

* fix(singleflight): 索引名带表前缀,放宽 POLL_MS 默认值至 200ms

- buildSingleFlightDdl 改为调用时经 getTablePrefix(env) 生成表名与索引名:
  多部署共用物理库时第二个部署的索引不再被 CREATE INDEX IF NOT EXISTS
  静默跳过(评审意见 #2)
- SINGLEFLIGHT_POLL_MS 默认 50 → 200:每次轮询都是一次协调表 SELECT,
  过密会消耗 D1 subrequest 配额与 CPU(评审意见 #1);README 同步说明
- 同步 wrangler.jsonc 注释默认值
…penListTeam#75)

* feat(cache): add file-tree and download-link caches (DB by default)

Implement the two-level cache from the OpenList.ts reference:

- File-tree cache (`ft`): directory listings are served from cache, keyed
  by storage + virtual path. Empty directories are cached too, so repeated
  browsing no longer hits the remote drive every time.
- Download-link cache (`ln`): the raw_url returned by the driver is reused
  instead of being re-signed/re-exchanged on every download, which is
  usually the most expensive and most rate-limited step.

Caching is DB-only by default. `CACHE_DRIVER=db` (the default) reuses the
backend resolved by `getStorageBackend()`, i.e. the same one as
`DB_DRIVER`, so zero configuration means "cache in the database".
Dedicated KV / Blob / cfkv / do / memory backends must be opted into
explicitly via `CACHE_DRIVER` (e.g. `db,kv`, `kv`, `blob`) and are never
auto-detected or auto-substituted, matching the existing DB_DRIVER rule
that an explicitly configured driver never falls back.

Safety boundary: only driver-layer results are cached (raw FileItem lists
and raw links). Permissions, meta passwords, hide rules and signatures are
still computed per request in server/fs.ts and server/raw.ts, so a cache
hit cannot leak privileges. Any cache error degrades to a no-op and never
breaks the request.

Invalidation: writes (mkdir/rename/remove/move/copy/put) invalidate the
affected path plus its parent directory for the file tree, and the path
itself for links. Storage create/update/enable/disable/delete clears that
storage's cache entirely.

New environment variables (all optional): CACHE_ENABLED, CACHE_DRIVER,
CACHE_FILE_TREE, CACHE_DOWNLOAD_LINK, CACHE_TTL, CACHE_LINK_TTL,
CACHE_EXCLUDE_DRIVERS, CACHE_PREFIX.

New admin endpoints: GET /cache/status, POST /cache/clear,
POST /storage/refresh, POST /storage/refresh_one.

* chore(git): ignore .workbuddy-ai/ local assistant data

The local assistant keeps its project memory under .workbuddy-ai/, which
must never be committed. Ignore it next to the existing .codebuddy entry.

* Delete docs/pr-cache.md

* fix(cache): 缓存键单射编码、补上传失效、前缀校验、链接缓存默认关闭

- encodeCachePathSegment 改为单射编码:转义前缀 x 本身转义为 x78,
  消除 /a/b 与 /ax2fb 键碰撞(评审实测复现的跨目录串列表/直链问题);
  超长路径折叠标记用 xg + 双 FNV-1a,xg 不可能出现在普通编码结果中,
  折叠键与普通键不会互相碰撞;补参数化「任意不同路径键必不相等」回归
- 补缓存失效:/fs/upload/complete、/fs/multipart/complete、/fs/other、
  /fs/get_direct_upload_info(含 other 回退分支)写入/签发直传 URL 后
  调用 invalidatePaths,避免「上传成功但刷新看不到」
- CACHE_PREFIX 字符集校验:仅允许 [A-Za-z0-9_](KV 键约束),非法回退
  默认值并告警,防止与业务键空间 / 整表 DELETE 冲突
- CACHE_DOWNLOAD_LINK 默认改为 false(维护者建议):部分网盘直链 TTL
  极短,缓存复用易把失效直链发给用户;CACHE_LINK_TTL 保持独立选项,
  同步 .env.example / .dev.vars.example / wrangler.jsonc / README

* fix(fs): 请求级存储上下文始终传递 env,修复缓存静默换后端

getStorageRequestContext() 原先在缺少 executionCtx.waitUntil 时直接返回
undefined,把 env 一并丢掉。缓存(CACHE_DRIVER=db)与 singleflight
(SINGLEFLIGHT=db)都靠它解析后端:拿不到 env 时 getStorageBackend(undefined)
会解析成内存驱动,而业务数据仍走全局 env ctx 的持久化后端 —— 缓存与数据库
被静默拆到两个后端。

表现为「列表看似命中缓存(内容陈旧),但持久层里一条缓存键都没有、
/cache/status 计数恒为 0、/cache/clear 永远 removed=0」,排查成本很高。

真正依赖 executionCtx 的只有 waitUntil,缺失时降级为同步等待
(scheduleStoragePersistence 已支持该路径),业务语义不变;另加一条
warn-once 提示该降级,避免同类问题再次静默。

新增回归测试 src/backend/server/storage_request_context.test.ts:刻意不传
app.request() 的 executionCtx,断言缓存键必须落到 CACHE_DRIVER=db 指向的
真实后端(修复前该用例失败)。

* fix(kv): list() 同时支持 Cloudflare 与 EdgeOne 的分页语义

在真实 Cloudflare Workers 上跑起来才暴露:binding 模式的 list() 只按
EdgeOne 语义解析(keys[].key + page.complete),而 Cloudflare KV 返回的是
keys[].name + list_complete + cursor ⇒ 一条都取不到,且首轮就被判为
complete,恒返回 []。

缓存模块的 cacheListKeys() 依赖本方法,于是「缓存统计恒为 0、
清空缓存 / 刷新文件树永远 removed=0」在 Cloudflare Workers 上静默失效 ——
而键其实早已写进 KV(实测:列目录 271ms → 命中缓存 36ms,KV 中确实存在
openlist_cache_ft_* 键)。

改为两套语义都支持:键名取 item.key ?? item.name;翻页终止看
list_complete || complete;下一页游标优先用 page.cursor,其次取本页最后一个
键;游标无进展即退出,避免语义不符时死循环;并用 Set 去重。

新增 src/backend/internal/model/store/driver/kv_list.test.ts,覆盖两套语义、
cursor 翻页、空结果、游标不前进时的终止性,以及无绑定时不静默返回 []。

* Revert "fix(kv): list() 同时支持 Cloudflare 与 EdgeOne 的分页语义"

同一问题已由 PR OpenListTeam#127 (fix/125-kv-binding-list) 以更完整的方式修复:除 binding
list 兼容 Cloudflare 语义外,还收敛了 format/key.ts 的 list 配额消耗,并加了
重复页检测。为避免两个 PR 重复修改 driver/kv.ts 并互相冲突,这里把该修复从本
PR 移除,交由 OpenListTeam#127 负责。

本 PR 因此只专注缓存功能。注意:在 OpenListTeam#127 合入之前,Cloudflare Workers 上缓存的
统计(GET /api/admin/cache/status)与清空(POST /api/admin/cache/clear、
POST /api/admin/storage/refresh)仍受该既有缺陷影响 —— 键能正常写入与命中,
但列举不到,属于既有问题,非本 PR 引入。

This reverts commit fb96e65.
…penListTeam#92)

* fix(authz): enforce current role, token revocation, S3 permissions and user-root isolation

checkAdminAuth: no longer trusts the JWT role claim. Logout and demotion previously had no effect on admin routes because only payload.role was read and jti was never checked; it now re-checks the revocation list, the disabled flag and the current database role.

authUserFromReq: reject disabled users. JWT only proves the identity was valid when issued; every resolver must re-check current status. Core APIs went through getUserFromContext (which checks), but the S3 gateway reused this function and did not.

S3 gateway: PutObject now requires WRITE_CONTENT and DeleteObject requires DELETE; object paths are scoped to the caller's base_path and bucket/key are validated. Previously any authenticated user, including read-only and disabled accounts, could write anywhere.

getActualPath: fold dot segments before joining base_path so a user cannot escape their root via plain or percent-encoded traversal. verify: 225 tests pass.

* fix(authz): per-op permission bits, archive ACL/boundaries, corrupt-config guard, upload limits, WebAuthn hardening

Permission bits: rename/batch_rename/regex_rename use RENAME, remove/remove_empty_directory use DELETE, move/recursive_move use MOVE, copy uses COPY, instead of all sharing WRITE_CONTENT.

meta.write_users now enforced at write time; archive read shares the /fs/get meta+password judgement; archive list/meta require READ_ARCHIVES and decompress requires DECOMPRESS plus write, with the destination scoped to the caller root and ZIP entry names restricted to relative-only so ../ cannot escape.

Uploads re-check the actual received byte count (Content-Length could be omitted to bypass the limit). map format now throws on damaged JSON instead of returning null, so corruption is no longer mistaken for a fresh deployment that reopens public setup.

Storage: key format writes before deleting, saveDb rolls memory/cache back on failed persistence, D1 batches retry per chunk and report partial commits, S3/WebDAV scope paths to the user root, archive fetch follows redirects safely.

Sessions: revocation list uses the unified storage backend with bounded reload, multipart sessions are owner-bound, WebAuthn challenges are server-issued and single-use with origin/UP/UV checks and disabled-user rejection.

verify: 285 tests pass; SSRF probe 22 block / 8 allow; WebAuthn probe all green.

* fix(authz): meta ACL parity on fs/webdav/s3, JWT password stamp, Go user-role guards

1) 目录级访问控制补齐(消除跨协议权限旁路):/fs/dirs、/fs/search、WebDAV(PROPFIND/GET/HEAD/PUT/MKCOL/DELETE/MOVE/COPY)、S3(GET/HEAD/PUT/DELETE) 现在与 /fs/list /fs/get 使用同一套 meta 判定(密码 + read_users + write_users + meta.write 绕过开关)。/fs/dirs 的 force_root 只对管理员开放。
2) /fs/get_direct_upload_info 对齐 Go FsGetDirectUploadInfo:file_name 单层相对路径校验、meta 写 ACL、存储挂载边界校验、Overwrite:false 已存在探测。
3) JWT 增加 pwd_ts 密码指纹(对齐 Go UserClaims.PwdTS):登录/SSO/WebAuthn 三处签发,authUserFromReq/getUserFromContext/checkAdminAuth 三处校验 —— 改密后已签发令牌立即失效(历史缺陷:改密不注销旧会话)。
4) 用户管理对齐 Go:create 禁止 admin/guest;update 禁止变更 role、禁止禁用管理员;delete 按角色保护 admin/guest(不再只硬编码 id===1)。
5) WebDAV Basic Auth 接入登录防爆破(失败计数 + 指数退避锁定,与 /api/auth/login 共用),OPTIONS 匿名放行(对齐 Go WebDAVAuth)。
6) 删除(按评审决定):bcrypt/无盐单层哈希兼容与登录时自动迁移(只保留 Go 双层哈希);user/create 自动生成随机密码并回显;TS 专有 /fs/upload/{create,part,complete} 端点(分片只保留官方 /fs/multipart/*)。
7) 顺带修复 5 个改动前就存在的 tsc 报错(db_cipher.test.ts 重复 DbCipher 导入、/form 的 getUploadSizeLimit 少传参、multipart/init 的 user 空值收窄)。
验证:tsc 0 error;server 179 / model+pkg 50 / store 12 / drivers 131 全绿;新增 security_hardening.test.ts 覆盖角色保护与改密失效。

* fix(review): 上传限额覆盖 /fs/multipart/chunk 与 S3 PutObject,移除伪造 ETag

回应 PR OpenListTeam#92 评审(Request Changes):
P1-1 /fs/multipart/chunk 此前只做 Content-Length 预检,省略该头即可绕过单片上限并把整片读入内存;现改用 readBodyWithinLimit(c, true),并删掉重复的预检分支。(评审提到的 /fs/upload/part 已在上一轮删除,分片只保留官方 /fs/multipart/*。)
P1-2 S3 PutObject 完全没有体积限制(MAX_UPLOAD/MAX_UPPART 都不生效,超大请求体整包读入内存);现接入共享限额实现,超限返回 S3 风格 EntityTooLarge(400)。为此把限额辅助函数抽到 pkg/upload-limit.ts,fs.ts 与 s3.ts 共用同一份。
P2-3 PutObject 不再返回伪造的 ETag(此前是 Date.now(),并非内容摘要,会误导依赖 ETag 做幂等/续传/校验的客户端)。真实 MD5 所需的算法 WebCrypto 不提供、纯 JS 实现要整包二次扫描、hash-wasm 的 WASM 在边缘运行时可能被禁用,故选择不返回 ETag(宁缺勿错),真实摘要留作后续跟进。
P2-4 /fs/other 签发的直传 URL 由客户端直连对象存储、不经过本站,MAX_UPLOAD/MAX_UPPART 覆盖不到——已在代码注释与 PR 描述中注明该边界。
P2-5 新增显式回归测试:normalizeSegments 越根钳制、getActualPath/isWithinUserRoot 边界、readBodyWithinLimit 的「省略 Content-Length 也按实际字节数拒绝」。
另修正 main 上既有失败测试:drivers/github_releases 的 gh_proxy 断言写成「代理值+完整链接」,而实现(与 Go strings.Replace 一致)是「替换 https://github.com 前缀」——纯测试改动。
验证:tsc 0 error;server 195 / model+pkg 81 / store 12 / drivers 151 全绿。

* fix(review): WebDAV meta 403 补指引提示;upload-limit 预检函数不再导出

回应第二轮评审(v3→v4,Approve)的两条 P2:
P2-1 WebDAV 侧 meta 判定失败时只返回裸 403,用户容易误判成账号权限问题。现统一改为 metaForbidden():403 + 一句指引(WebDAV 无法携带目录密码,请改用 Web 界面或 /api/fs/* 带 password),措辞刻意不区分密码/read_users/write_users,不泄露具体限制类型。共 8 处(PROPFIND、GET/HEAD、PUT、MKCOL、DELETE、MOVE、COPY×2)统一使用。
P2-2 pkg/upload-limit.ts 的 exceedsUploadLimit 只做 Content-Length 预检,被独立调用会误判为「已通过检查」。现补充 JSDoc 明确它不是完整保护,并取消导出(模块内仅供 readBodyWithinLimit 使用),从接口层面消除误用。
新增回归测试:WebDAV 受 meta 保护路径返回带指引的 403(不触发存储层)。
验证:tsc 0 error;server 196 / model+pkg 81 / store 12 / drivers 151 全绿。

---------

Co-authored-by: pikachuren <pikachuren@example.com>
基于城通网盘官方 OpenAPI (https://openapi.ctfile.com/, rest.ctfile.com/v1)
实现存储驱动,响应 OpenList#1217 的城通网盘接入需求:

- 支持公有云/私有云挂载(public/private 命名空间)
- 目录浏览(folder/list + file/list 自动翻页与防死循环保护)
- 下载直链(file/fetch_url;非 VIP 限速取决于账号等级)
- 建目录、重命名(文件/文件夹)、删除(入回收站)、移动
- 上传(file/upload 预签名地址 + multipart 直传,md5-size 校验和可秒传)
- Session 失效(401)转换为「开放接口登录密钥管理」重新生成的明确提示
- folder_id / file_id 前缀(d/f)在文档与社区实现间要求不一致,
  调用层内置 raw/stripped 双形态自动回退;批量 ids 内置字符串/数组双形态回退
- /task/refresh 定时巡检复用 checkCookieValid,Session 过期在后台标记
- 驱动别名:ctfile / ctfilepan / 400gb / 城通网盘

说明:城通网盘已关闭服务器端密码登录(/v1/user/auth/login 恒返回 410,
网页密码登录要求扫码验证),因此驱动仅支持开放接口 Session 方式。

验证:
- tsc --strict 全量类型检查通过(ctfile 目录及其依赖图)
- node:test 单测 10/10 通过(鉴权、ID 形态回退、翻页、秒传校验和等)
- 对 rest.ctfile.com 真实 API 冒烟:未配置/失效 Session 均返回可操作提示
- prettier 格式检查通过

Co-authored-by: MiMo (OpenCode) <noreply@opencode.ai>
…penListTeam#131)

* feat(drivers): add Cloudflare R2 driver (Workers binding + S3 API)

新增独立 R2 驱动,同时支持两种接入方式(binding 优先,自动回退):

- Workers R2 bucket binding 直连:无需 API Token,绑定名可在存储配置指定(bucket_binding),未指定时按 R2/R2_BUCKET/CF_R2/OPENLIST_R2 顺序探测;驱动实例跨请求复用,绑定在每次操作时从请求级 env 上下文(getEnvCtx)重新解析,避免绑定对象随 isolate 滞留。

- S3 兼容 API:account_id 自动推导 endpoint(https://<account>.r2.cloudflarestorage.com),或显式 endpoint;复用 S3Driver 的签名/列表/预签名实现,R2 固定 region=auto。

- 目录语义与 S3 对齐(delimiter 列目录、占位对象、root_folder_path 前缀、递归 copy/move/remove、批量删除)。

- 下载链路:binding 模式返回空 raw_url 触发服务端流式下载(createReadStream,Range 换算 R2 的 offset/length);配 custom_host(r2.dev/自定义域名)时改走公开直链。binding 不提供预签名直链(Workers 绑定无该能力),直传仅在 api 模式提供并给出明确指引。

- 注册与配置:storage.ts 路由 r2/cloudflarer2/cfr2/r2native;admin driverConfigs 新增 R2 表单并让 r2 别名指向新驱动(历史 driver=S3 的 R2 配置不受影响);wrangler.jsonc 补 r2_buckets 绑定说明;README 驱动清单更新。

- 测试:9 个用例覆盖绑定解析、cursor 翻页、root 前缀、mkdir/remove/rename/move/copy、Range 流式读取、api 回退与路由别名(内存版 R2Bucket mock)。

* docs(readme): sync the R2 driver entry to all localized READMEs

主 README 的驱动清单新增了 Cloudflare R2,本次把同一句同步到 readmes/ 下全部 12 个语言版本(ar/de/en/es/fr/hi/it/ja/ko/pt/ru/zh-TW),并按各文件既有的标点惯例处理(中日韩印用全角括号与「、」,其余用半角括号与逗号,阿拉伯语用「،」)。

* docs(readme): slim config sections, link to the docs site, fix stale facts

- README 与 12 个语言版本:把安装向导、JWT_SECRET、DB_* / CACHE_* / SINGLEFLIGHT 等大段配置说明压缩为表格 + doc.oplist.org 链接,正文只保留默认可用路径与建议显式配置的项

- 顺带修正过时描述:前端为 SolidJS + Hope UI(原写 React 19 + Ant Design)、运行平台补阿里云 ESA、数据存储口径改为 D1/KV/Blob/DO 与外部 MySQL

- .env.example / .dev.vars.example:缓存配置说明由「详见 README」改为指向文档站

* refactor(drivers): name the R2 driver "Cloudflare R2" and fix name-based lookups

- 驱动名由 R2 改为 Cloudflare R2(与既有的 GitHub Releases 等带空格命名一致);
  normalizeDriver 把 R2 / r2 / cloudflare_r2 / cfr2 全部归一到它。
  本分支可能已存在的 driver=R2 记录会在下次保存时自动改名,运行时两种写法
  都能命中新驱动。
- 修 server/fs.ts 的直传工具判定:改为按「归一化后的驱动名」匹配(小写 + 去非
  字母数字)。原实现拿原始字符串做 ^ 锚定正则,驱动名带空格后会漏判,导致
  api 模式下 s3_presigned 直传入口消失。
- R2 的直传工具只在 api 模式宣告:binding 无预签名能力,新增
  r2CanSignDirectUpload()(判定与 R2Driver.resolveMode 一致),
  不再向前端暴露必然报错的入口。
- 删除 op/storage.ts 的 S3 分支里已成死代码的 r2(已被上方 R2 分支接管)。
- 测试 +2:驱动名归一(含 S3 家族不被吞掉的回归护栏)、直传能力三态判定。

* chore(git): ignore the local-only wrangler.ui.jsonc config

该文件是本机部署配置(含真实 JWT_SECRET、KV namespace id 与 R2 绑定),与 .gitignore 中已有的 wrangler.local.jsonc / wrangler.d1.jsonc 属同类「本地专用配置、可能含真实密钥、禁止入库」条目。

---------

Co-authored-by: pikachuren <pikachuren@example.com>
…am#125) (OpenListTeam#127)

kv 驱动 binding 模式的 list() 只按 EdgeOne 语义读取(item.key /
page.complete),而 Cloudflare Workers KV 返回 item.name /
list_complete + 服务端 cursor。在 Cloudflare 上 keys 恒为空、
complete 恒 false,cursor 被赋值为 "",每轮以同一游标重复拉取同一页
直到 guard 上限:单次 list() 消耗上千次真实 KV list 请求,打满免费版
1000 次/天的 list 配额,随后 list 抛错 → saveDb 抛异常 → 初始化
500(issue OpenListTeam#125)。

修复:
- list() 逐页识别两种平台形态:名称字段 name/key、末页判定
  list_complete/complete、游标优先回传服务端 cursor(EdgeOne 回退为
  本页最后一个 key);新增重复页检测与「游标无法推进即中止」防护,
  未知形态最多多拉一页,绝不再空转。
- health() binding 模式增加一次 list 探测(prefix 不命中真实键,恰好
  1 次操作):此前只探测 get,list 配额耗尽/接口异常的环境会被
  /env_check 误报为「存储就绪」。health 仅在手动诊断路径被调用,
  不放大每请求配额消耗。
- key 格式 load/save 各自的逐表 list(6 张表 = 6 次)收敛为 1 次全局
  list 后按表前缀分组,结果与旧实现一致;免费版可承受的读写轮次
  提升约 6 倍。

新增回归测试 5 组:Cloudflare 两页游标分页、EdgeOne 末 key 游标、
未知形态防空转、health list 探测(配额耗尽报不可用)、key 格式
load/save 各恰 1 次 list。store + kv 测试 17/17 通过。

Fixes OpenListTeam#125

Co-authored-by: MiMo (OpenCode) <noreply@opencode.ai>
Co-authored-by: Pikachu Ren <40362270+PIKACHUIM@users.noreply.github.com>
… positives (OpenListTeam#97)

isSafeUrl 的 IPv6 判定原本是对 hostname 做子串匹配:

    const ipv6Patterns = ["::1", "::ffff:127.", "::ffff:10.",
      "::ffff:172.", "::ffff:192.168.", "::ffff:169.254.", ...]
    for (const pattern of ipv6Patterns) {
      if (host.includes(pattern)) return false
    }

根因:WHATWG URL 会把 IPv4-mapped IPv6 归一化成十六进制并保留方括号。
`new URL("http://[::ffff:169.254.169.254]/").hostname` 得到
`[::ffff:a9fe:a9fe]`,因此 `includes("::ffff:169.254.")` 恒为 false;
该 host 也不以字面量 "169.254.169.254" 结尾,同样躲过了 dangerousHosts。
子串匹配对归一化后的形式完全失效。

影响:以下目标修复前全部被放行,可直达云元数据 / loopback / 私网 ——
[::ffff:169.254.169.254]、[::ffff:a9fe:a9fe]、[::ffff:7f00:1]、
[::ffff:127.0.0.1]、[::ffff:127.1]、[0:0:0:0:0:ffff:127.0.0.1]、
[::ffff:a00:1]、[::ffff:c0a8:101]、[::ffff:ac10:1]、[::ffff:0:0],共 9 种。

可达性:SSRF 校验用在 raw.ts 的 safeProxyFetch(raw.ts:90 逐跳校验,
配合 raw.ts:97 的 redirect: "manual")、302 直链降级(raw.ts:177/259/573)
与 down_proxy_url(raw.ts:540)。/p /d /sd 在默认配置下无需鉴权即可驱动
整条代理链路,因此只要管理员配置了第三方实例(openlist / alist_v3 /
url_tree 等)且该实例返回恶意 raw_url,匿名用户一次请求即可让服务端去取
元数据凭据。属于防御纵深缺陷:host 来自驱动而非普通用户输入,故不构成
「任意匿名用户直连内网」,但 SSRF 黑名单作为最后一道防线在此失效。

同一处子串匹配还有反向问题:黑名单项 "::1" 会命中任何尾段含 "::1" 的
合法公网 IPv6,[2606:4700:4700::1111](即 1.1.1.1)与 [2400:cb00::1]
被误判为 loopback 而拦截,会打断真实下载链路 —— 原实现自带的可用性缺陷。

修复:改为数值分组判定,不再依赖字符串形态。

- 新增 parseIpv6Groups:展开 :: 压缩、剥离方括号、把尾部内嵌点分 IPv4 折成
  两个十六进制分组,输出 8 个 16 位分组;非法输入返回 null(fail-closed)。
- 新增 isSafeIpv6Literal:基于分组判定 ::1 / :: / fe80::/10 / fc00::/7,
  并对 ::ffff:a.b.c.d 与 ::a.b.c.d 取低 32 位后复用 IPv4 规则。
- 新增 isSafeIpv4:把原先内联的 IPv4 区间规则抽成函数,供 IPv4-mapped
  复用,避免两处规则随时间漂移。
- isSafeUrl 中对 [ 开头的 host 走上述判定并直接返回,其余分支一字未动。

保持 fail-closed:解析失败一律视为不安全并拦截。NAT64 保留前缀
64:ff9b::/96 未整体封禁(该前缀是标准转换前缀,整体封会破坏真实部署)。

验证:新增 src/backend/pkg/http_ssrf.test.ts(7 个用例,覆盖 10 种
IPv4-mapped 写法、原生 IPv6 各区间、IPv4 私网/元数据、协议与危险域名、
合法公网 IPv6 与云厂商 hostname、allowHosts 精确匹配)全部通过。
对修复前后做 36 例对照:修复后 0 例不符预期,其中 9 例由放行转为拦截,
2 例由误拦转为放行。

另:test:all 此前不覆盖 src/backend/pkg/**,导致同目录下已有的
crypto_security.test.ts 与 cipher_algorithms.test.ts 从未进入 test:all。
补充 test:pkg 脚本并接入 test:all。

Co-authored-by: wumingzhinu <wumingzhinu@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…th (OpenListTeam#121)

* fix(139,fs): throw on missing folder path segment; don't cache Virtual results

1. drivers/139/driver.ts — resolveCatalogId() used `break` when a path
   segment was not found in the folder listing, silently returning the
   last successfully resolved catalogId (typically the root).  list()
   and get() then appeared to succeed but returned root directory
   contents for any path whose intermediate folder could not be matched,
   producing the 'always shows root' symptom.  Changed to `throw` with
   a message that includes the missing segment and full path so
   misconfigured account types are immediately visible in logs.

2. server/fs.ts — when resolvePath() returns isVirtual=true the storage
   object is null and cacheExpiration was set to `undefined`.  The front
   end interprets undefined as a non-zero TTL and caches the Virtual
   result; subsequent requests hit the cache and keep showing the Virtual
   provider even after the real storage comes online.  Fixed by returning
   0 for Virtual paths so the front end never caches them.

* fix(139): 路由 host 硬失败 + 目录 id 并集回退 + 候选 URL 重试 (OpenListTeam#112)

OpenListTeam#121 把 resolveCatalogId 的 break 改为 throw 后问题依旧,因为还有三条
绕过 throw 的静默回退路径:

1) init() 捕获 queryRoutePolicy 失败后仅告警,personalHost 停在默认域
   yun.139.com。新版个人云的 /file/list 只存在于路由下发的 host 上,打到
   默认域会命中旧版端点:该端点忽略 parentFileId、恒返回根目录内容,且
   返回 HTTP 200 + success:true —— 按名解析目录因此仍然成功,任何 throw
   都不会被触发,表现为访问子目录却看到根目录全部文件夹,点击文件又出现
   /手机软件/阅读.apk/阅读.apk 自我嵌套。现对齐 Go 官方驱动
   ensurePersonalCloudHost:personal_new 拿不到路由 host 即初始化失败;
   旧版个人云走 /orchestration 硬编码路径,不依赖路由,仍只告警。

2) /file/list 目录项在部分账号/响应形态下以 catalogId|catalogID 携带 id
   而非 fileId(社区实现 gaozhangmin/boxplayer 的字段映射可佐证)。只读
   fileId 会拿到 undefined,listFiles(undefined) 回退 parentFileId="/"
   静默列出根目录。现改为并集回退,并在任何 id 都拿不到时显式抛错。

3) 路由下发的 host 可能带 /hcy 也可能只暴露 /orchestration,request()
   此前只有单一 URL。现按候选顺序尝试,仅 404 / "route not found" 才回退,
   其余错误原样抛出并附带请求 URL。

顺带:目录名比较改为 trim + NFC 归一化(中文名跨归一化形态不再误判为
不存在);分页游标沿用 Go 官方确认的 data.nextPageCursor,并防御游标
不前进导致的死循环。

新增 6 组回归测试(覆盖 issue OpenListTeam#112 两个症状、init 硬失败、id 并集回退、
Unicode 归一化、候选 URL 重试语义),9/9 通过。

* fix(139): 内容级自检 + 候选 URL 扩展,修复 OpenListTeam#112 静默返回父级目录

现象:部署后子目录(如 /139/手机备份)仍返回根目录内容,且不报任何错。

原因:某些路由 host 形态会命中"忽略 parentFileId、恒返回父级/根目录内容"的
端点,并且返回 HTTP 200 + success:true。此前只按 404 / "route not found"
回退候选 URL,这种"静默错内容"永远发现不了,也不会触发回退。

本次改动:
- 新增内容级自检 detectWrongListing():唯一判据是"一个节点不可能出现在它
  自己的子项里"。请求文件夹 F 的列表里出现 F 自己(/手机备份 的列表里出现
  手机备份、/手机软件 的列表里出现 手机软件)即为服务端忽略 parentFileId
  的铁证,此时将该候选视为失败;
- request() 支持 validate 回调:候选响应过不了自检就换下一个候选 URL,
  全部候选都不过才抛错,错误信息列出所有试过的 URL(会写入存储状态——
  EdgeOne 上看不到函数日志也能据此定位);
- 候选 URL 扩展为多个等价形态(host 直拼 / host+/orchestration /
  host+/hcy+/orchestration / 去掉 /hcy 的两种形态),顺序保持"现网已工作
  形态优先",健康账号仍只发一次请求;另按 boxplayer 语义把路由类业务码
  9000/9008/9100 也视为可回退。

回归测试:新增 3 组(自检命中后自动换候选并返回正确内容、全部候选都返回
错内容时报错并列出 URL、健康账号只产生一次请求),12/12 通过;tsc 无错误。

---------

Co-authored-by: Pikachu Bot <pikachuim@foxmail.com>
Co-authored-by: pikachuren <pikachuren@example.com>
根因:Worker 移植版 init() 只有 access_token / refresh_token 两条路径,
Go 原版的短信登录链路(captcha/verification/verify/signin)整个缺失,
用户按报错提示填 phone_number + verify_code 必然失败。

- util.ts: GuangYaPanClient 补 ensureCaptchaToken / requestVerificationID
  (captcha 失效自动刷新重试)/ loginBySMSCode(verify -> signin),
  回调泛化为 onStateChanged 以回写任意配置字段
- driver.ts: init 认证优先级对齐 Go 版(access_token -> refresh_token ->
  SMS 两阶段);normalize 补齐 verify_code/verification_id/captcha_token/
  send_code
- admin.ts: 表单补 captcha_token / send_code / verification_id 字段与帮助文案
- storage.ts: 持久化回调改为合并任意变更字段(空串用于清除一次性字段)
- 新增 5 组 mock fetch 测试,11/11 通过

Co-authored-by: MiMo (OpenCode) <noreply@opencode.ai>
…ap captcha retries (OpenListTeam#138)

跟进 PR OpenListTeam#136 评审意见:
- Blocking 1:构造参数/私有字段统一为 onStateChanged(与 client 属性一致)
- Blocking 2:新增 GuangYaPanStateChanges 接口替换 Record<string, any>
- Non-blocking 1:requestVerificationID 显式重试上限 MAX_CAPTCHA_RETRIES=2
- Non-blocking 2:新增错误路径测试(错误验证码、过期 verification_id、
  captcha 持续失效达到上限),共 3 组 mock fetch 用例
- Non-blocking 4:确认 driver/util 无敏感字段日志输出

Co-authored-by: nike64542-byte <nike64542-byte@users.noreply.github.com>
…nListTeam#139)

针对 issue OpenListTeam#112 定位(见 OpenListTeam#112 评论):EdgeOne 平台间歇性丢失/截断 POST 请求体,
被各 handler 的 c.req.json().catch(()=>({})) 吞掉后 body.path || "/" 静默降级
为根目录,表现为"访问子目录返回根目录内容且无报错",错误列表还会被缓存。

- router: 新增 jsonBodyGuard 中间件(/api/fs|share 空/非法 JSON 400,
  其余端点空 body 放行、非空非法 400,/api/task/* 显式跳过)
- fs: /list 与 /dirs 实现 refresh=true 失效文件树/链接缓存
- 不含 139 driver 改动(上游 OpenListTeam#121 已覆盖);无部署配置变更

评审回应(pikachuren AI 审计):
- P1: /api/fs/list 虚拟目录 cacheExpiration 恢复上游 0 语义(=不缓存,
  "always shows root / Virtual provider" 防御性注释一并还原),不再引入
  可能被上层解释为"未设置→继承默认 TTL"的 undefined;
- P2: jsonBodyGuard 签名收紧为 hono Context(import type Context)。

Co-authored-by: nike64542-byte <nike64542-byte@users.noreply.github.com>
* feat(seed): complete transfer seed API surface

- add /seed/diagnose for per-format conversion diagnostics

- add /seed/upload_parse for multipart seed uploads

- add /seed/update_channels alias and /seed/quick_save unified entry

- honor seed_single_direct_preview and accept SEED_SOURCE_ALLOWED_HOSTS

* ci(seeds): add focused seed regression tests

Runs the worker type check and seed route/codec unit tests when seed sources change.

* feat(seed): reuse driver hashes and add hash-based rapid upload

- generate seeds from existing driver hashes instead of downloading files

- report per-format feasibility and piece-hash availability in capabilities

- add rapidUpload for 189pc, baidu, 123pan, pikpak, 115open, 189_tv and aliyundrive_open

- keep BT seeds offline-downloadable in parse capabilities

* feat(seed): align remaining seed capabilities with the Go backend

- support remove_files, output_path and share_status in seed update

- add transit_path relayed transfer for seed saves

- create openlist-share sources for include_share/share_files

- add legacy /fs/torrent parse/upload_parse/rapid_upload/generate

- add hash-based rapid upload for 115, 123_open and quark_open

* feat(seed): expose save capabilities and conversion states

- report put_url/offline_download support and conversion feasibility

- include result names for transfer responses

- allow drive-side putUrl uploads when a driver implements them

* fix(seed): return the saved seed path from update and convert

* chore(ci): drop seed test workflow

* test(seed): cover target_format conversion contract

* docs(seed): document seed environment variables

* fix(seed): preserve GCID hashes in seed round trips

Go seeds can carry a GCID for Thunder/PikPak rapid upload. Keep the optional field when normalizing, applying the hash matrix, and encoding OSS or torrent metadata so conversion does not silently drop it.

---------

Co-authored-by: pikachuren <pikachuren@example.com>
… semantics (OpenListTeam#77)

解决与 main 的冲突:main 已将 F-11 user/create 语义改为"不生成随机密码、
不回显密码(对齐 Go SetPassword(""))",本 PR 在此基础上保留原修复意图:
- run() 隔离 harness(模块级 db 缓存重置 + 一次性内存 store + ADMIN_PASS 恢复)
- test 包装 concurrency:false,并注明 ADMIN_PASS 为进程级全局状态的并发约束
  (回应评审建议)
- seed 显式断言 saveDb 成功并默认 force:true(写入守卫仅约束生产,测试数据需要)
…stTeam#143)

- admin.ts: /admin/driver/names 与 driverConfigs 增加 QuarkUcTv
  (variant/refresh_token/device_id/order_by/order_direction/link_method)
- storage.ts: createDriver 分支补充 quarkuctv 归一化名,路由 QuarkUcTv -> drivers/quark_uc_tv
- quark_uc_tv/util.ts: variant 大小写兼容(UI 传 "Quark"/"UC")

Closes OpenListTeam#115
…istTeam#152)

* fix(115): 移除失效的旧 115 驱动,使 115/115Open 统一走 Pan115Driver

## 问题

后台添加 115Open 驱动后,存储状态恒为 [115] API error (),浏览任何目录都失败。

## 根因

仓库里存在两套 115 驱动实现:

- src/backend/drivers/115/(类 Client115)—— 已损坏
- src/backend/drivers/115open/(类 Pan115Driver)—— 正确,自 Go 版移植

storage.ts 的驱动工厂按 normDriver(toLowerCase().replace(/[^a-z0-9]/g,""))
做 if-else 链式匹配。DB 里的 115Open 归一化为 115open,被靠前的旧驱动分支
截走;下方正确的 Pan115Driver 分支因列出同样的 115open 而永远走不到,
成为死代码。

旧驱动 Client115 有三处错误,叠加导致报错信息为空:

1. URL 路径重复:util.ts:10 的 API_BASE 已含 /open,util.ts:100 又请求
   "/open/ufile/files",实际请求 /open/open/ufile/files。实测返回
   {"state":false,"message":"Method POST does not exist","code":0},
   且响应里没有 error / errno / errcode 字段。
2. HTTP 方法错误:util.ts:38-70 的 request() 用 POST + JSON body,
   而 115 开放平台要求 GET + query string。
3. 错误码字段名错误:util.ts:65-67 的错误模板取
   data.error || data.errmsg || data.errno || data.errcode,
   上述字段一个都不存在,于是输出 [115] API error () —— 括号里空空如也,
   与用户看到的 status 一字不差。

## 改动

1. 删除 src/backend/drivers/115/(driver.ts / types.ts / util.ts,共 489 行)。
   grep 确认无其他引用。
2. storage.ts:删掉 Driver115 的 import 与整个旧分支,留注释说明
   115 / 115Open 统一走 Pan115Driver。normDriver.startsWith("115")
   分支已能覆盖 115 / 115Open / 115cloud / 115netdisk 等所有变体。

## 验证

- npm run lint(tsc --noEmit)通过。
- 测试全过:drivers 189、server 206、model 39、regress 71、cache 24、
  pkg 50、189 20、store 13,合计 626 pass / 0 fail。
- 用真实 115 token 对 Pan115Driver 做全链路验证:init、list、get、
  mkdir、rename、delFile、refreshToken 均成功。
- 线上部署后存储 status 由 [115] API error () 变为 work,/115 可正常浏览。

原配置里的 access_token / refresh_token 已过期(40140126 / 40140120),
换新 token 后 status 恢复 work。该问题与本次代码修复相互独立。

* fix(115): 修复子目录解析失败(路径双斜杠 + 错误对象缺 code)

## 问题

115 存储挂载后根目录可浏览,但进入任意子目录(或访问子目录下的文件、
新建/重命名/移动)时报:

    115 网盘 API 错误(code 20001 目录名称不能为空。)

## 根因

两个缺陷叠加,导致一次性路径解析失败且兜底逻辑被跳过:

1. resolveFolderId 拼接的 fullPath 多出一个斜杠(driver.ts:184):

       const fullPath = `/${rootId === "0" ? "" : rootId}${clean === "/" ? "" : clean}`

   root_id 默认 "0",于是 `/` + `` + `/测试` = `//测试`。
   115 开放平台对 folder/get_info 的 path 参数严格校验:

   | path | 返回 |
   | --- | --- |
   | `/测试` | state:true,返回 file_id |
   | `//测试` | code 20001 目录名称不能为空。 |
   | `测试`(缺前导斜杠) | code 990002 参数错误。 |

2. request() 的业务错误是裸 Error,不带 code(util.ts:231),
   只有 430004 分支才设置 err.code。resolveFolderId 的回退判断
   `if (e?.code !== ERR_OBJECT_NOT_FOUND && e?.code !== 990002) throw e`
   因 e.code === undefined 而直接 rethrow,逐层解析兜底永远走不到
   —— 而逐层解析(用 ufile/files 列父目录)本身是通的。

根目录恰好能浏览,是因为 clean === "/" 时提前 return 了。

## 改动

1. driver.ts:root_id 非 "0" 时才拼前缀,避免产生 "//xxx":

       const rootPrefix = rootId === "0" ? "" : `/${rootId}`
       const fullPath = `${rootPrefix}${clean}`

2. driver.ts:回退条件增加 20001(空目录名),覆盖更多 path 相关失败。

3. util.ts:所有业务错误(含 token 刷新后重试仍失败)统一把 code
   挂到 Error 对象上,调用方才能按 code 决定是否回退。

## 验证

- 本地用真实 115 token 跑通 list("/")、list("/测试")、get(apk)
  (5874314 字节,成功取得下载链接)。
- npm run lint 通过。
- 测试全过:drivers 189、server 206、regress 71、189 20、
  model 39、cache 24、pkg 50、store 13,合计 612 pass / 0 fail。
- 线上部署后:
  - POST /api/fs/list {"path":"/115/测试"} → code 200,返回 apk
  - POST /api/fs/get {"path":"/115/测试/...apk"} → code 200,raw_url 正常

---------

Co-authored-by: nvdadmin <nvdadmin@users.noreply.github.com>
PikPak 服务端在 usage=CACHE 下对非媒体文件(txt/nfo/md5 等)不返回
web_content_link,也没有 medias,导致 /api/d 返回 404「该存储驱动未返回下载链接」。
此时回退 usage=FETCH 再请求一次文件详情;媒体文件 CACHE 模式有 medias 时保持单次请求。

新增两条单测:回退路径(两次请求,第二次 usage=FETCH)与负对照(有 medias 只请求一次)。

Co-authored-by: Claude <noreply@anthropic.com>
lava-lake and others added 20 commits October 26, 2026 17:30
…penListTeam#153)

Cloudflare Workers / Edge 运行时没有 canvas,QRCode.toDataURL() 会抛
"no canvas element",/api/auth/2fa/generate 直接 500。改为
QRCode.toString(url, { type: "svg" }) 生成 SVG 并编码为 Data URL,前端 <img> 可直接显示。

同步更新 auth_2fa.test.ts 对 Data URL MIME 的断言。

Fixes OpenListTeam#88

Co-authored-by: Claude <noreply@anthropic.com>
…istTeam#140) (OpenListTeam#141)

* 🐛 fix(webdav): 解析上游 PROPFIND 响应时反转义 XML 实体

- 【修复】新增 decodeXmlEntities,对 displayname 与 href 做 XML 实体与数字引用反转义
- 【修复】href 改为先 XML 解码再 URI 解码,避免百分号编码的字面实体被二次解码
- 【修复】越界与代理区数字引用透传原样,避免畸形上游响应触发 RangeError 导致列表失败

* 🐛 fix(webdav): 收紧 XML 实体解码的字符校验与测试保真度

- 【修复】数字引用按 XML 1.0 Char 集校验,拒绝 NUL、0xFFFF 及控制字符码点
- 【测试】href 用例改用真实 XML 实体并补 path 断言,使其能检出 XML 解码被移除
- 【测试】新增 XML-invalid 码点透传用例,锁定守卫行为
* fix(esa): support EdgeKV health checks in map mode

Issue OpenListTeam#159 reports that Alibaba Cloud ESA initialization is blocked by  for DB_FORMAT=map. ESA's EdgeKV API exposes get/put/delete but no list method. The KV binding health check called list unconditionally even though map format only reads/writes the single openlist_config key.

Skip the list probe unless the effective DB format is key, which requires listing. Match backend.readFormat semantics, including default map and DB_DRIVER=json compatibility, and return a clear error if key format is selected without list support. Reuse esa-entry's existing raw EdgeKV probe for health checking so the check does not add another per-request KV subrequest and can surface probe failures hidden by the wrapper's null fallback.

Validation: kv.test.ts 7/7; test:store 13/13; test:server 207/207; npm run lint passed; mutation check confirmed unconditional list probing fails the new regression test; git diff --check passed.

* fix(esa): share readFormat with the backend factory

健康检查曾复刻一份 DB_FORMAT 解析,与 backend.ts 各写一遍迟早漂移;下沉到 store/config.ts 后由驱动与工厂共用同一实现(含 env 优先级、缺省 map 与 DB_DRIVER=json 别名)。wrapEsaEdgeKV 的 healthProbe 改为可选:缺省时做一次真实探测,而不是假定可用。补一条缺省 map 不探测 list 的回归用例。

---------

Co-authored-by: pikachuren <pikachuren@example.com>
…penListTeam#172)

按 OpenListTeam#163 增量评审的两条 P2:
- store/config.ts 的 readEnv 注释写反了:`env || process.env` 只在 env 整体
  为空时才回退,env 存在但缺该键**不会**逐键回退。注释改成实际语义,并注明
  「改成逐键回退」会改变 KV/D1 配置解析结果,属破坏性变更。
- ESA 入口的原始探测 key(__openlist_probe__)与 kv 驱动 health 的
  __health_check__ 不同名。统一导出 KV_HEALTH_PROBE_KEY,三处(kv.get、
  kv.list 前缀、esa-entry 探测)共用,便于核对 ESA 每请求的 KV 子请求配额。

验证:tsc --noEmit 0 error;kv.test.ts 8/8。

Co-authored-by: pikachuren <pikachuren@example.com>
… (OpenListTeam#171)

* fix(esa): 上传链路改用 Uint8Array,修复 Buffer is not defined (OpenListTeam#169)

ESA 边缘运行时没有 Node 全局 Buffer(CF Workers 靠 nodejs_compat 注入),旧实现 pkg/upload-limit.ts 的 Buffer.from(await c.req.arrayBuffer()) 会在读请求体时抛 Buffer is not defined,导致 /fs/put、/fs/form、/fs/multipart/chunk、S3 PutObject、WebDAV PUT 全部 500。

改为 Uint8Array:readBodyWithinLimit/putItem/StorageDriver.put 类型收敛到 Uint8Array,url_tree 用 TextDecoder 解码,chunk 驱动不再包 Buffer.from;新增删除全局 Buffer 的回归用例。

* fix(driver): document the Uint8Array upload contract and drop Buffer from putBlob

按 OpenListTeam#171 评审的两条 P2:
- StorageDriver.put 的注释写明实现方不得使用 Buffer 专有方法
  (toString(encoding) / Buffer.from / Buffer.concat),并给出 TextDecoder /
  btoa 的替代;Cloudflare 靠 nodejs_compat 注入 Buffer、ESA 没有,同一段代码
  两边行为不同。
- upload-limit.ts 补注:new Uint8Array(arrayBuffer) 是视图而非拷贝,避免后人
  误以为多了一次全量拷贝又改回 Buffer。

顺带修掉评审提到的同类遗漏:drivers/github/util.ts 的 putBlob 仍在用
Buffer.from(content).toString("base64"),ESA 上传会抛 "Buffer is not
defined";改为纯 JS 的 bytesToBase64(分块 + btoa)。

验证:tsc --noEmit 0 error;drivers 197/197;security_hardening 8/8。

---------

Co-authored-by: pikachuren <pikachuren@example.com>
… backend (OpenListTeam#148)

* feat(media): implement media library, Emby and Subsonic APIs for Worker

* feat(media): support emby series/album detail and subsonic enc password

* feat(media): complete media library, Emby and Subsonic parity with Go backend

- scan: ID3v2/Vorbis/M4A tags, .lrc lyrics, path_merge, season/episode parsing, MIME, EPUB/CBZ/PDF covers

- scrape: concurrent worker pool, TMDB fuzzy matching, image/book local scrape, scrape stats

- storage: shard items/user_data/thumbs, diff-based writes, automatic migration from single JSON

- emby: Season hierarchy, Sessions progress, PlaybackInfo HTTP stream fix, anonymous images, websocket

- subsonic: XML/JSON/JSONP, formPost, playlists/rating/lyrics/queue, auth rate limiting

- api: progress semantics, favorites filter, real delete_invalid, import/export scope, /me/appkey, STRM

- security: cover proxy hop-by-hop SSRF checks + magic sniffing, media type access check, AES-GCM app keys

- transcode: force-disabled on Worker (settings hidden and rejected, API reports unsupported)

* fix(media): 收紧封面 MIME 信任与两个匿名入口

- 封面只信魔数:ID3 APIC / FLAC PICTURE / M4A covr 不再采信声明的 MIME;persistCover 去掉 mime 兜底,非栅格图(含 SVG)直接丢弃
- serveCover 的 data: 分支解码后嗅探 MIME 并补 CSP sandbox;/media/thumb 同样按魔数复核
- 匿名封面改为「token 用户 -> guest」加载媒体库,删除只过滤 hidden 的 all:true 分支,guest 缺失或被禁用返回 401
- 限流 IP 优先取 cf-connecting-ip,持久化写入增加 IP 格式校验与每分钟上限
- 禁止 guest 创建应用密钥,并限制每用户最多 20 个
- STRM 输出根为 "/" 时不再误判越界,抽出 withinOutRoot 并补用例

* fix(media): 对齐 Go 侧最新媒体库改动

- Emby 用户 Id 改为稳定 GUID 形状,/Users/{id} 兼容 GUID / 数字 / 用户名三种形式(对齐 2b6b8cbf)
- 应用密钥改用 prefix 预筛 + 解密常量时间比对;Emby token、密码与 Subsonic apiKey 三处统一
- 限流阈值与 Go 一致:5 次失败锁定 5 分钟;锁定到期后计数清零,避免「错一次锁五分钟」
- 限流 key 加固:用户名截断到 128 字符,buckets 增加硬上限(伪造用户名不能把 Map 撑爆)
- Subsonic 只对真正带凭据(apiKey 或 u+t/p)的失败计数,裸探测不再刷新锁定
- /System/Endpoint 按来源 IP 判断本机/内网;/System/Info 不再返回监听端口
- Emby 兼容版本对齐 4.10.0.5
- 新增限流单测:IP 来源优先级、IP/用户名双维度、锁定到期清零

* fix(media): 修复封面越权与分片持久化丢数据

封面接口统一走 coverOfId:mv/so/ep 对目标条目执行 canAccessItem,Series/Season/Album/Artist 逐条校验后取封面;删除重复注册的封面路由,Subsonic getcoverart 复用同一实现。

分片写入逐次检查结果,失败立即中断且不提交 meta,快照只在全部成功后推进;writePersistedSecret 把驱动返回 false 也视为失败;saveMediaMeta 纳入 saveChain 并在失败时失效缓存。

提交 meta 前与持久化版本合并分片索引与 next_id;读取时按前缀枚举分片,避免多实例互相覆盖导致分片不再被读取。

新增 store_persist.test.ts 与封面权限回归用例。

* fix(media): reject parent segments in STRM output paths

withinOutRoot() 只做字符串前缀比较,/out/../../etc/x.strm 以 /out/ 开头会被判成根内路径,刮削得到的外部名称一旦含 .. 就能写到输出目录之外。改为先拒绝任何 .. 段再比较前缀,并补三条回归用例。

* fix(media): reject encoded dot segments in STRM output paths

评论指出的第二点是编码点段:%2e%2e 在存储层解码后同样变成 "..",
仅检查原始段仍可穿越。withinOutRoot() 现在对每个段先 decodeURIComponent
(非法编码按原文处理)再判断是否为 "." / "..",并补两条编码点段用例。

* chore: drop local triage scratch files from the PR

* fix(media): address the remaining review threads on the media/Emby PR

剧集身份与播放:
- Series 身份改为按目录派生(不再用 scraped_name),单集标题存入
  episode_title,避免同一部剧按集裂成多个 Series(#1/#3)
- 单集文件也能解析 ep-<item>-<season>-<episode>,修复播放 404(#2)
- episode id / 进度 / 续播 / 下一集判定都带季号,旧 id 仍兼容(OpenListTeam#11)
- STRM 按每集自己的季号导出;合并专辑逐曲生成(OpenListTeam#23/OpenListTeam#24)
- 未合并单集与合并专辑的路径鉴权覆盖到最终文件(OpenListTeam#13)
- 按用户签发的 STRM 链接遵守 link_expiration(OpenListTeam#14)

媒体 API 与扫描:
- 专辑聚合逐曲鉴权,缓存按实际校验路径去重(OpenListTeam#12)
- 管理端改用 adminAuthMiddleware,不再接受 ?token=(OpenListTeam#15)
- scan_paths 删除/清空校验 id,非法 id 不再误删孤儿条目(OpenListTeam#16)
- 导入后移除无效的 invalidateMediaCache(OpenListTeam#17)
- delete_invalid 支持游标分页;目录探测只在明确 404 时删除(OpenListTeam#26/OpenListTeam#27)
- 封面网关单独白名单,原始 URL 仍需可信来源(OpenListTeam#28)
- 图片封面读取完整图片,超过 8MB 才跳过(OpenListTeam#19)
- FLAC metadata block 长度改为大端(OpenListTeam#18)
- Discogs 标题只用于合并专辑,不再覆盖单曲标题(OpenListTeam#20)
- 刮削路径全禁用时不再等于放行(OpenListTeam#21);运行态在 finally 中清理(OpenListTeam#22)

持久化与读取:
- 分片读取区分「不存在」与「读取失败」,失败时禁止写回(OpenListTeam#5)
- 提交 meta 时逐集合合并,旧快照不再整份覆盖(#4/OpenListTeam#6)
- 加载时填充分片快照,清空分片能真正写入 [](OpenListTeam#7)
- 保存前按持久化水位重映射本实例新增 id,降低跨 isolate 撞号(OpenListTeam#8)
- readFileRange 逐跳校验重定向并跨域剥离凭据(OpenListTeam#10)
- 本地存储直接读文件区间,不再依赖 raw_url(OpenListTeam#25)
- 旧快照写入前先合并进最新缓存(OpenListTeam#6)

验证:tsc --noEmit 0 error;media 16/16、server 220/220、model+store 52/52、
drivers 189/189。

* fix(media): fall back to the direct link when the storage forbids /p

媒体库前端固定用 stream_url 返回的 proxied(/p + force)播放,但 raw 路由
对 /p 有存储策略检查:storage.web_proxy=false 且扩展名不在 proxy_types /
text_types 时返回 403 "proxy not allowed"(与 Go 的 canProxy 一致)。
139Yun 这类默认 302_redirect 的存储因此全部播放失败。

stream_url 现在先解析存储并按同一套 canUseProxyEndpoint 规则判断:不可
代理时 proxied 直接返回 /d 直链(302 到网盘预签名地址),可代理时仍返回
/p。想强制走服务端代理的部署,在存储设置里打开 Web 代理即可恢复 /p。

验证:driver=139Yun、web_proxy=false 的存储,proxied 由 /p 变为 /d,
请求 /d 返回 302 到 OBS 预签名 URL。

* perf(media): parallelize scanning, shrink tag reads and save incrementally

用户实测 1142 首歌的目录跑了十几分钟才 9 条(约 20s/首)。原因有三:

1. 扫描主循环是串行的(for + await),每个文件要换链 2~4 次;
2. 每首歌无条件读 2MB 标签窗口,1142 首 = 2.2GB 流量;
3. 只在整批扫完才 saveMediaDb,中途列表一直是空的,失败还会丢全部结果。

改动:
- mapPool 并发(默认 4,上限 6,取 scrape_concurrency)
- 标签分级读取:先 256KB,关键字段全空且文件更长时再补到 2MB
- 每 30 条增量落盘

顺带修正上一提交的 id 水位判定:remapConflictingIds 改用**原始**持久化
meta 的 next_id 作为基准(mergeMetaForCommit 返回 max(本地,持久化),会把
自己刚分配的 id 误判成跨 isolate 冲突),并在每次成功保存后推进
state.baseNextId。

验证:tsc --noEmit 0 error;media + emby_protocol 30/30。

* fix(media): collect episodes recursively and honor the scan path type tag

对齐 Go scanner.go 的两处行为,修「删除后重扫扫不出来」:

1. Go 的 buildEpisodesFromFolder 是递归的(recursive=true,能把各季目录
   下的文件收进同一条记录);TS 的 listDirMedia 只列一层。于是
   `/影视资源/电影/哈利波特电影/*.mkv` 这类「分类目录 → 剧名 → 文件」
   布局在剧名目录拿到 0 个文件,buildItem 返回 {},扫描结果是
   「跳过 N 条」、列表全空。现在递归 3 层,并用相对路径作为
   episode.file_name,播放时才不会丢掉中间目录。

2. Go 用扫描路径的 type_tag 决定电影/剧集归属(scanner.go:1244-1250);
   TS 的目录分支硬编码 videoType="tv"。现在按 type_tag(电影/movie)
   判定,电影目录不再被当成剧集。

顺带修同一条链路上的解析问题:
- 季号支持中文目录(第二季 / 第2季);
- 目录季号优先于文件名里的 Sxx(压制组常把多季统一写成 S01);
- SxxExx 之后的发布信息(1080p / 中英双字 / WEB-DL)不再当集标题,
  回退到剧名。

验证:扫描 /Media/影视资源 从「跳过 1 条、0 条目」变为 1 条,
episodes 带 `第二季/群星.S01E05...`、season=2、title=群星。

* fix(media): serve playback through a media-scoped proxy and fill admin routes

三处问题,根因不同:

1. 播放/阅读拿到的地址不可用
   stream_url 之前对不可代理的存储回退成 /d(302 到网盘直链),于是
   Content-Type 丢失(application/octet-stream)、Content-Disposition 是
   attachment,PDF 打不开、视频也没有正确的 video/*。
   现在媒体库签发带 scope=media 的签名,raw 路由验签通过后跳过
   web_proxy 的 /p 限制:播放/阅读走同源代理,Range、Content-Type、
   inline 展示都成立(也让 MediaBunny 的 AC3 音频回退不再跨域)。
   代理响应在 scope=media 时把 attachment 改成 inline。

2. 管理端「媒体管理」载入不出来
   前端管理页调用 /admin/media/albums、/album/items、/album/update、
   /items/cover,后端一直没有这些路由,请求落到 SPA 兜底返回 HTML。
   补齐四个接口(专辑聚合、专辑曲目、批量更新、封面上传/回收)。

验证:
- PDF:206 + Content-Type: application/pdf + Content-Disposition: inline
- 视频:/p 206,Content-Range 正常
- /admin/media/albums:按专辑聚合返回条目

* fix(media): align the scrape strategy with the Go scraper

对比 OpenList-Backends/internal/media/scraper 后,TS 侧有三处策略差异导致
「刮削全部失败」:

1. 查询词没清洗
   TS 直接把 `item.scraped_name || file_name` 丢给 TMDB,文件名里的
   `S01E05`、`HD1080P`、`中英双字`、`霸王龙压制组` 全在 query 里,
   TMDB 基本零结果。Go 会先解析出中文标题/英文标题/年份再逐组尝试。
   新增 parseMediaTitle():去扩展名、括号别名、集号标记(SxxExx/第N集/EPxx)、
   中英文发布噪声,提取年份,产出「中文片段 → 括号别名 → 去噪标题 → 原串」
   候选;图书/豆瓣搜索也改用 primary。

2. 只搜一次、没有降级
   Go 用候选 × 语言 × 是否有年份 组合重试。tmdbSearch 现在按
   「候选(≤3) × [配置语言, 不限定语言] × [不带年份, 带年份]」枚举,
   强命中(≥0.6)立即返回;不限定语言时 TMDB 会用全部别名匹配,
   中文/港台译名召回明显更高。

3. 视频只有 TMDB,没有豆瓣兜底
   Go 的视频刮削是 TMDB + 豆瓣双源。TS 的 scrapeDouban 只挂在书籍分支,
   而且固定 cat=1002(电影)去搜书。现在按媒体类型选 1001/1002,
   解析「导演/主演/年份/类型」,并在 TMDB 未命中时作为视频兜底(low=true)。

另外:未配置 media_tmdb_key 时把原因写进 stats.last_error,
避免统计面板显示一片 0 而无从判断。

验证:media 17/17、server 220/220、tsc 0 error;
本地实测一次视频刮削 12 次搜索,Inception/复仇者联盟命中中文标题。

* fix(media): proxy external covers and send a Douban referer

刮削成功但封面裂图的两个原因:

1. resolveCoverUrl() 对外链原样返回,浏览器直连豆瓣图床(防盗链,空
   Referer 直接 403)与 TMDB 图片(国内常不可达)。现在外链默认走
   /api/public/media/proxy,命中缓存后由 KV/边缘缓存分发。
2. 代理拉图时 User-Agent 是 OpenList-Media/1.0、没有 Referer,豆瓣仍拒。
   改为常见浏览器 UA,并对 douban/doubanio 域名带 https://www.douban.com/。

验证:影视页 4 张封面(豆瓣 + TMDB 各 2 张)由空白变为全部正常显示,
代理请求均为 200。

* fix(media): keep large embedded covers when scanning music

「有的歌有封面、有的没有」是两个独立阈值造成的:

1. 扫描先只读 256KB 标签窗口,且只在「标题/艺术家/专辑全空」时才补读
   2MB。封面恰好是标签里最占空间的部分(实测 393KB~1.2MB),文本字段
   落在前 256KB、封面落在窗口之外时,hasCore 为真,永远不补读。
   现在按容器头解析标签实际长度(ID3v2 syncsafe / FLAC metadata block
   链)精确补读;解析不出长度时退回完整 2MB 窗口,不再依赖文本字段。

2. 缩略图持久化上限是 512KB,超过直接丢弃且无日志。FLAC/MP3 内嵌封面
   常见 0.6~1.2MB,于是同一批文件里只有小封面能存下来。上限提高到 2MB
   (缩略图按内容 hash 去重,同专辑只存一份),超限时打 warn 留痕。

验证:本地 20 首音乐(FLAC + MP3),修复前 missing_cover=8,
重扫+刮削后 missing_cover=0。

---------

Co-authored-by: pikachuren <pikachuren@example.com>
OpenListTeam#176)

Fixes part of OpenListTeam#174: variables configured in the Cloudflare dashboard
are wiped on every redeploy because wrangler treats the repo config
as the single source of truth. keep_vars=true makes wrangler stop
deleting dashboard-configured plain-text vars that are not declared
in wrangler.jsonc. Secrets are unaffected either way.

Refs: https://developers.cloudflare.com/workers/wrangler/configuration/
…ows (OpenListTeam#184)

The default config ships 114 settings, so sqlFormat.save() built a single
`DELETE ... WHERE key NOT IN (?, ...)` with 114 bound parameters. D1 (and
Durable Object SQLite) cap a statement at 100 bound parameters, so
/api/public/init/setup failed with
"D1_ERROR: too many SQL variables at offset 350" after the first 100
statements had already been committed (issue OpenListTeam#180).

- sqlFormat.save(): when a table keeps more than 100 keys, read the existing
  primary keys, diff them in memory, and delete the stale ones with chunked
  `IN (...)` statements (NOT IN cannot be split without changing semantics).
- d1 driver: reject any statement binding more than 100 parameters up front
  with the offending SQL, instead of the opaque SQLITE_ERROR.
- store tests: mock driver enforces the 100-parameter cap and covers the
  >100-key save path plus stale-row cleanup.

Co-authored-by: pikachuren <pikachuren@example.com>
…op all CACHE_* / SINGLEFLIGHT* variables (OpenListTeam#185)

* refactor(cache): remove persistent file-tree/link caches and all CACHE_* config

* refactor(storage): remove singleflight dedup and all SINGLEFLIGHT_* variables

---------

Co-authored-by: pikachuren <pikachuren@example.com>
…am#188)

* fix(init): simplify env check messages and make them bilingual

* docs: add ESA deploy entry, repo badges and simplify README

* chore(config): trim stale wrangler comments

* https://github.com/Mcchen1008/NextList

* docs: sync translated READMEs with the new layout

* docs: remove PR description from the repository

* docs: remove PR description documents

---------

Co-authored-by: pikachuren <pikachuren@example.com>
… (OpenListTeam#189)

EdgeOne Node 云函数在平台侧提前消费请求体,实测 30%~45% 的 JSON POST 在
c.req.text() 处失败(GET 不受影响;同代码部署 Cloudflare Workers 0 失败,
见 OpenListTeam#112 的对照实验)。OpenListTeam#139 引入的 jsonBodyGuard 把这种平台故障一律判成
400,前端不会重试,于是偶发丢包变成用户可见的硬报错(issue OpenListTeam#170)。

- router: 严格区分三类结果。「读不到 body」(bodyUsed / 读取抛异常 /
  Content-Length 与实际长度不符)返回 503 + Retry-After 与
  X-OpenList-Retry: body:这是服务端暂时不可用,且 handler 尚未执行,
  客户端重试不会产生重复副作用;真正的空 body / 非法 JSON 仍是 400,
  /api/fs、/api/share 的严格语义不变(仍是 400,不退化成 OpenListTeam#112 的静默根目录)。
- router: 补结构化 warn 日志(reason / declared / hasBody / bodyUsedBeforeRead),
  这是把结论交给腾讯云之前唯一还缺的现场证据。读取前必须先取快照,
  否则读过之后 bodyUsed 恒为 true,日志会失真。
- router: 严格路径判定抽成 isStrictBodyPath,改为精确匹配 + 尾斜杠,
  不再用 startsWith("/api/fs") 误伤 /api/fsx。
- api/_makers: 在 onRequest 最前面(任何 await 之前)预读 <=1MB 的 /api
  JSON body 并重建 Request,把与平台 drain 的竞态窗口压到接近 0;
  读不到时原样下交,由 guard 统一返回 503 并记日志。
- 新增 json_body_guard.test.ts:9 个用例覆盖 503 / 400 / 放行三条分支。

未采用 PR OpenListTeam#158 的「非严格路径 next() 放行」:Hono 的 bodyCache 里留下的是
同一个 rejected Promise,放行只是把错误推进 handler,且会让 /api/admin/*
这类写接口在空 body 下按默认值执行。

验证:npm run test:server 229 通过;tsc --noEmit 通过;build:edge 通过。

Co-authored-by: pikachuren <pikachuren@example.com>
…m#173)

按 OpenListTeam#171 评审的 P2:驱动里的 put() 仍普遍声明 content: Buffer。TS 方法参数
是双变的,编译器不会报错,但运行时实参早已是 Uint8Array —— 继续使用
Buffer 专有方法的实现会在 ESA(无全局 Buffer)上静默产出错误数据或直接抛错。

- 75 处 content: Buffer / Buffer | Uint8Array 统一为 Uint8Array
- url_tree: content.toString("utf8") → TextDecoder
- github putBlob: Buffer.from(content).toString("base64") → 纯 JS bytesToBase64
- server/fs.ts 解压写回:去掉多余的 Buffer.from(content)
- 189pc calcMD5/calcSHA1 参数放宽为 Uint8Array
- StorageDriver.put 注释写明实现方不得使用 Buffer 专有方法

验证:tsc --noEmit 0 error;drivers 197/197、server 206/206、pkg+model 89/89。

Co-authored-by: pikachuren <pikachuren@example.com>
…am#142)

- storage.ts: guangyapan onStateChanged 回调把驱动改动同步回 in-flight 的
  updatedStorage(以本次提交的 addition 为基底合并),并补上
  deferredTokenPersistence 判断。避免 /admin/storage/update 用旧快照覆盖
  verification_id/captcha_token/send_code,同时不把用户本次编辑的其它
  配置项回退成旧值。
- 新增 handler 级回归测试:提交改过的 page_size 同时驱动回写登录态,
  断言两者都保留。
- 不再包含与本修复无关的 build-edge createRequire banner(另行拆分)。
* fix(deploy): expose safer-buffer to Wrangler bundler

Wrangler fails to resolve safer-buffer from iconv-lite in the Worker bundle even though pnpm-lock.yaml contains it transitively. Declare safer-buffer as a direct production dependency so the package is available from the project root to Wrangler/esbuild under pnpm's hoisted node_modules layout. Update the lockfile importer; the existing locked 2.1.2 package is reused.

Validation: pnpm install --lockfile-only --offline --no-frozen-lockfile completed; git diff --check passed. Full Cloudflare deployment was not run because it requires installing dependencies and Cloudflare credentials.

* fix(deploy): pin safer-buffer to 2.1.2

issue OpenListTeam#151 的 8 处报错全部来自 node_modules/iconv-lite 的 require(''safer-buffer''),即根级 node_modules 里找不到该包;在 node-linker=hoisted 下显式声明可保证它被装到根,因此保留该依赖声明。按评审意见把 ^2.1.2 收敛为精确 2.1.2(仅为打包可见性引入,不应跟随上游自动升级),并同步 lockfile importer 段;pnpm install --lockfile-only --offline --frozen-lockfile 校验通过。

* fix(deploy): alias iconv-lite to a stub so Wrangler stops resolving safer-buffer

反证成立:.npmrc 是 node-linker=hoisted + shamefully-hoist,safer-buffer 作为
iconv-lite 的传递依赖本就被铺到顶层 node_modules;把它提为直接依赖是 no-op,
已移除该声明。

真正根因是两条构建链路不一致:scripts/build-edge.mjs 早已把 iconv-lite
external / 空模块化(ESA、EdgeOne、dist-server 产物),而 `wrangler deploy`
直接以 src/backend/worker.ts 为入口打包,仍会解析 FTP 驱动的
require("iconv-lite") 及其传递依赖 safer-buffer(issue OpenListTeam#151)。

修复:wrangler.jsonc 增加 alias,把 iconv-lite 指向新增的
drivers/ftp/iconv-stub.ts。Workers 没有 TCP socket,FTP 转码路径不会执行,
空桩只在打包期提供可解析的模块,真被调用时抛出明确错误。

验证:wrangler deploy --dry-run 产物由 4378.99 KiB 降到 3834.32 KiB,
worker.js 内联空桩文案且不再出现 safer-buffer。

---------

Co-authored-by: pikachuren <pikachuren@example.com>
…Team#164)

* fix(139): keep valid tokens when refresh metadata is absent

Issue OpenListTeam#122 reports that existing 139 authorizations became unusable as soon as they entered the 15-day refresh window, even when they were still accepted by the service. The PC refreshToken endpoint requires user_domain_id, but legacy storage configurations may not contain it.

Skip only the automatic refresh when user_domain_id is absent; retain the current authorization and allow the normal API request to determine whether the token is still valid. When user_domain_id is present, preserve the existing near-expiry refresh flow and persistence callback. Expose the optional field in the 139 storage form with a description.

Validation: 139 driver tests 13/13 passed; mutation check restoring the previous throw makes the new regression test fail; git diff --check passed.

* fix(139): refresh tokens without user_domain_id via legacy endpoint

The PC refreshToken API requires user_domain_id, so configs that only
contain Authorization were left with no automatic refresh and emitted a
near-expiry warning on every storage init.

Fall back to the legacy authTokenRefresh.do endpoint already used by the
Go backend when user_domain_id is empty. It only needs the current token
and account, accepts both <token> and <accessToken> candidates, and only
writes back a token with a later expiry. Refresh failures are silent and
keep the current authorization usable until the service rejects it.

user_domain_id stays optional and the PC path is unchanged when set.

Tests: 139 driver 14/14, drivers 199/199, server 206/206, tsc clean.

* feat(139): port email login to auto-fill user_domain_id

Align the Worker driver with the Go backend's credential flow:

- Port step1 mail password login, step2 artifact exchange and step3
  encrypted third-party login (AES-CBC/ECB, RSA PKCS1 v1.5).
- Support fast login with existing mail_cookies and SMS risk control
  (sms_code), including cookie persistence between attempts.
- Persist authorization, user_domain_id, mail_cookies and sms_code after
  a successful login, matching Go's MustSaveDriverStorage behavior.
- Keep manual Authorization + user_domain_id authoritative: when
  Authorization is present no login runs; a login only overwrites the
  manual value when it is actually triggered.
- Re-login after a refresh failure when credentials are available,
  matching Go's loginAfterAuthorizationFailure.
- Expose authorization (now optional), username, password, sms_code and
  mail_cookies in the 139 storage form.

Tests: driver 25/25, all drivers 210/210, server 206/206, tsc clean.

* fix(139): match Go's password-only refresh fallback

Go's loginAfterAuthorizationFailure never retries cookie fast login after
a refresh failure; mail_cookies are only reused as device context for
the password login request.

Restrict the refresh-failure re-login to username + password and add a
skipFastLogin option so the fallback performs step1 -> step2 -> step3
instead of step2 -> step3.

Tests: 139 driver 25/25, tsc clean.

---------

Co-authored-by: pikachuren <pikachuren@example.com>
…eam#190)

When a refresh failure triggers password login and 139 Mail asks for an
SMS code, propagate Yun139SmsRequiredError instead of swallowing it and
reporting only the original refresh error. The refreshed mail_cookies are
still persisted first, so the next save with sms_code can continue.

Tests: 139 driver 25/25, tsc clean.

Co-authored-by: pikachuren <pikachuren@example.com>
* fix(auth): honor disabled guest access

Issue OpenListTeam#133 reports that unauthenticated visitors can browse mounted content despite expecting sign-in. The public settings endpoint already exposes allow_guest=false, but getUserFromContext previously fell back to any active guest account without enforcing that setting.

When no Authorization header or query token is present, return null before guest fallback if allow_guest is false. Preserve the current default guest behavior when the setting is absent. Add a regression test proving default fallback remains enabled and that disabling it makes anonymous /api/fs/list return 401. Document that guest access is an OpenList application setting, not a Cloudflare environment variable.

Fixes OpenListTeam#133

Validation: git diff --cached --check passed. Targeted runtime test is pending dependency installation in an isolated verification copy.

* fix(auth): unify allow_guest check and cover Emby anonymous entry

- Add pkg/permission.isGuestAllowed(): tolerates boolean false / "False" /
  padded values, and is shared by /api/public/settings and every auth path so
  display and enforcement cannot drift apart
- getUserFromContext uses isGuestAllowed (bare === "false" failed open)
- Emby anonymous cover entry (guestUserOf) now honors allow_guest too
- Regression tests: boolean/case/padded settings values and Emby anonymous
  cover returning 401; both verified by mutation

Co-Authored-By: CodeBuddy <noreply@codebuddy.ai>

* feat(auth)!: require sign-in on fresh installs

- defaultDb: disable the guest account by default (issue OpenListTeam#133)
- defaultDb: make allow_guest a real site setting (value "true" on insert so
  existing deployments are not silently switched to "must sign in")
- init/setup and ADMIN_PASS first boot write allow_guest=false for new installs
- docs: two-step opt-in for anonymous read-only browsing
- tests: fresh install (both init paths) is blocked and persisted; an existing
  deployment without the setting keeps its previous behavior

Co-Authored-By: CodeBuddy <noreply@codebuddy.ai>

* refactor(auth)!: drop allow_guest to match the Go backend

Go has no allow_guest setting: anonymous access is decided only by whether the
guest account exists and is disabled (server/middlewares/auth.go Auth(false)).
The TS-only switch added earlier was therefore a divergence, not a fix.

- remove the default setting row and the init-time writes
- remove isGuestAllowed and the checks in getUserFromContext / Emby guestUserOf
- stop echoing allow_guest from /api/public/settings (the frontend never read it)
- keep the actual OpenListTeam#133 fix: fresh installs create a disabled guest, so anonymous
  browsing requires an explicit opt-in by enabling the guest user
- tests assert parity: no allow_guest in defaults or public settings, fresh
  install returns 401, an existing deployment is left untouched

BREAKING CHANGE: /api/public/settings no longer returns allow_guest.

Co-Authored-By: CodeBuddy <noreply@codebuddy.ai>

---------

Co-authored-by: pikachuren <pikachuren@example.com>
Co-authored-by: CodeBuddy <noreply@codebuddy.ai>
…ListTeam#187)

Issue OpenListTeam#175: mounting OpenList over WebDAV lets clients list directories and
see files, but any attempt to play a video returns
`401 sign verify failed` for the raw URL behind the redirect.

WebDAV GET/HEAD redirects to the raw endpoint returned by getItem()
(`/api/p/<path>` or `/api/d/<path>`) without appending a signature. Those
endpoints deliberately have no auth middleware, so access is decided solely by
needDownloadSign(): when sign_all, link_expiration, storage-level enable_sign or
a password-protected meta is in effect, the unsigned link is rejected.

This is the same root cause as OpenListTeam#66, which only fixed /fs/get. Add a shared
appendDownloadSignToRawUrl() helper (idempotent, same semantics as /fs/get) and
use it for the WebDAV GET/HEAD redirect as well as the S3 GetObject redirect,
which had the identical gap. Sign over the scoped actualPath so the signature
matches what the raw endpoint verifies.

Validation: webdav_get_sign 5/5 (2 of them fail on the pre-fix tree),
test:server 225/225, test:drivers 197/197, tsc --noEmit clean.

Co-authored-by: pikachuren <pikachuren@example.com>
Co-authored-by: DeepSeek <noreply@deepseek.com>
* fix(mega): handle API Hashcash challenges

Issue OpenListTeam#150 reports HTTP 402 from the MEGA API. The linked megajs issue identifies the cause as a 402 response carrying X-Hashcash, which the official MEGA web client solves and retries. The Worker client previously treated every non-2xx response as a terminal error.

Parse version-1 Hashcash challenges, validate the 48-byte token, and compute the 4-byte little-endian nonce using the official repeated-token SHA-256 input and threshold. Retry a challenged request once with the official X-Hashcash header; preserve ordinary 402 errors when no challenge is present, and cap attempts/time to avoid unbounded serverless CPU work. Add tests for nonce/threshold, malformed challenges, API retries, work limits, and non-Hashcash 402 responses.

Fixes OpenListTeam#150

Validation: Mega tests 8/8; npm run lint passed; a real WebCrypto solve for easiness 192 completed in 133 ms on the local Node runtime; git diff --check passed. No live MEGA credentials/service request was used.

* fix(mega): scale the Hashcash budget with the challenge easiness

固定 4096 次尝试在 easiness=192(期望约 256 次)时意味着 16 倍无效哈希,而每次尝试都要对 ~12 MiB 缓冲做一次 SHA-256(本地 WebCrypto 实测单次约 7.5 ms,512 次约 3.9 s)。默认预算改为 min(期望次数 x 2, 4096),保留 10 s 墙钟上限,并在注释里写明平台 CPU 代价。另:上游错误正文截断到 512 字符,避免整页 HTML 进日志;补 base64url token、预算收敛与错误截断三条回归用例。

* fix(mega): use a 4x Hashcash budget and stream the error prefix

2 倍期望只有 86.5% 成功率(easiness=192 时 512 次),为省尾部开销把失败率抬到 13.5% 不划算;改为 4 倍(1024 次、98.2%、约 7.7 s),仍落在 10 s 墙钟内。另修 P2:res.text().slice(0,512) 只截断输出,正文仍被完整缓冲;改为流式读取前 512 字节后 cancel,并补按 chunk 计数的回归用例。

* fix(mega): implement the real login protocol and node decryption (fixes OpenListTeam#150)

MEGA 的 402 只是反爬层的 Hashcash 挑战,PR OpenListTeam#166 已能正确求解;挑战通过后
服务端返回 -9(ENOENT),因为驱动从未实现登录协议:uh 直接用了
base64url(email),us 响应里也没有可用的 sid。

- us0 预登录识别 v1/v2,v1 走 prepareKey(65536 轮 AES-128-ECB) + stringhash,
  v2 走 PBKDF2-SHA512(100000) 并取派生值后 16 字节作为 uh
- us 登录携带可选 mfa;解 k 得到 master key,解 privk 后 RSA 解 csid 得到 sid
- 用 master key 解节点密钥,再用节点密钥 AES-CBC 解出属性/文件名
- getChildren 排除根节点自身;建目录时生成随机密钥并加密属性与密钥
- 新增 crypto.ts(AES-128、prepareKey、stringhash、MPI/RSA)及对照 node:crypto
  的测试,登录流程用合成账号覆盖 v1/v2/2FA/-9

* fix(mega): retry transient 503 / -3 / -4 responses with backoff

MEGA 的 PoW 只出现在 402 + X-Hashcash;503 "Server Too Busy" 是网关过载/
限流,与 PoW 无关,官方客户端与 megajs 都是退避后重试。此前驱动把 503
直接抛成 "Mega API HTTP error: 503",列目录/登录偶发失败。

- 503/500/502/504/429 与 JSON -3(EAGAIN)/-4(ERATELIMIT) 做指数退避重试,
  最多 4 次(400ms 起,封顶 8s,带抖动,尊重 Retry-After)
- 单元素数组形式的错误码(如 [-9])也会被识别,统一抛 MegaApiError,
  登录侧继续翻译 -9/-16/-26
- 已解出的 Hashcash proof 在随后的 503 重试中复用,不重复求解
- 新增 4 条测试:503 重试、重试上限、-3/-4、402→503→200 保留 proof

* fix(mega): persist login session and speed up prepareKey

每次 isolate 冷启动都会重新登录 MEGA,而登录会触发一次 Hashcash PoW
(期望 256 次、每次 12 MiB SHA-256,约 1-2 秒)。把登录态持久化后,
冷启动只需一次 fetchNodes。

- 登录成功后把 sid + master_key 写回 storage.addition(master key 是
  解密节点密钥/属性必需的,缺它只能拿到加密文件名)
- 驱动启动时优先复用会话;遇到 -15(ESID) 或会话格式损坏会回退重新登录
- 持久化复用 139/GuangYaPan 的 deferredTokenPersistence 逻辑,避免
  /admin/storage/update 用旧快照覆盖刚写入的会话
- prepareKey 的 65536 轮都使用同一把密钥,改为只做一次密钥扩展并原地
  加密,本地实测 83ms → 55ms;试过 WebCrypto 逐块 encrypt,65536 次
  subtle.encrypt 需要 ~1.15s,更慢,故保留同步纯 JS
- 新增 6 条测试:会话复用、过期重登、无缓存登录、异常传播、持久化失败
  容错、损坏会话回退,以及客户端 restoreSession/clearSession

---------

Co-authored-by: pikachuren <pikachuren@example.com>
Conflict resolution:
- wrangler.jsonc: keep name openlist-worker, DB_FORMAT=sql, DB_DRIVER=d1
  and the pinned D1 binding; take upstream's other changes (keep_vars,
  iconv-lite alias).
- README.md: regenerated from upstream's updated English README; translated
  READMEs stay removed. Kept readmes/assets/esa-deploy.svg, which the README
  references.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WHvprAc3MkwJLsuk533AsX
@cloudflare-workers-and-pages

Copy link
Copy Markdown

🚀 Deploying Preview to Cloudflare 🚀

Preview Deployments by commit

Status Deployment URL Commit Updated (UTC) See this deployment's details
  • Build: Success ✅

View logs ↗
No Preview URL. Enable ↗ 3f004b2 2026-11-11T21:54:41.743Z Visit the dashboard ↗

@rshaw-mcs
rshaw-mcs merged commit 97a2f43 into main Nov 11, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.