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>
This commit is contained in:
jim800121chen 2026-07-30 12:32:26 +08:00
parent 4c962dfec1
commit 9031153553
19 changed files with 3014 additions and 32 deletions

View File

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

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 package api
import ( import (
"net"
"net/http" "net/http"
"net/url" "net/url"
"os"
"strings" "strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
// allowedHosts 定義 CORS 白名單的 hostname。 // allowedHosts 定義 loopback CORS 白名單的 hostname。
// 任何 port 都允許scheme 只允許 http本機不可能是 https // 任何 port 都允許scheme 只允許 http本機不可能是 https
// //
// M8-8TDD v2/cors-security.md §3.1 // M8-8TDD v2/cors-security.md §3.1
// v2 模式下 UI 改在使用者瀏覽器中跑server 同時暴露給其他瀏覽器分頁, // v2 模式下 UI 改在使用者瀏覽器中跑server 同時暴露給其他瀏覽器分頁,
// 必須限定 cross-origin 來源在本機 loopback避免惡意網站透過 CORS 攻擊。 // 必須限定 cross-origin 來源在本機 loopback避免惡意網站透過 CORS 攻擊。
//
// ADR-019 §2.5:此 loopback 舊規則「保留不動」——不因開放雲端 origin 而變更。
var allowedHosts = map[string]bool{ var allowedHosts = map[string]bool{
"127.0.0.1": true, "127.0.0.1": true,
"localhost": true, "localhost": true,
@ -21,7 +25,53 @@ var allowedHosts = map[string]bool{
"::1": true, "::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 // 合法例http://127.0.0.1:3721 / http://localhost:3721 / http://[::1]:3721
// 不合法例https://127.0.0.1:3721 / http://evil.com / null / http://192.168.1.5:3721 // 不合法例https://127.0.0.1:3721 / http://evil.com / null / http://192.168.1.5:3721
@ -30,6 +80,8 @@ var allowedHosts = map[string]bool{
// - 空字串視為非白名單(呼叫端會自行決定 same-origin 路徑)。 // - 空字串視為非白名單(呼叫端會自行決定 same-origin 路徑)。
// - "null"local file、某些 sandboxed iframe一律拒絕。 // - "null"local file、某些 sandboxed iframe一律拒絕。
// - 只允許 http scheme本機不會有 https。 // - 只允許 http scheme本機不會有 https。
//
// ADR-019此函式維持 loopback 舊邏輯不動;雲端 origin 走 isAllowedCloudOrigin。
func isAllowedOrigin(origin string) bool { func isAllowedOrigin(origin string) bool {
if origin == "" || origin == "null" { if origin == "" || origin == "null" {
return false return false
@ -45,14 +97,19 @@ func isAllowedOrigin(origin string) bool {
return allowedHosts[host] 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→ 直接放行; // 1. Origin header 為空 → same-origin瀏覽器 same-origin 不送 Origin→ 直接放行;
// 若是 OPTIONS 預檢則回 204 即停(避免帶 ACA* 給沒人看的請求)。 // 若是 OPTIONS 預檢則回 204 即停(避免帶 ACA* 給沒人看的請求)。
// 2. Origin 在白名單 → 回完整 ACA* headersOPTIONS → 204其他方法 → 繼續執行 handler。 // 2. Origin 在 loopback 白名單 → 回完整 ACA* headers含 Allow-Credentials: true
// 3. Origin 不在白名單: // 沿用 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*。 // - state-changing 方法POST/PUT/DELETE/PATCH/OPTIONS→ 403 Forbidden不回 ACA*。
// - 簡單讀取GET/HEAD→ 執行 handler 但不回 ACA*,瀏覽器 JS 讀不到 body。 // - 簡單讀取GET/HEAD→ 執行 handler 但不回 ACA*,瀏覽器 JS 讀不到 body。
// //
@ -74,33 +131,109 @@ func CORSMiddleware() gin.HandlerFunc {
return return
} }
if !isAllowedOrigin(origin) { // 雲端白名單 originADR-019完整 origin 精確比對,獨立於 loopback。
// 非白名單 Origin if isAllowedCloudOrigin(origin) {
// - state-changing 方法 → 403嚴格擋 c.Header("Access-Control-Allow-Origin", origin)
// - GET/HEAD → 執行但不回 ACA*(瀏覽器層擋) c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
if method == http.MethodOptions || c.Header("Access-Control-Allow-Headers", "Content-Type, X-Visiona-Local-Token")
method == http.MethodPost || // M3雲端 origin 一律 false——用 header 帶 token、不需 cookie
method == http.MethodPut || // 避免無謂讓瀏覽器願意帶 credential 而擴大 CSRF / 憑證面。
method == http.MethodDelete || c.Header("Access-Control-Allow-Credentials", "false")
method == http.MethodPatch { c.Header("Access-Control-Max-Age", "600")
c.AbortWithStatus(http.StatusForbidden) 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 return
} }
c.Next() c.Next()
return return
} }
// 白名單 Origin回完整 ACA* headers // loopback 白名單 origin沿用 M8-8 既有行為Allow-Credentials: true
c.Header("Access-Control-Allow-Origin", origin) if isAllowedOrigin(origin) {
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") c.Header("Access-Control-Allow-Origin", origin)
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization") c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
c.Header("Access-Control-Allow-Credentials", "true") c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Visiona-Local-Token")
c.Header("Vary", "Origin") c.Header("Access-Control-Allow-Credentials", "true")
c.Header("Access-Control-Max-Age", "600")
c.Header("Vary", "Origin")
if method == http.MethodOptions { if method == http.MethodOptions {
c.AbortWithStatus(http.StatusNoContent) // 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 return
} }
c.Next() c.Next()
} }
} }
// HostGuard 是 DNS rebinding 的獨立第二道防護ADR-019 §2.5 M2必做
//
// 檢查 Host header去 port 後)必須 ∈ {127.0.0.1, localhost, ::1}
// 否則 400 Bad Request。與 CORS 正交CORS 擋 Origin、HostGuard 擋 Host。
//
// 套用範圍:
// - 所有 /api/local/*(含 WP-2 新增的 /api/local/media/upload/*
// - 舊 tunnel-path media route/api/media/upload/*)——關舊 route 的殘留面。
//
// 為什麼 tunnel 轉發不受影響tunnel client 轉發到本地 server 時
// req.URL.Host = 127.0.0.1:<port>client.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 ( import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings"
"testing" "testing"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@ -199,3 +200,253 @@ func TestCORSMiddleware_SameOrigin(t *testing.T) {
t.Errorf("same-origin 不應回 ACA-Origingot %q", got) 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) deviceHandler := handlers.NewDeviceHandler(deviceMgr, flashSvc, inferenceSvc, wsHub)
cameraHandler := handlers.NewCameraHandler(cameraMgr, deviceMgr, inferenceSvc, wsHub) cameraHandler := handlers.NewCameraHandler(cameraMgr, deviceMgr, inferenceSvc, wsHub)
// ADR-019本機直連 one-time token store記憶體、process 生命週期)。
// 惰性清理 + 背景 goroutine 每 60s 掃過期 token。
tokenStore := NewTokenStore()
tokenStore.StartCleanup()
localHandler := handlers.NewLocalHandler(deviceMgr, tokenStore)
api := r.Group("/api") api := r.Group("/api")
{ {
// System // System
@ -87,11 +93,30 @@ func NewRouter(
api.GET("/camera/stream", cameraHandler.StreamMJPEG) api.GET("/camera/stream", cameraHandler.StreamMJPEG)
// Media // Media
api.POST("/media/upload/image", cameraHandler.UploadImage) // ADR-019 §2.5 M2舊 tunnel-path media upload route 加 HostGuard
api.POST("/media/upload/video", cameraHandler.UploadVideo) // 關「同機直打舊 route」的殘留面Host=loopback 才放行)。
api.POST("/media/upload/batch-images", cameraHandler.UploadBatchImages) // 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.GET("/media/batch-images/:index", cameraHandler.GetBatchImageFrame)
api.POST("/media/seek", cameraHandler.SeekVideo) 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 // WebSocket
@ -179,8 +204,9 @@ func broadcasterLogger(b *logger.Broadcaster) gin.HandlerFunc {
// for Next.js static export client-side routing. // for Next.js static export client-side routing.
// //
// Next.js static export with generateStaticParams creates: // Next.js static export with generateStaticParams creates:
// /models/index.html — static page //
// /models/_/index.html — 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: // For a request like /models/yolov5-face-detection:
// 1. Try exact file → not found // 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

@ -92,6 +92,12 @@ type Deps struct {
// fallback不需真 tunnel。詳見 device_driver_status.go。 // fallback不需真 tunnel。詳見 device_driver_status.go。
DriverStatusFetcher driverStatusFetcher DriverStatusFetcher driverStatusFetcher
// LocalTokenIssuer 是 POST /api/devices/:id/local-upload-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 DeviceRepo device.Repository
ModelRepo model.Repository ModelRepo model.Repository

View File

@ -39,6 +39,12 @@ func registerDeviceRoutes(g *gin.RouterGroup, deps Deps) {
// Unpair雛形實作軟刪 DeviceRepo + CloseSession // Unpair雛形實作軟刪 DeviceRepo + CloseSession
g.POST("/devices/:id/unpair", devicesUnpairHandler(deps)) g.POST("/devices/:id/unpair", devicesUnpairHandler(deps))
// ADR-019 WP-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 回應中的單筆裝置。 // DeviceListItem 是 GET /api/devices 回應中的單筆裝置。

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

@ -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

@ -11,6 +11,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { import {
MAX_BATCH_IMAGES, MAX_BATCH_IMAGES,
MAX_BATCH_TOTAL_BYTES,
MAX_VIDEO_BYTES, MAX_VIDEO_BYTES,
VIDEO_FALLBACK_FPS, VIDEO_FALLBACK_FPS,
buildBatchImageUrl, buildBatchImageUrl,
@ -85,6 +86,41 @@ describe("media validation", () => {
expect(validateBatchFiles([makeFile("a.jpg"), makeFile("b.png")])).toBeNull(); expect(validateBatchFiles([makeFile("a.jpg"), makeFile("b.png")])).toBeNull();
}); });
it("批次合計上限:常數為 80 MB校驗", () => {
expect(MAX_BATCH_TOTAL_BYTES).toBe(80 * 1024 * 1024);
});
it("批次合計上限50 張各 19MB合計 950MB→ TOTAL_SIZE原地雷", () => {
const mb = 1024 * 1024;
const files = Array.from({ length: 50 }, (_, i) =>
makeSizedFile(`img${i}.jpg`, 19 * mb),
);
// 逐張都 ≤20MB 會通過單檔檢查,但合計 950MB 應被合計上限擋下
expect(validateBatchFiles(files)?.code).toBe("TOTAL_SIZE");
});
it("批次合計上限:剛好 80MB 通過、超過 1 byte 擋 TOTAL_SIZE邊界", () => {
const mb = 1024 * 1024;
// 8 張各 10MB = 80MB剛好等於上限用 > 判斷 → 通過)
const exactly = Array.from({ length: 8 }, (_, i) =>
makeSizedFile(`e${i}.jpg`, 10 * mb),
);
expect(validateBatchFiles(exactly)).toBeNull();
// 在 80MB 基礎上多 1 byte → 超過上限
const over = [
...Array.from({ length: 8 }, (_, i) => makeSizedFile(`o${i}.jpg`, 10 * mb)),
makeSizedFile("extra.jpg", 1),
];
expect(validateBatchFiles(over)?.code).toBe("TOTAL_SIZE");
});
it("批次合計上限單檔超限SIZE優先於合計檢查", () => {
// 一張 21MB單檔超 20MB 上限)→ 應回 SIZE 而非 TOTAL_SIZE
const files = [makeSizedFile("big.jpg", 21 * 1024 * 1024)];
expect(validateBatchFiles(files)?.code).toBe("SIZE");
});
}); });
describe("frameToSeekSeconds", () => { describe("frameToSeekSeconds", () => {

View File

@ -93,6 +93,16 @@ export const VIDEO_ACCEPT = ".mp4,.avi,.mov,.mpeg,.mpg";
export const MAX_IMAGE_BYTES = 20 * 1024 * 1024; // 20 MB export const MAX_IMAGE_BYTES = 20 * 1024 * 1024; // 20 MB
export const MAX_VIDEO_BYTES = 90 * 1024 * 1024; // 90 MB過渡值對齊 nginx client_max_body_size 100M留 10 MB buffer 給 multipart overhead避免 HTTP 413。未來影片走 localhost 直連後可放寬) export const MAX_VIDEO_BYTES = 90 * 1024 * 1024; // 90 MB過渡值對齊 nginx client_max_body_size 100M留 10 MB buffer 給 multipart overhead避免 HTTP 413。未來影片走 localhost 直連後可放寬)
/**
* ADR-019 §2.2 / batch 80MB
*
* `validateBatchFiles` MAX_IMAGE_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 { export interface UploadMediaOptions {
/** 上傳進度 callback0~100 */ /** 上傳進度 callback0~100 */
onProgress?: (percent: number) => void; onProgress?: (percent: number) => void;
@ -269,9 +279,18 @@ export function buildBatchImageUrl(index: number, cacheBust?: string): string {
return `${full}?_t=${encodeURIComponent(cacheBust)}`; return `${full}?_t=${encodeURIComponent(cacheBust)}`;
} }
/** 前端檔案驗證結果(給 UI 顯示錯誤用;不信任副檔名,也擋大小)。 */ /**
* UI
*
* code
* - `TYPE`
* - `SIZE` image 20MB / video 90MB
* - `COUNT` MAX_BATCH_IMAGES
* - `EMPTY`
* - `TOTAL_SIZE` MAX_BATCH_TOTAL_BYTESADR-019 §2.2 filename
*/
export interface FileValidationError { export interface FileValidationError {
code: "TYPE" | "SIZE" | "COUNT" | "EMPTY"; code: "TYPE" | "SIZE" | "COUNT" | "EMPTY" | "TOTAL_SIZE";
filename?: string; filename?: string;
} }
@ -297,13 +316,24 @@ export function validateVideoFile(file: File): FileValidationError | null {
return null; return null;
} }
/** 驗證整批圖片(數量 + 每張型別 / 大小)。回傳 null 表通過。 */ /**
* + / + null
*
*
* 1. EMPTY
* 2. COUNT
* 3. / TYPE / SIZE沿 validateImageFile
* 4. MAX_BATCH_TOTAL_BYTES TOTAL_SIZEADR-019 §2.2
*/
export function validateBatchFiles(files: File[]): FileValidationError | null { export function validateBatchFiles(files: File[]): FileValidationError | null {
if (files.length === 0) return { code: "EMPTY" }; if (files.length === 0) return { code: "EMPTY" };
if (files.length > MAX_BATCH_IMAGES) return { code: "COUNT" }; if (files.length > MAX_BATCH_IMAGES) return { code: "COUNT" };
let totalBytes = 0;
for (const f of files) { for (const f of files) {
const err = validateImageFile(f); const err = validateImageFile(f);
if (err) return err; if (err) return err;
totalBytes += f.size;
} }
if (totalBytes > MAX_BATCH_TOTAL_BYTES) return { code: "TOTAL_SIZE" };
return null; return null;
} }

View File

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