Skip to content

fix(sign): /fs/get raw_url 缺少下载签名导致下载 401 (#66) - #70

Merged
PIKACHUIM merged 6 commits into
mainfrom
fix/issue-66-raw-url-sign
Sep 21, 2026
Merged

PIKACHUIM merged 6 commits into
mainfrom
fix/issue-66-raw-url-sign

Conversation

@PIKACHUIM

@PIKACHUIM PIKACHUIM commented Sep 20, 2026 •

Copy link
Copy Markdown
Member

fix(sign): 修复 /fs/get raw_url 缺少下载签名导致的 401,并对齐 Go 的路径编码与过期语义

本文档即 PR 正文(docs/pr-sign-raw-url.md),与 .github/PULL_REQUEST_TEMPLATE.md 对齐。

Summary / 摘要

本 PR 修复 #66 [BUG] sign verify failed:下载始终 401,而预览看起来正常。

根因:/api/fs/get 返回的 raw_url 形如 /api/p/<path>,指向的正是需要验签的 /p 端点,但从来不携带 ?sign=;而前端把 raw_url 原样当作下载地址使用,不会再自己拼签名:

// OpenList-Frontend/src/pages/home/previews/download.tsx
<Button as="a" href={objStore.raw_url} target="_blank">
  {t("home.preview.download")}
</Button>

于是只要 sign_all / 存储级 enable_sign / 密码 meta 任一命中(即 needDownloadSign() === true),从预览页点「下载」就必然得到 401 sign verify failed。issue 里贴出的请求 GET https://xxx/api/p/存储1/7z2602-x64.exe 完全没有 ?sign=,与该链路完全吻合(sign_all 在 Go 版里默认就是 true,导入 Go 备份的部署必然踩中)。

Go 版是显式补签的:

// server/handles/fsread.go FsGet(MCP 的 buildFSGetRawURL 同理)
query := ""
if isEncrypt(meta, reqPath) || setting.GetBool(conf.SignAll) {
    query = "?sign=" + sign.Sign(reqPath)
}
rawURL = fmt.Sprintf("%s/p%s%s", common.GetApiUrl(c), utils.EncodePath(reqPath, true), query)

第二个提交把该链路周边与 Go 不一致的既有实现一并对齐:下载路径编码、link_expiration 的单位与 0 语义、down_proxy_url 的补签条件、/fs/link 的签名。

用户可感知的行为变化

  • 修复:sign_all / enable_sign / 密码 meta 生效时,预览页的「下载」按钮、图片/视频/PDF 预览、S3 网关与 WebDAV 的 302 目标不再返回 401 sign verify failed。
  • 修复:文件名含 % 时 /p、/d 下载不再 500(此前 decodeURIComponent 抛 URIError;现在返回 400 Bad Request: malformed path encoding)。
  • 修复:文件名含 # / ? / 空格的下载链接不再被浏览器当成 fragment / query 截断成另一个路径。
  • 行为变更:link_expiration 的单位由「秒」改为 小时(与 Go 及官方文档一致),且 0 表示永不过期(不再回退到「24 小时」)。
  • 行为变更:down_proxy_url 的补签不再要求目标与请求同 host,只要未开启 disable_proxy_sign 就会带上签名(对齐 Go GenerateDownProxyURL)。
  • 行为变更:/fs/link 的兜底分支改为「路径编码 + 无条件携带签名」(对齐 Go handles.Link)。

重要实现变化

  • server/fs.ts:/fs/get 的 raw_url 按需补签,条件与验签侧 needDownloadSign() 完全对称;/fs/link 兜底分支补签 + 编码。
  • pkg/path.ts(新增):encodeDownloadPath(),逐段对齐 Go utils.EncodePath(path, true)。
  • internal/op/storage.ts:getItem() 生成的 rawUrl 改走 encodeDownloadPath()(单点覆盖 /fs/get、S3、WebDAV、seed 的取址)。
  • pkg/sign.ts:link_expiration 按小时换算;expires === 0 表示永不过期(签发与验签两侧都实现,对齐 Go pkg/sign 的 expires != 0 判定)。
  • server/raw.ts:down_proxy_url 模板改用同一编码、补签条件对齐 Go;非法百分号转义回 400 而不是 500;修正「Go 支持 $path 占位符」的错误注释(Go 只是拼接 EncodePath)。
  • internal/driver/proxy.ts:getDownProxyUrl() 多行只取第一行(对齐 Go strings.Split(..., "\n")[0])。
  • server/webdav.ts / server/seed.ts:/api/p 拼接改走同一编码函数。

配置 / 存储 / API 变化

  • 无数据库 schema 变更、无新增配置项。

  • link_expiration 的取值单位语义发生变化(秒 → 小时),且 0 = 永不过期。详见下方兼容性说明。

  • /fs/get 的 raw_url 在需要签名时会多一个 ?sign= 查询参数(签名本身不会二次拼:已带 sign 的 URL 原样返回)。

  • 新增 11 个测试用例(server/sign_go_parity.test.ts 7 个 + server/fs_get_rawurl_sign.test.ts 4 个)。

  • 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 需要关联仓库同步修改。

关于兼容性:不涉及 API / 存储格式的破坏性变更,但存在两处配置语义变更,建议写入 release note:

  1. link_expiration 由「秒」改为「小时」(Go 与 OpenList-Document/pages/configuration/global.md 的既有定义就是小时)。若管理员此前按本仓库旧文案("Link Expiration in Seconds")填过值,例如 3600,升级后会被解释为 3600 小时。
  2. link_expiration = 0(默认)此前会签出 24 小时有效期的链接,现在表示永不过期(对齐 Go NotExpired)。需要限时的部署请显式填写小时数。

两处都是为了与 Go 版一致;分歧只存在于 TSWorker 侧,且此前无任何文档描述,视为实现偏差而非既有契约。

Related repository PRs / 关联仓库 PR:

  • OpenList: N/A(本 PR 为对齐 Go 版既有行为,无需 Go 侧改动)
  • OpenList-Docs: N/A(link_expiration 的单位在官方文档中已写明为小时)

Related Issues / 关联 Issue

Fixes #66

与 #51 的区分(两者都会报同一句 sign verify failed,但根因不同,可用于判断修复是否生效):

现象 根因 状态
请求 URL 没有 ?sign= /fs/get 的 raw_url 从未补签(本 PR) 本 PR 修复
请求 URL 有 ?sign= 但仍 401 多实例间签名密钥不一致:readPersistedSecret 只探测 KV、无 D1/MySQL 分支,冷启动即换钥(#51) 已由 #64 修复(resolveSecretDriver → getStorageBackend(),D1/MySQL/DO/KV/Blob 均可持久化)

因此升级后若仍看到 401,请先确认地址里是否带 ?sign=,再检查 JWT_SECRET / 持久化后端是否稳定。

Testing / 测试

  • go test ./...(本项目为 TypeScript,不适用;替代命令见下)
  • Manual test / 手动测试:

本项目使用的等价命令:

npx tsx --test "src/backend/server/*.test.ts"                       # 116 例 / 112 通过
npx tsx --test src/backend/internal/op/storage.test.ts \
                src/backend/internal/model/store/store.test.ts \
                src/backend/internal/driver/storageopts.test.ts     # 47 例 / 47 通过

结果是确定性的:src/backend/server/*.test.ts 失败的 4 例是既有问题,与本 PR 无关——用 git stash 暂存本 PR 的两个提交后跑同一套命令,失败集合完全一致(init_setup_guard / init_setup_blob 的 3 个初始化用例 + CAS codec matches casmeta base64 JSON field names)。这些用例单独运行时全部通过,属进程内的既有耦合问题。

反证(证明回归测试真的盯住了本 bug):把 fs.ts 的修复临时改回 raw_url: rawUrl,新测试立刻失败并打印出与 issue 完全同形的地址:

not ok 1 - fs/get: raw_url 自带签名,且该签名能通过 /p 验签(Issue #66)
  error: 'raw_url must carry the download sign, got /api/p/local/a.exe'

新增测试覆盖:

  • server/fs_get_rawurl_sign.test.ts(4 例):真实 Local 存储 + sign_all,走 /api/fs/get 断言 raw_url 带签名、sign 字段与 URL 内一致、verifyDownloadSign 通过、直接请求该 raw_url 不再 401;另加「篡改签名必 401」对照与「无需签名时不追加 sign」。
  • server/sign_go_parity.test.ts(7 例):
    • 编码字符集合与 Go EncodePath(path, true) 一致(100%.txt → 100%25.txt、a?b.txt → a%3Fb.txt、a#b.txt → a%23b.txt、a b.txt → a%20b.txt、存储1 → %E5%AD%98%E5%82%A81,$&+,:;=@ 保留);
    • 含 % / # / 中文 的文件名端到端可用(/fs/get → 直接请求 raw_url 非 401、非 500);
    • /p 对非法百分号转义返回 400;
    • link_expiration = 24 → 有效期 86400 秒(*-time.Hour);
    • sign_all + link_expiration = 0 → 签名 expire 字段为 0 且可验签(Go NotExpired);
    • 负数有效期仍视为已过期(Go 传负 duration 的行为)。

未做的验证:未在真实 Cloudflare Workers / EdgeOne 上做端到端回归。建议合并前手动确认一次:开启 sign_all → 打开任意 .exe(无内容预览的文件)的预览页 → 点「下载」→ 应正常下载;再用 curl -i '<origin>/api/fs/get' -H 'Content-Type: application/json' -d '{"path":"/..."}' 确认 raw_url 里带 ?sign=。产物需重新执行 node scripts/build-edge.mjs 生成。

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) / 其他(请注明): CodeBuddy (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 辅助内容。

待处理:本分支当前的两个提交尚未带 Co-Authored-By: CodeBuddy <noreply@codebuddy.ai> trailer。若维护者要求与仓库既有做法一致,需要 rebase 补 trailer 后 --force-with-lease 更新分支(文件树不变,提交哈希会变)。

Implementation Notes / 实现说明

为什么会「预览正常、下载 401」

前端动作 使用的 URL 是否自行拼 sign 结果
文件列表里下载 / 复制链接 useLink() 组装的 /d、/p 链接 会(用 /fs/list 返回的 sign) 正常
预览页「下载」按钮 <a href={objStore.raw_url}> 不会 需要签名时 401
图片 / 视频 / PDF 预览 <img>/<video>/pdf.js 的 src={objStore.raw_url} 不会 需要签名时同样 401(破图 / 黑屏)

issue 报告者用的是 .exe——这类文件没有内容预览,预览页只渲染元信息 + 下载按钮,所以「预览正常」而「下载 401」。问题比报告的更普遍:任何有内容预览的文件在同样配置下连预览都会坏。这也解释了为什么仓库用例 signed_link_auth.test.ts 一直没覆盖到——它只测了 /p/* 端点本身,没测「服务端返回给前端的 URL」是否合法。

与 Go 的对齐清单

已对齐(本 PR 修复):

环节 Go TS(本 PR 后)
是否需要给 raw_url 补签 isEncrypt(meta, path) || SignAll signPolicy.enabled || isEncryptPath()(同一判定,needDownloadSign() 与验签侧共用)
补签位置 {apiUrl}/p{EncodePath(path, true)}{query} /api/p{encodeDownloadPath(path)}{query}
路径编码 utils.EncodePath(path, true) pkg/path.encodeDownloadPath()(字符集合一致)
link_expiration 单位 / 0 小时;0 = NotExpired 小时;expire === 0 且验签跳过超期判定
down_proxy_url 补签 只看 disable_proxy_sign 只看 disable_proxy_sign(不再要求同 host)
getDownProxyUrl 多行 strings.Split(..., "\n")[0] 取第一行
/fs/link EncodePath + 无条件 sign 同(?d 无对应语义,未追加)

有意保留的差异(不改,理由如下):

差异 Go TS 不对齐的理由
sign_all 默认值 true(internal/bootstrap/data/setting.go) false(internal/model/db.ts) 属产品级默认值:改为 true 会让所有新装部署立即全站要求签名,而 s3.ts / webdav.ts / seed.ts 三处 raw_url 出口目前只编码、未补签,必须同批处理。建议单独 PR 决策。
raw_url 前缀 GetApiUrl(ctx) = site_url,返回绝对地址 请求相对的 /api/p/... TS 侧无对等配置(seed_site_url 语义是 transfer seed 源),且前端被强制同源部署(scripts/fetch-frontend.mjs 固定 VITE_API_URL=/),相对地址等价且不需要额外配置。
非代理存储 返回驱动真直链(model.GetUrl / fs.Link) 统一走 /api/p TS 有意为之:worker/edge 上大量驱动的直链必须带私有鉴权头(isAuthBoundDownload / raw_url_headers 即为它存在),且要处理 CORS 与平台 302/载荷限制。改成 Go 的行为会把这些问题重新引入。
签名串格式 / 密钥来源 base64url(hmac) + ":" + expire,密钥 conf.Token expire.hex(hmac),密钥 JWT_SECRET 两者本就不互通,换密钥无法获得互认收益;TS 侧 JWT_SECRET 已走 KV/D1 持久化。

未一并修复的同类出口(建议后续)

getItem() 的 rawUrl 还被 S3 网关与 WebDAV 用作 302 目标、被 seed.ts 用于服务端自取文件。本 PR 已统一它们的路径编码,但没有补签(S3/WebDAV 的客户端会跟随 302 到 /api/p,sign_all 开启时仍会 401)。根治办法是把补签下沉到 getItem()(需要把 env 传进 StorageRequestContext),会改动共享路径,故留给后续 PR。

Commits / 提交

  • acc8446 — fix(sign): /fs/get raw_url 缺少下载签名导致下载 401 (#66)
    (根因修复 + raw.ts buildDownProxyUrl 判定对齐 + server/fs_get_rawurl_sign.test.ts)
  • 058290c — fix(sign): 对齐 Go 的下载路径编码与 link_expiration 语义
    (pkg/path.ts、getItem/WebDAV/seed 编码、link_expiration 小时与永久语义、down_proxy_url 补签条件与多行解析、/fs/link 补签、server/sign_go_parity.test.ts)

预览页的下载按钮直接使用 /api/fs/get 返回的 raw_url(<a href={raw_url}>),图片/视频预览也直接把它当 src,前端不会再自己拼 ?sign=。而 raw_url 指向需要验签的 /p 端点,sign_all / 存储级 enable_sign / 密码 meta 任一命中时缺签名必然 401 sign verify failed:表现为预览页正常、点下载就 401(.exe 这类无内容预览的文件尤其明显)。

对齐 Go handles.FsGet:需要签名时把 ?sign= 追加到 raw_url。同时把 raw.ts buildDownProxyUrl 的补签条件从只看 sign_all 改为 needDownloadSign,与验签侧一致,避免只靠 enable_sign/密码 meta 的部署被 302 到无签名的地址。

新增回归测试 fs_get_rawurl_sign.test.ts:真实 Local 存储 + sign_all,校验 raw_url 带签名、签名可通过验签、直接请求 raw_url 不再 401,并附篡改签名必 401 的对照。
@cloudflare-workers-and-pages

This comment was marked as outdated.

@cloudflare-workers-and-pages

This comment was marked as outdated.

路径编码:新增 pkg/path.encodeDownloadPath,对齐 Go utils.EncodePath(path, true)
(保留 A-Za-z0-9-._~ 与 $&+,:;=@,其余含 % / ? / # / 空格 / 非 ASCII 逐字节编码)。
raw_url(getItem)、down_proxy_url 模板、WebDAV 302 Location、seed 自取地址统一改用
该函数。修复:文件名含 % 时 /p 的 decodeURIComponent 抛 URIError 导致 500(现回 400);
含 ? / # 的链接会被浏览器当成 query / fragment 截断成另一个路径。

link_expiration:单位由秒改为小时、0 表示永不过期(对齐 Go internal/sign 的
time.Duration(expire)*time.Hour 与官方文档 configuration/global.md "in hours")。
签名 expire=0 即永久,验签跳过超期判定(对齐 Go pkg/sign 的 expires != 0 分支);
负数有效期仍立即过期。

down_proxy_url:补签条件对齐 Go GenerateDownProxyURL —— 只看 disable_proxy_sign,
不再要求与请求同 host,避免 CDN / 前置代理 / 同实例另一域名这类典型配置拿到无签名
地址而必然 401;并修正「Go 支持 $path 占位符」的错误注释(Go 只是拼接 EncodePath)。
getDownProxyUrl 多行只取第一行(对齐 strings.Split(..., "\n")[0])。

/fs/link 兜底分支对齐 Go handles.Link:路径编码 + 无条件携带签名(Go 不看 needSign),
否则 sign_all / enable_sign / 密码 meta 生效时复制出来的链接必然 401。

新增 src/backend/server/sign_go_parity.test.ts:路径编码字符集合、含 % / # / 中文 的
raw_url 端到端可用、非法转义回 400、link_expiration 小时换算、expire=0 永久可验、
负有效期即过期。
@PIKACHUIM PIKACHUIM linked an issue Sep 20, 2026 that may be closed by this pull request
4 tasks
按 .github/PULL_REQUEST_TEMPLATE.md 撰写 docs/pr-sign-raw-url.md:根因链路(前端把
/fs/get 的 raw_url 当下载地址、服务端从未补签)、与 #51 的区分表、Go 对齐清单与有意
保留的差异、两处配置语义变更(link_expiration 单位与 0 语义)的兼容性提示、测试与
反证结果、以及未一并修复的同类出口。

顺带修正 storage.ts 中指向 pkg/utils 的过期注释(编码函数已移至 pkg/path.ts)。
PR 正文属评审材料,不进入仓库(本工作区既有约定是放在仓库外的 PR-*.md)。
内容已移至 ../PR-fix-66-sign-raw-url.md,可直接粘贴为 GitHub PR 正文。
#66 的补签修复上线后暴露第二个问题:预览页「下载」返回 403 proxy not allowed,
而文件列表右键下载正常。

原因:raw_url 恒为 `/api/p`,但 `/p` 会先跑 Go 的 canProxy()
(MustProxy || WebProxy || webdav_policy=use_proxy_url || proxy_types ||
text_types),不通过直接 403;`/d` 不受该门禁限制。文件列表走 `/d` 所以正常,
预览页的「下载」按钮与 img/video 的 src 用的却是 raw_url。

- op/storage.ts:新增 resolveRawUrlPrefix(),用 canUseProxyEndpoint() 决定 raw_url
  用 /api/p 还是 /api/d(与 Go handles.FsGet 只在 MustProxy||WebProxy 时才给 /p、
  其余走直链的语义一致,等价地落在 /d 上由 rawRouter 决策 302/proxy)。
- server/webdav.ts:删除重复的 prefix 计算(其 rawUrl 分支早已恒真,逻辑是死代码),
  直接用 getItem 的结果;未再使用的 import 一并移除。
- server/seed.ts:直链优先,回退用 getItem 的代理地址(前缀与编码已定),不再硬编码
  /api/p,否则未开代理的存储取种子文件同样 403。
- 新增 server/raw_url_prefix.test.ts:前缀选择(非代理→/d、web_proxy→/p)、
  proxy_types/text_types 判定、以及 /p 403 与 /d 非 403 的 HTTP 层对照。
实机报告的两个问题:

1. 文件预览页刷新报 Static resource not found(实测 404)
   assets.ts 里的「CDN 静态资源重定向」路由写成了 `/:folder/:filepath*`,
   它会吞掉**任意两段路径**。而文件预览页的地址正是 `<域名>/<挂载点>/<文件>`
   (例如 /存储2/xxx.exe),刷新时被它拦下,未配置 ASSET_URLS 时直接返回
   404 `Static resource not found`,请求永远走不到 SPA 兜底。
   改为对齐 Go server/static/static.go:只对固定目录白名单
   (assets / images / streamer / static)做 CDN 重定向;未配置 ASSET_URLS 时
   调用 next() 放行给本地静态资源(Workers ASSETS 绑定)与 SPA 兜底,
   不再回 404。logo / favicon 的重定向保持不变。

2. 后台存储编辑页的「序号」(storage.order)设置后排序不变
   - internal/op/storage.ts:虚拟根目录合并挂载点时直接沿用数据库数组顺序,
     现按 Go op.getStorageVirtualFilesByPath 的比较器排序:Order 升序,
     Order 相同时按 MountPath 升序;
   - server/admin.ts:/storage/list 按 Go db.GetStorages(addStorageOrder =
     `order, id`)排序返回,此前是数组原始顺序。

新增测试:
- server/assets_route.test.ts(5 例):`<挂载点>/<文件>` 刷新落到 SPA 兜底、
  未配置 ASSET_URLS 时 /assets/* 不再 404、配置后仅四个目录 302 且 $version
  被替换、logo/favicon 仍重定向;
- server/storage_order.test.ts(4 例):根目录挂载顺序(order / mount_path /
  跳过 disabled)、后台存储列表 order,id 顺序。
@PIKACHUIM
PIKACHUIM merged commit a1f9342 into main Sep 21, 2026
2 of 3 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.

[BUG] sign verify failed, proxy not allowed等问题

1 participant