Skip to content

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

Open
Wudarensheng wants to merge 4 commits into
OpenListTeam:mainfrom
Wudarensheng:feat/cache
Open

Wudarensheng wants to merge 4 commits into
OpenListTeam:mainfrom
Wudarensheng:feat/cache

Conversation

@Wudarensheng

Copy link
Copy Markdown
Member

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

Summary / 摘要

Browsing a directory and starting a download both hit the remote drive on every
single request. The download-link exchange (driver.get() → presigned / raw URL)
is usually the most expensive and most rate-limited step of the two. The upstream
OpenList.ts reference solves this with a two-level cache (file tree + download
links); this PR ports that design to this repository.

浏览目录和开始下载这两个动作,此前每次请求都会打到远端存储;其中「换链」
(driver.get() → 预签名 / 直链)通常是最贵、最容易被网盘限流的一步。
本 PR 参考上游 OpenList.ts 的实现,把它的两级缓存(文件树 + 下载链接)移植过来。

User-visible behavior / 用户可感知的变化

  • Directory listings are served from cache (default TTL 30 min, following the
    per-storage cache_expiration and custom_cache_policies). Empty directories
    are cached too, so repeated browsing stops hitting the remote drive.
    / 目录列表改为走缓存(默认 30 分钟,跟随存储级 cache_expiration 与
    custom_cache_policies);空目录同样会被缓存。
  • Download links returned by drivers are reused for a short window (default
    5 min), so repeated downloads / previews skip the link exchange.
    / 驱动换来的直链会在短时间内复用(默认 5 分钟),重复下载 / 预览不再重复换链。
  • 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. / 存储的新增 / 修改 / 启停 / 删除会清空该存储的全部缓存。
  • 4 new admin endpoints: GET /cache/status, POST /cache/clear,
    POST /storage/refresh, POST /storage/refresh_one.
    / 新增 4 个管理接口(含对齐参考实现的 refresh / refresh_one)。
  • GET /env_check now also reports the resolved cache config and the actual
    backends in use. / /env_check 额外回显生效的缓存配置与实际后端。

Implementation / 重要实现变化

  • New module src/backend/internal/cache/ — config.ts (env switches),
    store.ts (backend resolution + key encoding + envelope get/set/list/clear),
    filetree.ts, link.ts, index.ts (barrel + invalidation orchestration),
    plus cache.test.ts (22 cases).
  • Backend selection is entirely env-driven: CACHE_DRIVER=db (the default)
    resolves to the same backend as DB_DRIVER via getStorageBackend(), so
    zero configuration means "cache in the database".
  • Dedicated kv / blob / cfkv / do / memory backends must be opted into
    explicitly and are never auto-detected or auto-substituted — consistent with
    the existing rule that an explicitly configured driver never falls back. An
    unavailable backend is skipped with a one-time warning; if none are available
    the cache silently degrades to a no-op.
  • Cache entries are written through the driver's raw put / get / delete /
    list (deliberately not via saveDb(), which would trigger whole-config
    serialization, write guards and field encryption), under an isolated key prefix
    <CACHE_PREFIX>_<kind>_<storageId>_<encodedPath> (kind = ft / ln), so
    business-data keys are never touched. Paths longer than the EdgeOne KV key limit
    are folded with a double FNV-1a hash.
  • Safety boundary: only driver-layer results are cached (raw FileItem[] and
    raw links). Permissions, meta passwords, hide rules and signatures are still
    computed per request in server/fs.ts / server/raw.ts, so a cache hit cannot
    leak privileges across users. Any cache error degrades to a no-op and never
    fails the request.

Config / 配置 (all optional — defaults preserve "DB-only"):

Variable Default Meaning
CACHE_ENABLED true Master switch for both caches.
CACHE_DRIVER db Backend list, comma-separated: db / kv / blob / cfkv / do / memory / none.
CACHE_FILE_TREE true Toggle the file-tree cache.
CACHE_DOWNLOAD_LINK true Toggle the download-link cache.
CACHE_TTL 0 File-tree TTL in minutes; 0 = follow per-storage cache_expiration (30).
CACHE_LINK_TTL 5 Download-link TTL in minutes.
CACHE_EXCLUDE_DRIVERS virtual,alias,url_tree,strm,chunk Drivers excluded from caching.
CACHE_PREFIX openlist_cache Key prefix isolating cache entries from business data.

