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 | } | |