// models_sharing.go — 模型共享(Model Sharing)的 handler。 // // 端點(對齊 api/api-model-sharing.md): // - GET /api/models/library 共享庫列表(cursor 分頁 + sort/order/q/filter) // - GET /api/models/:id/profile 模型 profile(權限裁剪;不命中回 404) // - PATCH /api/models/:id/visibility 設公開對象(owner-only) // - GET /api/models/:id/shares 列授權清單(owner-only) // - PUT /api/models/:id/shares 加/更新 grantee 授權(owner-only) // - DELETE /api/models/:id/shares/:userId 移除 grantee 授權(owner-only) // // 核心安全設計:所有可見性判斷走唯一的 canAccessModel(single source of truth,避免 // profile / download 兩處邏輯漂移,TDD §6 SEC-2);enumeration 防護一律回 404(SEC-1)。 // // 對齊:feature-model-sharing-tdd.md §4/§5/§6、api/api-model-sharing.md。 package api import ( "context" "encoding/base64" "encoding/json" "errors" "net/http" "strconv" "strings" "time" "github.com/gin-gonic/gin" "visiona-backend/internal/auth" "visiona-backend/internal/model" ) // registerModelSharingRoutes 註冊模型共享相關 routes(掛在既有 /api group,走 AuthMiddleware)。 func registerModelSharingRoutes(g *gin.RouterGroup, deps Deps) { g.GET("/models/library", modelsLibraryHandler(deps)) g.GET("/models/:id/profile", modelsProfileHandler(deps)) g.PATCH("/models/:id/visibility", modelsSetVisibilityHandler(deps)) g.GET("/models/:id/shares", modelsListSharesHandler(deps)) g.PUT("/models/:id/shares", modelsPutShareHandler(deps)) g.DELETE("/models/:id/shares/:userId", modelsDeleteShareHandler(deps)) } // ========================================================================== // canAccessModel — single source of truth(可見性判斷) // ========================================================================== // canAccessModel 計算 uc 對 m 的有效 AccessLevel。這是 profile / download / (未來) load 的 // 唯一權限判斷入口——絕不在別處複製一份可見性邏輯(TDD §6 SEC-2)。 // // 判斷順序(取最高權限): // 1. owner(m.OwnerUserID == uc.UserID)→ AccessOwner // 2. share 命中 → editor / viewer(依 share.role) // 3. visibility=public → viewer // 4. visibility=tenant 且 owner.org_id == uc.OrgID 且兩者皆非空 → viewer(SEC-4 tenant 邊界) // 5. 皆不命中 → AccessNone // // preset 由呼叫端(handler)在進 canAccessModel 前處理(preset 無 owner、公用),不走此函式。 // // shareLookup 為查 (modelID, granteeUserID) 分享的函式(注入以利測試 / 共用 repo); // 傳 nil 時視為「無任何分享」(僅 visibility 判斷)。 func canAccessModel(ctx context.Context, uc *auth.UserContext, m *model.Model, shareLookup func(ctx context.Context, modelID, granteeUserID string) (*model.ModelShare, error), ) model.AccessLevel { if uc == nil || uc.UserID == "" || m == nil { return model.AccessNone } // 1. owner if m.OwnerUserID == uc.UserID { return model.AccessOwner } // 2. share 命中 if shareLookup != nil { if s, err := shareLookup(ctx, m.ID, uc.UserID); err == nil && s != nil { if s.Role == "editor" { return model.AccessEditor } return model.AccessViewer } } // 3. public if m.Visibility == model.VisibilityPublic { return model.AccessViewer } // 4. tenant(兩者皆非空才可能命中;空 org 一律不落 tenant 可見) if m.Visibility == model.VisibilityTenant && uc.OrgID != "" && m.OwnerUserID != "" { if ownerOrg := ownerOrgOf(ctx, m); ownerOrg != "" && ownerOrg == uc.OrgID { return model.AccessViewer } } return model.AccessNone } // ownerOrgOf 是 tenant 判斷取 owner.org_id 的鉤子。 // // 目前 OIDC 不帶 org claim(middleware 未填 UserContext.OrgID,恆空),故 canAccessModel // 第 4 步的前置 `uc.OrgID != ""` 一定為 false、永遠短路——本函式實務上不會被呼叫到。 // 保留為明確的擴充點:待 OIDC 補 org claim + repository 提供 owner.org_id 後在此接線。 // 現階段回空字串(= tenant 不命中,安全預設)。 func ownerOrgOf(_ context.Context, _ *model.Model) string { return "" } // ========================================================================== // GET /api/models/library // ========================================================================== // LibraryItemResponse 是共享庫列表的一列 DTO(api §1)。 // // owner 只揭露 id/name/is_me(不揭露 owner email);不含 storage_key / faa_object_key(SEC-3)。 type LibraryItemResponse 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"` Visibility string `json:"visibility"` Owner OwnerResponse `json:"owner"` SharedWithMe bool `json:"shared_with_me"` MyAccess string `json:"my_access"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } // OwnerResponse 是裁剪後的 owner 資訊(絕不含 email)。 type OwnerResponse struct { ID string `json:"id"` Name string `json:"name,omitempty"` IsMe bool `json:"is_me"` } // LibraryResponse 是 GET /api/models/library 的 data payload。 type LibraryResponse struct { Items []LibraryItemResponse `json:"items"` NextCursor string `json:"next_cursor,omitempty"` HasMore bool `json:"has_more"` } const ( libraryDefaultLimit = 20 libraryMaxLimit = 100 ) // modelsLibraryHandler 實作 GET /api/models/library。 func modelsLibraryHandler(deps Deps) gin.HandlerFunc { return func(c *gin.Context) { if deps.ModelRepo == nil { // 無 repo(最小骨架):至少回 preset(公用、所有人可見)。 WriteSuccess(c, http.StatusOK, LibraryResponse{Items: presetLibraryItems(c), HasMore: false}) return } uc, ok := UserContextFrom(c) if !ok || uc.UserID == "" { WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, "missing user context (auth middleware misconfigured?)", nil) return } q, verr := parseLibraryQuery(c, uc) if verr != "" { WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, verr, nil) return } ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second) defer cancel() items, hasMore, err := deps.ModelRepo.Library(ctx, q) if err != nil { WriteDBError(c, deps.Logger, "list model library", err) return } resp := LibraryResponse{ Items: make([]LibraryItemResponse, 0, len(items)), HasMore: hasMore, } for _, it := range items { resp.Items = append(resp.Items, toLibraryItemResponse(it, uc.UserID)) } if hasMore && len(items) > 0 { last := items[len(items)-1].Model resp.NextCursor = encodeCursor(q.Sort, last) } WriteSuccess(c, http.StatusOK, resp) } } // parseLibraryQuery 解析 + 驗證 query 參數,回傳 model.LibraryQuery;驗證失敗回錯誤訊息。 func parseLibraryQuery(c *gin.Context, uc *auth.UserContext) (model.LibraryQuery, string) { q := model.LibraryQuery{ UserID: uc.UserID, UserOrgID: uc.OrgID, // OIDC 現況恆空 → tenant 不命中 TargetChip: c.Query("target_chip"), Source: c.Query("source"), Q: strings.TrimSpace(c.Query("q")), Limit: libraryDefaultLimit, } // limit:clamp 到 1–100。 if raw := c.Query("limit"); raw != "" { n, err := strconv.Atoi(raw) if err != nil { return q, "limit must be an integer" } if n < 1 { n = 1 } if n > libraryMaxLimit { n = libraryMaxLimit } q.Limit = n } // sort 白名單。 switch c.Query("sort") { case "", "created_at": q.Sort = "created_at" case "name": q.Sort = "name" case "file_size": q.Sort = "file_size" default: return q, "sort must be one of: created_at, name, file_size" } // order 白名單。 switch c.Query("order") { case "", "desc": q.Order = "desc" case "asc": q.Order = "asc" default: return q, "order must be asc or desc" } // visibility filter(僅 public / tenant 有意義;其他忽略)。 switch c.Query("visibility") { case model.VisibilityPublic, model.VisibilityTenant: q.Visibility = c.Query("visibility") } // owned filter(true/false)。 if raw := c.Query("owned"); raw != "" { b, err := strconv.ParseBool(raw) if err != nil { return q, "owned must be a boolean" } q.Owned = &b } // cursor(不透明 base64)。 if raw := c.Query("cursor"); raw != "" { cur, err := decodeCursor(raw) if err != nil { return q, "invalid cursor" } q.Cursor = cur } return q, "" } // toLibraryItemResponse 把 LibraryItem 轉 DTO。my_access:owner 由 is_me 覆寫為 owner。 func toLibraryItemResponse(it *model.LibraryItem, userID string) LibraryItemResponse { m := it.Model status := "pending" if m.UploadedAt != nil { status = "ready" } isMe := m.OwnerUserID == userID access := it.MyAccess if isMe { access = model.AccessOwner } return LibraryItemResponse{ ID: m.ID, Name: m.Name, Description: m.Description, TargetChip: m.TargetChip, FileSize: m.FileSize, Source: m.Source, Status: status, Visibility: m.Visibility, Owner: OwnerResponse{ ID: m.OwnerUserID, Name: it.OwnerName, IsMe: isMe, }, SharedWithMe: it.SharedWithMe, MyAccess: access, CreatedAt: m.CreatedAt, UpdatedAt: m.UpdatedAt, } } // presetLibraryItems 把 preset 轉成 library DTO(公用、is_me=false、my_access=viewer)。 func presetLibraryItems(c *gin.Context) []LibraryItemResponse { presets := model.PresetModels() out := make([]LibraryItemResponse, 0, len(presets)) for _, m := range presets { status := "ready" out = append(out, LibraryItemResponse{ ID: m.ID, Name: m.Name, TargetChip: m.TargetChip, FileSize: m.FileSize, Source: m.Source, Status: status, Visibility: m.Visibility, Owner: OwnerResponse{ID: "", Name: "system", IsMe: false}, MyAccess: model.AccessViewer, CreatedAt: m.CreatedAt, UpdatedAt: m.UpdatedAt, }) } return out } // ========================================================================== // cursor 編/解碼(不透明 base64) // ========================================================================== // cursorPayload 是 cursor 的 JSON 內容(前端當黑箱)。 type cursorPayload struct { V string `json:"v"` // 排序值 ID string `json:"id"` // tie-breaker } // encodeCursor 依 sort 欄位取 last item 的排序值,組不透明 base64 游標。 func encodeCursor(sortField string, last *model.Model) string { var v string switch sortField { case "name": v = last.Name case "file_size": v = strconv.FormatInt(last.FileSize, 10) default: // created_at v = last.CreatedAt.UTC().Format(time.RFC3339Nano) } raw, _ := json.Marshal(cursorPayload{V: v, ID: last.ID}) return base64.RawURLEncoding.EncodeToString(raw) } // decodeCursor 解 base64 游標;格式錯誤回 error(handler 轉 400)。 func decodeCursor(s string) (*model.Cursor, error) { raw, err := base64.RawURLEncoding.DecodeString(s) if err != nil { return nil, err } var p cursorPayload if err := json.Unmarshal(raw, &p); err != nil { return nil, err } if p.ID == "" { return nil, errors.New("cursor missing id") } return &model.Cursor{SortValue: p.V, ID: p.ID}, nil } // ========================================================================== // GET /api/models/:id/profile // ========================================================================== // ProfileResponse 是 GET /api/models/:id/profile 的 data payload(api §2)。 // // 絕不含 storage_key / faa_object_key / owner email / file_checksum(SEC-3)。 type ProfileResponse 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"` Visibility string `json:"visibility"` InputShape []int `json:"input_shape,omitempty"` Classes []string `json:"classes,omitempty"` Framework string `json:"framework,omitempty"` Owner OwnerResponse `json:"owner"` MyAccess string `json:"my_access"` CanDownload bool `json:"can_download"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` UploadedAt *time.Time `json:"uploaded_at,omitempty"` } // modelsProfileHandler 實作 GET /api/models/:id/profile。 // // 可見性檢查為第一步;不命中回 404(不是 403,防 enumeration,SEC-1)。 func modelsProfileHandler(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 } // preset 公用、任何登入 user 可見。 if pm, ok := model.PresetByID(id); ok { WriteSuccess(c, http.StatusOK, presetProfileResponse(pm)) return } if deps.ModelRepo == nil { WriteError(c, http.StatusNotFound, ErrCodeNotFound, "model not found", nil) return } uc, ok := UserContextFrom(c) if !ok || uc.UserID == "" { WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, "missing user context (auth middleware misconfigured?)", nil) return } ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second) defer cancel() m, ownerName, err := deps.ModelRepo.GetWithOwner(ctx, id) if err != nil { if errors.Is(err, model.ErrNotFound) { WriteError(c, http.StatusNotFound, ErrCodeNotFound, "model not found", nil) return } WriteDBError(c, deps.Logger, "get model profile", err) return } access := canAccessModel(ctx, uc, m, deps.ModelRepo.GetShare) if access == model.AccessNone { // enumeration 防護:不揭露「id 存在但你沒權限」,回 404 與「不存在」無法區分。 WriteError(c, http.StatusNotFound, ErrCodeNotFound, "model not found", nil) return } WriteSuccess(c, http.StatusOK, toProfileResponse(m, ownerName, uc.UserID, access)) } } // toProfileResponse 組 profile DTO(依 access 裁剪;不揭露內部 key)。 // ownerName 由 GetWithOwner join users 帶出(api §2 owner.name);owner 未設 name 時為空。 func toProfileResponse(m *model.Model, ownerName, userID string, access model.AccessLevel) ProfileResponse { status := "pending" if m.UploadedAt != nil { status = "ready" } return ProfileResponse{ ID: m.ID, Name: m.Name, Description: m.Description, TargetChip: m.TargetChip, FileSize: m.FileSize, Source: m.Source, Status: status, Visibility: m.Visibility, InputShape: m.InputShape, Classes: m.Classes, Framework: m.Framework, Owner: OwnerResponse{ ID: m.OwnerUserID, Name: ownerName, // join users.name 帶出(SEC-3 白名單:只揭露 id/name/is_me,不含 email) IsMe: m.OwnerUserID == userID, }, MyAccess: access, CanDownload: access != model.AccessNone, CreatedAt: m.CreatedAt, UpdatedAt: m.UpdatedAt, UploadedAt: m.UploadedAt, } } // presetProfileResponse 組 preset 的 profile(公用、viewer、可下載)。 func presetProfileResponse(m *model.Model) ProfileResponse { return ProfileResponse{ ID: m.ID, Name: m.Name, Description: m.Description, TargetChip: m.TargetChip, FileSize: m.FileSize, Source: m.Source, Status: "ready", Visibility: model.VisibilityPublic, InputShape: m.InputShape, Classes: m.Classes, Framework: m.Framework, Owner: OwnerResponse{ID: "", Name: "system", IsMe: false}, MyAccess: model.AccessViewer, CanDownload: true, CreatedAt: m.CreatedAt, UpdatedAt: m.UpdatedAt, UploadedAt: m.UploadedAt, } } // ========================================================================== // PATCH /api/models/:id/visibility // ========================================================================== // SetVisibilityRequest 是 PATCH visibility 的 body。 type SetVisibilityRequest struct { Visibility string `json:"visibility"` } // SetVisibilityResponse 是 PATCH visibility 的 data payload。 type SetVisibilityResponse struct { ID string `json:"id"` Visibility string `json:"visibility"` UpdatedAt time.Time `json:"updated_at"` } // modelsSetVisibilityHandler 實作 PATCH /api/models/:id/visibility(owner-only)。 func modelsSetVisibilityHandler(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 } // preset 不可改 visibility(公用、無 owner)。 if model.IsPresetID(id) { WriteError(c, http.StatusForbidden, ErrCodeForbidden, "preset visibility is fixed", nil) return } uc, ok := UserContextFrom(c) if !ok || uc.UserID == "" { WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, "missing user context (auth middleware misconfigured?)", nil) return } var req SetVisibilityRequest if err := json.NewDecoder(c.Request.Body).Decode(&req); err != nil { WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "invalid JSON: "+err.Error(), nil) return } if !model.IsValidVisibility(req.Visibility) { WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "visibility must be one of: private, tenant, public", []FieldError{{Field: "visibility", Message: "invalid value"}}) return } // tenant 但 user 無 org → 400(無租戶歸屬不能設 tenant 可見,api §3)。 if req.Visibility == model.VisibilityTenant && uc.OrgID == "" { WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "cannot set tenant visibility without an organization", []FieldError{{Field: "visibility", Message: "no org membership"}}) return } ctx, cancel := context.WithTimeout(c.Request.Context(), 3*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 } WriteDBError(c, deps.Logger, "get model", err) return } // owner-only(SEC-5)。非 owner 回 403(此為「改權限」動作,回 403 合理—— // 與 profile/download 的 enumeration 情境不同:能走到這代表 model 存在且是寫入意圖)。 if m.OwnerUserID != uc.UserID { WriteError(c, http.StatusForbidden, ErrCodeForbidden, "not owner", nil) return } // 未 ready(未 finalize)不允許公開(api §3 409)。 if req.Visibility != model.VisibilityPrivate && m.UploadedAt == nil { WriteError(c, http.StatusConflict, ErrCodeConflict, "model must be ready (finalized) before it can be shared", nil) return } m.Visibility = req.Visibility now := time.Now().UTC() m.UpdatedAt = now if err := deps.ModelRepo.Save(ctx, m); err != nil { WriteDBError(c, deps.Logger, "save model visibility", err) return } logOrDefault(deps.Logger).Info("models: visibility updated", "model_id", m.ID, "user_id", uc.UserID, "visibility", req.Visibility, "request_id", RequestIDFrom(c)) WriteSuccess(c, http.StatusOK, SetVisibilityResponse{ ID: m.ID, Visibility: m.Visibility, UpdatedAt: m.UpdatedAt, }) } } // ========================================================================== // GET/PUT/DELETE /api/models/:id/shares — restricted 分享授權管理(owner-only) // ========================================================================== // ShareResponse 是一筆分享授權 DTO(owner 檢視清單用)。 // // 只揭露 grantee id + role + 授權時間;不揭露 grantee email(同 owner email 不揭露原則)。 type ShareResponse struct { GranteeUserID string `json:"grantee_user_id"` Role string `json:"role"` GrantedBy string `json:"granted_by"` CreatedAt time.Time `json:"created_at"` } // modelsListSharesHandler 實作 GET /api/models/:id/shares(owner-only)。 func modelsListSharesHandler(deps Deps) gin.HandlerFunc { return func(c *gin.Context) { m, uc, ok := requireOwnedModel(c, deps) if !ok { return } ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second) defer cancel() shares, err := deps.ModelRepo.ListShares(ctx, m.ID) if err != nil { WriteDBError(c, deps.Logger, "list model shares", err) return } out := make([]ShareResponse, 0, len(shares)) for _, s := range shares { out = append(out, ShareResponse{ GranteeUserID: s.GranteeUserID, Role: s.Role, GrantedBy: s.GrantedBy, CreatedAt: s.CreatedAt, }) } _ = uc WriteSuccess(c, http.StatusOK, gin.H{"shares": out}) } } // PutShareRequest 是 PUT shares 的 body(加/更新一個 grantee 授權)。 type PutShareRequest struct { GranteeUserID string `json:"grantee_user_id"` Role string `json:"role,omitempty"` // 'viewer'(預設)| 'editor' } // modelsPutShareHandler 實作 PUT /api/models/:id/shares(owner-only;加/更新授權)。 func modelsPutShareHandler(deps Deps) gin.HandlerFunc { return func(c *gin.Context) { m, uc, ok := requireOwnedModel(c, deps) if !ok { return } var req PutShareRequest if err := json.NewDecoder(c.Request.Body).Decode(&req); err != nil { WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "invalid JSON: "+err.Error(), nil) return } req.GranteeUserID = strings.TrimSpace(req.GranteeUserID) if req.GranteeUserID == "" { WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "grantee_user_id is required", []FieldError{{Field: "grantee_user_id", Message: "cannot be empty"}}) return } // 不能分享給自己(owner 已有完整權限)。 if req.GranteeUserID == uc.UserID { WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "cannot share a model with its owner", nil) return } role := req.Role if role == "" { role = "viewer" } if role != "viewer" && role != "editor" { WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "role must be viewer or editor", []FieldError{{Field: "role", Message: "invalid value"}}) return } ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second) defer cancel() if err := deps.ModelRepo.UpsertShare(ctx, &model.ModelShare{ ModelID: m.ID, GranteeUserID: req.GranteeUserID, Role: role, GrantedBy: uc.UserID, }); err != nil { WriteDBError(c, deps.Logger, "upsert model share", err) return } logOrDefault(deps.Logger).Info("models: share granted", "model_id", m.ID, "user_id", uc.UserID, "grantee", req.GranteeUserID, "role", role, "request_id", RequestIDFrom(c)) WriteSuccess(c, http.StatusOK, ShareResponse{ GranteeUserID: req.GranteeUserID, Role: role, GrantedBy: uc.UserID, CreatedAt: time.Now().UTC(), }) } } // modelsDeleteShareHandler 實作 DELETE /api/models/:id/shares/:userId(owner-only;撤銷授權)。 func modelsDeleteShareHandler(deps Deps) gin.HandlerFunc { return func(c *gin.Context) { m, uc, ok := requireOwnedModel(c, deps) if !ok { return } grantee := c.Param("userId") if grantee == "" { WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "user id required", nil) return } ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second) defer cancel() if err := deps.ModelRepo.DeleteShare(ctx, m.ID, grantee); err != nil { if errors.Is(err, model.ErrNotFound) { WriteError(c, http.StatusNotFound, ErrCodeNotFound, "share not found", nil) return } WriteDBError(c, deps.Logger, "delete model share", err) return } logOrDefault(deps.Logger).Info("models: share revoked", "model_id", m.ID, "user_id", uc.UserID, "grantee", grantee, "request_id", RequestIDFrom(c)) c.Status(http.StatusNoContent) } } // requireOwnedModel 是分享授權管理 API 的共用前置:取 model + 驗 owner-only。 // // 回傳 (model, userContext, ok);ok=false 時已寫好 error response,呼叫端直接 return。 // preset 不可管理分享(無 owner)→ 403。 func requireOwnedModel(c *gin.Context, deps Deps) (*model.Model, *auth.UserContext, bool) { if deps.ModelRepo == nil { WriteNotImplemented(c, "model repo not configured") return nil, nil, false } id := c.Param("id") if id == "" { WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "model id required", nil) return nil, nil, false } if model.IsPresetID(id) { WriteError(c, http.StatusForbidden, ErrCodeForbidden, "preset models cannot be shared", nil) return nil, nil, false } uc, ok := UserContextFrom(c) if !ok || uc.UserID == "" { WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, "missing user context (auth middleware misconfigured?)", nil) return nil, nil, false } ctx, cancel := context.WithTimeout(c.Request.Context(), 3*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 nil, nil, false } WriteDBError(c, deps.Logger, "get model", err) return nil, nil, false } if m.OwnerUserID != uc.UserID { // 分享授權管理是 owner-only 寫入意圖:非 owner 回 403。 WriteError(c, http.StatusForbidden, ErrCodeForbidden, "not owner", nil) return nil, nil, false } return m, uc, true }