Compatibility / 兼容性

  • Purely additive: no existing endpoint, config key, storage format or
    migration path is modified.

  • Caching is enabled by default. Set CACHE_ENABLED=false (or
    CACHE_DRIVER=none) to restore the previous behavior exactly; a single storage
    can opt out with cache_expiration=0 or a custom_cache_policies rule.
    / 缓存默认开启;CACHE_ENABLED=false(或 CACHE_DRIVER=none)可完全恢复原行为,
    单个存储也可用 cache_expiration=0 退出。

  • This PR has breaking changes.
    / 此 PR 包含破坏性变更。

  • This PR changes public API, config, storage format, or migration behavior.
    / 此 PR 修改了公开 API、配置、存储格式或迁移行为。

  • This PR requires corresponding changes in related repositories.
    / 此 PR 需要关联仓库同步修改。

Related repository PRs / 关联仓库 PR:

  • OpenList: — (no change required; the 4 new admin endpoints have no frontend UI yet, follow-up optional)
  • OpenList-Docs: to be opened — documents the 8 new CACHE_* variables and the cache admin endpoints

Testing / 测试

Platform: Windows 10/11, Node.js 22.22.2, tsx --test.

  • go test ./... — N/A: this repository is the TypeScript / Cloudflare Workers
    port; no Go sources are changed.
  • Type check: npx tsc -p tsconfig.json --noEmit — 0 errors in every file
    changed by this PR. (Repo-wide, 10 errors remain, all pre-existing and
    unrelated: pkg/validators.ts cannot resolve zod in this sandbox, and
    internal/model/db_cipher.test.ts has two pre-existing typing errors.)
  • npm run test:cache — 22/22 pass
  • npm run test:model — 39/39 pass
  • npm run test:store — 12/12 pass
  • npm run test:drivers — 111/111 pass
  • npm run test:189 — 20/20 pass
  • npm run test:server — 125/129 pass

The 4 test:server failures are pre-existing and unrelated to this PR. This was
verified by a control run: with this PR's four touched source files reverted to
HEAD, the suite produces the identical result (129 tests / 125 pass / 4 fail, the
same four cases):

  • default_credentials.test.ts (3) — admin bootstrap / password-reset semantics.
    Its import closure (db, auth, user, password, middlewares, op/sshkey)
    does not contain any file this PR touches.

  • seed.test.ts (1) — "CAS codec matches casmeta base64 JSON field names": the
    codec emits cloud / slice_md5s / slice_size beyond the field set the test
    asserts.

  • Manual test / 手动测试: not performed — and here is why. This environment has
    no credentials for a live remote storage (网盘 / object storage), so the cache
    could not be exercised end-to-end against a real drive. Coverage is unit-level,
    against the in-memory driver. Suggested manual check before merge:
    1. Attach a real storage, list a directory twice — the second listing should be
    served from cache (no remote request).
    2. Download a file twice — the second download should reuse the cached link.
    3. Rename / delete a file — both caches for that path and its parent should be
    invalidated, and the next listing should reflect the change.
    4. GET /api/admin/cache/status should report non-zero file_tree /
    download_link entry counts.

Checklist / 检查清单

  • I have read CONTRIBUTING.
    / 我已阅读 CONTRIBUTING.
  • I confirm this contribution follows the repository license, contribution policy, and code of conduct.
    / 我确认此贡献符合仓库许可证、贡献规范和行为准则。
  • I have formatted the changed code with gofmt, go fmt, or prettier where applicable.
    / 我已按适用情况使用 gofmt、go fmt 或 prettier 格式化变更代码。
  • I have requested review from relevant maintainers or code owners where applicable.
    / 我已在适用情况下请求相关维护者或代码所有者审查。

AI Disclosure / AI 使用声明

  • This PR includes AI-assisted content.
    / 此 PR 包含 AI 辅助内容。

Tools used / 使用工具:

  • ChatGPT
  • Codex
  • GitHub Copilot
  • Claude
  • Gemini
  • Other (please specify) / 其他(请注明): WorkBuddy AI (DeepSeek-V4.1-Flash)

Usage scope / 使用范围:

  • Code generation / 代码生成

  • Refactoring / 重构

  • Documentation / 文档

  • Tests / 测试

  • Translation / 翻译

  • Review assistance / 审查辅助

  • I have reviewed and validated all AI-assisted content included in this PR.
    / 我已审核并验证此 PR 中的所有 AI 辅助内容。

  • I have ensured that all AI-assisted commits include Co-Authored-By attribution.
    / 我已确保所有 AI 辅助提交都包含 Co-Authored-By 归属信息。

  • I can reproduce all AI-assisted content included in this PR without any AI tools.
    / 我可以在没有任何 AI 工具的情况下重现此 PR 中包含的所有 AI 辅助内容。

