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:
jim800121chen 2026-06-27 06:18:05 +08:00
parent c2f0b1549e
commit 3e45532f55
8 changed files with 327 additions and 314 deletions

View File

@ -57,6 +57,11 @@
- Phase 0.8 對前端 API → [`api/api-conversion.md`](api/api-conversion.md) - Phase 0.8 對前端 API → [`api/api-conversion.md`](api/api-conversion.md)
- 架構決策 → [`adr/adr-014-conversion-integration.md`](adr/adr-014-conversion-integration.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.3query-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. 前端資料流與狀態管理
- 見 §10本文件 - 見 §10本文件

View File

@ -1,7 +1,7 @@
# ADR-017: 模型庫存取架構File Access Agent 重設計) # 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-06v1.0/ 2026-06-06v1.1 修訂)/ 2026-06-07v1.2 修訂) 2026-06-06v1.0/ 2026-06-06v1.1 修訂)/ 2026-06-07v1.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 - `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` - FAA 自己用 `IDelegatedDownloadTokenValidator`=`MemberCenterDelegatedDownloadTokenValidator`)拿 token 打 MC validate`instanceOptions.TenantId` + `ObjectKey`(從 URL path 取)+ `Method=GET`
- `objectKey` 必須與簽 token 時的 `object_key` **完全一致**FAA validate boundary 檢查、不一致回 `object_key_mismatch`)。 - `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」
> **狀態**Accepted2026-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 決策 Atoken 放 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 的「pathobjectKey segment-escape+ querytoken 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 決策 CobjectKey / 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 範圍 | **單檔 + 單 methodGETboundary**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 的 120sQ2 已定 60300s
3. 已天然滿足opaque token + MC boundary + revoke 能力,無需額外開發。
4. **不需要**做更重的緩解(如 one-time token / IP 綁定)——與 FAA 設計範圍不符、第一階段過度工程。
### 11.7 決策 ECORS 殘留確認 — **整條鏈乾淨**
| 跳 | origin 關係 | CORS 風險 |
|----|-----------|-----------|
| 前端 → visionA backend `GET /api/models/:id/download` | **同 origin**9527→9527、走 `api.get`、帶 cookie | 無 |
| 前端 → FAA `GET /files/...?access_token=`(瀏覽器導航) | 跨 origin9527→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 blobloading 幾乎不可見——可保留作防重複點擊、但要意識到「下載進度」前端無法追蹤(瀏覽器接手)。
- [ ] `src/components/models/model-card.tsx`:下載按鈕邏輯大致不變(仍呼叫 `downloadModel`);但 toast「下載開始」語意仍成立導航觸發即視為開始
**Tests**
- [ ] `visionA-frontend/src/lib/api/model-download.test.ts`
- [ ] **刪除** `downloadModelFile` 整個 describefetch 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`)。其餘 case501/404/403/502不受影響。
**驗收(給 testing**
- [ ] 後端:`download_url` 結尾含 `?access_token=` + token 正確 escape。
- [ ] 前端:點下載 → 建立帶 FAA URL 的 `<a>` 並 click無 fetch 呼叫、無 CORS preflight
- [ ] e2estage實際點下載按鈕、瀏覽器發起對 FAA 的導航 GET、檔案落地檔名來自 FAA `Content-Disposition`)。
### 11.9 後果
**正面**:徹底解決 405 / CORSFAA 不需設 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 死碼(建議下次清理)。

View File

