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>
792 lines
26 KiB
Go
792 lines
26 KiB
Go
// 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
|
||
}
|