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.
This commit is contained in:
@@ -65,19 +65,19 @@ func InitializeApp() (*App, error) {
|
|||||||
channelService := wire.ProvideChannelService(channelRepository, cache)
|
channelService := wire.ProvideChannelService(channelRepository, cache)
|
||||||
postHandler := wire.ProvidePostHandler(postService, userService, channelService, logService)
|
postHandler := wire.ProvidePostHandler(postService, userService, channelService, logService)
|
||||||
commentHandler := handler.NewCommentHandler(commentService)
|
commentHandler := handler.NewCommentHandler(commentService)
|
||||||
chatService := wire.ProvideChatService(db, messageRepository, userRepository, hub, cache)
|
s3Client, err := wire.ProvideS3Client(config)
|
||||||
messageService := wire.ProvideMessageService(db, messageRepository, cache)
|
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)
|
groupRepository := repository.NewGroupRepository(db)
|
||||||
groupService := wire.ProvideGroupService(db, groupRepository, userRepository, messageRepository, hub, cache)
|
groupService := wire.ProvideGroupService(db, groupRepository, userRepository, messageRepository, hub, cache)
|
||||||
messageHandler := wire.ProvideMessageHandler(chatService, messageService, userService, groupService, hub)
|
messageHandler := wire.ProvideMessageHandler(chatService, messageService, userService, groupService, hub)
|
||||||
notificationRepository := repository.NewNotificationRepository(db)
|
notificationRepository := repository.NewNotificationRepository(db)
|
||||||
notificationService := wire.ProvideNotificationService(notificationRepository, cache)
|
notificationService := wire.ProvideNotificationService(notificationRepository, cache)
|
||||||
notificationHandler := handler.NewNotificationHandler(notificationService)
|
notificationHandler := handler.NewNotificationHandler(notificationService)
|
||||||
s3Client, err := wire.ProvideS3Client(config)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
uploadService := wire.ProvideUploadService(s3Client, userService)
|
|
||||||
uploadHandler := handler.NewUploadHandler(uploadService)
|
uploadHandler := handler.NewUploadHandler(uploadService)
|
||||||
jwtService := wire.ProvideJWTService(config)
|
jwtService := wire.ProvideJWTService(config)
|
||||||
pushHandler := handler.NewPushHandler(pushService)
|
pushHandler := handler.NewPushHandler(pushService)
|
||||||
|
|||||||
@@ -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
|
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 - 使用自定义域名
|
// GetURL 获取文件URL - 使用自定义域名
|
||||||
func (c *Client) GetURL(ctx context.Context, objectName string) (string, error) {
|
func (c *Client) GetURL(ctx context.Context, objectName string) (string, error) {
|
||||||
// 使用自定义域名构建URL,包含bucket名称
|
// 使用自定义域名构建URL,包含bucket名称
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ type chatServiceImpl struct {
|
|||||||
|
|
||||||
// 缓存相关字段
|
// 缓存相关字段
|
||||||
conversationCache *cache.ConversationCache
|
conversationCache *cache.ConversationCache
|
||||||
|
uploadService *UploadService // 校验聊天图片 URL 来源
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewChatService 创建聊天服务
|
// NewChatService 创建聊天服务
|
||||||
@@ -74,6 +75,7 @@ func NewChatService(
|
|||||||
sensitive SensitiveService,
|
sensitive SensitiveService,
|
||||||
sseHub *sse.Hub,
|
sseHub *sse.Hub,
|
||||||
cacheBackend cache.Cache,
|
cacheBackend cache.Cache,
|
||||||
|
uploadService *UploadService,
|
||||||
) ChatService {
|
) ChatService {
|
||||||
// 创建适配器
|
// 创建适配器
|
||||||
convRepoAdapter := cache.NewConversationRepositoryAdapter(repo)
|
convRepoAdapter := cache.NewConversationRepositoryAdapter(repo)
|
||||||
@@ -94,6 +96,7 @@ func NewChatService(
|
|||||||
sensitive: sensitive,
|
sensitive: sensitive,
|
||||||
sseHub: sseHub,
|
sseHub: sseHub,
|
||||||
conversationCache: conversationCache,
|
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 err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return nil, errors.New("您不是该会话的参与者")
|
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)
|
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{
|
message := &model.Message{
|
||||||
ConversationID: conversationID,
|
ConversationID: conversationID,
|
||||||
@@ -351,8 +360,6 @@ func (s *chatServiceImpl) SendMessage(ctx context.Context, senderID string, conv
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = participant // 避免未使用变量警告
|
|
||||||
|
|
||||||
return message, nil
|
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)
|
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{
|
message := &model.Message{
|
||||||
ConversationID: conversationID,
|
ConversationID: conversationID,
|
||||||
SenderID: senderID,
|
SenderID: senderID,
|
||||||
|
|||||||
66
internal/service/message_segment_image.go
Normal file
66
internal/service/message_segment_image.go
Normal 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
|
||||||
|
}
|
||||||
@@ -35,10 +35,12 @@ type MessageService struct {
|
|||||||
|
|
||||||
// 基础缓存(用于简单缓存操作)
|
// 基础缓存(用于简单缓存操作)
|
||||||
baseCache cache.Cache
|
baseCache cache.Cache
|
||||||
|
|
||||||
|
uploadService *UploadService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewMessageService 创建消息服务
|
// 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)
|
convRepoAdapter := cache.NewConversationRepositoryAdapter(messageRepo)
|
||||||
msgRepoAdapter := cache.NewMessageRepositoryAdapter(messageRepo)
|
msgRepoAdapter := cache.NewMessageRepositoryAdapter(messageRepo)
|
||||||
@@ -56,6 +58,7 @@ func NewMessageService(db *gorm.DB, messageRepo *repository.MessageRepository, c
|
|||||||
messageRepo: messageRepo,
|
messageRepo: messageRepo,
|
||||||
conversationCache: conversationCache,
|
conversationCache: conversationCache,
|
||||||
baseCache: cacheBackend,
|
baseCache: cacheBackend,
|
||||||
|
uploadService: uploadService,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,6 +77,12 @@ func (s *MessageService) SendMessage(ctx context.Context, senderID, receiverID s
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if s.uploadService != nil {
|
||||||
|
if err := s.uploadService.ValidateChatMessageImageSegments(segments); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
msg := &model.Message{
|
msg := &model.Message{
|
||||||
ConversationID: conv.ID,
|
ConversationID: conv.ID,
|
||||||
SenderID: senderID,
|
SenderID: senderID,
|
||||||
|
|||||||
@@ -75,6 +75,41 @@ func (s *UploadService) UploadImage(ctx context.Context, file *multipart.FileHea
|
|||||||
return url, previewURL, previewURLLarge, nil
|
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获取文件扩展名
|
// getExtFromContentType 根据Content-Type获取文件扩展名
|
||||||
func getExtFromContentType(contentType string) string {
|
func getExtFromContentType(contentType string) string {
|
||||||
baseType, _, err := mime.ParseMediaType(contentType)
|
baseType, _, err := mime.ParseMediaType(contentType)
|
||||||
|
|||||||
@@ -133,8 +133,9 @@ func ProvideMessageService(
|
|||||||
db *gorm.DB,
|
db *gorm.DB,
|
||||||
messageRepo *repository.MessageRepository,
|
messageRepo *repository.MessageRepository,
|
||||||
cacheBackend cache.Cache,
|
cacheBackend cache.Cache,
|
||||||
|
uploadService *service.UploadService,
|
||||||
) *service.MessageService {
|
) *service.MessageService {
|
||||||
return service.NewMessageService(db, messageRepo, cacheBackend)
|
return service.NewMessageService(db, messageRepo, cacheBackend, uploadService)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProvideNotificationService 提供通知服务
|
// ProvideNotificationService 提供通知服务
|
||||||
@@ -185,8 +186,9 @@ func ProvideChatService(
|
|||||||
userRepo *repository.UserRepository,
|
userRepo *repository.UserRepository,
|
||||||
sseHub *sse.Hub,
|
sseHub *sse.Hub,
|
||||||
cacheBackend cache.Cache,
|
cacheBackend cache.Cache,
|
||||||
|
uploadService *service.UploadService,
|
||||||
) service.ChatService {
|
) service.ChatService {
|
||||||
return service.NewChatService(db, messageRepo, userRepo, nil, sseHub, cacheBackend)
|
return service.NewChatService(db, messageRepo, userRepo, nil, sseHub, cacheBackend, uploadService)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProvideScheduleService 提供日程服务
|
// ProvideScheduleService 提供日程服务
|
||||||
|
|||||||
Reference in New Issue
Block a user