// // Copyright 2026 The InfiniFlow Authors. All Rights Reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package dao import ( "errors" "gorm.io/gorm" "ragflow/internal/entity" ) // ErrUserCanvasNotFound is returned by GetByIDForUser when the canvas is // missing or the caller has no read access. We deliberately do not // distinguish "missing" from "forbidden" so the response cannot be used // to enumerate other users' canvas ids — see plan §4.8 (IDOR mitigation). var ErrUserCanvasNotFound = errors.New("user_canvas: not found or access denied") // UserCanvasDAO user canvas data access object type UserCanvasDAO struct{} // NewUserCanvasDAO create user canvas DAO func NewUserCanvasDAO() *UserCanvasDAO { return &UserCanvasDAO{} } // Create user canvas func (dao *UserCanvasDAO) Create(userCanvas *entity.UserCanvas) error { return DB.Create(userCanvas).Error } // GetByID get user canvas by ID func (dao *UserCanvasDAO) GetByID(id string) (*entity.UserCanvas, error) { var canvas entity.UserCanvas err := DB.Where("id = ?", id).First(&canvas).Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrUserCanvasNotFound } return nil, err } return &canvas, nil } // GetByIDForUser fetches a canvas and enforces ownership visibility: // // - canvases with permission="me" or owned by the requesting user are // always returned; // - canvases with permission="team" are returned when the canvas owner // is a tenant the requesting user belongs to (the team membership // predicate mirrors user_canvas.ListByTenantIDs). // // Any other case — missing row, foreign private canvas, foreign team // canvas — yields ErrUserCanvasNotFound. The single error type stops // callers from leaking "exists but not yours" vs "doesn't exist" via the // HTTP status code. func (dao *UserCanvasDAO) GetByIDForUser(canvasID, userID string, tenantIDs []string) (*entity.UserCanvas, error) { if canvasID == "" { return nil, ErrUserCanvasNotFound } if userID == "" { return nil, ErrUserCanvasNotFound } // owner=userID is allowed regardless of permission, matching the // ListByTenantIDs predicate used by GET /api/v1/agents. ownerOrTeam := DB.Where("user_id = ?", userID) if len(tenantIDs) > 0 { ownerOrTeam = ownerOrTeam.Or( "user_id IN ? AND permission = ?", tenantIDs, "team", ) } var canvas entity.UserCanvas err := DB.Where("id = ?", canvasID).Where(ownerOrTeam).First(&canvas).Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrUserCanvasNotFound } return nil, err } return &canvas, nil } // Update update user canvas func (dao *UserCanvasDAO) Update(userCanvas *entity.UserCanvas) error { return DB.Save(userCanvas).Error } // Delete delete user canvas func (dao *UserCanvasDAO) Delete(id string) error { // gorm v2 treats the first non-int inline arg as a column name, not a // primary-key value — passing `id` verbatim produced WHERE ID = ? // and made MySQL complain about an unknown "AGENT_ID" column. The // explicit Where+Delete form is the same pattern used by // API4ConversationDAO.Delete (see api_token.go:142-144). return DB.Where("id = ?", id).Delete(&entity.UserCanvas{}).Error } // UpdateTx is the transactional variant of Update. Callers wrap a sequence // of *Tx calls in dao.DB.Transaction(func(tx *gorm.DB) error { ... }) so // multi-step writes (e.g. publish-agent, delete-agent) are atomic. func (dao *UserCanvasDAO) UpdateTx(tx *gorm.DB, userCanvas *entity.UserCanvas) error { return tx.Save(userCanvas).Error } // DeleteTx is the transactional variant of Delete. The canvas must // already be loaded and access-checked by the caller. func (dao *UserCanvasDAO) DeleteTx(tx *gorm.DB, id string) error { // See Delete() above for the rationale on Where("id = ?", id). return tx.Where("id = ?", id).Delete(&entity.UserCanvas{}).Error } // GetByUserAndTitle returns the canvas matching user_id + title (and // optional canvas_category), or (nil, nil) when no such canvas exists. // Used by service.AgentService.CreateAgent to enforce the "title // already exists" rule that the Python agent API mirrors with // UserCanvasService.query(user_id=..., title=...). func (dao *UserCanvasDAO) GetByUserAndTitle(userID, title, canvasCategory string) (*entity.UserCanvas, error) { q := DB.Where("user_id = ? AND title = ?", userID, title) if canvasCategory != "" { q = q.Where("canvas_category = ?", canvasCategory) } var row entity.UserCanvas if err := q.First(&row).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } return nil, err } return &row, nil } // GetList get canvases list with pagination and filtering // Similar to Python UserCanvasService.get_list func (dao *UserCanvasDAO) GetList(tenantID string, pageNumber, itemsPerPage int, orderby string, desc bool, id, title string, canvasCategory string) ([]*entity.UserCanvas, error) { query := DB.Model(&entity.UserCanvas{}). Where("user_id = ?", tenantID) if id != "" { query = query.Where("id = ?", id) } if title != "" { query = query.Where("title = ?", title) } if canvasCategory != "" { query = query.Where("canvas_category = ?", canvasCategory) } else { // Default to agent category query = query.Where("canvas_category = ?", "agent_canvas") } // Order by if desc { query = query.Order(orderby + " DESC") } else { query = query.Order(orderby + " ASC") } // Pagination if pageNumber > 0 && itemsPerPage > 0 { offset := (pageNumber - 1) * itemsPerPage query = query.Offset(offset).Limit(itemsPerPage) } var canvases []*entity.UserCanvas err := query.Find(&canvases).Error return canvases, err } // GetAllCanvasesByTenantIDs get all permitted canvases by tenant IDs // Similar to Python UserCanvasService.get_all_agents_by_tenant_ids func (dao *UserCanvasDAO) GetAllCanvasesByTenantIDs(tenantIDs []string, userID string) ([]*CanvasBasicInfo, error) { query := DB.Model(&entity.UserCanvas{}). Select("id, avatar, title, permission, canvas_type, canvas_category"). Where("user_id IN (?) AND permission = ?", tenantIDs, "team"). Or("user_id = ?", userID). Order("create_time ASC") var results []*CanvasBasicInfo err := query.Scan(&results).Error return results, err } // ListByTenantIDs lists agent canvases accessible to the given owner IDs with optional // keyword filter, pagination, and ordering. // Mirrors Python UserCanvasService.get_by_tenant_ids (list route only). func (dao *UserCanvasDAO) ListByTenantIDs(ownerIDs []string, userID string, page, pageSize int, orderby string, desc bool, keywords string, canvasCategory string) ([]*entity.UserCanvas, int64, error) { if len(ownerIDs) == 0 { return nil, 0, nil } // Canvases owned by any of the ownerIDs that are "team"-permission, plus all owned by userID. base := DB.Model(&entity.UserCanvas{}). Where( DB.Where("user_id IN ? AND permission = ?", ownerIDs, "team"). Or("user_id = ?", userID), ) if canvasCategory != "" { base = base.Where("canvas_category = ?", canvasCategory) } else { base = base.Where("canvas_category = ?", "agent_canvas") } if keywords != "" { like := "%" + keywords + "%" base = base.Where("title LIKE ?", like) } var total int64 if err := base.Count(&total).Error; err != nil { return nil, 0, err } order := orderby if desc { order += " DESC" } else { order += " ASC" } query := base.Order(order) if page > 0 && pageSize > 0 { query = query.Offset((page - 1) * pageSize).Limit(pageSize) } var canvases []*entity.UserCanvas if err := query.Find(&canvases).Error; err != nil { return nil, 0, err } return canvases, total, nil } // GetByCanvasID get user canvas by canvas ID (alias for GetByID) func (dao *UserCanvasDAO) GetByCanvasID(canvasID string) (*entity.UserCanvas, error) { return dao.GetByID(canvasID) } // CanvasBasicInfo basic canvas information for list responses type CanvasBasicInfo struct { ID string `gorm:"column:id" json:"id"` Avatar *string `gorm:"column:avatar" json:"avatar,omitempty"` Title *string `gorm:"column:title" json:"title,omitempty"` Permission string `gorm:"column:permission" json:"permission"` CanvasType *string `gorm:"column:canvas_type" json:"canvas_type,omitempty"` CanvasCategory string `gorm:"column:canvas_category" json:"canvas_category"` } // DeleteByUserID deletes all canvases by user ID (hard delete) func (dao *UserCanvasDAO) DeleteByUserID(userID string) (int64, error) { result := DB.Unscoped().Where("user_id = ?", userID).Delete(&entity.UserCanvas{}) return result.RowsAffected, result.Error } // GetAllCanvasIDsByUserID gets all canvas IDs by user ID func (dao *UserCanvasDAO) GetAllCanvasIDsByUserID(userID string) ([]string, error) { var canvasIDs []string err := DB.Model(&entity.UserCanvas{}). Where("user_id = ?", userID). Pluck("id", &canvasIDs).Error return canvasIDs, err } // UpdateDSL updates a canvas DSL by canvas ID. func (dao *UserCanvasDAO) UpdateDSL(canvasID string, dsl entity.JSONMap) (int64, error) { result := DB.Model(&entity.UserCanvas{}).Where("id = ?", canvasID).Update("dsl", dsl) return result.RowsAffected, result.Error } // UpdateTags updates a canvas's comma-separated tags by canvas ID. func (dao *UserCanvasDAO) UpdateTags(canvasID, tags string) (int64, error) { result := DB.Model(&entity.UserCanvas{}).Where("id = ?", canvasID).Update("tags", tags) return result.RowsAffected, result.Error }