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.

forge/instancerepo/instancerepo.go +254

@@ -0,0 +1,254 @@
1 // Package instancerepo reads the repository whose tree customizes the instance.
2 //
3 // The idea is the ".github" repository, adapted to Vole: fixed paths in one
4 // repository, no templating and no code execution. The tree supplies the landing
5 // page's introduction, one extra stylesheet, a favicon, and static assets. Every
6 // value is data — markdown is rendered through the issue sanitiser, CSS is served
7 // as CSS, assets are served as files — so there is no trust flag to turn on.
8 //
9 // A snapshot is built from one commit and swapped atomically. The reader never
10 // runs git and never builds a filesystem path from a URL; the reloader runs on a
11 // short timer and is the only writer.
12 package instancerepo
13
14 import (
15 "errors"
16 "fmt"
17 "os"
18 "path"
19 "strings"
20 "sync"
21 "sync/atomic"
22 "time"
23
24 "github.com/rodent-software/vole/core/git"
25 "github.com/rodent-software/vole/forge/repo"
26 )
27
28 const (
29 // ReadmePath is the landing page introduction shown to everyone.
30 ReadmePath = "profile/README.md"
31 // PrivateReadmePath replaces it for signed-in viewers when it exists.
32 PrivateReadmePath = "profile/private/README.md"
33 // CSSPath is appended after the built-in stylesheets on every page.
34 CSSPath = "profile/custom.css"
35 // FaviconPath replaces the built-in favicon links when it exists.
36 FaviconPath = "profile/favicon.svg"
37 // AssetsPrefix is served under /static/custom/assets/.
38 AssetsPrefix = "assets/"
39
40 // The caps keep one repository from turning into a large allocation on every
41 // reload. They are generous for a stylesheet, a favicon, and a logo.
42 maxThemeBytes = 8 << 20
43 maxFileBytes = 1 << 20
44 maxAssets = 100
45 )
46
47 // Asset is one file under assets/.
48 type Asset struct {
49 Body []byte
50 }
51
52 // Snapshot is one commit's contribution to the instance. It is immutable after
53 // it is published, so a request can hold it without a lock.
54 type Snapshot struct {
55 Commit string
56 Readme []byte
57 PrivateReadme []byte
58 CSS []byte
59 Favicon []byte
60 // Assets maps the path below assets/ to its contents.
61 Assets map[string]Asset
62 }
63
64 // Store resolves the instance repository to the current snapshot.
65 type Store struct {
66 repos *repo.Store
67 name string
68
69 mu sync.Mutex
70 snap atomic.Pointer[Snapshot]
71 errMsg string
72 errAt time.Time
73 lastTip string
74 }
75
76 // New names the repository. An empty name disables the store: no repository is
77 // read and the instance keeps its built-in presentation.
78 func New(repos *repo.Store, name string) *Store {
79 return &Store{repos: repos, name: strings.TrimSpace(name)}
80 }
81
82 func (s *Store) Name() string { return s.name }
83
84 func (s *Store) Enabled() bool { return s != nil && s.name != "" && s.repos != nil }
85
86 // Current is what a request reads. Nil means no usable snapshot yet.
87 func (s *Store) Current() *Snapshot {
88 if s == nil {
89 return nil
90 }
91 return s.snap.Load()
92 }
93
94 // Ready reports whether a snapshot has ever loaded.
95 func (s *Store) Ready() bool { return s.Current() != nil }
96
97 // Status is what the Admin page shows: the name, the commit, and the last load
98 // error when there is one. Err is emptied by the next successful load.
99 func (s *Store) Status() (name, commit, errMsg string, errAt time.Time) {
100 if s == nil {
101 return "", "", "", time.Time{}
102 }
103 s.mu.Lock()
104 defer s.mu.Unlock()
105 commit = s.lastTip
106 return s.name, commit, s.errMsg, s.errAt
107 }
108
109 // Reload reads the branch tip and rebuilds the snapshot when it moved. Every
110 // failure keeps the last good snapshot: a broken theme must not take the
111 // instance's own pages down.
112 func (s *Store) Reload() error {
113 if !s.Enabled() {
114 return nil
115 }
116 s.mu.Lock()
117 defer s.mu.Unlock()
118
119 m, err := s.repos.Open(s.name)
120 if err != nil {
121 if errors.Is(err, os.ErrNotExist) {
122 // A repository nobody has created yet is not a load failure: the
123 // instance keeps its built-in presentation, and the Admin page offers
124 // to scaffold it.
125 s.snap.Store(nil)
126 s.lastTip = ""
127 s.ok()
128 return nil
129 }
130 s.fail(err)
131 return err
132 }
133 tip, err := m.Git().RevParseRaw("refs/heads/" + m.DefaultBranch)
134 if err != nil {
135 s.fail(err)
136 return err
137 }
138 s.lastTip = tip
139 if tip == "" {
140 s.fail(errors.New("the repository has no commits"))
141 return nil
142 }
143 if cur := s.snap.Load(); cur != nil && cur.Commit == tip {
144 s.ok()
145 return nil
146 }
147 snap, err := load(m.Git(), tip)
148 if err != nil {
149 s.fail(err)
150 return err
151 }
152 s.snap.Store(snap)
153 s.ok()
154 return nil
155 }
156
157 func (s *Store) fail(err error) {
158 s.errMsg = err.Error()
159 s.errAt = time.Now().UTC()
160 }
161
162 func (s *Store) ok() {
163 s.errMsg = ""
164 s.errAt = time.Time{}
165 }
166
167 // load builds a snapshot from one commit. Anything outside the fixed paths is
168 // ignored rather than rejected, so the repository can also hold a README and
169 // notes without surprising the loader.
170 func load(g *git.Repo, tip string) (*Snapshot, error) {
171 entries, err := g.LsTreeR(tip)
172 if err != nil {
173 return nil, err
174 }
175 type pick struct {
176 path string
177 sha string
178 }
179 var picks []pick
180 assets := 0
181 for _, e := range entries {
182 if e.Type != "blob" {
183 continue
184 }
185 switch {
186 case e.Name == ReadmePath, e.Name == PrivateReadmePath, e.Name == CSSPath, e.Name == FaviconPath:
187 case strings.HasPrefix(e.Name, AssetsPrefix):
188 if !allowedAsset(e.Name) {
189 continue
190 }
191 assets++
192 if assets > maxAssets {
193 return nil, fmt.Errorf("%s holds more than %d assets", AssetsPrefix, maxAssets)
194 }
195 default:
196 continue
197 }
198 picks = append(picks, pick{path: e.Name, sha: e.SHA})
199 }
200 if len(picks) == 0 {
201 return &Snapshot{Commit: tip}, nil
202 }
203 shas := make([]string, 0, len(picks))
204 for _, p := range picks {
205 shas = append(shas, p.sha)
206 }
207 sizes, err := g.BlobSizes(shas)
208 if err != nil {
209 return nil, err
210 }
211 var total int64
212 for _, p := range picks {
213 size := sizes[p.sha]
214 if size > maxFileBytes {
215 return nil, fmt.Errorf("%s is %d bytes, over the %d byte limit", p.path, size, maxFileBytes)
216 }
217 total += size
218 }
219 if total > maxThemeBytes {
220 return nil, fmt.Errorf("the instance repository holds %d bytes of theme files, over the %d byte limit", total, maxThemeBytes)
221 }
222 bodies, err := g.BlobsBySHA(shas)
223 if err != nil {
224 return nil, err
225 }
226 snap := &Snapshot{Commit: tip, Assets: map[string]Asset{}}
227 for _, p := range picks {
228 body := bodies[p.sha]
229 switch p.path {
230 case ReadmePath:
231 snap.Readme = body
232 case PrivateReadmePath:
233 snap.PrivateReadme = body
234 case CSSPath:
235 snap.CSS = body
236 case FaviconPath:
237 snap.Favicon = body
238 default:
239 snap.Assets[strings.TrimPrefix(p.path, AssetsPrefix)] = Asset{Body: body}
240 }
241 }
242 return snap, nil
243 }
244
245 // allowedAsset is the extension whitelist. HTML and scripts are deliberately
246 // absent: an asset is served from the forge's origin, and a same-origin document
247 // or script is the whole session, not a theme.
248 func allowedAsset(name string) bool {
249 switch strings.ToLower(path.Ext(name)) {
250 case ".css", ".png", ".jpg", ".jpeg", ".webp", ".gif", ".svg", ".ico", ".woff", ".woff2", ".ttf", ".otf":
251 return true
252 }
253 return false
254 }