From 97c7e4ffa253a6f31781eb1dcdea1259fc716067 Mon Sep 17 00:00:00 2001 From: zhx Date: Wed, 22 Jul 2026 19:26:25 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=8A=E4=BC=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../transport/http/appapi/upload_handler.go | 32 +++++++++++-------- .../internal/transport/http/response_test.go | 13 ++++---- 2 files changed, 26 insertions(+), 19 deletions(-) diff --git a/services/gateway-service/internal/transport/http/appapi/upload_handler.go b/services/gateway-service/internal/transport/http/appapi/upload_handler.go index 6b955b69..6acb9db8 100644 --- a/services/gateway-service/internal/transport/http/appapi/upload_handler.go +++ b/services/gateway-service/internal/transport/http/appapi/upload_handler.go @@ -102,10 +102,15 @@ func (h *Handler) uploadObject(writer http.ResponseWriter, request *http.Request if !ok { return } + if isRasterUploadContentType(detectedContentType) { + // 通用文件入口也可能承接房间背景和资料图片。只要文件头能确定为受支持的 + // 栅格图片,就使用真实字节类型覆盖客户端 MIME,后续对象后缀也据此规范化。 + contentType = detectedContentType + } if policy.RequireImage { // 头像用文件头嗅探结果做安全判断,不信任客户端传入的 multipart Content-Type。 contentType = detectedContentType - if !avatarExtensionMatchesContentType(header.Filename, detectedContentType) || animatedWebPHeader(head) { + if animatedWebPHeader(head) { // 注册和旧头像入口只允许静态图片;动态 WebP 的 MIME 与静态相同,必须读取 VP8X 动画位拦截。 httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") return @@ -190,15 +195,10 @@ func isAvatarContentType(contentType string) bool { } } -func avatarExtensionMatchesContentType(filename string, contentType string) bool { - extension := strings.ToLower(path.Ext(filename)) +func isRasterUploadContentType(contentType string) bool { switch strings.ToLower(strings.TrimSpace(contentType)) { - case "image/jpeg": - return extension == ".jpg" || extension == ".jpeg" - case "image/png": - return extension == ".png" - case "image/webp": - return extension == ".webp" + case "image/jpeg", "image/png", "image/webp", "image/gif": + return true default: return false } @@ -227,10 +227,8 @@ func buildUploadObjectKey(policy uploadPolicy, ownerSegment string, filename str } func normalizedUploadExtension(filename string, contentType string) string { - if extension := cleanUploadExtension(path.Ext(filename)); extension != "" { - return extension - } - + // 受支持图片的规范后缀只能来自嗅探后的真实 Content-Type,不能让相册缓存文件名 + // 把 WebP/GIF 对象错误保存成 .jpg;非图片文件仍保留原始安全后缀。 switch strings.ToLower(strings.TrimSpace(contentType)) { case "image/jpeg": return ".jpg" @@ -238,6 +236,14 @@ func normalizedUploadExtension(filename string, contentType string) string { return ".png" case "image/webp": return ".webp" + case "image/gif": + return ".gif" + } + if extension := cleanUploadExtension(path.Ext(filename)); extension != "" { + return extension + } + + switch strings.ToLower(strings.TrimSpace(contentType)) { case "application/pdf": return ".pdf" default: diff --git a/services/gateway-service/internal/transport/http/response_test.go b/services/gateway-service/internal/transport/http/response_test.go index 6117ab1f..593d650f 100644 --- a/services/gateway-service/internal/transport/http/response_test.go +++ b/services/gateway-service/internal/transport/http/response_test.go @@ -11597,13 +11597,14 @@ func TestVoiceRoomRoutesRejectWrongHTTPMethod(t *testing.T) { } } -func TestUploadAvatarAllowsIncompleteProfileAndWritesObject(t *testing.T) { +func TestUploadAvatarUsesDetectedTypeAndAllowsIncompleteProfile(t *testing.T) { uploader := &fakeObjectUploader{} handler := NewHandler(&fakeRoomClient{}) handler.SetObjectUploader(uploader) router := handler.Routes(auth.NewVerifier("secret")) - payload := append([]byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}, bytes.Repeat([]byte{1}, 32)...) - body, contentType := multipartUploadBody(t, "file", "avatar.png", "image/png", payload) + payload := append([]byte{'R', 'I', 'F', 'F', 0x24, 0, 0, 0, 'W', 'E', 'B', 'P', 'V', 'P', '8', ' '}, bytes.Repeat([]byte{1}, 32)...) + // Android 相册缓存可能把 WebP/GIF 标成 JPEG;上传对象必须以文件头识别结果命名。 + body, contentType := multipartUploadBody(t, "file", "avatar.jpg", "image/jpeg", payload) request := httptest.NewRequest(http.MethodPost, "/api/v1/files/avatar/upload", body) request.Header.Set("Content-Type", contentType) request.Header.Set("Authorization", "Bearer "+signGatewayTokenWithProfile(t, "secret", 42, false)) @@ -11623,13 +11624,13 @@ func TestUploadAvatarAllowsIncompleteProfileAndWritesObject(t *testing.T) { if response.Code != httpkit.CodeOK || !ok { t.Fatalf("unexpected upload envelope: %+v", response) } - if !strings.HasPrefix(uploader.lastKey, "app/avatars/42/") || !strings.HasSuffix(uploader.lastKey, ".png") { + if !strings.HasPrefix(uploader.lastKey, "app/avatars/42/") || !strings.HasSuffix(uploader.lastKey, ".webp") { t.Fatalf("unexpected avatar object key: %q", uploader.lastKey) } - if string(uploader.lastContent) != string(payload) || uploader.lastSizeBytes != int64(len(payload)) || uploader.lastContentType != "image/png" { + if string(uploader.lastContent) != string(payload) || uploader.lastSizeBytes != int64(len(payload)) || uploader.lastContentType != "image/webp" { t.Fatalf("uploaded object mismatch: key=%s size=%d type=%s body=%x", uploader.lastKey, uploader.lastSizeBytes, uploader.lastContentType, uploader.lastContent) } - if data["url"] != "https://media.example.com/"+uploader.lastKey || data["object_key"] != uploader.lastKey || data["content_type"] != "image/png" { + if data["url"] != "https://media.example.com/"+uploader.lastKey || data["object_key"] != uploader.lastKey || data["content_type"] != "image/webp" { t.Fatalf("unexpected upload data: %+v", data) } }