tenseleyflow/shithub / c090746

Browse files

S10: avatars.Process — decode + EXIF-strip + center-crop + resize variants

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
c090746be2ed41c9c5653b77da54c5297dc975fb
Parents
6baf2b6
Tree
cb6f0f1

4 changed files

StatusFile+-
M go.mod 1 0
M go.sum 2 0
A internal/avatars/upload.go 144 0
A internal/avatars/upload_test.go 116 0
go.modmodified
@@ -58,6 +58,7 @@ require (
5858
 	go.uber.org/multierr v1.11.0 // indirect
5959
 	go.yaml.in/yaml/v2 v2.4.2 // indirect
6060
 	go.yaml.in/yaml/v3 v3.0.4 // indirect
61
+	golang.org/x/image v0.39.0 // indirect
6162
 	golang.org/x/net v0.53.0 // indirect
6263
 	golang.org/x/sync v0.20.0 // indirect
6364
 	golang.org/x/sys v0.43.0 // indirect
go.summodified
@@ -144,6 +144,8 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
144144
 go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
145145
 golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
146146
 golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
147
+golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
148
+golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
147149
 golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
148150
 golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
149151
 golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
internal/avatars/upload.goadded
@@ -0,0 +1,144 @@
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
+}
internal/avatars/upload_test.goadded
@@ -0,0 +1,116 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package avatars_test
4
+
5
+import (
6
+	"bytes"
7
+	"image"
8
+	"image/color"
9
+	"image/jpeg"
10
+	"image/png"
11
+	"strings"
12
+	"testing"
13
+
14
+	"github.com/tenseleyFlow/shithub/internal/avatars"
15
+)
16
+
17
+func makePNG(t *testing.T, w, h int) []byte {
18
+	t.Helper()
19
+	img := image.NewRGBA(image.Rect(0, 0, w, h))
20
+	for y := 0; y < h; y++ {
21
+		for x := 0; x < w; x++ {
22
+			img.Set(x, y, color.RGBA{R: 200, G: 100, B: 50, A: 255})
23
+		}
24
+	}
25
+	buf := &bytes.Buffer{}
26
+	if err := png.Encode(buf, img); err != nil {
27
+		t.Fatalf("encode: %v", err)
28
+	}
29
+	return buf.Bytes()
30
+}
31
+
32
+func makeJPEG(t *testing.T, w, h int) []byte {
33
+	t.Helper()
34
+	img := image.NewRGBA(image.Rect(0, 0, w, h))
35
+	buf := &bytes.Buffer{}
36
+	if err := jpeg.Encode(buf, img, &jpeg.Options{Quality: 80}); err != nil {
37
+		t.Fatalf("encode: %v", err)
38
+	}
39
+	return buf.Bytes()
40
+}
41
+
42
+func TestProcess_PNGRoundtrip(t *testing.T) {
43
+	t.Parallel()
44
+	src := makePNG(t, 800, 600) // landscape; expect center-crop to 600×600.
45
+	variants, hash, err := avatars.Process(bytes.NewReader(src))
46
+	if err != nil {
47
+		t.Fatalf("process: %v", err)
48
+	}
49
+	if len(variants) != len(avatars.VariantSizes) {
50
+		t.Fatalf("variants = %d, want %d", len(variants), len(avatars.VariantSizes))
51
+	}
52
+	for i, want := range avatars.VariantSizes {
53
+		v := variants[i]
54
+		if v.Size != want {
55
+			t.Errorf("variant[%d].Size = %d, want %d", i, v.Size, want)
56
+		}
57
+		cfg, err := png.DecodeConfig(bytes.NewReader(v.Data))
58
+		if err != nil {
59
+			t.Errorf("variant[%d] decode: %v", i, err)
60
+			continue
61
+		}
62
+		if cfg.Width != want || cfg.Height != want {
63
+			t.Errorf("variant[%d] dims = %dx%d, want %dx%d", i, cfg.Width, cfg.Height, want, want)
64
+		}
65
+	}
66
+	if len(hash) == 0 {
67
+		t.Fatal("hash is empty")
68
+	}
69
+}
70
+
71
+func TestProcess_JPEGAccepted(t *testing.T) {
72
+	t.Parallel()
73
+	src := makeJPEG(t, 1000, 1000)
74
+	if _, _, err := avatars.Process(bytes.NewReader(src)); err != nil {
75
+		t.Fatalf("jpeg accepted: %v", err)
76
+	}
77
+}
78
+
79
+func TestProcess_RejectsUnsupportedFormat(t *testing.T) {
80
+	t.Parallel()
81
+	_, _, err := avatars.Process(strings.NewReader("not an image at all"))
82
+	if err == nil {
83
+		t.Fatal("expected error for non-image input")
84
+	}
85
+}
86
+
87
+func TestProcess_RejectsTooLarge(t *testing.T) {
88
+	t.Parallel()
89
+	// Build a payload that exceeds MaxUploadBytes.
90
+	big := make([]byte, avatars.MaxUploadBytes+10)
91
+	_, _, err := avatars.Process(bytes.NewReader(big))
92
+	if err != avatars.ErrTooLarge {
93
+		t.Fatalf("err = %v, want ErrTooLarge", err)
94
+	}
95
+}
96
+
97
+func TestProcess_RejectsDecompressionBomb(t *testing.T) {
98
+	t.Parallel()
99
+	// Construct a tiny PNG header that *claims* huge dimensions but
100
+	// stays well under MaxUploadBytes.
101
+	// 10000 × 10000 = 100M pixels > 24M cap.
102
+	// Build a real but huge-size PNG using NewPaletted to keep file size low.
103
+	pal := []color.Color{color.White, color.Black}
104
+	img := image.NewPaletted(image.Rect(0, 0, 8000, 8000), pal)
105
+	buf := &bytes.Buffer{}
106
+	if err := png.Encode(buf, img); err != nil {
107
+		t.Fatalf("encode: %v", err)
108
+	}
109
+	if buf.Len() > avatars.MaxUploadBytes {
110
+		t.Skipf("constructed PNG (%d bytes) exceeds MaxUploadBytes; skipping", buf.Len())
111
+	}
112
+	_, _, err := avatars.Process(bytes.NewReader(buf.Bytes()))
113
+	if err != avatars.ErrDecompression {
114
+		t.Fatalf("err = %v, want ErrDecompression", err)
115
+	}
116
+}