合并 internal → main: 退款/提现/激活/CI 全面优化 #4
@@ -128,6 +128,10 @@ curl -X PUT 'https://bucket.s3.ap-east-1.amazonaws.com/...' \
|
|||||||
说明:
|
说明:
|
||||||
|
|
||||||
- `Content-Type` 需和 `init` 返回的 `headers.Content-Type` 一致
|
- `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 秒
|
- `upload_url` 有过期时间,通常 300 秒
|
||||||
- 成功时 S3 常见返回 `200` 或 `204`
|
- 成功时 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`,就按现有逻辑验签
|
||||||
- 如果没有 `X-App-Id`,仍按旧逻辑放行
|
- 如果没有 `X-App-Id`,仍按旧逻辑放行
|
||||||
- 如果要给该接口加签,签名时必须对原始 multipart body 计算 `BODY_SHA256`
|
- 如果要给该接口加签,签名时必须对原始 multipart body 计算 `BODY_SHA256`
|
||||||
|
- 允许的 `Content-Type` 与预签名三段式一致;multipart 文件字段未显式携带 `Content-Type` 时,服务端会基于文件内容嗅探常见类型。
|
||||||
|
|
||||||
## 常见错误码
|
## 常见错误码
|
||||||
|
|
||||||
- `200`: 成功
|
- `200`: 成功
|
||||||
- `400`: 参数错误
|
- `400`: 参数错误
|
||||||
|
- `400 content_type is not allowed`: 文件 `Content-Type` 不在 `S3.AllowedContentTypes` 白名单内
|
||||||
- `40008`: 缺少签名头
|
- `40008`: 缺少签名头
|
||||||
- `40009`: 签名已过期
|
- `40009`: 签名已过期
|
||||||
- `40010`: 签名无效
|
- `40010`: 签名无效
|
||||||
|
|||||||
+1
-1
@@ -74,7 +74,7 @@ S3:
|
|||||||
UsePathStyle: false
|
UsePathStyle: false
|
||||||
PresignExpireSeconds: 300
|
PresignExpireSeconds: 300
|
||||||
MaxUploadSize: 104857600
|
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:
|
device:
|
||||||
enable: true # 开启设备加密通信
|
enable: true # 开启设备加密通信
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ type S3Config struct {
|
|||||||
UsePathStyle bool `yaml:"UsePathStyle" default:"false"`
|
UsePathStyle bool `yaml:"UsePathStyle" default:"false"`
|
||||||
PresignExpireSeconds int64 `yaml:"PresignExpireSeconds" default:"300"`
|
PresignExpireSeconds int64 `yaml:"PresignExpireSeconds" default:"300"`
|
||||||
MaxUploadSize int64 `yaml:"MaxUploadSize" default:"104857600"`
|
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 {
|
type RedisConfig struct {
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ func validateInitRequest(svcCtx *svc.ServiceContext, bizType, fileName, contentT
|
|||||||
allowed := allowedContentTypeSet(svcCtx.Config.S3.AllowedContentTypes)
|
allowed := allowedContentTypeSet(svcCtx.Config.S3.AllowedContentTypes)
|
||||||
if len(allowed) > 0 {
|
if len(allowed) > 0 {
|
||||||
if _, ok := allowed[strings.ToLower(strings.TrimSpace(contentType))]; !ok {
|
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
|
return nil
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user