實作 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>
294 lines
14 KiB
Go
294 lines
14 KiB
Go
// Package api 實作 api-server 的 REST + WebSocket 入口。
|
||
//
|
||
// 對齊 `.autoflow/04-architecture/api/api-spec.md`。
|
||
//
|
||
// **B4 範圍**:Router / Middleware / 結構化錯誤回應骨架 + 少數 handler(/healthz、
|
||
// /api/system/health、/api/system/info、/api/pairing/token、/api/pairing/status)。
|
||
//
|
||
// **B5 範圍**(本檔):
|
||
// - Auth:login / logout / me(stub),register → 501
|
||
// - Pairing:list tokens / revoke token
|
||
// - Devices:list / get(讀雲端 repo + 合併 tunnel 狀態),scan / connect /
|
||
// disconnect / flash / inference.start/stop 走 proxy,unpair 軟刪
|
||
// - Models:list / get / init upload / finalize / delete
|
||
// - System:/system/deps(走 proxy)
|
||
// - Clusters:GET /clusters 回空陣列;其他 stub
|
||
// - Storage:/storage/* 的 LocalFS 假 presigned URL 代理(GET/PUT)
|
||
// - WebSocket:保留 501 stub(詳見 stubs.go;B7 TODO)
|
||
package api
|
||
|
||
import (
|
||
"log/slog"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
|
||
"visiona-backend/internal/auth"
|
||
"visiona-backend/internal/conversion"
|
||
"visiona-backend/internal/converter"
|
||
"visiona-backend/internal/device"
|
||
"visiona-backend/internal/fileaccess"
|
||
"visiona-backend/internal/model"
|
||
"visiona-backend/internal/oidc"
|
||
"visiona-backend/internal/session"
|
||
"visiona-backend/internal/storage"
|
||
"visiona-backend/internal/user"
|
||
"visiona-backend/internal/usersession"
|
||
)
|
||
|
||
// Deps 匯整 api router 所需的所有依賴;由 cmd/api-server/main.go 在啟動時注入。
|
||
//
|
||
// 之所以集中在一個 struct,是為了:
|
||
// 1. 讓 NewRouter 簽章穩定(之後加新依賴只改 struct,不破壞既有 caller)
|
||
// 2. integration test 容易組裝(只需建一個 Deps 物件)
|
||
// 3. 各 handler 透過 closure 取得依賴,不需要 global state
|
||
//
|
||
// OB5(2026-04-26)起 OIDC 是唯一認證路徑:OIDCProvider + SessionManager 必填,
|
||
// validate() 在 NewRouter 啟動時就會 panic 提前暴露 misconfiguration。
|
||
type Deps struct {
|
||
Logger *slog.Logger
|
||
|
||
// PairingStore 管理 Pairing Token 生命週期。為 nil 時 /api/pairing/* 會回 501。
|
||
PairingStore auth.PairingStore
|
||
|
||
// ─── OIDC(OB5 起為必填) ───
|
||
|
||
// OIDCProvider 封裝 OIDC client(authorization URL 組裝、token exchange、id_token 驗證)。
|
||
OIDCProvider oidc.Provider
|
||
|
||
// SessionManager 管理 cookie session(StartSession / GetSession / EndSession)。
|
||
SessionManager *usersession.Manager
|
||
|
||
// OIDCPostLoginURL 是 callback 完成後 302 回 frontend 的 base URL。
|
||
// 例:http://localhost:3000(dev)/ https://app.visiona.cloud(prod)。
|
||
// 為空字串時 callback handler 會 fallback 到 same-origin "/"(不建議生產配置)。
|
||
OIDCPostLoginURL string
|
||
|
||
// OIDCLogoutURL 是「讓使用者連帶登出 IdP(Member Center)session」的入口 URL。
|
||
//
|
||
// 背景:MC 不支援標準 OIDC RP-initiated logout,唯一瀏覽器可觸發的登出是
|
||
// MemberCenter.Web(:7880)的 /account/logout(GET 即 302)。詳見 config.OIDCConfig.LogoutURL。
|
||
//
|
||
// 非空時,logout handler(oidc_auth.go)會在 LogoutResponse 多回 idp_logout 欄位,
|
||
// 由前端 navigate 觸發 MC 登出。為空時不回該欄位、維持「只清本地 session」舊行為。
|
||
// 對齊 cfg.OIDC.LogoutURL(env VISIONA_OIDC_LOGOUT_URL)。
|
||
OIDCLogoutURL string
|
||
|
||
// UserStore 在 OIDC callback 驗 id_token 成功後 provision(upsert)一筆 users 列
|
||
// (DB-on FK 收尾,問題 #1)。D1-B:OIDC sub 直接當 users.id(Member Center sub 為 UUID)。
|
||
//
|
||
// 為何必須:DB-on(有 FK)下,真人登入後任何帶 owner_user_id FK 的寫入(上傳 model、配對、
|
||
// 發 pairing token)都需要 users 表已有對應列;不 upsert → FK violation。
|
||
//
|
||
// 為 nil 時 callback 略過 upsert(最小骨架 / 純 OIDC unit test 不強制注入);
|
||
// main.go 依 dbPool 注入 PostgresStore 或 InMemoryStore,兩模式都會 upsert 對齊行為。
|
||
UserStore user.Store
|
||
|
||
SessionStore session.Store
|
||
Forwarder *session.Forwarder
|
||
|
||
// DriverStatusFetcher 是 GET /api/devices/:id「合併 local agent 即時 driver status」
|
||
// (方案 Y-2)的可選注入點。為 nil 時 handler 從 Forwarder + SessionStore 組 default
|
||
//(forwarderDriverStatusFetcher,走既有 tunnel proxy)。unit test 注入 stub 驗合併 /
|
||
// fallback,不需真 tunnel。詳見 device_driver_status.go。
|
||
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
|
||
ModelRepo model.Repository
|
||
|
||
// DeviceUnpairer 將「軟刪 device + cascade 撤銷其 pairing/session token」包成單一原子
|
||
// (Postgres tx)或一致(in-memory 依序)操作(DB 接入塊 5.2,database.md §6)。
|
||
// 為 nil 時 unpair handler fallback 到「只軟刪 device、不 cascade」的舊行為
|
||
//(見 devices.go),確保最小骨架仍可啟動。main.go 依 dbPool 是否非 nil 擇一注入。
|
||
DeviceUnpairer DeviceUnpairer
|
||
|
||
Storage storage.Store
|
||
Converter converter.Client
|
||
|
||
// PresetModelsDir 是 7 個系統預設模型 .nef 檔(B8)的本地目錄(打包進 image)。
|
||
// 非空時 NewRouter 註冊 GET /preset-models/*filepath(不簽 token、不走 auth),
|
||
// 直接從此目錄串流檔案。為空時不註冊該路由,preset download 會回 501。
|
||
PresetModelsDir string
|
||
|
||
// PresetBaseURL 是 preset download_url 的前綴(對外可直接 GET 的 base)。
|
||
// 例 http://localhost:3721(api-server origin);download handler 組
|
||
// `{PresetBaseURL}/preset-models/{id}.nef` 回給前端導航下載。為空時 preset download 回 501。
|
||
PresetBaseURL string
|
||
|
||
// Conversion 是 Phase 0.8 轉檔功能的 Service interface(5 個 endpoint 共用)。
|
||
// 為 nil 時 /api/conversion/* 5 個 endpoint 全回 501 NOT_IMPLEMENTED
|
||
// (main.go 在 cfg.Conversion.Enabled() 為 false 時不 wire),對齊 api-conversion.md。
|
||
//
|
||
// 設計選擇:用 conversion.Service interface 而非 concrete type — 方便 unit test 注入 stub。
|
||
Conversion conversion.Service
|
||
|
||
// FileAccessIssuer 是 Phase 0.9「模型庫 model 直連 FAA 下載」的 download token 簽發者
|
||
// (ADR-017 (a))。為 nil 時 GET /api/models/:id/download 回 501 NOT_IMPLEMENTED
|
||
// (main.go 在 cfg.FileAccess.Enabled() 為 false 時不 wire)。
|
||
// 用 interface 方便 unit test 注入 fake。
|
||
FileAccessIssuer fileaccess.DownloadTokenIssuer
|
||
|
||
// FAABaseURL 是 File Access Agent 對外 base URL(不帶結尾斜線),用來組回給 Client
|
||
// 的 download_url(`{FAABaseURL}/files/{object_key}`)。
|
||
// 由 cfg.FileAccess.FAABaseURL 注入;FileAccessIssuer 非 nil 時必非空(main.go 確保)。
|
||
FAABaseURL string
|
||
|
||
// CORSAllowedOrigins 是允許的瀏覽器 Origin 白名單;空 slice 預設放行
|
||
// http://localhost:3000(前端 dev server)。
|
||
CORSAllowedOrigins []string
|
||
|
||
// Phase 0.7 security fix C1 (見 .autoflow/05-implementation/review/phase-0.7-security-audit.md)
|
||
// StaticUserID 欄位已移除:multi-tenant 環境下 fallback 到固定 user 是 latent multi-user
|
||
// 隔離破口(OWASP A01 + A04)。改 OIDC 後 AuthMiddleware 會擋下未登入請求,
|
||
// handler 拿不到 UserContext 一律 500(safer than silent fallback)。
|
||
// dev seed / unit test 仍可獨立讀 cfg.Auth.StaticUserID env,不再注入 Deps。
|
||
|
||
// MaxUploadSizeMB 是模型上傳大小上限(MB);0 代表不限(測試友善)。
|
||
// 對齊 feature-model-management.md:Phase 0 預設 100 MB(由 config.Model.MaxSizeMB 注入)。
|
||
MaxUploadSizeMB int
|
||
|
||
// SessionTokenStore 保存 Pairing → Session 交換後發出的 Session Token。
|
||
// Phase 0 雛形用 in-memory 實作(由 main.go 注入);Phase 1 改為 DB-backed。
|
||
// 為 nil 時 /api/pairing/exchange 會回 501 NOT_IMPLEMENTED。
|
||
SessionTokenStore auth.SessionTokenStore
|
||
|
||
// RelayPublicURL 是 agent 連 tunnel 用的 WSS URL(對外可訪問)。
|
||
// 由 `POST /api/pairing/exchange` 回給 agent;若為空會回預設 `wss://relay.visionA.cloud`(雛形 placeholder)。
|
||
// 對齊 build-deploy.md 的 VISIONA_RELAY_PUBLIC_URL 環境變數。
|
||
RelayPublicURL string
|
||
|
||
// PairingExchanger 在 pairing exchange 時自建一筆 device 並建綁該 device 的 session token
|
||
// (DB-on FK 收尾,問題 #2:session_tokens.device_id NOT NULL FK,但雛形流程從不建 device)。
|
||
// - Postgres:用 db.WithTx 把「建 device + 建 session token」包成單一交易,整筆原子。
|
||
// - in-memory:依序執行(無交易),行為一致。
|
||
// 為 nil 時 exchange handler fallback 到舊行為(直接用 info.DeviceID 建 session token,
|
||
// 不自建 device)——僅 DB-off 雛形相容用。main.go 依 dbPool 擇一注入。
|
||
PairingExchanger PairingExchanger
|
||
|
||
// HealthDBPool / HealthRedis 是 /healthz 要 ping 的依賴(DB 接入塊 5.4)。
|
||
// 由 main.go 注入 db.Pool / db.RedisClient(皆有 Ping(ctx))。為 nil 代表該依賴未啟用、
|
||
// /healthz 略過不檢查(in-memory 模式維持「process 活著就 ok」)。
|
||
// 任一非 nil 依賴 ping 失敗 → /healthz 回 503,讓 load balancer 拉出此實例。
|
||
HealthDBPool HealthPinger
|
||
HealthRedis HealthPinger
|
||
}
|
||
|
||
// validate 確認必要欄位都有;在 NewRouter 啟動時呼叫,避免 nil pointer panic 推到 runtime。
|
||
//
|
||
// 嚴格欄位(缺則 panic — fail fast,避免半設定狀態跑進生產):
|
||
// - OIDCProvider — OB5 起 OIDC 是唯一認證路徑
|
||
// - SessionManager — OIDC cookie session 必須
|
||
//
|
||
// 寬鬆欄位(缺有預設):Logger / CORSAllowedOrigins
|
||
//
|
||
// 其他欄位(PairingStore / SessionStore 等)若為 nil 不擋 — 個別 handler 會回 501,
|
||
// 允許「最小骨架」啟動跑 /healthz。
|
||
//
|
||
// Phase 0.7 security fix C1:移除 StaticUserID 預設 "demo-user" 的 fallback。
|
||
func (d *Deps) validate() {
|
||
if d.Logger == nil {
|
||
d.Logger = slog.Default()
|
||
}
|
||
if len(d.CORSAllowedOrigins) == 0 {
|
||
d.CORSAllowedOrigins = []string{"http://localhost:3000"}
|
||
}
|
||
if d.OIDCProvider == nil {
|
||
panic("api.NewRouter: Deps.OIDCProvider is required (OB5: OIDC is the only auth path)")
|
||
}
|
||
if d.SessionManager == nil {
|
||
panic("api.NewRouter: Deps.SessionManager is required (OB5: OIDC cookie session is mandatory)")
|
||
}
|
||
}
|
||
|
||
// NewRouter 建立 Gin engine 並註冊所有路由與中介層。
|
||
//
|
||
// 為何回 *gin.Engine 而非 http.Handler:cmd/api-server/main.go 需要 access
|
||
// engine.Run(也可拿 .Handler() 給標準 http.Server 用,所以這個選擇沒讓
|
||
// caller 失去彈性)。
|
||
func NewRouter(deps Deps) *gin.Engine {
|
||
deps.validate()
|
||
|
||
// gin 的 ReleaseMode 由 caller 視環境設定(cmd/api-server/main.go);
|
||
// 這裡不主動設,避免測試環境被汙染。
|
||
r := gin.New()
|
||
|
||
// 註冊全域 middleware(順序很重要:Recovery 第一,Logger 接著,CORS 之後)
|
||
r.Use(RecoveryMiddleware(deps.Logger))
|
||
r.Use(RequestIDMiddleware())
|
||
r.Use(LoggerMiddleware(deps.Logger))
|
||
r.Use(CORSMiddleware(deps.CORSAllowedOrigins))
|
||
r.Use(ErrorMiddleware()) // 統一把 c.Errors 轉成 JSON
|
||
|
||
// /healthz 不需要 auth — K8s liveness/readiness 用。
|
||
// 塊 5.4:啟用的依賴(Postgres / Redis)都 ping,任一失敗回 503(fail-fast)。
|
||
r.GET("/healthz", HealthzHandler(HealthDeps{
|
||
DBPool: deps.HealthDBPool,
|
||
Redis: deps.HealthRedis,
|
||
Logger: deps.Logger,
|
||
}))
|
||
|
||
// /storage/* 不走 AuthMiddleware(改用 HMAC 簽章)— 對齊 api-spec.md §10
|
||
registerStorageRoutes(r, deps)
|
||
|
||
// /preset-models/* 不走 AuthMiddleware、不簽 token(B8 系統預設模型公用、本不需授權)。
|
||
// 刻意用獨立 path 前綴而非掛在 /storage/* 底下:gin(httprouter) 不允許同層級兩個
|
||
// wildcard(/storage/*filepath 與 /storage/preset/*filepath 會 panic)。
|
||
registerPresetModelRoutes(r, deps)
|
||
|
||
// /api/pairing/exchange 刻意不走 AuthMiddleware:
|
||
// agent 尚未有 session token 時就得用 Pairing Token 換 Session Token,
|
||
// Pairing Token 本身就是這個 endpoint 的憑證。詳見 security.md §1.2。
|
||
registerPairingPublicRoutes(r, deps)
|
||
|
||
// /ws/* 雛形大多仍 501;已實作的 WS tunnel proxy(/ws/devices/:id/inference
|
||
// 與 /ws/devices/:id/flash-progress)改掛在下方 wsAuthGroup(AuthMiddleware group)。
|
||
registerWebSocketStubs(r)
|
||
|
||
// WS tunnel proxy group:走 same-origin cookie AuthMiddleware(security 定案,
|
||
// 不放 token 到 URL)。目前有兩條裝置級 WS:推論結果(/ws/devices/:id/inference)
|
||
// 與 flash 進度回顯(/ws/devices/:id/flash-progress),兩者都走透明 tunnel proxy。
|
||
// 刻意獨立成 group 而非掛 /api:WS endpoint 對外路徑就是 /ws/*(對齊前端與
|
||
// api-spec);但認證邏輯與 /api 共用 AuthMiddleware。
|
||
wsAuthGroup := r.Group("/ws")
|
||
wsAuthGroup.Use(AuthMiddleware(deps))
|
||
registerWebSocketRoutes(wsAuthGroup, deps)
|
||
|
||
// OIDC public routes(不走 AuthMiddleware):
|
||
// - GET /api/auth/login — 起始登入流程(user 還沒登入)
|
||
// - GET /api/auth/callback — OIDC IdP 302 回來
|
||
// 必須註冊在 AuthMiddleware 群組之外,否則使用者沒登入根本進不去。
|
||
registerOIDCPublicRoutes(r, deps)
|
||
|
||
// /api 群組:所有路由都走 OIDC AuthMiddleware(cookie session → UserContext)
|
||
apiGroup := r.Group("/api")
|
||
apiGroup.Use(AuthMiddleware(deps))
|
||
|
||
// B4 核心
|
||
registerSystemRoutes(apiGroup, deps)
|
||
registerPairingRoutes(apiGroup, deps)
|
||
|
||
// B5 新增:實際 handler
|
||
registerAuthRoutes(apiGroup, deps)
|
||
registerDeviceRoutes(apiGroup, deps)
|
||
registerModelRoutes(apiGroup, deps)
|
||
registerClusterRoutes(apiGroup, deps)
|
||
|
||
// Camera / Media 推論(走 tunnel proxy)— /api/camera/* + /api/media/*
|
||
// 對齊 .autoflow/04-architecture/camera-e2e-effort-estimate.md
|
||
registerCameraRoutes(apiGroup, deps)
|
||
|
||
// Phase 0.8:Conversion(轉檔)— 5 個 endpoint
|
||
// 對齊 .autoflow/04-architecture/api/api-conversion.md
|
||
registerConversionRoutes(apiGroup, deps)
|
||
|
||
// Stubs(只註冊「還沒有實際 handler」的那些 endpoint)
|
||
registerStubRoutes(apiGroup, deps)
|
||
|
||
return r
|
||
}
|