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.

Customize the instance from an instance repository

6ce635a84ad5d5d0c862d4deb4d46489dc97c677 · Keenan Nemetz · 1d ago · parent f2fd4024

Customize the instance from an instance repository

The landing page's prose was config-only, so changing it meant editing
vole.toml on the host and restarting, and the packaged service cannot write
that file. Custom CSS, assets, and a favicon had nowhere to live at all.

This adds an instance repository, modelled on GitHub's .github repository:
fixed paths in one private repository, read as data rather than executed.

    profile/README.md           the landing page introduction
    profile/private/README.md   replaces it for signed-in viewers
    profile/custom.css          loaded after the built-in stylesheets
    profile/favicon.svg         favicon override
    assets/                     served at /static/custom/assets/

forge/instancerepo reads one commit into an immutable snapshot on a two
second timer and swaps it atomically; a request never runs git, and a load
failure keeps the previous theme. Markdown goes through the issue sanitiser,
assets are an extension whitelist with size and count caps, and scripts and
HTML are not assets. The repository is private to instance admins and hidden
from the home grid and activity rail, but its files are served to visitors.

The Admin Configuration tab grows an Instance repository panel: status, the
last commit, the last load error, the clone URL, the recognized paths, and a
button that creates the repository with starter files. The home page's pencil
keeps the in-place editor, but a save is now a commit to profile/README.md
with the admin as the author and web.home.intro in the audit log, instead of a
row in users.db.

The rest of the landing page stays in the shape it had: instance_name as the
heading, the introduction card, then the repository grid beside the Latest
rail. Moving a configurable page layout into a template system was tried and
dropped: this is fixed files, not a templating surface.

This replaces the pending introduction-store change, which is closed.

server/landing_test.go +169

@@ -0,0 +1,169 @@
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 }