實作 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>
317 lines
15 KiB
Go
317 lines
15 KiB
Go
// 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)
|
||
}
|
||
}
|