feat(upload): integrate upload service for chat image validation and S3 uploads
All checks were successful
Build Backend / build (push) Successful in 13m35s
Build Backend / build-docker (push) Successful in 1m11s

- 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.
This commit is contained in:
lafay
2026-03-25 03:57:40 +08:00
parent a887e8ea23
commit 28a45caad3
7 changed files with 150 additions and 13 deletions

View File

@@ -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,

View File

@@ -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
}

View File

@@ -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,

View File

@@ -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)