feat avatar compress uploads to 20kb
This commit is contained in:
@@ -39,10 +39,11 @@ require (
|
|||||||
github.com/wechatpay-apiv3/wechatpay-go v0.2.21
|
github.com/wechatpay-apiv3/wechatpay-go v0.2.21
|
||||||
github.com/zeromicro/go-zero v1.9.4
|
github.com/zeromicro/go-zero v1.9.4
|
||||||
go.uber.org/zap v1.24.0
|
go.uber.org/zap v1.24.0
|
||||||
golang.org/x/crypto v0.48.0
|
golang.org/x/crypto v0.49.0
|
||||||
golang.org/x/net v0.49.0
|
golang.org/x/image v0.39.0
|
||||||
golang.org/x/sync v0.19.0
|
golang.org/x/net v0.52.0
|
||||||
golang.org/x/term v0.40.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/natefinch/lumberjack.v2 v2.2.1
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
modernc.org/sqlite v1.44.3
|
modernc.org/sqlite v1.44.3
|
||||||
@@ -103,7 +104,6 @@ require (
|
|||||||
github.com/goccy/go-json v0.10.2 // indirect
|
github.com/goccy/go-json v0.10.2 // indirect
|
||||||
github.com/google/go-cmp v0.7.0 // indirect
|
github.com/google/go-cmp v0.7.0 // indirect
|
||||||
github.com/google/go-querystring v1.1.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/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect
|
||||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||||
github.com/hashicorp/hcl/v2 v2.18.1 // indirect
|
github.com/hashicorp/hcl/v2 v2.18.1 // indirect
|
||||||
@@ -172,10 +172,10 @@ require (
|
|||||||
go.uber.org/multierr v1.9.0 // indirect
|
go.uber.org/multierr v1.9.0 // indirect
|
||||||
golang.org/x/arch v0.3.0 // indirect
|
golang.org/x/arch v0.3.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||||
golang.org/x/mod v0.32.0 // indirect
|
golang.org/x/mod v0.34.0 // indirect
|
||||||
golang.org/x/sys v0.41.0 // indirect
|
golang.org/x/sys v0.42.0 // indirect
|
||||||
golang.org/x/text v0.34.0 // indirect
|
golang.org/x/text v0.36.0 // indirect
|
||||||
golang.org/x/tools v0.41.0 // indirect
|
golang.org/x/tools v0.43.0 // indirect
|
||||||
google.golang.org/grpc v1.75.1 // indirect
|
google.golang.org/grpc v1.75.1 // indirect
|
||||||
google.golang.org/protobuf v1.36.10 // indirect
|
google.golang.org/protobuf v1.36.10 // indirect
|
||||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||||
|
|||||||
@@ -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/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 h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
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=
|
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/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 h1:LMTUjNRUybUkTPn8oJDq8Kg3JRBOBTcnDhKu7mzupKI=
|
||||||
github.com/imroc/req/v3 v3.57.0/go.mod h1:JL62ey1nvSLq81HORNcosvlf7SxZStONNqOprg0Pz00=
|
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 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
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.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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
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 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
|
||||||
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||||
github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
|
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/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 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
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 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
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/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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
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 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
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=
|
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/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 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
|
||||||
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
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 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ=
|
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.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 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
|
||||||
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
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.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
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 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
||||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
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/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
|
||||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
|
||||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
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-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-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.8.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.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
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 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||||
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
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.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
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=
|
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 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=
|
google.golang.org/genproto/googleapis/api v0.0.0-20250929231259-57b25ae835d4 h1:8XJ4pajGwOlasW+L13MnEGA8W4115jJySQtVfS2/IBU=
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
@@ -9,11 +10,19 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
stddraw "image/draw"
|
||||||
|
_ "image/gif"
|
||||||
|
"image/jpeg"
|
||||||
|
_ "image/png"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/url"
|
"net/url"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
xdraw "golang.org/x/image/draw"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -31,6 +40,7 @@ var (
|
|||||||
const (
|
const (
|
||||||
maxNotifyEmails = 3 // Maximum number of notification emails per user
|
maxNotifyEmails = 3 // Maximum number of notification emails per user
|
||||||
maxInlineAvatarBytes = 100 * 1024
|
maxInlineAvatarBytes = 100 * 1024
|
||||||
|
targetAvatarBytes = 20 * 1024
|
||||||
|
|
||||||
// User-level rate limiting for notify email verification codes
|
// User-level rate limiting for notify email verification codes
|
||||||
notifyCodeUserRateLimit = 5
|
notifyCodeUserRateLimit = 5
|
||||||
@@ -39,6 +49,11 @@ const (
|
|||||||
defaultUserIdentityRedirect = "/settings/profile"
|
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
|
// UserListFilters contains all filter options for listing users
|
||||||
type UserListFilters struct {
|
type UserListFilters struct {
|
||||||
Status string // User status filter
|
Status string // User status filter
|
||||||
@@ -432,6 +447,14 @@ func normalizeInlineUserAvatarInput(raw string) (UpsertUserAvatarInput, error) {
|
|||||||
return UpsertUserAvatarInput{}, ErrAvatarTooLarge
|
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)
|
sum := sha256.Sum256(decoded)
|
||||||
return UpsertUserAvatarInput{
|
return UpsertUserAvatarInput{
|
||||||
StorageProvider: "inline",
|
StorageProvider: "inline",
|
||||||
@@ -442,6 +465,38 @@ func normalizeInlineUserAvatarInput(raw string) (UpsertUserAvatarInput, error) {
|
|||||||
}, nil
|
}, 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 {
|
func (s *UserService) buildEmailIdentitySummary(user *User) UserIdentitySummary {
|
||||||
summary := UserIdentitySummary{
|
summary := UserIdentitySummary{
|
||||||
Provider: "email",
|
Provider: "email",
|
||||||
|
|||||||
@@ -3,11 +3,14 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
|
"image"
|
||||||
|
"image/png"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -361,6 +364,57 @@ func TestUpdateProfile_StoresInlineAvatarWithinLimit(t *testing.T) {
|
|||||||
require.Equal(t, hex.EncodeToString(expectedSum[:]), updated.AvatarSHA256)
|
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) {
|
func TestUpdateProfile_RejectsInlineAvatarOverLimit(t *testing.T) {
|
||||||
raw := make([]byte, maxInlineAvatarBytes+1)
|
raw := make([]byte, maxInlineAvatarBytes+1)
|
||||||
dataURL := "data:image/png;base64," + base64.StdEncoding.EncodeToString(raw)
|
dataURL := "data:image/png;base64," + base64.StdEncoding.EncodeToString(raw)
|
||||||
|
|||||||
@@ -176,6 +176,9 @@ const authStore = useAuthStore()
|
|||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
|
|
||||||
const maxAvatarBytes = 100 * 1024
|
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 avatarDraft = ref(props.user?.avatar_url?.trim() || '')
|
||||||
const avatarSaving = ref(false)
|
const avatarSaving = ref(false)
|
||||||
|
|
||||||
@@ -341,6 +344,72 @@ function readFileAsDataURL(file: File): Promise<string> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadImage(dataURL: string): Promise<HTMLImageElement> {
|
||||||
|
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<Blob> {
|
||||||
|
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<File> {
|
||||||
|
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<File> {
|
||||||
|
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) {
|
async function handleAvatarFileChange(event: Event) {
|
||||||
const input = event.target as HTMLInputElement | null
|
const input = event.target as HTMLInputElement | null
|
||||||
const file = input?.files?.[0]
|
const file = input?.files?.[0]
|
||||||
@@ -360,7 +429,8 @@ async function handleAvatarFileChange(event: Event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const dataURL = await readFileAsDataURL(file)
|
const preparedFile = await prepareAvatarUpload(file)
|
||||||
|
const dataURL = await readFileAsDataURL(preparedFile)
|
||||||
const normalized = validateAvatarInput(dataURL)
|
const normalized = validateAvatarInput(dataURL)
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { mount } from '@vue/test-utils'
|
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 ProfileInfoCard from '@/components/user/profile/ProfileInfoCard.vue'
|
||||||
import type { User } from '@/types'
|
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.inputLabel') return 'Avatar URL or data URL'
|
||||||
if (key === 'profile.avatar.inputPlaceholder') return 'https://cdn.example.com/avatar.png'
|
if (key === 'profile.avatar.inputPlaceholder') return 'https://cdn.example.com/avatar.png'
|
||||||
if (key === 'profile.avatar.uploadAction') return 'Upload image'
|
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.saveSuccess') return 'Avatar updated'
|
||||||
if (key === 'profile.avatar.deleteSuccess') return 'Avatar removed'
|
if (key === 'profile.avatar.deleteSuccess') return 'Avatar removed'
|
||||||
if (key === 'profile.avatar.invalidType') return 'Please choose an image file'
|
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.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.invalidValue') return 'Enter a valid avatar URL or image data URL'
|
||||||
if (key === 'profile.avatar.emptyDeleteHint') return 'Avatar already removed'
|
if (key === 'profile.avatar.emptyDeleteHint') return 'Avatar already removed'
|
||||||
if (key === 'profile.authBindings.providers.email') return 'Email'
|
if (key === 'profile.authBindings.providers.email') return 'Email'
|
||||||
@@ -92,6 +96,63 @@ function createUser(overrides: Partial<User> = {}): User {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function flushAsyncWork(): Promise<void> {
|
||||||
|
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<FileReader>) => any) | null = null
|
||||||
|
onerror: ((this: FileReader, ev: ProgressEvent<FileReader>) => 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', () => {
|
describe('ProfileInfoCard', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
updateProfileMock.mockReset()
|
updateProfileMock.mockReset()
|
||||||
@@ -100,6 +161,12 @@ describe('ProfileInfoCard', () => {
|
|||||||
authStoreState.user = null
|
authStoreState.user = null
|
||||||
})
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
globalThis.FileReader = originalFileReader
|
||||||
|
globalThis.Image = originalImage
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
it('saves a remote avatar URL and updates the auth store', async () => {
|
it('saves a remote avatar URL and updates the auth store', async () => {
|
||||||
const updatedUser = createUser({ avatar_url: 'https://cdn.example.com/new.png' })
|
const updatedUser = createUser({ avatar_url: 'https://cdn.example.com/new.png' })
|
||||||
updateProfileMock.mockResolvedValue(updatedUser)
|
updateProfileMock.mockResolvedValue(updatedUser)
|
||||||
@@ -148,6 +215,39 @@ describe('ProfileInfoCard', () => {
|
|||||||
expect(showErrorMock).toHaveBeenCalledWith('Avatar image must be 100KB or smaller')
|
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 () => {
|
it('deletes the current avatar', async () => {
|
||||||
const updatedUser = createUser({ avatar_url: null })
|
const updatedUser = createUser({ avatar_url: null })
|
||||||
updateProfileMock.mockResolvedValue(updatedUser)
|
updateProfileMock.mockResolvedValue(updatedUser)
|
||||||
|
|||||||
@@ -943,15 +943,19 @@ export default {
|
|||||||
},
|
},
|
||||||
avatar: {
|
avatar: {
|
||||||
title: 'Profile 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',
|
inputLabel: 'Avatar URL or data URL',
|
||||||
inputPlaceholder: 'https://cdn.example.com/avatar.png',
|
inputPlaceholder: 'https://cdn.example.com/avatar.png',
|
||||||
uploadAction: 'Upload image',
|
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',
|
saveSuccess: 'Avatar updated',
|
||||||
deleteSuccess: 'Avatar removed',
|
deleteSuccess: 'Avatar removed',
|
||||||
invalidType: 'Please choose an image file',
|
invalidType: 'Please choose an image file',
|
||||||
fileTooLarge: 'Avatar image must be 100KB or smaller',
|
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',
|
invalidValue: 'Enter a valid avatar URL or image data URL',
|
||||||
emptyDeleteHint: 'Avatar is already empty',
|
emptyDeleteHint: 'Avatar is already empty',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -947,15 +947,19 @@ export default {
|
|||||||
},
|
},
|
||||||
avatar: {
|
avatar: {
|
||||||
title: '资料头像',
|
title: '资料头像',
|
||||||
description: '支持填写远程图片 URL,或上传不超过 100KB 的头像图片。',
|
description: '支持填写远程图片 URL,或上传不超过 100KB 的头像图片;上传图片会自动压缩到 20KB 以内。',
|
||||||
inputLabel: '头像 URL 或 data URL',
|
inputLabel: '头像 URL 或 data URL',
|
||||||
inputPlaceholder: 'https://cdn.example.com/avatar.png',
|
inputPlaceholder: 'https://cdn.example.com/avatar.png',
|
||||||
uploadAction: '上传图片',
|
uploadAction: '上传图片',
|
||||||
uploadHint: '图片大小需不超过 100KB',
|
uploadHint: '上传图片需不超过 100KB,静态图片会自动压缩到 20KB 以内',
|
||||||
saveSuccess: '头像已更新',
|
saveSuccess: '头像已更新',
|
||||||
deleteSuccess: '头像已删除',
|
deleteSuccess: '头像已删除',
|
||||||
invalidType: '请选择图片文件',
|
invalidType: '请选择图片文件',
|
||||||
fileTooLarge: '头像图片必须不超过 100KB',
|
fileTooLarge: '头像图片必须不超过 100KB',
|
||||||
|
gifTooLarge: 'GIF 头像必须在 20KB 以内',
|
||||||
|
compressTooLarge: '无法将图片压缩到 20KB 以内,请换一张更小的图片',
|
||||||
|
compressFailed: '压缩所选图片失败',
|
||||||
|
readFailed: '读取所选图片失败',
|
||||||
invalidValue: '请输入有效的头像 URL 或图片 data URL',
|
invalidValue: '请输入有效的头像 URL 或图片 data URL',
|
||||||
emptyDeleteHint: '当前没有可删除的头像',
|
emptyDeleteHint: '当前没有可删除的头像',
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user