Go · 4499 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package avatars
4
5 import (
6 "bytes"
7 "crypto/sha256"
8 "encoding/hex"
9 "errors"
10 "fmt"
11 "image"
12 "image/png"
13 "io"
14 "strings"
15
16 // Side-effect imports register decoders for jpeg/gif inputs.
17 _ "image/gif"
18 _ "image/jpeg"
19
20 "golang.org/x/image/draw"
21 )
22
23 // MaxUploadBytes caps how big an uploaded avatar may be before decoding.
24 // Stops trivially large uploads from soaking RAM during decode.
25 const MaxUploadBytes = 5 * 1024 * 1024 // 5 MiB
26
27 // MaxPixelArea bounds the *decoded* image's pixel area as a defense-in-depth
28 // check against decompression-bomb attacks: a small file (well under
29 // MaxUploadBytes) can decode to a huge image. We reject anything past 24
30 // megapixels (e.g. 4900×4900), well above any reasonable avatar source.
31 const MaxPixelArea = 24 * 1000 * 1000
32
33 // VariantSizes is the set of resize targets we generate per upload. The
34 // largest is what the public avatar route serves; smaller ones are kept
35 // so a future "/avatars/:user/:size" route can serve them without
36 // re-resizing.
37 var VariantSizes = []int{460, 200, 40}
38
39 // Variant is one rendered output: a sized PNG ready for upload to the
40 // object store.
41 type Variant struct {
42 Size int // edge length in pixels (Size × Size)
43 Data []byte // PNG bytes
44 }
45
46 // Errors surfaced to the handler. Each maps to a friendly UI message.
47 var (
48 ErrTooLarge = errors.New("avatars: upload exceeds size limit")
49 ErrUnsupported = errors.New("avatars: unsupported image format")
50 ErrDecompression = errors.New("avatars: image dimensions exceed limit")
51 ErrDecode = errors.New("avatars: could not decode image")
52 )
53
54 // Process reads an uploaded image, validates it, and produces resized
55 // PNG variants. It strips EXIF as a side effect of re-encoding.
56 //
57 // Returns the variants in VariantSizes order plus a content-addressed key
58 // component (sha256 of the largest variant's bytes) the caller can embed
59 // in the storage path.
60 func Process(r io.Reader) ([]Variant, string, error) {
61 // Bound the read up-front; one extra byte to detect overflow.
62 limited := io.LimitReader(r, MaxUploadBytes+1)
63 raw, err := io.ReadAll(limited)
64 if err != nil {
65 return nil, "", fmt.Errorf("read upload: %w", err)
66 }
67 if int64(len(raw)) > MaxUploadBytes {
68 return nil, "", ErrTooLarge
69 }
70
71 // Decode metadata first so we can reject decompression bombs without
72 // allocating the full pixel buffer.
73 cfg, format, err := image.DecodeConfig(bytes.NewReader(raw))
74 if err != nil {
75 return nil, "", ErrDecode
76 }
77 if !isSupportedFormat(format) {
78 return nil, "", ErrUnsupported
79 }
80 if int64(cfg.Width)*int64(cfg.Height) > MaxPixelArea {
81 return nil, "", ErrDecompression
82 }
83
84 src, _, err := image.Decode(bytes.NewReader(raw))
85 if err != nil {
86 return nil, "", ErrDecode
87 }
88
89 // Square-crop to the shorter side so the resize doesn't squash.
90 cropped := centerSquareCrop(src)
91
92 out := make([]Variant, 0, len(VariantSizes))
93 for _, size := range VariantSizes {
94 dst := image.NewRGBA(image.Rect(0, 0, size, size))
95 draw.CatmullRom.Scale(dst, dst.Bounds(), cropped, cropped.Bounds(), draw.Over, nil)
96 buf := &bytes.Buffer{}
97 if err := png.Encode(buf, dst); err != nil {
98 return nil, "", fmt.Errorf("encode %dpx: %w", size, err)
99 }
100 out = append(out, Variant{Size: size, Data: buf.Bytes()})
101 }
102
103 // Content-addressed key from the largest (first) variant. Avatar URLs
104 // embed this hash so caches invalidate when the image changes.
105 digest := sha256.Sum256(out[0].Data)
106 return out, hex.EncodeToString(digest[:])[:16], nil
107 }
108
109 // isSupportedFormat whitelists the formats we'll accept. PNG, JPEG, GIF
110 // are the GitHub-set; we re-encode all to PNG so output is uniform.
111 func isSupportedFormat(format string) bool {
112 switch strings.ToLower(format) {
113 case "png", "jpeg", "gif":
114 return true
115 }
116 return false
117 }
118
119 // centerSquareCrop returns a centered square sub-image of src. If src is
120 // already square, returns src unchanged.
121 func centerSquareCrop(src image.Image) image.Image {
122 b := src.Bounds()
123 w, h := b.Dx(), b.Dy()
124 if w == h {
125 return src
126 }
127 side := w
128 if h < side {
129 side = h
130 }
131 x0 := b.Min.X + (w-side)/2
132 y0 := b.Min.Y + (h-side)/2
133 rect := image.Rect(x0, y0, x0+side, y0+side)
134 type subImager interface {
135 SubImage(r image.Rectangle) image.Image
136 }
137 if si, ok := src.(subImager); ok {
138 return si.SubImage(rect)
139 }
140 // Fallback: copy the cropped region.
141 dst := image.NewRGBA(image.Rect(0, 0, side, side))
142 draw.Copy(dst, image.Point{}, src, rect, draw.Src, nil)
143 return dst
144 }
145