@ -450,14 +450,19 @@ func modelsDeleteHandler(deps Deps) gin.HandlerFunc {
} }
} }
// ModelDownloadResponse 是 GET /api/models/:id/download 的 response dataADR-017 (a) 決策 2 // ModelDownloadResponse 是 GET /api/models/:id/download 的 response data
// //
// Clientlocal-tool / browser拿到後帶 `Authorization: Bearer {Token}` 直接 // ADR-017 v1.3§11瀏覽器下載改 query-string token + redirect 導航,避開 CORS
// GET DownloadURL= {FAA}/files/{object_key})下載——不經 visionA、不經 AWS。 // preflight 405。後端組好「已含 ?access_token= 的完整 URL」決策 A 乙案Client
// 把 DownloadURL 當不透明連結直接導航下載——不經 visionA、不經 AWS。
type ModelDownloadResponse struct { type ModelDownloadResponse struct {
// DownloadURL 是 FAA 下載 URL`{FAABaseURL}/files/{object_key}`)。 // DownloadURL 是 FAA 下載 URL已含 `?access_token=``{FAABaseURL}/files/{object_key}?access_token={token}`)。
// 可直接瀏覽器導航下載,無需自行帶 header / 拼 queryADR-017 §11.3 乙案)。
DownloadURL string `json:"download_url"` DownloadURL string `json:"download_url"`
// Token 是 MC 簽的 opaque download tokenfdt_放 Authorization: Bearer。 // Token 是 MC 簽的 opaque download tokenfdt_
//
// DeprecatedADR-017 v1.3 §11.3token 已併入 DownloadURL 的 ?access_token=
// Client 不應再單獨使用本欄位。保留純為向下相容(舊 local-tool / 既有 consumer 可能仍讀)。
Token string `json:"token"` Token string `json:"token"`
// ExpiresAt 是 token 到期時間RFC3339 / UTCMC 沒回填時為零值(前端不應依賴)。 // ExpiresAt 是 token 到期時間RFC3339 / UTCMC 沒回填時為零值(前端不應依賴)。
ExpiresAt time.Time `json:"expires_at,omitempty"` ExpiresAt time.Time `json:"expires_at,omitempty"`
@ -545,9 +550,14 @@ func modelsDownloadHandler(deps Deps) gin.HandlerFunc {
return return
} }
// 組對外 download_url{FAABaseURL}/files/{object_key} // 組對外 download_url{FAABaseURL}/files/{object_key}?access_token={token}
// object_key 內含 '/'models/{userID}/{jobID}.nef需逐段 escape 但保留 '/'。 // 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 併入 URLFAA 原生支援 access_token
// query、§11.2),由後端用 url.QueryEscape 統一 escape對齊 FAA test page 的
// Uri.EscapeDataString。path 此時不含 query直接 append `?access_token=` 即可。
// 注意downloadURL含 token絕不寫進 logtoken 維持不洩漏原則)。
downloadURL := strings.TrimRight(deps.FAABaseURL, "/") + "/files/" + escapeFAAObjectKey(m.FAAObjectKey) +
"?access_token=" + url.QueryEscape(issued.Token)
logOrDefault(deps.Logger).Info("models: download token issued", logOrDefault(deps.Logger).Info("models: download token issued",
"model_id", m.ID, "model_id", m.ID,

View File

@ -106,7 +106,11 @@ func TestModelsDownload_OK(t *testing.T) {
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &sb)) require.NoError(t, json.Unmarshal(w.Body.Bytes(), &sb))
data := sb.Data.(map[string]any) 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 tokendeprecated、向下相容§11.3)。
assert.Equal(t, "fdt_abc123", data["token"]) assert.Equal(t, "fdt_abc123", data["token"])
assert.Contains(t, data["expires_at"], "2026-06-07") 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) assert.Equal(t, 1, iss.calls)
} }
// TestModelsDownload_TokenURLEscaped 驗 token 內含 query-string 特殊字元時,
// download_url 以 url.QueryEscape 正確 escapeaccess_token 值不會破壞 URL 結構)。
func TestModelsDownload_TokenURLEscaped(t *testing.T) {
// 刻意用含 '+' / '/' / '=' / ' ' 的 tokenfdt 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 — 未配置 / 上傳類 // 501 — 未配置 / 上傳類
// ========================================================================== // ==========================================================================

View File

@ -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 * 1. getModelDownload 200 download_url + expires_at/ 404 / 403 / 501 / 502 / parse
* 2. downloadModelFile happyfetch + Bearer blob anchor click/ FAA 2xx / CORS network_error / abort * 2. triggerNavDownload anchorhref = downloadUrl download click DOM
* 3. triggerBlobDownload anchor + revokeObjectURL * 3. ModelDownloadError
* 4. deriveDownloadFilename URL path / fallback *
* v1.3 ADR §11.8
* - downloadModelFilefetch + Bearer + blobCORS 405
* - deriveDownloadFilenameredirect origin FAA Content-Disposition
* - triggerBlobDownloadblob object URL triggerNavDownloadFAA URL createObjectURL
*/ */
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { import {
deriveDownloadFilename,
downloadModelFile,
getModelDownload, getModelDownload,
ModelDownloadError, ModelDownloadError,
triggerBlobDownload, triggerNavDownload,
} from "./model-download"; } from "./model-download";
function jsonResponse(body: unknown, status = 200): Response { function jsonResponse(body: unknown, status = 200): Response {
@ -41,13 +43,14 @@ afterEach(() => {
/* ========================================================================== */ /* ========================================================================== */
describe("getModelDownload", () => { describe("getModelDownload", () => {
it("200 → 正規化 { downloadUrl, token, expiresAt }snake_case", async () => { it("200 → 正規化 { downloadUrl, expiresAt }snake_casedownload_url 已含 ?access_token=", async () => {
fetchMock.mockResolvedValue( fetchMock.mockResolvedValue(
jsonResponse({ jsonResponse({
success: true, success: true,
data: { data: {
download_url: "https://faa.example.com:5081/files/models/u1/j1.nef", download_url:
token: "fdt_abc123", "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", expires_at: "2026-06-07T12:02:00Z",
}, },
}), }),
@ -55,10 +58,11 @@ describe("getModelDownload", () => {
const grant = await getModelDownload("m1"); const grant = await getModelDownload("m1");
expect(grant.downloadUrl).toBe( 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"); 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 () => { it("camelCase 也吃downloadUrl / expiresAt", async () => {
@ -66,15 +70,14 @@ describe("getModelDownload", () => {
jsonResponse({ jsonResponse({
success: true, success: true,
data: { data: {
downloadUrl: "https://faa/x.nef", downloadUrl: "https://faa/x.nef?access_token=fdt_x",
token: "fdt_x",
expiresAt: "2026-06-07T00:00:00Z", expiresAt: "2026-06-07T00:00:00Z",
}, },
}), }),
); );
const grant = await getModelDownload("m1"); const grant = await getModelDownload("m1");
expect(grant.downloadUrl).toBe("https://faa/x.nef"); expect(grant.downloadUrl).toBe("https://faa/x.nef?access_token=fdt_x");
expect(grant.token).toBe("fdt_x"); expect(grant.expiresAt).toBe("2026-06-07T00:00:00Z");
}); });
it("空 modelId → validation_failed不打 API", async () => { 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( fetchMock.mockResolvedValue(
jsonResponse({ jsonResponse({
success: true, success: true,
data: { download_url: "https://faa/x.nef" }, data: { expires_at: "2026-06-07T12:02:00Z" },
}), }),
); );
await expect(getModelDownload("m1")).rejects.toMatchObject({ await expect(getModelDownload("m1")).rejects.toMatchObject({
@ -115,168 +118,62 @@ describe("getModelDownload", () => {
}); });
/* ========================================================================== */ /* ========================================================================== */
/* 2. downloadModelFile */ /* 2. triggerNavDownload */
/* ========================================================================== */ /* ========================================================================== */
describe("downloadModelFile", () => { describe("triggerNavDownload", () => {
let createObjectURL: ReturnType<typeof vi.fn>; it("建立 anchor、href = 傳入的 downloadUrl、有 download 屬性、click 後從 DOM 移除", () => {
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 throwCORS / 連不上)→ 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 移除", () => {
const clickSpy = vi const clickSpy = vi
.spyOn(HTMLAnchorElement.prototype, "click") .spyOn(HTMLAnchorElement.prototype, "click")
.mockImplementation(() => {}); .mockImplementation(() => {});
let capturedDownload = "";
let capturedHref = ""; let capturedHref = "";
let hasDownloadAttr = false;
const appendSpy = vi const appendSpy = vi
.spyOn(document.body, "appendChild") .spyOn(document.body, "appendChild")
.mockImplementation((node) => { .mockImplementation((node) => {
const a = node as HTMLAnchorElement; const a = node as HTMLAnchorElement;
capturedDownload = a.download;
capturedHref = a.href; capturedHref = a.href;
hasDownloadAttr = a.hasAttribute("download");
return node; return node;
}); });
const removeSpy = vi const removeSpy = vi
.spyOn(document.body, "removeChild") .spyOn(document.body, "removeChild")
.mockImplementation((node) => node); .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).toBe(url);
expect(capturedHref).toContain("blob:trigger-url"); expect(hasDownloadAttr).toBe(true);
expect(clickSpy).toHaveBeenCalledOnce(); expect(clickSpy).toHaveBeenCalledOnce();
expect(appendSpy).toHaveBeenCalledOnce(); expect(appendSpy).toHaveBeenCalledOnce();
expect(removeSpy).toHaveBeenCalledOnce(); expect(removeSpy).toHaveBeenCalledOnce();
}); });
});
/* ========================================================================== */ it("不建立 blob object URL不呼叫 createObjectURL", () => {
/* 4. deriveDownloadFilename */ const createObjectURL = vi.fn();
/* ========================================================================== */ globalThis.URL.createObjectURL =
createObjectURL as unknown as typeof URL.createObjectURL;
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
describe("deriveDownloadFilename", () => { triggerNavDownload("https://faa/x.nef?access_token=fdt_x");
it("從 URL path 取最後一段含副檔名", () => {
expect( expect(createObjectURL).not.toHaveBeenCalled();
deriveDownloadFilename(
"https://faa.example.com:5081/files/models/u1/j1.nef",
"YOLOv5s",
),
).toBe("j1.nef");
}); });
it("URL 帶 query string 也只取 pathname 最後段", () => { it("空 downloadUrl → validation_failed不建立 anchor", () => {
expect( const clickSpy = vi
deriveDownloadFilename("https://faa/files/u/abc.nef?token=x", "name"), .spyOn(HTMLAnchorElement.prototype, "click")
).toBe("abc.nef"); .mockImplementation(() => {});
}); expect(() => triggerNavDownload("")).toThrowError(
expect.objectContaining({ code: "validation_failed" }),
it("path 無副檔名 → fallback 用 modelName.nef", () => {
expect(deriveDownloadFilename("https://faa/files/u/blob", "My Model")).toBe(
"My_Model.nef",
); );
}); expect(clickSpy).not.toHaveBeenCalled();
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");
}); });
}); });
/* ========================================================================== */ /* ========================================================================== */
/* ModelDownloadError 形狀 */ /* 3. ModelDownloadError 形狀 */
/* ========================================================================== */ /* ========================================================================== */
describe("ModelDownloadError", () => { describe("ModelDownloadError", () => {

View File

@ -2,24 +2,26 @@
* Model Download API ClientPhase 0.9 FAA delegated download * Model Download API ClientPhase 0.9 FAA delegated download
* *
* *
* - `docs/autoflow/04-architecture/adr/adr-017-model-library-access.md` §10.4 + 2 * - `docs/autoflow/04-architecture/adr/adr-017-model-library-access.md` §11v1.3query-string token + redirect
* - backend endpoint `GET /api/models/:id/download`commit c63886astage e2e * - backend endpoint `GET /api/models/:id/download`
* *
* conversion.ts `getConversionDownloadURL` * v1.3 v1.2 fetch + Authorization Bearer + blob
* - origin visionA backend `<a href>` browser navigation302 redirect * - `fetch(faaUrl, { headers: { Authorization: 'Bearer fdt_...' } }) → blob → anchor`
* - ** origin FAA stage-9527:5081+ `Authorization: Bearer {token}`** * origin95275081+ Authorization header CORS preflight OPTIONS FAA CORS
* browser navigation`<a href download>` / `window.location.href`** Authorization header** * 405
* `fetch(url, { headers: { Authorization } }) → blob → 動態 <a> 觸發下載` * - 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 * ADR §11.8 frontend checklist
* 1. `getModelDownload(modelId)` visionA `GET /api/models/:id/download` * 1. `getModelDownload(modelId)` visionA `GET /api/models/:id/download`** origin CORS**
* `{ downloadUrl, token, expiresAt }`visionA MC opaque `fdt_` token * `{ downloadUrl, expiresAt }``downloadUrl` `?access_token={fdt token}` token
* 2. `downloadModelFile(downloadUrl, token, filename)` `Authorization: Bearer {token}` * 2. `triggerNavDownload(downloadUrl)` `<a href={downloadUrl} download>` + click
* GET `downloadUrl`FAA blob object URL + anchor 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>` * - client i18n store / UI `error.code` `models.download.error.<code>`
* - backend codemodel_not_found / forbidden / upload_not_supported / sign_failed
*/ */
import { ApiError, api } from "@/lib/api"; import { ApiError, api } from "@/lib/api";
@ -30,11 +32,12 @@ import { ApiError, api } from "@/lib/api";
/** `GET /api/models/:id/download` 正規化後的回傳。 */ /** `GET /api/models/:id/download` 正規化後的回傳。 */
export interface ModelDownloadGrant { 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; downloadUrl: string;
/** MC 簽的 opaque delegated download token`fdt_...`);只用於下一步的 Authorization header */ /** ISO 8601 — token 過期時間UI 可用來顯示「連結有效期」提示;第一階段未用)。 */
token: string;
/** ISO 8601 — token 過期時間 */
expiresAt: string; expiresAt: string;
} }
@ -46,11 +49,10 @@ export interface ModelDownloadGrant {
* store / UI `error.code` i18n key`models.download.error.<code>` * store / UI `error.code` i18n key`models.download.error.<code>`
* *
* code conversion.ts `ConversionAPIError` UI * code conversion.ts `ConversionAPIError` UI
* code * v1.3 code getModelDownload backend 4xx/5xx
* - `model_not_found`404/ `forbidden`403/ `upload_not_supported`501 * - `model_not_found`404/ `forbidden`403/ `upload_not_supported`501
* - `sign_failed`502MC token * - `sign_failed`502MC token
* - `download_failed`FAA GET 2xx CORS network_error * - `network_error` / `parse_error`client /
* - `network_error` / `timeout` / `aborted` / `parse_error`client
*/ */
export class ModelDownloadError extends Error { export class ModelDownloadError extends Error {
readonly status: number; 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 * visionA backend FAA delegated download
* *
* `api.get` wrapper cookie session envelopeApiError mapping * `api.get` wrapper cookie session envelopeApiError mapping
* snake_case / camelCasedownload_url / downloadUrl * ** origin95279527 CORS** snake_case / camelCasedownload_url / downloadUrl
*
* `downloadUrl` `?access_token=` token
* *
* @throws {ModelDownloadError} 404 model_not_found / 403 forbidden / * @throws {ModelDownloadError} 404 model_not_found / 403 forbidden /
* 501 upload_not_supported / 502 sign_failed / * 501 upload_not_supported / 502 sign_failed /
@ -107,127 +111,51 @@ export async function getModelDownload(modelId: string): Promise<ModelDownloadGr
); );
const r = raw ?? {}; const r = raw ?? {};
const downloadUrl = String(r.download_url ?? r.downloadUrl ?? ""); const downloadUrl = String(r.download_url ?? r.downloadUrl ?? "");
const token = String(r.token ?? "");
const expiresAt = String(r.expires_at ?? r.expiresAt ?? ""); const expiresAt = String(r.expires_at ?? r.expiresAt ?? "");
if (!downloadUrl || !token) { if (!downloadUrl) {
throw new ModelDownloadError( throw new ModelDownloadError(
500, 500,
"parse_error", "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) { } catch (err) {
throw wrapError(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 headerAuthorization * `<a download href>` click `window.location.href`
* FAA `TryReadAccessToken` `Authorization: Bearer` query / header * - `<a download>` FAA `Content-Disposition: attachment`
* fetch response.blob() URL.createObjectURL anchor click revokeObjectURL * ** SPA **`window.location.href` header / FAA SPA
* *
* CORS FAA visionA originADR-017 2 Q3 FAA CORS * origin `<a download="xxx">` `xxx` ****
* origin fetch throw `TypeError: Failed to fetch` `network_error`UI * FAA `Content-Disposition` `download`
* FAA ** code CORS **
* *
* @param downloadUrl FAA URL getModelDownload * `URL.createObjectURL` / `URL.revokeObjectURL`href FAA URL blob object URL
* @param token opaque `fdt_` token Authorization header URL / log *
* @param filename `{jobID}.nef` * `downloadUrl` token `?access_token=` **** console / log /
* @throws {ModelDownloadError} download_failedFAA 2xx/ network_error / aborted *
* @param downloadUrl FAA URL getModelDownload `?access_token=`
*/ */
export async function downloadModelFile( export function triggerNavDownload(downloadUrl: string): void {
downloadUrl: string, if (!downloadUrl) {
token: string, throw new ModelDownloadError(0, "validation_failed", "downloadUrl is required");
filename: string,
signal?: AbortSignal,
): Promise<void> {
if (!downloadUrl || !token) {
throw new ModelDownloadError(0, "validation_failed", "downloadUrl and token are required");
} }
const anchor = document.createElement("a");
let res: Response; anchor.href = downloadUrl;
try { // 跨 origin 導航下檔名由 FAA Content-Disposition 決定download 空值僅表達下載意圖。
res = await fetch(downloadUrl, { anchor.download = "";
method: "GET", anchor.rel = "noopener";
headers: { Authorization: `Bearer ${token}` }, // 部分瀏覽器需 anchor 在 DOM 內才會觸發下載append → click → remove。
// 跨 origin 直連 FAA不帶 visionA cookieFAA 用 delegated token 認證cookie 無意義) document.body.appendChild(anchor);
credentials: "omit", anchor.click();
signal, document.body.removeChild(anchor);
});
} 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`;
} }

View File

@ -5,6 +5,7 @@ import { ModelDownloadError } from "@/lib/api/model-download";
import { isModelDownloadable, useModelStore, type ModelSummary } from "./model-store"; import { isModelDownloadable, useModelStore, type ModelSummary } from "./model-store";
// mock 下載 API clientstore 的 downloadModel action 依賴它) // mock 下載 API clientstore 的 downloadModel action 依賴它)
// v1.3downloadModelFile 已移除,改 mock triggerNavDownload導航觸發
vi.mock("@/lib/api/model-download", async () => { vi.mock("@/lib/api/model-download", async () => {
const actual = await vi.importActual<typeof import("@/lib/api/model-download")>( const actual = await vi.importActual<typeof import("@/lib/api/model-download")>(
"@/lib/api/model-download", "@/lib/api/model-download",
@ -12,14 +13,14 @@ vi.mock("@/lib/api/model-download", async () => {
return { return {
...actual, ...actual,
getModelDownload: vi.fn(), 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 mockGetModelDownload = vi.mocked(getModelDownload);
const mockDownloadModelFile = vi.mocked(downloadModelFile); const mockTriggerNavDownload = vi.mocked(triggerNavDownload);
function reset() { function reset() {
useModelStore.setState({ useModelStore.setState({
@ -96,26 +97,19 @@ describe("downloadModel actionPhase 0.9", () => {
}); });
afterEach(() => vi.restoreAllMocks()); afterEach(() => vi.restoreAllMocks());
it("happy path取授權 → 下載 → 回 { ok: true },過程中 downloadingId = 該 model結束清空", async () => { it("happy path取授權 → 用對的 download_url 觸發導航 → 回 { ok: true },結束清空 downloadingId", async () => {
mockGetModelDownload.mockResolvedValue({ mockGetModelDownload.mockResolvedValue({
downloadUrl: "https://faa/files/u/m1.nef", downloadUrl: "https://faa/files/u/m1.nef?access_token=fdt_tok",
token: "fdt_tok",
expiresAt: "2026-06-07T12:00:00Z", expiresAt: "2026-06-07T12:00:00Z",
}); });
mockDownloadModelFile.mockResolvedValue(undefined);
const promise = useModelStore.getState().downloadModel(convertedReady); const result = await useModelStore.getState().downloadModel(convertedReady);
// action 同步階段已設 downloadingId
expect(useModelStore.getState().downloadingId).toBe("m1");
const result = await promise;
expect(result).toEqual({ ok: true }); expect(result).toEqual({ ok: true });
expect(mockGetModelDownload).toHaveBeenCalledWith("m1"); expect(mockGetModelDownload).toHaveBeenCalledWith("m1");
// 檔名從 URL path 推導m1.nef // 導航用後端組好的完整 download_url含 ?access_token=),前端不碰 token / 不 fetch。
expect(mockDownloadModelFile).toHaveBeenCalledWith( expect(mockTriggerNavDownload).toHaveBeenCalledWith(
"https://faa/files/u/m1.nef", "https://faa/files/u/m1.nef?access_token=fdt_tok",
"fdt_tok",
"m1.nef",
); );
// 結束後清空 loading // 結束後清空 loading
expect(useModelStore.getState().downloadingId).toBeNull(); expect(useModelStore.getState().downloadingId).toBeNull();
@ -150,17 +144,13 @@ describe("downloadModel actionPhase 0.9", () => {
expect(useModelStore.getState().downloadingId).toBeNull(); expect(useModelStore.getState().downloadingId).toBeNull();
}); });
it("downloadModelFile 拋 network_errorCORS→ 回 network_error", async () => { it("getModelDownload 拋 network_errorbackend 連不上)→ 回 network_error不觸發導航", async () => {
mockGetModelDownload.mockResolvedValue({ mockGetModelDownload.mockRejectedValue(
downloadUrl: "https://faa/files/u/m1.nef",
token: "fdt_tok",
expiresAt: "2026-06-07T12:00:00Z",
});
mockDownloadModelFile.mockRejectedValue(
new ModelDownloadError(0, "network_error", "Failed to fetch"), new ModelDownloadError(0, "network_error", "Failed to fetch"),
); );
const result = await useModelStore.getState().downloadModel(convertedReady); const result = await useModelStore.getState().downloadModel(convertedReady);
expect(result).toMatchObject({ ok: false, code: "network_error" }); expect(result).toMatchObject({ ok: false, code: "network_error" });
expect(mockTriggerNavDownload).not.toHaveBeenCalled();
expect(useModelStore.getState().downloadingId).toBeNull(); expect(useModelStore.getState().downloadingId).toBeNull();
}); });

View File

@ -20,10 +20,9 @@ import { create } from "zustand";
import { ApiError, api } from "@/lib/api"; import { ApiError, api } from "@/lib/api";
import { import {
deriveDownloadFilename,
downloadModelFile,
getModelDownload, getModelDownload,
ModelDownloadError, ModelDownloadError,
triggerNavDownload,
} from "@/lib/api/model-download"; } from "@/lib/api/model-download";
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
@ -190,7 +189,8 @@ interface ModelState {
/** 呼叫 `DELETE /api/models/:id` */ /** 呼叫 `DELETE /api/models/:id` */
deleteModel: (id: string) => Promise<boolean>; deleteModel: (id: string) => Promise<boolean>;
/** /**
* `GET /api/models/:id/download` FAA Bearer token FAA blob * `GET /api/models/:id/download` FAA `<a>`
* v1.3download_url `?access_token=` token fetch blob CORS 405
* `DownloadResult` UI toast / inline `code` i18n * `DownloadResult` UI toast / inline `code` i18n
*/ */
downloadModel: (model: ModelSummary) => Promise<DownloadResult>; downloadModel: (model: ModelSummary) => Promise<DownloadResult>;
@ -291,19 +291,22 @@ export const useModelStore = create<ModelState>()((set, get) => ({
set({ downloadingId: model.id }); set({ downloadingId: model.id });
try { try {
// 1. 同 origin 取授權download_url 後端已組好、含 ?access_token=)。
const grant = await getModelDownload(model.id); const grant = await getModelDownload(model.id);
const filename = deriveDownloadFilename(grant.downloadUrl, model.name); // 2. 用 <a> 導航觸發瀏覽器下載(無 fetch / 無 blob / 無自訂 header → 無 CORS
await downloadModelFile(grant.downloadUrl, grant.token, filename); // 導航是同步瞬間完成、瀏覽器接手後續傳輸,前端無法追蹤下載進度。
triggerNavDownload(grant.downloadUrl);
return { ok: true }; return { ok: true };
} catch (err) { } catch (err) {
// ModelDownloadError 帶 codei18n 用);其他 Error 退化成 unknown。 // ModelDownloadError 帶 codei18n 用);其他 Error 退化成 unknown。
// 注意FAA 端下載錯誤4xx/5xx導航式無法攔截這裡只會收到 getModelDownload 階段的錯誤。
if (err instanceof ModelDownloadError) { if (err instanceof ModelDownloadError) {
return { ok: false, code: err.code, message: err.message }; return { ok: false, code: err.code, message: err.message };
} }
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
return { ok: false, code: "unknown", message }; return { ok: false, code: "unknown", message };
} finally { } finally {
// 無論成功 / 失敗都要清掉 loading 狀態(避免按鈕卡在 disabled // 導航觸發是同步完成,立即清掉 loading 狀態(避免按鈕卡在 disabled
set({ downloadingId: null }); set({ downloadingId: null });
} }
}, },