Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .trellis/spec/app/logging/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 加一個「會遮」的案例;名稱
Expand Down
4 changes: 2 additions & 2 deletions .trellis/spec/app/plugins/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 …)`。
Expand Down
9 changes: 6 additions & 3 deletions .trellis/tasks/09-28-m1-skeleton-tracer/implement.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<fmp-plugins>/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 執行;逾時先送存活探測,沒回應才停用到重啟;
Expand Down Expand Up @@ -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)

Expand Down
3 changes: 2 additions & 1 deletion .trellis/tasks/09-28-m1-skeleton-tracer/task.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [],
Expand Down
Original file line number Diff line number Diff line change
@@ -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"}
Original file line number Diff line number Diff line change
@@ -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"}
29 changes: 29 additions & 0 deletions .trellis/tasks/archive/2026-09/09-30-redaction-cdn-rules/prd.md
Original file line number Diff line number Diff line change
@@ -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 上重播全綠。
26 changes: 26 additions & 0 deletions .trellis/tasks/archive/2026-09/09-30-redaction-cdn-rules/task.json
Original file line number Diff line number Diff line change
@@ -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": {}
}
8 changes: 6 additions & 2 deletions app/lib/core/redaction/redaction_lists.dart
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ const builtInKeyNames = <String>[

/// 已知媒體 CDN:串流網址裡的簽名、到期時間、使用者 IP 與 id。
const builtInMediaCdns = <MediaCdn>[
// 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),
Expand Down Expand Up @@ -92,9 +93,12 @@ const _bilibiliSigned = {
'trid',
'mid',
'oi',
'buvid',
'hdnts',
};

/// 一個媒體 CDN 的遮蔽規則。
/// 一個媒體 CDN 的遮蔽規則。一個網址符合多條規則(內建與插件追加的)時,
/// `Redactor` 全部合併套用。
@immutable
final class MediaCdn {
const MediaCdn({
Expand Down
11 changes: 7 additions & 4 deletions app/lib/core/redaction/redactor.dart
Original file line number Diff line number Diff line change
Expand Up @@ -209,20 +209,23 @@ 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()))
part,
].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,
Expand Down
45 changes: 45 additions & 0 deletions app/test/core/redaction/redactor_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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', () {
Expand Down
Loading