diff --git a/internal/bootstrap/config.go b/internal/bootstrap/config.go index f757c59..e3500e9 100644 --- a/internal/bootstrap/config.go +++ b/internal/bootstrap/config.go @@ -25,6 +25,7 @@ type Config struct { RateLimit RateLimitConfig `yaml:"rate_limit"` Message MessageConfig `yaml:"message"` ObjectStorage ObjectStorageConfig `yaml:"object_storage"` + Media MediaConfig `yaml:"media"` SMS SMSConfig `yaml:"sms"` } @@ -135,6 +136,23 @@ type ObjectStorageConfig struct { ChatExpireDays int `yaml:"chat_expire_days"` } +// MediaConfig is server-side media detect (users) + compress (bots). +// Client App still compresses user uploads; see docs/media.md. +type MediaConfig struct { + Enabled bool `yaml:"enabled"` + UserReject bool `yaml:"user_reject"` // false=log only; true=return 40005 + BotCompress bool `yaml:"bot_compress"` + ImageMaxEdge int `yaml:"image_max_edge"` + VideoMaxEdge int `yaml:"video_max_edge"` + AvatarMaxEdge int `yaml:"avatar_max_edge"` + ImageMaxBytes int64 `yaml:"image_max_bytes"` + VideoMaxBytes int64 `yaml:"video_max_bytes"` + AvatarMaxBytes int64 `yaml:"avatar_max_bytes"` + JPEGQuality int `yaml:"jpeg_quality"` + VideoCRF int `yaml:"video_crf"` + FFmpegPath string `yaml:"ffmpeg_path"` +} + type MessageConfig struct { MaxContentSize int `yaml:"max_content_size"` SyncLimit int `yaml:"sync_limit"` @@ -314,6 +332,42 @@ func applyDefaults(cfg *Config) { if cfg.RateLimit.UploadPerSecond < 1 { cfg.RateLimit.UploadPerSecond = 10 } + applyMediaDefaults(&cfg.Media) +} + +func applyMediaDefaults(m *MediaConfig) { + if m == nil { + return + } + // Numeric/path defaults only. Toggle via YAML media.enabled / media.bot_compress + // (configs ship with enabled: true). Explicit enabled: false stays off. + if m.ImageMaxEdge <= 0 { + m.ImageMaxEdge = 1080 + } + if m.VideoMaxEdge <= 0 { + m.VideoMaxEdge = 720 + } + if m.AvatarMaxEdge <= 0 { + m.AvatarMaxEdge = 200 + } + if m.ImageMaxBytes <= 0 { + m.ImageMaxBytes = 10 << 20 + } + if m.VideoMaxBytes <= 0 { + m.VideoMaxBytes = 100 << 20 + } + if m.AvatarMaxBytes <= 0 { + m.AvatarMaxBytes = 2 << 20 + } + if m.JPEGQuality <= 0 { + m.JPEGQuality = 85 + } + if m.VideoCRF <= 0 { + m.VideoCRF = 23 + } + if strings.TrimSpace(m.FFmpegPath) == "" { + m.FFmpegPath = "ffmpeg" + } } func parseEnv(raw string) string { diff --git a/internal/module/upload/logic/credential.go b/internal/module/upload/logic/credential.go index 996a096..81197ab 100644 --- a/internal/module/upload/logic/credential.go +++ b/internal/module/upload/logic/credential.go @@ -7,8 +7,11 @@ import ( "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. @@ -43,6 +46,9 @@ func (s *Service) issuePut(ctx context.Context, userID int64, in *CreateInput) ( 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 { @@ -69,6 +75,41 @@ func (s *Service) issuePut(ctx context.Context, userID int64, in *CreateInput) ( }, 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)