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.NewErrCode(xerr.InvalidParams), "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 }