Compare commits

...

9 Commits

Author SHA1 Message Date
b73c9b7b7e fix(api): 裝置 detail endpoint tunnel 判定給獨立 3s ctx,修 R-3 誤判離線
裝置 detail endpoint(/api/devices/:id)原用單一 2s ctx,先跑 DeviceRepo.Get
殘餘時間才輪到打 relay 的 store.List → tunnel 狀態查詢逾時被靜默判離線
→ 影片分頁 R-3 誤擋上傳(列表頁 3s 判在線、詳情頁 2s 判離線,同裝置相反)。

修法:tunnel 判定改用獨立 ctx(源自 request context、完整 3s、defer cancel),
與 list 對齊;detail 原 2s ctx 保留給 DeviceRepo.Get。list 也一併改獨立 ctx。
未動 resolveTunnelStatus 的逾時判離線 fail-safe 語意,只給足夠時間。

加可觀測性 log(deadline_exceeded / no-matching / 命中三分支,不含敏感資訊),
供 stage 分辨「真逾時」vs「UserID 比對不中」。前端未動(行為正確)。

reviewer 通過(0C/0M)。build/vet/全套 test 綠 + 3 新 test(含 ctx 完整預算斷言)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 18:18:34 +08:00
725ac3cc54
Merge pull request #2 from jim800121/feat/adr-019-wp4-video-wiring
feat(adr-019): 影片分頁接線 localhost 直連,上限 90MB→500MB(WP-4)
2026-07-30 14:23:48 +08:00
b10fbb8091 feat(adr-019): 影片分頁接線 localhost 直連,上限 90MB→500MB(WP-4)
影片分頁上傳從舊 tunnel 路徑(/api/media/upload/video + 90MB)切到同機
localhost 直連(取 token → resolveLocalAgent → /api/local/media/upload/video)。
端到端啟用 ADR-019,取代 90MB 過渡限制。

- 新 lib/local-media.ts 編排層:getLocalUploadTicket + uploadVideoViaLocalAgent
- MAX_LOCAL_VIDEO_BYTES=500MB + validateLocalVideoFile(只綁 localhost 路徑;
  舊 MAX_VIDEO_BYTES=90MB + tunnel uploadVideo 完全不碰,向下相容)
- R-3 tunnel 離線三層防護:UI disable 不渲染 uploader + ticket 502 + 錯誤映射
- 5 種錯誤 i18n(NOT_FOUND/MISMATCH/離線/401/413)+ AbortError 靜默

reviewer 通過(0C/0M)。tsc/eslint/build 0 error、WP-4 相關 70 test pass。
⚠️ 實機驗證(真序號 hash 同形 fail-closed / PNA / 500MB 大檔實傳 / 混合路徑
結果面)待 stage 部署後驗證。Minor M-1/M-2 留 WP-6 一併處理。

