| 1 | // SPDX-License-Identifier: AGPL-3.0-or-later |
| 2 | |
| 3 | // Package avatars owns avatar resolution for shithub user profiles. S09 |
| 4 | // ships the deterministic SVG identicon (rendered server-side, no upload |
| 5 | // needed) plus the routing skeleton for serving uploaded avatars from |
| 6 | // object storage. Real upload (resize / EXIF-strip) lands in S10. |
| 7 | package avatars |
| 8 | |
| 9 | import ( |
| 10 | "crypto/sha256" |
| 11 | "fmt" |
| 12 | "strings" |
| 13 | ) |
| 14 | |
| 15 | // IdenticonSize is the SVG viewBox side length. The image is square and |
| 16 | // renders at any CSS px size via width/height attributes. |
| 17 | const IdenticonSize = 5 |
| 18 | |
| 19 | // palette is the fixed accent set used to color identicons. Sourced from |
| 20 | // Primer's accent ramp so the result feels native to the rest of the |
| 21 | // shithub chrome. 12 entries gives reasonable variety without |
| 22 | // overwhelming the page. |
| 23 | var palette = []string{ |
| 24 | "#e85aad", // pink |
| 25 | "#bf3989", // pink-deep |
| 26 | "#a371f7", // purple |
| 27 | "#8957e5", // purple-deep |
| 28 | "#388bfd", // blue |
| 29 | "#1f6feb", // blue-deep |
| 30 | "#3fb950", // green |
| 31 | "#238636", // green-deep |
| 32 | "#d29922", // yellow-deep |
| 33 | "#db6d28", // orange |
| 34 | "#bd561d", // orange-deep |
| 35 | "#f85149", // red |
| 36 | } |
| 37 | |
| 38 | // Identicon returns an inline SVG identicon for username. Same input → |
| 39 | // same output (deterministic). The SVG carries width/height attributes |
| 40 | // so it can be embedded as inline markup OR served as a standalone file. |
| 41 | // |
| 42 | // The pattern is a 5×5 grid mirrored horizontally — left two columns |
| 43 | // are mirrored to the right, the middle column is independent. Each |
| 44 | // cell is colored if the corresponding bit in the digest is 1. |
| 45 | func Identicon(username string, pixelSize int) string { |
| 46 | if pixelSize <= 0 { |
| 47 | pixelSize = 80 |
| 48 | } |
| 49 | digest := sha256.Sum256([]byte(strings.ToLower(username))) |
| 50 | color := palette[int(digest[0])%len(palette)] |
| 51 | |
| 52 | var b strings.Builder |
| 53 | fmt.Fprintf(&b, |
| 54 | `<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" viewBox="0 0 %d %d" shape-rendering="crispEdges" role="img" aria-label="identicon">`, |
| 55 | pixelSize, pixelSize, IdenticonSize, IdenticonSize) |
| 56 | fmt.Fprintf(&b, `<rect width="%d" height="%d" fill="#21262d"/>`, IdenticonSize, IdenticonSize) |
| 57 | |
| 58 | // Walk 15 cells (5 rows × 3 unique columns). Mirror the first two |
| 59 | // columns onto the last two so the identicon is symmetric. |
| 60 | bit := 0 |
| 61 | for row := 0; row < IdenticonSize; row++ { |
| 62 | for col := 0; col < 3; col++ { |
| 63 | b8 := digest[1+(bit/8)] // bytes 1..15 cover the 25 bits we sample |
| 64 | on := (b8 >> (uint(bit) % 8)) & 1 |
| 65 | bit++ |
| 66 | if on != 1 { |
| 67 | continue |
| 68 | } |
| 69 | fmt.Fprintf(&b, `<rect x="%d" y="%d" width="1" height="1" fill="%s"/>`, col, row, color) |
| 70 | if col < 2 { |
| 71 | mirror := IdenticonSize - 1 - col |
| 72 | fmt.Fprintf(&b, `<rect x="%d" y="%d" width="1" height="1" fill="%s"/>`, mirror, row, color) |
| 73 | } |
| 74 | } |
| 75 | } |
| 76 | b.WriteString(`</svg>`) |
| 77 | return b.String() |
| 78 | } |
| 79 |