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 }