From 8a9e202b5648c681d68685955962089c8f83bcfe Mon Sep 17 00:00:00 2001 From: richjohnrambo Date: Mon, 7 Sep 2026 16:13:11 +0800 Subject: [PATCH] Preserve uploaded file extensions --- server/hdl_files.go | 15 ++++---- server/hdl_files_test.go | 4 +-- server/media/fs/filesys.go | 3 +- server/media/media.go | 52 +++++++++++++++++++++++++++ server/media/media_test.go | 70 +++++++++++++++++++++++++++++++++++++ server/media/oss/oss.go | 3 +- server/media/s3/s3.go | 3 +- server/store/types/types.go | 3 ++ 8 files changed, 139 insertions(+), 14 deletions(-) diff --git a/server/hdl_files.go b/server/hdl_files.go index a86b9df..b38b3c2 100644 --- a/server/hdl_files.go +++ b/server/hdl_files.go @@ -27,6 +27,7 @@ import ( "github.com/tinode/chat/pbx" "github.com/tinode/chat/server/auth" "github.com/tinode/chat/server/logs" + "github.com/tinode/chat/server/media" "github.com/tinode/chat/server/store" "github.com/tinode/chat/server/store/types" "google.golang.org/grpc/peer" @@ -310,15 +311,16 @@ func largeFileReceiveHTTP(wrt http.ResponseWriter, req *http.Request) { return } - mimeType := http.DetectContentType(buff) + detectedMimeType := http.DetectContentType(buff) + clientContentType := "" // If DetectContentType fails, see if client-provided content type can be used. - if mimeType == "application/octet-stream" { + if detectedMimeType == "application/octet-stream" { if userContentType, params, err := mime.ParseMediaType(header.Header.Get("Content-Type")); err == nil { // Make sure the content-type is legit. for _, allowed := range allowedMimeTypes { if strings.HasPrefix(userContentType, allowed) { if userContentType = mime.FormatMediaType(userContentType, params); userContentType != "" { - mimeType = userContentType + clientContentType = userContentType } break } @@ -330,9 +332,10 @@ func largeFileReceiveHTTP(wrt http.ResponseWriter, req *http.Request) { ObjHeader: types.ObjHeader{ Id: store.Store.GetUidString(), }, - TenantID: principal.TenantID, - User: principal.UID.String(), - MimeType: mimeType, + TenantID: principal.TenantID, + User: principal.UID.String(), + MimeType: media.MimeTypeForUpload(detectedMimeType, clientContentType, header.Filename), + OriginalName: header.Filename, } fdef.InitTimes() diff --git a/server/hdl_files_test.go b/server/hdl_files_test.go index 62ccc07..a2abea1 100644 --- a/server/hdl_files_test.go +++ b/server/hdl_files_test.go @@ -34,7 +34,7 @@ func (h *testLargeFileMediaHandler) Upload(def *types.FileDef, file io.Reader) ( data, _ := io.ReadAll(file) h.def = def h.data = string(data) - def.Location = "/v0/file/s/" + def.Uid().String() + ".txt" + def.Location = "/v0/file/s/" + media.FileURLName(def) def.ETag = "etag" return def.Location, int64(len(data)), nil } @@ -252,7 +252,7 @@ func TestLargeFileReceiveMeetingNoteUploadCreatesNoteFile(t *testing.T) { params["meeting_id"] != "mtg_test" || params["note_file_id"] != "mnote_note-1" { t.Fatalf("unexpected upload params: %#v", params) } - if handler.data != "hello notes" || notes.createdFileID != fileID.String() || notes.createdMeetingID != "mtg_test" || notes.createdIdempotencyKey != "upload-meeting-note-1" { + if handler.data != "hello notes" || handler.def.OriginalName != "notes.txt" || notes.createdFileID != fileID.String() || notes.createdMeetingID != "mtg_test" || notes.createdIdempotencyKey != "upload-meeting-note-1" { t.Fatalf("unexpected side effects: handler=%#v notes=%#v", handler, notes) } } diff --git a/server/media/fs/filesys.go b/server/media/fs/filesys.go index fa26e11..36104dc 100644 --- a/server/media/fs/filesys.go +++ b/server/media/fs/filesys.go @@ -139,8 +139,7 @@ func (fh *fshandler) Upload(fdef *types.FileDef, file io.Reader) (string, int64, return "", 0, err } - fname := fdef.Id - fname += media.ExtensionByMimeType(fdef.MimeType) + fname := media.FileURLName(fdef) fdef.Location = location // Use file path to create ETag. File paths are unique so will be the ETag. diff --git a/server/media/media.go b/server/media/media.go index 906e01a..7978b2e 100644 --- a/server/media/media.go +++ b/server/media/media.go @@ -69,6 +69,58 @@ type AllowedOrigin struct { var fileNamePattern = regexp.MustCompile(`^[-_A-Za-z0-9]+`) +var uploadExtensionMimeTypes = map[string]string{ + ".aac": "audio/aac", + ".m4a": "audio/x-m4a", + ".doc": "application/msword", + ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ".ppt": "application/vnd.ms-powerpoint", + ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation", + ".xls": "application/vnd.ms-excel", + ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", +} + +// MimeTypeForUpload picks the MIME type to persist for an uploaded file. Some common formats are +// containers and are misdetected by http.DetectContentType, for example m4a as video/mp4 and Office +// documents as zip, so the original file extension wins for those cases. +func MimeTypeForUpload(detected, clientContentType, originalName string) string { + ext := ExtensionByFilename(originalName) + if extMime := uploadExtensionMimeTypes[ext]; extMime != "" { + return extMime + } + if clientContentType != "" { + return clientContentType + } + return strings.TrimSpace(detected) +} + +// FileURLName returns the public URL file name for a stored file. +func FileURLName(fdef *types.FileDef) string { + if fdef == nil { + return "" + } + fname := fdef.Id + if ext := ExtensionByFilename(fdef.OriginalName); ext != "" { + return fname + ext + } + return fname + ExtensionByMimeType(fdef.MimeType) +} + +// ExtensionByFilename returns a safe lower-case extension from a client-provided file name. +func ExtensionByFilename(name string) string { + _, fname := path.Split(strings.TrimSpace(name)) + ext := strings.ToLower(path.Ext(fname)) + if len(ext) < 2 || len(ext) > 16 { + return "" + } + for _, r := range ext[1:] { + if (r < 'a' || r > 'z') && (r < '0' || r > '9') { + return "" + } + } + return ext +} + // ExtensionByMimeType returns a stable URL extension for uploaded media. func ExtensionByMimeType(mimeType string) string { switch strings.ToLower(strings.TrimSpace(strings.Split(mimeType, ";")[0])) { diff --git a/server/media/media_test.go b/server/media/media_test.go index 8dafe02..5e9c21d 100644 --- a/server/media/media_test.go +++ b/server/media/media_test.go @@ -3,6 +3,8 @@ package media import ( "strings" "testing" + + "github.com/tinode/chat/server/store/types" ) func TestMatchCORSOrigin(t *testing.T) { @@ -237,3 +239,71 @@ func TestExtensionByMimeType(t *testing.T) { } } } + +func TestMimeTypeForUploadPrefersContainerFilename(t *testing.T) { + cases := []struct { + name string + detect string + client string + filename string + want string + }{ + { + name: "m4a misdetected as mp4", + detect: "video/mp4", + filename: "voice.m4a", + want: "audio/x-m4a", + }, + { + name: "xlsx misdetected as zip", + detect: "application/zip", + filename: "iOS任务拆解.xlsx", + want: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }, + { + name: "client content type fallback", + detect: "application/octet-stream", + client: "image/jfif", + filename: "thumb", + want: "image/jfif", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := MimeTypeForUpload(tc.detect, tc.client, tc.filename); got != tc.want { + t.Fatalf("MimeTypeForUpload()=%q, want %q", got, tc.want) + } + }) + } +} + +func TestFileURLNamePrefersOriginalExtension(t *testing.T) { + cases := []struct { + name string + def *types.FileDef + want string + }{ + { + name: "voice keeps m4a extension", + def: &types.FileDef{ObjHeader: types.ObjHeader{Id: "file123"}, MimeType: "video/mp4", OriginalName: "voice.m4a"}, + want: "file123.m4a", + }, + { + name: "xlsx keeps xlsx extension", + def: &types.FileDef{ObjHeader: types.ObjHeader{Id: "sheet123"}, MimeType: "application/zip", OriginalName: "iOS任务拆解.xlsx"}, + want: "sheet123.xlsx", + }, + { + name: "falls back to mime extension", + def: &types.FileDef{ObjHeader: types.ObjHeader{Id: "image123"}, MimeType: "image/jpeg"}, + want: "image123.jpg", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := FileURLName(tc.def); got != tc.want { + t.Fatalf("FileURLName()=%q, want %q", got, tc.want) + } + }) + } +} diff --git a/server/media/oss/oss.go b/server/media/oss/oss.go index d5b8629..be243eb 100644 --- a/server/media/oss/oss.go +++ b/server/media/oss/oss.go @@ -245,8 +245,7 @@ func (oh *ossHandler) Upload(fdef *types.FileDef, file io.Reader) (string, int64 return "", 0, err } - fname := fdef.Id - fname += media.ExtensionByMimeType(fdef.MimeType) + fname := media.FileURLName(fdef) fdef.Location = key fdef.ETag = etagFromLocation(key) diff --git a/server/media/s3/s3.go b/server/media/s3/s3.go index f75acf4..41e7539 100644 --- a/server/media/s3/s3.go +++ b/server/media/s3/s3.go @@ -294,8 +294,7 @@ func (ah *awshandler) Upload(fdef *types.FileDef, file io.Reader) (string, int64 return "", 0, err } - fname := fdef.Id - fname += media.ExtensionByMimeType(fdef.MimeType) + fname := media.FileURLName(fdef) fdef.Location = key if result.ETag != nil { diff --git a/server/store/types/types.go b/server/store/types/types.go index 1e01018..5b82afa 100644 --- a/server/store/types/types.go +++ b/server/store/types/types.go @@ -1479,6 +1479,9 @@ type FileDef struct { User string // Type of the file. MimeType string + // OriginalName is the client-provided file name. It is not persisted; media handlers use it + // only to keep the public URL extension consistent with the uploaded file. + OriginalName string `db:"-" bson:"-" json:"-"` // Size of the file in bytes. Size int64 // Internal file location, i.e. path on disk or an S3 blob address.