Skip to content

Commit 82224c5

Browse files
Improve avatar uploading / resizing / compressing, remove Fomantic card module (#24653)
Fixes: #8972 Fixes: #24263 And I think it also (partially) fix #24263 (no need to convert) , because users could upload any supported image format if it isn't larger than AVATAR_MAX_ORIGIN_SIZE The main idea: * if the uploaded file size is not larger than AVATAR_MAX_ORIGIN_SIZE, use the origin * if the resized size is larger than the origin, use the origin Screenshots: JPG: <details> ![image](https://github.com/go-gitea/gitea/assets/2114189/70e98bb0-ecb9-4c4e-a89f-4a37d4e37f8e) </details> APNG: <details> ![image](https://github.com/go-gitea/gitea/assets/2114189/9055135b-5e2d-4152-bd72-596fcb7c6671) ![image](https://github.com/go-gitea/gitea/assets/2114189/50364caf-f7f6-4241-a289-e485fe4cd582) </details> WebP (animated) <details> ![image](https://github.com/go-gitea/gitea/assets/2114189/f642eb85-498a-49a5-86bf-0a7b04089ae0) </details> The only exception: if a WebP image is larger than MaxOriginSize and it is animated, then current `webp` package can't decode it, so only in this case it isn't supported. IMO no need to support such case: why a user would upload a 1MB animated webp as avatar? crazy ..... --------- Co-authored-by: silverwind <[email protected]>
1 parent 9f1d377 commit 82224c5

File tree

17 files changed

+304
-1505
lines changed

17 files changed

+304
-1505
lines changed

custom/conf/app.example.ini

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1773,16 +1773,19 @@ ROUTER = console
17731773
;; Max Width and Height of uploaded avatars.
17741774
;; This is to limit the amount of RAM used when resizing the image.
17751775
;AVATAR_MAX_WIDTH = 4096
1776-
;AVATAR_MAX_HEIGHT = 3072
1776+
;AVATAR_MAX_HEIGHT = 4096
17771777
;;
17781778
;; The multiplication factor for rendered avatar images.
17791779
;; Larger values result in finer rendering on HiDPI devices.
1780-
;AVATAR_RENDERED_SIZE_FACTOR = 3
1780+
;AVATAR_RENDERED_SIZE_FACTOR = 2
17811781
;;
17821782
;; Maximum allowed file size for uploaded avatars.
17831783
;; This is to limit the amount of RAM used when resizing the image.
17841784
;AVATAR_MAX_FILE_SIZE = 1048576
17851785
;;
1786+
;; If the uploaded file is not larger than this byte size, the image will be used as is, without resizing/converting.
1787+
;AVATAR_MAX_ORIGIN_SIZE = 262144
1788+
;;
17861789
;; Chinese users can choose "duoshuo"
17871790
;; or a custom avatar source, like: http://cn.gravatar.com/avatar/
17881791
;GRAVATAR_SOURCE = gravatar

docs/content/doc/administration/config-cheat-sheet.en-us.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -792,9 +792,10 @@ and
792792
- `AVATAR_STORAGE_TYPE`: **default**: Storage type defined in `[storage.xxx]`. Default is `default` which will read `[storage]` if no section `[storage]` will be a type `local`.
793793
- `AVATAR_UPLOAD_PATH`: **data/avatars**: Path to store user avatar image files.
794794
- `AVATAR_MAX_WIDTH`: **4096**: Maximum avatar image width in pixels.
795-
- `AVATAR_MAX_HEIGHT`: **3072**: Maximum avatar image height in pixels.
796-
- `AVATAR_MAX_FILE_SIZE`: **1048576** (1Mb): Maximum avatar image file size in bytes.
797-
- `AVATAR_RENDERED_SIZE_FACTOR`: **3**: The multiplication factor for rendered avatar images. Larger values result in finer rendering on HiDPI devices.
795+
- `AVATAR_MAX_HEIGHT`: **4096**: Maximum avatar image height in pixels.
796+
- `AVATAR_MAX_FILE_SIZE`: **1048576** (1MiB): Maximum avatar image file size in bytes.
797+
- `AVATAR_MAX_ORIGIN_SIZE`: **262144** (256KiB): If the uploaded file is not larger than this byte size, the image will be used as is, without resizing/converting.
798+
- `AVATAR_RENDERED_SIZE_FACTOR`: **2**: The multiplication factor for rendered avatar images. Larger values result in finer rendering on HiDPI devices.
798799

799800
- `REPOSITORY_AVATAR_STORAGE_TYPE`: **default**: Storage type defined in `[storage.xxx]`. Default is `default` which will read `[storage]` if no section `[storage]` will be a type `local`.
800801
- `REPOSITORY_AVATAR_UPLOAD_PATH`: **data/repo-avatars**: Path to store repository avatar image files.

docs/content/doc/administration/config-cheat-sheet.zh-cn.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -214,8 +214,8 @@ menu:
214214
- `AVATAR_STORAGE_TYPE`: **local**: 头像存储类型,可以为 `local``minio`,分别支持本地文件系统和 minio 兼容的API。
215215
- `AVATAR_UPLOAD_PATH`: **data/avatars**: 存储头像的文件系统路径。
216216
- `AVATAR_MAX_WIDTH`: **4096**: 头像最大宽度,单位像素。
217-
- `AVATAR_MAX_HEIGHT`: **3072**: 头像最大高度,单位像素。
218-
- `AVATAR_MAX_FILE_SIZE`: **1048576** (1Mb): 头像最大大小。
217+
- `AVATAR_MAX_HEIGHT`: **4096**: 头像最大高度,单位像素。
218+
- `AVATAR_MAX_FILE_SIZE`: **1048576** (1MiB): 头像最大大小。
219219

220220
- `REPOSITORY_AVATAR_STORAGE_TYPE`: **local**: 仓库头像存储类型,可以为 `local``minio`,分别支持本地文件系统和 minio 兼容的API。
221221
- `REPOSITORY_AVATAR_UPLOAD_PATH`: **data/repo-avatars**: 存储仓库头像的路径。

modules/avatar/avatar.go

Lines changed: 55 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,14 @@ package avatar
55

66
import (
77
"bytes"
8+
"errors"
89
"fmt"
910
"image"
1011
"image/color"
12+
"image/png"
1113

1214
_ "image/gif" // for processing gif images
1315
_ "image/jpeg" // for processing jpeg images
14-
_ "image/png" // for processing png images
1516

1617
"code.gitea.io/gitea/modules/avatar/identicon"
1718
"code.gitea.io/gitea/modules/setting"
@@ -22,8 +23,11 @@ import (
2223
_ "golang.org/x/image/webp" // for processing webp images
2324
)
2425

25-
// AvatarSize returns avatar's size
26-
const AvatarSize = 290
26+
// DefaultAvatarSize is the target CSS pixel size for avatar generation. It is
27+
// multiplied by setting.Avatar.RenderedSizeFactor and the resulting size is the
28+
// usual size of avatar image saved on server, unless the original file is smaller
29+
// than the size after resizing.
30+
const DefaultAvatarSize = 256
2731

2832
// RandomImageSize generates and returns a random avatar image unique to input data
2933
// in custom size (height and width).
@@ -39,28 +43,44 @@ func RandomImageSize(size int, data []byte) (image.Image, error) {
3943
// RandomImage generates and returns a random avatar image unique to input data
4044
// in default size (height and width).
4145
func RandomImage(data []byte) (image.Image, error) {
42-
return RandomImageSize(AvatarSize, data)
46+
return RandomImageSize(DefaultAvatarSize*setting.Avatar.RenderedSizeFactor, data)
4347
}
4448

45-
// Prepare accepts a byte slice as input, validates it contains an image of an
46-
// acceptable format, and crops and resizes it appropriately.
47-
func Prepare(data []byte) (*image.Image, error) {
48-
imgCfg, _, err := image.DecodeConfig(bytes.NewReader(data))
49+
// processAvatarImage process the avatar image data, crop and resize it if necessary.
50+
// the returned data could be the original image if no processing is needed.
51+
func processAvatarImage(data []byte, maxOriginSize int64) ([]byte, error) {
52+
imgCfg, imgType, err := image.DecodeConfig(bytes.NewReader(data))
4953
if err != nil {
50-
return nil, fmt.Errorf("DecodeConfig: %w", err)
54+
return nil, fmt.Errorf("image.DecodeConfig: %w", err)
5155
}
56+
57+
// for safety, only accept known types explicitly
58+
if imgType != "png" && imgType != "jpeg" && imgType != "gif" && imgType != "webp" {
59+
return nil, errors.New("unsupported avatar image type")
60+
}
61+
62+
// do not process image which is too large, it would consume too much memory
5263
if imgCfg.Width > setting.Avatar.MaxWidth {
53-
return nil, fmt.Errorf("Image width is too large: %d > %d", imgCfg.Width, setting.Avatar.MaxWidth)
64+
return nil, fmt.Errorf("image width is too large: %d > %d", imgCfg.Width, setting.Avatar.MaxWidth)
5465
}
5566
if imgCfg.Height > setting.Avatar.MaxHeight {
56-
return nil, fmt.Errorf("Image height is too large: %d > %d", imgCfg.Height, setting.Avatar.MaxHeight)
67+
return nil, fmt.Errorf("image height is too large: %d > %d", imgCfg.Height, setting.Avatar.MaxHeight)
68+
}
69+
70+
// If the origin is small enough, just use it, then APNG could be supported,
71+
// otherwise, if the image is processed later, APNG loses animation.
72+
// And one more thing, webp is not fully supported, for animated webp, image.DecodeConfig works but Decode fails.
73+
// So for animated webp, if the uploaded file is smaller than maxOriginSize, it will be used, if it's larger, there will be an error.
74+
if len(data) < int(maxOriginSize) {
75+
return data, nil
5776
}
5877

5978
img, _, err := image.Decode(bytes.NewReader(data))
6079
if err != nil {
61-
return nil, fmt.Errorf("Decode: %w", err)
80+
return nil, fmt.Errorf("image.Decode: %w", err)
6281
}
6382

83+
// try to crop and resize the origin image if necessary
6484
if imgCfg.Width != imgCfg.Height {
6585
var newSize, ax, ay int
6686
if imgCfg.Width > imgCfg.Height {
@@ -74,13 +94,33 @@ func Prepare(data []byte) (*image.Image, error) {
7494
img, err = cutter.Crop(img, cutter.Config{
7595
Width: newSize,
7696
Height: newSize,
77-
Anchor: image.Point{ax, ay},
97+
Anchor: image.Point{X: ax, Y: ay},
7898
})
7999
if err != nil {
80100
return nil, err
81101
}
82102
}
83103

84-
img = resize.Resize(AvatarSize, AvatarSize, img, resize.Bilinear)
85-
return &img, nil
104+
targetSize := uint(DefaultAvatarSize * setting.Avatar.RenderedSizeFactor)
105+
img = resize.Resize(targetSize, targetSize, img, resize.Bilinear)
106+
107+
// try to encode the cropped/resized image to png
108+
bs := bytes.Buffer{}
109+
if err = png.Encode(&bs, img); err != nil {
110+
return nil, err
111+
}
112+
resized := bs.Bytes()
113+
114+
// usually the png compression is not good enough, use the original image (no cropping/resizing) if the origin is smaller
115+
if len(data) <= len(resized) {
116+
return data, nil
117+
}
118+
119+
return resized, nil
120+
}
121+
122+
// ProcessAvatarImage process the avatar image data, crop and resize it if necessary.
123+
// the returned data could be the original image if no processing is needed.
124+
func ProcessAvatarImage(data []byte) ([]byte, error) {
125+
return processAvatarImage(data, setting.Avatar.MaxOriginSize)
86126
}

modules/avatar/avatar_test.go

Lines changed: 79 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
package avatar
55

66
import (
7+
"bytes"
8+
"image"
9+
"image/png"
710
"os"
811
"testing"
912

@@ -25,49 +28,109 @@ func Test_RandomImage(t *testing.T) {
2528
assert.NoError(t, err)
2629
}
2730

28-
func Test_PrepareWithPNG(t *testing.T) {
31+
func Test_ProcessAvatarPNG(t *testing.T) {
2932
setting.Avatar.MaxWidth = 4096
3033
setting.Avatar.MaxHeight = 4096
3134

3235
data, err := os.ReadFile("testdata/avatar.png")
3336
assert.NoError(t, err)
3437

35-
imgPtr, err := Prepare(data)
38+
_, err = processAvatarImage(data, 262144)
3639
assert.NoError(t, err)
37-
38-
assert.Equal(t, 290, (*imgPtr).Bounds().Max.X)
39-
assert.Equal(t, 290, (*imgPtr).Bounds().Max.Y)
4040
}
4141

42-
func Test_PrepareWithJPEG(t *testing.T) {
42+
func Test_ProcessAvatarJPEG(t *testing.T) {
4343
setting.Avatar.MaxWidth = 4096
4444
setting.Avatar.MaxHeight = 4096
4545

4646
data, err := os.ReadFile("testdata/avatar.jpeg")
4747
assert.NoError(t, err)
4848

49-
imgPtr, err := Prepare(data)
49+
_, err = processAvatarImage(data, 262144)
5050
assert.NoError(t, err)
51-
52-
assert.Equal(t, 290, (*imgPtr).Bounds().Max.X)
53-
assert.Equal(t, 290, (*imgPtr).Bounds().Max.Y)
5451
}
5552

56-
func Test_PrepareWithInvalidImage(t *testing.T) {
53+
func Test_ProcessAvatarInvalidData(t *testing.T) {
5754
setting.Avatar.MaxWidth = 5
5855
setting.Avatar.MaxHeight = 5
5956

60-
_, err := Prepare([]byte{})
61-
assert.EqualError(t, err, "DecodeConfig: image: unknown format")
57+
_, err := processAvatarImage([]byte{}, 12800)
58+
assert.EqualError(t, err, "image.DecodeConfig: image: unknown format")
6259
}
6360

64-
func Test_PrepareWithInvalidImageSize(t *testing.T) {
61+
func Test_ProcessAvatarInvalidImageSize(t *testing.T) {
6562
setting.Avatar.MaxWidth = 5
6663
setting.Avatar.MaxHeight = 5
6764

6865
data, err := os.ReadFile("testdata/avatar.png")
6966
assert.NoError(t, err)
7067

71-
_, err = Prepare(data)
72-
assert.EqualError(t, err, "Image width is too large: 10 > 5")
68+
_, err = processAvatarImage(data, 12800)
69+
assert.EqualError(t, err, "image width is too large: 10 > 5")
70+
}
71+
72+
func Test_ProcessAvatarImage(t *testing.T) {
73+
setting.Avatar.MaxWidth = 4096
74+
setting.Avatar.MaxHeight = 4096
75+
scaledSize := DefaultAvatarSize * setting.Avatar.RenderedSizeFactor
76+
77+
newImgData := func(size int, optHeight ...int) []byte {
78+
width := size
79+
height := size
80+
if len(optHeight) == 1 {
81+
height = optHeight[0]
82+
}
83+
img := image.NewRGBA(image.Rect(0, 0, width, height))
84+
bs := bytes.Buffer{}
85+
err := png.Encode(&bs, img)
86+
assert.NoError(t, err)
87+
return bs.Bytes()
88+
}
89+
90+
// if origin image canvas is too large, crop and resize it
91+
origin := newImgData(500, 600)
92+
result, err := processAvatarImage(origin, 0)
93+
assert.NoError(t, err)
94+
assert.NotEqual(t, origin, result)
95+
decoded, err := png.Decode(bytes.NewReader(result))
96+
assert.NoError(t, err)
97+
assert.EqualValues(t, scaledSize, decoded.Bounds().Max.X)
98+
assert.EqualValues(t, scaledSize, decoded.Bounds().Max.Y)
99+
100+
// if origin image is smaller than the default size, use the origin image
101+
origin = newImgData(1)
102+
result, err = processAvatarImage(origin, 0)
103+
assert.NoError(t, err)
104+
assert.Equal(t, origin, result)
105+
106+
// use the origin image if the origin is smaller
107+
origin = newImgData(scaledSize + 100)
108+
result, err = processAvatarImage(origin, 0)
109+
assert.NoError(t, err)
110+
assert.Less(t, len(result), len(origin))
111+
112+
// still use the origin image if the origin doesn't exceed the max-origin-size
113+
origin = newImgData(scaledSize + 100)
114+
result, err = processAvatarImage(origin, 262144)
115+
assert.NoError(t, err)
116+
assert.Equal(t, origin, result)
117+
118+
// allow to use known image format (eg: webp) if it is small enough
119+
origin, err = os.ReadFile("testdata/animated.webp")
120+
assert.NoError(t, err)
121+
result, err = processAvatarImage(origin, 262144)
122+
assert.NoError(t, err)
123+
assert.Equal(t, origin, result)
124+
125+
// do not support unknown image formats, eg: SVG may contain embedded JS
126+
origin = []byte("<svg></svg>")
127+
_, err = processAvatarImage(origin, 262144)
128+
assert.ErrorContains(t, err, "image: unknown format")
129+
130+
// make sure the canvas size limit works
131+
setting.Avatar.MaxWidth = 5
132+
setting.Avatar.MaxHeight = 5
133+
origin = newImgData(10)
134+
_, err = processAvatarImage(origin, 262144)
135+
assert.ErrorContains(t, err, "image width is too large: 10 > 5")
73136
}

modules/avatar/testdata/animated.webp

4.82 KB
Binary file not shown.

modules/repository/commits_test.go

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ package repository
66
import (
77
"crypto/md5"
88
"fmt"
9+
"strconv"
910
"testing"
1011
"time"
1112

@@ -136,13 +137,11 @@ func TestPushCommits_AvatarLink(t *testing.T) {
136137
enableGravatar(t)
137138

138139
assert.Equal(t,
139-
"https://secure.gravatar.com/avatar/ab53a2911ddf9b4817ac01ddcd3d975f?d=identicon&s=84",
140+
"https://secure.gravatar.com/avatar/ab53a2911ddf9b4817ac01ddcd3d975f?d=identicon&s="+strconv.Itoa(28*setting.Avatar.RenderedSizeFactor),
140141
pushCommits.AvatarLink(db.DefaultContext, "[email protected]"))
141142

142143
assert.Equal(t,
143-
"https://secure.gravatar.com/avatar/"+
144-
fmt.Sprintf("%x", md5.Sum([]byte("[email protected]")))+
145-
"?d=identicon&s=84",
144+
fmt.Sprintf("https://secure.gravatar.com/avatar/%x?d=identicon&s=%d", md5.Sum([]byte("[email protected]")), 28*setting.Avatar.RenderedSizeFactor),
146145
pushCommits.AvatarLink(db.DefaultContext, "[email protected]"))
147146
}
148147

modules/setting/picture.go

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,23 @@
33

44
package setting
55

6-
// settings
6+
// Avatar settings
7+
78
var (
8-
// Picture settings
99
Avatar = struct {
1010
Storage
1111

1212
MaxWidth int
1313
MaxHeight int
1414
MaxFileSize int64
15+
MaxOriginSize int64
1516
RenderedSizeFactor int
1617
}{
1718
MaxWidth: 4096,
18-
MaxHeight: 3072,
19+
MaxHeight: 4096,
1920
MaxFileSize: 1048576,
20-
RenderedSizeFactor: 3,
21+
MaxOriginSize: 262144,
22+
RenderedSizeFactor: 2,
2123
}
2224

2325
GravatarSource string
@@ -44,9 +46,10 @@ func loadPictureFrom(rootCfg ConfigProvider) {
4446
Avatar.Storage = getStorage(rootCfg, "avatars", storageType, avatarSec)
4547

4648
Avatar.MaxWidth = sec.Key("AVATAR_MAX_WIDTH").MustInt(4096)
47-
Avatar.MaxHeight = sec.Key("AVATAR_MAX_HEIGHT").MustInt(3072)
49+
Avatar.MaxHeight = sec.Key("AVATAR_MAX_HEIGHT").MustInt(4096)
4850
Avatar.MaxFileSize = sec.Key("AVATAR_MAX_FILE_SIZE").MustInt64(1048576)
49-
Avatar.RenderedSizeFactor = sec.Key("AVATAR_RENDERED_SIZE_FACTOR").MustInt(3)
51+
Avatar.MaxOriginSize = sec.Key("AVATAR_MAX_ORIGIN_SIZE").MustInt64(262144)
52+
Avatar.RenderedSizeFactor = sec.Key("AVATAR_RENDERED_SIZE_FACTOR").MustInt(2)
5053

5154
switch source := sec.Key("GRAVATAR_SOURCE").MustString("gravatar"); source {
5255
case "duoshuo":
@@ -94,5 +97,5 @@ func loadRepoAvatarFrom(rootCfg ConfigProvider) {
9497
RepoAvatar.Storage = getStorage(rootCfg, "repo-avatars", storageType, repoAvatarSec)
9598

9699
RepoAvatar.Fallback = sec.Key("REPOSITORY_AVATAR_FALLBACK").MustString("none")
97-
RepoAvatar.FallbackImage = sec.Key("REPOSITORY_AVATAR_FALLBACK_IMAGE").MustString("/assets/img/repo_default.png")
100+
RepoAvatar.FallbackImage = sec.Key("REPOSITORY_AVATAR_FALLBACK_IMAGE").MustString(AppSubURL + "/assets/img/repo_default.png")
98101
}

0 commit comments

Comments
 (0)