vole
Clone
Read only, no credentials:
https://rodent.software/git/vole.git
Write over SSH with a registered key:
git clone ssh://git@0.0.0.0: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/instance_repo_test.go +161
| @@ -0,0 +1,161 @@ | |||
| 1 | package server_test | ||
| 2 | |||
| 3 | // The instance repository is the ".github" idea: fixed paths in one private | ||
| 4 | // repository, read as data. These tests cover the files that reach a page, the | ||
| 5 | // static handler, the private README audience, and the admin scaffold. | ||
| 6 | |||
| 7 | import ( | ||
| 8 | "net/url" | ||
| 9 | "strings" | ||
| 10 | "testing" | ||
| 11 | ) | ||
| 12 | |||
| 13 | func TestInstanceRepoSuppliesThePage(t *testing.T) { | ||
| 14 | t.Parallel() | ||
| 15 | f := browserFixture(t) | ||
| 16 | f.instanceRepo(t, map[string][]byte{ | ||
| 17 | "profile/README.md": []byte("# From the repo\n\nCustom words."), | ||
| 18 | "profile/private/README.md": []byte("# Members only\n\nInternal words."), | ||
| 19 | "profile/custom.css": []byte(":root { --accent: #ff0000; }\n"), | ||
| 20 | "profile/favicon.svg": []byte(`<svg xmlns="http://www.w3.org/2000/svg"></svg>`), | ||
| 21 | "assets/logo.png": []byte("PNG"), | ||
| 22 | "assets/evil.js": []byte("alert(1)"), | ||
| 23 | }) | ||
| 24 | |||
| 25 | _, body := f.get("/") | ||
| 26 | for _, want := range []string{ | ||
| 27 | "From the repo", "Custom words.", | ||
| 28 | `href="/static/custom/profile/custom.css?v=`, | ||
| 29 | `href="/static/custom/profile/favicon.svg?v=`, | ||
| 30 | } { | ||
| 31 | if !strings.Contains(body, want) { | ||
| 32 | t.Fatalf("home is missing %q:\n%s", want, body) | ||
| 33 | } | ||
| 34 | } | ||
| 35 | if strings.Contains(body, "Members only") { | ||
| 36 | t.Fatalf("the private README leaked to an anonymous viewer:\n%s", body) | ||
| 37 | } | ||
| 38 | |||
| 39 | // A signed-in viewer gets the private README instead of the public one. | ||
| 40 | _, cookie := f.browserSession(t, f.ownerID) | ||
| 41 | _, signed := f.browserGet(t, "/", cookie) | ||
| 42 | if !strings.Contains(signed, "Members only") || strings.Contains(signed, "Custom words.") { | ||
| 43 | t.Fatalf("signed-in home did not use the private README:\n%s", signed) | ||
| 44 | } | ||
| 45 | |||
| 46 | resp, css := f.get("/static/custom/profile/custom.css") | ||
| 47 | if resp != 200 || !strings.Contains(css, "--accent") { | ||
| 48 | t.Fatalf("custom.css = %d %q", resp, css) | ||
| 49 | } | ||
| 50 | resp, png := f.get("/static/custom/assets/logo.png") | ||
| 51 | if resp != 200 || png != "PNG" { | ||
| 52 | t.Fatalf("asset = %d %q", resp, png) | ||
| 53 | } | ||
| 54 | // A same-origin script is not an asset. | ||
| 55 | if resp, _ := f.get("/static/custom/assets/evil.js"); resp != 404 { | ||
| 56 | t.Fatalf("a script under assets/ = %d, want 404", resp) | ||
| 57 | } | ||
| 58 | } | ||
| 59 | |||
| 60 | func TestInstanceRepoIsNotAProject(t *testing.T) { | ||
| 61 | t.Parallel() | ||
| 62 | f := newFixture(t) | ||
| 63 | f.publish(f.repo) | ||
| 64 | f.instanceRepo(t, map[string][]byte{ | ||
| 65 | "profile/README.md": []byte("What this forge is for."), | ||
| 66 | }) | ||
| 67 | |||
| 68 | _, body := f.get("/") | ||
| 69 | if strings.Contains(body, `href="/r/`+f.cfg.InstanceRepo+`"`) { | ||
| 70 | t.Fatalf("the instance repository is listed as a project:\n%s", body) | ||
| 71 | } | ||
| 72 | if !strings.Contains(body, `href="/r/demo"`) { | ||
| 73 | t.Fatalf("the project repository disappeared:\n%s", body) | ||
| 74 | } | ||
| 75 | } | ||
| 76 | |||
| 77 | func TestAdminCreatesTheInstanceRepository(t *testing.T) { | ||
| 78 | t.Parallel() | ||
| 79 | f := browserFixture(t) | ||
| 80 | if _, err := f.srv.Identity.SetAdmin(f.ownerID, true, "test"); err != nil { | ||
| 81 | t.Fatal(err) | ||
| 82 | } | ||
| 83 | sess, cookie := f.browserSession(t, f.ownerID) | ||
| 84 | |||
| 85 | resp, body := f.browserGet(t, "/admin?tab=configuration", cookie) | ||
| 86 | if resp.StatusCode != 200 || !strings.Contains(body, `action="/admin/instance/repo"`) || | ||
| 87 | !strings.Contains(body, "does not exist yet") { | ||
| 88 | t.Fatalf("configuration tab has no create action = %d:\n%s", resp.StatusCode, body) | ||
| 89 | } | ||
| 90 | resp, _ = f.browserPost(t, "/admin/instance/repo", cookie, url.Values{"csrf": {sess.CSRF}}) | ||
| 91 | if resp.StatusCode != 303 || resp.Header.Get("Location") != "/admin?tab=configuration&created=1" { | ||
| 92 | t.Fatalf("create = %d %q", resp.StatusCode, resp.Header.Get("Location")) | ||
| 93 | } | ||
| 94 | m, err := f.srv.Repos.Open(f.cfg.InstanceRepo) | ||
| 95 | if err != nil { | ||
| 96 | t.Fatal(err) | ||
| 97 | } | ||
| 98 | blob, err := m.Git().BlobAt("refs/heads/"+m.DefaultBranch, "profile/README.md") | ||
| 99 | if err != nil || !strings.Contains(string(blob), "landing page introduction") { | ||
| 100 | t.Fatalf("scaffold profile/README.md = %q, %v", blob, err) | ||
| 101 | } | ||
| 102 | _, body = f.browserGet(t, "/admin?tab=configuration", cookie) | ||
| 103 | if !strings.Contains(body, "Loaded from") { | ||
| 104 | t.Fatalf("configuration tab does not show the loaded repository:\n%s", body) | ||
| 105 | } | ||
| 106 | // The scaffolded introduction is on the home page. | ||
| 107 | if _, home := f.get("/"); !strings.Contains(home, "landing page introduction") { | ||
| 108 | t.Fatalf("home does not read the scaffolded introduction:\n%s", home) | ||
| 109 | } | ||
| 110 | } | ||
| 111 | |||
| 112 | func TestHomeHasNoIntroEditorWithoutTheInstanceRepo(t *testing.T) { | ||
| 113 | t.Parallel() | ||
| 114 | f := browserFixture(t) | ||
| 115 | if _, err := f.srv.Identity.SetAdmin(f.ownerID, true, "test"); err != nil { | ||
| 116 | t.Fatal(err) | ||
| 117 | } | ||
| 118 | _, cookie := f.browserSession(t, f.ownerID) | ||
| 119 | _, body := f.browserGet(t, "/?edit=1", cookie) | ||
| 120 | if strings.Contains(body, `name="intro"`) || strings.Contains(body, `href="/?edit=1#readme"`) { | ||
| 121 | t.Fatalf("an editor appeared without an instance repository:\n%s", body) | ||
| 122 | } | ||
| 123 | if !strings.Contains(body, "Admin Configuration") { | ||
| 124 | t.Fatalf("home does not say where to set up customization:\n%s", body) | ||
| 125 | } | ||
| 126 | } | ||
| 127 | |||
| 128 | // The in-page editor always writes the public README, even when a signed-in | ||
| 129 | // admin is looking at the private one. Saving the private text into the public | ||
| 130 | // file would publish it. | ||
| 131 | func TestHomeEditorWritesThePublicReadme(t *testing.T) { | ||
| 132 | t.Parallel() | ||
| 133 | f := browserFixture(t) | ||
| 134 | f.instanceRepo(t, map[string][]byte{ | ||
| 135 | "profile/README.md": []byte("# Public"), | ||
| 136 | "profile/private/README.md": []byte("# Members only"), | ||
| 137 | }) | ||
| 138 | if _, err := f.srv.Identity.SetAdmin(f.ownerID, true, "test"); err != nil { | ||
| 139 | t.Fatal(err) | ||
| 140 | } | ||
| 141 | sess, cookie := f.browserSession(t, f.ownerID) | ||
| 142 | |||
| 143 | _, body := f.browserGet(t, "/?edit=1", cookie) | ||
| 144 | if !strings.Contains(body, "# Public") || strings.Contains(body, "# Members only") { | ||
| 145 | t.Fatalf("the editor did not open the public README:\n%s", body) | ||
| 146 | } | ||
| 147 | form := url.Values{"csrf": {sess.CSRF}, "intro": {"# New public"}} | ||
| 148 | if resp, _ := f.browserPost(t, "/", cookie, form); resp.StatusCode != 303 { | ||
| 149 | t.Fatalf("save = %d", resp.StatusCode) | ||
| 150 | } | ||
| 151 | // Signed-in viewers still see the private README; anonymous viewers see the | ||
| 152 | // public one that was just written. | ||
| 153 | _, signed := f.browserGet(t, "/", cookie) | ||
| 154 | if !strings.Contains(signed, "Members only") || strings.Contains(signed, "New public") { | ||
| 155 | t.Fatalf("signed-in home = %s", signed) | ||
| 156 | } | ||
| 157 | _, anon := f.get("/") | ||
| 158 | if !strings.Contains(anon, "New public") { | ||
| 159 | t.Fatalf("anonymous home did not show the public README:\n%s", anon) | ||
| 160 | } | ||
| 161 | } | ||