Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
6da08262
"frontend/src/components/vscode:/vscode.git/clone" did not exist on "906802abe3ac8604aac2c0e199a3a5797f94f669"
Commit
6da08262
authored
Apr 21, 2026
by
IanShaw027
Browse files
feat avatar compress uploads to 20kb
parent
07f23aaa
Changes
8
Show whitespace changes
Inline
Side-by-side
backend/go.mod
View file @
6da08262
...
@@ -39,10 +39,11 @@ require (
...
@@ -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 (
...
@@ -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 (
...
@@ -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.3
2
.0 // indirect
golang.org/x/mod v0.3
4
.0 // indirect
golang.org/x/sys v0.4
1
.0 // indirect
golang.org/x/sys v0.4
2
.0 // indirect
golang.org/x/text v0.3
4
.0 // indirect
golang.org/x/text v0.3
6
.0 // indirect
golang.org/x/tools v0.4
1
.0 // indirect
golang.org/x/tools v0.4
3
.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
...
...
backend/go.sum
View file @
6da08262
...
@@ -162,8 +162,6 @@ github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17
...
@@ -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=
...
@@ -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
...
@@ -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=
...
@@ -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
...
@@ -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=
...
@@ -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=
...
@@ -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.4
8
.0 h1:
/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts
=
golang.org/x/crypto v0.4
9
.0 h1:
+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4
=
golang.org/x/crypto v0.4
8
.0/go.mod h1:
r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos
=
golang.org/x/crypto v0.4
9
.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
...
@@ -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.4
1
.0 h1:
Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k
=
golang.org/x/sys v0.4
2
.0 h1:
omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo
=
golang.org/x/sys v0.4
1
.0/go.mod h1:
OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks
=
golang.org/x/sys v0.4
2
.0/go.mod h1:
4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw
=
golang.org/x/term v0.4
0
.0 h1:
36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg
=
golang.org/x/term v0.4
1
.0 h1:
QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU
=
golang.org/x/term v0.4
0
.0/go.mod h1:
w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM
=
golang.org/x/term v0.4
1
.0/go.mod h1:
3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A
=
golang.org/x/text v0.3
4
.0 h1:
oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk
=
golang.org/x/text v0.3
6
.0 h1:
JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg
=
golang.org/x/text v0.3
4
.0/go.mod h1:
homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA
=
golang.org/x/text v0.3
6
.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.4
1
.0 h1:
a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc
=
golang.org/x/tools v0.4
3
.0 h1:
12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s
=
golang.org/x/tools v0.4
1
.0/go.mod h1:
XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg
=
golang.org/x/tools v0.4
3
.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=
...
...
backend/internal/service/user_service.go
View file @
6da08262
package
service
package
service
import
(
import
(
"bytes"
"context"
"context"
"crypto/sha256"
"crypto/sha256"
"crypto/subtle"
"crypto/subtle"
...
@@ -9,11 +10,19 @@ import (
...
@@ -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 (
...
@@ -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 (
...
@@ -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) {
...
@@ -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) {
...
@@ -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"
,
...
...
backend/internal/service/user_service_test.go
View file @
6da08262
...
@@ -3,11 +3,14 @@
...
@@ -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) {
...
@@ -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
)
...
...
frontend/src/components/user/profile/ProfileInfoCard.vue
View file @
6da08262
...
@@ -176,6 +176,9 @@ const authStore = useAuthStore()
...
@@ -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> {
...
@@ -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) {
...
@@ -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
...
...
frontend/src/components/user/profile/__tests__/ProfileInfoCard.spec.ts
View file @
6da08262
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) => {
...
@@ -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 {
...
@@ -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', () => {
...
@@ -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', () => {
...
@@ -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
)
...
...
frontend/src/i18n/locales/en.ts
View file @
6da08262
...
@@ -943,15 +943,19 @@ export default {
...
@@ -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 a
n 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
:
'
I
mages must be 100KB or smaller
'
,
uploadHint
:
'
Uploaded i
mages 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
'
,
},
},
...
...
frontend/src/i18n/locales/zh.ts
View file @
6da08262
...
@@ -947,15 +947,19 @@ export default {
...
@@ -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
:
'
当前没有可删除的头像
'
,
},
},
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment