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>
This commit is contained in:
Antonin Delpeuch 2026-05-16 12:04:05 +02:00 committed by Gusted
parent d4d2c64d23
commit 0a57672544
21 changed files with 822 additions and 175 deletions

View file

@ -20,6 +20,7 @@ import (
migrate_base "forgejo.org/models/gitea_migrations/base"
repo_model "forgejo.org/models/repo"
user_model "forgejo.org/models/user"
"forgejo.org/modules/avatarstore"
"forgejo.org/modules/container"
"forgejo.org/modules/log"
"forgejo.org/modules/setting"
@ -28,6 +29,7 @@ import (
exif_terminator "code.superseriousbusiness.org/exif-terminator"
"github.com/urfave/cli/v3"
"xorm.io/builder"
)
// CmdDoctor represents the available doctor sub-command.
@ -43,6 +45,7 @@ func cmdDoctor() *cli.Command {
cmdDoctorConvert(),
cmdAvatarStripExif(),
cmdCleanupCommitStatuses(),
cmdResizeAvatars(),
},
}
}
@ -117,6 +120,30 @@ func cmdAvatarStripExif() *cli.Command {
}
}
func cmdResizeAvatars() *cli.Command {
return &cli.Command{
Name: "avatar-resize",
Usage: "Generate resized versions of user or repository avatars",
Description: `Forgejo serves small versions of avatars for inclusion in the web UI.
Those rescaled versions are computed on-demand and cached in the avatar storage.
This command pre-computes rescaled versions of avatars ahead of time.`,
Before: noDanglingArgs,
Action: runAvatarResize,
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "user",
Usage: "Resize the user avatars",
},
&cli.BoolFlag{
Name: "repository",
Usage: "Resize the repository avatars",
},
},
}
}
func cmdCleanupCommitStatuses() *cli.Command {
return &cli.Command{
Name: "cleanup-commit-status",
@ -373,6 +400,79 @@ func runAvatarStripExif(ctx context.Context, c *cli.Command) error {
return nil
}
func precomputeResizedAvatars(imgStorage storage.ObjectStorage, imgPath string, maxOriginSize int64) error {
// Load the avatar
avatarBytes, err := imgStorage.Open(imgPath)
if err != nil {
return err
}
meta, err := avatarBytes.Stat()
if err != nil {
return err
}
// If the avatar is small enough, don't compute resized versions for it.
// This makes it possible to preserve animated avatars when they are small enough.
if meta.Size() < maxOriginSize {
return nil
}
img, _, err := image.Decode(avatarBytes)
if err != nil {
return err
}
return avatarstore.PrecomputeResizedAvatars(imgStorage, img, imgPath)
}
func runAvatarResize(ctx context.Context, c *cli.Command) error {
ctx, cancel := installSignals(ctx)
defer cancel()
if err := initDB(ctx); err != nil {
return err
}
if err := storage.Init(); err != nil {
return err
}
runUser := c.Bool("user")
runRepo := c.Bool("repository")
return RunAvatarResize(ctx, runUser, runRepo)
}
func RunAvatarResize(ctx context.Context, runUser, runRepo bool) error {
if !runUser && !runRepo {
return fmt.Errorf("at least one of --user or --repository should be provided")
}
if runUser {
log.Info("Resizing user avatars")
if err := db.Iterate(
ctx,
builder.Neq{"avatar": ""},
func(ctx context.Context, user *user_model.User) error {
return precomputeResizedAvatars(storage.Avatars, user.Avatar, setting.Avatar.MaxOriginSize)
},
); err != nil {
return err
}
}
if runRepo {
log.Info("Resizing repository avatars")
if err := db.Iterate(
ctx,
builder.Neq{"avatar": ""},
func(ctx context.Context, repo *repo_model.Repository) error {
return precomputeResizedAvatars(storage.RepoAvatars, repo.Avatar, setting.Avatar.MaxOriginSize)
},
); err != nil {
return err
}
}
return nil
}
func runCleanupCommitStatus(ctx context.Context, cli *cli.Command) error {
ctx, cancel := installSignals(ctx)
defer cancel()

View file

@ -15,6 +15,7 @@ import (
"sync/atomic"
"forgejo.org/models/db"
"forgejo.org/modules/avatar"
"forgejo.org/modules/cache"
"forgejo.org/modules/log"
"forgejo.org/modules/setting"
@ -169,6 +170,15 @@ func GenerateUserAvatarImageLink(userAvatar string) string {
return setting.AppSubURL + "/avatars/" + url.PathEscape(userAvatar)
}
// GenerateUserResizedAvatarLink returns the URL to the smallest resized version of the avatar bigger than the supplied size
func GenerateUserResizedAvatarLink(userAvatar string, requestedSize int) string {
cachedSize := avatar.BestAvatarCachedSize(requestedSize)
if cachedSize == 0 {
return GenerateUserAvatarImageLink(userAvatar)
}
return fmt.Sprintf("%s/avatars/%s?size=%d", setting.AppSubURL, url.PathEscape(userAvatar), cachedSize)
}
// generateRecognizedAvatarURL generate a recognized avatar (Gravatar/Libravatar) URL, it modifies the URL so the parameter is passed by a copy
func generateRecognizedAvatarURL(u url.URL, size int) string {
urlQuery := u.Query()

View file

@ -9,6 +9,7 @@ import (
avatars_model "forgejo.org/models/avatars"
"forgejo.org/models/db"
system_model "forgejo.org/models/system"
"forgejo.org/modules/avatar"
"forgejo.org/modules/setting"
"forgejo.org/modules/setting/config"
@ -57,3 +58,10 @@ func TestSizedAvatarLink(t *testing.T) {
avatars_model.GenerateEmailAvatarFastLink(db.DefaultContext, "gitea@example.com", 100),
)
}
func TestBestAvatarCachedSize(t *testing.T) {
assert.Equal(t, 64, avatar.BestAvatarCachedSize(2))
assert.Equal(t, 64, avatar.BestAvatarCachedSize(64))
assert.Equal(t, 128, avatar.BestAvatarCachedSize(65))
assert.Equal(t, 0, avatar.BestAvatarCachedSize(1000))
}

View file

@ -32,7 +32,7 @@ func ExistsWithAvatarAtStoragePath(ctx context.Context, storagePath string) (boo
// RelAvatarLink returns a relative link to the repository's avatar.
func (repo *Repository) RelAvatarLink(ctx context.Context) string {
return repo.relAvatarLink(ctx)
return repo.relAvatarLink(ctx, 0)
}
// generateRandomAvatar generates a random avatar for repository.
@ -65,7 +65,7 @@ func generateRandomAvatar(ctx context.Context, repo *Repository) error {
return nil
}
func (repo *Repository) relAvatarLink(ctx context.Context) string {
func (repo *Repository) relAvatarLink(ctx context.Context, size int) string {
// If no avatar - path is empty
avatarPath := repo.CustomAvatarRelativePath()
if len(avatarPath) == 0 {
@ -81,12 +81,21 @@ func (repo *Repository) relAvatarLink(ctx context.Context) string {
return ""
}
}
return setting.AppSubURL + "/repo-avatars/" + url.PathEscape(repo.Avatar)
cachedSize := avatar.BestAvatarCachedSize(size)
if cachedSize == 0 {
return setting.AppSubURL + "/repo-avatars/" + url.PathEscape(repo.Avatar)
}
return fmt.Sprintf("%s/repo-avatars/%s?size=%d", setting.AppSubURL, url.PathEscape(repo.Avatar), cachedSize)
}
// AvatarLink returns a link to the repository's avatar.
func (repo *Repository) AvatarLink(ctx context.Context) string {
link := repo.relAvatarLink(ctx)
return repo.AvatarLinkWithSize(ctx, 0)
}
// Returns URL to the smallest resized version of the avatar bigger than the supplied size
func (repo *Repository) AvatarLinkWithSize(ctx context.Context, size int) string {
link := repo.relAvatarLink(ctx, size)
// we only prepend our AppURL to our known (relative, internal) avatar link to get an absolute URL
if strings.HasPrefix(link, "/") && !strings.HasPrefix(link, "//") {
return setting.AppURL + strings.TrimPrefix(link, setting.AppSubURL)[1:]

View file

@ -60,8 +60,8 @@ func GenerateRandomAvatar(ctx context.Context, u *User) error {
return nil
}
// AvatarLinkWithSize returns a link to the user's avatar. Size is only used for
// GenerateEmailAvatarFastLink, for external email-based avatar services
// AvatarLinkWithSize returns a link to the user's avatar. It may be a relative
// or absolute URL.
func (u *User) AvatarLinkWithSize(ctx context.Context, size int) string {
if u.IsGhost() || u.ID <= 0 {
return avatars.DefaultAvatarLink()
@ -89,7 +89,7 @@ func (u *User) AvatarLinkWithSize(ctx context.Context, size int) string {
if u.Avatar == "" {
return avatars.DefaultAvatarLink()
}
return avatars.GenerateUserAvatarImageLink(u.Avatar)
return avatars.GenerateUserResizedAvatarLink(u.Avatar, size)
}
return avatars.GenerateEmailAvatarFastLink(ctx, u.AvatarEmail, size)
}

View file

@ -32,6 +32,14 @@ func TestUserAvatarLink(t *testing.T) {
assert.Equal(t, "https://localhost/sub-path/avatars/avatar.png", link)
}
func TestUserAvatarLinkWithSize(t *testing.T) {
u := &User{ID: 1, Avatar: "avatar.png"}
link := u.AvatarLinkWithSize(db.DefaultContext, 12)
assert.Equal(t, "/avatars/avatar.png?size=64", link)
link = u.AvatarLinkWithSize(db.DefaultContext, 2048)
assert.Equal(t, "/avatars/avatar.png", link)
}
func TestUserAvatarGenerate(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
var err error

View file

@ -30,6 +30,9 @@ import (
// than the size after resizing.
const DefaultAvatarSize = 256
// Sizes to which we allow resizing an avatar down to. They must be specified in increasing order.
var AllowedResizedAvatarSizes = []int{64, 128}
// RandomImageSize generates and returns a random avatar image unique to input data
// in custom size (height and width).
func RandomImageSize(size int, data []byte) (image.Image, error) {
@ -49,34 +52,34 @@ func RandomImage(data []byte) (image.Image, error) {
// 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) {
func processAvatarImage(data []byte, maxOriginSize int64) ([]byte, image.Image, error) {
imgCfg, imgType, err := image.DecodeConfig(bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("image.DecodeConfig: %w", err)
return nil, 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")
return nil, 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, 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, nil, fmt.Errorf("image height is too large: %d > %d", imgCfg.Height, setting.Avatar.MaxHeight)
}
var cleanedBytes []byte
if imgType != "gif" { // "gif" is the only imgType supported above, but not supported by exif_terminator
cleanedData, err := exif_terminator.Terminate(bytes.NewReader(data), imgType)
if err != nil {
return nil, fmt.Errorf("error cleaning exif data: %w", err)
return nil, nil, fmt.Errorf("error cleaning exif data: %w", err)
}
cleanedBytes, err = io.ReadAll(cleanedData)
if err != nil {
return nil, fmt.Errorf("error reading cleaned data: %w", err)
return nil, nil, fmt.Errorf("error reading cleaned data: %w", err)
}
} else { // gif
cleanedBytes = data
@ -87,43 +90,44 @@ func processAvatarImage(data []byte, maxOriginSize int64) ([]byte, error) {
// 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 cleanedBytes, nil
//nolint:nilnil
return cleanedBytes, nil, nil
}
img, _, err := image.Decode(bytes.NewReader(cleanedBytes))
if err != nil {
return nil, fmt.Errorf("image.Decode: %w", err)
return nil, nil, fmt.Errorf("image.Decode: %w", err)
}
// try to crop and resize the origin image if necessary
img = cropSquare(img)
targetSize := DefaultAvatarSize * setting.Avatar.RenderedSizeFactor
img = scale(img, targetSize, targetSize, draw.BiLinear)
img = Scale(img, targetSize, targetSize, draw.BiLinear)
// try to encode the cropped/resized image to png
bs := bytes.Buffer{}
if err = png.Encode(&bs, img); err != nil {
return nil, err
return nil, 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 cleanedBytes, nil
return cleanedBytes, img, nil
}
return resized, nil
return resized, img, 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) {
func ProcessAvatarImage(data []byte) ([]byte, image.Image, error) {
return processAvatarImage(data, setting.Avatar.MaxOriginSize)
}
// scale resizes the image to width x height using the given scaler.
func scale(src image.Image, width, height int, scale draw.Scaler) image.Image {
// Scale resizes the image to width x height using the given scaler.
func Scale(src image.Image, width, height int, scale draw.Scaler) image.Image {
rect := image.Rect(0, 0, width, height)
dst := image.NewRGBA(rect)
scale.Scale(dst, rect, src, src.Bounds(), draw.Over, nil)
@ -153,3 +157,17 @@ func cropSquare(src image.Image) image.Image {
draw.Draw(dst, rect, src, rect.Min, draw.Src)
return dst
}
// BestAvatarCachedSize computes the size at which the avatar should be downloaded, to display it at the desired size.
// When it returns 0, it means that the original should be downloaded.
func BestAvatarCachedSize(size int) int {
if size == 0 {
return 0
}
for i := 0; i < len(AllowedResizedAvatarSizes); i++ {
if AllowedResizedAvatarSizes[i] >= size {
return AllowedResizedAvatarSizes[i]
}
}
return 0
}

View file

@ -39,8 +39,9 @@ func Test_ProcessAvatarPNG(t *testing.T) {
data, err := os.ReadFile("testdata/avatar.png")
require.NoError(t, err)
_, err = processAvatarImage(data, 262144)
_, img, err := processAvatarImage(data, 262144)
require.NoError(t, err)
require.Nil(t, img)
}
func Test_ProcessAvatarJPEG(t *testing.T) {
@ -50,8 +51,9 @@ func Test_ProcessAvatarJPEG(t *testing.T) {
data, err := os.ReadFile("testdata/avatar.jpeg")
require.NoError(t, err)
_, err = processAvatarImage(data, 262144)
_, img, err := processAvatarImage(data, 262144)
require.NoError(t, err)
require.Nil(t, img)
}
func Test_ProcessAvatarGIF(t *testing.T) {
@ -61,15 +63,16 @@ func Test_ProcessAvatarGIF(t *testing.T) {
data, err := os.ReadFile("testdata/avatar.gif")
require.NoError(t, err)
_, err = processAvatarImage(data, 262144)
_, img, err := processAvatarImage(data, 262144)
require.NoError(t, err)
require.Nil(t, img)
}
func Test_ProcessAvatarInvalidData(t *testing.T) {
defer test.MockVariableValue(&setting.Avatar.MaxWidth, 5)()
defer test.MockVariableValue(&setting.Avatar.MaxHeight, 5)()
_, err := processAvatarImage([]byte{}, 12800)
_, _, err := processAvatarImage([]byte{}, 12800)
assert.EqualError(t, err, "image.DecodeConfig: image: unknown format")
}
@ -80,7 +83,7 @@ func Test_ProcessAvatarInvalidImageSize(t *testing.T) {
data, err := os.ReadFile("testdata/avatar.png")
require.NoError(t, err)
_, err = processAvatarImage(data, 12800)
_, _, err = processAvatarImage(data, 12800)
assert.EqualError(t, err, "image width is too large: 10 > 5")
}
@ -104,49 +107,53 @@ func Test_ProcessAvatarImage(t *testing.T) {
// if origin image canvas is too large, crop and resize it
origin := newImgData(500, 600)
result, err := processAvatarImage(origin, 0)
result, img, err := processAvatarImage(origin, 0)
require.NoError(t, err)
assert.NotEqual(t, origin, result)
decoded, err := png.Decode(bytes.NewReader(result))
require.NoError(t, err)
assert.Equal(t, scaledSize, decoded.Bounds().Max.X)
assert.Equal(t, scaledSize, decoded.Bounds().Max.Y)
assert.Equal(t, scaledSize, img.Bounds().Max.X)
assert.Equal(t, scaledSize, img.Bounds().Max.Y)
// if origin image is smaller than the default size, use the origin image
origin = newImgData(1)
result, err = processAvatarImage(origin, 0)
result, _, err = processAvatarImage(origin, 0)
require.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)
result, _, err = processAvatarImage(origin, 0)
require.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)
result, img, err = processAvatarImage(origin, 262144)
require.NoError(t, err)
require.Nil(t, img)
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")
require.NoError(t, err)
result, err = processAvatarImage(origin, 262144)
result, img, err = processAvatarImage(origin, 262144)
require.NoError(t, err)
require.Nil(t, img)
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)
_, _, err = processAvatarImage(origin, 262144)
require.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)
_, _, err = processAvatarImage(origin, 262144)
require.ErrorContains(t, err, "image width is too large: 10 > 5")
}
@ -173,7 +180,7 @@ func Test_ProcessAvatarExif(t *testing.T) {
data, err := os.ReadFile("testdata/exif.jpg")
require.NoError(t, err)
processedData, err := processAvatarImage(data, 12800)
processedData, _, err := processAvatarImage(data, 12800)
require.NoError(t, err)
safeExifJpeg(t, processedData)
})
@ -181,7 +188,7 @@ func Test_ProcessAvatarExif(t *testing.T) {
data, err := os.ReadFile("testdata/exif.jpg")
require.NoError(t, err)
processedData, err := processAvatarImage(data, 128000)
processedData, _, err := processAvatarImage(data, 128000)
require.NoError(t, err)
safeExifJpeg(t, processedData)
})

View file

@ -0,0 +1,66 @@
// Copyright 2026 the Forgejo authors. All rights reserved.
// SPDX-License-Identifier: MIT
package avatarstore
import (
"errors"
"fmt"
"image"
"image/png"
"io"
"os"
"forgejo.org/modules/avatar"
"forgejo.org/modules/log"
"forgejo.org/modules/storage"
"golang.org/x/image/draw"
)
// StoreAvatar stores an avatar in an object store and precomputes resized versions of it
func StoreAvatar(avatarPath string, avatarData []byte, avatarImg image.Image, imgStore storage.ObjectStorage) error {
err := storage.SaveFrom(imgStore, avatarPath, func(w io.Writer) error {
_, err := w.Write(avatarData)
return err
})
if err != nil {
return err
}
if avatarImg != nil {
// pre-compute rescaled versions of the avatar
return PrecomputeResizedAvatars(imgStore, avatarImg, avatarPath)
}
return nil
}
// PrecomputeResizedAvatars computes resized versions of the avatars and stores them in the cache
func PrecomputeResizedAvatars(resizedStore storage.ObjectStorage, avatarImg image.Image, avatarPath string) error {
for _, size := range avatar.AllowedResizedAvatarSizes {
rescaled := avatar.Scale(avatarImg, size, size, draw.BiLinear)
err := storage.SaveFrom(resizedStore, fmt.Sprintf("resized/%d/%s", size, avatarPath), func(w io.Writer) error {
return png.Encode(w, rescaled)
})
if err != nil {
return err
}
}
return nil
}
// DeleteAvatar removes an avatar file from both the original and resized storage
func DeleteAvatar(avatarPath string, imgStore storage.ObjectStorage) error {
if err := imgStore.Delete(avatarPath); err != nil {
if !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("failed to remove %s: %w", avatarPath, err)
}
log.Warn("Deleting avatar %s but it doesn't exist", avatarPath)
}
for _, size := range avatar.AllowedResizedAvatarSizes {
err := imgStore.Delete(fmt.Sprintf("resized/%d/%s", size, avatarPath))
if err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("failed to remove resized avatar resized/%d/%s: %w", size, avatarPath, err)
}
}
return nil
}

View file

@ -124,7 +124,7 @@ func TestPushCommits_AvatarLink(t *testing.T) {
}
assert.Equal(t,
"/avatars/ab53a2911ddf9b4817ac01ddcd3d975f",
"/avatars/ab53a2911ddf9b4817ac01ddcd3d975f?size=64",
pushCommits.AvatarLink(db.DefaultContext, "user2@example.com"))
assert.Equal(t,

View file

@ -1,98 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package web
import (
"errors"
"fmt"
"net/http"
"os"
"path"
"strings"
"forgejo.org/modules/httpcache"
"forgejo.org/modules/log"
"forgejo.org/modules/setting"
"forgejo.org/modules/storage"
"forgejo.org/modules/util"
"forgejo.org/modules/web/routing"
)
func storageHandler(storageSetting *setting.Storage, prefix string, objStore storage.ObjectStorage) http.HandlerFunc {
prefix = strings.Trim(prefix, "/")
funcInfo := routing.GetFuncInfo(storageHandler, prefix)
if storageSetting.MinioConfig.ServeDirect {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if req.Method != "GET" && req.Method != "HEAD" {
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
return
}
if !strings.HasPrefix(req.URL.Path, "/"+prefix+"/") {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return
}
routing.UpdateFuncInfo(req.Context(), funcInfo)
rPath := strings.TrimPrefix(req.URL.Path, "/"+prefix+"/")
rPath = util.PathJoinRelX(rPath)
u, err := objStore.URL(rPath, path.Base(rPath), nil)
if err != nil {
if os.IsNotExist(err) || errors.Is(err, os.ErrNotExist) {
log.Warn("Unable to find %s %s", prefix, rPath)
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return
}
log.Error("Error whilst getting URL for %s %s. Error: %v", prefix, rPath, err)
http.Error(w, fmt.Sprintf("Error whilst getting URL for %s %s", prefix, rPath), http.StatusInternalServerError)
return
}
http.Redirect(w, req, u.String(), http.StatusTemporaryRedirect)
})
}
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if req.Method != "GET" && req.Method != "HEAD" {
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
return
}
if !strings.HasPrefix(req.URL.Path, "/"+prefix+"/") {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return
}
routing.UpdateFuncInfo(req.Context(), funcInfo)
rPath := strings.TrimPrefix(req.URL.Path, "/"+prefix+"/")
rPath = util.PathJoinRelX(rPath)
if rPath == "" || rPath == "." {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return
}
fi, err := objStore.Stat(rPath)
if err != nil {
if os.IsNotExist(err) || errors.Is(err, os.ErrNotExist) {
log.Warn("Unable to find %s %s", prefix, rPath)
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return
}
log.Error("Error whilst opening %s %s. Error: %v", prefix, rPath, err)
http.Error(w, fmt.Sprintf("Error whilst opening %s %s", prefix, rPath), http.StatusInternalServerError)
return
}
fr, err := objStore.Open(rPath)
if err != nil {
log.Error("Error whilst opening %s %s. Error: %v", prefix, rPath, err)
http.Error(w, fmt.Sprintf("Error whilst opening %s %s", prefix, rPath), http.StatusInternalServerError)
return
}
defer fr.Close()
httpcache.ServeContentWithCacheControl(w, req, path.Base(rPath), fi.ModTime(), fr)
})
}

184
routers/web/resizing.go Normal file
View file

@ -0,0 +1,184 @@
// Copyright 2017 The Gitea Authors. All rights reserved.
// Copyright 2024 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package web
import (
"bytes"
"errors"
"fmt"
"image"
"image/png"
"io"
"net/http"
"os"
"path"
"slices"
"strconv"
"strings"
"time"
"forgejo.org/modules/avatar"
"forgejo.org/modules/httpcache"
"forgejo.org/modules/log"
"forgejo.org/modules/setting"
"forgejo.org/modules/storage"
"forgejo.org/modules/util"
"forgejo.org/modules/web/routing"
"golang.org/x/image/draw"
)
// resizingHandler resizes images to one of the supported sizes.
// It expects URLs of the form "{prefix}/{size}/{image_id}"
func resizingHandler(prefix string, imgStore storage.ObjectStorage, allowedSizes []int) http.HandlerFunc {
whenMissing := func(path string, size int) ([]byte, error) {
return resizeImageFromStorage(imgStore, path, size, allowedSizes)
}
return cachingHandler(prefix, imgStore, whenMissing)
}
// resizeImageFromStorage retrieves an image from an ObjectStorage and resizes it
func resizeImageFromStorage(imgStore storage.ObjectStorage, imgPath string, targetSize int, allowedSizes []int) ([]byte, error) {
if !slices.Contains(allowedSizes, targetSize) {
return nil, errors.New("invalid image size requested")
}
// Get the original image
reader, err := imgStore.Open(imgPath)
if err != nil {
return nil, err
}
// Read the bytes in memory for the purpose of re-using them if the image is small
originalBytes, err := io.ReadAll(reader)
if err != nil {
return nil, err
}
originalReader := bytes.NewReader(originalBytes)
// Decode it as an image
image, _, err := image.Decode(originalReader)
if err != nil {
return nil, err
}
buffer := new(bytes.Buffer)
width := image.Bounds().Dx()
height := image.Bounds().Dy()
if width <= targetSize && height <= targetSize {
// The original image is smaller than the requested size,
// just return it directly.
// This will still put a copy of it in the storage for the
// requested size which we could avoid, but that will also
// avoid the need for decoding the image next time it is requested.
return originalBytes, err
}
thumbnail := avatar.Scale(image, targetSize, targetSize, draw.BiLinear)
err = png.Encode(buffer, thumbnail)
return buffer.Bytes(), err
}
// cachingHandler serves blobs from an ObjectStorage,
// computing them on the fly if they are missing. It falls back on the original image if no size parameter is supplied.
func cachingHandler(prefix string, imgStore storage.ObjectStorage, whenMissing func(string, int) ([]byte, error)) http.HandlerFunc {
prefix = strings.Trim(prefix, "/")
funcInfo := routing.GetFuncInfo(cachingHandler, prefix)
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if req.Method != "GET" && req.Method != "HEAD" {
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
return
}
if !strings.HasPrefix(req.URL.Path, "/"+prefix+"/") {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return
}
routing.UpdateFuncInfo(req.Context(), funcInfo)
sizeParam := req.URL.Query().Get("size")
if sizeParam == "" {
sizeParam = "0"
}
size, err := strconv.Atoi(sizeParam)
if err != nil {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return
}
rPath := strings.TrimPrefix(req.URL.Path, "/"+prefix+"/")
rPath = util.PathJoinRelX(rPath)
if rPath == "" || rPath == "." || strings.Contains(rPath, "/") {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return
}
if size != 0 && !slices.Contains(avatar.AllowedResizedAvatarSizes, size) {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return
}
originalFile, err := imgStore.Stat(rPath)
if err != nil {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return
}
// If no size is provided, or if the original file is small enough, fall back on the original image.
// This is primarily for the purpose of preserving animated images that are small enough, because
// the image-resizing code would remove the animation from the images otherwise.
// An alternative could be to only preserve such images if they are actually animated, but it would
// require reading the image and and inspecting its contents.
if size == 0 || originalFile.Size() < setting.Avatar.MaxOriginSize {
reader, err := imgStore.Open(rPath)
if err != nil {
log.Error("Error whilst opening %s %s. Error: %v", prefix, rPath, err)
http.Error(w, fmt.Sprintf("Error whilst opening %s %s", prefix, rPath), http.StatusInternalServerError)
return
}
defer reader.Close()
httpcache.ServeContentWithCacheControl(w, req, path.Base(rPath), originalFile.ModTime(), reader)
return
}
cachePath := fmt.Sprintf("resized/%d/%s", size, rPath)
fi, err := imgStore.Stat(cachePath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
// attempt to compute the missing value via the callback provided
computed, err := whenMissing(rPath, size)
if err != nil {
log.Warn("Unable to compute %s %s", prefix, rPath)
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return
}
reader := bytes.NewReader(computed)
_, err = imgStore.Save(cachePath, reader, int64(len(computed)))
if err != nil {
// only log the error, still return the computed resource without caching it
log.Warn("Unable to save %s %s: %s", prefix, cachePath, err)
}
reader = bytes.NewReader(computed)
httpcache.ServeContentWithCacheControl(w, req, path.Base(cachePath), time.Now(), reader)
return
}
log.Error("Error whilst opening %s %s. Error: %v", prefix, rPath, err)
http.Error(w, fmt.Sprintf("Error whilst opening %s %s", prefix, rPath), http.StatusInternalServerError)
return
}
fr, err := imgStore.Open(cachePath)
if err != nil {
log.Error("Error whilst opening %s %s. Error: %v", prefix, cachePath, err)
http.Error(w, fmt.Sprintf("Error whilst opening %s %s", prefix, cachePath), http.StatusInternalServerError)
return
}
defer fr.Close()
httpcache.ServeContentWithCacheControl(w, req, path.Base(rPath), fi.ModTime(), fr)
})
}

View file

@ -15,6 +15,7 @@ import (
"forgejo.org/models/perm"
quota_model "forgejo.org/models/quota"
"forgejo.org/models/unit"
"forgejo.org/modules/avatar"
"forgejo.org/modules/log"
"forgejo.org/modules/metrics"
"forgejo.org/modules/public"
@ -276,8 +277,8 @@ func Routes() *web.Route {
routes.Head("/", misc.DummyOK) // for health check - doesn't need to be passed through gzip handler
routes.Methods("GET, HEAD, OPTIONS", "/assets/*", optionsCorsHandler(), public.FileHandlerFunc())
routes.Methods("GET, HEAD", "/avatars/*", storageHandler(setting.Avatar.Storage, "avatars", storage.Avatars))
routes.Methods("GET, HEAD", "/repo-avatars/*", storageHandler(setting.RepoAvatar.Storage, "repo-avatars", storage.RepoAvatars))
routes.Methods("GET, HEAD", "/avatars/*", resizingHandler("avatars", storage.Avatars, avatar.AllowedResizedAvatarSizes))
routes.Methods("GET, HEAD", "/repo-avatars/*", resizingHandler("repo-avatars", storage.RepoAvatars, avatar.AllowedResizedAvatarSizes))
routes.Methods("GET, HEAD", "/apple-touch-icon.png", misc.StaticRedirect("/assets/img/apple-touch-icon.png"))
routes.Methods("GET, HEAD", "/apple-touch-icon-precomposed.png", misc.StaticRedirect("/assets/img/apple-touch-icon.png"))
routes.Methods("GET, HEAD", "/favicon.ico", misc.StaticRedirect("/assets/img/favicon.png"))

View file

@ -48,5 +48,5 @@ func garbageCollectLFSCheck(ctx context.Context, logger log.Logger, autofix bool
return err
}
return checkStorage(&checkStorageOptions{LFS: true})(ctx, logger, autofix)
return CheckStorage(&CheckStorageOptions{LFS: true})(ctx, logger, autofix)
}

View file

@ -79,7 +79,7 @@ func commonCheckStorage(logger log.Logger, autofix bool, opts *commonStorageChec
return nil
}
type checkStorageOptions struct {
type CheckStorageOptions struct {
All bool
Attachments bool
LFS bool
@ -89,8 +89,8 @@ type checkStorageOptions struct {
Packages bool
}
// checkStorage will return a doctor check function to check the requested storage types for "orphaned" stored object/files and optionally delete them
func checkStorage(opts *checkStorageOptions) func(ctx context.Context, logger log.Logger, autofix bool) error {
// CheckStorage will return a doctor check function to check the requested storage types for "orphaned" stored object/files and optionally delete them
func CheckStorage(opts *CheckStorageOptions) func(ctx context.Context, logger log.Logger, autofix bool) error {
return func(ctx context.Context, logger log.Logger, autofix bool) error {
if err := storage.Init(); err != nil {
logger.Error("storage.Init failed: %v", err)
@ -136,7 +136,13 @@ func checkStorage(opts *checkStorageOptions) func(ctx context.Context, logger lo
&commonStorageCheckOptions{
storer: storage.Avatars,
isOrphaned: func(path string, obj storage.Object, stat fs.FileInfo) (bool, error) {
exists, err := user.ExistsWithAvatarAtStoragePath(ctx, path)
// The path is either just a hash, if the file is an original avatar uploaded by the user,
// or of the form "resized/<size>/<hash>" if it is a resized version of the avatar.
// In both cases, we retain the file if and only if the hash corresponds to an avatar
// of an existing user.
pathParts := strings.Split(path, "/")
hash := pathParts[len(pathParts)-1]
exists, err := user.ExistsWithAvatarAtStoragePath(ctx, hash)
return !exists, err
},
name: "avatar",
@ -150,7 +156,10 @@ func checkStorage(opts *checkStorageOptions) func(ctx context.Context, logger lo
&commonStorageCheckOptions{
storer: storage.RepoAvatars,
isOrphaned: func(path string, obj storage.Object, stat fs.FileInfo) (bool, error) {
exists, err := repo.ExistsWithAvatarAtStoragePath(ctx, path)
// See the comment above to explain the handling of original and resized avatars.
pathParts := strings.Split(path, "/")
hash := pathParts[len(pathParts)-1]
exists, err := repo.ExistsWithAvatarAtStoragePath(ctx, hash)
return !exists, err
},
name: "repo avatar",
@ -212,7 +221,7 @@ func init() {
Title: "Check if there are orphaned storage files",
Name: "storages",
IsDefault: false,
Run: checkStorage(&checkStorageOptions{All: true}),
Run: CheckStorage(&CheckStorageOptions{All: true}),
AbortIfFailed: false,
SkipDatabaseInitialization: false,
Priority: 1,
@ -222,7 +231,7 @@ func init() {
Title: "Check if there are orphaned attachments in storage",
Name: "storage-attachments",
IsDefault: false,
Run: checkStorage(&checkStorageOptions{Attachments: true}),
Run: CheckStorage(&CheckStorageOptions{Attachments: true}),
AbortIfFailed: false,
SkipDatabaseInitialization: false,
Priority: 1,
@ -232,7 +241,7 @@ func init() {
Title: "Check if there are orphaned lfs files in storage",
Name: "storage-lfs",
IsDefault: false,
Run: checkStorage(&checkStorageOptions{LFS: true}),
Run: CheckStorage(&CheckStorageOptions{LFS: true}),
AbortIfFailed: false,
SkipDatabaseInitialization: false,
Priority: 1,
@ -242,7 +251,7 @@ func init() {
Title: "Check if there are orphaned avatars in storage",
Name: "storage-avatars",
IsDefault: false,
Run: checkStorage(&checkStorageOptions{Avatars: true, RepoAvatars: true}),
Run: CheckStorage(&CheckStorageOptions{Avatars: true, RepoAvatars: true}),
AbortIfFailed: false,
SkipDatabaseInitialization: false,
Priority: 1,
@ -252,7 +261,7 @@ func init() {
Title: "Check if there are orphaned archives in storage",
Name: "storage-archives",
IsDefault: false,
Run: checkStorage(&checkStorageOptions{RepoArchives: true}),
Run: CheckStorage(&CheckStorageOptions{RepoArchives: true}),
AbortIfFailed: false,
SkipDatabaseInitialization: false,
Priority: 1,
@ -262,7 +271,7 @@ func init() {
Title: "Check if there are orphaned package blobs in storage",
Name: "storage-packages",
IsDefault: false,
Run: checkStorage(&checkStorageOptions{Packages: true}),
Run: CheckStorage(&CheckStorageOptions{Packages: true}),
AbortIfFailed: false,
SkipDatabaseInitialization: false,
Priority: 1,

View file

@ -12,6 +12,7 @@ import (
"forgejo.org/models/db"
repo_model "forgejo.org/models/repo"
"forgejo.org/modules/avatar"
"forgejo.org/modules/avatarstore"
"forgejo.org/modules/log"
"forgejo.org/modules/storage"
)
@ -19,7 +20,7 @@ import (
// UploadAvatar saves custom avatar for repository.
// FIXME: split uploads to different subdirs in case we have massive number of repos.
func UploadAvatar(ctx context.Context, repo *repo_model.Repository, data []byte) error {
avatarData, err := avatar.ProcessAvatarImage(data)
avatarData, img, err := avatar.ProcessAvatarImage(data)
if err != nil {
return err
}
@ -44,15 +45,12 @@ func UploadAvatar(ctx context.Context, repo *repo_model.Repository, data []byte)
return fmt.Errorf("UploadAvatar: Update repository avatar: %w", err)
}
if err := storage.SaveFrom(storage.RepoAvatars, repo.CustomAvatarRelativePath(), func(w io.Writer) error {
_, err := w.Write(avatarData)
return err
}); err != nil {
return fmt.Errorf("UploadAvatar %s failed: Failed to remove old repo avatar %s: %w", repo.RepoPath(), newAvatar, err)
if err := avatarstore.StoreAvatar(repo.CustomAvatarRelativePath(), avatarData, img, storage.RepoAvatars); err != nil {
return fmt.Errorf("UploadAvatar %s failed: Failed to store repo avatar %s: %w", repo.RepoPath(), newAvatar, err)
}
if len(oldAvatarPath) > 0 {
if err := storage.RepoAvatars.Delete(oldAvatarPath); err != nil {
if err := avatarstore.DeleteAvatar(oldAvatarPath, storage.RepoAvatars); err != nil {
return fmt.Errorf("UploadAvatar: Failed to remove old repo avatar %s: %w", oldAvatarPath, err)
}
}
@ -81,7 +79,7 @@ func DeleteAvatar(ctx context.Context, repo *repo_model.Repository) error {
return fmt.Errorf("DeleteAvatar: Update repository avatar: %w", err)
}
if err := storage.RepoAvatars.Delete(avatarPath); err != nil {
if err := avatarstore.DeleteAvatar(avatarPath, storage.RepoAvatars); err != nil {
return fmt.Errorf("DeleteAvatar: Failed to remove %s: %w", avatarPath, err)
}

View file

@ -5,21 +5,19 @@ package user
import (
"context"
"errors"
"fmt"
"io"
"os"
"forgejo.org/models/db"
user_model "forgejo.org/models/user"
"forgejo.org/modules/avatar"
"forgejo.org/modules/avatarstore"
"forgejo.org/modules/log"
"forgejo.org/modules/storage"
)
// UploadAvatar saves custom avatar for user.
func UploadAvatar(ctx context.Context, u *user_model.User, data []byte) error {
avatarData, err := avatar.ProcessAvatarImage(data)
avatarData, img, err := avatar.ProcessAvatarImage(data)
if err != nil {
return err
}
@ -31,16 +29,20 @@ func UploadAvatar(ctx context.Context, u *user_model.User, data []byte) error {
defer committer.Close()
u.UseCustomAvatar = true
previousAvatar := u.Avatar
u.Avatar = avatar.HashAvatar(u.ID, data)
if err = user_model.UpdateUserCols(ctx, u, "use_custom_avatar", "avatar"); err != nil {
return fmt.Errorf("updateUser: %w", err)
}
if err := storage.SaveFrom(storage.Avatars, u.CustomAvatarRelativePath(), func(w io.Writer) error {
_, err := w.Write(avatarData)
return err
}); err != nil {
return fmt.Errorf("Failed to create dir %s: %w", u.CustomAvatarRelativePath(), err)
if err := avatarstore.StoreAvatar(u.CustomAvatarRelativePath(), avatarData, img, storage.Avatars); err != nil {
return fmt.Errorf("Failed to store avatar at %s: %w", u.CustomAvatarRelativePath(), err)
}
if len(previousAvatar) > 0 {
err := avatarstore.DeleteAvatar(previousAvatar, storage.Avatars)
if err != nil {
return err
}
}
return committer.Commit()
@ -60,14 +62,8 @@ func DeleteAvatar(ctx context.Context, u *user_model.User) error {
}
if hasAvatar {
if err := storage.Avatars.Delete(aPath); err != nil {
if !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("failed to remove %s: %w", aPath, err)
}
log.Warn("Deleting avatar %s but it doesn't exist", aPath)
}
return avatarstore.DeleteAvatar(aPath, storage.Avatars)
}
return nil
})
}

View file

@ -5,14 +5,19 @@ package user
import (
"bytes"
"fmt"
"image"
"image/color"
"image/png"
"os"
"strings"
"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/storage"
"forgejo.org/modules/test"
@ -65,17 +70,70 @@ func TestUserDeleteAvatar(t *testing.T) {
t.Run("Success", func(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
defer test.MockVariableValue(&setting.Avatar.MaxOriginSize, 3)()
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
err := UploadAvatar(db.DefaultContext, user, buff.Bytes())
require.NoError(t, err)
verification := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
assert.NotEmpty(t, verification.Avatar)
avatarID := strings.Clone(verification.Avatar)
// Check that resized versions are stored in the cache
for _, size := range avatar.AllowedResizedAvatarSizes {
_, err := storage.Avatars.Stat(fmt.Sprintf("resized/%d/%s", size, avatarID))
require.NoError(t, err)
}
err = DeleteAvatar(db.DefaultContext, user)
require.NoError(t, err)
verification = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
assert.Empty(t, verification.Avatar)
// The avatar is deleted from the storage
fi, err := storage.Avatars.Stat(avatarID)
require.Error(t, err)
assert.Nil(t, fi)
// All resized versions of the avatar are also deleted
for _, size := range avatar.AllowedResizedAvatarSizes {
fi, err := storage.Avatars.Stat(fmt.Sprintf("resized/%d/%s", size, avatarID))
require.Error(t, err)
assert.Nil(t, fi)
}
})
}
func TestUserReplaceAvatar(t *testing.T) {
firstImage := image.NewRGBA(image.Rect(0, 0, 1, 1))
var firstBuff bytes.Buffer
png.Encode(&firstBuff, firstImage)
secondImage := image.NewRGBA(image.Rect(0, 0, 2, 2))
secondImage.Set(0, 0, color.White)
secondImage.Set(1, 1, color.Black)
var secondBuff bytes.Buffer
png.Encode(&secondBuff, secondImage)
t.Run("Success", func(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
err := UploadAvatar(db.DefaultContext, user, firstBuff.Bytes())
require.NoError(t, err)
user = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
firstImageHash := user.Avatar
assert.NotEmpty(t, firstImageHash)
err = UploadAvatar(db.DefaultContext, user, secondBuff.Bytes())
require.NoError(t, err)
user = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
secondImageHash := user.Avatar
assert.NotEmpty(t, secondImageHash)
assert.NotEqual(t, firstImageHash, secondImageHash)
// The previous avatar is deleted from storage
fi, err := storage.Avatars.Stat(firstImageHash)
require.Error(t, err)
assert.Nil(t, fi)
})
}

View file

@ -0,0 +1,101 @@
// Copyright 2026 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"bytes"
"fmt"
"image"
"image/png"
"testing"
cmd "forgejo.org/cmd"
"forgejo.org/models/db"
"forgejo.org/models/repo"
"forgejo.org/models/unittest"
"forgejo.org/models/user"
"forgejo.org/modules/setting"
"forgejo.org/modules/storage"
"forgejo.org/modules/test"
"forgejo.org/tests"
"github.com/stretchr/testify/require"
)
func TestPrecomputeUserAvatars(t *testing.T) {
defer tests.PrepareTestEnv(t, 1)()
var err error
// make the maximum uncached image size small, so that our test image is bigger than that
defer test.MockVariableValue(&setting.Avatar.MaxOriginSize, 3)()
ctx := db.DefaultContext
u := unittest.AssertExistsAndLoadBean(t, &user.User{ID: 2})
// generate an avatar for this user
myImage := image.NewRGBA(image.Rect(0, 0, 1024, 1024))
var buff bytes.Buffer
png.Encode(&buff, myImage)
// store the avatar, but only the original (not the resized versions)
avatarPath := "some_id"
storage.Avatars.Save(avatarPath, bytes.NewReader(buff.Bytes()), -1)
u.UseCustomAvatar = true
u.Avatar = avatarPath
err = user.UpdateUserCols(ctx, u, "use_custom_avatar", "avatar")
require.NoError(t, err)
// run the doctor command
err = cmd.RunAvatarResize(ctx, true, false)
require.NoError(t, err)
// the resized version of the avatar is now stored in the cache
_, err = storage.Avatars.Stat(fmt.Sprintf("resized/64/%s", avatarPath))
require.NoError(t, err)
}
func TestPrecomputeRepoAvatars(t *testing.T) {
defer tests.PrepareTestEnv(t, 1)()
var err error
// make the maximum uncached image size small, so that our test image is bigger than that
defer test.MockVariableValue(&setting.Avatar.MaxOriginSize, 3)()
ctx := db.DefaultContext
u := unittest.AssertExistsAndLoadBean(t, &repo.Repository{ID: 2})
// generate an avatar for this repo
myImage := image.NewRGBA(image.Rect(0, 0, 1024, 1024))
var buff bytes.Buffer
png.Encode(&buff, myImage)
// store the avatar, but only the original (not the resized versions)
avatarPath := "some_id"
storage.RepoAvatars.Save(avatarPath, bytes.NewReader(buff.Bytes()), -1)
u.Avatar = avatarPath
err = repo.UpdateRepositoryCols(ctx, u, "avatar")
require.NoError(t, err)
// make sure there is no resized version of the avatar in the storage yet
storage.RepoAvatars.Delete(fmt.Sprintf("resized/64/%s", avatarPath))
_, err = storage.RepoAvatars.Stat(fmt.Sprintf("resized/64/%s", avatarPath))
require.Error(t, err)
// run the doctor command
err = cmd.RunAvatarResize(ctx, false, true)
require.NoError(t, err)
// the resized version of the avatar is now stored in the cache
_, err = storage.RepoAvatars.Stat(fmt.Sprintf("resized/64/%s", avatarPath))
require.NoError(t, err)
}
func TestPrecomputeAvatarsWithoutArgument(t *testing.T) {
defer tests.PrepareTestEnv(t, 1)()
var err error
ctx := db.DefaultContext
// run the doctor command
err = cmd.RunAvatarResize(ctx, false, false)
// there is an error because we didn't specify which avatars to resize
require.Error(t, err)
}

View file

@ -0,0 +1,139 @@
// Copyright 2026 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"bytes"
"context"
"fmt"
"image"
"image/color"
"image/png"
"testing"
"forgejo.org/models/db"
"forgejo.org/models/repo"
"forgejo.org/models/unittest"
"forgejo.org/models/user"
"forgejo.org/modules/log"
"forgejo.org/modules/setting"
"forgejo.org/modules/storage"
"forgejo.org/modules/test"
doctor "forgejo.org/services/doctor"
repo_service "forgejo.org/services/repository"
user_service "forgejo.org/services/user"
"forgejo.org/tests"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func generateUserAvatar(ctx context.Context, t *testing.T, user *user.User) string {
err := user_service.UploadAvatar(ctx, user, generateAvatar(user.ID))
require.NoError(t, err)
assert.NotEmpty(t, user.Avatar)
return user.CustomAvatarRelativePath()
}
func generateRepoAvatar(ctx context.Context, t *testing.T, repo *repo.Repository) string {
err := repo_service.UploadAvatar(ctx, repo, generateAvatar(repo.ID))
require.NoError(t, err)
assert.NotEmpty(t, repo.Avatar)
return repo.CustomAvatarRelativePath()
}
func generateAvatar(objectID int64) []byte {
avatar := image.NewRGBA(image.Rect(0, 0, 1024, 1024))
// make the avatar distinctive for the user by setting a pixel based on the object id
avatar.SetRGBA(3, 3, color.RGBA{R: uint8(objectID % 255), G: uint8(objectID / 255 % 255), B: 55, A: 66})
var buff bytes.Buffer
png.Encode(&buff, avatar)
return buff.Bytes()
}
func TestRemoveUnusedUserAvatars(t *testing.T) {
defer tests.PrepareTestEnv(t, 1)()
// make the maximum uncached image size small, so that our test image is bigger than that
defer test.MockVariableValue(&setting.Avatar.MaxOriginSize, 3)()
var err error
ctx := db.DefaultContext
user2 := unittest.AssertExistsAndLoadBean(t, &user.User{ID: 2})
user3 := unittest.AssertExistsAndLoadBean(t, &user.User{ID: 3})
avatarPathUser2 := generateUserAvatar(ctx, t, user2)
avatarPathUser3 := generateUserAvatar(ctx, t, user3)
// disconnect the avatar from user2, keeping the avatar for user3
user2.Avatar = ""
err = user.UpdateUserCols(ctx, user2, "avatar")
require.NoError(t, err)
// make sure both avatars are still stored as a file
_, err = storage.Avatars.Stat(avatarPathUser2)
require.NoError(t, err)
_, err = storage.Avatars.Stat(avatarPathUser3)
require.NoError(t, err)
// the downscaled versions are also stored
_, err = storage.Avatars.Stat(fmt.Sprintf("resized/64/%s", avatarPathUser2))
require.NoError(t, err)
_, err = storage.Avatars.Stat(fmt.Sprintf("resized/64/%s", avatarPathUser3))
require.NoError(t, err)
doctor.CheckStorage(&doctor.CheckStorageOptions{Avatars: true})(ctx, log.GetLogger("doctor"), true)
// the avatar for user2 is no longer stored
_, err = storage.Avatars.Stat(avatarPathUser2)
require.Error(t, err)
// its downscaled versions are not stored either
_, err = storage.Avatars.Stat(fmt.Sprintf("resized/64/%s", avatarPathUser2))
require.Error(t, err)
// the avatar for user3 is still stored stored
_, err = storage.Avatars.Stat(avatarPathUser3)
require.NoError(t, err)
// the downscaled versions are also stored
_, err = storage.Avatars.Stat(fmt.Sprintf("resized/64/%s", avatarPathUser3))
require.NoError(t, err)
}
func TestRemoveUnusedRepoAvatars(t *testing.T) {
defer tests.PrepareTestEnv(t, 1)()
// make the maximum uncached image size small, so that our test image is bigger than that
defer test.MockVariableValue(&setting.Avatar.MaxOriginSize, 3)()
var err error
ctx := db.DefaultContext
repo2 := unittest.AssertExistsAndLoadBean(t, &repo.Repository{ID: 2})
repo3 := unittest.AssertExistsAndLoadBean(t, &repo.Repository{ID: 3})
avatarPathRepo2 := generateRepoAvatar(ctx, t, repo2)
avatarPathRepo3 := generateRepoAvatar(ctx, t, repo3)
// disconnect the avatar from repo2, but keep it for repo3
repo2.Avatar = ""
err = repo.UpdateRepositoryCols(ctx, repo2, "avatar")
require.NoError(t, err)
// make sure the avatar is still stored as a file
_, err = storage.RepoAvatars.Stat(avatarPathRepo2)
require.NoError(t, err)
// the downscaled versions are also stored
_, err = storage.RepoAvatars.Stat(fmt.Sprintf("resized/64/%s", avatarPathRepo2))
require.NoError(t, err)
doctor.CheckStorage(&doctor.CheckStorageOptions{RepoAvatars: true})(ctx, log.GetLogger("doctor"), true)
// the avatar for repo2 is no longer stored
_, err = storage.RepoAvatars.Stat(avatarPathRepo2)
require.Error(t, err)
// its downscaled versions are not stored either
_, err = storage.RepoAvatars.Stat(fmt.Sprintf("resized/64/%s", avatarPathRepo2))
require.Error(t, err)
// the avatar for repo3 is still stored
_, err = storage.RepoAvatars.Stat(avatarPathRepo3)
require.NoError(t, err)
// its downscaled versions are also still stored
_, err = storage.RepoAvatars.Stat(fmt.Sprintf("resized/64/%s", avatarPathRepo3))
require.NoError(t, err)
}

View file

@ -6,6 +6,7 @@ package integration
import (
"bytes"
"fmt"
"image"
"image/png"
"io"
"mime/multipart"
@ -17,6 +18,8 @@ import (
"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"
@ -25,6 +28,9 @@ import (
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
@ -82,7 +88,34 @@ func TestUserAvatar(t *testing.T) {
resp := MakeRequest(t, req, http.StatusSeeOther)
assert.Equal(t, fmt.Sprintf("/avatars/%s", user2.Avatar), resp.Header().Get("location"))
// Can't test if the response matches because the image is re-generated on upload but checking that this at least doesn't give a 404 should be enough.
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) {