Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 4 additions & 8 deletions util/fsutil/file.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"encoding/json"
"io"
"mime/multipart"
"net/http"
"os"
"sync"

Expand Down Expand Up @@ -143,19 +142,16 @@ func (file *File) Save(fs WritableFS, path string, name string) (filename string
func ParseMultipartFiles(headers []*multipart.FileHeader) ([]File, error) {
files := []File{}
for _, fh := range headers {
fileHeader := make([]byte, 512)
mimeType := "application/octet-stream"

if fh.Size != 0 {
f, err := fh.Open()
if err != nil {
return nil, errors.New(err)
}
if _, err := f.Read(fileHeader); err != nil {
_ = f.Close()
return nil, errors.New(err)
}

if _, err := f.Seek(0, 0); err != nil {
mimeType, err = DetectContentType(f, "")
if err != nil {
_ = f.Close()
return nil, errors.New(err)
}
Expand All @@ -166,7 +162,7 @@ func ParseMultipartFiles(headers []*multipart.FileHeader) ([]File, error) {

file := File{
Header: fh,
MIMEType: http.DetectContentType(fileHeader),
MIMEType: mimeType,
}
files = append(files, file)
}
Expand Down
41 changes: 40 additions & 1 deletion util/fsutil/fsutil.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package fsutil

import (
"bytes"
"io"
"io/fs"
"net/http"
Expand Down Expand Up @@ -117,7 +118,9 @@ func GetMIMEType(filesystem fs.FS, file string) (contentType string, size int64,
// DetectContentType by sniffing the first 512 bytes of the given reader using `http.DetectContentType`.
//
// If the detected content type is `"application/octet-stream"` or `"text/plain"`, this function will attempt to
// find a more precise one using `fsutil.DetectContentTypeByExtension`.
// find a more precise one using `fsutil.DetectContentTypeByExtension`, unless `fileName` is empty.
// If the detected content type is `"text/xml"` or `"application/xml"`, this function promotes it to
// `"image/svg+xml"` only if the content signature indicates SVG.
// The header parameter is retained (e.g: `charset=utf-8`).
//
// If there is no error, this function always returns a valid MIME type. If it cannot determine a more specific one,
Expand All @@ -140,11 +143,47 @@ func DetectContentType(r io.Reader, fileName string) (string, error) {
contentType := http.DetectContentType(buffer)
if strings.HasPrefix(contentType, "application/octet-stream") || strings.HasPrefix(contentType, "text/plain") {
contentType = detectContentTypeByExtension(fileName, contentType)
} else if (strings.HasPrefix(contentType, "text/xml") || strings.HasPrefix(contentType, "application/xml")) && hasSVGSignature(buffer) {
contentType = "image/svg+xml"
}
return contentType, nil
}

func hasSVGSignature(buffer []byte) bool {
start := bytes.IndexRune(buffer, '<')
if start == -1 {
return false
}

content := strings.TrimSpace(strings.ToLower(string(buffer[start:])))
// Skip optional XML tag and possible comments
for {
Comment thread
fnoopv marked this conversation as resolved.
if strings.HasPrefix(content, "<?xml") {
end := strings.Index(content, "?>")
if end == -1 {
break
}
content = strings.TrimSpace(content[end+2:])
continue
}
if strings.HasPrefix(content, "<!--") {
end := strings.Index(content, "-->")
if end == -1 {
break
}
content = strings.TrimSpace(content[end+3:])
continue
}
break
}

return strings.HasPrefix(content, "<svg")
}

func detectContentTypeByExtension(fileName, contentType string) string {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add an early return here if fileName is empty to avoid extra processing.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok

if fileName == "" {
return contentType
}
for ext, t := range contentTypeByExtension {
if strings.HasSuffix(fileName, ext) {
tmp := t
Expand Down
120 changes: 120 additions & 0 deletions util/fsutil/fsutil_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,16 @@ func TestDetectContentType(t *testing.T) {
buf: []byte{1, 2, 3},
wantMIME: "text/javascript",
},
{
fileName: "image.svg",
buf: []byte("<?xml version=\"1.0\" encoding=\"UTF-8\"?><svg xmlns=\"http://www.w3.org/2000/svg\"></svg>"),
wantMIME: "image/svg+xml",
},
{
fileName: "script.js",
buf: []byte("<?xml version=\"1.0\" encoding=\"UTF-8\"?><root></root>"),
wantMIME: "text/xml; charset=utf-8",
},
{
fileName: "eof",
buf: []byte{},
Expand Down Expand Up @@ -276,6 +286,12 @@ func TestDetectContentTypeByExtension(t *testing.T) {
contentType: "unknown",
fileName: "test.xyz",
},
{
desc: "empty_filename",
want: "unknown",
contentType: "unknown",
fileName: "",
},
{
desc: "unknown_with_params",
want: "unknown; charset=utf-8",
Expand All @@ -300,6 +316,12 @@ func TestDetectContentTypeByExtension(t *testing.T) {
contentType: "text/plain; charset=utf-8",
fileName: "test.css",
},
{
desc: "utf8_svg",
want: "image/svg+xml; charset=utf-8",
contentType: "text/xml; charset=utf-8",
fileName: "image.svg",
},
}

for _, c := range cases {
Expand All @@ -309,6 +331,41 @@ func TestDetectContentTypeByExtension(t *testing.T) {
}
}

func TestHasSVGSignature(t *testing.T) {
cases := []struct {
desc string
buf []byte
want bool
}{
{
desc: "direct_svg",
buf: []byte("<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>"),
want: true,
},
{
desc: "svg_with_prolog",
buf: []byte("<?xml version=\"1.0\"?><svg></svg>"),
want: true,
},
{
desc: "svg_with_comment",
buf: []byte("<?xml version=\"1.0\"?><!--comment--><svg></svg>"),
want: true,
},
{
desc: "xml_not_svg",
buf: []byte("<?xml version=\"1.0\"?><root></root>"),
want: false,
},
}

for _, c := range cases {
t.Run(c.desc, func(t *testing.T) {
assert.Equal(t, c.want, hasSVGSignature(c.buf))
})
}
}

func TestFileExists(t *testing.T) {
assert.True(t, FileExists(&osfs.FS{}, toAbsolutePath("resources/img/logo/goyave_16.png")))
assert.False(t, FileExists(&osfs.FS{}, toAbsolutePath("doesn't exist")))
Expand Down Expand Up @@ -425,6 +482,69 @@ func TestParseMultipartFiles(t *testing.T) {
assert.NoError(t, err)
})

t.Run("svg", func(t *testing.T) {
svgPath := toAbsolutePath("util/fsutil/test.svg")
err := os.WriteFile(svgPath, []byte("<?xml version=\"1.0\" encoding=\"UTF-8\"?><svg xmlns=\"http://www.w3.org/2000/svg\"></svg>"), 0644)
require.NoError(t, err)
t.Cleanup(func() {
deleteFile(svgPath)
})

form := createTestForm("util/fsutil/test.svg")
files, err := ParseMultipartFiles(form.File["file"])

expected := []File{
{
Header: form.File["file"][0],
MIMEType: "image/svg+xml",
},
}
assert.Equal(t, expected, files)
assert.NoError(t, err)
})

t.Run("xml_content_with_js_extension", func(t *testing.T) {
Comment thread
fnoopv marked this conversation as resolved.
filePath := toAbsolutePath("util/fsutil/test.js")
err := os.WriteFile(filePath, []byte("<?xml version=\"1.0\" encoding=\"UTF-8\"?><root></root>"), 0644)
require.NoError(t, err)
t.Cleanup(func() {
deleteFile(filePath)
})

form := createTestForm("util/fsutil/test.js")
files, err := ParseMultipartFiles(form.File["file"])

require.NoError(t, err)
require.Len(t, files, 1)
assert.Equal(t, "text/xml; charset=utf-8", files[0].MIMEType)
})

t.Run("declared_content_type_separate_from_detected_mime", func(t *testing.T) {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)

headers := textproto.MIMEHeader{}
headers.Set("Content-Disposition", `form-data; name="file"; filename="custom.js"`)
headers.Set("Content-Type", "application/javascript")

part, err := writer.CreatePart(headers)
require.NoError(t, err)
_, err = part.Write([]byte("<?xml version=\"1.0\" encoding=\"UTF-8\"?><root></root>"))
require.NoError(t, err)
require.NoError(t, writer.Close())

reader := multipart.NewReader(body, writer.Boundary())
form, err := reader.ReadForm(math.MaxInt64 - 1)
require.NoError(t, err)

files, err := ParseMultipartFiles(form.File["file"])
require.NoError(t, err)
require.Len(t, files, 1)

assert.Equal(t, "application/javascript", files[0].Header.Header.Get("Content-Type"))
assert.Equal(t, "text/xml; charset=utf-8", files[0].MIMEType)
})

t.Run("empty_file", func(t *testing.T) {
headers := []*multipart.FileHeader{
{
Expand Down
Loading