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/home.go +82−45
| @@ -2,12 +2,14 @@ package web | |||
| 2 | 2 | ||
| 3 | 3 | // The landing page and the instance-wide activity feed. | |
| 4 | 4 | // | |
| 5 | // The landing page answers three questions in one screen: what this forge is, | ||
| 6 | // what is in it, and what happened lately. Repositories are the main column; the | ||
| 7 | // merged feed is a rail beside them. Both come from one read per repository, so | ||
| 8 | // listing a repository does not cost a second walk over its history. | ||
| 5 | // The landing page is a heading named by the instance, an introduction card an | ||
| 6 | // instance admin can edit in place, and then the repository grid beside a rail | ||
| 7 | // of what is happening and what the code is written in. The cards and the rail | ||
| 8 | // come from one read per repository, so listing a repository does not cost a | ||
| 9 | // second walk over its history. | ||
| 9 | 10 | ||
| 10 | 11 | import ( | |
| 12 | "html/template" | ||
| 11 | 13 | "net/http" | |
| 12 | 14 | "net/url" | |
| 13 | 15 | "sort" | |
| @@ -18,6 +20,7 @@ import ( | |||
| 18 | 20 | "github.com/rodent-software/vole/core/git" | |
| 19 | 21 | "github.com/rodent-software/vole/forge/access" | |
| 20 | 22 | "github.com/rodent-software/vole/forge/index" | |
| 23 | "github.com/rodent-software/vole/forge/instancerepo" | ||
| 21 | 24 | "github.com/rodent-software/vole/forge/repo" | |
| 22 | 25 | "github.com/rodent-software/vole/internal/lang" | |
| 23 | 26 | ) | |
| @@ -30,21 +33,6 @@ const ( | |||
| 30 | 33 | instanceEventPage = 50 | |
| 31 | 34 | ) | |
| 32 | 35 | ||
| 33 | // instanceInfo is the identity the landing page shows. It is derived from the | ||
| 34 | // configuration rather than hard-coded, so an operator can name their forge | ||
| 35 | // without changing the wordmark. | ||
| 36 | type instanceInfo struct { | ||
| 37 | Name string | ||
| 38 | Tagline string | ||
| 39 | } | ||
| 40 | |||
| 41 | // homeVitals are the signs of life on the landing page. Every number is derived | ||
| 42 | // from what the viewer can read: a private repository's activity is not counted. | ||
| 43 | type homeVitals struct { | ||
| 44 | Repos int | ||
| 45 | LastActivity time.Time | ||
| 46 | } | ||
| 47 | |||
| 48 | 36 | // repoCard is one repository in the repository grid. It is a summary of the same | |
| 49 | 37 | // activity feed the rail uses, not a second source of truth. | |
| 50 | 38 | type repoCard struct { | |
| @@ -63,8 +51,18 @@ type repoCard struct { | |||
| 63 | 51 | ||
| 64 | 52 | type homeData struct { | |
| 65 | 53 | Page | |
| 66 | Instance instanceInfo | ||
| 67 | Vitals homeVitals | ||
| 54 | // InstanceName is the landing page heading. | ||
| 55 | InstanceName string | ||
| 56 | // Intro is the rendered, sanitized introduction. Empty means the card has no | ||
| 57 | // body; an instance admin still sees it, with an invitation to write one. | ||
| 58 | Intro template.HTML | ||
| 59 | // IntroText is the markdown shown in the edit form, and Editing says whether | ||
| 60 | // that form is open. Only an instance admin ever gets both. | ||
| 61 | IntroText string | ||
| 62 | Editing bool | ||
| 63 | // RepoReady reports that the instance repository has a usable snapshot, so | ||
| 64 | // the page can be customized at all. | ||
| 65 | RepoReady bool | ||
| 68 | 66 | Cards []repoCard | |
| 69 | 67 | Events []activityEvent | |
| 70 | 68 | OpenRegistration bool | |
| @@ -82,19 +80,64 @@ type instanceActivityData struct { | |||
| 82 | 80 | // repository rather than a table row. | |
| 83 | 81 | func (w *Web) IndexPage(rw http.ResponseWriter, r *http.Request) { | |
| 84 | 82 | query := strings.TrimSpace(r.URL.Query().Get("q")) | |
| 85 | entries, err := w.visibleRepos(r, query) | ||
| 83 | data, err := w.homeView(r, query, r.URL.Query().Get("edit") == "1", nil, "") | ||
| 86 | 84 | if err != nil { | |
| 87 | 85 | w.serverError(rw, r, "index unavailable: "+err.Error()) | |
| 88 | 86 | return | |
| 89 | 87 | } | |
| 88 | w.render(rw, "index", data, 0) | ||
| 89 | } | ||
| 90 | 90 | ||
| 91 | // homeView builds the landing page. A save that failed passes its text back in | ||
| 92 | // submitted so the editor reopens with the paragraph still in it; msg is the | ||
| 93 | // error the page shows above it. | ||
| 94 | func (w *Web) homeView(r *http.Request, query string, wantEdit bool, submitted *string, msg string) (homeData, error) { | ||
| 95 | entries, err := w.visibleRepos(r, query) | ||
| 96 | if err != nil { | ||
| 97 | return homeData{}, err | ||
| 98 | } | ||
| 91 | 99 | page := w.page(r, "Repositories") | |
| 92 | 100 | page.Query = query | |
| 101 | page.Error = msg | ||
| 102 | // Only an instance admin can edit, so ?edit=1 from anybody else is ignored | ||
| 103 | // rather than refused: the page they asked for is the page they get. | ||
| 104 | editing := wantEdit && page.InstanceAdmin | ||
| 93 | 105 | data := homeData{ | |
| 94 | 106 | Page: page, | |
| 95 | Instance: w.instanceInfo(r), | ||
| 107 | InstanceName: w.instanceName(), | ||
| 108 | Editing: editing, | ||
| 96 | 109 | OpenRegistration: w.Cfg != nil && w.Cfg.OpenRegistration, | |
| 97 | 110 | } | |
| 111 | var snap *instancerepo.Snapshot | ||
| 112 | if w.InstanceRepo != nil { | ||
| 113 | snap = w.InstanceRepo.Current() | ||
| 114 | } | ||
| 115 | data.RepoReady = snap != nil | ||
| 116 | var readme string | ||
| 117 | if snap != nil { | ||
| 118 | readme = string(snap.Readme) | ||
| 119 | } | ||
| 120 | if !data.RepoReady { | ||
| 121 | editing = false | ||
| 122 | data.Editing = false | ||
| 123 | } | ||
| 124 | if editing { | ||
| 125 | // The editor always writes the public file. A private README is for | ||
| 126 | // signed-in readers, not a second editing surface. | ||
| 127 | data.IntroText = strings.TrimSpace(readme) | ||
| 128 | } else if snap != nil { | ||
| 129 | // A signed-in viewer sees the member-only README when one exists. | ||
| 130 | shown := readme | ||
| 131 | if page.LoggedIn && len(snap.PrivateReadme) > 0 { | ||
| 132 | shown = string(snap.PrivateReadme) | ||
| 133 | } | ||
| 134 | if text := strings.TrimSpace(shown); text != "" { | ||
| 135 | data.Intro = w.renderInstanceMarkdown(text) | ||
| 136 | } | ||
| 137 | } | ||
| 138 | if editing && submitted != nil { | ||
| 139 | data.IntroText = *submitted | ||
| 140 | } | ||
| 98 | 141 | ||
| 99 | 142 | var events []activityEvent | |
| 100 | 143 | memo := memoOf(r) | |
| @@ -114,9 +157,8 @@ func (w *Web) IndexPage(rw http.ResponseWriter, r *http.Request) { | |||
| 114 | 157 | events = events[:homeEventLimit] | |
| 115 | 158 | } | |
| 116 | 159 | data.Events = events | |
| 117 | data.Vitals = w.homeVitals(r, entries, events) | ||
| 118 | 160 | } | |
| 119 | w.render(rw, "index", data, 0) | ||
| 161 | return data, nil | ||
| 120 | 162 | } | |
| 121 | 163 | ||
| 122 | 164 | // InstanceActivity serves GET /activity: the whole feed, with the type filter | |
| @@ -176,6 +218,12 @@ func (w *Web) visibleRepos(r *http.Request, query string) ([]index.Entry, error) | |||
| 176 | 218 | needle := strings.ToLower(strings.TrimSpace(query)) | |
| 177 | 219 | var visible []index.Entry | |
| 178 | 220 | for _, e := range entries { | |
| 221 | if w.isInstanceRepo(e.Name) { | ||
| 222 | // The instance's own customization repository is not a project. It is | ||
| 223 | // managed from the Admin page, and listing it as a card would make it | ||
| 224 | // look like one. | ||
| 225 | continue | ||
| 226 | } | ||
| 179 | 227 | // Meta rather than Open: visibility needs the id and the ACL, not the | |
| 180 | 228 | // default branch, so do not spend a git call per repository on it. | |
| 181 | 229 | m, err := w.Repos.Meta(e.Name) | |
| @@ -360,30 +408,19 @@ func sortCards(cards []repoCard) { | |||
| 360 | 408 | }) | |
| 361 | 409 | } | |
| 362 | 410 | ||
| 363 | // homeVitals is the public front-door summary. Runner presence is deliberately | ||
| 364 | // absent: the Admin page is the instance-admin surface for that. | ||
| 365 | func (w *Web) homeVitals(r *http.Request, entries []index.Entry, events []activityEvent) homeVitals { | ||
| 366 | v := homeVitals{Repos: len(entries)} | ||
| 367 | if len(events) > 0 { | ||
| 368 | v.LastActivity = events[0].Time | ||
| 369 | } | ||
| 370 | return v | ||
| 411 | // isInstanceRepo reports whether name is the repository the instance reads for | ||
| 412 | // its presentation. It is hidden from the home grid and the activity rail. | ||
| 413 | func (w *Web) isInstanceRepo(name string) bool { | ||
| 414 | return w.Cfg != nil && name != "" && name == strings.TrimSpace(w.Cfg.InstanceRepo) | ||
| 371 | 415 | } | |
| 372 | 416 | ||
| 373 | // instanceInfo names the forge. The address is already in the browser bar, so | ||
| 374 | // the page does not repeat it. | ||
| 375 | func (w *Web) instanceInfo(_ *http.Request) instanceInfo { | ||
| 376 | info := instanceInfo{ | ||
| 377 | Name: "Vole", | ||
| 378 | Tagline: "A forge that lives in your Git repo.", | ||
| 379 | } | ||
| 417 | // instanceName is the heading the home page shows. The config file names the | ||
| 418 | // forge; an instance that never set it is "Vole". | ||
| 419 | func (w *Web) instanceName() string { | ||
| 380 | 420 | if w.Cfg != nil { | |
| 381 | 421 | if v := strings.TrimSpace(w.Cfg.InstanceName); v != "" { | |
| 382 | info.Name = v | ||
| 383 | } | ||
| 384 | if v := strings.TrimSpace(w.Cfg.InstanceTagline); v != "" { | ||
| 385 | info.Tagline = v | ||
| 422 | return v | ||
| 386 | 423 | } | |
| 387 | 424 | } | |
| 388 | return info | ||
| 425 | return "Vole" | ||
| 389 | 426 | } | |