f452f80100
- 在 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 <github@multica.ai>
149 lines
4.4 KiB
Go
149 lines
4.4 KiB
Go
package file
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha1"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/perfect-panel/server/internal/model/user"
|
|
"github.com/perfect-panel/server/internal/svc"
|
|
"github.com/perfect-panel/server/pkg/constant"
|
|
"github.com/perfect-panel/server/pkg/xerr"
|
|
"github.com/pkg/errors"
|
|
)
|
|
|
|
const (
|
|
fileUploadInitStatus = "init"
|
|
fileUploadCompleteStatus = "completed"
|
|
fileUploadRedisPrefix = "file:upload:"
|
|
)
|
|
|
|
var safeFileNameRegexp = regexp.MustCompile(`[^a-zA-Z0-9._-]+`)
|
|
|
|
type uploadMeta struct {
|
|
FileID string `json:"file_id"`
|
|
UserID int64 `json:"user_id"`
|
|
BizType string `json:"biz_type"`
|
|
FileName string `json:"file_name"`
|
|
ContentType string `json:"content_type"`
|
|
ObjectKey string `json:"object_key"`
|
|
Size int64 `json:"size"`
|
|
Sha256 string `json:"sha256"`
|
|
Status string `json:"status"`
|
|
Etag string `json:"etag,omitempty"`
|
|
CreatedAt int64 `json:"created_at"`
|
|
CompletedAt int64 `json:"completed_at,omitempty"`
|
|
}
|
|
|
|
func currentUserFromContext(ctx context.Context) (*user.User, error) {
|
|
u, ok := ctx.Value(constant.CtxKeyUser).(*user.User)
|
|
if !ok || u == nil {
|
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
|
|
}
|
|
return u, nil
|
|
}
|
|
|
|
func allowedContentTypeSet(csv string) map[string]struct{} {
|
|
set := make(map[string]struct{})
|
|
for _, item := range strings.Split(csv, ",") {
|
|
item = strings.TrimSpace(strings.ToLower(item))
|
|
if item == "" {
|
|
continue
|
|
}
|
|
set[item] = struct{}{}
|
|
}
|
|
return set
|
|
}
|
|
|
|
func validateInitRequest(svcCtx *svc.ServiceContext, bizType, fileName, contentType string, size int64) error {
|
|
if svcCtx.S3Store == nil || !svcCtx.Config.S3.Enable {
|
|
return errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "S3 upload is not enabled")
|
|
}
|
|
if strings.TrimSpace(bizType) == "" {
|
|
return errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "biz_type is required")
|
|
}
|
|
if strings.TrimSpace(fileName) == "" {
|
|
return errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "file_name is required")
|
|
}
|
|
if size <= 0 {
|
|
return errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "size must be greater than 0")
|
|
}
|
|
if svcCtx.Config.S3.MaxUploadSize > 0 && size > svcCtx.Config.S3.MaxUploadSize {
|
|
return errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "file size exceeds max limit")
|
|
}
|
|
allowed := allowedContentTypeSet(svcCtx.Config.S3.AllowedContentTypes)
|
|
if len(allowed) > 0 {
|
|
if _, ok := allowed[strings.ToLower(strings.TrimSpace(contentType))]; !ok {
|
|
return errors.Wrapf(xerr.NewErrCodeMsg(xerr.InvalidParams, "content_type is not allowed"), "content_type is not allowed")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func buildFileID(userID int64, bizType, fileName string) string {
|
|
h := sha1.New()
|
|
_, _ = h.Write([]byte(fmt.Sprintf("%d:%s:%s:%d", userID, bizType, fileName, time.Now().UnixNano())))
|
|
return hex.EncodeToString(h.Sum(nil))[:24]
|
|
}
|
|
|
|
func safeFileName(name string) string {
|
|
base := filepath.Base(strings.TrimSpace(name))
|
|
if base == "." || base == "/" || base == "" {
|
|
return "file.bin"
|
|
}
|
|
safe := safeFileNameRegexp.ReplaceAllString(base, "_")
|
|
if safe == "" {
|
|
return "file.bin"
|
|
}
|
|
return safe
|
|
}
|
|
|
|
func buildObjectKey(prefix string, userID int64, bizType, fileID, fileName string, now time.Time) string {
|
|
prefix = strings.Trim(prefix, "/")
|
|
if prefix == "" {
|
|
prefix = "app-upload"
|
|
}
|
|
return fmt.Sprintf("%s/%04d/%02d/%02d/%d/%s__%s",
|
|
prefix,
|
|
now.Year(),
|
|
int(now.Month()),
|
|
now.Day(),
|
|
userID,
|
|
safeFileName(fileName),
|
|
fileID,
|
|
)
|
|
}
|
|
|
|
func uploadRedisKey(fileID string) string {
|
|
return fileUploadRedisPrefix + fileID
|
|
}
|
|
|
|
func saveUploadMeta(ctx context.Context, svcCtx *svc.ServiceContext, meta *uploadMeta, ttl time.Duration) error {
|
|
data, err := json.Marshal(meta)
|
|
if err != nil {
|
|
return errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "marshal upload meta failed")
|
|
}
|
|
if err := svcCtx.Redis.Set(ctx, uploadRedisKey(meta.FileID), data, ttl).Err(); err != nil {
|
|
return errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "save upload meta failed")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func loadUploadMeta(ctx context.Context, svcCtx *svc.ServiceContext, fileID string) (*uploadMeta, error) {
|
|
val, err := svcCtx.Redis.Get(ctx, uploadRedisKey(fileID)).Result()
|
|
if err != nil {
|
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "upload meta not found")
|
|
}
|
|
var meta uploadMeta
|
|
if err := json.Unmarshal([]byte(val), &meta); err != nil {
|
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "parse upload meta failed")
|
|
}
|
|
return &meta, nil
|
|
}
|