diff --git a/internal/base/middleware/avatar.go b/internal/base/middleware/avatar.go index 42b28da99..e8658b3db 100644 --- a/internal/base/middleware/avatar.go +++ b/internal/base/middleware/avatar.go @@ -21,6 +21,7 @@ package middleware import ( "fmt" + "mime" "net/http" "net/url" "os" @@ -73,7 +74,7 @@ func (am *AvatarMiddleware) AvatarThumb() gin.HandlerFunc { ctx.Abort() return } - ctx.Header("content-type", fmt.Sprintf("image/%s", strings.TrimLeft(path.Ext(filePath), "."))) + ctx.Header("Content-Type", contentTypeByExt(path.Ext(filePath))) _, err = ctx.Writer.Write(avatarFile) if err != nil { log.Error(err) @@ -86,9 +87,23 @@ func (am *AvatarMiddleware) AvatarThumb() gin.HandlerFunc { ctx.Next() return } - ext := strings.TrimPrefix(filepath.Ext(urlInfo.Path), ".") - ctx.Header("content-type", fmt.Sprintf("image/%s", ext)) + ctx.Header("Content-Type", contentTypeByExt(filepath.Ext(urlInfo.Path))) } ctx.Next() } } + +// contentTypeByExt returns the MIME type for a file extension (with leading dot). +// It prefers the registered MIME table (e.g. ".svg" -> "image/svg+xml", which browsers +// require to render SVG in ) and falls back to the legacy "image/" guess. +// Paths without an extension get application/octet-stream rather than an invalid "image/". +func contentTypeByExt(ext string) string { + ext = strings.ToLower(ext) + if ct := mime.TypeByExtension(ext); ct != "" { + return ct + } + if name := strings.TrimPrefix(ext, "."); name != "" { + return fmt.Sprintf("image/%s", name) + } + return "application/octet-stream" +} diff --git a/internal/base/middleware/avatar_test.go b/internal/base/middleware/avatar_test.go new file mode 100644 index 000000000..12b32dafe --- /dev/null +++ b/internal/base/middleware/avatar_test.go @@ -0,0 +1,71 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package middleware + +import ( + "mime" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" +) + +func TestContentTypeByExt(t *testing.T) { + // compare the media type only: mime.TypeByExtension may add parameters (e.g. charset) + // depending on the platform's MIME database + assert.Equal(t, "image/svg+xml", mediaType(t, contentTypeByExt(".svg"))) + assert.Equal(t, "image/png", mediaType(t, contentTypeByExt(".png"))) + assert.Equal(t, "image/jpeg", mediaType(t, contentTypeByExt(".JPG"))) + assert.Equal(t, "image/webp", mediaType(t, contentTypeByExt(".webp"))) + assert.Equal(t, "application/octet-stream", contentTypeByExt("")) + assert.Equal(t, "application/octet-stream", contentTypeByExt(".")) + // unknown extension keeps the legacy behaviour + assert.Equal(t, "image/unknownext", contentTypeByExt(".unknownext")) +} + +func TestAvatarThumbSetsContentTypeForUploads(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use((&AvatarMiddleware{}).AvatarThumb()) + r.GET("/uploads/branding/:file", func(ctx *gin.Context) { ctx.Status(http.StatusOK) }) + + for path, want := range map[string]string{ + "/uploads/branding/logo.svg": "image/svg+xml", + "/uploads/branding/logo.png": "image/png", + } { + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, path, nil) + req.RequestURI = path // set by the real server; the middleware reads it + r.ServeHTTP(w, req) + assert.Equal(t, want, mediaType(t, w.Header().Get("Content-Type")), path) + } +} + +// mediaType strips any parameters from a Content-Type value +func mediaType(t *testing.T, contentType string) string { + t.Helper() + mt, _, err := mime.ParseMediaType(contentType) + if err != nil { + t.Fatalf("invalid content type %q: %v", contentType, err) + } + return mt +}