feat(chat): add endpoint to get quoted message by ID
Add GET /api/v1/messages/:id endpoint to retrieve quoted/replied messages for preview fill and navigation purposes. The endpoint includes authorization checks ensuring only conversation participants can access the message. Additionally improve file upload handling for native clients (expo-file-system) by accepting explicit displayName from frontend, with sanitization to prevent path traversal and 255-character length limit. File extension detection now prioritizes the real display name over the multipart header filename.
This commit is contained in:
@@ -358,6 +358,37 @@ func (h *MessageHandler) GetMessages(c *gin.Context) {
|
|||||||
response.Paginated(c, result, total, page, pageSize)
|
response.Paginated(c, result, total, page, pageSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetReplyMessage 按消息 ID 获取被引用消息(用于引用预览回填/跳转)
|
||||||
|
// GET /api/v1/messages/:id
|
||||||
|
func (h *MessageHandler) GetReplyMessage(c *gin.Context) {
|
||||||
|
userID := c.GetString("user_id")
|
||||||
|
if userID == "" {
|
||||||
|
response.Unauthorized(c, "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
messageID := c.Param("id")
|
||||||
|
if messageID == "" {
|
||||||
|
response.BadRequest(c, "message id is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
message, err := h.chatService.GetReplyMessage(c.Request.Context(), messageID, userID)
|
||||||
|
if err != nil {
|
||||||
|
response.BadRequest(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换为响应格式(标记已过期文件 / 撤回占位等,保持与列表接口一致)
|
||||||
|
result := h.convertMessagesWithExpiry(c.Request.Context(), []*model.Message{message})
|
||||||
|
if len(result) == 0 {
|
||||||
|
response.InternalServerError(c, "failed to convert message")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Success(c, result[0])
|
||||||
|
}
|
||||||
|
|
||||||
// SendMessage 发送消息
|
// SendMessage 发送消息
|
||||||
// POST /api/conversations/:id/messages
|
// POST /api/conversations/:id/messages
|
||||||
func (h *MessageHandler) SendMessage(c *gin.Context) {
|
func (h *MessageHandler) SendMessage(c *gin.Context) {
|
||||||
|
|||||||
@@ -109,8 +109,12 @@ func (h *UploadHandler) UploadFile(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
folder := c.DefaultPostForm("folder", "chat")
|
folder := c.DefaultPostForm("folder", "chat")
|
||||||
|
// 前端(尤其原生端 expo-file-system)无法自定义 multipart filename,
|
||||||
|
// 文件常被复制到缓存目录并以 UUID 命名,导致 header Filename 丢失真实名。
|
||||||
|
// 因此前端额外把真实文件名通过 "name" 字段回传,作为权威展示名。
|
||||||
|
displayName := c.PostForm("name")
|
||||||
|
|
||||||
result, err := h.uploadService.UploadFile(c.Request.Context(), file, folder, userID)
|
result, err := h.uploadService.UploadFile(c.Request.Context(), file, folder, userID, displayName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// 参数类错误(大小/类型)返回 400,其余返回 500
|
// 参数类错误(大小/类型)返回 400,其余返回 500
|
||||||
if file.Size > 0 && file.Size <= 100<<20 {
|
if file.Size > 0 && file.Size <= 100<<20 {
|
||||||
|
|||||||
@@ -407,6 +407,8 @@ func (r *Router) setupRoutes() {
|
|||||||
messages := v1.Group("/messages")
|
messages := v1.Group("/messages")
|
||||||
messages.Use(authMiddleware)
|
messages.Use(authMiddleware)
|
||||||
{
|
{
|
||||||
|
// 按 ID 获取被引用消息(引用预览回填/跳转)
|
||||||
|
messages.GET("/:id", r.MessageHandler.GetReplyMessage)
|
||||||
// 撤回/删除消息(统一接口)
|
// 撤回/删除消息(统一接口)
|
||||||
messages.POST("/delete_msg", middleware.RequireVerified(r.UserService), r.MessageHandler.HandleDeleteMsg)
|
messages.POST("/delete_msg", middleware.RequireVerified(r.UserService), r.MessageHandler.HandleDeleteMsg)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ type ChatService interface {
|
|||||||
// 消息扩展功能
|
// 消息扩展功能
|
||||||
RecallMessage(ctx context.Context, messageID string, userID string) error
|
RecallMessage(ctx context.Context, messageID string, userID string) error
|
||||||
DeleteMessage(ctx context.Context, messageID string, userID string) error
|
DeleteMessage(ctx context.Context, messageID string, userID string) error
|
||||||
|
// GetReplyMessage 按 ID 获取被引用消息(鉴权:必须是该会话参与者)
|
||||||
|
GetReplyMessage(ctx context.Context, messageID string, userID string) (*model.Message, error)
|
||||||
|
|
||||||
// 实时事件相关
|
// 实时事件相关
|
||||||
SendTyping(ctx context.Context, senderID string, conversationID string)
|
SendTyping(ctx context.Context, senderID string, conversationID string)
|
||||||
@@ -987,6 +989,23 @@ func (s *chatServiceImpl) DeleteMessage(ctx context.Context, messageID string, u
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetReplyMessage 按消息 ID 获取被引用消息(用于引用预览/跳转的回填)
|
||||||
|
// 鉴权:调用者必须是该消息所属会话的参与者,否则拒绝(防止 IDOR)
|
||||||
|
func (s *chatServiceImpl) GetReplyMessage(ctx context.Context, messageID string, userID string) (*model.Message, error) {
|
||||||
|
message, err := s.repo.GetMessageByID(messageID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("message not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证用户是否是会话参与者
|
||||||
|
_, err = s.getParticipant(ctx, message.ConversationID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("no permission to view this message")
|
||||||
|
}
|
||||||
|
|
||||||
|
return message, nil
|
||||||
|
}
|
||||||
|
|
||||||
// SendTyping 发送正在输入状态
|
// SendTyping 发送正在输入状态
|
||||||
func (s *chatServiceImpl) SendTyping(ctx context.Context, senderID string, conversationID string) {
|
func (s *chatServiceImpl) SendTyping(ctx context.Context, senderID string, conversationID string) {
|
||||||
if s.wsHub == nil {
|
if s.wsHub == nil {
|
||||||
|
|||||||
@@ -41,7 +41,12 @@ type UploadService interface {
|
|||||||
UploadImageBytes(ctx context.Context, raw []byte, filename string) (url, previewURL, previewURLLarge string, err error)
|
UploadImageBytes(ctx context.Context, raw []byte, filename string) (url, previewURL, previewURLLarge string, err error)
|
||||||
UploadAvatar(ctx context.Context, userID string, file *multipart.FileHeader) (string, error)
|
UploadAvatar(ctx context.Context, userID string, file *multipart.FileHeader) (string, error)
|
||||||
UploadCover(ctx context.Context, userID string, file *multipart.FileHeader) (string, error)
|
UploadCover(ctx context.Context, userID string, file *multipart.FileHeader) (string, error)
|
||||||
UploadFile(ctx context.Context, file *multipart.FileHeader, folder string, uploaderID string) (*UploadFileResult, error)
|
// UploadFile 上传聊天文件。
|
||||||
|
// displayName 为前端显式传入的真实文件名(用于展示);
|
||||||
|
// 当为空时回退到 multipart header 中的 Filename。
|
||||||
|
// 注意:原生端 expo-file-system 的 File.upload() 无法自定义 multipart filename,
|
||||||
|
// 文件常被复制到缓存目录并以 UUID 命名,导致 Filename 丢失真实名,因此需要显式回传。
|
||||||
|
UploadFile(ctx context.Context, file *multipart.FileHeader, folder string, uploaderID string, displayName string) (*UploadFileResult, error)
|
||||||
GetURL(ctx context.Context, objectName string) (string, error)
|
GetURL(ctx context.Context, objectName string) (string, error)
|
||||||
Delete(ctx context.Context, objectName string) error
|
Delete(ctx context.Context, objectName string) error
|
||||||
GeneratePreviewImages(ctx context.Context, originalData []byte, hash string) (previewURL, previewURLLarge string, err error)
|
GeneratePreviewImages(ctx context.Context, originalData []byte, hash string) (previewURL, previewURLLarge string, err error)
|
||||||
@@ -339,10 +344,40 @@ func validateChatFile(size int64, mimeType string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sanitizeFileName 清洗用于展示的文件名:
|
||||||
|
// - 去除首尾空白与两侧的路径分隔符/点号,避免展示绝对路径或隐藏文件名;
|
||||||
|
// - 去掉任何目录成分(防止 "a/b.pdf" 这类注入展示层);
|
||||||
|
// - 限制长度,避免超长名影响存储与列表展示。
|
||||||
|
//
|
||||||
|
// 注意:仅用于"展示名",不影响 S3 对象名(对象名始终为内容哈希)。
|
||||||
|
func sanitizeFileName(name string) string {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// 统一分隔符后只保留文件名部分(basename)
|
||||||
|
name = filepath.Base(strings.ReplaceAll(name, "\\", "/"))
|
||||||
|
// 去除开头多余的 "."(避免 ".file" 之外出现 "..file" 之类异常),但保留正常的隐藏文件名
|
||||||
|
name = strings.TrimLeft(name, ".")
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if len(name) > 255 {
|
||||||
|
// 过长时保留扩展名,截断主名
|
||||||
|
ext := strings.ToLower(filepath.Ext(name))
|
||||||
|
cut := 255 - len(ext)
|
||||||
|
if cut < 1 {
|
||||||
|
cut = 1
|
||||||
|
}
|
||||||
|
name = name[:cut] + ext
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
// UploadFile 上传聊天文件(非图片,按文件类型原样存储到 S3)
|
// UploadFile 上传聊天文件(非图片,按文件类型原样存储到 S3)
|
||||||
// folder 用于隔离不同来源(如 chat/post),仅允许字母数字与下划线。
|
// folder 用于隔离不同来源(如 chat/post),仅允许字母数字与下划线。
|
||||||
// uploaderID 为上传者用户 ID,用于审计。
|
// uploaderID 为上传者用户 ID,用于审计。
|
||||||
func (s *uploadService) UploadFile(ctx context.Context, file *multipart.FileHeader, folder string, uploaderID string) (*UploadFileResult, error) {
|
// displayName 为前端显式传入的真实文件名(用于展示),
|
||||||
|
// 为空时回退到 multipart header 中的 Filename。
|
||||||
|
func (s *uploadService) UploadFile(ctx context.Context, file *multipart.FileHeader, folder string, uploaderID string, displayName string) (*UploadFileResult, error) {
|
||||||
if file == nil {
|
if file == nil {
|
||||||
return nil, fmt.Errorf("文件为空")
|
return nil, fmt.Errorf("文件为空")
|
||||||
}
|
}
|
||||||
@@ -350,6 +385,12 @@ func (s *uploadService) UploadFile(ctx context.Context, file *multipart.FileHead
|
|||||||
return nil, fmt.Errorf("文件大小超过限制(最大 %d MB)", MaxChatFileSize>>20)
|
return nil, fmt.Errorf("文件大小超过限制(最大 %d MB)", MaxChatFileSize>>20)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 选择用于展示的真实文件名:优先用前端显式回传的 displayName,
|
||||||
|
// 它最贴近用户原始文件名(multipart header 的 Filename 在原生端常被 UUID 覆盖)。
|
||||||
|
displayName = sanitizeFileName(displayName)
|
||||||
|
headerName := sanitizeFileName(file.Filename)
|
||||||
|
fileName := cmp.Or(displayName, headerName)
|
||||||
|
|
||||||
// 校验与规范化 folder,避免路径穿越
|
// 校验与规范化 folder,避免路径穿越
|
||||||
folder = strings.Map(func(r rune) rune {
|
folder = strings.Map(func(r rune) rune {
|
||||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' {
|
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' {
|
||||||
@@ -383,10 +424,15 @@ func (s *uploadService) UploadFile(ctx context.Context, file *multipart.FileHead
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 以内容哈希命名,避免重复存储;保留原始扩展名(便于浏览器识别)
|
// 以内容哈希命名,避免重复存储;扩展名优先取真实文件名(便于浏览器识别)
|
||||||
hash := sha256.Sum256(data)
|
hash := sha256.Sum256(data)
|
||||||
hashStr := fmt.Sprintf("%x", hash)
|
hashStr := fmt.Sprintf("%x", hash)
|
||||||
ext := strings.ToLower(filepath.Ext(file.Filename))
|
// header Filename 在原生端常为 UUID,因此优先从真实展示名推导扩展名,
|
||||||
|
// 两者都没有时再从 MIME 推断
|
||||||
|
ext := strings.ToLower(filepath.Ext(fileName))
|
||||||
|
if ext == "" {
|
||||||
|
ext = strings.ToLower(filepath.Ext(file.Filename))
|
||||||
|
}
|
||||||
if ext == "" {
|
if ext == "" {
|
||||||
// 从 MIME 推断扩展名(mime.ExtensionsByType 在新版本返回 (exts, err))
|
// 从 MIME 推断扩展名(mime.ExtensionsByType 在新版本返回 (exts, err))
|
||||||
if es, _ := mime.ExtensionsByType(mimeType); len(es) > 0 {
|
if es, _ := mime.ExtensionsByType(mimeType); len(es) > 0 {
|
||||||
@@ -407,7 +453,7 @@ func (s *uploadService) UploadFile(ctx context.Context, file *multipart.FileHead
|
|||||||
record := &model.UploadedFile{
|
record := &model.UploadedFile{
|
||||||
URL: url,
|
URL: url,
|
||||||
ObjectName: objectName,
|
ObjectName: objectName,
|
||||||
Name: file.Filename,
|
Name: fileName,
|
||||||
Size: file.Size,
|
Size: file.Size,
|
||||||
MimeType: mimeType,
|
MimeType: mimeType,
|
||||||
Folder: folder,
|
Folder: folder,
|
||||||
@@ -420,7 +466,7 @@ func (s *uploadService) UploadFile(ctx context.Context, file *multipart.FileHead
|
|||||||
|
|
||||||
return &UploadFileResult{
|
return &UploadFileResult{
|
||||||
URL: url,
|
URL: url,
|
||||||
Name: file.Filename,
|
Name: fileName,
|
||||||
Size: file.Size,
|
Size: file.Size,
|
||||||
MimeType: mimeType,
|
MimeType: mimeType,
|
||||||
}, nil
|
}, nil
|
||||||
|
|||||||
Reference in New Issue
Block a user