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/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 }