gitforge/tests/integration/user_avatar_test.go
Antonin Delpeuch 0a57672544 feat: serve downsized versions of avatars (#11242)
Fixes #2325.

This introduces a way to download downsized versions of the user and repository avatars:
* `/avatars/123abcd` still serves the full-size avatar
* `/avatars/123abcd?size=64` serves it at size 64x64 px

Those downsized versions are computed on demand when requested for the first time and cached. The caching is done in a storage location configurable in the instance settings, just like the storage locations for the full-sized avatars are. The sizes of the downsized images are restricted to a fixed set of sizes, so that the cache doesn't grow too big. The caching and resizing logic is exposed in a way that could potentially be reused for other types of images (such as user uploads in issue discussions).

Luckily, the Go templates already specify in many places which size those avatars should be rendered, even if this information was only used for external avatar providers (such as Gravatar) until now.

The range of sizes requested by the HTML templates is rather wide: the table below lists all the sizes I could find, and the corresponding size served by the backend with the logic I implemented. The scaling factor of 2 was already used for requesting resized external avatars, and likely exists to make sure that users with display scaling enabled get a sharper picture.

| Size requested in the template | After scaling (x2)  | Size of the image served |
|---------|---------|---------|
| 256 px |  512 px | original (512 px) |
| 140 px | 280 px | original (512 px) |
| 48 px | 96 px | 128 px |
| 40 px | 80 px | 128 px |
| 32 px | 64 px | 64 px |
| 28 px | 56 px | 64 px |
| 24 px | 48 px | 64 px |
| 20 px | 40 px | 64 px |

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/11242
Reviewed-by: Gusted <gusted@noreply.codeberg.org>
2026-05-16 12:04:05 +02:00

155 lines
4.8 KiB
Go

// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"bytes"
"fmt"
"image"
"image/png"
"io"
"mime/multipart"
"net/http"
"net/url"
"testing"
"forgejo.org/models/db"
"forgejo.org/models/unittest"
user_model "forgejo.org/models/user"
"forgejo.org/modules/avatar"
"forgejo.org/modules/setting"
"forgejo.org/modules/test"
"forgejo.org/tests"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestUserAvatar(t *testing.T) {
defer tests.PrepareTestEnv(t)()
defer test.MockVariableValue(&setting.Avatar.Storage.Type, setting.LocalStorageType)()
// make the maximum uncached image size small, so that our test image is bigger than that
defer test.MockVariableValue(&setting.Avatar.MaxOriginSize, 3)()
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) // owner of the repo3, is an org
seed := user2.Email
if len(seed) == 0 {
seed = user2.Name
}
img, err := avatar.RandomImage([]byte(seed))
if err != nil {
require.NoError(t, err)
return
}
session := loginUser(t, "user2")
imgData := &bytes.Buffer{}
body := &bytes.Buffer{}
// Setup multi-part
writer := multipart.NewWriter(body)
writer.WriteField("source", "local")
part, err := writer.CreateFormFile("avatar", "avatar-for-testuseravatar.png")
if err != nil {
require.NoError(t, err)
return
}
if err := png.Encode(imgData, img); err != nil {
require.NoError(t, err)
return
}
if _, err := io.Copy(part, imgData); err != nil {
require.NoError(t, err)
return
}
if err := writer.Close(); err != nil {
require.NoError(t, err)
return
}
req := NewRequestWithBody(t, "POST", "/user/settings/avatar", body)
req.Header.Add("Content-Type", writer.FormDataContentType())
session.MakeRequest(t, req, http.StatusSeeOther)
user2 = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) // owner of the repo3, is an org
req = NewRequest(t, "GET", user2.AvatarLinkWithSize(db.DefaultContext, 0))
_ = session.MakeRequest(t, req, http.StatusOK)
req = NewRequestf(t, "GET", "/%s.png", user2.Name)
resp := MakeRequest(t, req, http.StatusSeeOther)
assert.Equal(t, fmt.Sprintf("/avatars/%s", user2.Avatar), resp.Header().Get("location"))
req = NewRequest(t, "GET", resp.Header().Get("location"))
resp = MakeRequest(t, req, http.StatusOK)
// check that it's a valid image with the expected dimensions
respImg, _, err := image.Decode(resp.Body)
require.NoError(t, err)
assert.Equal(t, 512, respImg.Bounds().Dx())
assert.Equal(t, 512, respImg.Bounds().Dy())
// request an avatar that doesn't exist
req = NewRequest(t, "GET", "/avatars/not_found")
MakeRequest(t, req, http.StatusNotFound)
// request an avatar that exists, but with an invalid size
req = NewRequest(t, "GET", user2.AvatarLinkWithSize(db.DefaultContext, 0)+"?size=123456")
MakeRequest(t, req, http.StatusNotFound)
// request an avatar with a correct size
req = NewRequest(t, "GET", user2.AvatarLinkWithSize(db.DefaultContext, 64))
resp = MakeRequest(t, req, http.StatusOK)
respImg, _, err = image.Decode(resp.Body)
require.NoError(t, err)
assert.Equal(t, 64, respImg.Bounds().Dx())
assert.Equal(t, 64, respImg.Bounds().Dy())
// request a resized avatar using its internal storage path: not found
req = NewRequest(t, "GET", fmt.Sprintf("/avatars/resized/64/%s", user2.Avatar))
MakeRequest(t, req, http.StatusNotFound)
}
func TestAvatarAnchorDestination(t *testing.T) {
defer tests.PrepareTestEnv(t)()
// If the user is logged in, and looking at their own profile,
// the avatar becomes a link towards the user settings page.
// Test that the link does not show up when not viewing one's own profile,
// and that, if the link does show up, there is a corresponding element
// on the user settings page matching the fragment of the anchor.
t.Run("viewing other's profile", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
profilePage := NewHTMLParser(t, MakeRequest(t, NewRequest(t, "GET", "/user2"), http.StatusOK).Body)
profilePage.AssertElement(t, "#profile-avatar", true)
// When viewing another user's profile, there shouldn't be a link to user settings
profilePage.AssertElement(t, "#profile-avatar a", false)
})
t.Run("viewing own profile", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
session := loginUser(t, "user2")
profilePage := NewHTMLParser(t, session.MakeRequest(t, NewRequest(t, "GET", "/user2"), http.StatusOK).Body)
profilePage.AssertElement(t, "#profile-avatar a", true)
href, has := profilePage.Find("#profile-avatar a").Attr("href")
assert.True(t, has)
settingsURL, err := url.Parse(href)
require.NoError(t, err, "Change avatar link can't be parsed to URL")
settingsPage := NewHTMLParser(t, session.MakeRequest(t, NewRequest(t, "GET", href), http.StatusOK).Body)
settingsPage.AssertElement(t, fmt.Sprintf("#%s", settingsURL.Fragment), true)
})
}