mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-08-01 00:27:35 +02:00
1211 lines
36 KiB
Go
1211 lines
36 KiB
Go
package handler
|
||
|
||
import (
|
||
"archive/zip"
|
||
"crypto/rand"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"mime"
|
||
"net/http"
|
||
"os"
|
||
"path/filepath"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
"unicode/utf8"
|
||
|
||
"cyberstrike-ai/internal/audit"
|
||
"cyberstrike-ai/internal/database"
|
||
"cyberstrike-ai/internal/security"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"go.uber.org/zap"
|
||
)
|
||
|
||
const (
|
||
chatUploadsRootDirName = "chat_uploads"
|
||
reductionRootDirName = "tmp/reduction"
|
||
artifactsRootDirName = "data/conversation_artifacts"
|
||
reductionVirtualPrefix = "__reduction__/"
|
||
artifactVirtualPrefix = "__conversation_artifact__/"
|
||
chatUploadSourceUpload = "upload"
|
||
chatUploadSourceReduction = "reduction"
|
||
chatUploadSourceConversation = "conversation_artifact"
|
||
maxChatUploadEditBytes = 2 * 1024 * 1024 // 文本编辑上限
|
||
)
|
||
|
||
// ChatUploadsHandler 对话中上传附件(chat_uploads 目录)的管理 API
|
||
type ChatUploadsHandler struct {
|
||
logger *zap.Logger
|
||
audit *audit.Service
|
||
db *database.DB
|
||
}
|
||
|
||
// SetAudit wires platform audit logging.
|
||
func (h *ChatUploadsHandler) SetAudit(s *audit.Service) {
|
||
h.audit = s
|
||
}
|
||
|
||
// NewChatUploadsHandler 创建处理器
|
||
func NewChatUploadsHandler(logger *zap.Logger, databases ...*database.DB) *ChatUploadsHandler {
|
||
h := &ChatUploadsHandler{logger: logger}
|
||
if len(databases) > 0 {
|
||
h.db = databases[0]
|
||
}
|
||
return h
|
||
}
|
||
|
||
func (h *ChatUploadsHandler) pathAllowed(c *gin.Context, relativePath string) bool {
|
||
session, ok := security.CurrentSession(c)
|
||
if !ok || h.db == nil {
|
||
return false
|
||
}
|
||
if session.Scope == database.RBACScopeAll {
|
||
return true
|
||
}
|
||
rel := filepath.ToSlash(filepath.Clean(filepath.FromSlash(strings.TrimSpace(relativePath))))
|
||
rel = strings.Trim(rel, "/")
|
||
if conversationID, ownerUserID, found := h.db.GetChatUploadArtifact(rel); found {
|
||
return strings.TrimSpace(ownerUserID) == session.UserID || h.db.UserCanAccessResource(session.UserID, session.Scope, "conversation", conversationID)
|
||
}
|
||
parts := strings.Split(strings.Trim(rel, "/"), "/")
|
||
if len(parts) < 2 || parts[1] == "" || parts[1] == "_manual" {
|
||
return false
|
||
}
|
||
return h.db.UserCanAccessResource(session.UserID, session.Scope, "conversation", parts[1])
|
||
}
|
||
|
||
func (h *ChatUploadsHandler) reductionPathAllowed(c *gin.Context, scope, id string) bool {
|
||
session, ok := security.CurrentSession(c)
|
||
if !ok || h.db == nil {
|
||
return false
|
||
}
|
||
if session.Scope == database.RBACScopeAll {
|
||
return true
|
||
}
|
||
id = strings.TrimSpace(id)
|
||
switch scope {
|
||
case "conversations":
|
||
if id == "" || id == "default" {
|
||
return false
|
||
}
|
||
return h.db.UserCanAccessResource(session.UserID, session.Scope, "conversation", id)
|
||
case "projects":
|
||
if id == "" {
|
||
return false
|
||
}
|
||
return h.db.UserCanAccessResource(session.UserID, session.Scope, "project", id)
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func (h *ChatUploadsHandler) reductionVirtualPathAllowed(c *gin.Context, relativePath string) bool {
|
||
rel := strings.TrimPrefix(strings.TrimSpace(relativePath), reductionVirtualPrefix)
|
||
parts := strings.Split(filepath.ToSlash(rel), "/")
|
||
if len(parts) < 2 {
|
||
return false
|
||
}
|
||
return h.reductionPathAllowed(c, parts[0], parts[1])
|
||
}
|
||
|
||
func (h *ChatUploadsHandler) conversationArtifactPathAllowed(c *gin.Context, conversationID string) bool {
|
||
session, ok := security.CurrentSession(c)
|
||
if !ok || h.db == nil {
|
||
return false
|
||
}
|
||
if session.Scope == database.RBACScopeAll {
|
||
return true
|
||
}
|
||
conversationID = strings.TrimSpace(conversationID)
|
||
if conversationID == "" || conversationID == "default" {
|
||
return false
|
||
}
|
||
return h.db.UserCanAccessResource(session.UserID, session.Scope, "conversation", conversationID)
|
||
}
|
||
|
||
func (h *ChatUploadsHandler) conversationArtifactVirtualPathAllowed(c *gin.Context, relativePath string) bool {
|
||
rel := strings.TrimPrefix(strings.TrimSpace(relativePath), artifactVirtualPrefix)
|
||
parts := strings.Split(filepath.ToSlash(rel), "/")
|
||
if len(parts) < 1 {
|
||
return false
|
||
}
|
||
return h.conversationArtifactPathAllowed(c, parts[0])
|
||
}
|
||
|
||
func (h *ChatUploadsHandler) absRoot() (string, error) {
|
||
cwd, err := os.Getwd()
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
return filepath.Abs(filepath.Join(cwd, chatUploadsRootDirName))
|
||
}
|
||
|
||
func (h *ChatUploadsHandler) absReductionRoot() (string, error) {
|
||
if h.db != nil {
|
||
if base := strings.TrimSpace(h.db.EinoReductionBaseDir()); base != "" {
|
||
if filepath.IsAbs(base) {
|
||
return filepath.Abs(base)
|
||
}
|
||
cwd, err := os.Getwd()
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
return filepath.Abs(filepath.Join(cwd, base))
|
||
}
|
||
}
|
||
cwd, err := os.Getwd()
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
return filepath.Abs(filepath.Join(cwd, reductionRootDirName))
|
||
}
|
||
|
||
func (h *ChatUploadsHandler) absConversationArtifactsRoot() (string, error) {
|
||
if h.db != nil {
|
||
if base := strings.TrimSpace(h.db.ConversationArtifactsBaseDir()); base != "" {
|
||
if filepath.IsAbs(base) {
|
||
return filepath.Abs(base)
|
||
}
|
||
cwd, err := os.Getwd()
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
return filepath.Abs(filepath.Join(cwd, base))
|
||
}
|
||
}
|
||
cwd, err := os.Getwd()
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
return filepath.Abs(filepath.Join(cwd, artifactsRootDirName))
|
||
}
|
||
|
||
// resolveUnderChatUploads 校验 relativePath(使用 / 分隔)对应文件必须在 chat_uploads 根下
|
||
func (h *ChatUploadsHandler) resolveUnderChatUploads(relativePath string) (abs string, err error) {
|
||
root, err := h.absRoot()
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
rel := strings.TrimSpace(relativePath)
|
||
if rel == "" {
|
||
return "", fmt.Errorf("empty path")
|
||
}
|
||
rel = filepath.Clean(filepath.FromSlash(rel))
|
||
if rel == "." || strings.HasPrefix(rel, "..") {
|
||
return "", fmt.Errorf("invalid path")
|
||
}
|
||
full := filepath.Join(root, rel)
|
||
full, err = filepath.Abs(full)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
rootAbs, _ := filepath.Abs(root)
|
||
if full != rootAbs && !strings.HasPrefix(full, rootAbs+string(filepath.Separator)) {
|
||
return "", fmt.Errorf("path escapes chat_uploads root")
|
||
}
|
||
return full, nil
|
||
}
|
||
|
||
// ChatUploadFileItem 列表项
|
||
type ChatUploadFileItem struct {
|
||
RelativePath string `json:"relativePath"`
|
||
AbsolutePath string `json:"absolutePath"` // 服务器上的绝对路径,便于在对话中引用(与附件落盘路径一致)
|
||
Name string `json:"name"`
|
||
Source string `json:"source,omitempty"`
|
||
Size int64 `json:"size"`
|
||
ModifiedUnix int64 `json:"modifiedUnix"`
|
||
Date string `json:"date"`
|
||
ConversationID string `json:"conversationId"`
|
||
ProjectID string `json:"projectId,omitempty"`
|
||
// SubPath 为日期、会话目录之下的子路径(不含文件名),如 date/conv/a/b/file 则为 "a/b";无嵌套则为 ""。
|
||
SubPath string `json:"subPath"`
|
||
}
|
||
|
||
func (h *ChatUploadsHandler) conversationProjectID(conversationID string, cache map[string]string) string {
|
||
conversationID = strings.TrimSpace(conversationID)
|
||
if conversationID == "" || conversationID == "_manual" || conversationID == "_new" || h.db == nil {
|
||
return ""
|
||
}
|
||
if v, ok := cache[conversationID]; ok {
|
||
return v
|
||
}
|
||
projectID, err := h.db.GetConversationProjectID(conversationID)
|
||
if err != nil {
|
||
projectID = ""
|
||
}
|
||
cache[conversationID] = projectID
|
||
return projectID
|
||
}
|
||
|
||
func (h *ChatUploadsHandler) collectFiles(c *gin.Context, conversationFilter, projectFilter string) ([]ChatUploadFileItem, []string, error) {
|
||
root, err := h.absRoot()
|
||
if err != nil {
|
||
return nil, nil, err
|
||
}
|
||
// 保证根目录存在,否则「按文件夹」浏览时无法 mkdir,且首次列表为空时界面无路径工具栏
|
||
if err := os.MkdirAll(root, 0755); err != nil {
|
||
return nil, nil, err
|
||
}
|
||
var files []ChatUploadFileItem
|
||
var folders []string
|
||
projectCache := make(map[string]string)
|
||
err = filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error {
|
||
if walkErr != nil {
|
||
return walkErr
|
||
}
|
||
rel, err := filepath.Rel(root, path)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if rel == "." {
|
||
return nil
|
||
}
|
||
relSlash := filepath.ToSlash(rel)
|
||
if d.IsDir() {
|
||
folders = append(folders, relSlash)
|
||
return nil
|
||
}
|
||
info, err := d.Info()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
parts := strings.Split(relSlash, "/")
|
||
var dateStr, convID string
|
||
if len(parts) >= 2 {
|
||
dateStr = parts[0]
|
||
}
|
||
if len(parts) >= 3 {
|
||
convID = parts[1]
|
||
}
|
||
projectID := h.conversationProjectID(convID, projectCache)
|
||
var subPath string
|
||
if len(parts) >= 4 {
|
||
subPath = strings.Join(parts[2:len(parts)-1], "/")
|
||
}
|
||
if conversationFilter != "" && convID != conversationFilter {
|
||
return nil
|
||
}
|
||
if projectFilter != "" && projectID != projectFilter {
|
||
return nil
|
||
}
|
||
absPath, _ := filepath.Abs(path)
|
||
files = append(files, ChatUploadFileItem{
|
||
RelativePath: relSlash,
|
||
AbsolutePath: absPath,
|
||
Name: d.Name(),
|
||
Source: chatUploadSourceUpload,
|
||
Size: info.Size(),
|
||
ModifiedUnix: info.ModTime().Unix(),
|
||
Date: dateStr,
|
||
ConversationID: convID,
|
||
ProjectID: projectID,
|
||
SubPath: subPath,
|
||
})
|
||
return nil
|
||
})
|
||
if err != nil {
|
||
return nil, nil, err
|
||
}
|
||
if conversationFilter != "" || projectFilter != "" {
|
||
filteredFolders := make([]string, 0, len(folders))
|
||
for _, rel := range folders {
|
||
parts := strings.Split(rel, "/")
|
||
if len(parts) >= 2 && (conversationFilter == "" || parts[1] == conversationFilter) && (projectFilter == "" || h.conversationProjectID(parts[1], projectCache) == projectFilter) {
|
||
filteredFolders = append(filteredFolders, rel)
|
||
continue
|
||
}
|
||
if len(parts) == 1 {
|
||
prefix := rel + "/"
|
||
for _, f := range files {
|
||
if strings.HasPrefix(f.RelativePath, prefix) {
|
||
filteredFolders = append(filteredFolders, rel)
|
||
break
|
||
}
|
||
}
|
||
}
|
||
}
|
||
folders = filteredFolders
|
||
}
|
||
files = filterSlice(files, func(file ChatUploadFileItem) bool {
|
||
return h.pathAllowed(c, file.RelativePath)
|
||
})
|
||
folders = filterSlice(folders, func(folder string) bool {
|
||
if h.pathAllowed(c, folder) {
|
||
return true
|
||
}
|
||
prefix := strings.TrimSuffix(folder, "/") + "/"
|
||
for _, file := range files {
|
||
if strings.HasPrefix(file.RelativePath, prefix) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
})
|
||
sort.Strings(folders)
|
||
sort.Slice(files, func(i, j int) bool {
|
||
return files[i].ModifiedUnix > files[j].ModifiedUnix
|
||
})
|
||
reductionFiles, err := h.collectReductionFiles(c, conversationFilter, projectFilter)
|
||
if err != nil {
|
||
h.logger.Warn("列举 reduction 产物失败", zap.Error(err))
|
||
} else if len(reductionFiles) > 0 {
|
||
files = append(files, reductionFiles...)
|
||
}
|
||
artifactFiles, err := h.collectConversationArtifactFiles(c, conversationFilter, projectFilter)
|
||
if err != nil {
|
||
h.logger.Warn("列举 conversation_artifacts 产物失败", zap.Error(err))
|
||
} else if len(artifactFiles) > 0 {
|
||
files = append(files, artifactFiles...)
|
||
}
|
||
sort.Slice(files, func(i, j int) bool {
|
||
return files[i].ModifiedUnix > files[j].ModifiedUnix
|
||
})
|
||
return files, folders, nil
|
||
}
|
||
|
||
func (h *ChatUploadsHandler) collectReductionFiles(c *gin.Context, conversationFilter, projectFilter string) ([]ChatUploadFileItem, error) {
|
||
root, err := h.absReductionRoot()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if st, err := os.Stat(root); err != nil || !st.IsDir() {
|
||
return nil, nil
|
||
}
|
||
projectCache := make(map[string]string)
|
||
files := make([]ChatUploadFileItem, 0)
|
||
for _, scope := range []string{"conversations", "projects"} {
|
||
scopeRoot := filepath.Join(root, scope)
|
||
_ = filepath.WalkDir(scopeRoot, func(path string, d os.DirEntry, walkErr error) error {
|
||
if walkErr != nil || d == nil || d.IsDir() {
|
||
return nil
|
||
}
|
||
rel, err := filepath.Rel(root, path)
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
relSlash := filepath.ToSlash(rel)
|
||
parts := strings.Split(relSlash, "/")
|
||
if len(parts) < 3 {
|
||
return nil
|
||
}
|
||
ownerID := parts[1]
|
||
if !h.reductionPathAllowed(c, scope, ownerID) {
|
||
return nil
|
||
}
|
||
var convID, projectID string
|
||
if scope == "conversations" {
|
||
convID = ownerID
|
||
projectID = h.conversationProjectID(convID, projectCache)
|
||
} else {
|
||
projectID = ownerID
|
||
}
|
||
if conversationFilter != "" && convID != conversationFilter {
|
||
return nil
|
||
}
|
||
if projectFilter != "" && projectID != projectFilter {
|
||
return nil
|
||
}
|
||
info, err := d.Info()
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
name := d.Name()
|
||
if filepath.Ext(name) == "" {
|
||
name += ".txt"
|
||
}
|
||
abs, _ := filepath.Abs(path)
|
||
files = append(files, ChatUploadFileItem{
|
||
RelativePath: reductionVirtualPrefix + relSlash,
|
||
AbsolutePath: abs,
|
||
Name: name,
|
||
Source: chatUploadSourceReduction,
|
||
Size: info.Size(),
|
||
ModifiedUnix: info.ModTime().Unix(),
|
||
Date: info.ModTime().Format("2006-01-02"),
|
||
ConversationID: convID,
|
||
ProjectID: projectID,
|
||
SubPath: strings.Join(parts[2:len(parts)-1], "/"),
|
||
})
|
||
return nil
|
||
})
|
||
}
|
||
return files, nil
|
||
}
|
||
|
||
func (h *ChatUploadsHandler) collectConversationArtifactFiles(c *gin.Context, conversationFilter, projectFilter string) ([]ChatUploadFileItem, error) {
|
||
root, err := h.absConversationArtifactsRoot()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if st, err := os.Stat(root); err != nil || !st.IsDir() {
|
||
return nil, nil
|
||
}
|
||
projectCache := make(map[string]string)
|
||
files := make([]ChatUploadFileItem, 0)
|
||
_ = filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error {
|
||
if walkErr != nil || d == nil || d.IsDir() {
|
||
return nil
|
||
}
|
||
rel, err := filepath.Rel(root, path)
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
relSlash := filepath.ToSlash(rel)
|
||
parts := strings.Split(relSlash, "/")
|
||
if len(parts) < 2 {
|
||
return nil
|
||
}
|
||
convID := parts[0]
|
||
if !h.conversationArtifactPathAllowed(c, convID) {
|
||
return nil
|
||
}
|
||
projectID := h.conversationProjectID(convID, projectCache)
|
||
if conversationFilter != "" && convID != conversationFilter {
|
||
return nil
|
||
}
|
||
if projectFilter != "" && projectID != projectFilter {
|
||
return nil
|
||
}
|
||
info, err := d.Info()
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
name := d.Name()
|
||
if filepath.Ext(name) == "" {
|
||
name += ".txt"
|
||
}
|
||
abs, _ := filepath.Abs(path)
|
||
files = append(files, ChatUploadFileItem{
|
||
RelativePath: artifactVirtualPrefix + relSlash,
|
||
AbsolutePath: abs,
|
||
Name: name,
|
||
Source: chatUploadSourceConversation,
|
||
Size: info.Size(),
|
||
ModifiedUnix: info.ModTime().Unix(),
|
||
Date: info.ModTime().Format("2006-01-02"),
|
||
ConversationID: convID,
|
||
ProjectID: projectID,
|
||
SubPath: strings.Join(parts[1:len(parts)-1], "/"),
|
||
})
|
||
return nil
|
||
})
|
||
return files, nil
|
||
}
|
||
|
||
func (h *ChatUploadsHandler) resolveReductionVirtualPath(relativePath string) (string, error) {
|
||
rel := strings.TrimPrefix(strings.TrimSpace(relativePath), reductionVirtualPrefix)
|
||
rel = filepath.Clean(filepath.FromSlash(rel))
|
||
if rel == "." || strings.HasPrefix(rel, "..") {
|
||
return "", fmt.Errorf("invalid path")
|
||
}
|
||
root, err := h.absReductionRoot()
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
full, err := filepath.Abs(filepath.Join(root, rel))
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
rootAbs, _ := filepath.Abs(root)
|
||
if full != rootAbs && !strings.HasPrefix(full, rootAbs+string(filepath.Separator)) {
|
||
return "", fmt.Errorf("path escapes reduction root")
|
||
}
|
||
return full, nil
|
||
}
|
||
|
||
func (h *ChatUploadsHandler) resolveConversationArtifactVirtualPath(relativePath string) (string, error) {
|
||
rel := strings.TrimPrefix(strings.TrimSpace(relativePath), artifactVirtualPrefix)
|
||
rel = filepath.Clean(filepath.FromSlash(rel))
|
||
if rel == "." || strings.HasPrefix(rel, "..") {
|
||
return "", fmt.Errorf("invalid path")
|
||
}
|
||
root, err := h.absConversationArtifactsRoot()
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
full, err := filepath.Abs(filepath.Join(root, rel))
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
rootAbs, _ := filepath.Abs(root)
|
||
if full != rootAbs && !strings.HasPrefix(full, rootAbs+string(filepath.Separator)) {
|
||
return "", fmt.Errorf("path escapes conversation artifacts root")
|
||
}
|
||
return full, nil
|
||
}
|
||
|
||
func chatUploadItemIsInternal(item ChatUploadFileItem) bool {
|
||
return item.Source == chatUploadSourceReduction ||
|
||
item.Source == chatUploadSourceConversation ||
|
||
strings.HasPrefix(item.RelativePath, reductionVirtualPrefix) ||
|
||
strings.HasPrefix(item.RelativePath, artifactVirtualPrefix)
|
||
}
|
||
|
||
func (h *ChatUploadsHandler) resolveListedFilePath(item ChatUploadFileItem) (string, error) {
|
||
switch {
|
||
case item.Source == chatUploadSourceReduction || strings.HasPrefix(item.RelativePath, reductionVirtualPrefix):
|
||
return h.resolveReductionVirtualPath(item.RelativePath)
|
||
case item.Source == chatUploadSourceConversation || strings.HasPrefix(item.RelativePath, artifactVirtualPrefix):
|
||
return h.resolveConversationArtifactVirtualPath(item.RelativePath)
|
||
default:
|
||
return h.resolveUnderChatUploads(item.RelativePath)
|
||
}
|
||
}
|
||
|
||
func chatUploadSourceMatches(item ChatUploadFileItem, sourceFilter string) bool {
|
||
sourceFilter = strings.TrimSpace(sourceFilter)
|
||
if sourceFilter == "" || sourceFilter == "all" {
|
||
return true
|
||
}
|
||
source := strings.TrimSpace(item.Source)
|
||
if source == "" {
|
||
source = chatUploadSourceUpload
|
||
}
|
||
return source == sourceFilter
|
||
}
|
||
|
||
func chatUploadSearchMatches(item ChatUploadFileItem, search string) bool {
|
||
search = strings.ToLower(strings.TrimSpace(search))
|
||
if search == "" {
|
||
return true
|
||
}
|
||
values := []string{
|
||
item.RelativePath,
|
||
item.Name,
|
||
item.Source,
|
||
item.Date,
|
||
item.ConversationID,
|
||
item.ProjectID,
|
||
item.SubPath,
|
||
}
|
||
for _, value := range values {
|
||
if strings.Contains(strings.ToLower(value), search) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func filterChatUploadItems(files []ChatUploadFileItem, sourceFilter, search string) []ChatUploadFileItem {
|
||
if strings.TrimSpace(sourceFilter) == "" && strings.TrimSpace(search) == "" {
|
||
return files
|
||
}
|
||
out := make([]ChatUploadFileItem, 0, len(files))
|
||
for _, item := range files {
|
||
if chatUploadSourceMatches(item, sourceFilter) && chatUploadSearchMatches(item, search) {
|
||
out = append(out, item)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func parsePositiveIntQuery(c *gin.Context, key string, def, max int) int {
|
||
raw := strings.TrimSpace(c.Query(key))
|
||
if raw == "" {
|
||
return def
|
||
}
|
||
if strings.EqualFold(raw, "all") {
|
||
return 0
|
||
}
|
||
n, err := strconv.Atoi(raw)
|
||
if err != nil || n <= 0 {
|
||
return def
|
||
}
|
||
if max > 0 && n > max {
|
||
return max
|
||
}
|
||
return n
|
||
}
|
||
|
||
func paginateChatUploadItems(files []ChatUploadFileItem, page, pageSize int) []ChatUploadFileItem {
|
||
if pageSize <= 0 {
|
||
return files
|
||
}
|
||
if page <= 0 {
|
||
page = 1
|
||
}
|
||
start := (page - 1) * pageSize
|
||
if start >= len(files) {
|
||
return []ChatUploadFileItem{}
|
||
}
|
||
end := start + pageSize
|
||
if end > len(files) {
|
||
end = len(files)
|
||
}
|
||
return files[start:end]
|
||
}
|
||
|
||
// List GET /api/chat-uploads
|
||
func (h *ChatUploadsHandler) List(c *gin.Context) {
|
||
conversationFilter := strings.TrimSpace(c.Query("conversation"))
|
||
projectFilter := strings.TrimSpace(c.Query("project"))
|
||
sourceFilter := strings.TrimSpace(c.Query("source"))
|
||
search := strings.TrimSpace(c.Query("search"))
|
||
page := parsePositiveIntQuery(c, "page", 1, 0)
|
||
pageSize := parsePositiveIntQuery(c, "pageSize", 20, 200)
|
||
files, folders, err := h.collectFiles(c, conversationFilter, projectFilter)
|
||
if err != nil {
|
||
h.logger.Warn("列举对话附件失败", zap.Error(err))
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
files = filterChatUploadItems(files, sourceFilter, search)
|
||
total := len(files)
|
||
paged := paginateChatUploadItems(files, page, pageSize)
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"files": paged,
|
||
"folders": folders,
|
||
"total": total,
|
||
"page": page,
|
||
"pageSize": pageSize,
|
||
})
|
||
}
|
||
|
||
// Export GET /api/chat-uploads/export?conversation=...&project=...
|
||
func (h *ChatUploadsHandler) Export(c *gin.Context) {
|
||
conversationFilter := strings.TrimSpace(c.Query("conversation"))
|
||
projectFilter := strings.TrimSpace(c.Query("project"))
|
||
sourceFilter := strings.TrimSpace(c.Query("source"))
|
||
search := strings.TrimSpace(c.Query("search"))
|
||
files, _, err := h.collectFiles(c, conversationFilter, projectFilter)
|
||
if err != nil {
|
||
h.logger.Warn("导出对话附件失败", zap.Error(err))
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
files = filterChatUploadItems(files, sourceFilter, search)
|
||
if len(files) == 0 {
|
||
c.JSON(http.StatusNotFound, gin.H{"error": "no files to export"})
|
||
return
|
||
}
|
||
nameParts := []string{"chat-files"}
|
||
if projectFilter != "" {
|
||
nameParts = append(nameParts, "project-"+projectFilter)
|
||
}
|
||
if conversationFilter != "" {
|
||
nameParts = append(nameParts, "conversation-"+conversationFilter)
|
||
}
|
||
nameParts = append(nameParts, time.Now().Format("20060102-150405"))
|
||
filename := strings.Join(nameParts, "-") + ".zip"
|
||
c.Header("Content-Type", "application/zip")
|
||
c.Header("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": filename}))
|
||
|
||
zw := zip.NewWriter(c.Writer)
|
||
defer zw.Close()
|
||
|
||
manifest := gin.H{
|
||
"exportedAt": time.Now().UTC().Format(time.RFC3339),
|
||
"conversationId": conversationFilter,
|
||
"projectId": projectFilter,
|
||
"source": sourceFilter,
|
||
"search": search,
|
||
"fileCount": len(files),
|
||
"files": files,
|
||
"layout": "chat uploads are stored under conversations/<conversationId>/; internal outputs are stored under internal/<source>/",
|
||
"sourceDirectory": []string{chatUploadsRootDirName, reductionRootDirName, artifactsRootDirName},
|
||
}
|
||
manifestBytes, _ := json.MarshalIndent(manifest, "", " ")
|
||
mw, err := zw.Create("manifest.json")
|
||
if err != nil {
|
||
h.logger.Warn("写入附件导出清单失败", zap.Error(err))
|
||
return
|
||
}
|
||
_, _ = mw.Write(manifestBytes)
|
||
|
||
used := make(map[string]int)
|
||
for _, item := range files {
|
||
abs, err := h.resolveListedFilePath(item)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
st, err := os.Stat(abs)
|
||
if err != nil || st.IsDir() {
|
||
continue
|
||
}
|
||
var zipName string
|
||
if item.Source == chatUploadSourceReduction || strings.HasPrefix(item.RelativePath, reductionVirtualPrefix) {
|
||
rel := strings.TrimPrefix(item.RelativePath, reductionVirtualPrefix)
|
||
zipName = filepath.ToSlash(filepath.Join("internal", "reduction", rel))
|
||
if filepath.Ext(zipName) == "" {
|
||
zipName += ".txt"
|
||
}
|
||
} else if item.Source == chatUploadSourceConversation || strings.HasPrefix(item.RelativePath, artifactVirtualPrefix) {
|
||
rel := strings.TrimPrefix(item.RelativePath, artifactVirtualPrefix)
|
||
zipName = filepath.ToSlash(filepath.Join("internal", "conversation_artifacts", rel))
|
||
if filepath.Ext(zipName) == "" {
|
||
zipName += ".txt"
|
||
}
|
||
} else {
|
||
conv := strings.TrimSpace(item.ConversationID)
|
||
if conv == "" || conv == "_manual" || conv == "_new" {
|
||
conv = "manual"
|
||
}
|
||
zipName = filepath.ToSlash(filepath.Join("conversations", conv, strings.TrimSpace(item.SubPath), item.Name))
|
||
}
|
||
if used[zipName] > 0 {
|
||
ext := filepath.Ext(zipName)
|
||
zipName = strings.TrimSuffix(zipName, ext) + fmt.Sprintf("-%d", used[zipName]+1) + ext
|
||
}
|
||
used[zipName]++
|
||
fw, err := zw.Create(zipName)
|
||
if err != nil {
|
||
h.logger.Warn("创建附件导出项失败", zap.String("path", item.RelativePath), zap.Error(err))
|
||
continue
|
||
}
|
||
src, err := os.Open(abs)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
_, copyErr := io.Copy(fw, src)
|
||
_ = src.Close()
|
||
if copyErr != nil {
|
||
h.logger.Warn("复制附件导出项失败", zap.String("path", item.RelativePath), zap.Error(copyErr))
|
||
return
|
||
}
|
||
}
|
||
if h.audit != nil {
|
||
h.audit.RecordOK(c, "file", "export", "导出对话附件", "chat_upload", filename, map[string]interface{}{
|
||
"conversation_id": conversationFilter,
|
||
"project_id": projectFilter,
|
||
"file_count": len(files),
|
||
})
|
||
}
|
||
}
|
||
|
||
// Download GET /api/chat-uploads/download?path=...
|
||
func (h *ChatUploadsHandler) Download(c *gin.Context) {
|
||
p := c.Query("path")
|
||
if strings.HasPrefix(strings.TrimSpace(p), reductionVirtualPrefix) {
|
||
if !h.reductionVirtualPathAllowed(c, p) {
|
||
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
|
||
return
|
||
}
|
||
abs, err := h.resolveReductionVirtualPath(p)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
st, err := os.Stat(abs)
|
||
if err != nil || st.IsDir() {
|
||
c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
|
||
return
|
||
}
|
||
name := filepath.Base(abs)
|
||
if filepath.Ext(name) == "" {
|
||
name += ".txt"
|
||
}
|
||
c.FileAttachment(abs, name)
|
||
return
|
||
}
|
||
if strings.HasPrefix(strings.TrimSpace(p), artifactVirtualPrefix) {
|
||
if !h.conversationArtifactVirtualPathAllowed(c, p) {
|
||
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
|
||
return
|
||
}
|
||
abs, err := h.resolveConversationArtifactVirtualPath(p)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
st, err := os.Stat(abs)
|
||
if err != nil || st.IsDir() {
|
||
c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
|
||
return
|
||
}
|
||
name := filepath.Base(abs)
|
||
if filepath.Ext(name) == "" {
|
||
name += ".txt"
|
||
}
|
||
c.FileAttachment(abs, name)
|
||
return
|
||
}
|
||
if !h.pathAllowed(c, p) {
|
||
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
|
||
return
|
||
}
|
||
abs, err := h.resolveUnderChatUploads(p)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
st, err := os.Stat(abs)
|
||
if err != nil || st.IsDir() {
|
||
c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
|
||
return
|
||
}
|
||
c.FileAttachment(abs, filepath.Base(abs))
|
||
}
|
||
|
||
type chatUploadPathBody struct {
|
||
Path string `json:"path"`
|
||
}
|
||
|
||
// Delete DELETE /api/chat-uploads
|
||
func (h *ChatUploadsHandler) Delete(c *gin.Context) {
|
||
var body chatUploadPathBody
|
||
if err := c.ShouldBindJSON(&body); err != nil || strings.TrimSpace(body.Path) == "" {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
|
||
return
|
||
}
|
||
if !h.pathAllowed(c, body.Path) {
|
||
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
|
||
return
|
||
}
|
||
abs, err := h.resolveUnderChatUploads(body.Path)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
st, err := os.Stat(abs)
|
||
if err != nil {
|
||
if os.IsNotExist(err) {
|
||
c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
|
||
return
|
||
}
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
if st.IsDir() {
|
||
if err := os.RemoveAll(abs); err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
} else {
|
||
if err := os.Remove(abs); err != nil {
|
||
if os.IsNotExist(err) {
|
||
c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
|
||
return
|
||
}
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
}
|
||
_ = h.db.DeleteChatUploadArtifactPath(filepath.ToSlash(filepath.Clean(filepath.FromSlash(body.Path))))
|
||
if h.audit != nil {
|
||
h.audit.RecordOK(c, "file", "delete", "删除对话附件", "chat_upload", body.Path, nil)
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||
}
|
||
|
||
type chatUploadMkdirBody struct {
|
||
Parent string `json:"parent"`
|
||
Name string `json:"name"`
|
||
}
|
||
|
||
// Mkdir POST /api/chat-uploads/mkdir — 在 parent 目录下新建子目录(parent 为 chat_uploads 下相对路径,空表示根目录;name 为单段目录名)
|
||
func (h *ChatUploadsHandler) Mkdir(c *gin.Context) {
|
||
var body chatUploadMkdirBody
|
||
if err := c.ShouldBindJSON(&body); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
|
||
return
|
||
}
|
||
name := strings.TrimSpace(body.Name)
|
||
if name == "" || strings.ContainsAny(name, `/\`) || name == "." || name == ".." {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid name"})
|
||
return
|
||
}
|
||
if utf8.RuneCountInString(name) > 200 {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "name too long"})
|
||
return
|
||
}
|
||
|
||
parent := strings.TrimSpace(body.Parent)
|
||
parent = filepath.ToSlash(filepath.Clean(filepath.FromSlash(parent)))
|
||
parent = strings.Trim(parent, "/")
|
||
if parent == "." {
|
||
parent = ""
|
||
}
|
||
if !h.pathAllowed(c, parent) {
|
||
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
|
||
return
|
||
}
|
||
|
||
root, err := h.absRoot()
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
|
||
if parent != "" {
|
||
absParent, err := h.resolveUnderChatUploads(parent)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
st, err := os.Stat(absParent)
|
||
if err != nil || !st.IsDir() {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "parent not found"})
|
||
return
|
||
}
|
||
}
|
||
|
||
var rel string
|
||
if parent == "" {
|
||
rel = name
|
||
} else {
|
||
rel = parent + "/" + name
|
||
}
|
||
absNew, err := h.resolveUnderChatUploads(rel)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
if _, err := os.Stat(absNew); err == nil {
|
||
c.JSON(http.StatusConflict, gin.H{"error": "already exists"})
|
||
return
|
||
}
|
||
if err := os.Mkdir(absNew, 0755); err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
relOut, _ := filepath.Rel(root, absNew)
|
||
c.JSON(http.StatusOK, gin.H{"ok": true, "relativePath": filepath.ToSlash(relOut)})
|
||
}
|
||
|
||
type chatUploadRenameBody struct {
|
||
Path string `json:"path"`
|
||
NewName string `json:"newName"`
|
||
}
|
||
|
||
// Rename PUT /api/chat-uploads/rename
|
||
func (h *ChatUploadsHandler) Rename(c *gin.Context) {
|
||
var body chatUploadRenameBody
|
||
if err := c.ShouldBindJSON(&body); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
|
||
return
|
||
}
|
||
if !h.pathAllowed(c, body.Path) {
|
||
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
|
||
return
|
||
}
|
||
newName := strings.TrimSpace(body.NewName)
|
||
if newName == "" || strings.ContainsAny(newName, `/\`) {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid newName"})
|
||
return
|
||
}
|
||
abs, err := h.resolveUnderChatUploads(body.Path)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
dir := filepath.Dir(abs)
|
||
newAbs := filepath.Join(dir, filepath.Base(newName))
|
||
root, _ := h.absRoot()
|
||
newAbs, _ = filepath.Abs(newAbs)
|
||
if newAbs != root && !strings.HasPrefix(newAbs, root+string(filepath.Separator)) {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid target path"})
|
||
return
|
||
}
|
||
if err := os.Rename(abs, newAbs); err != nil {
|
||
if os.IsNotExist(err) {
|
||
c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
|
||
return
|
||
}
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
newRel, _ := filepath.Rel(root, newAbs)
|
||
oldRel := filepath.ToSlash(filepath.Clean(filepath.FromSlash(body.Path)))
|
||
_ = h.db.RenameChatUploadArtifactPath(oldRel, filepath.ToSlash(newRel))
|
||
c.JSON(http.StatusOK, gin.H{"ok": true, "relativePath": filepath.ToSlash(newRel)})
|
||
}
|
||
|
||
type chatUploadContentBody struct {
|
||
Path string `json:"path"`
|
||
Content string `json:"content"`
|
||
}
|
||
|
||
// GetContent GET /api/chat-uploads/content?path=...
|
||
func (h *ChatUploadsHandler) GetContent(c *gin.Context) {
|
||
p := c.Query("path")
|
||
if !h.pathAllowed(c, p) {
|
||
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
|
||
return
|
||
}
|
||
abs, err := h.resolveUnderChatUploads(p)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
st, err := os.Stat(abs)
|
||
if err != nil || st.IsDir() {
|
||
c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
|
||
return
|
||
}
|
||
if st.Size() > maxChatUploadEditBytes {
|
||
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "file too large for editor"})
|
||
return
|
||
}
|
||
b, err := os.ReadFile(abs)
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
if !utf8.Valid(b) {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "binary file not editable in UI"})
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"content": string(b)})
|
||
}
|
||
|
||
// PutContent PUT /api/chat-uploads/content
|
||
func (h *ChatUploadsHandler) PutContent(c *gin.Context) {
|
||
var body chatUploadContentBody
|
||
if err := c.ShouldBindJSON(&body); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
|
||
return
|
||
}
|
||
if !h.pathAllowed(c, body.Path) {
|
||
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
|
||
return
|
||
}
|
||
if !utf8.ValidString(body.Content) {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "content must be valid UTF-8"})
|
||
return
|
||
}
|
||
if len(body.Content) > maxChatUploadEditBytes {
|
||
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "content too large"})
|
||
return
|
||
}
|
||
abs, err := h.resolveUnderChatUploads(body.Path)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
if err := os.WriteFile(abs, []byte(body.Content), 0644); err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||
}
|
||
|
||
func chatUploadShortRand(n int) string {
|
||
const letters = "0123456789abcdef"
|
||
b := make([]byte, n)
|
||
_, _ = rand.Read(b)
|
||
for i := range b {
|
||
b[i] = letters[int(b[i])%len(letters)]
|
||
}
|
||
return string(b)
|
||
}
|
||
|
||
// Upload POST /api/chat-uploads multipart: file;conversationId 可选;relativeDir 可选(chat_uploads 下目录的相对路径,将文件直接上传至该目录)
|
||
func (h *ChatUploadsHandler) Upload(c *gin.Context) {
|
||
fh, err := c.FormFile("file")
|
||
if err != nil || fh == nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "missing file"})
|
||
return
|
||
}
|
||
root, err := h.absRoot()
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
|
||
var targetDir string
|
||
targetRel := strings.TrimSpace(c.PostForm("relativeDir"))
|
||
if targetRel != "" {
|
||
if !h.pathAllowed(c, targetRel) {
|
||
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
|
||
return
|
||
}
|
||
absDir, err := h.resolveUnderChatUploads(targetRel)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
st, err := os.Stat(absDir)
|
||
if err != nil {
|
||
if os.IsNotExist(err) {
|
||
if err := os.MkdirAll(absDir, 0755); err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
} else {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
} else if !st.IsDir() {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "relativeDir is not a directory"})
|
||
return
|
||
}
|
||
targetDir = absDir
|
||
} else {
|
||
convID := strings.TrimSpace(c.PostForm("conversationId"))
|
||
dateStr := time.Now().Format("2006-01-02")
|
||
if !h.pathAllowed(c, filepath.ToSlash(filepath.Join(dateStr, convID))) {
|
||
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
|
||
return
|
||
}
|
||
convDir := convID
|
||
if convDir == "" {
|
||
convDir = "_manual"
|
||
} else {
|
||
convDir = strings.ReplaceAll(convDir, string(filepath.Separator), "_")
|
||
}
|
||
targetDir = filepath.Join(root, dateStr, convDir)
|
||
if err := os.MkdirAll(targetDir, 0755); err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
}
|
||
baseName := filepath.Base(fh.Filename)
|
||
if baseName == "" || baseName == "." {
|
||
baseName = "file"
|
||
}
|
||
baseName = strings.ReplaceAll(baseName, string(filepath.Separator), "_")
|
||
ext := filepath.Ext(baseName)
|
||
nameNoExt := strings.TrimSuffix(baseName, ext)
|
||
suffix := fmt.Sprintf("_%s_%s", time.Now().Format("150405"), chatUploadShortRand(6))
|
||
var unique string
|
||
if ext != "" {
|
||
unique = nameNoExt + suffix + ext
|
||
} else {
|
||
unique = baseName + suffix
|
||
}
|
||
fullPath := filepath.Join(targetDir, unique)
|
||
src, err := fh.Open()
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
defer src.Close()
|
||
dst, err := os.Create(fullPath)
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
defer dst.Close()
|
||
if _, err := io.Copy(dst, src); err != nil {
|
||
_ = os.Remove(fullPath)
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
rel, _ := filepath.Rel(root, fullPath)
|
||
absSaved, _ := filepath.Abs(fullPath)
|
||
if session, ok := security.CurrentSession(c); ok {
|
||
conversationID := strings.TrimSpace(c.PostForm("conversationId"))
|
||
if conversationID == "" {
|
||
parts := strings.Split(filepath.ToSlash(rel), "/")
|
||
if len(parts) >= 2 {
|
||
conversationID = parts[1]
|
||
}
|
||
}
|
||
_ = h.db.UpsertChatUploadArtifact(filepath.ToSlash(rel), conversationID, session.UserID)
|
||
}
|
||
if h.audit != nil {
|
||
h.audit.RecordOK(c, "file", "upload", "上传对话附件", "chat_upload", filepath.ToSlash(rel), map[string]interface{}{
|
||
"name": unique,
|
||
})
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"ok": true,
|
||
"relativePath": filepath.ToSlash(rel),
|
||
"absolutePath": absSaved,
|
||
"name": unique,
|
||
})
|
||
}
|