mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2026-07-18 23:47:50 +00:00
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>
184 lines
6.2 KiB
Go
184 lines
6.2 KiB
Go
// 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)
|
|
})
|
|
}
|