feat avatar compress uploads to 20kb
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
@@ -9,11 +10,19 @@ import (
|
||||
"fmt"
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||
"image"
|
||||
"image/color"
|
||||
stddraw "image/draw"
|
||||
_ "image/gif"
|
||||
"image/jpeg"
|
||||
_ "image/png"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
xdraw "golang.org/x/image/draw"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -31,6 +40,7 @@ var (
|
||||
const (
|
||||
maxNotifyEmails = 3 // Maximum number of notification emails per user
|
||||
maxInlineAvatarBytes = 100 * 1024
|
||||
targetAvatarBytes = 20 * 1024
|
||||
|
||||
// User-level rate limiting for notify email verification codes
|
||||
notifyCodeUserRateLimit = 5
|
||||
@@ -39,6 +49,11 @@ const (
|
||||
defaultUserIdentityRedirect = "/settings/profile"
|
||||
)
|
||||
|
||||
var (
|
||||
avatarScaleSteps = []float64{1, 0.92, 0.84, 0.76, 0.68, 0.6, 0.52, 0.44, 0.36}
|
||||
avatarQualitySteps = []int{88, 80, 72, 64, 56, 48, 40, 32}
|
||||
)
|
||||
|
||||
// UserListFilters contains all filter options for listing users
|
||||
type UserListFilters struct {
|
||||
Status string // User status filter
|
||||
@@ -432,6 +447,14 @@ func normalizeInlineUserAvatarInput(raw string) (UpsertUserAvatarInput, error) {
|
||||
return UpsertUserAvatarInput{}, ErrAvatarTooLarge
|
||||
}
|
||||
|
||||
if len(decoded) > targetAvatarBytes {
|
||||
decoded, contentType, err = compressInlineAvatar(decoded)
|
||||
if err != nil {
|
||||
return UpsertUserAvatarInput{}, err
|
||||
}
|
||||
raw = "data:" + contentType + ";base64," + base64.StdEncoding.EncodeToString(decoded)
|
||||
}
|
||||
|
||||
sum := sha256.Sum256(decoded)
|
||||
return UpsertUserAvatarInput{
|
||||
StorageProvider: "inline",
|
||||
@@ -442,6 +465,38 @@ func normalizeInlineUserAvatarInput(raw string) (UpsertUserAvatarInput, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func compressInlineAvatar(decoded []byte) ([]byte, string, error) {
|
||||
src, _, err := image.Decode(bytes.NewReader(decoded))
|
||||
if err != nil {
|
||||
return nil, "", ErrAvatarInvalid
|
||||
}
|
||||
|
||||
srcBounds := src.Bounds()
|
||||
if srcBounds.Empty() {
|
||||
return nil, "", ErrAvatarInvalid
|
||||
}
|
||||
|
||||
for _, scale := range avatarScaleSteps {
|
||||
width := max(1, int(float64(srcBounds.Dx())*scale))
|
||||
height := max(1, int(float64(srcBounds.Dy())*scale))
|
||||
dst := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||
stddraw.Draw(dst, dst.Bounds(), &image.Uniform{C: color.White}, image.Point{}, stddraw.Src)
|
||||
xdraw.CatmullRom.Scale(dst, dst.Bounds(), src, srcBounds, stddraw.Over, nil)
|
||||
|
||||
for _, quality := range avatarQualitySteps {
|
||||
var buf bytes.Buffer
|
||||
if err := jpeg.Encode(&buf, dst, &jpeg.Options{Quality: quality}); err != nil {
|
||||
return nil, "", ErrAvatarInvalid
|
||||
}
|
||||
if buf.Len() <= targetAvatarBytes {
|
||||
return buf.Bytes(), "image/jpeg", nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil, "", ErrAvatarTooLarge
|
||||
}
|
||||
|
||||
func (s *UserService) buildEmailIdentitySummary(user *User) UserIdentitySummary {
|
||||
summary := UserIdentitySummary{
|
||||
Provider: "email",
|
||||
|
||||
@@ -3,11 +3,14 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"image"
|
||||
"image/png"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
@@ -361,6 +364,57 @@ func TestUpdateProfile_StoresInlineAvatarWithinLimit(t *testing.T) {
|
||||
require.Equal(t, hex.EncodeToString(expectedSum[:]), updated.AvatarSHA256)
|
||||
}
|
||||
|
||||
func TestUpdateProfile_CompressesInlineAvatarToTwentyKilobytes(t *testing.T) {
|
||||
var encoded bytes.Buffer
|
||||
for _, size := range []int{192, 224, 256, 288} {
|
||||
encoded.Reset()
|
||||
var img image.RGBA
|
||||
img.Rect = image.Rect(0, 0, size, size)
|
||||
img.Stride = size * 4
|
||||
img.Pix = make([]byte, size*size*4)
|
||||
for y := 0; y < size; y++ {
|
||||
for x := 0; x < size; x++ {
|
||||
offset := y*img.Stride + x*4
|
||||
img.Pix[offset] = uint8((x*x + y*17) % 255)
|
||||
img.Pix[offset+1] = uint8((y*y + x*29) % 255)
|
||||
img.Pix[offset+2] = uint8(((x * y) + x*13 + y*7) % 255)
|
||||
img.Pix[offset+3] = 0xff
|
||||
}
|
||||
}
|
||||
require.NoError(t, png.Encode(&encoded, &img))
|
||||
if encoded.Len() > 20*1024 && encoded.Len() <= maxInlineAvatarBytes {
|
||||
break
|
||||
}
|
||||
}
|
||||
require.Greater(t, encoded.Len(), 20*1024)
|
||||
require.LessOrEqual(t, encoded.Len(), maxInlineAvatarBytes)
|
||||
|
||||
dataURL := "data:image/png;base64," + base64.StdEncoding.EncodeToString(encoded.Bytes())
|
||||
repo := &mockUserRepo{
|
||||
getByIDUser: &User{
|
||||
ID: 17,
|
||||
Email: "avatar-compress@example.com",
|
||||
Username: "avatar-compress",
|
||||
},
|
||||
}
|
||||
svc := NewUserService(repo, nil, nil, nil)
|
||||
|
||||
updated, err := svc.UpdateProfile(context.Background(), 17, UpdateProfileRequest{
|
||||
AvatarURL: &dataURL,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, repo.upsertAvatarArgs, 1)
|
||||
require.Equal(t, "inline", repo.upsertAvatarArgs[0].StorageProvider)
|
||||
require.LessOrEqual(t, repo.upsertAvatarArgs[0].ByteSize, 20*1024)
|
||||
require.Equal(t, "image/jpeg", repo.upsertAvatarArgs[0].ContentType)
|
||||
require.Contains(t, repo.upsertAvatarArgs[0].URL, "data:image/jpeg;base64,")
|
||||
require.Equal(t, "inline", updated.AvatarSource)
|
||||
require.Equal(t, "image/jpeg", updated.AvatarMIME)
|
||||
require.LessOrEqual(t, updated.AvatarByteSize, 20*1024)
|
||||
require.Contains(t, updated.AvatarURL, "data:image/jpeg;base64,")
|
||||
require.NotEmpty(t, updated.AvatarSHA256)
|
||||
}
|
||||
|
||||
func TestUpdateProfile_RejectsInlineAvatarOverLimit(t *testing.T) {
|
||||
raw := make([]byte, maxInlineAvatarBytes+1)
|
||||
dataURL := "data:image/png;base64," + base64.StdEncoding.EncodeToString(raw)
|
||||
|
||||
Reference in New Issue
Block a user