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