From 3e45532f554e4d49f306f1d4e498a020e3b67556 Mon Sep 17 00:00:00 2001 From: jim800121chen Date: Sat, 27 Jun 2026 06:18:05 +0800 Subject: [PATCH] =?UTF-8?q?fix(model-download):=20=E6=94=B9=20redirect/que?= =?UTF-8?q?ry-string=20token=20=E4=B8=8B=E8=BC=89=EF=BC=8C=E6=A0=B9?= =?UTF-8?q?=E9=99=A4=20CORS=20preflight=20405?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 模型庫下載原本前端用 fetch + Authorization: Bearer 跨 origin 直連 FAA, 觸發 CORS preflight(OPTIONS);FAA 未設 CORS、OPTIONS 回 405 → 下載失敗。 FAA 設計本就支援「token 放 query string(access_token) + redirect 導航下載」, 故不需 FAA 設 CORS。改採 ADR-017 §11 v1.3 乙案: - backend: download_url 組成含 ?access_token={url.QueryEscape(token)} 完整 FAA URL; ModelDownloadResponse.Token 標 deprecated 保留(向下相容);token 不進 log - frontend: 移除 downloadModelFile/deriveDownloadFilename(fetch+blob), 改 triggerNavDownload 用 導航;ModelDownloadGrant 移除 token 欄 - 整條鏈無 CORS:前端→backend 同 origin、前端→FAA 導航無 preflight docs: ADR-017 增補 §11(v1.3) + TDD.md §9.5 tests: backend 8 PASS(含 token escape 邊界)、frontend 35 PASS;Reviewer 通過(0C/0M) Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/autoflow/04-architecture/TDD.md | 5 + .../adr/adr-017-model-library-access.md | 152 +++++++++++++- visionA-backend/internal/api/models.go | 24 ++- .../internal/api/models_download_test.go | 32 ++- .../src/lib/api/model-download.test.ts | 197 +++++------------- .../src/lib/api/model-download.ts | 180 +++++----------- .../src/stores/model-store.test.ts | 36 ++-- visionA-frontend/src/stores/model-store.ts | 15 +- 8 files changed, 327 insertions(+), 314 deletions(-) diff --git a/docs/autoflow/04-architecture/TDD.md b/docs/autoflow/04-architecture/TDD.md index 85a84d8..a0eec84 100644 --- a/docs/autoflow/04-architecture/TDD.md +++ b/docs/autoflow/04-architecture/TDD.md @@ -57,6 +57,11 @@ - Phase 0.8 對前端 API → [`api/api-conversion.md`](api/api-conversion.md) - 架構決策 → [`adr/adr-014-conversion-integration.md`](adr/adr-014-conversion-integration.md) +### 9.5 模型庫存取 / 下載(FAA delegated download) +- 完整架構決策(認證鏈 / 權限模型 / object_key 斷層 / stage e2e 實證) → [`adr/adr-017-model-library-access.md`](adr/adr-017-model-library-access.md) +- **下載對接權威規格(v1.3:query-string token + redirect、推翻舊「fetch + Bearer」、解 CORS 405)** → [`adr/adr-017-model-library-access.md` §11](adr/adr-017-model-library-access.md#11-v13瀏覽器下載改-query-string-token--redirect推翻決策-2--104-的fetch--bearer) +- CORS 405 根因定位(個人層)→ `.autoflow/04-architecture/download-cors-405-diagnosis.md` + ### 10. 前端資料流與狀態管理 - 見 §10(本文件) diff --git a/docs/autoflow/04-architecture/adr/adr-017-model-library-access.md b/docs/autoflow/04-architecture/adr/adr-017-model-library-access.md index 706ff39..a619263 100644 --- a/docs/autoflow/04-architecture/adr/adr-017-model-library-access.md +++ b/docs/autoflow/04-architecture/adr/adr-017-model-library-access.md @@ -1,7 +1,7 @@ # ADR-017: 模型庫存取架構(File Access Agent 重設計) ## 狀態 -Proposed(待使用者裁決)— **v1.2 修訂(2026-06-07):(a) 已用 stage 真實環境 + 真 secret + 真 user e2e 實測打通,跨團隊 blocking 歸零,剩餘全是 visionA 端純實作。** 保留 v1.0(推 (c))/ v1.1(改推 (a)、待跨團隊驗證)歷史。 +Proposed(待使用者裁決)— **v1.3 修訂(2026-06-27):瀏覽器下載改 query-string token + redirect(推翻 v1.2 決策 2 / §10.4 的「fetch + Authorization Bearer」),徹底解 CORS 405。詳見 §11。** **v1.2 修訂(2026-06-07):(a) 已用 stage 真實環境 + 真 secret + 真 user e2e 實測打通,跨團隊 blocking 歸零,剩餘全是 visionA 端純實作。** 保留 v1.0(推 (c))/ v1.1(改推 (a)、待跨團隊驗證)歷史。 ## 日期 2026-06-06(v1.0)/ 2026-06-06(v1.1 修訂)/ 2026-06-07(v1.2 修訂) @@ -489,3 +489,153 @@ ADR-016 讓 **轉檔結果 NEF download** 走 `converter GET /api/v1/jobs/{id}/r - `GET {FAA}/files/{objectKey}`,token 放 `Authorization: Bearer {fdt_token}`(FAA `TryReadAccessToken` **只認 Authorization Bearer**,不認 query / 自訂 header)。 - FAA 自己用 `IDelegatedDownloadTokenValidator`(=`MemberCenterDelegatedDownloadTokenValidator`)拿 token 打 MC validate,帶 `instanceOptions.TenantId` + `ObjectKey`(從 URL path 取)+ `Method=GET`。 - `objectKey` 必須與簽 token 時的 `object_key` **完全一致**(FAA validate boundary 檢查、不一致回 `object_key_mismatch`)。 + +> **⚠️ v1.3 修正(2026-06-27)**:上面這行「FAA `TryReadAccessToken` **只認 Authorization Bearer**,不認 query」**已過時、被 §11 推翻**。重讀 FAA repo 最新 master(`~/file_access_agent`)發現 `TryReadAccessToken`(`src/FileAccessAgent.Api/Program.cs:324-341`)**先讀 `Authorization: Bearer`、沒有才讀 `?access_token=` query string**——即 FAA **原生支援 query-string token**。v1.2 的「只認 Bearer」結論是當時測 stage 沒測 query path 造成的誤判。下載對接因此改為 **query-string token + 瀏覽器 redirect 導航**(避開 CORS preflight),決策與落地規格見 **§11**。 + +--- + +## 11. v1.3:瀏覽器下載改 query-string token + redirect(推翻決策 2 / §10.4 的「fetch + Bearer」) + +> **狀態**:Accepted(2026-06-27)。本節 supersede 決策 2 第 5 步、§10.4 的「FAA 只認 Authorization Bearer」結論,以及前端 `model-download.ts` 現行的 `fetch + Bearer + blob` 實作。**只改「Client→FAA 那一跳怎麼帶 token」,不改認證鏈本體**(MC 簽 fdt / FAA 打 MC validate / boundary 檢查全部不變)。 + +### 11.1 背景(為什麼要改) + +v1.2 決策 2 / §10.4 要求前端用 `fetch(faaUrl, { headers: { Authorization: 'Bearer fdt_...' } })` + `response.blob()` + 動態 anchor 觸發下載。stage 實測(截圖實錘)顯示: + +- 前端(`9527`)`fetch` 跨 origin 直連 FAA(`5081`)+ 帶自訂 `Authorization` header → 瀏覽器判定為 **non-simple request**、先送 **CORS preflight `OPTIONS`**。 +- FAA 沒設 CORS(無 `AddCors` / `UseCors`)、`OPTIONS /files/...` 回 **405** → 瀏覽器報 **CORS error** → 下載死。 +- 根因定位見 `.autoflow/04-architecture/download-cors-405-diagnosis.md`。 + +**FAA team 立場**:不設 CORS。因為 FAA 設計就是「token 放 query string + redirect 導航下載」——這條路徑天生不觸發 CORS preflight(瀏覽器導航不是 fetch、無自訂 header)。 + +### 11.2 FAA repo 實證(修正 v1.2 的誤判) + +| 事實 | 證據(`~/file_access_agent` 最新 master) | +|------|------------------------------------------| +| FAA download token **支援放 query string**,參數名 `access_token` | `src/FileAccessAgent.Api/Program.cs:324-341` `TryReadAccessToken`:先讀 `Authorization: Bearer`、`else` 讀 `Request.Query["access_token"]` | +| FAA 官方 test page 的正解就是 query + redirect | `src/FileAccessAgent.TestSite/Controllers/HomeController.cs:255-282` `DownloadFileDirect`:組 `/files/{encodedObjectKey}?access_token={Uri.EscapeDataString(token)}` 後 `Redirect()` 導航 | +| FAA 確實沒設 CORS | 全 repo 無 `AddCors` / `UseCors` | +| objectKey encode 規則 | segment-wise URL encode(每段各自 escape、保留 `/`);token 用 `Uri.EscapeDataString` | + +→ **query-string token 是 FAA 原生支援、且是官方建議用法**。改用它 = 對齊 FAA 設計,不是 workaround。 + +### 11.3 決策 A:token 放 URL 的責任切分 — **推薦「乙:後端組好完整 URL」** + +| 維度 | 甲:後端回 `{download_url, token, expires_at}`、**前端**拼 `?access_token=` | 乙:**後端**直接回已含 `?access_token=` 的完整 URL ★推薦 | +|------|----------------------------------------------------------------------|---------------------------------------------------------| +| token escape 責任 | 前端做 `encodeURIComponent(token)`(多一處易錯點) | 後端 `url.QueryEscape(token)`(與 objectKey escape 同一處、一致) | +| objectKey 知識 | 前端不需碰(download_url 已含 objectKey);但要懂「在尾巴 append query」 | 前端完全不碰 URL 組裝、拿到直接導航 | +| 職責清晰度 | URL 由前後端**各組一半**(後端組 path、前端組 query)→ 切割點尷尬 | URL 完全由後端產生 = **單一真實來源**、token 不外露給前端邏輯層處理 | +| API 契約衝擊 | **不變**(仍回 3 欄)→ 後端 test 不用大改 | **變**:`download_url` 內含 token;`token` / `expires_at` 欄位處置見下 | +| 安全(token 出現處) | token 在前端 JS 變數 + URL;前端任何 log 都可能印到 | token 由後端直接埋進 URL;前端只當「不透明連結」傳給瀏覽器,**不進前端邏輯層處理** | +| 既有 backend test | 幾乎不動(仍驗 3 欄) | 需改 `models_download_test.go`:驗 `download_url` 含 `?access_token=` | + +**推薦乙、理由**: + +1. **URL 是單一真實來源**:FAA 下載 URL 的「path(objectKey segment-escape)+ query(token escape)」是一套 FAA encode 規則,由**同一端(後端)**用同一套邏輯組完最不易出錯。甲案把這套規則切兩半(後端組 path、前端 append query),切割點本身就是 bug 溫床。 +2. **token 不下放到前端邏輯層**:乙案前端把 `download_url` 當「不透明可導航連結」,token 不再是前端要 `encodeURIComponent` / 存變數 / 可能誤印 log 的東西。降低 token 在前端誤洩風險。 +3. **對齊 FAA 官方用法**:FAA test page(§11.2)就是 server 端組好完整 URL 再 redirect。乙案 = 同模式。 +4. **甲案唯一好處(API 契約不變)價值低**:契約本來就要改(fetch→redirect 是行為大改、前端 test 一定要重寫),保留 3 欄結構省不了多少。 + +**乙案的 API 契約處置(給 backend,重要)**: + +- `download_url`:改為 `{FAABaseURL}/files/{segmentEscapedObjectKey}?access_token={queryEscapedToken}`(在現有 §10.4 / `models.go:550` 組好的 URL 尾巴 append `?access_token=`)。 +- `token` 欄位:**保留但標記 deprecated**(向下相容——舊版 local-tool / 既有呼叫方可能還讀它)。值仍回原始 `fdt_` token。前端**改為不使用它**(不再需要拼 header / 拼 URL)。**不要直接移除欄位**,避免破壞既有 consumer。 +- `expires_at`:**保留**(前端 / UI 仍可用來顯示「連結有效期」提示,雖然第一階段未用)。 +- 即:response 結構**只是 `download_url` 內容變化 + `token` 轉 deprecated**,欄位不刪 → 對既有 consumer 向下相容。 + +### 11.4 決策 B:前端導航方式 — **推薦「動態 `` click」** + +| 方式 | 評估 | +|------|------| +| **動態 `` + click** ★推薦 | 明確語意「下載」;`download` 屬性提示瀏覽器存檔而非導頁;同分頁不會把目前 SPA 畫面導走 | +| `window.location.href = url` | 會嘗試把**目前分頁**導去該 URL。若 FAA 回 `Content-Disposition: attachment`,多數瀏覽器會轉成下載、不離開頁面;但若 header 缺失或瀏覽器行為差異,可能變成「整頁導去 FAA」破壞 SPA。風險較高 | + +**推薦動態 ``、理由**:跨 origin 時 `` 的 `download` 屬性檔名提示會被瀏覽器忽略(安全限制),但**「觸發下載而非導頁」的行為仍比 `window.location` 穩**——尤其搭配 FAA 的 `Content-Disposition: attachment`,瀏覽器會以下載處理且不離開當前頁。可重用既有 `triggerBlobDownload` 的 anchor 建立/click/remove 樣式(但 href 改成 FAA URL、不再是 blob object URL,且**不需要 `URL.revokeObjectURL`**)。 + +**檔名處理(重要簡化)**: + +- FAA 下載回 `Content-Disposition: attachment; filename=...`,**瀏覽器會用 FAA 給的檔名**。 +- 跨 origin 導航時 `` 的 `xxx` **會被瀏覽器忽略**(跨 origin 安全限制)→ 前端設不設 `download` 屬性的檔名都不影響結果。 +- → **`deriveDownloadFilename` 在 redirect 方案下失去作用**(它原本是給 blob 方案命名用的;blob 是 same-origin object URL、`download` 屬性有效)。redirect 方案下檔名完全由 FAA 的 `Content-Disposition` 決定。 +- **建議**:移除 `deriveDownloadFilename` 的呼叫(store 不再需要),函式本身可留著標 deprecated 或一併刪(由 frontend agent 判斷,傾向刪以免死碼)。`` 仍可設 `download=""`(空值)純粹表達「這是下載意圖」,但不依賴它命名。 + +### 11.5 決策 C:objectKey / URL encode 釐清 + +- **後端現況**:`models.go:550` `downloadURL = {FAABaseURL}/files/{escapeFAAObjectKey(objectKey)}`,`escapeFAAObjectKey` 已做 segment-wise `url.PathEscape`、保留 `/`(`models.go:570-576`)——**與 FAA encode 規則對齊、已組好含 objectKey 的完整 path、不含 query**。 +- **結論**:採乙案後,**前端完全不碰 objectKey encode**。後端在現有 URL 尾巴 append `?access_token={url.QueryEscape(token)}` 即可(用 `url.QueryEscape` 對齊 FAA test page 的 `Uri.EscapeDataString`)。前端只是把這個完整 URL 丟給 ``。 + +### 11.6 決策 D:安全考量(token 進 URL)— **結論:可接受 + 輕量緩解** + +token 放 query string 會落在:瀏覽器歷史、FAA server access log(可能)、`Referer`(導航到 FAA,但 FAA 是 token 的目的地、非第三方)。 + +**風險評估(結論:可接受)**: + +| 因素 | 評估 | +|------|------| +| token TTL | **120s 短效**(§10.3 實測 `expires_in_seconds`)→ 落歷史 / log 後很快失效 | +| token 範圍 | **單檔 + 單 method(GET)boundary**(MC validate 綁 object_key + method)→ 截走只能下載那一個檔 | +| token 形態 | **opaque `fdt_`**(非 JWT、不含可解析的 user/權限資訊)+ MC 可 `RevokedAt` 即時撤銷 | +| 使用次數 | 一次性下載場景(非長期憑證) | + +→ **這正是 FAA 設計 query-string token 時就接受的取捨**(FAA 官方 test page 即如此用)。短 TTL + 單檔 boundary + opaque + 可 revoke,四重特性使「token 落 URL」風險可接受。 + +**輕量緩解(不過度工程)**: + +1. **不要在前端 console / log / 錯誤訊息印出含 token 的完整 URL**(前端錯誤回報只記 modelId / status code,不記 download_url)。 +2. **沿用短 TTL**(維持 §10.3 的 120s,Q2 已定 60–300s)。 +3. (已天然滿足)opaque token + MC boundary + revoke 能力,無需額外開發。 +4. **不需要**做更重的緩解(如 one-time token / IP 綁定)——與 FAA 設計範圍不符、第一階段過度工程。 + +### 11.7 決策 E:CORS 殘留確認 — **整條鏈乾淨** + +| 跳 | origin 關係 | CORS 風險 | +|----|-----------|-----------| +| 前端 → visionA backend `GET /api/models/:id/download` | **同 origin**(9527→9527、走 `api.get`、帶 cookie) | 無 | +| 前端 → FAA `GET /files/...?access_token=`(瀏覽器導航) | 跨 origin(9527→5081),但**是導航、非 fetch、無自訂 header** | **無 preflight** → 無 CORS | + +→ 改 redirect 後,**整條下載鏈不再有任何 CORS preflight**。FAA 不需設 CORS(對齊 FAA team 立場)。徹底解決 405。 + +### 11.8 落地 checklist(工程師照表施工) + +**Backend(`visionA-backend`)— 採乙案:** + +- [ ] `internal/api/models.go` `modelsDownloadHandler`(~`:550`):`downloadURL` 尾巴 append `?access_token=` + `url.QueryEscape(issued.Token)`。即 `downloadURL = {FAABaseURL}/files/{escapeFAAObjectKey(key)}?access_token={url.QueryEscape(token)}`。 +- [ ] `ModelDownloadResponse`(`:457`):`Token` 欄位註解標 **deprecated**(仍回原始值、向下相容、前端不再使用);`DownloadURL` 註解更新為「已含 `?access_token=`、可直接瀏覽器導航下載」;`ExpiresAt` 保留。 +- [ ] **不刪欄位**(向下相容)。 + +**Frontend(`visionA-frontend`):** + +- [ ] `src/lib/api/model-download.ts`: + - [ ] **移除** `downloadModelFile`(fetch + Bearer + blob 那套)。 + - [ ] 新增(或改造 `triggerBlobDownload`)成 `triggerNavDownload(downloadUrl: string)`:動態建 `` + append + click + remove(**不需 createObjectURL / revokeObjectURL**)。 + - [ ] `getModelDownload`:保留(仍打同 origin backend)。回傳型別 `ModelDownloadGrant` 的 `token` 改標 deprecated / optional(前端不再用)。 + - [ ] **移除** `deriveDownloadFilename`(redirect 跨 origin 下檔名由 FAA `Content-Disposition` 決定、前端命名無效;見 §11.4)。 + - [ ] 錯誤分層:導航式下載**無法攔截 FAA 端 4xx/5xx**(瀏覽器導航不回 Response 給 JS)。故 `network_error` / `download_failed` 這類「FAA 回應錯誤」前端**偵測不到**——錯誤處理範圍縮小為「getModelDownload 階段的錯誤」(backend 4xx/5xx)。更新註解說明此限制。 +- [ ] `src/stores/model-store.ts` `downloadModel` action(~`:278`): + - [ ] 改為 `getModelDownload(model.id)` → `triggerNavDownload(grant.downloadUrl)`(拿掉 `deriveDownloadFilename` + `downloadModelFile`)。 + - [ ] `downloadingId` loading 狀態:導航觸發是同步瞬間完成(無 await blob),loading 幾乎不可見——可保留作防重複點擊、但要意識到「下載進度」前端無法追蹤(瀏覽器接手)。 +- [ ] `src/components/models/model-card.tsx`:下載按鈕邏輯大致不變(仍呼叫 `downloadModel`);但 toast「下載開始」語意仍成立(導航觸發即視為開始)。 + +**Tests:** + +- [ ] `visionA-frontend/src/lib/api/model-download.test.ts`: + - [ ] **刪除** `downloadModelFile` 整個 describe(fetch happy / 非2xx / CORS network_error / abort / 空token)——該函式移除。 + - [ ] **刪除** `deriveDownloadFilename` 整個 describe——函式移除。 + - [ ] `triggerBlobDownload` describe → 改為 `triggerNavDownload`:驗「建立 anchor、href = 傳入的 downloadUrl、有 download 屬性、click 後從 DOM 移除」(**不再驗 createObjectURL / revokeObjectURL**)。 + - [ ] `getModelDownload` describe:保留(仍驗 200 正規化 / 4xx / 5xx / parse)。 +- [ ] `visionA-frontend/src/stores/model-store.test.ts`:`downloadModel` 相關 case 改為驗「呼叫 getModelDownload 後觸發導航(spy triggerNavDownload / spy anchor click)」,而非「呼叫 downloadModelFile」。 +- [ ] `visionA-frontend/src/components/models/model-card.test.tsx`:互動 test 大致不變(mock `downloadModel` 回傳),確認按鈕點擊 → toast 行為。 +- [ ] `visionA-backend/internal/api/models_download_test.go` `TestModelsDownload_OK`:`download_url` 斷言改為**含 `?access_token=`**(如 `.../files/models/demo-user/job-1.nef?access_token=fdt_abc123`)。其餘 case(501/404/403/502)不受影響。 + +**驗收(給 testing):** + +- [ ] 後端:`download_url` 結尾含 `?access_token=` + token 正確 escape。 +- [ ] 前端:點下載 → 建立帶 FAA URL 的 `` 並 click(無 fetch 呼叫、無 CORS preflight)。 +- [ ] e2e(stage):實際點下載按鈕、瀏覽器發起對 FAA 的導航 GET、檔案落地(檔名來自 FAA `Content-Disposition`)。 + +### 11.9 後果 + +**正面**:徹底解決 405 / CORS(FAA 不需設 CORS、對齊 FAA team 立場 + FAA 官方用法);前端大幅簡化(拿掉 fetch+blob+createObjectURL+檔名推導);token escape / URL 組裝收口到後端單一真實來源。 + +**負面(接受的取捨)**:(1) token 落瀏覽器歷史 / FAA log(§11.6 評估可接受);(2) 前端**無法攔截 FAA 端下載錯誤**(導航式下載不回 Response 給 JS)——FAA 4xx/5xx 時使用者看到的是瀏覽器原生錯誤頁 / 空白,而非 visionA 的 toast。第一階段接受(短 TTL + owner-only 已驗權限,FAA 端錯誤機率低);若未來需要前端可控的錯誤體驗,再評估「backend proxy 下載」(決策 2 §4 替代方案 A)。(3) `token` / `deriveDownloadFilename` 留為 deprecated 死碼(建議下次清理)。 diff --git a/visionA-backend/internal/api/models.go b/visionA-backend/internal/api/models.go index afece59..b413755 100644 --- a/visionA-backend/internal/api/models.go +++ b/visionA-backend/internal/api/models.go @@ -450,14 +450,19 @@ func modelsDeleteHandler(deps Deps) gin.HandlerFunc { } } -// ModelDownloadResponse 是 GET /api/models/:id/download 的 response data(ADR-017 (a) 決策 2)。 +// ModelDownloadResponse 是 GET /api/models/:id/download 的 response data。 // -// Client(local-tool / browser)拿到後,帶 `Authorization: Bearer {Token}` 直接 -// GET DownloadURL(= {FAA}/files/{object_key})下載——不經 visionA、不經 AWS。 +// ADR-017 v1.3(§11):瀏覽器下載改 query-string token + redirect 導航,避開 CORS +// preflight 405。後端組好「已含 ?access_token= 的完整 URL」(決策 A 乙案),Client +// 把 DownloadURL 當不透明連結直接導航下載——不經 visionA、不經 AWS。 type ModelDownloadResponse struct { - // DownloadURL 是 FAA 下載 URL(`{FAABaseURL}/files/{object_key}`)。 + // DownloadURL 是 FAA 下載 URL,已含 `?access_token=`(`{FAABaseURL}/files/{object_key}?access_token={token}`)。 + // 可直接瀏覽器導航下載,無需自行帶 header / 拼 query(ADR-017 §11.3 乙案)。 DownloadURL string `json:"download_url"` - // Token 是 MC 簽的 opaque download token(fdt_);放 Authorization: Bearer。 + // Token 是 MC 簽的 opaque download token(fdt_)。 + // + // Deprecated(ADR-017 v1.3 §11.3):token 已併入 DownloadURL 的 ?access_token=, + // Client 不應再單獨使用本欄位。保留純為向下相容(舊 local-tool / 既有 consumer 可能仍讀)。 Token string `json:"token"` // ExpiresAt 是 token 到期時間(RFC3339 / UTC);MC 沒回填時為零值(前端不應依賴)。 ExpiresAt time.Time `json:"expires_at,omitempty"` @@ -545,9 +550,14 @@ func modelsDownloadHandler(deps Deps) gin.HandlerFunc { return } - // 組對外 download_url:{FAABaseURL}/files/{object_key}。 + // 組對外 download_url:{FAABaseURL}/files/{object_key}?access_token={token}。 // object_key 內含 '/'(models/{userID}/{jobID}.nef),需逐段 escape 但保留 '/'。 - downloadURL := strings.TrimRight(deps.FAABaseURL, "/") + "/files/" + escapeFAAObjectKey(m.FAAObjectKey) + // ADR-017 v1.3(§11.3 乙案):token 以 query string 併入 URL(FAA 原生支援 access_token + // query、§11.2),由後端用 url.QueryEscape 統一 escape(對齊 FAA test page 的 + // Uri.EscapeDataString)。path 此時不含 query,直接 append `?access_token=` 即可。 + // 注意:downloadURL(含 token)絕不寫進 log(token 維持不洩漏原則)。 + downloadURL := strings.TrimRight(deps.FAABaseURL, "/") + "/files/" + escapeFAAObjectKey(m.FAAObjectKey) + + "?access_token=" + url.QueryEscape(issued.Token) logOrDefault(deps.Logger).Info("models: download token issued", "model_id", m.ID, diff --git a/visionA-backend/internal/api/models_download_test.go b/visionA-backend/internal/api/models_download_test.go index 5a8acec..6faf712 100644 --- a/visionA-backend/internal/api/models_download_test.go +++ b/visionA-backend/internal/api/models_download_test.go @@ -106,7 +106,11 @@ func TestModelsDownload_OK(t *testing.T) { require.NoError(t, json.Unmarshal(w.Body.Bytes(), &sb)) data := sb.Data.(map[string]any) - assert.Equal(t, "https://faa.example.com:5081/files/models/demo-user/job-1.nef", data["download_url"]) + // ADR-017 v1.3(§11.3 乙案):download_url 已含 ?access_token=(後端組好的完整 URL)。 + assert.Equal(t, + "https://faa.example.com:5081/files/models/demo-user/job-1.nef?access_token=fdt_abc123", + data["download_url"]) + // token 欄位仍回原始 fdt token(deprecated、向下相容,§11.3)。 assert.Equal(t, "fdt_abc123", data["token"]) assert.Contains(t, data["expires_at"], "2026-06-07") @@ -116,6 +120,32 @@ func TestModelsDownload_OK(t *testing.T) { assert.Equal(t, 1, iss.calls) } +// TestModelsDownload_TokenURLEscaped 驗 token 內含 query-string 特殊字元時, +// download_url 以 url.QueryEscape 正確 escape(access_token 值不會破壞 URL 結構)。 +func TestModelsDownload_TokenURLEscaped(t *testing.T) { + // 刻意用含 '+' / '/' / '=' / ' ' 的 token(fdt token 理論上是 opaque,需保證 escape 安全)。 + iss := &fakeIssuer{token: "fdt_a+b/c=d e"} + r, repo := newDownloadFixture(t, iss, "https://faa.example.com:5081", "demo-user") + seedConvertedModel(t, repo, "m-conv-1", "demo-user", "models/demo-user/job-1.nef") + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/models/m-conv-1/download", nil) + r.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String()) + + var sb SuccessBody + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &sb)) + data := sb.Data.(map[string]any) + + // url.QueryEscape:"+"→"%2B"、"/"→"%2F"、"="→"%3D"、" "→"+"。 + assert.Equal(t, + "https://faa.example.com:5081/files/models/demo-user/job-1.nef?access_token=fdt_a%2Bb%2Fc%3Dd+e", + data["download_url"]) + // token 欄位回原始未 escape 值(deprecated、向下相容)。 + assert.Equal(t, "fdt_a+b/c=d e", data["token"]) +} + // ========================================================================== // 501 — 未配置 / 上傳類 // ========================================================================== diff --git a/visionA-frontend/src/lib/api/model-download.test.ts b/visionA-frontend/src/lib/api/model-download.test.ts index 6102208..f76f8bc 100644 --- a/visionA-frontend/src/lib/api/model-download.test.ts +++ b/visionA-frontend/src/lib/api/model-download.test.ts @@ -1,21 +1,23 @@ /** - * Model Download API Client 單元測試(Phase 0.9) + * Model Download API Client 單元測試(Phase 0.9 / ADR-017 v1.3 — query-string token + redirect 導航) * * 覆蓋: - * 1. getModelDownload — 200 正規化 / 404 / 403 / 501 / 502 / parse 缺欄 - * 2. downloadModelFile — happy(fetch + Bearer → blob → anchor click)/ FAA 非 2xx / CORS network_error / abort - * 3. triggerBlobDownload — anchor 屬性 + revokeObjectURL - * 4. deriveDownloadFilename — 從 URL path 取檔名 / fallback + * 1. getModelDownload — 200 正規化(download_url + expires_at)/ 404 / 403 / 501 / 502 / parse 缺欄 + * 2. triggerNavDownload — 建立 anchor、href = 傳入的 downloadUrl、有 download 屬性、click 後從 DOM 移除 + * 3. ModelDownloadError 形狀 + * + * v1.3 移除(對齊 ADR §11.8): + * - downloadModelFile(fetch + Bearer + blob,CORS 405 元兇)整段拔掉 → 不再測。 + * - deriveDownloadFilename(redirect 跨 origin 檔名由 FAA Content-Disposition 決定、前端命名無效)→ 不再測。 + * - triggerBlobDownload(blob object URL)→ 改為 triggerNavDownload(FAA 真實 URL,不需 createObjectURL)。 */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { - deriveDownloadFilename, - downloadModelFile, getModelDownload, ModelDownloadError, - triggerBlobDownload, + triggerNavDownload, } from "./model-download"; function jsonResponse(body: unknown, status = 200): Response { @@ -41,13 +43,14 @@ afterEach(() => { /* ========================================================================== */ describe("getModelDownload", () => { - it("200 → 正規化 { downloadUrl, token, expiresAt }(snake_case)", async () => { + it("200 → 正規化 { downloadUrl, expiresAt }(snake_case;download_url 已含 ?access_token=)", async () => { fetchMock.mockResolvedValue( jsonResponse({ success: true, data: { - download_url: "https://faa.example.com:5081/files/models/u1/j1.nef", - token: "fdt_abc123", + download_url: + "https://faa.example.com:5081/files/models/u1/j1.nef?access_token=fdt_abc123", + token: "fdt_abc123", // 後端仍回(deprecated),前端不使用 expires_at: "2026-06-07T12:02:00Z", }, }), @@ -55,10 +58,11 @@ describe("getModelDownload", () => { const grant = await getModelDownload("m1"); expect(grant.downloadUrl).toBe( - "https://faa.example.com:5081/files/models/u1/j1.nef", + "https://faa.example.com:5081/files/models/u1/j1.nef?access_token=fdt_abc123", ); - expect(grant.token).toBe("fdt_abc123"); expect(grant.expiresAt).toBe("2026-06-07T12:02:00Z"); + // 前端不再讀 token 欄位(已移出 ModelDownloadGrant 型別) + expect((grant as unknown as Record).token).toBeUndefined(); }); it("camelCase 也吃(downloadUrl / expiresAt)", async () => { @@ -66,15 +70,14 @@ describe("getModelDownload", () => { jsonResponse({ success: true, data: { - downloadUrl: "https://faa/x.nef", - token: "fdt_x", + downloadUrl: "https://faa/x.nef?access_token=fdt_x", expiresAt: "2026-06-07T00:00:00Z", }, }), ); const grant = await getModelDownload("m1"); - expect(grant.downloadUrl).toBe("https://faa/x.nef"); - expect(grant.token).toBe("fdt_x"); + expect(grant.downloadUrl).toBe("https://faa/x.nef?access_token=fdt_x"); + expect(grant.expiresAt).toBe("2026-06-07T00:00:00Z"); }); it("空 modelId → validation_failed(不打 API)", async () => { @@ -101,11 +104,11 @@ describe("getModelDownload", () => { }); }); - it("200 但缺 token → parse_error", async () => { + it("200 但缺 download_url → parse_error", async () => { fetchMock.mockResolvedValue( jsonResponse({ success: true, - data: { download_url: "https://faa/x.nef" }, + data: { expires_at: "2026-06-07T12:02:00Z" }, }), ); await expect(getModelDownload("m1")).rejects.toMatchObject({ @@ -115,168 +118,62 @@ describe("getModelDownload", () => { }); /* ========================================================================== */ -/* 2. downloadModelFile */ +/* 2. triggerNavDownload */ /* ========================================================================== */ -describe("downloadModelFile", () => { - let createObjectURL: ReturnType; - let revokeObjectURL: ReturnType; - let clickSpy: ReturnType; - - beforeEach(() => { - vi.useFakeTimers(); - createObjectURL = vi.fn(() => "blob:mock-url"); - revokeObjectURL = vi.fn(); - globalThis.URL.createObjectURL = createObjectURL as unknown as typeof URL.createObjectURL; - globalThis.URL.revokeObjectURL = revokeObjectURL as unknown as typeof URL.revokeObjectURL; - // anchor.click 在 jsdom 預設不觸發 navigation,但我們仍想斷言它被呼叫 - clickSpy = vi - .spyOn(HTMLAnchorElement.prototype, "click") - .mockImplementation(() => {}); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it("happy path:帶 Authorization Bearer → blob → anchor click + 延遲 revoke", async () => { - // 直接給帶 .blob() 的物件,避免 fake timers 下 jsdom Response/Blob stream 內部報錯 - fetchMock.mockResolvedValue({ - ok: true, - status: 200, - headers: new Headers(), - blob: () => Promise.resolve(new Blob(["nef-bytes"])), - } as unknown as Response); - - await downloadModelFile("https://faa/x.nef", "fdt_tok", "x.nef"); - - // 驗 fetch 帶對的 header + credentials omit - expect(fetchMock).toHaveBeenCalledWith( - "https://faa/x.nef", - expect.objectContaining({ - method: "GET", - credentials: "omit", - headers: { Authorization: "Bearer fdt_tok" }, - }), - ); - expect(createObjectURL).toHaveBeenCalledOnce(); - expect(clickSpy).toHaveBeenCalledOnce(); - // revoke 延遲觸發 - expect(revokeObjectURL).not.toHaveBeenCalled(); - vi.advanceTimersByTime(1000); - expect(revokeObjectURL).toHaveBeenCalledWith("blob:mock-url"); - }); - - it("FAA 非 2xx → download_failed(帶 status)", async () => { - fetchMock.mockResolvedValue(new Response("nope", { status: 404 })); - await expect( - downloadModelFile("https://faa/x.nef", "fdt_tok", "x.nef"), - ).rejects.toMatchObject({ code: "download_failed", status: 404 }); - }); - - it("fetch throw(CORS / 連不上)→ network_error", async () => { - fetchMock.mockRejectedValue(new TypeError("Failed to fetch")); - await expect( - downloadModelFile("https://faa/x.nef", "fdt_tok", "x.nef"), - ).rejects.toMatchObject({ code: "network_error" }); - }); - - it("AbortError → aborted", async () => { - const abortErr = new Error("The operation was aborted"); - abortErr.name = "AbortError"; - fetchMock.mockRejectedValue(abortErr); - await expect( - downloadModelFile("https://faa/x.nef", "fdt_tok", "x.nef"), - ).rejects.toMatchObject({ code: "aborted" }); - }); - - it("空 token → validation_failed(不打 fetch)", async () => { - await expect( - downloadModelFile("https://faa/x.nef", "", "x.nef"), - ).rejects.toMatchObject({ code: "validation_failed" }); - expect(fetchMock).not.toHaveBeenCalled(); - }); -}); - -/* ========================================================================== */ -/* 3. triggerBlobDownload */ -/* ========================================================================== */ - -describe("triggerBlobDownload", () => { - beforeEach(() => { - vi.useFakeTimers(); - globalThis.URL.createObjectURL = vi.fn( - () => "blob:trigger-url", - ) as unknown as typeof URL.createObjectURL; - globalThis.URL.revokeObjectURL = vi.fn() as unknown as typeof URL.revokeObjectURL; - }); - afterEach(() => vi.useRealTimers()); - - it("建立 anchor、設 download 屬性、click 後從 DOM 移除", () => { +describe("triggerNavDownload", () => { + it("建立 anchor、href = 傳入的 downloadUrl、有 download 屬性、click 後從 DOM 移除", () => { const clickSpy = vi .spyOn(HTMLAnchorElement.prototype, "click") .mockImplementation(() => {}); - let capturedDownload = ""; let capturedHref = ""; + let hasDownloadAttr = false; const appendSpy = vi .spyOn(document.body, "appendChild") .mockImplementation((node) => { const a = node as HTMLAnchorElement; - capturedDownload = a.download; capturedHref = a.href; + hasDownloadAttr = a.hasAttribute("download"); return node; }); const removeSpy = vi .spyOn(document.body, "removeChild") .mockImplementation((node) => node); - triggerBlobDownload(new Blob(["x"]), "my-model.nef"); + const url = "https://faa.example.com:5081/files/models/u1/j1.nef?access_token=fdt_abc"; + triggerNavDownload(url); - expect(capturedDownload).toBe("my-model.nef"); - expect(capturedHref).toContain("blob:trigger-url"); + expect(capturedHref).toBe(url); + expect(hasDownloadAttr).toBe(true); expect(clickSpy).toHaveBeenCalledOnce(); expect(appendSpy).toHaveBeenCalledOnce(); expect(removeSpy).toHaveBeenCalledOnce(); }); -}); -/* ========================================================================== */ -/* 4. deriveDownloadFilename */ -/* ========================================================================== */ + it("不建立 blob object URL(不呼叫 createObjectURL)", () => { + const createObjectURL = vi.fn(); + globalThis.URL.createObjectURL = + createObjectURL as unknown as typeof URL.createObjectURL; + vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {}); -describe("deriveDownloadFilename", () => { - it("從 URL path 取最後一段含副檔名", () => { - expect( - deriveDownloadFilename( - "https://faa.example.com:5081/files/models/u1/j1.nef", - "YOLOv5s", - ), - ).toBe("j1.nef"); + triggerNavDownload("https://faa/x.nef?access_token=fdt_x"); + + expect(createObjectURL).not.toHaveBeenCalled(); }); - it("URL 帶 query string 也只取 pathname 最後段", () => { - expect( - deriveDownloadFilename("https://faa/files/u/abc.nef?token=x", "name"), - ).toBe("abc.nef"); - }); - - it("path 無副檔名 → fallback 用 modelName.nef", () => { - expect(deriveDownloadFilename("https://faa/files/u/blob", "My Model")).toBe( - "My_Model.nef", + it("空 downloadUrl → validation_failed(不建立 anchor)", () => { + const clickSpy = vi + .spyOn(HTMLAnchorElement.prototype, "click") + .mockImplementation(() => {}); + expect(() => triggerNavDownload("")).toThrowError( + expect.objectContaining({ code: "validation_failed" }), ); - }); - - it("URL 無法解析 → fallback;非法字元被取代", () => { - expect(deriveDownloadFilename("not a url", "a/b c")).toBe("a_b_c.nef"); - }); - - it("modelName 已含 .nef 不重複加", () => { - expect(deriveDownloadFilename("bad", "model.nef")).toBe("model.nef"); + expect(clickSpy).not.toHaveBeenCalled(); }); }); /* ========================================================================== */ -/* ModelDownloadError 形狀 */ +/* 3. ModelDownloadError 形狀 */ /* ========================================================================== */ describe("ModelDownloadError", () => { diff --git a/visionA-frontend/src/lib/api/model-download.ts b/visionA-frontend/src/lib/api/model-download.ts index 4882bf8..ac4fb98 100644 --- a/visionA-frontend/src/lib/api/model-download.ts +++ b/visionA-frontend/src/lib/api/model-download.ts @@ -2,24 +2,26 @@ * Model Download API Client(Phase 0.9 — 模型庫「FAA delegated download」對接) * * 對齊: - * - `docs/autoflow/04-architecture/adr/adr-017-model-library-access.md` §10.4 + 決策 2 - * - backend endpoint `GET /api/models/:id/download`(commit c63886a,stage e2e 驗過) + * - `docs/autoflow/04-architecture/adr/adr-017-model-library-access.md` §11(v1.3:query-string token + redirect 導航) + * - backend endpoint `GET /api/models/:id/download` * - * ⚠️ 為什麼這支「下載」跟既有轉檔下載(conversion.ts `getConversionDownloadURL`)完全不同: - * - 轉檔下載:同 origin、走 visionA backend、用 `` browser navigation(302 redirect)。 - * - 模型庫下載:**跨 origin 直連 FAA(如 stage-9527:5081)+ 必須帶 `Authorization: Bearer {token}`**。 - * → browser navigation(`` / `window.location.href`)**無法帶 Authorization header**, - * 所以一定要用 `fetch(url, { headers: { Authorization } }) → blob → 動態 觸發下載`。 + * ⚠️ v1.3 重大改動(推翻 v1.2 的「fetch + Authorization Bearer + blob」): + * - 舊作法:前端 `fetch(faaUrl, { headers: { Authorization: 'Bearer fdt_...' } }) → blob → anchor`。 + * 跨 origin(9527→5081)+ 自訂 Authorization header → 觸發 CORS preflight OPTIONS → FAA 沒設 CORS + * → 405 → 下載死。 + * - 新作法:後端把 token 組進 download_url(`?access_token=...`),前端把 download_url 當「不透明可導航連結」, + * 用動態 `` + click 觸發**瀏覽器導航下載**。導航不是 fetch、無自訂 header → 無 preflight → 無 CORS。 + * 根因與決策見 ADR §11.1–§11.7。 * - * 兩步驟流程(ADR-017 決策 2 v1.2 實測流程): - * 1. `getModelDownload(modelId)`:打 visionA `GET /api/models/:id/download`, - * 回 `{ downloadUrl, token, expiresAt }`(visionA 已向 MC 簽好 opaque `fdt_` token)。 - * 2. `downloadModelFile(downloadUrl, token, filename)`:帶 `Authorization: Bearer {token}` - * 直接 GET `downloadUrl`(FAA),取 blob,建立 object URL + 動態 anchor click 觸發瀏覽器下載。 + * 兩步驟流程(ADR §11.8 frontend checklist): + * 1. `getModelDownload(modelId)`:打 visionA `GET /api/models/:id/download`(**同 origin、無 CORS**), + * 回 `{ downloadUrl, expiresAt }`。`downloadUrl` 後端已組好、含 `?access_token={fdt token}`,前端不碰 token。 + * 2. `triggerNavDownload(downloadUrl)`:動態建 `` + click 觸發瀏覽器下載。 * - * 錯誤分層: + * 錯誤分層(v1.3 縮小範圍): + * - 導航式下載**無法攔截 FAA 端 4xx/5xx**(瀏覽器導航不回 Response 給 JS)→ FAA 端錯誤前端偵測不到。 + * 錯誤處理只涵蓋「getModelDownload 階段」的 backend 4xx/5xx(同 origin、走 api wrapper)。 * - client 不翻譯成中文 — i18n 留在 store / UI(用 `error.code` 對應 `models.download.error.`)。 - * - backend 回的 code(model_not_found / forbidden / upload_not_supported / sign_failed …)原樣透出。 */ import { ApiError, api } from "@/lib/api"; @@ -30,11 +32,12 @@ import { ApiError, api } from "@/lib/api"; /** `GET /api/models/:id/download` 正規化後的回傳。 */ export interface ModelDownloadGrant { - /** FAA 直連下載 URL(跨 origin,如 https://stage-9527...:5081/files/models/{userID}/{jobID}.nef) */ + /** + * FAA 直連下載 URL(跨 origin,且**已含 `?access_token={fdt token}`**)。 + * 前端把它當「不透明可導航連結」直接丟給 ``,不解析、不附加 token。 + */ downloadUrl: string; - /** MC 簽的 opaque delegated download token(`fdt_...`);只用於下一步的 Authorization header */ - token: string; - /** ISO 8601 — token 過期時間 */ + /** ISO 8601 — token 過期時間(UI 可用來顯示「連結有效期」提示;第一階段未用)。 */ expiresAt: string; } @@ -46,11 +49,10 @@ export interface ModelDownloadGrant { * 模型下載專用錯誤。store / UI 用 `error.code` 對應 i18n key(`models.download.error.`)。 * * code 統一全小寫(對齊 conversion.ts `ConversionAPIError` 的命名規範,避免 UI 端做大小寫處理)。 - * 常見 code: + * v1.3 後 code 只來自 getModelDownload 階段(backend 4xx/5xx): * - `model_not_found`(404)/ `forbidden`(403)/ `upload_not_supported`(501,第一階段不支援上傳類) * - `sign_failed`(502,MC 簽 token 失敗) - * - `download_failed`(FAA GET 非 2xx;可能含 CORS 被擋 → network_error) - * - `network_error` / `timeout` / `aborted` / `parse_error`(client 端網路層) + * - `network_error` / `parse_error`(client 端 / 回應解析) */ export class ModelDownloadError extends Error { readonly status: number; @@ -85,14 +87,16 @@ function wrapError(err: unknown): ModelDownloadError { } /* -------------------------------------------------------------------------- */ -/* 1. GET /api/models/:id/download — 取 FAA 下載授權 */ +/* 1. GET /api/models/:id/download — 取 FAA 下載授權(同 origin、無 CORS) */ /* -------------------------------------------------------------------------- */ /** * 向 visionA backend 取得模型的 FAA delegated download 授權。 * * 走既有 `api.get` wrapper(自動帶 cookie session、解 envelope、ApiError mapping)。 - * 寬容讀取 snake_case / camelCase(download_url / downloadUrl 等)。 + * **同 origin(9527→9527)→ 無 CORS。** 寬容讀取 snake_case / camelCase(download_url / downloadUrl 等)。 + * + * 回傳的 `downloadUrl` 後端已組好、含 `?access_token=`,前端不需要、也不應該再碰 token。 * * @throws {ModelDownloadError} 404 model_not_found / 403 forbidden / * 501 upload_not_supported / 502 sign_failed / 其他網路層錯誤 @@ -107,127 +111,51 @@ export async function getModelDownload(modelId: string): Promise click → 觸發瀏覽器導航下載 */ /* -------------------------------------------------------------------------- */ /** - * 帶 `Authorization: Bearer {token}` 跨 origin 直連 FAA 下載檔案,並觸發瀏覽器存檔。 + * 動態建立 `` + click 觸發**瀏覽器導航下載**。 * - * 為什麼不能用 ``:browser navigation 無法帶自訂 header(Authorization), - * 而 FAA `TryReadAccessToken` 只認 `Authorization: Bearer`(不認 query / 自訂 header)。 - * → 必須 fetch → response.blob() → URL.createObjectURL → 動態 anchor click → revokeObjectURL。 + * 為什麼用 `` click 而非 `window.location.href`: + * - `` 語意明確「下載」;搭配 FAA 的 `Content-Disposition: attachment`,瀏覽器以下載處理、 + * **不會把目前 SPA 畫面導走**。`window.location.href` 在 header 缺失 / 瀏覽器差異時可能整頁導去 FAA、破壞 SPA。 * - * CORS 註記:FAA 端需允許 visionA 前端 origin(ADR-017 決策 2 Q3)。若 FAA 未設 CORS, - * 跨 origin fetch 會 throw `TypeError: Failed to fetch` → 落到 `network_error`(UI 顯示下載失敗)。 - * 這是 FAA 端設定問題,**前端 code 不為 CORS 改**。 + * 檔名(重要):跨 origin 導航時 `` 的 `xxx` 會被瀏覽器**忽略**(安全限制), + * 檔名由 FAA 的 `Content-Disposition` 決定。故這裡只設 `download`(空值)純粹表達「下載意圖」、不傳檔名。 * - * @param downloadUrl FAA 絕對 URL(來自 getModelDownload) - * @param token opaque `fdt_` token(只用於 Authorization header,不寫進 URL / log) - * @param filename 存檔檔名(如 `{jobID}.nef`) - * @throws {ModelDownloadError} download_failed(FAA 非 2xx)/ network_error / aborted + * 不需要 `URL.createObjectURL` / `URL.revokeObjectURL`:href 是 FAA 的真實 URL,不是 blob object URL。 + * + * ⚠️ 安全:`downloadUrl` 含 token(在 `?access_token=`)→ **絕不**把它印進 console / log / 錯誤訊息。 + * + * @param downloadUrl FAA 絕對 URL(來自 getModelDownload,已含 `?access_token=`) */ -export async function downloadModelFile( - downloadUrl: string, - token: string, - filename: string, - signal?: AbortSignal, -): Promise { - if (!downloadUrl || !token) { - throw new ModelDownloadError(0, "validation_failed", "downloadUrl and token are required"); +export function triggerNavDownload(downloadUrl: string): void { + if (!downloadUrl) { + throw new ModelDownloadError(0, "validation_failed", "downloadUrl is required"); } - - let res: Response; - try { - res = await fetch(downloadUrl, { - method: "GET", - headers: { Authorization: `Bearer ${token}` }, - // 跨 origin 直連 FAA:不帶 visionA cookie(FAA 用 delegated token 認證,cookie 無意義) - credentials: "omit", - signal, - }); - } catch (err) { - if (err instanceof Error && (err.name === "AbortError" || /aborted/i.test(err.message))) { - throw new ModelDownloadError(0, "aborted", "Download aborted"); - } - // TypeError: Failed to fetch — 通常是 CORS 被擋 / 連不上 FAA - throw new ModelDownloadError( - 0, - "network_error", - `Failed to reach FAA: ${err instanceof Error ? err.message : String(err)}`, - ); - } - - if (!res.ok) { - throw new ModelDownloadError( - res.status, - "download_failed", - `FAA download failed: HTTP ${res.status}`, - res.headers.get("X-Request-Id") ?? undefined, - ); - } - - const blob = await res.blob(); - triggerBlobDownload(blob, filename); -} - -/** - * 建立 object URL + 動態 anchor click 觸發瀏覽器存檔,完成後 revoke 避免記憶體洩漏。 - * - * 抽成獨立函式:便於測試(驗 anchor 屬性 / revoke 被呼叫)、也讓上面 fetch 流程更乾淨。 - */ -export function triggerBlobDownload(blob: Blob, filename: string): void { - const objectUrl = URL.createObjectURL(blob); - try { - const anchor = document.createElement("a"); - anchor.href = objectUrl; - anchor.download = filename; - anchor.rel = "noopener"; - // 不掛進 DOM 也能 click(現代瀏覽器支援),但部分 Firefox 版本需 append;保險起見 append + remove - document.body.appendChild(anchor); - anchor.click(); - document.body.removeChild(anchor); - } finally { - // 立即 revoke 可能在某些瀏覽器中斷下載,延遲一拍再 revoke - setTimeout(() => URL.revokeObjectURL(objectUrl), 1000); - } -} - -/* -------------------------------------------------------------------------- */ -/* Helper */ -/* -------------------------------------------------------------------------- */ - -/** - * 從 FAA download URL 推導存檔檔名(`.../models/{userID}/{jobID}.nef` → `{jobID}.nef`)。 - * - * 回退:URL 解析失敗 / 無副檔名時用 `{modelName}.nef`(modelName 已去除非法字元)。 - */ -export function deriveDownloadFilename(downloadUrl: string, modelName: string): string { - try { - // downloadUrl 可能含 query string;取 pathname 最後一段 - const path = new URL(downloadUrl).pathname; - const last = path.split("/").filter(Boolean).pop() ?? ""; - if (last && /\.[a-z0-9]+$/i.test(last)) { - return decodeURIComponent(last); - } - } catch { - // URL 解析失敗 → 走 fallback - } - const safe = (modelName || "model").replace(/[^\w.\-]+/g, "_"); - return safe.endsWith(".nef") ? safe : `${safe}.nef`; + const anchor = document.createElement("a"); + anchor.href = downloadUrl; + // 跨 origin 導航下檔名由 FAA Content-Disposition 決定;download 空值僅表達下載意圖。 + anchor.download = ""; + anchor.rel = "noopener"; + // 部分瀏覽器需 anchor 在 DOM 內才會觸發下載;append → click → remove。 + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); } diff --git a/visionA-frontend/src/stores/model-store.test.ts b/visionA-frontend/src/stores/model-store.test.ts index f27969a..fafffc0 100644 --- a/visionA-frontend/src/stores/model-store.test.ts +++ b/visionA-frontend/src/stores/model-store.test.ts @@ -5,6 +5,7 @@ import { ModelDownloadError } from "@/lib/api/model-download"; import { isModelDownloadable, useModelStore, type ModelSummary } from "./model-store"; // mock 下載 API client(store 的 downloadModel action 依賴它) +// v1.3:downloadModelFile 已移除,改 mock triggerNavDownload(導航觸發)。 vi.mock("@/lib/api/model-download", async () => { const actual = await vi.importActual( "@/lib/api/model-download", @@ -12,14 +13,14 @@ vi.mock("@/lib/api/model-download", async () => { return { ...actual, getModelDownload: vi.fn(), - downloadModelFile: vi.fn(), + triggerNavDownload: vi.fn(), }; }); -import { downloadModelFile, getModelDownload } from "@/lib/api/model-download"; +import { getModelDownload, triggerNavDownload } from "@/lib/api/model-download"; const mockGetModelDownload = vi.mocked(getModelDownload); -const mockDownloadModelFile = vi.mocked(downloadModelFile); +const mockTriggerNavDownload = vi.mocked(triggerNavDownload); function reset() { useModelStore.setState({ @@ -96,26 +97,19 @@ describe("downloadModel action(Phase 0.9)", () => { }); afterEach(() => vi.restoreAllMocks()); - it("happy path:取授權 → 下載 → 回 { ok: true },過程中 downloadingId = 該 model,結束清空", async () => { + it("happy path:取授權 → 用對的 download_url 觸發導航 → 回 { ok: true },結束清空 downloadingId", async () => { mockGetModelDownload.mockResolvedValue({ - downloadUrl: "https://faa/files/u/m1.nef", - token: "fdt_tok", + downloadUrl: "https://faa/files/u/m1.nef?access_token=fdt_tok", expiresAt: "2026-06-07T12:00:00Z", }); - mockDownloadModelFile.mockResolvedValue(undefined); - const promise = useModelStore.getState().downloadModel(convertedReady); - // action 同步階段已設 downloadingId - expect(useModelStore.getState().downloadingId).toBe("m1"); + const result = await useModelStore.getState().downloadModel(convertedReady); - const result = await promise; expect(result).toEqual({ ok: true }); expect(mockGetModelDownload).toHaveBeenCalledWith("m1"); - // 檔名從 URL path 推導(m1.nef) - expect(mockDownloadModelFile).toHaveBeenCalledWith( - "https://faa/files/u/m1.nef", - "fdt_tok", - "m1.nef", + // 導航用後端組好的完整 download_url(含 ?access_token=),前端不碰 token / 不 fetch。 + expect(mockTriggerNavDownload).toHaveBeenCalledWith( + "https://faa/files/u/m1.nef?access_token=fdt_tok", ); // 結束後清空 loading expect(useModelStore.getState().downloadingId).toBeNull(); @@ -150,17 +144,13 @@ describe("downloadModel action(Phase 0.9)", () => { expect(useModelStore.getState().downloadingId).toBeNull(); }); - it("downloadModelFile 拋 network_error(CORS)→ 回 network_error", async () => { - mockGetModelDownload.mockResolvedValue({ - downloadUrl: "https://faa/files/u/m1.nef", - token: "fdt_tok", - expiresAt: "2026-06-07T12:00:00Z", - }); - mockDownloadModelFile.mockRejectedValue( + it("getModelDownload 拋 network_error(backend 連不上)→ 回 network_error,不觸發導航", async () => { + mockGetModelDownload.mockRejectedValue( new ModelDownloadError(0, "network_error", "Failed to fetch"), ); const result = await useModelStore.getState().downloadModel(convertedReady); expect(result).toMatchObject({ ok: false, code: "network_error" }); + expect(mockTriggerNavDownload).not.toHaveBeenCalled(); expect(useModelStore.getState().downloadingId).toBeNull(); }); diff --git a/visionA-frontend/src/stores/model-store.ts b/visionA-frontend/src/stores/model-store.ts index a5fb303..e68133f 100644 --- a/visionA-frontend/src/stores/model-store.ts +++ b/visionA-frontend/src/stores/model-store.ts @@ -20,10 +20,9 @@ import { create } from "zustand"; import { ApiError, api } from "@/lib/api"; import { - deriveDownloadFilename, - downloadModelFile, getModelDownload, ModelDownloadError, + triggerNavDownload, } from "@/lib/api/model-download"; /* -------------------------------------------------------------------------- */ @@ -190,7 +189,8 @@ interface ModelState { /** 呼叫 `DELETE /api/models/:id` */ deleteModel: (id: string) => Promise; /** - * 下載模型檔(兩步:`GET /api/models/:id/download` 取 FAA 授權 → 帶 Bearer token 直連 FAA 取 blob)。 + * 下載模型檔(兩步:`GET /api/models/:id/download` 取 FAA 授權 → 用 `` 導航觸發瀏覽器下載)。 + * v1.3:download_url 後端已含 `?access_token=`,前端只導航、不碰 token、不 fetch、不 blob(解 CORS 405)。 * 回 `DownloadResult`,由 UI 決定顯示 toast / inline 錯誤(用 `code` 對應 i18n)。 */ downloadModel: (model: ModelSummary) => Promise; @@ -291,19 +291,22 @@ export const useModelStore = create()((set, get) => ({ set({ downloadingId: model.id }); try { + // 1. 同 origin 取授權(download_url 後端已組好、含 ?access_token=)。 const grant = await getModelDownload(model.id); - const filename = deriveDownloadFilename(grant.downloadUrl, model.name); - await downloadModelFile(grant.downloadUrl, grant.token, filename); + // 2. 用 導航觸發瀏覽器下載(無 fetch / 無 blob / 無自訂 header → 無 CORS)。 + // 導航是同步瞬間完成、瀏覽器接手後續傳輸,前端無法追蹤下載進度。 + triggerNavDownload(grant.downloadUrl); return { ok: true }; } catch (err) { // ModelDownloadError 帶 code(i18n 用);其他 Error 退化成 unknown。 + // 注意:FAA 端下載錯誤(4xx/5xx)導航式無法攔截,這裡只會收到 getModelDownload 階段的錯誤。 if (err instanceof ModelDownloadError) { return { ok: false, code: err.code, message: err.message }; } const message = err instanceof Error ? err.message : String(err); return { ok: false, code: "unknown", message }; } finally { - // 無論成功 / 失敗都要清掉 loading 狀態(避免按鈕卡在 disabled)。 + // 導航觸發是同步完成,立即清掉 loading 狀態(避免按鈕卡在 disabled)。 set({ downloadingId: null }); } },