package logic import ( "context" "strconv" "spark-server/internal/module/upload/entity" "spark-server/internal/pkg/errs" "spark-server/internal/pkg/i18n" "spark-server/internal/pkg/media" "spark-server/internal/pkg/reqctx" "spark-server/internal/pkg/validate" "go.uber.org/zap" ) // CreateInput requests a single-object Presigned PUT. The client does not choose the key. type CreateInput struct { Purpose string `json:"purpose" validate:"required" label:"field.purpose" errmsg:"required:validate.upload.purpose_required"` Mime string `json:"mime" validate:"required,max=128" label:"field.mime" errmsg:"required:validate.upload.mime_required;max:validate.upload.mime_invalid"` Size int64 `json:"size" validate:"required,gt=0" label:"field.size" errmsg:"required:validate.upload.size_required;gt:validate.upload.size_invalid"` GroupID int64 `json:"group_id" validate:"omitempty,gt=0" label:"field.group_id"` } func (s *Service) CreateCredential(ctx context.Context, userID int64, in *CreateInput) (*entity.Credential, error) { if !s.ready() { return nil, errs.ErrAvailable } if userID <= 0 { return nil, errs.ErrUnauthorized } if in == nil { return nil, errs.ErrInvalidArgument } if err := validate.StructLang(reqctx.Lang(ctx), in); err != nil { return nil, err } if !s.allow(userID) { return nil, errs.ErrRateLimited } return s.issuePut(ctx, userID, in) } func (s *Service) issuePut(ctx context.Context, userID int64, in *CreateInput) (*entity.Credential, error) { _, mime, err := matchSpec(ctx, in) if err != nil { return nil, err } if err := s.checkDeclaredSize(ctx, in.Purpose, in.Size); err != nil { return nil, err } name := reqctx.NewObjectName() ownerID := userID if in.Purpose == entity.PurposeGroupAvatar { if err := s.readyGroupAvatar(ctx, userID, in.GroupID); err != nil { return nil, err } ownerID = in.GroupID } key := entity.BuildObjectKey(in.Purpose, ownerID, name, dayOf(s.now()), entity.ExtForMIME(mime)) url, err := s.store.PresignPut(ctx, key, mime, in.Size, s.ttl) if err != nil { return nil, err } return &entity.Credential{ ObjectKey: key, UploadURL: url, Method: entity.MethodPUT, Headers: map[string]string{ "Content-Type": mime, "Content-Length": strconv.FormatInt(in.Size, 10), "If-None-Match": "*", }, ExpiresIn: s.expiresIn(), }, nil } // checkDeclaredSize applies soft media policy on credential size (no dimensions at this stage). func (s *Service) checkDeclaredSize(ctx context.Context, purpose string, size int64) error { if s == nil || !s.media.Enabled { return nil } var v *media.Violation switch purpose { case entity.PurposeAvatar, entity.PurposeGroupAvatar, entity.PurposeMomentCover: v = s.media.CheckAvatarMeta(0, 0, size) case entity.PurposeChatVideo, entity.PurposeMomentVideo, entity.PurposeSquareVideo: v = s.media.CheckVideoMeta(0, 0, size, 0) case entity.PurposeChatImage, entity.PurposeMomentImage, entity.PurposeSquareImage, entity.PurposeChatGIF: v = s.media.CheckImageMeta(0, 0, size) default: return nil } if v == nil { return nil } log := s.log if log == nil { log = zap.NewNop() } log.Warn("upload media size soft-check", zap.String("purpose", purpose), zap.String("reason", v.Reason), zap.Int64("size", v.Size), zap.Int64("max_size", v.MaxSize), ) if !s.media.UserReject { return nil } return errs.ErrMediaOversized.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyMediaOversized, nil)) } func matchSpec(ctx context.Context, in *CreateInput) (entity.Spec, string, error) { lang := reqctx.Lang(ctx) spec, ok := entity.LookupSpec(in.Purpose) if !ok { return entity.Spec{}, "", errs.ErrInvalidArgument.WithMsg(i18n.T(lang, i18n.KeyUploadPurposeInvalid, nil)) } mime := entity.NormalizeMIME(in.Mime) if _, ok := spec.MIMEs[mime]; !ok { return entity.Spec{}, "", errs.ErrInvalidArgument.WithMsg(i18n.T(lang, i18n.KeyUploadMIMEInvalid, nil)) } if in.Size > spec.MaxSize { return entity.Spec{}, "", errs.ErrInvalidArgument.WithMsg(i18n.T(lang, i18n.KeyUploadSizeExceeded, nil)) } return spec, mime, nil } func (s *Service) readyGroupAvatar(ctx context.Context, userID, groupID int64) error { if groupID <= 0 { return errs.ErrInvalidArgument.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyUploadGroupRequired, nil)) } if s.groups == nil { return nil } return s.groups.CanSetAvatar(ctx, userID, groupID) }