From 28a45caad38ce597880d08dc45d87ecf6f69f86f Mon Sep 17 00:00:00 2001 From: lafay <2021211506@stu.hit.edu.cn> Date: Wed, 25 Mar 2026 03:57:40 +0800 Subject: [PATCH] feat(upload): integrate upload service for chat image validation and S3 uploads - Added UploadService to handle image uploads and validation for chat messages. - Updated ChatService and MessageService to utilize UploadService for validating image segments in messages. - Enhanced wire generation to inject UploadService into relevant services. - Introduced UploadImageBytes method in UploadService for uploading images directly from memory. - Added TrustedPublicURLPrefix method in S3 Client for generating public URLs for uploaded images. --- cmd/server/wire_gen.go | 14 ++--- internal/pkg/s3/s3.go | 12 +++++ internal/service/chat_service.go | 19 +++++-- internal/service/message_segment_image.go | 66 +++++++++++++++++++++++ internal/service/message_service.go | 11 +++- internal/service/upload_service.go | 35 ++++++++++++ internal/wire/service.go | 6 ++- 7 files changed, 150 insertions(+), 13 deletions(-) create mode 100644 internal/service/message_segment_image.go diff --git a/cmd/server/wire_gen.go b/cmd/server/wire_gen.go index 18f346e..f54a7b5 100644 --- a/cmd/server/wire_gen.go +++ b/cmd/server/wire_gen.go @@ -65,19 +65,19 @@ func InitializeApp() (*App, error) { channelService := wire.ProvideChannelService(channelRepository, cache) postHandler := wire.ProvidePostHandler(postService, userService, channelService, logService) commentHandler := handler.NewCommentHandler(commentService) - chatService := wire.ProvideChatService(db, messageRepository, userRepository, hub, cache) - messageService := wire.ProvideMessageService(db, messageRepository, cache) + s3Client, err := wire.ProvideS3Client(config) + if err != nil { + return nil, err + } + uploadService := wire.ProvideUploadService(s3Client, userService) + chatService := wire.ProvideChatService(db, messageRepository, userRepository, hub, cache, uploadService) + messageService := wire.ProvideMessageService(db, messageRepository, cache, uploadService) groupRepository := repository.NewGroupRepository(db) groupService := wire.ProvideGroupService(db, groupRepository, userRepository, messageRepository, hub, cache) messageHandler := wire.ProvideMessageHandler(chatService, messageService, userService, groupService, hub) notificationRepository := repository.NewNotificationRepository(db) notificationService := wire.ProvideNotificationService(notificationRepository, cache) notificationHandler := handler.NewNotificationHandler(notificationService) - s3Client, err := wire.ProvideS3Client(config) - if err != nil { - return nil, err - } - uploadService := wire.ProvideUploadService(s3Client, userService) uploadHandler := handler.NewUploadHandler(uploadService) jwtService := wire.ProvideJWTService(config) pushHandler := handler.NewPushHandler(pushService) diff --git a/internal/pkg/s3/s3.go b/internal/pkg/s3/s3.go index 47d0b45..8a35ca3 100644 --- a/internal/pkg/s3/s3.go +++ b/internal/pkg/s3/s3.go @@ -88,6 +88,18 @@ func (c *Client) UploadData(ctx context.Context, objectName string, data []byte, return fmt.Sprintf("%s://%s/%s/%s", scheme, c.domain, c.bucket, objectName), nil } +// TrustedPublicURLPrefix 本站公开对象 URL 前缀(scheme://domain/bucket/),用于校验聊天图片等仅引用本地上传资源。 +func (c *Client) TrustedPublicURLPrefix() string { + if c == nil || c.domain == "" || c.bucket == "" { + return "" + } + scheme := "https" + if c.domain == c.bucket { + scheme = "http" + } + return fmt.Sprintf("%s://%s/%s/", scheme, c.domain, c.bucket) +} + // GetURL 获取文件URL - 使用自定义域名 func (c *Client) GetURL(ctx context.Context, objectName string) (string, error) { // 使用自定义域名构建URL,包含bucket名称 diff --git a/internal/service/chat_service.go b/internal/service/chat_service.go index 6fd3bec..afcf5fd 100644 --- a/internal/service/chat_service.go +++ b/internal/service/chat_service.go @@ -64,6 +64,7 @@ type chatServiceImpl struct { // 缓存相关字段 conversationCache *cache.ConversationCache + uploadService *UploadService // 校验聊天图片 URL 来源 } // NewChatService 创建聊天服务 @@ -74,6 +75,7 @@ func NewChatService( sensitive SensitiveService, sseHub *sse.Hub, cacheBackend cache.Cache, + uploadService *UploadService, ) ChatService { // 创建适配器 convRepoAdapter := cache.NewConversationRepositoryAdapter(repo) @@ -94,6 +96,7 @@ func NewChatService( sensitive: sensitive, sseHub: sseHub, conversationCache: conversationCache, + uploadService: uploadService, } } @@ -269,7 +272,7 @@ func (s *chatServiceImpl) SendMessage(ctx context.Context, senderID string, conv } // 验证用户是否是会话参与者 - participant, err := s.getParticipant(ctx, conversationID, senderID) + _, err = s.getParticipant(ctx, conversationID, senderID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errors.New("您不是该会话的参与者") @@ -277,6 +280,12 @@ func (s *chatServiceImpl) SendMessage(ctx context.Context, senderID string, conv return nil, fmt.Errorf("failed to get participant: %w", err) } + if s.uploadService != nil { + if err := s.uploadService.ValidateChatMessageImageSegments(segments); err != nil { + return nil, err + } + } + // 创建消息 message := &model.Message{ ConversationID: conversationID, @@ -351,8 +360,6 @@ func (s *chatServiceImpl) SendMessage(ctx context.Context, senderID string, conv } } - _ = participant // 避免未使用变量警告 - return message, nil } @@ -701,6 +708,12 @@ func (s *chatServiceImpl) SaveMessage(ctx context.Context, senderID string, conv return nil, fmt.Errorf("failed to get participant: %w", err) } + if s.uploadService != nil { + if err := s.uploadService.ValidateChatMessageImageSegments(segments); err != nil { + return nil, err + } + } + message := &model.Message{ ConversationID: conversationID, SenderID: senderID, diff --git a/internal/service/message_segment_image.go b/internal/service/message_segment_image.go new file mode 100644 index 0000000..88e04b2 --- /dev/null +++ b/internal/service/message_segment_image.go @@ -0,0 +1,66 @@ +package service + +import ( + "fmt" + "net/url" + "strings" + + "carrot_bbs/internal/model" +) + +// ValidateChatMessageImageSegments 校验聊天消息中的图片段:禁止 data:/base64 内联,仅允许引用本站 S3 公开前缀下的 URL。 +func (s *UploadService) ValidateChatMessageImageSegments(segments model.MessageSegments) error { + if s == nil || s.s3Client == nil { + return nil + } + prefix := strings.TrimSpace(s.s3Client.TrustedPublicURLPrefix()) + if prefix == "" { + return nil + } + + for _, seg := range segments { + if seg.Type != string(model.ContentTypeImage) && seg.Type != "image" { + continue + } + if seg.Data == nil { + return fmt.Errorf("图片消息数据无效") + } + urlStr, _ := seg.Data["url"].(string) + urlStr = strings.TrimSpace(urlStr) + if urlStr == "" { + return fmt.Errorf("图片消息缺少 url") + } + low := strings.ToLower(urlStr) + if strings.HasPrefix(low, "data:") || strings.HasPrefix(low, "base64:") || strings.HasPrefix(low, "base64://") { + return fmt.Errorf("禁止在消息中内联 base64 或 data URL,请先通过上传接口获取图片地址") + } + if strings.ContainsAny(urlStr, "\n\r\x00") { + return fmt.Errorf("非法图片地址") + } + + u, err := url.Parse(urlStr) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") { + return fmt.Errorf("图片地址必须为 http(s) 链接") + } + if u.Host == "" { + return fmt.Errorf("图片地址无效") + } + + if !strings.HasPrefix(urlStr, prefix) { + return fmt.Errorf("图片必须使用本站上传接口生成的资源地址") + } + + if thumb, ok := seg.Data["thumbnail_url"].(string); ok { + thumb = strings.TrimSpace(thumb) + if thumb != "" { + if strings.HasPrefix(strings.ToLower(thumb), "data:") { + return fmt.Errorf("禁止在图片消息中使用 data URL 缩略图") + } + if !strings.HasPrefix(thumb, prefix) { + return fmt.Errorf("缩略图地址不合法") + } + } + } + } + return nil +} diff --git a/internal/service/message_service.go b/internal/service/message_service.go index 69b32ca..a5e5ba9 100644 --- a/internal/service/message_service.go +++ b/internal/service/message_service.go @@ -35,10 +35,12 @@ type MessageService struct { // 基础缓存(用于简单缓存操作) baseCache cache.Cache + + uploadService *UploadService } // NewMessageService 创建消息服务 -func NewMessageService(db *gorm.DB, messageRepo *repository.MessageRepository, cacheBackend cache.Cache) *MessageService { +func NewMessageService(db *gorm.DB, messageRepo *repository.MessageRepository, cacheBackend cache.Cache, uploadService *UploadService) *MessageService { // 创建适配器 convRepoAdapter := cache.NewConversationRepositoryAdapter(messageRepo) msgRepoAdapter := cache.NewMessageRepositoryAdapter(messageRepo) @@ -56,6 +58,7 @@ func NewMessageService(db *gorm.DB, messageRepo *repository.MessageRepository, c messageRepo: messageRepo, conversationCache: conversationCache, baseCache: cacheBackend, + uploadService: uploadService, } } @@ -74,6 +77,12 @@ func (s *MessageService) SendMessage(ctx context.Context, senderID, receiverID s return nil, err } + if s.uploadService != nil { + if err := s.uploadService.ValidateChatMessageImageSegments(segments); err != nil { + return nil, err + } + } + msg := &model.Message{ ConversationID: conv.ID, SenderID: senderID, diff --git a/internal/service/upload_service.go b/internal/service/upload_service.go index 2a1fd54..b162f25 100644 --- a/internal/service/upload_service.go +++ b/internal/service/upload_service.go @@ -75,6 +75,41 @@ func (s *UploadService) UploadImage(ctx context.Context, file *multipart.FileHea return url, previewURL, previewURLLarge, nil } +const maxUploadImageBytesFromBuffer = 15 << 20 // 与聊天内联解码上限一致,略大于 upload.max_file_size 时可接受 + +// UploadImageBytes 从内存字节上传图片(与 multipart 上传走同一套压缩与存储逻辑)。 +func (s *UploadService) UploadImageBytes(ctx context.Context, raw []byte, filename string) (url, previewURL, previewURLLarge string, err error) { + if len(raw) == 0 { + return "", "", "", fmt.Errorf("empty image data") + } + if len(raw) > maxUploadImageBytesFromBuffer { + return "", "", "", fmt.Errorf("image data too large") + } + + detectedType := normalizeImageContentType(http.DetectContentType(raw)) + compressedData, compressedType, cerr := compressImageData(raw, detectedType) + if cerr != nil { + compressedData = raw + compressedType = detectedType + } + compressedType = cmp.Or(compressedType, http.DetectContentType(compressedData)) + ext := cmp.Or(getExtFromContentType(compressedType), strings.ToLower(filepath.Ext(filename)), ".jpg") + + hash := sha256.Sum256(compressedData) + hashStr := fmt.Sprintf("%x", hash) + objectName := fmt.Sprintf("images/%s%s", hashStr, ext) + url, err = s.s3Client.UploadData(ctx, objectName, compressedData, compressedType) + if err != nil { + return "", "", "", fmt.Errorf("failed to upload to S3: %w", err) + } + + previewURL, previewURLLarge, prevErr := s.GeneratePreviewImages(ctx, compressedData, hashStr) + if prevErr != nil { + zap.L().Warn("UploadImageBytes: preview generation failed", zap.Error(prevErr)) + } + return url, previewURL, previewURLLarge, nil +} + // getExtFromContentType 根据Content-Type获取文件扩展名 func getExtFromContentType(contentType string) string { baseType, _, err := mime.ParseMediaType(contentType) diff --git a/internal/wire/service.go b/internal/wire/service.go index 50aa146..26760ca 100644 --- a/internal/wire/service.go +++ b/internal/wire/service.go @@ -133,8 +133,9 @@ func ProvideMessageService( db *gorm.DB, messageRepo *repository.MessageRepository, cacheBackend cache.Cache, + uploadService *service.UploadService, ) *service.MessageService { - return service.NewMessageService(db, messageRepo, cacheBackend) + return service.NewMessageService(db, messageRepo, cacheBackend, uploadService) } // ProvideNotificationService 提供通知服务 @@ -185,8 +186,9 @@ func ProvideChatService( userRepo *repository.UserRepository, sseHub *sse.Hub, cacheBackend cache.Cache, + uploadService *service.UploadService, ) service.ChatService { - return service.NewChatService(db, messageRepo, userRepo, nil, sseHub, cacheBackend) + return service.NewChatService(db, messageRepo, userRepo, nil, sseHub, cacheBackend, uploadService) } // ProvideScheduleService 提供日程服务