從 edge-ai-platform POC 轉為正式產品的雲端後端,含以下整合階段:
- Phase 0:雛形骨架 — `cmd/api-server` (REST :3721) + `cmd/remote-proxy`
(tunnel :3800 / internal :3801) 雙 binary 共用 internal/,沿用 POC 的
WebSocket+yamux tunnel 協定但解耦 relay 與 API
- Phase 0.6:OIDC BFF 接 Innovedus Member Center
- internal/oidc package(coreos/go-oidc + PKCE S256 + state + nonce)
- internal/usersession package(HMAC-SHA256 cookie + RotateSessionID
防 session fixation, OWASP ASVS V3.2.1)
- 4 個 OIDC handler(/api/auth/login|callback|me|logout)+ AuthMiddleware
- 完全拔除 StaticAuthProvider,OIDC 是唯一認證路徑
- 9 個 ADR(含 ADR-010 BFF / ADR-011 取代 static auth /
ADR-012 pending session shared cookie / ADR-013 PKCE-only public client)
- Phase 0.7:A1 改造 + security audit 修復
- OIDC ClientSecret 變選填,支援 stage MC 的 public PKCE-only client
(AuthStyleInParams 強制 token endpoint 不送 client_secret)
- 預留 ServiceClient* 欄位給未來 client_credentials grant
- 移除 13+ 處 resolveUserID(uc, StaticUserID) fallback 改 strict mode
(Audit C1:multi-tenant 隔離破口)
- Pairing exchange MarkUsed 失敗 abort + revoke session token(Audit M3)
- 新增 all_endpoints_require_auth_test 整合測試(51 endpoint × 401)
驗證:go test -race -count=3 ./... 17 packages 全綠 / go vet 0 warning
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
135 lines
4.5 KiB
Go
135 lines
4.5 KiB
Go
// 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)
|
||
}
|
||
}
|