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:
parent
4c962dfec1
commit
9031153553
@ -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 可能是不同的(或 nil)VideoSource。此處對 h.videoPath 明確補一次
|
||||||
|
// os.Remove 作為 belt-and-suspenders——已被刪過時第二次 Remove 是無害 no-op。
|
||||||
|
if h.videoPath != "" {
|
||||||
|
_ = os.Remove(h.videoPath)
|
||||||
|
}
|
||||||
h.activeSource = nil
|
h.activeSource = nil
|
||||||
h.sourceType = ""
|
h.sourceType = ""
|
||||||
h.videoPath = ""
|
h.videoPath = ""
|
||||||
|
|||||||
167
local-agent/server/internal/api/handlers/local_handler.go
Normal file
167
local-agent/server/internal/api/handlers/local_handler.go
Normal 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 雜湊的固定公開常數 salt(ADR-019 §2.3 / api-spec §6.3 議題 1 裁決)。
|
||||||
|
//
|
||||||
|
// 刻意「公開、固定、前後端共用寫死」——非 server 私有隨機值。
|
||||||
|
// 目的是讓前端能用 Web Crypto 獨立重算 SHA-256("visiona-local-v1" || serial) 比對,
|
||||||
|
// 而非「防暴力還原」(序號熵低、salt 公開時仍可枚舉回推,security 判定與威脅相稱)。
|
||||||
|
//
|
||||||
|
// 明確不要做:不得用 crypto/rand 私有 salt(前端算不出);不得 per-request 隨機 salt。
|
||||||
|
const LocalSerialSalt = "visiona-local-v1"
|
||||||
|
|
||||||
|
// deviceLister 抽象 device.Manager 的 ListDevices,方便測試注入。
|
||||||
|
type deviceLister interface {
|
||||||
|
ListDevices() []deviceInfoView
|
||||||
|
}
|
||||||
|
|
||||||
|
// deviceInfoView 是 hello 需要的最小 device 視圖(只要 serial)。
|
||||||
|
type deviceInfoView struct {
|
||||||
|
SerialNumber string
|
||||||
|
}
|
||||||
|
|
||||||
|
// managerAdapter 把 *device.Manager 轉成 deviceLister。
|
||||||
|
type managerAdapter struct {
|
||||||
|
mgr *device.Manager
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a managerAdapter) ListDevices() []deviceInfoView {
|
||||||
|
infos := a.mgr.ListDevices()
|
||||||
|
out := make([]deviceInfoView, 0, len(infos))
|
||||||
|
for _, info := range infos {
|
||||||
|
out = append(out, deviceInfoView{SerialNumber: info.SerialNumber})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeSerialNumber 是 pyusb-fallback placeholder,代表「沒有真實序號」。
|
||||||
|
// 與 device 套件保持一致(device.manager.go),不對它計 hash(無意義且會洩漏 placeholder)。
|
||||||
|
const fakeSerialNumber = "0x00000000"
|
||||||
|
|
||||||
|
// LocalHandler 提供 ADR-019 的本機直連支援 endpoint(hello / issue-token)。
|
||||||
|
type LocalHandler struct {
|
||||||
|
devices deviceLister
|
||||||
|
store localTokenStore
|
||||||
|
}
|
||||||
|
|
||||||
|
// localTokenStore 是 LocalHandler 依賴的 token store 介面(issue 用)。
|
||||||
|
// 對應 api.TokenStore,用介面避免 handlers → api 的反向依賴。
|
||||||
|
type localTokenStore interface {
|
||||||
|
Issue(deviceID string) (token string, expiresAt time.Time, err error)
|
||||||
|
IsLimitErr(err error) bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLocalHandler 建立 LocalHandler。mgr 提供裝置序號、store 提供 token 發放。
|
||||||
|
func NewLocalHandler(mgr *device.Manager, store localTokenStore) *LocalHandler {
|
||||||
|
return &LocalHandler{
|
||||||
|
devices: managerAdapter{mgr: mgr},
|
||||||
|
store: store,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// hashSerial 計算 SHA-256(salt || fullSerial) 的 lowercase hex(ADR-019 §2.3)。
|
||||||
|
func hashSerial(serial string) string {
|
||||||
|
sum := sha256.Sum256([]byte(LocalSerialSalt + serial))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hello 是 GET /api/local/hello — 同機偵測 + 身分驗證(bootstrap,無 token)。
|
||||||
|
//
|
||||||
|
// 回傳(最小揭露,api-spec §6.3):
|
||||||
|
// - serialHashes:每個 = SHA-256("visiona-local-v1" || fullSerial) hex
|
||||||
|
// - supportsLocalUpload:布林 true
|
||||||
|
//
|
||||||
|
// 明確不回:agentVersion、完整 serial、deviceId、機器名、任何其他欄位。
|
||||||
|
func (h *LocalHandler) Hello(c *gin.Context) {
|
||||||
|
infos := h.devices.ListDevices()
|
||||||
|
hashes := make([]string, 0, len(infos))
|
||||||
|
for _, info := range infos {
|
||||||
|
serial := strings.TrimSpace(info.SerialNumber)
|
||||||
|
// 跳過空 / fake placeholder 序號——無真實身分、hash 它只會洩漏 placeholder。
|
||||||
|
if serial == "" || strings.EqualFold(serial, fakeSerialNumber) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
hashes = append(hashes, hashSerial(serial))
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"data": gin.H{
|
||||||
|
"serialHashes": hashes,
|
||||||
|
"supportsLocalUpload": true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// issueTokenRequest 是 issue-token 的請求 body。
|
||||||
|
type issueTokenRequest struct {
|
||||||
|
Serial string `json:"serial"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IssueToken 是 POST /api/local/issue-token — 產 one-time upload token。
|
||||||
|
//
|
||||||
|
// 取得路徑:僅經既有 tunnel 由 api-server 轉發呼叫(受 HostGuard 約束 = loopback)。
|
||||||
|
// 產出的 token 綁 deviceId(此處 = serial)+ one-time + 120s TTL。
|
||||||
|
// 達 32 上限 → 429 LOCAL_TOKEN_LIMIT。
|
||||||
|
//
|
||||||
|
// 稽核 log:記 deviceId + 成功/失敗,絕不 log token 明文。
|
||||||
|
func (h *LocalHandler) IssueToken(c *gin.Context) {
|
||||||
|
var req issueTokenRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": gin.H{
|
||||||
|
"code": "BAD_REQUEST", "message": "serial is required",
|
||||||
|
}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
serial := strings.TrimSpace(req.Serial)
|
||||||
|
if serial == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": gin.H{
|
||||||
|
"code": "BAD_REQUEST", "message": "serial is required",
|
||||||
|
}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token, expiresAt, err := h.store.Issue(serial)
|
||||||
|
if err != nil {
|
||||||
|
if h.store.IsLimitErr(err) {
|
||||||
|
// 稽核:達上限(不含 token)。
|
||||||
|
log.Printf("[local-token] issue REJECTED (limit) deviceId=%s ts=%s",
|
||||||
|
serial, time.Now().UTC().Format(time.RFC3339))
|
||||||
|
c.JSON(http.StatusTooManyRequests, gin.H{"success": false, "error": gin.H{
|
||||||
|
"code": "LOCAL_TOKEN_LIMIT", "message": "too many unused upload tokens",
|
||||||
|
}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("[local-token] issue ERROR deviceId=%s ts=%s err=%v",
|
||||||
|
serial, time.Now().UTC().Format(time.RFC3339), err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": gin.H{
|
||||||
|
"code": "INTERNAL_ERROR", "message": "failed to issue token",
|
||||||
|
}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 稽核:發放成功(deviceId + 時間,絕不記 token 明文)。
|
||||||
|
log.Printf("[local-token] issue OK deviceId=%s ts=%s",
|
||||||
|
serial, time.Now().UTC().Format(time.RFC3339))
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"data": gin.H{
|
||||||
|
"token": token,
|
||||||
|
"expiresAt": expiresAt.UnixMilli(),
|
||||||
|
"ttlSeconds": 120,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
234
local-agent/server/internal/api/handlers/local_handler_test.go
Normal file
234
local-agent/server/internal/api/handlers/local_handler_test.go
Normal 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_SerialHashes:hello 回 serialHashes(正確 hex)+ supportsLocalUpload,
|
||||||
|
// 跳過空 / fake 序號,且不回 agentVersion / 完整 serial。
|
||||||
|
func TestHello_SerialHashes(t *testing.T) {
|
||||||
|
h := &LocalHandler{
|
||||||
|
devices: fakeDeviceLister{serials: []string{
|
||||||
|
"0x1A2B3C4D",
|
||||||
|
"", // 空 → 跳過
|
||||||
|
"0x00000000", // fake placeholder → 跳過
|
||||||
|
"0xDEADBEEF",
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(w)
|
||||||
|
c.Request = httptest.NewRequest(http.MethodGet, "/api/local/hello", nil)
|
||||||
|
h.Hello(c)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Data struct {
|
||||||
|
SerialHashes []string `json:"serialHashes"`
|
||||||
|
SupportsLocalUpload bool `json:"supportsLocalUpload"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !resp.Success {
|
||||||
|
t.Error("success 應為 true")
|
||||||
|
}
|
||||||
|
if !resp.Data.SupportsLocalUpload {
|
||||||
|
t.Error("supportsLocalUpload 應為 true")
|
||||||
|
}
|
||||||
|
// 只應有兩個真實序號的 hash
|
||||||
|
if len(resp.Data.SerialHashes) != 2 {
|
||||||
|
t.Fatalf("serialHashes 數量 = %d, want 2(空與 fake 應被跳過)", len(resp.Data.SerialHashes))
|
||||||
|
}
|
||||||
|
wantSet := map[string]bool{
|
||||||
|
expectedHash("0x1A2B3C4D"): true,
|
||||||
|
expectedHash("0xDEADBEEF"): true,
|
||||||
|
}
|
||||||
|
for _, got := range resp.Data.SerialHashes {
|
||||||
|
if !wantSet[got] {
|
||||||
|
t.Errorf("非預期的 hash: %q", got)
|
||||||
|
}
|
||||||
|
// hex 必須是 lowercase、長度 64(SHA-256 = 32 bytes → 64 hex chars)
|
||||||
|
if len(got) != 64 {
|
||||||
|
t.Errorf("hash 長度 = %d, want 64", len(got))
|
||||||
|
}
|
||||||
|
if got != strings.ToLower(got) {
|
||||||
|
t.Errorf("hash 必須 lowercase hex,got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 最小揭露:不得出現 agentVersion / 完整 serial 明文
|
||||||
|
bodyStr := w.Body.String()
|
||||||
|
if strings.Contains(bodyStr, "agentVersion") {
|
||||||
|
t.Error("hello 不應回 agentVersion")
|
||||||
|
}
|
||||||
|
if strings.Contains(bodyStr, "0x1A2B3C4D") || strings.Contains(bodyStr, "0xDEADBEEF") {
|
||||||
|
t.Error("hello 不應回完整 serial 明文")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHello_EmptyDevices:無裝置 → serialHashes 為空陣列(非 null)。
|
||||||
|
func TestHello_EmptyDevices(t *testing.T) {
|
||||||
|
h := &LocalHandler{devices: fakeDeviceLister{serials: nil}}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(w)
|
||||||
|
c.Request = httptest.NewRequest(http.MethodGet, "/api/local/hello", nil)
|
||||||
|
h.Hello(c)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(w.Body.String(), `"serialHashes":[]`) {
|
||||||
|
t.Errorf("空裝置應回 serialHashes:[],got %s", w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHashSerial_Contract 直接驗證被測 hashSerial 的字串拼接 / 編碼 / hex 大小寫
|
||||||
|
// 與前端逐 byte 一致性複核需要的契約:SHA-256("visiona-local-v1"||serial) lowercase hex。
|
||||||
|
func TestHashSerial_Contract(t *testing.T) {
|
||||||
|
serial := "0x1A2B3C4D"
|
||||||
|
got := hashSerial(serial)
|
||||||
|
want := expectedHash(serial)
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("hashSerial(%q) = %q, want %q", serial, got, want)
|
||||||
|
}
|
||||||
|
// 明確固定一個已知向量,供前端對照(salt+serial 直接字串相接、UTF-8、SHA-256、lowercase hex)
|
||||||
|
// echo -n "visiona-local-v10x1A2B3C4D" | shasum -a 256
|
||||||
|
if len(got) != 64 || got != strings.ToLower(got) {
|
||||||
|
t.Errorf("hex 格式不符:len=%d lower=%v", len(got), got == strings.ToLower(got))
|
||||||
|
}
|
||||||
|
if LocalSerialSalt != "visiona-local-v1" {
|
||||||
|
t.Errorf("LocalSerialSalt = %q, want visiona-local-v1(前後端共用常數)", LocalSerialSalt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIssueToken_Success:正常發放 → 200 + token/expiresAt/ttlSeconds,deviceId 綁 serial。
|
||||||
|
func TestIssueToken_Success(t *testing.T) {
|
||||||
|
exp := time.UnixMilli(1_700_000_000_000)
|
||||||
|
store := &fakeStore{token: "tok-xyz", expiresAt: exp}
|
||||||
|
h := &LocalHandler{store: store}
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(w)
|
||||||
|
c.Request = httptest.NewRequest(http.MethodPost, "/api/local/issue-token",
|
||||||
|
strings.NewReader(`{"serial":"0xAAAA0001"}`))
|
||||||
|
c.Request.Header.Set("Content-Type", "application/json")
|
||||||
|
h.IssueToken(c)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200 (body=%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var resp struct {
|
||||||
|
Data struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
ExpiresAt int64 `json:"expiresAt"`
|
||||||
|
TTLSeconds int `json:"ttlSeconds"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if resp.Data.Token != "tok-xyz" {
|
||||||
|
t.Errorf("token = %q, want tok-xyz", resp.Data.Token)
|
||||||
|
}
|
||||||
|
if resp.Data.ExpiresAt != exp.UnixMilli() {
|
||||||
|
t.Errorf("expiresAt = %d, want %d", resp.Data.ExpiresAt, exp.UnixMilli())
|
||||||
|
}
|
||||||
|
if resp.Data.TTLSeconds != 120 {
|
||||||
|
t.Errorf("ttlSeconds = %d, want 120", resp.Data.TTLSeconds)
|
||||||
|
}
|
||||||
|
if store.gotDevice != "0xAAAA0001" {
|
||||||
|
t.Errorf("Issue deviceID = %q, want 0xAAAA0001(token 綁 serial)", store.gotDevice)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIssueToken_Limit:達上限 → 429 LOCAL_TOKEN_LIMIT。
|
||||||
|
func TestIssueToken_Limit(t *testing.T) {
|
||||||
|
store := &fakeStore{err: errors.New("limit"), isLimit: true}
|
||||||
|
h := &LocalHandler{store: store}
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(w)
|
||||||
|
c.Request = httptest.NewRequest(http.MethodPost, "/api/local/issue-token",
|
||||||
|
strings.NewReader(`{"serial":"0xAAAA0001"}`))
|
||||||
|
c.Request.Header.Set("Content-Type", "application/json")
|
||||||
|
h.IssueToken(c)
|
||||||
|
|
||||||
|
if w.Code != http.StatusTooManyRequests {
|
||||||
|
t.Fatalf("status = %d, want 429", w.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(w.Body.String(), "LOCAL_TOKEN_LIMIT") {
|
||||||
|
t.Errorf("body 應含 LOCAL_TOKEN_LIMIT,got %s", w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIssueToken_MissingSerial:缺 serial → 400。
|
||||||
|
func TestIssueToken_MissingSerial(t *testing.T) {
|
||||||
|
h := &LocalHandler{store: &fakeStore{}}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(w)
|
||||||
|
c.Request = httptest.NewRequest(http.MethodPost, "/api/local/issue-token",
|
||||||
|
strings.NewReader(`{}`))
|
||||||
|
c.Request.Header.Set("Content-Type", "application/json")
|
||||||
|
h.IssueToken(c)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want 400", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
97
local-agent/server/internal/api/local_middleware.go
Normal file
97
local-agent/server/internal/api/local_middleware.go
Normal 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、不看 Origin,C1)。
|
||||||
|
// 2. 用 http.MaxBytesReader 把 request body 包上 maxBytes 硬牆——
|
||||||
|
// 在讀取 multipart body 之前就限制總位元組,避免「未驗證就先收無上限大檔」(M1)。
|
||||||
|
// 3. 解析出 deviceId(PostForm 觸發 multipart 解析,但已被 MaxBytesReader 上限保護)。
|
||||||
|
// 若超過上限 → ParseMultipartForm 回 *http.MaxBytesError → 413 LOCAL_UPLOAD_TOO_LARGE。
|
||||||
|
// 4. Consume(token, deviceId)——single-flight 持鎖(查存在+比對+刪除同一 Lock,防 race,m2);
|
||||||
|
// deviceId 綁定不符 / 過期 / 已用 / 不存在 → 401 LOCAL_TOKEN_INVALID。
|
||||||
|
// 5. 通過 → c.Next() 進既有 handler(handler 業務邏輯零改動、直接 FormFile 讀已快取的表單)。
|
||||||
|
//
|
||||||
|
// 稽核 log:consume 成功/失敗記 deviceId + 時間,絕不 log token 明文。
|
||||||
|
//
|
||||||
|
// 為什麼 deviceId 取自表單而非 token:token 在 issue 時已綁 deviceId,Consume 會用
|
||||||
|
// ConstantTimeCompare 驗證「表單 deviceId == token 綁定 deviceId」,兩者不符即 401。
|
||||||
|
// 表單 deviceId 是既有 handler 本來就讀的欄位(api-spec §6.2 body 格式不變)。
|
||||||
|
func LocalUploadGuard(store tokenConsumer, maxBytes int64) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
token := c.GetHeader("X-Visiona-Local-Token")
|
||||||
|
if token == "" {
|
||||||
|
respondTokenInvalid(c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// M1:body 硬牆。放在解析 multipart 之前。
|
||||||
|
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxBytes)
|
||||||
|
|
||||||
|
// 觸發 multipart 解析取 deviceId。body 已被 MaxBytesReader 上限保護。
|
||||||
|
// 解析錯誤要區分「超過 size 上限(413)」與「其他 400」。
|
||||||
|
if err := c.Request.ParseMultipartForm(32 << 20); err != nil {
|
||||||
|
var maxErr *http.MaxBytesError
|
||||||
|
if errors.As(err, &maxErr) {
|
||||||
|
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"success": false, "error": gin.H{
|
||||||
|
"code": "LOCAL_UPLOAD_TOO_LARGE", "message": "upload exceeds size limit",
|
||||||
|
}})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": gin.H{
|
||||||
|
"code": "BAD_REQUEST", "message": "invalid multipart form",
|
||||||
|
}})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
deviceID := c.Request.FormValue("deviceId")
|
||||||
|
|
||||||
|
if err := store.Consume(token, deviceID); err != nil {
|
||||||
|
// 稽核:consume 失敗(deviceId + 時間,不含 token)。
|
||||||
|
log.Printf("[local-token] consume REJECTED deviceId=%s ts=%s",
|
||||||
|
deviceID, time.Now().UTC().Format(time.RFC3339))
|
||||||
|
respondTokenInvalid(c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 稽核:consume 成功。
|
||||||
|
log.Printf("[local-token] consume OK deviceId=%s ts=%s",
|
||||||
|
deviceID, time.Now().UTC().Format(time.RFC3339))
|
||||||
|
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// respondTokenInvalid 統一回 401 LOCAL_TOKEN_INVALID 並中止。
|
||||||
|
func respondTokenInvalid(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": gin.H{
|
||||||
|
"code": "LOCAL_TOKEN_INVALID", "message": "missing or invalid upload token",
|
||||||
|
}})
|
||||||
|
c.Abort()
|
||||||
|
}
|
||||||
269
local-agent/server/internal/api/local_middleware_test.go
Normal file
269
local-agent/server/internal/api/local_middleware_test.go
Normal 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 的 router,handler 記錄是否被呼叫並回讀 file。
|
||||||
|
func newGuardRouter(store tokenConsumer, maxBytes int64, handlerCalled *bool) *gin.Engine {
|
||||||
|
r := gin.New()
|
||||||
|
r.POST("/api/local/media/upload/video",
|
||||||
|
LocalUploadGuard(store, maxBytes),
|
||||||
|
func(c *gin.Context) {
|
||||||
|
*handlerCalled = true
|
||||||
|
// 模擬既有 handler 讀 file(驗證 middleware 解析後 handler 仍可 FormFile)
|
||||||
|
_, _, err := c.Request.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"formfile_err": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||||
|
})
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeErrCode 從 response body 取出 error.code。
|
||||||
|
func decodeErrCode(t *testing.T, body []byte) string {
|
||||||
|
t.Helper()
|
||||||
|
var resp struct {
|
||||||
|
Error struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
} `json:"error"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &resp); err != nil {
|
||||||
|
t.Fatalf("decode body %q: %v", string(body), err)
|
||||||
|
}
|
||||||
|
return resp.Error.Code
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLocalUploadGuard_MissingToken:無 X-Visiona-Local-Token → 401 LOCAL_TOKEN_INVALID,
|
||||||
|
// 且 store.Consume 不被呼叫、handler 不被呼叫。
|
||||||
|
func TestLocalUploadGuard_MissingToken(t *testing.T) {
|
||||||
|
store := &fakeConsumer{}
|
||||||
|
var handlerCalled bool
|
||||||
|
r := newGuardRouter(store, maxVideoUploadBytes, &handlerCalled)
|
||||||
|
|
||||||
|
body, ct := buildMultipart(t, "dev-1", []byte("small"))
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/local/media/upload/video", body)
|
||||||
|
req.Header.Set("Content-Type", ct)
|
||||||
|
// 刻意不帶 token
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("status = %d, want 401", w.Code)
|
||||||
|
}
|
||||||
|
if code := decodeErrCode(t, w.Body.Bytes()); code != "LOCAL_TOKEN_INVALID" {
|
||||||
|
t.Errorf("error code = %q, want LOCAL_TOKEN_INVALID", code)
|
||||||
|
}
|
||||||
|
if store.called {
|
||||||
|
t.Error("無 token 不應呼叫 Consume")
|
||||||
|
}
|
||||||
|
if handlerCalled {
|
||||||
|
t.Error("無 token 不應進入 handler")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLocalUploadGuard_ValidToken:有效 token → 放行、Consume 被呼叫且帶正確 token+deviceId、
|
||||||
|
// handler 被呼叫。
|
||||||
|
func TestLocalUploadGuard_ValidToken(t *testing.T) {
|
||||||
|
store := &fakeConsumer{returnErr: nil} // Consume 成功
|
||||||
|
var handlerCalled bool
|
||||||
|
r := newGuardRouter(store, maxVideoUploadBytes, &handlerCalled)
|
||||||
|
|
||||||
|
body, ct := buildMultipart(t, "dev-42", []byte("video-bytes"))
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/local/media/upload/video", body)
|
||||||
|
req.Header.Set("Content-Type", ct)
|
||||||
|
req.Header.Set("X-Visiona-Local-Token", "tok-abc")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200 (body=%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if !store.called {
|
||||||
|
t.Fatal("有效 token 應呼叫 Consume")
|
||||||
|
}
|
||||||
|
if store.gotToken != "tok-abc" {
|
||||||
|
t.Errorf("Consume token = %q, want tok-abc", store.gotToken)
|
||||||
|
}
|
||||||
|
if store.gotDeviceID != "dev-42" {
|
||||||
|
t.Errorf("Consume deviceID = %q, want dev-42(取自表單)", store.gotDeviceID)
|
||||||
|
}
|
||||||
|
if !handlerCalled {
|
||||||
|
t.Error("有效 token 應進入 handler")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLocalUploadGuard_ConsumedOrExpiredToken:Consume 回 ErrTokenInvalid(已用/過期/deviceId不符)
|
||||||
|
// → 401 LOCAL_TOKEN_INVALID,handler 不被呼叫。
|
||||||
|
func TestLocalUploadGuard_ConsumedOrExpiredToken(t *testing.T) {
|
||||||
|
store := &fakeConsumer{returnErr: ErrTokenInvalid}
|
||||||
|
var handlerCalled bool
|
||||||
|
r := newGuardRouter(store, maxVideoUploadBytes, &handlerCalled)
|
||||||
|
|
||||||
|
body, ct := buildMultipart(t, "dev-1", []byte("x"))
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/local/media/upload/video", body)
|
||||||
|
req.Header.Set("Content-Type", ct)
|
||||||
|
req.Header.Set("X-Visiona-Local-Token", "stale-tok")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("status = %d, want 401", w.Code)
|
||||||
|
}
|
||||||
|
if code := decodeErrCode(t, w.Body.Bytes()); code != "LOCAL_TOKEN_INVALID" {
|
||||||
|
t.Errorf("error code = %q, want LOCAL_TOKEN_INVALID", code)
|
||||||
|
}
|
||||||
|
if handlerCalled {
|
||||||
|
t.Error("無效 token 不應進入 handler")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLocalUploadGuard_TokenCheckedBeforeHandler:token 驗證發生在 handler(FormFile 讀檔)之前。
|
||||||
|
// 用「Consume 失敗時 handler 不被呼叫」+「Consume 成功時才進 handler」共同證明順序:
|
||||||
|
// 若 handler 先跑,無效 token 情境下 handlerCalled 會是 true。
|
||||||
|
func TestLocalUploadGuard_TokenCheckedBeforeHandler(t *testing.T) {
|
||||||
|
store := &fakeConsumer{returnErr: ErrTokenInvalid}
|
||||||
|
var handlerCalled bool
|
||||||
|
r := newGuardRouter(store, maxVideoUploadBytes, &handlerCalled)
|
||||||
|
|
||||||
|
body, ct := buildMultipart(t, "dev-1", bytes.Repeat([]byte("A"), 1024))
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/local/media/upload/video", body)
|
||||||
|
req.Header.Set("Content-Type", ct)
|
||||||
|
req.Header.Set("X-Visiona-Local-Token", "bad")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if handlerCalled {
|
||||||
|
t.Error("token 驗證失敗時 handler 不得被呼叫(證明 token 檢查在 handler 前)")
|
||||||
|
}
|
||||||
|
if !store.called {
|
||||||
|
t.Error("Consume 應在進 handler 前被呼叫")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLocalUploadGuard_TooLarge:body 超過 size 上限 → 413 LOCAL_UPLOAD_TOO_LARGE,
|
||||||
|
// handler 不被呼叫。用很小的 maxBytes 觸發。
|
||||||
|
func TestLocalUploadGuard_TooLarge(t *testing.T) {
|
||||||
|
const tinyMax = 64 // 64 bytes,遠小於下方 body
|
||||||
|
store := &fakeConsumer{returnErr: nil}
|
||||||
|
var handlerCalled bool
|
||||||
|
r := newGuardRouter(store, tinyMax, &handlerCalled)
|
||||||
|
|
||||||
|
body, ct := buildMultipart(t, "dev-1", bytes.Repeat([]byte("A"), 4096))
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/local/media/upload/video", body)
|
||||||
|
req.Header.Set("Content-Type", ct)
|
||||||
|
req.Header.Set("X-Visiona-Local-Token", "tok")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusRequestEntityTooLarge {
|
||||||
|
t.Fatalf("status = %d, want 413 (body=%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if code := decodeErrCode(t, w.Body.Bytes()); code != "LOCAL_UPLOAD_TOO_LARGE" {
|
||||||
|
t.Errorf("error code = %q, want LOCAL_UPLOAD_TOO_LARGE", code)
|
||||||
|
}
|
||||||
|
if handlerCalled {
|
||||||
|
t.Error("超過 size 上限不應進入 handler")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLocalUploadGuard_TooLarge_BeforeTokenConsumed:超過上限時,即使帶了看似有效的 token,
|
||||||
|
// 也不應 consume 掉那個 token(size 檢查在 consume 之前,避免大檔攻擊順手燒掉 token)。
|
||||||
|
func TestLocalUploadGuard_TooLarge_BeforeTokenConsumed(t *testing.T) {
|
||||||
|
const tinyMax = 64
|
||||||
|
store := &fakeConsumer{returnErr: nil}
|
||||||
|
var handlerCalled bool
|
||||||
|
r := newGuardRouter(store, tinyMax, &handlerCalled)
|
||||||
|
|
||||||
|
body, ct := buildMultipart(t, "dev-1", bytes.Repeat([]byte("A"), 4096))
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/local/media/upload/video", body)
|
||||||
|
req.Header.Set("Content-Type", ct)
|
||||||
|
req.Header.Set("X-Visiona-Local-Token", "tok")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if store.called {
|
||||||
|
t.Error("超過 size 上限時不應呼叫 Consume(size 檢查在 consume 前)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLocalUploadGuard_ErrorCodeMatchesSpec:確保錯誤碼字串與 api-spec §6.5 完全一致。
|
||||||
|
func TestLocalUploadGuard_ErrorCodeMatchesSpec(t *testing.T) {
|
||||||
|
// 直接驗證 respondTokenInvalid 的輸出格式。
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(w)
|
||||||
|
respondTokenInvalid(c)
|
||||||
|
|
||||||
|
if w.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("status = %d, want 401", w.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(w.Body.String(), "LOCAL_TOKEN_INVALID") {
|
||||||
|
t.Errorf("body 應含 LOCAL_TOKEN_INVALID,got %s", w.Body.String())
|
||||||
|
}
|
||||||
|
var resp struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(w.Body.Bytes(), &resp)
|
||||||
|
if resp.Success {
|
||||||
|
t.Error("錯誤回應 success 應為 false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 確認 ErrTokenInvalid / ErrTokenLimit 是 sentinel(errors.Is 可比對),供 handler/middleware 對應錯誤碼。
|
||||||
|
func TestSentinelErrors(t *testing.T) {
|
||||||
|
if !errors.Is(ErrTokenInvalid, ErrTokenInvalid) {
|
||||||
|
t.Error("ErrTokenInvalid 應可自比對")
|
||||||
|
}
|
||||||
|
if errors.Is(ErrTokenInvalid, ErrTokenLimit) {
|
||||||
|
t.Error("ErrTokenInvalid 與 ErrTokenLimit 不應相等")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,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-8(TDD v2/cors-security.md §3.1):
|
// M8-8(TDD v2/cors-security.md §3.1):
|
||||||
// v2 模式下 UI 改在使用者瀏覽器中跑,server 同時暴露給其他瀏覽器分頁,
|
// v2 模式下 UI 改在使用者瀏覽器中跑,server 同時暴露給其他瀏覽器分頁,
|
||||||
// 必須限定 cross-origin 來源在本機 loopback,避免惡意網站透過 CORS 攻擊。
|
// 必須限定 cross-origin 來源在本機 loopback,避免惡意網站透過 CORS 攻擊。
|
||||||
|
//
|
||||||
|
// ADR-019 §2.5:此 loopback 舊規則「保留不動」——不因開放雲端 origin 而變更。
|
||||||
var allowedHosts = map[string]bool{
|
var allowedHosts = map[string]bool{
|
||||||
"127.0.0.1": true,
|
"127.0.0.1": true,
|
||||||
"localhost": true,
|
"localhost": true,
|
||||||
@ -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 的 isAllowedOrigin(hostname-only + 任意 port + 只收 http):
|
||||||
|
// - 若沿用 hostname-only,會變成「該網域任意 port 都放行」,攻擊面過大。
|
||||||
|
// - 若放寬 scheme 檢查,會讓 http/https 混用可繞過。
|
||||||
|
//
|
||||||
|
// 故雲端 origin 一律走「完整 origin 精確比對」,來源 env VISIONA_CLOUD_ORIGINS。
|
||||||
|
// 於 init 時載入一次(server 生命週期內固定)。
|
||||||
|
var cloudOrigins = loadCloudOrigins(os.Getenv("VISIONA_CLOUD_ORIGINS"))
|
||||||
|
|
||||||
|
// loadCloudOrigins 解析逗號分隔的完整 origin 字串,回傳精確比對用的 set。
|
||||||
|
//
|
||||||
|
// 每個項目做 TrimSpace,過濾空字串。不做任何 hostname/port 拆解——
|
||||||
|
// 白名單存的就是完整 origin,比對時整串相等才通過(ADR-019 §2.5 M3)。
|
||||||
|
func loadCloudOrigins(raw string) map[string]bool {
|
||||||
|
set := make(map[string]bool)
|
||||||
|
if raw == "" {
|
||||||
|
return set
|
||||||
|
}
|
||||||
|
for _, part := range strings.Split(raw, ",") {
|
||||||
|
origin := strings.TrimSpace(part)
|
||||||
|
if origin != "" {
|
||||||
|
set[origin] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return set
|
||||||
|
}
|
||||||
|
|
||||||
|
// isAllowedCloudOrigin 判斷 Origin 是否為雲端白名單 origin(完整 origin 精確比對)。
|
||||||
|
func isAllowedCloudOrigin(origin string) bool {
|
||||||
|
if origin == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return cloudOrigins[origin]
|
||||||
|
}
|
||||||
|
|
||||||
|
// isAllowedOrigin 判斷 Origin header 是否屬於 loopback 白名單。
|
||||||
//
|
//
|
||||||
// 合法例:http://127.0.0.1:3721 / http://localhost:3721 / http://[::1]:3721
|
// 合法例:http://127.0.0.1:3721 / http://localhost:3721 / http://[::1]:3721
|
||||||
// 不合法例:https://127.0.0.1:3721 / http://evil.com / null / http://192.168.1.5:3721
|
// 不合法例:https://127.0.0.1:3721 / http://evil.com / null / http://192.168.1.5:3721
|
||||||
@ -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* headers;OPTIONS → 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,7 +131,52 @@ func CORSMiddleware() gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !isAllowedOrigin(origin) {
|
// 雲端白名單 origin(ADR-019):完整 origin 精確比對,獨立於 loopback。
|
||||||
|
if isAllowedCloudOrigin(origin) {
|
||||||
|
c.Header("Access-Control-Allow-Origin", origin)
|
||||||
|
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||||
|
c.Header("Access-Control-Allow-Headers", "Content-Type, X-Visiona-Local-Token")
|
||||||
|
// M3:雲端 origin 一律 false——用 header 帶 token、不需 cookie,
|
||||||
|
// 避免無謂讓瀏覽器願意帶 credential 而擴大 CSRF / 憑證面。
|
||||||
|
c.Header("Access-Control-Allow-Credentials", "false")
|
||||||
|
c.Header("Access-Control-Max-Age", "600")
|
||||||
|
c.Header("Vary", "Origin")
|
||||||
|
|
||||||
|
if method == http.MethodOptions {
|
||||||
|
// PNA(ADR-019 §2.5,必做):preflight 帶
|
||||||
|
// Access-Control-Request-Private-Network: true 且通過白名單 → 回 PNA header。
|
||||||
|
// 防未來 Chrome 把 PNA 從 warning 升為 blocking 時舊版 agent 無預警壞掉。
|
||||||
|
if c.GetHeader("Access-Control-Request-Private-Network") == "true" {
|
||||||
|
c.Header("Access-Control-Allow-Private-Network", "true")
|
||||||
|
}
|
||||||
|
c.AbortWithStatus(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// loopback 白名單 origin:沿用 M8-8 既有行為(Allow-Credentials: true)。
|
||||||
|
if isAllowedOrigin(origin) {
|
||||||
|
c.Header("Access-Control-Allow-Origin", origin)
|
||||||
|
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||||
|
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Visiona-Local-Token")
|
||||||
|
c.Header("Access-Control-Allow-Credentials", "true")
|
||||||
|
c.Header("Access-Control-Max-Age", "600")
|
||||||
|
c.Header("Vary", "Origin")
|
||||||
|
|
||||||
|
if method == http.MethodOptions {
|
||||||
|
// 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
|
// 非白名單 Origin
|
||||||
// - state-changing 方法 → 403(嚴格擋)
|
// - state-changing 方法 → 403(嚴格擋)
|
||||||
// - GET/HEAD → 執行但不回 ACA*(瀏覽器層擋)
|
// - GET/HEAD → 執行但不回 ACA*(瀏覽器層擋)
|
||||||
@ -87,20 +189,51 @@ func CORSMiddleware() gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.Next()
|
c.Next()
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 白名單 Origin:回完整 ACA* headers
|
// HostGuard 是 DNS rebinding 的獨立第二道防護(ADR-019 §2.5 M2,必做)。
|
||||||
c.Header("Access-Control-Allow-Origin", origin)
|
//
|
||||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
// 檢查 Host header(去 port 後)必須 ∈ {127.0.0.1, localhost, ::1},
|
||||||
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
// 否則 400 Bad Request。與 CORS 正交:CORS 擋 Origin、HostGuard 擋 Host。
|
||||||
c.Header("Access-Control-Allow-Credentials", "true")
|
//
|
||||||
c.Header("Vary", "Origin")
|
// 套用範圍:
|
||||||
|
// - 所有 /api/local/*(含 WP-2 新增的 /api/local/media/upload/*)
|
||||||
if method == http.MethodOptions {
|
// - 舊 tunnel-path media route(/api/media/upload/*)——關舊 route 的殘留面。
|
||||||
c.AbortWithStatus(http.StatusNoContent)
|
//
|
||||||
|
// 為什麼 tunnel 轉發不受影響:tunnel client 轉發到本地 server 時
|
||||||
|
// req.URL.Host = 127.0.0.1:<port>(client.go),Host header 本就是 loopback,通過。
|
||||||
|
//
|
||||||
|
// DNS rebinding 情境:攻擊者把 evil.com 重綁到 127.0.0.1,
|
||||||
|
// fetch('http://evil.com:<port>/...') 實際打到本機、但 Host header 為
|
||||||
|
// evil.com:<port> ≠ loopback → 被 400 擋下。
|
||||||
|
func HostGuard() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if !isLoopbackHost(c.Request.Host) {
|
||||||
|
c.AbortWithStatus(http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.Next()
|
c.Next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isLoopbackHost 判斷 Host header(可能含 port)的 hostname 是否為 loopback。
|
||||||
|
//
|
||||||
|
// net.SplitHostPort 在無 port 時回 error,此時退回原字串當 hostname。
|
||||||
|
// IPv6 的 "[::1]:port" 經 SplitHostPort 會得到 "::1"(去掉方括號),
|
||||||
|
// 故 loopbackHostnames 存的是 "::1" 而非 "[::1]"。
|
||||||
|
func isLoopbackHost(host string) bool {
|
||||||
|
if host == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
h, _, err := net.SplitHostPort(host)
|
||||||
|
if err != nil {
|
||||||
|
// 無 port(如 "localhost")或格式異常 → 退回原字串比對。
|
||||||
|
h = host
|
||||||
|
}
|
||||||
|
h = strings.ToLower(strings.TrimSpace(h))
|
||||||
|
// 去掉 IPv6 可能殘留的方括號(無 port 的 "[::1]" 這類邊界情況)。
|
||||||
|
h = strings.TrimPrefix(h, "[")
|
||||||
|
h = strings.TrimSuffix(h, "]")
|
||||||
|
return loopbackHostnames[h]
|
||||||
|
}
|
||||||
|
|||||||
@ -3,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-Origin,got %q", got)
|
t.Errorf("same-origin 不應回 ACA-Origin,got %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----- ADR-019 WP-1:雲端 origin 精確比對 + PNA + HostGuard -----
|
||||||
|
|
||||||
|
// TestLoadCloudOrigins 驗證 VISIONA_CLOUD_ORIGINS 解析(逗號分隔、TrimSpace、過濾空字串)。
|
||||||
|
func TestLoadCloudOrigins(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
raw string
|
||||||
|
want map[string]bool
|
||||||
|
}{
|
||||||
|
{"empty", "", map[string]bool{}},
|
||||||
|
{"single", "https://stage-9527.innovedus.com:9527",
|
||||||
|
map[string]bool{"https://stage-9527.innovedus.com:9527": true}},
|
||||||
|
{"multi with spaces", " https://a.com:443 , http://192.168.0.130:9527 ",
|
||||||
|
map[string]bool{"https://a.com:443": true, "http://192.168.0.130:9527": true}},
|
||||||
|
{"trailing comma", "http://localhost:3000,,",
|
||||||
|
map[string]bool{"http://localhost:3000": true}},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
got := loadCloudOrigins(tc.raw)
|
||||||
|
if len(got) != len(tc.want) {
|
||||||
|
t.Errorf("%s: len = %d, want %d (%v)", tc.name, len(got), len(tc.want), got)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for k := range tc.want {
|
||||||
|
if !got[k] {
|
||||||
|
t.Errorf("%s: missing origin %q in %v", tc.name, k, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIsAllowedCloudOrigin_ExactMatch 驗證雲端 origin 必須 scheme+host+port 全等(M3)。
|
||||||
|
// 關鍵:不可像 loopback 那樣 hostname-only + 任意 port。
|
||||||
|
func TestIsAllowedCloudOrigin_ExactMatch(t *testing.T) {
|
||||||
|
// 直接注入測試白名單,避免依賴環境變數。
|
||||||
|
saved := cloudOrigins
|
||||||
|
cloudOrigins = map[string]bool{
|
||||||
|
"https://stage-9527.innovedus.com:9527": true,
|
||||||
|
"http://192.168.0.130:9527": true,
|
||||||
|
}
|
||||||
|
defer func() { cloudOrigins = saved }()
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
origin string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
// 完全相符
|
||||||
|
{"https://stage-9527.innovedus.com:9527", true},
|
||||||
|
{"http://192.168.0.130:9527", true},
|
||||||
|
|
||||||
|
// 同 host 不同 port → 不通過(證明不是 hostname-only)
|
||||||
|
{"https://stage-9527.innovedus.com:8080", false},
|
||||||
|
{"https://stage-9527.innovedus.com", false},
|
||||||
|
{"http://192.168.0.130:8080", false},
|
||||||
|
|
||||||
|
// 同 host 不同 scheme → 不通過(證明不放寬 scheme)
|
||||||
|
{"http://stage-9527.innovedus.com:9527", false},
|
||||||
|
{"https://192.168.0.130:9527", false},
|
||||||
|
|
||||||
|
// 其他
|
||||||
|
{"", false},
|
||||||
|
{"null", false},
|
||||||
|
{"https://evil.com:9527", false},
|
||||||
|
{"https://stage-9527.innovedus.com:9527.evil.com", false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
if got := isAllowedCloudOrigin(tc.origin); got != tc.want {
|
||||||
|
t.Errorf("isAllowedCloudOrigin(%q) = %v, want %v", tc.origin, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newCloudTestRouter 建一台掛 CORSMiddleware 的 router,並注入測試用雲端白名單。
|
||||||
|
func newCloudTestRouter(t *testing.T) *gin.Engine {
|
||||||
|
t.Helper()
|
||||||
|
saved := cloudOrigins
|
||||||
|
cloudOrigins = map[string]bool{"https://cloud.example.com:9527": true}
|
||||||
|
t.Cleanup(func() { cloudOrigins = saved })
|
||||||
|
return newTestRouter()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCORSMiddleware_CloudOriginPOST:雲端白名單 origin 的 POST 應放行 + Credentials:false。
|
||||||
|
func TestCORSMiddleware_CloudOriginPOST(t *testing.T) {
|
||||||
|
r := newCloudTestRouter(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/do", nil)
|
||||||
|
req.Header.Set("Origin", "https://cloud.example.com:9527")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "https://cloud.example.com:9527" {
|
||||||
|
t.Errorf("ACA-Origin = %q, want cloud origin", got)
|
||||||
|
}
|
||||||
|
if got := w.Header().Get("Access-Control-Allow-Credentials"); got != "false" {
|
||||||
|
t.Errorf("ACA-Credentials = %q, want false (M3)", got)
|
||||||
|
}
|
||||||
|
if got := w.Header().Get("Access-Control-Allow-Headers"); !strings.Contains(got, "X-Visiona-Local-Token") {
|
||||||
|
t.Errorf("ACA-Headers = %q, 必須含 X-Visiona-Local-Token", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCORSMiddleware_CloudPreflightPNA:雲端 origin preflight 帶 PNA request → 回 PNA header + Max-Age。
|
||||||
|
func TestCORSMiddleware_CloudPreflightPNA(t *testing.T) {
|
||||||
|
r := newCloudTestRouter(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodOptions, "/api/do", nil)
|
||||||
|
req.Header.Set("Origin", "https://cloud.example.com:9527")
|
||||||
|
req.Header.Set("Access-Control-Request-Method", "POST")
|
||||||
|
req.Header.Set("Access-Control-Request-Private-Network", "true")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("status = %d, want 204", w.Code)
|
||||||
|
}
|
||||||
|
if got := w.Header().Get("Access-Control-Allow-Private-Network"); got != "true" {
|
||||||
|
t.Errorf("ACA-Private-Network = %q, want true (PNA 必做)", got)
|
||||||
|
}
|
||||||
|
if got := w.Header().Get("Access-Control-Max-Age"); got != "600" {
|
||||||
|
t.Errorf("Max-Age = %q, want 600", got)
|
||||||
|
}
|
||||||
|
if got := w.Header().Get("Access-Control-Allow-Credentials"); got != "false" {
|
||||||
|
t.Errorf("ACA-Credentials = %q, want false", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCORSMiddleware_CloudPreflightNoPNARequest:preflight 未帶 PNA request → 不回 PNA header。
|
||||||
|
func TestCORSMiddleware_CloudPreflightNoPNARequest(t *testing.T) {
|
||||||
|
r := newCloudTestRouter(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodOptions, "/api/do", nil)
|
||||||
|
req.Header.Set("Origin", "https://cloud.example.com:9527")
|
||||||
|
req.Header.Set("Access-Control-Request-Method", "POST")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("status = %d, want 204", w.Code)
|
||||||
|
}
|
||||||
|
if got := w.Header().Get("Access-Control-Allow-Private-Network"); got != "" {
|
||||||
|
t.Errorf("未帶 PNA request 不應回 PNA header,got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCORSMiddleware_NonWhitelistedCloudPortPOST:同 host 但不在白名單的 port → 403。
|
||||||
|
func TestCORSMiddleware_NonWhitelistedCloudPortPOST(t *testing.T) {
|
||||||
|
r := newCloudTestRouter(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/do", nil)
|
||||||
|
req.Header.Set("Origin", "https://cloud.example.com:8080") // 不同 port
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("status = %d, want 403(不同 port 不應通過精確比對)", w.Code)
|
||||||
|
}
|
||||||
|
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "" {
|
||||||
|
t.Errorf("不應回 ACA-Origin,got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCORSMiddleware_LoopbackCredentialsUnchanged:loopback origin 仍回 Credentials:true(M8-8 保留不動)。
|
||||||
|
func TestCORSMiddleware_LoopbackCredentialsUnchanged(t *testing.T) {
|
||||||
|
r := newTestRouter()
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/ping", nil)
|
||||||
|
req.Header.Set("Origin", "http://127.0.0.1:3721")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if got := w.Header().Get("Access-Control-Allow-Credentials"); got != "true" {
|
||||||
|
t.Errorf("loopback ACA-Credentials = %q, want true(ADR-019 保留 loopback 舊規則)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- HostGuard -----
|
||||||
|
|
||||||
|
// newHostGuardRouter 建一台掛 HostGuard 的 router。
|
||||||
|
func newHostGuardRouter() *gin.Engine {
|
||||||
|
r := gin.New()
|
||||||
|
r.POST("/api/media/upload/video", HostGuard(), func(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
})
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHostGuard 驗證 Host header 必須 = loopback,否則 400。
|
||||||
|
func TestHostGuard(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
host string
|
||||||
|
wantCode int
|
||||||
|
}{
|
||||||
|
{"127.0.0.1 with port", "127.0.0.1:3721", http.StatusOK},
|
||||||
|
{"localhost with port", "localhost:3721", http.StatusOK},
|
||||||
|
{"localhost no port", "localhost", http.StatusOK},
|
||||||
|
{"127.0.0.1 no port", "127.0.0.1", http.StatusOK},
|
||||||
|
{"ipv6 loopback with port", "[::1]:3721", http.StatusOK},
|
||||||
|
{"uppercase LOCALHOST", "LOCALHOST:3721", http.StatusOK},
|
||||||
|
// DNS rebinding:Host 為攻擊者網域 → 400
|
||||||
|
{"evil domain", "evil.com:3721", http.StatusBadRequest},
|
||||||
|
{"evil domain no port", "evil.com", http.StatusBadRequest},
|
||||||
|
{"lan ip", "192.168.0.130:9527", http.StatusBadRequest},
|
||||||
|
{"public ip", "8.8.8.8:80", http.StatusBadRequest},
|
||||||
|
// suffix 攻擊
|
||||||
|
{"loopback suffix attack", "127.0.0.1.evil.com:3721", http.StatusBadRequest},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
r := newHostGuardRouter()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/media/upload/video", nil)
|
||||||
|
req.Host = tc.host
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
if w.Code != tc.wantCode {
|
||||||
|
t.Errorf("Host=%q → %d, want %d", tc.host, w.Code, tc.wantCode)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIsLoopbackHost 直接單元測試 host 判斷邏輯。
|
||||||
|
func TestIsLoopbackHost(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
host string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"127.0.0.1:3721", true},
|
||||||
|
{"127.0.0.1", true},
|
||||||
|
{"localhost:8080", true},
|
||||||
|
{"localhost", true},
|
||||||
|
{"[::1]:3721", true},
|
||||||
|
{"::1", true},
|
||||||
|
{"", false},
|
||||||
|
{"evil.com", false},
|
||||||
|
{"evil.com:3721", false},
|
||||||
|
{"192.168.0.130:9527", false},
|
||||||
|
{"127.0.0.1.evil.com:80", false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
if got := isLoopbackHost(tc.host); got != tc.want {
|
||||||
|
t.Errorf("isLoopbackHost(%q) = %v, want %v", tc.host, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -49,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/*)。
|
||||||
|
// 整組套 HostGuard(M2,Host=loopback 才放行,DNS rebinding 第二道防護)。
|
||||||
|
local := api.Group("/local", HostGuard())
|
||||||
|
{
|
||||||
|
// 同機偵測 + 身分驗證(bootstrap,無 token)。
|
||||||
|
local.GET("/hello", localHandler.Hello)
|
||||||
|
// 產 one-time upload token(僅經 tunnel 由 api-server 轉發呼叫;受 HostGuard 約束)。
|
||||||
|
local.POST("/issue-token", localHandler.IssueToken)
|
||||||
|
|
||||||
|
// 瀏覽器 localhost 直連 upload route:一律要 token(不看 Origin,C1)+ size 上限(M1)。
|
||||||
|
// LocalUploadGuard 在 FormFile 前驗 token + size,通過後轉呼叫既有 handler(業務邏輯零改動)。
|
||||||
|
local.POST("/media/upload/video", LocalUploadGuard(tokenStore, maxVideoUploadBytes), cameraHandler.UploadVideo)
|
||||||
|
local.POST("/media/upload/image", LocalUploadGuard(tokenStore, maxImageUploadBytes), cameraHandler.UploadImage)
|
||||||
|
local.POST("/media/upload/batch-images", LocalUploadGuard(tokenStore, maxBatchUploadBytes), cameraHandler.UploadBatchImages)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// WebSocket
|
// WebSocket
|
||||||
@ -179,6 +204,7 @@ func broadcasterLogger(b *logger.Broadcaster) gin.HandlerFunc {
|
|||||||
// for Next.js static export client-side routing.
|
// for Next.js static export client-side routing.
|
||||||
//
|
//
|
||||||
// Next.js static export with generateStaticParams creates:
|
// Next.js static export with generateStaticParams creates:
|
||||||
|
//
|
||||||
// /models/index.html — static page
|
// /models/index.html — static page
|
||||||
// /models/_/index.html — dynamic route shell (placeholder param '_')
|
// /models/_/index.html — dynamic route shell (placeholder param '_')
|
||||||
//
|
//
|
||||||
|
|||||||
173
local-agent/server/internal/api/token_store.go
Normal file
173
local-agent/server/internal/api/token_store.go
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ADR-019 §2.4:one-time upload token store。
|
||||||
|
//
|
||||||
|
// 設計(已經 security review 議題 2 通過):
|
||||||
|
// - token = crypto/rand 32 bytes → base64url(禁 math/rand)
|
||||||
|
// - TTL 120s、one-time(consume 即刪)、綁 deviceId
|
||||||
|
// - 記憶體 store(map + 單一 sync.Mutex,不持久化)
|
||||||
|
// - 未使用上限 32 個(防記憶體 DoS)
|
||||||
|
// - 比對用 crypto/subtle.ConstantTimeCompare(防 timing attack)
|
||||||
|
// - consume single-flight:查存在 + 比對 + 刪除三步在同一 Lock 內完成(security m2,防 race)
|
||||||
|
const (
|
||||||
|
tokenTTL = 120 * time.Second
|
||||||
|
maxUnusedTokens = 32
|
||||||
|
tokenCleanupPeriod = 60 * time.Second
|
||||||
|
tokenRandBytes = 32
|
||||||
|
)
|
||||||
|
|
||||||
|
// 明確的錯誤,供 handler 對應到 api-spec §6.5 的錯誤碼。
|
||||||
|
var (
|
||||||
|
// ErrTokenLimit:未使用 token 達 32 上限(→ 429 LOCAL_TOKEN_LIMIT)。
|
||||||
|
ErrTokenLimit = errors.New("local token limit reached")
|
||||||
|
// ErrTokenInvalid:token 不存在 / 過期 / 已使用 / deviceId 不符(→ 401 LOCAL_TOKEN_INVALID)。
|
||||||
|
ErrTokenInvalid = errors.New("local token invalid")
|
||||||
|
)
|
||||||
|
|
||||||
|
// tokenEntry 是一筆未消費的 token 記錄。
|
||||||
|
type tokenEntry struct {
|
||||||
|
deviceID string
|
||||||
|
expiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenStore 是執行緒安全的 one-time token 記憶體 store。
|
||||||
|
//
|
||||||
|
// 併發正確性核心:所有讀寫都在單一 mu 內完成。
|
||||||
|
// Consume 是 single-flight——「查存在 + ConstantTimeCompare + 刪除」在同一 Lock()
|
||||||
|
// 內原子完成,兩個併發 consume 同一 token 不可能都成功(防 one-time 失效)。
|
||||||
|
type TokenStore struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
tokens map[string]tokenEntry
|
||||||
|
now func() time.Time // 可注入,方便測試過期邏輯
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTokenStore 建立 store。now 預設為 time.Now。
|
||||||
|
func NewTokenStore() *TokenStore {
|
||||||
|
return &TokenStore{
|
||||||
|
tokens: make(map[string]tokenEntry),
|
||||||
|
now: time.Now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Issue 產生一個新 token 綁定 deviceID,single-flight 持鎖完成
|
||||||
|
// 「清過期 + 查 len < 32 + 插入」。達上限回 ErrTokenLimit。
|
||||||
|
//
|
||||||
|
// token 值以 crypto/rand 產生(32 bytes → base64url RawURL)。
|
||||||
|
func (s *TokenStore) Issue(deviceID string) (string, time.Time, error) {
|
||||||
|
// 先在鎖外產生亂數(crypto/rand 可能較慢,避免長時間持鎖)。
|
||||||
|
buf := make([]byte, tokenRandBytes)
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
return "", time.Time{}, err
|
||||||
|
}
|
||||||
|
token := base64.RawURLEncoding.EncodeToString(buf)
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
// 惰性清理過期 token,順便為上限計算釋放名額。
|
||||||
|
s.pruneExpiredLocked()
|
||||||
|
|
||||||
|
if len(s.tokens) >= maxUnusedTokens {
|
||||||
|
return "", time.Time{}, ErrTokenLimit
|
||||||
|
}
|
||||||
|
|
||||||
|
expiresAt := s.now().Add(tokenTTL)
|
||||||
|
s.tokens[token] = tokenEntry{deviceID: deviceID, expiresAt: expiresAt}
|
||||||
|
return token, expiresAt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Consume 驗證並消費一個 token(one-time)。single-flight 持鎖:
|
||||||
|
// 查存在 + 比對 deviceID + 未過期 + 刪除,全部在同一 Lock 內完成。
|
||||||
|
//
|
||||||
|
// 成功 → 回 nil(token 已從 store 移除,不可再用)。
|
||||||
|
// 失敗(不存在 / 過期 / deviceId 不符)→ 回 ErrTokenInvalid。
|
||||||
|
//
|
||||||
|
// deviceID 比對用 ConstantTimeCompare(雖然 deviceID 非高機密,維持一致的常數時間比對紀律)。
|
||||||
|
func (s *TokenStore) Consume(token, deviceID string) error {
|
||||||
|
if token == "" {
|
||||||
|
return ErrTokenInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
entry, ok := s.tokens[token]
|
||||||
|
if !ok {
|
||||||
|
return ErrTokenInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
// 不論後續成功與否,one-time 語意要求「命中即刪」——刪除放在最前面,
|
||||||
|
// 確保兩個併發 consume 只有第一個拿到 entry、第二個 map 查不到。
|
||||||
|
delete(s.tokens, token)
|
||||||
|
|
||||||
|
// 過期檢查(惰性)。
|
||||||
|
if !s.now().Before(entry.expiresAt) {
|
||||||
|
return ErrTokenInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
// deviceID 綁定檢查(常數時間比對)。
|
||||||
|
if subtle.ConstantTimeCompare([]byte(entry.deviceID), []byte(deviceID)) != 1 {
|
||||||
|
return ErrTokenInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsLimitErr 回報 err 是否為「達 token 上限」(給 handler 對應 429)。
|
||||||
|
// 讓 handlers 套件不需 import sentinel error 即可判斷。
|
||||||
|
func (s *TokenStore) IsLimitErr(err error) bool {
|
||||||
|
return errors.Is(err, ErrTokenLimit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// pruneExpiredLocked 移除所有已過期的 token。呼叫端必須已持有 mu。
|
||||||
|
func (s *TokenStore) pruneExpiredLocked() {
|
||||||
|
now := s.now()
|
||||||
|
for tok, entry := range s.tokens {
|
||||||
|
if !now.Before(entry.expiresAt) {
|
||||||
|
delete(s.tokens, tok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pruneExpired 是背景 goroutine 用的加鎖版本。
|
||||||
|
func (s *TokenStore) pruneExpired() {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.pruneExpiredLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
// len 回傳目前未使用 token 數(測試用)。
|
||||||
|
func (s *TokenStore) len() int {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return len(s.tokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartCleanup 啟動背景清理 goroutine,每 tokenCleanupPeriod 掃一次過期 token。
|
||||||
|
// 惰性清理(Consume/Issue 時)+ 背景清理雙保險。
|
||||||
|
// 回傳 stop 函式(給測試 / graceful shutdown 用)。
|
||||||
|
func (s *TokenStore) StartCleanup() (stop func()) {
|
||||||
|
ticker := time.NewTicker(tokenCleanupPeriod)
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
s.pruneExpired()
|
||||||
|
case <-done:
|
||||||
|
ticker.Stop()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return func() { close(done) }
|
||||||
|
}
|
||||||
213
local-agent/server/internal/api/token_store_test.go
Normal file
213
local-agent/server/internal/api/token_store_test.go
Normal 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 應 ErrTokenInvalid,got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTokenStore_Consume_DeviceMismatch:deviceId 不符 → ErrTokenInvalid,且 token 已被消費。
|
||||||
|
func TestTokenStore_Consume_DeviceMismatch(t *testing.T) {
|
||||||
|
s := NewTokenStore()
|
||||||
|
token, _, _ := s.Issue("dev-1")
|
||||||
|
|
||||||
|
// deviceId 不符 → 失敗
|
||||||
|
if err := s.Consume(token, "dev-2"); !errors.Is(err, ErrTokenInvalid) {
|
||||||
|
t.Errorf("deviceId 不符應 ErrTokenInvalid,got %v", err)
|
||||||
|
}
|
||||||
|
// 即使不符,token 也應已被移除(命中即刪,防以正確 deviceId 重試)
|
||||||
|
if err := s.Consume(token, "dev-1"); !errors.Is(err, ErrTokenInvalid) {
|
||||||
|
t.Errorf("不符後 token 應已被消費,got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTokenStore_Consume_MissingAndEmpty:不存在 / 空字串 token → ErrTokenInvalid。
|
||||||
|
func TestTokenStore_Consume_MissingAndEmpty(t *testing.T) {
|
||||||
|
s := NewTokenStore()
|
||||||
|
if err := s.Consume("nonexistent", "dev-1"); !errors.Is(err, ErrTokenInvalid) {
|
||||||
|
t.Errorf("不存在 token 應 ErrTokenInvalid,got %v", err)
|
||||||
|
}
|
||||||
|
if err := s.Consume("", "dev-1"); !errors.Is(err, ErrTokenInvalid) {
|
||||||
|
t.Errorf("空 token 應 ErrTokenInvalid,got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTokenStore_Expiry:過期 token 消費失敗。用可注入的 now 模擬時間流逝。
|
||||||
|
func TestTokenStore_Expiry(t *testing.T) {
|
||||||
|
s := NewTokenStore()
|
||||||
|
base := time.Now()
|
||||||
|
current := base
|
||||||
|
s.now = func() time.Time { return current }
|
||||||
|
|
||||||
|
token, _, _ := s.Issue("dev-1")
|
||||||
|
|
||||||
|
// 前進超過 TTL
|
||||||
|
current = base.Add(tokenTTL + time.Second)
|
||||||
|
|
||||||
|
if err := s.Consume(token, "dev-1"); !errors.Is(err, ErrTokenInvalid) {
|
||||||
|
t.Errorf("過期 token 應 ErrTokenInvalid,got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTokenStore_Limit:未使用 token 達 32 上限 → ErrTokenLimit;消費一個後可再發。
|
||||||
|
func TestTokenStore_Limit(t *testing.T) {
|
||||||
|
s := NewTokenStore()
|
||||||
|
|
||||||
|
tokens := make([]string, 0, maxUnusedTokens)
|
||||||
|
for i := 0; i < maxUnusedTokens; i++ {
|
||||||
|
tok, _, err := s.Issue("dev-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("第 %d 個 Issue 不應失敗,got %v", i, err)
|
||||||
|
}
|
||||||
|
tokens = append(tokens, tok)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 第 33 個應被拒
|
||||||
|
if _, _, err := s.Issue("dev-1"); !errors.Is(err, ErrTokenLimit) {
|
||||||
|
t.Errorf("達上限應 ErrTokenLimit,got %v", err)
|
||||||
|
}
|
||||||
|
if !s.IsLimitErr(ErrTokenLimit) {
|
||||||
|
t.Error("IsLimitErr(ErrTokenLimit) 應為 true")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 消費一個後釋放名額,可再發
|
||||||
|
if err := s.Consume(tokens[0], "dev-1"); err != nil {
|
||||||
|
t.Fatalf("Consume 應成功,got %v", err)
|
||||||
|
}
|
||||||
|
if _, _, err := s.Issue("dev-1"); err != nil {
|
||||||
|
t.Errorf("釋放名額後 Issue 應成功,got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTokenStore_Limit_ExpiredFreesSlot:過期 token 在 Issue 時被惰性清理,釋放上限名額。
|
||||||
|
func TestTokenStore_Limit_ExpiredFreesSlot(t *testing.T) {
|
||||||
|
s := NewTokenStore()
|
||||||
|
base := time.Now()
|
||||||
|
current := base
|
||||||
|
s.now = func() time.Time { return current }
|
||||||
|
|
||||||
|
for i := 0; i < maxUnusedTokens; i++ {
|
||||||
|
if _, _, err := s.Issue("dev-1"); err != nil {
|
||||||
|
t.Fatalf("第 %d 個 Issue 失敗: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 全部過期
|
||||||
|
current = base.Add(tokenTTL + time.Second)
|
||||||
|
|
||||||
|
// 再 Issue 應觸發惰性清理、成功
|
||||||
|
if _, _, err := s.Issue("dev-1"); err != nil {
|
||||||
|
t.Errorf("過期清理後 Issue 應成功,got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTokenStore_ConcurrentConsume_SingleFlight 是 security m2 的關鍵測試:
|
||||||
|
// 多個 goroutine 同時消費同一 token,必須「恰好一個成功」(防 one-time 失效 / 雙重消費)。
|
||||||
|
func TestTokenStore_ConcurrentConsume_SingleFlight(t *testing.T) {
|
||||||
|
const goroutines = 200
|
||||||
|
|
||||||
|
// 跑多輪,提高抓到 race 的機率。
|
||||||
|
for round := 0; round < 50; round++ {
|
||||||
|
s := NewTokenStore()
|
||||||
|
token, _, _ := s.Issue("dev-1")
|
||||||
|
|
||||||
|
var successCount int32
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
start := make(chan struct{})
|
||||||
|
wg.Add(goroutines)
|
||||||
|
for i := 0; i < goroutines; i++ {
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
<-start // 同時起跑,最大化競爭
|
||||||
|
if err := s.Consume(token, "dev-1"); err == nil {
|
||||||
|
atomic.AddInt32(&successCount, 1)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
close(start)
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
if successCount != 1 {
|
||||||
|
t.Fatalf("round %d: 併發消費同一 token 成功數 = %d,必須恰好 1(single-flight 失效)",
|
||||||
|
round, successCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTokenStore_ConcurrentIssue_LimitHeld 是 security m2 的另一半:
|
||||||
|
// 併發 Issue 時,未使用 token 數不得突破 32 上限。
|
||||||
|
func TestTokenStore_ConcurrentIssue_LimitHeld(t *testing.T) {
|
||||||
|
const goroutines = 200
|
||||||
|
|
||||||
|
for round := 0; round < 30; round++ {
|
||||||
|
s := NewTokenStore()
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
start := make(chan struct{})
|
||||||
|
wg.Add(goroutines)
|
||||||
|
for i := 0; i < goroutines; i++ {
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
<-start
|
||||||
|
_, _, _ = s.Issue("dev-1")
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
close(start)
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
if got := s.len(); got > maxUnusedTokens {
|
||||||
|
t.Fatalf("round %d: 併發 Issue 後 token 數 = %d,不得超過上限 %d(上限檢查非 atomic)",
|
||||||
|
round, got, maxUnusedTokens)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTokenStore_TokenUniqueness:連續發放的 token 值不重複。
|
||||||
|
func TestTokenStore_TokenUniqueness(t *testing.T) {
|
||||||
|
s := NewTokenStore()
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
for i := 0; i < maxUnusedTokens; i++ {
|
||||||
|
tok, _, err := s.Issue("dev-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Issue 失敗: %v", err)
|
||||||
|
}
|
||||||
|
if seen[tok] {
|
||||||
|
t.Fatalf("token 重複: %q", tok)
|
||||||
|
}
|
||||||
|
seen[tok] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTokenStore_Cleanup_StopWorks:StartCleanup 回傳的 stop 可正常關閉 goroutine。
|
||||||
|
func TestTokenStore_Cleanup_StopWorks(t *testing.T) {
|
||||||
|
s := NewTokenStore()
|
||||||
|
stop := s.StartCleanup()
|
||||||
|
// 立即停止不應 panic / deadlock
|
||||||
|
stop()
|
||||||
|
}
|
||||||
@ -92,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-ticket(ADR-019 WP-5)的可選注入點。
|
||||||
|
// 為 nil 時 handler 從 Forwarder + SessionStore 組 default(forwarderLocalTokenIssuer,經
|
||||||
|
// 既有 tunnel 打 local-agent /api/local/issue-token)。unit test 注入 stub 驗成功 / 429 /
|
||||||
|
// tunnel 錯誤分支,不需真 tunnel。詳見 local_upload_ticket.go。
|
||||||
|
LocalTokenIssuer localTokenIssuer
|
||||||
|
|
||||||
DeviceRepo device.Repository
|
DeviceRepo device.Repository
|
||||||
ModelRepo model.Repository
|
ModelRepo model.Repository
|
||||||
|
|
||||||
|
|||||||
@ -39,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-5:localhost 直連上傳的 one-time token 取得路徑(經既有 tunnel 打
|
||||||
|
// local-agent issue-token)。契約 path 為 /api/devices/:serial/local-upload-ticket,
|
||||||
|
// 但 gin/httprouter 要求同層級同名,故沿用 :id 佔位(其值語意為裝置序號 serial,
|
||||||
|
// handler 用它走 GetBySerial 做歸屬檢查)。見 local_upload_ticket.go。
|
||||||
|
g.POST("/devices/:id/local-upload-ticket", localUploadTicketHandler(deps))
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeviceListItem 是 GET /api/devices 回應中的單筆裝置。
|
// DeviceListItem 是 GET /api/devices 回應中的單筆裝置。
|
||||||
|
|||||||
316
visionA-backend/internal/api/local_upload_ticket.go
Normal file
316
visionA-backend/internal/api/local_upload_ticket.go
Normal file
@ -0,0 +1,316 @@
|
|||||||
|
// local_upload_ticket.go — POST /api/devices/:id/local-upload-ticket 的雲端 ticket handler(ADR-019 WP-5)。
|
||||||
|
//
|
||||||
|
// 背景(ADR-019 §2.4 認證流程 [1]):影片 / 圖片 / 批次上傳改走「同機瀏覽器直連 local-agent
|
||||||
|
// 的 localhost endpoint」(繞過 tunnel,解決大檔頻寬雙倍 + nginx 100M + 300s timeout)。但開放
|
||||||
|
// 雲端 origin 直連 local-agent 後,必須有認證作為第二道防線(CORS 白名單擋不住 XSS / DNS
|
||||||
|
// rebinding)。one-time token 的**取得路徑**刻意保留走既有已認證 tunnel(控制面):
|
||||||
|
//
|
||||||
|
// [1] 瀏覽器 → 雲端 api-server:POST /api/devices/:id/local-upload-ticket(本 handler)
|
||||||
|
// api-server 驗 OIDC session(AuthMiddleware)+ 裝置歸屬(GetBySerial)
|
||||||
|
// → 經既有 tunnel 轉發打 local-agent:POST /api/local/issue-token(body {"serial":...})
|
||||||
|
// → 回傳 local-agent 產的 one-time token 給瀏覽器
|
||||||
|
// [2..4] 瀏覽器拿 token 掃 localhost port → 帶 X-Visiona-Local-Token 直連上傳(非本 handler 範圍)
|
||||||
|
//
|
||||||
|
// 為什麼走既有 tunnel 而非新機制:token 取得屬控制面,資料量極小(~100 bytes),沿用 devices.go
|
||||||
|
// 的裝置歸屬檢查 + proxy.go / device_driver_status.go 的 tunnel forward 模式即可,零新基礎設施。
|
||||||
|
//
|
||||||
|
// 契約來源:api-spec.md §6.3(POST /api/devices/:serial/local-upload-ticket + POST
|
||||||
|
// /api/local/issue-token 的 request / response 形狀)。local-agent 端 /api/local/issue-token
|
||||||
|
// 由 local-agent stream 另行實作,本 handler **對契約**打即可(path + body 依 api-spec §6.3)。
|
||||||
|
//
|
||||||
|
// 可測性:把「經 tunnel 打 local-agent issue-token」抽成 localTokenIssuer 介面,default 實作
|
||||||
|
// 包 session.Forwarder(走既有 proxy 基礎設施),unit test 注入 stub 驗成功 / 各種錯誤分支,
|
||||||
|
// 不需要真 tunnel。此模式對齊 device_driver_status.go 的 driverStatusFetcher。
|
||||||
|
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"visiona-backend/internal/device"
|
||||||
|
"visiona-backend/internal/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
// issueTokenProxyTimeout 是「經 tunnel 打 local-agent issue-token」的整體 timeout。
|
||||||
|
//
|
||||||
|
// 刻意設短(5s):issue-token 是控制面小請求(產一個記憶體 token 立即回),不像 media 上傳可能
|
||||||
|
// 很久。不能讓 local-agent hang 住時把「取 ticket」拖到 defaultProxyRequestTimeout(300s) 那麼久
|
||||||
|
// ——前端還要拿這個 token 去掃 port,整條互動應該是秒級。
|
||||||
|
const issueTokenProxyTimeout = 5 * time.Second
|
||||||
|
|
||||||
|
// localAgentIssueTokenPath 是 local-agent 上「產 one-time upload token」的 endpoint。
|
||||||
|
// 對齊 api-spec.md §6.3 POST /api/local/issue-token(僅經既有 tunnel 由 api-server 轉發呼叫)。
|
||||||
|
const localAgentIssueTokenPath = "/api/local/issue-token"
|
||||||
|
|
||||||
|
// errCodeLocalTokenLimit 是 local-agent issue-token 回傳的「未使用 token 達上限」錯誤碼
|
||||||
|
// (api-spec §6.3 LOCAL_TOKEN_LIMIT)。api-server 據此把 IssueToken 錯誤映射成 429(見
|
||||||
|
// writeLocalTokenError)。此碼是 local-agent 產的、非 api-server 對外碼,故不放進 errors.go
|
||||||
|
// 的雲端錯誤碼常數,只在本檔內部用於解析 local-agent 回應。
|
||||||
|
const errCodeLocalTokenLimit = "LOCAL_TOKEN_LIMIT"
|
||||||
|
|
||||||
|
// LocalUploadTicket 是 POST /api/devices/:id/local-upload-ticket 回應的 data 欄位。
|
||||||
|
//
|
||||||
|
// 直接對齊 api-spec.md §6.3:`{ "token", "expiresAt", "ttlSeconds": 120 }`。
|
||||||
|
// api-server 透傳 local-agent issue-token 的產出,不改寫欄位語意。
|
||||||
|
type LocalUploadTicket struct {
|
||||||
|
// Token 是 local-agent 產的 one-time upload token(瀏覽器帶 X-Visiona-Local-Token 直連上傳)。
|
||||||
|
Token string `json:"token"`
|
||||||
|
// ExpiresAt 是 token 過期時間(unix milliseconds),對齊 api-spec §6.3。
|
||||||
|
ExpiresAt int64 `json:"expiresAt"`
|
||||||
|
// TTLSeconds 是 token 存活秒數(契約固定 120,由 local-agent 決定;api-server 透傳)。
|
||||||
|
TTLSeconds int `json:"ttlSeconds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// localTokenIssuer 抽象「經 tunnel 向 local-agent 要一個 one-time upload token」。
|
||||||
|
//
|
||||||
|
// 回傳的 LocalUploadTicket 是 local-agent issue-token 的產出(透傳)。error 語意:
|
||||||
|
// - errLocalTokenLimit:local-agent 回 429(未使用 token 達 32 上限)→ caller 透傳 429。
|
||||||
|
// - session.ErrSessionNotFound / ErrSessionClosed:tunnel 離線 → caller 回 502 TUNNEL_DISCONNECTED。
|
||||||
|
// - 其他:local-agent 不可達 / 非預期回應 → caller 回 502 TUNNEL_ERROR。
|
||||||
|
//
|
||||||
|
// default 實作 forwarderLocalTokenIssuer 走既有 session.Forwarder proxy 基礎設施;
|
||||||
|
// unit test 注入 stub 驗各分支,不需要真 tunnel。
|
||||||
|
type localTokenIssuer interface {
|
||||||
|
// IssueToken 經 tunnel 打 local-agent POST /api/local/issue-token(body {"serial":serial})。
|
||||||
|
// userID 用來挑當前 user 的 active session token(與其他 proxy 端點同一套 posture)。
|
||||||
|
IssueToken(ctx context.Context, userID, serial string) (LocalUploadTicket, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// errLocalTokenLimit 表示 local-agent 回 429(未使用 token 達 32 上限,api-spec §6.3
|
||||||
|
// LOCAL_TOKEN_LIMIT)。與傳輸層錯誤(tunnel 離線)語意區隔,讓 handler 能透傳 429 而非 502。
|
||||||
|
var errLocalTokenLimit = errors.New("local agent: unused upload token limit reached")
|
||||||
|
|
||||||
|
// issueTokenRequest 是打 local-agent /api/local/issue-token 的 request body(api-spec §6.3)。
|
||||||
|
type issueTokenRequest struct {
|
||||||
|
Serial string `json:"serial"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// issueTokenEnvelope 是 local-agent /api/local/issue-token 的回應 envelope(api-spec §6.3):
|
||||||
|
//
|
||||||
|
// { "success": true, "data": { "token": "...", "expiresAt": <unix_ms>, "ttlSeconds": 120 } }
|
||||||
|
// { "success": false, "error": { "code": "LOCAL_TOKEN_LIMIT" } } (429)
|
||||||
|
type issueTokenEnvelope struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Data LocalUploadTicket `json:"data"`
|
||||||
|
Error *struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
} `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// forwarderLocalTokenIssuer 是 localTokenIssuer 的 production 實作:
|
||||||
|
// 透過 session.Forwarder 把 POST /api/local/issue-token 經 tunnel 送到 local-agent。
|
||||||
|
type forwarderLocalTokenIssuer struct {
|
||||||
|
forwarder *session.Forwarder
|
||||||
|
sessionStore session.Store
|
||||||
|
}
|
||||||
|
|
||||||
|
// newForwarderLocalTokenIssuer 從 Deps 組出 default issuer。
|
||||||
|
// forwarder / sessionStore 任一為 nil 時回 nil(caller 據此回 501,代表 tunnel 未配置)。
|
||||||
|
func newForwarderLocalTokenIssuer(deps Deps) localTokenIssuer {
|
||||||
|
if deps.Forwarder == nil || deps.SessionStore == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &forwarderLocalTokenIssuer{
|
||||||
|
forwarder: deps.Forwarder,
|
||||||
|
sessionStore: deps.SessionStore,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IssueToken 實作 localTokenIssuer。
|
||||||
|
//
|
||||||
|
// 流程(對齊 device_driver_status.go FetchDriverStatus,但目標是 POST issue-token):
|
||||||
|
// 1. 挑當前 user 的 active session token(pickActiveSessionToken)
|
||||||
|
// 2. 組 POST /api/local/issue-token(body {"serial":serial}),經 Forwarder.ForwardHTTP 送到 local-agent
|
||||||
|
// 3. 解 envelope:429 → errLocalTokenLimit;success + 有 token → 回 ticket;其他 → error
|
||||||
|
func (i *forwarderLocalTokenIssuer) IssueToken(ctx context.Context, userID, serial string) (LocalUploadTicket, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, issueTokenProxyTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
token, err := pickActiveSessionToken(ctx, i.sessionStore, userID, nil)
|
||||||
|
if err != nil {
|
||||||
|
// tunnel 離線 / 無 active session → 交由 caller 映射 502 TUNNEL_DISCONNECTED。
|
||||||
|
return LocalUploadTicket{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal(issueTokenRequest{Serial: serial})
|
||||||
|
if err != nil {
|
||||||
|
return LocalUploadTicket{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
outReq, err := http.NewRequestWithContext(ctx, http.MethodPost, localAgentIssueTokenPath,
|
||||||
|
strings.NewReader(string(body)))
|
||||||
|
if err != nil {
|
||||||
|
return LocalUploadTicket{}, err
|
||||||
|
}
|
||||||
|
outReq.Header.Set("Content-Type", "application/json")
|
||||||
|
outReq.ContentLength = int64(len(body))
|
||||||
|
|
||||||
|
resp, err := i.forwarder.ForwardHTTP(ctx, token, outReq)
|
||||||
|
if err != nil {
|
||||||
|
// local-agent 不可達 / dial 失敗 / timeout → caller 映射 502 TUNNEL_ERROR。
|
||||||
|
return LocalUploadTicket{}, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// 限讀 body(issue-token 回應極小;防禦性 64KB 上界,避免異常 local-agent 撐爆記憶體)。
|
||||||
|
raw, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
|
||||||
|
if err != nil {
|
||||||
|
return LocalUploadTicket{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 429:token 上限已滿 → 透傳 errLocalTokenLimit(不試著解析成功欄位)。
|
||||||
|
if resp.StatusCode == http.StatusTooManyRequests {
|
||||||
|
return LocalUploadTicket{}, errLocalTokenLimit
|
||||||
|
}
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return LocalUploadTicket{}, errLocalTokenUnavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
var env issueTokenEnvelope
|
||||||
|
if err := json.Unmarshal(raw, &env); err != nil {
|
||||||
|
return LocalUploadTicket{}, err
|
||||||
|
}
|
||||||
|
// 契約允許 local-agent 在 200 body 內用 success:false 表達 LOCAL_TOKEN_LIMIT(雖然主契約走
|
||||||
|
// 429,但兩者都映射到 token 上限,防禦性一併處理)。
|
||||||
|
if !env.Success {
|
||||||
|
if env.Error != nil && env.Error.Code == errCodeLocalTokenLimit {
|
||||||
|
return LocalUploadTicket{}, errLocalTokenLimit
|
||||||
|
}
|
||||||
|
return LocalUploadTicket{}, errLocalTokenUnavailable
|
||||||
|
}
|
||||||
|
if env.Data.Token == "" {
|
||||||
|
return LocalUploadTicket{}, errLocalTokenUnavailable
|
||||||
|
}
|
||||||
|
return env.Data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// errLocalTokenUnavailable 表示 local-agent 回應存在但沒帶可用 token(非 2xx 且非 429 /
|
||||||
|
// success:false / 空 token)。與 tunnel 傳輸層錯誤語意區隔,caller 統一映射 502 TUNNEL_ERROR。
|
||||||
|
var errLocalTokenUnavailable = errors.New("local agent: upload token unavailable")
|
||||||
|
|
||||||
|
// resolveLocalTokenIssuer 決定要用哪個 issuer:
|
||||||
|
// - Deps.LocalTokenIssuer 非 nil(測試注入 stub)→ 用它
|
||||||
|
// - 否則從 Forwarder + SessionStore 組 default(production)
|
||||||
|
// - 兩者皆缺 → 回 nil(handler 回 501,代表 tunnel 未配置)
|
||||||
|
func resolveLocalTokenIssuer(deps Deps) localTokenIssuer {
|
||||||
|
if deps.LocalTokenIssuer != nil {
|
||||||
|
return deps.LocalTokenIssuer
|
||||||
|
}
|
||||||
|
return newForwarderLocalTokenIssuer(deps)
|
||||||
|
}
|
||||||
|
|
||||||
|
// localUploadTicketHandler 實作 POST /api/devices/:id/local-upload-ticket(ADR-019 WP-5)。
|
||||||
|
//
|
||||||
|
// 註:route 參數名為 `:id`(gin/httprouter 要求同層級同名,devices.go 既有 /devices/:id/*
|
||||||
|
// 已佔用 :id),但語意上是**裝置序號(serial)**——契約 path 為 /api/devices/:serial/...。
|
||||||
|
// 這裡的 :id 值即 serial,用它走 GetBySerial 做裝置歸屬檢查 + 傳給 local-agent。
|
||||||
|
//
|
||||||
|
// 流程:
|
||||||
|
// 1. AuthMiddleware 已驗 OIDC session → 取 UserContext(拿不到 = 500,middleware 設定錯誤)
|
||||||
|
// 2. 裝置歸屬:DeviceRepo.GetBySerial(userID, serial)——查不到 = 該序號不屬於當前 user → 404
|
||||||
|
// (沿用 devices.go 既有 owner 檢查慣例;GetBySerial 本身就綁 ownerUserID,天然阻擋 IDOR)
|
||||||
|
// 3. tunnel_online 檢查(R-3):無 active session → tunnel 離線 → 502 TUNNEL_DISCONNECTED,
|
||||||
|
// 明確告知前端「裝置離線、無法取得上傳 ticket」(issue token 本就需經 tunnel)
|
||||||
|
// 4. 經 tunnel 打 local-agent issue-token → 透傳 token;429 透傳;tunnel 錯誤映射 502
|
||||||
|
func localUploadTicketHandler(deps Deps) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if deps.DeviceRepo == nil {
|
||||||
|
WriteNotImplemented(c, "device repo not configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
serial := c.Param("id") // :id 語意為 serial,見 handler 註解
|
||||||
|
if serial == "" {
|
||||||
|
WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "device serial required", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthMiddleware 已驗 OIDC session(見 api.go apiGroup);拿不到 UserContext 代表
|
||||||
|
// middleware 設定錯誤,回 500 比 silent fallback 安全(對齊 devices.go C1 fix)。
|
||||||
|
uc, ok := UserContextFrom(c)
|
||||||
|
if !ok || uc.UserID == "" {
|
||||||
|
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
|
||||||
|
"missing user context (auth middleware misconfigured?)", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID := uc.UserID
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// 裝置歸屬檢查(沿用 devices.go 慣例):GetBySerial 綁 ownerUserID,查不到即
|
||||||
|
// 「該序號不屬於當前 user」或「不存在」,一律回 404(不洩漏「存在但非你的」以免 enumeration)。
|
||||||
|
d, err := deps.DeviceRepo.GetBySerial(ctx, userID, serial)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, device.ErrNotFound) {
|
||||||
|
WriteError(c, http.StatusNotFound, ErrCodeNotFound,
|
||||||
|
"device not found or not owned by current user", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// DB 錯誤經 errors.go 映射(PG down → 503、其餘 → 500),不洩漏 raw DB error。
|
||||||
|
WriteDBError(c, deps.Logger, "get device by serial", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
issuer := resolveLocalTokenIssuer(deps)
|
||||||
|
if issuer == nil {
|
||||||
|
// Forwarder / SessionStore 未配置 → 無法經 tunnel 取 token。回 501(非 500),
|
||||||
|
// 語意為「此部署未啟用 tunnel forward」,對齊 proxy.go 的 WriteNotImplemented 慣例。
|
||||||
|
WriteNotImplemented(c, "tunnel forwarder not configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 經 tunnel 打 local-agent issue-token(issuer 內部用 d.SerialNumber 走 tunnel)。
|
||||||
|
// 用 DB 記錄的 SerialNumber(已通過歸屬檢查)而非原始 path 值,確保傳給 local-agent 的
|
||||||
|
// 序號與雲端 device 記錄一致。
|
||||||
|
ticket, err := issuer.IssueToken(c.Request.Context(), userID, d.SerialNumber)
|
||||||
|
if err != nil {
|
||||||
|
writeLocalTokenError(c, deps, userID, d.SerialNumber, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logOrDefault(deps.Logger).Info("local-upload-ticket: issued",
|
||||||
|
"user_id", userID,
|
||||||
|
"serial", d.SerialNumber,
|
||||||
|
"device_id", d.ID,
|
||||||
|
"ttl_seconds", ticket.TTLSeconds,
|
||||||
|
"request_id", RequestIDFrom(c))
|
||||||
|
|
||||||
|
WriteSuccess(c, http.StatusOK, ticket)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeLocalTokenError 把 IssueToken 的 error 映射到統一 API 錯誤格式。
|
||||||
|
//
|
||||||
|
// - errLocalTokenLimit → 429 RATE_LIMITED(透傳 local-agent 的 token 上限;api-spec §6.3
|
||||||
|
// LOCAL_TOKEN_LIMIT 對應 429,這裡用雲端統一的 RATE_LIMITED 碼 + message 標明來源)
|
||||||
|
// - session.ErrSessionNotFound / ErrSessionClosed → 502 TUNNEL_DISCONNECTED(裝置離線,R-3)
|
||||||
|
// - 其他 → 502 TUNNEL_ERROR(local-agent 不可達 / 非預期回應)
|
||||||
|
func writeLocalTokenError(c *gin.Context, deps Deps, userID, serial string, err error) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, errLocalTokenLimit):
|
||||||
|
logOrDefault(deps.Logger).Warn("local-upload-ticket: local agent token limit reached",
|
||||||
|
"user_id", userID, "serial", serial, "request_id", RequestIDFrom(c))
|
||||||
|
WriteError(c, http.StatusTooManyRequests, ErrCodeRateLimited,
|
||||||
|
"上傳 token 已達上限,請稍後再試", nil)
|
||||||
|
case errors.Is(err, session.ErrSessionNotFound) || errors.Is(err, session.ErrSessionClosed):
|
||||||
|
// R-3:tunnel 離線時無法取得 token → 明確告知裝置離線(前端據此 disable 上傳)。
|
||||||
|
WriteError(c, http.StatusBadGateway, ErrCodeTunnelDisconnect,
|
||||||
|
"裝置未連線,無法取得上傳 ticket", nil)
|
||||||
|
default:
|
||||||
|
logOrDefault(deps.Logger).Warn("local-upload-ticket: issue token failed",
|
||||||
|
"user_id", userID, "serial", serial, "error", err.Error(),
|
||||||
|
"request_id", RequestIDFrom(c))
|
||||||
|
WriteError(c, http.StatusBadGateway, ErrCodeTunnelError,
|
||||||
|
"取得上傳 ticket 失敗", nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
204
visionA-backend/internal/api/local_upload_ticket_test.go
Normal file
204
visionA-backend/internal/api/local_upload_ticket_test.go
Normal 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_DISCONNECTED(R-3:裝置未連線無法取 token,前端據此 disable 上傳)。
|
||||||
|
func TestLocalTicket_TunnelDisconnected_502(t *testing.T) {
|
||||||
|
issuer := &stubLocalTokenIssuer{err: session.ErrSessionNotFound}
|
||||||
|
r := newLocalTicketFixture(t, ownedDevice(), "demo-user",
|
||||||
|
func(d *Deps) { d.LocalTokenIssuer = issuer })
|
||||||
|
|
||||||
|
code, body := postLocalTicket(t, r, "0xB906162C")
|
||||||
|
require.Equal(t, http.StatusBadGateway, code)
|
||||||
|
errObj, _ := body["error"].(map[string]any)
|
||||||
|
assert.Equal(t, ErrCodeTunnelDisconnect, errObj["code"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLocalTicket_TunnelError_502 驗證:local-agent 不可達 / 非預期回應(errLocalTokenUnavailable)
|
||||||
|
// → 502 TUNNEL_ERROR。
|
||||||
|
func TestLocalTicket_TunnelError_502(t *testing.T) {
|
||||||
|
issuer := &stubLocalTokenIssuer{err: errLocalTokenUnavailable}
|
||||||
|
r := newLocalTicketFixture(t, ownedDevice(), "demo-user",
|
||||||
|
func(d *Deps) { d.LocalTokenIssuer = issuer })
|
||||||
|
|
||||||
|
code, body := postLocalTicket(t, r, "0xB906162C")
|
||||||
|
require.Equal(t, http.StatusBadGateway, code)
|
||||||
|
errObj, _ := body["error"].(map[string]any)
|
||||||
|
assert.Equal(t, ErrCodeTunnelError, errObj["code"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLocalTicket_NoIssuer_501 驗證:Forwarder/SessionStore 未配置(resolveLocalTokenIssuer
|
||||||
|
// 回 nil)→ 501 NOT_IMPLEMENTED,而非 panic / 500。
|
||||||
|
func TestLocalTicket_NoIssuer_501(t *testing.T) {
|
||||||
|
// 不注入 LocalTokenIssuer,且 Deps.Forwarder 為 nil → newForwarderLocalTokenIssuer 回 nil。
|
||||||
|
// fixture 預設有 SessionStore 但無 Forwarder,故 default issuer 為 nil。
|
||||||
|
r := newLocalTicketFixture(t, ownedDevice(), "demo-user", nil)
|
||||||
|
|
||||||
|
code, body := postLocalTicket(t, r, "0xB906162C")
|
||||||
|
require.Equal(t, http.StatusNotImplemented, code)
|
||||||
|
errObj, _ := body["error"].(map[string]any)
|
||||||
|
assert.Equal(t, ErrCodeNotImplemented, errObj["code"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestResolveLocalTokenIssuer_NilWhenNoForwarder 驗證:Forwarder 為 nil 時 default issuer 為 nil。
|
||||||
|
func TestResolveLocalTokenIssuer_NilWhenNoForwarder(t *testing.T) {
|
||||||
|
assert.Nil(t, resolveLocalTokenIssuer(Deps{SessionStore: &fakeSessionStore{}}),
|
||||||
|
"Forwarder 為 nil 應回 nil issuer")
|
||||||
|
assert.Nil(t, resolveLocalTokenIssuer(Deps{}),
|
||||||
|
"Forwarder + SessionStore 皆 nil 應回 nil issuer")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestResolveLocalTokenIssuer_InjectedWins 驗證:Deps.LocalTokenIssuer 非 nil 時優先用注入的 stub。
|
||||||
|
func TestResolveLocalTokenIssuer_InjectedWins(t *testing.T) {
|
||||||
|
stub := &stubLocalTokenIssuer{}
|
||||||
|
got := resolveLocalTokenIssuer(Deps{LocalTokenIssuer: stub})
|
||||||
|
assert.Same(t, stub, got)
|
||||||
|
}
|
||||||
363
visionA-frontend/src/lib/local-agent.test.ts
Normal file
363
visionA-frontend/src/lib/local-agent.test.ts
Normal file
@ -0,0 +1,363 @@
|
|||||||
|
/**
|
||||||
|
* local-agent.ts 單元測試(ADR-019 WP-3)
|
||||||
|
*
|
||||||
|
* 對契約 mock(不需實機瀏覽器 / 不需 local-agent):
|
||||||
|
* - computeSerialHash:SHA-256("visiona-local-v1" || serial) hex(用真 Web Crypto 驗算)
|
||||||
|
* - probeLocalAgentPort:快取 → 3721 → 3722–3740 並發、timeout、無回應
|
||||||
|
* - resolveLocalAgent:OK / NOT_FOUND / MISMATCH 三狀態
|
||||||
|
* - uploadToLocalAgent:打對 loopback URL、帶 token header、不帶 cookie、錯誤碼映射
|
||||||
|
*/
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
LOCAL_AGENT_PORT_END,
|
||||||
|
LOCAL_AGENT_PORT_START,
|
||||||
|
LOCAL_TOKEN_HEADER,
|
||||||
|
LOCAL_UPLOAD_VIDEO_PATH,
|
||||||
|
PORT_CACHE_KEY,
|
||||||
|
SERIAL_HASH_SALT,
|
||||||
|
computeSerialHash,
|
||||||
|
probeLocalAgentPort,
|
||||||
|
resolveLocalAgent,
|
||||||
|
uploadToLocalAgent,
|
||||||
|
} from "./local-agent";
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* 工具:組 hello envelope、算 hash 的獨立參考實作 */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
function helloOk(serialHashes: string[]): Response {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
data: { serialHashes, supportsLocalUpload: true },
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用 Node 內建 crypto 獨立算一次(避免「用 SUT 驗 SUT」)。 */
|
||||||
|
async function refHash(serial: string): Promise<string> {
|
||||||
|
const { createHash } = await import("node:crypto");
|
||||||
|
return createHash("sha256")
|
||||||
|
.update(`${SERIAL_HASH_SALT}${serial}`)
|
||||||
|
.digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* computeSerialHash */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
describe("computeSerialHash", () => {
|
||||||
|
it("SHA-256(salt || serial) 的 lowercase hex,與獨立 node:crypto 實作一致", async () => {
|
||||||
|
const serial = "KN12345678";
|
||||||
|
const got = await computeSerialHash(serial);
|
||||||
|
const ref = await refHash(serial);
|
||||||
|
expect(got).toBe(ref);
|
||||||
|
expect(got).toMatch(/^[0-9a-f]{64}$/); // 64 hex chars, lowercase
|
||||||
|
});
|
||||||
|
|
||||||
|
it("salt 常數為 visiona-local-v1(契約校驗,前後端共用寫死)", () => {
|
||||||
|
expect(SERIAL_HASH_SALT).toBe("visiona-local-v1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("不同 serial → 不同 hash", async () => {
|
||||||
|
expect(await computeSerialHash("KN-A")).not.toBe(await computeSerialHash("KN-B"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* probeLocalAgentPort / resolveLocalAgent(mock fetch) */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
describe("port 探測 + 同機判定", () => {
|
||||||
|
const origFetch = globalThis.fetch;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
globalThis.sessionStorage?.clear();
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
globalThis.fetch = origFetch;
|
||||||
|
globalThis.sessionStorage?.clear();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 只在指定 port 回 hello,其餘 reject(模擬無回應)。 */
|
||||||
|
function mockFetchOnPort(targetPort: number, serialHashes: string[]) {
|
||||||
|
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url.includes(`:${targetPort}/api/local/hello`)) {
|
||||||
|
return helloOk(serialHashes);
|
||||||
|
}
|
||||||
|
throw new TypeError("Failed to fetch"); // 其他 port 無回應
|
||||||
|
}) as typeof fetch;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("3721 有回應 → 回傳該 port 並寫入快取", async () => {
|
||||||
|
mockFetchOnPort(3721, ["abc"]);
|
||||||
|
const hit = await probeLocalAgentPort();
|
||||||
|
expect(hit.port).toBe(3721);
|
||||||
|
expect(globalThis.sessionStorage?.getItem(PORT_CACHE_KEY)).toBe("3721");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("3721 無回應、3730 有回應 → 並發掃描命中 3730", async () => {
|
||||||
|
mockFetchOnPort(3730, ["abc"]);
|
||||||
|
const hit = await probeLocalAgentPort();
|
||||||
|
expect(hit.port).toBe(3730);
|
||||||
|
expect(hit.port).toBeGreaterThanOrEqual(LOCAL_AGENT_PORT_START);
|
||||||
|
expect(hit.port).toBeLessThanOrEqual(LOCAL_AGENT_PORT_END);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("快取命中 → 只打快取 port(不重掃全範圍)", async () => {
|
||||||
|
globalThis.sessionStorage?.setItem(PORT_CACHE_KEY, "3735");
|
||||||
|
const fetchSpy = vi.fn(async (input: RequestInfo | URL) => {
|
||||||
|
if (String(input).includes(":3735/api/local/hello")) return helloOk(["abc"]);
|
||||||
|
throw new TypeError("Failed to fetch");
|
||||||
|
});
|
||||||
|
globalThis.fetch = fetchSpy as typeof fetch;
|
||||||
|
|
||||||
|
const hit = await probeLocalAgentPort();
|
||||||
|
expect(hit.port).toBe(3735);
|
||||||
|
// 快取命中應只打 1 次(不對 20 個 port 發請求)
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("快取失效 → 清快取並重掃到真實 port", async () => {
|
||||||
|
globalThis.sessionStorage?.setItem(PORT_CACHE_KEY, "3739"); // 舊 port 已無 agent
|
||||||
|
mockFetchOnPort(3721, ["abc"]);
|
||||||
|
const hit = await probeLocalAgentPort();
|
||||||
|
expect(hit.port).toBe(3721);
|
||||||
|
expect(globalThis.sessionStorage?.getItem(PORT_CACHE_KEY)).toBe("3721");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("全範圍無回應 → probeLocalAgentPort reject", async () => {
|
||||||
|
globalThis.fetch = vi.fn(async () => {
|
||||||
|
throw new TypeError("Failed to fetch");
|
||||||
|
}) as typeof fetch;
|
||||||
|
await expect(probeLocalAgentPort()).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolveLocalAgent:serial 相符 → OK + port", async () => {
|
||||||
|
const serial = "KN-DEVICE-1";
|
||||||
|
const hash = await refHash(serial);
|
||||||
|
mockFetchOnPort(3721, [hash, "other"]);
|
||||||
|
|
||||||
|
const res = await resolveLocalAgent(serial);
|
||||||
|
expect(res.status).toBe("OK");
|
||||||
|
expect(res.port).toBe(3721);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolveLocalAgent:無回應 → NOT_FOUND", async () => {
|
||||||
|
globalThis.fetch = vi.fn(async () => {
|
||||||
|
throw new TypeError("Failed to fetch");
|
||||||
|
}) as typeof fetch;
|
||||||
|
const res = await resolveLocalAgent("KN-DEVICE-1");
|
||||||
|
expect(res.status).toBe("NOT_FOUND");
|
||||||
|
expect(res.port).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolveLocalAgent:有回應但 serial 不符 → MISMATCH + 清快取", async () => {
|
||||||
|
const otherHash = await refHash("KN-OTHER-DEVICE");
|
||||||
|
mockFetchOnPort(3721, [otherHash]);
|
||||||
|
|
||||||
|
const res = await resolveLocalAgent("KN-DEVICE-1");
|
||||||
|
expect(res.status).toBe("MISMATCH");
|
||||||
|
expect(res.port).toBeUndefined();
|
||||||
|
// MISMATCH 應清快取,避免下次又先撞別台 agent 的 port
|
||||||
|
expect(globalThis.sessionStorage?.getItem(PORT_CACHE_KEY)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hello 回應格式不符(缺 serialHashes)→ 視為該 port 無效", async () => {
|
||||||
|
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
|
||||||
|
if (String(input).includes(":3721/api/local/hello")) {
|
||||||
|
return new Response(JSON.stringify({ success: true, data: { foo: 1 } }), {
|
||||||
|
status: 200,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw new TypeError("Failed to fetch");
|
||||||
|
}) as typeof fetch;
|
||||||
|
// 3721 格式不符、其他 port 無回應 → 整體 NOT_FOUND
|
||||||
|
const res = await resolveLocalAgent("KN-DEVICE-1");
|
||||||
|
expect(res.status).toBe("NOT_FOUND");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* uploadToLocalAgent(mock XMLHttpRequest) */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
interface FakeXHR {
|
||||||
|
method?: string;
|
||||||
|
url?: string;
|
||||||
|
withCredentials?: boolean;
|
||||||
|
timeout?: number;
|
||||||
|
sent?: FormData;
|
||||||
|
headers: Record<string, string>;
|
||||||
|
status: number;
|
||||||
|
responseText: string;
|
||||||
|
upload: { onprogress: ((ev: ProgressEvent) => void) | null };
|
||||||
|
onload: (() => void) | null;
|
||||||
|
onerror: (() => void) | null;
|
||||||
|
ontimeout: (() => void) | null;
|
||||||
|
open(method: string, url: string): void;
|
||||||
|
setRequestHeader(k: string, v: string): void;
|
||||||
|
send(body: FormData): void;
|
||||||
|
abort(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastXhr: FakeXHR | null = null;
|
||||||
|
|
||||||
|
function installXhrMock(responder: (xhr: FakeXHR) => void) {
|
||||||
|
function XHRMock(this: unknown) {
|
||||||
|
const xhr: FakeXHR = {
|
||||||
|
withCredentials: true, // 預設 true,讓測試能驗證 SUT 主動設回 false
|
||||||
|
timeout: 0,
|
||||||
|
status: 200,
|
||||||
|
responseText: "",
|
||||||
|
headers: {},
|
||||||
|
upload: { onprogress: null },
|
||||||
|
onload: null,
|
||||||
|
onerror: null,
|
||||||
|
ontimeout: null,
|
||||||
|
open(method: string, url: string) {
|
||||||
|
xhr.method = method;
|
||||||
|
xhr.url = url;
|
||||||
|
},
|
||||||
|
setRequestHeader(k: string, v: string) {
|
||||||
|
xhr.headers[k] = v;
|
||||||
|
},
|
||||||
|
send(body: FormData) {
|
||||||
|
xhr.sent = body;
|
||||||
|
queueMicrotask(() => responder(xhr));
|
||||||
|
},
|
||||||
|
abort() {},
|
||||||
|
};
|
||||||
|
lastXhr = xhr;
|
||||||
|
return xhr;
|
||||||
|
}
|
||||||
|
(globalThis as { XMLHttpRequest?: unknown }).XMLHttpRequest =
|
||||||
|
XHRMock as unknown as typeof XMLHttpRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("uploadToLocalAgent(XHR mock)", () => {
|
||||||
|
const origXHR = globalThis.XMLHttpRequest;
|
||||||
|
beforeEach(() => {
|
||||||
|
lastXhr = null;
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
(globalThis as { XMLHttpRequest?: unknown }).XMLHttpRequest = origXHR;
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeForm(): FormData {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("deviceId", "KN-DEVICE-1");
|
||||||
|
form.append(
|
||||||
|
"file",
|
||||||
|
new File([new Blob([new Uint8Array(8)])], "v.mp4"),
|
||||||
|
);
|
||||||
|
return form;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("POST 到 loopback URL、帶 token header、不帶 cookie、回傳 data", async () => {
|
||||||
|
installXhrMock((xhr) => {
|
||||||
|
xhr.status = 200;
|
||||||
|
xhr.responseText = JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
data: { streamUrl: "/api/camera/stream", sourceType: "video", totalFrames: 100 },
|
||||||
|
});
|
||||||
|
xhr.onload?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await uploadToLocalAgent(3721, LOCAL_UPLOAD_VIDEO_PATH, makeForm(), {
|
||||||
|
token: "tok-abc",
|
||||||
|
});
|
||||||
|
expect(res.sourceType).toBe("video");
|
||||||
|
expect(lastXhr?.method).toBe("POST");
|
||||||
|
expect(lastXhr?.url).toBe("http://127.0.0.1:3721/api/local/media/upload/video");
|
||||||
|
// 直連 loopback:不帶 cookie
|
||||||
|
expect(lastXhr?.withCredentials).toBe(false);
|
||||||
|
// token 走 header、非 URL
|
||||||
|
expect(lastXhr?.headers[LOCAL_TOKEN_HEADER]).toBe("tok-abc");
|
||||||
|
expect(lastXhr?.url).not.toContain("tok-abc");
|
||||||
|
expect(lastXhr?.sent?.get("deviceId")).toBe("KN-DEVICE-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("401 → 映射 LOCAL_TOKEN_INVALID(envelope 有 code 時保留 code)", async () => {
|
||||||
|
installXhrMock((xhr) => {
|
||||||
|
xhr.status = 401;
|
||||||
|
xhr.responseText = JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
error: { code: "LOCAL_TOKEN_INVALID", message: "token invalid" },
|
||||||
|
});
|
||||||
|
xhr.onload?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
uploadToLocalAgent(3721, LOCAL_UPLOAD_VIDEO_PATH, makeForm(), { token: "bad" }),
|
||||||
|
).rejects.toMatchObject({ code: "LOCAL_TOKEN_INVALID", status: 401 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("413 → 映射 LOCAL_UPLOAD_TOO_LARGE", async () => {
|
||||||
|
installXhrMock((xhr) => {
|
||||||
|
xhr.status = 413;
|
||||||
|
xhr.responseText = JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
error: { code: "LOCAL_UPLOAD_TOO_LARGE", message: "too large" },
|
||||||
|
});
|
||||||
|
xhr.onload?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
uploadToLocalAgent(3721, LOCAL_UPLOAD_VIDEO_PATH, makeForm(), { token: "t" }),
|
||||||
|
).rejects.toMatchObject({ code: "LOCAL_UPLOAD_TOO_LARGE", status: 413 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("401 無 error envelope → fallback 為 LOCAL_TOKEN_INVALID", async () => {
|
||||||
|
installXhrMock((xhr) => {
|
||||||
|
xhr.status = 401;
|
||||||
|
xhr.responseText = "";
|
||||||
|
xhr.onload?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
uploadToLocalAgent(3721, LOCAL_UPLOAD_VIDEO_PATH, makeForm(), { token: "t" }),
|
||||||
|
).rejects.toMatchObject({ code: "LOCAL_TOKEN_INVALID", status: 401 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("回報上傳進度", async () => {
|
||||||
|
installXhrMock((xhr) => {
|
||||||
|
xhr.upload.onprogress?.({
|
||||||
|
lengthComputable: true,
|
||||||
|
loaded: 25,
|
||||||
|
total: 100,
|
||||||
|
} as ProgressEvent);
|
||||||
|
xhr.status = 200;
|
||||||
|
xhr.responseText = JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
data: { streamUrl: "/s", sourceType: "video" },
|
||||||
|
});
|
||||||
|
xhr.onload?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
const onProgress = vi.fn();
|
||||||
|
await uploadToLocalAgent(3721, LOCAL_UPLOAD_VIDEO_PATH, makeForm(), {
|
||||||
|
token: "t",
|
||||||
|
onProgress,
|
||||||
|
});
|
||||||
|
expect(onProgress).toHaveBeenCalledWith(25);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("已 abort 的 signal → 立即拋 AbortError(不送出)", async () => {
|
||||||
|
installXhrMock(() => {
|
||||||
|
/* 不該被呼叫 */
|
||||||
|
});
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
ctrl.abort();
|
||||||
|
await expect(
|
||||||
|
uploadToLocalAgent(3721, LOCAL_UPLOAD_VIDEO_PATH, makeForm(), {
|
||||||
|
token: "t",
|
||||||
|
signal: ctrl.signal,
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code: "ABORTED" });
|
||||||
|
});
|
||||||
|
});
|
||||||
443
visionA-frontend/src/lib/local-agent.ts
Normal file
443
visionA-frontend/src/lib/local-agent.ts
Normal file
@ -0,0 +1,443 @@
|
|||||||
|
/**
|
||||||
|
* Local-agent localhost 直連工具 — visionA Cloud 前端(ADR-019 WP-3)
|
||||||
|
*
|
||||||
|
* 職責(純 client util、endpoint 無關):
|
||||||
|
* - `probeLocalAgentPort()`:探測本機 local-agent 的動態 port
|
||||||
|
* (sessionStorage 快取 → 3721 → 3722–3740 並發,各 timeout 500ms,打 GET /api/local/hello)。
|
||||||
|
* - `resolveLocalAgent(serial)`:探測 + 同機判定 + serial 身分驗證,回傳 { port } 或
|
||||||
|
* LOCAL_AGENT_NOT_FOUND(無回應)/ LOCAL_AGENT_MISMATCH(有回應但 serial 不符)。
|
||||||
|
* - `uploadToLocalAgent(port, path, form, options)`:endpoint 無關的通用上傳函式
|
||||||
|
* (三個 caller 換 path 即可),打 http://127.0.0.1:<port><path>,帶 X-Visiona-Local-Token。
|
||||||
|
*
|
||||||
|
* 為什麼是獨立檔(不併進 media.ts):
|
||||||
|
* media.ts 的上傳走雲端 same-origin cookie session(BFF);local-agent 直連走的是
|
||||||
|
* loopback 絕對 URL + one-time token header,認證模型完全不同(ADR-019 §2.1 混合路徑)。
|
||||||
|
* 拆開避免兩套認證邏輯混淆。
|
||||||
|
*
|
||||||
|
* 契約來源(不自行更改,見 api-spec §6.2–6.5 / ADR-019 §2.3):
|
||||||
|
* - port 範圍 3721–3740(local-agent pickPort 動態)
|
||||||
|
* - GET /api/local/hello 回 { serialHashes: string[], supportsLocalUpload: boolean }
|
||||||
|
* - serialHashes[i] = SHA-256("visiona-local-v1" || fullSerial) 的 lowercase hex
|
||||||
|
* - salt 常數 "visiona-local-v1" 前後端共用寫死
|
||||||
|
* - 上傳路徑 /api/local/media/upload/{video|image|batch-images},Header X-Visiona-Local-Token
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
AbortError,
|
||||||
|
ApiError,
|
||||||
|
NetworkError,
|
||||||
|
TimeoutError,
|
||||||
|
} from "@/lib/api";
|
||||||
|
import type { ApiErrorShape } from "@/types/api";
|
||||||
|
import type { MediaUploadResponse } from "@/types/camera";
|
||||||
|
import type { UploadMediaOptions } from "@/lib/media";
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* 契約常數 */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
/** local-agent loopback host(強制綁 127.0.0.1,見 local-agent server/config.go)。 */
|
||||||
|
export const LOCAL_AGENT_HOST = "127.0.0.1";
|
||||||
|
|
||||||
|
/** port 探測範圍(local-agent pickPort 3721 → 3740 fallback,ADR-019 §2.3)。 */
|
||||||
|
export const LOCAL_AGENT_PORT_START = 3721;
|
||||||
|
export const LOCAL_AGENT_PORT_END = 3740;
|
||||||
|
|
||||||
|
/** 每次探測單一 port 的 timeout(毫秒,ADR-019 §2.3)。 */
|
||||||
|
export const PROBE_TIMEOUT_MS = 500;
|
||||||
|
|
||||||
|
/** sessionStorage 快取「上次探到的 port」的 key(同分頁 session 內免重掃)。 */
|
||||||
|
export const PORT_CACHE_KEY = "visiona.localAgent.port";
|
||||||
|
|
||||||
|
/** 同機偵測 / 身分驗證用的固定公開 salt(前後端共用寫死,api-spec §6.3;**不可改**)。 */
|
||||||
|
export const SERIAL_HASH_SALT = "visiona-local-v1";
|
||||||
|
|
||||||
|
/** 探測 endpoint 路徑(專用、非 /api/system/health,ADR-019 §2.3)。 */
|
||||||
|
export const LOCAL_HELLO_PATH = "/api/local/hello";
|
||||||
|
|
||||||
|
/** 上傳 token 的 request header 名(api-spec §6.2)。 */
|
||||||
|
export const LOCAL_TOKEN_HEADER = "X-Visiona-Local-Token";
|
||||||
|
|
||||||
|
/** 直連上傳 route 路徑(endpoint 無關函式的 caller 換這三個之一)。 */
|
||||||
|
export const LOCAL_UPLOAD_IMAGE_PATH = "/api/local/media/upload/image";
|
||||||
|
export const LOCAL_UPLOAD_VIDEO_PATH = "/api/local/media/upload/video";
|
||||||
|
export const LOCAL_UPLOAD_BATCH_PATH = "/api/local/media/upload/batch-images";
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* 型別 */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
/** GET /api/local/hello 回傳的 data(api-spec §6.3,最小揭露)。 */
|
||||||
|
export interface LocalHelloData {
|
||||||
|
/** SHA-256("visiona-local-v1" || fullSerial) 的 lowercase hex 陣列。 */
|
||||||
|
serialHashes: string[];
|
||||||
|
/** 是否支援 local upload(布林,取代原 agentVersion)。 */
|
||||||
|
supportsLocalUpload: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 探測到的單一候選(某 port 有回應且回了 hello data)。 */
|
||||||
|
interface ProbeHit {
|
||||||
|
port: number;
|
||||||
|
data: LocalHelloData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* resolveLocalAgent 的結果碼(對齊 api-spec §6.5 前端內部狀態):
|
||||||
|
* - OK 找到同機且 serial 相符的 agent
|
||||||
|
* - NOT_FOUND 掃描無任何回應(非同機 / agent 沒跑)→ LOCAL_AGENT_NOT_FOUND
|
||||||
|
* - MISMATCH 有回應但沒有任一 serial 相符 → LOCAL_AGENT_MISMATCH
|
||||||
|
*/
|
||||||
|
export type LocalAgentResolveStatus = "OK" | "NOT_FOUND" | "MISMATCH";
|
||||||
|
|
||||||
|
export interface LocalAgentResolveResult {
|
||||||
|
status: LocalAgentResolveStatus;
|
||||||
|
/** status === "OK" 時為探到的 port;否則 undefined。 */
|
||||||
|
port?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* SHA-256 serial hash(Web Crypto,前端獨立重算比對) */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 算 `SHA-256("visiona-local-v1" || serial)` 的 lowercase hex。
|
||||||
|
*
|
||||||
|
* 用 Web Crypto `crypto.subtle.digest`(jsdom / Node 20+ / Chrome / Edge 皆有)。
|
||||||
|
* 與後端 salted SHA-256 契約一致(api-spec §6.3),供同機身分比對。
|
||||||
|
*/
|
||||||
|
export async function computeSerialHash(serial: string): Promise<string> {
|
||||||
|
const bytes = new TextEncoder().encode(`${SERIAL_HASH_SALT}${serial}`);
|
||||||
|
const digest = await crypto.subtle.digest("SHA-256", bytes);
|
||||||
|
return bytesToHex(new Uint8Array(digest));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Uint8Array → lowercase hex 字串(與後端 hex.EncodeToString 對齊)。 */
|
||||||
|
function bytesToHex(bytes: Uint8Array): string {
|
||||||
|
let hex = "";
|
||||||
|
for (const b of bytes) {
|
||||||
|
hex += b.toString(16).padStart(2, "0");
|
||||||
|
}
|
||||||
|
return hex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* sessionStorage 快取 */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
/** 讀 sessionStorage 快取的 port(無效 / 不存在 / 超出範圍 → null)。 */
|
||||||
|
function readCachedPort(): number | null {
|
||||||
|
try {
|
||||||
|
const raw = globalThis.sessionStorage?.getItem(PORT_CACHE_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
const port = Number.parseInt(raw, 10);
|
||||||
|
if (
|
||||||
|
Number.isInteger(port) &&
|
||||||
|
port >= LOCAL_AGENT_PORT_START &&
|
||||||
|
port <= LOCAL_AGENT_PORT_END
|
||||||
|
) {
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
// sessionStorage 不可用(SSR / 隱私模式)→ 當作無快取
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 寫 sessionStorage 快取(失敗靜默——快取只是最佳化,不可用不影響功能)。 */
|
||||||
|
function writeCachedPort(port: number): void {
|
||||||
|
try {
|
||||||
|
globalThis.sessionStorage?.setItem(PORT_CACHE_KEY, String(port));
|
||||||
|
} catch {
|
||||||
|
// ignore:快取寫入失敗不影響探測結果
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清掉快取(快取的 port 探測失敗時呼叫,避免下次又先撞舊 port)。 */
|
||||||
|
function clearCachedPort(): void {
|
||||||
|
try {
|
||||||
|
globalThis.sessionStorage?.removeItem(PORT_CACHE_KEY);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* 單一 port 探測 */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打單一 port 的 GET /api/local/hello,timeout 500ms。
|
||||||
|
*
|
||||||
|
* 回傳 ProbeHit(成功且拿到合法 hello data)或 reject(無回應 / 非預期格式 / timeout)。
|
||||||
|
* 這裡**只判斷「有沒有 local-agent 在這個 port」**,不做 serial 比對(比對在 resolveLocalAgent)。
|
||||||
|
*/
|
||||||
|
async function probeSinglePort(port: number): Promise<ProbeHit> {
|
||||||
|
const url = `http://${LOCAL_AGENT_HOST}:${port}${LOCAL_HELLO_PATH}`;
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => ctrl.abort("probe-timeout"), PROBE_TIMEOUT_MS);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: "GET",
|
||||||
|
signal: ctrl.signal,
|
||||||
|
// 直連 local-agent 用 header token、不需 cookie(api-spec §6.4 Allow-Credentials: false)
|
||||||
|
credentials: "omit",
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`hello returned HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
const parsed: unknown = await res.json();
|
||||||
|
const data = extractHelloData(parsed);
|
||||||
|
if (!data) {
|
||||||
|
throw new Error("hello response shape invalid");
|
||||||
|
}
|
||||||
|
return { port, data };
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 從 hello 回應解 envelope 取 data,並驗證 serialHashes 型別。回傳 null 表格式不符。 */
|
||||||
|
function extractHelloData(parsed: unknown): LocalHelloData | null {
|
||||||
|
if (
|
||||||
|
!parsed ||
|
||||||
|
typeof parsed !== "object" ||
|
||||||
|
!("success" in parsed) ||
|
||||||
|
(parsed as { success: unknown }).success !== true ||
|
||||||
|
!("data" in parsed)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const data = (parsed as { data: unknown }).data;
|
||||||
|
if (!data || typeof data !== "object" || !("serialHashes" in data)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const hashes = (data as { serialHashes: unknown }).serialHashes;
|
||||||
|
if (!Array.isArray(hashes) || !hashes.every((h) => typeof h === "string")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const supports =
|
||||||
|
"supportsLocalUpload" in data
|
||||||
|
? Boolean((data as { supportsLocalUpload: unknown }).supportsLocalUpload)
|
||||||
|
: false;
|
||||||
|
return { serialHashes: hashes as string[], supportsLocalUpload: supports };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* port 探測(快取 → 3721 → 3722–3740 並發) */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 探測本機 local-agent 的 port,回傳第一個有回應者的 ProbeHit。
|
||||||
|
*
|
||||||
|
* 策略(ADR-019 §2.3):
|
||||||
|
* 1. sessionStorage 快取的 port(同分頁 session 內免重掃)
|
||||||
|
* 2. 3721(最常見的預設 port)
|
||||||
|
* 3. 3722–3740 並發(Promise.any 取第一個成功者)
|
||||||
|
* 每次 timeout 500ms。全部失敗 → reject(呼叫端視為 NOT_FOUND)。
|
||||||
|
*
|
||||||
|
* 為什麼分三段而非一次全並發:
|
||||||
|
* 快取 / 3721 命中率最高,先試可避免每次都對 20 個 port 發 preflight(成本 + 觸發防火牆告警)。
|
||||||
|
*/
|
||||||
|
export async function probeLocalAgentPort(): Promise<ProbeHit> {
|
||||||
|
// 1) 快取
|
||||||
|
const cached = readCachedPort();
|
||||||
|
if (cached !== null) {
|
||||||
|
try {
|
||||||
|
const hit = await probeSinglePort(cached);
|
||||||
|
writeCachedPort(hit.port);
|
||||||
|
return hit;
|
||||||
|
} catch {
|
||||||
|
// 快取失效(agent 換 port / 沒跑)→ 清掉,往下重掃
|
||||||
|
clearCachedPort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) 3721 預設 port(避開重複試快取剛失敗的那個)
|
||||||
|
if (cached !== LOCAL_AGENT_PORT_START) {
|
||||||
|
try {
|
||||||
|
const hit = await probeSinglePort(LOCAL_AGENT_PORT_START);
|
||||||
|
writeCachedPort(hit.port);
|
||||||
|
return hit;
|
||||||
|
} catch {
|
||||||
|
// 往下並發掃剩餘範圍
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) 3722–3740 並發,取第一個成功者
|
||||||
|
const rest: number[] = [];
|
||||||
|
for (let p = LOCAL_AGENT_PORT_START + 1; p <= LOCAL_AGENT_PORT_END; p++) {
|
||||||
|
if (p !== cached) rest.push(p);
|
||||||
|
}
|
||||||
|
if (rest.length === 0) {
|
||||||
|
throw new NetworkError("No local-agent found on any candidate port");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const hit = await Promise.any(rest.map((p) => probeSinglePort(p)));
|
||||||
|
writeCachedPort(hit.port);
|
||||||
|
return hit;
|
||||||
|
} catch {
|
||||||
|
// Promise.any 全 reject → AggregateError
|
||||||
|
throw new NetworkError("No local-agent found on any candidate port");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* 同機判定 + serial 身分驗證 */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 探測 + 同機判定 + serial 身分驗證。
|
||||||
|
*
|
||||||
|
* @param serial 目前選定裝置的 serialNumber(kn_number;ADR-018 serial 路由)
|
||||||
|
* @returns
|
||||||
|
* - { status: "OK", port } 找到同機 agent 且其 serialHashes 含此 serial 的雜湊
|
||||||
|
* - { status: "NOT_FOUND" } 掃描無回應(非同機 / agent 沒跑)→ LOCAL_AGENT_NOT_FOUND
|
||||||
|
* - { status: "MISMATCH" } 有回應但無任一 serial 相符(同機跑著別台 agent)→ LOCAL_AGENT_MISMATCH
|
||||||
|
*
|
||||||
|
* 為什麼比對 serial:防「同機跑著另一台 agent、影片被送到錯的裝置」的靜默錯誤(ADR-019 §2.3 / R-4)。
|
||||||
|
*/
|
||||||
|
export async function resolveLocalAgent(
|
||||||
|
serial: string,
|
||||||
|
): Promise<LocalAgentResolveResult> {
|
||||||
|
let hit: ProbeHit;
|
||||||
|
try {
|
||||||
|
hit = await probeLocalAgentPort();
|
||||||
|
} catch {
|
||||||
|
return { status: "NOT_FOUND" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const expected = await computeSerialHash(serial);
|
||||||
|
if (hit.data.serialHashes.includes(expected)) {
|
||||||
|
return { status: "OK", port: hit.port };
|
||||||
|
}
|
||||||
|
// 有回應但 serial 不符 → 快取的 port 可能是別台 agent,清掉避免誤導下次
|
||||||
|
clearCachedPort();
|
||||||
|
return { status: "MISMATCH" };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* 通用上傳(endpoint 無關,帶 token header) */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
/** uploadToLocalAgent 的選項:沿用 media 的進度 / 取消 / timeout,加 token。 */
|
||||||
|
export interface LocalUploadOptions extends UploadMediaOptions {
|
||||||
|
/** one-time upload token(經雲端 ticket 取得,放 X-Visiona-Local-Token header)。 */
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 以 XHR + FormData 直連 local-agent 上傳 multipart(endpoint 無關)。
|
||||||
|
*
|
||||||
|
* 三個 caller(image / video / batch)只需換 `path`:
|
||||||
|
* LOCAL_UPLOAD_IMAGE_PATH | LOCAL_UPLOAD_VIDEO_PATH | LOCAL_UPLOAD_BATCH_PATH
|
||||||
|
*
|
||||||
|
* @param port probeLocalAgentPort / resolveLocalAgent 探到的 port
|
||||||
|
* @param path 直連上傳 route 相對路徑(/api/local/media/upload/*)
|
||||||
|
* @param form 已組好的 FormData(含 deviceId + file/files,格式與雲端 route 相同)
|
||||||
|
* @param options token + 進度 / 取消 / timeout
|
||||||
|
* @returns 解開 envelope 的 MediaUploadResponse(與雲端 route 回傳格式相同)
|
||||||
|
* @throws ApiError | NetworkError | TimeoutError | AbortError(與 api.ts 一致的錯誤體系)
|
||||||
|
*
|
||||||
|
* 認證與雲端 route 不同:
|
||||||
|
* - 走絕對 loopback URL(http://127.0.0.1:<port>),非 same-origin
|
||||||
|
* - credentials 不帶 cookie(withCredentials = false;api-spec §6.4 Allow-Credentials: false)
|
||||||
|
* - token 放 header X-Visiona-Local-Token(不放 URL,避免洩漏到 log / referrer)
|
||||||
|
*/
|
||||||
|
export function uploadToLocalAgent(
|
||||||
|
port: number,
|
||||||
|
path: string,
|
||||||
|
form: FormData,
|
||||||
|
options: LocalUploadOptions,
|
||||||
|
): Promise<MediaUploadResponse> {
|
||||||
|
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
||||||
|
const url = `http://${LOCAL_AGENT_HOST}:${port}${normalizedPath}`;
|
||||||
|
|
||||||
|
return new Promise<MediaUploadResponse>((resolve, reject) => {
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
xhr.open("POST", url, true);
|
||||||
|
// 直連 loopback:不帶 cookie(token 走 header)
|
||||||
|
xhr.withCredentials = false;
|
||||||
|
// 不設 Content-Type,讓瀏覽器帶 multipart boundary
|
||||||
|
xhr.setRequestHeader(LOCAL_TOKEN_HEADER, options.token);
|
||||||
|
|
||||||
|
if (options.timeoutMs && options.timeoutMs > 0) {
|
||||||
|
xhr.timeout = options.timeoutMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.onProgress) {
|
||||||
|
xhr.upload.onprogress = (ev) => {
|
||||||
|
if (ev.lengthComputable) {
|
||||||
|
options.onProgress!(
|
||||||
|
Math.min(100, Math.round((ev.loaded / ev.total) * 100)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
xhr.onload = () => {
|
||||||
|
let parsed: unknown = null;
|
||||||
|
try {
|
||||||
|
parsed = xhr.responseText ? JSON.parse(xhr.responseText) : null;
|
||||||
|
} catch {
|
||||||
|
// 非 JSON body
|
||||||
|
}
|
||||||
|
|
||||||
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
|
if (
|
||||||
|
parsed &&
|
||||||
|
typeof parsed === "object" &&
|
||||||
|
"success" in parsed &&
|
||||||
|
(parsed as { success: boolean }).success === true &&
|
||||||
|
"data" in parsed
|
||||||
|
) {
|
||||||
|
resolve((parsed as { data: MediaUploadResponse }).data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reject(
|
||||||
|
new ApiError(xhr.status, {
|
||||||
|
code: "PARSE_ERROR",
|
||||||
|
message: "Unexpected upload response shape",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// non-2xx:盡量取 envelope 的 error(LOCAL_TOKEN_INVALID / LOCAL_UPLOAD_TOO_LARGE 等)
|
||||||
|
let errShape: ApiErrorShape = {
|
||||||
|
code: xhr.status === 401 ? "LOCAL_TOKEN_INVALID" : "INTERNAL_ERROR",
|
||||||
|
message: `Local upload failed: HTTP ${xhr.status}`,
|
||||||
|
};
|
||||||
|
if (
|
||||||
|
parsed &&
|
||||||
|
typeof parsed === "object" &&
|
||||||
|
"error" in parsed &&
|
||||||
|
(parsed as { error?: unknown }).error
|
||||||
|
) {
|
||||||
|
errShape = (parsed as { error: ApiErrorShape }).error;
|
||||||
|
}
|
||||||
|
reject(new ApiError(xhr.status, errShape));
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.onerror = () => reject(new NetworkError(`Local upload to ${url} failed`));
|
||||||
|
xhr.ontimeout = () => reject(new TimeoutError(`Local upload to ${url} timed out`));
|
||||||
|
|
||||||
|
if (options.signal) {
|
||||||
|
if (options.signal.aborted) {
|
||||||
|
reject(new AbortError());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
options.signal.addEventListener(
|
||||||
|
"abort",
|
||||||
|
() => {
|
||||||
|
xhr.abort();
|
||||||
|
reject(new AbortError());
|
||||||
|
},
|
||||||
|
{ once: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
xhr.send(form);
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -11,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", () => {
|
||||||
|
|||||||
@ -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_BYTES(20MB)、**不加總**——
|
||||||
|
* 50 張各 19MB(合計 950MB)會通過前端驗證後撞後端 size 上限(LOCAL_UPLOAD_TOO_LARGE / 413)。
|
||||||
|
* 補此合計上限把地雷擋在前端,給使用者明確錯誤而非到後端才 413。
|
||||||
|
* 值 = 80MB(留 nginx 100M 的 20% 餘裕,對 localhost 直連仍保留為資源上界,見 ADR-019 §4.3.1)。
|
||||||
|
*/
|
||||||
|
export const MAX_BATCH_TOTAL_BYTES = 80 * 1024 * 1024; // 80 MB
|
||||||
|
|
||||||
export interface UploadMediaOptions {
|
export interface UploadMediaOptions {
|
||||||
/** 上傳進度 callback(0~100) */
|
/** 上傳進度 callback(0~100) */
|
||||||
onProgress?: (percent: number) => void;
|
onProgress?: (percent: number) => void;
|
||||||
@ -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_BYTES(ADR-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_SIZE(ADR-019 §2.2,補逐張檢查漏掉的加總地雷)
|
||||||
|
*/
|
||||||
export function validateBatchFiles(files: File[]): FileValidationError | null {
|
export function validateBatchFiles(files: File[]): FileValidationError | null {
|
||||||
if (files.length === 0) return { code: "EMPTY" };
|
if (files.length === 0) return { code: "EMPTY" };
|
||||||
if (files.length > MAX_BATCH_IMAGES) return { code: "COUNT" };
|
if (files.length > MAX_BATCH_IMAGES) return { code: "COUNT" };
|
||||||
|
let totalBytes = 0;
|
||||||
for (const f of files) {
|
for (const f of files) {
|
||||||
const err = validateImageFile(f);
|
const err = validateImageFile(f);
|
||||||
if (err) return err;
|
if (err) return err;
|
||||||
|
totalBytes += f.size;
|
||||||
}
|
}
|
||||||
|
if (totalBytes > MAX_BATCH_TOTAL_BYTES) return { code: "TOTAL_SIZE" };
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -41,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" // 401:token 不存在 / 過期 / 已使用 / 缺失
|
||||||
|
| "LOCAL_TOKEN_LIMIT" // 429:未使用 token 達 32 上限
|
||||||
|
| "LOCAL_UPLOAD_TOO_LARGE" // 413:上傳超過 size 上限
|
||||||
// 前端自建,表示「非後端回傳」的錯誤
|
// 前端自建,表示「非後端回傳」的錯誤
|
||||||
| "NETWORK_ERROR"
|
| "NETWORK_ERROR"
|
||||||
| "TIMEOUT"
|
| "TIMEOUT"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user