Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

A free and open-source forge that lives in your Git repo.

Draw avatars with DiceBear instead of by hand

97f77001eed76cc287aeba532c4d670488121a18 · Keenan Nemetz · 8d ago · parent ca098f62

Draw avatars with DiceBear instead of by hand

The marks this package drew itself were a design I kept losing arguments
with. Avatars are now DiceBear's Shapes style, which is what the request
asked for.

DiceBear is JavaScript and Vole is a Go binary, so the split is: the
server renders the account's initial on its Everforest ground, and an
embedded bundle replaces it with the mark when the page runs. The
initial is what a browser without scripts keeps, so nothing depends on
the bundle arriving; the seed is the account name, so the same person
draws the same mark everywhere; and the ground is read from the element,
so vole.css remains the only palette.

The bundle is core 9.3.1 plus Shapes 9.4.2 (MIT code, CC0 design), built
with esbuild and committed like the other vendored scripts: 18KB, 5.5KB
gzipped. Its source is scripts/avatars-entry.js, and the licence files
beside it are inventoried in NOTICE, so the verify workflow's licence
check keeps passing. No request leaves the instance and no avatar is
stored.

Two details worth naming. Each mark gets a unique clip-mask id, because
SVG ids are document-wide and a directory page holds fifty of them. And
the RDF metadata block DiceBear embeds is stripped: it is identical for
every avatar and its licence is in NOTICE, so repeating it in the DOM
would be weight without information.

server/web/avatar_test.go +87−91

