From f452f80100a7793186524e518279729671eddf7b Mon Sep 17 00:00:00 2001 From: shanshanzhong Date: Wed, 27 May 2026 19:50:51 -0700 Subject: [PATCH] =?UTF-8?q?=E9=85=8D=E7=BD=AE(#98):=20=E6=89=A9=E5=B1=95?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E4=B8=8A=E4=BC=A0=20Content-Type=20=E7=99=BD?= =?UTF-8?q?=E5=90=8D=E5=8D=95=E6=94=AF=E6=8C=81=E5=9B=BE=E7=89=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 etc/ppanel.yaml 与 internal/config/config.go 的 S3.AllowedContentTypes 新增 image/jpeg,image/jpg,image/png,image/webp,image/gif,image/heic,image/heif,image/bmp - 保留原 zip/gzip/text/json/octet-stream - validateInitRequest 在拒绝时携带 content_type is not allowed 业务消息 - 新增 internal/logic/public/file/common_test.go,覆盖允许/拒绝及无 Content-Type 嗅探 - doc/tapi-file-upload-zh.md 同步允许类型列表与错误码说明 Co-authored-by: multica-agent --- doc/tapi-file-upload-zh.md | 6 ++ etc/ppanel.yaml | 2 +- internal/config/config.go | 2 +- internal/logic/public/file/common.go | 2 +- internal/logic/public/file/common_test.go | 91 +++++++++++++++++++++++ 5 files changed, 100 insertions(+), 3 deletions(-) create mode 100644 internal/logic/public/file/common_test.go diff --git a/doc/tapi-file-upload-zh.md b/doc/tapi-file-upload-zh.md index c257041..8785fe1 100644 --- a/doc/tapi-file-upload-zh.md +++ b/doc/tapi-file-upload-zh.md @@ -128,6 +128,10 @@ curl -X PUT 'https://bucket.s3.ap-east-1.amazonaws.com/...' \ 说明: - `Content-Type` 需和 `init` 返回的 `headers.Content-Type` 一致 +- 允许的 `Content-Type` 由服务端 `S3.AllowedContentTypes` 配置控制,默认包含: + - 压缩包:`application/zip`、`application/x-zip-compressed`、`application/gzip`、`application/x-gzip` + - 通用文件:`application/octet-stream`、`text/plain`、`application/json` + - 图片:`image/jpeg`、`image/jpg`、`image/png`、`image/webp`、`image/gif`、`image/heic`、`image/heif`、`image/bmp` - `upload_url` 有过期时间,通常 300 秒 - 成功时 S3 常见返回 `200` 或 `204` @@ -164,11 +168,13 @@ curl -X POST 'https://tapi.hifast.biz/v1/public/file/upload/complete' \ - 如果请求带了 `X-App-Id`,就按现有逻辑验签 - 如果没有 `X-App-Id`,仍按旧逻辑放行 - 如果要给该接口加签,签名时必须对原始 multipart body 计算 `BODY_SHA256` +- 允许的 `Content-Type` 与预签名三段式一致;multipart 文件字段未显式携带 `Content-Type` 时,服务端会基于文件内容嗅探常见类型。 ## 常见错误码 - `200`: 成功 - `400`: 参数错误 +- `400 content_type is not allowed`: 文件 `Content-Type` 不在 `S3.AllowedContentTypes` 白名单内 - `40008`: 缺少签名头 - `40009`: 签名已过期 - `40010`: 签名无效 diff --git a/etc/ppanel.yaml b/etc/ppanel.yaml index a2423cf..7c947a3 100644 --- a/etc/ppanel.yaml +++ b/etc/ppanel.yaml @@ -74,7 +74,7 @@ S3: UsePathStyle: false PresignExpireSeconds: 300 MaxUploadSize: 104857600 - AllowedContentTypes: "application/zip,application/x-zip-compressed,application/gzip,application/x-gzip,application/octet-stream,text/plain,application/json" + AllowedContentTypes: "application/zip,application/x-zip-compressed,application/gzip,application/x-gzip,application/octet-stream,text/plain,application/json,image/jpeg,image/jpg,image/png,image/webp,image/gif,image/heic,image/heif,image/bmp" device: enable: true # 开启设备加密通信 diff --git a/internal/config/config.go b/internal/config/config.go index 4e6ebe5..e479c09 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -58,7 +58,7 @@ type S3Config struct { UsePathStyle bool `yaml:"UsePathStyle" default:"false"` PresignExpireSeconds int64 `yaml:"PresignExpireSeconds" default:"300"` MaxUploadSize int64 `yaml:"MaxUploadSize" default:"104857600"` - AllowedContentTypes string `yaml:"AllowedContentTypes" default:"application/zip,application/x-zip-compressed,application/gzip,application/x-gzip,application/octet-stream,text/plain,application/json"` + AllowedContentTypes string `yaml:"AllowedContentTypes" default:"application/zip,application/x-zip-compressed,application/gzip,application/x-gzip,application/octet-stream,text/plain,application/json,image/jpeg,image/jpg,image/png,image/webp,image/gif,image/heic,image/heif,image/bmp"` } type RedisConfig struct { diff --git a/internal/logic/public/file/common.go b/internal/logic/public/file/common.go index cd1e909..80f78b7 100644 --- a/internal/logic/public/file/common.go +++ b/internal/logic/public/file/common.go @@ -80,7 +80,7 @@ func validateInitRequest(svcCtx *svc.ServiceContext, bizType, fileName, contentT allowed := allowedContentTypeSet(svcCtx.Config.S3.AllowedContentTypes) if len(allowed) > 0 { if _, ok := allowed[strings.ToLower(strings.TrimSpace(contentType))]; !ok { - return errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "content_type is not allowed") + return errors.Wrapf(xerr.NewErrCodeMsg(xerr.InvalidParams, "content_type is not allowed"), "content_type is not allowed") } } return nil diff --git a/internal/logic/public/file/common_test.go b/internal/logic/public/file/common_test.go new file mode 100644 index 0000000..528d1d1 --- /dev/null +++ b/internal/logic/public/file/common_test.go @@ -0,0 +1,91 @@ +package file + +import ( + "mime/multipart" + "strings" + "testing" + + "github.com/perfect-panel/server/internal/config" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/pkg/storage" +) + +const testAllowedContentTypes = "application/zip,application/x-zip-compressed,application/gzip,application/x-gzip,application/octet-stream,text/plain,application/json,image/jpeg,image/jpg,image/png,image/webp,image/gif,image/heic,image/heif,image/bmp" + +type testMultipartFile struct { + *strings.Reader +} + +func (testMultipartFile) Close() error { + return nil +} + +func TestValidateInitRequestAllowedContentTypes(t *testing.T) { + svcCtx := &svc.ServiceContext{ + Config: config.Config{ + S3: config.S3Config{ + Enable: true, + MaxUploadSize: 1024, + AllowedContentTypes: testAllowedContentTypes, + }, + }, + S3Store: &storage.S3Store{}, + } + + tests := []struct { + name string + contentType string + wantErr bool + }{ + {name: "allow jpeg", contentType: "image/jpeg"}, + {name: "allow png", contentType: "image/png"}, + {name: "allow webp", contentType: "image/webp"}, + {name: "reject unknown", contentType: "application/x-sh", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateInitRequest(svcCtx, "app-package", "demo.bin", tt.contentType, 10) + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "content_type is not allowed") { + t.Fatalf("expected content type error, got %v", err) + } + return + } + if err != nil { + t.Fatalf("expected nil error, got %v", err) + } + }) + } +} + +func TestSniffContentTypeDetectsCommonImages(t *testing.T) { + tests := []struct { + name string + data string + want string + }{ + {name: "jpeg", data: "\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01", want: "image/jpeg"}, + {name: "png", data: "\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR", want: "image/png"}, + {name: "webp", data: "RIFF\x1a\x00\x00\x00WEBPVP8 \x0e\x00\x00\x00", want: "image/webp"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + file := testMultipartFile{Reader: strings.NewReader(tt.data)} + got, err := sniffContentType(&multipart.FileHeader{}, file) + if err != nil { + t.Fatalf("expected nil error, got %v", err) + } + if got != tt.want { + t.Fatalf("expected %q, got %q", tt.want, got) + } + if pos, err := file.Seek(0, 1); err != nil || pos != 0 { + t.Fatalf("expected reader reset to start, pos=%d err=%v", pos, err) + } + }) + } +}