diff --git a/.trellis/spec/app/logging/index.md b/.trellis/spec/app/logging/index.md index 5f1159b8..4ef01736 100644 --- a/.trellis/spec/app/logging/index.md +++ b/.trellis/spec/app/logging/index.md @@ -43,9 +43,13 @@ log.warning( - 鍵名(`鍵=值`、`"鍵": "值"`、欄位的鍵)→ `builtInKeyNames`。比對不分大小寫, 而且是「以名稱結尾」:加 `token` 就涵蓋 `access_token`,不必逐一列; - 媒體 CDN → `builtInMediaCdns`,`host` 涵蓋子網域,`signedQueryParameters` 列要去掉 - 的參數,路徑本身帶簽章時設 `signedPath: true`。 + 的參數,路徑本身帶簽章時設 `signedPath: true`。同一個網址符合多條規則(內建與插件 + 追加的)時全部合併:參數取聯集,任一條 `signedPath` 為真就換路徑。 - 只有某個插件知道的:插件載入時呼叫 `Redactor.addRules(...)`(M1 的 B 站插件在 PR 9)。 名單只增不減。 +- 內建名單變嚴格,插件庫(`1morr/fmp-plugins`)既有的 fixture 可能過不了契約測試的 + 「再遮一次不變」掃描:同一個 PR 裡用新名單跑一次各插件的契約測試,紅了就重錄,或照 + 遮蔽函式的輸出改那幾個值(例如拿掉 `buvid=***`),在插件庫另開 PR。 - 帳號的實際憑證值:登入或載入帳號時 `registerSecret`,登出時 `unregisterSecret` (帳號層在 M3)。少於 4 個字元的值會被拒絕。 - 每加一項,在 `test/core/redaction/redactor_test.dart` 加一個「會遮」的案例;名稱 diff --git a/.trellis/spec/app/plugins/index.md b/.trellis/spec/app/plugins/index.md index 4d7dbc12..43c43e93 100644 --- a/.trellis/spec/app/plugins/index.md +++ b/.trellis/spec/app/plugins/index.md @@ -115,8 +115,8 @@ export async function resolveStream({ sourceId, cid, formats }) { 需要登入的案例錄不了(M1 沒有憑證,會以 `AuthRequired` 失敗)。 - 錯誤案例(風控、下架)多半錄不到:手寫或把錄到的改掉,在 `meta.edited` 寫理由,錄製就不會蓋掉 那個案例。手寫的 fixture 也要是遮過的樣子(值寫 `***`),掃描不會放過。 -- 會變的 query 參數(時間戳、簽名)在 fixture 裡寫 `***` 就不比值;遮蔽名單上的參數錄的時候 - 已經是 `***`。 +- 會變的 query 參數(時間戳、簽名)在 fixture 裡寫 `***` 就不比值;鍵名名單上的參數錄的時候 + 已經是 `***`,媒體 CDN 規則上的簽名參數則整個拿掉。 - 跑:`FMP_PLUGIN_DIR=<絕對路徑> flutter test test/plugins/contract/contract_test.dart`;沒設 `FMP_PLUGIN_DIR` 就是跑 `app/` 內的測試插件(裸 `flutter test` 已包含)。失敗訊息列出每一條 違反,例如 `search: request #1 (GET …) does not match fixtures/search/001.json (GET …)`。 diff --git a/.trellis/tasks/09-28-m1-skeleton-tracer/implement.md b/.trellis/tasks/09-28-m1-skeleton-tracer/implement.md index 8d354c7d..e3af076d 100644 --- a/.trellis/tasks/09-28-m1-skeleton-tracer/implement.md +++ b/.trellis/tasks/09-28-m1-skeleton-tracer/implement.md @@ -34,7 +34,8 @@ - **PR 9c 完成**(子任務已 archive 到 `.trellis/tasks/archive/2026-09/09-30-bilibili-plugin/`):公開 repo `1morr/fmp-plugins`,本機 clone 在與 FMP 同層的 `fmp-plugins/`;B 站插件由該 repo 的 #1 合併(`e2b224b`),`bilibili/bilibili.js` 只有 `search`、`resolveStream`。 - 在 dev App 裝它:`fmp.exe --fmp-dev-plugin=/bilibili/bilibili.js`(Android 照 9a 的 `run-as` 做法)。 - 真實連線:兩次錄製共 8 個 GET,沒有遇到風控;fixture 人工逐檔檢查過。 -- **下一步**:FMP 遮蔽修正(「9c 留下的後續」前兩項,小 PR)→ YouTube.js 探針(與 10–13 並行)→ 10 播放核心 → 11 verify-on-device → 12 UI → 13 五平台建置與發版 workflow → 里程碑驗收。 +- **遮蔽修正**:`hdnts`/`buvid` 進內建名單、同 host 的規則合併套用;fmp-plugins 的 B 站 fixture 同步重新遮蔽。 +- **下一步**:YouTube.js 探針(與 10–13 並行)→ 10 播放核心 → 11 verify-on-device → 12 UI → 13 五平台建置與發版 workflow → 里程碑驗收。 - **擁有者決定**:1–8 都在父任務 `prd.md`「擁有者的決定」。9a、9b 期間新增了三項: - 決定 6:插件安裝檔是單一 `.js`,開頭帶 `==FMP Plugin==` manifest; - 決定 7:插件在背景 isolate 執行;逾時先送存活探測,沒回應才停用到重啟; @@ -195,11 +196,13 @@ 9c 留下的後續: -- [ ] 內建遮蔽名單缺 `hdnts`(Akamai)與 `buvid`(`app/lib/core/redaction/redaction_lists.dart` 的 `_bilibiliSigned`);官方插件靠 manifest 補上,使用者自寫的插件會漏。 -- [ ] `Redactor` 只套用第一個符合的 `MediaCdn`(`redactor.dart` 的 `firstOrNull`),插件追加的 CDN 規則蓋不到內建已有的 host;應合併所有符合項的參數。 +- [x] (遮蔽修正 PR)內建遮蔽名單缺 `hdnts`(Akamai)與 `buvid`(`app/lib/core/redaction/redaction_lists.dart` 的 `_bilibiliSigned`);官方插件靠 manifest 補上,使用者自寫的插件會漏。 +- [x] (遮蔽修正 PR)`Redactor` 只套用第一個符合的 `MediaCdn`(`redactor.dart` 的 `firstOrNull`),插件追加的 CDN 規則蓋不到內建已有的 host;應合併所有符合項的參數。 - [ ] 登入後 `_AuthInterceptor` 以 `headers.addAll` 注入 `Cookie`,會整個蓋掉插件送的匿名 `buvid3`;舊專案是合併。M3 登入任務決定合併或交給插件。 - [ ] B 站插件的 `rateLimit`(併發 2、間隔 300ms)沒有量測依據;`allowedHosts` 外的 PCDN(`szbdyd.com`、直接寫 IP 的節點)會被丟掉,舊專案不限制。M6 媒體 client 接上時一併看。 - [ ] PR 8 的 `ignoreInvalidCookies` 觀察:兩次錄製的回應都沒有 `Set-Cookie`,沒觀察到;留到會發 cookie 的端點(登入)。 +- [ ] 媒體 CDN 的簽名參數目前是整個拿掉;內建名單每變嚴格一次,既有 fixture 就過不了「再遮蔽一次不變」的掃描(遮蔽修正 PR 時手動改了 fmp-plugins 的 24 個網址)。審查建議改成「值換成 `***`」:已遮過的不再誤紅、明文照樣紅。改動是 `_redactMediaUrl` 一行加既有測試期望,M3 插件庫 CI 上線前做。 +- [ ] 插件每重新載入一次,`Redactor._mediaCdns` 就多一份相同規則(輸出不受影響,只是多掃);M3 插件頁的重新載入出現時一併去重。 ### 探針:YouTube.js(擁有者決定 3) diff --git a/.trellis/tasks/09-28-m1-skeleton-tracer/task.json b/.trellis/tasks/09-28-m1-skeleton-tracer/task.json index 64c9aa15..65fe5cdd 100644 --- a/.trellis/tasks/09-28-m1-skeleton-tracer/task.json +++ b/.trellis/tasks/09-28-m1-skeleton-tracer/task.json @@ -29,7 +29,8 @@ "09-29-network-layer", "09-30-js-runtime", "09-30-plugin-contract", - "09-30-bilibili-plugin" + "09-30-bilibili-plugin", + "09-30-redaction-cdn-rules" ], "parent": "09-26-fmp-rewrite", "relatedFiles": [], diff --git a/.trellis/tasks/archive/2026-09/09-30-redaction-cdn-rules/check.jsonl b/.trellis/tasks/archive/2026-09/09-30-redaction-cdn-rules/check.jsonl new file mode 100644 index 00000000..235a0137 --- /dev/null +++ b/.trellis/tasks/archive/2026-09/09-30-redaction-cdn-rules/check.jsonl @@ -0,0 +1,3 @@ +{"file": "docs/adr/0011-settings-and-logging.md", "reason": "Redaction function and lists"} +{"file": ".trellis/spec/app/logging/index.md", "reason": "Redactor usage"} +{"file": ".trellis/tasks/archive/2026-09/09-30-bilibili-plugin/research/notes.md", "reason": "Recording evidence for the gaps"} diff --git a/.trellis/tasks/archive/2026-09/09-30-redaction-cdn-rules/implement.jsonl b/.trellis/tasks/archive/2026-09/09-30-redaction-cdn-rules/implement.jsonl new file mode 100644 index 00000000..235a0137 --- /dev/null +++ b/.trellis/tasks/archive/2026-09/09-30-redaction-cdn-rules/implement.jsonl @@ -0,0 +1,3 @@ +{"file": "docs/adr/0011-settings-and-logging.md", "reason": "Redaction function and lists"} +{"file": ".trellis/spec/app/logging/index.md", "reason": "Redactor usage"} +{"file": ".trellis/tasks/archive/2026-09/09-30-bilibili-plugin/research/notes.md", "reason": "Recording evidence for the gaps"} diff --git a/.trellis/tasks/archive/2026-09/09-30-redaction-cdn-rules/prd.md b/.trellis/tasks/archive/2026-09/09-30-redaction-cdn-rules/prd.md new file mode 100644 index 00000000..0fa5817d --- /dev/null +++ b/.trellis/tasks/archive/2026-09/09-30-redaction-cdn-rules/prd.md @@ -0,0 +1,29 @@ +# 遮蔽:補 hdnts/buvid 並合併 CDN 規則 + +父任務:`../09-28-m1-skeleton-tracer`(「9c 留下的後續」前兩項)。依據:ADR 0011(遮蔽函式);9c 的錄製證據在 `../archive/2026-09/09-30-bilibili-plugin/research/notes.md`。 + +## 問題(都有失效條件) + +1. 內建的 B 站 CDN 規則(`app/lib/core/redaction/redaction_lists.dart` 的 `_bilibiliSigned`)不含 `buvid` 與 `hdnts`。 + - 9c 第一次錄製時,串流網址帶著 `buvid=<匿名裝置 id>`,Akamai 鏡像的網址帶著 `hdnts=exp=…~hmac=…`。 + - 官方插件靠 manifest 的 `keyNames` 補上了;沒有補的插件,串流網址一進 log 或 fixture 就是原值。 +2. `Redactor._redactMediaUrl` 只套用第一個符合 host 的 `MediaCdn`。內建規則排在前面,所以插件以 `addRules(mediaCdns:)` 替同一個 host 追加的參數不會生效。 + +## 做什麼 + +- `_bilibiliSigned` 加上 `buvid`、`hdnts`。 +- `_redactMediaUrl` 合併所有符合的規則:參數取聯集,`signedPath` 任一條為真就套用。 +- 測試,修正前都會紅: + - Akamai 網址的 `hdnts` 與 `buvid` 被拿掉; + - 插件規則與內建規則同時生效; + - 任一條規則的 `signedPath` 都會生效。 +- `1morr/fmp-plugins` 的 `bilibili/fixtures/resolveStream/003.json` 在內建規則變嚴格後,過不了「再遮蔽一次不變」的掃描。 + - 用同樣的轉換拿掉 CDN 網址裡的 `hdnts=***`、`buvid=***`,只動那 24 個網址,不重錄。 + - 結果和遮蔽函式的輸出相同,由掃描本身驗證。 + - 該 repo 另開 PR 合併。 + +## 驗收 + +- [ ] `app/` 驗證清單全過:format、analyze、`flutter test`、哨兵。 +- [ ] 新測試在修正前會紅。 +- [ ] B 站插件在 fmp-plugins 修改後的 fixture 上重播全綠。 diff --git a/.trellis/tasks/archive/2026-09/09-30-redaction-cdn-rules/task.json b/.trellis/tasks/archive/2026-09/09-30-redaction-cdn-rules/task.json new file mode 100644 index 00000000..86336327 --- /dev/null +++ b/.trellis/tasks/archive/2026-09/09-30-redaction-cdn-rules/task.json @@ -0,0 +1,26 @@ +{ + "id": "redaction-cdn-rules", + "name": "redaction-cdn-rules", + "title": "遮蔽:補 hdnts/buvid 並合併 CDN 規則", + "description": "Add hdnts and buvid to built-in media redaction and apply every matching MediaCdn rule", + "status": "completed", + "dev_type": null, + "scope": null, + "package": "app", + "priority": "P2", + "creator": "1morr", + "assignee": "1morr", + "createdAt": "2026-09-30", + "completedAt": "2026-09-30", + "branch": "fix/redaction-cdn-rules", + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": "09-28-m1-skeleton-tracer", + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/app/lib/core/redaction/redaction_lists.dart b/app/lib/core/redaction/redaction_lists.dart index 3b6579f2..baebcce7 100644 --- a/app/lib/core/redaction/redaction_lists.dart +++ b/app/lib/core/redaction/redaction_lists.dart @@ -54,7 +54,8 @@ const builtInKeyNames = [ /// 已知媒體 CDN:串流網址裡的簽名、到期時間、使用者 IP 與 id。 const builtInMediaCdns = [ - // Bilibili upos;`e` 是編碼過的簽名內容,`mid` 是使用者 id、`oi` 由 IP 算出。 + // Bilibili upos;`e` 是編碼過的簽名內容,`mid` 是使用者 id、`oi` 由 IP 算出, + // `buvid` 是請求帶的裝置 id,`hdnts` 是 Akamai 鏡像的 token(`exp=…~hmac=…`)。 MediaCdn(host: 'bilivideo.com', signedQueryParameters: _bilibiliSigned), MediaCdn(host: 'bilivideo.cn', signedQueryParameters: _bilibiliSigned), MediaCdn(host: 'akamaized.net', signedQueryParameters: _bilibiliSigned), @@ -92,9 +93,12 @@ const _bilibiliSigned = { 'trid', 'mid', 'oi', + 'buvid', + 'hdnts', }; -/// 一個媒體 CDN 的遮蔽規則。 +/// 一個媒體 CDN 的遮蔽規則。一個網址符合多條規則(內建與插件追加的)時, +/// `Redactor` 全部合併套用。 @immutable final class MediaCdn { const MediaCdn({ diff --git a/app/lib/core/redaction/redactor.dart b/app/lib/core/redaction/redactor.dart index 047cd39c..507c09c1 100644 --- a/app/lib/core/redaction/redactor.dart +++ b/app/lib/core/redaction/redactor.dart @@ -209,12 +209,15 @@ final class Redactor { final text = matched.replaceAll(r'\/', '/'); final uri = Uri.tryParse(text); if (uri == null || !uri.hasAuthority) return matched; - final cdn = _mediaCdns.where((cdn) => cdn.matches(uri.host)).firstOrNull; - if (cdn == null) return matched; + // 內建與插件追加的規則可能同時符合同一個 host,全部合併套用。 + final cdns = _mediaCdns.where((cdn) => cdn.matches(uri.host)).toList(); + if (cdns.isEmpty) return matched; final signed = { - for (final name in cdn.signedQueryParameters) name.toLowerCase(), + for (final cdn in cdns) + for (final name in cdn.signedQueryParameters) name.toLowerCase(), }; + final signedPath = cdns.any((cdn) => cdn.signedPath); final query = [ for (final part in uri.query.split('&')) if (part.isNotEmpty && !signed.contains(_queryName(part).toLowerCase())) @@ -222,7 +225,7 @@ final class Redactor { ].join('&'); // 保留原本的編碼:切原始路徑,不用解碼過的 pathSegments。第一段是空字串。 final segments = uri.path.split('/'); - final path = cdn.signedPath && segments.length > 2 + final path = signedPath && segments.length > 2 ? [ '', for (var i = 2; i < segments.length; i++) redactedValue, diff --git a/app/test/core/redaction/redactor_test.dart b/app/test/core/redaction/redactor_test.dart index db92dc85..91ff7508 100644 --- a/app/test/core/redaction/redactor_test.dart +++ b/app/test/core/redaction/redactor_test.dart @@ -258,6 +258,51 @@ void main() { expect(redactor.redact(url), 'https://cdn.fake-source.test/a.mp3?q=1'); }); + + test('Bilibili stream URLs lose the device id and the Akamai token', () { + expect( + redactor.redact( + 'https://upos-hz-mirrorakam.akamaized.net/x.m4s?gen=playurlv3' + '&hdnts=exp=1~hmac=FAKE_HMAC_1&bw=1&buvid=FAKE_BUVID_1', + ), + 'https://upos-hz-mirrorakam.akamaized.net/x.m4s?gen=playurlv3&bw=1', + ); + }); + + test('a plugin rule adds to a built-in rule for the same host', () { + const url = + 'https://upos-fake.bilivideo.com/a.m4s?upsig=FAKE_UP_3&fake_sig=FAKE_S&q=1'; + + redactor.addRules( + mediaCdns: [ + const MediaCdn( + host: 'upos-fake.bilivideo.com', + signedQueryParameters: {'fake_sig'}, + ), + ], + ); + + expect(redactor.redact(url), 'https://upos-fake.bilivideo.com/a.m4s?q=1'); + }); + + test('a signed path from any matching rule applies', () { + const url = 'https://m1.fake-source.test/111/222/a.mp3?q=1'; + + redactor.addRules( + mediaCdns: [ + const MediaCdn( + host: 'fake-source.test', + signedQueryParameters: {'x'}, + ), + const MediaCdn(host: 'm1.fake-source.test', signedPath: true), + ], + ); + + expect( + redactor.redact(url), + 'https://m1.fake-source.test/***/***/a.mp3?q=1', + ); + }); }); group('plugin lists', () {