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.
Simplify the home page around the instance introduction
ff9870f1a26a17d54c21d411000a6c7d58ab5c0d · Keenan Nemetz · 1d ago · parent 6ce635a8
Simplify the home page around the instance introduction Drop the page header and its hero actions. The instance name and sign-in links already live in the top bar, and the introduction is the place to say what the forge is. Move New repository into the Repositories heading, where it sits with the list it creates. Remove the introduction card header and its in-page editor. The introduction is profile/README.md in the instance repository, so it is read and pushed like every other customization. The POST / handler and its editor code go with it; an instance admin without an instance repository still gets the setup hint.
server/landing_test.go −169
| @@ -1,169 +0,0 @@ | |||
| 1 | package server_test | ||
| 2 | |||
| 3 | // The home page's introduction is edited in place by an instance admin and | ||
| 4 | // committed to the instance repository. These tests cover the whole path: the | ||
| 5 | // card, the editor, the commit, the audit record, the render, and removing it. | ||
| 6 | |||
| 7 | import ( | ||
| 8 | "net/url" | ||
| 9 | "os" | ||
| 10 | "strings" | ||
| 11 | "testing" | ||
| 12 | ) | ||
| 13 | |||
| 14 | func TestHomeIntroductionIsEditedInPlace(t *testing.T) { | ||
| 15 | t.Parallel() | ||
| 16 | f := browserFixture(t) | ||
| 17 | f.instanceRepo(t, map[string][]byte{"README.md": []byte("theme notes\n")}) | ||
| 18 | if _, err := f.srv.Identity.SetAdmin(f.ownerID, true, "test"); err != nil { | ||
| 19 | t.Fatal(err) | ||
| 20 | } | ||
| 21 | sess, cookie := f.browserSession(t, f.ownerID) | ||
| 22 | |||
| 23 | // An admin with no introduction still gets the card, so the page invites the | ||
| 24 | // first edit instead of showing an empty column. | ||
| 25 | _, body := f.browserGet(t, "/", cookie) | ||
| 26 | for _, want := range []string{ | ||
| 27 | `class="panel readme-card"`, `href="/?edit=1#readme"`, "no introduction yet", | ||
| 28 | } { | ||
| 29 | if !strings.Contains(body, want) { | ||
| 30 | t.Fatalf("empty home card is missing %q:\n%s", want, body) | ||
| 31 | } | ||
| 32 | } | ||
| 33 | |||
| 34 | // The editor opens on the same page, with the form that saves it. | ||
| 35 | resp, body := f.browserGet(t, "/?edit=1", cookie) | ||
| 36 | if resp.StatusCode != 200 { | ||
| 37 | t.Fatalf("edit mode = %d:\n%s", resp.StatusCode, body) | ||
| 38 | } | ||
| 39 | for _, want := range []string{ | ||
| 40 | `action="/"`, `name="intro"`, `name="csrf"`, "Markdown", "Cancel", | ||
| 41 | } { | ||
| 42 | if !strings.Contains(body, want) { | ||
| 43 | t.Fatalf("edit mode is missing %q:\n%s", want, body) | ||
| 44 | } | ||
| 45 | } | ||
| 46 | |||
| 47 | form := url.Values{ | ||
| 48 | "csrf": {sess.CSRF}, | ||
| 49 | "intro": {"# Start here\n\nBegin with the [guide](/docs) and the issues.\n\n<script>alert(1)</script>"}, | ||
| 50 | } | ||
| 51 | resp, _ = f.browserPost(t, "/", cookie, form) | ||
| 52 | if resp.StatusCode != 303 || resp.Header.Get("Location") != "/" { | ||
| 53 | t.Fatalf("save = %d %q", resp.StatusCode, resp.Header.Get("Location")) | ||
| 54 | } | ||
| 55 | |||
| 56 | _, body = f.get("/") | ||
| 57 | for _, want := range []string{ | ||
| 58 | "<h1>Vole</h1>", | ||
| 59 | `class="panel readme-card"`, `class="md readme-body"`, | ||
| 60 | "<h1>Start here</h1>", `href="/docs"`, | ||
| 61 | } { | ||
| 62 | if !strings.Contains(body, want) { | ||
| 63 | t.Fatalf("home is missing %q after the save:\n%s", want, body) | ||
| 64 | } | ||
| 65 | } | ||
| 66 | if strings.Contains(body, "<script>alert") { | ||
| 67 | t.Fatalf("the introduction was not sanitized:\n%s", body) | ||
| 68 | } | ||
| 69 | |||
| 70 | audit, err := os.ReadFile(f.cfg.AuditLog()) | ||
| 71 | if err != nil { | ||
| 72 | t.Fatal(err) | ||
| 73 | } | ||
| 74 | if !strings.Contains(string(audit), "web.home.intro") { | ||
| 75 | t.Fatalf("the introduction change was not audited:\n%s", audit) | ||
| 76 | } | ||
| 77 | // The text is in the repository, and the commit is its history. | ||
| 78 | m, err := f.srv.Repos.Open(f.cfg.InstanceRepo) | ||
| 79 | if err != nil { | ||
| 80 | t.Fatal(err) | ||
| 81 | } | ||
| 82 | saved, err := m.Git().BlobAt("refs/heads/"+m.DefaultBranch, "profile/README.md") | ||
| 83 | if err != nil || !strings.Contains(string(saved), "# Start here") { | ||
| 84 | t.Fatalf("profile/README.md = %q, %v", saved, err) | ||
| 85 | } | ||
| 86 | |||
| 87 | // An empty form removes the card's body, and for a visitor the card itself. | ||
| 88 | form.Set("intro", "") | ||
| 89 | if resp, _ := f.browserPost(t, "/", cookie, form); resp.StatusCode != 303 { | ||
| 90 | t.Fatalf("clear = %d", resp.StatusCode) | ||
| 91 | } | ||
| 92 | _, body = f.get("/") | ||
| 93 | if strings.Contains(body, "<h1>Start here</h1>") { | ||
| 94 | t.Fatalf("clearing the introduction did not remove it:\n%s", body) | ||
| 95 | } | ||
| 96 | if strings.Contains(body, `readme-card`) { | ||
| 97 | t.Fatalf("a visitor sees an empty introduction card:\n%s", body) | ||
| 98 | } | ||
| 99 | if _, err := m.Git().BlobAt("refs/heads/"+m.DefaultBranch, "profile/README.md"); err == nil { | ||
| 100 | t.Fatal("clearing the introduction left profile/README.md in the repository") | ||
| 101 | } | ||
| 102 | } | ||
| 103 | |||
| 104 | func TestHomeIntroEditIsInstanceAdminOnly(t *testing.T) { | ||
| 105 | t.Parallel() | ||
| 106 | f := browserFixture(t) | ||
| 107 | f.addWriter(t) | ||
| 108 | sess, cookie := f.browserSession(t, f.writerID) | ||
| 109 | |||
| 110 | _, body := f.browserGet(t, "/?edit=1", cookie) | ||
| 111 | for _, unwanted := range []string{`name="intro"`, `href="/?edit=1#readme"`} { | ||
| 112 | if strings.Contains(body, unwanted) { | ||
| 113 | t.Fatalf("a non-admin got %q:\n%s", unwanted, body) | ||
| 114 | } | ||
| 115 | } | ||
| 116 | form := url.Values{"csrf": {sess.CSRF}, "intro": {"# Hijacked"}} | ||
| 117 | resp, _ := f.browserPost(t, "/", cookie, form) | ||
| 118 | if resp.StatusCode != 404 { | ||
| 119 | t.Fatalf("non-admin save = %d, want 404", resp.StatusCode) | ||
| 120 | } | ||
| 121 | _, body = f.get("/") | ||
| 122 | if strings.Contains(body, "Hijacked") { | ||
| 123 | t.Fatalf("a non-admin changed the introduction:\n%s", body) | ||
| 124 | } | ||
| 125 | } | ||
| 126 | |||
| 127 | // A rejected save reopens the editor with the submitted text, so a paragraph the | ||
| 128 | // admin pasted does not have to be pasted a second time. | ||
| 129 | func TestHomeIntroRejectsWhatItCannotSave(t *testing.T) { | ||
| 130 | t.Parallel() | ||
| 131 | f := browserFixture(t) | ||
| 132 | f.instanceRepo(t, map[string][]byte{"README.md": []byte("theme notes\n")}) | ||
| 133 | if _, err := f.srv.Identity.SetAdmin(f.ownerID, true, "test"); err != nil { | ||
| 134 | t.Fatal(err) | ||
| 135 | } | ||
| 136 | sess, cookie := f.browserSession(t, f.ownerID) | ||
| 137 | |||
| 138 | long := "MARKER" + strings.Repeat("a", 20000) | ||
| 139 | form := url.Values{"csrf": {sess.CSRF}, "intro": {long}} | ||
| 140 | resp, body := f.browserPost(t, "/", cookie, form) | ||
| 141 | if resp.StatusCode != 400 || !strings.Contains(body, "longer than 20000 bytes") { | ||
| 142 | t.Fatalf("long introduction = %d:\n%s", resp.StatusCode, body) | ||
| 143 | } | ||
| 144 | if !strings.Contains(body, "MARKER") || !strings.Contains(body, `name="intro"`) { | ||
| 145 | t.Fatalf("a rejected save lost the submitted text or the editor:\n%s", body) | ||
| 146 | } | ||
| 147 | } | ||
| 148 | |||
| 149 | func TestHomeIntroIsNotOnSearchResults(t *testing.T) { | ||
| 150 | t.Parallel() | ||
| 151 | f := browserFixture(t) | ||
| 152 | f.instanceRepo(t, map[string][]byte{"README.md": []byte("theme notes\n")}) | ||
| 153 | if _, err := f.srv.Identity.SetAdmin(f.ownerID, true, "test"); err != nil { | ||
| 154 | t.Fatal(err) | ||
| 155 | } | ||
| 156 | sess, cookie := f.browserSession(t, f.ownerID) | ||
| 157 | form := url.Values{"csrf": {sess.CSRF}, "intro": {"# Start here"}} | ||
| 158 | if resp, _ := f.browserPost(t, "/", cookie, form); resp.StatusCode != 303 { | ||
| 159 | t.Fatalf("save = %d", resp.StatusCode) | ||
| 160 | } | ||
| 161 | |||
| 162 | _, body := f.get("/?q=nothing-matches-this") | ||
| 163 | if !strings.Contains(body, "No repositories match") { | ||
| 164 | t.Fatalf("search did not render its empty state:\n%s", body) | ||
| 165 | } | ||
| 166 | if strings.Contains(body, "<h1>Start here</h1>") || strings.Contains(body, `readme-card`) { | ||
| 167 | t.Fatalf("search results carry the landing introduction:\n%s", body) | ||
| 168 | } | ||
| 169 | } | ||