// storage.go — /storage/* 的假 presigned URL 代理(雛形 LocalFS 用)。 // // 流程: // - 前端拿到 /api/models/init 回來的 upload_url(例:http://localhost:3721/storage/models/xxx.nef?expires=...&signature=...) // - 直接對該 URL 發 PUT(body = 檔案內容) // - 此 handler 驗簽 → 呼叫 Storage.Put 寫入 LocalFS // // GET 路徑對稱:驗簽 → Storage.Get → 串流回瀏覽器 // // Phase 1 切換成 S3 後,整個 /storage/* handler 就可刪除 // (前端直接 PUT 到 S3 presigned URL,不經過 api-server)。 package api import ( "errors" "io" "net/http" "strconv" "strings" "github.com/gin-gonic/gin" "visiona-backend/internal/storage" ) // registerStorageRoutes 註冊 /storage/* proxy。**不在 /api/ 底下**,對齊 api-spec.md §10。 // // 由 NewRouter 呼叫(不透過 AuthMiddleware — 因為已經用 HMAC 簽章控管存取)。 func registerStorageRoutes(r *gin.Engine, deps Deps) { if deps.Storage == nil { // 沒 storage 時就不註冊這條路由 return } r.GET("/storage/*filepath", storageGetHandler(deps)) r.PUT("/storage/*filepath", storagePutHandler(deps)) } // verifyStorageSignature 從 query 抽 expires / signature 並呼叫 LocalFSStore.VerifySignature。 // // Storage interface 本身沒有 VerifySignature 方法(那是 LocalFS 專用), // 所以這裡用 type assertion 抓到 *LocalFSStore 再驗。 // Phase 1 S3 的 presigned URL 驗證由 S3 自己處理 — api-server 不會收到這些請求。 func verifyStorageSignature(c *gin.Context, deps Deps, method, key string) error { ls, ok := deps.Storage.(*storage.LocalFSStore) if !ok { return storage.ErrInvalidSignature // 非 LocalFS 不應走這條 endpoint } expiresStr := c.Query("expires") sig := c.Query("signature") if expiresStr == "" || sig == "" { return storage.ErrInvalidSignature } expires, err := strconv.ParseInt(expiresStr, 10, 64) if err != nil { return storage.ErrInvalidSignature } return ls.VerifySignature(method, key, expires, sig) } // storageKeyFromPath 把 /storage/*filepath 的 filepath 截出來(gin 會帶前導 "/")。 func storageKeyFromPath(p string) string { return strings.TrimPrefix(p, "/") } // storageGetHandler 實作 GET /storage/*filepath。 // // 驗簽 → Stat 取 size / mtime → Get 串流。 // 對 streaming-friendly:用 io.Copy 直接寫入 ResponseWriter。 func storageGetHandler(deps Deps) gin.HandlerFunc { return func(c *gin.Context) { key := storageKeyFromPath(c.Param("filepath")) if key == "" { WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "empty key", nil) return } if err := verifyStorageSignature(c, deps, "GET", key); err != nil { WriteError(c, http.StatusForbidden, ErrCodeInvalidSignature, "invalid or expired signature", nil) return } reader, obj, err := deps.Storage.Get(c.Request.Context(), key) if err != nil { if errors.Is(err, storage.ErrNotFound) { WriteError(c, http.StatusNotFound, ErrCodeNotFound, "object not found", nil) return } WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, "get storage failed: "+err.Error(), nil) return } defer reader.Close() c.Writer.Header().Set("Content-Type", obj.ContentType) c.Writer.Header().Set("Content-Length", strconv.FormatInt(obj.Size, 10)) c.Writer.WriteHeader(http.StatusOK) _, _ = io.Copy(c.Writer, reader) } } // storagePutHandler 實作 PUT /storage/*filepath。 // // 驗簽 → 讀 body → Storage.Put。 // // 請求大小限制:雛形不在此強制(前端已經在 /api/models/init 被擋過 MaxUploadSizeMB); // 若要守第二道,可在此檢查 c.Request.ContentLength。 func storagePutHandler(deps Deps) gin.HandlerFunc { return func(c *gin.Context) { key := storageKeyFromPath(c.Param("filepath")) if key == "" { WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "empty key", nil) return } if err := verifyStorageSignature(c, deps, "PUT", key); err != nil { WriteError(c, http.StatusForbidden, ErrCodeInvalidSignature, "invalid or expired signature", nil) return } // 寫入 storage if err := deps.Storage.Put(c.Request.Context(), key, c.Request.Body, c.Request.ContentLength, nil); err != nil { WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, "put storage failed: "+err.Error(), nil) return } logOrDefault(deps.Logger).Info("storage: put", "key", key, "size", c.Request.ContentLength, "request_id", RequestIDFrom(c)) c.Status(http.StatusNoContent) } }