Refs: ADR-019 WP-4。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 14:23:21 +08:00
329023085e docs(arch): ADR-019 轉 Accepted(實作已 merge,據實標記 WP-4 未接線)
實作經完整審查流程(security 1C+3M → 契約修正 → confirm-only →
逐 WP reviewer → security code-level APPROVED → testing 270 測試全綠)
並 merge(PR #1 / 3eaf3dc)後轉 Accepted。

明確記錄「Accepted ≠ 端到端啟用」:僅底層能力已 merge,WP-4 影片分頁
接線未做,影片仍走 tunnel、90MB 過渡上限仍生效。含兩筆 backlog。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 12:53:16 +08:00
3eaf3dceb0
Merge pull request #1 from jim800121/feat/adr-019-local-direct-upload
feat(adr-019): 影片/圖片/批次上傳走同機 localhost 直連 local-agent
2026-07-30 12:50:04 +08:00
9031153553 feat(adr-019): 影片/圖片/批次上傳走同機 localhost 直連 local-agent
實作 ADR-019 混合路徑:影片/圖片/批次的檔案上傳改由瀏覽器同機直連
local-agent localhost endpoint(繞過雲端 tunnel),控制面 + MJPEG 結果 +
推論 WS 仍走 tunnel。解決大檔頻寬雙倍 + nginx 100M + 300s timeout。

三條 stream(全數過 reviewer + security code-level 複審 APPROVED):

local-agent(Go):
- CORS 雲端 origin 完整精確比對 + Allow-Credentials:false + HostGuard(loopback)
  + PNA header(middleware.go)
- 新 route /api/local/media/upload/*(一律要 token、不看 Origin,關 C1 後門)
- one-time token store(crypto/rand、TTL 120s、綁 deviceId、single-flight consume、
  上限 32→429;200 goroutine -race 綠)
- GET /api/local/hello(回 salted SHA-256 serialHashes、最小揭露)
  + POST /api/local/issue-token(Host-based)
- LocalUploadGuard(token+size 驗證放 FormFile 前);video≤500MB / batch 合計 80MB
  → 413;stopActivePipeline + batch 生命週期 temp 檔清理

cloud(visionA-backend):
- POST /api/devices/:serial/local-upload-ticket(OIDC + 裝置歸屬 + 經 tunnel
  轉發 issue-token;IDOR-safe、錯誤不洩漏)

frontend(visionA-frontend):
- lib/local-agent.ts(port 探測 3721-3740 並發+快取、Web Crypto serial hash 比對
  同機判定、uploadToLocalAgent 通用函式)
- validateBatchFiles 合計大小檢查(MAX_BATCH_TOTAL_BYTES=80MB,消 50×19MB 撞 413 地雷)

回歸:ADR-019 相關 270 測試全綠、既有 tunnel 路徑未被打斷、無 regression。
既有 tunnel(無 Origin)不要求 token(C1 route 分離相容性保證)。

Refs: ADR-019。WP-0(PNA 實機)/WP-4(影片分頁接線)下一批。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 12:32:26 +08:00
4c962dfec1 docs(arch): ADR-019 影片/圖片/批次改走同機 localhost 直連(契約定稿)
新增 ADR-019:影片/圖片/批次上傳從經雲端 tunnel 改為同機瀏覽器直連
local-agent 的 localhost endpoint,修補 design-doc §5.3「大檔不走 tunnel」
原則未套用到 media 的邏輯漏洞。採混合路徑(上傳走 localhost、控制面 +
MJPEG 結果 + 推論 WS 仍走 tunnel)。

已完成 security pre-implementation review(1 Critical + 3 Major)+ architect
依審查修正契約 + security confirm-only 複審 APPROVED。契約含:
- C1: route 分離(新 route /api/local/media/upload/* 一律要 token、不看 Origin),
  消除「無 Origin 免 token」後門
- M2: Host header 驗證 = loopback 升為必做(DNS rebinding 緩解)
- M3: CORS 雲端 origin 完整字串精確比對 + Allow-Credentials: false
- 議題1: serial 回 SHA-256(visiona-local-v1‖serial) 雜湊、移除 agentVersion
- M1: media size 上限(video≤500MB / batch 合計 80MB)+ temp 檔清理升為必做

ADR 維持 Proposed,待實作完成 + code-level security 複審後轉 Accepted。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 03:31:16 +08:00
c595bb8b91 fix(frontend): 影片上傳上限 500→90MB 對齊 nginx,解 HTTP 413
stage 推論工作區上傳影片撞 nginx client_max_body_size 100M 回 413,
但前端影片上限標 500MB(af44a9f 只改前端沒同步 nginx)。

過渡修復:MAX_VIDEO_BYTES 調回 90MB,留 10MB buffer 給 multipart
overhead(boundary/header/deviceId 欄位),可靠避開 100M 硬上限。
未來影片改走 localhost 直連(ADR-019)後可放寬此值。

- media.ts:94 MAX_VIDEO_BYTES 500→90MB + 註解說明過渡性質
- media.test.ts 常數斷言 + 邊界 89/90/91MB
- i18n zh-Hant/en 影片 hint 500→90MB
- 模型上傳(500MB)/圖片(20MB)/批次上限未動

Reviewer: Approve(0 Critical/Major/Minor)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-26 21:17:39 +08:00
9397a4d31f docs: 新增三平台 build troubleshooting 參考
把 M10 這輪三平台 build 踩的雷整理成 troubleshooting 文件,供未來
build 安裝包時參考。

涵蓋 9 條逐項 troubleshooting(統一格式:症狀 → 根因 → 為什麼發生
→ 解法 → 如何根治)、快速症狀索引表、5 條橫向教訓、KneronPLUS
2.0.0 vs 3.1.2 版本相容附錄、build 驗收 checklist。

核心教訓:症狀常不指向根因(Error 12 是 input size、pip 秒退是 wheels
多版本、中文亂碼是 stdio code page),macOS 開發環境測不到大量
Windows-only 問題(編碼、DLL 路徑、SDK 版本差異)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 09:08:13 +08:00
31 changed files with 4502 additions and 59 deletions

View File

@ -0,0 +1,254 @@
# ADR-019: 影片 / 圖片 / 批次上傳走同機 localhost 直連 local-agent混合路徑
## 狀態
Accepted。
**底層能力已於 2026-07-30 完成並 merge 進 maingithub PR #1、merge commit `3eaf3dc`)。** 走過完整審查流程security pre-implementation review1 Critical + 3 Major→ 契約修正C1 / M1 / M2 / M3 / 議題1→ security confirm-only 複審 → 逐 WP reviewer 通過 → security code-level 複審 **APPROVED** → testing 回歸270 測試全綠、既有 tunnel 路徑未打斷、無 regression。C1 定案採方案 Aroute 分離),見 §2.4。
> ⚠️ **「Accepted」≠「端到端功能已對使用者啟用」。** 本次 merge 的是 **backend/frontend 的底層能力**local-agent CORS/PNA/token/新 route、雲端 ticket endpoint、前端 port 探測抽象、批次合計檢查)。**前端影片分頁 UI 尚未切到新的 localhost 路徑WP-4 未做)**,故:
> - 影片上傳目前**仍走既有雲端 tunnel 路徑**端到端「localhost 直連」尚未對使用者啟用。
> - **90MB 過渡上限仍在生效**(見 `.autoflow/` 相關過渡處置)。
> - WP-4 接線完成前不可視為「ADR-019 功能已上線可用」。詳見 §7 合規性與 §8 WP 清單狀態標記。
## 日期
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 磁碟落地** | 即使調大 nginx10 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 serverGin強制綁 `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 / EdgeChromium 系。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``37223740 並發``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 → 內部轉呼叫既有 handlerhandler 零改動)
```
Token 設計方向(已經 security agent 審,核心設計通過,見 review 議題 2
- 生成 `crypto/rand` 32 bytes → base64url**不准 `math/rand`**TTL 120sone-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 C1Critical 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 clientWails app shell module `visiona-agent`)與 HTTP server獨立子行程 module `server`)是**兩個獨立行程**。結論:**同機程序可完全偽裝成「tunnel 來的請求」**server 端無法用 request 本身區分。
**定案:採方案 Aroute 分離)**,不採方案 Btunnel 注入 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 驗證。內部轉呼叫既有 handlerhandler 零改動)。
- **既有 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 rebindingrebinding 後 `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/EdgeChrome 未來可能把 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→loopback0.12.5s,改善約 1001000 倍);不受 nginx / timeout 約束handler 零改動 | 需同機操作local-agent 新增對外攻擊面需認證PNA 未來風險 | ✅ 採用 |
---
## 4. 後果 (Consequences)
### 4.1 正面影響
- **頻寬**500MB 影片從「1000MB 經雲端 tunnel」降為「loopback 本機傳輸」(現代 SSD 筆電 0.10.5s、較舊機器 12.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即時攝影機仍支援遠端檢視**——只有「上行大檔」需同機限制。
- 依使用者拍板「跨機不存在」,此限制不影響實際使用;且現況跨機使用者本來就傳不了 >100MB413此限制是「把隱性限制變成明確提示」非新增限制。
- **不動 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 M1temp 檔洩漏**已確認為實錘**(非待查證)——`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-2Safari 相容性)因使用者確認僅支援 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)` 的 hexsalt 常數 = `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.1token 認證不得被無 Origin 繞過,是 token 設計的前置 blockerb**Host 驗證必做**§2.5rebinding 主防護,非 optionalc**CORS 完整 origin 精確比對 + Allow-Credentials: false**§2.5dtoken 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-018serial 路由)的識別慣例被本 ADR **沿用**§2.3),無衝突。
---
## 7. 合規性
- [x] 與使用者確認方向(同機 localhost 直連 + 混合路徑 + 範圍含影片/圖片/批次 + 非同機停用 + 僅 Chromium + 認證必做)— ✅ 已裁決
- [x] **security agent 審 token / 隱私設計**§5 兩點)— ✅ 已審(初審 REQUEST CHANGES [1C+3M] → 契約已依 C1/M1/M2/M3/議題1 修正 → confirm-only 複審 → code-level 複審 **APPROVED**
- [x] backend agent 落地 local-agent CORS/PNA/token/endpoints + 雲端 ticket endpoint — ✅ 已 mergePR #1
- [x] frontend agent 落地 port 探測抽象WP-3+ 批次合計檢查WP-7— ✅ 已 mergePR #1
- [ ] frontend agent **影片分頁接線WP-4** — ⏳ **未做(下一批)**。WP-3 port 探測抽象與 WP-7 批次合計檢查已完成,但「影片分頁 UI 切到 `/api/local/media/upload/video` 新路徑」屬 WP-4尚未落地 → 端到端功能未對使用者啟用、90MB 過渡上限仍生效
- [x] testing agent 回歸:既有 tunnel 路徑(無 Origin不受 token 影響 — ✅ 270 測試全綠、既有 tunnel 未打斷、無 regression
- [x] 成本影響:**無新雲端資源**(沿用現有 DB / tunnel主要是三 module 開發工時(見 §8 WP 清單);反而降低 stage 磁碟與頻寬壓力 — ✅ 已實作,符合預期(無新增雲端資源)
> **Backlog本次 merge 未處理,記錄於此供後續追蹤):**
> - **同機程序存取控制**(優先級 Low既有 loopback 攻擊面(非本 ADR 新增,見 §2.4.1 誠實揭露的殘留風險)。同機惡意程序仍可直打舊 tunnel-path route根治需 OS 層 peer credential / socket 權限驗證,超出本 ADR 範圍。
> - **WP-4 接線時的真實 Kneron 序號實機驗收**:需做真實序號的前後端 hash 實機驗證——後端 `SHA-256` 輸入的序號格式(`0x%08X` 大寫)與前端 `serialNumber` 須同形,否則 hash 不符會誤判非同機。此為 fail-closed不符即停用分頁WP-4 接線時必驗。
---
## 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 精確比對)。
> **實作狀態2026-07-30PR #1 merge commit `3eaf3dc`**WP-1 / WP-2 / WP-3 / WP-5 / WP-7 ✅ 已完成並 mergeWP-0 / WP-4 / WP-6 為**下一批(未做)**。**底層能力已具備,但 WP-4影片分頁接線未做 → 前端 UI 尚未切到新 localhost 路徑、端到端功能未對使用者啟用、90MB 過渡上限仍生效。**
| WP | 內容 | 負責 agent | 依賴 | 人時 | 狀態 |
|----|------|-----------|------|------|------|
| WP-0 | SpikeChrome/Edge 實測 `https/http 入口 → http://127.0.0.1:3721` + PNA console warning | frontend | 無 | 24 | ⬜ 下一批(未做) |
| WP-1 | local-agent CORS / Host / PNA雲端 origin **完整 origin 精確比對**(獨立於既有 hostname-only 邏輯,**不放寬既有 scheme 檢查**+ **`Allow-Credentials: false`(雲端 origin** + **Host header 驗證 = loopbackM2 必做,套用 `/api/local/*` 與舊 media route** + PNA header + `Access-Control-Max-Age: 600` | backendGo | 契約 §2.5 | 57 | ✅ 已完成並 merge |
| 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 storesingle-flight 持鎖)+ token 驗證中介(放 `FormFile` 前)+ **media size 上限video ≤500MB 硬牆 / batch 合計 80MBM1 必做)** + **temp 檔清理(`stopActivePipeline` 補 `os.Remove` + batch 生命週期刪檔M1 必做)** | backendGo | WP-1 | 913 | ✅ 已完成並 merge |
| WP-3 | 前端:`lib/local-agent.ts`port 探測並發+快取+timeout、同機判定用 salted SHA-256 比對 serialHashes、`uploadToLocalAgent()` endpoint 無關通用函式;上傳目標改新 route `/api/local/media/upload/*` | frontend | 契約 §2.3/§6.3 | 610 | ✅ 已完成並 merge |
| WP-4 | 前端:影片分頁接線(取 token、改上傳目標為 `/api/local/media/upload/video`、三種錯誤訊息 i18n、tunnel 離線檢查 R-3 | frontend | WP-3 | 46 | ⬜ **下一批(未做)** — 端到端啟用的關鍵缺口;接線時須做真實 Kneron 序號前後端 hash 實機驗收(後端 `0x%08X` 大寫 vs 前端 serialNumber 須同形、fail-closed |
| WP-5 | 雲端:`POST /api/devices/:serial/local-upload-ticket`(經 tunnel 轉發 issue-token | backendGo | 契約 C-1 | 24 | ✅ 已完成並 merge |
| WP-6可選 | 圖片 + 批次接上 localhost 路徑 | frontend | WP-4 驗證通過 | 35 | ⬜ 下一批(未做) |
| WP-7獨立建議先做 | `validateBatchFiles` 加合計大小檢查 | frontend | 無 | 0.51 | ✅ 已完成並 merge |
**推薦範圍WP-0..WP-5 + WP-7總計26.543 小時 ≈ 3.35.4 人天。**
**關鍵路徑(串行、決定最短工期)**:契約定稿 → WP-0 → WP-1 → WP-2 → 整合 → E2E ≈ 1422 小時。
**可平行**WP-3前端對契約 mock與 WP-1/WP-2backend完全平行WP-5雲端 ticket與 WP-1/WP-2 平行WP-7 完全獨立。建議開 23 條 work-streamfrontend / 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 用前端掃描 37213740 + serial 身分驗證偵測同機;認證用經既有 tunnel 下發的 one-time token縱深防禦擋 XSS / DNS rebindingCORS 擴充雙入口白名單 + PNA header防未來 Chrome 升級 blocking非同機停用該分頁產品定位「跨機不存在」不做 fallback。token / 隱私設計待 security agent 審。

View File

@ -217,17 +217,105 @@
## 6. Camera / Media
與 local-tool 相同,全部透過 tunnel forward
**路徑分流2026-07 起,見 ADR-019**Camera 與推論**結果**串流走雲端 tunnel forward影片 / 圖片 / 批次的**檔案上傳**改走**同機 localhost 直連 local-agent**(繞過 tunnel解決大檔頻寬雙倍 + nginx 100M + 300s timeout
### 6.1 走 tunnel forward與 local-tool 相同,不變)
### GET `/api/camera/list`
### POST `/api/camera/start`
### POST `/api/camera/stop`
### 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 動態 ∈ 37213740**不經雲端 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 handlerhandler 零改動)。
- **size 上限必做ADR-019 §4.3.1**video 硬牆 ≤ 500MB前端正常上限 90MBbatch 合計 ≤ 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/video`
### POST `/api/media/upload/batch-images`
### GET `/api/media/batch-images/:index`
### POST `/api/media/seek`
> tunnel client 內部轉發至此,路徑 / 行為不變。此路徑**不因本 ADR 新開放給雲端 origin**;同機直打舊 route 仍受 §6.4 Host 驗證約束Host=loopback
> 推論**結果**MJPEG 串流 `streamUrl` / 推論 WS `/ws/devices/:id/inference`)仍走雲端 tunnel。這是「上傳走 localhost、結果走雲端」的混合路徑ADR-019 §2.1)。
### 6.3 localhost 直連的支援 endpointlocal-agent 新增,見 ADR-019
以下 endpoint 為 localhost 直連機制所需,**僅存在於 local-agent 本機 server**,不對應雲端路由:
#### GET `/api/local/hello` — 同機偵測 + 身分驗證
- Authbootstrap 需要——拿 token 前需先知道 portserial 以雜湊揭露、最小化 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。
- 用途:前端掃描 37213740比對 `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 429token 上限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`(逗號分隔完整 originstage 預設含雙入口 `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 合計 ≤80MBM1 | local-agent |
| Host 驗證失敗) | 400 | `Host` header 非 loopbackDNS rebinding 防護M2 | local-agent |
---

View File

@ -312,6 +312,32 @@ POC 已完整實作此流程visionA 直接沿用。
- S3 presigned URL 讓瀏覽器直連scalable
- 需要時 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.12.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 / §5token / serial 隱私已經 security agent 審並依 C1/M1/M2/M3/議題1 修正契約)。
### 5.4 轉檔呼叫Phase 1
```

View File

@ -530,6 +530,15 @@ func (h *CameraHandler) stopActivePipeline() {
if h.sourceType == camera.SourceCamera {
h.cameraMgr.Close()
}
// ADR-019 §4.3.1 M1補刪前一支影片的 temp 檔,防磁碟 DoS。
//
// 為什麼要在這裡補VideoSource.Close() 雖已 os.Remove(filePath),但 seek 流程用
// CloseWithoutRemove() 保留檔案供重新 seek之後 h.videoPath 仍指向 temp 檔而
// activeSource 可能是不同的(或 nilVideoSource。此處對 h.videoPath 明確補一次
// os.Remove 作為 belt-and-suspenders——已被刪過時第二次 Remove 是無害 no-op。
if h.videoPath != "" {
_ = os.Remove(h.videoPath)
}
h.activeSource = nil
h.sourceType = ""
h.videoPath = ""

View File

@ -0,0 +1,167 @@
package handlers
import (
"crypto/sha256"
"encoding/hex"
"log"
"net/http"
"strings"
"time"
"visiona-agent/server/internal/device"
"github.com/gin-gonic/gin"
)
// LocalSerialSalt 是 serial 雜湊的固定公開常數 saltADR-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 的本機直連支援 endpointhello / 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 hexADR-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,
},
})
}

View File

@ -0,0 +1,234 @@
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_SerialHasheshello 回 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、長度 64SHA-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 hexgot %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/ttlSecondsdeviceId 綁 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 0xAAAA0001token 綁 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_LIMITgot %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)
}
}

View File

@ -0,0 +1,97 @@
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、不看 OriginC1
// 2. 用 http.MaxBytesReader 把 request body 包上 maxBytes 硬牆——
// 在讀取 multipart body 之前就限制總位元組避免「未驗證就先收無上限大檔」M1
// 3. 解析出 deviceIdPostForm 觸發 multipart 解析,但已被 MaxBytesReader 上限保護)。
// 若超過上限 → ParseMultipartForm 回 *http.MaxBytesError → 413 LOCAL_UPLOAD_TOO_LARGE。
// 4. Consume(token, deviceId)——single-flight 持鎖(查存在+比對+刪除同一 Lock防 racem2
// deviceId 綁定不符 / 過期 / 已用 / 不存在 → 401 LOCAL_TOKEN_INVALID。
// 5. 通過 → c.Next() 進既有 handlerhandler 業務邏輯零改動、直接 FormFile 讀已快取的表單)。
//
// 稽核 logconsume 成功/失敗記 deviceId + 時間,絕不 log token 明文。
//
// 為什麼 deviceId 取自表單而非 tokentoken 在 issue 時已綁 deviceIdConsume 會用
// 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
}
// M1body 硬牆。放在解析 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()
}

View File

@ -0,0 +1,269 @@
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 的 routerhandler 記錄是否被呼叫並回讀 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_ConsumedOrExpiredTokenConsume 回 ErrTokenInvalid已用/過期/deviceId不符
// → 401 LOCAL_TOKEN_INVALIDhandler 不被呼叫。
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_TokenCheckedBeforeHandlertoken 驗證發生在 handlerFormFile 讀檔)之前。
// 用「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_TooLargebody 超過 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 掉那個 tokensize 檢查在 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 上限時不應呼叫 Consumesize 檢查在 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_INVALIDgot %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 是 sentinelerrors.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 不應相等")
}
}

View File

@ -1,19 +1,23 @@
package api
import (
"net"
"net/http"
"net/url"
"os"
"strings"
"github.com/gin-gonic/gin"
)
// allowedHosts 定義 CORS 白名單的 hostname。
// allowedHosts 定義 loopback CORS 白名單的 hostname。
// 任何 port 都允許scheme 只允許 http本機不可能是 https
//
// M8-8TDD v2/cors-security.md §3.1
// v2 模式下 UI 改在使用者瀏覽器中跑server 同時暴露給其他瀏覽器分頁,
// 必須限定 cross-origin 來源在本機 loopback避免惡意網站透過 CORS 攻擊。
//
// ADR-019 §2.5:此 loopback 舊規則「保留不動」——不因開放雲端 origin 而變更。
var allowedHosts = map[string]bool{
"127.0.0.1": true,
"localhost": true,
@ -21,7 +25,53 @@ var allowedHosts = map[string]bool{
"::1": true,
}
// isAllowedOrigin 判斷 Origin header 是否屬於白名單。
// loopbackHostnames 是 Host header 驗證ADR-019 §2.5 M2允許的 hostname 集合。
// 與 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 的 isAllowedOriginhostname-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
// 不合法例https://127.0.0.1:3721 / http://evil.com / null / http://192.168.1.5:3721
@ -30,6 +80,8 @@ var allowedHosts = map[string]bool{
// - 空字串視為非白名單(呼叫端會自行決定 same-origin 路徑)。
// - "null"local file、某些 sandboxed iframe一律拒絕。
// - 只允許 http scheme本機不會有 https。
//
// ADR-019此函式維持 loopback 舊邏輯不動;雲端 origin 走 isAllowedCloudOrigin。
func isAllowedOrigin(origin string) bool {
if origin == "" || origin == "null" {
return false
@ -45,14 +97,19 @@ func isAllowedOrigin(origin string) bool {
return allowedHosts[host]
}
// CORSMiddleware 僅允許 127.0.0.1/localhost/::1 任意 port 的跨來源請求
// CORSMiddleware 處理跨來源請求,區分 loopback 與雲端 origin 兩條路徑
//
// 行為M8-8 / TDD v2/cors-security.md §4.1
// 行為M8-8 / TDD v2/cors-security.md §4.1 + ADR-019 §2.5
//
// 1. Origin header 為空 → same-origin瀏覽器 same-origin 不送 Origin→ 直接放行;
// 若是 OPTIONS 預檢則回 204 即停(避免帶 ACA* 給沒人看的請求)。
// 2. Origin 在白名單 → 回完整 ACA* headersOPTIONS → 204其他方法 → 繼續執行 handler。
// 3. Origin 不在白名單:
// 2. Origin 在 loopback 白名單 → 回完整 ACA* headers含 Allow-Credentials: true
// 沿用 M8-8 既有行為OPTIONS → 204其他方法 → 繼續執行 handler。
// 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*。
// - 簡單讀取GET/HEAD→ 執行 handler 但不回 ACA*,瀏覽器 JS 讀不到 body。
//
@ -74,33 +131,109 @@ func CORSMiddleware() gin.HandlerFunc {
return
}
if !isAllowedOrigin(origin) {
// 非白名單 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)
// 雲端白名單 originADR-019完整 origin 精確比對,獨立於 loopback。
if isAllowedCloudOrigin(origin) {
c.Header("Access-Control-Allow-Origin", origin)
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Content-Type, X-Visiona-Local-Token")
// M3雲端 origin 一律 false——用 header 帶 token、不需 cookie
// 避免無謂讓瀏覽器願意帶 credential 而擴大 CSRF / 憑證面。
c.Header("Access-Control-Allow-Credentials", "false")
c.Header("Access-Control-Max-Age", "600")
c.Header("Vary", "Origin")
if method == http.MethodOptions {
// PNAADR-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
}
c.Next()
return
}
// 白名單 Origin回完整 ACA* headers
c.Header("Access-Control-Allow-Origin", origin)
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-Credentials", "true")
c.Header("Vary", "Origin")
// loopback 白名單 origin沿用 M8-8 既有行為Allow-Credentials: true
if isAllowedOrigin(origin) {
c.Header("Access-Control-Allow-Origin", origin)
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Visiona-Local-Token")
c.Header("Access-Control-Allow-Credentials", "true")
c.Header("Access-Control-Max-Age", "600")
c.Header("Vary", "Origin")
if method == http.MethodOptions {
c.AbortWithStatus(http.StatusNoContent)
if method == http.MethodOptions {
// loopback 直連也可能帶 PNA preflight同機不同 port 屬 private network
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
}
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.goHost 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]
}

View File

@ -3,6 +3,7 @@ package api
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
@ -199,3 +200,253 @@ func TestCORSMiddleware_SameOrigin(t *testing.T) {
t.Errorf("same-origin 不應回 ACA-Origingot %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_CloudPreflightNoPNARequestpreflight 未帶 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 headergot %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-Origingot %q", got)
}
}
// TestCORSMiddleware_LoopbackCredentialsUnchangedloopback origin 仍回 Credentials:trueM8-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 trueADR-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 rebindingHost 為攻擊者網域 → 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)
}
}
}

View File

@ -49,6 +49,12 @@ func NewRouter(
deviceHandler := handlers.NewDeviceHandler(deviceMgr, flashSvc, 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")
{
// System
@ -87,11 +93,30 @@ func NewRouter(
api.GET("/camera/stream", cameraHandler.StreamMJPEG)
// Media
api.POST("/media/upload/image", cameraHandler.UploadImage)
api.POST("/media/upload/video", cameraHandler.UploadVideo)
api.POST("/media/upload/batch-images", cameraHandler.UploadBatchImages)
// ADR-019 §2.5 M2舊 tunnel-path media upload route 加 HostGuard
// 關「同機直打舊 route」的殘留面Host=loopback 才放行)。
// tunnel 轉發的 Host 本就是 127.0.0.1:<port> 故不受影響。
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.POST("/media/seek", cameraHandler.SeekVideo)
// ADR-019本機直連支援 endpoint/api/local/*)。
// 整組套 HostGuardM2Host=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不看 OriginC1+ 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
@ -179,8 +204,9 @@ func broadcasterLogger(b *logger.Broadcaster) gin.HandlerFunc {
// for Next.js static export client-side routing.
//
// Next.js static export with generateStaticParams creates:
// /models/index.html — static page
// /models/_/index.html — dynamic route shell (placeholder param '_')
//
// /models/index.html — static page
// /models/_/index.html — dynamic route shell (placeholder param '_')
//
// For a request like /models/yolov5-face-detection:
// 1. Try exact file → not found

View File

@ -0,0 +1,173 @@
package api
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"sync"
"time"
)
// ADR-019 §2.4one-time upload token store。
//
// 設計(已經 security review 議題 2 通過):
// - token = crypto/rand 32 bytes → base64url禁 math/rand
// - TTL 120s、one-timeconsume 即刪)、綁 deviceId
// - 記憶體 storemap + 單一 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")
// ErrTokenInvalidtoken 不存在 / 過期 / 已使用 / 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 綁定 deviceIDsingle-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 驗證並消費一個 tokenone-time。single-flight 持鎖:
// 查存在 + 比對 deviceID + 未過期 + 刪除,全部在同一 Lock 內完成。
//
// 成功 → 回 niltoken 已從 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) }
}

View File

@ -0,0 +1,213 @@
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 應 ErrTokenInvalidgot %v", err)
}
}
// TestTokenStore_Consume_DeviceMismatchdeviceId 不符 → 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 不符應 ErrTokenInvalidgot %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 應 ErrTokenInvalidgot %v", err)
}
if err := s.Consume("", "dev-1"); !errors.Is(err, ErrTokenInvalid) {
t.Errorf("空 token 應 ErrTokenInvalidgot %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 應 ErrTokenInvalidgot %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("達上限應 ErrTokenLimitgot %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必須恰好 1single-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_StopWorksStartCleanup 回傳的 stop 可正常關閉 goroutine。
func TestTokenStore_Cleanup_StopWorks(t *testing.T) {
s := NewTokenStore()
stop := s.StartCleanup()
// 立即停止不應 panic / deadlock
stop()
}

View File

@ -0,0 +1,371 @@
# Build Troubleshooting — visionA-local
> **這份文件的用途**:記錄 visionA-local 打包安裝檔macOS / Windows / Linux時實際踩過的雷
> 讓下次遇到同樣症狀能**快速定位**,而不是重新 debug 一輪。
>
> **目標讀者**:未來要在 Windows / Linux build 這個專案安裝包的開發者(可能是你自己,也可能是團隊成員)。
>
> **這不是** build 教學。build 流程本身看 `build-pipeline.md`Makefile 骨架、vendor 目錄、CI 策略)。
> 這份是它的 troubleshooting 補充:**症狀 → 根因 → 為什麼會發生 → 解法 → 如何預防/根治**。
>
> **紀錄來源**2026-07 這輪M10 classification + 特規打包 + 三平台實機 build踩的雷
> 每條都對到 progress.md 或實際 code 的 `檔案:行號`。若與現況不符,以 code 為準。
---
## 快速症狀索引表
先在這張表找你看到的症狀,跳到對應章節。**症狀常常不指向真正的根因**——這正是這份文件存在的理由。
| 你看到的症狀 | 可能的雷 | 跳到 |
|-------------|---------|------|
| 裝置 reset 後 `load_model Error code 24`、停在 `KDP2 Loader` | 開發模式 `dist/scripts/``firmware/` | §1 |
| 前端顯示「載入模型失敗」、terminal 出現 `darwin_usb.c:584` / SIGABRT | 除錯時 curl 與 UI 搶同一顆 USB | §2 |
| Windows build`go: command not found` / `wails: command not found` / `pnpm not found`(明明裝了)| MSYS2 login shell 重建 PATH | §3 |
| Build 後置檢查失敗「預期 2 個 .nef實際 8 個」 | `copy_bundled_data` 沒清空目標、殘留舊 .nef | §4 |
| Windows 推論 `KP_ERROR_INVALID_PARAM_12`Error 12 | input size 用了使用者亂填的 declared 值 | §5 |
| 每次啟動都「Python 相依未通過」但又不重裝,怎樣都好不了 | venv 半套安裝、只檢查 python.exe 存在就跳過 | §6 |
| 明明環境是好的卻被判「Python 相依驗證失敗」擋住啟動 | 健康檢查 probe 沒補 Windows DLL 搜尋路徑 | §7 |
| App 一啟動 pip 就秒退 `ResolutionImpossible`certifi ×3 之類) | vendor wheels 累積多版本 | §8 |
| Windows 中文標籤亂碼「布」→「撣<E3808C>」、log 出現 `<60>` | Python stdio 綁 cp950 而非 UTF-8 | §9 |
如果症狀不在表上,先讀 §10 共通教訓——很多雷的「表面症狀」都不指向根因。
---
## 環境前置需求(三平台)
打包前的一次性環境安裝,用 bootstrap 腳本,不要手動裝:
| 平台 | 腳本 | 裝什麼 |
|------|------|--------|
| Windows | `scripts/bootstrap-windows.ps1` | winget 裝 git / go / node / pnpm / python / **MSYS2**(提供 bash + make/ Inno Setup + wails |
| Linux | `scripts/bootstrap-linux.sh` | apt 裝 go 1.22.5 / node 20 / pnpm / wails + GTK/WebKit/libusb dev headers |
| macOS | (手動)| go / node / pnpm / wails / `brew install create-dmg`DMG 美化,選用) |
三平台都需要 `make vendor-sync` 先把第三方二進位Python runtime / wheels / ffmpeg下載到 `vendor/`
**⚠️ Windows 特別注意**make 在 Windows 上是透過 `C:\msys64\usr\bin\bash.exe` 執行的Windows 沒有原生 make
這帶來 §3 的 PATH 坑。詳見該節。
---
## 逐條 Troubleshooting
### §1 開發模式資源不同步 → 裝置 reset 後 load_model Error 24
**症狀**
- 裝置首次連線、reset 後,`load_model``Error code 24`
- Python bridge 卡在 `KDP2 Loader` 狀態、進不到 `KDP2 Comp`
- **只在手動開發模式(`dist/scripts/`)發生,打包出來的安裝包沒這問題。**
**根因**
- 開發模式下 `dist/scripts/` 需要同時含 **`kneron_bridge.py` + `firmware/` + `drivers/`**`dist/data/` 需含 `models.json``.nef`
- 曾經只手動複製了 `kneron_bridge.py`、**漏掉 `firmware/`** → 裝置 reset 後 bridge 重啟時找不到 firmware → 停在 Loader → `load_model Error 24`
**為什麼會發生**
- 手動同步「就複製那個我改到的檔案」很直覺,但 bridge 執行期會去讀 `firmware/``drivers/` 這些不會每次都動、容易被忘記的相依資源。
- 打包流程之所以沒事,是因為 Makefile 三平台都用整包搬(`cp -R server/scripts/*` / `find | cp` 整包),不是逐檔挑。
**解法**
- 開發模式同步時,`dist/scripts/` 一律帶齊 `kneron_bridge.py` + `firmware/` + `drivers/``dist/data/` 帶齊 `models.json` + `.nef`。不要只複製「這次改到的那個檔」。
**如何預防 / 根治**
- 走打包流程驗證而不是手動同步:`make payload-macos`progress.md L413 實跑驗證過 firmware/drivers 都在)。
- 已寫入 memory`~/.claude/projects/-Users-jimchen-visionA/memory/project_local_tool_dev_env.md`
- 來源progress.md L409-414。
---
### §2 除錯時 curl 戳 flash 與 UI 搶同一顆 USB → SIGABRT
**症狀**
- 使用者端顯示「載入模型失敗」。
- terminal 出現 libusb 的 assert`darwin_usb.c:584`,程式 SIGABRT 直接掛掉。
**根因**
- 除錯時用 `curl` 觸發 flashload model**同時**使用者還在用 UI 操作同一顆裝置 → 兩個行程同時對同一個 USB endpoint 下命令 → libusb 在 macOS 上直接 assert / abort。
**為什麼會發生**
- USB 裝置不是可多路複用的資源。KneronPLUS SDK / libusb 沒有替你做互斥;兩個 client 同時搶就炸。
**解法 / 預防**
- **除錯期間不要碰裝置**——要嘛用 curl 手動測、要嘛用 UI 測,不要兩個同時來。
- 這不是 code bug是除錯操作紀律。記住這個 assert 訊息(`darwin_usb.c:584`),下次看到就知道是雙頭搶 USB不用往 code 裡挖。
- 來源progress.md L412。
---
### §3 bootstrap-windows 的 MSYS2 PATH → Go / wails / node / pnpm 找不到
**症狀**
- Windows build 時,`go` / `wails` / `node` / `pnpm` 明明用 winget 裝好了,跑 make 卻報 `command not found`
**根因**
- Windows 沒有原生 make本專案透過 `C:\msys64\usr\bin\bash.exe -l`**login shell**)來跑 Makefile。
- login shell 啟動時會重跑 `/etc/profile`**把 PATH 整個重建**成 MSYS2 自己的一套Windows 上用 winget 裝的工具目錄就這樣被洗掉了。
- `MSYS2_PATH_TYPE=inherit`bootstrap 有設,`bootstrap-windows.ps1:74`**只影響 MSYS2 自己的 shell 啟動器**`msys2.exe` / `mingw64.exe`);直接呼叫 `bash.exe -l` 時它不生效。
**為什麼會發生**
- `inherit` 的作用範圍與「直接呼叫 bash.exe -l」的實際入口不重疊是一個很容易誤解的設定。這也是為什麼 Inno Setup 呼叫、Python 呼叫本來就必須各自手動 export PATH。
**解法**
- 跟 Inno Setup 一樣,**明確把工具目錄轉成 MSYS2 路徑格式(`C:\foo``/c/foo`)後再 export**,補在 `$PATH` 之前。
- 實作見 `bootstrap-windows.ps1:73-113`(註解完整說明)、`:200`(去重合併成單一 export避免多行 export 互相覆蓋)、`:284-294`
**如何預防 / 根治**
- 任何要在 MSYS2 `bash.exe -l` 下被找到的工具,都不能依賴 `inherit`,一律顯式 export 轉譯後的 MSYS2 路徑。
- 來源:`bootstrap-windows.ps1:73-113`、progress.md L1401M7 同型坑的前身)。
---
### §4 copy_bundled_data 不冪等 → 「預期 2 個,實際 8 個」
**症狀**
- Build 後置檢查失敗:`!! ERROR: 預期 2 個 .nef實際 8 個 !!`build 中止。
**根因**
- `payload-windows` / `payload-linux` **刻意不 `rm -rf` 整個 `payload/<os>/`**(因為 `build-server-*` 已先把 binary 放進 `bin/`),所以前次 build 的 `data/` 殘留會留著。
- 白名單機制M5-a、`BUNDLED_NEFS` 只留 2 個 .nef上線前舊 build 會把 8 個 .nef 全複製進去。升級到白名單版本後再 build就變成「新複製 2 個 + 殘留 6 個 = 8 個」,被後置檢查擋下。
- `payload-macos` 沒踩到,只是因為它有 `rm -rf payload/darwin`
**為什麼這個後置檢查是「對的」**
- installer`installer/windows/visiona-local.iss`)是用 `data\* + recursesubdirs` **整包收**,殘留什麼就出貨什麼。所以「數 .nef 個數不符就 fail」是防止安裝包偷偷多帶不該帶的模型——這個 fail-loud 檢查要保留。
**解法**
- `copy_bundled_data` helper 先**清空整個目標 data 目錄**再複製(步驟 (a)),確保重複執行結果一致、不受既有內容影響。
- 為什麼清整包而不是只清 `nef/`:目標目錄內容 100% 由這個 helper 產生Makefile 中只有這裡寫入 `payload/<os>/data/`),沒有其他來源需要保留;只清 `nef/` 的話,未來從 `server/data/` 移除的其他檔案殘留仍會被 installer 整包打包出貨。清整包才是真正冪等。
- 實作 + 完整註解見 `Makefile:39-118``define copy_bundled_data`)。
**如何預防 / 根治**
- helper 內建 fail-loud 後置檢查:白名單缺檔直接 fail`Makefile:104-108`)、複製後驗 `models.json` 存在(`:101-103`+ .nef 數量 == 白名單長度(`:113-116`)。
- **不用 rsync**Windows CI 跑在 Git Bash`windows-2022` + `shell: bash`),該環境**沒有 rsync** → 一律用 POSIX `find` / `cp``Makefile:71-72`)。
- 來源:`Makefile:39-118`、progress.md L112-124。
---
### §5 input size 優先序錯 → Windows 推論 Error 12
**症狀**
- Windows 上推論回 `KP_ERROR_INVALID_PARAM_12`Error 12
- 同一個模型在 macOS 上正常。
**根因**
- input size 的來源可信度排序,**declared使用者在上傳表單手填的值不能排在檔名解析之前**。
- 上傳表單的 `inputSize` 欄位長期沒有實際作用,使用者是「隨手填」的(實際案例:填 640×640模型其實是 224×224
- KneronPLUS **3.1.2Windows不再提供 2.0.0macOS的 `shape_onnx` 屬性**SDK 這層在 Windows 直接落空 → 於是**垃圾 declared 值成為實際採用值**,送進 NPU 得到 Error 12。
- 檔名的 `wNNNhNNN` 是模型編譯工具鏈產生的,沒有人為亂填空間,**明確解析出來時比 declared 可信**。
**為什麼是 Windows 才炸**
- 見 §附錄「KneronPLUS 版本相容性」macOS 用 2.0.0、Windows 用 3.1.2`shape` 欄位在 3.x 搬進巢狀 union`TensorDescriptor.tensor_shape_info.data`),舊寫法 `getattr(node, "shape_onnx")` 在 3.1.2 拋 AttributeError 被靜默吃掉 → SDK 來源落空 → 掉到 declared。
**解法**
- input size 可信度排序改為:**SDK(0) → filename(1) → declared(2) → known-model-id(3) → default(4)**。declared 降到第 3。
- 常數與完整根因註解見 `kneron_bridge.py:351-371``INPUT_SIZE_SOURCE_RANK`)。
- 逐軸解析(不再沿用單一純量 `_model_input_size`,因為模型輸入不保證正方形):`kneron_bridge.py:159-193`
- 相容兩版 SDK 的 shape 讀取(先試 3.x 巢狀 `tensor_shape_info`、再退平鋪欄位):`kneron_bridge.py:479-523`
**如何預防 / 根治**
- 實機驗收時 grep log 印出的 input size source`kneron_bridge.py:347-349` 說明這是唯一能一眼看出尺寸怎麼來的線索)——尺寸錯掉時 NPU **不一定報錯**,可能只是安靜給錯結果。
- ⚠️ 這是 §10「湊巧正確」教訓的實例舊版檔名猜 224 湊巧對,改成「更可信」的 declared 反而錯。
- 來源:`kneron_bridge.py:345-376, 479-523`
---
### §6 venv 半套安裝 → 永久卡住、怎樣都好不了
**症狀**
- 每次啟動都跑「Python 相依驗證未通過」,但又不重裝、也沒有提示,怎樣都好不了。
- 只能手動刪掉 `runtime/venv` 才能恢復。
**根因**
- 舊邏輯只檢查 `python.exe``venv/Scripts/python.exe``venv/bin/python3`**存在**就跳過安裝。
- 但「python 執行檔存在」**不等於**「相依裝好了」pip install 中途失敗(斷網 / 磁碟滿 / 防毒攔截)會留下 venv 與 python.exe 都在、但 `import kp` 失敗的**半套環境**。
- 於是每次啟動都「看到 python.exe → 跳過安裝 → 但 import 失敗」,永久卡死且無提示。
**解法**
- venv 已存在的日常啟動路徑改為「驗健康、必要時嘗試修復」:`reuseExistingVenv``app.go:1078-1103`)。
- venv 在但相依看起來壞了 → **只補裝 wheels、不整個重建 venv**(重建要重解壓 ~100MB tarball而失敗幾乎都出在 pip 階段):`app.go:1091-1093`
- 修復失敗時記錄 warning 但**不阻斷啟動**(見 §7改以現有 venv 繼續:`app.go:1096-1101`
**如何預防 / 根治**
- 狀態檢查要驗「真的能用」而非「檔案在」——這是 §10 的橫向教訓。
- 快路徑用標記檔 `venv-ready.txt` 記錄「wheels 已成功裝完 + 當前 wheels 指紋」,指紋不符才真的跑一次 import 驗證(`app.go:1105-1152`)。
- 來源:`app.go:995-1103`、progress.mdM4 之後的 fix
---
### §7 健康檢查阻斷啟動(自造 regression→ 健康的 venv 被判壞、擋住啟動
**症狀**
- 環境明明是好的bridge 實際能跑、能掃到裝置app 卻因「Python 相依驗證失敗」擋住啟動。
**根因(這是修 §6 時自己造成的 regression**
- §6 的健康檢查 probe 裸跑 `python -c "import kp"`**沒有補 Windows 的 DLL 搜尋路徑**。
- `kp` 在 import 時就會 `ctypes.CDLL` 載入 `kp/lib` 下的 native DLLlibkplus / libusb-1.0 / libwdi + MinGW runtime共 6 個。Windows 上這些 DLL 不在預設搜尋路徑、必須先 `add_dll_directory`
- 真正跑 bridge 時 `kl720_driver.go``startPython()``platform_windows.go` 的 driver 安裝腳本**有**做這件事,但 probe 沒做 → 健康的環境也 import 失敗 → 被判壞掉 → 擋住啟動。
**解法**
- probe 的環境要盡量貼近「真正跑 bridge 的環境」:`pythonProbeScript` 加一段 preamble先把 `site-packages/*/lib` 掛進 `PATH` + `os.add_dll_directory` 再 import`app.go:1182-1205`)。
- **更重要的原則:健康檢查絕不可以成為啟動的阻斷點。** 完整理由見 `app.go:999-1016`
1. 這個 probe 會誤判(環境貼合度永遠不可能 100%)。
2. 就算真的壞了,讓 bridge 自己回報「掃不到裝置」這類具體錯誤,比在啟動前用一個間接的 probe 攔死更好——kneron_bridge.py 本來就把 import kp 失敗當可降級的情況處理。
- 型別設計落地這個原則:`reuseExistingVenv` **沒有 error 回傳值**`app.go:1078-1081`)——型別本身就保證了「健康檢查不會阻斷啟動」。
**如何預防 / 根治**
- 加任何「啟動前健康檢查」時,先問:檢查失敗會不會擋住啟動?如果會,它就有能力誤殺健康環境。健康檢查應該是「發警告 + 嘗試修復」,不是 gate。
- 來源:`app.go:999-1016, 1078-1103, 1154-1205`
---
### §8 wheels 多版本 → pip ResolutionImpossible、app 一啟動就秒退
**症狀**
- App 首次啟動安裝依賴時pip 在 ~2 秒內 `ResolutionImpossible` 失敗,使用者完全無法啟動。
- vendor 目錄裡同一套件有多顆(如 `certifi ×3``numpy ×2``idna ×2`)。
**根因**
- `vendor-wheels*``pip download --dest`,而 `pip download` **只「補下載缺的」、不移除舊版**
- upstream 每發一次新版,`vendor/wheels/<os>` 就多留一顆 whl → 實機累積成 16 顆(正常應為 9 顆)。
- 這些多版本原封不動被 `payload-*` 複製進安裝包 → app 啟動 `pip install a.whl b.whl ...` 同時收到 `certifi 2026.2.25``2026.6.17` → 直接 ResolutionImpossible。
- **debug 成本放大**:這個 pip 錯誤還被 Makefile 的 Auto 分支吞掉(`|| echo WARN`),導致三輪來回都拿不到線索。
**解法(兩層都要)**
- **源頭**`clean_wheels_dir` helper 在 `vendor-wheels*` 前先清空並重建 `vendor/wheels/<os>/`,確保冪等、不累積多版本(`Makefile:143-166`,完整事故迴歸註解在 `:121-142`)。
- **救援**app 端 `selectLatestWheelPerPackage` 對每個套件只保留版本最高的一顆再交給 pip`app.go:1314-1325+`),能救「已出貨」的舊安裝包。
- 兩層都要有:源頭杜絕讓新安裝包乾淨、救援讓舊安裝包也能自癒。
**如何預防 / 根治**
- 版本比較用逐段整數比較的簡化 PEP 440`compareWheelVersions``app.go:1282-1312`);套件名依 PEP 503 正規化,讓 `opencv_python_headless``opencv-python-headless` 視為同套件(`normalizeDistName``app.go:1255-1271`)。
- **錯誤不要被吞掉**:這條的三輪 debug 成本就是 pip 錯誤被 `|| echo WARN` 吞掉造成的。詳見 §10。
- 來源:`Makefile:121-166``app.go:1207-1325`、progress.md L135-136。
---
### §9 Windows 中文亂碼 → 標籤「布」變「撣<E3808C>
**症狀**
- Windows繁中上 classification 標籤亂碼「布」變「撣<E3808C>」。
- stderr log 的中文與 em dashU+2014出現 `<60>`(如 `<60>X SDK did not report`)。
**根因**
- Windows 的 Python 把 stdio 綁到「系統 ANSI code page」而非 UTF-8。繁中 Windows 的 ANSI code page 是 **cp950**
- bridge 的 JSON-RPC 兩個方向**不對稱**
- **Go → Python (stdin)**Go 的 `encoding/json` **不** escape 非 ASCII`{"labels":["布"]}` 在 wire 上是 raw UTF-8 `e5 b8 83`。以 cp950 解碼得到「撣」+ U+FFFD截圖的亂碼嚴格模式下直接 UnicodeDecodeError 讓整個 bridge 掛掉。
- **Python → Go (stdout)**`json.dumps` 預設 `ensure_ascii=True`,中文被 escape 成 `\uXXXX` 純 ASCII所以這條「目前剛好沒壞」——但那是隱性依賴任何人加上 `ensure_ascii=False` 就會壞。
- **stderr**`_log()` 的中文與 em dash 現在就會壞。
**解法**
- `_force_utf8_stdio()` 把 stdin / stdout / stderr **一律** `reconfigure(encoding="utf-8", errors="replace")``kneron_bridge.py:22-71`),不理會系統預設編碼。
- **在 module import 時就呼叫**`kneron_bridge.py:71`),不是在 `main()` 裡——必須早於任何 I/O`main()``os.dup()` stdout、import 期間的例外也走 stderr
- 用 `errors="replace"` 而非 `strict`:這是診斷 log 通道與協定通道,遇到極端無效位元組寧可看到一個 U+FFFD 也不要讓整個 bridge 因一行 log 而崩潰bridge 掛掉 = 裝置失聯,比壞字元嚴重)。
**如何預防 / 根治**
- 把「stdout 是 UTF-8」變成**顯式契約而非巧合**(連目前沒壞的 stdout 方向也一併綁定)。
- ⚠️ `PYTHONUTF8``PYTHONIOENCODING` 管的範圍不同:前者是整個 Python 的 UTF-8 mode含檔案系統編碼等、後者只管 stdio 的編碼。這裡用程式內 `reconfigure` 是最直接、不依賴環境變數是否被正確傳入的做法。
- 測試釘死:`server/scripts/test_kneron_bridge_encoding.py`
- 來源:`kneron_bridge.py:22-71`
---
## §10 共通教訓(橫向 pattern
**這節比逐條 bug 更有價值**——上面 9 個雷裡反覆出現的幾個 pattern下次寫 code / debug 時記住這些,能少踩一半。
### 10.1 macOS 開發 ≠ Windows 實機
好幾個雷都是「macOS 有、Windows 沒有」或「兩邊行為不同」造成的。macOS 上測不到、只有 Windows 實機才會爆的類別:
| 類別 | macOS | Windows | 踩到的雷 |
|------|-------|---------|---------|
| stdio 編碼 | 預設 UTF-8 | 預設 cp950繁中 ANSI code page | §9 中文亂碼 |
| native DLL 搜尋路徑 | dyld可用 ctypes 絕對路徑預載) | 需 `add_dll_directory`、不在預設路徑 | §7 健康檢查誤殺 |
| KneronPLUS SDK 版本 | 2.0.0(有 `shape_onnx` | 3.1.2shape 搬進巢狀 union | §5 input size Error 12 |
| shell / make | 原生 bash + make | MSYS2 login shell 重建 PATH | §3 工具找不到 |
| 既有環境 | 開發機常有現成工具 | 乾淨機、什麼都要 bootstrap | §3 / §4 |
**原則**:任何「編碼 / DLL 路徑 / SDK 版本 / code page / shell」相關的東西**不能只在 macOS 驗**,一定要 Windows 實機或至少想清楚兩邊差異。
### 10.2 「檔案存在」≠「可用」
§6venv 半套安裝)和 §7健康檢查都栽在這。
- venv 只檢查 `python.exe` 存在就跳過安裝 → 半套環境永久卡死。
- 狀態檢查要驗**「真的能用」**(實際 import / 實際跑一次),而非「檔案在」。
- 但驗「能用」時要注意 §7 的教訓:驗證環境要貼近真實執行環境,否則會誤殺健康的環境;而且**健康檢查不能是啟動阻斷點**。
### 10.3 錯誤被吞掉會讓 debug 成本爆炸
§8wheels 多版本)的三輪來回,根因是 pip 錯誤被 Makefile 的 `|| echo WARN` 分支吞掉,拿不到真正的錯誤訊息。
- 吞錯誤 = 把「一次就能定位」變成「三輪還在猜」。
- fail-loud 優於 fail-silent。§4 的後置檢查(數 .nef 個數不符就直接 fail就是正面教材——它讓「安裝包偷偷多帶模型」在 build 期就爆,而不是等出貨後使用者才發現。
- 對照 §5input size 錯掉時 NPU **不報錯只給錯結果**——這種「靜默失敗」最貴,所以要靠 log 印出 source 當唯一線索。
### 10.4 「湊巧正確」的行為改動要格外小心
§5input size 優先序)是經典案例:
- 舊版用檔名猜測,某些模型湊巧猜到 224、剛好對。
- 改成「看起來更可信」的 declared使用者手填值反而錯——因為使用者是隨手填的。
- **教訓**當你把一個「能動但你覺得不夠嚴謹」的邏輯改成「更正確」的版本時先確認新來源真的更可信。「更正式的欄位」不代表「更可信的值」——declared 是正式欄位、但值是垃圾。
### 10.5 兩層防護(源頭 + 救援)
§8 的解法是兩層都做:`clean_wheels_dir` 從源頭杜絕(新安裝包乾淨)+ `selectLatestWheelPerPackage` 救援(舊安裝包自癒)。
- 只做源頭 → 已出貨的舊安裝包救不了。
- 只做救援 → 每個新安裝包都帶著髒 vendor 出貨、依賴 app 端補救。
- 涉及「已出貨產物」的問題,通常源頭與救援都要有。
---
## §附錄 AKneronPLUS 版本相容性
三平台的 KneronPLUS wheel 版本**不一致**,這是好幾個雷的隱形根因。寫任何碰 SDK 的 code 時必讀。
| 平台 | KneronPLUS wheel 版本 | 影響 |
|------|----------------------|------|
| macOS | 2.0.0 | `TensorDescriptor` 有平鋪的 `shape_onnx` / `shape_npu` 屬性 |
| Linux | 2.0.0 | 同上 |
| Windows | 3.1.2 | **無** `shape_onnx` 屬性shape 搬進巢狀 `tensor_shape_info.data`V1/V2 unionDLL 需求更嚴 |
**具體差異與注意事項**
1. **shape 欄位位置不同**(→ §5 Error 12
- 2.0.0`node.shape_onnx` / `node.shape_npu` 直接可讀。
- 3.1.2`node.tensor_shape_info.version``ModelTensorShapeInformationVersion`+ `.data``TensorShapeInfoV1``shape_onnx`/`shape_npu``TensorShapeInfoV2` 只有 `.shape`docstring 明寫是 ONNX shape
- 舊寫法 `getattr(node, "shape_onnx")` 在 3.1.2 拋 AttributeError → 被 except 靜默吃掉 → SDK 這層以為「模型沒帶 shape」其實是讀錯欄位。
- 相容兩版的讀法見 `kneron_bridge.py:479-523`(先試巢狀、再退平鋪)。
2. **native DLL 需求**(→ §7 健康檢查誤殺)
- `import kp` 在 Windows 會載入 `kp/lib` 下的 native DLLlibkplus / libusb-1.0 / libwdi + MinGW runtime共 6 個),不在預設搜尋路徑 → 需 `add_dll_directory`
- macOS 走 dyld可用 ctypes 絕對路徑預載(`_preload_kneron_dylibs_macos``kneron_bridge.py:74+`)。
3. **wheel 三平台版本不一致本身是風險**progress.md M9-6 findings L587
- 若未來要加 KL630/KL730 等新晶片支援macOS/Linux 的 2.0.0 wheel **沒有**對應 enum必須先升 wheel。
- `update_kdp_firmware_from_files` 在 3.1.2 Python wrapper 中不存在warrenchen 是 ctypes 直打 .so C symbol這類 API 差異在跨版本時要逐一確認。
**原則**:任何讀 SDK 結構shape / enum / API 簽章)的 code都要同時對 2.0.0 與 3.1.2 驗,或明確寫成「先試新版結構、失敗再退舊版」的相容寫法。
---
## §附錄 BBuild 完驗收 Checklist
Build 完一個安裝包後,**至少**驗這些(每項標了對應的雷,避免重蹈覆轍):
- [ ] **.nef 數量正確**:打包版(不是開發模式)確認只帶白名單的 2 個 `.nef``FCOS Detection` / `Tiny YOLOv3`),不是 8 個。→ §4
(開發模式驗不出來:`server/data/nef/` 8 個都在、過濾器只濾「檔案不存在」,開發模式會顯示 7 個模型。M5 必須用打包版驗、progress.md L206。
- [ ] **models.json 有進安裝包**:啟動後 `/api/models` 回非空、log 印 `Loaded N built-in models`(不是 0
- [ ] **中文顯示正常**Windows 繁中機classification 標籤、log 的中文都不亂碼。→ §9
- [ ] **input size source 正確**:實機推論後 grep log 的 input size source確認不是掉到 declared / default 撿到垃圾值Windows 上尤其要看。→ §5
- [ ] **推論不報 Error 12 / Error 24**Windows 推論不 Error 12§5、裝置 reset 後 load_model 不 Error 24§1
- [ ] **Python venv 健康**:全新機器首次啟動能自動裝好 wheels不 ResolutionImpossible §8且啟動不被健康檢查誤擋§7
- [ ] **driver 綁定**Windows 上 KneronPLUS native DLL 能被載入(`import kp` 成功、掃得到裝置)。→ §7 / 附錄 A
- [ ] **Wails 視窗確認****開 app window** 確認主 UI 是 Next.js 而非 splash / installer wizard / 白畫面progress.md L1602 的歷史教訓M1 只用瀏覽器連 localhost 驗、沒開 window讓 wizard 殘留混過 M1-M6
**驗收原則**不要只驗「server 有回應」——很多雷§1 / §4 / §5 / §9`/api/health` 200 的情況下照樣存在。要驗到「真的能推論 + 顯示正確」。
---
## 相關文件
- `build-pipeline.md` — Makefile 骨架、vendor 目錄結構、CI 策略、版本號管理build 流程本體)。
- progress.md`.autoflow/progress.md`)— M10 各段開發紀錄與踩坑細節的一手來源。
- memory`~/.claude/projects/-Users-jimchen-visionA/memory/project_local_tool_dev_env.md` — 開發模式資源同步坑。

View File

@ -92,6 +92,12 @@ type Deps struct {
// fallback不需真 tunnel。詳見 device_driver_status.go。
DriverStatusFetcher driverStatusFetcher
// LocalTokenIssuer 是 POST /api/devices/:id/local-upload-ticketADR-019 WP-5的可選注入點。
// 為 nil 時 handler 從 Forwarder + SessionStore 組 defaultforwarderLocalTokenIssuer
// 既有 tunnel 打 local-agent /api/local/issue-token。unit test 注入 stub 驗成功 / 429 /
// tunnel 錯誤分支,不需真 tunnel。詳見 local_upload_ticket.go。
LocalTokenIssuer localTokenIssuer
DeviceRepo device.Repository
ModelRepo model.Repository

View File

@ -13,6 +13,7 @@ package api
import (
"context"
"errors"
"log/slog"
"net/http"
"time"
@ -39,6 +40,12 @@ func registerDeviceRoutes(g *gin.RouterGroup, deps Deps) {
// Unpair雛形實作軟刪 DeviceRepo + CloseSession
g.POST("/devices/:id/unpair", devicesUnpairHandler(deps))
// ADR-019 WP-5localhost 直連上傳的 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 回應中的單筆裝置。
@ -103,8 +110,13 @@ func devicesListHandler(deps Deps) gin.HandlerFunc {
return
}
// 查 tunnel 狀態(雛形:列全部 session 找當前 user 的;為空不致命)
tunnelAlive, lastSeen := resolveTunnelStatus(ctx, deps.SessionStore, userID)
// 查 tunnel 狀態(雛形:列全部 session 找當前 user 的;為空不致命)。
// 用獨立 ctx源自 request context給 tunnel 判定完整 3s 預算,避免前面 DeviceRepo.List
// 吃掉共用 ctx 的時間導致 store.List 逾時被靜默判離線R-3 離線誤判)。
tunnelCtx, tunnelCancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
defer tunnelCancel()
tunnelAlive, lastSeen := resolveTunnelStatus(
tunnelCtx, deps.SessionStore, userID, deps.Logger, "list", RequestIDFrom(c))
out := make([]DeviceListItem, 0, len(devices))
for _, d := range devices {
@ -188,7 +200,15 @@ func devicesGetHandler(deps Deps) gin.HandlerFunc {
return
}
tunnelAlive, lastSeen := resolveTunnelStatus(ctx, deps.SessionStore, userID)
// R-3 離線誤判修復(見 .autoflow/05-implementation/r3-offline-misjudge-rootcause.md
// detail 過去用同一個 2s ctx 先跑 DeviceRepo.Get 再跑 resolveTunnelStatus前面的 DB
// 呼叫吃掉時間後,打 relay 的 store.List 常逾時被靜默判離線,導致前端 fallback 到恆
// offline 的 DB 靜態值、R-3 誤擋上傳。改用獨立 ctx源自 request context給 tunnel
// 判定完整 3s 預算,與 list endpoint 對齊。2s 對打 relay 的 HTTP 本來就偏緊。
tunnelCtx, tunnelCancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
defer tunnelCancel()
tunnelAlive, lastSeen := resolveTunnelStatus(
tunnelCtx, deps.SessionStore, userID, deps.Logger, "detail", RequestIDFrom(c))
item := DeviceListItem{
ID: d.ID,
Name: d.Name,
@ -333,19 +353,55 @@ func devicesUnpairHandler(deps Deps) gin.HandlerFunc {
// Phase 0.7 security audit M2寬鬆比對暫保留待人工介入。
// 詳細理由見 pickActiveSessionToken 註解relay 端 LocalHandle.Summary 不帶 UserID。
// 修復 caller (handler) 已先做 strict UserContext 檢查userID 必非空。
func resolveTunnelStatus(ctx context.Context, store session.Store, userID string) (bool, time.Time) {
//
// 可觀測性R-3 離線誤判排查,見 .autoflow/05-implementation/r3-offline-misjudge-rootcause.md
// list 與 detail 都呼叫此函式,但 detail 曾用較緊的 ctx timeout 導致 store.List 逾時被靜默
// 判離線。加 log 以在 stage 重現時分辨兩個嫌疑:
// - 嫌疑 1store.List 回 err尤其 context deadline exceeded→ 逾時判離線。
// - 嫌疑 2拿到 summaries 但沒有一筆命中 userID → 比對不中判離線。
//
// endpoint 參數("list" / "detail"標明呼叫來源log 不帶 token 等敏感資訊。
func resolveTunnelStatus(
ctx context.Context,
store session.Store,
userID string,
logger *slog.Logger,
endpoint string,
requestID string,
) (bool, time.Time) {
if store == nil || userID == "" {
return false, time.Time{}
}
log := logOrDefault(logger)
summaries, err := store.List(ctx)
if err != nil {
// 嫌疑 1List 逾時 / 報錯 → 靜默判離線fail-safe語意保留
// deadline 標記讓 stage log 能一眼分辨「ctx 逾時」vs「relay 其他錯誤」。
log.Warn("devices: resolveTunnelStatus store.List failed, treating tunnel as offline",
"endpoint", endpoint,
"user_id", userID,
"deadline_exceeded", errors.Is(err, context.DeadlineExceeded),
"error", err.Error(),
"request_id", requestID)
return false, time.Time{}
}
for _, s := range summaries {
// 寬鬆比對:暫接受 s.UserID == "" 直到 relay 端 backfill UserIDM2 待人工介入)。
if s.UserID == "" || s.UserID == userID {
log.Debug("devices: resolveTunnelStatus matched session, tunnel online",
"endpoint", endpoint,
"user_id", userID,
"session_user_id_empty", s.UserID == "",
"summaries_count", len(summaries),
"request_id", requestID)
return true, s.LastHeartbeat
}
}
// 嫌疑 2拿到 list 但沒有一筆命中 → 比對不中判離線。
log.Debug("devices: resolveTunnelStatus no matching session, tunnel offline",
"endpoint", endpoint,
"user_id", userID,
"summaries_count", len(summaries),
"request_id", requestID)
return false, time.Time{}
}

View File

@ -13,6 +13,7 @@ import (
"github.com/stretchr/testify/require"
"visiona-backend/internal/device"
"visiona-backend/internal/session"
)
// newDevicesFixture 建立 router 並塞好必要依賴InMemory repo + fakeSessionStore
@ -114,3 +115,78 @@ func TestDevicesGet_NotFound(t *testing.T) {
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/devices/ghost", nil))
assert.Equal(t, http.StatusNotFound, w.Code)
}
// deadlineRecordingStore 記錄 List 收到的 ctx 剩餘 deadline並回一筆命中 session。
// 用來驗證 detail handler 給 resolveTunnelStatus 的 ctx 有足夠預算≥3s非被前面
// DB 呼叫吃掉的殘餘)。
type deadlineRecordingStore struct {
fakeSessionStore
gotRemaining time.Duration
hasDeadline bool
}
func (s *deadlineRecordingStore) List(ctx context.Context) ([]*session.Summary, error) {
if dl, ok := ctx.Deadline(); ok {
s.hasDeadline = true
s.gotRemaining = time.Until(dl)
}
return []*session.Summary{
{UserID: "demo-user", LastHeartbeat: time.Now().UTC()},
}, nil
}
// TestDevicesGet_TunnelCtxHasFullBudget 驗證 R-3 離線誤判修復:
// detail endpoint 給 tunnel 判定的 ctx 有完整 3s 預算(獨立於前面的 DeviceRepo.Get
// 且能正確回 tunnel_online=true。修復前 detail 用同一個 2s ctx前面的 DB 呼叫吃掉時間後
// 打 relay 的 store.List 常逾時被靜默判離線。
func TestDevicesGet_TunnelCtxHasFullBudget(t *testing.T) {
repo := device.NewInMemoryRepository()
require.NoError(t, repo.Save(context.Background(), &device.Device{
ID: "mine", OwnerUserID: "demo-user", Name: "kl520", DeviceType: "kl520",
}))
store := &deadlineRecordingStore{}
r := gin.New()
r.Use(RequestIDMiddleware())
r.Use(injectStaticUserContext("demo-user", ""))
g := r.Group("/api")
registerDeviceRoutes(g, Deps{
DeviceRepo: repo,
SessionStore: store,
})
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/devices/mine", nil))
require.Equal(t, http.StatusOK, w.Code)
var sb SuccessBody
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &sb))
item := sb.Data.(map[string]any)
assert.Equal(t, true, item["tunnel_online"], "命中 session 應判 tunnel_online=true")
// 核心斷言tunnel 判定拿到的 ctx 剩餘預算應接近完整 3s獨立 ctx
// 而非修復前殘餘的 <2s。給寬鬆下界 2.5s 容忍測試機排程抖動。
require.True(t, store.hasDeadline, "tunnel ctx 應有 deadline")
assert.Greater(t, store.gotRemaining, 2500*time.Millisecond,
"tunnel 判定應拿到近乎完整的 3s 預算,不被前面 DeviceRepo.Get 吃掉")
}
// TestResolveTunnelStatus_ListTimeoutTreatedOffline 驗證嫌疑 1 語意保留:
// store.List 逾時context deadline exceeded→ 靜默判離線fail-safe 不變)。
func TestResolveTunnelStatus_ListTimeoutTreatedOffline(t *testing.T) {
store := &fakeSessionStore{listErr: context.DeadlineExceeded}
alive, _ := resolveTunnelStatus(
context.Background(), store, "demo-user", nil, "detail", "test-req")
assert.False(t, alive, "List 逾時應判離線fail-safe 語意保留)")
}
// TestResolveTunnelStatus_NoMatchTreatedOffline 驗證嫌疑 2 語意:
// 拿到 list 但沒有一筆命中 userID → 判離線。
func TestResolveTunnelStatus_NoMatchTreatedOffline(t *testing.T) {
store := &fakeSessionStore{sessions: []*session.Summary{
{UserID: "someone-else", LastHeartbeat: time.Now().UTC()},
}}
alive, _ := resolveTunnelStatus(
context.Background(), store, "demo-user", nil, "detail", "test-req")
assert.False(t, alive, "無命中 session 應判離線")
}

View File

@ -0,0 +1,316 @@
// local_upload_ticket.go — POST /api/devices/:id/local-upload-ticket 的雲端 ticket handlerADR-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-serverPOST /api/devices/:id/local-upload-ticket本 handler
// api-server 驗 OIDC sessionAuthMiddleware+ 裝置歸屬GetBySerial
// → 經既有 tunnel 轉發打 local-agentPOST /api/local/issue-tokenbody {"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.3POST /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。
//
// 刻意設短5sissue-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 語意:
// - errLocalTokenLimitlocal-agent 回 429未使用 token 達 32 上限)→ caller 透傳 429。
// - session.ErrSessionNotFound / ErrSessionClosedtunnel 離線 → 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-tokenbody {"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 bodyapi-spec §6.3)。
type issueTokenRequest struct {
Serial string `json:"serial"`
}
// issueTokenEnvelope 是 local-agent /api/local/issue-token 的回應 envelopeapi-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 時回 nilcaller 據此回 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 tokenpickActiveSessionToken
// 2. 組 POST /api/local/issue-tokenbody {"serial":serial}),經 Forwarder.ForwardHTTP 送到 local-agent
// 3. 解 envelope429 → errLocalTokenLimitsuccess + 有 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()
// 限讀 bodyissue-token 回應極小;防禦性 64KB 上界,避免異常 local-agent 撐爆記憶體)。
raw, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
if err != nil {
return LocalUploadTicket{}, err
}
// 429token 上限已滿 → 透傳 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 組 defaultproduction
// - 兩者皆缺 → 回 nilhandler 回 501代表 tunnel 未配置)
func resolveLocalTokenIssuer(deps Deps) localTokenIssuer {
if deps.LocalTokenIssuer != nil {
return deps.LocalTokenIssuer
}
return newForwarderLocalTokenIssuer(deps)
}
// localUploadTicketHandler 實作 POST /api/devices/:id/local-upload-ticketADR-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拿不到 = 500middleware 設定錯誤)
// 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 → 透傳 token429 透傳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-tokenissuer 內部用 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_ERRORlocal-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-3tunnel 離線時無法取得 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)
}
}

View File

@ -0,0 +1,204 @@
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_DISCONNECTEDR-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)
}

View File

@ -29,6 +29,18 @@ vi.mock("@/hooks/use-inference-stream", () => ({
useInferenceStream: vi.fn(),
}));
// ADR-019 WP-4mock 影片 localhost 直連編排單元隔離local-media 自身有 local-media.test.ts
// 保留真實 LocalMediaError 供錯誤分支測試。
const uploadVideoViaLocalAgent = vi.fn();
vi.mock("@/lib/local-media", async () => {
const actual =
await vi.importActual<typeof import("@/lib/local-media")>("@/lib/local-media");
return {
...actual,
uploadVideoViaLocalAgent: (...a: unknown[]) => uploadVideoViaLocalAgent(...a),
};
});
// 可控 ResizeObserver把 callback 存起來,測試決定何時觸發
let roCallbacks: ResizeObserverCallback[] = [];
class ControllableRO {
@ -43,6 +55,7 @@ class ControllableRO {
}
import { useInferenceStream } from "@/hooks/use-inference-stream";
import { LocalMediaError } from "@/lib/local-media";
import { WorkspaceClient } from "./workspace-client";
@ -91,6 +104,7 @@ function renderClient() {
beforeEach(() => {
post.mockReset();
uploadVideoViaLocalAgent.mockReset();
roCallbacks = [];
(globalThis as { ResizeObserver?: unknown }).ResizeObserver = ControllableRO;
useInferenceStore.setState({
@ -280,3 +294,93 @@ describe("WorkspaceClient — serial 路由WP-C / ADR-018", () => {
expect(screen.queryByTestId("media-no-serial")).not.toBeInTheDocument();
});
});
/**
* ADR-019 WP-4 localhost local-agent tunnel
* uploadVideoViaLocalAgent serial
* tunnel friendly i18n
*/
describe("WorkspaceClient — 影片 localhost 直連WP-4", () => {
/** 在影片分頁的 dropzone 選檔(觸發上傳流程)。 */
function selectVideo(file: File) {
const input = document.querySelector(
'[data-testid="media-uploader-video"] input[type=file]',
) as HTMLInputElement;
Object.defineProperty(input, "files", { value: [file], configurable: true });
fireEvent.change(input);
}
it("影片上傳走 uploadVideoViaLocalAgent帶 serial不走雲端 uploadVideo", async () => {
uploadVideoViaLocalAgent.mockResolvedValue({
streamUrl: "/api/camera/stream",
sourceType: "video",
totalFrames: 100,
});
renderClient();
await switchTab("影片");
// 線上 + 有 serial → 顯示 uploader非離線提示
expect(screen.getByTestId("media-uploader-video")).toBeInTheDocument();
selectVideo(new File([new Blob(["x"])], "clip.mp4"));
await waitFor(() => {
expect(uploadVideoViaLocalAgent).toHaveBeenCalled();
});
// 第一個參數是 serial、第二個是 File
const [serialArg, fileArg] = uploadVideoViaLocalAgent.mock.calls[0]!;
expect(serialArg).toBe("KN00123456");
expect(fileArg).toBeInstanceOf(File);
});
it("tunnel 離線remoteStatus != online→ 影片分頁停用上傳 + 顯示離線提示", async () => {
useDeviceStore.setState({
selectedDevice: {
id: "dev-1",
name: "KL520",
remoteStatus: "offline",
lastSeenAt: null,
serialNumber: "KN00123456",
} as ReturnType<typeof useDeviceStore.getState>["selectedDevice"],
isLoading: false,
});
renderClient();
// 離線時整頁會有 offline 遮罩,但分頁內容仍 render切到影片分頁驗證停用提示
await switchTab("影片");
expect(
screen.getByTestId("media-upload-disabled-video"),
).toBeInTheDocument();
// 不出現 uploader
expect(screen.queryByTestId("media-uploader-video")).not.toBeInTheDocument();
});
it("NOT_FOUND → 顯示「需同機操作」friendly 訊息(非 raw code", async () => {
uploadVideoViaLocalAgent.mockRejectedValue(
new LocalMediaError("LOCAL_AGENT_NOT_FOUND"),
);
renderClient();
await switchTab("影片");
selectVideo(new File([new Blob(["x"])], "clip.mp4"));
await waitFor(() => {
const err = screen.getByTestId("media-uploader-video-error");
expect(err.textContent).toContain("同一台電腦");
// 不可洩漏 raw code
expect(err.textContent).not.toContain("LOCAL_AGENT_NOT_FOUND");
});
});
it("MISMATCH → 顯示「Agent 與裝置不符」friendly 訊息", async () => {
uploadVideoViaLocalAgent.mockRejectedValue(
new LocalMediaError("LOCAL_AGENT_MISMATCH"),
);
renderClient();
await switchTab("影片");
selectVideo(new File([new Blob(["x"])], "clip.mp4"));
await waitFor(() => {
expect(
screen.getByTestId("media-uploader-video-error").textContent,
).toContain("不符");
});
});
});

View File

@ -51,18 +51,26 @@ import {
VIDEO_ACCEPT,
uploadBatchImages,
uploadImage,
uploadVideo,
validateBatchFiles,
validateImageFile,
validateVideoFile,
validateLocalVideoFile,
} from "@/lib/media";
import {
LocalMediaError,
uploadVideoViaLocalAgent,
} from "@/lib/local-media";
import { useT } from "@/lib/i18n/context";
import { useDeviceStore } from "@/stores/device-store";
import { useInferenceStore } from "@/stores/inference-store";
import type { MediaUploadResponse } from "@/types/camera";
import { toast } from "sonner";
/** 影片上傳 timeout 拉長(大檔經 tunnel評估 R-M20 = 不限。 */
/**
* timeout0 =
*
* ADR-019 WP-4 localhost loopback0.12.5s tunnel
* 0 / 500MB timeout
*/
const VIDEO_UPLOAD_TIMEOUT_MS = 0;
interface WorkspaceClientProps {
@ -114,6 +122,36 @@ export function WorkspaceClient({ deviceId }: WorkspaceClientProps) {
const serialNumber = selectedDevice?.serialNumber ?? null;
const hasSerial = !!serialNumber;
// ADR-019 WP-4影片 localhost 直連上傳失敗 → 依 error.code 對應 friendly i18n。
// MediaUploader 以 thrown error 的 message 顯示,故這裡把 code 轉成已本地化的字串再拋。
const localVideoErrorMessage = useCallback(
(err: unknown): string => {
const code =
err instanceof LocalMediaError || err instanceof ApiError
? err.code
: undefined;
switch (code) {
case "LOCAL_AGENT_NOT_FOUND":
return t("workspace.media.local.notFound");
case "LOCAL_AGENT_MISMATCH":
return t("workspace.media.local.mismatch");
case "LOCAL_TOKEN_INVALID":
return t("workspace.media.local.tokenInvalid");
case "LOCAL_UPLOAD_TOO_LARGE":
return t("workspace.media.local.tooLarge");
// tunnel 離線時取 token 會失敗502理論上 UI 已先 disable見 video tab
// uploadDisabled此為防禦性 fallback。
case "TUNNEL_DISCONNECTED":
case "TUNNEL_ERROR":
return t("workspace.media.local.offline");
default:
// 其他network / timeout / 未預期)→ 保留原始訊息(含 abort 由 uploader 自行忽略)
return err instanceof Error ? err.message : String(err);
}
},
[t],
);
// 塊 3切換 tab。離開 camera tab 時若正在推論 → 停掉(避免 camera 與 media 兩套 WS 同時灌 store
const handleTabChange = useCallback(
(value: string) => {
@ -345,6 +383,10 @@ export function WorkspaceClient({ deviceId }: WorkspaceClientProps) {
)}
</TabsContent>
{/* ADR-019 WP-4 localhost local-agent tunnel
- validate 500MB localhost media.ts validateLocalVideoFile
- R-3tunnel !isOnline + tunnel fallback
- error.code friendly i18nlocalVideoErrorMessage */}
<TabsContent value="video" className="space-y-4">
{!hasSerial ? (
noSerialNotice
@ -354,15 +396,25 @@ export function WorkspaceClient({ deviceId }: WorkspaceClientProps) {
isOnline={!!isOnline}
sourceType="video"
accept={VIDEO_ACCEPT}
uploadDisabled={!isOnline}
uploadDisabledMessage={t("workspace.media.local.offline")}
validate={(files) =>
files[0] ? validateVideoFile(files[0]) : { code: "EMPTY" }
files[0] ? validateLocalVideoFile(files[0]) : { code: "EMPTY" }
}
upload={async (files, ctx) => {
return uploadVideo(serialNumber!, files[0]!, {
onProgress: ctx.onProgress,
signal: ctx.signal,
timeoutMs: VIDEO_UPLOAD_TIMEOUT_MS,
});
try {
return await uploadVideoViaLocalAgent(serialNumber!, files[0]!, {
onProgress: ctx.onProgress,
signal: ctx.signal,
timeoutMs: VIDEO_UPLOAD_TIMEOUT_MS,
});
} catch (err) {
// 使用者取消AbortErrorapi.ts code=ABORTED / name=AbortError→ 原樣拋回,
// 讓 MediaUploader 依 signal.aborted 靜默回 idle不顯示錯誤
if (err instanceof Error && err.name === "AbortError") throw err;
// 其餘NOT_FOUND / MISMATCH / token / 413 / network …)轉 friendly i18n
throw new Error(localVideoErrorMessage(err));
}
}}
/>
)}

View File

@ -55,6 +55,14 @@ export interface MediaTabProps {
files: File[],
ctx: { onProgress: (p: number) => void; signal: AbortSignal },
) => Promise<MediaUploadResponse>;
/**
* ADR-019 R-3 localhost tunnel
* / disable + 使
* false uploader
*/
uploadDisabled?: boolean;
/** uploadDisabled 為 true 時顯示的提示文字i18n由 caller 傳入)。 */
uploadDisabledMessage?: string;
}
export function MediaTab({
@ -65,6 +73,8 @@ export function MediaTab({
multiple = false,
validate,
upload,
uploadDisabled = false,
uploadDisabledMessage,
}: MediaTabProps) {
const t = useT();
const [streamUrl, setStreamUrl] = useState("");
@ -163,7 +173,18 @@ export function MediaTab({
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[1fr_20rem]">
<Card className="min-h-[60vh]">
<CardContent className="grid h-full min-h-[60vh] place-items-center p-6">
{!effectiveStreamUrl ? (
{!effectiveStreamUrl && uploadDisabled ? (
// R-3tunnel 離線 → 停用上傳 + 明確提示(不顯示 uploader
<div
role="alert"
className="max-w-md space-y-2 text-center"
data-testid={`media-upload-disabled-${sourceType}`}
>
<p className="text-sm font-medium">
{uploadDisabledMessage ?? t("workspace.media.errorGeneric")}
</p>
</div>
) : !effectiveStreamUrl ? (
<MediaUploader
accept={accept}
multiple={multiple}

View File

@ -369,6 +369,16 @@ export const en: Dictionary = {
"workspace.media.batch.hint": "JPG or PNG · up to 50 files · 20 MB each",
"workspace.media.batch.progress": "Image {current} / {total}",
// ── Video localhost direct-upload errors (ADR-019 WP-4) ──
"workspace.media.local.notFound":
"Video upload must be done on the same computer running the visionA Agent. Make sure the Agent is running on this machine and try again.",
"workspace.media.local.mismatch":
"The detected Agent doesn't match this device. Make sure this computer is running the visionA Agent for this device.",
"workspace.media.local.offline":
"This device is offline; video upload is unavailable. Please wait for the device to reconnect.",
"workspace.media.local.tokenInvalid": "Upload authorization expired. Please try uploading again.",
"workspace.media.local.tooLarge": "Video file is too large (500 MB max).",
// ── Settings ──
"settings.title": "Settings",
"settings.subtitle": "Manage preferences and cloud endpoints",

View File

@ -357,6 +357,16 @@ export const zhHant: Dictionary = {
"workspace.media.batch.hint": "JPG 或 PNG · 最多 50 張 · 每張上限 20 MB",
"workspace.media.batch.progress": "第 {current} / {total} 張",
// ── 影片 localhost 直連上傳錯誤ADR-019 WP-4──
"workspace.media.local.notFound":
"影片上傳需在執行 visionA Agent 的同一台電腦上操作。請確認 Agent 正在此電腦執行後再試。",
"workspace.media.local.mismatch":
"偵測到的 Agent 與目前裝置不符。請確認此電腦執行的是這台裝置的 visionA Agent。",
"workspace.media.local.offline":
"裝置目前離線,無法上傳影片。請待裝置重新連線後再試。",
"workspace.media.local.tokenInvalid": "上傳授權已失效,請重新上傳。",
"workspace.media.local.tooLarge": "影片檔案過大(上限 500 MB。",
// ── Settings ──
"settings.title": "設定",
"settings.subtitle": "管理偏好與雲端端點",

View File

@ -0,0 +1,363 @@
/**
* local-agent.ts ADR-019 WP-3
*
* mock / local-agent
* - computeSerialHashSHA-256("visiona-local-v1" || serial) hex Web Crypto
* - probeLocalAgentPort 3721 37223740 timeout
* - resolveLocalAgentOK / 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 / resolveLocalAgentmock 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("resolveLocalAgentserial 相符 → 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");
});
});
/* -------------------------------------------------------------------------- */
/* uploadToLocalAgentmock 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("uploadToLocalAgentXHR 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_INVALIDenvelope 有 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" });
});
});

View File

@ -0,0 +1,443 @@
/**
* Local-agent localhost visionA Cloud ADR-019 WP-3
*
* client utilendpoint
* - `probeLocalAgentPort()` local-agent port
* sessionStorage 3721 37223740 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 sessionBFFlocal-agent
* loopback URL + one-time token headerADR-019 §2.1
*
*
* api-spec §6.26.5 / ADR-019 §2.3
* - port 37213740local-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 fallbackADR-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/healthADR-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 回傳的 dataapi-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 hashWeb 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/hellotimeout 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、不需 cookieapi-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 → 37223740 並發) */
/* -------------------------------------------------------------------------- */
/**
* local-agent port ProbeHit
*
* ADR-019 §2.3
* 1. sessionStorage port session
* 2. 3721 port
* 3. 37223740 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) 37223740 並發,取第一個成功者
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 serialNumberkn_numberADR-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 agentADR-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 multipartendpoint
*
* callerimage / 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 URLhttp://127.0.0.1:<port>),非 same-origin
* - credentials cookiewithCredentials = falseapi-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不帶 cookietoken 走 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 的 errorLOCAL_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);
});
}

View File

@ -0,0 +1,208 @@
/**
* local-media.ts ADR-019 WP-4
*
* mock local-agent
* - getLocalUploadTicket ticket route token
* - uploadVideoViaLocalAgent ticket resolveLocalAgent uploadToLocalAgent
* happy path NOT_FOUND / MISMATCH / ticket /
*
* mock
* - mock `@/lib/api` api.postticket endpoint
* - mock `@/lib/local-agent` resolveLocalAgent / uploadToLocalAgentWP-3
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
// mock 雲端 api clientticket endpoint
const apiPost = vi.fn();
vi.mock("@/lib/api", async () => {
const actual = await vi.importActual<typeof import("@/lib/api")>("@/lib/api");
return { ...actual, api: { ...actual.api, post: (...a: unknown[]) => apiPost(...a) } };
});
// mock WP-3 低階 util各自已有 local-agent.test.ts 覆蓋)
const resolveLocalAgent = vi.fn();
const uploadToLocalAgent = vi.fn();
vi.mock("@/lib/local-agent", async () => {
const actual =
await vi.importActual<typeof import("@/lib/local-agent")>("@/lib/local-agent");
return {
...actual,
resolveLocalAgent: (...a: unknown[]) => resolveLocalAgent(...a),
uploadToLocalAgent: (...a: unknown[]) => uploadToLocalAgent(...a),
};
});
import { ApiError } from "@/lib/api";
import { LOCAL_UPLOAD_VIDEO_PATH } from "@/lib/local-agent";
import type { MediaUploadResponse } from "@/types/camera";
import {
LOCAL_UPLOAD_TICKET_PATH,
LocalMediaError,
getLocalUploadTicket,
uploadVideoViaLocalAgent,
} from "./local-media";
const SERIAL = "KN00123456";
function videoFile(): File {
return new File([new Blob([new Uint8Array(8)])], "v.mp4");
}
function okUploadResponse(): MediaUploadResponse {
return {
streamUrl: "/api/camera/stream",
sourceType: "video",
totalFrames: 100,
durationSeconds: 3.3,
};
}
beforeEach(() => {
apiPost.mockReset();
resolveLocalAgent.mockReset();
uploadToLocalAgent.mockReset();
});
/* -------------------------------------------------------------------------- */
/* getLocalUploadTicket */
/* -------------------------------------------------------------------------- */
describe("getLocalUploadTicket", () => {
it("打對雲端 ticket route含 serial encode、回 token", async () => {
apiPost.mockResolvedValue({ token: "tok-1", expiresAt: 123, ttlSeconds: 120 });
const ticket = await getLocalUploadTicket(SERIAL);
expect(ticket.token).toBe("tok-1");
expect(apiPost).toHaveBeenCalledWith(LOCAL_UPLOAD_TICKET_PATH(SERIAL));
expect(LOCAL_UPLOAD_TICKET_PATH(SERIAL)).toBe(
`/api/devices/${SERIAL}/local-upload-ticket`,
);
});
it("serial 含特殊字元 → path 有 encode", () => {
expect(LOCAL_UPLOAD_TICKET_PATH("a/b c")).toBe(
"/api/devices/a%2Fb%20c/local-upload-ticket",
);
});
});
/* -------------------------------------------------------------------------- */
/* uploadVideoViaLocalAgent — happy path */
/* -------------------------------------------------------------------------- */
describe("uploadVideoViaLocalAgent — happy path", () => {
it("ticket → resolve OK → 直連上傳,回 MediaUploadResponse", async () => {
apiPost.mockResolvedValue({ token: "tok-abc", ttlSeconds: 120 });
resolveLocalAgent.mockResolvedValue({ status: "OK", port: 3725 });
uploadToLocalAgent.mockResolvedValue(okUploadResponse());
const res = await uploadVideoViaLocalAgent(SERIAL, videoFile(), {
timeoutMs: 0,
});
expect(res.sourceType).toBe("video");
// resolve 用 serial
expect(resolveLocalAgent).toHaveBeenCalledWith(SERIAL);
// upload 用 resolve 回的 port + 影片 route + token
const [port, path, form, opts] = uploadToLocalAgent.mock.calls[0]!;
expect(port).toBe(3725);
expect(path).toBe(LOCAL_UPLOAD_VIDEO_PATH);
expect((form as FormData).get("deviceId")).toBe(SERIAL);
expect((form as FormData).get("file")).toBeInstanceOf(File);
expect((opts as { token: string }).token).toBe("tok-abc");
expect((opts as { timeoutMs: number }).timeoutMs).toBe(0);
});
it("progress / signal 透傳給 uploadToLocalAgent", async () => {
apiPost.mockResolvedValue({ token: "t" });
resolveLocalAgent.mockResolvedValue({ status: "OK", port: 3721 });
uploadToLocalAgent.mockResolvedValue(okUploadResponse());
const onProgress = vi.fn();
const ctrl = new AbortController();
await uploadVideoViaLocalAgent(SERIAL, videoFile(), {
onProgress,
signal: ctrl.signal,
});
const opts = uploadToLocalAgent.mock.calls[0]![3] as {
onProgress: unknown;
signal: unknown;
};
expect(opts.onProgress).toBe(onProgress);
expect(opts.signal).toBe(ctrl.signal);
});
});
/* -------------------------------------------------------------------------- */
/* uploadVideoViaLocalAgent — 錯誤分支 */
/* -------------------------------------------------------------------------- */
describe("uploadVideoViaLocalAgent — 錯誤分支", () => {
it("resolve NOT_FOUND → 拋 LocalMediaError(LOCAL_AGENT_NOT_FOUND),不呼叫 upload", async () => {
apiPost.mockResolvedValue({ token: "t" });
resolveLocalAgent.mockResolvedValue({ status: "NOT_FOUND" });
await expect(
uploadVideoViaLocalAgent(SERIAL, videoFile()),
).rejects.toMatchObject({ code: "LOCAL_AGENT_NOT_FOUND" });
expect(uploadToLocalAgent).not.toHaveBeenCalled();
});
it("resolve MISMATCH → 拋 LocalMediaError(LOCAL_AGENT_MISMATCH),不呼叫 upload", async () => {
apiPost.mockResolvedValue({ token: "t" });
resolveLocalAgent.mockResolvedValue({ status: "MISMATCH" });
const err = await uploadVideoViaLocalAgent(SERIAL, videoFile()).catch(
(e: unknown) => e,
);
expect(err).toBeInstanceOf(LocalMediaError);
expect((err as LocalMediaError).code).toBe("LOCAL_AGENT_MISMATCH");
expect(uploadToLocalAgent).not.toHaveBeenCalled();
});
it("resolve 回 OK 但沒有 port防禦→ 視為 MISMATCH", async () => {
apiPost.mockResolvedValue({ token: "t" });
resolveLocalAgent.mockResolvedValue({ status: "OK", port: undefined });
await expect(
uploadVideoViaLocalAgent(SERIAL, videoFile()),
).rejects.toMatchObject({ code: "LOCAL_AGENT_MISMATCH" });
expect(uploadToLocalAgent).not.toHaveBeenCalled();
});
it("ticket 取得失敗(如 tunnel 離線 502→ 直接往上拋、不 resolve / upload", async () => {
apiPost.mockRejectedValue(
new ApiError(502, { code: "TUNNEL_DISCONNECTED", message: "offline" }),
);
await expect(
uploadVideoViaLocalAgent(SERIAL, videoFile()),
).rejects.toMatchObject({ code: "TUNNEL_DISCONNECTED", status: 502 });
expect(resolveLocalAgent).not.toHaveBeenCalled();
expect(uploadToLocalAgent).not.toHaveBeenCalled();
});
it("上傳階段 413 → 原樣拋 ApiError(LOCAL_UPLOAD_TOO_LARGE)", async () => {
apiPost.mockResolvedValue({ token: "t" });
resolveLocalAgent.mockResolvedValue({ status: "OK", port: 3721 });
uploadToLocalAgent.mockRejectedValue(
new ApiError(413, { code: "LOCAL_UPLOAD_TOO_LARGE", message: "too large" }),
);
await expect(
uploadVideoViaLocalAgent(SERIAL, videoFile()),
).rejects.toMatchObject({ code: "LOCAL_UPLOAD_TOO_LARGE", status: 413 });
});
it("上傳階段 401 → 原樣拋 ApiError(LOCAL_TOKEN_INVALID)", async () => {
apiPost.mockResolvedValue({ token: "t" });
resolveLocalAgent.mockResolvedValue({ status: "OK", port: 3721 });
uploadToLocalAgent.mockRejectedValue(
new ApiError(401, { code: "LOCAL_TOKEN_INVALID", message: "invalid" }),
);
await expect(
uploadVideoViaLocalAgent(SERIAL, videoFile()),
).rejects.toMatchObject({ code: "LOCAL_TOKEN_INVALID", status: 401 });
});
});

View File

@ -0,0 +1,135 @@
/**
* Local-agent visionA Cloud ADR-019 WP-4
*
* WP-3 util
* 1. `getLocalUploadTicket(serial)` `POST /api/devices/:serial/local-upload-ticket`
* one-time upload token OIDC cookie session + tunnel api-spec §6.3
* 2. `uploadVideoViaLocalAgent(serial, file, opts)` token local-agent
* POST `/api/local/media/upload/video` X-Visiona-Local-Token
*
* media.ts tunnel
* - media.ts uploadVideo same-origin cookieBFF+ tunnel forward
* - localhosttokenADR-019 §2.1
* - ** localhost tunnel fallback**ADR §4.2
* media.ts uploadVideo
*
* caller UI i18napi-spec §6.5
* - resolve `LocalMediaError`code: LOCAL_AGENT_NOT_FOUND / LOCAL_AGENT_MISMATCH
* - token / HTTP 沿 api.ts ApiErrorcode: LOCAL_TOKEN_INVALID /
* LOCAL_TOKEN_LIMIT / LOCAL_UPLOAD_TOO_LARGE / TUNNEL_DISCONNECTED /
* callerworkspace-client error.code i18n
*/
import { api } from "@/lib/api";
import {
LOCAL_UPLOAD_VIDEO_PATH,
resolveLocalAgent,
uploadToLocalAgent,
} from "@/lib/local-agent";
import type { UploadMediaOptions } from "@/lib/media";
import type { KnownErrorCode } from "@/types/api";
import type { MediaUploadResponse } from "@/types/camera";
/** 雲端 ticket endpointapi-spec §6.3;驗 OIDC session + 裝置歸屬後經 tunnel issue-token。 */
export const LOCAL_UPLOAD_TICKET_PATH = (serial: string): string =>
`/api/devices/${encodeURIComponent(serial)}/local-upload-ticket`;
/** ticket endpoint 回傳api-spec §6.3issue-token 的 data 透傳)。 */
export interface LocalUploadTicket {
token: string;
/** token 到期時間unix ms。 */
expiresAt?: number;
/** TTL 秒數(契約固定 120s。 */
ttlSeconds?: number;
}
/**
* resolve local-agent HTTP
*
* Error 沿 ApiError
* NOT_FOUND / MISMATCH api-spec §6.5
* HTTP status caller `instanceof` code
*/
export class LocalMediaError extends Error {
readonly code: Extract<
KnownErrorCode,
"LOCAL_AGENT_NOT_FOUND" | "LOCAL_AGENT_MISMATCH"
>;
constructor(
code: LocalMediaError["code"],
message = code,
) {
super(message);
this.name = "LocalMediaError";
this.code = code;
}
}
/**
* one-time upload token OIDC cookie session + tunnel
*
* @param serial serialNumberkn_numberADR-018 serial
* @throws ApiErrorTUNNEL_DISCONNECTED / LOCAL_TOKEN_LIMIT / envelope
*/
export function getLocalUploadTicket(
serial: string,
): Promise<LocalUploadTicket> {
// api.post 已解 envelope、回 data走 same-origin cookieBFF與其他雲端呼叫一致。
return api.post<LocalUploadTicket>(LOCAL_UPLOAD_TICKET_PATH(serial));
}
/** uploadVideoViaLocalAgent 的選項(沿用 media 的進度 / 取消 / timeout。 */
export type UploadLocalVideoOptions = UploadMediaOptions;
/**
* ADR-019 §2.4 [1][4]
*
*
* 1. token ticket ApiError
* 2. resolveLocalAgent(serial) + + serial
* NOT_FOUND / MISMATCH LocalMediaErrorcaller i18n
* 3. uploadToLocalAgent(port, /api/local/media/upload/video, form, { token })
*
* **caller ** tunnel R-3tunnel
* token tunnel issue-token 使
* caller UI disable workspace-client
*
* @param serial serialNumberkn_number
* @param file / caller media.ts validateLocalVideoFile
* @param options / / timeout
* @returns envelope MediaUploadResponse streamUrl / totalFrames / durationSeconds
* @throws LocalMediaError | ApiError | NetworkError | TimeoutError | AbortError
*/
export async function uploadVideoViaLocalAgent(
serial: string,
file: File,
options: UploadLocalVideoOptions = {},
): Promise<MediaUploadResponse> {
// [1] 取 token雲端此步失敗會拋 ApiError含 TUNNEL_DISCONNECTED 等)
const ticket = await getLocalUploadTicket(serial);
// 已取消就不必再探測 / 上傳
if (options.signal?.aborted) {
// 讓 caller 的 abort 分支一致:沿用 local-agent 的 AbortError透過 upload 拋)
// 這裡直接進入 upload 前的 guard 由 uploadToLocalAgent 處理 aborted signal。
}
// [2] 探測同機 local-agent + serial 身分驗證
const resolved = await resolveLocalAgent(serial);
if (resolved.status === "NOT_FOUND") {
throw new LocalMediaError("LOCAL_AGENT_NOT_FOUND");
}
if (resolved.status === "MISMATCH" || resolved.port === undefined) {
throw new LocalMediaError("LOCAL_AGENT_MISMATCH");
}
// [3] 直連上傳FormData 欄位名維持 deviceId、值帶 serial與雲端 route 相同)
const form = new FormData();
form.append("deviceId", serial);
form.append("file", file);
return uploadToLocalAgent(resolved.port, LOCAL_UPLOAD_VIDEO_PATH, form, {
...options,
token: ticket.token,
});
}

View File

@ -11,6 +11,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
MAX_BATCH_IMAGES,
MAX_BATCH_TOTAL_BYTES,
MAX_LOCAL_VIDEO_BYTES,
MAX_VIDEO_BYTES,
VIDEO_FALLBACK_FPS,
buildBatchImageUrl,
@ -20,6 +22,7 @@ import {
uploadImage,
validateBatchFiles,
validateImageFile,
validateLocalVideoFile,
validateVideoFile,
} from "./media";
@ -59,16 +62,35 @@ describe("media validation", () => {
expect(validateVideoFile(makeFile("v.jpg"))?.code).toBe("TYPE");
});
it("影片:上限為 500 MB常數校驗", () => {
expect(MAX_VIDEO_BYTES).toBe(500 * 1024 * 1024);
it("影片:上限為 90 MB常數校驗", () => {
expect(MAX_VIDEO_BYTES).toBe(90 * 1024 * 1024);
});
it("影片:499 MB 通過,剛好 500 MB 通過501 MB 擋 SIZE邊界", () => {
it("影片:89 MB 通過,剛好 90 MB 通過91 MB 擋 SIZE邊界", () => {
const mb = 1024 * 1024;
expect(validateVideoFile(makeSizedFile("v.mp4", 499 * mb))).toBeNull();
expect(validateVideoFile(makeSizedFile("v.mp4", 89 * mb))).toBeNull();
// 剛好等於上限validateVideoFile 用 > 判斷,等於上限應通過
expect(validateVideoFile(makeSizedFile("v.mp4", 500 * mb))).toBeNull();
expect(validateVideoFile(makeSizedFile("v.mp4", 501 * mb))?.code).toBe("SIZE");
expect(validateVideoFile(makeSizedFile("v.mp4", 90 * mb))).toBeNull();
expect(validateVideoFile(makeSizedFile("v.mp4", 91 * mb))?.code).toBe("SIZE");
});
it("影片 localhost 路徑:上限為 500 MBADR-019 WP-4 放寬,常數校驗)", () => {
expect(MAX_LOCAL_VIDEO_BYTES).toBe(500 * 1024 * 1024);
});
it("validateLocalVideoFile91 MB過 tunnel 上限)仍通過,剛好 500 MB 通過501 MB 擋 SIZE", () => {
const mb = 1024 * 1024;
// 91 MB 若走舊 tunnel 上限90MB會被擋localhost 路徑放寬到 500MB → 通過
expect(validateLocalVideoFile(makeSizedFile("v.mp4", 91 * mb))).toBeNull();
expect(validateLocalVideoFile(makeSizedFile("v.mp4", 500 * mb))).toBeNull();
expect(validateLocalVideoFile(makeSizedFile("v.mp4", 501 * mb))?.code).toBe(
"SIZE",
);
});
it("validateLocalVideoFile型別檢查與 validateVideoFile 相同(非影片擋 TYPE", () => {
expect(validateLocalVideoFile(makeFile("v.mp4"))).toBeNull();
expect(validateLocalVideoFile(makeFile("v.jpg"))?.code).toBe("TYPE");
});
it("批次:空陣列 → EMPTY超過 50 → COUNT含非圖 → TYPE", () => {
@ -85,6 +107,41 @@ describe("media validation", () => {
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", () => {

View File

@ -91,7 +91,32 @@ export const VIDEO_ACCEPT = ".mp4,.avi,.mov,.mpeg,.mpg";
/** 前端上傳大小上限(防呆;影片經 tunnel 有 timeout 考量,見評估 R-M2。 */
export const MAX_IMAGE_BYTES = 20 * 1024 * 1024; // 20 MB
export const MAX_VIDEO_BYTES = 500 * 1024 * 1024; // 500 MB大檔經 tunnelcaller 端 timeout 設 0 = 不限,見 workspace-client VIDEO_UPLOAD_TIMEOUT_MS
export const MAX_VIDEO_BYTES = 90 * 1024 * 1024; // 90 MB過渡值對齊 nginx client_max_body_size 100M留 10 MB buffer 給 multipart overhead避免 HTTP 413。此上限僅適用於**經 tunnel 的雲端路徑**uploadVideo影片分頁已改走 localhost 直連,見 MAX_LOCAL_VIDEO_BYTES
/**
* **localhost local-agent** ADR-019 WP-4
*
* 90MB 500MB
* 90MB nginx `client_max_body_size 100M` **** tunnel
* localhost nginx / 300s timeout / stage
* 500MBADR-019 §4.3.1`http.MaxBytesReader` 500MB
* 500MBsecurity loopback 1GB loopback
*
* ** localhost tunnel fallback**
* ADR §4.2退 90MB tunnel
* `MAX_VIDEO_BYTES`90MB tunnel `uploadVideo` / caller
*/
export const MAX_LOCAL_VIDEO_BYTES = 500 * 1024 * 1024; // 500 MB對齊後端 localhost 硬牆)
/**
* ADR-019 §2.2 / batch 80MB
*
* `validateBatchFiles` MAX_IMAGE_BYTES20MB****
* 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 {
/** 上傳進度 callback0~100 */
@ -269,9 +294,18 @@ export function buildBatchImageUrl(index: number, cacheBust?: string): string {
return `${full}?_t=${encodeURIComponent(cacheBust)}`;
}
/** 前端檔案驗證結果(給 UI 顯示錯誤用;不信任副檔名,也擋大小)。 */
/**
* UI
*
* code
* - `TYPE`
* - `SIZE` image 20MB / video 90MB
* - `COUNT` MAX_BATCH_IMAGES
* - `EMPTY`
* - `TOTAL_SIZE` MAX_BATCH_TOTAL_BYTESADR-019 §2.2 filename
*/
export interface FileValidationError {
code: "TYPE" | "SIZE" | "COUNT" | "EMPTY";
code: "TYPE" | "SIZE" | "COUNT" | "EMPTY" | "TOTAL_SIZE";
filename?: string;
}
@ -286,24 +320,55 @@ export function validateImageFile(file: File): FileValidationError | null {
return null;
}
/** 依副檔名 + 大小驗證影片。回傳 null 表通過。 */
export function validateVideoFile(file: File): FileValidationError | null {
/**
* + null
*
* @param file
* @param maxBytes MAX_VIDEO_BYTES = 90MB** tunnel **
* localhost MAX_LOCAL_VIDEO_BYTES500MB validateLocalVideoFile
*/
export function validateVideoFile(
file: File,
maxBytes: number = MAX_VIDEO_BYTES,
): FileValidationError | null {
if (!/\.(mp4|avi|mov|mpe?g)$/i.test(file.name)) {
return { code: "TYPE", filename: file.name };
}
if (file.size > MAX_VIDEO_BYTES) {
if (file.size > maxBytes) {
return { code: "SIZE", filename: file.name };
}
return null;
}
/** 驗證整批圖片(數量 + 每張型別 / 大小)。回傳 null 表通過。 */
/**
* localhost local-agent ADR-019 WP-4 500MB
*
* validateVideoFile size
* caller inline maxBytes 500MB
* image / batch validator
*/
export function validateLocalVideoFile(file: File): FileValidationError | null {
return validateVideoFile(file, MAX_LOCAL_VIDEO_BYTES);
}
/**
* + / + null
*
*
* 1. EMPTY
* 2. COUNT
* 3. / TYPE / SIZE沿 validateImageFile
* 4. MAX_BATCH_TOTAL_BYTES TOTAL_SIZEADR-019 §2.2
*/
export function validateBatchFiles(files: File[]): FileValidationError | null {
if (files.length === 0) return { code: "EMPTY" };
if (files.length > MAX_BATCH_IMAGES) return { code: "COUNT" };
let totalBytes = 0;
for (const f of files) {
const err = validateImageFile(f);
if (err) return err;
totalBytes += f.size;
}
if (totalBytes > MAX_BATCH_TOTAL_BYTES) return { code: "TOTAL_SIZE" };
return null;
}

View File

@ -41,6 +41,12 @@ export type KnownErrorCode =
| "NOT_IMPLEMENTED"
| "RATE_LIMITED"
| "INTERNAL_ERROR"
// ADR-019 local-agent 直連api-spec §6.5
| "LOCAL_AGENT_NOT_FOUND" // 前端內部狀態:掃描無回應(非同機 / agent 沒跑)
| "LOCAL_AGENT_MISMATCH" // 前端內部狀態:有回應但 serial 不符
| "LOCAL_TOKEN_INVALID" // 401token 不存在 / 過期 / 已使用 / 缺失
| "LOCAL_TOKEN_LIMIT" // 429未使用 token 達 32 上限
| "LOCAL_UPLOAD_TOO_LARGE" // 413上傳超過 size 上限
// 前端自建,表示「非後端回傳」的錯誤
| "NETWORK_ERROR"
| "TIMEOUT"