@pikachuren

Copy link
Copy Markdown
Collaborator

评审结论:必须修改(缓存键可碰撞,实测复现)

「只缓存驱动层结果、权限/hide/meta/签名在 server 层每请求重算」这个关键声明我核对后成立
(server/fs.ts 的 canAccess + isHidden、raw.ts 的验签都在缓存读取之前)。
但缓存键存在可复现碰撞:

🔴 缓存键不是单射 → 跨目录/跨文件串数据(实测复现)

internal/cache/store.ts 的 encodeCachePathSegment 复用了 internal/model/store/keycodec.ts
的 encodeKeyPart:它对 [A-Za-z0-9_] 原样保留、其余按 xHH 转义,而 x 本身属于保留字符集,
所以「原文件名里恰好写了 x2f」与「转义出来的 /」不可区分。

keycodec.ts 的注释其实明确承认过这点,理由是「键名仅作存储地址、主键值取自记录 JSON,无需从键名反解」——
但这个理由对缓存不成立:缓存是「用键取回值」,键碰撞 = 取回别的路径的数据。

用真实 encodeKeyPart + 逐字复制的 encodeCachePathSegment 实测:

路径 A                路径 B                  键 A                        键 B                      碰撞?
/a/b                  /ax2fb                  x2fax2fb                    x2fax2fb                  *** 是 ***
/a/b.txt              /ax2fb.txt              x2fax2fbx2etxt              x2fax2fbx2etxt            *** 是 ***
/photos/2024/img.jpg  /photosx2f2024/img.jpg  x2fphotosx2f2024x2fimgx2ejpg  (同上)                 *** 是 ***
/x/y                  /xx2fy                  x2fxx2fy                    x2fxx2fy                  *** 是 ***
/secret/report.pdf    /secretx2freport.pdf    x2fsecretx2freportx2epdf     (同上)                  *** 是 ***
命中碰撞:5/5

影响:只要存在一个名为 ax2fb 的条目,请求 /ax2fb 就会命中 /a/b 的缓存——目录列表(ft)
与下载直链(ln)都受影响
。由于权限/hide/meta 是按请求路径(/ax2fb)判定的、而返回的是
/a/b 的内容,构成可见性与内容串号:可绕过 hide 规则/meta 密码读到别的目录条目,或拿到别的文件的直链。

建议:让键片段单射 —— 保留可读性就把转义前缀 x 也纳入转义(如 x → x78,使 isPlain 排除 x);
或放弃可读性,路径段一律用已存在的双 FNV-1a 哈希(当前只在编码后 >180 字符时才用)。
并补一条参数化断言:任意两个不同虚拟路径的缓存键必须不相等(当前用例只断言了字符集与确定性,抓不到碰撞)。

另需确认(代码走查,未实测)

  • 失效只挂在 internal/op/storage.ts 的 mkdir/rename/remove/move/copy/put 六处;而
    server/fs.ts 的 /fs/upload/complete、/fs/multipart/complete 直接调驱动落盘、/fs/other 签发 S3 直传 URL,
    都不调用 invalidatePaths()。缓存默认开启、TTL 30 分钟,「上传成功但刷新看不到」可稳定复现。
    建议这三处补失效。
  • CACHE_PREFIX 未做字符集校验:若被配成与表名冲突的值,keyFormat.save() 的整表 DELETE 可能把缓存行卷进去。
  • 测试只覆盖 store 层 happy path(全部用假驱动);resolveDrivers 的 db 解析、op 层命中/失效集成均无用例。

本评论由 AI 辅助的自动化评审生成,基于对该 PR 当前 head 提交的源码核对。标注「实测复现」的结论已在本地用真实源码(打补丁后)跑脚本验证;其余为代码走查结论。请以人工复核为准。

@PIKACHUIM

Copy link
Copy Markdown
Member

这个改动比较大,由于有些网盘的直链TTL非常短
建议考虑一下使用独立的缓存时间选项控制(并且默认关闭

Wudarensheng and others added 4 commits October 2, 2026 16:54
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.
The local assistant keeps its project memory under .workbuddy-ai/, which
must never be committed. Ignore it next to the existing .codebuddy entry.
- 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
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.

3 participants