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、不看 Origin,C1)。 // 2. 用 http.MaxBytesReader 把 request body 包上 maxBytes 硬牆—— // 在讀取 multipart body 之前就限制總位元組,避免「未驗證就先收無上限大檔」(M1)。 // 3. 解析出 deviceId(PostForm 觸發 multipart 解析,但已被 MaxBytesReader 上限保護)。 // 若超過上限 → ParseMultipartForm 回 *http.MaxBytesError → 413 LOCAL_UPLOAD_TOO_LARGE。 // 4. Consume(token, deviceId)——single-flight 持鎖(查存在+比對+刪除同一 Lock,防 race,m2); // deviceId 綁定不符 / 過期 / 已用 / 不存在 → 401 LOCAL_TOKEN_INVALID。 // 5. 通過 → c.Next() 進既有 handler(handler 業務邏輯零改動、直接 FormFile 讀已快取的表單)。 // // 稽核 log:consume 成功/失敗記 deviceId + 時間,絕不 log token 明文。 // // 為什麼 deviceId 取自表單而非 token:token 在 issue 時已綁 deviceId,Consume 會用 // 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 } // M1:body 硬牆。放在解析 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() }