// 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": , "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) } }