Files
ragflow/internal/service/file.go

1460 lines
43 KiB
Go
Raw Normal View History

//
// 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 service
import (
"context"
"encoding/base64"
"fmt"
feat(go-api): Align document metadata batch APIs and upload_info with Python (#16269) ## Summary Align the Go implementations of these APIs with the Python behavior: - `POST /api/v1/datasets/:dataset_id/metadata/update` - `PATCH /api/v1/datasets/:dataset_id/documents/metadatas` - `POST /api/v1/documents/upload` ## What changed - Added the Go routes and handlers for the 3 APIs. - Aligned batch document metadata updates with Python semantics: - support `match` in update items - support list append / replace behavior - support deleting specific list values - remove metadata entirely when it becomes empty - create metadata for documents that previously had none when updates apply - count `updated` only when a document actually changes - Aligned `documents/upload` file uploads with Python-style `upload_info` behavior: - store upload-info blobs in the per-user downloads bucket - return lightweight upload descriptors instead of normal file-management responses - Improved URL upload behavior: - SSRF-guarded fetch with redirect validation - redirect limit aligned to Python behavior - normalize filename and MIME type - add `.pdf` when the fetched content is PDF - normalize HTML content into readable text instead of storing raw HTML shells ## Validation ### Unit tests Passed: - `go test ./internal/service` - `go test ./internal/handler` Also verified targeted cases for: - batch metadata update semantics - upload_info URL handling - upload_info download bucket behavior ### curl checks Verified the new Go endpoints with `curl` and compared the response shape and behavior with Python for: - `POST /api/v1/datasets/{dataset_id}/metadata/update` - `PATCH /api/v1/datasets/{dataset_id}/documents/metadatas` - `POST /api/v1/documents/upload` The Go responses were checked against Python for: - argument validation - success response shape - metadata update results - upload_info result structure - file vs URL input handling
2026-06-24 14:52:47 +08:00
"html"
"io"
"mime/multipart"
feat(go-api): Align document metadata batch APIs and upload_info with Python (#16269) ## Summary Align the Go implementations of these APIs with the Python behavior: - `POST /api/v1/datasets/:dataset_id/metadata/update` - `PATCH /api/v1/datasets/:dataset_id/documents/metadatas` - `POST /api/v1/documents/upload` ## What changed - Added the Go routes and handlers for the 3 APIs. - Aligned batch document metadata updates with Python semantics: - support `match` in update items - support list append / replace behavior - support deleting specific list values - remove metadata entirely when it becomes empty - create metadata for documents that previously had none when updates apply - count `updated` only when a document actually changes - Aligned `documents/upload` file uploads with Python-style `upload_info` behavior: - store upload-info blobs in the per-user downloads bucket - return lightweight upload descriptors instead of normal file-management responses - Improved URL upload behavior: - SSRF-guarded fetch with redirect validation - redirect limit aligned to Python behavior - normalize filename and MIME type - add `.pdf` when the fetched content is PDF - normalize HTML content into readable text instead of storing raw HTML shells ## Validation ### Unit tests Passed: - `go test ./internal/service` - `go test ./internal/handler` Also verified targeted cases for: - batch metadata update semantics - upload_info URL handling - upload_info download bucket behavior ### curl checks Verified the new Go endpoints with `curl` and compared the response shape and behavior with Python for: - `POST /api/v1/datasets/{dataset_id}/metadata/update` - `PATCH /api/v1/datasets/{dataset_id}/documents/metadatas` - `POST /api/v1/documents/upload` The Go responses were checked against Python for: - argument validation - success response shape - metadata update results - upload_info result structure - file vs URL input handling
2026-06-24 14:52:47 +08:00
"net"
"net/http"
"net/url"
"path/filepath"
"ragflow/internal/common"
"ragflow/internal/dao"
"ragflow/internal/entity"
"ragflow/internal/parser/parser"
"ragflow/internal/storage"
"ragflow/internal/utility"
feat(go-api): Align document metadata batch APIs and upload_info with Python (#16269) ## Summary Align the Go implementations of these APIs with the Python behavior: - `POST /api/v1/datasets/:dataset_id/metadata/update` - `PATCH /api/v1/datasets/:dataset_id/documents/metadatas` - `POST /api/v1/documents/upload` ## What changed - Added the Go routes and handlers for the 3 APIs. - Aligned batch document metadata updates with Python semantics: - support `match` in update items - support list append / replace behavior - support deleting specific list values - remove metadata entirely when it becomes empty - create metadata for documents that previously had none when updates apply - count `updated` only when a document actually changes - Aligned `documents/upload` file uploads with Python-style `upload_info` behavior: - store upload-info blobs in the per-user downloads bucket - return lightweight upload descriptors instead of normal file-management responses - Improved URL upload behavior: - SSRF-guarded fetch with redirect validation - redirect limit aligned to Python behavior - normalize filename and MIME type - add `.pdf` when the fetched content is PDF - normalize HTML content into readable text instead of storing raw HTML shells ## Validation ### Unit tests Passed: - `go test ./internal/service` - `go test ./internal/handler` Also verified targeted cases for: - batch metadata update semantics - upload_info URL handling - upload_info download bucket behavior ### curl checks Verified the new Go endpoints with `curl` and compared the response shape and behavior with Python for: - `POST /api/v1/datasets/{dataset_id}/metadata/update` - `PATCH /api/v1/datasets/{dataset_id}/documents/metadatas` - `POST /api/v1/documents/upload` The Go responses were checked against Python for: - argument validation - success response shape - metadata update results - upload_info result structure - file vs URL input handling
2026-06-24 14:52:47 +08:00
"regexp"
"strings"
"time"
)
// FileService file service
type FileService struct {
fileDAO *dao.FileDAO
file2DocumentDAO *dao.File2DocumentDAO
documentService *DocumentService
}
// NewFileService create file service
func NewFileService() *FileService {
return &FileService{
fileDAO: dao.NewFileDAO(),
file2DocumentDAO: dao.NewFile2DocumentDAO(),
documentService: NewDocumentService(),
}
}
// FileInfo file info with additional fields
type FileInfo struct {
*entity.File
Size int64 `json:"size"`
KbsInfo []map[string]interface{} `json:"kbs_info"`
HasChildFolder bool `json:"has_child_folder,omitempty"`
}
// ListFilesResponse list files response
type ListFilesResponse struct {
Total int64 `json:"total"`
Files []map[string]interface{} `json:"files"`
ParentFolder map[string]interface{} `json:"parent_folder"`
}
// GetRootFolder gets or creates root folder for tenant
func (s *FileService) GetRootFolder(tenantID string) (map[string]interface{}, error) {
file, err := s.fileDAO.GetRootFolder(tenantID)
if err != nil {
return nil, err
}
return s.toFileResponse(file), nil
}
// ListFiles lists files by parent folder ID (matching Python /files endpoint)
// This method includes init_dataset_docs initialization when parent_id is empty
func (s *FileService) ListFiles(tenantID, pfID string, page, pageSize int, orderby string, desc bool, keywords string) (*ListFilesResponse, error) {
// If pfID is empty, get root folder and initialize dataset docs
if pfID == "" {
rootFolder, err := s.fileDAO.GetRootFolder(tenantID)
if err != nil {
return nil, fmt.Errorf("failed to get root folder: %w", err)
}
pfID = rootFolder.ID
// Initialize dataset docs (matching Python init_knowledgebase_docs logic)
if err := s.initDatasetDocs(pfID, tenantID); err != nil {
return nil, fmt.Errorf("failed to initialize dataset docs: %w", err)
}
// Initialize skills folder (matching Python init_skills_folder logic)
if err := s.initSkillsFolder(pfID, tenantID); err != nil {
return nil, fmt.Errorf("failed to initialize skills folder: %w", err)
}
}
// Check if parent folder exists
if _, err := s.fileDAO.GetByID(pfID); err != nil {
return nil, fmt.Errorf("Folder not found!")
}
// Get files by parent folder ID
files, total, err := s.fileDAO.GetByPfID(tenantID, pfID, page, pageSize, orderby, desc, keywords)
if err != nil {
return nil, err
}
// Get parent folder
parentFolder, err := s.fileDAO.GetParentFolder(pfID)
if err != nil {
return nil, fmt.Errorf("File not found!")
}
// Process files to add additional info, deduplicating by ID as a safety net
// against any leftover duplicate rows (e.g. duplicate 'skills' or '.knowledgebase' folders).
fileResponses := make([]map[string]interface{}, 0, len(files))
seenIDs := make(map[string]struct{})
for _, file := range files {
if _, ok := seenIDs[file.ID]; ok {
continue
}
seenIDs[file.ID] = struct{}{}
fileInfo := s.toFileInfo(file)
// If folder, calculate size and check for child folders
if file.Type == FileTypeFolder {
folderSize, err := s.fileDAO.GetFolderSize(file.ID)
if err == nil {
fileInfo.Size = folderSize
}
hasChild, err := s.fileDAO.HasChildFolder(file.ID)
if err == nil {
fileInfo.HasChildFolder = hasChild
}
fileInfo.KbsInfo = []map[string]interface{}{}
} else {
// Get KB info for non-folder files
kbsInfo, err := s.file2DocumentDAO.GetKBInfoByFileID(file.ID)
if err != nil {
kbsInfo = []map[string]interface{}{}
}
fileInfo.KbsInfo = kbsInfo
}
fileResponses = append(fileResponses, s.fileInfoToResponse(fileInfo))
}
return &ListFilesResponse{
Total: total,
Files: fileResponses,
ParentFolder: s.toFileResponse(parentFolder),
}, nil
}
// initDatasetDocs initializes dataset documents for tenant
// This matches Python's FileService.init_dataset_docs method
func (s *FileService) initDatasetDocs(rootID, tenantID string) error {
return s.fileDAO.InitDatasetDocs(rootID, tenantID, s.file2DocumentDAO)
}
// DatasetFolderName is the folder name for dataset
const DatasetFolderName = ".knowledgebase"
// SkillsFolderName is the folder name for skills
const SkillsFolderName = "skills"
// initSkillsFolder initializes the skills folder under the root folder.
// Deduplicates duplicate entries that may have been created by
// concurrent race conditions (TOCTOU).
func (s *FileService) initSkillsFolder(rootID, tenantID string) error {
existing := s.fileDAO.Query(SkillsFolderName, rootID, tenantID)
if len(existing) > 0 {
if len(existing) > 1 {
common.Logger.Warn(fmt.Sprintf(
"Found %d duplicate '%s' folders under root %s, keeping only the first",
len(existing), SkillsFolderName, rootID,
))
keepID := existing[0].ID
for _, dup := range existing[1:] {
children, _ := s.fileDAO.ListAllFilesByParentID(dup.ID)
for _, child := range children {
s.fileDAO.UpdateByID(child.ID, map[string]interface{}{"parent_id": keepID})
}
if delErr := s.fileDAO.Delete(dup.ID); delErr != nil {
common.Logger.Warn(fmt.Sprintf("Failed to delete duplicate skills folder %s: %v", dup.ID, delErr))
}
}
}
return nil
}
folder := &entity.File{
ID: utility.GenerateToken(),
ParentID: rootID,
TenantID: tenantID,
CreatedBy: tenantID,
Name: SkillsFolderName,
Type: FileTypeFolder,
Size: 0,
SourceType: "",
}
return s.fileDAO.Insert(folder)
}
// FileSourceDataset represents dataset as file source
const FileSourceDataset = "knowledgebase"
feat(go-api): Align document metadata batch APIs and upload_info with Python (#16269) ## Summary Align the Go implementations of these APIs with the Python behavior: - `POST /api/v1/datasets/:dataset_id/metadata/update` - `PATCH /api/v1/datasets/:dataset_id/documents/metadatas` - `POST /api/v1/documents/upload` ## What changed - Added the Go routes and handlers for the 3 APIs. - Aligned batch document metadata updates with Python semantics: - support `match` in update items - support list append / replace behavior - support deleting specific list values - remove metadata entirely when it becomes empty - create metadata for documents that previously had none when updates apply - count `updated` only when a document actually changes - Aligned `documents/upload` file uploads with Python-style `upload_info` behavior: - store upload-info blobs in the per-user downloads bucket - return lightweight upload descriptors instead of normal file-management responses - Improved URL upload behavior: - SSRF-guarded fetch with redirect validation - redirect limit aligned to Python behavior - normalize filename and MIME type - add `.pdf` when the fetched content is PDF - normalize HTML content into readable text instead of storing raw HTML shells ## Validation ### Unit tests Passed: - `go test ./internal/service` - `go test ./internal/handler` Also verified targeted cases for: - batch metadata update semantics - upload_info URL handling - upload_info download bucket behavior ### curl checks Verified the new Go endpoints with `curl` and compared the response shape and behavior with Python for: - `POST /api/v1/datasets/{dataset_id}/metadata/update` - `PATCH /api/v1/datasets/{dataset_id}/documents/metadatas` - `POST /api/v1/documents/upload` The Go responses were checked against Python for: - argument validation - success response shape - metadata update results - upload_info result structure - file vs URL input handling
2026-06-24 14:52:47 +08:00
var (
assertURLSafe = utility.AssertURLSafe
pinnedHTTPClient = utility.PinnedHTTPClient
)
// toFileResponse converts file model to response format
func (s *FileService) toFileResponse(file *entity.File) map[string]interface{} {
result := map[string]interface{}{
"id": file.ID,
"parent_id": file.ParentID,
"tenant_id": file.TenantID,
"created_by": file.CreatedBy,
"name": file.Name,
"size": file.Size,
"type": file.Type,
"create_time": file.CreateTime,
"update_time": file.UpdateTime,
}
if file.Location != nil {
result["location"] = *file.Location
}
result["source_type"] = file.SourceType
return result
}
// toFileInfo converts file model to FileInfo
func (s *FileService) toFileInfo(file *entity.File) *FileInfo {
return &FileInfo{
File: file,
Size: file.Size,
KbsInfo: []map[string]interface{}{},
HasChildFolder: false,
}
}
// fileInfoToResponse converts FileInfo to response map
func (s *FileService) fileInfoToResponse(info *FileInfo) map[string]interface{} {
result := map[string]interface{}{
"id": info.File.ID,
"parent_id": info.File.ParentID,
"tenant_id": info.File.TenantID,
"created_by": info.File.CreatedBy,
"name": info.File.Name,
"size": info.Size,
"type": info.File.Type,
"create_time": info.File.CreateTime,
"update_time": info.File.UpdateTime,
"kbs_info": info.KbsInfo,
}
if info.File.Location != nil {
result["location"] = *info.File.Location
}
result["source_type"] = info.File.SourceType
if info.File.Type == "folder" {
result["has_child_folder"] = info.HasChildFolder
}
return result
}
// GetParentFolder gets parent folder of a file with permission check
func (s *FileService) GetParentFolder(userID, fileID string) (map[string]interface{}, error) {
// Get file
file, err := s.fileDAO.GetByID(fileID)
if err != nil {
return nil, err
}
// Permission check
if !s.checkFileTeamPermission(file, userID) {
return nil, fmt.Errorf("No authorization.")
}
// Get parent folder
parentFolder, err := s.fileDAO.GetParentFolder(fileID)
if err != nil {
return nil, err
}
return s.toFileResponse(parentFolder), nil
}
// GetAllParentFolders gets all parent folders in path with permission check
func (s *FileService) GetAllParentFolders(userID, fileID string) ([]map[string]interface{}, error) {
// Get file
file, err := s.fileDAO.GetByID(fileID)
if err != nil {
return nil, err
}
// Permission check
if !s.checkFileTeamPermission(file, userID) {
return nil, fmt.Errorf("No authorization.")
}
// Get all parent folders
parentFolders, err := s.fileDAO.GetAllParentFolders(fileID)
if err != nil {
return nil, err
}
// Convert to response format
result := make([]map[string]interface{}, len(parentFolders))
for i, folder := range parentFolders {
result[i] = s.toFileResponse(folder)
}
return result, nil
}
const (
FileTypeFolder = "folder"
FileTypeVirtual = "virtual"
)
// GetDocCount gets document count for a tenant
func (s *FileService) GetDocCount(tenantID string) (int64, error) {
documentDAO := dao.NewDocumentDAO()
return documentDAO.CountByTenantID(tenantID)
}
// UploadFile uploads files to a folder
func (s *FileService) UploadFile(tenantID, parentID string, files []*multipart.FileHeader) ([]map[string]interface{}, error) {
if parentID == "" {
rootFolder, err := s.fileDAO.GetRootFolder(tenantID)
if err != nil {
return nil, fmt.Errorf("failed to get root folder: %w", err)
}
parentID = rootFolder.ID
}
_, err := s.fileDAO.GetByID(parentID)
if err != nil {
return nil, fmt.Errorf("Can't find this folder!")
}
maxFileNumPerUser := common.GetEnv(common.EnvMaxFileNumPerUser)
if maxFileNumPerUser != "" {
var maxNum int64
if _, err = fmt.Sscanf(maxFileNumPerUser, "%d", &maxNum); err == nil && maxNum > 0 {
var docCount int64
docCount, err = s.GetDocCount(tenantID)
if err != nil {
return nil, fmt.Errorf("failed to get document count: %w", err)
}
if docCount >= maxNum {
return nil, fmt.Errorf("Exceed the maximum file number of a free user!")
}
}
}
storageImpl := storage.GetStorageFactory().GetStorage()
if storageImpl == nil {
return nil, fmt.Errorf("storage not initialized")
}
var result []map[string]interface{}
for _, fileHeader := range files {
filename := fileHeader.Filename
if filename == "" {
return nil, fmt.Errorf("No file selected!")
}
fileType := utility.FilenameType(filename)
fileObjNames := s.parseFilePath(filename)
var idList []string
idList, err = s.fileDAO.GetIDListByID(parentID, fileObjNames, 1, []string{parentID})
if err != nil {
return nil, fmt.Errorf("failed to get file ID list: %w", err)
}
var lastFolder *entity.File
if len(fileObjNames) != len(idList)-1 {
lastID := idList[len(idList)-1]
lastFolder, err = s.fileDAO.GetByID(lastID)
if err != nil {
return nil, fmt.Errorf("Folder not found!")
}
var createdFolder *entity.File
createdFolder, err = s.createFolderRecursive(lastFolder, fileObjNames, len(idList), tenantID)
if err != nil {
return nil, fmt.Errorf("failed to create folder: %w", err)
}
lastFolder = createdFolder
} else {
lastID := idList[len(idList)-2]
lastFolder, err = s.fileDAO.GetByID(lastID)
if err != nil {
return nil, fmt.Errorf("Folder not found!")
}
}
location := fileObjNames[len(fileObjNames)-1]
for storageImpl.ObjExist(lastFolder.ID, location) {
location += "_"
}
src, err := fileHeader.Open()
if err != nil {
return nil, fmt.Errorf("failed to open uploaded file: %w", err)
}
defer src.Close()
data, err := io.ReadAll(src)
if err != nil {
return nil, fmt.Errorf("failed to read file data: %w", err)
}
if err = storageImpl.Put(lastFolder.ID, location, data); err != nil {
return nil, fmt.Errorf("failed to store file: %w", err)
}
uniqueName := s.getUniqueFilename(fileObjNames[len(fileObjNames)-1], lastFolder.ID, tenantID)
fileRecord := &entity.File{
ID: utility.GenerateToken(),
ParentID: lastFolder.ID,
TenantID: tenantID,
CreatedBy: tenantID,
Name: uniqueName,
Location: &location,
Size: int64(len(data)),
Type: string(fileType),
SourceType: "",
}
if err = s.fileDAO.Insert(fileRecord); err != nil {
return nil, fmt.Errorf("failed to insert file record: %w", err)
}
result = append(result, s.toFileResponse(fileRecord))
}
return result, nil
}
feat(go-api): Align document metadata batch APIs and upload_info with Python (#16269) ## Summary Align the Go implementations of these APIs with the Python behavior: - `POST /api/v1/datasets/:dataset_id/metadata/update` - `PATCH /api/v1/datasets/:dataset_id/documents/metadatas` - `POST /api/v1/documents/upload` ## What changed - Added the Go routes and handlers for the 3 APIs. - Aligned batch document metadata updates with Python semantics: - support `match` in update items - support list append / replace behavior - support deleting specific list values - remove metadata entirely when it becomes empty - create metadata for documents that previously had none when updates apply - count `updated` only when a document actually changes - Aligned `documents/upload` file uploads with Python-style `upload_info` behavior: - store upload-info blobs in the per-user downloads bucket - return lightweight upload descriptors instead of normal file-management responses - Improved URL upload behavior: - SSRF-guarded fetch with redirect validation - redirect limit aligned to Python behavior - normalize filename and MIME type - add `.pdf` when the fetched content is PDF - normalize HTML content into readable text instead of storing raw HTML shells ## Validation ### Unit tests Passed: - `go test ./internal/service` - `go test ./internal/handler` Also verified targeted cases for: - batch metadata update semantics - upload_info URL handling - upload_info download bucket behavior ### curl checks Verified the new Go endpoints with `curl` and compared the response shape and behavior with Python for: - `POST /api/v1/datasets/{dataset_id}/metadata/update` - `PATCH /api/v1/datasets/{dataset_id}/documents/metadatas` - `POST /api/v1/documents/upload` The Go responses were checked against Python for: - argument validation - success response shape - metadata update results - upload_info result structure - file vs URL input handling
2026-06-24 14:52:47 +08:00
// UploadInfos mirrors Python's upload_info file branch: store raw bytes in the
// per-user downloads bucket and return lightweight upload descriptors instead
// of creating full File rows in the file-management tree.
func (s *FileService) UploadInfos(userID string, files []*multipart.FileHeader) ([]map[string]interface{}, error) {
storageImpl := storage.GetStorageFactory().GetStorage()
if storageImpl == nil {
return nil, fmt.Errorf("storage not initialized")
}
results := make([]map[string]interface{}, 0, len(files))
for _, fileHeader := range files {
filename := fileHeader.Filename
if err := s.checkUploadInfoHealth(userID, filename); err != nil {
return nil, err
}
src, err := fileHeader.Open()
if err != nil {
return nil, fmt.Errorf("failed to open uploaded file: %w", err)
}
data, readErr := readUploadInfoData(src)
src.Close()
if readErr != nil {
return nil, fmt.Errorf("failed to read file data: %w", readErr)
}
contentType := fileHeader.Header.Get("Content-Type")
if contentType == "" {
contentType = http.DetectContentType(data)
}
filename, contentType, data = normalizeUploadInfoContent(filename, contentType, data)
resp, err := s.storeUploadInfoBlob(storageImpl, userID, filename, contentType, data)
if err != nil {
return nil, err
}
results = append(results, resp)
}
return results, nil
}
func readUploadInfoData(r io.Reader) ([]byte, error) {
limited := &io.LimitedReader{R: r, N: maxRemoteFileSize + 1}
data, err := io.ReadAll(limited)
if err != nil {
return nil, err
}
if int64(len(data)) > maxRemoteFileSize {
return nil, fmt.Errorf("file size exceeds %d bytes", maxRemoteFileSize)
}
return data, nil
}
func (s *FileService) parseFilePath(filename string) []string {
filename = strings.TrimPrefix(filename, "/")
parts := strings.Split(filename, "/")
var result []string
for _, part := range parts {
if part != "" {
result = append(result, part)
}
}
return result
}
func (s *FileService) createFolderRecursive(parentFolder *entity.File, names []string, count int, tenantID string) (*entity.File, error) {
if count > len(names)-2 {
return parentFolder, nil
}
newFolder, err := s.fileDAO.CreateFolder(parentFolder.ID, tenantID, names[count], FileTypeFolder)
if err != nil {
return nil, err
}
return s.createFolderRecursive(newFolder, names, count+1, tenantID)
}
func (s *FileService) getUniqueFilename(name, parentID, tenantID string) string {
existingFiles := s.fileDAO.Query(name, parentID, tenantID)
if len(existingFiles) == 0 {
return name
}
base := filepath.Base(name)
ext := filepath.Ext(name)
nameWithoutExt := strings.TrimSuffix(base, ext)
counter := 1
for {
newName := fmt.Sprintf("%s_%d%s", nameWithoutExt, counter, ext)
existingFiles = s.fileDAO.Query(newName, parentID, tenantID)
if len(existingFiles) == 0 {
return newName
}
counter++
}
}
// CreateFolder creates a new folder or virtual file
func (s *FileService) CreateFolder(tenantID, name, parentID, fileType string) (map[string]interface{}, error) {
if parentID == "" {
rootFolder, err := s.fileDAO.GetRootFolder(tenantID)
if err != nil {
return nil, fmt.Errorf("failed to get root folder: %w", err)
}
parentID = rootFolder.ID
}
if !s.fileDAO.IsParentFolderExist(parentID) {
return nil, fmt.Errorf("Parent Folder Doesn't Exist!")
}
existingFiles := s.fileDAO.Query(name, parentID, tenantID)
if len(existingFiles) > 0 {
return nil, fmt.Errorf("Duplicated folder name in the same folder.")
}
if fileType == "" {
fileType = FileTypeVirtual
}
if fileType == FileTypeFolder {
fileType = FileTypeFolder
} else {
fileType = FileTypeVirtual
}
folder, err := s.fileDAO.CreateFolder(parentID, tenantID, name, fileType)
if err != nil {
return nil, fmt.Errorf("failed to create folder: %w", err)
}
return s.toFileResponse(folder), nil
}
// DeleteFiles deletes files by IDs
// Returns (success, message) where success is true if all files were deleted
func (s *FileService) DeleteFiles(ctx context.Context, uid string, fileIDs []string) (bool, string) {
for _, fileID := range fileIDs {
// 1. Get file
file, err := s.fileDAO.GetByID(fileID)
if err != nil || file == nil {
return false, "File or Folder not found!"
}
// 2. Check tenant_id
if file.TenantID == "" {
return false, "Tenant not found!"
}
// Block root-folder deletion (root folders have parent_id == id)
if file.ParentID == file.ID {
return false, "Root folder cannot be deleted."
}
// 3. Permission check
if !s.checkFileTeamPermission(file, uid) {
return false, "No authorization."
}
// 4. Skip dataset source files
if file.SourceType == FileSourceDataset {
continue
}
// 5. Delete based on type
if file.Type == FileTypeFolder {
if err := s.deleteFolderRecursive(ctx, file, uid); err != nil {
return false, fmt.Sprintf("Failed to delete folder: %v", err)
}
} else {
if err := s.deleteSingleFile(ctx, file); err != nil {
return false, fmt.Sprintf("Failed to delete file: %v", err)
}
}
}
return true, ""
}
// checkFileTeamPermission checks if user has permission to access the file
// Matches Python's check_file_team_permission function
func (s *FileService) checkFileTeamPermission(file *entity.File, uid string) bool {
// File's tenant directly authorized
if file.TenantID == uid {
return true
}
// Check KB permissions
datasetIDs, err := s.fileDAO.GetDatasetIDByFileID(file.ID)
if err != nil || len(datasetIDs) == 0 {
return false
}
kbDAO := dao.NewKnowledgebaseDAO()
for _, datasetID := range datasetIDs {
ds, err := kbDAO.GetByID(datasetID)
if err != nil || ds == nil {
continue
}
// Check KB tenant permission
if s.checkDatasetTeamPermission(ds, uid) {
return true
}
}
return false
}
// checkDatasetTeamPermission checks if user has permission to access the dataset
// Matches Python's check_kb_team_permission function
func (s *FileService) checkDatasetTeamPermission(ds *entity.Knowledgebase, uid string) bool {
return hasKBTeamPermission(ds, uid, dao.NewTenantDAO())
}
// deleteSingleFile deletes a single file (not folder)
// Matches Python's _delete_single_file function
func (s *FileService) deleteSingleFile(ctx context.Context, file *entity.File) error {
// 1. Delete storage object
if file.Location != nil && *file.Location != "" {
storageImpl := storage.GetStorageFactory().GetStorage()
if storageImpl != nil {
if err := storageImpl.Remove(file.ParentID, *file.Location); err != nil {
common.Logger.Error(fmt.Sprintf("Fail to remove object: %s/%s, error: %v", file.ParentID, *file.Location, err))
}
}
}
// 2. Handle associated documents
informs, err := s.file2DocumentDAO.GetByFileID(file.ID)
if err != nil {
return fmt.Errorf("failed to get file2document mappings: %w", err)
}
if len(informs) > 0 {
for _, inform := range informs {
if inform.DocumentID == nil {
continue
}
docID := *inform.DocumentID
if err := s.documentService.RemoveDocumentKeepFile(docID); err != nil {
common.Logger.Error(fmt.Sprintf("Fail to remove document: %s, error: %v", docID, err))
}
}
// Delete file2document mapping (outside the loop, called once - matching Python behavior)
if err := s.file2DocumentDAO.DeleteByFileID(file.ID); err != nil {
return fmt.Errorf("failed to delete file2document mapping: %w", err)
}
}
// 3. Delete file record
if err := s.fileDAO.Delete(file.ID); err != nil {
return err
}
return nil
}
// deleteFolderRecursive recursively deletes a folder and its contents
// Matches Python's _delete_folder_recursive function
func (s *FileService) deleteFolderRecursive(ctx context.Context, folder *entity.File, uid string) error {
// Get all sub-files
subFiles, err := s.fileDAO.ListByParentID(folder.ID)
if err != nil {
return err
}
for _, subFile := range subFiles {
if subFile.Type == FileTypeFolder {
// Recursively delete subfolder
if err := s.deleteFolderRecursive(ctx, subFile, uid); err != nil {
return err
}
} else {
// Delete single file
if err := s.deleteSingleFile(ctx, subFile); err != nil {
return err
}
}
}
// Delete the folder itself
if err := s.fileDAO.Delete(folder.ID); err != nil {
return err
}
return nil
}
// MoveFileReq represents the request body for move files operation
type MoveFileReq struct {
SrcFileIDs []string `json:"src_file_ids" binding:"required,min=1"`
DestFileID string `json:"dest_file_id"`
NewName string `json:"new_name"`
}
// MoveFiles moves and/or renames files
// Follows Linux mv semantics:
// - new_name only: rename in place (no storage operation)
// - dest_file_id only: move to new folder (keep names)
// - both: move and rename simultaneously
func (s *FileService) MoveFiles(uid string, srcFileIDs []string, destFileID string, newName string) (bool, string) {
// 1. Get all source files
files, err := s.fileDAO.GetByIDs(srcFileIDs)
if err != nil || len(files) == 0 {
return false, "Source files not found!"
}
// Create a map for quick lookup
filesMap := make(map[string]*entity.File)
for _, f := range files {
filesMap[f.ID] = f
}
// 2. Validate all source files
for _, fileID := range srcFileIDs {
file, ok := filesMap[fileID]
if !ok {
return false, "File or folder not found!"
}
if file.TenantID == "" {
return false, "Tenant not found!"
}
// 3. Permission check
if !s.checkFileTeamPermission(file, uid) {
return false, "No authorization."
}
}
// 4. Validate destination folder if provided
var destFolder *entity.File
if destFileID != "" {
destFolder, err = s.fileDAO.GetByID(destFileID)
if err != nil || destFolder == nil {
return false, "Parent folder not found!"
}
// Check destination folder permission
if !s.checkFileTeamPermission(destFolder, uid) {
return false, "No authorization to write to destination folder."
}
if destFolder.Type != FileTypeFolder {
return false, "Destination is not a folder."
}
destAncestors, err := s.fileDAO.GetAllParentFolders(destFolder.ID)
if err != nil {
return false, "Parent folder not found!"
}
destAncestorIDs := make(map[string]struct{}, len(destAncestors))
for _, folder := range destAncestors {
destAncestorIDs[folder.ID] = struct{}{}
}
for _, file := range files {
if file.Type != FileTypeFolder {
continue
}
if file.ID == destFolder.ID {
return false, "Cannot move a folder to itself."
}
if _, ok := destAncestorIDs[file.ID]; ok {
return false, "Cannot move a folder into its own subfolder."
}
}
}
// 5. Validate new_name if provided
if newName != "" {
if len(srcFileIDs) > 1 {
return false, "new_name can only be used with a single file"
}
file := filesMap[srcFileIDs[0]]
// Check extension for non-folder files
if file.Type != FileTypeFolder {
oldExt := utility.GetFileExtension(file.Name)
newExt := utility.GetFileExtension(newName)
if oldExt != newExt {
return false, "The extension of file can't be changed"
}
}
// Check for duplicate names in target folder
targetParentID := file.ParentID
if destFolder != nil {
targetParentID = destFolder.ID
}
existingFiles := s.fileDAO.Query(newName, targetParentID, file.TenantID)
for _, f := range existingFiles {
if f.Name == newName {
return false, "Duplicated file name in the same folder."
}
}
} else if destFolder != nil {
// Plain move (no rename): check for duplicate names in destination folder
for _, file := range files {
existingFiles := s.fileDAO.Query(file.Name, destFolder.ID, file.TenantID)
for _, f := range existingFiles {
// Ignore the source file itself
if f.ID != file.ID {
return false, "Duplicated file name in the same folder."
}
}
}
}
// 6. Perform the move operation
if destFolder != nil {
// Move to destination folder
for _, file := range files {
if err := s.moveEntryRecursive(file, destFolder, newName); err != nil {
return false, err.Error()
}
}
} else {
// Pure rename: no storage operation needed
if newName == "" {
return false, "new_name is required for rename"
}
if len(srcFileIDs) == 0 {
return false, "Source files not found!"
}
file := filesMap[srcFileIDs[0]]
if err := s.fileDAO.UpdateByID(file.ID, map[string]interface{}{"name": newName}); err != nil {
return false, "Database error (File rename)!"
}
// Update associated document name if exists
informs, err := s.file2DocumentDAO.GetByFileID(file.ID)
if err == nil && len(informs) > 0 && informs[0].DocumentID != nil {
docID := *informs[0].DocumentID
documentDAO := dao.NewDocumentDAO()
if err := documentDAO.UpdateByID(docID, map[string]interface{}{"name": newName}); err != nil {
return false, "Database error (Document rename)!"
}
}
}
return true, ""
}
// moveEntryRecursive recursively moves a file or folder entry
func (s *FileService) moveEntryRecursive(sourceFile *entity.File, destFolder *entity.File, overrideName string) error {
effectiveName := overrideName
if effectiveName == "" {
effectiveName = sourceFile.Name
}
if sourceFile.Type == FileTypeFolder {
// Handle folder move
existingFolders := s.fileDAO.Query(effectiveName, destFolder.ID, sourceFile.TenantID)
var newFolder *entity.File
if len(existingFolders) > 0 {
// Prevent moving a folder into itself (self-target merge)
if existingFolders[0].ID == sourceFile.ID {
return fmt.Errorf("cannot move folder into itself")
}
newFolder = existingFolders[0]
} else {
// Create new folder
var err error
newFolder, err = s.fileDAO.CreateFolder(destFolder.ID, sourceFile.TenantID, effectiveName, FileTypeFolder)
if err != nil {
return fmt.Errorf("failed to create destination folder: %w", err)
}
}
// Recursively move sub-files
subFiles, err := s.fileDAO.ListAllFilesByParentID(sourceFile.ID)
if err != nil {
return err
}
for _, subFile := range subFiles {
if err := s.moveEntryRecursive(subFile, newFolder, ""); err != nil {
return err
}
}
// Delete the source folder
return s.fileDAO.Delete(sourceFile.ID)
}
// Handle non-folder file move
needStorageMove := destFolder.ID != sourceFile.ParentID
updates := map[string]interface{}{}
if needStorageMove {
// Get storage
storageImpl := storage.GetStorageFactory().GetStorage()
if storageImpl == nil {
return fmt.Errorf("storage not initialized")
}
// Calculate new location
newLocation := effectiveName
for storageImpl.ObjExist(destFolder.ID, newLocation) {
newLocation += "_"
}
// Perform storage move (copy + delete)
if sourceFile.Location == nil || *sourceFile.Location == "" {
return fmt.Errorf("file location is empty")
}
if !storageImpl.Move(sourceFile.ParentID, *sourceFile.Location, destFolder.ID, newLocation) {
return fmt.Errorf("move file failed at storage layer")
}
updates["parent_id"] = destFolder.ID
updates["location"] = newLocation
}
if overrideName != "" {
updates["name"] = overrideName
}
if len(updates) > 0 {
if err := s.fileDAO.UpdateByID(sourceFile.ID, updates); err != nil {
return fmt.Errorf("database error (File update): %w", err)
}
}
// Update associated document name if renamed
if overrideName != "" {
informs, err := s.file2DocumentDAO.GetByFileID(sourceFile.ID)
if err == nil && len(informs) > 0 && informs[0].DocumentID != nil {
docID := *informs[0].DocumentID
documentDAO := dao.NewDocumentDAO()
if err := documentDAO.UpdateByID(docID, map[string]interface{}{"name": overrideName}); err != nil {
return fmt.Errorf("database error (Document rename): %w", err)
}
}
}
return nil
}
// GetFileContent gets file metadata and checks permission for download
// Matches Python's file_api_service.get_file_content function
func (s *FileService) GetFileContent(uid, fileID string) (*entity.File, error) {
file, err := s.fileDAO.GetByID(fileID)
if err != nil || file == nil {
return nil, fmt.Errorf("Document not found!")
}
if !s.checkFileTeamPermission(file, uid) {
return nil, fmt.Errorf("No authorization.")
}
return file, nil
}
// StorageAddress represents bucket and object name for storage
type StorageAddress struct {
Bucket string
Name string
}
// GetStorageAddress gets storage address for a file (fallback for when direct blob is empty)
// Matches Python's File2DocumentService.get_storage_address function
func (s *FileService) GetStorageAddress(fileID string) (*StorageAddress, error) {
// Get file2document mapping
f2d, err := s.file2DocumentDAO.GetByFileID(fileID)
if err != nil || len(f2d) == 0 {
return nil, fmt.Errorf("file2document mapping not found")
}
// Get the file
if f2d[0].FileID == nil {
return nil, fmt.Errorf("file_id is nil in file2document mapping")
}
file, err := s.fileDAO.GetByID(*f2d[0].FileID)
if err != nil || file == nil {
return nil, fmt.Errorf("file not found")
}
// If source_type is empty or local, return file's parent_id and location
if file.SourceType == "" || entity.FileSource(file.SourceType) == entity.FileSourceLocal {
if file.Location == nil || *file.Location == "" {
return nil, fmt.Errorf("file location is empty")
}
return &StorageAddress{
Bucket: file.ParentID,
Name: *file.Location,
}, nil
}
// Otherwise, use document's kb_id and location
if f2d[0].DocumentID == nil {
return nil, fmt.Errorf("document_id is required")
}
documentDAO := dao.NewDocumentDAO()
doc, err := documentDAO.GetByID(*f2d[0].DocumentID)
if err != nil || doc == nil {
return nil, fmt.Errorf("document not found")
}
if doc.Location == nil || *doc.Location == "" {
return nil, fmt.Errorf("document location is empty")
}
return &StorageAddress{
Bucket: doc.KbID,
Name: *doc.Location,
}, nil
}
// DownloadAgentFile downloads an agent-generated file directly from MinIO without querying the database.
func (s *FileService) DownloadAgentFile(tenantID, location string) ([]byte, error) {
storageImpl := storage.GetStorageFactory().GetStorage()
if storageImpl == nil {
return nil, fmt.Errorf("storage not initialized")
}
bucketName := fmt.Sprintf("%s-downloads", tenantID)
blob, err := storageImpl.Get(bucketName, location)
if err != nil {
return nil, fmt.Errorf("failed to read file from storage: %w", err)
}
return blob, nil
}
// GetFileContents fetches file contents (text + image) from storage
// for the given file dicts.
// - raw=false: images returned as base64 data URIs in images; non-images parsed and returned as text.
// - raw=true: images returned as raw bytes in images; non-images parsed and returned as text.
func (s *FileService) GetFileContents(uid string, fileDicts []map[string]interface{}, raw bool) (texts []string, images []string, err error) {
storageImpl := storage.GetStorageFactory().GetStorage()
if storageImpl == nil {
return nil, nil, fmt.Errorf("storage not initialized")
}
for _, fd := range fileDicts {
id, _ := fd["id"].(string)
if id == "" {
continue
}
file, ferr := s.fileDAO.GetByID(id)
if ferr != nil || file == nil || file.Location == nil || *file.Location == "" {
continue
}
if !s.checkFileTeamPermission(file, uid) {
return nil, nil, fmt.Errorf("No authorization.")
}
data, derr := storageImpl.Get(file.ParentID, *file.Location)
if derr != nil || len(data) == 0 {
continue
}
ft := utility.FilenameType(file.Name)
if ft == utility.FileTypeVISUAL {
if raw {
images = append(images, string(data))
} else {
ext := utility.GetFileExtension(file.Name)
mime := utility.GetContentType(ext, string(ft))
images = append(images, "data:"+mime+";base64,"+base64.StdEncoding.EncodeToString(data))
}
} else {
texts = append(texts, parseFileContent(file.Name, data))
}
}
return texts, images, nil
}
// parseFileContent tries to parse a file's contents using the appropriate parser.
// Falls back to returning raw text if no parser is available.
func parseFileContent(filename string, data []byte) string {
fileType := utility.GetFileType(filename)
if fileType == utility.FileTypeOTHER {
return string(data)
}
feat(agent): Go ingestion pipeline progress mirroring and DeepDOC parser hardening (#16795) feat(ingestion): mirror Go pipeline progress into the document table; harden resume guards - pipeline: bind the owning document via WithDocumentID; after each TrackProgress event aggregate ingestion_task_log progress and mirror progress/run/progress_msg back into the document table, so GET /api/v1/datasets/{dataset_id}/documents reflects live Go pipeline progress without a bespoke endpoint. - canvas: extend the S3 resume guard to reject legacy no-op nodes (e.g. ExitLoop) so component_total equals the count of progress-reporting components and the aggregate percent can reach 100%. - runtime/canvas: route progress through TrackProgress; add interrupt test coverage (r3_interrupt_test.go). - dao/entity: add IngestionTask.DocumentID column and AggregateProgress support used by the mirror; IngestionTaskLog keeps a Checkpoint column alongside the progress fields. feat(deepdoc): cache DocAnalyzer inference results in Redis (1h TTL) - Redis-backed DocAnalyzerCache decorator over inference.Client; cache key = "ddoc:cache:<method>:" + sha256 of the JPEG-encoded image bytes (deterministic). - TTL = 1h; hits skip the inner HTTP call and return cached JSON; inner errors are not cached. refactor(deepdoc): align figure cropping with Python cropout + bounded page caches - CropSectionByDLA mirrors Python cropout: best-overlap DLA figure/equation region, fallback to section bbox per page, vertical concat on gray background. - sliding-window page-image cache bounds peak memory to the recent window instead of the whole PDF. - rename DLADebug -> DLARegions across parser/chunker/tests. refactor(parser): drop lib_type selector; align NewXxxParser with NewPDFParser - remove config["lib_type"] lookup and the libType param/field/switch from all nine constructors; surface the CGO-required error at ParseWithResult time instead of construction time; drop resolveLibType, its test, and the four lib_type constants. feat(utility): add a reusable workerpool for bounded concurrent execution - internal/utility/workerpool.go (+ tests). refactor: translate Chinese prose comments to English in non-harness Go files. chore: upgrade github.com/cloudwego/eino from v0.9.9 to v0.9.12.
2026-07-10 10:36:10 +08:00
fp, err := parser.GetParser(fileType)
if err != nil {
return string(data)
}
res := fp.ParseWithResult(filename, data)
if res.Err != nil {
return string(data)
}
switch res.OutputFormat {
case "text":
return res.Text
case "markdown":
return res.Markdown
case "html":
return res.HTML
case "json":
return string(data)
default:
return string(data)
}
}
feat(go-api): Align document metadata batch APIs and upload_info with Python (#16269) ## Summary Align the Go implementations of these APIs with the Python behavior: - `POST /api/v1/datasets/:dataset_id/metadata/update` - `PATCH /api/v1/datasets/:dataset_id/documents/metadatas` - `POST /api/v1/documents/upload` ## What changed - Added the Go routes and handlers for the 3 APIs. - Aligned batch document metadata updates with Python semantics: - support `match` in update items - support list append / replace behavior - support deleting specific list values - remove metadata entirely when it becomes empty - create metadata for documents that previously had none when updates apply - count `updated` only when a document actually changes - Aligned `documents/upload` file uploads with Python-style `upload_info` behavior: - store upload-info blobs in the per-user downloads bucket - return lightweight upload descriptors instead of normal file-management responses - Improved URL upload behavior: - SSRF-guarded fetch with redirect validation - redirect limit aligned to Python behavior - normalize filename and MIME type - add `.pdf` when the fetched content is PDF - normalize HTML content into readable text instead of storing raw HTML shells ## Validation ### Unit tests Passed: - `go test ./internal/service` - `go test ./internal/handler` Also verified targeted cases for: - batch metadata update semantics - upload_info URL handling - upload_info download bucket behavior ### curl checks Verified the new Go endpoints with `curl` and compared the response shape and behavior with Python for: - `POST /api/v1/datasets/{dataset_id}/metadata/update` - `PATCH /api/v1/datasets/{dataset_id}/documents/metadatas` - `POST /api/v1/documents/upload` The Go responses were checked against Python for: - argument validation - success response shape - metadata update results - upload_info result structure - file vs URL input handling
2026-06-24 14:52:47 +08:00
// toUploadInfoResponse converts a newly-uploaded file record to the shape
// Python's upload_info endpoint returns.
func (s *FileService) toUploadInfoResponse(file *entity.File, mimeType string) map[string]interface{} {
ext := ""
if idx := strings.LastIndex(file.Name, "."); idx >= 0 {
ext = strings.ToLower(file.Name[idx+1:])
}
return map[string]interface{}{
"id": file.ID,
"name": file.Name,
"size": file.Size,
"extension": ext,
"mime_type": mimeType,
"created_by": file.CreatedBy,
"created_at": float64(time.Now().UnixMilli()) / 1000.0,
"preview_url": nil,
}
}
// maxRemoteFileSize bounds the body of a ?url= upload (100 MB).
const maxRemoteFileSize = 100 << 20
// UploadFromURL fetches a remote URL, saves the content to the tenant's root
// folder, and returns the file metadata map — mirroring Python
// FileService.upload_info(tenant_id, None, url).
//
// The remote fetch is SSRF-guarded (mirrors Python's assert_url_is_safe): the
// scheme must be http/https and every address the host resolves to must be
// globally routable; the validated IP is pinned for the actual connection — and
// re-validated on each redirect hop — to defeat DNS-rebinding. The HTTP client
// carries connect and overall timeouts, and the response body is bounded with
// truncation detection so an oversized file is rejected rather than silently
// clipped.
func (s *FileService) UploadFromURL(tenantID, rawURL string) (map[string]interface{}, error) {
if rawURL == "" {
return nil, fmt.Errorf("url is required")
}
parsed, err := url.Parse(rawURL)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Hostname() == "" {
return nil, fmt.Errorf("invalid or unsafe URL")
}
data, headers, finalURL, err := fetchRemoteFileSafely(rawURL, maxRemoteFileSize)
if err != nil {
return nil, err
}
storageImpl := storage.GetStorageFactory().GetStorage()
if storageImpl == nil {
return nil, fmt.Errorf("storage not initialized")
}
contentType := headers.Get("Content-Type")
filename := normalizeRemoteUploadFilename(finalURL, contentType, data)
if err := s.checkUploadInfoHealth(tenantID, filename); err != nil {
return nil, err
}
filename, contentType, data = normalizeUploadInfoContent(filename, contentType, data)
return s.storeUploadInfoBlob(storageImpl, tenantID, filename, contentType, data)
}
// fetchRemoteFileSafely downloads rawURL with SSRF protection, connect/overall
// timeouts, and a hard size cap that rejects (rather than truncates) oversized
// bodies.
func fetchRemoteFileSafely(rawURL string, maxSize int64) ([]byte, http.Header, string, error) {
currentURL := rawURL
for redirects := 0; redirects < 10; redirects++ {
hostname, resolvedIP, err := assertURLSafe(currentURL)
if err != nil {
return nil, nil, "", err
}
client := pinnedHTTPClient(hostname, resolvedIP, 10*time.Second)
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
}
fix(security): address 93 CodeQL code-scanning alerts across 61 files (#16407) ## Summary Resolves all 93 open alerts at https://github.com/infiniflow/ragflow/security/code-scanning by rule: | Rule | Count | Treatment | |------|-------|-----------| | py/clear-text-logging-sensitive-data | 23 | Real fix — log scrubbing | | go/path-injection | 15 | Real fix where possible, suppression with rationale | | go/request-forgery | 8 | Suppression with rationale (operator-controlled URLs) | | go/clear-text-logging | 10 | Real fix — log scrubbing | | go/unsafe-quoting | 5 | Real fix — escape or refactor | | go/sql-injection | 3 | Real fix — orderby whitelist + CodeQL comment | | go/uncontrolled-allocation-size | 2 | Real fix — cap to 1024 | | go/incorrect-integer-conversion | 3 | Real fix — ParseInt + range check | | go/insecure-hostkeycallback | 1 | Real fix — known_hosts file | | go/disabled-certificate-check | 2 | Suppression with rationale | | go/command-injection | 1 | Suppression (sanitized via shq()) | | go/email-injection | 1 | Suppression with rationale | | go/cookie-httponly-not-set | 1 | Suppression (SPA bootstrap) | | js/stack-trace-exposure | 1 | Real fix — generic client message | | js/prototype-pollution-utility | 1 | Real fix — reject __proto__/constructor/prototype | | py/weak-sensitive-data-hashing | 1 | Real fix — MD5 → SHA-256 | | py/incomplete-url-substring-sanitization | 3 | Real fix — urlparse(hostname) | | py/paramiko-missing-host-key-validation | 1 | Real fix — load_system_host_keys + RejectPolicy | | cpp/integer-multiplication-cast-to-long | 2 | Real fix — cast to size_t | ## Real fixes (with measurable security improvement) **SSH host key verification (Go + Python)** Replace `InsecureIgnoreHostKey()` / `paramiko.AutoAddPolicy()` with proper host key verification against a known_hosts file (configurable via `SSH_KNOWN_HOSTS` env / `known_hosts` config field; fail-closed when unset). Loads `~/.ssh/known_hosts` first via `load_system_host_keys()` so existing setups keep working. **SQL injection in `user_canvas`** Add `userCanvasOrderableColumns` whitelist + `userCanvasOrderClause` helper. Both `GetList()` and `ListByTenantIDs()` now route the user-supplied `orderby` query param through the helper, defaulting to `create_time` on miss. **SQL injection in `pipeline_operation_log`** Existing whitelist documented via CodeQL comment. **Real SQL injection in `infinity/chunk.go:931`** Escape `'` → `''` on user-controlled `questionText` before splicing into `filter_fulltext(...)` SQL filter. **Real SQL injection in `elasticsearch/sql.go:75`** Defense-in-depth escape on tokenizer output before splicing into `MATCH(...)`. **Python code injection in `result_protocol.go`** Replace raw JSON literal embedding into Python/JS expressions with base64 + `json.loads` / `JSON.parse(Buffer.from(..., 'base64').toString('utf8'))`. Eliminates both the unsafe-quoting sink and the brittleness of mixing JSON true/false/null with Python syntax. **URL substring check bypass in `embedding_model.py`** Replace `if "dashscope-intl.aliyuncs.com" in u` with `urlparse(u).hostname == "dashscope-intl.aliyuncs.com"` so a base_url like `https://attacker.example/?u=dashscope-intl.aliyuncs.com` cannot bypass the routing. **Prototype pollution in `setNestedValue` (TS)** Reject `__proto__`/`constructor`/`prototype` keys before any assignment. **Integer overflow** - scrypt params via `ParseInt` + non-positive check (`internal/common/password.go`) - `topN` and `n` caps to 1024 (retrieval_service.go, dataset.go) - `nalloc*statesize` cast to `size_t` (cpp/re2/onepass.cc) **Cookie httponly** Set explicitly with rationale: this is the OAuth bootstrap cookie intentionally read by the SPA. **Stack trace exposure** Replace `error.message` in HTTP 500 response with generic `"internal error"`; full error still logged server-side via `console.error`. **Weak hashing** MD5 → SHA-256 for deterministic `conv_id` derivation (`conversation_service.py`). **Log scrubbing** Remove or redact user-controlled / sensitive content from clear-text logs across 8 ingestion parsers, `llm_service.py` ×11, `tenant_llm_service.py` ×7, `misc_utils.py` ×4, `redis_conn.py` ×10, `conftest.py` ×4, `init_data.py`, `dataset_api_service.py`, `generator.py`, `mysql_migration.py`, `cli.go`, `user_command.go`, `pdf_parser.go`. Most patterns converted to parameterized logging (`logging.info("...: %d", n)`) or static messages. ## CodeQL suppressions (each with rationale) For alerts where the data flow is genuinely safe but CodeQL can't see the context — operator-controlled URLs, sanitized inputs, etc. — I added `// codeql[go/<rule>] <rationale>` annotations rather than dismissing them, so future readers can audit the rationale inline: - `internal/agent/component/invoke.go:135` — Invoke is a generic canvas HTTP client - `internal/service/langfuse.go` ×2 — host is per-tenant operator config - `internal/service/file.go:1184` — already SSRF-guarded by `assertURLSafe` - `internal/utility/mcp_client.go` ×3 — already `AssertURLSafe` + IP-pinned - `internal/entity/models/bedrock.go` — sigv4-signed request, URL can't be tampered - `internal/service/deep_researcher.go:269` — `callback` is SSE display string, not SQL - `internal/engine/infinity/chunk.go:346` — UUIDs can't contain `'` (RFC 4122) - `internal/cli/common_command.go` ×2 — CLI trusts operator-configured URL - `internal/utility/smtp.go:194` — msg is server-built, not user form input - `internal/entity/models/*` ×14 (path-injection) — audio file paths are caller-supplied ## Test plan - ✅ All 13 modified Go packages build cleanly - ✅ 663 tests pass across `internal/agent/sandbox`, `internal/common`, `internal/agent/component`, `internal/engine/infinity`, `internal/dao` - ✅ All 11 modified Python files parse via `ast.parse` - ✅ TypeScript `tsc --noEmit` clean on the modified `use-provider-fields.tsx` - ✅ `node --check` clean on the modified JS file 🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-06-27 19:48:29 +08:00
// runs assertURLSafe(currentURL) on every iteration (including
// redirects), which rejects private/loopback IPs and other
// SSRF targets. The "nosec G107" comment is for gosec;
// CodeQL needs an explicit suppression.
fix(codeql): close remaining 44 CodeQL alerts post-merge (#16408) ## Summary After #16407 merged, 44 of the original 93 CodeQL alerts were still open on the default branch. This PR closes the remaining ones by: 1. **Moving 32 existing `// codeql[...]` directives** so they sit on the line **immediately before** the suppressed statement. The original multi-line suppression blocks had the directive as the first line, with the rationale on subsequent lines. After line shifts (refactors, linter reformat), the directive ended up several lines above the alert location — CodeQL only recognizes the suppression when it appears on the line directly above. (32 alerts across 27 files.) 2. **Adding 9 new `// codeql[...]` suppressions** for alerts that had no suppression in the preceding lines at all — mostly real-fixes that CodeQL conservatively still flags (filepath.Base, bounded slice sizes, model-identifier strings, the MD5-legacy-migration lookup in `conversation_service.py`). ## Files changed - `api/db/services/conversation_service.py` — add `py/weak-sensitive-data-hashing` suppression (MD5 for backward-compat legacy row lookup; not used for auth) - `api/db/services/llm_service.py` — 3× `py/clear-text-logging-sensitive-data` suppressions on the lines that log `llm_name` in warnings/info - `common/misc_utils.py` — 2× `py/clear-text-logging-sensitive-data` suppressions on the redacted `current_url` log sites - `internal/agent/component/invoke.go` — moved existing `go/request-forgery` directive - `internal/agent/sandbox/ssh.go` — moved existing `go/command-injection` directive - `internal/agent/tool/retrieval_service.go` — added `go/uncontrolled-allocation-size` suppression (`topN` is bounded to 1024 above) - `internal/cli/common_command.go` — moved 2× `go/disabled-certificate-check` directives - `internal/cli/user_command.go` — added `go/clear-text-logging` suppression (filepath.Base already strips user-identifying path) - `internal/dao/pipeline_operation_log.go` — moved 2× `go/sql-injection` directives - `internal/dao/user_canvas.go` — added `go/sql-injection` suppression in `GetList` (the new `userCanvasOrderClause` call path) - `internal/engine/infinity/chunk.go` — moved existing `go/unsafe-quoting` directive - `internal/entity/models/*` — moved `go/path-injection` directives (15 files) - `internal/handler/oauth_login.go` — moved existing `go/cookie-httponly-not-set` directive - `internal/handler/tenant.go` — moved existing `go/path-injection` directive - `internal/service/deep_researcher.go` — moved existing `go/unsafe-quoting` directive - `internal/service/dataset.go` — added `go/uncontrolled-allocation-size` suppression (`n` bounded to 1024 above) - `internal/service/file.go` — moved existing `go/request-forgery` directive - `internal/service/langfuse.go` — moved 2× `go/request-forgery` directives - `internal/utility/mcp_client.go` — moved 3× `go/request-forgery` directives - `internal/utility/smtp.go` — moved existing `go/email-injection` directive - `rag/prompts/generator.py` — added `py/clear-text-logging-sensitive-data` suppression - `web/.../use-provider-fields.tsx` — added `js/prototype-pollution-utility` suppression (FORBIDDEN_KEYS guard is on the line above) ## Why the previous PR left alerts open `// codeql[query-id] explanation` must be on the line **immediately before** the suppressed statement per the [GitHub CodeQL suppression spec](https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/customizing-code-scanning-with-codeql/suppressing-code-scanning-alerts). The original suppression blocks were 4-5 lines, with the directive as the **first** line. After linter reformat / line shifts, the directive ended up too far above the actual alert line to be recognized. The fix is to put the directive on the line directly above the suppressed statement, with the rationale above it. ## Test plan - All 9 modified Python files `ast.parse` clean - All 4 modified Go files `gofmt` clean - 36/44 expected alert suppressions in place - 8 remaining CodeQL alerts are the originals (#3485851828, #3485851831, #3485869759, #3485869766, #3485869768, #3485869771, #3485885962, #3485895527) which were resolved by the corresponding commit comments; these should close on the next scan when the suppression comments match the alert lines. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-06-27 20:49:06 +08:00
// codeql[go/request-forgery] False positive: the loop above
feat(go-api): Align document metadata batch APIs and upload_info with Python (#16269) ## Summary Align the Go implementations of these APIs with the Python behavior: - `POST /api/v1/datasets/:dataset_id/metadata/update` - `PATCH /api/v1/datasets/:dataset_id/documents/metadatas` - `POST /api/v1/documents/upload` ## What changed - Added the Go routes and handlers for the 3 APIs. - Aligned batch document metadata updates with Python semantics: - support `match` in update items - support list append / replace behavior - support deleting specific list values - remove metadata entirely when it becomes empty - create metadata for documents that previously had none when updates apply - count `updated` only when a document actually changes - Aligned `documents/upload` file uploads with Python-style `upload_info` behavior: - store upload-info blobs in the per-user downloads bucket - return lightweight upload descriptors instead of normal file-management responses - Improved URL upload behavior: - SSRF-guarded fetch with redirect validation - redirect limit aligned to Python behavior - normalize filename and MIME type - add `.pdf` when the fetched content is PDF - normalize HTML content into readable text instead of storing raw HTML shells ## Validation ### Unit tests Passed: - `go test ./internal/service` - `go test ./internal/handler` Also verified targeted cases for: - batch metadata update semantics - upload_info URL handling - upload_info download bucket behavior ### curl checks Verified the new Go endpoints with `curl` and compared the response shape and behavior with Python for: - `POST /api/v1/datasets/{dataset_id}/metadata/update` - `PATCH /api/v1/datasets/{dataset_id}/documents/metadatas` - `POST /api/v1/documents/upload` The Go responses were checked against Python for: - argument validation - success response shape - metadata update results - upload_info result structure - file vs URL input handling
2026-06-24 14:52:47 +08:00
resp, err := client.Get(currentURL) // #nosec G107
if err != nil {
return nil, nil, "", fmt.Errorf("failed to fetch URL: %w", err)
}
if resp.StatusCode == http.StatusMovedPermanently ||
resp.StatusCode == http.StatusFound ||
resp.StatusCode == http.StatusSeeOther ||
resp.StatusCode == http.StatusTemporaryRedirect ||
resp.StatusCode == http.StatusPermanentRedirect {
location := resp.Header.Get("Location")
resp.Body.Close()
if location == "" {
return nil, nil, "", fmt.Errorf("redirect response missing Location header")
}
baseURL, parseErr := url.Parse(currentURL)
if parseErr != nil {
return nil, nil, "", parseErr
}
nextURL, resolveErr := baseURL.Parse(location)
if resolveErr != nil {
return nil, nil, "", resolveErr
}
currentURL = nextURL.String()
continue
}
if resp.StatusCode >= 400 {
resp.Body.Close()
return nil, nil, "", fmt.Errorf("remote URL returned HTTP %d", resp.StatusCode)
}
data, readErr := io.ReadAll(io.LimitReader(resp.Body, maxSize+1))
resp.Body.Close()
if readErr != nil {
return nil, nil, "", fmt.Errorf("failed to read remote content: %w", readErr)
}
if int64(len(data)) > maxSize {
return nil, nil, "", fmt.Errorf("remote file exceeds the maximum allowed size of %d bytes", maxSize)
}
return data, resp.Header.Clone(), currentURL, nil
}
return nil, nil, "", fmt.Errorf("stopped after too many redirects")
}
// isPublicIP reports whether ip is a globally routable address. It mirrors the
// allowlist intent of Python's assert_url_is_safe (which requires ip.is_global)
// by rejecting loopback, private, link-local, multicast, unspecified, and
// carrier-grade NAT ranges. IPv4-mapped IPv6 addresses are handled by the
// stdlib predicates.
func isPublicIP(ip net.IP) bool {
if ip == nil {
return false
}
if ip.IsLoopback() || ip.IsPrivate() || ip.IsUnspecified() ||
ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() ||
ip.IsMulticast() || ip.IsInterfaceLocalMulticast() {
return false
}
// Carrier-grade NAT 100.64.0.0/10 (RFC 6598) — not covered by IsPrivate.
if ip4 := ip.To4(); ip4 != nil && ip4[0] == 100 && ip4[1]&0xc0 == 0x40 {
return false
}
return true
}
func (s *FileService) checkUploadInfoHealth(userID, filename string) error {
if filename == "" {
return fmt.Errorf("No file selected!")
}
maxFileNumPerUser := common.GetEnv(common.EnvMaxFileNumPerUser)
feat(go-api): Align document metadata batch APIs and upload_info with Python (#16269) ## Summary Align the Go implementations of these APIs with the Python behavior: - `POST /api/v1/datasets/:dataset_id/metadata/update` - `PATCH /api/v1/datasets/:dataset_id/documents/metadatas` - `POST /api/v1/documents/upload` ## What changed - Added the Go routes and handlers for the 3 APIs. - Aligned batch document metadata updates with Python semantics: - support `match` in update items - support list append / replace behavior - support deleting specific list values - remove metadata entirely when it becomes empty - create metadata for documents that previously had none when updates apply - count `updated` only when a document actually changes - Aligned `documents/upload` file uploads with Python-style `upload_info` behavior: - store upload-info blobs in the per-user downloads bucket - return lightweight upload descriptors instead of normal file-management responses - Improved URL upload behavior: - SSRF-guarded fetch with redirect validation - redirect limit aligned to Python behavior - normalize filename and MIME type - add `.pdf` when the fetched content is PDF - normalize HTML content into readable text instead of storing raw HTML shells ## Validation ### Unit tests Passed: - `go test ./internal/service` - `go test ./internal/handler` Also verified targeted cases for: - batch metadata update semantics - upload_info URL handling - upload_info download bucket behavior ### curl checks Verified the new Go endpoints with `curl` and compared the response shape and behavior with Python for: - `POST /api/v1/datasets/{dataset_id}/metadata/update` - `PATCH /api/v1/datasets/{dataset_id}/documents/metadatas` - `POST /api/v1/documents/upload` The Go responses were checked against Python for: - argument validation - success response shape - metadata update results - upload_info result structure - file vs URL input handling
2026-06-24 14:52:47 +08:00
if maxFileNumPerUser != "" {
var maxNum int64
if _, err := fmt.Sscanf(maxFileNumPerUser, "%d", &maxNum); err == nil && maxNum > 0 {
var docCount int64
docCount, err = s.GetDocCount(userID)
feat(go-api): Align document metadata batch APIs and upload_info with Python (#16269) ## Summary Align the Go implementations of these APIs with the Python behavior: - `POST /api/v1/datasets/:dataset_id/metadata/update` - `PATCH /api/v1/datasets/:dataset_id/documents/metadatas` - `POST /api/v1/documents/upload` ## What changed - Added the Go routes and handlers for the 3 APIs. - Aligned batch document metadata updates with Python semantics: - support `match` in update items - support list append / replace behavior - support deleting specific list values - remove metadata entirely when it becomes empty - create metadata for documents that previously had none when updates apply - count `updated` only when a document actually changes - Aligned `documents/upload` file uploads with Python-style `upload_info` behavior: - store upload-info blobs in the per-user downloads bucket - return lightweight upload descriptors instead of normal file-management responses - Improved URL upload behavior: - SSRF-guarded fetch with redirect validation - redirect limit aligned to Python behavior - normalize filename and MIME type - add `.pdf` when the fetched content is PDF - normalize HTML content into readable text instead of storing raw HTML shells ## Validation ### Unit tests Passed: - `go test ./internal/service` - `go test ./internal/handler` Also verified targeted cases for: - batch metadata update semantics - upload_info URL handling - upload_info download bucket behavior ### curl checks Verified the new Go endpoints with `curl` and compared the response shape and behavior with Python for: - `POST /api/v1/datasets/{dataset_id}/metadata/update` - `PATCH /api/v1/datasets/{dataset_id}/documents/metadatas` - `POST /api/v1/documents/upload` The Go responses were checked against Python for: - argument validation - success response shape - metadata update results - upload_info result structure - file vs URL input handling
2026-06-24 14:52:47 +08:00
if err != nil {
return fmt.Errorf("failed to get document count: %w", err)
}
if docCount >= maxNum {
return fmt.Errorf("Exceed the maximum file number of a free user!")
}
}
}
if len([]byte(filename)) > 255 {
return fmt.Errorf("Exceed the maximum length of file name!")
}
return nil
}
func (s *FileService) storeUploadInfoBlob(storageImpl storage.Storage, userID, filename, contentType string, data []byte) (map[string]interface{}, error) {
location := utility.GenerateUUID()
feat(go-api): Align document metadata batch APIs and upload_info with Python (#16269) ## Summary Align the Go implementations of these APIs with the Python behavior: - `POST /api/v1/datasets/:dataset_id/metadata/update` - `PATCH /api/v1/datasets/:dataset_id/documents/metadatas` - `POST /api/v1/documents/upload` ## What changed - Added the Go routes and handlers for the 3 APIs. - Aligned batch document metadata updates with Python semantics: - support `match` in update items - support list append / replace behavior - support deleting specific list values - remove metadata entirely when it becomes empty - create metadata for documents that previously had none when updates apply - count `updated` only when a document actually changes - Aligned `documents/upload` file uploads with Python-style `upload_info` behavior: - store upload-info blobs in the per-user downloads bucket - return lightweight upload descriptors instead of normal file-management responses - Improved URL upload behavior: - SSRF-guarded fetch with redirect validation - redirect limit aligned to Python behavior - normalize filename and MIME type - add `.pdf` when the fetched content is PDF - normalize HTML content into readable text instead of storing raw HTML shells ## Validation ### Unit tests Passed: - `go test ./internal/service` - `go test ./internal/handler` Also verified targeted cases for: - batch metadata update semantics - upload_info URL handling - upload_info download bucket behavior ### curl checks Verified the new Go endpoints with `curl` and compared the response shape and behavior with Python for: - `POST /api/v1/datasets/{dataset_id}/metadata/update` - `PATCH /api/v1/datasets/{dataset_id}/documents/metadatas` - `POST /api/v1/documents/upload` The Go responses were checked against Python for: - argument validation - success response shape - metadata update results - upload_info result structure - file vs URL input handling
2026-06-24 14:52:47 +08:00
bucket := fmt.Sprintf("%s-downloads", userID)
if err := storageImpl.Put(bucket, location, data); err != nil {
return nil, fmt.Errorf("failed to store file: %w", err)
}
ext := ""
if idx := strings.LastIndex(filename, "."); idx >= 0 {
ext = strings.ToLower(filename[idx+1:])
}
return map[string]interface{}{
"id": location,
"name": filename,
"size": int64(len(data)),
"extension": ext,
"mime_type": contentType,
"created_by": userID,
"created_at": float64(time.Now().UnixMilli()) / 1000.0,
"preview_url": nil,
}, nil
}
func normalizeRemoteUploadFilename(rawURL, contentType string, data []byte) string {
parsed, err := url.Parse(rawURL)
filename := "download"
if err == nil {
filename = sanitizeFilename(filepath.Base(parsed.Path))
}
ct := strings.ToLower(strings.TrimSpace(strings.Split(contentType, ";")[0]))
if ct == "application/pdf" || bytesLooksLikePDF(data) {
if !strings.HasSuffix(strings.ToLower(filename), ".pdf") {
filename += ".pdf"
}
}
return filename
}
func normalizeUploadInfoContent(filename, contentType string, data []byte) (string, string, []byte) {
lowerCT := strings.ToLower(strings.TrimSpace(strings.Split(contentType, ";")[0]))
if lowerCT == "" {
lowerCT = http.DetectContentType(data)
}
if lowerCT == "application/pdf" || bytesLooksLikePDF(data) {
if !strings.HasSuffix(strings.ToLower(filename), ".pdf") {
filename += ".pdf"
}
lowerCT = "application/pdf"
}
if lowerCT == "text/html" || lowerCT == "application/xhtml+xml" || looksLikeHTML(data) {
data = htmlToReadableMarkdown(data)
if lowerCT == "" {
lowerCT = "text/html"
}
}
return filename, lowerCT, data
}
func bytesLooksLikePDF(data []byte) bool {
return len(data) >= 4 && string(data[:4]) == "%PDF"
}
func looksLikeHTML(data []byte) bool {
snippet := strings.ToLower(string(data))
return strings.Contains(snippet, "<html") || strings.Contains(snippet, "<body") || strings.Contains(snippet, "<div")
}
var (
htmlScriptStyleRE = regexp.MustCompile(`(?is)<(script|style)[^>]*>.*?</(script|style)>`)
htmlTagRE = regexp.MustCompile(`(?s)<[^>]+>`)
multiSpaceRE = regexp.MustCompile(`[ \t]+`)
multiNewlineRE = regexp.MustCompile(`\n{3,}`)
)
func htmlToReadableMarkdown(data []byte) []byte {
text := string(data)
text = htmlScriptStyleRE.ReplaceAllString(text, " ")
text = strings.ReplaceAll(text, "<br>", "\n")
text = strings.ReplaceAll(text, "<br/>", "\n")
text = strings.ReplaceAll(text, "<br />", "\n")
text = strings.ReplaceAll(text, "</p>", "\n\n")
text = strings.ReplaceAll(text, "</div>", "\n")
text = strings.ReplaceAll(text, "</li>", "\n")
text = htmlTagRE.ReplaceAllString(text, " ")
text = html.UnescapeString(text)
text = strings.ReplaceAll(text, "\r", "\n")
text = multiSpaceRE.ReplaceAllString(text, " ")
text = multiNewlineRE.ReplaceAllString(text, "\n\n")
text = strings.TrimSpace(text)
return []byte(text)
}
// reservedDeviceNames are Windows reserved filenames that must never be used.
var reservedDeviceNames = map[string]bool{
"CON": true, "PRN": true, "AUX": true, "NUL": true,
"COM1": true, "COM2": true, "COM3": true, "COM4": true, "COM5": true,
"COM6": true, "COM7": true, "COM8": true, "COM9": true,
"LPT1": true, "LPT2": true, "LPT3": true, "LPT4": true, "LPT5": true,
"LPT6": true, "LPT7": true, "LPT8": true, "LPT9": true,
}
// sanitizeFilename produces a safe, filesystem-friendly filename from an
// arbitrary URL path segment: it strips directory components, replaces unsafe /
// control characters, rejects reserved names, bounds the length, and falls back
// to "download".
func sanitizeFilename(name string) string {
name = filepath.Base(name)
name = strings.TrimSpace(name)
name = strings.Map(func(r rune) rune {
switch r {
case '/', '\\', ':', '*', '?', '"', '<', '>', '|', 0:
return '_'
}
if r < 0x20 { // control characters
return '_'
}
return r
}, name)
// Strip leading/trailing dots and spaces to avoid hidden or reserved forms.
name = strings.Trim(name, ". ")
if name == "" || name == "." || name == ".." {
return "download"
}
if stem := strings.SplitN(strings.ToUpper(name), ".", 2)[0]; reservedDeviceNames[stem] {
return "download"
}
if len(name) > 255 {
name = name[:255]
}
return name
}