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/web/landing.go +95

@@ -0,0 +1,95 @@
1 package web
2
3 // The landing page's introduction, edited from the home page itself. The text is
4 // committed to the instance repository (forge/instancerepo), not written to
5 // vole.toml: the packaged service cannot write its config file, and the theme
6 // repository is where a customization belongs. The commit is the history; the
7 // audit log records who changed the page.
8 //
9 // The introduction is markdown written by an instance admin rather than pushed,
10 // but it still ends up in a page, so it is rendered through the same parser and
11 // sanitizer as an issue body.
12
13 import (
14 "net/http"
15 "strconv"
16 "strings"
17
18 "github.com/rodent-software/vole/core/git"
19 "github.com/rodent-software/vole/forge/instancerepo"
20 )
21
22 const (
23 // maxIntroBytes bounds the saved markdown. The body bound leaves room for it
24 // plus the form overhead.
25 maxIntroBytes = 20000
26 maxIntroForm = 64 << 10
27 )
28
29 // SaveHomeIntro answers the introduction card's form on the home page. An empty
30 // introduction removes profile/README.md, which is how the card's body goes away;
31 // the markdown is kept as written and rendered on the way out.
32 func (w *Web) SaveHomeIntro(rw http.ResponseWriter, r *http.Request) {
33 pr, ok := w.beginAdminPost(rw, r, maxIntroForm)
34 if !ok {
35 return
36 }
37 if w.InstanceRepo == nil || !w.InstanceRepo.Ready() {
38 w.serverError(rw, r, "the instance repository is not ready")
39 return
40 }
41 next := normalizeIntro(r.PostFormValue("intro"))
42 if len(next) > maxIntroBytes {
43 msg := "The introduction is longer than " + strconv.Itoa(maxIntroBytes) + " bytes."
44 w.renderIntroEditor(rw, r, next, msg, http.StatusBadRequest)
45 return
46 }
47 m, err := w.Repos.Open(w.InstanceRepo.Name())
48 if err != nil {
49 w.renderIntroEditor(rw, r, next, "The instance repository could not be opened: "+err.Error(), http.StatusInternalServerError)
50 return
51 }
52 prev := ""
53 if snap := w.InstanceRepo.Current(); snap != nil {
54 prev = string(snap.Readme)
55 }
56 if normalizeIntro(prev) == next {
57 // Nothing changed: do not add a commit that says nothing.
58 http.Redirect(rw, r, "/", http.StatusSeeOther)
59 return
60 }
61 if _, err := git.CommitFilesBy(m.Git(), "refs/heads/"+m.DefaultBranch, func(files map[string][]byte) (string, error) {
62 if next == "" {
63 delete(files, instancerepo.ReadmePath)
64 } else {
65 files[instancerepo.ReadmePath] = []byte(next)
66 }
67 return pr.account.Name, nil
68 }, "Update the landing page introduction"); err != nil {
69 w.renderIntroEditor(rw, r, next, "The introduction could not be saved: "+err.Error(), http.StatusInternalServerError)
70 return
71 }
72 if err := w.InstanceRepo.Reload(); err != nil && w.Logger != nil {
73 w.Logger("instance repository reload after intro save: %v", err)
74 }
75 w.audit("web.home.intro", pr.account.Name, "", instancerepo.ReadmePath)
76 http.Redirect(rw, r, "/", http.StatusSeeOther)
77 }
78
79 // normalizeIntro is the one shape the store and the page agree on: no carriage
80 // returns, and no whitespace that would render as an empty block.
81 func normalizeIntro(raw string) string {
82 return strings.TrimSpace(strings.ReplaceAll(raw, "\r\n", "\n"))
83 }
84
85 // renderIntroEditor re-renders the home page with the editor open and the
86 // submitted text still in it, so a rejected save does not make the admin retype
87 // a paragraph.
88 func (w *Web) renderIntroEditor(rw http.ResponseWriter, r *http.Request, submitted, msg string, status int) {
89 data, err := w.homeView(r, "", true, &submitted, msg)
90 if err != nil {
91 w.serverError(rw, r, "index unavailable: "+err.Error())
92 return
93 }
94 w.render(rw, "index", data, status)
95 }