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>  </details> APNG: <details>   </details> WebP (animated) <details>  </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 <me@silverwind.io>
This commit is contained in:
parent
9f1d377b87
commit
82224c54e0
17 changed files with 304 additions and 1505 deletions
|
@ -5,13 +5,14 @@ package avatar
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
|
||||
_ "image/gif" // for processing gif images
|
||||
_ "image/jpeg" // for processing jpeg images
|
||||
_ "image/png" // for processing png images
|
||||
|
||||
"code.gitea.io/gitea/modules/avatar/identicon"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
|
@ -22,8 +23,11 @@ import (
|
|||
_ "golang.org/x/image/webp" // for processing webp images
|
||||
)
|
||||
|
||||
// AvatarSize returns avatar's size
|
||||
const AvatarSize = 290
|
||||
// DefaultAvatarSize is the target CSS pixel size for avatar generation. It is
|
||||
// multiplied by setting.Avatar.RenderedSizeFactor and the resulting size is the
|
||||
// usual size of avatar image saved on server, unless the original file is smaller
|
||||
// than the size after resizing.
|
||||
const DefaultAvatarSize = 256
|
||||
|
||||
// RandomImageSize generates and returns a random avatar image unique to input data
|
||||
// in custom size (height and width).
|
||||
|
@ -39,28 +43,44 @@ func RandomImageSize(size int, data []byte) (image.Image, error) {
|
|||
// RandomImage generates and returns a random avatar image unique to input data
|
||||
// in default size (height and width).
|
||||
func RandomImage(data []byte) (image.Image, error) {
|
||||
return RandomImageSize(AvatarSize, data)
|
||||
return RandomImageSize(DefaultAvatarSize*setting.Avatar.RenderedSizeFactor, data)
|
||||
}
|
||||
|
||||
// Prepare accepts a byte slice as input, validates it contains an image of an
|
||||
// acceptable format, and crops and resizes it appropriately.
|
||||
func Prepare(data []byte) (*image.Image, error) {
|
||||
imgCfg, _, err := image.DecodeConfig(bytes.NewReader(data))
|
||||
// processAvatarImage process the avatar image data, crop and resize it if necessary.
|
||||
// the returned data could be the original image if no processing is needed.
|
||||
func processAvatarImage(data []byte, maxOriginSize int64) ([]byte, error) {
|
||||
imgCfg, imgType, err := image.DecodeConfig(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("DecodeConfig: %w", err)
|
||||
return nil, fmt.Errorf("image.DecodeConfig: %w", err)
|
||||
}
|
||||
|
||||
// for safety, only accept known types explicitly
|
||||
if imgType != "png" && imgType != "jpeg" && imgType != "gif" && imgType != "webp" {
|
||||
return nil, errors.New("unsupported avatar image type")
|
||||
}
|
||||
|
||||
// do not process image which is too large, it would consume too much memory
|
||||
if imgCfg.Width > setting.Avatar.MaxWidth {
|
||||
return nil, fmt.Errorf("Image width is too large: %d > %d", imgCfg.Width, setting.Avatar.MaxWidth)
|
||||
return nil, fmt.Errorf("image width is too large: %d > %d", imgCfg.Width, setting.Avatar.MaxWidth)
|
||||
}
|
||||
if imgCfg.Height > setting.Avatar.MaxHeight {
|
||||
return nil, fmt.Errorf("Image height is too large: %d > %d", imgCfg.Height, setting.Avatar.MaxHeight)
|
||||
return nil, fmt.Errorf("image height is too large: %d > %d", imgCfg.Height, setting.Avatar.MaxHeight)
|
||||
}
|
||||
|
||||
// If the origin is small enough, just use it, then APNG could be supported,
|
||||
// otherwise, if the image is processed later, APNG loses animation.
|
||||
// And one more thing, webp is not fully supported, for animated webp, image.DecodeConfig works but Decode fails.
|
||||
// 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.
|
||||
if len(data) < int(maxOriginSize) {
|
||||
return data, nil
|
||||
}
|
||||
|
||||
img, _, err := image.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Decode: %w", err)
|
||||
return nil, fmt.Errorf("image.Decode: %w", err)
|
||||
}
|
||||
|
||||
// try to crop and resize the origin image if necessary
|
||||
if imgCfg.Width != imgCfg.Height {
|
||||
var newSize, ax, ay int
|
||||
if imgCfg.Width > imgCfg.Height {
|
||||
|
@ -74,13 +94,33 @@ func Prepare(data []byte) (*image.Image, error) {
|
|||
img, err = cutter.Crop(img, cutter.Config{
|
||||
Width: newSize,
|
||||
Height: newSize,
|
||||
Anchor: image.Point{ax, ay},
|
||||
Anchor: image.Point{X: ax, Y: ay},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
img = resize.Resize(AvatarSize, AvatarSize, img, resize.Bilinear)
|
||||
return &img, nil
|
||||
targetSize := uint(DefaultAvatarSize * setting.Avatar.RenderedSizeFactor)
|
||||
img = resize.Resize(targetSize, targetSize, img, resize.Bilinear)
|
||||
|
||||
// try to encode the cropped/resized image to png
|
||||
bs := bytes.Buffer{}
|
||||
if err = png.Encode(&bs, img); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resized := bs.Bytes()
|
||||
|
||||
// usually the png compression is not good enough, use the original image (no cropping/resizing) if the origin is smaller
|
||||
if len(data) <= len(resized) {
|
||||
return data, nil
|
||||
}
|
||||
|
||||
return resized, nil
|
||||
}
|
||||
|
||||
// ProcessAvatarImage process the avatar image data, crop and resize it if necessary.
|
||||
// the returned data could be the original image if no processing is needed.
|
||||
func ProcessAvatarImage(data []byte) ([]byte, error) {
|
||||
return processAvatarImage(data, setting.Avatar.MaxOriginSize)
|
||||
}
|
||||
|
|
|
@ -4,6 +4,9 @@
|
|||
package avatar
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/png"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
|
@ -25,49 +28,109 @@ func Test_RandomImage(t *testing.T) {
|
|||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func Test_PrepareWithPNG(t *testing.T) {
|
||||
func Test_ProcessAvatarPNG(t *testing.T) {
|
||||
setting.Avatar.MaxWidth = 4096
|
||||
setting.Avatar.MaxHeight = 4096
|
||||
|
||||
data, err := os.ReadFile("testdata/avatar.png")
|
||||
assert.NoError(t, err)
|
||||
|
||||
imgPtr, err := Prepare(data)
|
||||
_, err = processAvatarImage(data, 262144)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 290, (*imgPtr).Bounds().Max.X)
|
||||
assert.Equal(t, 290, (*imgPtr).Bounds().Max.Y)
|
||||
}
|
||||
|
||||
func Test_PrepareWithJPEG(t *testing.T) {
|
||||
func Test_ProcessAvatarJPEG(t *testing.T) {
|
||||
setting.Avatar.MaxWidth = 4096
|
||||
setting.Avatar.MaxHeight = 4096
|
||||
|
||||
data, err := os.ReadFile("testdata/avatar.jpeg")
|
||||
assert.NoError(t, err)
|
||||
|
||||
imgPtr, err := Prepare(data)
|
||||
_, err = processAvatarImage(data, 262144)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 290, (*imgPtr).Bounds().Max.X)
|
||||
assert.Equal(t, 290, (*imgPtr).Bounds().Max.Y)
|
||||
}
|
||||
|
||||
func Test_PrepareWithInvalidImage(t *testing.T) {
|
||||
func Test_ProcessAvatarInvalidData(t *testing.T) {
|
||||
setting.Avatar.MaxWidth = 5
|
||||
setting.Avatar.MaxHeight = 5
|
||||
|
||||
_, err := Prepare([]byte{})
|
||||
assert.EqualError(t, err, "DecodeConfig: image: unknown format")
|
||||
_, err := processAvatarImage([]byte{}, 12800)
|
||||
assert.EqualError(t, err, "image.DecodeConfig: image: unknown format")
|
||||
}
|
||||
|
||||
func Test_PrepareWithInvalidImageSize(t *testing.T) {
|
||||
func Test_ProcessAvatarInvalidImageSize(t *testing.T) {
|
||||
setting.Avatar.MaxWidth = 5
|
||||
setting.Avatar.MaxHeight = 5
|
||||
|
||||
data, err := os.ReadFile("testdata/avatar.png")
|
||||
assert.NoError(t, err)
|
||||
|
||||
_, err = Prepare(data)
|
||||
assert.EqualError(t, err, "Image width is too large: 10 > 5")
|
||||
_, err = processAvatarImage(data, 12800)
|
||||
assert.EqualError(t, err, "image width is too large: 10 > 5")
|
||||
}
|
||||
|
||||
func Test_ProcessAvatarImage(t *testing.T) {
|
||||
setting.Avatar.MaxWidth = 4096
|
||||
setting.Avatar.MaxHeight = 4096
|
||||
scaledSize := DefaultAvatarSize * setting.Avatar.RenderedSizeFactor
|
||||
|
||||
newImgData := func(size int, optHeight ...int) []byte {
|
||||
width := size
|
||||
height := size
|
||||
if len(optHeight) == 1 {
|
||||
height = optHeight[0]
|
||||
}
|
||||
img := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||
bs := bytes.Buffer{}
|
||||
err := png.Encode(&bs, img)
|
||||
assert.NoError(t, err)
|
||||
return bs.Bytes()
|
||||
}
|
||||
|
||||
// if origin image canvas is too large, crop and resize it
|
||||
origin := newImgData(500, 600)
|
||||
result, err := processAvatarImage(origin, 0)
|
||||
assert.NoError(t, err)
|
||||
assert.NotEqual(t, origin, result)
|
||||
decoded, err := png.Decode(bytes.NewReader(result))
|
||||
assert.NoError(t, err)
|
||||
assert.EqualValues(t, scaledSize, decoded.Bounds().Max.X)
|
||||
assert.EqualValues(t, scaledSize, decoded.Bounds().Max.Y)
|
||||
|
||||
// if origin image is smaller than the default size, use the origin image
|
||||
origin = newImgData(1)
|
||||
result, err = processAvatarImage(origin, 0)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, origin, result)
|
||||
|
||||
// use the origin image if the origin is smaller
|
||||
origin = newImgData(scaledSize + 100)
|
||||
result, err = processAvatarImage(origin, 0)
|
||||
assert.NoError(t, err)
|
||||
assert.Less(t, len(result), len(origin))
|
||||
|
||||
// still use the origin image if the origin doesn't exceed the max-origin-size
|
||||
origin = newImgData(scaledSize + 100)
|
||||
result, err = processAvatarImage(origin, 262144)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, origin, result)
|
||||
|
||||
// allow to use known image format (eg: webp) if it is small enough
|
||||
origin, err = os.ReadFile("testdata/animated.webp")
|
||||
assert.NoError(t, err)
|
||||
result, err = processAvatarImage(origin, 262144)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, origin, result)
|
||||
|
||||
// do not support unknown image formats, eg: SVG may contain embedded JS
|
||||
origin = []byte("<svg></svg>")
|
||||
_, err = processAvatarImage(origin, 262144)
|
||||
assert.ErrorContains(t, err, "image: unknown format")
|
||||
|
||||
// make sure the canvas size limit works
|
||||
setting.Avatar.MaxWidth = 5
|
||||
setting.Avatar.MaxHeight = 5
|
||||
origin = newImgData(10)
|
||||
_, err = processAvatarImage(origin, 262144)
|
||||
assert.ErrorContains(t, err, "image width is too large: 10 > 5")
|
||||
}
|
||||
|
|
BIN
modules/avatar/testdata/animated.webp
vendored
Normal file
BIN
modules/avatar/testdata/animated.webp
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.8 KiB |
|
@ -6,6 +6,7 @@ package repository
|
|||
import (
|
||||
"crypto/md5"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
|
@ -136,13 +137,11 @@ func TestPushCommits_AvatarLink(t *testing.T) {
|
|||
enableGravatar(t)
|
||||
|
||||
assert.Equal(t,
|
||||
"https://secure.gravatar.com/avatar/ab53a2911ddf9b4817ac01ddcd3d975f?d=identicon&s=84",
|
||||
"https://secure.gravatar.com/avatar/ab53a2911ddf9b4817ac01ddcd3d975f?d=identicon&s="+strconv.Itoa(28*setting.Avatar.RenderedSizeFactor),
|
||||
pushCommits.AvatarLink(db.DefaultContext, "user2@example.com"))
|
||||
|
||||
assert.Equal(t,
|
||||
"https://secure.gravatar.com/avatar/"+
|
||||
fmt.Sprintf("%x", md5.Sum([]byte("nonexistent@example.com")))+
|
||||
"?d=identicon&s=84",
|
||||
fmt.Sprintf("https://secure.gravatar.com/avatar/%x?d=identicon&s=%d", md5.Sum([]byte("nonexistent@example.com")), 28*setting.Avatar.RenderedSizeFactor),
|
||||
pushCommits.AvatarLink(db.DefaultContext, "nonexistent@example.com"))
|
||||
}
|
||||
|
||||
|
|
|
@ -3,21 +3,23 @@
|
|||
|
||||
package setting
|
||||
|
||||
// settings
|
||||
// Avatar settings
|
||||
|
||||
var (
|
||||
// Picture settings
|
||||
Avatar = struct {
|
||||
Storage
|
||||
|
||||
MaxWidth int
|
||||
MaxHeight int
|
||||
MaxFileSize int64
|
||||
MaxOriginSize int64
|
||||
RenderedSizeFactor int
|
||||
}{
|
||||
MaxWidth: 4096,
|
||||
MaxHeight: 3072,
|
||||
MaxHeight: 4096,
|
||||
MaxFileSize: 1048576,
|
||||
RenderedSizeFactor: 3,
|
||||
MaxOriginSize: 262144,
|
||||
RenderedSizeFactor: 2,
|
||||
}
|
||||
|
||||
GravatarSource string
|
||||
|
@ -44,9 +46,10 @@ func loadPictureFrom(rootCfg ConfigProvider) {
|
|||
Avatar.Storage = getStorage(rootCfg, "avatars", storageType, avatarSec)
|
||||
|
||||
Avatar.MaxWidth = sec.Key("AVATAR_MAX_WIDTH").MustInt(4096)
|
||||
Avatar.MaxHeight = sec.Key("AVATAR_MAX_HEIGHT").MustInt(3072)
|
||||
Avatar.MaxHeight = sec.Key("AVATAR_MAX_HEIGHT").MustInt(4096)
|
||||
Avatar.MaxFileSize = sec.Key("AVATAR_MAX_FILE_SIZE").MustInt64(1048576)
|
||||
Avatar.RenderedSizeFactor = sec.Key("AVATAR_RENDERED_SIZE_FACTOR").MustInt(3)
|
||||
Avatar.MaxOriginSize = sec.Key("AVATAR_MAX_ORIGIN_SIZE").MustInt64(262144)
|
||||
Avatar.RenderedSizeFactor = sec.Key("AVATAR_RENDERED_SIZE_FACTOR").MustInt(2)
|
||||
|
||||
switch source := sec.Key("GRAVATAR_SOURCE").MustString("gravatar"); source {
|
||||
case "duoshuo":
|
||||
|
@ -94,5 +97,5 @@ func loadRepoAvatarFrom(rootCfg ConfigProvider) {
|
|||
RepoAvatar.Storage = getStorage(rootCfg, "repo-avatars", storageType, repoAvatarSec)
|
||||
|
||||
RepoAvatar.Fallback = sec.Key("REPOSITORY_AVATAR_FALLBACK").MustString("none")
|
||||
RepoAvatar.FallbackImage = sec.Key("REPOSITORY_AVATAR_FALLBACK_IMAGE").MustString("/assets/img/repo_default.png")
|
||||
RepoAvatar.FallbackImage = sec.Key("REPOSITORY_AVATAR_FALLBACK_IMAGE").MustString(AppSubURL + "/assets/img/repo_default.png")
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue