Compare commits
No commits in common. "90311535539efa0c5900a46a426869fbeac714c0" and "c595bb8b911f47b2fbec090aedcca3abed65bfae" have entirely different histories.
9031153553
...
c595bb8b91
@ -1,240 +0,0 @@
|
|||||||
# ADR-019: 影片 / 圖片 / 批次上傳走同機 localhost 直連 local-agent(混合路徑)
|
|
||||||
|
|
||||||
## 狀態
|
|
||||||
Proposed(尚未實作)。**已依 security review(`.autoflow/05-implementation/review/adr-019-security-review.md`)修正契約(C1 / M1 / M2 / M3 / 議題1),待 security confirm-only 複審 + 實作完成後轉 Accepted。** C1 定案採方案 A(route 分離),見 §2.4。
|
|
||||||
|
|
||||||
## 日期
|
|
||||||
2026-07-24
|
|
||||||
|
|
||||||
## 作者
|
|
||||||
Architect Agent
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 背景與範圍 (Context)
|
|
||||||
|
|
||||||
### 1.1 觸發問題
|
|
||||||
|
|
||||||
visionA 的 media 上傳(影片 / 圖片 / 批次)目前**全部經雲端 tunnel forward**(見 `api/api-spec.md §6`:「Camera / Media — 與 local-tool 相同,全部透過 tunnel forward」)。對影片這種大檔,此路徑有三個實質問題:
|
|
||||||
|
|
||||||
| 問題 | 說明 | 證據 |
|
|
||||||
|------|------|------|
|
|
||||||
| **頻寬雙倍佔用** | 500MB 影片經 tunnel = 上傳(本機→雲端)500MB + 結果下行(雲端→本機)500MB ≈ 1000MB 總傳輸,兩次都佔用使用者本地頻寬 | 第一輪資料流調查 §5.3 |
|
|
||||||
| **撞 nginx 上限** | stage nginx 兩處 `client_max_body_size 100M`,500MB 影片直接 413 | `docker/nginx.stage.conf:97, 417` |
|
|
||||||
| **api-server 300s timeout + stage 磁碟落地** | 即使調大 nginx,10 Mbps 上行需 400s > 300s timeout;且 `proxy_request_buffering` 預設 on,每次上傳在 stage 落地 500MB 暫存檔 | `visionA-backend/internal/api/proxy.go:32, 89-94`;`camera.go:47` |
|
|
||||||
|
|
||||||
### 1.2 被忽略的邏輯漏洞(本 ADR 修補的核心)
|
|
||||||
|
|
||||||
`design-doc.md §5.3`(模型上傳)早已建立原則:
|
|
||||||
|
|
||||||
> **「模型檔可達百 MB,走 tunnel = 占用使用者本地頻寬兩次(上傳→雲端→下載)」**,故模型上傳**不走 tunnel**(改 presigned URL 瀏覽器直連)。
|
|
||||||
|
|
||||||
**這個「大檔不走 tunnel」的原則從未套用到 media 上傳**——影片同樣是大檔、同樣佔用雙倍頻寬,卻仍走 tunnel。本 ADR 把同一原則延伸到 media,修補此文件內部邏輯漏洞。
|
|
||||||
|
|
||||||
### 1.3 有利前提(讀 code 後確認)
|
|
||||||
|
|
||||||
- local-agent 已有完整的 loopback HTTP server(Gin,強制綁 `127.0.0.1:<port>`,`local-agent/server/main.go:298-305`、`config.go:34-35,45`)。
|
|
||||||
- `POST /api/media/upload/video`(及 image / batch)**route 已存在於 local-agent,路徑與雲端完全一致**(`local-agent/server/internal/api/router.go:90-94`)。handler(`camera_handler.go:232-336`)與 tunnel **完全解耦**(tunnel client 只做透明轉發,`internal/tunnel/client.go:337-371`)——瀏覽器直連走的是同一個 handler、同一 code path,**handler 零改動**。
|
|
||||||
|
|
||||||
### 1.4 使用者已拍板的前提
|
|
||||||
|
|
||||||
- **跨機不存在**:使用者確認「都是同機操作」,故非同機情境採「停用該分頁 + 明確提示」,不做 tunnel fallback。這是**產品定位**,不是能力缺口。
|
|
||||||
- **瀏覽器範圍**:僅需支援 Chrome / Edge(Chromium 系)。Safari 不在支援範圍。
|
|
||||||
- **認證必做**:本機 endpoint 經既有 tunnel 下發 one-time token。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 決策 (Decision)
|
|
||||||
|
|
||||||
我們將讓瀏覽器在**同機情境**下,直接 POST 影片 / 圖片 / 批次到 local-agent 的 **localhost HTTP endpoint**,繞過雲端 tunnel。**控制面(token 取得)與結果面(MJPEG 串流、推論 WS)仍走雲端 tunnel**——這是「上傳走本機、結果走雲端」的**混合路徑**,不是把整條資料流搬到本機。
|
|
||||||
|
|
||||||
具體包含五塊決策:
|
|
||||||
|
|
||||||
### 2.1 混合路徑(只搬「上行大檔」,控制面 / 結果面不動)
|
|
||||||
|
|
||||||
- **改走 localhost**:影片 / 圖片 / 批次的**檔案上傳** POST。
|
|
||||||
- **仍走 tunnel(刻意保留)**:token 取得(控制面)、MJPEG 結果串流(`streamUrl` 是相對路徑 `/api/camera/stream`,前端用 `getApiBaseUrl()` 組雲端絕對路徑,`camera_handler.go:325`;`visionA-frontend/src/lib/camera.ts:35-42`)、推論結果 WS(`/ws/devices/:id/inference`)。
|
|
||||||
- 理由:MJPEG 結果資料量遠小於原始影片(15fps down-scaled JPEG),走 tunnel 成本可接受;強行把結果也搬本機需在前端維護兩套連線來源,複雜度大增。**保持「控制面 + 結果面走雲端、只有大檔上傳走 localhost」是風險最低的最小切口。**
|
|
||||||
|
|
||||||
### 2.2 範圍:影片 + 圖片 + 批次全部改走 localhost
|
|
||||||
|
|
||||||
- 三個分頁全部改走 localhost 直連(不只影片)。前端上傳抽象設計為 **endpoint 無關**(`uploadToLocalAgent(path, form, options)`),三個 caller 換 path 即可,不需重構。
|
|
||||||
- **批次額外補「合計大小檢查」**:現況 `validateBatchFiles`(`visionA-frontend/src/lib/media.ts:301-309`)只逐張檢查 ≤20MB、**不加總**——50 張各 19MB(合計 950MB)會通過前端驗證後撞 413。這是已上膛的地雷,須補合計上限(建議 `MAX_BATCH_TOTAL_BYTES = 80MB`,留 nginx 100M 的 20% 餘裕)。
|
|
||||||
|
|
||||||
### 2.3 同機偵測 + port 探測(前端掃描候選 port + 身分驗證)
|
|
||||||
|
|
||||||
- local-agent 的 port 是**動態**的(`pickPort` 從 3721 fallback 到 3740,`app.go:46-47, 1450-1468`),前端**不可寫死 3721**。
|
|
||||||
- 前端探測策略:`sessionStorage 快取` → `3721` → `3722–3740 並發`(`Promise.any` 取第一個成功者),每次 timeout 500ms。
|
|
||||||
- **探測動作本身就是同機偵測**:任一 port 回應且身分相符 = 同機;無回應 = 非同機。
|
|
||||||
- 新增專用探測 endpoint `GET /api/local/hello`(不用既有 `/api/system/health`——後者對非白名單 origin 的 GET 不回 ACA*,瀏覽器 JS 讀不到內容)。
|
|
||||||
- **身分驗證必做**:`/api/local/hello` 回傳 agent 管理的裝置 serial 的 **salted SHA-256 雜湊**(`SHA-256("visiona-local-v1" || serial)`,已裁決見 §5 / api-spec §6.3),前端重算比對 `selectedDevice.serialNumber` 是否相符。防止「同機跑著另一台 agent、影片被送到錯的裝置」這種靜默錯誤資料流。沿用 ADR-018 的 serial 路由慣例,不新造識別體系。
|
|
||||||
|
|
||||||
### 2.4 認證:經既有 tunnel 下發 one-time token(縱深防禦)
|
|
||||||
|
|
||||||
開放雲端 origin 打 local-agent 後,威脅模型從「只有本機 loopback 網頁能打」變成「同機任何網頁只要通過 origin 檢查就可能控制 Kneron 裝置」。**CORS 白名單擋不住 XSS(合法 origin)與 DNS rebinding(繞過 DNS 解析層)**,故必須有認證作為第二道防線。
|
|
||||||
|
|
||||||
流程(契約):
|
|
||||||
```
|
|
||||||
[1] 前端進影片分頁 → 向雲端要 token(經既有 OIDC 認證 + 既有 tunnel)
|
|
||||||
POST /api/devices/:serial/local-upload-ticket(新增雲端 endpoint)
|
|
||||||
→ api-server 驗 OIDC session + 裝置歸屬 → 經 tunnel 打 local-agent
|
|
||||||
POST /api/local/issue-token → 產 token 回傳
|
|
||||||
[2] 前端掃描找到 localhost port(§2.3)
|
|
||||||
[3] 前端 POST 影片 → http://127.0.0.1:<port>/api/local/media/upload/video ← 新的、一律要 token 的 route
|
|
||||||
Header: X-Visiona-Local-Token: <token>
|
|
||||||
[4] local-agent 驗 token(存在 / 未過期 / 未用過 / deviceId 相符)+ 驗 Host = loopback(§2.5)
|
|
||||||
→ 消耗 token → 內部轉呼叫既有 handler(handler 零改動)
|
|
||||||
```
|
|
||||||
|
|
||||||
Token 設計方向(已經 security agent 審,核心設計通過,見 review 議題 2):
|
|
||||||
- 生成 `crypto/rand` 32 bytes → base64url(**不准 `math/rand`**);TTL 120s;one-time(驗證後立即刪);綁 deviceId;記憶體 store(`map + sync.Mutex`,不持久化);上限同時 32 個未使用 token(防 DoS);比對用 `crypto/subtle.ConstantTimeCompare`(防 timing attack)。
|
|
||||||
- **實作時注意項(security review m2 / m3,不改契約但工程師必守)**:consume 與 issue 各自在**單一** `Lock()` 內完成(consume = 查存在 + ConstantTimeCompare + 刪除三步一次持鎖;issue = 查 len<32 + 插入一次持鎖),避免併發雙重消費 / 上限突破;token 驗證放在讀 multipart body(`FormFile`)**之前**(用 middleware 更佳),避免「未驗證就先收大檔」放大 DoS。
|
|
||||||
|
|
||||||
### 2.4.1 [C1 — 已定案:方案 A route 分離] 認證判準不得依賴攻擊者可控的 Origin
|
|
||||||
|
|
||||||
> **背景(security review C1,Critical blocker)**:原契約訂「來自 tunnel 的請求(無 Origin header)**不得**要求 token」,把「有無 Origin」當成「是不是可信 tunnel 來源」的唯一判準。但 Origin header 由請求發送方完全控制,**同機任何程序(curl / 惡意 App / 被入侵的其他本機服務)都能發出不帶 Origin 的 HTTP 請求到 `127.0.0.1:<port>`**(local-agent 綁 loopback、同機所有程序皆可連),等於「不帶 Origin 就免 token」的後門,整層 token 認證被繞過。
|
|
||||||
|
|
||||||
**查證結論(讀 `local-agent/visiona-agent/internal/tunnel/client.go`)**:tunnel client 的 `handleStream` 把收到的 HTTP request 直接 `http.DefaultTransport.RoundTrip(req)`、`req.URL.Host = c.localAddr`(= `127.0.0.1:<port>`,普通 HTTP over loopback,見 client.go:337-355、73)。tunnel **未在轉發時注入任何可辨識標記**,且 tunnel client(Wails app shell module `visiona-agent`)與 HTTP server(獨立子行程 module `server`)是**兩個獨立行程**。結論:**同機程序可完全偽裝成「tunnel 來的請求」**,server 端無法用 request 本身區分。
|
|
||||||
|
|
||||||
**定案:採方案 A(route 分離)**,不採方案 B(tunnel 注入 process-local secret)。理由:
|
|
||||||
1. tunnel client 與 HTTP server 是兩個獨立行程 / 獨立 Go module(見 client.go 檔頂註解),方案 B 的「process-local 共享 secret」實際上需要跨行程 IPC 傳遞 nonce,脆弱且增加耦合;方案 A 不需任何跨行程協調。
|
|
||||||
2. 方案 A 把「要不要 token」交給 **server 端固定的 route** 決定,不由攻擊者可控的 Origin 決定。
|
|
||||||
|
|
||||||
**契約鐵則(取代原「無 Origin 免 token」硬規則)**:
|
|
||||||
- **媒體上傳 endpoint 絕不能有「無任何憑證即放行」的 code path。** 刪除原「來自 tunnel 的請求(無 Origin)不得要求 token」的無條件放行語意。
|
|
||||||
- **瀏覽器 localhost 直連**走**新的、一律要求 token 的 route**:`POST /api/local/media/upload/{video|image|batch-images}`。此 route **不看 Origin、一律驗 token**(token 無效 / 缺失 → 401),並經 §2.5 Host 驗證。內部轉呼叫既有 handler(handler 零改動)。
|
|
||||||
- **既有 tunnel 轉發路徑**維持既有 `POST /api/media/upload/{video|image|batch-images}`(tunnel client 內部轉發至此,路徑不變、行為不變)。此路徑**不因本 ADR 新開放給任何雲端 origin**(CORS 白名單擴充只作用於直連情境的 `/api/local/*` 行為)。
|
|
||||||
|
|
||||||
**誠實揭露的殘留風險**:因 loopback 對同機程序無隔離,同機惡意程序仍可**直接**打舊 tunnel-path route(`/api/media/upload/*`)——但這是 **ADR-019 之前就存在的既有 loopback 攻擊面**(非本 ADR 新增),且舊 route 未被本 ADR 新開放給雲端 origin。緩解:舊 tunnel-path route 也套用 §2.5 Host 驗證(Host=loopback 才放行),把「同機直打舊 route」限制在同機、且不影響 tunnel 轉發(tunnel 轉發的 Host 本就是 `127.0.0.1:<port>`)。完整消除同機程序攻擊面超出本 ADR 範圍(需 OS 層 socket 權限 / peer credential 驗證),列為 backlog。
|
|
||||||
|
|
||||||
### 2.5 CORS 白名單擴充 + Host 驗證 + PNA 緩解(全部必做)
|
|
||||||
|
|
||||||
- Origin 白名單改為可設定(env `VISIONA_CLOUD_ORIGINS`,逗號分隔完整 origin),**stage 需同時允許雙入口**:
|
|
||||||
- `https://stage-9527.innovedus.com:9527`(公網 HTTPS,`nginx.stage.conf:86-89`)
|
|
||||||
- `http://192.168.0.130:9527`(**內網純 HTTP** 入口,`nginx.stage.conf:411-414`)
|
|
||||||
- (dev)`http://localhost:3000`
|
|
||||||
- loopback 舊規則(`middleware.go:17-22`)保留不動。
|
|
||||||
- **[M3 — 契約必做] 雲端 origin 採「完整 origin 精確比對」**(scheme+host+port **全等**逐字比對),**不可**沿用既有 `isAllowedOrigin` 的 hostname-only + 任意 port + 放寬 scheme 邏輯(`middleware.go:41-45`)。白名單裡存的是完整 origin 字串(如 `https://stage-9527.innovedus.com:9527`),比對時整串相等才通過。原因:既有 `allowedHosts` 只比 hostname、放任任意 port,若直接把雲端網域塞進去會變成「該網域任意 port 都放行」,攻擊面比預期大;放寬 scheme 若寫成「不檢查 scheme」則 `https://127.0.0.1`、`http://` 混用可繞過。故雲端 origin 必須**獨立於既有 loopback 邏輯**,走完整字串精確比對。
|
|
||||||
- **[M3 — 契約必做] `Access-Control-Allow-Credentials: false`(對雲端 origin)**:本路徑用 header(`X-Visiona-Local-Token`)帶 token、不需 cookie。**不可**沿用既有 middleware 對雲端 origin 回 `Allow-Credentials: true`(`middleware.go:97`)——那會無謂讓瀏覽器願意帶 credential、擴大 CSRF / 憑證面。雲端 origin 一律回 `false`(api-spec §6.4 已列)。
|
|
||||||
- **[M2 — 契約必做,從「待審 / optional」升為「必做」] Host header 驗證 = loopback**:對所有 `/api/local/*`(含新的 `/api/local/media/upload/*`)與舊 tunnel-path media route,加 Host 驗證 middleware——`Host` header(去掉 port 後)必須 ∈ `{127.0.0.1, localhost, ::1}`,否則 `400 Bad Request`。這是**獨立於 CORS 的第二道**、擋 DNS rebinding(rebinding 後 `Host` 為攻擊者網域 ≠ loopback → 400),成本低、對本機服務屬 baseline 而非 optional。tunnel 轉發的 Host 本就是 `127.0.0.1:<port>`(見 client.go:347)故不受影響。驗證邏輯(契約,不寫進 production code):
|
|
||||||
```
|
|
||||||
host := c.Request.Host // 含 port
|
|
||||||
h, _, _ := net.SplitHostPort(host) // 去 port(無 port 時退回原值)
|
|
||||||
if h ∉ {"127.0.0.1", "localhost", "::1"} → 400 Bad Request
|
|
||||||
```
|
|
||||||
- **PNA 緩解(必做,非 optional)**:對帶 `Access-Control-Request-Private-Network: true` 且通過白名單的 preflight,回 `Access-Control-Allow-Private-Network: true`。原因見 §4.3 R-1——即使只支援 Chrome/Edge,Chrome 未來可能把 PNA 從 warning 升為 blocking,而已安裝的舊版 local-agent 不會自動升級,此 header 現在補、成本 <30 分鐘,省未來一顆不定時炸彈。
|
|
||||||
- **preflight 帶 `Access-Control-Max-Age: 600`**(api-spec §6.4,緩解 port 掃描的大量 preflight)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 考慮過的替代方案 (Alternatives Considered)
|
|
||||||
|
|
||||||
| 方案 | 優點 | 缺點 | 排除原因 |
|
|
||||||
|------|------|------|---------|
|
|
||||||
| **維持 tunnel + 調大 nginx `client_max_body_size`** | 改動最小、不動前端架構 | 不解決頻寬雙倍;批次分頁仍是地雷;stage 每次落地 500MB 暫存檔;還需 IT 改公司邊界 nginx(`STAGE-DEPLOY.md:397-401` 待辦未完成);api-server 300s timeout 仍會炸 | 治標不治本,且引入「改 nginx→改 timeout→關 request buffering→改公司邊界 nginx」的連鎖,與「最小改動」背道而馳 |
|
|
||||||
| **傳檔案路徑給 local-agent(不傳檔案內容)** | 傳輸量 ~100 bytes | 瀏覽器基於安全**拿不到真實路徑**(`File.path` 已被所有現代瀏覽器移除);需在 local-agent 加業務 UI,違反 `server_control.go:1023-1033`「使用者不感知 server 存在」的既有決策;path traversal 風險 | 技術不可行(拿不到路徑)+ 違反既有架構方向 |
|
|
||||||
| **前端壓縮 / 抽 frame 後上傳(縮小檔案再走 tunnel)** | 沿用現有 tunnel 路徑 | 失真影響推論準確度;瀏覽器端影片處理相容性差、耗時;不解決架構問題(只是把大檔變小檔) | 犧牲推論品質,且沒有真正解決頻寬 / 落地問題 |
|
|
||||||
| **localhost 直連(採用)** | 傳輸量 1000MB→loopback(0.1–2.5s,改善約 100–1000 倍);不受 nginx / timeout 約束;handler 零改動 | 需同機操作;local-agent 新增對外攻擊面(需認證);PNA 未來風險 | ✅ 採用 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 後果 (Consequences)
|
|
||||||
|
|
||||||
### 4.1 正面影響
|
|
||||||
- **頻寬**:500MB 影片從「1000MB 經雲端 tunnel」降為「loopback 本機傳輸」(現代 SSD 筆電 0.1–0.5s、較舊機器 1–2.5s)。
|
|
||||||
- **不受基礎設施約束**:繞過 nginx 100M 上限、api-server 300s timeout、stage 磁碟落地。
|
|
||||||
- **handler 零改動**:local-agent 的 `UploadVideo` handler 一行不用改(route 已存在、與 tunnel 解耦)。
|
|
||||||
- **修補文件邏輯漏洞**:把 `design-doc.md §5.3` 的「大檔不走 tunnel」原則正確延伸到 media。
|
|
||||||
- **順帶修批次地雷**:補合計大小檢查,消除 50×19MB 撞 413 的隱患。
|
|
||||||
|
|
||||||
### 4.2 負面影響與限制(接受的取捨)
|
|
||||||
- **影片 / 圖片 / 批次上傳需同機操作**:必須在執行 visionA Agent 的那台電腦上操作。非同機時該分頁停用 + 提示。**Camera(即時攝影機)仍支援遠端檢視**——只有「上行大檔」需同機限制。
|
|
||||||
- 依使用者拍板「跨機不存在」,此限制不影響實際使用;且現況跨機使用者本來就傳不了 >100MB(413),此限制是「把隱性限制變成明確提示」,非新增限制。
|
|
||||||
- **不動 PRD**:使用者已明確跨機不存在,此限制在 ADR 記錄即可,不需 PM 改 PRD。
|
|
||||||
- **local-agent 新增對外攻擊面**:首次開放雲端 origin 直連 local-agent,需維護 CORS 白名單 + token 認證 + PNA header 的安全性。
|
|
||||||
- **僅支援 Chromium 系(Chrome / Edge)**:Safari 不支援(使用者已確認不需要)。
|
|
||||||
- **狀態分裂風險**:上傳走 localhost、結果走雲端 tunnel,兩條路徑可能不同步(見 §4.3 R-3)。
|
|
||||||
|
|
||||||
### 4.3 風險
|
|
||||||
| # | 風險 | 等級 | 緩解 |
|
|
||||||
|---|------|------|------|
|
|
||||||
| **R-1** | PNA 未來變硬阻擋,已安裝的舊版 local-agent 無法自動升級 → 影片功能無預警壞掉 | **高** | 第一版就加 `Access-Control-Allow-Private-Network: true`(§2.5,成本 <30min) |
|
|
||||||
| **R-3** | 狀態分裂:上傳走 localhost 成功,但結果 tunnel 剛好斷線 → 上傳成功卻看不到結果 | 中 | 上傳前檢查既有 `tunnel_online` 欄位(`visionA-backend/internal/api/devices.go:66-67`),離線時 disable 上傳 |
|
|
||||||
| **R-4** | 同機多個 visionA agent(多裝置場景),掃描命中錯的 agent | 中 | §2.3 serial 身分驗證;掃描收集所有回應再挑 serial 相符者 |
|
|
||||||
| **R-6** | 影片 / 圖片 / 批次上傳**完全無 size 上限** + temp 檔洩漏(`stopActivePipeline` 切 pipeline 時把 `videoPath=""` 但**不** `os.Remove`,batch 成功路徑後也未刪),反覆上傳可磁碟 DoS(配合 C1 未修時為 High) | **中→高** | **[M1 — 契約必做,非查證] 見 §4.3.1** |
|
|
||||||
|
|
||||||
### 4.3.1 [M1 — 契約必做] media 上傳 size 上限 + temp 檔清理
|
|
||||||
|
|
||||||
> security review M1:temp 檔洩漏**已確認為實錘**(非待查證)——`camera_handler.go` 的 `stopActivePipeline`(line 535)把 `h.videoPath = ""` 但從未對前一支影片 temp 檔 `os.Remove`;batch 路徑在 `NewMultiImageSource` 成功後也未追蹤刪除。結合無 size 上限 → 反覆上傳磁碟必爆。
|
|
||||||
|
|
||||||
**契約必做項(WP-2,非「查證」)**:
|
|
||||||
- **size 上限(各自值,必做)**:video / image / batch-images 各自的上傳 size 上限納入契約。
|
|
||||||
- **video 上限:與前端 90MB 對齊,硬上限不超過 500MB**(security 明確要求「不要因為走 loopback 就給 1GB」——攻擊面不因 loopback 縮小)。建議實作值 = 前端 90MB(正常上限);server 端 `http.MaxBytesReader` 硬牆設 ≤ 500MB 作為 DoS 上界。
|
|
||||||
- image / batch 單張沿用既有逐張限制;**batch 合計上限** = `MAX_BATCH_TOTAL_BYTES = 80MB`(§2.2,補 nginx 100M 的 20% 餘裕;此上限對 localhost 直連仍保留,作為資源上界)。
|
|
||||||
- 驗證時機:size 檢查與 token 驗證都放在讀 multipart body(`FormFile`)**之前**(review m3)。
|
|
||||||
- **temp 檔清理(必做)**:`stopActivePipeline` 切換 / 停止 pipeline 時對前一支 `videoPath` 補 `os.Remove`;batch 的 `MultiImageSource` 在生命週期結束時刪除其 `filePaths`。反覆上傳不得殘留 temp 檔(testing 寫回歸測試驗證)。
|
|
||||||
| **R-5** | 企業防火牆 / EDR 攔截 localhost HTTP | 低 | 明確錯誤訊息讓使用者知道要檢查什麼 |
|
|
||||||
|
|
||||||
> 註:第一輪風險表中的 R-2(Safari 相容性)因使用者確認僅支援 Chromium,已移除。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. security agent 裁決事項(已裁決)
|
|
||||||
|
|
||||||
依 Architect CLAUDE.md §14.3 邊界表,token / secret 管理與隱私洩露屬 security agent 審查範圍。以下兩點已經 security agent 審查裁決(`.autoflow/05-implementation/review/adr-019-security-review.md` 議題 1 / 議題 2),契約內容已依裁決更新:
|
|
||||||
|
|
||||||
| # | 議題 | 裁決結果(已落實於本 ADR / api-spec) |
|
|
||||||
|---|------|------------------|
|
|
||||||
| 1 | **`GET /api/local/hello` 回傳完整 serial 還是雜湊 serial?** | **已裁決:回「salted SHA-256 雜湊」,不回完整 serial。** `serialHashes[i] = SHA-256(公開固定 salt \|\| fullSerial)` 的 hex,salt 常數 = `visiona-local-v1`(前後端共用、非 server 私有,讓前端可獨立重算比對);`agentVersion` 粗化或移除(改回布林 `supportsLocalUpload: true` 已足夠);hello 不回任何其他欄位(最小揭露)。契約見 api-spec §6.3。殘留風險(序號熵低 + salt 公開 → 有心攻擊者可枚舉回推)security 判定「與威脅相稱、不需過度設計」(序號非 credential、控制裝置仍需 token+OIDC)。 |
|
|
||||||
| 2 | **one-time token 設計是否足夠 + DNS rebinding 完整緩解** | **已裁決:token 核心設計通過。** 附加要求已落實:(a)**C1 route 分離**(§2.4.1,token 認證不得被無 Origin 繞過,是 token 設計的前置 blocker);(b)**Host 驗證必做**(§2.5,rebinding 主防護,非 optional);(c)**CORS 完整 origin 精確比對 + Allow-Credentials: false**(§2.5);(d)token race 用 single-flight 持鎖(實作注意項 m2)。upload handler **不**需在內部再綁 Origin 二次驗證(CORS 已做、且避免與 C1 方案衝突)。 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 本 ADR 修正 / supersede 的內容
|
|
||||||
|
|
||||||
| 文件 | 章節 | 原內容 | 修正為 |
|
|
||||||
|------|------|--------|--------|
|
|
||||||
| `api/api-spec.md` | §6 Camera / Media | 「與 local-tool 相同,**全部透過 tunnel forward**」 | 影片 / 圖片 / 批次上傳走 localhost 直連;Camera 與推論結果串流仍走 tunnel(見本 ADR) |
|
|
||||||
| `design-doc.md` | §5.3 之後 | 「大檔不走 tunnel」原則只涵蓋模型上傳 | 新增一節說明 media 上傳套用同原則(localhost 直連),修補邏輯漏洞 |
|
|
||||||
|
|
||||||
> 註:本 ADR 不 supersede 任何既有 ADR,僅修正上述兩份設計文件的敘述。ADR-018(serial 路由)的識別慣例被本 ADR **沿用**(§2.3),無衝突。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 合規性
|
|
||||||
- [x] 與使用者確認方向(同機 localhost 直連 + 混合路徑 + 範圍含影片/圖片/批次 + 非同機停用 + 僅 Chromium + 認證必做)— ✅ 已裁決
|
|
||||||
- [x] **security agent 審 token / 隱私設計**(§5 兩點)— ✅ 已審(verdict: REQUEST CHANGES → 契約已依 C1/M1/M2/M3/議題1 修正,待 confirm-only 複審)
|
|
||||||
- [ ] backend agent 落地 local-agent CORS/PNA/token/endpoints + 雲端 ticket endpoint
|
|
||||||
- [ ] frontend agent 落地 port 探測抽象 + 影片分頁接線 + 批次合計檢查
|
|
||||||
- [ ] testing agent 回歸:既有 tunnel 路徑(無 Origin)不受 token 影響;跨瀏覽器(Chrome/Edge)驗證
|
|
||||||
- [ ] 成本影響:**無新雲端資源**(沿用現有 DB / tunnel),主要是三 module 開發工時,粗估 3.3–5.4 人天(見 §8 WP 清單);反而降低 stage 磁碟與頻寬壓力
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. WP 工作包清單(給 Orchestrator 排程)
|
|
||||||
|
|
||||||
> 完整依賴圖 / 關鍵路徑 / work-stream 同步點見 `.autoflow/04-architecture/video-localhost-wp-breakdown.md`(個人層)。此處為摘要。
|
|
||||||
|
|
||||||
> **本清單已依 security review 更新**(C1 route 分離、M1 size 上限 + temp 清理必做、M2 Host 驗證必做、M3 CORS 完整 origin 精確比對)。
|
|
||||||
|
|
||||||
| WP | 內容 | 負責 agent | 依賴 | 人時 |
|
|
||||||
|----|------|-----------|------|------|
|
|
||||||
| WP-0 | Spike:Chrome/Edge 實測 `https/http 入口 → http://127.0.0.1:3721` + PNA console warning | frontend | 無 | 2–4 |
|
|
||||||
| WP-1 | local-agent CORS / Host / PNA:雲端 origin **完整 origin 精確比對**(獨立於既有 hostname-only 邏輯,**不放寬既有 scheme 檢查**)+ **`Allow-Credentials: false`(雲端 origin)** + **Host header 驗證 = loopback(M2 必做,套用 `/api/local/*` 與舊 media route)** + PNA header + `Access-Control-Max-Age: 600` | backend(Go) | 契約 §2.5 | 5–7 |
|
|
||||||
| WP-2 | local-agent:**新 route `/api/local/media/upload/{video\|image\|batch-images}`(一律要 token、不看 Origin、內部轉呼叫既有 handler)** + `/api/local/hello`(回 salted SHA-256 serialHashes) + `/api/local/issue-token` + token store(single-flight 持鎖)+ token 驗證中介(放 `FormFile` 前)+ **media size 上限(video ≤500MB 硬牆 / batch 合計 80MB,M1 必做)** + **temp 檔清理(`stopActivePipeline` 補 `os.Remove` + batch 生命週期刪檔,M1 必做)** | backend(Go) | WP-1 | 9–13 |
|
|
||||||
| WP-3 | 前端:`lib/local-agent.ts`(port 探測並發+快取+timeout、同機判定用 salted SHA-256 比對 serialHashes、`uploadToLocalAgent()` endpoint 無關通用函式;上傳目標改新 route `/api/local/media/upload/*`) | frontend | 契約 §2.3/§6.3 | 6–10 |
|
|
||||||
| WP-4 | 前端:影片分頁接線(取 token、改上傳目標為 `/api/local/media/upload/video`、三種錯誤訊息 i18n、tunnel 離線檢查 R-3) | frontend | WP-3 | 4–6 |
|
|
||||||
| WP-5 | 雲端:`POST /api/devices/:serial/local-upload-ticket`(經 tunnel 轉發 issue-token) | backend(Go) | 契約 C-1 | 2–4 |
|
|
||||||
| WP-6(可選) | 圖片 + 批次接上 localhost 路徑 | frontend | WP-4 驗證通過 | 3–5 |
|
|
||||||
| WP-7(獨立,建議先做) | `validateBatchFiles` 加合計大小檢查 | frontend | 無 | 0.5–1 |
|
|
||||||
|
|
||||||
**推薦範圍(WP-0..WP-5 + WP-7)總計:26.5–43 小時 ≈ 3.3–5.4 人天。**
|
|
||||||
|
|
||||||
**關鍵路徑(串行、決定最短工期)**:契約定稿 → WP-0 → WP-1 → WP-2 → 整合 → E2E ≈ 14–22 小時。
|
|
||||||
**可平行**:WP-3(前端對契約 mock)與 WP-1/WP-2(backend)完全平行;WP-5(雲端 ticket)與 WP-1/WP-2 平行;WP-7 完全獨立。建議開 2–3 條 work-stream(frontend / local-agent backend / cloud backend),不宜再切。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. 一句話總結
|
|
||||||
|
|
||||||
影片 / 圖片 / 批次上傳從「經雲端 tunnel(頻寬雙倍 + 撞 nginx 100M + 300s timeout + stage 落地)」改為**同機瀏覽器直連 local-agent 的 localhost endpoint**(handler 零改動、route 已存在),修補 `design-doc.md §5.3`「大檔不走 tunnel」原則未套用到 media 的邏輯漏洞;採**混合路徑**(上傳走 localhost、控制面 + MJPEG 結果 + 推論 WS 仍走 tunnel);動態 port 用前端掃描 3721–3740 + serial 身分驗證偵測同機;認證用經既有 tunnel 下發的 one-time token(縱深防禦,擋 XSS / DNS rebinding);CORS 擴充雙入口白名單 + PNA header(防未來 Chrome 升級 blocking);非同機停用該分頁(產品定位「跨機不存在」,不做 fallback)。token / 隱私設計待 security agent 審。
|
|
||||||
@ -217,105 +217,17 @@
|
|||||||
|
|
||||||
## 6. Camera / Media
|
## 6. Camera / Media
|
||||||
|
|
||||||
**路徑分流(2026-07 起,見 ADR-019)**:Camera 與推論**結果**串流走雲端 tunnel forward;影片 / 圖片 / 批次的**檔案上傳**改走**同機 localhost 直連 local-agent**(繞過 tunnel,解決大檔頻寬雙倍 + nginx 100M + 300s timeout)。
|
與 local-tool 相同,全部透過 tunnel forward:
|
||||||
|
|
||||||
### 6.1 走 tunnel forward(與 local-tool 相同,不變)
|
|
||||||
|
|
||||||
### GET `/api/camera/list`
|
### GET `/api/camera/list`
|
||||||
### POST `/api/camera/start`
|
### POST `/api/camera/start`
|
||||||
### POST `/api/camera/stop`
|
### POST `/api/camera/stop`
|
||||||
### GET `/api/camera/stream` — MJPEG(透過 tunnel streaming)
|
### GET `/api/camera/stream` — MJPEG(透過 tunnel streaming)
|
||||||
### GET `/api/media/batch-images/:index` — 讀取批次結果(走 tunnel)
|
|
||||||
### POST `/api/media/seek` — 影片 seek(走 tunnel)
|
|
||||||
|
|
||||||
> Camera 支援遠端(跨機)檢視,這是產品核心價值,不受同機限制。
|
|
||||||
|
|
||||||
### 6.2 走 localhost 直連 local-agent(影片 / 圖片 / 批次上傳,見 ADR-019)
|
|
||||||
|
|
||||||
以下**上傳** endpoint 由瀏覽器在**同機情境**下直接 POST 到 local-agent 的 loopback server(`http://127.0.0.1:<port>`,port 動態 ∈ 3721–3740),**不經雲端 tunnel**。
|
|
||||||
|
|
||||||
> **[C1 — security review 定案:route 分離]** 瀏覽器 localhost 直連走**新的、一律要求 token 的** route(`/api/local/media/upload/*`),**不看 Origin**;既有 tunnel 轉發路徑維持 `/api/media/upload/*` 不變。認證判準由 server 端固定 route 決定,**不**由攻擊者可控的 Origin 決定(原「無 Origin 免 token」規則已刪除——見 ADR-019 §2.4.1)。
|
|
||||||
|
|
||||||
#### 瀏覽器 localhost 直連 route(新增、一律要 token)
|
|
||||||
### POST `/api/local/media/upload/image` — 直連 local-agent
|
|
||||||
### POST `/api/local/media/upload/video` — 直連 local-agent(大檔主要受益者)
|
|
||||||
### POST `/api/local/media/upload/batch-images` — 直連 local-agent
|
|
||||||
|
|
||||||
**上傳請求契約(瀏覽器 → local-agent)**:
|
|
||||||
- Target:`http://127.0.0.1:<port>/api/local/media/upload/{video|image|batch-images}`(port 由前端探測取得,見下方 §6.3)
|
|
||||||
- Header:`X-Visiona-Local-Token: <token>`(**一律要求,不看 Origin**;缺失 / 無效 → 401)
|
|
||||||
- Host 驗證:`Host` header(去 port)必須 ∈ `{127.0.0.1, localhost, ::1}`,否則 `400`(DNS rebinding 防護,見 §6.4)
|
|
||||||
- Body:既有 `multipart/form-data`(含 `deviceId` 欄位 + `file`),格式不變。此 route 內部轉呼叫既有 upload handler(handler 零改動)。
|
|
||||||
- **size 上限(必做,ADR-019 §4.3.1)**:video 硬牆 ≤ 500MB(前端正常上限 90MB);batch 合計 ≤ 80MB。size 與 token 驗證都在讀 body(`FormFile`)**之前**。
|
|
||||||
- 驗證失敗 → `401 { "success": false, "error": { "code": "LOCAL_TOKEN_INVALID" } }`
|
|
||||||
- 檔案過大 → `413 { "success": false, "error": { "code": "LOCAL_UPLOAD_TOO_LARGE" } }`
|
|
||||||
|
|
||||||
#### 既有 tunnel 轉發 route(保留不變,供雲端路徑)
|
|
||||||
### POST `/api/media/upload/image`
|
### POST `/api/media/upload/image`
|
||||||
### POST `/api/media/upload/video`
|
### POST `/api/media/upload/video`
|
||||||
### POST `/api/media/upload/batch-images`
|
### POST `/api/media/upload/batch-images`
|
||||||
> tunnel client 內部轉發至此,路徑 / 行為不變。此路徑**不因本 ADR 新開放給雲端 origin**;同機直打舊 route 仍受 §6.4 Host 驗證約束(Host=loopback)。
|
### GET `/api/media/batch-images/:index`
|
||||||
|
### POST `/api/media/seek`
|
||||||
> 推論**結果**(MJPEG 串流 `streamUrl` / 推論 WS `/ws/devices/:id/inference`)仍走雲端 tunnel。這是「上傳走 localhost、結果走雲端」的混合路徑(ADR-019 §2.1)。
|
|
||||||
|
|
||||||
### 6.3 localhost 直連的支援 endpoint(local-agent 新增,見 ADR-019)
|
|
||||||
|
|
||||||
以下 endpoint 為 localhost 直連機制所需,**僅存在於 local-agent 本機 server**,不對應雲端路由:
|
|
||||||
|
|
||||||
#### GET `/api/local/hello` — 同機偵測 + 身分驗證
|
|
||||||
- Auth:無(bootstrap 需要——拿 token 前需先知道 port;serial 以雜湊揭露、最小化 bootstrap 洩露,見下方裁決)
|
|
||||||
- Response 200:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"data": {
|
|
||||||
"serialHashes": ["<hex>", "..."],
|
|
||||||
"supportsLocalUpload": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
- **serialHashes 契約(security review 議題 1 裁決,必守)**:
|
|
||||||
- 每個元素 = `SHA-256(salt || fullSerial)` 的 **hex** 字串(lowercase hex)。演算法固定 **SHA-256**(非 MD5 / SHA-1;非 bcrypt/argon2——目的是「前端可重算比對」而非「防暴力還原」)。
|
|
||||||
- **salt = 固定公開常數 `visiona-local-v1`**(前後端共用寫死的 app-level 常數、**非** server 私有隨機值)。前端用 Web Crypto `crypto.subtle.digest('SHA-256', utf8("visiona-local-v1" + fullSerial))` 獨立重算比對。
|
|
||||||
- **明確不要做**:不得用 server 啟動時 `crypto/rand` 生成的私有 salt(前端算不出、破壞比對);不得 per-request 隨機 salt。
|
|
||||||
- 用途:前端掃描 3721–3740,比對 `SHA-256("visiona-local-v1" || selectedDevice.serialNumber) ∈ serialHashes` 判定同機(沿用 ADR-018 serial 路由慣例)。
|
|
||||||
- 誠實揭露殘留風險:序號熵低 + salt 公開 → 雜湊**不能**真正隱藏序號(可枚舉回推);它擋的是「順手抓完整序號」的被動洩露,非「防定向還原」。security 判定與威脅相稱(序號非 credential、控制裝置仍需 token+OIDC),不需過度設計。
|
|
||||||
- **`agentVersion` 已移除**(security review 議題 1:完整 build 版本 = 給攻擊者精準 CVE 對照,A06)。前端只需判斷「支不支援 local upload」→ `supportsLocalUpload: true` 布林已足夠。
|
|
||||||
- **最小揭露**:hello **不回**任何其他欄位(deviceId 明文 / 裝置型號 / driver 版本 / 機器名一律不回)。
|
|
||||||
- CORS:需回完整 ACA* + PNA header(`Access-Control-Allow-Private-Network: true`);Host 驗證 = loopback(§6.4)。
|
|
||||||
|
|
||||||
#### POST `/api/local/issue-token` — 產 one-time upload token
|
|
||||||
- 取得路徑:僅經既有 tunnel 由 api-server 轉發呼叫(api-server 已驗 OIDC session + 裝置歸屬,見下方 `/api/devices/:serial/local-upload-ticket`)。**不對瀏覽器 CORS 白名單開放**(非 `/api/local/media/upload/*` 直連 route);受 §6.4 Host 驗證約束。
|
|
||||||
- 註(C1 一致性):不再用「無 Origin 即放行」作判準(該反模式已於 ADR-019 §2.4.1 刪除)。token 只是縱深防禦一層——同機惡意程序的攻擊面由 C1 route 分離 + Host 驗證處理,issue-token 產出的 token 仍綁 deviceId + one-time + 120s TTL,即使被同機程序取得也僅能對已歸屬裝置發一次短期上傳。
|
|
||||||
- Request:`{ "serial": "string" }`
|
|
||||||
- Response 200:`{ "success": true, "data": { "token": "string", "expiresAt": <unix_ms>, "ttlSeconds": 120 } }`
|
|
||||||
- Response 429:token 上限(32 個未使用)已滿 → `{ "error": { "code": "LOCAL_TOKEN_LIMIT" } }`
|
|
||||||
- Token 設計見 ADR-019 §2.4,核心設計已經 security agent 審通過(review 議題 2)
|
|
||||||
|
|
||||||
#### POST `/api/devices/:serial/local-upload-ticket` — 雲端 ticket(新增雲端 endpoint)
|
|
||||||
- 位置:**api-server(雲端)**,非 local-agent
|
|
||||||
- 驗 OIDC session + 裝置歸屬 → 經既有 tunnel 打 local-agent 的 `/api/local/issue-token` → 回傳 token 給瀏覽器
|
|
||||||
- 這是 token 的取得路徑,走既有已認證的 tunnel(控制面)
|
|
||||||
|
|
||||||
### 6.4 CORS / Host 驗證 / PNA 要求(local-agent,見 ADR-019 §2.5)
|
|
||||||
|
|
||||||
- Origin 白名單來源:env `VISIONA_CLOUD_ORIGINS`(逗號分隔完整 origin),stage 預設含雙入口 `https://stage-9527.innovedus.com:9527` + `http://192.168.0.130:9527`(純 HTTP 內網入口)+ dev `http://localhost:3000`
|
|
||||||
- loopback 舊白名單(`middleware.go:17-22`)保留不動
|
|
||||||
- **[M3 — 必做] 雲端 origin 採「完整 origin 精確比對」**(scheme+host+port **全等**逐字比對),**不可**沿用既有 `isAllowedOrigin` 的 hostname-only + 任意 port + 放寬 scheme 邏輯。白名單存完整 origin 字串、整串相等才通過(否則會變成「該網域任意 port / 任意 scheme 都放行」)。
|
|
||||||
- Response(雲端 origin 通過):**`Access-Control-Allow-Credentials: false`(M3 必做,不可對雲端 origin 回 true)**、`Access-Control-Allow-Headers: Content-Type, X-Visiona-Local-Token`、`Access-Control-Max-Age: 600`、`Vary: Origin`
|
|
||||||
- **[M2 — 必做] Host header 驗證 = loopback**:對所有 `/api/local/*`(含 `/api/local/media/upload/*`)與舊 tunnel-path media route,`Host`(去 port)必須 ∈ `{127.0.0.1, localhost, ::1}`,否則 `400 Bad Request`(DNS rebinding 獨立第二道防護;tunnel 轉發的 Host 本就是 `127.0.0.1:<port>` 故不受影響)。
|
|
||||||
- **PNA(必做)**:preflight 帶 `Access-Control-Request-Private-Network: true` 且通過白名單 → 回 `Access-Control-Allow-Private-Network: true`
|
|
||||||
|
|
||||||
### 6.5 錯誤碼(跨模組統一)
|
|
||||||
|
|
||||||
| 錯誤碼 | HTTP | 情境 | 誰產生 |
|
|
||||||
|--------|------|------|--------|
|
|
||||||
| `LOCAL_AGENT_NOT_FOUND` | —(前端內部狀態) | 掃描無回應(非同機 / agent 沒跑) | frontend |
|
|
||||||
| `LOCAL_AGENT_MISMATCH` | —(前端內部狀態) | 掃描有回應但 serial 不符 | frontend |
|
|
||||||
| `LOCAL_TOKEN_INVALID` | 401 | token 不存在 / 過期 / 已使用 / 缺失(直連 route 一律要 token) | local-agent |
|
|
||||||
| `LOCAL_TOKEN_LIMIT` | 429 | 未使用 token 達 32 上限 | local-agent |
|
|
||||||
| `LOCAL_UPLOAD_TOO_LARGE` | 413 | 上傳超過 size 上限(video ≤500MB / batch 合計 ≤80MB,M1) | local-agent |
|
|
||||||
| (Host 驗證失敗) | 400 | `Host` header 非 loopback(DNS rebinding 防護,M2) | local-agent |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -312,32 +312,6 @@ POC 已完整實作此流程,visionA 直接沿用。
|
|||||||
- S3 presigned URL 讓瀏覽器直連,scalable
|
- S3 presigned URL 讓瀏覽器直連,scalable
|
||||||
- 需要時 api-server 再叫 local agent「下載這個 model 的 URL」,由 local agent 用自己網路下載
|
- 需要時 api-server 再叫 local agent「下載這個 model 的 URL」,由 local agent 用自己網路下載
|
||||||
|
|
||||||
### 5.3.1 media 上傳(影片 / 圖片 / 批次,**同機 localhost 直連、不走 tunnel**,見 ADR-019)
|
|
||||||
|
|
||||||
> **此節修補 §5.3 的邏輯漏洞**:§5.3 建立了「大檔不走 tunnel,因為走 tunnel = 占用使用者本地頻寬兩次」的原則,但此原則過去**只套用到模型上傳,未套用到 media**。影片同樣是大檔(可達 500MB),卻仍走 tunnel(頻寬雙倍 + 撞 nginx 100M + 300s timeout + stage 落地)。ADR-019 把同一原則延伸到 media。
|
|
||||||
|
|
||||||
```
|
|
||||||
[瀏覽器] 進影片分頁 → 掃描 127.0.0.1:3721..3740 找 local agent(同機偵測 + serial 身分驗證)
|
|
||||||
↓ 經既有 tunnel 向雲端要 one-time upload token(控制面走 tunnel)
|
|
||||||
[api-server] POST /api/devices/:serial/local-upload-ticket → 驗 OIDC + 歸屬 → 經 tunnel 打 local agent issue-token
|
|
||||||
↓ token 回瀏覽器
|
|
||||||
[瀏覽器] 直接 POST 影片到 http://127.0.0.1:<port>/api/local/media/upload/video(不走 tunnel)
|
|
||||||
Header: X-Visiona-Local-Token(新的、一律要 token 的直連 route,見 ADR-019 §2.4.1 C1 route 分離)
|
|
||||||
↓
|
|
||||||
[local HTTP server] 驗 token + 驗 Host=loopback → 內部轉呼叫既有 UploadVideo handler(零改動)→ 啟動推論 pipeline
|
|
||||||
↓ MJPEG 結果串流 / 推論 WS 結果 → 仍走雲端 tunnel(結果面走 tunnel)
|
|
||||||
[瀏覽器] 從雲端 tunnel 收到推論結果
|
|
||||||
```
|
|
||||||
|
|
||||||
**混合路徑(關鍵)**:只有「上行大檔」走 localhost,控制面(token)與結果面(MJPEG / 推論 WS)仍走雲端 tunnel。這是繼「§5.1 REST via tunnel」「§5.2 WS via tunnel」「§5.3 模型上傳 presigned 直連」之後的**第四種資料路徑**:**同機 localhost 直連**。
|
|
||||||
|
|
||||||
**為何走 localhost 而非 tunnel(延續 §5.3 原則)?**
|
|
||||||
- 500MB 影片經 tunnel = 上傳 500MB + 結果下行 ≈ 1000MB,兩次都佔使用者本地頻寬;改 loopback 後上傳成本趨近零(0.1–2.5s)
|
|
||||||
- 繞過 nginx `client_max_body_size 100M`、api-server 300s timeout、stage 磁碟落地
|
|
||||||
- local agent 端 media route 已存在、handler 與 tunnel 解耦,瀏覽器直連零改動
|
|
||||||
|
|
||||||
**限制(產品定位)**:需在執行 visionA Agent 的同一台電腦操作(非同機時該分頁停用 + 提示);僅支援 Chromium 系(Chrome/Edge)。Camera(即時攝影機)不受此限、仍支援遠端檢視——只有上行大檔需同機。認證與隱私設計細節見 ADR-019 §2.4 / §5(token / serial 隱私已經 security agent 審並依 C1/M1/M2/M3/議題1 修正契約)。
|
|
||||||
|
|
||||||
### 5.4 轉檔呼叫(Phase 1)
|
### 5.4 轉檔呼叫(Phase 1)
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@ -530,15 +530,6 @@ func (h *CameraHandler) stopActivePipeline() {
|
|||||||
if h.sourceType == camera.SourceCamera {
|
if h.sourceType == camera.SourceCamera {
|
||||||
h.cameraMgr.Close()
|
h.cameraMgr.Close()
|
||||||
}
|
}
|
||||||
// ADR-019 §4.3.1 M1:補刪前一支影片的 temp 檔,防磁碟 DoS。
|
|
||||||
//
|
|
||||||
// 為什麼要在這裡補:VideoSource.Close() 雖已 os.Remove(filePath),但 seek 流程用
|
|
||||||
// CloseWithoutRemove() 保留檔案供重新 seek,之後 h.videoPath 仍指向 temp 檔而
|
|
||||||
// activeSource 可能是不同的(或 nil)VideoSource。此處對 h.videoPath 明確補一次
|
|
||||||
// os.Remove 作為 belt-and-suspenders——已被刪過時第二次 Remove 是無害 no-op。
|
|
||||||
if h.videoPath != "" {
|
|
||||||
_ = os.Remove(h.videoPath)
|
|
||||||
}
|
|
||||||
h.activeSource = nil
|
h.activeSource = nil
|
||||||
h.sourceType = ""
|
h.sourceType = ""
|
||||||
h.videoPath = ""
|
h.videoPath = ""
|
||||||
|
|||||||
@ -1,167 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"visiona-agent/server/internal/device"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
// LocalSerialSalt 是 serial 雜湊的固定公開常數 salt(ADR-019 §2.3 / api-spec §6.3 議題 1 裁決)。
|
|
||||||
//
|
|
||||||
// 刻意「公開、固定、前後端共用寫死」——非 server 私有隨機值。
|
|
||||||
// 目的是讓前端能用 Web Crypto 獨立重算 SHA-256("visiona-local-v1" || serial) 比對,
|
|
||||||
// 而非「防暴力還原」(序號熵低、salt 公開時仍可枚舉回推,security 判定與威脅相稱)。
|
|
||||||
//
|
|
||||||
// 明確不要做:不得用 crypto/rand 私有 salt(前端算不出);不得 per-request 隨機 salt。
|
|
||||||
const LocalSerialSalt = "visiona-local-v1"
|
|
||||||
|
|
||||||
// deviceLister 抽象 device.Manager 的 ListDevices,方便測試注入。
|
|
||||||
type deviceLister interface {
|
|
||||||
ListDevices() []deviceInfoView
|
|
||||||
}
|
|
||||||
|
|
||||||
// deviceInfoView 是 hello 需要的最小 device 視圖(只要 serial)。
|
|
||||||
type deviceInfoView struct {
|
|
||||||
SerialNumber string
|
|
||||||
}
|
|
||||||
|
|
||||||
// managerAdapter 把 *device.Manager 轉成 deviceLister。
|
|
||||||
type managerAdapter struct {
|
|
||||||
mgr *device.Manager
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a managerAdapter) ListDevices() []deviceInfoView {
|
|
||||||
infos := a.mgr.ListDevices()
|
|
||||||
out := make([]deviceInfoView, 0, len(infos))
|
|
||||||
for _, info := range infos {
|
|
||||||
out = append(out, deviceInfoView{SerialNumber: info.SerialNumber})
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// fakeSerialNumber 是 pyusb-fallback placeholder,代表「沒有真實序號」。
|
|
||||||
// 與 device 套件保持一致(device.manager.go),不對它計 hash(無意義且會洩漏 placeholder)。
|
|
||||||
const fakeSerialNumber = "0x00000000"
|
|
||||||
|
|
||||||
// LocalHandler 提供 ADR-019 的本機直連支援 endpoint(hello / issue-token)。
|
|
||||||
type LocalHandler struct {
|
|
||||||
devices deviceLister
|
|
||||||
store localTokenStore
|
|
||||||
}
|
|
||||||
|
|
||||||
// localTokenStore 是 LocalHandler 依賴的 token store 介面(issue 用)。
|
|
||||||
// 對應 api.TokenStore,用介面避免 handlers → api 的反向依賴。
|
|
||||||
type localTokenStore interface {
|
|
||||||
Issue(deviceID string) (token string, expiresAt time.Time, err error)
|
|
||||||
IsLimitErr(err error) bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewLocalHandler 建立 LocalHandler。mgr 提供裝置序號、store 提供 token 發放。
|
|
||||||
func NewLocalHandler(mgr *device.Manager, store localTokenStore) *LocalHandler {
|
|
||||||
return &LocalHandler{
|
|
||||||
devices: managerAdapter{mgr: mgr},
|
|
||||||
store: store,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// hashSerial 計算 SHA-256(salt || fullSerial) 的 lowercase hex(ADR-019 §2.3)。
|
|
||||||
func hashSerial(serial string) string {
|
|
||||||
sum := sha256.Sum256([]byte(LocalSerialSalt + serial))
|
|
||||||
return hex.EncodeToString(sum[:])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hello 是 GET /api/local/hello — 同機偵測 + 身分驗證(bootstrap,無 token)。
|
|
||||||
//
|
|
||||||
// 回傳(最小揭露,api-spec §6.3):
|
|
||||||
// - serialHashes:每個 = SHA-256("visiona-local-v1" || fullSerial) hex
|
|
||||||
// - supportsLocalUpload:布林 true
|
|
||||||
//
|
|
||||||
// 明確不回:agentVersion、完整 serial、deviceId、機器名、任何其他欄位。
|
|
||||||
func (h *LocalHandler) Hello(c *gin.Context) {
|
|
||||||
infos := h.devices.ListDevices()
|
|
||||||
hashes := make([]string, 0, len(infos))
|
|
||||||
for _, info := range infos {
|
|
||||||
serial := strings.TrimSpace(info.SerialNumber)
|
|
||||||
// 跳過空 / fake placeholder 序號——無真實身分、hash 它只會洩漏 placeholder。
|
|
||||||
if serial == "" || strings.EqualFold(serial, fakeSerialNumber) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
hashes = append(hashes, hashSerial(serial))
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"success": true,
|
|
||||||
"data": gin.H{
|
|
||||||
"serialHashes": hashes,
|
|
||||||
"supportsLocalUpload": true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// issueTokenRequest 是 issue-token 的請求 body。
|
|
||||||
type issueTokenRequest struct {
|
|
||||||
Serial string `json:"serial"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// IssueToken 是 POST /api/local/issue-token — 產 one-time upload token。
|
|
||||||
//
|
|
||||||
// 取得路徑:僅經既有 tunnel 由 api-server 轉發呼叫(受 HostGuard 約束 = loopback)。
|
|
||||||
// 產出的 token 綁 deviceId(此處 = serial)+ one-time + 120s TTL。
|
|
||||||
// 達 32 上限 → 429 LOCAL_TOKEN_LIMIT。
|
|
||||||
//
|
|
||||||
// 稽核 log:記 deviceId + 成功/失敗,絕不 log token 明文。
|
|
||||||
func (h *LocalHandler) IssueToken(c *gin.Context) {
|
|
||||||
var req issueTokenRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": gin.H{
|
|
||||||
"code": "BAD_REQUEST", "message": "serial is required",
|
|
||||||
}})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
serial := strings.TrimSpace(req.Serial)
|
|
||||||
if serial == "" {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": gin.H{
|
|
||||||
"code": "BAD_REQUEST", "message": "serial is required",
|
|
||||||
}})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
token, expiresAt, err := h.store.Issue(serial)
|
|
||||||
if err != nil {
|
|
||||||
if h.store.IsLimitErr(err) {
|
|
||||||
// 稽核:達上限(不含 token)。
|
|
||||||
log.Printf("[local-token] issue REJECTED (limit) deviceId=%s ts=%s",
|
|
||||||
serial, time.Now().UTC().Format(time.RFC3339))
|
|
||||||
c.JSON(http.StatusTooManyRequests, gin.H{"success": false, "error": gin.H{
|
|
||||||
"code": "LOCAL_TOKEN_LIMIT", "message": "too many unused upload tokens",
|
|
||||||
}})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
log.Printf("[local-token] issue ERROR deviceId=%s ts=%s err=%v",
|
|
||||||
serial, time.Now().UTC().Format(time.RFC3339), err)
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": gin.H{
|
|
||||||
"code": "INTERNAL_ERROR", "message": "failed to issue token",
|
|
||||||
}})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 稽核:發放成功(deviceId + 時間,絕不記 token 明文)。
|
|
||||||
log.Printf("[local-token] issue OK deviceId=%s ts=%s",
|
|
||||||
serial, time.Now().UTC().Format(time.RFC3339))
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"success": true,
|
|
||||||
"data": gin.H{
|
|
||||||
"token": token,
|
|
||||||
"expiresAt": expiresAt.UnixMilli(),
|
|
||||||
"ttlSeconds": 120,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@ -1,234 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
}
|
|
||||||
|
|
||||||
// fakeDeviceLister 是 deviceLister 的測試替身。
|
|
||||||
type fakeDeviceLister struct {
|
|
||||||
serials []string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f fakeDeviceLister) ListDevices() []deviceInfoView {
|
|
||||||
out := make([]deviceInfoView, 0, len(f.serials))
|
|
||||||
for _, s := range f.serials {
|
|
||||||
out = append(out, deviceInfoView{SerialNumber: s})
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// fakeStore 是 localTokenStore 的測試替身。
|
|
||||||
type fakeStore struct {
|
|
||||||
token string
|
|
||||||
expiresAt time.Time
|
|
||||||
err error
|
|
||||||
isLimit bool
|
|
||||||
gotDevice string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeStore) Issue(deviceID string) (string, time.Time, error) {
|
|
||||||
f.gotDevice = deviceID
|
|
||||||
return f.token, f.expiresAt, f.err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeStore) IsLimitErr(err error) bool { return f.isLimit && err != nil }
|
|
||||||
|
|
||||||
// expectedHash 用測試獨立的實作重算 SHA-256(salt||serial) hex,
|
|
||||||
// 避免直接呼叫被測函式(防同一個 bug 同時存在於實作與預期)。
|
|
||||||
func expectedHash(serial string) string {
|
|
||||||
sum := sha256.Sum256([]byte("visiona-local-v1" + serial))
|
|
||||||
return hex.EncodeToString(sum[:])
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHello_SerialHashes:hello 回 serialHashes(正確 hex)+ supportsLocalUpload,
|
|
||||||
// 跳過空 / fake 序號,且不回 agentVersion / 完整 serial。
|
|
||||||
func TestHello_SerialHashes(t *testing.T) {
|
|
||||||
h := &LocalHandler{
|
|
||||||
devices: fakeDeviceLister{serials: []string{
|
|
||||||
"0x1A2B3C4D",
|
|
||||||
"", // 空 → 跳過
|
|
||||||
"0x00000000", // fake placeholder → 跳過
|
|
||||||
"0xDEADBEEF",
|
|
||||||
}},
|
|
||||||
}
|
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
c, _ := gin.CreateTestContext(w)
|
|
||||||
c.Request = httptest.NewRequest(http.MethodGet, "/api/local/hello", nil)
|
|
||||||
h.Hello(c)
|
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200", w.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
var resp struct {
|
|
||||||
Success bool `json:"success"`
|
|
||||||
Data struct {
|
|
||||||
SerialHashes []string `json:"serialHashes"`
|
|
||||||
SupportsLocalUpload bool `json:"supportsLocalUpload"`
|
|
||||||
} `json:"data"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
||||||
t.Fatalf("decode: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !resp.Success {
|
|
||||||
t.Error("success 應為 true")
|
|
||||||
}
|
|
||||||
if !resp.Data.SupportsLocalUpload {
|
|
||||||
t.Error("supportsLocalUpload 應為 true")
|
|
||||||
}
|
|
||||||
// 只應有兩個真實序號的 hash
|
|
||||||
if len(resp.Data.SerialHashes) != 2 {
|
|
||||||
t.Fatalf("serialHashes 數量 = %d, want 2(空與 fake 應被跳過)", len(resp.Data.SerialHashes))
|
|
||||||
}
|
|
||||||
wantSet := map[string]bool{
|
|
||||||
expectedHash("0x1A2B3C4D"): true,
|
|
||||||
expectedHash("0xDEADBEEF"): true,
|
|
||||||
}
|
|
||||||
for _, got := range resp.Data.SerialHashes {
|
|
||||||
if !wantSet[got] {
|
|
||||||
t.Errorf("非預期的 hash: %q", got)
|
|
||||||
}
|
|
||||||
// hex 必須是 lowercase、長度 64(SHA-256 = 32 bytes → 64 hex chars)
|
|
||||||
if len(got) != 64 {
|
|
||||||
t.Errorf("hash 長度 = %d, want 64", len(got))
|
|
||||||
}
|
|
||||||
if got != strings.ToLower(got) {
|
|
||||||
t.Errorf("hash 必須 lowercase hex,got %q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 最小揭露:不得出現 agentVersion / 完整 serial 明文
|
|
||||||
bodyStr := w.Body.String()
|
|
||||||
if strings.Contains(bodyStr, "agentVersion") {
|
|
||||||
t.Error("hello 不應回 agentVersion")
|
|
||||||
}
|
|
||||||
if strings.Contains(bodyStr, "0x1A2B3C4D") || strings.Contains(bodyStr, "0xDEADBEEF") {
|
|
||||||
t.Error("hello 不應回完整 serial 明文")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHello_EmptyDevices:無裝置 → serialHashes 為空陣列(非 null)。
|
|
||||||
func TestHello_EmptyDevices(t *testing.T) {
|
|
||||||
h := &LocalHandler{devices: fakeDeviceLister{serials: nil}}
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
c, _ := gin.CreateTestContext(w)
|
|
||||||
c.Request = httptest.NewRequest(http.MethodGet, "/api/local/hello", nil)
|
|
||||||
h.Hello(c)
|
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200", w.Code)
|
|
||||||
}
|
|
||||||
if !strings.Contains(w.Body.String(), `"serialHashes":[]`) {
|
|
||||||
t.Errorf("空裝置應回 serialHashes:[],got %s", w.Body.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHashSerial_Contract 直接驗證被測 hashSerial 的字串拼接 / 編碼 / hex 大小寫
|
|
||||||
// 與前端逐 byte 一致性複核需要的契約:SHA-256("visiona-local-v1"||serial) lowercase hex。
|
|
||||||
func TestHashSerial_Contract(t *testing.T) {
|
|
||||||
serial := "0x1A2B3C4D"
|
|
||||||
got := hashSerial(serial)
|
|
||||||
want := expectedHash(serial)
|
|
||||||
if got != want {
|
|
||||||
t.Errorf("hashSerial(%q) = %q, want %q", serial, got, want)
|
|
||||||
}
|
|
||||||
// 明確固定一個已知向量,供前端對照(salt+serial 直接字串相接、UTF-8、SHA-256、lowercase hex)
|
|
||||||
// echo -n "visiona-local-v10x1A2B3C4D" | shasum -a 256
|
|
||||||
if len(got) != 64 || got != strings.ToLower(got) {
|
|
||||||
t.Errorf("hex 格式不符:len=%d lower=%v", len(got), got == strings.ToLower(got))
|
|
||||||
}
|
|
||||||
if LocalSerialSalt != "visiona-local-v1" {
|
|
||||||
t.Errorf("LocalSerialSalt = %q, want visiona-local-v1(前後端共用常數)", LocalSerialSalt)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestIssueToken_Success:正常發放 → 200 + token/expiresAt/ttlSeconds,deviceId 綁 serial。
|
|
||||||
func TestIssueToken_Success(t *testing.T) {
|
|
||||||
exp := time.UnixMilli(1_700_000_000_000)
|
|
||||||
store := &fakeStore{token: "tok-xyz", expiresAt: exp}
|
|
||||||
h := &LocalHandler{store: store}
|
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
c, _ := gin.CreateTestContext(w)
|
|
||||||
c.Request = httptest.NewRequest(http.MethodPost, "/api/local/issue-token",
|
|
||||||
strings.NewReader(`{"serial":"0xAAAA0001"}`))
|
|
||||||
c.Request.Header.Set("Content-Type", "application/json")
|
|
||||||
h.IssueToken(c)
|
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200 (body=%s)", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
var resp struct {
|
|
||||||
Data struct {
|
|
||||||
Token string `json:"token"`
|
|
||||||
ExpiresAt int64 `json:"expiresAt"`
|
|
||||||
TTLSeconds int `json:"ttlSeconds"`
|
|
||||||
} `json:"data"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
||||||
t.Fatalf("decode: %v", err)
|
|
||||||
}
|
|
||||||
if resp.Data.Token != "tok-xyz" {
|
|
||||||
t.Errorf("token = %q, want tok-xyz", resp.Data.Token)
|
|
||||||
}
|
|
||||||
if resp.Data.ExpiresAt != exp.UnixMilli() {
|
|
||||||
t.Errorf("expiresAt = %d, want %d", resp.Data.ExpiresAt, exp.UnixMilli())
|
|
||||||
}
|
|
||||||
if resp.Data.TTLSeconds != 120 {
|
|
||||||
t.Errorf("ttlSeconds = %d, want 120", resp.Data.TTLSeconds)
|
|
||||||
}
|
|
||||||
if store.gotDevice != "0xAAAA0001" {
|
|
||||||
t.Errorf("Issue deviceID = %q, want 0xAAAA0001(token 綁 serial)", store.gotDevice)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestIssueToken_Limit:達上限 → 429 LOCAL_TOKEN_LIMIT。
|
|
||||||
func TestIssueToken_Limit(t *testing.T) {
|
|
||||||
store := &fakeStore{err: errors.New("limit"), isLimit: true}
|
|
||||||
h := &LocalHandler{store: store}
|
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
c, _ := gin.CreateTestContext(w)
|
|
||||||
c.Request = httptest.NewRequest(http.MethodPost, "/api/local/issue-token",
|
|
||||||
strings.NewReader(`{"serial":"0xAAAA0001"}`))
|
|
||||||
c.Request.Header.Set("Content-Type", "application/json")
|
|
||||||
h.IssueToken(c)
|
|
||||||
|
|
||||||
if w.Code != http.StatusTooManyRequests {
|
|
||||||
t.Fatalf("status = %d, want 429", w.Code)
|
|
||||||
}
|
|
||||||
if !strings.Contains(w.Body.String(), "LOCAL_TOKEN_LIMIT") {
|
|
||||||
t.Errorf("body 應含 LOCAL_TOKEN_LIMIT,got %s", w.Body.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestIssueToken_MissingSerial:缺 serial → 400。
|
|
||||||
func TestIssueToken_MissingSerial(t *testing.T) {
|
|
||||||
h := &LocalHandler{store: &fakeStore{}}
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
c, _ := gin.CreateTestContext(w)
|
|
||||||
c.Request = httptest.NewRequest(http.MethodPost, "/api/local/issue-token",
|
|
||||||
strings.NewReader(`{}`))
|
|
||||||
c.Request.Header.Set("Content-Type", "application/json")
|
|
||||||
h.IssueToken(c)
|
|
||||||
|
|
||||||
if w.Code != http.StatusBadRequest {
|
|
||||||
t.Fatalf("status = %d, want 400", w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,97 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ADR-019 §2.4.1 + §4.3.1:本機直連 upload route 的 token 驗證 + size 上限中介。
|
|
||||||
//
|
|
||||||
// size 上限(M1,各 route 自己的值)。video 硬牆 ≤ 500MB(前端正常上限 90MB,
|
|
||||||
// 但 server 端硬牆設 500MB 作為 DoS 上界——攻擊面不因走 loopback 而縮小)。
|
|
||||||
// batch 合計 ≤ 80MB。image 沿用 batch 上界即可(單檔遠小於此)。
|
|
||||||
const (
|
|
||||||
maxVideoUploadBytes = 500 * 1024 * 1024 // 500MB
|
|
||||||
maxBatchUploadBytes = 80 * 1024 * 1024 // 80MB(合計)
|
|
||||||
maxImageUploadBytes = 80 * 1024 * 1024 // 80MB(單檔,寬鬆上界)
|
|
||||||
)
|
|
||||||
|
|
||||||
// tokenConsumer 抽象 TokenStore.Consume,方便測試注入。
|
|
||||||
type tokenConsumer interface {
|
|
||||||
Consume(token, deviceID string) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// LocalUploadGuard 是本機直連 upload route 的中介,順序如下(安全關鍵):
|
|
||||||
//
|
|
||||||
// 1. 先要求 X-Visiona-Local-Token header——缺失即 401(一律要 token、不看 Origin,C1)。
|
|
||||||
// 2. 用 http.MaxBytesReader 把 request body 包上 maxBytes 硬牆——
|
|
||||||
// 在讀取 multipart body 之前就限制總位元組,避免「未驗證就先收無上限大檔」(M1)。
|
|
||||||
// 3. 解析出 deviceId(PostForm 觸發 multipart 解析,但已被 MaxBytesReader 上限保護)。
|
|
||||||
// 若超過上限 → ParseMultipartForm 回 *http.MaxBytesError → 413 LOCAL_UPLOAD_TOO_LARGE。
|
|
||||||
// 4. Consume(token, deviceId)——single-flight 持鎖(查存在+比對+刪除同一 Lock,防 race,m2);
|
|
||||||
// deviceId 綁定不符 / 過期 / 已用 / 不存在 → 401 LOCAL_TOKEN_INVALID。
|
|
||||||
// 5. 通過 → c.Next() 進既有 handler(handler 業務邏輯零改動、直接 FormFile 讀已快取的表單)。
|
|
||||||
//
|
|
||||||
// 稽核 log:consume 成功/失敗記 deviceId + 時間,絕不 log token 明文。
|
|
||||||
//
|
|
||||||
// 為什麼 deviceId 取自表單而非 token:token 在 issue 時已綁 deviceId,Consume 會用
|
|
||||||
// ConstantTimeCompare 驗證「表單 deviceId == token 綁定 deviceId」,兩者不符即 401。
|
|
||||||
// 表單 deviceId 是既有 handler 本來就讀的欄位(api-spec §6.2 body 格式不變)。
|
|
||||||
func LocalUploadGuard(store tokenConsumer, maxBytes int64) gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
token := c.GetHeader("X-Visiona-Local-Token")
|
|
||||||
if token == "" {
|
|
||||||
respondTokenInvalid(c)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// M1:body 硬牆。放在解析 multipart 之前。
|
|
||||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxBytes)
|
|
||||||
|
|
||||||
// 觸發 multipart 解析取 deviceId。body 已被 MaxBytesReader 上限保護。
|
|
||||||
// 解析錯誤要區分「超過 size 上限(413)」與「其他 400」。
|
|
||||||
if err := c.Request.ParseMultipartForm(32 << 20); err != nil {
|
|
||||||
var maxErr *http.MaxBytesError
|
|
||||||
if errors.As(err, &maxErr) {
|
|
||||||
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"success": false, "error": gin.H{
|
|
||||||
"code": "LOCAL_UPLOAD_TOO_LARGE", "message": "upload exceeds size limit",
|
|
||||||
}})
|
|
||||||
c.Abort()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": gin.H{
|
|
||||||
"code": "BAD_REQUEST", "message": "invalid multipart form",
|
|
||||||
}})
|
|
||||||
c.Abort()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
deviceID := c.Request.FormValue("deviceId")
|
|
||||||
|
|
||||||
if err := store.Consume(token, deviceID); err != nil {
|
|
||||||
// 稽核:consume 失敗(deviceId + 時間,不含 token)。
|
|
||||||
log.Printf("[local-token] consume REJECTED deviceId=%s ts=%s",
|
|
||||||
deviceID, time.Now().UTC().Format(time.RFC3339))
|
|
||||||
respondTokenInvalid(c)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 稽核:consume 成功。
|
|
||||||
log.Printf("[local-token] consume OK deviceId=%s ts=%s",
|
|
||||||
deviceID, time.Now().UTC().Format(time.RFC3339))
|
|
||||||
|
|
||||||
c.Next()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// respondTokenInvalid 統一回 401 LOCAL_TOKEN_INVALID 並中止。
|
|
||||||
func respondTokenInvalid(c *gin.Context) {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": gin.H{
|
|
||||||
"code": "LOCAL_TOKEN_INVALID", "message": "missing or invalid upload token",
|
|
||||||
}})
|
|
||||||
c.Abort()
|
|
||||||
}
|
|
||||||
@ -1,269 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"mime/multipart"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
// fakeConsumer 是 tokenConsumer 的測試替身,記錄 Consume 被呼叫的參數,
|
|
||||||
// 並可設定回傳的錯誤(模擬有效 / 已消費 / 過期 / deviceId 不符)。
|
|
||||||
type fakeConsumer struct {
|
|
||||||
called bool
|
|
||||||
gotToken string
|
|
||||||
gotDeviceID string
|
|
||||||
returnErr error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeConsumer) Consume(token, deviceID string) error {
|
|
||||||
f.called = true
|
|
||||||
f.gotToken = token
|
|
||||||
f.gotDeviceID = deviceID
|
|
||||||
return f.returnErr
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildMultipart 建一個含 deviceId + file 欄位的 multipart body,回傳 body 與 content-type。
|
|
||||||
func buildMultipart(t *testing.T, deviceID string, fileContent []byte) (*bytes.Buffer, string) {
|
|
||||||
t.Helper()
|
|
||||||
var buf bytes.Buffer
|
|
||||||
w := multipart.NewWriter(&buf)
|
|
||||||
if err := w.WriteField("deviceId", deviceID); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
fw, err := w.CreateFormFile("file", "test.mp4")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if _, err := fw.Write(fileContent); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := w.Close(); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
return &buf, w.FormDataContentType()
|
|
||||||
}
|
|
||||||
|
|
||||||
// newGuardRouter 建一台掛 LocalUploadGuard 的 router,handler 記錄是否被呼叫並回讀 file。
|
|
||||||
func newGuardRouter(store tokenConsumer, maxBytes int64, handlerCalled *bool) *gin.Engine {
|
|
||||||
r := gin.New()
|
|
||||||
r.POST("/api/local/media/upload/video",
|
|
||||||
LocalUploadGuard(store, maxBytes),
|
|
||||||
func(c *gin.Context) {
|
|
||||||
*handlerCalled = true
|
|
||||||
// 模擬既有 handler 讀 file(驗證 middleware 解析後 handler 仍可 FormFile)
|
|
||||||
_, _, err := c.Request.FormFile("file")
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"formfile_err": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
|
||||||
})
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
|
|
||||||
// decodeErrCode 從 response body 取出 error.code。
|
|
||||||
func decodeErrCode(t *testing.T, body []byte) string {
|
|
||||||
t.Helper()
|
|
||||||
var resp struct {
|
|
||||||
Error struct {
|
|
||||||
Code string `json:"code"`
|
|
||||||
} `json:"error"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(body, &resp); err != nil {
|
|
||||||
t.Fatalf("decode body %q: %v", string(body), err)
|
|
||||||
}
|
|
||||||
return resp.Error.Code
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestLocalUploadGuard_MissingToken:無 X-Visiona-Local-Token → 401 LOCAL_TOKEN_INVALID,
|
|
||||||
// 且 store.Consume 不被呼叫、handler 不被呼叫。
|
|
||||||
func TestLocalUploadGuard_MissingToken(t *testing.T) {
|
|
||||||
store := &fakeConsumer{}
|
|
||||||
var handlerCalled bool
|
|
||||||
r := newGuardRouter(store, maxVideoUploadBytes, &handlerCalled)
|
|
||||||
|
|
||||||
body, ct := buildMultipart(t, "dev-1", []byte("small"))
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/local/media/upload/video", body)
|
|
||||||
req.Header.Set("Content-Type", ct)
|
|
||||||
// 刻意不帶 token
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
r.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusUnauthorized {
|
|
||||||
t.Fatalf("status = %d, want 401", w.Code)
|
|
||||||
}
|
|
||||||
if code := decodeErrCode(t, w.Body.Bytes()); code != "LOCAL_TOKEN_INVALID" {
|
|
||||||
t.Errorf("error code = %q, want LOCAL_TOKEN_INVALID", code)
|
|
||||||
}
|
|
||||||
if store.called {
|
|
||||||
t.Error("無 token 不應呼叫 Consume")
|
|
||||||
}
|
|
||||||
if handlerCalled {
|
|
||||||
t.Error("無 token 不應進入 handler")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestLocalUploadGuard_ValidToken:有效 token → 放行、Consume 被呼叫且帶正確 token+deviceId、
|
|
||||||
// handler 被呼叫。
|
|
||||||
func TestLocalUploadGuard_ValidToken(t *testing.T) {
|
|
||||||
store := &fakeConsumer{returnErr: nil} // Consume 成功
|
|
||||||
var handlerCalled bool
|
|
||||||
r := newGuardRouter(store, maxVideoUploadBytes, &handlerCalled)
|
|
||||||
|
|
||||||
body, ct := buildMultipart(t, "dev-42", []byte("video-bytes"))
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/local/media/upload/video", body)
|
|
||||||
req.Header.Set("Content-Type", ct)
|
|
||||||
req.Header.Set("X-Visiona-Local-Token", "tok-abc")
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
r.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200 (body=%s)", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
if !store.called {
|
|
||||||
t.Fatal("有效 token 應呼叫 Consume")
|
|
||||||
}
|
|
||||||
if store.gotToken != "tok-abc" {
|
|
||||||
t.Errorf("Consume token = %q, want tok-abc", store.gotToken)
|
|
||||||
}
|
|
||||||
if store.gotDeviceID != "dev-42" {
|
|
||||||
t.Errorf("Consume deviceID = %q, want dev-42(取自表單)", store.gotDeviceID)
|
|
||||||
}
|
|
||||||
if !handlerCalled {
|
|
||||||
t.Error("有效 token 應進入 handler")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestLocalUploadGuard_ConsumedOrExpiredToken:Consume 回 ErrTokenInvalid(已用/過期/deviceId不符)
|
|
||||||
// → 401 LOCAL_TOKEN_INVALID,handler 不被呼叫。
|
|
||||||
func TestLocalUploadGuard_ConsumedOrExpiredToken(t *testing.T) {
|
|
||||||
store := &fakeConsumer{returnErr: ErrTokenInvalid}
|
|
||||||
var handlerCalled bool
|
|
||||||
r := newGuardRouter(store, maxVideoUploadBytes, &handlerCalled)
|
|
||||||
|
|
||||||
body, ct := buildMultipart(t, "dev-1", []byte("x"))
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/local/media/upload/video", body)
|
|
||||||
req.Header.Set("Content-Type", ct)
|
|
||||||
req.Header.Set("X-Visiona-Local-Token", "stale-tok")
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
r.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusUnauthorized {
|
|
||||||
t.Fatalf("status = %d, want 401", w.Code)
|
|
||||||
}
|
|
||||||
if code := decodeErrCode(t, w.Body.Bytes()); code != "LOCAL_TOKEN_INVALID" {
|
|
||||||
t.Errorf("error code = %q, want LOCAL_TOKEN_INVALID", code)
|
|
||||||
}
|
|
||||||
if handlerCalled {
|
|
||||||
t.Error("無效 token 不應進入 handler")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestLocalUploadGuard_TokenCheckedBeforeHandler:token 驗證發生在 handler(FormFile 讀檔)之前。
|
|
||||||
// 用「Consume 失敗時 handler 不被呼叫」+「Consume 成功時才進 handler」共同證明順序:
|
|
||||||
// 若 handler 先跑,無效 token 情境下 handlerCalled 會是 true。
|
|
||||||
func TestLocalUploadGuard_TokenCheckedBeforeHandler(t *testing.T) {
|
|
||||||
store := &fakeConsumer{returnErr: ErrTokenInvalid}
|
|
||||||
var handlerCalled bool
|
|
||||||
r := newGuardRouter(store, maxVideoUploadBytes, &handlerCalled)
|
|
||||||
|
|
||||||
body, ct := buildMultipart(t, "dev-1", bytes.Repeat([]byte("A"), 1024))
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/local/media/upload/video", body)
|
|
||||||
req.Header.Set("Content-Type", ct)
|
|
||||||
req.Header.Set("X-Visiona-Local-Token", "bad")
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
r.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if handlerCalled {
|
|
||||||
t.Error("token 驗證失敗時 handler 不得被呼叫(證明 token 檢查在 handler 前)")
|
|
||||||
}
|
|
||||||
if !store.called {
|
|
||||||
t.Error("Consume 應在進 handler 前被呼叫")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestLocalUploadGuard_TooLarge:body 超過 size 上限 → 413 LOCAL_UPLOAD_TOO_LARGE,
|
|
||||||
// handler 不被呼叫。用很小的 maxBytes 觸發。
|
|
||||||
func TestLocalUploadGuard_TooLarge(t *testing.T) {
|
|
||||||
const tinyMax = 64 // 64 bytes,遠小於下方 body
|
|
||||||
store := &fakeConsumer{returnErr: nil}
|
|
||||||
var handlerCalled bool
|
|
||||||
r := newGuardRouter(store, tinyMax, &handlerCalled)
|
|
||||||
|
|
||||||
body, ct := buildMultipart(t, "dev-1", bytes.Repeat([]byte("A"), 4096))
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/local/media/upload/video", body)
|
|
||||||
req.Header.Set("Content-Type", ct)
|
|
||||||
req.Header.Set("X-Visiona-Local-Token", "tok")
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
r.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusRequestEntityTooLarge {
|
|
||||||
t.Fatalf("status = %d, want 413 (body=%s)", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
if code := decodeErrCode(t, w.Body.Bytes()); code != "LOCAL_UPLOAD_TOO_LARGE" {
|
|
||||||
t.Errorf("error code = %q, want LOCAL_UPLOAD_TOO_LARGE", code)
|
|
||||||
}
|
|
||||||
if handlerCalled {
|
|
||||||
t.Error("超過 size 上限不應進入 handler")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestLocalUploadGuard_TooLarge_BeforeTokenConsumed:超過上限時,即使帶了看似有效的 token,
|
|
||||||
// 也不應 consume 掉那個 token(size 檢查在 consume 之前,避免大檔攻擊順手燒掉 token)。
|
|
||||||
func TestLocalUploadGuard_TooLarge_BeforeTokenConsumed(t *testing.T) {
|
|
||||||
const tinyMax = 64
|
|
||||||
store := &fakeConsumer{returnErr: nil}
|
|
||||||
var handlerCalled bool
|
|
||||||
r := newGuardRouter(store, tinyMax, &handlerCalled)
|
|
||||||
|
|
||||||
body, ct := buildMultipart(t, "dev-1", bytes.Repeat([]byte("A"), 4096))
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/local/media/upload/video", body)
|
|
||||||
req.Header.Set("Content-Type", ct)
|
|
||||||
req.Header.Set("X-Visiona-Local-Token", "tok")
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
r.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if store.called {
|
|
||||||
t.Error("超過 size 上限時不應呼叫 Consume(size 檢查在 consume 前)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestLocalUploadGuard_ErrorCodeMatchesSpec:確保錯誤碼字串與 api-spec §6.5 完全一致。
|
|
||||||
func TestLocalUploadGuard_ErrorCodeMatchesSpec(t *testing.T) {
|
|
||||||
// 直接驗證 respondTokenInvalid 的輸出格式。
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
c, _ := gin.CreateTestContext(w)
|
|
||||||
respondTokenInvalid(c)
|
|
||||||
|
|
||||||
if w.Code != http.StatusUnauthorized {
|
|
||||||
t.Fatalf("status = %d, want 401", w.Code)
|
|
||||||
}
|
|
||||||
if !strings.Contains(w.Body.String(), "LOCAL_TOKEN_INVALID") {
|
|
||||||
t.Errorf("body 應含 LOCAL_TOKEN_INVALID,got %s", w.Body.String())
|
|
||||||
}
|
|
||||||
var resp struct {
|
|
||||||
Success bool `json:"success"`
|
|
||||||
}
|
|
||||||
_ = json.Unmarshal(w.Body.Bytes(), &resp)
|
|
||||||
if resp.Success {
|
|
||||||
t.Error("錯誤回應 success 應為 false")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 確認 ErrTokenInvalid / ErrTokenLimit 是 sentinel(errors.Is 可比對),供 handler/middleware 對應錯誤碼。
|
|
||||||
func TestSentinelErrors(t *testing.T) {
|
|
||||||
if !errors.Is(ErrTokenInvalid, ErrTokenInvalid) {
|
|
||||||
t.Error("ErrTokenInvalid 應可自比對")
|
|
||||||
}
|
|
||||||
if errors.Is(ErrTokenInvalid, ErrTokenLimit) {
|
|
||||||
t.Error("ErrTokenInvalid 與 ErrTokenLimit 不應相等")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,23 +1,19 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
// allowedHosts 定義 loopback CORS 白名單的 hostname。
|
// allowedHosts 定義 CORS 白名單的 hostname。
|
||||||
// 任何 port 都允許,scheme 只允許 http(本機不可能是 https)。
|
// 任何 port 都允許,scheme 只允許 http(本機不可能是 https)。
|
||||||
//
|
//
|
||||||
// M8-8(TDD v2/cors-security.md §3.1):
|
// M8-8(TDD v2/cors-security.md §3.1):
|
||||||
// v2 模式下 UI 改在使用者瀏覽器中跑,server 同時暴露給其他瀏覽器分頁,
|
// v2 模式下 UI 改在使用者瀏覽器中跑,server 同時暴露給其他瀏覽器分頁,
|
||||||
// 必須限定 cross-origin 來源在本機 loopback,避免惡意網站透過 CORS 攻擊。
|
// 必須限定 cross-origin 來源在本機 loopback,避免惡意網站透過 CORS 攻擊。
|
||||||
//
|
|
||||||
// ADR-019 §2.5:此 loopback 舊規則「保留不動」——不因開放雲端 origin 而變更。
|
|
||||||
var allowedHosts = map[string]bool{
|
var allowedHosts = map[string]bool{
|
||||||
"127.0.0.1": true,
|
"127.0.0.1": true,
|
||||||
"localhost": true,
|
"localhost": true,
|
||||||
@ -25,53 +21,7 @@ var allowedHosts = map[string]bool{
|
|||||||
"::1": true,
|
"::1": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// loopbackHostnames 是 Host header 驗證(ADR-019 §2.5 M2)允許的 hostname 集合。
|
// isAllowedOrigin 判斷 Origin header 是否屬於白名單。
|
||||||
// 與 allowedHosts 概念不同:allowedHosts 比對「Origin header 的 hostname」,
|
|
||||||
// 這裡比對「Host header 的 hostname」——DNS rebinding 防護的獨立第二道。
|
|
||||||
var loopbackHostnames = map[string]bool{
|
|
||||||
"127.0.0.1": true,
|
|
||||||
"localhost": true,
|
|
||||||
"::1": true,
|
|
||||||
}
|
|
||||||
|
|
||||||
// cloudOrigins 是 ADR-019 §2.5 M3 的雲端 origin 白名單——
|
|
||||||
// 存「完整 origin 字串」(scheme+host+port 全等),比對時逐字精確相等。
|
|
||||||
//
|
|
||||||
// 刻意獨立於 loopback 的 isAllowedOrigin(hostname-only + 任意 port + 只收 http):
|
|
||||||
// - 若沿用 hostname-only,會變成「該網域任意 port 都放行」,攻擊面過大。
|
|
||||||
// - 若放寬 scheme 檢查,會讓 http/https 混用可繞過。
|
|
||||||
//
|
|
||||||
// 故雲端 origin 一律走「完整 origin 精確比對」,來源 env VISIONA_CLOUD_ORIGINS。
|
|
||||||
// 於 init 時載入一次(server 生命週期內固定)。
|
|
||||||
var cloudOrigins = loadCloudOrigins(os.Getenv("VISIONA_CLOUD_ORIGINS"))
|
|
||||||
|
|
||||||
// loadCloudOrigins 解析逗號分隔的完整 origin 字串,回傳精確比對用的 set。
|
|
||||||
//
|
|
||||||
// 每個項目做 TrimSpace,過濾空字串。不做任何 hostname/port 拆解——
|
|
||||||
// 白名單存的就是完整 origin,比對時整串相等才通過(ADR-019 §2.5 M3)。
|
|
||||||
func loadCloudOrigins(raw string) map[string]bool {
|
|
||||||
set := make(map[string]bool)
|
|
||||||
if raw == "" {
|
|
||||||
return set
|
|
||||||
}
|
|
||||||
for _, part := range strings.Split(raw, ",") {
|
|
||||||
origin := strings.TrimSpace(part)
|
|
||||||
if origin != "" {
|
|
||||||
set[origin] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return set
|
|
||||||
}
|
|
||||||
|
|
||||||
// isAllowedCloudOrigin 判斷 Origin 是否為雲端白名單 origin(完整 origin 精確比對)。
|
|
||||||
func isAllowedCloudOrigin(origin string) bool {
|
|
||||||
if origin == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return cloudOrigins[origin]
|
|
||||||
}
|
|
||||||
|
|
||||||
// isAllowedOrigin 判斷 Origin header 是否屬於 loopback 白名單。
|
|
||||||
//
|
//
|
||||||
// 合法例:http://127.0.0.1:3721 / http://localhost:3721 / http://[::1]:3721
|
// 合法例:http://127.0.0.1:3721 / http://localhost:3721 / http://[::1]:3721
|
||||||
// 不合法例:https://127.0.0.1:3721 / http://evil.com / null / http://192.168.1.5:3721
|
// 不合法例:https://127.0.0.1:3721 / http://evil.com / null / http://192.168.1.5:3721
|
||||||
@ -80,8 +30,6 @@ func isAllowedCloudOrigin(origin string) bool {
|
|||||||
// - 空字串視為非白名單(呼叫端會自行決定 same-origin 路徑)。
|
// - 空字串視為非白名單(呼叫端會自行決定 same-origin 路徑)。
|
||||||
// - "null"(local file、某些 sandboxed iframe)一律拒絕。
|
// - "null"(local file、某些 sandboxed iframe)一律拒絕。
|
||||||
// - 只允許 http scheme,本機不會有 https。
|
// - 只允許 http scheme,本機不會有 https。
|
||||||
//
|
|
||||||
// ADR-019:此函式維持 loopback 舊邏輯不動;雲端 origin 走 isAllowedCloudOrigin。
|
|
||||||
func isAllowedOrigin(origin string) bool {
|
func isAllowedOrigin(origin string) bool {
|
||||||
if origin == "" || origin == "null" {
|
if origin == "" || origin == "null" {
|
||||||
return false
|
return false
|
||||||
@ -97,19 +45,14 @@ func isAllowedOrigin(origin string) bool {
|
|||||||
return allowedHosts[host]
|
return allowedHosts[host]
|
||||||
}
|
}
|
||||||
|
|
||||||
// CORSMiddleware 處理跨來源請求,區分 loopback 與雲端 origin 兩條路徑。
|
// CORSMiddleware 僅允許 127.0.0.1/localhost/::1 任意 port 的跨來源請求。
|
||||||
//
|
//
|
||||||
// 行為(M8-8 / TDD v2/cors-security.md §4.1 + ADR-019 §2.5):
|
// 行為(M8-8 / TDD v2/cors-security.md §4.1):
|
||||||
//
|
//
|
||||||
// 1. Origin header 為空 → same-origin(瀏覽器 same-origin 不送 Origin)→ 直接放行;
|
// 1. Origin header 為空 → same-origin(瀏覽器 same-origin 不送 Origin)→ 直接放行;
|
||||||
// 若是 OPTIONS 預檢則回 204 即停(避免帶 ACA* 給沒人看的請求)。
|
// 若是 OPTIONS 預檢則回 204 即停(避免帶 ACA* 給沒人看的請求)。
|
||||||
// 2. Origin 在 loopback 白名單 → 回完整 ACA* headers(含 Allow-Credentials: true,
|
// 2. Origin 在白名單 → 回完整 ACA* headers;OPTIONS → 204;其他方法 → 繼續執行 handler。
|
||||||
// 沿用 M8-8 既有行為);OPTIONS → 204;其他方法 → 繼續執行 handler。
|
// 3. Origin 不在白名單:
|
||||||
// 3. Origin 在雲端白名單(ADR-019)→ 回 ACA* headers,但
|
|
||||||
// Allow-Credentials: false(本路徑用 X-Visiona-Local-Token header 帶 token、不需 cookie)、
|
|
||||||
// Allow-Headers 含 X-Visiona-Local-Token、Max-Age: 600、
|
|
||||||
// 並在 preflight 帶 PNA 請求時回 Access-Control-Allow-Private-Network: true。
|
|
||||||
// 4. Origin 都不在白名單:
|
|
||||||
// - state-changing 方法(POST/PUT/DELETE/PATCH/OPTIONS)→ 403 Forbidden,不回 ACA*。
|
// - state-changing 方法(POST/PUT/DELETE/PATCH/OPTIONS)→ 403 Forbidden,不回 ACA*。
|
||||||
// - 簡單讀取(GET/HEAD)→ 執行 handler 但不回 ACA*,瀏覽器 JS 讀不到 body。
|
// - 簡單讀取(GET/HEAD)→ 執行 handler 但不回 ACA*,瀏覽器 JS 讀不到 body。
|
||||||
//
|
//
|
||||||
@ -131,109 +74,33 @@ func CORSMiddleware() gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 雲端白名單 origin(ADR-019):完整 origin 精確比對,獨立於 loopback。
|
if !isAllowedOrigin(origin) {
|
||||||
if isAllowedCloudOrigin(origin) {
|
// 非白名單 Origin
|
||||||
c.Header("Access-Control-Allow-Origin", origin)
|
// - state-changing 方法 → 403(嚴格擋)
|
||||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
// - GET/HEAD → 執行但不回 ACA*(瀏覽器層擋)
|
||||||
c.Header("Access-Control-Allow-Headers", "Content-Type, X-Visiona-Local-Token")
|
if method == http.MethodOptions ||
|
||||||
// M3:雲端 origin 一律 false——用 header 帶 token、不需 cookie,
|
method == http.MethodPost ||
|
||||||
// 避免無謂讓瀏覽器願意帶 credential 而擴大 CSRF / 憑證面。
|
method == http.MethodPut ||
|
||||||
c.Header("Access-Control-Allow-Credentials", "false")
|
method == http.MethodDelete ||
|
||||||
c.Header("Access-Control-Max-Age", "600")
|
method == http.MethodPatch {
|
||||||
c.Header("Vary", "Origin")
|
c.AbortWithStatus(http.StatusForbidden)
|
||||||
|
|
||||||
if method == http.MethodOptions {
|
|
||||||
// PNA(ADR-019 §2.5,必做):preflight 帶
|
|
||||||
// Access-Control-Request-Private-Network: true 且通過白名單 → 回 PNA header。
|
|
||||||
// 防未來 Chrome 把 PNA 從 warning 升為 blocking 時舊版 agent 無預警壞掉。
|
|
||||||
if c.GetHeader("Access-Control-Request-Private-Network") == "true" {
|
|
||||||
c.Header("Access-Control-Allow-Private-Network", "true")
|
|
||||||
}
|
|
||||||
c.AbortWithStatus(http.StatusNoContent)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.Next()
|
c.Next()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// loopback 白名單 origin:沿用 M8-8 既有行為(Allow-Credentials: true)。
|
// 白名單 Origin:回完整 ACA* headers
|
||||||
if isAllowedOrigin(origin) {
|
c.Header("Access-Control-Allow-Origin", origin)
|
||||||
c.Header("Access-Control-Allow-Origin", origin)
|
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||||
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Visiona-Local-Token")
|
c.Header("Access-Control-Allow-Credentials", "true")
|
||||||
c.Header("Access-Control-Allow-Credentials", "true")
|
c.Header("Vary", "Origin")
|
||||||
c.Header("Access-Control-Max-Age", "600")
|
|
||||||
c.Header("Vary", "Origin")
|
|
||||||
|
|
||||||
if method == http.MethodOptions {
|
if method == http.MethodOptions {
|
||||||
// loopback 直連也可能帶 PNA preflight(同機不同 port 屬 private network)。
|
c.AbortWithStatus(http.StatusNoContent)
|
||||||
if c.GetHeader("Access-Control-Request-Private-Network") == "true" {
|
|
||||||
c.Header("Access-Control-Allow-Private-Network", "true")
|
|
||||||
}
|
|
||||||
c.AbortWithStatus(http.StatusNoContent)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.Next()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 非白名單 Origin
|
|
||||||
// - state-changing 方法 → 403(嚴格擋)
|
|
||||||
// - GET/HEAD → 執行但不回 ACA*(瀏覽器層擋)
|
|
||||||
if method == http.MethodOptions ||
|
|
||||||
method == http.MethodPost ||
|
|
||||||
method == http.MethodPut ||
|
|
||||||
method == http.MethodDelete ||
|
|
||||||
method == http.MethodPatch {
|
|
||||||
c.AbortWithStatus(http.StatusForbidden)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.Next()
|
c.Next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HostGuard 是 DNS rebinding 的獨立第二道防護(ADR-019 §2.5 M2,必做)。
|
|
||||||
//
|
|
||||||
// 檢查 Host header(去 port 後)必須 ∈ {127.0.0.1, localhost, ::1},
|
|
||||||
// 否則 400 Bad Request。與 CORS 正交:CORS 擋 Origin、HostGuard 擋 Host。
|
|
||||||
//
|
|
||||||
// 套用範圍:
|
|
||||||
// - 所有 /api/local/*(含 WP-2 新增的 /api/local/media/upload/*)
|
|
||||||
// - 舊 tunnel-path media route(/api/media/upload/*)——關舊 route 的殘留面。
|
|
||||||
//
|
|
||||||
// 為什麼 tunnel 轉發不受影響:tunnel client 轉發到本地 server 時
|
|
||||||
// req.URL.Host = 127.0.0.1:<port>(client.go),Host header 本就是 loopback,通過。
|
|
||||||
//
|
|
||||||
// DNS rebinding 情境:攻擊者把 evil.com 重綁到 127.0.0.1,
|
|
||||||
// fetch('http://evil.com:<port>/...') 實際打到本機、但 Host header 為
|
|
||||||
// evil.com:<port> ≠ loopback → 被 400 擋下。
|
|
||||||
func HostGuard() gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
if !isLoopbackHost(c.Request.Host) {
|
|
||||||
c.AbortWithStatus(http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.Next()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// isLoopbackHost 判斷 Host header(可能含 port)的 hostname 是否為 loopback。
|
|
||||||
//
|
|
||||||
// net.SplitHostPort 在無 port 時回 error,此時退回原字串當 hostname。
|
|
||||||
// IPv6 的 "[::1]:port" 經 SplitHostPort 會得到 "::1"(去掉方括號),
|
|
||||||
// 故 loopbackHostnames 存的是 "::1" 而非 "[::1]"。
|
|
||||||
func isLoopbackHost(host string) bool {
|
|
||||||
if host == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
h, _, err := net.SplitHostPort(host)
|
|
||||||
if err != nil {
|
|
||||||
// 無 port(如 "localhost")或格式異常 → 退回原字串比對。
|
|
||||||
h = host
|
|
||||||
}
|
|
||||||
h = strings.ToLower(strings.TrimSpace(h))
|
|
||||||
// 去掉 IPv6 可能殘留的方括號(無 port 的 "[::1]" 這類邊界情況)。
|
|
||||||
h = strings.TrimPrefix(h, "[")
|
|
||||||
h = strings.TrimSuffix(h, "]")
|
|
||||||
return loopbackHostnames[h]
|
|
||||||
}
|
|
||||||
|
|||||||
@ -3,7 +3,6 @@ package api
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@ -200,253 +199,3 @@ func TestCORSMiddleware_SameOrigin(t *testing.T) {
|
|||||||
t.Errorf("same-origin 不應回 ACA-Origin,got %q", got)
|
t.Errorf("same-origin 不應回 ACA-Origin,got %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----- ADR-019 WP-1:雲端 origin 精確比對 + PNA + HostGuard -----
|
|
||||||
|
|
||||||
// TestLoadCloudOrigins 驗證 VISIONA_CLOUD_ORIGINS 解析(逗號分隔、TrimSpace、過濾空字串)。
|
|
||||||
func TestLoadCloudOrigins(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
raw string
|
|
||||||
want map[string]bool
|
|
||||||
}{
|
|
||||||
{"empty", "", map[string]bool{}},
|
|
||||||
{"single", "https://stage-9527.innovedus.com:9527",
|
|
||||||
map[string]bool{"https://stage-9527.innovedus.com:9527": true}},
|
|
||||||
{"multi with spaces", " https://a.com:443 , http://192.168.0.130:9527 ",
|
|
||||||
map[string]bool{"https://a.com:443": true, "http://192.168.0.130:9527": true}},
|
|
||||||
{"trailing comma", "http://localhost:3000,,",
|
|
||||||
map[string]bool{"http://localhost:3000": true}},
|
|
||||||
}
|
|
||||||
for _, tc := range cases {
|
|
||||||
got := loadCloudOrigins(tc.raw)
|
|
||||||
if len(got) != len(tc.want) {
|
|
||||||
t.Errorf("%s: len = %d, want %d (%v)", tc.name, len(got), len(tc.want), got)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for k := range tc.want {
|
|
||||||
if !got[k] {
|
|
||||||
t.Errorf("%s: missing origin %q in %v", tc.name, k, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestIsAllowedCloudOrigin_ExactMatch 驗證雲端 origin 必須 scheme+host+port 全等(M3)。
|
|
||||||
// 關鍵:不可像 loopback 那樣 hostname-only + 任意 port。
|
|
||||||
func TestIsAllowedCloudOrigin_ExactMatch(t *testing.T) {
|
|
||||||
// 直接注入測試白名單,避免依賴環境變數。
|
|
||||||
saved := cloudOrigins
|
|
||||||
cloudOrigins = map[string]bool{
|
|
||||||
"https://stage-9527.innovedus.com:9527": true,
|
|
||||||
"http://192.168.0.130:9527": true,
|
|
||||||
}
|
|
||||||
defer func() { cloudOrigins = saved }()
|
|
||||||
|
|
||||||
cases := []struct {
|
|
||||||
origin string
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
// 完全相符
|
|
||||||
{"https://stage-9527.innovedus.com:9527", true},
|
|
||||||
{"http://192.168.0.130:9527", true},
|
|
||||||
|
|
||||||
// 同 host 不同 port → 不通過(證明不是 hostname-only)
|
|
||||||
{"https://stage-9527.innovedus.com:8080", false},
|
|
||||||
{"https://stage-9527.innovedus.com", false},
|
|
||||||
{"http://192.168.0.130:8080", false},
|
|
||||||
|
|
||||||
// 同 host 不同 scheme → 不通過(證明不放寬 scheme)
|
|
||||||
{"http://stage-9527.innovedus.com:9527", false},
|
|
||||||
{"https://192.168.0.130:9527", false},
|
|
||||||
|
|
||||||
// 其他
|
|
||||||
{"", false},
|
|
||||||
{"null", false},
|
|
||||||
{"https://evil.com:9527", false},
|
|
||||||
{"https://stage-9527.innovedus.com:9527.evil.com", false},
|
|
||||||
}
|
|
||||||
for _, tc := range cases {
|
|
||||||
if got := isAllowedCloudOrigin(tc.origin); got != tc.want {
|
|
||||||
t.Errorf("isAllowedCloudOrigin(%q) = %v, want %v", tc.origin, got, tc.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// newCloudTestRouter 建一台掛 CORSMiddleware 的 router,並注入測試用雲端白名單。
|
|
||||||
func newCloudTestRouter(t *testing.T) *gin.Engine {
|
|
||||||
t.Helper()
|
|
||||||
saved := cloudOrigins
|
|
||||||
cloudOrigins = map[string]bool{"https://cloud.example.com:9527": true}
|
|
||||||
t.Cleanup(func() { cloudOrigins = saved })
|
|
||||||
return newTestRouter()
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestCORSMiddleware_CloudOriginPOST:雲端白名單 origin 的 POST 應放行 + Credentials:false。
|
|
||||||
func TestCORSMiddleware_CloudOriginPOST(t *testing.T) {
|
|
||||||
r := newCloudTestRouter(t)
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/do", nil)
|
|
||||||
req.Header.Set("Origin", "https://cloud.example.com:9527")
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
r.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200", w.Code)
|
|
||||||
}
|
|
||||||
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "https://cloud.example.com:9527" {
|
|
||||||
t.Errorf("ACA-Origin = %q, want cloud origin", got)
|
|
||||||
}
|
|
||||||
if got := w.Header().Get("Access-Control-Allow-Credentials"); got != "false" {
|
|
||||||
t.Errorf("ACA-Credentials = %q, want false (M3)", got)
|
|
||||||
}
|
|
||||||
if got := w.Header().Get("Access-Control-Allow-Headers"); !strings.Contains(got, "X-Visiona-Local-Token") {
|
|
||||||
t.Errorf("ACA-Headers = %q, 必須含 X-Visiona-Local-Token", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestCORSMiddleware_CloudPreflightPNA:雲端 origin preflight 帶 PNA request → 回 PNA header + Max-Age。
|
|
||||||
func TestCORSMiddleware_CloudPreflightPNA(t *testing.T) {
|
|
||||||
r := newCloudTestRouter(t)
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodOptions, "/api/do", nil)
|
|
||||||
req.Header.Set("Origin", "https://cloud.example.com:9527")
|
|
||||||
req.Header.Set("Access-Control-Request-Method", "POST")
|
|
||||||
req.Header.Set("Access-Control-Request-Private-Network", "true")
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
r.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusNoContent {
|
|
||||||
t.Fatalf("status = %d, want 204", w.Code)
|
|
||||||
}
|
|
||||||
if got := w.Header().Get("Access-Control-Allow-Private-Network"); got != "true" {
|
|
||||||
t.Errorf("ACA-Private-Network = %q, want true (PNA 必做)", got)
|
|
||||||
}
|
|
||||||
if got := w.Header().Get("Access-Control-Max-Age"); got != "600" {
|
|
||||||
t.Errorf("Max-Age = %q, want 600", got)
|
|
||||||
}
|
|
||||||
if got := w.Header().Get("Access-Control-Allow-Credentials"); got != "false" {
|
|
||||||
t.Errorf("ACA-Credentials = %q, want false", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestCORSMiddleware_CloudPreflightNoPNARequest:preflight 未帶 PNA request → 不回 PNA header。
|
|
||||||
func TestCORSMiddleware_CloudPreflightNoPNARequest(t *testing.T) {
|
|
||||||
r := newCloudTestRouter(t)
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodOptions, "/api/do", nil)
|
|
||||||
req.Header.Set("Origin", "https://cloud.example.com:9527")
|
|
||||||
req.Header.Set("Access-Control-Request-Method", "POST")
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
r.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusNoContent {
|
|
||||||
t.Fatalf("status = %d, want 204", w.Code)
|
|
||||||
}
|
|
||||||
if got := w.Header().Get("Access-Control-Allow-Private-Network"); got != "" {
|
|
||||||
t.Errorf("未帶 PNA request 不應回 PNA header,got %q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestCORSMiddleware_NonWhitelistedCloudPortPOST:同 host 但不在白名單的 port → 403。
|
|
||||||
func TestCORSMiddleware_NonWhitelistedCloudPortPOST(t *testing.T) {
|
|
||||||
r := newCloudTestRouter(t)
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/do", nil)
|
|
||||||
req.Header.Set("Origin", "https://cloud.example.com:8080") // 不同 port
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
r.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusForbidden {
|
|
||||||
t.Fatalf("status = %d, want 403(不同 port 不應通過精確比對)", w.Code)
|
|
||||||
}
|
|
||||||
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "" {
|
|
||||||
t.Errorf("不應回 ACA-Origin,got %q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestCORSMiddleware_LoopbackCredentialsUnchanged:loopback origin 仍回 Credentials:true(M8-8 保留不動)。
|
|
||||||
func TestCORSMiddleware_LoopbackCredentialsUnchanged(t *testing.T) {
|
|
||||||
r := newTestRouter()
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api/ping", nil)
|
|
||||||
req.Header.Set("Origin", "http://127.0.0.1:3721")
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
r.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if got := w.Header().Get("Access-Control-Allow-Credentials"); got != "true" {
|
|
||||||
t.Errorf("loopback ACA-Credentials = %q, want true(ADR-019 保留 loopback 舊規則)", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----- HostGuard -----
|
|
||||||
|
|
||||||
// newHostGuardRouter 建一台掛 HostGuard 的 router。
|
|
||||||
func newHostGuardRouter() *gin.Engine {
|
|
||||||
r := gin.New()
|
|
||||||
r.POST("/api/media/upload/video", HostGuard(), func(c *gin.Context) {
|
|
||||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
|
||||||
})
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHostGuard 驗證 Host header 必須 = loopback,否則 400。
|
|
||||||
func TestHostGuard(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
host string
|
|
||||||
wantCode int
|
|
||||||
}{
|
|
||||||
{"127.0.0.1 with port", "127.0.0.1:3721", http.StatusOK},
|
|
||||||
{"localhost with port", "localhost:3721", http.StatusOK},
|
|
||||||
{"localhost no port", "localhost", http.StatusOK},
|
|
||||||
{"127.0.0.1 no port", "127.0.0.1", http.StatusOK},
|
|
||||||
{"ipv6 loopback with port", "[::1]:3721", http.StatusOK},
|
|
||||||
{"uppercase LOCALHOST", "LOCALHOST:3721", http.StatusOK},
|
|
||||||
// DNS rebinding:Host 為攻擊者網域 → 400
|
|
||||||
{"evil domain", "evil.com:3721", http.StatusBadRequest},
|
|
||||||
{"evil domain no port", "evil.com", http.StatusBadRequest},
|
|
||||||
{"lan ip", "192.168.0.130:9527", http.StatusBadRequest},
|
|
||||||
{"public ip", "8.8.8.8:80", http.StatusBadRequest},
|
|
||||||
// suffix 攻擊
|
|
||||||
{"loopback suffix attack", "127.0.0.1.evil.com:3721", http.StatusBadRequest},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
r := newHostGuardRouter()
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/media/upload/video", nil)
|
|
||||||
req.Host = tc.host
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
r.ServeHTTP(w, req)
|
|
||||||
if w.Code != tc.wantCode {
|
|
||||||
t.Errorf("Host=%q → %d, want %d", tc.host, w.Code, tc.wantCode)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestIsLoopbackHost 直接單元測試 host 判斷邏輯。
|
|
||||||
func TestIsLoopbackHost(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
host string
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
{"127.0.0.1:3721", true},
|
|
||||||
{"127.0.0.1", true},
|
|
||||||
{"localhost:8080", true},
|
|
||||||
{"localhost", true},
|
|
||||||
{"[::1]:3721", true},
|
|
||||||
{"::1", true},
|
|
||||||
{"", false},
|
|
||||||
{"evil.com", false},
|
|
||||||
{"evil.com:3721", false},
|
|
||||||
{"192.168.0.130:9527", false},
|
|
||||||
{"127.0.0.1.evil.com:80", false},
|
|
||||||
}
|
|
||||||
for _, tc := range cases {
|
|
||||||
if got := isLoopbackHost(tc.host); got != tc.want {
|
|
||||||
t.Errorf("isLoopbackHost(%q) = %v, want %v", tc.host, got, tc.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -49,12 +49,6 @@ func NewRouter(
|
|||||||
deviceHandler := handlers.NewDeviceHandler(deviceMgr, flashSvc, inferenceSvc, wsHub)
|
deviceHandler := handlers.NewDeviceHandler(deviceMgr, flashSvc, inferenceSvc, wsHub)
|
||||||
cameraHandler := handlers.NewCameraHandler(cameraMgr, deviceMgr, inferenceSvc, wsHub)
|
cameraHandler := handlers.NewCameraHandler(cameraMgr, deviceMgr, inferenceSvc, wsHub)
|
||||||
|
|
||||||
// ADR-019:本機直連 one-time token store(記憶體、process 生命週期)。
|
|
||||||
// 惰性清理 + 背景 goroutine 每 60s 掃過期 token。
|
|
||||||
tokenStore := NewTokenStore()
|
|
||||||
tokenStore.StartCleanup()
|
|
||||||
localHandler := handlers.NewLocalHandler(deviceMgr, tokenStore)
|
|
||||||
|
|
||||||
api := r.Group("/api")
|
api := r.Group("/api")
|
||||||
{
|
{
|
||||||
// System
|
// System
|
||||||
@ -93,30 +87,11 @@ func NewRouter(
|
|||||||
api.GET("/camera/stream", cameraHandler.StreamMJPEG)
|
api.GET("/camera/stream", cameraHandler.StreamMJPEG)
|
||||||
|
|
||||||
// Media
|
// Media
|
||||||
// ADR-019 §2.5 M2:舊 tunnel-path media upload route 加 HostGuard,
|
api.POST("/media/upload/image", cameraHandler.UploadImage)
|
||||||
// 關「同機直打舊 route」的殘留面(Host=loopback 才放行)。
|
api.POST("/media/upload/video", cameraHandler.UploadVideo)
|
||||||
// tunnel 轉發的 Host 本就是 127.0.0.1:<port> 故不受影響。
|
api.POST("/media/upload/batch-images", cameraHandler.UploadBatchImages)
|
||||||
api.POST("/media/upload/image", HostGuard(), cameraHandler.UploadImage)
|
|
||||||
api.POST("/media/upload/video", HostGuard(), cameraHandler.UploadVideo)
|
|
||||||
api.POST("/media/upload/batch-images", HostGuard(), cameraHandler.UploadBatchImages)
|
|
||||||
api.GET("/media/batch-images/:index", cameraHandler.GetBatchImageFrame)
|
api.GET("/media/batch-images/:index", cameraHandler.GetBatchImageFrame)
|
||||||
api.POST("/media/seek", cameraHandler.SeekVideo)
|
api.POST("/media/seek", cameraHandler.SeekVideo)
|
||||||
|
|
||||||
// ADR-019:本機直連支援 endpoint(/api/local/*)。
|
|
||||||
// 整組套 HostGuard(M2,Host=loopback 才放行,DNS rebinding 第二道防護)。
|
|
||||||
local := api.Group("/local", HostGuard())
|
|
||||||
{
|
|
||||||
// 同機偵測 + 身分驗證(bootstrap,無 token)。
|
|
||||||
local.GET("/hello", localHandler.Hello)
|
|
||||||
// 產 one-time upload token(僅經 tunnel 由 api-server 轉發呼叫;受 HostGuard 約束)。
|
|
||||||
local.POST("/issue-token", localHandler.IssueToken)
|
|
||||||
|
|
||||||
// 瀏覽器 localhost 直連 upload route:一律要 token(不看 Origin,C1)+ size 上限(M1)。
|
|
||||||
// LocalUploadGuard 在 FormFile 前驗 token + size,通過後轉呼叫既有 handler(業務邏輯零改動)。
|
|
||||||
local.POST("/media/upload/video", LocalUploadGuard(tokenStore, maxVideoUploadBytes), cameraHandler.UploadVideo)
|
|
||||||
local.POST("/media/upload/image", LocalUploadGuard(tokenStore, maxImageUploadBytes), cameraHandler.UploadImage)
|
|
||||||
local.POST("/media/upload/batch-images", LocalUploadGuard(tokenStore, maxBatchUploadBytes), cameraHandler.UploadBatchImages)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// WebSocket
|
// WebSocket
|
||||||
@ -204,9 +179,8 @@ func broadcasterLogger(b *logger.Broadcaster) gin.HandlerFunc {
|
|||||||
// for Next.js static export client-side routing.
|
// for Next.js static export client-side routing.
|
||||||
//
|
//
|
||||||
// Next.js static export with generateStaticParams creates:
|
// Next.js static export with generateStaticParams creates:
|
||||||
//
|
// /models/index.html — static page
|
||||||
// /models/index.html — static page
|
// /models/_/index.html — dynamic route shell (placeholder param '_')
|
||||||
// /models/_/index.html — dynamic route shell (placeholder param '_')
|
|
||||||
//
|
//
|
||||||
// For a request like /models/yolov5-face-detection:
|
// For a request like /models/yolov5-face-detection:
|
||||||
// 1. Try exact file → not found
|
// 1. Try exact file → not found
|
||||||
|
|||||||
@ -1,173 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/rand"
|
|
||||||
"crypto/subtle"
|
|
||||||
"encoding/base64"
|
|
||||||
"errors"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ADR-019 §2.4:one-time upload token store。
|
|
||||||
//
|
|
||||||
// 設計(已經 security review 議題 2 通過):
|
|
||||||
// - token = crypto/rand 32 bytes → base64url(禁 math/rand)
|
|
||||||
// - TTL 120s、one-time(consume 即刪)、綁 deviceId
|
|
||||||
// - 記憶體 store(map + 單一 sync.Mutex,不持久化)
|
|
||||||
// - 未使用上限 32 個(防記憶體 DoS)
|
|
||||||
// - 比對用 crypto/subtle.ConstantTimeCompare(防 timing attack)
|
|
||||||
// - consume single-flight:查存在 + 比對 + 刪除三步在同一 Lock 內完成(security m2,防 race)
|
|
||||||
const (
|
|
||||||
tokenTTL = 120 * time.Second
|
|
||||||
maxUnusedTokens = 32
|
|
||||||
tokenCleanupPeriod = 60 * time.Second
|
|
||||||
tokenRandBytes = 32
|
|
||||||
)
|
|
||||||
|
|
||||||
// 明確的錯誤,供 handler 對應到 api-spec §6.5 的錯誤碼。
|
|
||||||
var (
|
|
||||||
// ErrTokenLimit:未使用 token 達 32 上限(→ 429 LOCAL_TOKEN_LIMIT)。
|
|
||||||
ErrTokenLimit = errors.New("local token limit reached")
|
|
||||||
// ErrTokenInvalid:token 不存在 / 過期 / 已使用 / deviceId 不符(→ 401 LOCAL_TOKEN_INVALID)。
|
|
||||||
ErrTokenInvalid = errors.New("local token invalid")
|
|
||||||
)
|
|
||||||
|
|
||||||
// tokenEntry 是一筆未消費的 token 記錄。
|
|
||||||
type tokenEntry struct {
|
|
||||||
deviceID string
|
|
||||||
expiresAt time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// TokenStore 是執行緒安全的 one-time token 記憶體 store。
|
|
||||||
//
|
|
||||||
// 併發正確性核心:所有讀寫都在單一 mu 內完成。
|
|
||||||
// Consume 是 single-flight——「查存在 + ConstantTimeCompare + 刪除」在同一 Lock()
|
|
||||||
// 內原子完成,兩個併發 consume 同一 token 不可能都成功(防 one-time 失效)。
|
|
||||||
type TokenStore struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
tokens map[string]tokenEntry
|
|
||||||
now func() time.Time // 可注入,方便測試過期邏輯
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewTokenStore 建立 store。now 預設為 time.Now。
|
|
||||||
func NewTokenStore() *TokenStore {
|
|
||||||
return &TokenStore{
|
|
||||||
tokens: make(map[string]tokenEntry),
|
|
||||||
now: time.Now,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Issue 產生一個新 token 綁定 deviceID,single-flight 持鎖完成
|
|
||||||
// 「清過期 + 查 len < 32 + 插入」。達上限回 ErrTokenLimit。
|
|
||||||
//
|
|
||||||
// token 值以 crypto/rand 產生(32 bytes → base64url RawURL)。
|
|
||||||
func (s *TokenStore) Issue(deviceID string) (string, time.Time, error) {
|
|
||||||
// 先在鎖外產生亂數(crypto/rand 可能較慢,避免長時間持鎖)。
|
|
||||||
buf := make([]byte, tokenRandBytes)
|
|
||||||
if _, err := rand.Read(buf); err != nil {
|
|
||||||
return "", time.Time{}, err
|
|
||||||
}
|
|
||||||
token := base64.RawURLEncoding.EncodeToString(buf)
|
|
||||||
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
|
|
||||||
// 惰性清理過期 token,順便為上限計算釋放名額。
|
|
||||||
s.pruneExpiredLocked()
|
|
||||||
|
|
||||||
if len(s.tokens) >= maxUnusedTokens {
|
|
||||||
return "", time.Time{}, ErrTokenLimit
|
|
||||||
}
|
|
||||||
|
|
||||||
expiresAt := s.now().Add(tokenTTL)
|
|
||||||
s.tokens[token] = tokenEntry{deviceID: deviceID, expiresAt: expiresAt}
|
|
||||||
return token, expiresAt, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Consume 驗證並消費一個 token(one-time)。single-flight 持鎖:
|
|
||||||
// 查存在 + 比對 deviceID + 未過期 + 刪除,全部在同一 Lock 內完成。
|
|
||||||
//
|
|
||||||
// 成功 → 回 nil(token 已從 store 移除,不可再用)。
|
|
||||||
// 失敗(不存在 / 過期 / deviceId 不符)→ 回 ErrTokenInvalid。
|
|
||||||
//
|
|
||||||
// deviceID 比對用 ConstantTimeCompare(雖然 deviceID 非高機密,維持一致的常數時間比對紀律)。
|
|
||||||
func (s *TokenStore) Consume(token, deviceID string) error {
|
|
||||||
if token == "" {
|
|
||||||
return ErrTokenInvalid
|
|
||||||
}
|
|
||||||
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
|
|
||||||
entry, ok := s.tokens[token]
|
|
||||||
if !ok {
|
|
||||||
return ErrTokenInvalid
|
|
||||||
}
|
|
||||||
|
|
||||||
// 不論後續成功與否,one-time 語意要求「命中即刪」——刪除放在最前面,
|
|
||||||
// 確保兩個併發 consume 只有第一個拿到 entry、第二個 map 查不到。
|
|
||||||
delete(s.tokens, token)
|
|
||||||
|
|
||||||
// 過期檢查(惰性)。
|
|
||||||
if !s.now().Before(entry.expiresAt) {
|
|
||||||
return ErrTokenInvalid
|
|
||||||
}
|
|
||||||
|
|
||||||
// deviceID 綁定檢查(常數時間比對)。
|
|
||||||
if subtle.ConstantTimeCompare([]byte(entry.deviceID), []byte(deviceID)) != 1 {
|
|
||||||
return ErrTokenInvalid
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsLimitErr 回報 err 是否為「達 token 上限」(給 handler 對應 429)。
|
|
||||||
// 讓 handlers 套件不需 import sentinel error 即可判斷。
|
|
||||||
func (s *TokenStore) IsLimitErr(err error) bool {
|
|
||||||
return errors.Is(err, ErrTokenLimit)
|
|
||||||
}
|
|
||||||
|
|
||||||
// pruneExpiredLocked 移除所有已過期的 token。呼叫端必須已持有 mu。
|
|
||||||
func (s *TokenStore) pruneExpiredLocked() {
|
|
||||||
now := s.now()
|
|
||||||
for tok, entry := range s.tokens {
|
|
||||||
if !now.Before(entry.expiresAt) {
|
|
||||||
delete(s.tokens, tok)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// pruneExpired 是背景 goroutine 用的加鎖版本。
|
|
||||||
func (s *TokenStore) pruneExpired() {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
s.pruneExpiredLocked()
|
|
||||||
}
|
|
||||||
|
|
||||||
// len 回傳目前未使用 token 數(測試用)。
|
|
||||||
func (s *TokenStore) len() int {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
return len(s.tokens)
|
|
||||||
}
|
|
||||||
|
|
||||||
// StartCleanup 啟動背景清理 goroutine,每 tokenCleanupPeriod 掃一次過期 token。
|
|
||||||
// 惰性清理(Consume/Issue 時)+ 背景清理雙保險。
|
|
||||||
// 回傳 stop 函式(給測試 / graceful shutdown 用)。
|
|
||||||
func (s *TokenStore) StartCleanup() (stop func()) {
|
|
||||||
ticker := time.NewTicker(tokenCleanupPeriod)
|
|
||||||
done := make(chan struct{})
|
|
||||||
go func() {
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ticker.C:
|
|
||||||
s.pruneExpired()
|
|
||||||
case <-done:
|
|
||||||
ticker.Stop()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
return func() { close(done) }
|
|
||||||
}
|
|
||||||
@ -1,213 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"sync"
|
|
||||||
"sync/atomic"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestTokenStore_IssueConsume_HappyPath:發放後可消費一次,第二次消費失敗(one-time)。
|
|
||||||
func TestTokenStore_IssueConsume_HappyPath(t *testing.T) {
|
|
||||||
s := NewTokenStore()
|
|
||||||
|
|
||||||
token, expiresAt, err := s.Issue("dev-1")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Issue error: %v", err)
|
|
||||||
}
|
|
||||||
if token == "" {
|
|
||||||
t.Fatal("token 不應為空")
|
|
||||||
}
|
|
||||||
if !expiresAt.After(time.Now()) {
|
|
||||||
t.Errorf("expiresAt %v 應在未來", expiresAt)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 第一次消費成功
|
|
||||||
if err := s.Consume(token, "dev-1"); err != nil {
|
|
||||||
t.Fatalf("第一次 Consume 應成功,got %v", err)
|
|
||||||
}
|
|
||||||
// 第二次消費必失敗(one-time)
|
|
||||||
if err := s.Consume(token, "dev-1"); !errors.Is(err, ErrTokenInvalid) {
|
|
||||||
t.Errorf("第二次 Consume 應 ErrTokenInvalid,got %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestTokenStore_Consume_DeviceMismatch:deviceId 不符 → ErrTokenInvalid,且 token 已被消費。
|
|
||||||
func TestTokenStore_Consume_DeviceMismatch(t *testing.T) {
|
|
||||||
s := NewTokenStore()
|
|
||||||
token, _, _ := s.Issue("dev-1")
|
|
||||||
|
|
||||||
// deviceId 不符 → 失敗
|
|
||||||
if err := s.Consume(token, "dev-2"); !errors.Is(err, ErrTokenInvalid) {
|
|
||||||
t.Errorf("deviceId 不符應 ErrTokenInvalid,got %v", err)
|
|
||||||
}
|
|
||||||
// 即使不符,token 也應已被移除(命中即刪,防以正確 deviceId 重試)
|
|
||||||
if err := s.Consume(token, "dev-1"); !errors.Is(err, ErrTokenInvalid) {
|
|
||||||
t.Errorf("不符後 token 應已被消費,got %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestTokenStore_Consume_MissingAndEmpty:不存在 / 空字串 token → ErrTokenInvalid。
|
|
||||||
func TestTokenStore_Consume_MissingAndEmpty(t *testing.T) {
|
|
||||||
s := NewTokenStore()
|
|
||||||
if err := s.Consume("nonexistent", "dev-1"); !errors.Is(err, ErrTokenInvalid) {
|
|
||||||
t.Errorf("不存在 token 應 ErrTokenInvalid,got %v", err)
|
|
||||||
}
|
|
||||||
if err := s.Consume("", "dev-1"); !errors.Is(err, ErrTokenInvalid) {
|
|
||||||
t.Errorf("空 token 應 ErrTokenInvalid,got %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestTokenStore_Expiry:過期 token 消費失敗。用可注入的 now 模擬時間流逝。
|
|
||||||
func TestTokenStore_Expiry(t *testing.T) {
|
|
||||||
s := NewTokenStore()
|
|
||||||
base := time.Now()
|
|
||||||
current := base
|
|
||||||
s.now = func() time.Time { return current }
|
|
||||||
|
|
||||||
token, _, _ := s.Issue("dev-1")
|
|
||||||
|
|
||||||
// 前進超過 TTL
|
|
||||||
current = base.Add(tokenTTL + time.Second)
|
|
||||||
|
|
||||||
if err := s.Consume(token, "dev-1"); !errors.Is(err, ErrTokenInvalid) {
|
|
||||||
t.Errorf("過期 token 應 ErrTokenInvalid,got %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestTokenStore_Limit:未使用 token 達 32 上限 → ErrTokenLimit;消費一個後可再發。
|
|
||||||
func TestTokenStore_Limit(t *testing.T) {
|
|
||||||
s := NewTokenStore()
|
|
||||||
|
|
||||||
tokens := make([]string, 0, maxUnusedTokens)
|
|
||||||
for i := 0; i < maxUnusedTokens; i++ {
|
|
||||||
tok, _, err := s.Issue("dev-1")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("第 %d 個 Issue 不應失敗,got %v", i, err)
|
|
||||||
}
|
|
||||||
tokens = append(tokens, tok)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 第 33 個應被拒
|
|
||||||
if _, _, err := s.Issue("dev-1"); !errors.Is(err, ErrTokenLimit) {
|
|
||||||
t.Errorf("達上限應 ErrTokenLimit,got %v", err)
|
|
||||||
}
|
|
||||||
if !s.IsLimitErr(ErrTokenLimit) {
|
|
||||||
t.Error("IsLimitErr(ErrTokenLimit) 應為 true")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 消費一個後釋放名額,可再發
|
|
||||||
if err := s.Consume(tokens[0], "dev-1"); err != nil {
|
|
||||||
t.Fatalf("Consume 應成功,got %v", err)
|
|
||||||
}
|
|
||||||
if _, _, err := s.Issue("dev-1"); err != nil {
|
|
||||||
t.Errorf("釋放名額後 Issue 應成功,got %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestTokenStore_Limit_ExpiredFreesSlot:過期 token 在 Issue 時被惰性清理,釋放上限名額。
|
|
||||||
func TestTokenStore_Limit_ExpiredFreesSlot(t *testing.T) {
|
|
||||||
s := NewTokenStore()
|
|
||||||
base := time.Now()
|
|
||||||
current := base
|
|
||||||
s.now = func() time.Time { return current }
|
|
||||||
|
|
||||||
for i := 0; i < maxUnusedTokens; i++ {
|
|
||||||
if _, _, err := s.Issue("dev-1"); err != nil {
|
|
||||||
t.Fatalf("第 %d 個 Issue 失敗: %v", i, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 全部過期
|
|
||||||
current = base.Add(tokenTTL + time.Second)
|
|
||||||
|
|
||||||
// 再 Issue 應觸發惰性清理、成功
|
|
||||||
if _, _, err := s.Issue("dev-1"); err != nil {
|
|
||||||
t.Errorf("過期清理後 Issue 應成功,got %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestTokenStore_ConcurrentConsume_SingleFlight 是 security m2 的關鍵測試:
|
|
||||||
// 多個 goroutine 同時消費同一 token,必須「恰好一個成功」(防 one-time 失效 / 雙重消費)。
|
|
||||||
func TestTokenStore_ConcurrentConsume_SingleFlight(t *testing.T) {
|
|
||||||
const goroutines = 200
|
|
||||||
|
|
||||||
// 跑多輪,提高抓到 race 的機率。
|
|
||||||
for round := 0; round < 50; round++ {
|
|
||||||
s := NewTokenStore()
|
|
||||||
token, _, _ := s.Issue("dev-1")
|
|
||||||
|
|
||||||
var successCount int32
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
start := make(chan struct{})
|
|
||||||
wg.Add(goroutines)
|
|
||||||
for i := 0; i < goroutines; i++ {
|
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
<-start // 同時起跑,最大化競爭
|
|
||||||
if err := s.Consume(token, "dev-1"); err == nil {
|
|
||||||
atomic.AddInt32(&successCount, 1)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
close(start)
|
|
||||||
wg.Wait()
|
|
||||||
|
|
||||||
if successCount != 1 {
|
|
||||||
t.Fatalf("round %d: 併發消費同一 token 成功數 = %d,必須恰好 1(single-flight 失效)",
|
|
||||||
round, successCount)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestTokenStore_ConcurrentIssue_LimitHeld 是 security m2 的另一半:
|
|
||||||
// 併發 Issue 時,未使用 token 數不得突破 32 上限。
|
|
||||||
func TestTokenStore_ConcurrentIssue_LimitHeld(t *testing.T) {
|
|
||||||
const goroutines = 200
|
|
||||||
|
|
||||||
for round := 0; round < 30; round++ {
|
|
||||||
s := NewTokenStore()
|
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
start := make(chan struct{})
|
|
||||||
wg.Add(goroutines)
|
|
||||||
for i := 0; i < goroutines; i++ {
|
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
<-start
|
|
||||||
_, _, _ = s.Issue("dev-1")
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
close(start)
|
|
||||||
wg.Wait()
|
|
||||||
|
|
||||||
if got := s.len(); got > maxUnusedTokens {
|
|
||||||
t.Fatalf("round %d: 併發 Issue 後 token 數 = %d,不得超過上限 %d(上限檢查非 atomic)",
|
|
||||||
round, got, maxUnusedTokens)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestTokenStore_TokenUniqueness:連續發放的 token 值不重複。
|
|
||||||
func TestTokenStore_TokenUniqueness(t *testing.T) {
|
|
||||||
s := NewTokenStore()
|
|
||||||
seen := make(map[string]bool)
|
|
||||||
for i := 0; i < maxUnusedTokens; i++ {
|
|
||||||
tok, _, err := s.Issue("dev-1")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Issue 失敗: %v", err)
|
|
||||||
}
|
|
||||||
if seen[tok] {
|
|
||||||
t.Fatalf("token 重複: %q", tok)
|
|
||||||
}
|
|
||||||
seen[tok] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestTokenStore_Cleanup_StopWorks:StartCleanup 回傳的 stop 可正常關閉 goroutine。
|
|
||||||
func TestTokenStore_Cleanup_StopWorks(t *testing.T) {
|
|
||||||
s := NewTokenStore()
|
|
||||||
stop := s.StartCleanup()
|
|
||||||
// 立即停止不應 panic / deadlock
|
|
||||||
stop()
|
|
||||||
}
|
|
||||||
@ -92,12 +92,6 @@ type Deps struct {
|
|||||||
// fallback,不需真 tunnel。詳見 device_driver_status.go。
|
// fallback,不需真 tunnel。詳見 device_driver_status.go。
|
||||||
DriverStatusFetcher driverStatusFetcher
|
DriverStatusFetcher driverStatusFetcher
|
||||||
|
|
||||||
// LocalTokenIssuer 是 POST /api/devices/:id/local-upload-ticket(ADR-019 WP-5)的可選注入點。
|
|
||||||
// 為 nil 時 handler 從 Forwarder + SessionStore 組 default(forwarderLocalTokenIssuer,經
|
|
||||||
// 既有 tunnel 打 local-agent /api/local/issue-token)。unit test 注入 stub 驗成功 / 429 /
|
|
||||||
// tunnel 錯誤分支,不需真 tunnel。詳見 local_upload_ticket.go。
|
|
||||||
LocalTokenIssuer localTokenIssuer
|
|
||||||
|
|
||||||
DeviceRepo device.Repository
|
DeviceRepo device.Repository
|
||||||
ModelRepo model.Repository
|
ModelRepo model.Repository
|
||||||
|
|
||||||
|
|||||||
@ -39,12 +39,6 @@ func registerDeviceRoutes(g *gin.RouterGroup, deps Deps) {
|
|||||||
|
|
||||||
// Unpair(雛形實作:軟刪 DeviceRepo + CloseSession)
|
// Unpair(雛形實作:軟刪 DeviceRepo + CloseSession)
|
||||||
g.POST("/devices/:id/unpair", devicesUnpairHandler(deps))
|
g.POST("/devices/:id/unpair", devicesUnpairHandler(deps))
|
||||||
|
|
||||||
// ADR-019 WP-5:localhost 直連上傳的 one-time token 取得路徑(經既有 tunnel 打
|
|
||||||
// local-agent issue-token)。契約 path 為 /api/devices/:serial/local-upload-ticket,
|
|
||||||
// 但 gin/httprouter 要求同層級同名,故沿用 :id 佔位(其值語意為裝置序號 serial,
|
|
||||||
// handler 用它走 GetBySerial 做歸屬檢查)。見 local_upload_ticket.go。
|
|
||||||
g.POST("/devices/:id/local-upload-ticket", localUploadTicketHandler(deps))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeviceListItem 是 GET /api/devices 回應中的單筆裝置。
|
// DeviceListItem 是 GET /api/devices 回應中的單筆裝置。
|
||||||
|
|||||||
@ -1,316 +0,0 @@
|
|||||||
// local_upload_ticket.go — POST /api/devices/:id/local-upload-ticket 的雲端 ticket handler(ADR-019 WP-5)。
|
|
||||||
//
|
|
||||||
// 背景(ADR-019 §2.4 認證流程 [1]):影片 / 圖片 / 批次上傳改走「同機瀏覽器直連 local-agent
|
|
||||||
// 的 localhost endpoint」(繞過 tunnel,解決大檔頻寬雙倍 + nginx 100M + 300s timeout)。但開放
|
|
||||||
// 雲端 origin 直連 local-agent 後,必須有認證作為第二道防線(CORS 白名單擋不住 XSS / DNS
|
|
||||||
// rebinding)。one-time token 的**取得路徑**刻意保留走既有已認證 tunnel(控制面):
|
|
||||||
//
|
|
||||||
// [1] 瀏覽器 → 雲端 api-server:POST /api/devices/:id/local-upload-ticket(本 handler)
|
|
||||||
// api-server 驗 OIDC session(AuthMiddleware)+ 裝置歸屬(GetBySerial)
|
|
||||||
// → 經既有 tunnel 轉發打 local-agent:POST /api/local/issue-token(body {"serial":...})
|
|
||||||
// → 回傳 local-agent 產的 one-time token 給瀏覽器
|
|
||||||
// [2..4] 瀏覽器拿 token 掃 localhost port → 帶 X-Visiona-Local-Token 直連上傳(非本 handler 範圍)
|
|
||||||
//
|
|
||||||
// 為什麼走既有 tunnel 而非新機制:token 取得屬控制面,資料量極小(~100 bytes),沿用 devices.go
|
|
||||||
// 的裝置歸屬檢查 + proxy.go / device_driver_status.go 的 tunnel forward 模式即可,零新基礎設施。
|
|
||||||
//
|
|
||||||
// 契約來源:api-spec.md §6.3(POST /api/devices/:serial/local-upload-ticket + POST
|
|
||||||
// /api/local/issue-token 的 request / response 形狀)。local-agent 端 /api/local/issue-token
|
|
||||||
// 由 local-agent stream 另行實作,本 handler **對契約**打即可(path + body 依 api-spec §6.3)。
|
|
||||||
//
|
|
||||||
// 可測性:把「經 tunnel 打 local-agent issue-token」抽成 localTokenIssuer 介面,default 實作
|
|
||||||
// 包 session.Forwarder(走既有 proxy 基礎設施),unit test 注入 stub 驗成功 / 各種錯誤分支,
|
|
||||||
// 不需要真 tunnel。此模式對齊 device_driver_status.go 的 driverStatusFetcher。
|
|
||||||
|
|
||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
|
|
||||||
"visiona-backend/internal/device"
|
|
||||||
"visiona-backend/internal/session"
|
|
||||||
)
|
|
||||||
|
|
||||||
// issueTokenProxyTimeout 是「經 tunnel 打 local-agent issue-token」的整體 timeout。
|
|
||||||
//
|
|
||||||
// 刻意設短(5s):issue-token 是控制面小請求(產一個記憶體 token 立即回),不像 media 上傳可能
|
|
||||||
// 很久。不能讓 local-agent hang 住時把「取 ticket」拖到 defaultProxyRequestTimeout(300s) 那麼久
|
|
||||||
// ——前端還要拿這個 token 去掃 port,整條互動應該是秒級。
|
|
||||||
const issueTokenProxyTimeout = 5 * time.Second
|
|
||||||
|
|
||||||
// localAgentIssueTokenPath 是 local-agent 上「產 one-time upload token」的 endpoint。
|
|
||||||
// 對齊 api-spec.md §6.3 POST /api/local/issue-token(僅經既有 tunnel 由 api-server 轉發呼叫)。
|
|
||||||
const localAgentIssueTokenPath = "/api/local/issue-token"
|
|
||||||
|
|
||||||
// errCodeLocalTokenLimit 是 local-agent issue-token 回傳的「未使用 token 達上限」錯誤碼
|
|
||||||
// (api-spec §6.3 LOCAL_TOKEN_LIMIT)。api-server 據此把 IssueToken 錯誤映射成 429(見
|
|
||||||
// writeLocalTokenError)。此碼是 local-agent 產的、非 api-server 對外碼,故不放進 errors.go
|
|
||||||
// 的雲端錯誤碼常數,只在本檔內部用於解析 local-agent 回應。
|
|
||||||
const errCodeLocalTokenLimit = "LOCAL_TOKEN_LIMIT"
|
|
||||||
|
|
||||||
// LocalUploadTicket 是 POST /api/devices/:id/local-upload-ticket 回應的 data 欄位。
|
|
||||||
//
|
|
||||||
// 直接對齊 api-spec.md §6.3:`{ "token", "expiresAt", "ttlSeconds": 120 }`。
|
|
||||||
// api-server 透傳 local-agent issue-token 的產出,不改寫欄位語意。
|
|
||||||
type LocalUploadTicket struct {
|
|
||||||
// Token 是 local-agent 產的 one-time upload token(瀏覽器帶 X-Visiona-Local-Token 直連上傳)。
|
|
||||||
Token string `json:"token"`
|
|
||||||
// ExpiresAt 是 token 過期時間(unix milliseconds),對齊 api-spec §6.3。
|
|
||||||
ExpiresAt int64 `json:"expiresAt"`
|
|
||||||
// TTLSeconds 是 token 存活秒數(契約固定 120,由 local-agent 決定;api-server 透傳)。
|
|
||||||
TTLSeconds int `json:"ttlSeconds"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// localTokenIssuer 抽象「經 tunnel 向 local-agent 要一個 one-time upload token」。
|
|
||||||
//
|
|
||||||
// 回傳的 LocalUploadTicket 是 local-agent issue-token 的產出(透傳)。error 語意:
|
|
||||||
// - errLocalTokenLimit:local-agent 回 429(未使用 token 達 32 上限)→ caller 透傳 429。
|
|
||||||
// - session.ErrSessionNotFound / ErrSessionClosed:tunnel 離線 → caller 回 502 TUNNEL_DISCONNECTED。
|
|
||||||
// - 其他:local-agent 不可達 / 非預期回應 → caller 回 502 TUNNEL_ERROR。
|
|
||||||
//
|
|
||||||
// default 實作 forwarderLocalTokenIssuer 走既有 session.Forwarder proxy 基礎設施;
|
|
||||||
// unit test 注入 stub 驗各分支,不需要真 tunnel。
|
|
||||||
type localTokenIssuer interface {
|
|
||||||
// IssueToken 經 tunnel 打 local-agent POST /api/local/issue-token(body {"serial":serial})。
|
|
||||||
// userID 用來挑當前 user 的 active session token(與其他 proxy 端點同一套 posture)。
|
|
||||||
IssueToken(ctx context.Context, userID, serial string) (LocalUploadTicket, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// errLocalTokenLimit 表示 local-agent 回 429(未使用 token 達 32 上限,api-spec §6.3
|
|
||||||
// LOCAL_TOKEN_LIMIT)。與傳輸層錯誤(tunnel 離線)語意區隔,讓 handler 能透傳 429 而非 502。
|
|
||||||
var errLocalTokenLimit = errors.New("local agent: unused upload token limit reached")
|
|
||||||
|
|
||||||
// issueTokenRequest 是打 local-agent /api/local/issue-token 的 request body(api-spec §6.3)。
|
|
||||||
type issueTokenRequest struct {
|
|
||||||
Serial string `json:"serial"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// issueTokenEnvelope 是 local-agent /api/local/issue-token 的回應 envelope(api-spec §6.3):
|
|
||||||
//
|
|
||||||
// { "success": true, "data": { "token": "...", "expiresAt": <unix_ms>, "ttlSeconds": 120 } }
|
|
||||||
// { "success": false, "error": { "code": "LOCAL_TOKEN_LIMIT" } } (429)
|
|
||||||
type issueTokenEnvelope struct {
|
|
||||||
Success bool `json:"success"`
|
|
||||||
Data LocalUploadTicket `json:"data"`
|
|
||||||
Error *struct {
|
|
||||||
Code string `json:"code"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
} `json:"error"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// forwarderLocalTokenIssuer 是 localTokenIssuer 的 production 實作:
|
|
||||||
// 透過 session.Forwarder 把 POST /api/local/issue-token 經 tunnel 送到 local-agent。
|
|
||||||
type forwarderLocalTokenIssuer struct {
|
|
||||||
forwarder *session.Forwarder
|
|
||||||
sessionStore session.Store
|
|
||||||
}
|
|
||||||
|
|
||||||
// newForwarderLocalTokenIssuer 從 Deps 組出 default issuer。
|
|
||||||
// forwarder / sessionStore 任一為 nil 時回 nil(caller 據此回 501,代表 tunnel 未配置)。
|
|
||||||
func newForwarderLocalTokenIssuer(deps Deps) localTokenIssuer {
|
|
||||||
if deps.Forwarder == nil || deps.SessionStore == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &forwarderLocalTokenIssuer{
|
|
||||||
forwarder: deps.Forwarder,
|
|
||||||
sessionStore: deps.SessionStore,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// IssueToken 實作 localTokenIssuer。
|
|
||||||
//
|
|
||||||
// 流程(對齊 device_driver_status.go FetchDriverStatus,但目標是 POST issue-token):
|
|
||||||
// 1. 挑當前 user 的 active session token(pickActiveSessionToken)
|
|
||||||
// 2. 組 POST /api/local/issue-token(body {"serial":serial}),經 Forwarder.ForwardHTTP 送到 local-agent
|
|
||||||
// 3. 解 envelope:429 → errLocalTokenLimit;success + 有 token → 回 ticket;其他 → error
|
|
||||||
func (i *forwarderLocalTokenIssuer) IssueToken(ctx context.Context, userID, serial string) (LocalUploadTicket, error) {
|
|
||||||
ctx, cancel := context.WithTimeout(ctx, issueTokenProxyTimeout)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
token, err := pickActiveSessionToken(ctx, i.sessionStore, userID, nil)
|
|
||||||
if err != nil {
|
|
||||||
// tunnel 離線 / 無 active session → 交由 caller 映射 502 TUNNEL_DISCONNECTED。
|
|
||||||
return LocalUploadTicket{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := json.Marshal(issueTokenRequest{Serial: serial})
|
|
||||||
if err != nil {
|
|
||||||
return LocalUploadTicket{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
outReq, err := http.NewRequestWithContext(ctx, http.MethodPost, localAgentIssueTokenPath,
|
|
||||||
strings.NewReader(string(body)))
|
|
||||||
if err != nil {
|
|
||||||
return LocalUploadTicket{}, err
|
|
||||||
}
|
|
||||||
outReq.Header.Set("Content-Type", "application/json")
|
|
||||||
outReq.ContentLength = int64(len(body))
|
|
||||||
|
|
||||||
resp, err := i.forwarder.ForwardHTTP(ctx, token, outReq)
|
|
||||||
if err != nil {
|
|
||||||
// local-agent 不可達 / dial 失敗 / timeout → caller 映射 502 TUNNEL_ERROR。
|
|
||||||
return LocalUploadTicket{}, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
// 限讀 body(issue-token 回應極小;防禦性 64KB 上界,避免異常 local-agent 撐爆記憶體)。
|
|
||||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
|
|
||||||
if err != nil {
|
|
||||||
return LocalUploadTicket{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 429:token 上限已滿 → 透傳 errLocalTokenLimit(不試著解析成功欄位)。
|
|
||||||
if resp.StatusCode == http.StatusTooManyRequests {
|
|
||||||
return LocalUploadTicket{}, errLocalTokenLimit
|
|
||||||
}
|
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
||||||
return LocalUploadTicket{}, errLocalTokenUnavailable
|
|
||||||
}
|
|
||||||
|
|
||||||
var env issueTokenEnvelope
|
|
||||||
if err := json.Unmarshal(raw, &env); err != nil {
|
|
||||||
return LocalUploadTicket{}, err
|
|
||||||
}
|
|
||||||
// 契約允許 local-agent 在 200 body 內用 success:false 表達 LOCAL_TOKEN_LIMIT(雖然主契約走
|
|
||||||
// 429,但兩者都映射到 token 上限,防禦性一併處理)。
|
|
||||||
if !env.Success {
|
|
||||||
if env.Error != nil && env.Error.Code == errCodeLocalTokenLimit {
|
|
||||||
return LocalUploadTicket{}, errLocalTokenLimit
|
|
||||||
}
|
|
||||||
return LocalUploadTicket{}, errLocalTokenUnavailable
|
|
||||||
}
|
|
||||||
if env.Data.Token == "" {
|
|
||||||
return LocalUploadTicket{}, errLocalTokenUnavailable
|
|
||||||
}
|
|
||||||
return env.Data, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// errLocalTokenUnavailable 表示 local-agent 回應存在但沒帶可用 token(非 2xx 且非 429 /
|
|
||||||
// success:false / 空 token)。與 tunnel 傳輸層錯誤語意區隔,caller 統一映射 502 TUNNEL_ERROR。
|
|
||||||
var errLocalTokenUnavailable = errors.New("local agent: upload token unavailable")
|
|
||||||
|
|
||||||
// resolveLocalTokenIssuer 決定要用哪個 issuer:
|
|
||||||
// - Deps.LocalTokenIssuer 非 nil(測試注入 stub)→ 用它
|
|
||||||
// - 否則從 Forwarder + SessionStore 組 default(production)
|
|
||||||
// - 兩者皆缺 → 回 nil(handler 回 501,代表 tunnel 未配置)
|
|
||||||
func resolveLocalTokenIssuer(deps Deps) localTokenIssuer {
|
|
||||||
if deps.LocalTokenIssuer != nil {
|
|
||||||
return deps.LocalTokenIssuer
|
|
||||||
}
|
|
||||||
return newForwarderLocalTokenIssuer(deps)
|
|
||||||
}
|
|
||||||
|
|
||||||
// localUploadTicketHandler 實作 POST /api/devices/:id/local-upload-ticket(ADR-019 WP-5)。
|
|
||||||
//
|
|
||||||
// 註:route 參數名為 `:id`(gin/httprouter 要求同層級同名,devices.go 既有 /devices/:id/*
|
|
||||||
// 已佔用 :id),但語意上是**裝置序號(serial)**——契約 path 為 /api/devices/:serial/...。
|
|
||||||
// 這裡的 :id 值即 serial,用它走 GetBySerial 做裝置歸屬檢查 + 傳給 local-agent。
|
|
||||||
//
|
|
||||||
// 流程:
|
|
||||||
// 1. AuthMiddleware 已驗 OIDC session → 取 UserContext(拿不到 = 500,middleware 設定錯誤)
|
|
||||||
// 2. 裝置歸屬:DeviceRepo.GetBySerial(userID, serial)——查不到 = 該序號不屬於當前 user → 404
|
|
||||||
// (沿用 devices.go 既有 owner 檢查慣例;GetBySerial 本身就綁 ownerUserID,天然阻擋 IDOR)
|
|
||||||
// 3. tunnel_online 檢查(R-3):無 active session → tunnel 離線 → 502 TUNNEL_DISCONNECTED,
|
|
||||||
// 明確告知前端「裝置離線、無法取得上傳 ticket」(issue token 本就需經 tunnel)
|
|
||||||
// 4. 經 tunnel 打 local-agent issue-token → 透傳 token;429 透傳;tunnel 錯誤映射 502
|
|
||||||
func localUploadTicketHandler(deps Deps) gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
if deps.DeviceRepo == nil {
|
|
||||||
WriteNotImplemented(c, "device repo not configured")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
serial := c.Param("id") // :id 語意為 serial,見 handler 註解
|
|
||||||
if serial == "" {
|
|
||||||
WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "device serial required", nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// AuthMiddleware 已驗 OIDC session(見 api.go apiGroup);拿不到 UserContext 代表
|
|
||||||
// middleware 設定錯誤,回 500 比 silent fallback 安全(對齊 devices.go C1 fix)。
|
|
||||||
uc, ok := UserContextFrom(c)
|
|
||||||
if !ok || uc.UserID == "" {
|
|
||||||
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
|
|
||||||
"missing user context (auth middleware misconfigured?)", nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
userID := uc.UserID
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
// 裝置歸屬檢查(沿用 devices.go 慣例):GetBySerial 綁 ownerUserID,查不到即
|
|
||||||
// 「該序號不屬於當前 user」或「不存在」,一律回 404(不洩漏「存在但非你的」以免 enumeration)。
|
|
||||||
d, err := deps.DeviceRepo.GetBySerial(ctx, userID, serial)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, device.ErrNotFound) {
|
|
||||||
WriteError(c, http.StatusNotFound, ErrCodeNotFound,
|
|
||||||
"device not found or not owned by current user", nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// DB 錯誤經 errors.go 映射(PG down → 503、其餘 → 500),不洩漏 raw DB error。
|
|
||||||
WriteDBError(c, deps.Logger, "get device by serial", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
issuer := resolveLocalTokenIssuer(deps)
|
|
||||||
if issuer == nil {
|
|
||||||
// Forwarder / SessionStore 未配置 → 無法經 tunnel 取 token。回 501(非 500),
|
|
||||||
// 語意為「此部署未啟用 tunnel forward」,對齊 proxy.go 的 WriteNotImplemented 慣例。
|
|
||||||
WriteNotImplemented(c, "tunnel forwarder not configured")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 經 tunnel 打 local-agent issue-token(issuer 內部用 d.SerialNumber 走 tunnel)。
|
|
||||||
// 用 DB 記錄的 SerialNumber(已通過歸屬檢查)而非原始 path 值,確保傳給 local-agent 的
|
|
||||||
// 序號與雲端 device 記錄一致。
|
|
||||||
ticket, err := issuer.IssueToken(c.Request.Context(), userID, d.SerialNumber)
|
|
||||||
if err != nil {
|
|
||||||
writeLocalTokenError(c, deps, userID, d.SerialNumber, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
logOrDefault(deps.Logger).Info("local-upload-ticket: issued",
|
|
||||||
"user_id", userID,
|
|
||||||
"serial", d.SerialNumber,
|
|
||||||
"device_id", d.ID,
|
|
||||||
"ttl_seconds", ticket.TTLSeconds,
|
|
||||||
"request_id", RequestIDFrom(c))
|
|
||||||
|
|
||||||
WriteSuccess(c, http.StatusOK, ticket)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeLocalTokenError 把 IssueToken 的 error 映射到統一 API 錯誤格式。
|
|
||||||
//
|
|
||||||
// - errLocalTokenLimit → 429 RATE_LIMITED(透傳 local-agent 的 token 上限;api-spec §6.3
|
|
||||||
// LOCAL_TOKEN_LIMIT 對應 429,這裡用雲端統一的 RATE_LIMITED 碼 + message 標明來源)
|
|
||||||
// - session.ErrSessionNotFound / ErrSessionClosed → 502 TUNNEL_DISCONNECTED(裝置離線,R-3)
|
|
||||||
// - 其他 → 502 TUNNEL_ERROR(local-agent 不可達 / 非預期回應)
|
|
||||||
func writeLocalTokenError(c *gin.Context, deps Deps, userID, serial string, err error) {
|
|
||||||
switch {
|
|
||||||
case errors.Is(err, errLocalTokenLimit):
|
|
||||||
logOrDefault(deps.Logger).Warn("local-upload-ticket: local agent token limit reached",
|
|
||||||
"user_id", userID, "serial", serial, "request_id", RequestIDFrom(c))
|
|
||||||
WriteError(c, http.StatusTooManyRequests, ErrCodeRateLimited,
|
|
||||||
"上傳 token 已達上限,請稍後再試", nil)
|
|
||||||
case errors.Is(err, session.ErrSessionNotFound) || errors.Is(err, session.ErrSessionClosed):
|
|
||||||
// R-3:tunnel 離線時無法取得 token → 明確告知裝置離線(前端據此 disable 上傳)。
|
|
||||||
WriteError(c, http.StatusBadGateway, ErrCodeTunnelDisconnect,
|
|
||||||
"裝置未連線,無法取得上傳 ticket", nil)
|
|
||||||
default:
|
|
||||||
logOrDefault(deps.Logger).Warn("local-upload-ticket: issue token failed",
|
|
||||||
"user_id", userID, "serial", serial, "error", err.Error(),
|
|
||||||
"request_id", RequestIDFrom(c))
|
|
||||||
WriteError(c, http.StatusBadGateway, ErrCodeTunnelError,
|
|
||||||
"取得上傳 ticket 失敗", nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,204 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
|
|
||||||
"visiona-backend/internal/device"
|
|
||||||
"visiona-backend/internal/session"
|
|
||||||
)
|
|
||||||
|
|
||||||
// stubLocalTokenIssuer 是 localTokenIssuer 的測試替身。
|
|
||||||
//
|
|
||||||
// 可設定:回傳的 ticket / error,並記錄呼叫參數,用來驗證 handler 是否有嘗試取 token、
|
|
||||||
// 以及傳的 serial / userID 正確。
|
|
||||||
type stubLocalTokenIssuer struct {
|
|
||||||
ticket LocalUploadTicket
|
|
||||||
err error
|
|
||||||
|
|
||||||
called bool
|
|
||||||
gotUserID string
|
|
||||||
gotSerial string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *stubLocalTokenIssuer) IssueToken(_ context.Context, userID, serial string) (LocalUploadTicket, error) {
|
|
||||||
s.called = true
|
|
||||||
s.gotUserID = userID
|
|
||||||
s.gotSerial = serial
|
|
||||||
return s.ticket, s.err
|
|
||||||
}
|
|
||||||
|
|
||||||
// newLocalTicketFixture 建 router + 塞一顆 device,可注入自訂 Deps 欄位(issuer)。
|
|
||||||
// loginUserID 是「已登入 user」(AuthMiddleware 塞的 UserContext),可與 device owner 不同以驗 IDOR。
|
|
||||||
func newLocalTicketFixture(t *testing.T, d *device.Device, loginUserID string, mutate func(*Deps)) *gin.Engine {
|
|
||||||
t.Helper()
|
|
||||||
repo := device.NewInMemoryRepository()
|
|
||||||
require.NoError(t, repo.Save(context.Background(), d))
|
|
||||||
|
|
||||||
r := gin.New()
|
|
||||||
r.Use(RequestIDMiddleware())
|
|
||||||
r.Use(injectStaticUserContext(loginUserID, ""))
|
|
||||||
g := r.Group("/api")
|
|
||||||
|
|
||||||
deps := Deps{
|
|
||||||
DeviceRepo: repo,
|
|
||||||
SessionStore: &fakeSessionStore{},
|
|
||||||
}
|
|
||||||
if mutate != nil {
|
|
||||||
mutate(&deps)
|
|
||||||
}
|
|
||||||
registerDeviceRoutes(g, deps)
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
|
|
||||||
// postLocalTicket 打 POST /api/devices/:serial/local-upload-ticket 並回 (status, 解出的 body)。
|
|
||||||
func postLocalTicket(t *testing.T, r *gin.Engine, serial string) (int, map[string]any) {
|
|
||||||
t.Helper()
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/devices/"+serial+"/local-upload-ticket", strings.NewReader("{}"))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
r.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
var body map[string]any
|
|
||||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body), "body=%s", w.Body.String())
|
|
||||||
return w.Code, body
|
|
||||||
}
|
|
||||||
|
|
||||||
func ownedDevice() *device.Device {
|
|
||||||
return &device.Device{
|
|
||||||
ID: "dev1", OwnerUserID: "demo-user", Name: "KL520", DeviceType: "kl520",
|
|
||||||
SerialNumber: "0xB906162C",
|
|
||||||
RemoteStatus: device.RemoteStatusOnline,
|
|
||||||
Status: device.USBStatusOnline,
|
|
||||||
CreatedAt: time.Now().UTC(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestLocalTicket_Success 驗證:裝置歸屬 + tunnel 正常時,透傳 local-agent 產的 token。
|
|
||||||
func TestLocalTicket_Success(t *testing.T) {
|
|
||||||
issuer := &stubLocalTokenIssuer{ticket: LocalUploadTicket{
|
|
||||||
Token: "tok_abc123", ExpiresAt: 1700000000000, TTLSeconds: 120,
|
|
||||||
}}
|
|
||||||
r := newLocalTicketFixture(t, ownedDevice(), "demo-user",
|
|
||||||
func(d *Deps) { d.LocalTokenIssuer = issuer })
|
|
||||||
|
|
||||||
code, body := postLocalTicket(t, r, "0xB906162C")
|
|
||||||
require.Equal(t, http.StatusOK, code, "body=%v", body)
|
|
||||||
require.Equal(t, true, body["success"])
|
|
||||||
|
|
||||||
data, ok := body["data"].(map[string]any)
|
|
||||||
require.True(t, ok, "data 應為物件,body=%v", body)
|
|
||||||
assert.Equal(t, "tok_abc123", data["token"])
|
|
||||||
assert.Equal(t, float64(1700000000000), data["expiresAt"])
|
|
||||||
assert.Equal(t, float64(120), data["ttlSeconds"])
|
|
||||||
|
|
||||||
assert.True(t, issuer.called, "應嘗試打 local agent issue-token")
|
|
||||||
assert.Equal(t, "0xB906162C", issuer.gotSerial, "應以 device 記錄的序號打 local agent")
|
|
||||||
assert.Equal(t, "demo-user", issuer.gotUserID, "應帶當前登入 user")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestLocalTicket_DeviceNotOwned_404 驗證:serial 不屬於當前登入 user 時回 404,
|
|
||||||
// 且**完全不打 local agent**(歸屬檢查先於 issue-token)。這是 IDOR 防護的核心路徑。
|
|
||||||
func TestLocalTicket_DeviceNotOwned_404(t *testing.T) {
|
|
||||||
issuer := &stubLocalTokenIssuer{ticket: LocalUploadTicket{Token: "should_not_be_returned"}}
|
|
||||||
// device owner = demo-user;但登入者是 attacker → GetBySerial(attacker, serial) 查不到。
|
|
||||||
r := newLocalTicketFixture(t, ownedDevice(), "attacker",
|
|
||||||
func(d *Deps) { d.LocalTokenIssuer = issuer })
|
|
||||||
|
|
||||||
code, body := postLocalTicket(t, r, "0xB906162C")
|
|
||||||
require.Equal(t, http.StatusNotFound, code, "非 owner 應回 404")
|
|
||||||
errObj, ok := body["error"].(map[string]any)
|
|
||||||
require.True(t, ok, "應有 error 物件,body=%v", body)
|
|
||||||
assert.Equal(t, ErrCodeNotFound, errObj["code"])
|
|
||||||
assert.False(t, issuer.called, "非 owner 不該打 local agent(歸屬檢查先擋)")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestLocalTicket_UnknownSerial_404 驗證:序號不存在(連 owner 自己都沒這顆)→ 404。
|
|
||||||
func TestLocalTicket_UnknownSerial_404(t *testing.T) {
|
|
||||||
issuer := &stubLocalTokenIssuer{}
|
|
||||||
r := newLocalTicketFixture(t, ownedDevice(), "demo-user",
|
|
||||||
func(d *Deps) { d.LocalTokenIssuer = issuer })
|
|
||||||
|
|
||||||
code, body := postLocalTicket(t, r, "0xDEADBEEF") // owner 有 dev1(0xB906162C) 但無此序號
|
|
||||||
require.Equal(t, http.StatusNotFound, code)
|
|
||||||
errObj, _ := body["error"].(map[string]any)
|
|
||||||
assert.Equal(t, ErrCodeNotFound, errObj["code"])
|
|
||||||
assert.False(t, issuer.called)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestLocalTicket_TokenLimit_429 驗證:local-agent 回 token 上限(errLocalTokenLimit)→
|
|
||||||
// 透傳 429 RATE_LIMITED,不當成 500 / 502。
|
|
||||||
func TestLocalTicket_TokenLimit_429(t *testing.T) {
|
|
||||||
issuer := &stubLocalTokenIssuer{err: errLocalTokenLimit}
|
|
||||||
r := newLocalTicketFixture(t, ownedDevice(), "demo-user",
|
|
||||||
func(d *Deps) { d.LocalTokenIssuer = issuer })
|
|
||||||
|
|
||||||
code, body := postLocalTicket(t, r, "0xB906162C")
|
|
||||||
require.Equal(t, http.StatusTooManyRequests, code, "token 上限應透傳 429")
|
|
||||||
errObj, _ := body["error"].(map[string]any)
|
|
||||||
assert.Equal(t, ErrCodeRateLimited, errObj["code"])
|
|
||||||
assert.True(t, issuer.called)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestLocalTicket_TunnelDisconnected_502 驗證:tunnel 離線(session.ErrSessionNotFound)→
|
|
||||||
// 502 TUNNEL_DISCONNECTED(R-3:裝置未連線無法取 token,前端據此 disable 上傳)。
|
|
||||||
func TestLocalTicket_TunnelDisconnected_502(t *testing.T) {
|
|
||||||
issuer := &stubLocalTokenIssuer{err: session.ErrSessionNotFound}
|
|
||||||
r := newLocalTicketFixture(t, ownedDevice(), "demo-user",
|
|
||||||
func(d *Deps) { d.LocalTokenIssuer = issuer })
|
|
||||||
|
|
||||||
code, body := postLocalTicket(t, r, "0xB906162C")
|
|
||||||
require.Equal(t, http.StatusBadGateway, code)
|
|
||||||
errObj, _ := body["error"].(map[string]any)
|
|
||||||
assert.Equal(t, ErrCodeTunnelDisconnect, errObj["code"])
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestLocalTicket_TunnelError_502 驗證:local-agent 不可達 / 非預期回應(errLocalTokenUnavailable)
|
|
||||||
// → 502 TUNNEL_ERROR。
|
|
||||||
func TestLocalTicket_TunnelError_502(t *testing.T) {
|
|
||||||
issuer := &stubLocalTokenIssuer{err: errLocalTokenUnavailable}
|
|
||||||
r := newLocalTicketFixture(t, ownedDevice(), "demo-user",
|
|
||||||
func(d *Deps) { d.LocalTokenIssuer = issuer })
|
|
||||||
|
|
||||||
code, body := postLocalTicket(t, r, "0xB906162C")
|
|
||||||
require.Equal(t, http.StatusBadGateway, code)
|
|
||||||
errObj, _ := body["error"].(map[string]any)
|
|
||||||
assert.Equal(t, ErrCodeTunnelError, errObj["code"])
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestLocalTicket_NoIssuer_501 驗證:Forwarder/SessionStore 未配置(resolveLocalTokenIssuer
|
|
||||||
// 回 nil)→ 501 NOT_IMPLEMENTED,而非 panic / 500。
|
|
||||||
func TestLocalTicket_NoIssuer_501(t *testing.T) {
|
|
||||||
// 不注入 LocalTokenIssuer,且 Deps.Forwarder 為 nil → newForwarderLocalTokenIssuer 回 nil。
|
|
||||||
// fixture 預設有 SessionStore 但無 Forwarder,故 default issuer 為 nil。
|
|
||||||
r := newLocalTicketFixture(t, ownedDevice(), "demo-user", nil)
|
|
||||||
|
|
||||||
code, body := postLocalTicket(t, r, "0xB906162C")
|
|
||||||
require.Equal(t, http.StatusNotImplemented, code)
|
|
||||||
errObj, _ := body["error"].(map[string]any)
|
|
||||||
assert.Equal(t, ErrCodeNotImplemented, errObj["code"])
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestResolveLocalTokenIssuer_NilWhenNoForwarder 驗證:Forwarder 為 nil 時 default issuer 為 nil。
|
|
||||||
func TestResolveLocalTokenIssuer_NilWhenNoForwarder(t *testing.T) {
|
|
||||||
assert.Nil(t, resolveLocalTokenIssuer(Deps{SessionStore: &fakeSessionStore{}}),
|
|
||||||
"Forwarder 為 nil 應回 nil issuer")
|
|
||||||
assert.Nil(t, resolveLocalTokenIssuer(Deps{}),
|
|
||||||
"Forwarder + SessionStore 皆 nil 應回 nil issuer")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestResolveLocalTokenIssuer_InjectedWins 驗證:Deps.LocalTokenIssuer 非 nil 時優先用注入的 stub。
|
|
||||||
func TestResolveLocalTokenIssuer_InjectedWins(t *testing.T) {
|
|
||||||
stub := &stubLocalTokenIssuer{}
|
|
||||||
got := resolveLocalTokenIssuer(Deps{LocalTokenIssuer: stub})
|
|
||||||
assert.Same(t, stub, got)
|
|
||||||
}
|
|
||||||
@ -1,363 +0,0 @@
|
|||||||
/**
|
|
||||||
* local-agent.ts 單元測試(ADR-019 WP-3)
|
|
||||||
*
|
|
||||||
* 對契約 mock(不需實機瀏覽器 / 不需 local-agent):
|
|
||||||
* - computeSerialHash:SHA-256("visiona-local-v1" || serial) hex(用真 Web Crypto 驗算)
|
|
||||||
* - probeLocalAgentPort:快取 → 3721 → 3722–3740 並發、timeout、無回應
|
|
||||||
* - resolveLocalAgent:OK / NOT_FOUND / MISMATCH 三狀態
|
|
||||||
* - uploadToLocalAgent:打對 loopback URL、帶 token header、不帶 cookie、錯誤碼映射
|
|
||||||
*/
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
import {
|
|
||||||
LOCAL_AGENT_PORT_END,
|
|
||||||
LOCAL_AGENT_PORT_START,
|
|
||||||
LOCAL_TOKEN_HEADER,
|
|
||||||
LOCAL_UPLOAD_VIDEO_PATH,
|
|
||||||
PORT_CACHE_KEY,
|
|
||||||
SERIAL_HASH_SALT,
|
|
||||||
computeSerialHash,
|
|
||||||
probeLocalAgentPort,
|
|
||||||
resolveLocalAgent,
|
|
||||||
uploadToLocalAgent,
|
|
||||||
} from "./local-agent";
|
|
||||||
|
|
||||||
/* -------------------------------------------------------------------------- */
|
|
||||||
/* 工具:組 hello envelope、算 hash 的獨立參考實作 */
|
|
||||||
/* -------------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
function helloOk(serialHashes: string[]): Response {
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
success: true,
|
|
||||||
data: { serialHashes, supportsLocalUpload: true },
|
|
||||||
}),
|
|
||||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 用 Node 內建 crypto 獨立算一次(避免「用 SUT 驗 SUT」)。 */
|
|
||||||
async function refHash(serial: string): Promise<string> {
|
|
||||||
const { createHash } = await import("node:crypto");
|
|
||||||
return createHash("sha256")
|
|
||||||
.update(`${SERIAL_HASH_SALT}${serial}`)
|
|
||||||
.digest("hex");
|
|
||||||
}
|
|
||||||
|
|
||||||
/* -------------------------------------------------------------------------- */
|
|
||||||
/* computeSerialHash */
|
|
||||||
/* -------------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
describe("computeSerialHash", () => {
|
|
||||||
it("SHA-256(salt || serial) 的 lowercase hex,與獨立 node:crypto 實作一致", async () => {
|
|
||||||
const serial = "KN12345678";
|
|
||||||
const got = await computeSerialHash(serial);
|
|
||||||
const ref = await refHash(serial);
|
|
||||||
expect(got).toBe(ref);
|
|
||||||
expect(got).toMatch(/^[0-9a-f]{64}$/); // 64 hex chars, lowercase
|
|
||||||
});
|
|
||||||
|
|
||||||
it("salt 常數為 visiona-local-v1(契約校驗,前後端共用寫死)", () => {
|
|
||||||
expect(SERIAL_HASH_SALT).toBe("visiona-local-v1");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("不同 serial → 不同 hash", async () => {
|
|
||||||
expect(await computeSerialHash("KN-A")).not.toBe(await computeSerialHash("KN-B"));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
/* -------------------------------------------------------------------------- */
|
|
||||||
/* probeLocalAgentPort / resolveLocalAgent(mock fetch) */
|
|
||||||
/* -------------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
describe("port 探測 + 同機判定", () => {
|
|
||||||
const origFetch = globalThis.fetch;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
globalThis.sessionStorage?.clear();
|
|
||||||
});
|
|
||||||
afterEach(() => {
|
|
||||||
globalThis.fetch = origFetch;
|
|
||||||
globalThis.sessionStorage?.clear();
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
/** 只在指定 port 回 hello,其餘 reject(模擬無回應)。 */
|
|
||||||
function mockFetchOnPort(targetPort: number, serialHashes: string[]) {
|
|
||||||
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
|
|
||||||
const url = String(input);
|
|
||||||
if (url.includes(`:${targetPort}/api/local/hello`)) {
|
|
||||||
return helloOk(serialHashes);
|
|
||||||
}
|
|
||||||
throw new TypeError("Failed to fetch"); // 其他 port 無回應
|
|
||||||
}) as typeof fetch;
|
|
||||||
}
|
|
||||||
|
|
||||||
it("3721 有回應 → 回傳該 port 並寫入快取", async () => {
|
|
||||||
mockFetchOnPort(3721, ["abc"]);
|
|
||||||
const hit = await probeLocalAgentPort();
|
|
||||||
expect(hit.port).toBe(3721);
|
|
||||||
expect(globalThis.sessionStorage?.getItem(PORT_CACHE_KEY)).toBe("3721");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("3721 無回應、3730 有回應 → 並發掃描命中 3730", async () => {
|
|
||||||
mockFetchOnPort(3730, ["abc"]);
|
|
||||||
const hit = await probeLocalAgentPort();
|
|
||||||
expect(hit.port).toBe(3730);
|
|
||||||
expect(hit.port).toBeGreaterThanOrEqual(LOCAL_AGENT_PORT_START);
|
|
||||||
expect(hit.port).toBeLessThanOrEqual(LOCAL_AGENT_PORT_END);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("快取命中 → 只打快取 port(不重掃全範圍)", async () => {
|
|
||||||
globalThis.sessionStorage?.setItem(PORT_CACHE_KEY, "3735");
|
|
||||||
const fetchSpy = vi.fn(async (input: RequestInfo | URL) => {
|
|
||||||
if (String(input).includes(":3735/api/local/hello")) return helloOk(["abc"]);
|
|
||||||
throw new TypeError("Failed to fetch");
|
|
||||||
});
|
|
||||||
globalThis.fetch = fetchSpy as typeof fetch;
|
|
||||||
|
|
||||||
const hit = await probeLocalAgentPort();
|
|
||||||
expect(hit.port).toBe(3735);
|
|
||||||
// 快取命中應只打 1 次(不對 20 個 port 發請求)
|
|
||||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("快取失效 → 清快取並重掃到真實 port", async () => {
|
|
||||||
globalThis.sessionStorage?.setItem(PORT_CACHE_KEY, "3739"); // 舊 port 已無 agent
|
|
||||||
mockFetchOnPort(3721, ["abc"]);
|
|
||||||
const hit = await probeLocalAgentPort();
|
|
||||||
expect(hit.port).toBe(3721);
|
|
||||||
expect(globalThis.sessionStorage?.getItem(PORT_CACHE_KEY)).toBe("3721");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("全範圍無回應 → probeLocalAgentPort reject", async () => {
|
|
||||||
globalThis.fetch = vi.fn(async () => {
|
|
||||||
throw new TypeError("Failed to fetch");
|
|
||||||
}) as typeof fetch;
|
|
||||||
await expect(probeLocalAgentPort()).rejects.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("resolveLocalAgent:serial 相符 → OK + port", async () => {
|
|
||||||
const serial = "KN-DEVICE-1";
|
|
||||||
const hash = await refHash(serial);
|
|
||||||
mockFetchOnPort(3721, [hash, "other"]);
|
|
||||||
|
|
||||||
const res = await resolveLocalAgent(serial);
|
|
||||||
expect(res.status).toBe("OK");
|
|
||||||
expect(res.port).toBe(3721);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("resolveLocalAgent:無回應 → NOT_FOUND", async () => {
|
|
||||||
globalThis.fetch = vi.fn(async () => {
|
|
||||||
throw new TypeError("Failed to fetch");
|
|
||||||
}) as typeof fetch;
|
|
||||||
const res = await resolveLocalAgent("KN-DEVICE-1");
|
|
||||||
expect(res.status).toBe("NOT_FOUND");
|
|
||||||
expect(res.port).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("resolveLocalAgent:有回應但 serial 不符 → MISMATCH + 清快取", async () => {
|
|
||||||
const otherHash = await refHash("KN-OTHER-DEVICE");
|
|
||||||
mockFetchOnPort(3721, [otherHash]);
|
|
||||||
|
|
||||||
const res = await resolveLocalAgent("KN-DEVICE-1");
|
|
||||||
expect(res.status).toBe("MISMATCH");
|
|
||||||
expect(res.port).toBeUndefined();
|
|
||||||
// MISMATCH 應清快取,避免下次又先撞別台 agent 的 port
|
|
||||||
expect(globalThis.sessionStorage?.getItem(PORT_CACHE_KEY)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("hello 回應格式不符(缺 serialHashes)→ 視為該 port 無效", async () => {
|
|
||||||
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
|
|
||||||
if (String(input).includes(":3721/api/local/hello")) {
|
|
||||||
return new Response(JSON.stringify({ success: true, data: { foo: 1 } }), {
|
|
||||||
status: 200,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
throw new TypeError("Failed to fetch");
|
|
||||||
}) as typeof fetch;
|
|
||||||
// 3721 格式不符、其他 port 無回應 → 整體 NOT_FOUND
|
|
||||||
const res = await resolveLocalAgent("KN-DEVICE-1");
|
|
||||||
expect(res.status).toBe("NOT_FOUND");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
/* -------------------------------------------------------------------------- */
|
|
||||||
/* uploadToLocalAgent(mock XMLHttpRequest) */
|
|
||||||
/* -------------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
interface FakeXHR {
|
|
||||||
method?: string;
|
|
||||||
url?: string;
|
|
||||||
withCredentials?: boolean;
|
|
||||||
timeout?: number;
|
|
||||||
sent?: FormData;
|
|
||||||
headers: Record<string, string>;
|
|
||||||
status: number;
|
|
||||||
responseText: string;
|
|
||||||
upload: { onprogress: ((ev: ProgressEvent) => void) | null };
|
|
||||||
onload: (() => void) | null;
|
|
||||||
onerror: (() => void) | null;
|
|
||||||
ontimeout: (() => void) | null;
|
|
||||||
open(method: string, url: string): void;
|
|
||||||
setRequestHeader(k: string, v: string): void;
|
|
||||||
send(body: FormData): void;
|
|
||||||
abort(): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let lastXhr: FakeXHR | null = null;
|
|
||||||
|
|
||||||
function installXhrMock(responder: (xhr: FakeXHR) => void) {
|
|
||||||
function XHRMock(this: unknown) {
|
|
||||||
const xhr: FakeXHR = {
|
|
||||||
withCredentials: true, // 預設 true,讓測試能驗證 SUT 主動設回 false
|
|
||||||
timeout: 0,
|
|
||||||
status: 200,
|
|
||||||
responseText: "",
|
|
||||||
headers: {},
|
|
||||||
upload: { onprogress: null },
|
|
||||||
onload: null,
|
|
||||||
onerror: null,
|
|
||||||
ontimeout: null,
|
|
||||||
open(method: string, url: string) {
|
|
||||||
xhr.method = method;
|
|
||||||
xhr.url = url;
|
|
||||||
},
|
|
||||||
setRequestHeader(k: string, v: string) {
|
|
||||||
xhr.headers[k] = v;
|
|
||||||
},
|
|
||||||
send(body: FormData) {
|
|
||||||
xhr.sent = body;
|
|
||||||
queueMicrotask(() => responder(xhr));
|
|
||||||
},
|
|
||||||
abort() {},
|
|
||||||
};
|
|
||||||
lastXhr = xhr;
|
|
||||||
return xhr;
|
|
||||||
}
|
|
||||||
(globalThis as { XMLHttpRequest?: unknown }).XMLHttpRequest =
|
|
||||||
XHRMock as unknown as typeof XMLHttpRequest;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("uploadToLocalAgent(XHR mock)", () => {
|
|
||||||
const origXHR = globalThis.XMLHttpRequest;
|
|
||||||
beforeEach(() => {
|
|
||||||
lastXhr = null;
|
|
||||||
});
|
|
||||||
afterEach(() => {
|
|
||||||
(globalThis as { XMLHttpRequest?: unknown }).XMLHttpRequest = origXHR;
|
|
||||||
});
|
|
||||||
|
|
||||||
function makeForm(): FormData {
|
|
||||||
const form = new FormData();
|
|
||||||
form.append("deviceId", "KN-DEVICE-1");
|
|
||||||
form.append(
|
|
||||||
"file",
|
|
||||||
new File([new Blob([new Uint8Array(8)])], "v.mp4"),
|
|
||||||
);
|
|
||||||
return form;
|
|
||||||
}
|
|
||||||
|
|
||||||
it("POST 到 loopback URL、帶 token header、不帶 cookie、回傳 data", async () => {
|
|
||||||
installXhrMock((xhr) => {
|
|
||||||
xhr.status = 200;
|
|
||||||
xhr.responseText = JSON.stringify({
|
|
||||||
success: true,
|
|
||||||
data: { streamUrl: "/api/camera/stream", sourceType: "video", totalFrames: 100 },
|
|
||||||
});
|
|
||||||
xhr.onload?.();
|
|
||||||
});
|
|
||||||
|
|
||||||
const res = await uploadToLocalAgent(3721, LOCAL_UPLOAD_VIDEO_PATH, makeForm(), {
|
|
||||||
token: "tok-abc",
|
|
||||||
});
|
|
||||||
expect(res.sourceType).toBe("video");
|
|
||||||
expect(lastXhr?.method).toBe("POST");
|
|
||||||
expect(lastXhr?.url).toBe("http://127.0.0.1:3721/api/local/media/upload/video");
|
|
||||||
// 直連 loopback:不帶 cookie
|
|
||||||
expect(lastXhr?.withCredentials).toBe(false);
|
|
||||||
// token 走 header、非 URL
|
|
||||||
expect(lastXhr?.headers[LOCAL_TOKEN_HEADER]).toBe("tok-abc");
|
|
||||||
expect(lastXhr?.url).not.toContain("tok-abc");
|
|
||||||
expect(lastXhr?.sent?.get("deviceId")).toBe("KN-DEVICE-1");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("401 → 映射 LOCAL_TOKEN_INVALID(envelope 有 code 時保留 code)", async () => {
|
|
||||||
installXhrMock((xhr) => {
|
|
||||||
xhr.status = 401;
|
|
||||||
xhr.responseText = JSON.stringify({
|
|
||||||
success: false,
|
|
||||||
error: { code: "LOCAL_TOKEN_INVALID", message: "token invalid" },
|
|
||||||
});
|
|
||||||
xhr.onload?.();
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
uploadToLocalAgent(3721, LOCAL_UPLOAD_VIDEO_PATH, makeForm(), { token: "bad" }),
|
|
||||||
).rejects.toMatchObject({ code: "LOCAL_TOKEN_INVALID", status: 401 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("413 → 映射 LOCAL_UPLOAD_TOO_LARGE", async () => {
|
|
||||||
installXhrMock((xhr) => {
|
|
||||||
xhr.status = 413;
|
|
||||||
xhr.responseText = JSON.stringify({
|
|
||||||
success: false,
|
|
||||||
error: { code: "LOCAL_UPLOAD_TOO_LARGE", message: "too large" },
|
|
||||||
});
|
|
||||||
xhr.onload?.();
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
uploadToLocalAgent(3721, LOCAL_UPLOAD_VIDEO_PATH, makeForm(), { token: "t" }),
|
|
||||||
).rejects.toMatchObject({ code: "LOCAL_UPLOAD_TOO_LARGE", status: 413 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("401 無 error envelope → fallback 為 LOCAL_TOKEN_INVALID", async () => {
|
|
||||||
installXhrMock((xhr) => {
|
|
||||||
xhr.status = 401;
|
|
||||||
xhr.responseText = "";
|
|
||||||
xhr.onload?.();
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
uploadToLocalAgent(3721, LOCAL_UPLOAD_VIDEO_PATH, makeForm(), { token: "t" }),
|
|
||||||
).rejects.toMatchObject({ code: "LOCAL_TOKEN_INVALID", status: 401 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("回報上傳進度", async () => {
|
|
||||||
installXhrMock((xhr) => {
|
|
||||||
xhr.upload.onprogress?.({
|
|
||||||
lengthComputable: true,
|
|
||||||
loaded: 25,
|
|
||||||
total: 100,
|
|
||||||
} as ProgressEvent);
|
|
||||||
xhr.status = 200;
|
|
||||||
xhr.responseText = JSON.stringify({
|
|
||||||
success: true,
|
|
||||||
data: { streamUrl: "/s", sourceType: "video" },
|
|
||||||
});
|
|
||||||
xhr.onload?.();
|
|
||||||
});
|
|
||||||
|
|
||||||
const onProgress = vi.fn();
|
|
||||||
await uploadToLocalAgent(3721, LOCAL_UPLOAD_VIDEO_PATH, makeForm(), {
|
|
||||||
token: "t",
|
|
||||||
onProgress,
|
|
||||||
});
|
|
||||||
expect(onProgress).toHaveBeenCalledWith(25);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("已 abort 的 signal → 立即拋 AbortError(不送出)", async () => {
|
|
||||||
installXhrMock(() => {
|
|
||||||
/* 不該被呼叫 */
|
|
||||||
});
|
|
||||||
const ctrl = new AbortController();
|
|
||||||
ctrl.abort();
|
|
||||||
await expect(
|
|
||||||
uploadToLocalAgent(3721, LOCAL_UPLOAD_VIDEO_PATH, makeForm(), {
|
|
||||||
token: "t",
|
|
||||||
signal: ctrl.signal,
|
|
||||||
}),
|
|
||||||
).rejects.toMatchObject({ code: "ABORTED" });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,443 +0,0 @@
|
|||||||
/**
|
|
||||||
* Local-agent localhost 直連工具 — visionA Cloud 前端(ADR-019 WP-3)
|
|
||||||
*
|
|
||||||
* 職責(純 client util、endpoint 無關):
|
|
||||||
* - `probeLocalAgentPort()`:探測本機 local-agent 的動態 port
|
|
||||||
* (sessionStorage 快取 → 3721 → 3722–3740 並發,各 timeout 500ms,打 GET /api/local/hello)。
|
|
||||||
* - `resolveLocalAgent(serial)`:探測 + 同機判定 + serial 身分驗證,回傳 { port } 或
|
|
||||||
* LOCAL_AGENT_NOT_FOUND(無回應)/ LOCAL_AGENT_MISMATCH(有回應但 serial 不符)。
|
|
||||||
* - `uploadToLocalAgent(port, path, form, options)`:endpoint 無關的通用上傳函式
|
|
||||||
* (三個 caller 換 path 即可),打 http://127.0.0.1:<port><path>,帶 X-Visiona-Local-Token。
|
|
||||||
*
|
|
||||||
* 為什麼是獨立檔(不併進 media.ts):
|
|
||||||
* media.ts 的上傳走雲端 same-origin cookie session(BFF);local-agent 直連走的是
|
|
||||||
* loopback 絕對 URL + one-time token header,認證模型完全不同(ADR-019 §2.1 混合路徑)。
|
|
||||||
* 拆開避免兩套認證邏輯混淆。
|
|
||||||
*
|
|
||||||
* 契約來源(不自行更改,見 api-spec §6.2–6.5 / ADR-019 §2.3):
|
|
||||||
* - port 範圍 3721–3740(local-agent pickPort 動態)
|
|
||||||
* - GET /api/local/hello 回 { serialHashes: string[], supportsLocalUpload: boolean }
|
|
||||||
* - serialHashes[i] = SHA-256("visiona-local-v1" || fullSerial) 的 lowercase hex
|
|
||||||
* - salt 常數 "visiona-local-v1" 前後端共用寫死
|
|
||||||
* - 上傳路徑 /api/local/media/upload/{video|image|batch-images},Header X-Visiona-Local-Token
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
|
||||||
AbortError,
|
|
||||||
ApiError,
|
|
||||||
NetworkError,
|
|
||||||
TimeoutError,
|
|
||||||
} from "@/lib/api";
|
|
||||||
import type { ApiErrorShape } from "@/types/api";
|
|
||||||
import type { MediaUploadResponse } from "@/types/camera";
|
|
||||||
import type { UploadMediaOptions } from "@/lib/media";
|
|
||||||
|
|
||||||
/* -------------------------------------------------------------------------- */
|
|
||||||
/* 契約常數 */
|
|
||||||
/* -------------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
/** local-agent loopback host(強制綁 127.0.0.1,見 local-agent server/config.go)。 */
|
|
||||||
export const LOCAL_AGENT_HOST = "127.0.0.1";
|
|
||||||
|
|
||||||
/** port 探測範圍(local-agent pickPort 3721 → 3740 fallback,ADR-019 §2.3)。 */
|
|
||||||
export const LOCAL_AGENT_PORT_START = 3721;
|
|
||||||
export const LOCAL_AGENT_PORT_END = 3740;
|
|
||||||
|
|
||||||
/** 每次探測單一 port 的 timeout(毫秒,ADR-019 §2.3)。 */
|
|
||||||
export const PROBE_TIMEOUT_MS = 500;
|
|
||||||
|
|
||||||
/** sessionStorage 快取「上次探到的 port」的 key(同分頁 session 內免重掃)。 */
|
|
||||||
export const PORT_CACHE_KEY = "visiona.localAgent.port";
|
|
||||||
|
|
||||||
/** 同機偵測 / 身分驗證用的固定公開 salt(前後端共用寫死,api-spec §6.3;**不可改**)。 */
|
|
||||||
export const SERIAL_HASH_SALT = "visiona-local-v1";
|
|
||||||
|
|
||||||
/** 探測 endpoint 路徑(專用、非 /api/system/health,ADR-019 §2.3)。 */
|
|
||||||
export const LOCAL_HELLO_PATH = "/api/local/hello";
|
|
||||||
|
|
||||||
/** 上傳 token 的 request header 名(api-spec §6.2)。 */
|
|
||||||
export const LOCAL_TOKEN_HEADER = "X-Visiona-Local-Token";
|
|
||||||
|
|
||||||
/** 直連上傳 route 路徑(endpoint 無關函式的 caller 換這三個之一)。 */
|
|
||||||
export const LOCAL_UPLOAD_IMAGE_PATH = "/api/local/media/upload/image";
|
|
||||||
export const LOCAL_UPLOAD_VIDEO_PATH = "/api/local/media/upload/video";
|
|
||||||
export const LOCAL_UPLOAD_BATCH_PATH = "/api/local/media/upload/batch-images";
|
|
||||||
|
|
||||||
/* -------------------------------------------------------------------------- */
|
|
||||||
/* 型別 */
|
|
||||||
/* -------------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
/** GET /api/local/hello 回傳的 data(api-spec §6.3,最小揭露)。 */
|
|
||||||
export interface LocalHelloData {
|
|
||||||
/** SHA-256("visiona-local-v1" || fullSerial) 的 lowercase hex 陣列。 */
|
|
||||||
serialHashes: string[];
|
|
||||||
/** 是否支援 local upload(布林,取代原 agentVersion)。 */
|
|
||||||
supportsLocalUpload: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 探測到的單一候選(某 port 有回應且回了 hello data)。 */
|
|
||||||
interface ProbeHit {
|
|
||||||
port: number;
|
|
||||||
data: LocalHelloData;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* resolveLocalAgent 的結果碼(對齊 api-spec §6.5 前端內部狀態):
|
|
||||||
* - OK 找到同機且 serial 相符的 agent
|
|
||||||
* - NOT_FOUND 掃描無任何回應(非同機 / agent 沒跑)→ LOCAL_AGENT_NOT_FOUND
|
|
||||||
* - MISMATCH 有回應但沒有任一 serial 相符 → LOCAL_AGENT_MISMATCH
|
|
||||||
*/
|
|
||||||
export type LocalAgentResolveStatus = "OK" | "NOT_FOUND" | "MISMATCH";
|
|
||||||
|
|
||||||
export interface LocalAgentResolveResult {
|
|
||||||
status: LocalAgentResolveStatus;
|
|
||||||
/** status === "OK" 時為探到的 port;否則 undefined。 */
|
|
||||||
port?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* -------------------------------------------------------------------------- */
|
|
||||||
/* SHA-256 serial hash(Web Crypto,前端獨立重算比對) */
|
|
||||||
/* -------------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 算 `SHA-256("visiona-local-v1" || serial)` 的 lowercase hex。
|
|
||||||
*
|
|
||||||
* 用 Web Crypto `crypto.subtle.digest`(jsdom / Node 20+ / Chrome / Edge 皆有)。
|
|
||||||
* 與後端 salted SHA-256 契約一致(api-spec §6.3),供同機身分比對。
|
|
||||||
*/
|
|
||||||
export async function computeSerialHash(serial: string): Promise<string> {
|
|
||||||
const bytes = new TextEncoder().encode(`${SERIAL_HASH_SALT}${serial}`);
|
|
||||||
const digest = await crypto.subtle.digest("SHA-256", bytes);
|
|
||||||
return bytesToHex(new Uint8Array(digest));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Uint8Array → lowercase hex 字串(與後端 hex.EncodeToString 對齊)。 */
|
|
||||||
function bytesToHex(bytes: Uint8Array): string {
|
|
||||||
let hex = "";
|
|
||||||
for (const b of bytes) {
|
|
||||||
hex += b.toString(16).padStart(2, "0");
|
|
||||||
}
|
|
||||||
return hex;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* -------------------------------------------------------------------------- */
|
|
||||||
/* sessionStorage 快取 */
|
|
||||||
/* -------------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
/** 讀 sessionStorage 快取的 port(無效 / 不存在 / 超出範圍 → null)。 */
|
|
||||||
function readCachedPort(): number | null {
|
|
||||||
try {
|
|
||||||
const raw = globalThis.sessionStorage?.getItem(PORT_CACHE_KEY);
|
|
||||||
if (!raw) return null;
|
|
||||||
const port = Number.parseInt(raw, 10);
|
|
||||||
if (
|
|
||||||
Number.isInteger(port) &&
|
|
||||||
port >= LOCAL_AGENT_PORT_START &&
|
|
||||||
port <= LOCAL_AGENT_PORT_END
|
|
||||||
) {
|
|
||||||
return port;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
} catch {
|
|
||||||
// sessionStorage 不可用(SSR / 隱私模式)→ 當作無快取
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 寫 sessionStorage 快取(失敗靜默——快取只是最佳化,不可用不影響功能)。 */
|
|
||||||
function writeCachedPort(port: number): void {
|
|
||||||
try {
|
|
||||||
globalThis.sessionStorage?.setItem(PORT_CACHE_KEY, String(port));
|
|
||||||
} catch {
|
|
||||||
// ignore:快取寫入失敗不影響探測結果
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 清掉快取(快取的 port 探測失敗時呼叫,避免下次又先撞舊 port)。 */
|
|
||||||
function clearCachedPort(): void {
|
|
||||||
try {
|
|
||||||
globalThis.sessionStorage?.removeItem(PORT_CACHE_KEY);
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* -------------------------------------------------------------------------- */
|
|
||||||
/* 單一 port 探測 */
|
|
||||||
/* -------------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 打單一 port 的 GET /api/local/hello,timeout 500ms。
|
|
||||||
*
|
|
||||||
* 回傳 ProbeHit(成功且拿到合法 hello data)或 reject(無回應 / 非預期格式 / timeout)。
|
|
||||||
* 這裡**只判斷「有沒有 local-agent 在這個 port」**,不做 serial 比對(比對在 resolveLocalAgent)。
|
|
||||||
*/
|
|
||||||
async function probeSinglePort(port: number): Promise<ProbeHit> {
|
|
||||||
const url = `http://${LOCAL_AGENT_HOST}:${port}${LOCAL_HELLO_PATH}`;
|
|
||||||
const ctrl = new AbortController();
|
|
||||||
const timeoutId = setTimeout(() => ctrl.abort("probe-timeout"), PROBE_TIMEOUT_MS);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(url, {
|
|
||||||
method: "GET",
|
|
||||||
signal: ctrl.signal,
|
|
||||||
// 直連 local-agent 用 header token、不需 cookie(api-spec §6.4 Allow-Credentials: false)
|
|
||||||
credentials: "omit",
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(`hello returned HTTP ${res.status}`);
|
|
||||||
}
|
|
||||||
const parsed: unknown = await res.json();
|
|
||||||
const data = extractHelloData(parsed);
|
|
||||||
if (!data) {
|
|
||||||
throw new Error("hello response shape invalid");
|
|
||||||
}
|
|
||||||
return { port, data };
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 從 hello 回應解 envelope 取 data,並驗證 serialHashes 型別。回傳 null 表格式不符。 */
|
|
||||||
function extractHelloData(parsed: unknown): LocalHelloData | null {
|
|
||||||
if (
|
|
||||||
!parsed ||
|
|
||||||
typeof parsed !== "object" ||
|
|
||||||
!("success" in parsed) ||
|
|
||||||
(parsed as { success: unknown }).success !== true ||
|
|
||||||
!("data" in parsed)
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const data = (parsed as { data: unknown }).data;
|
|
||||||
if (!data || typeof data !== "object" || !("serialHashes" in data)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const hashes = (data as { serialHashes: unknown }).serialHashes;
|
|
||||||
if (!Array.isArray(hashes) || !hashes.every((h) => typeof h === "string")) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const supports =
|
|
||||||
"supportsLocalUpload" in data
|
|
||||||
? Boolean((data as { supportsLocalUpload: unknown }).supportsLocalUpload)
|
|
||||||
: false;
|
|
||||||
return { serialHashes: hashes as string[], supportsLocalUpload: supports };
|
|
||||||
}
|
|
||||||
|
|
||||||
/* -------------------------------------------------------------------------- */
|
|
||||||
/* port 探測(快取 → 3721 → 3722–3740 並發) */
|
|
||||||
/* -------------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 探測本機 local-agent 的 port,回傳第一個有回應者的 ProbeHit。
|
|
||||||
*
|
|
||||||
* 策略(ADR-019 §2.3):
|
|
||||||
* 1. sessionStorage 快取的 port(同分頁 session 內免重掃)
|
|
||||||
* 2. 3721(最常見的預設 port)
|
|
||||||
* 3. 3722–3740 並發(Promise.any 取第一個成功者)
|
|
||||||
* 每次 timeout 500ms。全部失敗 → reject(呼叫端視為 NOT_FOUND)。
|
|
||||||
*
|
|
||||||
* 為什麼分三段而非一次全並發:
|
|
||||||
* 快取 / 3721 命中率最高,先試可避免每次都對 20 個 port 發 preflight(成本 + 觸發防火牆告警)。
|
|
||||||
*/
|
|
||||||
export async function probeLocalAgentPort(): Promise<ProbeHit> {
|
|
||||||
// 1) 快取
|
|
||||||
const cached = readCachedPort();
|
|
||||||
if (cached !== null) {
|
|
||||||
try {
|
|
||||||
const hit = await probeSinglePort(cached);
|
|
||||||
writeCachedPort(hit.port);
|
|
||||||
return hit;
|
|
||||||
} catch {
|
|
||||||
// 快取失效(agent 換 port / 沒跑)→ 清掉,往下重掃
|
|
||||||
clearCachedPort();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) 3721 預設 port(避開重複試快取剛失敗的那個)
|
|
||||||
if (cached !== LOCAL_AGENT_PORT_START) {
|
|
||||||
try {
|
|
||||||
const hit = await probeSinglePort(LOCAL_AGENT_PORT_START);
|
|
||||||
writeCachedPort(hit.port);
|
|
||||||
return hit;
|
|
||||||
} catch {
|
|
||||||
// 往下並發掃剩餘範圍
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3) 3722–3740 並發,取第一個成功者
|
|
||||||
const rest: number[] = [];
|
|
||||||
for (let p = LOCAL_AGENT_PORT_START + 1; p <= LOCAL_AGENT_PORT_END; p++) {
|
|
||||||
if (p !== cached) rest.push(p);
|
|
||||||
}
|
|
||||||
if (rest.length === 0) {
|
|
||||||
throw new NetworkError("No local-agent found on any candidate port");
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const hit = await Promise.any(rest.map((p) => probeSinglePort(p)));
|
|
||||||
writeCachedPort(hit.port);
|
|
||||||
return hit;
|
|
||||||
} catch {
|
|
||||||
// Promise.any 全 reject → AggregateError
|
|
||||||
throw new NetworkError("No local-agent found on any candidate port");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* -------------------------------------------------------------------------- */
|
|
||||||
/* 同機判定 + serial 身分驗證 */
|
|
||||||
/* -------------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 探測 + 同機判定 + serial 身分驗證。
|
|
||||||
*
|
|
||||||
* @param serial 目前選定裝置的 serialNumber(kn_number;ADR-018 serial 路由)
|
|
||||||
* @returns
|
|
||||||
* - { status: "OK", port } 找到同機 agent 且其 serialHashes 含此 serial 的雜湊
|
|
||||||
* - { status: "NOT_FOUND" } 掃描無回應(非同機 / agent 沒跑)→ LOCAL_AGENT_NOT_FOUND
|
|
||||||
* - { status: "MISMATCH" } 有回應但無任一 serial 相符(同機跑著別台 agent)→ LOCAL_AGENT_MISMATCH
|
|
||||||
*
|
|
||||||
* 為什麼比對 serial:防「同機跑著另一台 agent、影片被送到錯的裝置」的靜默錯誤(ADR-019 §2.3 / R-4)。
|
|
||||||
*/
|
|
||||||
export async function resolveLocalAgent(
|
|
||||||
serial: string,
|
|
||||||
): Promise<LocalAgentResolveResult> {
|
|
||||||
let hit: ProbeHit;
|
|
||||||
try {
|
|
||||||
hit = await probeLocalAgentPort();
|
|
||||||
} catch {
|
|
||||||
return { status: "NOT_FOUND" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const expected = await computeSerialHash(serial);
|
|
||||||
if (hit.data.serialHashes.includes(expected)) {
|
|
||||||
return { status: "OK", port: hit.port };
|
|
||||||
}
|
|
||||||
// 有回應但 serial 不符 → 快取的 port 可能是別台 agent,清掉避免誤導下次
|
|
||||||
clearCachedPort();
|
|
||||||
return { status: "MISMATCH" };
|
|
||||||
}
|
|
||||||
|
|
||||||
/* -------------------------------------------------------------------------- */
|
|
||||||
/* 通用上傳(endpoint 無關,帶 token header) */
|
|
||||||
/* -------------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
/** uploadToLocalAgent 的選項:沿用 media 的進度 / 取消 / timeout,加 token。 */
|
|
||||||
export interface LocalUploadOptions extends UploadMediaOptions {
|
|
||||||
/** one-time upload token(經雲端 ticket 取得,放 X-Visiona-Local-Token header)。 */
|
|
||||||
token: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 以 XHR + FormData 直連 local-agent 上傳 multipart(endpoint 無關)。
|
|
||||||
*
|
|
||||||
* 三個 caller(image / video / batch)只需換 `path`:
|
|
||||||
* LOCAL_UPLOAD_IMAGE_PATH | LOCAL_UPLOAD_VIDEO_PATH | LOCAL_UPLOAD_BATCH_PATH
|
|
||||||
*
|
|
||||||
* @param port probeLocalAgentPort / resolveLocalAgent 探到的 port
|
|
||||||
* @param path 直連上傳 route 相對路徑(/api/local/media/upload/*)
|
|
||||||
* @param form 已組好的 FormData(含 deviceId + file/files,格式與雲端 route 相同)
|
|
||||||
* @param options token + 進度 / 取消 / timeout
|
|
||||||
* @returns 解開 envelope 的 MediaUploadResponse(與雲端 route 回傳格式相同)
|
|
||||||
* @throws ApiError | NetworkError | TimeoutError | AbortError(與 api.ts 一致的錯誤體系)
|
|
||||||
*
|
|
||||||
* 認證與雲端 route 不同:
|
|
||||||
* - 走絕對 loopback URL(http://127.0.0.1:<port>),非 same-origin
|
|
||||||
* - credentials 不帶 cookie(withCredentials = false;api-spec §6.4 Allow-Credentials: false)
|
|
||||||
* - token 放 header X-Visiona-Local-Token(不放 URL,避免洩漏到 log / referrer)
|
|
||||||
*/
|
|
||||||
export function uploadToLocalAgent(
|
|
||||||
port: number,
|
|
||||||
path: string,
|
|
||||||
form: FormData,
|
|
||||||
options: LocalUploadOptions,
|
|
||||||
): Promise<MediaUploadResponse> {
|
|
||||||
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
|
||||||
const url = `http://${LOCAL_AGENT_HOST}:${port}${normalizedPath}`;
|
|
||||||
|
|
||||||
return new Promise<MediaUploadResponse>((resolve, reject) => {
|
|
||||||
const xhr = new XMLHttpRequest();
|
|
||||||
xhr.open("POST", url, true);
|
|
||||||
// 直連 loopback:不帶 cookie(token 走 header)
|
|
||||||
xhr.withCredentials = false;
|
|
||||||
// 不設 Content-Type,讓瀏覽器帶 multipart boundary
|
|
||||||
xhr.setRequestHeader(LOCAL_TOKEN_HEADER, options.token);
|
|
||||||
|
|
||||||
if (options.timeoutMs && options.timeoutMs > 0) {
|
|
||||||
xhr.timeout = options.timeoutMs;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.onProgress) {
|
|
||||||
xhr.upload.onprogress = (ev) => {
|
|
||||||
if (ev.lengthComputable) {
|
|
||||||
options.onProgress!(
|
|
||||||
Math.min(100, Math.round((ev.loaded / ev.total) * 100)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
xhr.onload = () => {
|
|
||||||
let parsed: unknown = null;
|
|
||||||
try {
|
|
||||||
parsed = xhr.responseText ? JSON.parse(xhr.responseText) : null;
|
|
||||||
} catch {
|
|
||||||
// 非 JSON body
|
|
||||||
}
|
|
||||||
|
|
||||||
if (xhr.status >= 200 && xhr.status < 300) {
|
|
||||||
if (
|
|
||||||
parsed &&
|
|
||||||
typeof parsed === "object" &&
|
|
||||||
"success" in parsed &&
|
|
||||||
(parsed as { success: boolean }).success === true &&
|
|
||||||
"data" in parsed
|
|
||||||
) {
|
|
||||||
resolve((parsed as { data: MediaUploadResponse }).data);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
reject(
|
|
||||||
new ApiError(xhr.status, {
|
|
||||||
code: "PARSE_ERROR",
|
|
||||||
message: "Unexpected upload response shape",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// non-2xx:盡量取 envelope 的 error(LOCAL_TOKEN_INVALID / LOCAL_UPLOAD_TOO_LARGE 等)
|
|
||||||
let errShape: ApiErrorShape = {
|
|
||||||
code: xhr.status === 401 ? "LOCAL_TOKEN_INVALID" : "INTERNAL_ERROR",
|
|
||||||
message: `Local upload failed: HTTP ${xhr.status}`,
|
|
||||||
};
|
|
||||||
if (
|
|
||||||
parsed &&
|
|
||||||
typeof parsed === "object" &&
|
|
||||||
"error" in parsed &&
|
|
||||||
(parsed as { error?: unknown }).error
|
|
||||||
) {
|
|
||||||
errShape = (parsed as { error: ApiErrorShape }).error;
|
|
||||||
}
|
|
||||||
reject(new ApiError(xhr.status, errShape));
|
|
||||||
};
|
|
||||||
|
|
||||||
xhr.onerror = () => reject(new NetworkError(`Local upload to ${url} failed`));
|
|
||||||
xhr.ontimeout = () => reject(new TimeoutError(`Local upload to ${url} timed out`));
|
|
||||||
|
|
||||||
if (options.signal) {
|
|
||||||
if (options.signal.aborted) {
|
|
||||||
reject(new AbortError());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
options.signal.addEventListener(
|
|
||||||
"abort",
|
|
||||||
() => {
|
|
||||||
xhr.abort();
|
|
||||||
reject(new AbortError());
|
|
||||||
},
|
|
||||||
{ once: true },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
xhr.send(form);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -11,7 +11,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
MAX_BATCH_IMAGES,
|
MAX_BATCH_IMAGES,
|
||||||
MAX_BATCH_TOTAL_BYTES,
|
|
||||||
MAX_VIDEO_BYTES,
|
MAX_VIDEO_BYTES,
|
||||||
VIDEO_FALLBACK_FPS,
|
VIDEO_FALLBACK_FPS,
|
||||||
buildBatchImageUrl,
|
buildBatchImageUrl,
|
||||||
@ -86,41 +85,6 @@ describe("media validation", () => {
|
|||||||
|
|
||||||
expect(validateBatchFiles([makeFile("a.jpg"), makeFile("b.png")])).toBeNull();
|
expect(validateBatchFiles([makeFile("a.jpg"), makeFile("b.png")])).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("批次合計上限:常數為 80 MB(校驗)", () => {
|
|
||||||
expect(MAX_BATCH_TOTAL_BYTES).toBe(80 * 1024 * 1024);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("批次合計上限:50 張各 19MB(合計 950MB)→ TOTAL_SIZE(原地雷)", () => {
|
|
||||||
const mb = 1024 * 1024;
|
|
||||||
const files = Array.from({ length: 50 }, (_, i) =>
|
|
||||||
makeSizedFile(`img${i}.jpg`, 19 * mb),
|
|
||||||
);
|
|
||||||
// 逐張都 ≤20MB 會通過單檔檢查,但合計 950MB 應被合計上限擋下
|
|
||||||
expect(validateBatchFiles(files)?.code).toBe("TOTAL_SIZE");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("批次合計上限:剛好 80MB 通過、超過 1 byte 擋 TOTAL_SIZE(邊界)", () => {
|
|
||||||
const mb = 1024 * 1024;
|
|
||||||
// 8 張各 10MB = 80MB,剛好等於上限(用 > 判斷 → 通過)
|
|
||||||
const exactly = Array.from({ length: 8 }, (_, i) =>
|
|
||||||
makeSizedFile(`e${i}.jpg`, 10 * mb),
|
|
||||||
);
|
|
||||||
expect(validateBatchFiles(exactly)).toBeNull();
|
|
||||||
|
|
||||||
// 在 80MB 基礎上多 1 byte → 超過上限
|
|
||||||
const over = [
|
|
||||||
...Array.from({ length: 8 }, (_, i) => makeSizedFile(`o${i}.jpg`, 10 * mb)),
|
|
||||||
makeSizedFile("extra.jpg", 1),
|
|
||||||
];
|
|
||||||
expect(validateBatchFiles(over)?.code).toBe("TOTAL_SIZE");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("批次合計上限:單檔超限(SIZE)優先於合計檢查", () => {
|
|
||||||
// 一張 21MB(單檔超 20MB 上限)→ 應回 SIZE 而非 TOTAL_SIZE
|
|
||||||
const files = [makeSizedFile("big.jpg", 21 * 1024 * 1024)];
|
|
||||||
expect(validateBatchFiles(files)?.code).toBe("SIZE");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("frameToSeekSeconds", () => {
|
describe("frameToSeekSeconds", () => {
|
||||||
|
|||||||
@ -93,16 +93,6 @@ export const VIDEO_ACCEPT = ".mp4,.avi,.mov,.mpeg,.mpg";
|
|||||||
export const MAX_IMAGE_BYTES = 20 * 1024 * 1024; // 20 MB
|
export const MAX_IMAGE_BYTES = 20 * 1024 * 1024; // 20 MB
|
||||||
export const MAX_VIDEO_BYTES = 90 * 1024 * 1024; // 90 MB(過渡值:對齊 nginx client_max_body_size 100M,留 10 MB buffer 給 multipart overhead,避免 HTTP 413。未來影片走 localhost 直連後可放寬)
|
export const MAX_VIDEO_BYTES = 90 * 1024 * 1024; // 90 MB(過渡值:對齊 nginx client_max_body_size 100M,留 10 MB buffer 給 multipart overhead,避免 HTTP 413。未來影片走 localhost 直連後可放寬)
|
||||||
|
|
||||||
/**
|
|
||||||
* 批次上傳「合計大小」上限(對齊 ADR-019 §2.2 / 後端 batch 合計 80MB)。
|
|
||||||
*
|
|
||||||
* 為什麼要加:`validateBatchFiles` 原本只逐張檢查 ≤ MAX_IMAGE_BYTES(20MB)、**不加總**——
|
|
||||||
* 50 張各 19MB(合計 950MB)會通過前端驗證後撞後端 size 上限(LOCAL_UPLOAD_TOO_LARGE / 413)。
|
|
||||||
* 補此合計上限把地雷擋在前端,給使用者明確錯誤而非到後端才 413。
|
|
||||||
* 值 = 80MB(留 nginx 100M 的 20% 餘裕,對 localhost 直連仍保留為資源上界,見 ADR-019 §4.3.1)。
|
|
||||||
*/
|
|
||||||
export const MAX_BATCH_TOTAL_BYTES = 80 * 1024 * 1024; // 80 MB
|
|
||||||
|
|
||||||
export interface UploadMediaOptions {
|
export interface UploadMediaOptions {
|
||||||
/** 上傳進度 callback(0~100) */
|
/** 上傳進度 callback(0~100) */
|
||||||
onProgress?: (percent: number) => void;
|
onProgress?: (percent: number) => void;
|
||||||
@ -279,18 +269,9 @@ export function buildBatchImageUrl(index: number, cacheBust?: string): string {
|
|||||||
return `${full}?_t=${encodeURIComponent(cacheBust)}`;
|
return `${full}?_t=${encodeURIComponent(cacheBust)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 前端檔案驗證結果(給 UI 顯示錯誤用;不信任副檔名,也擋大小)。 */
|
||||||
* 前端檔案驗證結果(給 UI 顯示錯誤用;不信任副檔名,也擋大小)。
|
|
||||||
*
|
|
||||||
* code 語意:
|
|
||||||
* - `TYPE` 副檔名不在白名單
|
|
||||||
* - `SIZE` 單檔超過上限(image 20MB / video 90MB)
|
|
||||||
* - `COUNT` 批次張數超過 MAX_BATCH_IMAGES
|
|
||||||
* - `EMPTY` 批次為空
|
|
||||||
* - `TOTAL_SIZE` 批次「合計」超過 MAX_BATCH_TOTAL_BYTES(ADR-019 §2.2;無 filename)
|
|
||||||
*/
|
|
||||||
export interface FileValidationError {
|
export interface FileValidationError {
|
||||||
code: "TYPE" | "SIZE" | "COUNT" | "EMPTY" | "TOTAL_SIZE";
|
code: "TYPE" | "SIZE" | "COUNT" | "EMPTY";
|
||||||
filename?: string;
|
filename?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -316,24 +297,13 @@ export function validateVideoFile(file: File): FileValidationError | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 驗證整批圖片(數量 + 每張型別 / 大小)。回傳 null 表通過。 */
|
||||||
* 驗證整批圖片(數量 + 每張型別 / 大小 + 合計大小)。回傳 null 表通過。
|
|
||||||
*
|
|
||||||
* 檢查順序(先擋明確錯、再累加):
|
|
||||||
* 1. 空陣列 → EMPTY
|
|
||||||
* 2. 張數超限 → COUNT
|
|
||||||
* 3. 逐張型別 / 單檔大小 → TYPE / SIZE(沿用 validateImageFile)
|
|
||||||
* 4. 合計大小超過 MAX_BATCH_TOTAL_BYTES → TOTAL_SIZE(ADR-019 §2.2,補逐張檢查漏掉的加總地雷)
|
|
||||||
*/
|
|
||||||
export function validateBatchFiles(files: File[]): FileValidationError | null {
|
export function validateBatchFiles(files: File[]): FileValidationError | null {
|
||||||
if (files.length === 0) return { code: "EMPTY" };
|
if (files.length === 0) return { code: "EMPTY" };
|
||||||
if (files.length > MAX_BATCH_IMAGES) return { code: "COUNT" };
|
if (files.length > MAX_BATCH_IMAGES) return { code: "COUNT" };
|
||||||
let totalBytes = 0;
|
|
||||||
for (const f of files) {
|
for (const f of files) {
|
||||||
const err = validateImageFile(f);
|
const err = validateImageFile(f);
|
||||||
if (err) return err;
|
if (err) return err;
|
||||||
totalBytes += f.size;
|
|
||||||
}
|
}
|
||||||
if (totalBytes > MAX_BATCH_TOTAL_BYTES) return { code: "TOTAL_SIZE" };
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -41,12 +41,6 @@ export type KnownErrorCode =
|
|||||||
| "NOT_IMPLEMENTED"
|
| "NOT_IMPLEMENTED"
|
||||||
| "RATE_LIMITED"
|
| "RATE_LIMITED"
|
||||||
| "INTERNAL_ERROR"
|
| "INTERNAL_ERROR"
|
||||||
// ADR-019 local-agent 直連(api-spec §6.5)
|
|
||||||
| "LOCAL_AGENT_NOT_FOUND" // 前端內部狀態:掃描無回應(非同機 / agent 沒跑)
|
|
||||||
| "LOCAL_AGENT_MISMATCH" // 前端內部狀態:有回應但 serial 不符
|
|
||||||
| "LOCAL_TOKEN_INVALID" // 401:token 不存在 / 過期 / 已使用 / 缺失
|
|
||||||
| "LOCAL_TOKEN_LIMIT" // 429:未使用 token 達 32 上限
|
|
||||||
| "LOCAL_UPLOAD_TOO_LARGE" // 413:上傳超過 size 上限
|
|
||||||
// 前端自建,表示「非後端回傳」的錯誤
|
// 前端自建,表示「非後端回傳」的錯誤
|
||||||
| "NETWORK_ERROR"
|
| "NETWORK_ERROR"
|
||||||
| "TIMEOUT"
|
| "TIMEOUT"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user