B 設備管理(feature-device-mgmt-tdd): - POST /api/devices/:id/register + /unregister(owner 檢查 + representative 擋 + 已註冊擋 + SetRegistered 單欄翻轉,不碰 unpair 軟刪) - error codes ALREADY_REGISTERED / REPRESENTATIVE_DEVICE(409) - 不需 migration(registered_at 欄/index/讀寫已在 0005) C 模型共享(feature-model-sharing-tdd,security 深審 APPROVE): - migration 0006:models.visibility enum DEFAULT 'private'(零行為改變)+ model_shares 表 - canAccessModel single source(owner ∪ share ∪ public ∪ tenant):profile + download 共用 - GET /library(cursor keyset)/ GET /:id/profile(404 防列舉、GetWithOwner join name 不洩 email) / PATCH /:id/visibility(owner-only)/ shares CRUD / download 放寬 - tenant 因 OIDC 無 org claim 留 stub(恆空、安全預設;補 org claim 需重送 security 深審) reviewer 通過(B 三條紅線 / C security APPROVE 無 C/M)。130 dbtest 全綠、gosec 新檔 0。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
635 lines
24 KiB
Go
635 lines
24 KiB
Go
// models.go — /api/models/* 的 handler 實作。
|
||
//
|
||
// 雛形重點:
|
||
// - GET /api/models:列當前 user 的模型(ModelRepo in-memory)
|
||
// - GET /api/models/:id:取單一模型 metadata
|
||
// - POST /api/models/init:兩階段上傳第一步 — 驗證輸入、產 storageKey 與 presigned PUT URL
|
||
// - POST /api/models/:id/finalize:第二步 — 驗證檔案已存在(storage.Exists)與大小,標為 ready
|
||
// - DELETE /api/models/:id:軟刪
|
||
//
|
||
// **兩階段上傳(Init → PUT → Finalize)的設計理由**:
|
||
// - 讓前端直接 PUT 到 storage,不佔 api-server 記憶體 / bandwidth
|
||
// - Phase 0 LocalFS 用假 presigned URL;Phase 1 S3 用原生 presigned URL
|
||
//
|
||
// 對齊 api-spec.md §4、feature-model-management.md。
|
||
|
||
package api
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"net/http"
|
||
"net/url"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"github.com/google/uuid"
|
||
|
||
"visiona-backend/internal/model"
|
||
"visiona-backend/internal/storage"
|
||
)
|
||
|
||
// modelUploadURLTTL 是 presigned PUT URL 的存活時間。
|
||
const modelUploadURLTTL = 15 * time.Minute
|
||
|
||
// registerModelRoutes 註冊 /api/models/* 的 routes。
|
||
func registerModelRoutes(g *gin.RouterGroup, deps Deps) {
|
||
g.GET("/models", modelsListHandler(deps))
|
||
g.GET("/models/:id", modelsGetHandler(deps))
|
||
g.POST("/models/init", modelsInitUploadHandler(deps))
|
||
g.POST("/models/:id/finalize", modelsFinalizeHandler(deps))
|
||
g.DELETE("/models/:id", modelsDeleteHandler(deps))
|
||
|
||
// Phase 0.9 模型庫 model 直連 FAA 下載(ADR-017 (a))。
|
||
g.GET("/models/:id/download", modelsDownloadHandler(deps))
|
||
|
||
// 模型共享(library / profile / visibility / shares)。
|
||
registerModelSharingRoutes(g, deps)
|
||
|
||
// load-to-device 雛形先 stub(完整實作需要 presigned GET + 透過 tunnel 送指令給 local agent)
|
||
g.POST("/models/:id/load-to-device", func(c *gin.Context) {
|
||
WriteNotImplemented(c, "models.load-to-device — pending Phase 1")
|
||
})
|
||
}
|
||
|
||
// ModelResponse 是 API 回傳的 Model DTO;對應 api-spec.md §4 的格式。
|
||
type ModelResponse struct {
|
||
ID string `json:"id"`
|
||
Name string `json:"name"`
|
||
Description string `json:"description,omitempty"`
|
||
TargetChip string `json:"target_chip,omitempty"`
|
||
FileSize int64 `json:"file_size"`
|
||
Source string `json:"source"`
|
||
Status string `json:"status"` // "pending" / "ready"
|
||
// 模型 metadata(B4 鏈路最後一個序列化點)。
|
||
// snake_case 對齊本 DTO 既有慣例(target_chip / file_size / created_at);
|
||
// 前端 model-store normalize 雙吃 snake/camel,input_shape(snake) 在其讀取範圍內。
|
||
// omitempty:上傳類 / 舊 model 無 metadata 時不輸出,不破壞既有回應結構。
|
||
InputShape []int `json:"input_shape,omitempty"`
|
||
Classes []string `json:"classes,omitempty"`
|
||
Framework string `json:"framework,omitempty"`
|
||
// Visibility:模型共享功能新增。既有前端未讀此欄不受影響(加欄相容);
|
||
// 共享 UI 讀此欄顯示公開對象 badge。既有 model 遷移後為 "private"。
|
||
Visibility string `json:"visibility,omitempty"`
|
||
CreatedAt time.Time `json:"created_at"`
|
||
UpdatedAt time.Time `json:"updated_at"`
|
||
UploadedAt *time.Time `json:"uploaded_at,omitempty"`
|
||
}
|
||
|
||
// toModelResponse 把 domain model 轉為 API DTO;「status」由 UploadedAt 是否 set 決定。
|
||
func toModelResponse(m *model.Model) ModelResponse {
|
||
status := "pending"
|
||
if m.UploadedAt != nil {
|
||
status = "ready"
|
||
}
|
||
return ModelResponse{
|
||
ID: m.ID,
|
||
Name: m.Name,
|
||
Description: m.Description,
|
||
TargetChip: m.TargetChip,
|
||
FileSize: m.FileSize,
|
||
Source: m.Source,
|
||
Status: status,
|
||
InputShape: m.InputShape,
|
||
Classes: m.Classes,
|
||
Framework: m.Framework,
|
||
Visibility: m.Visibility,
|
||
CreatedAt: m.CreatedAt,
|
||
UpdatedAt: m.UpdatedAt,
|
||
UploadedAt: m.UploadedAt,
|
||
}
|
||
}
|
||
|
||
// modelsListHandler 實作 GET /api/models。
|
||
func modelsListHandler(deps Deps) gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
if deps.ModelRepo == nil {
|
||
// B8:即使無 user repo(最小骨架),7 個系統預設模型仍對所有人可見。
|
||
WriteSuccess(c, http.StatusOK, presetResponses())
|
||
return
|
||
}
|
||
// Phase 0.7 security fix C1 (見 .autoflow/05-implementation/review/phase-0.7-security-audit.md)
|
||
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()
|
||
|
||
models, err := deps.ModelRepo.List(ctx, model.ListFilter{OwnerUserID: userID})
|
||
if err != nil {
|
||
// 塊 5.4:DB 錯誤經 errors.go 映射(PG down → 503,其餘 → 500),不洩漏 raw DB error。
|
||
WriteDBError(c, deps.Logger, "list models", err)
|
||
return
|
||
}
|
||
// B8:list = 7 個系統預設模型(公用、所有人可見、放最前面)+ 該 user 的 model(DB 查)。
|
||
presets := presetResponses()
|
||
out := make([]ModelResponse, 0, len(presets)+len(models))
|
||
out = append(out, presets...)
|
||
for _, m := range models {
|
||
out = append(out, toModelResponse(m))
|
||
}
|
||
WriteSuccess(c, http.StatusOK, out)
|
||
}
|
||
}
|
||
|
||
// presetResponses 把 7 個系統預設模型轉成 API DTO(順序固定)。
|
||
func presetResponses() []ModelResponse {
|
||
presets := model.PresetModels()
|
||
out := make([]ModelResponse, 0, len(presets))
|
||
for _, m := range presets {
|
||
out = append(out, toModelResponse(m))
|
||
}
|
||
return out
|
||
}
|
||
|
||
// modelsGetHandler 實作 GET /api/models/:id。
|
||
func modelsGetHandler(deps Deps) gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
if deps.ModelRepo == nil {
|
||
WriteError(c, http.StatusNotFound, ErrCodeNotFound, "model not found", nil)
|
||
return
|
||
}
|
||
id := c.Param("id")
|
||
if id == "" {
|
||
WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "model id required", nil)
|
||
return
|
||
}
|
||
// B8:preset 是公用模型(無 owner、不在 DB),任何已登入 user 都查得到,不做 ownership 檢查。
|
||
if pm, ok := model.PresetByID(id); ok {
|
||
WriteSuccess(c, http.StatusOK, toModelResponse(pm))
|
||
return
|
||
}
|
||
// Phase 0.7 security fix C1 (見 .autoflow/05-implementation/review/phase-0.7-security-audit.md)
|
||
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(), 2*time.Second)
|
||
defer cancel()
|
||
|
||
m, err := deps.ModelRepo.Get(ctx, id)
|
||
if err != nil {
|
||
if errors.Is(err, model.ErrNotFound) {
|
||
WriteError(c, http.StatusNotFound, ErrCodeNotFound, "model not found", nil)
|
||
return
|
||
}
|
||
// 塊 5.4:DB 錯誤經 errors.go 映射(PG down → 503,其餘 → 500),不洩漏 raw DB error。
|
||
WriteDBError(c, deps.Logger, "get model", err)
|
||
return
|
||
}
|
||
if m.OwnerUserID != userID {
|
||
WriteError(c, http.StatusForbidden, ErrCodeForbidden, "not owner", nil)
|
||
return
|
||
}
|
||
WriteSuccess(c, http.StatusOK, toModelResponse(m))
|
||
}
|
||
}
|
||
|
||
// ModelInitRequest 是 POST /api/models/init 的 request body。
|
||
type ModelInitRequest struct {
|
||
Name string `json:"name"`
|
||
FileSize int64 `json:"file_size"`
|
||
Checksum string `json:"checksum,omitempty"` // sha256 hex(Phase 1 驗)
|
||
TargetChip string `json:"target_chip,omitempty"`
|
||
Description string `json:"description,omitempty"`
|
||
}
|
||
|
||
// ModelInitResponse 是 POST /api/models/init 的 response data。
|
||
type ModelInitResponse struct {
|
||
ModelID string `json:"model_id"`
|
||
UploadURL string `json:"upload_url"`
|
||
UploadExpiresAt time.Time `json:"upload_expires_at"`
|
||
StorageKey string `json:"storage_key"`
|
||
}
|
||
|
||
// modelsInitUploadHandler 實作 POST /api/models/init。
|
||
//
|
||
// 流程:
|
||
// 1. 驗證 request(name 必填、file_size 不能超過配置)
|
||
// 2. 產新 model_id + storage_key(`models/{userID}/{modelID}.nef`)
|
||
// 3. 用 storage.PresignedPutURL 取 PUT URL
|
||
// 4. 在 ModelRepo 建立 pending 紀錄(UploadedAt = nil)
|
||
// 5. 回應 model_id + upload_url
|
||
//
|
||
// 錯誤:413 PAYLOAD_TOO_LARGE、400 VALIDATION_FAILED、501(storage/repo 未設)
|
||
func modelsInitUploadHandler(deps Deps) gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
if deps.ModelRepo == nil || deps.Storage == nil {
|
||
WriteNotImplemented(c, "model repo or storage not configured")
|
||
return
|
||
}
|
||
|
||
var req ModelInitRequest
|
||
if err := json.NewDecoder(c.Request.Body).Decode(&req); err != nil {
|
||
WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed,
|
||
"invalid JSON: "+err.Error(), nil)
|
||
return
|
||
}
|
||
|
||
// 驗證 name
|
||
if strings.TrimSpace(req.Name) == "" {
|
||
WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed,
|
||
"name is required", []FieldError{{Field: "name", Message: "cannot be empty"}})
|
||
return
|
||
}
|
||
// 驗證 file_size > 0 且不超過上限
|
||
if req.FileSize <= 0 {
|
||
WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed,
|
||
"file_size must be > 0",
|
||
[]FieldError{{Field: "file_size", Message: "must be positive"}})
|
||
return
|
||
}
|
||
// 大小上限檢查(MaxUploadSizeMB 取自 Deps;若為 0 則不限,給測試友善)
|
||
if deps.MaxUploadSizeMB > 0 {
|
||
limit := int64(deps.MaxUploadSizeMB) * 1024 * 1024
|
||
if req.FileSize > limit {
|
||
WriteError(c, http.StatusRequestEntityTooLarge, ErrCodePayloadTooLarge,
|
||
"file_size exceeds upload limit",
|
||
[]FieldError{{Field: "file_size",
|
||
Message: "max allowed is configured by server"}})
|
||
return
|
||
}
|
||
}
|
||
|
||
// Phase 0.7 security fix C1 (見 .autoflow/05-implementation/review/phase-0.7-security-audit.md)
|
||
uc, ok := UserContextFrom(c)
|
||
if !ok || uc.UserID == "" {
|
||
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
|
||
"missing user context (auth middleware misconfigured?)", nil)
|
||
return
|
||
}
|
||
userID := uc.UserID
|
||
|
||
modelID := uuid.NewString()
|
||
storageKey := "models/" + userID + "/" + modelID + ".nef"
|
||
|
||
ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second)
|
||
defer cancel()
|
||
|
||
// 產 presigned PUT URL
|
||
uploadURL, err := deps.Storage.PresignedPutURL(ctx, storageKey, modelUploadURLTTL)
|
||
if err != nil {
|
||
// 塊 5 Minor-1:storage 錯誤統一映射,不把 raw err(bucket/endpoint 等)洩漏給前端。
|
||
WriteStorageError(c, deps.Logger, "presigned put url", err)
|
||
return
|
||
}
|
||
|
||
// 建立 pending 紀錄
|
||
now := time.Now().UTC()
|
||
m := &model.Model{
|
||
ID: modelID,
|
||
OwnerUserID: userID,
|
||
Name: req.Name,
|
||
Description: req.Description,
|
||
TargetChip: req.TargetChip,
|
||
FileSize: req.FileSize,
|
||
FileChecksum: req.Checksum,
|
||
StorageKey: storageKey,
|
||
Source: model.SourceUploaded,
|
||
CreatedAt: now,
|
||
UpdatedAt: now,
|
||
// UploadedAt 保持 nil 直到 finalize
|
||
}
|
||
if err := deps.ModelRepo.Save(ctx, m); err != nil {
|
||
// 塊 5.4:DB 錯誤經 errors.go 映射(PG down → 503,其餘 → 500),不洩漏 raw DB error。
|
||
WriteDBError(c, deps.Logger, "save model", err)
|
||
return
|
||
}
|
||
|
||
logOrDefault(deps.Logger).Info("models: upload init",
|
||
"model_id", modelID,
|
||
"user_id", userID,
|
||
"file_size", req.FileSize,
|
||
"request_id", RequestIDFrom(c))
|
||
|
||
WriteSuccess(c, http.StatusOK, ModelInitResponse{
|
||
ModelID: modelID,
|
||
UploadURL: uploadURL,
|
||
UploadExpiresAt: now.Add(modelUploadURLTTL),
|
||
StorageKey: storageKey,
|
||
})
|
||
}
|
||
}
|
||
|
||
// modelsFinalizeHandler 實作 POST /api/models/:id/finalize。
|
||
//
|
||
// 流程:
|
||
// 1. 取 model(ownership 檢查)
|
||
// 2. 透過 storage.Stat 驗證檔案已存在
|
||
// 3. 驗證 Stat().Size == model.FileSize(雛形只做 size 比對;Phase 1 加 checksum)
|
||
// 4. 更新 UploadedAt;存回 Repo
|
||
//
|
||
// 錯誤:
|
||
// - 檔案還沒 PUT → 400 VALIDATION_FAILED (file not uploaded)
|
||
// - Size 不符 → 400 VALIDATION_FAILED (size mismatch)
|
||
func modelsFinalizeHandler(deps Deps) gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
if deps.ModelRepo == nil || deps.Storage == nil {
|
||
WriteNotImplemented(c, "model repo or storage not configured")
|
||
return
|
||
}
|
||
id := c.Param("id")
|
||
if id == "" {
|
||
WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "model id required", nil)
|
||
return
|
||
}
|
||
// Phase 0.7 security fix C1 (見 .autoflow/05-implementation/review/phase-0.7-security-audit.md)
|
||
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(), 5*time.Second)
|
||
defer cancel()
|
||
|
||
m, err := deps.ModelRepo.Get(ctx, id)
|
||
if err != nil {
|
||
if errors.Is(err, model.ErrNotFound) {
|
||
WriteError(c, http.StatusNotFound, ErrCodeNotFound, "model not found", nil)
|
||
return
|
||
}
|
||
// 塊 5.4:DB 錯誤經 errors.go 映射(PG down → 503,其餘 → 500),不洩漏 raw DB error。
|
||
WriteDBError(c, deps.Logger, "get model", err)
|
||
return
|
||
}
|
||
if m.OwnerUserID != userID {
|
||
WriteError(c, http.StatusForbidden, ErrCodeForbidden, "not owner", nil)
|
||
return
|
||
}
|
||
|
||
// 驗證檔案已存在
|
||
obj, statErr := deps.Storage.Stat(ctx, m.StorageKey)
|
||
if statErr != nil {
|
||
if errors.Is(statErr, storage.ErrNotFound) {
|
||
WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed,
|
||
"file not uploaded yet; PUT to upload_url first", nil)
|
||
return
|
||
}
|
||
// 塊 5 Minor-1:storage 錯誤統一映射,不把 raw err(bucket/endpoint 等)洩漏給前端。
|
||
WriteStorageError(c, deps.Logger, "stat object", statErr)
|
||
return
|
||
}
|
||
// Size 驗證(雛形只比對 size;Phase 1 加 checksum)
|
||
if obj.Size != m.FileSize {
|
||
WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed,
|
||
"uploaded size mismatch",
|
||
[]FieldError{
|
||
{Field: "file_size", Message: "declared vs actual differ"},
|
||
})
|
||
return
|
||
}
|
||
|
||
// 標記 ready
|
||
now := time.Now().UTC()
|
||
m.UploadedAt = &now
|
||
m.UpdatedAt = now
|
||
if err := deps.ModelRepo.Save(ctx, m); err != nil {
|
||
// 塊 5.4:DB 錯誤經 errors.go 映射(PG down → 503,其餘 → 500),不洩漏 raw DB error。
|
||
WriteDBError(c, deps.Logger, "save model", err)
|
||
return
|
||
}
|
||
|
||
logOrDefault(deps.Logger).Info("models: upload finalized",
|
||
"model_id", m.ID,
|
||
"user_id", userID,
|
||
"size", m.FileSize,
|
||
"request_id", RequestIDFrom(c))
|
||
|
||
WriteSuccess(c, http.StatusOK, toModelResponse(m))
|
||
}
|
||
}
|
||
|
||
// modelsDeleteHandler 實作 DELETE /api/models/:id。
|
||
//
|
||
// 雛形行為:軟刪(ModelRepo.Delete 已做軟刪)。
|
||
// 是否一併刪 storage 檔案 — 雛形保留檔案(方便 debug);Phase 1 接 S3 後,
|
||
// 建議由後台 worker 在 grace period 後刪除(避免使用者誤刪)。
|
||
func modelsDeleteHandler(deps Deps) gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
if deps.ModelRepo == nil {
|
||
WriteNotImplemented(c, "model repo not configured")
|
||
return
|
||
}
|
||
id := c.Param("id")
|
||
if id == "" {
|
||
WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "model id required", nil)
|
||
return
|
||
}
|
||
// Phase 0.7 security fix C1 (見 .autoflow/05-implementation/review/phase-0.7-security-audit.md)
|
||
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()
|
||
|
||
// Ownership 檢查
|
||
m, err := deps.ModelRepo.Get(ctx, id)
|
||
if err != nil {
|
||
if errors.Is(err, model.ErrNotFound) {
|
||
WriteError(c, http.StatusNotFound, ErrCodeNotFound, "model not found", nil)
|
||
return
|
||
}
|
||
// 塊 5.4:DB 錯誤經 errors.go 映射(PG down → 503,其餘 → 500),不洩漏 raw DB error。
|
||
WriteDBError(c, deps.Logger, "get model", err)
|
||
return
|
||
}
|
||
if m.OwnerUserID != userID {
|
||
WriteError(c, http.StatusForbidden, ErrCodeForbidden, "not owner", nil)
|
||
return
|
||
}
|
||
|
||
if err := deps.ModelRepo.Delete(ctx, id); err != nil {
|
||
if errors.Is(err, model.ErrNotFound) {
|
||
WriteError(c, http.StatusNotFound, ErrCodeNotFound, "model not found", nil)
|
||
return
|
||
}
|
||
// 塊 5.4:DB 錯誤經 errors.go 映射(PG down → 503,其餘 → 500),不洩漏 raw DB error。
|
||
WriteDBError(c, deps.Logger, "delete model", err)
|
||
return
|
||
}
|
||
|
||
logOrDefault(deps.Logger).Info("models: deleted",
|
||
"model_id", id,
|
||
"user_id", userID,
|
||
"request_id", RequestIDFrom(c))
|
||
|
||
c.Status(http.StatusNoContent)
|
||
}
|
||
}
|
||
|
||
// ModelDownloadResponse 是 GET /api/models/:id/download 的 response data。
|
||
//
|
||
// ADR-017 v1.3(§11):瀏覽器下載改 query-string token + redirect 導航,避開 CORS
|
||
// preflight 405。後端組好「已含 ?access_token= 的完整 URL」(決策 A 乙案),Client
|
||
// 把 DownloadURL 當不透明連結直接導航下載——不經 visionA、不經 AWS。
|
||
type ModelDownloadResponse struct {
|
||
// DownloadURL 是 FAA 下載 URL,已含 `?access_token=`(`{FAABaseURL}/files/{object_key}?access_token={token}`)。
|
||
// 可直接瀏覽器導航下載,無需自行帶 header / 拼 query(ADR-017 §11.3 乙案)。
|
||
DownloadURL string `json:"download_url"`
|
||
// Token 是 MC 簽的 opaque download token(fdt_)。
|
||
//
|
||
// Deprecated(ADR-017 v1.3 §11.3):token 已併入 DownloadURL 的 ?access_token=,
|
||
// Client 不應再單獨使用本欄位。保留純為向下相容(舊 local-tool / 既有 consumer 可能仍讀)。
|
||
Token string `json:"token"`
|
||
// ExpiresAt 是 token 到期時間(RFC3339 / UTC);MC 沒回填時為零值(前端不應依賴)。
|
||
ExpiresAt time.Time `json:"expires_at,omitempty"`
|
||
}
|
||
|
||
// modelsDownloadHandler 實作 GET /api/models/:id/download(ADR-017 (a) 模型庫直連 FAA 下載)。
|
||
//
|
||
// 流程(對齊 adr-017 §10.3 e2e 藍本 + 決策 2):
|
||
// 1. ownership 驗(第一階段 owner-only;B 分享是後續階段)
|
||
// 2. model 必須有 FAAObjectKey(= 轉檔→promote 類);上傳類(空 key)回 501(Q7 範圍框死)
|
||
// 3. FileAccessIssuer 簽 MC download token(fdt_)
|
||
// 4. 回 {download_url, token, expires_at} 給 Client 直連 FAA
|
||
//
|
||
// 錯誤:
|
||
// - issuer / FAABaseURL 未配置(deps.FileAccessIssuer == nil)→ 501 NOT_IMPLEMENTED
|
||
// - model 不存在 / 非 owner → 404 / 403
|
||
// - 上傳類 model(無 FAAObjectKey)→ 501 NOT_IMPLEMENTED(明確訊息)
|
||
// - 簽 token 失敗(MC 不可用)→ 502 INTERNAL_ERROR(對外 mask,不洩漏 MC 內部狀態)
|
||
func modelsDownloadHandler(deps Deps) gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
id := c.Param("id")
|
||
if id == "" {
|
||
WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "model id required", nil)
|
||
return
|
||
}
|
||
|
||
// B8:preset 是公用模型,download 走 visionA 自己(簡單版不簽 token)。
|
||
// 必須在 FAA / ModelRepo nil 檢查之前處理——preset 完全不依賴 FAA / DB。
|
||
if pm, ok := model.PresetByID(id); ok {
|
||
if deps.PresetModelsDir == "" || deps.PresetBaseURL == "" {
|
||
// preset 檔案目錄 / base URL 未配置(main.go 沒 wire)→ 無從組可下載 URL。
|
||
WriteNotImplemented(c, "preset model download not configured (set VISIONA_PRESET_MODELS_DIR)")
|
||
return
|
||
}
|
||
// preset StorageKey = models/preset/{id}.nef;對外 URL 用檔名 {id}.nef。
|
||
downloadURL := strings.TrimRight(deps.PresetBaseURL, "/") + "/preset-models/" + url.PathEscape(id) + ".nef"
|
||
logOrDefault(deps.Logger).Info("models: preset download",
|
||
"model_id", pm.ID,
|
||
"request_id", RequestIDFrom(c))
|
||
WriteSuccess(c, http.StatusOK, ModelDownloadResponse{
|
||
DownloadURL: downloadURL,
|
||
// preset 不簽 token:Token / ExpiresAt 留空(前端只需 DownloadURL 即可下載)。
|
||
})
|
||
return
|
||
}
|
||
|
||
if deps.ModelRepo == nil {
|
||
WriteError(c, http.StatusNotFound, ErrCodeNotFound, "model not found", nil)
|
||
return
|
||
}
|
||
// FAA 直連下載未啟用(cfg.FileAccess.Enabled() == false → main.go 不 wire)
|
||
if deps.FileAccessIssuer == nil || deps.FAABaseURL == "" {
|
||
WriteNotImplemented(c, "model FAA download not configured (set VISIONA_FILE_ACCESS_* env)")
|
||
return
|
||
}
|
||
// Phase 0.7 security fix C1 (見 .autoflow/05-implementation/review/phase-0.7-security-audit.md)
|
||
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(), 5*time.Second)
|
||
defer cancel()
|
||
|
||
m, err := deps.ModelRepo.Get(ctx, id)
|
||
if err != nil {
|
||
if errors.Is(err, model.ErrNotFound) {
|
||
WriteError(c, http.StatusNotFound, ErrCodeNotFound, "model not found", nil)
|
||
return
|
||
}
|
||
// 塊 5.4:DB 錯誤經 errors.go 映射(PG down → 503,其餘 → 500),不洩漏 raw DB error。
|
||
WriteDBError(c, deps.Logger, "get model", err)
|
||
return
|
||
}
|
||
// 模型共享放寬:owner-only → 共享權限檢查(TDD §5 download 連帶變更)。
|
||
// 與 profile 可見性共用同一 canAccessModel(single source of truth,杜絕邏輯漂移,SEC-2)。
|
||
// 不命中回 404(不是 403,防 enumeration,與 profile 一致,SEC-1)。
|
||
if canAccessModel(ctx, uc, m, deps.ModelRepo.GetShare) == model.AccessNone {
|
||
WriteError(c, http.StatusNotFound, ErrCodeNotFound, "model not found", nil)
|
||
return
|
||
}
|
||
|
||
// 第一階段只支援「轉檔→promote 進 FAA」類 model(有 FAAObjectKey)。
|
||
// 上傳類 model 只在 visionA 自己 storage、不在 FAA,無法直連(ADR-017 Q7 範圍框死)。
|
||
if m.FAAObjectKey == "" {
|
||
WriteNotImplemented(c,
|
||
"uploaded models do not support FAA direct download in this phase (only converted models)")
|
||
return
|
||
}
|
||
|
||
// 簽 MC download token(fdt_)。userID = OIDC sub(MC 真實 user);
|
||
// objectKey 必須與 FAA GET path key 一致(= 簽 token 時的 object_key)。
|
||
issued, err := deps.FileAccessIssuer.IssueDownloadToken(ctx, userID, m.FAAObjectKey)
|
||
if err != nil {
|
||
// 對外 mask 成 502(不洩漏「MC 不可用 / token 簽發細節」這類內部運維狀態);
|
||
// SRE 從 server log 的 error 看 fileaccess sentinel 分類。
|
||
logOrDefault(deps.Logger).Warn("models: download token issue failed",
|
||
"model_id", m.ID,
|
||
"user_id", userID,
|
||
"err", err.Error(),
|
||
"request_id", RequestIDFrom(c))
|
||
WriteError(c, http.StatusBadGateway, ErrCodeInternalError,
|
||
"download token service unavailable", nil)
|
||
return
|
||
}
|
||
|
||
// 組對外 download_url:{FAABaseURL}/files/{object_key}?access_token={token}。
|
||
// object_key 內含 '/'(models/{userID}/{jobID}.nef),需逐段 escape 但保留 '/'。
|
||
// ADR-017 v1.3(§11.3 乙案):token 以 query string 併入 URL(FAA 原生支援 access_token
|
||
// query、§11.2),由後端用 url.QueryEscape 統一 escape(對齊 FAA test page 的
|
||
// Uri.EscapeDataString)。path 此時不含 query,直接 append `?access_token=` 即可。
|
||
// 注意:downloadURL(含 token)絕不寫進 log(token 維持不洩漏原則)。
|
||
downloadURL := strings.TrimRight(deps.FAABaseURL, "/") + "/files/" + escapeFAAObjectKey(m.FAAObjectKey) +
|
||
"?access_token=" + url.QueryEscape(issued.Token)
|
||
|
||
logOrDefault(deps.Logger).Info("models: download token issued",
|
||
"model_id", m.ID,
|
||
"user_id", userID,
|
||
"request_id", RequestIDFrom(c))
|
||
|
||
WriteSuccess(c, http.StatusOK, ModelDownloadResponse{
|
||
DownloadURL: downloadURL,
|
||
Token: issued.Token,
|
||
ExpiresAt: issued.ExpiresAt,
|
||
})
|
||
}
|
||
}
|
||
|
||
// escapeFAAObjectKey 對 FAA object_key 逐段 path-escape,保留 '/' 為 path separator。
|
||
//
|
||
// object_key 形如 models/{userID}/{jobID}.nef;userID(OIDC sub) / jobID(UUID) 都是
|
||
// 安全字元,但仍逐段 url.PathEscape 防禦(避免任何特殊字元破壞 URL 結構)。
|
||
// 不整體 PathEscape 是因為那會把 '/' 變 %2F,破壞 FAA `/files/{**objectKey}` 的 path 比對。
|
||
func escapeFAAObjectKey(objectKey string) string {
|
||
segs := strings.Split(objectKey, "/")
|
||
for i, s := range segs {
|
||
segs[i] = url.PathEscape(s)
|
||
}
|
||
return strings.Join(segs, "/")
|
||
}
|