diff --git a/backend/go.mod b/backend/go.mod index 66b6cc25..627851bf 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -39,10 +39,11 @@ require ( github.com/wechatpay-apiv3/wechatpay-go v0.2.21 github.com/zeromicro/go-zero v1.9.4 go.uber.org/zap v1.24.0 - golang.org/x/crypto v0.48.0 - golang.org/x/net v0.49.0 - golang.org/x/sync v0.19.0 - golang.org/x/term v0.40.0 + golang.org/x/crypto v0.49.0 + golang.org/x/image v0.39.0 + golang.org/x/net v0.52.0 + golang.org/x/sync v0.20.0 + golang.org/x/term v0.41.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.44.3 @@ -103,7 +104,6 @@ require ( github.com/goccy/go-json v0.10.2 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-querystring v1.1.0 // indirect - github.com/google/subcommands v1.2.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/hashicorp/hcl/v2 v2.18.1 // indirect @@ -172,10 +172,10 @@ require ( go.uber.org/multierr v1.9.0 // indirect golang.org/x/arch v0.3.0 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect - golang.org/x/mod v0.32.0 // indirect - golang.org/x/sys v0.41.0 // indirect - golang.org/x/text v0.34.0 // indirect - golang.org/x/tools v0.41.0 // indirect + golang.org/x/mod v0.34.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.36.0 // indirect + golang.org/x/tools v0.43.0 // indirect google.golang.org/grpc v1.75.1 // indirect google.golang.org/protobuf v1.36.10 // indirect gopkg.in/ini.v1 v1.67.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index 9312af63..f1c864f5 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -162,8 +162,6 @@ github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17 github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= -github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE= -github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4= @@ -183,8 +181,6 @@ github.com/icholy/digest v1.1.0 h1:HfGg9Irj7i+IX1o1QAmPfIBNu/Q5A5Tu3n/MED9k9H4= github.com/icholy/digest v1.1.0/go.mod h1:QNrsSGQ5v7v9cReDI0+eyjsXGUoRSUZQHeQ5C4XLa0Y= github.com/imroc/req/v3 v3.57.0 h1:LMTUjNRUybUkTPn8oJDq8Kg3JRBOBTcnDhKu7mzupKI= github.com/imroc/req/v3 v3.57.0/go.mod h1:JL62ey1nvSLq81HORNcosvlf7SxZStONNqOprg0Pz00= -github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= -github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -220,8 +216,6 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= -github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM= github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= @@ -255,8 +249,6 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= -github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= -github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= @@ -286,8 +278,6 @@ github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEv github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= @@ -320,8 +310,6 @@ github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= -github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= -github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ= @@ -413,16 +401,18 @@ go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= -golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= -golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= -golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww= +golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA= +golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= +golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -432,16 +422,16 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= -golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= -golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= -golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= +golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17 h1:wpZ8pe2x1Q3f2KyT5f8oP/fa9rHAKgFPr/HZdNuS+PQ= google.golang.org/genproto/googleapis/api v0.0.0-20250929231259-57b25ae835d4 h1:8XJ4pajGwOlasW+L13MnEGA8W4115jJySQtVfS2/IBU= diff --git a/backend/internal/service/user_service.go b/backend/internal/service/user_service.go index 6994ec6c..9c7d4747 100644 --- a/backend/internal/service/user_service.go +++ b/backend/internal/service/user_service.go @@ -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", diff --git a/backend/internal/service/user_service_test.go b/backend/internal/service/user_service_test.go index c0cc36cb..d771cb75 100644 --- a/backend/internal/service/user_service_test.go +++ b/backend/internal/service/user_service_test.go @@ -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) diff --git a/frontend/src/components/user/profile/ProfileInfoCard.vue b/frontend/src/components/user/profile/ProfileInfoCard.vue index 460e5c7f..dd7ae0ea 100644 --- a/frontend/src/components/user/profile/ProfileInfoCard.vue +++ b/frontend/src/components/user/profile/ProfileInfoCard.vue @@ -176,6 +176,9 @@ const authStore = useAuthStore() const appStore = useAppStore() const maxAvatarBytes = 100 * 1024 +const targetAvatarUploadBytes = 20 * 1024 +const avatarScaleSteps = [1, 0.92, 0.84, 0.76, 0.68, 0.6, 0.52, 0.44, 0.36] +const avatarQualitySteps = [0.92, 0.84, 0.76, 0.68, 0.6, 0.52, 0.44, 0.36] const avatarDraft = ref(props.user?.avatar_url?.trim() || '') const avatarSaving = ref(false) @@ -341,6 +344,72 @@ function readFileAsDataURL(file: File): Promise { }) } +function loadImage(dataURL: string): Promise { + return new Promise((resolve, reject) => { + const image = new Image() + image.onload = () => resolve(image) + image.onerror = () => reject(new Error(t('profile.avatar.readFailed'))) + image.src = dataURL + }) +} + +function canvasToBlob(canvas: HTMLCanvasElement, type: string, quality: number): Promise { + return new Promise((resolve, reject) => { + canvas.toBlob((blob) => { + if (!blob) { + reject(new Error(t('profile.avatar.compressFailed'))) + return + } + resolve(blob) + }, type, quality) + }) +} + +async function compressAvatarFile(file: File): Promise { + const sourceDataURL = await readFileAsDataURL(file) + const image = await loadImage(sourceDataURL) + const canvas = document.createElement('canvas') + const ctx = canvas.getContext('2d') + if (!ctx) { + throw new Error(t('profile.avatar.compressFailed')) + } + + for (const scale of avatarScaleSteps) { + const width = Math.max(1, Math.round(image.naturalWidth * scale)) + const height = Math.max(1, Math.round(image.naturalHeight * scale)) + canvas.width = width + canvas.height = height + ctx.clearRect(0, 0, width, height) + ctx.drawImage(image, 0, 0, width, height) + + for (const quality of avatarQualitySteps) { + const blob = await canvasToBlob(canvas, 'image/webp', quality) + if (blob.size <= targetAvatarUploadBytes) { + const fileName = file.name.replace(/\.[^.]+$/, '') || 'avatar' + return new File([blob], `${fileName}.webp`, { type: 'image/webp' }) + } + } + } + + throw new Error(t('profile.avatar.compressTooLarge')) +} + +async function prepareAvatarUpload(file: File): Promise { + if (!file.type.startsWith('image/')) { + throw new Error(t('profile.avatar.invalidType')) + } + if (file.type === 'image/gif') { + if (file.size > targetAvatarUploadBytes) { + throw new Error(t('profile.avatar.gifTooLarge')) + } + return file + } + if (file.size <= targetAvatarUploadBytes) { + return file + } + return compressAvatarFile(file) +} + async function handleAvatarFileChange(event: Event) { const input = event.target as HTMLInputElement | null const file = input?.files?.[0] @@ -360,7 +429,8 @@ async function handleAvatarFileChange(event: Event) { } try { - const dataURL = await readFileAsDataURL(file) + const preparedFile = await prepareAvatarUpload(file) + const dataURL = await readFileAsDataURL(preparedFile) const normalized = validateAvatarInput(dataURL) if (!normalized) { return diff --git a/frontend/src/components/user/profile/__tests__/ProfileInfoCard.spec.ts b/frontend/src/components/user/profile/__tests__/ProfileInfoCard.spec.ts index 6e9e4dd5..4c2b25ca 100644 --- a/frontend/src/components/user/profile/__tests__/ProfileInfoCard.spec.ts +++ b/frontend/src/components/user/profile/__tests__/ProfileInfoCard.spec.ts @@ -1,5 +1,5 @@ import { mount } from '@vue/test-utils' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import ProfileInfoCard from '@/components/user/profile/ProfileInfoCard.vue' import type { User } from '@/types' @@ -51,11 +51,15 @@ vi.mock('vue-i18n', async (importOriginal) => { if (key === 'profile.avatar.inputLabel') return 'Avatar URL or data URL' if (key === 'profile.avatar.inputPlaceholder') return 'https://cdn.example.com/avatar.png' if (key === 'profile.avatar.uploadAction') return 'Upload image' - if (key === 'profile.avatar.uploadHint') return 'Images must be 100KB or smaller' + if (key === 'profile.avatar.uploadHint') return 'Images must be 100KB or smaller and will be compressed to 20KB' if (key === 'profile.avatar.saveSuccess') return 'Avatar updated' if (key === 'profile.avatar.deleteSuccess') return 'Avatar removed' if (key === 'profile.avatar.invalidType') return 'Please choose an image file' if (key === 'profile.avatar.fileTooLarge') return 'Avatar image must be 100KB or smaller' + if (key === 'profile.avatar.gifTooLarge') return 'GIF avatars must already be 20KB or smaller' + if (key === 'profile.avatar.compressTooLarge') return 'Unable to compress this image below 20KB' + if (key === 'profile.avatar.compressFailed') return 'Failed to compress the selected image' + if (key === 'profile.avatar.readFailed') return 'Failed to read the selected image' if (key === 'profile.avatar.invalidValue') return 'Enter a valid avatar URL or image data URL' if (key === 'profile.avatar.emptyDeleteHint') return 'Avatar already removed' if (key === 'profile.authBindings.providers.email') return 'Email' @@ -92,6 +96,63 @@ function createUser(overrides: Partial = {}): User { } } +async function flushAsyncWork(): Promise { + await Promise.resolve() + await Promise.resolve() +} + +const originalFileReader = globalThis.FileReader +const originalImage = globalThis.Image +const originalCreateElement = document.createElement.bind(document) + +function installAvatarCompressionMocks() { + class MockFileReader { + result: string | ArrayBuffer | null = null + onload: ((this: FileReader, ev: ProgressEvent) => any) | null = null + onerror: ((this: FileReader, ev: ProgressEvent) => any) | null = null + error: DOMException | null = null + + readAsDataURL(blob: Blob) { + if (blob.type === 'image/webp') { + this.result = 'data:image/webp;base64,' + Buffer.from('compressed-avatar').toString('base64') + } else { + this.result = 'data:image/png;base64,' + Buffer.from('original-avatar').toString('base64') + } + this.onload?.call(this as unknown as FileReader, new ProgressEvent('load')) + } + } + + class MockImage { + naturalWidth = 1200 + naturalHeight = 1200 + onload: (() => void) | null = null + onerror: (() => void) | null = null + + set src(_value: string) { + this.onload?.() + } + } + + globalThis.FileReader = MockFileReader as unknown as typeof FileReader + globalThis.Image = MockImage as unknown as typeof Image + vi.spyOn(document, 'createElement').mockImplementation(((tagName: string, options?: ElementCreationOptions) => { + if (tagName === 'canvas') { + return { + width: 0, + height: 0, + getContext: () => ({ + clearRect: vi.fn(), + drawImage: vi.fn(), + }), + toBlob: (callback: BlobCallback) => { + callback(new Blob([new Uint8Array(8 * 1024)], { type: 'image/webp' })) + }, + } as unknown as HTMLCanvasElement + } + return originalCreateElement(tagName, options) + }) as typeof document.createElement) +} + describe('ProfileInfoCard', () => { beforeEach(() => { updateProfileMock.mockReset() @@ -100,6 +161,12 @@ describe('ProfileInfoCard', () => { authStoreState.user = null }) + afterEach(() => { + globalThis.FileReader = originalFileReader + globalThis.Image = originalImage + vi.restoreAllMocks() + }) + it('saves a remote avatar URL and updates the auth store', async () => { const updatedUser = createUser({ avatar_url: 'https://cdn.example.com/new.png' }) updateProfileMock.mockResolvedValue(updatedUser) @@ -148,6 +215,39 @@ describe('ProfileInfoCard', () => { expect(showErrorMock).toHaveBeenCalledWith('Avatar image must be 100KB or smaller') }) + it('compresses uploaded images under 100KB before saving', async () => { + installAvatarCompressionMocks() + const updatedUser = createUser({ avatar_url: 'data:image/webp;base64,Y29tcHJlc3NlZC1hdmF0YXI=' }) + updateProfileMock.mockResolvedValue(updatedUser) + authStoreState.user = createUser() + + const wrapper = mount(ProfileInfoCard, { + props: { + user: authStoreState.user + }, + global: { + stubs: { + Icon: true, + ProfileIdentityBindingsSection: true + } + } + }) + + const fileInput = wrapper.get('[data-testid="profile-avatar-file-input"]') + Object.defineProperty(fileInput.element, 'files', { + value: [new File([new Uint8Array(80 * 1024)], 'avatar.png', { type: 'image/png' })], + configurable: true + }) + + await fileInput.trigger('change') + await flushAsyncWork() + await wrapper.get('[data-testid="profile-avatar-save"]').trigger('click') + + expect(updateProfileMock).toHaveBeenCalledWith({ + avatar_url: 'data:image/webp;base64,Y29tcHJlc3NlZC1hdmF0YXI=' + }) + }) + it('deletes the current avatar', async () => { const updatedUser = createUser({ avatar_url: null }) updateProfileMock.mockResolvedValue(updatedUser) diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 78db9e8a..ec9c1ea3 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -943,15 +943,19 @@ export default { }, avatar: { title: 'Profile Avatar', - description: 'Set your avatar with a remote image URL or upload a small image.', + description: 'Set your avatar with a remote image URL or upload an image under 100KB. Uploaded images are compressed to 20KB.', inputLabel: 'Avatar URL or data URL', inputPlaceholder: 'https://cdn.example.com/avatar.png', uploadAction: 'Upload image', - uploadHint: 'Images must be 100KB or smaller', + uploadHint: 'Uploaded images must be 100KB or smaller. Static images are compressed to 20KB.', saveSuccess: 'Avatar updated', deleteSuccess: 'Avatar removed', invalidType: 'Please choose an image file', fileTooLarge: 'Avatar image must be 100KB or smaller', + gifTooLarge: 'GIF avatars must already be 20KB or smaller', + compressTooLarge: 'Unable to compress this image below 20KB. Try a smaller image.', + compressFailed: 'Failed to compress the selected image.', + readFailed: 'Failed to read the selected image.', invalidValue: 'Enter a valid avatar URL or image data URL', emptyDeleteHint: 'Avatar is already empty', }, diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 9bb265e1..9941d323 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -947,15 +947,19 @@ export default { }, avatar: { title: '资料头像', - description: '支持填写远程图片 URL,或上传不超过 100KB 的头像图片。', + description: '支持填写远程图片 URL,或上传不超过 100KB 的头像图片;上传图片会自动压缩到 20KB 以内。', inputLabel: '头像 URL 或 data URL', inputPlaceholder: 'https://cdn.example.com/avatar.png', uploadAction: '上传图片', - uploadHint: '图片大小需不超过 100KB', + uploadHint: '上传图片需不超过 100KB,静态图片会自动压缩到 20KB 以内', saveSuccess: '头像已更新', deleteSuccess: '头像已删除', invalidType: '请选择图片文件', fileTooLarge: '头像图片必须不超过 100KB', + gifTooLarge: 'GIF 头像必须在 20KB 以内', + compressTooLarge: '无法将图片压缩到 20KB 以内,请换一张更小的图片', + compressFailed: '压缩所选图片失败', + readFailed: '读取所选图片失败', invalidValue: '请输入有效的头像 URL 或图片 data URL', emptyDeleteHint: '当前没有可删除的头像', },