fix(model-download): 改 redirect/query-string token 下載,根除 CORS preflight 405
模型庫下載原本前端用 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 用 <a download href> 導航;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) <noreply@anthropic.com>
This commit is contained in:
parent
c2f0b1549e
commit
3e45532f55
@ -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(本文件)
|
||||
|
||||
|
||||
@ -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=<escaped 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:前端導航方式 — **推薦「動態 `<a download href>` click」**
|
||||
|
||||
| 方式 | 評估 |
|
||||
|------|------|
|
||||
| **動態 `<a href download>` + click** ★推薦 | 明確語意「下載」;`download` 屬性提示瀏覽器存檔而非導頁;同分頁不會把目前 SPA 畫面導走 |
|
||||
| `window.location.href = url` | 會嘗試把**目前分頁**導去該 URL。若 FAA 回 `Content-Disposition: attachment`,多數瀏覽器會轉成下載、不離開頁面;但若 header 缺失或瀏覽器行為差異,可能變成「整頁導去 FAA」破壞 SPA。風險較高 |
|
||||
|
||||
**推薦動態 `<a download href>`、理由**:跨 origin 時 `<a download>` 的 `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 導航時 `<a download="xxx">` 的 `xxx` **會被瀏覽器忽略**(跨 origin 安全限制)→ 前端設不設 `download` 屬性的檔名都不影響結果。
|
||||
- → **`deriveDownloadFilename` 在 redirect 方案下失去作用**(它原本是給 blob 方案命名用的;blob 是 same-origin object URL、`download` 屬性有效)。redirect 方案下檔名完全由 FAA 的 `Content-Disposition` 決定。
|
||||
- **建議**:移除 `deriveDownloadFilename` 的呼叫(store 不再需要),函式本身可留著標 deprecated 或一併刪(由 frontend agent 判斷,傾向刪以免死碼)。`<a>` 仍可設 `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 丟給 `<a href>`。
|
||||
|
||||
### 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)`:動態建 `<a href={downloadUrl} download rel="noopener">` + 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=<url.QueryEscape(fdt 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 的 `<a>` 並 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 死碼(建議下次清理)。
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 — 未配置 / 上傳類
|
||||
// ==========================================================================
|
||||
|
||||
@ -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<string, unknown>).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<typeof vi.fn>;
|
||||
let revokeObjectURL: ReturnType<typeof vi.fn>;
|
||||
let clickSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
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", () => {
|
||||
|
||||
@ -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、用 `<a href>` browser navigation(302 redirect)。
|
||||
* - 模型庫下載:**跨 origin 直連 FAA(如 stage-9527:5081)+ 必須帶 `Authorization: Bearer {token}`**。
|
||||
* → browser navigation(`<a href download>` / `window.location.href`)**無法帶 Authorization header**,
|
||||
* 所以一定要用 `fetch(url, { headers: { Authorization } }) → blob → 動態 <a> 觸發下載`。
|
||||
* ⚠️ 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 當「不透明可導航連結」,
|
||||
* 用動態 `<a download href>` + 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)`:動態建 `<a href={downloadUrl} download>` + 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.<code>`)。
|
||||
* - 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}`**)。
|
||||
* 前端把它當「不透明可導航連結」直接丟給 `<a href>`,不解析、不附加 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>`)。
|
||||
*
|
||||
* 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<ModelDownloadGr
|
||||
);
|
||||
const r = raw ?? {};
|
||||
const downloadUrl = String(r.download_url ?? r.downloadUrl ?? "");
|
||||
const token = String(r.token ?? "");
|
||||
const expiresAt = String(r.expires_at ?? r.expiresAt ?? "");
|
||||
if (!downloadUrl || !token) {
|
||||
if (!downloadUrl) {
|
||||
throw new ModelDownloadError(
|
||||
500,
|
||||
"parse_error",
|
||||
"download: missing download_url or token in response",
|
||||
"download: missing download_url in response",
|
||||
);
|
||||
}
|
||||
return { downloadUrl, token, expiresAt };
|
||||
return { downloadUrl, expiresAt };
|
||||
} catch (err) {
|
||||
throw wrapError(err);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* 2. fetch FAA + Bearer header + blob → 觸發瀏覽器下載 */
|
||||
/* 2. 動態 <a download href> click → 觸發瀏覽器導航下載 */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* 帶 `Authorization: Bearer {token}` 跨 origin 直連 FAA 下載檔案,並觸發瀏覽器存檔。
|
||||
* 動態建立 `<a download href={downloadUrl}>` + click 觸發**瀏覽器導航下載**。
|
||||
*
|
||||
* 為什麼不能用 `<a href download>`:browser navigation 無法帶自訂 header(Authorization),
|
||||
* 而 FAA `TryReadAccessToken` 只認 `Authorization: Bearer`(不認 query / 自訂 header)。
|
||||
* → 必須 fetch → response.blob() → URL.createObjectURL → 動態 anchor click → revokeObjectURL。
|
||||
* 為什麼用 `<a download href>` click 而非 `window.location.href`:
|
||||
* - `<a download>` 語意明確「下載」;搭配 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 導航時 `<a download="xxx">` 的 `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<void> {
|
||||
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.href = downloadUrl;
|
||||
// 跨 origin 導航下檔名由 FAA Content-Disposition 決定;download 空值僅表達下載意圖。
|
||||
anchor.download = "";
|
||||
anchor.rel = "noopener";
|
||||
// 不掛進 DOM 也能 click(現代瀏覽器支援),但部分 Firefox 版本需 append;保險起見 append + remove
|
||||
// 部分瀏覽器需 anchor 在 DOM 內才會觸發下載;append → click → 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`;
|
||||
}
|
||||
|
||||
@ -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<typeof import("@/lib/api/model-download")>(
|
||||
"@/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();
|
||||
});
|
||||
|
||||
|
||||
@ -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<boolean>;
|
||||
/**
|
||||
* 下載模型檔(兩步:`GET /api/models/:id/download` 取 FAA 授權 → 帶 Bearer token 直連 FAA 取 blob)。
|
||||
* 下載模型檔(兩步:`GET /api/models/:id/download` 取 FAA 授權 → 用 `<a>` 導航觸發瀏覽器下載)。
|
||||
* v1.3:download_url 後端已含 `?access_token=`,前端只導航、不碰 token、不 fetch、不 blob(解 CORS 405)。
|
||||
* 回 `DownloadResult`,由 UI 決定顯示 toast / inline 錯誤(用 `code` 對應 i18n)。
|
||||
*/
|
||||
downloadModel: (model: ModelSummary) => Promise<DownloadResult>;
|
||||
@ -291,19 +291,22 @@ export const useModelStore = create<ModelState>()((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. 用 <a> 導航觸發瀏覽器下載(無 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 });
|
||||
}
|
||||
},
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user