// Package avatar stores user avatars as 128x128 PNG files. package avatar import ( "bytes" "fmt" "image" "image/png" "math" "os" "path/filepath" "strconv" "strings" // Register the decoders we accept for uploads. _ "image/gif" _ "image/jpeg" _ "image/png" "github.com/gabriel-vasile/mimetype" _ "golang.org/x/image/bmp" "golang.org/x/image/draw" _ "golang.org/x/image/tiff" _ "golang.org/x/image/webp" ) // Size is the stored avatar edge length in pixels. const Size = 128 // maxInputPixels caps the decoded source image. A small compressed file can // claim 16k x 16k and force a ~1 GB allocation. 16 MP is far above any // plausible avatar source. const maxInputPixels = 4096 * 4096 // Path returns the file path of a user's avatar. func Path(dir string, userID int64) string { return filepath.Join(dir, strconv.FormatInt(userID, 10)+".png") } // Save decodes an uploaded image, crops it to a centered square, scales it to // 128x128 and writes it as PNG. func Save(dir string, userID int64, upload []byte) error { mt := mimetype.Detect(upload) if !strings.HasPrefix(mt.String(), "image/") { return fmt.Errorf("invalid image type %q", mt.String()) } cfg, _, err := image.DecodeConfig(bytes.NewReader(upload)) if err != nil { return fmt.Errorf("decode config: %w", err) } if cfg.Width <= 0 || cfg.Height <= 0 || int64(cfg.Width)*int64(cfg.Height) > maxInputPixels { return fmt.Errorf("image too large: %dx%d", cfg.Width, cfg.Height) } src, _, err := image.Decode(bytes.NewReader(upload)) if err != nil { return fmt.Errorf("decode image: %w", err) } dst := image.NewNRGBA(image.Rect(0, 0, Size, Size)) draw.CatmullRom.Scale(dst, dst.Bounds(), src, coverRect(src.Bounds()), draw.Src, nil) return write(dir, userID, dst) } // coverRect returns the largest centered square inside b. // Scaling that square to the square output reproduces "cover" with center // position: the long axis is cropped evenly on both sides. func coverRect(b image.Rectangle) image.Rectangle { side := min(b.Dx(), b.Dy()) x := b.Min.X + (b.Dx()-side)/2 y := b.Min.Y + (b.Dy()-side)/2 return image.Rect(x, y, x+side, y+side) } func write(dir string, userID int64, img image.Image) error { if err := os.MkdirAll(dir, 0o755); err != nil { return err } var buf bytes.Buffer if err := png.Encode(&buf, img); err != nil { return err } return os.WriteFile(Path(dir, userID), buf.Bytes(), 0o644) } // SaveDefault writes a generated identicon for the user. // The pixel algorithm is fixed, so a user always gets the same picture. func SaveDefault(dir string, userID int64, username string) error { return write(dir, userID, Identicon(username)) } // Identicon draws a 5x5 symmetric identicon on a #f0f0f0 background. func Identicon(username string) *image.NRGBA { b := hashBytes(username, 16) hue := float64((int(b[0]) | int(b[1])<<8) % 360) sat := float64(int(b[2])%20 + 65) // 65-84% lit := float64(int(b[3])%20 + 40) // 40-59% fr, fg, fb := hslToRGB(hue, sat, lit) // 2*24 padding + 5*16 cells = 128 const padding, cell = 24, 16 img := image.NewNRGBA(image.Rect(0, 0, Size, Size)) for i := 0; i < len(img.Pix); i += 4 { img.Pix[i], img.Pix[i+1], img.Pix[i+2], img.Pix[i+3] = 240, 240, 240, 255 } for row := range 5 { for col := range 5 { srcCol := col if col >= 3 { srcCol = 4 - col } if b[row*3+srcCol]&1 == 0 { continue } x0, y0 := padding+col*cell, padding+row*cell for y := y0; y < y0+cell; y++ { for x := x0; x < x0+cell; x++ { i := y*img.Stride + x*4 img.Pix[i], img.Pix[i+1], img.Pix[i+2] = fr, fg, fb } } } } return img } // hashBytes derives n deterministic bytes from a string with FNV-1a. // It mirrors the JavaScript original, which mixes each character's first // UTF-16 code unit. func hashBytes(s string, n int) []byte { var h uint32 = 0x811c9dc5 for _, r := range s { h ^= uint32(utf16First(r)) h *= 0x01000193 } out := make([]byte, 0, n+4) for len(out) < n { h = (h ^ uint32(len(out)&0xff)) * 0x01000193 out = append(out, byte(h), byte(h>>8), byte(h>>16), byte(h>>24)) } return out[:n] } // utf16First returns the first UTF-16 code unit of a rune. // JavaScript's charCodeAt(0) on a code point yields the high surrogate for // non-BMP characters. func utf16First(r rune) uint16 { if r > 0xffff { return uint16(0xd800 + ((r - 0x10000) >> 10)) } return uint16(r) } // hslToRGB converts HSL with h in degrees and s, l in percent. func hslToRGB(h, s, l float64) (uint8, uint8, uint8) { s /= 100 l /= 100 a := s * math.Min(l, 1-l) f := func(n float64) uint8 { k := math.Mod(n+h/30, 12) v := l - a*math.Max(-1, math.Min(math.Min(k-3, 9-k), 1)) return uint8(math.Round(v * 255)) } return f(0), f(8), f(4) }