visionA/local-agent/server/internal/api/local_middleware.go
jim800121chen 9031153553 feat(adr-019): 影片/圖片/批次上傳走同機 localhost 直連 local-agent
實作 ADR-019 混合路徑:影片/圖片/批次的檔案上傳改由瀏覽器同機直連
local-agent localhost endpoint(繞過雲端 tunnel),控制面 + MJPEG 結果 +
推論 WS 仍走 tunnel。解決大檔頻寬雙倍 + nginx 100M + 300s timeout。

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 12:32:26 +08:00

98 lines
3.9 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package api
import (
"errors"
"log"
"net/http"
"time"
"github.com/gin-gonic/gin"
)
// ADR-019 §2.4.1 + §4.3.1:本機直連 upload route 的 token 驗證 + size 上限中介。
//
// size 上限M1各 route 自己的值。video 硬牆 ≤ 500MB前端正常上限 90MB
// 但 server 端硬牆設 500MB 作為 DoS 上界——攻擊面不因走 loopback 而縮小)。
// batch 合計 ≤ 80MB。image 沿用 batch 上界即可(單檔遠小於此)。
const (
maxVideoUploadBytes = 500 * 1024 * 1024 // 500MB
maxBatchUploadBytes = 80 * 1024 * 1024 // 80MB合計
maxImageUploadBytes = 80 * 1024 * 1024 // 80MB單檔寬鬆上界
)
// tokenConsumer 抽象 TokenStore.Consume方便測試注入。
type tokenConsumer interface {
Consume(token, deviceID string) error
}
// LocalUploadGuard 是本機直連 upload route 的中介,順序如下(安全關鍵):
//
// 1. 先要求 X-Visiona-Local-Token header——缺失即 401一律要 token、不看 OriginC1
// 2. 用 http.MaxBytesReader 把 request body 包上 maxBytes 硬牆——
// 在讀取 multipart body 之前就限制總位元組避免「未驗證就先收無上限大檔」M1
// 3. 解析出 deviceIdPostForm 觸發 multipart 解析,但已被 MaxBytesReader 上限保護)。
// 若超過上限 → ParseMultipartForm 回 *http.MaxBytesError → 413 LOCAL_UPLOAD_TOO_LARGE。
// 4. Consume(token, deviceId)——single-flight 持鎖(查存在+比對+刪除同一 Lock防 racem2
// deviceId 綁定不符 / 過期 / 已用 / 不存在 → 401 LOCAL_TOKEN_INVALID。
// 5. 通過 → c.Next() 進既有 handlerhandler 業務邏輯零改動、直接 FormFile 讀已快取的表單)。
//
// 稽核 logconsume 成功/失敗記 deviceId + 時間,絕不 log token 明文。
//
// 為什麼 deviceId 取自表單而非 tokentoken 在 issue 時已綁 deviceIdConsume 會用
// ConstantTimeCompare 驗證「表單 deviceId == token 綁定 deviceId」兩者不符即 401。
// 表單 deviceId 是既有 handler 本來就讀的欄位api-spec §6.2 body 格式不變)。
func LocalUploadGuard(store tokenConsumer, maxBytes int64) gin.HandlerFunc {
return func(c *gin.Context) {
token := c.GetHeader("X-Visiona-Local-Token")
if token == "" {
respondTokenInvalid(c)
return
}
// M1body 硬牆。放在解析 multipart 之前。
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxBytes)
// 觸發 multipart 解析取 deviceId。body 已被 MaxBytesReader 上限保護。
// 解析錯誤要區分「超過 size 上限413」與「其他 400」。
if err := c.Request.ParseMultipartForm(32 << 20); err != nil {
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"success": false, "error": gin.H{
"code": "LOCAL_UPLOAD_TOO_LARGE", "message": "upload exceeds size limit",
}})
c.Abort()
return
}
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": gin.H{
"code": "BAD_REQUEST", "message": "invalid multipart form",
}})
c.Abort()
return
}
deviceID := c.Request.FormValue("deviceId")
if err := store.Consume(token, deviceID); err != nil {
// 稽核consume 失敗deviceId + 時間,不含 token
log.Printf("[local-token] consume REJECTED deviceId=%s ts=%s",
deviceID, time.Now().UTC().Format(time.RFC3339))
respondTokenInvalid(c)
return
}
// 稽核consume 成功。
log.Printf("[local-token] consume OK deviceId=%s ts=%s",
deviceID, time.Now().UTC().Format(time.RFC3339))
c.Next()
}
}
// respondTokenInvalid 統一回 401 LOCAL_TOKEN_INVALID 並中止。
func respondTokenInvalid(c *gin.Context) {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": gin.H{
"code": "LOCAL_TOKEN_INVALID", "message": "missing or invalid upload token",
}})
c.Abort()
}