@@ -1,98 +1,71 @@
1 1 package web
2 2
3 // The mark is a deterministic function of the name, and the markup is built from
4 // geometry rather than from the name. These tests hold both: the same name draws
5 // the same mark, different names draw different marks, and a name cannot put
6 // anything into the SVG.
3 // The server renders the ground and the fallback letter; the mark itself is drawn
4 // in the browser by the embedded DiceBear bundle. These tests hold the server's
5 // half of that contract: a stable ground, a stable letter, the name as the seed,
6 // a profile link only for real accounts, and the assets the browser needs.
7 7
8 8 import (
9 "encoding/xml"
10 "io"
9 "bytes"
11 10 "strings"
12 11 "testing"
13 12 )
14 13
15 func wellFormed(t *testing.T, markup string) {
16 t.Helper()
17 decoder := xml.NewDecoder(strings.NewReader(markup))
18 for {
19 _, err := decoder.Token()
20 if err == io.EOF {
21 return
22 }
23 if err != nil {
24 t.Fatalf("avatar is not well-formed XML: %v\n%s", err, markup)
25 }
26 }
27 }
28
29 14 func TestAvatarIsStableForAName(t *testing.T) {
30 15 t.Parallel()
31 16 first := portrait("alice")
32 17 for i := 0; i < 5; i++ {
33 18 again := portrait("alice")
34 if again.Tone != first.Tone || again.SVG != first.SVG {
35 t.Fatalf("mark for alice changed between calls:\n%s\n%s", first.SVG, again.SVG)
19 if again.Tone != first.Tone || again.Initial != first.Initial {
20 t.Fatalf("avatar for alice changed between calls: %+v then %+v", first, again)
36 21 }
37 22 }
38 if portrait("alice").Tone != portrait("alice").Tone {
39 t.Fatal("tone is not deterministic")
23 if first.Initial != "A" {
24 t.Fatalf("initial = %q, want A", first.Initial)
40 25 }
41 // A name is not case-folded into another account's face by accident: the
42 // canonical account name is what the caller passes, and that is what varies.
43 if portrait("alice").SVG == portrait("bob").SVG && portrait("alice").Tone == portrait("bob").Tone {
44 t.Fatal("two names drew the same mark")
26 if portrait("").Initial != "?" {
27 t.Fatalf("empty name initial = %q, want ?", portrait("").Initial)
45 28 }
46 }
47
48 func TestAvatarVariesAcrossNames(t *testing.T) {
49 t.Parallel()
50 tones := map[int]bool{}
51 faces := map[string]bool{}
52 for _, name := range []string{
53 "alice", "bob", "adam", "keenan", "wilbur", "coding-agent", "reviewer",
54 "a", "z", "aa", "ab", "ba", "someone-with-a-long-name", "x1", "x2",
55 "moss", "bark", "clay", "river", "heather", "straw", "stone",
56 } {
57 v := portrait(name)
58 tones[v.Tone] = true
59 faces[string(v.SVG)] = true
60 wellFormed(t, string(v.SVG))
61 }
62 if len(tones) < 5 {
63 t.Fatalf("only %d of 7 tones appeared: %v", len(tones), tones)
64 }
65 if len(faces) < 15 {
66 t.Fatalf("only %d distinct faces across 22 names", len(faces))
29 if portrait("alice").Tone == portrait("bob").Tone && portrait("alice").Tone == portrait("adam").Tone {
30 t.Fatal("three names share one ground, so the tone is not deriving from the name")
67 31 }
68 32 }
69 33
70 34 func TestAvatarToneIsInRange(t *testing.T) {
71 35 t.Parallel()
36 seen := map[int]bool{}
72 37 for i := 0; i < 200; i++ {
73 38 name := "account-" + string(rune('a'+i%26)) + string(rune('a'+i/26))
74 if tone := portrait(name).Tone; tone < 1 || tone > 7 {
75 t.Fatalf("mark for %q has tone %d, want 1..7", name, tone)
39 tone := portrait(name).Tone
40 if tone < 1 || tone > 7 {
41 t.Fatalf("portrait(%q).Tone = %d, want 1..7", name, tone)
76 42 }
43 seen[tone] = true
44 }
45 if len(seen) < 6 {
46 t.Fatalf("only %d of 7 grounds appeared across 200 names", len(seen))
77 47 }
78 48 }
79 49
80 // The name is never interpolated into the markup, so the one place a page hands
81 // the template raw HTML cannot carry user text. This is the test that keeps it
82 // that way: if a future change prints the name into a title or a data attribute,
83 // it fails here.
84 func TestAvatarMarkupCannotCarryTheName(t *testing.T) {
50 // The seed is the name and it reaches the page as an attribute, so the escaping
51 // the template does is all that stands between a hostile name and the DOM. A Git
52 // author is a string somebody typed, so this is a real input and not a
53 // hypothetical one.
54 func TestAvatarMarkupEscapesTheName(t *testing.T) {
85 55 t.Parallel()
86 hostile := `</svg><script>alert(1)</script>`
87 v := portrait(hostile)
88 markup := string(v.SVG)
89 if strings.Contains(markup, "<script") || strings.Contains(markup, "alert") {
90 t.Fatalf("the name reached the markup:\n%s", markup)
56 s := newSite(t)
57 hostile := `"><script>alert(1)</script>`
58 var out bytes.Buffer
59 if err := s.web.Tmpl.ExecuteTemplate(&out, "avatar", s.web.avatar(hostile)); err != nil {
60 t.Fatal(err)
91 61 }
92 if strings.Contains(markup, "svg><svg") {
93 t.Fatalf("the name closed the element:\n%s", markup)
62 markup := out.String()
63 if strings.Contains(markup, "<script") {
64 t.Fatalf("a hostile name escaped the attribute:\n%s", markup)
65 }
66 if !strings.Contains(markup, "data-avatar=") {
67 t.Fatalf("the avatar lost its seed:\n%s", markup)
94 68 }
95 wellFormed(t, markup)
96 69 }
97 70
98 71 func TestAvatarLinkedOnlyWhenTheNameIsAnAccount(t *testing.T) {
@@ -100,17 +73,17 @@ func TestAvatarLinkedOnlyWhenTheNameIsAnAccount(t *testing.T) {
100 73 s := newSite(t)
101 74
102 75 plain := s.web.avatar("alice")
103 if plain.Href != "" {
104 t.Fatalf("a bare mark has an href: %+v", plain)
76 if plain.Href != "" || plain.Name != "alice" {
77 t.Fatalf("a bare avatar = %+v", plain)
105 78 }
106 79 linked := s.web.avatarUser(s.web.userRef("alice"))
107 if linked.Href != "/u/alice" {
108 t.Fatalf("linked mark href = %q", linked.Href)
80 if linked.Href != "/u/alice" || linked.Name != "alice" {
81 t.Fatalf("linked avatar = %+v", linked)
109 82 }
110 // A name that is not an account still gets a face, but no link to a 404.
83 // A name that is not an account still gets an avatar, but no link to a 404.
111 84 stranger := s.web.avatarUser(s.web.userRef("nobody"))
112 if stranger.Href != "" || stranger.SVG == "" {
113 t.Fatalf("unknown account mark = %+v", stranger)
85 if stranger.Href != "" || stranger.Name != "nobody" {
86 t.Fatalf("unknown account avatar = %+v", stranger)
114 87 }
115 88 }
116 89
@@ -118,34 +91,57 @@ func TestAvatarRendersInTheChrome(t *testing.T) {
118 91 t.Parallel()
119 92 s := newSite(t)
120 93 body := s.serve(t, nil, "/u/alice").Body.String()
121 if !strings.Contains(body, `<svg class="vole-avatar`) {
122 t.Fatalf("the profile does not render a mark:\n%s", body)
94
95 // The server's half: a ground, the letter, and the name for the browser.
96 if !strings.Contains(body, `data-avatar="alice"`) {
97 t.Fatalf("the profile does not carry the avatar seed:\n%s", body)
98 }
99 if !strings.Contains(body, `>A</span>`) && !strings.Contains(body, `>A</a>`) {
100 t.Fatalf("the profile has no fallback letter:\n%s", body)
123 101 }
124 // The portrait is decoration: the account name is beside it, so it is hidden
125 // from assistive technology and skipped by Tab.
126 if !strings.Contains(body, `aria-hidden="true"`) {
127 t.Fatalf("the profile mark is not hidden from assistive technology:\n%s", body)
102 // No server-rendered SVG any more: the mark is the browser's job.
103 if strings.Contains(body, `class="vole-avatar"`) {
104 t.Fatalf("the server is still drawing a mark:\n%s", body)
128 105 }
129 if strings.Contains(body, `class="avatar c7"`) && !strings.Contains(body, "<svg") {
130 t.Fatalf("an avatar rendered without its mark:\n%s", body)
106 // The page loads the bundle, and the avatar is decoration beside the name.
107 if !strings.Contains(body, `/static/dicebear-avatars.min.js`) {
108 t.Fatalf("the profile does not load the avatar bundle:\n%s", body)
109 }
110 if !strings.Contains(body, `aria-hidden="true" data-avatar="alice"`) {
111 t.Fatalf("the avatar is not hidden from assistive technology:\n%s", body)
131 112 }
132 113 }
133 114
134 // The mark is one stroke: no fills, no second shape. That is what keeps it quiet
135 // beside a name and legible at 17px, so a later change that adds a second element
136 // has to say so here.
137 func TestAvatarIsOneStroke(t *testing.T) {
115 // The bundle and its licences are embedded like every other asset, because the
116 // binary has to be the whole program and the licence inventory in NOTICE has to
117 // match what ships.
118 func TestAvatarAssetsAreEmbedded(t *testing.T) {
138 119 t.Parallel()
139 markup := string(portrait("alice").SVG)
140 if got := strings.Count(markup, "<path"); got != 1 {
141 t.Fatalf("the mark has %d paths, want 1:\n%s", got, markup)
120 bundle, err := staticFS.ReadFile("static/dicebear-avatars.min.js")
121 if err != nil {
122 t.Fatalf("the avatar bundle is not embedded: %v", err)
123 }
124 if len(bundle) < 10_000 {
125 t.Fatalf("the avatar bundle is %d bytes, which cannot be DiceBear", len(bundle))
142 126 }
143 for _, unwanted := range []string{"<circle", "<ellipse", "<rect", "<polygon"} {
144 if strings.Contains(markup, unwanted) {
145 t.Fatalf("the mark carries %s:\n%s", unwanted, markup)
127 for _, want := range []string{"VoleAvatars", "DiceBear"} {
128 if !strings.Contains(string(bundle), want) {
129 t.Fatalf("the avatar bundle does not mention %q", want)
146 130 }
147 131 }
148 if !strings.Contains(markup, `fill="none"`) {
149 t.Fatalf("the mark does not say it is a stroke, so it would be filled without the sheet:\n%s", markup)
132
133 core, err := staticFS.ReadFile("static/dicebear.LICENSE")
134 if err != nil {
135 t.Fatalf("the DiceBear licence is not embedded: %v", err)
136 }
137 if !strings.Contains(string(core), "MIT License") {
138 t.Fatalf("dicebear.LICENSE does not read as MIT:\n%s", core)
139 }
140 style, err := staticFS.ReadFile("static/dicebear-shapes.LICENSE")
141 if err != nil {
142 t.Fatalf("the Shapes licence is not embedded: %v", err)
143 }
144 if !strings.Contains(string(style), "CC0 1.0") {
145 t.Fatalf("dicebear-shapes.LICENSE does not name CC0:\n%s", style)
150 146 }
151 147 }