From 02cee812c4a01718c2effcae5bf718a1f1b9670c Mon Sep 17 00:00:00 2001 From: 1morr Date: Tue, 29 Sep 2026 23:51:05 +0800 Subject: [PATCH 1/3] feat(app): add the per-plugin http client Requests reach only the plugin's https hosts, redirects are followed by hand with every hop checked, cookies stay with the host that set them, and retries re-run auth, throttling and the network log per attempt. --- .trellis/spec/app/errors/index.md | 7 +- .trellis/spec/app/network/index.md | 109 +++ app/AGENTS.md | 56 +- app/lib/core/errors/app_error.dart | 7 +- app/lib/core/errors/report_error.dart | 12 +- app/lib/core/logging/uncaught_errors.dart | 43 +- app/lib/core/network/allowed_hosts.dart | 50 + app/lib/core/network/auth.dart | 62 ++ app/lib/core/network/interceptors.dart | 336 +++++++ app/lib/core/network/media_headers.dart | 15 + app/lib/core/network/request_throttle.dart | 99 ++ app/lib/core/network/source_http_client.dart | 357 ++++++++ .../lib/src/rules/layer_imports.dart | 3 + .../test/rules/layer_imports_test.dart | 6 +- app/pubspec.lock | 40 + app/pubspec.yaml | 5 + .../core/errors/app_error_surface_test.dart | 3 + app/test/core/errors/app_error_test.dart | 2 + app/test/core/errors/report_error_test.dart | 11 + .../core/logging/uncaught_errors_test.dart | 52 ++ app/test/core/network/allowed_hosts_test.dart | 92 ++ app/test/core/network/auth_test.dart | 102 +++ app/test/core/network/harness.dart | 89 ++ app/test/core/network/media_headers_test.dart | 42 + .../core/network/request_throttle_test.dart | 129 +++ .../core/network/source_http_client_test.dart | 854 ++++++++++++++++++ app/test/support/fake_http_adapter.dart | 50 + app/test/support/pump_until.dart | 20 + 28 files changed, 2628 insertions(+), 25 deletions(-) create mode 100644 .trellis/spec/app/network/index.md create mode 100644 app/lib/core/network/allowed_hosts.dart create mode 100644 app/lib/core/network/auth.dart create mode 100644 app/lib/core/network/interceptors.dart create mode 100644 app/lib/core/network/media_headers.dart create mode 100644 app/lib/core/network/request_throttle.dart create mode 100644 app/lib/core/network/source_http_client.dart create mode 100644 app/test/core/network/allowed_hosts_test.dart create mode 100644 app/test/core/network/auth_test.dart create mode 100644 app/test/core/network/harness.dart create mode 100644 app/test/core/network/media_headers_test.dart create mode 100644 app/test/core/network/request_throttle_test.dart create mode 100644 app/test/core/network/source_http_client_test.dart create mode 100644 app/test/support/fake_http_adapter.dart create mode 100644 app/test/support/pump_until.dart diff --git a/.trellis/spec/app/errors/index.md b/.trellis/spec/app/errors/index.md index 9cc9459a..047706d5 100644 --- a/.trellis/spec/app/errors/index.md +++ b/.trellis/spec/app/errors/index.md @@ -36,7 +36,7 @@ throw RateLimited( ``` - `report` 的 `message` 寫失敗的動作(英文),會變的值不拼進去;`tag` 是模組或音源 id。 - 層級由 `expected` 決定,不自己選。 + 層級由 `expected` 決定,不自己選;`level` 參數只給未捕捉錯誤的處理器用。 - 背景工作也 `report`,但不跳 toast,只更新對應畫面的狀態(ADR 0013 §決定 5)。 - 不要把 `AppError` 直接傳給 `log.error(error: ...)`:它的 `toString()` 沒有原始 error, 那筆 log 就少了原因。 @@ -87,7 +87,10 @@ extension 的成員(含靜態)與頂層宣告。加欄位、方法或頂層 並寫出為什麼不是給使用者看的文字。原始 error 這類只給 log 的東西用私有欄位,在 `report_error.dart` 讀。 -## 重試(網路層,PR 8 起) +## 重試(網路層) + +實作在 `SourceHttpClient._sendWithRetry`(`.trellis/spec/app/network/index.md`), +形狀如下: ```dart for (var attempt = 0; ; attempt++) { diff --git a/.trellis/spec/app/network/index.md b/.trellis/spec/app/network/index.md new file mode 100644 index 00000000..4edcac82 --- /dev/null +++ b/.trellis/spec/app/network/index.md @@ -0,0 +1,109 @@ +# 網路(`app/lib/core/network/`) + +發 HTTP 請求、改攔截器、接插件的請求、寫網路相關測試時適用。規則(`Dio` 只在這裡建、 +攔截器順序、重試只在這裡、網域與轉址、網路紀錄欄位)與閘門見 `app/AGENTS.md` § 網路; +為什麼這樣做,見 ADR 0012 §決定 1–2、ADR 0013 §決定 2、4。這裡只寫怎麼做。 + +## 目錄 + +``` +lib/core/network/ + source_http_client.dart # SourceHttpClientFactory、SourceHttpClient、SourceRequest、SourceResponse、RequestCancelled + interceptors.dart # part:五個攔截器、每次送出的狀態 _Attempt、只收自己 host 的 cookie jar + auth.dart # AuthRequirement、decideAuth、CredentialSource、NoCredentials + allowed_hosts.dart # AllowedHosts:manifest 網域比對(請求與 cookie 的 Domain 共用) + request_throttle.dart # RequestThrottle:併發上限+最小間隔 + media_headers.dart # mediaRequestHeaders:媒體請求只留的 header +``` + +## 發一個請求 + +```dart +// 組裝點(PR 9 起):一個 factory,log 與認證來源給一次。 +final factory = SourceHttpClientFactory(log: log); +final client = factory.create( + pluginId: manifest.id, + allowedHosts: manifest.allowedHosts, + retryPolicy: manifest.retryPolicy ?? const RetryPolicy(), + rateLimitPolicy: manifest.rateLimitPolicy, +); + +final response = await client.send( + SourceRequest( + url, + headers: {'Referer': referer}, + auth: AuthRequirement.userPreference, + ), + abortTrigger: cancelled.future, +); +``` + +- `send` 丟的只有 `AppError` 與 `RequestCancelled`。網域不符、轉址出網域、`Location` + 解析不了或超過 5 次是 `Unsupported`;未登入的 `required` 是 `AuthRequired`;傳輸錯誤是 `NetworkError`; + 429 與帶 `Retry-After` 的 503 是 `RateLimited`(都已經照策略重試過)。 +- 其他狀態碼照樣回 `SourceResponse`,插件在自己的邊界依錯誤對應表轉成 `AppError` + (`.trellis/spec/app/errors/index.md` § 音源的錯誤對應表)。 +- `body` 是位元組;JSON 由插件自己 `utf8.decode`。 +- 方法照 RFC 9110 大寫(`GET`),重試只看它判斷冪等;`get` 會被當成不冪等。 +- 網址寫在 `lib/core/endpoints.dart`(`fmp_url_literal`),插件的網址在插件裡。 + +## 一次 `send` 的流程 + +1. 檢查網域(`AllowedHosts.allows`)。 +2. 送出:`_Attempt` 帶新的紀錄 id 放進 `extra`,經五個攔截器到 adapter。 +3. 失敗時依 `shouldRetry`/`delayFor` 決定要不要等了再送,回到 2(新的紀錄 id、 + `retry` 加一)。 +4. 301/302/303/307/308 帶 `Location`:檢查網域,照 RFC 9110 §15.4 改方法,跨 host + 就拿掉 `Cookie`、`Authorization` 並改成 `AuthRequirement.never`(之後各跳沿用, + 轉回原本的 host 也不再帶),回到 1。 + +## 改攔截器 + +- 攔截器都在 `interceptors.dart`(`source_http_client.dart` 的 `part`),順序在 + `SourceHttpClientFactory.create` 的 `interceptors.addAll`。 +- 攔截器之間共用的狀態放 `_Attempt`,不另開 `extra` 的鍵。 +- reject 一律 `handler.reject(error, true)`;在 onRequest 或 onResponse 裡要讓請求失敗 + 時,把 `AppError` 放進 `DioException.error`(`_rejection`),`send` 會把它拆出來丟。 +- 會等的攔截器(例如限流)先把要釋放的東西記進 `_Attempt` 再等:等的時候被取消,dio + 直接走 onError,要在那裡釋放。 +- 改了順序,`interceptors run in the ADR 0012 order` 應該會紅;新的前後關係如果看得出 + 行為差異,在那個測試加一條斷言。 + +## 測試 + +```dart +final harness = Harness( + (options) => switch (options.uri.path) { + '/a' => redirect('/b'), + _ => reply(200, body: '{}'), + }, + credentials: FakeCredentials(headers: {'Cookie': 'SESSDATA=FAKE_SESSDATA_123'}), + retryPolicy: const RetryPolicy(maxRetries: 0), +); +await harness.get('https://example.test/a', auth: AuthRequirement.userPreference); +expect(harness.adapter.requests.last.headers['cookie'], ...); +expect(harness.records.single.fields['status'], 200); +``` + +- `test/core/network/harness.dart`:client 加上 `test/support/fake_http_adapter.dart` + 的假 adapter(不聯網,記下送到最底層的 `RequestOptions`)、假時鐘(`waits` 記下每次 + 等待,等待立刻完成並把時鐘往前撥)、固定種子的 `Random`、`LogLevel.debug` 的 log。 +- adapter 要模擬傳輸錯誤就在 handler 裡 `throw DioException.connectionTimeout(...)` 之類; + 要模擬掛著的請求就回一個自己控制的 `Completer` 的 future。 +- 等非同步進度用 `test/support/pump_until.dart` 的 `pumpUntil`/`settle` + (`fmp_test_waits`)。 +- 網域在測試裡用 `example.test`、`cdn.example`(RFC 2606 保留網域),不用真實網站。 +- 驗遮蔽時兩個出口都看:`harness.log.history` 與 `LogFile`(見 + `.trellis/spec/app/logging/index.md` § 測試)。 + +## 媒體 header + +媒體 client 延到 M6。播放後端(PR 10)拿到插件給的串流 headers 時先過 +`mediaRequestHeaders`,再交給 just_audio/media_kit。要多放一個 header 時改 +`mediaHeaderNames`,並在 `media_headers_test.dart` 加一個會留與一個不會留的案例。 + +## Quality Check + +- `test/core/network/` 全綠;新行為在對應群組有案例。 +- `lib/core/network/` 沒有 import 上層(`fmp_layer_imports`)、沒有網址字面值。 +- 改了網路紀錄的欄位:`app/AGENTS.md` § 網路與 `network log` 群組一起改。 diff --git a/app/AGENTS.md b/app/AGENTS.md index f3dd6f91..bdb43921 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -133,7 +133,7 @@ lint 的範圍是整個 `lib/platform/`,組裝點以外的平台層檔案、 追加)。門面在交給 talker 之前就把 error、stackTrace 轉成遮蔽過的字串,原始物件不進 歷史。閘門:`test/core/logging/log_test.dart`(假憑證經訊息、error、stackTrace、深層 欄位寫入後,記憶體歷史與 log 檔都沒有原值)、`test/core/redaction/redactor_test.dart`。 - 「其他出口也經過它」要等出口出現(網路紀錄 PR 8、診斷包 M3)各自補測試。 + 網路紀錄經門面寫入,閘門見「網路」;診斷包(M3)出現時各自補測試。 - 遮蔽本身拋錯時,門面把那筆換成只有層級與失敗型別的 `Redaction failed` 紀錄,不退回 原文。閘門:`log_test.dart` 的 `a record that cannot be redacted`。 - log 檔:資料目錄的 `logs/fmp.jsonl`,JSON Lines,單檔 2MB,輪替成 `fmp.1.jsonl`、 @@ -143,7 +143,11 @@ lint 的範圍是整個 `lib/platform/`,組裝點以外的平台層檔案、 `lastFailure`。沒有資料目錄的平台只有記憶體歷史。 - 層級:debug build 為 `debug`,profile/release 為 `info`;console 只在 debug build。 未捕捉的錯誤(`FlutterError.onError`、`PlatformDispatcher.onError`)經門面以 `error` - 寫入,`main()` 在解析資料目錄之後才接上,之前的錯誤走 Flutter 預設處理。 + 寫入;是 `AppError` 的改走 `log.report`(才寫得出原因),以 `level` 參數固定為 + `error`,不依 `expected`(沒人接就是沒處理)。`level` 只給這裡用,處理過的錯誤不傳。 + `main()` 在解析資料目錄之後才接上,之前的錯誤走 Flutter 預設處理。閘門: + `test/core/logging/uncaught_errors_test.dart`(預期內的 `AppError` 未捕捉仍是 `error`, + 原因經遮蔽寫出)。 ## 錯誤 @@ -160,13 +164,57 @@ lint 的範圍是整個 `lib/platform/`,組裝點以外的平台層檔案、 寫進錯誤歷史,那是唯一讀得到原始 error 的路徑。閘門:`report_error_test.dart` 驗層級、欄位與遮蔽;「處理了卻沒 report」沒有閘門,review 時看。 - 重試只有網路層一層,用 `retry_policy.dart` 的純函數;Riverpod 的重試已關(見 - 「Riverpod」)。閘門:`retry_policy_test.dart`;網路層真的只在那裡重試,由 PR 8 的 - 測試接手。 + 「Riverpod」)。閘門:`retry_policy_test.dart`;網路層的重試見「網路」。 - 禁止空 catch 與靜默吞錯。閘門:lint `fmp_no_empty_catch`(只擋空的本體;catch 了 只 `return null` 之類的吞錯沒有閘門)。 - `report` 的欄位名稱與 `type` 的值(寫死的類別名,不是 `runtimeType`)進 log 檔, 是持久化格式。閘門:`report_error_test.dart` 的 `writes the structured fields`。 +## 網路 + +`lib/core/network/`(ADR 0012 §決定 1–2、ADR 0013 §決定 2、4)。怎麼發請求、改攔截器、 +寫測試:`.trellis/spec/app/network/index.md`。測試都在 `test/core/network/`。 + +- 每插件一個 `SourceHttpClient`,由 `SourceHttpClientFactory.create` 建立;`Dio` 只在 + 那裡建立,`dio`(含 `dio_cookie_manager`)與 `cookie_jar` 只准在 `lib/core/network/` + import。閘門:lint `fmp_http_client_owner`、`fmp_layer_imports`。 +- 攔截器順序:認證 → cookie → 錯誤對應 → 限流 → 網路紀錄。dio 的 onRequest、 + onResponse、onError 都依加入順序執行,回程不反轉。攔截器 reject 一律帶第二個參數 + `true`,否則後面的 onError 全被跳過:限流拿不回位置、網路紀錄少一筆。閘門: + `source_http_client_test.dart` 的 `interceptors run in the ADR 0012 order`(看得到的 + 前後關係)、`a failed request gives its place back`。 +- 重試在 `SourceHttpClient` 的迴圈裡,不在攔截器:每次重試重新走整條攔截器鏈(重新 + 判斷認證、重新排限流),每次送出各一筆網路紀錄。閘門:同一檔的 `retry` 群組(只重試 + 冪等請求、次數上限、`Retry-After`、取消不重試)。 +- 網域:只准 `https`,host 等於 manifest 允許清單的項目或是它的子網域(帶百分比編碼的 + host 一律不准);不符就不發請求,丟 `Unsupported`。轉址手動跟隨 + (`followRedirects: false`),每跳都檢查,最多 5 次,`Location` 解析不了也是 + `Unsupported`;跨 host 的下一跳拿掉原請求的 `Cookie`、`Authorization`,之後各跳都不再 + 帶憑證。閘門:`allowed_hosts_test.dart`、`redirects` 群組。 +- cookie 只存給設它的 host:`dio_cookie_manager` 原本會把轉址回應的 `Set-Cookie` 也存給 + `Location` 的 host,`cookie_jar` 也不檢查 `Domain` 屬性;這裡兩處都改了,`Domain` + 必須涵蓋回應的 host 而且本身在允許清單內,否則丟掉。閘門:`redirects` 群組的 + `a cross-host hop drops Cookie…`、`Set-Cookie Domain`。 +- 狀態碼:網路層只把 429 與帶 `Retry-After` 的 503 轉成 `RateLimited`,其他回應原樣 + 交給插件對應;傳輸錯誤轉 `NetworkError`。閘門:`error mapping` 群組。 +- 取消(`abortTrigger`)丟 `RequestCancelled`,不是 `AppError`:只有取消的一方收到, + 不重試、不 report。 +- cookie:每插件一個記憶體 jar,網路層不持久化。匿名 cookie(B 站 `buvid`)要跨重啟 + 時,由插件從回應的 `Set-Cookie` 取值寫進自己的 storage(`plugin_storage`,ADR 0014 + §決定 5),下次以 `Cookie` header 帶上(cookie 管理會併進 jar 的 cookie)。沒有閘門, + review 時看。 +- 網路紀錄:tag `network`,每次送出一筆,欄位 `id`、`pluginId`、`method`、`host`、 + `path`、`query`、`status`、`ms`、`bytes`、`error`、`credentials`、`retry`;不記 + body。未登入而拒絕的 `required` 請求沒送出,也有一筆(沒有 `status`、`ms`), + `AuthRequired` 帶它的 id。失敗或狀態碼 ≥ 400 用 `warning`,其餘 `debug`。欄位名稱是 log 檔的持久化格式; + 網路層產生的 `AppError` 帶那一筆的 `networkRecordId`。閘門:`network log` 群組 + (欄位逐一比對;query 裡的假憑證與 body 不出現在記憶體歷史與 log 檔)。 +- 認證:請求宣告 `AuthRequirement`,攔截器只依 `decideAuth` 的表注入。M1 的認證來源是 + `NoCredentials`(每個音源都未登入)。閘門:`auth_test.dart`(三種標記 × 三種狀態)。 +- 媒體 client 延到 M6。交給播放後端的串流 headers 一律先經 `mediaRequestHeaders`:只留 + `Referer`、`User-Agent`、`Origin`、`Range`。閘門:`media_headers_test.dart`;後端確實 + 經過它,由 PR 10 的測試接手。 + ## 設定 `lib/settings/`(ADR 0011 §決定 7)。怎麼加一個設定欄位:`.trellis/spec/app/settings/index.md`。 diff --git a/app/lib/core/errors/app_error.dart b/app/lib/core/errors/app_error.dart index 39722ab1..0ef9ba38 100644 --- a/app/lib/core/errors/app_error.dart +++ b/app/lib/core/errors/app_error.dart @@ -83,12 +83,17 @@ sealed class AppError implements Exception { /// bug,為假。決定錯誤歷史的層級。 final bool expected; - /// 對應的網路紀錄 id(ADR 0011 §決定 4),網路層在 PR 8 填入。 + /// 對應的網路紀錄 id(ADR 0011 §決定 4)。網路層建立錯誤時填入那次送出的 + /// 紀錄 id;沒有送出(例如網域不符)時為 `null`。 final int? networkRecordId; final Object? _cause; final StackTrace? _stackTrace; + /// log 與網路紀錄用的型別名稱(寫死的類別名,不是 `runtimeType`)。它是 + /// log 檔的持久化值,不是給使用者看的文字。 + String get typeName => _typeName(this); + /// 只給 log 用。不含原始 error:原文可能帶憑證或伺服器訊息,只經 /// [AppErrorReport.report] 交給門面遮蔽後寫出。 @override diff --git a/app/lib/core/errors/report_error.dart b/app/lib/core/errors/report_error.dart index 33c27f54..647347ce 100644 --- a/app/lib/core/errors/report_error.dart +++ b/app/lib/core/errors/report_error.dart @@ -11,8 +11,16 @@ extension AppErrorReport on Log { /// 音源 id,比照 [Log.write]。層級:[AppError.expected] 為真是 `warning`, /// 否則是 `error`。原始 error 與 stackTrace 交給門面遮蔽;型別、音源、網路 /// 紀錄 id、可否重試與 `Retry-After` 放結構化欄位,Debug 頁依它們篩選。 - void report(String message, AppError error, {required String tag}) => write( - error.expected ? LogLevel.warning : LogLevel.error, + /// + /// [level] 只給未捕捉錯誤的處理器(`routeUncaughtErrors`)用:沒人接的錯誤 + /// 一律是 `error`,不論 [AppError.expected]。處理過的錯誤不傳,層級不自己選。 + void report( + String message, + AppError error, { + required String tag, + LogLevel? level, + }) => write( + level ?? (error.expected ? LogLevel.warning : LogLevel.error), message, tag: tag, error: error._cause, diff --git a/app/lib/core/logging/uncaught_errors.dart b/app/lib/core/logging/uncaught_errors.dart index c715453b..c647c0e4 100644 --- a/app/lib/core/logging/uncaught_errors.dart +++ b/app/lib/core/logging/uncaught_errors.dart @@ -1,6 +1,8 @@ import 'package:flutter/foundation.dart'; +import 'package:fmp/core/errors/app_error.dart'; import 'package:fmp/core/logging/log.dart'; +import 'package:fmp/core/logging/log_record.dart'; /// 把未捕捉的錯誤經門面以 `error` 寫入(ADR 0011 §決定 5): /// @@ -8,25 +10,36 @@ import 'package:fmp/core/logging/log.dart'; /// - [PlatformDispatcher.onError]:其餘沒人接的非同步錯誤。回傳 `true` 表示已 /// 處理,引擎不再印到 console,release 版因此不輸出到 logcat。 /// +/// 未捕捉的是 [AppError] 時改走 [AppErrorReport.report]:它的原始 error 與 +/// stackTrace 是私有欄位,傳給 `error:` 只會寫出不含原因的 `toString()`。 +/// 層級仍固定是 `error`(`report` 的 `level`):沒人接的錯誤就是沒處理, +/// [AppError.expected] 只決定處理過的錯誤的層級。 +/// /// debug build 的 console 由門面輸出,所以不再接回原本的處理器。 void routeUncaughtErrors(Log log, PlatformDispatcher dispatcher) { - FlutterError.onError = (details) => log.error( - 'Uncaught Flutter error', - tag: 'flutter', - error: details.exception, - stackTrace: details.stack, - fields: { - 'library': ?details.library, - if (details.context case final context?) 'context': '$context', - }, - ); - dispatcher.onError = (error, stackTrace) { + FlutterError.onError = (details) { + const message = 'Uncaught Flutter error'; + if (details.exception case final AppError error) { + return log.report(message, error, tag: 'flutter', level: LogLevel.error); + } log.error( - 'Uncaught error', - tag: 'platform', - error: error, - stackTrace: stackTrace, + message, + tag: 'flutter', + error: details.exception, + stackTrace: details.stack, + fields: { + 'library': ?details.library, + if (details.context case final context?) 'context': '$context', + }, ); + }; + dispatcher.onError = (error, stackTrace) { + const message = 'Uncaught error'; + if (error case final AppError appError) { + log.report(message, appError, tag: 'platform', level: LogLevel.error); + } else { + log.error(message, tag: 'platform', error: error, stackTrace: stackTrace); + } return true; }; } diff --git a/app/lib/core/network/allowed_hosts.dart b/app/lib/core/network/allowed_hosts.dart new file mode 100644 index 00000000..27b27455 --- /dev/null +++ b/app/lib/core/network/allowed_hosts.dart @@ -0,0 +1,50 @@ +/// 一個插件可以連的網域(manifest 的允許網域,ADR 0014 §決定 3)。 +/// +/// 網址的 host 與清單項目相同,或是它的子網域(以 `.` 為界)才算; +/// `evil-bilibili.com` 不是 `bilibili.com` 的子網域。scheme 只准 `https`。 +/// 比對不分大小寫、忽略結尾的 `.`(舊版 `SourceUrlPolicy.normalizeHost`); +/// 帶百分比編碼的 host 一律不准。 +final class AllowedHosts { + AllowedHosts(Iterable hosts) + : _hosts = { + for (final host in hosts) + if (_normalize(host) case final normalized when normalized.isNotEmpty) + normalized, + }; + + final Set _hosts; + + /// [url] 可不可以連。 + bool allows(Uri url) => url.scheme == 'https' && allowsHost(url.host); + + /// [host] 是清單的項目或它的子網域。cookie 的 `Domain` 屬性也用它檢查, + /// `Domain=com` 這種比清單還寬的網域因此存不進去。 + bool allowsHost(String host) { + final normalized = _normalize(host); + // Uri 把非 ASCII 與保留字元留成百分比編碼(`evil.com%2F.bilibili.com`)。 + // 插件的網域都是 ASCII(IDN 寫 punycode),這種 host 一律不准,不去賭 + // 下游會不會把它解碼成別的 host。 + if (normalized.isEmpty || normalized.contains('%')) return false; + return _hosts.any((allowed) => isSameOrSubdomain(normalized, allowed)); + } + + /// [host] 等於 [domain] 或是它的子網域(以 `.` 為界;RFC 6265 §5.1.3 的 + /// domain-match)。 + static bool isSameOrSubdomain(String host, String domain) { + final h = _normalize(host); + final d = _normalize(domain); + return d.isNotEmpty && (h == d || h.endsWith('.$d')); + } + + /// [a] 與 [b] 是不是同一個 host(轉址時判斷是否跨網域)。 + static bool sameHost(Uri a, Uri b) => + _normalize(a.host) == _normalize(b.host); + + static String _normalize(String host) { + var normalized = host.trim().toLowerCase(); + while (normalized.endsWith('.')) { + normalized = normalized.substring(0, normalized.length - 1); + } + return normalized; + } +} diff --git a/app/lib/core/network/auth.dart b/app/lib/core/network/auth.dart new file mode 100644 index 00000000..12b69145 --- /dev/null +++ b/app/lib/core/network/auth.dart @@ -0,0 +1,62 @@ +/// 請求要不要帶憑證,由音源插件在請求的定義處宣告(ADR 0012 §決定 2)。 +/// 認證攔截器只依這個標記注入,不看網址或 service。 +enum AuthRequirement { + /// 寫入遠端歌單、讀收藏夾與私人歌單:未登入就不發請求,直接回 + /// `AuthRequired`。 + required, + + /// 搜尋、排行、詳情、串流解析等:已登入且「以登入身分瀏覽與播放」開啟 + /// 才帶(ADR 0012 §決定 6)。 + userPreference, + + /// 預設。公開頁面抓取、第三方歌詞源。 + never, +} + +/// [decideAuth] 的結果。 +enum AuthDecision { + /// 帶上憑證送出。 + attach, + + /// 不帶憑證送出。 + omit, + + /// 不發請求,回 `AuthRequired`。 + refuse, +} + +/// ADR 0012 §決定 2 的表:[requirement] 在「是否已登入」「以登入身分瀏覽的 +/// 開關」下怎麼處理。開關只影響 [AuthRequirement.userPreference]。 +AuthDecision decideAuth( + AuthRequirement requirement, { + required bool loggedIn, + required bool browseAsLoggedIn, +}) => switch (requirement) { + AuthRequirement.required => + loggedIn ? AuthDecision.attach : AuthDecision.refuse, + AuthRequirement.userPreference => + loggedIn && browseAsLoggedIn ? AuthDecision.attach : AuthDecision.omit, + AuthRequirement.never => AuthDecision.omit, +}; + +/// 認證攔截器的資料來源。M3 由 `CredentialStore`(ADR 0012 §決定 3)與每音源 +/// 設定表(ADR 0011 §決定 7)實作;M1 只有 [NoCredentials]。 +abstract interface class CredentialSource { + /// [pluginId] 的憑證,以要加到請求上的 headers 表示;未登入回 `null`。 + Future?> credentialHeaders(String pluginId); + + /// [pluginId] 的「以登入身分瀏覽與播放」開關。 + Future browseAsLoggedIn(String pluginId); +} + +/// M1 的認證來源:每個音源都未登入。 +final class NoCredentials implements CredentialSource { + const NoCredentials(); + + @override + Future?> credentialHeaders(String pluginId) async => null; + + /// ADR 0012 §決定 6 的預設(開)。沒有憑證時這個值不影響結果。 + @override + Future browseAsLoggedIn(String pluginId) async => true; +} diff --git a/app/lib/core/network/interceptors.dart b/app/lib/core/network/interceptors.dart new file mode 100644 index 00000000..028e6b39 --- /dev/null +++ b/app/lib/core/network/interceptors.dart @@ -0,0 +1,336 @@ +part of 'source_http_client.dart'; + +// API client 的五個攔截器,順序照 ADR 0012 §決定 1:認證注入 → cookie 管理 → +// 錯誤對應 → 限流 → 網路紀錄。dio 對 onRequest、onResponse、onError 都依加入 +// 的順序(FIFO)執行,不像 middleware 那樣回程反轉(dio `DioMixin.fetch`); +// 所以網路紀錄在三條路上都是最後一個,記到的是前面處理過的結果。 +// +// 攔截器 reject 一律帶 `callFollowingErrorInterceptor: true`:不帶的話 dio 會 +// 跳過其後所有 onError,限流拿不回位置、網路紀錄也少一筆。 + +/// 一次送出(一跳的一次嘗試)的狀態。放在 `RequestOptions.extra`,五個攔截器 +/// 與 [SourceHttpClient] 共用。 +final class _Attempt { + _Attempt({ + required this.recordId, + required this.pluginId, + required this.auth, + required this.retry, + }); + + static const _key = 'fmp.attempt'; + + static _Attempt of(RequestOptions options) => + options.extra[_key]! as _Attempt; + + final int recordId; + final String pluginId; + final AuthRequirement auth; + + /// 這是第幾次重試(原本那次為 0)。 + final int retry; + + bool credentialsAttached = false; + DateTime? startedAt; + ThrottleSlot? slot; + + Map get extra => {_key: this}; +} + +DioException _rejection( + RequestOptions options, + AppError error, { + Response? response, +}) => DioException( + requestOptions: options, + response: response, + error: error, + message: error.typeName, +); + +/// 認證注入:只依請求宣告的 [AuthRequirement] 與 [decideAuth] 的表決定。 +final class _AuthInterceptor extends Interceptor { + _AuthInterceptor(this._credentials); + + final CredentialSource _credentials; + + @override + Future onRequest( + RequestOptions options, + RequestInterceptorHandler handler, + ) async { + final attempt = _Attempt.of(options); + if (attempt.auth == AuthRequirement.never) return handler.next(options); + final headers = await _credentials.credentialHeaders(attempt.pluginId); + final decision = decideAuth( + attempt.auth, + loggedIn: headers != null, + browseAsLoggedIn: await _credentials.browseAsLoggedIn(attempt.pluginId), + ); + switch (decision) { + case AuthDecision.attach: + // 蓋掉插件自己給的同名 header;cookie 管理之後會把 jar 的 cookie + // 併進 `Cookie`。 + options.headers.addAll(headers!); + attempt.credentialsAttached = true; + handler.next(options); + case AuthDecision.omit: + handler.next(options); + case AuthDecision.refuse: + handler.reject( + _rejection( + options, + AuthRequired( + pluginId: attempt.pluginId, + networkRecordId: attempt.recordId, + ), + ), + true, + ); + } + } +} + +/// cookie 管理:`dio_cookie_manager`,只改一處。 +/// +/// 原版在 `followRedirects: false` 收到轉址時,會把這個回應的 `Set-Cookie` +/// 也存到 `Location` 的 host 底下(`CookieManager.saveCookies`),跨網域的 +/// 下一跳就帶著上一個 host 設的 cookie。這裡只存到回應自己的網址(RFC 6265 +/// §5.3 的 request-uri)。 +final class _OwnHostCookieManager extends CookieManager { + _OwnHostCookieManager(super.cookieJar); + + @override + Future saveCookies(Response response) { + final headers = {...response.headers.map} + ..remove(HttpHeaders.locationHeader); + return super.saveCookies( + Response( + requestOptions: response.requestOptions, + statusCode: response.statusCode, + headers: Headers.fromMap(headers), + ), + ); + } +} + +/// 每插件一個的記憶體 cookie jar。`cookie_jar` 的 `DefaultCookieJar` 照單收下 +/// `Set-Cookie` 的 `Domain` 屬性,不檢查它是否涵蓋回應的 host(RFC 6265 §5.3 +/// 第 6 步要求忽略這種 cookie),`example.test` 的回應就能替 `cdn.example` 設 +/// cookie。這裡只收 `Domain` 涵蓋回應 host、而且本身在允許網域內的 cookie; +/// 後者代替 public suffix list,擋掉 `Domain=com`。 +final class _OwnHostCookieJar extends DefaultCookieJar { + _OwnHostCookieJar(this._allowedHosts); + + final AllowedHosts _allowedHosts; + + @override + Future saveFromResponse(Uri uri, List cookies) => + super.saveFromResponse(uri, [ + for (final cookie in cookies) + if (_accepts(uri.host, cookie.domain)) cookie, + ]); + + bool _accepts(String host, String? domainAttribute) { + if (domainAttribute == null) return true; + // `Domain=.example.test` 開頭的點不算(RFC 6265 §5.2.3)。 + final domain = domainAttribute.startsWith('.') + ? domainAttribute.substring(1) + : domainAttribute; + return AllowedHosts.isSameOrSubdomain(host, domain) && + _allowedHosts.allowsHost(domain); + } +} + +/// 錯誤對應:傳輸錯誤轉 [NetworkError],HTTP 通用的限流語意轉 [RateLimited] +/// (ADR 0013 §決定 2)。其他狀態碼原樣交給插件,由插件在自己的邊界對應。 +final class _ErrorMappingInterceptor extends Interceptor { + _ErrorMappingInterceptor(this._now); + + final DateTime Function() _now; + + @override + void onResponse( + Response response, + ResponseInterceptorHandler handler, + ) { + final rateLimited = _rateLimited(response); + if (rateLimited == null) return handler.next(response); + handler.reject( + _rejection(response.requestOptions, rateLimited, response: response), + true, + ); + } + + /// RFC 6585 §4:429 Too Many Requests,可以帶 `Retry-After`。 + /// RFC 9110 §15.6.4:503 Service Unavailable 帶 `Retry-After` 表示暫時超載、 + /// 多久後再試;沒帶的 503 不一定是限流,交給插件。 + RateLimited? _rateLimited(Response response) { + final status = response.statusCode; + if (status != 429 && status != 503) return null; + final header = response.headers[HttpHeaders.retryAfterHeader]?.first; + final retryAfter = header == null + ? null + : parseRetryAfter(header, now: _now()); + if (status == 503 && retryAfter == null) return null; + final attempt = _Attempt.of(response.requestOptions); + return RateLimited( + pluginId: attempt.pluginId, + retryAfter: retryAfter, + networkRecordId: attempt.recordId, + cause: 'HTTP $status', + ); + } + + @override + void onError(DioException err, ErrorInterceptorHandler handler) { + if (err.type == DioExceptionType.cancel || err.error is AppError) { + return handler.next(err); + } + final attempt = _Attempt.of(err.requestOptions); + handler.next(err.copyWith(error: _map(err, attempt))); + } + + AppError _map(DioException err, _Attempt attempt) { + NetworkError network({bool retryable = true}) => NetworkError( + pluginId: attempt.pluginId, + retryable: retryable, + networkRecordId: attempt.recordId, + cause: err, + stackTrace: err.stackTrace, + ); + return switch (err.type) { + DioExceptionType.connectionTimeout || + DioExceptionType.sendTimeout || + DioExceptionType.receiveTimeout || + DioExceptionType.connectionError => network(), + // 憑證驗證不過,重送也一樣。 + DioExceptionType.badCertificate => network(retryable: false), + // TLS 握手失敗(HandshakeException)、連線中斷(HttpException)等 + // dio 沒歸類的傳輸錯誤都是 IOException。 + DioExceptionType.unknown when err.error is IOException => network(), + DioExceptionType.unknown || + DioExceptionType.badResponse || + DioExceptionType.transformTimeout || + DioExceptionType.cancel => UnexpectedError( + pluginId: attempt.pluginId, + networkRecordId: attempt.recordId, + cause: err, + stackTrace: err.stackTrace, + ), + }; + } +} + +/// 限流:插件宣告了 [RateLimitPolicy] 才有作用。等位置的時間不算進網路紀錄的 +/// 耗時,因為網路紀錄排在它後面。 +final class _ThrottleInterceptor extends Interceptor { + _ThrottleInterceptor(this._throttle); + + final RequestThrottle? _throttle; + + @override + Future onRequest( + RequestOptions options, + RequestInterceptorHandler handler, + ) async { + final throttle = _throttle; + if (throttle == null) return handler.next(options); + // 先記下位置再等:等的時候被取消,dio 直接走 onError,要在那裡讓出。 + final slot = _Attempt.of(options).slot = throttle.enqueue(); + await slot.granted; + handler.next(options); + } + + @override + void onResponse( + Response response, + ResponseInterceptorHandler handler, + ) { + _Attempt.of(response.requestOptions).slot?.release(); + handler.next(response); + } + + @override + void onError(DioException err, ErrorInterceptorHandler handler) { + _Attempt.of(err.requestOptions).slot?.release(); + handler.next(err); + } +} + +/// 網路紀錄(ADR 0011 §決定 4):每次送出一筆摘要,經 log 門面寫入,不記 +/// body。query 原樣交給門面,由遮蔽函式處理。 +final class _NetworkLogInterceptor extends Interceptor { + _NetworkLogInterceptor(this._log, this._now); + + final Log _log; + final DateTime Function() _now; + + @override + void onRequest(RequestOptions options, RequestInterceptorHandler handler) { + _Attempt.of(options).startedAt = _now(); + handler.next(options); + } + + @override + void onResponse( + Response response, + ResponseInterceptorHandler handler, + ) { + _write( + response.requestOptions, + response: response, + failed: (response.statusCode ?? 0) >= 400, + ); + handler.next(response); + } + + @override + void onError(DioException err, ErrorInterceptorHandler handler) { + final cancelled = err.type == DioExceptionType.cancel; + _write( + err.requestOptions, + response: err.response, + error: switch (err.error) { + _ when cancelled => 'Cancelled', + final AppError error => error.typeName, + _ => 'UnexpectedError', + }, + // 取消是呼叫端自己要的,不算失敗。 + failed: !cancelled, + ); + handler.next(err); + } + + /// [failed]:產生了錯誤,或狀態碼 ≥ 400。失敗用 `warning`,release 的預設 + /// 層級(info)也看得到;其餘用 `debug`。 + void _write( + RequestOptions options, { + required bool failed, + Response? response, + String? error, + }) { + final attempt = _Attempt.of(options); + final uri = options.uri; + _log.write( + failed ? LogLevel.warning : LogLevel.debug, + failed ? 'HTTP request failed' : 'HTTP request', + tag: networkLogTag, + fields: { + 'id': attempt.recordId, + 'pluginId': attempt.pluginId, + 'method': options.method, + 'host': uri.host, + 'path': uri.path, + if (uri.hasQuery) 'query': uri.query, + 'status': ?response?.statusCode, + if (attempt.startedAt case final started?) + 'ms': _now().difference(started).inMilliseconds, + if (response?.data case final List body) 'bytes': body.length, + 'error': ?error, + 'credentials': attempt.credentialsAttached, + 'retry': attempt.retry, + }, + ); + } +} diff --git a/app/lib/core/network/media_headers.dart b/app/lib/core/network/media_headers.dart new file mode 100644 index 00000000..e5d4db7b --- /dev/null +++ b/app/lib/core/network/media_headers.dart @@ -0,0 +1,15 @@ +/// 媒體請求(抓音訊位元組)可以帶的 header,小寫。 +/// +/// ADR 0012:憑證只用在向音源解析串流與 API 請求,抓音訊位元組的請求一律不帶 +/// 憑證,只帶媒體 headers。其他 header 一律丟掉,所以之後插件多給的 +/// `Cookie`、`Authorization` 或任何自訂憑證 header 都到不了 CDN。 +const mediaHeaderNames = {'referer', 'user-agent', 'origin', 'range'}; + +/// 媒體 header 政策:從插件給的串流 [headers] 只留 [mediaHeaderNames](名稱 +/// 不分大小寫,保留原本的寫法與值)。 +/// +/// 播放後端(M1 PR 10)與之後的媒體 client(M6)拿到的 headers 都要先經過它。 +Map mediaRequestHeaders(Map headers) => { + for (final MapEntry(:key, :value) in headers.entries) + if (mediaHeaderNames.contains(key.toLowerCase())) key: value, +}; diff --git a/app/lib/core/network/request_throttle.dart b/app/lib/core/network/request_throttle.dart new file mode 100644 index 00000000..a4098d1e --- /dev/null +++ b/app/lib/core/network/request_throttle.dart @@ -0,0 +1,99 @@ +import 'dart:async'; +import 'dart:collection'; + +import 'package:fmp/core/errors/retry_policy.dart'; + +/// 一個插件的請求排程:同時進行中的請求不超過 +/// [RateLimitPolicy.maxConcurrentRequests],兩次開始之間至少隔 +/// [RateLimitPolicy.minRequestInterval](ADR 0013 §決定 4:事先避開限流)。 +/// +/// 先來先開始。時鐘與等待由外面注入,測試才能固定。 +final class RequestThrottle { + RequestThrottle(this.policy, {required this._now, required this._wait}) { + if (policy.maxConcurrentRequests < 1) { + throw RangeError.value( + policy.maxConcurrentRequests, + 'maxConcurrentRequests', + 'must be at least 1', + ); + } + } + + final RateLimitPolicy policy; + final DateTime Function() _now; + final Future Function(Duration) _wait; + + final _queue = Queue(); + int _active = 0; + DateTime? _lastStart; + bool _waitingForInterval = false; + + /// 排進佇列。拿到的 [ThrottleSlot] 在 [ThrottleSlot.granted] 完成時可以 + /// 開始;請求結束(成功、失敗或取消)時一定要 [ThrottleSlot.release]。 + ThrottleSlot enqueue() { + final slot = ThrottleSlot._(this); + _queue.add(slot); + _pump(); + return slot; + } + + void _pump() { + if (_waitingForInterval) return; + while (_queue.isNotEmpty && _active < policy.maxConcurrentRequests) { + final now = _now(); + // 時鐘往回撥(使用者改時間、NTP 校正)時上次開始會在未來,照算要等到 + // 時鐘追上;改從現在起算,最多等一個間隔,不讓整個插件卡住。 + if (_lastStart case final last? when now.isBefore(last)) { + _lastStart = now; + } + if (_lastStart?.add(policy.minRequestInterval) case final ready? + when now.isBefore(ready)) { + _waitingForInterval = true; + unawaited( + _wait(ready.difference(now)).whenComplete(() { + _waitingForInterval = false; + _pump(); + }), + ); + return; + } + final slot = _queue.removeFirst(); + _active++; + _lastStart = now; + slot._state = _SlotState.granted; + slot._granted.complete(); + } + } + + void _release(ThrottleSlot slot) { + switch (slot._state) { + case _SlotState.queued: + // 還在排隊就結束了(被取消):移出佇列,[ThrottleSlot.granted] + // 永遠不會完成,等它的人已經不在了。 + _queue.remove(slot); + case _SlotState.granted: + _active--; + _pump(); + case _SlotState.released: + return; + } + slot._state = _SlotState.released; + } +} + +enum _SlotState { queued, granted, released } + +/// [RequestThrottle] 裡的一個位置。 +final class ThrottleSlot { + ThrottleSlot._(this._throttle); + + final RequestThrottle _throttle; + final _granted = Completer(); + _SlotState _state = _SlotState.queued; + + /// 輪到這個請求時完成。 + Future get granted => _granted.future; + + /// 請求結束:還在排隊就移出佇列,已經開始就讓出位置。重複呼叫沒有作用。 + void release() => _throttle._release(this); +} diff --git a/app/lib/core/network/source_http_client.dart b/app/lib/core/network/source_http_client.dart new file mode 100644 index 00000000..68ee3d26 --- /dev/null +++ b/app/lib/core/network/source_http_client.dart @@ -0,0 +1,357 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:math' as math; +import 'dart:typed_data'; + +import 'package:cookie_jar/cookie_jar.dart'; +import 'package:dio/dio.dart'; +import 'package:dio/io.dart'; +import 'package:dio_cookie_manager/dio_cookie_manager.dart'; + +import 'package:fmp/core/errors/app_error.dart'; +import 'package:fmp/core/errors/retry_policy.dart'; +import 'package:fmp/core/logging/log.dart'; +import 'package:fmp/core/logging/log_record.dart'; +import 'package:fmp/core/network/allowed_hosts.dart'; +import 'package:fmp/core/network/auth.dart'; +import 'package:fmp/core/network/request_throttle.dart'; + +part 'interceptors.dart'; + +/// 網路紀錄的 log tag。 +const networkLogTag = 'network'; + +/// 一次最多跟隨幾次轉址(ADR 0012 §決定 1;dio 的 `maxRedirects` 預設也是 5)。 +const maxRedirects = 5; + +/// 沿用舊版 `AppConstants.networkConnectTimeout`/`networkReceiveTimeout`。 +/// receive 是兩次收到資料之間的上限,不是整個回應(dio `receiveTimeout`)。 +const _connectTimeout = Duration(seconds: 10); +const _receiveTimeout = Duration(seconds: 30); + +/// 會轉址的狀態碼(RFC 9110 §15.4)。300 與 304 不自動跟隨。 +const _redirectStatuses = {301, 302, 303, 307, 308}; + +/// 跨網域轉址時從原請求拿掉的 header(名稱小寫)。 +const _crossHostStrippedHeaders = { + 'cookie', + 'authorization', + 'proxy-authorization', +}; + +/// 插件給的一個 API 請求。 +final class SourceRequest { + const SourceRequest( + this.url, { + this.method = 'GET', + this.headers = const {}, + this.body, + this.auth = AuthRequirement.never, + }); + + final Uri url; + + /// HTTP 方法,照 RFC 9110 分大小寫(`GET`,不是 `get`)。重試只看它判斷 + /// 冪等(`isIdempotent`)。 + final String method; + + final Map headers; + final String? body; + + /// 帶不帶憑證(ADR 0012 §決定 2)。 + final AuthRequirement auth; +} + +/// 請求的結果。狀態碼不在網路層判斷:429 與帶 `Retry-After` 的 503 以外, +/// 原樣交給插件對應(ADR 0013 §決定 2)。 +final class SourceResponse { + const SourceResponse({ + required this.url, + required this.statusCode, + required this.headers, + required this.body, + }); + + /// 跟隨轉址後的最終網址。 + final Uri url; + final int statusCode; + + /// 名稱小寫。 + final Map> headers; + final Uint8List body; +} + +/// 呼叫端以 `abortTrigger` 取消了請求。只有取消的一方會收到,不是 +/// [AppError],不重試、不 report。 +final class RequestCancelled implements Exception { + const RequestCancelled(); + + @override + String toString() => 'RequestCancelled'; +} + +/// 依插件建立 [SourceHttpClient](ADR 0012 §決定 1:每個音源一個 API +/// client)。App 共用的東西(log、認證來源、時鐘)在這裡給一次。 +/// +/// [createAdapter] 是 dio 最底層的 `HttpClientAdapter`,每個 client 各建一個; +/// fixture 的錄製與重播(ADR 0015 §決定 5)換掉它。[now]、[wait]、[random] +/// 給重試、限流與網路紀錄用,測試注入假的。 +final class SourceHttpClientFactory { + SourceHttpClientFactory({ + required this._log, + this._credentials = const NoCredentials(), + this._createAdapter = IOHttpClientAdapter.new, + this._now = DateTime.now, + this._wait = _delay, + math.Random? random, + }) : _random = random ?? math.Random(); + + final Log _log; + final CredentialSource _credentials; + final HttpClientAdapter Function() _createAdapter; + final DateTime Function() _now; + final Future Function(Duration) _wait; + final math.Random _random; + + /// 網路紀錄的 id,整個 App 執行期間遞增。 + int _lastRecordId = 0; + + /// [pluginId] 的 client。[allowedHosts] 是 manifest 的允許網域; + /// [retryPolicy]、[rateLimitPolicy] 是 manifest 宣告的策略,沒宣告限流就 + /// 不限。 + SourceHttpClient create({ + required String pluginId, + required Iterable allowedHosts, + RetryPolicy retryPolicy = const RetryPolicy(), + RateLimitPolicy? rateLimitPolicy, + }) { + final hosts = AllowedHosts(allowedHosts); + // 唯一建立 Dio 的地方(fmp_http_client_owner)。 + final dio = + Dio( + BaseOptions( + connectTimeout: _connectTimeout, + receiveTimeout: _receiveTimeout, + ), + ) + ..httpClientAdapter = _createAdapter() + ..interceptors.addAll([ + _AuthInterceptor(_credentials), + // 每插件一個記憶體 cookie jar;要跨重啟的匿名 cookie 由插件自己 + // 存(app/AGENTS.md § 網路)。 + _OwnHostCookieManager(_OwnHostCookieJar(hosts)), + _ErrorMappingInterceptor(_now), + _ThrottleInterceptor(switch (rateLimitPolicy) { + null => null, + final policy => RequestThrottle(policy, now: _now, wait: _wait), + }), + _NetworkLogInterceptor(_log, _now), + ]); + return SourceHttpClient._( + pluginId: pluginId, + allowedHosts: hosts, + retryPolicy: retryPolicy, + dio: dio, + nextRecordId: () => ++_lastRecordId, + wait: _wait, + random: _random, + ); + } +} + +Future _delay(Duration duration) => Future.delayed(duration); + +/// 一個插件的 API client:該插件所有請求共用(ADR 0012 §決定 1)。 +/// +/// [send] 依序做:網域檢查 → 送出(經五個攔截器)→ 可重試的錯誤依 +/// [RetryPolicy] 退避重送 → 轉址就檢查網域後跟隨下一跳。丟出的錯誤都是 +/// [AppError],取消例外([RequestCancelled])。 +final class SourceHttpClient { + SourceHttpClient._({ + required this.pluginId, + required this._allowedHosts, + required this._retryPolicy, + required this._dio, + required this._nextRecordId, + required this._wait, + required this._random, + }); + + final String pluginId; + final AllowedHosts _allowedHosts; + final RetryPolicy _retryPolicy; + final Dio _dio; + final int Function() _nextRecordId; + final Future Function(Duration) _wait; + final math.Random _random; + + /// 送出 [request]。[abortTrigger] 完成時取消(`package:http` 的 + /// `Abortable.abortTrigger` 同樣的寫法),丟 [RequestCancelled]。 + /// + /// 網址不在允許網域或不是 `https` 時不發請求,丟 [Unsupported];轉址出網域、 + /// `Location` 解析不了或超過 [maxRedirects] 次也是。 + Future send( + SourceRequest request, { + Future? abortTrigger, + }) async { + final cancelToken = CancelToken(); + // 觸發的 Future 以錯誤結束也算取消。 + abortTrigger?.whenComplete(cancelToken.cancel).ignore(); + var hop = request; + for (var redirects = 0; ; redirects++) { + if (!_allowedHosts.allows(hop.url)) { + throw Unsupported( + pluginId: pluginId, + cause: StateError('Host not allowed: ${hop.url.host}'), + stackTrace: StackTrace.current, + ); + } + final (:response, :recordId) = await _sendWithRetry(hop, cancelToken); + final location = _redirectLocation(response); + if (location == null) return response; + if (redirects == maxRedirects) { + throw Unsupported( + pluginId: pluginId, + networkRecordId: recordId, + cause: StateError('More than $maxRedirects redirects'), + stackTrace: StackTrace.current, + ); + } + final Uri next; + try { + next = hop.url.resolve(location); + } on FormatException catch (error, stackTrace) { + // 伺服器給的 `Location` 解析不了:跟不下去,同出網域一樣失敗。 + throw Unsupported( + pluginId: pluginId, + networkRecordId: recordId, + cause: error, + stackTrace: stackTrace, + ); + } + if (!_allowedHosts.allows(next)) { + throw Unsupported( + pluginId: pluginId, + networkRecordId: recordId, + cause: StateError('Redirect to a host not allowed: ${next.host}'), + stackTrace: StackTrace.current, + ); + } + hop = _redirected(hop, next, response.statusCode); + } + } + + /// 關閉底層的連線。 + void close() => _dio.close(force: true); + + Future<({SourceResponse response, int recordId})> _sendWithRetry( + SourceRequest request, + CancelToken cancelToken, + ) async { + for (var retry = 0; ; retry++) { + if (cancelToken.isCancelled) throw const RequestCancelled(); + final attempt = _Attempt( + recordId: _nextRecordId(), + pluginId: pluginId, + auth: request.auth, + retry: retry, + ); + final AppError error; + try { + final response = await _dio.requestUri>( + request.url, + data: request.body, + cancelToken: cancelToken, + options: Options( + method: request.method, + headers: {...request.headers}, + extra: attempt.extra, + responseType: ResponseType.bytes, + followRedirects: false, + validateStatus: (_) => true, + ), + ); + return ( + response: SourceResponse( + url: request.url, + statusCode: response.statusCode!, + headers: response.headers.map, + body: switch (response.data) { + null => Uint8List(0), + final Uint8List bytes => bytes, + final List bytes => Uint8List.fromList(bytes), + }, + ), + recordId: attempt.recordId, + ); + } on DioException catch (failure) { + if (failure.type == DioExceptionType.cancel) { + throw const RequestCancelled(); + } + error = switch (failure.error) { + final AppError mapped => mapped, + // 錯誤對應攔截器已經轉好;走到這裡代表它之後的攔截器出錯。 + _ => UnexpectedError( + pluginId: pluginId, + networkRecordId: attempt.recordId, + cause: failure, + stackTrace: failure.stackTrace, + ), + }; + } + final delay = + shouldRetry( + error, + attempt: retry, + method: request.method, + policy: _retryPolicy, + ) + ? delayFor( + error, + attempt: retry, + policy: _retryPolicy, + random: _random, + ) + : null; + if (delay == null) throw error; + await Future.any([_wait(delay), cancelToken.whenCancel]); + } + } + + /// [response] 要跟隨的 `Location`;不是轉址回 `null`。 + static String? _redirectLocation(SourceResponse response) { + if (!_redirectStatuses.contains(response.statusCode)) return null; + final location = response.headers[HttpHeaders.locationHeader]?.first; + return location == null || location.isEmpty ? null : location; + } + + /// 往 [next] 的下一跳。 + /// + /// - 303,以及 POST 收到 301/302:改用 GET、不帶 body(RFC 9110 §15.4.2–4; + /// 瀏覽器的 fetch 也這樣做)。307/308 保留方法與 body。 + /// - 跨 host:拿掉原請求的 `Cookie`、`Authorization`,而且不再帶憑證 + /// ([AuthRequirement.never])。jar 裡的 cookie 由 cookie 管理照網域決定。 + static SourceRequest _redirected( + SourceRequest hop, + Uri next, + int statusCode, + ) { + final toGet = + statusCode == 303 || + ((statusCode == 301 || statusCode == 302) && hop.method == 'POST'); + final crossHost = !AllowedHosts.sameHost(hop.url, next); + return SourceRequest( + next, + method: toGet && hop.method != 'HEAD' ? 'GET' : hop.method, + headers: { + for (final MapEntry(:key, :value) in hop.headers.entries) + if (!(crossHost && + _crossHostStrippedHeaders.contains(key.toLowerCase())) && + !(toGet && key.toLowerCase() == 'content-type')) + key: value, + }, + body: toGet ? null : hop.body, + auth: crossHost ? AuthRequirement.never : hop.auth, + ); + } +} diff --git a/app/packages/fmp_lints/lib/src/rules/layer_imports.dart b/app/packages/fmp_lints/lib/src/rules/layer_imports.dart index 184d05d2..2ce94d62 100644 --- a/app/packages/fmp_lints/lib/src/rules/layer_imports.dart +++ b/app/packages/fmp_lints/lib/src/rules/layer_imports.dart @@ -17,7 +17,10 @@ final externalPackageOwners = { // ADR 0018:兩個播放後端。 'just_audio': 'lib/playback/backends', 'media_kit': 'lib/playback/backends', + // ADR 0012:HTTP client 與 cookie 只在網路層;`dio_cookie_manager` 算在 + // `dio` 系列裡。 'dio': 'lib/core/network', + 'cookie_jar': 'lib/core/network', 'flutter_js': 'lib/plugins/runtime', // M6 才有,先列入。 'background_downloader': 'lib/downloads', diff --git a/app/packages/fmp_lints/test/rules/layer_imports_test.dart b/app/packages/fmp_lints/test/rules/layer_imports_test.dart index 2da59384..fcfd6aa1 100644 --- a/app/packages/fmp_lints/test/rules/layer_imports_test.dart +++ b/app/packages/fmp_lints/test/rules/layer_imports_test.dart @@ -47,6 +47,8 @@ class LayerImportsTest extends FmpRuleTest { 'lib/playback/controller.dart', "import [!'package:just_audio/just_audio.dart'!];\n" "import [!'package:dio/dio.dart'!];\n" + "import [!'package:dio_cookie_manager/dio_cookie_manager.dart'!];\n" + "import [!'package:cookie_jar/cookie_jar.dart'!];\n" "import [!'package:flutter_js/flutter_js.dart'!];\n" "import [!'package:isar_community/isar.dart'!];\n" "import [!'package:background_downloader/background_downloader.dart'!];\n", @@ -97,7 +99,9 @@ class LayerImportsTest extends FmpRuleTest { ); await assertLints( 'lib/core/network/http.dart', - "import 'package:dio/dio.dart';\n", + "import 'package:dio/dio.dart';\n" + "import 'package:dio_cookie_manager/dio_cookie_manager.dart';\n" + "import 'package:cookie_jar/cookie_jar.dart';\n", ); await assertLints( 'lib/legacy_import/reader.dart', diff --git a/app/pubspec.lock b/app/pubspec.lock index 9a11740d..764de84e 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -193,6 +193,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + cookie_jar: + dependency: "direct main" + description: + name: cookie_jar + sha256: "963da02c1ef64cb5ac20de948c9e5940aa351f1e34a12b1d327c83d85b7e8fff" + url: "https://pub.dev" + source: hosted + version: "4.0.9" coverage: dependency: transitive description: @@ -225,6 +233,30 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.13" + dio: + dependency: "direct main" + description: + name: dio + sha256: "852ec3b48cc431ac04fff978413c541502b67ffc3e26921e74e3d994694192c1" + url: "https://pub.dev" + source: hosted + version: "5.11.1" + dio_cookie_manager: + dependency: "direct main" + description: + name: dio_cookie_manager + sha256: "4ed4669cacb11931517c1158876a2189f19386674b9dab498abcca063dbe4c61" + url: "https://pub.dev" + source: hosted + version: "3.5.0" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "3a1b2cd7be71086f38504956e3ebcd2837288d231ff454bafa78021244102bfc" + url: "https://pub.dev" + source: hosted + version: "2.2.2" drift: dependency: "direct main" description: @@ -829,6 +861,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + universal_io: + dependency: transitive + description: + name: universal_io + sha256: f63cbc48103236abf48e345e07a03ce5757ea86285ed313a6a032596ed9301e2 + url: "https://pub.dev" + source: hosted + version: "2.3.1" uuid: dependency: transitive description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 189872b7..04c35dd5 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -14,6 +14,11 @@ workspace: - packages/fmp_lints dependencies: + # 網路層(ADR 0012 §決定 1):每插件一個 dio 與記憶體 cookie jar。三個都只准 + # 在 lib/core/network/ import(fmp_layer_imports)。 + cookie_jar: ^4.0.9 + dio: ^5.11.1 + dio_cookie_manager: ^3.5.0 # 資料層(ADR 0010)。sqlite3 3.x 以 build hooks 打包 SQLite 原生庫,不用已 # EOL 的 sqlite3_flutter_libs;兩者只准在 lib/data/ import(fmp_layer_imports)。 drift: ^2.35.0 diff --git a/app/test/core/errors/app_error_surface_test.dart b/app/test/core/errors/app_error_surface_test.dart index b80cdb88..f69f57da 100644 --- a/app/test/core/errors/app_error_surface_test.dart +++ b/app/test/core/errors/app_error_surface_test.dart @@ -25,6 +25,7 @@ const _reviewedSurface = { 'AppError.messageArgs': 'Map', 'AppError.expected': 'bool', 'AppError.networkRecordId': 'int?', + 'AppError.typeName': 'String', 'AppError.toString()': 'String', 'Unavailable.reason': 'UnavailableReason', 'AppErrorReport.report()': 'void', @@ -34,6 +35,8 @@ const _reviewedSurface = { const _allowedTextMembers = { // 插件 id,不是訊息;呈現層拿它查插件的顯示名稱。 'AppError.pluginId', + // 寫死的類別名,給 log 與網路紀錄的 `type`/`error` 欄位;不含任何值。 + 'AppError.typeName', // 只給 log,而且不含原始 error(app_error_test.dart 驗證)。 'AppError.toString()', }; diff --git a/app/test/core/errors/app_error_test.dart b/app/test/core/errors/app_error_test.dart index 41698d75..00cb0087 100644 --- a/app/test/core/errors/app_error_test.dart +++ b/app/test/core/errors/app_error_test.dart @@ -84,6 +84,8 @@ void main() { expect(error.pluginId, isNull); expect(error.retryAfter, isNull); expect(error.networkRecordId, isNull); + // 測試不混淆,runtimeType 就是類別名。 + expect(error.typeName, '${error.runtimeType}'); }); } diff --git a/app/test/core/errors/report_error_test.dart b/app/test/core/errors/report_error_test.dart index 352dd0ee..1227a14b 100644 --- a/app/test/core/errors/report_error_test.dart +++ b/app/test/core/errors/report_error_test.dart @@ -32,6 +32,17 @@ void main() { expect(log.history.map((r) => r.level), [LogLevel.warning, LogLevel.error]); }); + test('an explicit level (uncaught errors only) overrides expected', () { + log.report( + 'Uncaught error', + NetworkError(), + tag: 'platform', + level: LogLevel.error, + ); + + expect(log.history.single.level, LogLevel.error); + }); + test('writes the structured fields', () { log.report( 'Stream failed', diff --git a/app/test/core/logging/uncaught_errors_test.dart b/app/test/core/logging/uncaught_errors_test.dart index 97183082..90202b48 100644 --- a/app/test/core/logging/uncaught_errors_test.dart +++ b/app/test/core/logging/uncaught_errors_test.dart @@ -1,5 +1,6 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:fmp/core/errors/app_error.dart'; import 'package:fmp/core/logging/log.dart'; import 'package:fmp/core/logging/log_record.dart'; import 'package:fmp/core/logging/uncaught_errors.dart'; @@ -45,4 +46,55 @@ void main() { expect(platform.error, 'Exception: async failure'); expect(platform.stackTrace, '#0 later (b.dart:2)'); }); + + test( + 'an uncaught AppError goes through report with its cause, as an error', + () { + final dispatcher = PlatformDispatcher.instance; + final previousFlutterHandler = FlutterError.onError; + final previousDispatcherHandler = dispatcher.onError; + final log = Log(redactor: Redactor(), minimumLevel: LogLevel.info); + + try { + routeUncaughtErrors(log, dispatcher); + FlutterError.onError!( + FlutterErrorDetails( + exception: NetworkError( + pluginId: 'bilibili', + networkRecordId: 3, + cause: StateError('SESSDATA=FAKE_SESSDATA_123'), + stackTrace: StackTrace.fromString('#0 send (c.dart:3)'), + ), + ), + ); + dispatcher.onError!( + NotFound( + cause: StateError('access_key=FAKE_ACCESS_KEY_123'), + stackTrace: StackTrace.fromString('#0 later (d.dart:4)'), + ), + StackTrace.fromString('#0 zone (e.dart:5)'), + ); + } finally { + FlutterError.onError = previousFlutterHandler; + dispatcher.onError = previousDispatcherHandler; + } + + final [flutter, platform] = log.history; + // 沒人接的錯誤一律是 error:兩個都是預期內的(處理過時 report 寫 + // warning),未捕捉就代表沒處理。 + expect(flutter.level, LogLevel.error); + expect(flutter.tag, 'flutter'); + expect(flutter.message, 'Uncaught Flutter error'); + expect(flutter.error, 'Bad state: SESSDATA=***'); + expect(flutter.stackTrace, '#0 send (c.dart:3)'); + expect(flutter.fields, containsPair('type', 'NetworkError')); + expect(flutter.fields, containsPair('networkRecordId', 3)); + expect(platform.level, LogLevel.error); + expect(platform.tag, 'platform'); + expect(platform.error, 'Bad state: access_key=***'); + // stackTrace 用 AppError 自己的,不是 zone 回報的那一個。 + expect(platform.stackTrace, '#0 later (d.dart:4)'); + expect(platform.fields, containsPair('type', 'NotFound')); + }, + ); } diff --git a/app/test/core/network/allowed_hosts_test.dart b/app/test/core/network/allowed_hosts_test.dart new file mode 100644 index 00000000..94f8f694 --- /dev/null +++ b/app/test/core/network/allowed_hosts_test.dart @@ -0,0 +1,92 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:fmp/core/network/allowed_hosts.dart'; + +void main() { + final hosts = AllowedHosts(['bilibili.com', 'HDSLB.com.', '']); + + bool allows(String url) => hosts.allows(Uri.parse(url)); + + test('the same host and its subdomains are allowed', () { + expect(allows('https://bilibili.com/'), isTrue); + expect(allows('https://api.bilibili.com/x/web-interface/search'), isTrue); + expect(allows('https://a.b.bilibili.com/'), isTrue); + // 清單項目與網址都不分大小寫、忽略結尾的點。 + expect(allows('https://i0.hdslb.com/bfs/a.jpg'), isTrue); + expect(allows('https://API.Bilibili.COM./'), isTrue); + }); + + test('a lookalike without the dot boundary is not a subdomain', () { + expect(allows('https://evil-bilibili.com/'), isFalse); + expect(allows('https://bilibili.com.evil.net/'), isFalse); + expect(allows('https://notbilibili.com/'), isFalse); + }); + + test('only https', () { + expect(allows('http://api.bilibili.com/'), isFalse); + expect(allows('ftp://api.bilibili.com/'), isFalse); + expect(allows('api.bilibili.com/x'), isFalse); + }); + + test('the host is what counts, not userinfo, fragment or port', () { + // userinfo 與 fragment 裡的允許網域不算:真正連的是 evil.com。 + expect(allows('https://bilibili.com@evil.com/'), isFalse); + expect(allows('https://api.bilibili.com:x@evil.com/'), isFalse); + expect(allows('https://evil.com#@api.bilibili.com'), isFalse); + expect(allows('https://evil.com/?next=https://api.bilibili.com/'), isFalse); + // 連接埠不在比對範圍內。 + expect(allows('https://api.bilibili.com:8443/'), isTrue); + }); + + test('non-ASCII and percent-encoded hosts are refused', () { + // Dart 的 Uri 不做 IDNA,非 ASCII 與保留字元留成百分比編碼。 + expect( + Uri.parse('https://evil.com%2F.bilibili.com/').host, + 'evil.com%2F.bilibili.com', + ); + expect(allows('https://evil.com%2F.bilibili.com/'), isFalse); + expect(allows('https://evil.com%00.bilibili.com/'), isFalse); + expect(allows('https://bücher.bilibili.com/'), isFalse); + // 相鄰案例:punycode 的子網域照常允許。 + expect(allows('https://xn--bcher-kva.bilibili.com/'), isTrue); + }); + + test('allowsHost and isSameOrSubdomain use the dot boundary', () { + expect(hosts.allowsHost('bilibili.com'), isTrue); + expect(hosts.allowsHost('com'), isFalse); + expect(hosts.allowsHost(''), isFalse); + expect( + AllowedHosts.isSameOrSubdomain('api.bilibili.com', 'bilibili.com'), + isTrue, + ); + expect( + AllowedHosts.isSameOrSubdomain('evil-bilibili.com', 'bilibili.com'), + isFalse, + ); + expect(AllowedHosts.isSameOrSubdomain('bilibili.com', ''), isFalse); + }); + + test('an empty entry does not allow everything', () { + expect(allows('https://example.org/'), isFalse); + expect( + AllowedHosts([]).allows(Uri.parse('https://bilibili.com/')), + isFalse, + ); + }); + + test('sameHost compares normalized hosts only', () { + expect( + AllowedHosts.sameHost( + Uri.parse('https://API.bilibili.com./a'), + Uri.parse('https://api.bilibili.com/b?c=d'), + ), + isTrue, + ); + expect( + AllowedHosts.sameHost( + Uri.parse('https://api.bilibili.com/'), + Uri.parse('https://www.bilibili.com/'), + ), + isFalse, + ); + }); +} diff --git a/app/test/core/network/auth_test.dart b/app/test/core/network/auth_test.dart new file mode 100644 index 00000000..ab07da1e --- /dev/null +++ b/app/test/core/network/auth_test.dart @@ -0,0 +1,102 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:fmp/core/errors/app_error.dart'; +import 'package:fmp/core/network/auth.dart'; + +import '../../support/fake_http_adapter.dart'; +import 'harness.dart'; + +/// ADR 0012 §如何確認:三種標記在「未登入/已登入且開關開/已登入且開關關」 +/// 下的注入結果。 +enum _State { loggedOut, loggedInOn, loggedInOff } + +const _expected = { + (_State.loggedOut, AuthRequirement.required): AuthDecision.refuse, + (_State.loggedOut, AuthRequirement.userPreference): AuthDecision.omit, + (_State.loggedOut, AuthRequirement.never): AuthDecision.omit, + (_State.loggedInOn, AuthRequirement.required): AuthDecision.attach, + (_State.loggedInOn, AuthRequirement.userPreference): AuthDecision.attach, + (_State.loggedInOn, AuthRequirement.never): AuthDecision.omit, + // 開關只管 userPreference;required 已登入就一定帶。 + (_State.loggedInOff, AuthRequirement.required): AuthDecision.attach, + (_State.loggedInOff, AuthRequirement.userPreference): AuthDecision.omit, + (_State.loggedInOff, AuthRequirement.never): AuthDecision.omit, +}; + +const _credential = {'Authorization': 'Bearer FAKE_TOKEN_123'}; + +void main() { + test('the table covers every state and requirement', () { + expect(_expected, hasLength(_State.values.length * 3)); + }); + + group('decideAuth', () { + for (final MapEntry(key: (state, requirement), value: decision) + in _expected.entries) { + test('${requirement.name} when ${state.name} → ${decision.name}', () { + expect( + decideAuth( + requirement, + loggedIn: state != _State.loggedOut, + browseAsLoggedIn: state != _State.loggedInOff, + ), + decision, + ); + }); + } + }); + + group('the auth interceptor injects only by the decision', () { + for (final MapEntry(key: (state, requirement), value: decision) + in _expected.entries) { + test('${requirement.name} when ${state.name}', () async { + final harness = Harness( + (_) => reply(200), + credentials: FakeCredentials( + headers: state == _State.loggedOut ? null : _credential, + browseAsLoggedInValue: state != _State.loggedInOff, + ), + ); + final send = harness.get('https://example.test/a', auth: requirement); + + switch (decision) { + case AuthDecision.refuse: + final error = await send.then( + (_) => null, + onError: (Object error) => error, + ); + expect(error, isA()); + expect((error! as AuthRequired).pluginId, pluginId); + // 不發請求。 + expect(harness.adapter.requests, isEmpty); + case AuthDecision.attach: + await send; + expect( + harness.adapter.requests.single.headers['authorization'], + 'Bearer FAKE_TOKEN_123', + ); + case AuthDecision.omit: + await send; + expect( + harness.adapter.requests.single.headers['authorization'], + isNull, + ); + } + final record = harness.records.single; + expect(record.fields['credentials'], decision == AuthDecision.attach); + }); + } + }); + + test('M1 has no credentials: nothing is attached', () async { + final harness = Harness((_) => reply(200)); + await harness.get( + 'https://example.test/a', + auth: AuthRequirement.userPreference, + ); + expect(harness.adapter.requests.single.headers['authorization'], isNull); + await expectLater( + harness.get('https://example.test/a', auth: AuthRequirement.required), + throwsA(isA()), + ); + }); +} diff --git a/app/test/core/network/harness.dart b/app/test/core/network/harness.dart new file mode 100644 index 00000000..e87508e2 --- /dev/null +++ b/app/test/core/network/harness.dart @@ -0,0 +1,89 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:dio/dio.dart'; +import 'package:fmp/core/errors/retry_policy.dart'; +import 'package:fmp/core/logging/log.dart'; +import 'package:fmp/core/logging/log_file.dart'; +import 'package:fmp/core/logging/log_record.dart'; +import 'package:fmp/core/network/auth.dart'; +import 'package:fmp/core/network/source_http_client.dart'; +import 'package:fmp/core/redaction/redactor.dart'; + +import '../../support/fake_http_adapter.dart'; + +const pluginId = 'test-source'; +const allowedHosts = ['example.test', 'cdn.example']; + +/// 可以設定的假認證來源。 +final class FakeCredentials implements CredentialSource { + FakeCredentials({this.headers, this.browseAsLoggedInValue = true}); + + /// `null`=未登入。 + final Map? headers; + final bool browseAsLoggedInValue; + + @override + Future?> credentialHeaders(String pluginId) async => + headers; + + @override + Future browseAsLoggedIn(String pluginId) async => browseAsLoggedInValue; +} + +/// 一個 client 加上它的假 adapter、假時鐘與 log。等待立刻完成並把時鐘 +/// 往前撥;[waits] 記下每次等了多久。 +final class Harness { + Harness( + FutureOr Function(RequestOptions options) handler, { + CredentialSource credentials = const NoCredentials(), + RetryPolicy retryPolicy = const RetryPolicy(), + RateLimitPolicy? rateLimitPolicy, + LogFile? logFile, + }) : adapter = FakeHttpAdapter(handler) { + log = Log( + redactor: Redactor(), + minimumLevel: LogLevel.debug, + file: logFile, + ); + client = + SourceHttpClientFactory( + log: log, + credentials: credentials, + createAdapter: () => adapter, + now: () => now, + wait: (duration) async { + waits.add(duration); + now = now.add(duration); + }, + random: math.Random(7), + ).create( + pluginId: pluginId, + allowedHosts: allowedHosts, + retryPolicy: retryPolicy, + rateLimitPolicy: rateLimitPolicy, + ); + } + + final FakeHttpAdapter adapter; + late final Log log; + late final SourceHttpClient client; + DateTime now = DateTime.utc(2026, 9, 29, 12); + final waits = []; + + Future get( + String url, { + Map headers = const {}, + AuthRequirement auth = AuthRequirement.never, + Future? abortTrigger, + }) => client.send( + SourceRequest(Uri.parse(url), headers: headers, auth: auth), + abortTrigger: abortTrigger, + ); + + /// 網路紀錄(tag `network`),由舊到新。 + List get records => [ + for (final record in log.history) + if (record.tag == networkLogTag) record, + ]; +} diff --git a/app/test/core/network/media_headers_test.dart b/app/test/core/network/media_headers_test.dart new file mode 100644 index 00000000..687183ac --- /dev/null +++ b/app/test/core/network/media_headers_test.dart @@ -0,0 +1,42 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:fmp/core/network/media_headers.dart'; + +void main() { + // ADR 0012 §如何確認:媒體請求不帶 Cookie/Authorization。M1 由這個函數與 + // PR 10 的播放後端接線守。 + test('keeps only the media headers, whatever the case', () { + final headers = mediaRequestHeaders({ + 'Referer': 'https://www.bilibili.com/', + 'user-agent': 'Mozilla/5.0', + 'ORIGIN': 'https://www.bilibili.com', + 'Range': 'bytes=0-', + 'Cookie': 'SESSDATA=FAKE_SESSDATA_123', + 'cookie': 'buvid3=FAKE_BUVID_123', + 'Authorization': 'Bearer FAKE_TOKEN_123', + 'Proxy-Authorization': 'Basic FAKE_BASIC_123', + 'X-Csrf-Token': 'FAKE_CSRF_123', + 'Accept': 'application/json', + }); + + expect(headers, { + 'Referer': 'https://www.bilibili.com/', + 'user-agent': 'Mozilla/5.0', + 'ORIGIN': 'https://www.bilibili.com', + 'Range': 'bytes=0-', + }); + }); + + test('a name that only contains an allowed name is dropped', () { + expect( + mediaRequestHeaders({ + 'X-Referer-Cookie': 'FAKE_123', + 'Range-Cookie': 'FAKE_456', + }), + isEmpty, + ); + }); + + test('no headers in, no headers out', () { + expect(mediaRequestHeaders({}), isEmpty); + }); +} diff --git a/app/test/core/network/request_throttle_test.dart b/app/test/core/network/request_throttle_test.dart new file mode 100644 index 00000000..fde89e52 --- /dev/null +++ b/app/test/core/network/request_throttle_test.dart @@ -0,0 +1,129 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:fmp/core/errors/retry_policy.dart'; +import 'package:fmp/core/network/request_throttle.dart'; + +import '../../support/pump_until.dart'; + +void main() { + late DateTime now; + late List waits; + + setUp(() { + now = DateTime.utc(2026, 9, 29, 12); + waits = []; + }); + + /// 假時鐘:等待立刻完成,並把時鐘往前撥。 + RequestThrottle throttle({ + int maxConcurrentRequests = 1, + Duration minRequestInterval = Duration.zero, + }) => RequestThrottle( + RateLimitPolicy( + maxConcurrentRequests: maxConcurrentRequests, + minRequestInterval: minRequestInterval, + ), + now: () => now, + wait: (duration) async { + waits.add(duration); + now = now.add(duration); + }, + ); + + test( + 'never more than the concurrency limit at once, first come first', + () async { + final subject = throttle(maxConcurrentRequests: 2); + final slots = [for (var i = 0; i < 4; i++) subject.enqueue()]; + final granted = []; + for (final (index, slot) in slots.indexed) { + slot.granted.then((_) => granted.add(index)); + } + + await settle(); + expect(granted, [0, 1]); + + slots[1].release(); + await pumpUntil(() => granted.length == 3); + expect(granted, [0, 1, 2]); + + // 重複 release 不會多讓出一個位置。 + slots[1].release(); + await settle(); + expect(granted, [0, 1, 2]); + + slots[0].release(); + await pumpUntil(() => granted.length == 4); + expect(granted, [0, 1, 2, 3]); + expect(waits, isEmpty); + }, + ); + + test('starts are spaced by the minimum interval (fake clock)', () async { + final start = now; + final subject = throttle( + maxConcurrentRequests: 5, + minRequestInterval: const Duration(milliseconds: 400), + ); + var granted = 0; + for (var i = 0; i < 3; i++) { + subject.enqueue().granted.then((_) => granted++); + } + + await pumpUntil(() => granted == 3); + // 第一個立刻開始,之後每個都等滿間隔;位置還夠,所以只受間隔限制。 + expect(waits, [ + const Duration(milliseconds: 400), + const Duration(milliseconds: 400), + ]); + expect(now.difference(start), const Duration(milliseconds: 800)); + }); + + test('no wait when the interval has already passed', () async { + final subject = throttle( + maxConcurrentRequests: 5, + minRequestInterval: const Duration(seconds: 1), + ); + subject.enqueue(); + now = now.add(const Duration(seconds: 2)); + var granted = false; + subject.enqueue().granted.then((_) => granted = true); + + await pumpUntil(() => granted); + expect(waits, isEmpty); + }); + + test('a clock set back waits at most one interval', () async { + final subject = throttle( + maxConcurrentRequests: 5, + minRequestInterval: const Duration(seconds: 1), + ); + subject.enqueue(); + now = now.subtract(const Duration(hours: 1)); + var granted = false; + subject.enqueue().granted.then((_) => granted = true); + + await pumpUntil(() => granted); + // 照原本的算法要等一小時一秒。 + expect(waits, [const Duration(seconds: 1)]); + }); + + test('releasing a queued slot removes it without taking a place', () async { + final subject = throttle(); + final first = subject.enqueue(); + final cancelled = subject.enqueue(); + final third = subject.enqueue(); + var cancelledGranted = false; + var thirdGranted = false; + cancelled.granted.then((_) => cancelledGranted = true); + third.granted.then((_) => thirdGranted = true); + + cancelled.release(); + first.release(); + await pumpUntil(() => thirdGranted); + expect(cancelledGranted, isFalse); + }); + + test('a limit below one is rejected', () { + expect(() => throttle(maxConcurrentRequests: 0), throwsRangeError); + }); +} diff --git a/app/test/core/network/source_http_client_test.dart b/app/test/core/network/source_http_client_test.dart new file mode 100644 index 00000000..3e0b5dce --- /dev/null +++ b/app/test/core/network/source_http_client_test.dart @@ -0,0 +1,854 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fmp/core/errors/app_error.dart'; +import 'package:fmp/core/errors/retry_policy.dart'; +import 'package:fmp/core/logging/log_file.dart'; +import 'package:fmp/core/logging/log_record.dart'; +import 'package:fmp/core/network/auth.dart'; +import 'package:fmp/core/network/source_http_client.dart'; +import 'package:path/path.dart' as p; + +import '../../support/fake_http_adapter.dart'; +import '../../support/pump_until.dart'; +import 'harness.dart'; + +/// [future] 丟出的錯誤;沒丟就讓測試失敗。 +Future errorOf(Future future) async { + try { + await future; + } on Object catch (error) { + return error; + } + fail('expected an error'); +} + +const _noRetry = RetryPolicy(maxRetries: 0); + +void main() { + group('interceptors run in the ADR 0012 order', () { + // dio 的 onRequest/onResponse/onError 都依加入順序執行。可以從外面觀察 + // 的前後關係逐一斷言;錯誤對應與限流之間、cookie 與錯誤對應之間的先後 + // 沒有行為差異(兩者各自只看自己的欄位),不另外斷言。 + test('auth → cookie → error mapping → throttle → network log', () async { + final harness = Harness( + (options) => switch (options.uri.path) { + '/first' => reply( + 200, + headers: {'Set-Cookie': 'buvid3=FAKE_BUVID_123; Path=/'}, + ), + '/timeout' => throw DioException.connectionTimeout( + requestOptions: options, + timeout: const Duration(seconds: 10), + ), + _ => reply(200), + }, + credentials: FakeCredentials( + headers: {'Cookie': 'SESSDATA=FAKE_SESSDATA_123'}, + ), + retryPolicy: _noRetry, + rateLimitPolicy: const RateLimitPolicy( + maxConcurrentRequests: 1, + minRequestInterval: Duration(seconds: 1), + ), + ); + + await harness.get( + 'https://example.test/first', + auth: AuthRequirement.userPreference, + ); + await harness.get( + 'https://example.test/second', + auth: AuthRequirement.userPreference, + ); + final error = await errorOf(harness.get('https://example.test/timeout')); + + // 認證在 cookie 之前:cookie 管理把 jar 的 cookie 併進認證放的 Cookie。 + expect( + harness.adapter.requests[1].headers['cookie'], + 'SESSDATA=FAKE_SESSDATA_123; buvid3=FAKE_BUVID_123', + ); + // 限流在網路紀錄之前:第二個請求等了 1 秒,紀錄的耗時不含這段。 + expect(harness.waits, contains(const Duration(seconds: 1))); + final [first, second, timeout] = harness.records; + expect(second.fields['ms'], 0); + // 認證在網路紀錄之前:紀錄知道有沒有帶憑證。 + expect(first.fields['credentials'], isTrue); + expect(timeout.fields['credentials'], isFalse); + // 錯誤對應在網路紀錄之前:紀錄看到的是轉好的類型。 + expect(error, isA()); + expect(timeout.fields['error'], 'NetworkError'); + }); + }); + + group('allowed hosts', () { + test('the same host and a subdomain are sent', () async { + final harness = Harness((_) => reply(200)); + await harness.get('https://example.test/a'); + await harness.get('https://api.example.test/b'); + expect(harness.adapter.requests, hasLength(2)); + }); + + test('case and a trailing dot do not matter', () async { + final harness = Harness((_) => reply(200)); + await harness.get('https://API.Example.TEST./a'); + expect(harness.adapter.requests, hasLength(1)); + }); + + for (final url in [ + 'https://evil-example.test/a', + 'https://example.test.evil.net/a', + 'http://example.test/a', + 'https://example.test@evil.test/a', + 'https://evil.test#@example.test', + ]) { + test('$url is refused without a request', () async { + final harness = Harness((_) => reply(200)); + final error = await errorOf(harness.get(url)); + expect(error, isA()); + expect((error as Unsupported).pluginId, pluginId); + expect(error.networkRecordId, isNull); + expect(harness.adapter.requests, isEmpty); + expect(harness.records, isEmpty); + }); + } + }); + + group('redirects', () { + /// `/r/` 轉到 `/r/`,`/r/0` 回 200。 + ResponseBody chain(RequestOptions options) { + final remaining = int.parse(options.uri.pathSegments.last); + return remaining == 0 + ? reply(200, body: 'done') + : redirect('/r/${remaining - 1}'); + } + + test('up to $maxRedirects redirects are followed', () async { + final harness = Harness(chain); + final response = await harness.get('https://example.test/r/5'); + expect(response.statusCode, 200); + expect(response.url, Uri.parse('https://example.test/r/0')); + expect(utf8.decode(response.body), 'done'); + expect(harness.adapter.requests, hasLength(6)); + }); + + test('the sixth redirect fails', () async { + final harness = Harness(chain); + final error = await errorOf(harness.get('https://example.test/r/6')); + expect(error, isA()); + expect(harness.adapter.requests, hasLength(6)); + // 帶上回了第六次轉址的那筆紀錄。 + expect( + (error as Unsupported).networkRecordId, + harness.records.last.fields['id'], + ); + }); + + for (final (name, target) in [ + ('outside the allowed hosts', 'https://evil-example.test/x'), + ('to http', 'http://example.test/x'), + ]) { + test('a redirect $name fails before it is sent', () async { + final harness = Harness((_) => redirect(target)); + final error = await errorOf(harness.get('https://example.test/a')); + expect(error, isA()); + expect(harness.adapter.requests, hasLength(1)); + }); + } + + test( + 'a cross-host hop drops Cookie, Authorization and credentials', + () async { + final harness = Harness( + (options) => switch (options.uri.path) { + '/a' => reply( + 302, + headers: { + 'Location': 'https://cdn.example/file', + 'Set-Cookie': 'sid=FAKE_SID_123; Path=/', + }, + ), + _ => reply(200), + }, + credentials: FakeCredentials(headers: {'X-Session': 'FAKE_SESSION'}), + ); + + await harness.get( + 'https://example.test/a', + headers: { + 'Cookie': 'pref=FAKE_PREF_123', + 'authorization': 'Bearer FAKE_TOKEN_123', + 'Referer': 'https://example.test/', + }, + auth: AuthRequirement.userPreference, + ); + + final [first, hop] = harness.adapter.requests; + expect(first.headers['x-session'], 'FAKE_SESSION'); + expect(hop.uri, Uri.parse('https://cdn.example/file')); + expect(hop.headers['cookie'], isNull); + expect(hop.headers['authorization'], isNull); + expect(hop.headers['x-session'], isNull); + expect(hop.headers['referer'], 'https://example.test/'); + expect(harness.records.last.fields['credentials'], isFalse); + + // 轉址回應設的 cookie 只存給它自己的 host。 + await harness.get('https://example.test/b'); + expect( + harness.adapter.requests.last.headers['cookie'], + 'sid=FAKE_SID_123', + ); + }, + ); + + test('a same-host hop keeps the headers', () async { + final harness = Harness( + (options) => options.uri.path == '/a' ? redirect('/b') : reply(200), + ); + await harness.get( + 'https://example.test/a', + headers: {'Cookie': 'pref=FAKE_PREF_123'}, + ); + expect( + harness.adapter.requests.last.headers['cookie'], + 'pref=FAKE_PREF_123', + ); + }); + + test('303 turns a POST into a GET without a body', () async { + final harness = Harness( + (options) => options.uri.path == '/form' + ? redirect('/done', status: 303) + : reply(200), + ); + await harness.client.send( + SourceRequest( + Uri.parse('https://example.test/form'), + method: 'POST', + headers: {'Content-Type': 'application/x-www-form-urlencoded'}, + body: 'a=1', + ), + ); + final hop = harness.adapter.requests.last; + expect(hop.method, 'GET'); + expect(hop.data, isNull); + expect(hop.headers['content-type'], isNull); + }); + + test('307 keeps the method and the body', () async { + final harness = Harness( + (options) => options.uri.path == '/form' + ? redirect('/done', status: 307) + : reply(200), + ); + await harness.client.send( + SourceRequest( + Uri.parse('https://example.test/form'), + method: 'POST', + body: 'a=1', + ), + ); + final hop = harness.adapter.requests.last; + expect(hop.method, 'POST'); + expect(hop.data, 'a=1'); + }); + + test('a protocol-relative Location is checked like any other', () async { + final harness = Harness((_) => redirect('//evil-example.test/x')); + final error = await errorOf(harness.get('https://example.test/a')); + expect(error, isA()); + expect(harness.adapter.requests, hasLength(1)); + }); + + test('a Location that cannot be parsed is Unsupported', () async { + final harness = Harness((_) => redirect('https://[bad')); + final error = await errorOf(harness.get('https://example.test/a')); + expect(error, isA()); + expect( + (error as Unsupported).networkRecordId, + harness.records.single.fields['id'], + ); + }); + + test('a redirect status without Location is returned as is', () async { + final harness = Harness((_) => reply(302)); + final response = await harness.get('https://example.test/a'); + expect(response.statusCode, 302); + expect(harness.adapter.requests, hasLength(1)); + }); + + test('a cross-host hop never gets credentials back', () async { + // example.test → cdn.example → example.test:回到原本的 host 也不再帶。 + final harness = Harness( + (options) => switch ((options.uri.host, options.uri.path)) { + ('example.test', '/a') => redirect('https://cdn.example/b'), + ('cdn.example', _) => redirect('https://example.test/c'), + _ => reply(200), + }, + credentials: FakeCredentials(headers: {'X-Session': 'FAKE_SESSION'}), + ); + await harness.get( + 'https://example.test/a', + auth: AuthRequirement.required, + ); + expect(harness.adapter.requests.map((r) => r.headers['x-session']), [ + 'FAKE_SESSION', + null, + null, + ]); + expect(harness.records.map((r) => r.fields['credentials']), [ + true, + false, + false, + ]); + }); + + test('a same-host hop keeps the credentials', () async { + final harness = Harness( + (options) => options.uri.path == '/a' ? redirect('/b') : reply(200), + credentials: FakeCredentials(headers: {'X-Session': 'FAKE_SESSION'}), + ); + await harness.get( + 'https://example.test/a', + auth: AuthRequirement.userPreference, + ); + expect( + harness.adapter.requests.last.headers['x-session'], + 'FAKE_SESSION', + ); + }); + + for (final (status, method, expectedMethod) in [ + (301, 'POST', 'GET'), + (302, 'POST', 'GET'), + (302, 'PUT', 'PUT'), + (303, 'PUT', 'GET'), + (303, 'HEAD', 'HEAD'), + (308, 'POST', 'POST'), + ]) { + test('$status turns $method into $expectedMethod', () async { + final harness = Harness( + (options) => options.uri.path == '/form' + ? redirect('/done', status: status) + : reply(200), + ); + await harness.client.send( + SourceRequest( + Uri.parse('https://example.test/form'), + method: method, + body: method == 'HEAD' ? null : 'a=1', + ), + ); + final hop = harness.adapter.requests.last; + expect(hop.method, expectedMethod); + expect( + hop.data, + expectedMethod == method && method != 'HEAD' ? 'a=1' : isNull, + ); + }); + } + + group('Set-Cookie Domain', () { + /// api.example.test 的回應設 [setCookie],再各打一次 example.test、 + /// api.example.test 與 cdn.example,回傳三者帶的 Cookie。 + Future> cookiesAfter(String setCookie) async { + final harness = Harness( + (options) => options.uri.path == '/set' + ? reply(200, headers: {'Set-Cookie': setCookie}) + : reply(200), + ); + await harness.get('https://api.example.test/set'); + for (final url in [ + 'https://example.test/x', + 'https://api.example.test/x', + 'https://cdn.example/x', + ]) { + await harness.get(url); + } + return [ + for (final request in harness.adapter.requests.skip(1)) + request.headers['cookie'], + ]; + } + + test('a parent domain inside the allowed hosts is shared', () async { + expect(await cookiesAfter('a=FAKE_1; Domain=example.test; Path=/'), [ + 'a=FAKE_1', + 'a=FAKE_1', + null, + ]); + }); + + test('no Domain stays with the host that set it', () async { + expect(await cookiesAfter('a=FAKE_1; Path=/'), [ + null, + 'a=FAKE_1', + null, + ]); + }); + + for (final domain in ['cdn.example', 'example', 'test', 'other.test']) { + test('Domain=$domain is not stored', () async { + expect(await cookiesAfter('a=FAKE_1; Domain=$domain; Path=/'), [ + null, + null, + null, + ]); + }); + } + }); + + test('300 and 304 are returned as they are', () async { + final harness = Harness((_) => redirect('/elsewhere', status: 304)); + final response = await harness.get('https://example.test/a'); + expect(response.statusCode, 304); + expect(harness.adapter.requests, hasLength(1)); + }); + }); + + group('error mapping', () { + Future failWith( + DioException Function(RequestOptions options) failure, + ) { + final harness = Harness( + (options) => throw failure(options), + retryPolicy: _noRetry, + ); + return errorOf(harness.get('https://example.test/a')); + } + + test('timeouts and connection failures become NetworkError', () async { + for (final failure in [ + (o) => DioException.connectionTimeout( + requestOptions: o, + timeout: const Duration(seconds: 10), + ), + (o) => DioException.receiveTimeout( + requestOptions: o, + timeout: const Duration(seconds: 30), + ), + (o) => DioException.connectionError( + requestOptions: o, + reason: 'refused', + error: const SocketException('refused'), + ), + // TLS 握手失敗:dio 歸為 unknown,error 是 HandshakeException。 + (o) => DioException( + requestOptions: o, + error: const HandshakeException('handshake failed'), + ), + ]) { + final error = await failWith(failure); + expect(error, isA()); + expect((error as NetworkError).retryable, isTrue); + expect(error.pluginId, pluginId); + expect(error.networkRecordId, isNotNull); + } + }); + + test( + 'a rejected certificate is a NetworkError that is not retried', + () async { + final error = await failWith( + (o) => DioException.badCertificate(requestOptions: o), + ); + expect(error, isA()); + expect((error as NetworkError).retryable, isFalse); + }, + ); + + test('anything else is an UnexpectedError', () async { + final error = await failWith( + (o) => DioException(requestOptions: o, error: StateError('bug')), + ); + expect(error, isA()); + }); + + test('429 with Retry-After in seconds', () async { + final harness = Harness( + (_) => reply(429, headers: {'Retry-After': '120'}), + retryPolicy: _noRetry, + ); + final error = await errorOf(harness.get('https://example.test/a')); + expect(error, isA()); + expect((error as RateLimited).retryAfter, const Duration(seconds: 120)); + }); + + test('429 with Retry-After as a date (fake clock)', () async { + final harness = Harness( + (_) => reply( + 429, + headers: {'Retry-After': 'Tue, 29 Sep 2026 12:00:45 GMT'}, + ), + retryPolicy: _noRetry, + ); + final error = await errorOf(harness.get('https://example.test/a')); + expect((error as RateLimited).retryAfter, const Duration(seconds: 45)); + }); + + test('429 without Retry-After', () async { + final harness = Harness((_) => reply(429), retryPolicy: _noRetry); + final error = await errorOf(harness.get('https://example.test/a')); + expect((error as RateLimited).retryAfter, isNull); + }); + + test('503 with Retry-After is rate limiting', () async { + final harness = Harness( + (_) => reply(503, headers: {'Retry-After': '5'}), + retryPolicy: _noRetry, + ); + final error = await errorOf(harness.get('https://example.test/a')); + expect((error as RateLimited).retryAfter, const Duration(seconds: 5)); + }); + + test('503 with an unparseable Retry-After goes to the plugin', () async { + final harness = Harness( + (_) => reply(503, headers: {'Retry-After': 'soon'}), + ); + final response = await harness.get('https://example.test/a'); + expect(response.statusCode, 503); + expect(harness.adapter.requests, hasLength(1)); + }); + + test( + '503 without Retry-After and other statuses go to the plugin', + () async { + for (final status in [503, 500, 404, 412]) { + final harness = Harness((_) => reply(status, body: 'x')); + final response = await harness.get('https://example.test/a'); + expect(response.statusCode, status); + expect(harness.adapter.requests, hasLength(1), reason: '$status'); + } + }, + ); + }); + + group('retry', () { + DioException refused(RequestOptions options) => + DioException.connectionError( + requestOptions: options, + reason: 'refused', + ); + + test( + 'an idempotent request is retried with backoff until it works', + () async { + var calls = 0; + final harness = Harness( + (options) => ++calls < 3 ? throw refused(options) : reply(200), + ); + final response = await harness.get('https://example.test/a'); + expect(response.statusCode, 200); + expect(harness.adapter.requests, hasLength(3)); + // 全抖動:第 n 次重試的等待在 [0, 500ms × 2^n) 之內。 + final [firstWait, secondWait] = harness.waits; + expect(firstWait, lessThan(const Duration(milliseconds: 500))); + expect(secondWait, lessThan(const Duration(milliseconds: 1000))); + expect(harness.records.map((r) => r.fields['retry']), [0, 1, 2]); + }, + ); + + test('stops at the retry limit', () async { + final harness = Harness( + (options) => throw refused(options), + retryPolicy: const RetryPolicy(maxRetries: 2), + ); + final error = await errorOf(harness.get('https://example.test/a')); + expect(error, isA()); + expect(harness.adapter.requests, hasLength(3)); + // 丟出的錯誤帶最後一次送出的紀錄 id。 + expect( + (error as NetworkError).networkRecordId, + harness.records.last.fields['id'], + ); + }); + + test('a non-idempotent request is not retried', () async { + final harness = Harness((options) => throw refused(options)); + final error = await errorOf( + harness.client.send( + SourceRequest(Uri.parse('https://example.test/a'), method: 'POST'), + ), + ); + expect(error, isA()); + expect(harness.adapter.requests, hasLength(1)); + expect(harness.waits, isEmpty); + }); + + test('Retry-After is respected (fake clock)', () async { + var calls = 0; + final harness = Harness( + (_) => ++calls == 1 + ? reply(429, headers: {'Retry-After': '3'}) + : reply(200), + ); + final response = await harness.get('https://example.test/a'); + expect(response.statusCode, 200); + expect(harness.waits, [const Duration(seconds: 3)]); + }); + + test('a Retry-After beyond the policy limit is not waited for', () async { + final harness = Harness( + (_) => reply(429, headers: {'Retry-After': '120'}), + retryPolicy: const RetryPolicy(maxRetryAfter: Duration(seconds: 60)), + ); + final error = await errorOf(harness.get('https://example.test/a')); + expect(error, isA()); + expect(harness.adapter.requests, hasLength(1)); + expect(harness.waits, isEmpty); + }); + + test('a cancelled request is not retried', () async { + final pending = Completer(); + final abort = Completer(); + final harness = Harness((_) => pending.future); + + final send = errorOf( + harness.get('https://example.test/a', abortTrigger: abort.future), + ); + await pumpUntil(() => harness.adapter.requests.isNotEmpty); + abort.complete(); + + expect(await send, isA()); + expect(harness.adapter.requests, hasLength(1)); + expect(harness.waits, isEmpty); + final record = harness.records.single; + expect(record.fields['error'], 'Cancelled'); + // 取消是呼叫端要的,不算失敗。 + expect(record.level, LogLevel.debug); + }); + }); + + group('rate limit', () { + test('never more requests in flight than the limit', () async { + final pending = >[]; + final harness = Harness( + (_) { + final completer = Completer(); + pending.add(completer); + return completer.future; + }, + rateLimitPolicy: const RateLimitPolicy( + maxConcurrentRequests: 2, + minRequestInterval: Duration.zero, + ), + ); + + final sends = [ + for (var i = 0; i < 3; i++) harness.get('https://example.test/$i'), + ]; + await pumpUntil(() => pending.length == 2); + await settle(); + expect(harness.adapter.requests, hasLength(2)); + + pending.first.complete(reply(200)); + await pumpUntil(() => pending.length == 3); + for (final completer in pending.skip(1)) { + completer.complete(reply(200)); + } + await Future.wait(sends); + }); + + test('a failed request gives its place back', () async { + // 傳輸錯誤(dio 在送出時 reject)、429(錯誤對應在 onResponse reject)、 + // 未登入(認證在拿位置之前 reject)三種失敗之後,位置都要還在;沒讓出 + // 的話下一個請求會永遠排隊,pumpUntil 會失敗。 + final harness = Harness( + (options) => switch (options.uri.path) { + '/refused' => throw DioException.connectionError( + requestOptions: options, + reason: 'refused', + ), + '/limited' => reply(429), + _ => reply(200), + }, + retryPolicy: _noRetry, + rateLimitPolicy: const RateLimitPolicy( + maxConcurrentRequests: 1, + minRequestInterval: Duration.zero, + ), + ); + for (final (path, auth) in [ + ('/refused', AuthRequirement.never), + ('/limited', AuthRequirement.never), + ('/private', AuthRequirement.required), + ]) { + await errorOf(harness.get('https://example.test$path', auth: auth)); + var done = false; + unawaited( + harness.get('https://example.test/ok').then((_) => done = true), + ); + await pumpUntil(() => done, reason: 'blocked after $path'); + } + expect(harness.adapter.requests, hasLength(5)); + }); + + test('a cancelled request gives its place back', () async { + // 一個在送出中、一個在排隊時被取消;兩者都要讓出位置。 + final pending = >[]; + final harness = Harness( + (_) { + final completer = Completer(); + pending.add(completer); + return completer.future; + }, + rateLimitPolicy: const RateLimitPolicy( + maxConcurrentRequests: 1, + minRequestInterval: Duration.zero, + ), + ); + final abortInFlight = Completer(); + final abortQueued = Completer(); + + final inFlight = errorOf( + harness.get( + 'https://example.test/1', + abortTrigger: abortInFlight.future, + ), + ); + await pumpUntil(() => pending.length == 1); + final queued = errorOf( + harness.get('https://example.test/2', abortTrigger: abortQueued.future), + ); + await settle(); + abortQueued.complete(); + expect(await queued, isA()); + abortInFlight.complete(); + expect(await inFlight, isA()); + + final last = harness.get('https://example.test/3'); + await pumpUntil(() => pending.length == 2); + pending.last.complete(reply(200)); + expect((await last).statusCode, 200); + expect(harness.adapter.requests.map((r) => r.uri.path), ['/1', '/3']); + }); + + test('the minimum interval spaces the starts (fake clock)', () async { + final harness = Harness( + (_) => reply(200), + rateLimitPolicy: const RateLimitPolicy( + maxConcurrentRequests: 4, + minRequestInterval: Duration(milliseconds: 250), + ), + ); + await Future.wait([ + for (var i = 0; i < 3; i++) harness.get('https://example.test/$i'), + ]); + expect(harness.waits, [ + const Duration(milliseconds: 250), + const Duration(milliseconds: 250), + ]); + }); + }); + + group('network log', () { + late Directory temp; + setUp(() async { + temp = await Directory.systemTemp.createTemp('fmp_network_test'); + addTearDown(() => temp.delete(recursive: true)); + }); + + test('one record per request with every field', () async { + final harness = Harness( + (_) => reply(200, body: 'FAKE_RESPONSE_BODY'), + credentials: FakeCredentials(headers: {'X-Session': 'FAKE_SESSION'}), + ); + await harness.get( + 'https://api.example.test/x/search?keyword=a&page=2', + auth: AuthRequirement.userPreference, + ); + + final record = harness.records.single; + expect(record.level, LogLevel.debug); + expect(record.message, 'HTTP request'); + expect(record.fields, { + 'id': 1, + 'pluginId': pluginId, + 'method': 'GET', + 'host': 'api.example.test', + 'path': '/x/search', + 'query': 'keyword=a&page=2', + 'status': 200, + 'ms': 0, + 'bytes': 'FAKE_RESPONSE_BODY'.length, + 'credentials': true, + 'retry': 0, + }); + }); + + test('a failure is a warning and the AppError carries its id', () async { + final harness = Harness((_) => reply(429), retryPolicy: _noRetry); + await errorOf(harness.get('https://example.test/before')); + final error = await errorOf(harness.get('https://example.test/a')); + + final record = harness.records.last; + expect(record.level, LogLevel.warning); + expect(record.message, 'HTTP request failed'); + expect(record.fields['status'], 429); + expect(record.fields['error'], 'RateLimited'); + expect(record.fields['id'], 2); + expect((error as RateLimited).networkRecordId, 2); + }); + + test( + 'a status of 400 or more is a warning, the response still returns', + () async { + final harness = Harness((_) => reply(404)); + final response = await harness.get('https://example.test/a'); + expect(response.statusCode, 404); + expect(harness.records.single.level, LogLevel.warning); + expect(harness.records.single.fields.containsKey('error'), isFalse); + }, + ); + + test( + 'fake secrets in the query are redacted and bodies are not logged', + () async { + final logFile = LogFile(Directory(p.join(temp.path, logDirectoryName))); + final harness = Harness( + (_) => reply(200, body: 'FAKE_RESPONSE_BODY_123'), + logFile: logFile, + ); + await harness.client.send( + SourceRequest( + Uri.parse( + 'https://example.test/a?access_key=FAKE_ACCESS_KEY_123' + '&csrf=FAKE_CSRF_123&keyword=ok', + ), + method: 'POST', + body: 'FAKE_REQUEST_BODY_123', + ), + ); + + final query = harness.records.single.fields['query']! as String; + expect(query, contains('keyword=ok')); + await logFile.flush(); + final stored = await logFile.currentFile.readAsString(); + final history = jsonEncode([ + for (final record in harness.log.history) record.toJsonLine(), + ]); + for (final output in [history, stored]) { + expect(output, contains('keyword=ok')); + for (final secret in [ + 'FAKE_ACCESS_KEY_123', + 'FAKE_CSRF_123', + 'FAKE_RESPONSE_BODY_123', + 'FAKE_REQUEST_BODY_123', + ]) { + expect(output, isNot(contains(secret))); + } + } + }, + ); + + test('every request gets the next id', () async { + final harness = Harness((_) => reply(200)); + await harness.get('https://example.test/a'); + await harness.get('https://example.test/b'); + expect(harness.records.map((r) => r.fields['id']), [1, 2]); + }); + }); +} diff --git a/app/test/support/fake_http_adapter.dart b/app/test/support/fake_http_adapter.dart new file mode 100644 index 00000000..6a508e47 --- /dev/null +++ b/app/test/support/fake_http_adapter.dart @@ -0,0 +1,50 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:dio/dio.dart'; + +/// 假的 dio 最底層 adapter:不聯網,每個請求交給 [handler] 回應,並記下 +/// 送到這一層時的 [RequestOptions](攔截器都跑完之後的樣子)。 +final class FakeHttpAdapter implements HttpClientAdapter { + FakeHttpAdapter(this.handler); + + final FutureOr Function(RequestOptions options) handler; + + /// 送到 adapter 的請求,依序。 + final requests = []; + + @override + Future fetch( + RequestOptions options, + Stream? requestStream, + Future? cancelFuture, + ) async { + requests.add(options); + return handler(options); + } + + @override + void close({bool force = false}) {} +} + +/// 一個回應。[headers] 的名稱照 HTTP 慣例寫,這裡轉成小寫(dart:io 的 +/// adapter 也給小寫)。 +ResponseBody reply( + int status, { + String body = '', + Map headers = const {}, + Map> multiHeaders = const {}, +}) => ResponseBody.fromString( + body, + status, + headers: { + for (final MapEntry(:key, :value) in headers.entries) + key.toLowerCase(): [value], + for (final MapEntry(:key, :value) in multiHeaders.entries) + key.toLowerCase(): value, + }, +); + +/// 轉址到 [location]。 +ResponseBody redirect(String location, {int status = 302}) => + reply(status, headers: {'Location': location}); diff --git a/app/test/support/pump_until.dart b/app/test/support/pump_until.dart new file mode 100644 index 00000000..102654b7 --- /dev/null +++ b/app/test/support/pump_until.dart @@ -0,0 +1,20 @@ +import 'package:flutter_test/flutter_test.dart'; + +/// 讓事件佇列跑到 [condition] 成立為止;跑了 [maxRounds] 輪仍不成立就失敗。 +/// +/// 測試裡等非同步進度只用這裡的助手(lint `fmp_test_waits`),不直接呼叫 +/// `pumpEventQueue`,也不用實際時間的 `Future.delayed`。 +Future pumpUntil( + bool Function() condition, { + int maxRounds = 20, + String? reason, +}) async { + for (var round = 0; round < maxRounds; round++) { + if (condition()) return; + await pumpEventQueue(); + } + expect(condition(), isTrue, reason: reason ?? 'condition never became true'); +} + +/// 讓事件佇列跑完目前排著的工作(斷言「什麼都沒發生」之前用)。 +Future settle() => pumpEventQueue(); From b13cc9a5d367e3a659293265c59e4f3fd3e60cb8 Mon Sep 17 00:00:00 2001 From: 1morr Date: Tue, 29 Sep 2026 23:51:05 +0800 Subject: [PATCH 2/3] chore(task): defer the media client and note cookie follow-up --- .trellis/tasks/09-28-m1-skeleton-tracer/design.md | 3 ++- .trellis/tasks/09-28-m1-skeleton-tracer/implement.md | 1 + .trellis/tasks/09-28-m1-skeleton-tracer/task.json | 3 ++- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.trellis/tasks/09-28-m1-skeleton-tracer/design.md b/.trellis/tasks/09-28-m1-skeleton-tracer/design.md index f17a2166..b342686e 100644 --- a/.trellis/tasks/09-28-m1-skeleton-tracer/design.md +++ b/.trellis/tasks/09-28-m1-skeleton-tracer/design.md @@ -100,7 +100,8 @@ app/ | § | 項目 | 決定 | 理由 | |---|---|---|---| | 3.2 | Flutter 版本 | `app/` 用 3.47.5(Dart 3.13.4),CI 的 `app` job 釘這版;舊專案 job 維持 3.47.1。本機升到 3.47.5,舊專案同一個 minor,照常建置 | ADR「當時的 stable」 | -| 3.4 | `AuthRequirement` | M1 建型別與宣告點(manifest、請求 DTO),認證攔截器在沒有 `CredentialStore` 時一律不注入;`CredentialStore` 與三種標記的注入測試在 M3 | B 站搜尋與解串流不需登入;宣告點是插件 API 的一部分,晚加會改 DTO | +| 3.4 | `AuthRequirement` | M1 建型別與宣告點(manifest、請求 DTO),認證攔截器在沒有 `CredentialStore` 時一律不注入;三種標記的注入測試在 PR 8 以假的認證來源寫,`CredentialStore` 在 M3 | B 站搜尋與解串流不需登入;宣告點是插件 API 的一部分,晚加會改 DTO | +| — | 媒體 client | 延到 M6。M1 在 PR 8 建媒體 header 政策(`mediaRequestHeaders`:只留 `Referer`、`User-Agent`、`Origin`、`Range`),PR 10 的播放後端拿到的串流 headers 一律先經過它 | M1 沒有使用者:播放後端自己抓串流網址,下載在 M6;M1 需要的只是「交給播放後端的 headers 不帶憑證」。ADR 0009「不寫空實作」 | | 3.5 | `PermissionGateway` | M1 不建 | M1 沒有需要執行期權限的功能;ADR 0009「不寫空實作」 | | 3.6 | 串流網址快取 | M1 不建;`StreamResolver` 每次呼叫插件 | ADR 0016 整包在 M2;兩首的佇列用不到 | | 3.8 | log 檔格式 | 一開始就寫 JSON Lines(ADR 0025 §決定 3 的欄位),2MB×3 | 先寫純文字、M3 再改,等於改兩次;7 天保留仍在 M2 的維護清單 | diff --git a/.trellis/tasks/09-28-m1-skeleton-tracer/implement.md b/.trellis/tasks/09-28-m1-skeleton-tracer/implement.md index fa3497c8..68d1ad9f 100644 --- a/.trellis/tasks/09-28-m1-skeleton-tracer/implement.md +++ b/.trellis/tasks/09-28-m1-skeleton-tracer/implement.md @@ -125,6 +125,7 @@ - [ ] TypeScript 型別定義。 - [ ] `packages/plugin_contract/`:契約執行器、fixture 格式、`checks.json`。 - [ ] `test/fixtures/plugins/test_plugin/`:合成資料、本機音檔。 +- [ ] 接真實 B 站時觀察:伺服器回不合法的 `Set-Cookie` 是否讓請求變成 `UnexpectedError`(`dio_cookie_manager` 的 `ignoreInvalidCookies` 預設 false;PR 8 檢查提出,沒有重現案例前不改)。 - [ ] 建立 `1morr/fmp-plugins`: - `bilibili/` 的 `search`、`resolveStream`; - 錄一次 fixture(真實連線,最少操作)。 diff --git a/.trellis/tasks/09-28-m1-skeleton-tracer/task.json b/.trellis/tasks/09-28-m1-skeleton-tracer/task.json index 5b5b3bd8..96bf5199 100644 --- a/.trellis/tasks/09-28-m1-skeleton-tracer/task.json +++ b/.trellis/tasks/09-28-m1-skeleton-tracer/task.json @@ -25,7 +25,8 @@ "09-29-platform-layer", "09-29-drift-schema", "09-29-logging-settings", - "09-29-error-model" + "09-29-error-model", + "09-29-network-layer" ], "parent": "09-26-fmp-rewrite", "relatedFiles": [], From c640b834a2d97f89c282b2b82473ef6f2fcac7ba Mon Sep 17 00:00:00 2001 From: 1morr Date: Tue, 29 Sep 2026 23:51:05 +0800 Subject: [PATCH 3/3] chore(task): archive network-layer --- .../2026-09/09-29-network-layer/check.jsonl | 5 + .../09-29-network-layer/implement.jsonl | 5 + .../2026-09/09-29-network-layer/prd.md | 83 ++++++++++++ .../09-29-network-layer/research/notes.md | 128 ++++++++++++++++++ .../2026-09/09-29-network-layer/task.json | 26 ++++ 5 files changed, 247 insertions(+) create mode 100644 .trellis/tasks/archive/2026-09/09-29-network-layer/check.jsonl create mode 100644 .trellis/tasks/archive/2026-09/09-29-network-layer/implement.jsonl create mode 100644 .trellis/tasks/archive/2026-09/09-29-network-layer/prd.md create mode 100644 .trellis/tasks/archive/2026-09/09-29-network-layer/research/notes.md create mode 100644 .trellis/tasks/archive/2026-09/09-29-network-layer/task.json diff --git a/.trellis/tasks/archive/2026-09/09-29-network-layer/check.jsonl b/.trellis/tasks/archive/2026-09/09-29-network-layer/check.jsonl new file mode 100644 index 00000000..98eabaec --- /dev/null +++ b/.trellis/tasks/archive/2026-09/09-29-network-layer/check.jsonl @@ -0,0 +1,5 @@ +{"file": "docs/adr/0012-network-layer-and-accounts.md", "reason": "Network layer, redirects, AuthRequirement"} +{"file": "docs/adr/0013-unified-error-model.md", "reason": "Transport error mapping and retry"} +{"file": ".trellis/spec/app/errors/index.md", "reason": "AppError and retry policy usage"} +{"file": ".trellis/spec/app/logging/index.md", "reason": "Log facade and redaction"} +{"file": ".trellis/spec/app/lints/index.md", "reason": "Lint rules incl. fmp_http_client_owner"} diff --git a/.trellis/tasks/archive/2026-09/09-29-network-layer/implement.jsonl b/.trellis/tasks/archive/2026-09/09-29-network-layer/implement.jsonl new file mode 100644 index 00000000..98eabaec --- /dev/null +++ b/.trellis/tasks/archive/2026-09/09-29-network-layer/implement.jsonl @@ -0,0 +1,5 @@ +{"file": "docs/adr/0012-network-layer-and-accounts.md", "reason": "Network layer, redirects, AuthRequirement"} +{"file": "docs/adr/0013-unified-error-model.md", "reason": "Transport error mapping and retry"} +{"file": ".trellis/spec/app/errors/index.md", "reason": "AppError and retry policy usage"} +{"file": ".trellis/spec/app/logging/index.md", "reason": "Log facade and redaction"} +{"file": ".trellis/spec/app/lints/index.md", "reason": "Lint rules incl. fmp_http_client_owner"} diff --git a/.trellis/tasks/archive/2026-09/09-29-network-layer/prd.md b/.trellis/tasks/archive/2026-09/09-29-network-layer/prd.md new file mode 100644 index 00000000..4f37c0fa --- /dev/null +++ b/.trellis/tasks/archive/2026-09/09-29-network-layer/prd.md @@ -0,0 +1,83 @@ +# 網路層(M1 PR 8) + +父任務:`../09-28-m1-skeleton-tracer`(implement「8.」)。 + +依據: +- ADR 0012 §決定 1–2:API client、攔截器順序、轉址、`AuthRequirement`; +- ADR 0011 §決定 4:網路紀錄; +- ADR 0013 §決定 2、4:傳輸錯誤轉 `NetworkError`、只在網路層重試、併發上限與最小間隔; +- ADR 0015 §決定 5:fixture 錄製與重播接在 dio 最底層的 `HttpClientAdapter`,本 PR 讓它可以替換,PR 9 實作。 + +## 範圍調整(主對話決定) + +**媒體 client 延到 M6。** +- M1 沒有使用者:播放後端自己抓串流網址,下載在 M6。 +- M1 需要的是「交給播放後端的 headers 不帶憑證」。本 PR 做成媒體 header 政策,PR 10 使用。 +- 理由:ADR 0009 的「不寫空實作」精神。這一條寫進父任務的 design §3。 + +## 做什麼 + +所有程式放在 `lib/core/network/`,`Dio(` 只准出現在這裡,`fmp_http_client_owner` 守。 + +1. **每插件一個 API client**:`SourceHttpClient`(名稱可調),由一個工廠依插件建立。 + - 輸入: + - 插件 id; + - 允許的網域清單(manifest,PR 9); + - `RetryPolicy`、`RateLimitPolicy`(PR 7); + - 認證來源介面:M1 沒有 `CredentialStore`,用「沒有憑證」的實作; + - log 門面; + - 可替換的 `HttpClientAdapter`,預設為 dio 的 IO adapter。 + - 攔截器順序照 ADR 0012 §決定 1:認證注入 → cookie 管理 → 錯誤對應 → 限流與退避 → 網路紀錄。實作形式照 dio 官方做法,最後的執行順序要有測試斷言。 +2. **網域與轉址**: + - 請求的 host 必須符合允許清單:與清單項目相同,或是它的子網域(`.` 邊界);只准 `https`。不符合時不發請求,直接回 `Unsupported`,或一個明確的 AppError 子類。 + - 轉址手動跟隨(`followRedirects: false`),每一跳都檢查網域,最多 5 跳;超過或出網域就失敗。 + - 做法參考舊版 `lib/data/sources/source_url_policy.dart` 的 `resolveRedirects`。 + - 跨網域的轉址不帶原請求的 `Cookie`、`Authorization`。 +3. **認證**: + - `AuthRequirement` enum:`required`、`userPreference`、`never`(預設)。 + - 判斷函式:輸入「是否已登入」「以登入身分瀏覽的開關」、標記,輸出三種結果:帶憑證、不帶、或不發請求並回 `AuthRequired`(`required` 且未登入時)。 + - 認證攔截器只依這個結果注入。M1 的來源一律「未登入」,三種標記在三種狀態下的結果照 ADR 0012 §如何確認寫測試,用假的認證來源。 + - `CredentialStore` 與登入在 M3。 +4. **cookie**:`cookie_jar`+`dio_cookie_manager`,每插件一個記憶體 cookie jar。 + - 匿名 cookie(例如 B 站 `buvid`)要跨重啟保存時,由插件寫進自己的 storage(`plugin_storage`,ADR 0014);網路層不另建持久化。 + - 這個做法寫進 AGENTS.md。 +5. **錯誤對應**: + - 傳輸層錯誤轉 `NetworkError`:逾時、連線失敗、TLS 失敗、被取消另外處理。 + - HTTP 429,以及帶 `Retry-After` 的 503,轉 `RateLimited`,用 PR 7 的 `parseRetryAfter`。這是 HTTP 通用語意(RFC 6585 §4、RFC 9110 §15.6.4)。 + - 其他狀態碼不在網路層判斷,把回應原樣交給插件,由插件在自己的邊界對應(ADR 0013 §決定 2)。 +6. **重試與限流**: + - 用 PR 7 的 `shouldRetry`、`delayFor` 重試;時鐘與 `Random` 可注入。 + - 每插件的併發上限與最小請求間隔(`RateLimitPolicy`)。 + - 被取消的請求不重試。 +7. **網路紀錄**:每個請求一筆摘要,經 log 門面以 `debug` 寫入,失敗時用 `warning`。 + - 欄位:方法、host、path、遮過的 query、狀態、耗時、回應大小、插件 id、錯誤類型、是否帶了憑證、重試次數。 + - 不記 body。 + - 每筆有一個 id;產生的 `AppError` 帶上這個 id(PR 7 的網路紀錄 id 欄位)。 +8. **媒體 header 政策**:一個純函數,輸入插件給的串流 headers,只保留 `Referer`、`User-Agent`、`Origin`、`Range`,其餘一律丟掉,特別是 `Cookie` 與 `Authorization`。 + - ADR 0012 §如何確認的「媒體請求不帶 Cookie/Authorization」在 M1 由這個函數與 PR 10 的後端接線守。 +9. **PR 7 的待辦**:未捕捉錯誤若是 `AppError`,改走 `log.report`。 +10. **測試**:一律用假的 `HttpClientAdapter`,不聯網;零聯網防線照常生效。 + - 攔截器順序; + - 網域:相同、子網域、`evil-bilibili.com` 不算、`http` 被拒; + - 轉址:5 跳內成功、第 6 跳失敗、出網域失敗、跨網域不帶 Cookie; + - `AuthRequirement` 的表; + - 錯誤對應:逾時轉 `NetworkError`;429 帶秒數與日期的 `Retry-After`;503 有與沒有 `Retry-After`; + - 重試:只重試冪等請求、次數上限、尊重 `Retry-After`(假時鐘); + - 限流:併發上限、最小間隔(假時鐘); + - 網路紀錄:欄位完整、query 裡的假憑證被遮、沒有 body、`AppError` 帶上紀錄 id; + - 媒體 header 政策; + - 未捕捉的 `AppError` 走 `report`。 +11. **文件**: + - `app/AGENTS.md` 網路段; + - `.trellis/spec/app/network/index.md`(繁中); + - 父任務 design §3 補「媒體 client 延到 M6」一列。 + +## 驗收 + +- [ ] `app/`: + - format 通過; + - codegen 沒有變動; + - `dart analyze --fatal-infos`、`flutter analyze` 零問題; + - `flutter test` 全綠; + - 哨兵通過。 +- [ ] 不是使用者看得到的改動,不需要實機驗證。真實連線在 PR 9 的 B 站插件驗證。 diff --git a/.trellis/tasks/archive/2026-09/09-29-network-layer/research/notes.md b/.trellis/tasks/archive/2026-09/09-29-network-layer/research/notes.md new file mode 100644 index 00000000..63608a6b --- /dev/null +++ b/.trellis/tasks/archive/2026-09/09-29-network-layer/research/notes.md @@ -0,0 +1,128 @@ +# 網路層(PR 8)研究筆記 + +查證方式:這個環境沒有 context7 工具,所以 dio 系列一律讀 pub cache 裡的原始碼 +(`%LOCALAPPDATA%/Pub/Cache/hosted/pub.dev/<套件>-<版本>/`),版本以 pub.dev API +(`https://pub.dev/api/packages/<名稱>`)的 latest 為準;RFC 以 rfc-editor.org 的純文字版 +逐字核對。2026-09-29 查。 + +## 1. 版本 + +| 套件 | 版本 | 來源 | +|---|---|---| +| `dio` | 5.11.1(latest) | pub.dev API | +| `dio_cookie_manager` | 3.5.0(latest,依賴 `dio ^5.2.0`、`cookie_jar ^4.0.0`) | pub.dev API | +| `cookie_jar` | 4.0.9(latest) | pub.dev API | + +三個都是 stable;`pubspec.yaml` 用 caret。 + +## 2. dio 攔截器的執行順序 + +`dio-5.11.1/lib/src/dio_mixin.dart` 的 `fetch`(約 418–600 行): + +- 先把每個攔截器的 `onRequest` 依 `interceptors` 的順序串成 `future.then(...)`,然後是 + 送出(`_dispatchRequest`),再依**同樣的順序**串 `onResponse`,最後以 `catchError` + 依同樣的順序串 `onError`。註解原文:「Build a request flow in which the + processors(interceptors) execute in FIFO order.」 +- 所以 dio 不是 middleware 洋蔥模型,回程不反轉:ADR 0012 的順序(認證 → cookie → + 錯誤對應 → 限流 → 網路紀錄)在三條路上都成立,網路紀錄永遠最後,看到的是前面處理過 + 的結果。 +- `Interceptors` 預設第一個是 `ImplyContentTypeInterceptor`(`interceptor.dart` 約 442 + 行),我們的五個接在它後面。 +- `handler.reject(error)` 不帶第二個參數時狀態是 `reject`,`errorInterceptorWrapper` + 看到非 `next`/`rejectCallFollowing` 就直接 `throw`,**其後所有 onError 都不跑**; + 帶 `true` 才是 `rejectCallFollowing`,從第一個攔截器的 onError 跑起。dio 自己的送出 + 失敗也是 `handler.reject(e, true)`。→ 我們的攔截器 reject 一律帶 `true`,否則限流拿 + 不回位置、網路紀錄少一筆(有變異測試)。 +- 攔截器 async 方法丟出的錯誤由 `_observeInterceptorCallback` 接住轉成 reject(帶 + `true`),所以認證來源讀取失敗會走到錯誤對應,變成 `UnexpectedError`。 +- 取消:每個攔截器的工作都包在 `listenCancelForAsyncTask(cancelToken, ...)`,和 + `cancelToken.whenCancel` 賽跑;取消時直接進 onError 鏈,原本在等的攔截器 future 被 + 丟下。→ 限流攔截器先把位置記進 `_Attempt` 再等,onError 才能釋放(排隊中的位置直接 + 移出佇列)。 +- `QueuedInterceptor`(`interceptor.dart` 約 493 行):onRequest/onResponse/onError + 各一條佇列,一次只處理一個回呼;被取消時會推進佇列(`_handleQueue` 的 `advanced`)。 + ADR 0012 的憑證刷新單飛用它(M3)。限流沒用它:它排的是「回呼」,而限流要管的是 + 從開始送出到收到回應這整段、同時最多 N 個,位置得跨 onRequest 到 onResponse/onError + 持有;這段計數本來就要自己寫,所以整個放進可以不經 dio 單獨測試的 `RequestThrottle`。 + +## 3. 重試放在哪裡 + +dio 官方 README 與 `QueuedInterceptor` 的刷新範例是在 onError 裡 `dio.fetch` 重送再 +`handler.resolve`。這裡沒有照做,改在 `SourceHttpClient` 的迴圈重試,理由: + +- FIFO 之下網路紀錄排在限流之後。若在限流的 onError 裡重送,失敗那次的網路紀錄 + onError 還沒跑,`resolve` 之後又會被跳過,**失敗的那次就沒有紀錄**;重送不成功時 + `next` 又會讓外層再記一次。 +- 在迴圈重試時每次都從頭走完整條鏈:重新判斷認證、重新排限流、各自一筆紀錄(`retry` + 欄位),`AppError` 帶最後一次的紀錄 id。 +- 等待(退避)不佔限流的位置。 + +## 4. 轉址 + +- `IOHttpClientAdapter.fetch` 直接把 `options.followRedirects`/`maxRedirects` 設給 + `HttpClientRequest`(`adapters/io_adapter.dart` 約 138 行);`followRedirects: false` + 時 3xx 原樣回來。 +- 舊版 `lib/data/sources/source_url_policy.dart` 的 `resolveRedirects` 迴圈 5 次,實際 + 只跟隨 4 次轉址;這裡照 ADR「最多 5 跳」與 dio `maxRedirects` 預設 5,允許 5 次轉址 + (共 6 個請求),第 6 次轉址失敗。 +- RFC 9110 §15.4(Redirection 3xx)自動跟隨時:「Consider removing header fields that + were not automatically generated by the implementation ... this includes but is not + limited to Authorization and Cookie.」→ 跨 host 的下一跳拿掉原請求的 `Cookie`、 + `Authorization`(加上 `Proxy-Authorization`),並改成 `AuthRequirement.never`。 +- 方法:§15.4.2/§15.4.3 的註記「For historical reasons, a user agent MAY change the + request method from POST to GET」;§15.4.4 的 303 是 GET(HEAD 除外);307/308 不改 + 方法。→ 303 與 POST 收到 301/302 改 GET、丟 body 與 `Content-Type`。 +- 300 與 304 不是要自動跟隨的轉址,原樣交給插件。 + +## 5. cookie + +- `dio_cookie_manager-3.5.0/lib/src/cookie_mgr.dart`: + - `onRequest` 把請求原本的 `Cookie` header 與 jar 的 cookie 合併(`loadCookies`), + 所以認證放的 `Cookie` 會與 jar 的 cookie 併在一起。類別註解要求「Register this + after interceptors that may change RequestOptions.uri or the Cookie request + header」,與 ADR 的「認證 → cookie」一致。 + - `saveCookies` 在 3xx 且有 `Location` 時,把同一組 `Set-Cookie` 也存到每個 + `Location` 的網址底下。跨 host 轉址時,下一跳就會帶著上一個 host 設的 cookie。 + RFC 6265 §5.3 的儲存以 request-uri 為準,所以覆寫 `saveCookies`:去掉 `Location` + 再交給原實作,只存給回應自己的網址(`_OwnHostCookieManager`)。 +- `cookie_jar-4.0.9/lib/src/jar/default.dart` 的 `saveFromResponse` 不檢查 `Domain` + 屬性是否 domain-match 請求的 host(RFC 6265 §5.3 第 6 步要求忽略這種 cookie), + 也沒有 public suffix 檢查(`Domain=com` 照收)。check 階段補上:`_OwnHostCookieJar` + 只收 `Domain` 涵蓋回應 host、而且本身在允許網域內的 cookie(以 manifest 代替 PSL)。 +- 記憶體的 `DefaultCookieJar`;持久化的 `PersistCookieJar` 不用, + 匿名 cookie 由插件自己存(ADR 0014 §決定 5 的 `plugin_storage`)。 + +## 6. 錯誤對應 + +- `DioExceptionType`(`dio_exception.dart` 15–44 行):`connectionTimeout`、 + `sendTimeout`、`receiveTimeout`、`badCertificate`、`badResponse`、`cancel`、 + `connectionError`、`unknown`、`transformTimeout`。 +- IO adapter:`SocketException` 在建連線時轉成 `connectionTimeout` 或 + `connectionError`(約 113–135 行);`HandshakeException`(TLS)不在那個 catch 裡, + 以 `unknown` 包著原例外。→ `unknown` 且 `error is IOException`(`SocketException`、 + `HttpException`、`TlsException` 都是)轉 `NetworkError`;其他 `unknown` 是 + `UnexpectedError`。 +- `badCertificate` 只在設了 `validateCertificate` 時出現,重送結果一樣,所以 + `NetworkError(retryable: false)`。 +- `validateStatus: (_) => true`:所有狀態碼都走 onResponse,`badResponse` 不會由 dio + 產生。 +- RFC 6585 §4:「The 429 status code indicates that the user has sent too many + requests in a given amount of time ("rate limiting"). ... MAY include a Retry-After + header」→ 429 一律 `RateLimited`。 +- RFC 9110 §15.6.4:503「indicates that the server is currently unable to handle the + request due to a temporary overload or scheduled maintenance ... The server MAY send + a Retry-After header field」→ 帶可解析的 `Retry-After` 才轉 `RateLimited`;沒帶的 503 + 不一定是限流,照 ADR 0013 §決定 2 交給插件。 + +## 7. 其他 + +- 取消的介面照 `package:http` 1.6.0 的 `Abortable.abortTrigger`(`lib/src/abortable.dart` + 34 行):一個 `Future`,完成就取消。這樣呼叫端不用 import dio 的 `CancelToken`。 +- 逾時沿用舊版 `lib/core/constants/app_constants.dart` 的 `networkConnectTimeout` + (10 秒)、`networkReceiveTimeout`(30 秒)。dio 的 `receiveTimeout` 是「兩次收到資料 + 之間」的上限,不是整個回應(`options.dart` 約 412–423 行的註解)。 +- `AppError` 新增公開的 `typeName`(寫死的類別名)給網路紀錄的 `error` 欄位,與 + `report` 的 `type` 同一個值;列進 `app_error_surface_test.dart` 的審過清單與文字例外。 +- 未捕捉的 `AppError` 改走 `log.report`(才寫得出原因),層級以 `report` 的 `level` + 參數固定為 `error`(主對話決定:沒人接的錯誤就是沒處理,不依 `expected`);原本的 `library`/`context` 欄位與未捕捉時的 + stackTrace 不寫,stackTrace 用 `AppError` 自己的。 diff --git a/.trellis/tasks/archive/2026-09/09-29-network-layer/task.json b/.trellis/tasks/archive/2026-09/09-29-network-layer/task.json new file mode 100644 index 00000000..8a20b24c --- /dev/null +++ b/.trellis/tasks/archive/2026-09/09-29-network-layer/task.json @@ -0,0 +1,26 @@ +{ + "id": "network-layer", + "name": "network-layer", + "title": "網路層", + "description": "M1 PR 8: per-plugin API client (dio) with ordered interceptors, host allow-list and manual redirects, transport error mapping, retry and rate limiting, network log, AuthRequirement decision, media header policy", + "status": "completed", + "dev_type": null, + "scope": null, + "package": "app", + "priority": "P2", + "creator": "1morr", + "assignee": "1morr", + "createdAt": "2026-09-29", + "completedAt": "2026-09-29", + "branch": "feat/network-layer", + "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