package app import ( "bytes" "context" "errors" "fmt" "image" _ "image/gif" _ "image/jpeg" _ "image/png" "net/http" "time" "github.com/chai2010/webp" _ "golang.org/x/image/webp" "github.com/geo-platform/tenant-api/internal/shared/auth" "github.com/geo-platform/tenant-api/internal/shared/objectstorage" "github.com/geo-platform/tenant-api/internal/shared/response" ) const ( kolAvatarMaxSizeBytes = 2 * 1024 * 1024 kolAvatarMaxDimension = 2048 ) var kolAvatarAllowedMIMEs = map[string]struct{}{ "image/png": {}, "image/jpeg": {}, "image/gif": {}, "image/webp": {}, } type KolAvatarUploadResponse struct { URL string `json:"url"` ObjectKey string `json:"object_key"` ContentType string `json:"content_type"` SizeBytes int64 `json:"size_bytes"` } func (s *KolProfileService) UploadAvatar(ctx context.Context, actor auth.Actor, filename string, content []byte) (*KolAvatarUploadResponse, error) { if s.objectStorage == nil { return nil, response.ErrServiceUnavailable(50303, "object_storage_unavailable", "object storage is not configured") } profile, err := s.RequireActiveKol(ctx, actor) if err != nil { return nil, err } if err := s.objectStorage.Validate(); err != nil { if errors.Is(err, objectstorage.ErrNotConfigured) { return nil, response.ErrServiceUnavailable(50303, "object_storage_unavailable", "object storage is not configured") } return nil, response.ErrInternal(50064, "kol_avatar_storage_validation_failed", err.Error()) } if len(content) == 0 { return nil, response.ErrBadRequest(40004, "kol_avatar_invalid", "avatar file is required") } if len(content) > kolAvatarMaxSizeBytes { return nil, response.ErrBadRequest(40005, "kol_avatar_too_large", "avatar size cannot exceed 2 MB") } contentType := http.DetectContentType(content) if _, ok := kolAvatarAllowedMIMEs[contentType]; !ok { return nil, response.ErrBadRequest(40006, "kol_avatar_type_not_supported", "only PNG, JPG, GIF, or WebP is supported") } cfg, _, err := image.DecodeConfig(bytes.NewReader(content)) if err != nil || cfg.Width <= 0 || cfg.Height <= 0 { return nil, response.ErrBadRequest(40004, "kol_avatar_invalid", "avatar file cannot be decoded") } if cfg.Width > kolAvatarMaxDimension || cfg.Height > kolAvatarMaxDimension { return nil, response.ErrBadRequest(40007, "kol_avatar_dimensions_too_large", "avatar dimensions exceed the maximum allowed size") } storedContent := content storedContentType := contentType if contentType != "image/webp" { decoded, _, decodeErr := image.Decode(bytes.NewReader(content)) if decodeErr != nil { return nil, response.ErrBadRequest(40004, "kol_avatar_invalid", "avatar file cannot be decoded") } var buf bytes.Buffer if encodeErr := webp.Encode(&buf, decoded, &webp.Options{Quality: float32(webpEncodeQuality)}); encodeErr != nil { return nil, response.ErrInternal(50065, "kol_avatar_encode_failed", "failed to encode avatar") } storedContent = buf.Bytes() storedContentType = "image/webp" } objectKey := buildKolAvatarObjectKey(actor.TenantID, profile.ID) if err := s.objectStorage.PutBytes(ctx, objectKey, storedContent, storedContentType); err != nil { if errors.Is(err, objectstorage.ErrNotConfigured) { return nil, response.ErrServiceUnavailable(50303, "object_storage_unavailable", "object storage is not configured") } return nil, response.ErrInternal(50066, "kol_avatar_upload_failed", "failed to upload avatar") } _ = filename return &KolAvatarUploadResponse{ URL: "", ObjectKey: objectKey, ContentType: storedContentType, SizeBytes: int64(len(storedContent)), }, nil } func buildKolAvatarObjectKey(tenantID, profileID int64) string { return fmt.Sprintf("tenants/%d/kol-avatars/%d/%d.webp", tenantID, profileID, time.Now().UTC().UnixNano()) }