avatar.go
⎇
Raw
1// Package avatar stores user avatars as 128x128 PNG files.
2package avatar
3
4import (
5 "bytes"
6 "fmt"
7 "image"
8 "image/png"
9 "math"
10 "os"
11 "path/filepath"
12 "strconv"
13 "strings"
14
15 // Register the decoders we accept for uploads.
16 _ "image/gif"
17 _ "image/jpeg"
18 _ "image/png"
19
20 "github.com/gabriel-vasile/mimetype"
21 _ "golang.org/x/image/bmp"
22 "golang.org/x/image/draw"
23 _ "golang.org/x/image/tiff"
24 _ "golang.org/x/image/webp"
25)
26
27// Size is the stored avatar edge length in pixels.
28const Size = 128
29
30// maxInputPixels caps the decoded source image. A small compressed file can
31// claim 16k x 16k and force a ~1 GB allocation. 16 MP is far above any
32// plausible avatar source.
33const maxInputPixels = 4096 * 4096
34
35// Path returns the file path of a user's avatar.
36func Path(dir string, userID int64) string {
37 return filepath.Join(dir, strconv.FormatInt(userID, 10)+".png")
38}
39
40// Save decodes an uploaded image, crops it to a centered square, scales it to
41// 128x128 and writes it as PNG.
42func Save(dir string, userID int64, upload []byte) error {
43 mt := mimetype.Detect(upload)
44 if !strings.HasPrefix(mt.String(), "image/") {
45 return fmt.Errorf("invalid image type %q", mt.String())
46 }
47
48 cfg, _, err := image.DecodeConfig(bytes.NewReader(upload))
49 if err != nil {
50 return fmt.Errorf("decode config: %w", err)
51 }
52 if cfg.Width <= 0 || cfg.Height <= 0 || int64(cfg.Width)*int64(cfg.Height) > maxInputPixels {
53 return fmt.Errorf("image too large: %dx%d", cfg.Width, cfg.Height)
54 }
55
56 src, _, err := image.Decode(bytes.NewReader(upload))
57 if err != nil {
58 return fmt.Errorf("decode image: %w", err)
59 }
60
61 dst := image.NewNRGBA(image.Rect(0, 0, Size, Size))
62 draw.CatmullRom.Scale(dst, dst.Bounds(), src, coverRect(src.Bounds()), draw.Src, nil)
63 return write(dir, userID, dst)
64}
65
66// coverRect returns the largest centered square inside b.
67// Scaling that square to the square output reproduces "cover" with center
68// position: the long axis is cropped evenly on both sides.
69func coverRect(b image.Rectangle) image.Rectangle {
70 side := min(b.Dx(), b.Dy())
71 x := b.Min.X + (b.Dx()-side)/2
72 y := b.Min.Y + (b.Dy()-side)/2
73 return image.Rect(x, y, x+side, y+side)
74}
75
76func write(dir string, userID int64, img image.Image) error {
77 if err := os.MkdirAll(dir, 0o755); err != nil {
78 return err
79 }
80 var buf bytes.Buffer
81 if err := png.Encode(&buf, img); err != nil {
82 return err
83 }
84 return os.WriteFile(Path(dir, userID), buf.Bytes(), 0o644)
85}
86
87// SaveDefault writes a generated identicon for the user.
88// The pixel algorithm is fixed, so a user always gets the same picture.
89func SaveDefault(dir string, userID int64, username string) error {
90 return write(dir, userID, Identicon(username))
91}
92
93// Identicon draws a 5x5 symmetric identicon on a #f0f0f0 background.
94func Identicon(username string) *image.NRGBA {
95 b := hashBytes(username, 16)
96
97 hue := float64((int(b[0]) | int(b[1])<<8) % 360)
98 sat := float64(int(b[2])%20 + 65) // 65-84%
99 lit := float64(int(b[3])%20 + 40) // 40-59%
100 fr, fg, fb := hslToRGB(hue, sat, lit)
101
102 // 2*24 padding + 5*16 cells = 128
103 const padding, cell = 24, 16
104 img := image.NewNRGBA(image.Rect(0, 0, Size, Size))
105 for i := 0; i < len(img.Pix); i += 4 {
106 img.Pix[i], img.Pix[i+1], img.Pix[i+2], img.Pix[i+3] = 240, 240, 240, 255
107 }
108
109 for row := range 5 {
110 for col := range 5 {
111 srcCol := col
112 if col >= 3 {
113 srcCol = 4 - col
114 }
115 if b[row*3+srcCol]&1 == 0 {
116 continue
117 }
118 x0, y0 := padding+col*cell, padding+row*cell
119 for y := y0; y < y0+cell; y++ {
120 for x := x0; x < x0+cell; x++ {
121 i := y*img.Stride + x*4
122 img.Pix[i], img.Pix[i+1], img.Pix[i+2] = fr, fg, fb
123 }
124 }
125 }
126 }
127 return img
128}
129
130// hashBytes derives n deterministic bytes from a string with FNV-1a.
131// It mirrors the JavaScript original, which mixes each character's first
132// UTF-16 code unit.
133func hashBytes(s string, n int) []byte {
134 var h uint32 = 0x811c9dc5
135 for _, r := range s {
136 h ^= uint32(utf16First(r))
137 h *= 0x01000193
138 }
139 out := make([]byte, 0, n+4)
140 for len(out) < n {
141 h = (h ^ uint32(len(out)&0xff)) * 0x01000193
142 out = append(out, byte(h), byte(h>>8), byte(h>>16), byte(h>>24))
143 }
144 return out[:n]
145}
146
147// utf16First returns the first UTF-16 code unit of a rune.
148// JavaScript's charCodeAt(0) on a code point yields the high surrogate for
149// non-BMP characters.
150func utf16First(r rune) uint16 {
151 if r > 0xffff {
152 return uint16(0xd800 + ((r - 0x10000) >> 10))
153 }
154 return uint16(r)
155}
156
157// hslToRGB converts HSL with h in degrees and s, l in percent.
158func hslToRGB(h, s, l float64) (uint8, uint8, uint8) {
159 s /= 100
160 l /= 100
161 a := s * math.Min(l, 1-l)
162 f := func(n float64) uint8 {
163 k := math.Mod(n+h/30, 12)
164 v := l - a*math.Max(-1, math.Min(math.Min(k-3, 9-k), 1))
165 return uint8(math.Round(v * 255))
166 }
167 return f(0), f(8), f(4)
168}
169