Preserve uploaded file extensions
This commit is contained in:
+9
-6
@@ -27,6 +27,7 @@ import (
|
|||||||
"github.com/tinode/chat/pbx"
|
"github.com/tinode/chat/pbx"
|
||||||
"github.com/tinode/chat/server/auth"
|
"github.com/tinode/chat/server/auth"
|
||||||
"github.com/tinode/chat/server/logs"
|
"github.com/tinode/chat/server/logs"
|
||||||
|
"github.com/tinode/chat/server/media"
|
||||||
"github.com/tinode/chat/server/store"
|
"github.com/tinode/chat/server/store"
|
||||||
"github.com/tinode/chat/server/store/types"
|
"github.com/tinode/chat/server/store/types"
|
||||||
"google.golang.org/grpc/peer"
|
"google.golang.org/grpc/peer"
|
||||||
@@ -310,15 +311,16 @@ func largeFileReceiveHTTP(wrt http.ResponseWriter, req *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
mimeType := http.DetectContentType(buff)
|
detectedMimeType := http.DetectContentType(buff)
|
||||||
|
clientContentType := ""
|
||||||
// If DetectContentType fails, see if client-provided content type can be used.
|
// 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 {
|
if userContentType, params, err := mime.ParseMediaType(header.Header.Get("Content-Type")); err == nil {
|
||||||
// Make sure the content-type is legit.
|
// Make sure the content-type is legit.
|
||||||
for _, allowed := range allowedMimeTypes {
|
for _, allowed := range allowedMimeTypes {
|
||||||
if strings.HasPrefix(userContentType, allowed) {
|
if strings.HasPrefix(userContentType, allowed) {
|
||||||
if userContentType = mime.FormatMediaType(userContentType, params); userContentType != "" {
|
if userContentType = mime.FormatMediaType(userContentType, params); userContentType != "" {
|
||||||
mimeType = userContentType
|
clientContentType = userContentType
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -330,9 +332,10 @@ func largeFileReceiveHTTP(wrt http.ResponseWriter, req *http.Request) {
|
|||||||
ObjHeader: types.ObjHeader{
|
ObjHeader: types.ObjHeader{
|
||||||
Id: store.Store.GetUidString(),
|
Id: store.Store.GetUidString(),
|
||||||
},
|
},
|
||||||
TenantID: principal.TenantID,
|
TenantID: principal.TenantID,
|
||||||
User: principal.UID.String(),
|
User: principal.UID.String(),
|
||||||
MimeType: mimeType,
|
MimeType: media.MimeTypeForUpload(detectedMimeType, clientContentType, header.Filename),
|
||||||
|
OriginalName: header.Filename,
|
||||||
}
|
}
|
||||||
fdef.InitTimes()
|
fdef.InitTimes()
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ func (h *testLargeFileMediaHandler) Upload(def *types.FileDef, file io.Reader) (
|
|||||||
data, _ := io.ReadAll(file)
|
data, _ := io.ReadAll(file)
|
||||||
h.def = def
|
h.def = def
|
||||||
h.data = string(data)
|
h.data = string(data)
|
||||||
def.Location = "/v0/file/s/" + def.Uid().String() + ".txt"
|
def.Location = "/v0/file/s/" + media.FileURLName(def)
|
||||||
def.ETag = "etag"
|
def.ETag = "etag"
|
||||||
return def.Location, int64(len(data)), nil
|
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" {
|
params["meeting_id"] != "mtg_test" || params["note_file_id"] != "mnote_note-1" {
|
||||||
t.Fatalf("unexpected upload params: %#v", params)
|
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)
|
t.Fatalf("unexpected side effects: handler=%#v notes=%#v", handler, notes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,8 +139,7 @@ func (fh *fshandler) Upload(fdef *types.FileDef, file io.Reader) (string, int64,
|
|||||||
return "", 0, err
|
return "", 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
fname := fdef.Id
|
fname := media.FileURLName(fdef)
|
||||||
fname += media.ExtensionByMimeType(fdef.MimeType)
|
|
||||||
|
|
||||||
fdef.Location = location
|
fdef.Location = location
|
||||||
// Use file path to create ETag. File paths are unique so will be the ETag.
|
// Use file path to create ETag. File paths are unique so will be the ETag.
|
||||||
|
|||||||
@@ -69,6 +69,58 @@ type AllowedOrigin struct {
|
|||||||
|
|
||||||
var fileNamePattern = regexp.MustCompile(`^[-_A-Za-z0-9]+`)
|
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.
|
// ExtensionByMimeType returns a stable URL extension for uploaded media.
|
||||||
func ExtensionByMimeType(mimeType string) string {
|
func ExtensionByMimeType(mimeType string) string {
|
||||||
switch strings.ToLower(strings.TrimSpace(strings.Split(mimeType, ";")[0])) {
|
switch strings.ToLower(strings.TrimSpace(strings.Split(mimeType, ";")[0])) {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package media
|
|||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/tinode/chat/server/store/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestMatchCORSOrigin(t *testing.T) {
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -245,8 +245,7 @@ func (oh *ossHandler) Upload(fdef *types.FileDef, file io.Reader) (string, int64
|
|||||||
return "", 0, err
|
return "", 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
fname := fdef.Id
|
fname := media.FileURLName(fdef)
|
||||||
fname += media.ExtensionByMimeType(fdef.MimeType)
|
|
||||||
|
|
||||||
fdef.Location = key
|
fdef.Location = key
|
||||||
fdef.ETag = etagFromLocation(key)
|
fdef.ETag = etagFromLocation(key)
|
||||||
|
|||||||
@@ -294,8 +294,7 @@ func (ah *awshandler) Upload(fdef *types.FileDef, file io.Reader) (string, int64
|
|||||||
return "", 0, err
|
return "", 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
fname := fdef.Id
|
fname := media.FileURLName(fdef)
|
||||||
fname += media.ExtensionByMimeType(fdef.MimeType)
|
|
||||||
|
|
||||||
fdef.Location = key
|
fdef.Location = key
|
||||||
if result.ETag != nil {
|
if result.ETag != nil {
|
||||||
|
|||||||
@@ -1479,6 +1479,9 @@ type FileDef struct {
|
|||||||
User string
|
User string
|
||||||
// Type of the file.
|
// Type of the file.
|
||||||
MimeType string
|
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 of the file in bytes.
|
||||||
Size int64
|
Size int64
|
||||||
// Internal file location, i.e. path on disk or an S3 blob address.
|
// Internal file location, i.e. path on disk or an S3 blob address.
|
||||||
|
|||||||
Reference in New Issue
Block a user