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
+100
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()