visionA/visionA-backend/internal/api/local_upload_ticket.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

317 lines
15 KiB
Go
Raw 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.

// local_upload_ticket.go — POST /api/devices/:id/local-upload-ticket 的雲端 ticket handlerADR-019 WP-5
//
// 背景ADR-019 §2.4 認證流程 [1]):影片 / 圖片 / 批次上傳改走「同機瀏覽器直連 local-agent
// 的 localhost endpoint」繞過 tunnel解決大檔頻寬雙倍 + nginx 100M + 300s timeout。但開放
// 雲端 origin 直連 local-agent 後必須有認證作為第二道防線CORS 白名單擋不住 XSS / DNS
// rebinding。one-time token 的**取得路徑**刻意保留走既有已認證 tunnel控制面
//
// [1] 瀏覽器 → 雲端 api-serverPOST /api/devices/:id/local-upload-ticket本 handler
// api-server 驗 OIDC sessionAuthMiddleware+ 裝置歸屬GetBySerial
// → 經既有 tunnel 轉發打 local-agentPOST /api/local/issue-tokenbody {"serial":...}
// → 回傳 local-agent 產的 one-time token 給瀏覽器
// [2..4] 瀏覽器拿 token 掃 localhost port → 帶 X-Visiona-Local-Token 直連上傳(非本 handler 範圍)
//
// 為什麼走既有 tunnel 而非新機制token 取得屬控制面,資料量極小(~100 bytes沿用 devices.go
// 的裝置歸屬檢查 + proxy.go / device_driver_status.go 的 tunnel forward 模式即可,零新基礎設施。
//
// 契約來源api-spec.md §6.3POST /api/devices/:serial/local-upload-ticket + POST
// /api/local/issue-token 的 request / response 形狀。local-agent 端 /api/local/issue-token
// 由 local-agent stream 另行實作,本 handler **對契約**打即可path + body 依 api-spec §6.3)。
//
// 可測性:把「經 tunnel 打 local-agent issue-token」抽成 localTokenIssuer 介面default 實作
// 包 session.Forwarder走既有 proxy 基礎設施unit test 注入 stub 驗成功 / 各種錯誤分支,
// 不需要真 tunnel。此模式對齊 device_driver_status.go 的 driverStatusFetcher。
package api
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"visiona-backend/internal/device"
"visiona-backend/internal/session"
)
// issueTokenProxyTimeout 是「經 tunnel 打 local-agent issue-token」的整體 timeout。
//
// 刻意設短5sissue-token 是控制面小請求(產一個記憶體 token 立即回),不像 media 上傳可能
// 很久。不能讓 local-agent hang 住時把「取 ticket」拖到 defaultProxyRequestTimeout(300s) 那麼久
// ——前端還要拿這個 token 去掃 port整條互動應該是秒級。
const issueTokenProxyTimeout = 5 * time.Second
// localAgentIssueTokenPath 是 local-agent 上「產 one-time upload token」的 endpoint。
// 對齊 api-spec.md §6.3 POST /api/local/issue-token僅經既有 tunnel 由 api-server 轉發呼叫)。
const localAgentIssueTokenPath = "/api/local/issue-token"
// errCodeLocalTokenLimit 是 local-agent issue-token 回傳的「未使用 token 達上限」錯誤碼
// api-spec §6.3 LOCAL_TOKEN_LIMIT。api-server 據此把 IssueToken 錯誤映射成 429
// writeLocalTokenError。此碼是 local-agent 產的、非 api-server 對外碼,故不放進 errors.go
// 的雲端錯誤碼常數,只在本檔內部用於解析 local-agent 回應。
const errCodeLocalTokenLimit = "LOCAL_TOKEN_LIMIT"
// LocalUploadTicket 是 POST /api/devices/:id/local-upload-ticket 回應的 data 欄位。
//
// 直接對齊 api-spec.md §6.3`{ "token", "expiresAt", "ttlSeconds": 120 }`。
// api-server 透傳 local-agent issue-token 的產出,不改寫欄位語意。
type LocalUploadTicket struct {
// Token 是 local-agent 產的 one-time upload token瀏覽器帶 X-Visiona-Local-Token 直連上傳)。
Token string `json:"token"`
// ExpiresAt 是 token 過期時間unix milliseconds對齊 api-spec §6.3。
ExpiresAt int64 `json:"expiresAt"`
// TTLSeconds 是 token 存活秒數(契約固定 120由 local-agent 決定api-server 透傳)。
TTLSeconds int `json:"ttlSeconds"`
}
// localTokenIssuer 抽象「經 tunnel 向 local-agent 要一個 one-time upload token」。
//
// 回傳的 LocalUploadTicket 是 local-agent issue-token 的產出透傳。error 語意:
// - errLocalTokenLimitlocal-agent 回 429未使用 token 達 32 上限)→ caller 透傳 429。
// - session.ErrSessionNotFound / ErrSessionClosedtunnel 離線 → caller 回 502 TUNNEL_DISCONNECTED。
// - 其他local-agent 不可達 / 非預期回應 → caller 回 502 TUNNEL_ERROR。
//
// default 實作 forwarderLocalTokenIssuer 走既有 session.Forwarder proxy 基礎設施;
// unit test 注入 stub 驗各分支,不需要真 tunnel。
type localTokenIssuer interface {
// IssueToken 經 tunnel 打 local-agent POST /api/local/issue-tokenbody {"serial":serial})。
// userID 用來挑當前 user 的 active session token與其他 proxy 端點同一套 posture
IssueToken(ctx context.Context, userID, serial string) (LocalUploadTicket, error)
}
// errLocalTokenLimit 表示 local-agent 回 429未使用 token 達 32 上限api-spec §6.3
// LOCAL_TOKEN_LIMIT。與傳輸層錯誤tunnel 離線)語意區隔,讓 handler 能透傳 429 而非 502。
var errLocalTokenLimit = errors.New("local agent: unused upload token limit reached")
// issueTokenRequest 是打 local-agent /api/local/issue-token 的 request bodyapi-spec §6.3)。
type issueTokenRequest struct {
Serial string `json:"serial"`
}
// issueTokenEnvelope 是 local-agent /api/local/issue-token 的回應 envelopeapi-spec §6.3
//
// { "success": true, "data": { "token": "...", "expiresAt": <unix_ms>, "ttlSeconds": 120 } }
// { "success": false, "error": { "code": "LOCAL_TOKEN_LIMIT" } } 429
type issueTokenEnvelope struct {
Success bool `json:"success"`
Data LocalUploadTicket `json:"data"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
// forwarderLocalTokenIssuer 是 localTokenIssuer 的 production 實作:
// 透過 session.Forwarder 把 POST /api/local/issue-token 經 tunnel 送到 local-agent。
type forwarderLocalTokenIssuer struct {
forwarder *session.Forwarder
sessionStore session.Store
}
// newForwarderLocalTokenIssuer 從 Deps 組出 default issuer。
// forwarder / sessionStore 任一為 nil 時回 nilcaller 據此回 501代表 tunnel 未配置)。
func newForwarderLocalTokenIssuer(deps Deps) localTokenIssuer {
if deps.Forwarder == nil || deps.SessionStore == nil {
return nil
}
return &forwarderLocalTokenIssuer{
forwarder: deps.Forwarder,
sessionStore: deps.SessionStore,
}
}
// IssueToken 實作 localTokenIssuer。
//
// 流程(對齊 device_driver_status.go FetchDriverStatus但目標是 POST issue-token
// 1. 挑當前 user 的 active session tokenpickActiveSessionToken
// 2. 組 POST /api/local/issue-tokenbody {"serial":serial}),經 Forwarder.ForwardHTTP 送到 local-agent
// 3. 解 envelope429 → errLocalTokenLimitsuccess + 有 token → 回 ticket其他 → error
func (i *forwarderLocalTokenIssuer) IssueToken(ctx context.Context, userID, serial string) (LocalUploadTicket, error) {
ctx, cancel := context.WithTimeout(ctx, issueTokenProxyTimeout)
defer cancel()
token, err := pickActiveSessionToken(ctx, i.sessionStore, userID, nil)
if err != nil {
// tunnel 離線 / 無 active session → 交由 caller 映射 502 TUNNEL_DISCONNECTED。
return LocalUploadTicket{}, err
}
body, err := json.Marshal(issueTokenRequest{Serial: serial})
if err != nil {
return LocalUploadTicket{}, err
}
outReq, err := http.NewRequestWithContext(ctx, http.MethodPost, localAgentIssueTokenPath,
strings.NewReader(string(body)))
if err != nil {
return LocalUploadTicket{}, err
}
outReq.Header.Set("Content-Type", "application/json")
outReq.ContentLength = int64(len(body))
resp, err := i.forwarder.ForwardHTTP(ctx, token, outReq)
if err != nil {
// local-agent 不可達 / dial 失敗 / timeout → caller 映射 502 TUNNEL_ERROR。
return LocalUploadTicket{}, err
}
defer resp.Body.Close()
// 限讀 bodyissue-token 回應極小;防禦性 64KB 上界,避免異常 local-agent 撐爆記憶體)。
raw, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
if err != nil {
return LocalUploadTicket{}, err
}
// 429token 上限已滿 → 透傳 errLocalTokenLimit不試著解析成功欄位
if resp.StatusCode == http.StatusTooManyRequests {
return LocalUploadTicket{}, errLocalTokenLimit
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return LocalUploadTicket{}, errLocalTokenUnavailable
}
var env issueTokenEnvelope
if err := json.Unmarshal(raw, &env); err != nil {
return LocalUploadTicket{}, err
}
// 契約允許 local-agent 在 200 body 內用 success:false 表達 LOCAL_TOKEN_LIMIT雖然主契約走
// 429但兩者都映射到 token 上限,防禦性一併處理)。
if !env.Success {
if env.Error != nil && env.Error.Code == errCodeLocalTokenLimit {
return LocalUploadTicket{}, errLocalTokenLimit
}
return LocalUploadTicket{}, errLocalTokenUnavailable
}
if env.Data.Token == "" {
return LocalUploadTicket{}, errLocalTokenUnavailable
}
return env.Data, nil
}
// errLocalTokenUnavailable 表示 local-agent 回應存在但沒帶可用 token非 2xx 且非 429 /
// success:false / 空 token。與 tunnel 傳輸層錯誤語意區隔caller 統一映射 502 TUNNEL_ERROR。
var errLocalTokenUnavailable = errors.New("local agent: upload token unavailable")
// resolveLocalTokenIssuer 決定要用哪個 issuer
// - Deps.LocalTokenIssuer 非 nil測試注入 stub→ 用它
// - 否則從 Forwarder + SessionStore 組 defaultproduction
// - 兩者皆缺 → 回 nilhandler 回 501代表 tunnel 未配置)
func resolveLocalTokenIssuer(deps Deps) localTokenIssuer {
if deps.LocalTokenIssuer != nil {
return deps.LocalTokenIssuer
}
return newForwarderLocalTokenIssuer(deps)
}
// localUploadTicketHandler 實作 POST /api/devices/:id/local-upload-ticketADR-019 WP-5
//
// 註route 參數名為 `:id`gin/httprouter 要求同層級同名devices.go 既有 /devices/:id/*
// 已佔用 :id但語意上是**裝置序號serial**——契約 path 為 /api/devices/:serial/...。
// 這裡的 :id 值即 serial用它走 GetBySerial 做裝置歸屬檢查 + 傳給 local-agent。
//
// 流程:
// 1. AuthMiddleware 已驗 OIDC session → 取 UserContext拿不到 = 500middleware 設定錯誤)
// 2. 裝置歸屬DeviceRepo.GetBySerial(userID, serial)——查不到 = 該序號不屬於當前 user → 404
// (沿用 devices.go 既有 owner 檢查慣例GetBySerial 本身就綁 ownerUserID天然阻擋 IDOR
// 3. tunnel_online 檢查R-3無 active session → tunnel 離線 → 502 TUNNEL_DISCONNECTED
// 明確告知前端「裝置離線、無法取得上傳 ticket」issue token 本就需經 tunnel
// 4. 經 tunnel 打 local-agent issue-token → 透傳 token429 透傳tunnel 錯誤映射 502
func localUploadTicketHandler(deps Deps) gin.HandlerFunc {
return func(c *gin.Context) {
if deps.DeviceRepo == nil {
WriteNotImplemented(c, "device repo not configured")
return
}
serial := c.Param("id") // :id 語意為 serial見 handler 註解
if serial == "" {
WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "device serial required", nil)
return
}
// AuthMiddleware 已驗 OIDC session見 api.go apiGroup拿不到 UserContext 代表
// middleware 設定錯誤,回 500 比 silent fallback 安全(對齊 devices.go C1 fix
uc, ok := UserContextFrom(c)
if !ok || uc.UserID == "" {
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
"missing user context (auth middleware misconfigured?)", nil)
return
}
userID := uc.UserID
ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
defer cancel()
// 裝置歸屬檢查(沿用 devices.go 慣例GetBySerial 綁 ownerUserID查不到即
// 「該序號不屬於當前 user」或「不存在」一律回 404不洩漏「存在但非你的」以免 enumeration
d, err := deps.DeviceRepo.GetBySerial(ctx, userID, serial)
if err != nil {
if errors.Is(err, device.ErrNotFound) {
WriteError(c, http.StatusNotFound, ErrCodeNotFound,
"device not found or not owned by current user", nil)
return
}
// DB 錯誤經 errors.go 映射PG down → 503、其餘 → 500不洩漏 raw DB error。
WriteDBError(c, deps.Logger, "get device by serial", err)
return
}
issuer := resolveLocalTokenIssuer(deps)
if issuer == nil {
// Forwarder / SessionStore 未配置 → 無法經 tunnel 取 token。回 501非 500
// 語意為「此部署未啟用 tunnel forward」對齊 proxy.go 的 WriteNotImplemented 慣例。
WriteNotImplemented(c, "tunnel forwarder not configured")
return
}
// 經 tunnel 打 local-agent issue-tokenissuer 內部用 d.SerialNumber 走 tunnel
// 用 DB 記錄的 SerialNumber已通過歸屬檢查而非原始 path 值,確保傳給 local-agent 的
// 序號與雲端 device 記錄一致。
ticket, err := issuer.IssueToken(c.Request.Context(), userID, d.SerialNumber)
if err != nil {
writeLocalTokenError(c, deps, userID, d.SerialNumber, err)
return
}
logOrDefault(deps.Logger).Info("local-upload-ticket: issued",
"user_id", userID,
"serial", d.SerialNumber,
"device_id", d.ID,
"ttl_seconds", ticket.TTLSeconds,
"request_id", RequestIDFrom(c))
WriteSuccess(c, http.StatusOK, ticket)
}
}
// writeLocalTokenError 把 IssueToken 的 error 映射到統一 API 錯誤格式。
//
// - errLocalTokenLimit → 429 RATE_LIMITED透傳 local-agent 的 token 上限api-spec §6.3
// LOCAL_TOKEN_LIMIT 對應 429這裡用雲端統一的 RATE_LIMITED 碼 + message 標明來源)
// - session.ErrSessionNotFound / ErrSessionClosed → 502 TUNNEL_DISCONNECTED裝置離線R-3
// - 其他 → 502 TUNNEL_ERRORlocal-agent 不可達 / 非預期回應)
func writeLocalTokenError(c *gin.Context, deps Deps, userID, serial string, err error) {
switch {
case errors.Is(err, errLocalTokenLimit):
logOrDefault(deps.Logger).Warn("local-upload-ticket: local agent token limit reached",
"user_id", userID, "serial", serial, "request_id", RequestIDFrom(c))
WriteError(c, http.StatusTooManyRequests, ErrCodeRateLimited,
"上傳 token 已達上限,請稍後再試", nil)
case errors.Is(err, session.ErrSessionNotFound) || errors.Is(err, session.ErrSessionClosed):
// R-3tunnel 離線時無法取得 token → 明確告知裝置離線(前端據此 disable 上傳)。
WriteError(c, http.StatusBadGateway, ErrCodeTunnelDisconnect,
"裝置未連線,無法取得上傳 ticket", nil)
default:
logOrDefault(deps.Logger).Warn("local-upload-ticket: issue token failed",
"user_id", userID, "serial", serial, "error", err.Error(),
"request_id", RequestIDFrom(c))
WriteError(c, http.StatusBadGateway, ErrCodeTunnelError,
"取得上傳 ticket 失敗", nil)
}
}