feat avatar compress uploads to 20kb

This commit is contained in:
IanShaw027
2026-04-21 08:53:59 +08:00
parent 07f23aaa7d
commit 6da08262d7
8 changed files with 321 additions and 44 deletions

View File

@@ -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",