visionA/visionA-backend/internal/api/models_sharing.go
jim800121chen 47a1d4d0ef feat(backend): 設備註冊 + 模型共享 backend(B 設備管理 + C 模型共享)
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>
2026-08-02 16:29:50 +08:00

792 lines
26 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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
//
// 核心安全設計:所有可見性判斷走唯一的 canAccessModelsingle source of truth避免
// profile / download 兩處邏輯漂移TDD §6 SEC-2enumeration 防護一律回 404SEC-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. ownerm.OwnerUserID == uc.UserID→ AccessOwner
// 2. share 命中 → editor / viewer依 share.role
// 3. visibility=public → viewer
// 4. visibility=tenant 且 owner.org_id == uc.OrgID 且兩者皆非空 → viewerSEC-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 claimmiddleware 未填 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 是共享庫列表的一列 DTOapi §1
//
// owner 只揭露 id/name/is_me不揭露 owner email不含 storage_key / faa_object_keySEC-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,
}
// limitclamp 到 1100。
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 filtertrue/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_accessowner 由 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 游標;格式錯誤回 errorhandler 轉 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 payloadapi §2
//
// 絕不含 storage_key / faa_object_key / owner email / file_checksumSEC-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防 enumerationSEC-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.nameowner 未設 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/visibilityowner-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-onlySEC-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 是一筆分享授權 DTOowner 檢視清單用)。
//
// 只揭露 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/sharesowner-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/sharesowner-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/:userIdowner-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
}