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:
@@ -41,7 +41,12 @@ type UploadService interface {
|
||||
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)
|
||||
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)
|
||||
Delete(ctx context.Context, objectName string) 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
|
||||
}
|
||||
|
||||
// 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)
|
||||
// folder 用于隔离不同来源(如 chat/post),仅允许字母数字与下划线。
|
||||
// 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 {
|
||||
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)
|
||||
}
|
||||
|
||||
// 选择用于展示的真实文件名:优先用前端显式回传的 displayName,
|
||||
// 它最贴近用户原始文件名(multipart header 的 Filename 在原生端常被 UUID 覆盖)。
|
||||
displayName = sanitizeFileName(displayName)
|
||||
headerName := sanitizeFileName(file.Filename)
|
||||
fileName := cmp.Or(displayName, headerName)
|
||||
|
||||
// 校验与规范化 folder,避免路径穿越
|
||||
folder = strings.Map(func(r rune) rune {
|
||||
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
|
||||
}
|
||||
|
||||
// 以内容哈希命名,避免重复存储;保留原始扩展名(便于浏览器识别)
|
||||
// 以内容哈希命名,避免重复存储;扩展名优先取真实文件名(便于浏览器识别)
|
||||
hash := sha256.Sum256(data)
|
||||
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 == "" {
|
||||
// 从 MIME 推断扩展名(mime.ExtensionsByType 在新版本返回 (exts, err))
|
||||
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{
|
||||
URL: url,
|
||||
ObjectName: objectName,
|
||||
Name: file.Filename,
|
||||
Name: fileName,
|
||||
Size: file.Size,
|
||||
MimeType: mimeType,
|
||||
Folder: folder,
|
||||
@@ -420,7 +466,7 @@ func (s *uploadService) UploadFile(ctx context.Context, file *multipart.FileHead
|
||||
|
||||
return &UploadFileResult{
|
||||
URL: url,
|
||||
Name: file.Filename,
|
||||
Name: fileName,
|
||||
Size: file.Size,
|
||||
MimeType: mimeType,
|
||||
}, nil
|
||||
|
||||
Reference in New Issue
Block a user