109 lines
3.7 KiB
Go
109 lines
3.7 KiB
Go
package app
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"image"
|
|
"image/color"
|
|
"image/draw"
|
|
"image/jpeg"
|
|
"io"
|
|
"strings"
|
|
|
|
"github.com/disintegration/imaging"
|
|
_ "golang.org/x/image/webp"
|
|
)
|
|
|
|
const (
|
|
avatarDisplaySize = 640
|
|
avatarThumbnailSize = 256
|
|
maxAvatarPixels = 24_000_000
|
|
)
|
|
|
|
// Bound peak decoding memory on small servers. Do not queue decoded photos in
|
|
// memory when multiple clients upload avatars at the same time.
|
|
var avatarProcessing = make(chan struct{}, 1)
|
|
var errAvatarBusy = errors.New("头像正在处理中,请稍后重试")
|
|
|
|
type avatarImages struct {
|
|
Display, Thumbnail []byte
|
|
Width, Height int
|
|
}
|
|
|
|
// avatarThumbnailURL only derives URLs for object families that are known to
|
|
// publish an immutable 256px sibling. Older and third-party avatars remain
|
|
// unchanged so list clients never receive a URL that cannot exist.
|
|
func avatarThumbnailURL(source string) string {
|
|
if source == "" || strings.ContainsAny(source, "?#") {
|
|
return source
|
|
}
|
|
lower := strings.ToLower(source)
|
|
if strings.HasSuffix(lower, "-thumb.jpg") {
|
|
return source
|
|
}
|
|
if strings.HasSuffix(lower, "-av1.jpg") {
|
|
return source[:len(source)-len(".jpg")] + "-thumb.jpg"
|
|
}
|
|
if strings.Contains(lower, "/test-users/") {
|
|
for _, extension := range []string{".jpeg", ".jpg", ".png", ".webp"} {
|
|
if strings.HasSuffix(lower, extension) {
|
|
return source[:len(source)-len(extension)] + "-thumb.jpg"
|
|
}
|
|
}
|
|
}
|
|
return source
|
|
}
|
|
|
|
func createAvatarImages(ctx context.Context, source io.ReadSeeker) (avatarImages, error) {
|
|
select {
|
|
case avatarProcessing <- struct{}{}:
|
|
defer func() { <-avatarProcessing }()
|
|
default:
|
|
return avatarImages{}, errAvatarBusy
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
return avatarImages{}, err
|
|
}
|
|
config, format, err := image.DecodeConfig(source)
|
|
if err != nil || (format != "jpeg" && format != "png" && format != "gif" && format != "webp") {
|
|
return avatarImages{}, errors.New("无法读取头像,请选择有效的 JPG、PNG、GIF 或 WebP 图片")
|
|
}
|
|
if config.Width <= 0 || config.Height <= 0 || config.Width > 16384 || config.Height > 16384 || int64(config.Width)*int64(config.Height) > maxAvatarPixels {
|
|
return avatarImages{}, errors.New("头像分辨率过大,请选择不超过 2400 万像素的图片")
|
|
}
|
|
if _, err = source.Seek(0, io.SeekStart); err != nil {
|
|
return avatarImages{}, err
|
|
}
|
|
// Orient phone photos before resizing. Re-encoding also removes EXIF/GPS
|
|
// from the display variants. Animated images use their first decoded frame.
|
|
src, err := imaging.Decode(source, imaging.AutoOrientation(true))
|
|
if err != nil {
|
|
return avatarImages{}, errors.New("头像文件损坏或格式暂不支持,请重新选择图片")
|
|
}
|
|
if err = ctx.Err(); err != nil {
|
|
return avatarImages{}, err
|
|
}
|
|
display := imaging.Fit(src, avatarDisplaySize, avatarDisplaySize, imaging.Lanczos)
|
|
thumbnail := imaging.Fit(display, avatarThumbnailSize, avatarThumbnailSize, imaging.Lanczos)
|
|
result := avatarImages{Width: display.Bounds().Dx(), Height: display.Bounds().Dy()}
|
|
if result.Display, err = avatarJPEG(display, 82); err != nil {
|
|
return avatarImages{}, err
|
|
}
|
|
if result.Thumbnail, err = avatarJPEG(thumbnail, 78); err != nil {
|
|
return avatarImages{}, err
|
|
}
|
|
_, err = source.Seek(0, io.SeekStart)
|
|
return result, err
|
|
}
|
|
|
|
func avatarJPEG(src image.Image, quality int) ([]byte, error) {
|
|
// Composite transparency onto white instead of producing black PNG edges.
|
|
opaque := image.NewRGBA(src.Bounds())
|
|
draw.Draw(opaque, opaque.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src)
|
|
draw.Draw(opaque, opaque.Bounds(), src, src.Bounds().Min, draw.Over)
|
|
var buffer bytes.Buffer
|
|
err := jpeg.Encode(&buffer, opaque, &jpeg.Options{Quality: quality})
|
|
return buffer.Bytes(), err
|
|
}
|