vole
Clone
Read only, no credentials:
https://rodent.software/git/vole.git
Write over SSH with a registered key:
git clone ssh://git@0.0.0.0:2222/vole.git
A free and open-source forge that lives in your Git repo.
|
6ce635a8
Keenan Nemetz
1d ago
|
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" | |
|
f7a34d87
Keenan Nemetz
1d ago
|
19 | "regexp" |
|
d222b0cf
Keenan Nemetz
1d ago
|
20 | "sort" |
|
6ce635a8
Keenan Nemetz
1d ago
|
21 | "strings" |
| 22 | "sync" | |
| 23 | "sync/atomic" | |
| 24 | "time" | |
|
d222b0cf
Keenan Nemetz
1d ago
|
25 | "unicode" |
|
6ce635a8
Keenan Nemetz
1d ago
|
26 | |
|
d37d301f
Keenan Nemetz
16h ago
|
27 | "rodent.software/vole/core/git" |
| 28 | "rodent.software/vole/forge/repo" | |
|
6ce635a8
Keenan Nemetz
1d ago
|
29 | ) |
| 30 | ||
| 31 | const ( | |
| 32 | // ReadmePath is the landing page introduction shown to everyone. | |
| 33 | ReadmePath = "profile/README.md" | |
| 34 | // PrivateReadmePath replaces it for signed-in viewers when it exists. | |
| 35 | PrivateReadmePath = "profile/private/README.md" | |
| 36 | // CSSPath is appended after the built-in stylesheets on every page. | |
| 37 | CSSPath = "profile/custom.css" | |
|
d222b0cf
Keenan Nemetz
1d ago
|
38 | // ThemesDir holds one stylesheet per selectable theme. The file name is the |
| 39 | // theme id and the label: acme-ocean.css is the "Acme Ocean" theme. | |
|
f7a34d87
Keenan Nemetz
1d ago
|
40 | ThemesDir = "profile/themes/" |
|
6ce635a8
Keenan Nemetz
1d ago
|
41 | // FaviconPath replaces the built-in favicon links when it exists. |
| 42 | FaviconPath = "profile/favicon.svg" | |
|
ed03b3d1
Keenan Nemetz
3h ago
|
43 | // AssetsPrefix is served under /-/static/custom/assets/. |
|
6ce635a8
Keenan Nemetz
1d ago
|
44 | AssetsPrefix = "assets/" |
| 45 | ||
| 46 | // The caps keep one repository from turning into a large allocation on every | |
| 47 | // reload. They are generous for a stylesheet, a favicon, and a logo. | |
| 48 | maxThemeBytes = 8 << 20 | |
| 49 | maxFileBytes = 1 << 20 | |
| 50 | maxAssets = 100 | |
|
f7a34d87
Keenan Nemetz
1d ago
|
51 | maxThemes = 24 |
|
6ce635a8
Keenan Nemetz
1d ago
|
52 | ) |
| 53 | ||
|
f7a34d87
Keenan Nemetz
1d ago
|
54 | var themeIDRE = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,40}$`) |
| 55 | ||
|
6ce635a8
Keenan Nemetz
1d ago
|
56 | // Asset is one file under assets/. |
| 57 | type Asset struct { | |
| 58 | Body []byte | |
| 59 | } | |
| 60 | ||
|
d222b0cf
Keenan Nemetz
1d ago
|
61 | // Theme is one selectable stylesheet from the instance repository. The file |
| 62 | // name is the id and the source of the label; the stylesheet is expected to | |
| 63 | // scope its tokens to :root[data-theme="<ID>"]. | |
|
f7a34d87
Keenan Nemetz
1d ago
|
64 | type Theme struct { |
| 65 | ID string | |
| 66 | Name string | |
| 67 | CSS []byte | |
| 68 | } | |
| 69 | ||
|
6ce635a8
Keenan Nemetz
1d ago
|
70 | // Snapshot is one commit's contribution to the instance. It is immutable after |
| 71 | // it is published, so a request can hold it without a lock. | |
| 72 | type Snapshot struct { | |
| 73 | Commit string | |
| 74 | Readme []byte | |
| 75 | PrivateReadme []byte | |
| 76 | CSS []byte | |
| 77 | Favicon []byte | |
|
f7a34d87
Keenan Nemetz
1d ago
|
78 | // Themes are the selectable theme stylesheets, in manifest order. |
| 79 | Themes []Theme | |
|
6ce635a8
Keenan Nemetz
1d ago
|
80 | // Assets maps the path below assets/ to its contents. |
| 81 | Assets map[string]Asset | |
| 82 | } | |
| 83 | ||
| 84 | // Store resolves the instance repository to the current snapshot. | |
| 85 | type Store struct { | |
| 86 | repos *repo.Store | |
| 87 | name string | |
| 88 | ||
| 89 | mu sync.Mutex | |
| 90 | snap atomic.Pointer[Snapshot] | |
| 91 | errMsg string | |
| 92 | errAt time.Time | |
| 93 | lastTip string | |
| 94 | } | |
| 95 | ||
| 96 | // New names the repository. An empty name disables the store: no repository is | |
| 97 | // read and the instance keeps its built-in presentation. | |
| 98 | func New(repos *repo.Store, name string) *Store { | |
| 99 | return &Store{repos: repos, name: strings.TrimSpace(name)} | |
| 100 | } | |
| 101 | ||
| 102 | func (s *Store) Name() string { return s.name } | |
| 103 | ||
| 104 | func (s *Store) Enabled() bool { return s != nil && s.name != "" && s.repos != nil } | |
| 105 | ||
| 106 | // Current is what a request reads. Nil means no usable snapshot yet. | |
| 107 | func (s *Store) Current() *Snapshot { | |
| 108 | if s == nil { | |
| 109 | return nil | |
| 110 | } | |
| 111 | return s.snap.Load() | |
| 112 | } | |
| 113 | ||
| 114 | // Ready reports whether a snapshot has ever loaded. | |
| 115 | func (s *Store) Ready() bool { return s.Current() != nil } | |
| 116 | ||
| 117 | // Status is what the Admin page shows: the name, the commit, and the last load | |
| 118 | // error when there is one. Err is emptied by the next successful load. | |
| 119 | func (s *Store) Status() (name, commit, errMsg string, errAt time.Time) { | |
| 120 | if s == nil { | |
| 121 | return "", "", "", time.Time{} | |
| 122 | } | |
| 123 | s.mu.Lock() | |
| 124 | defer s.mu.Unlock() | |
| 125 | commit = s.lastTip | |
| 126 | return s.name, commit, s.errMsg, s.errAt | |
| 127 | } | |
| 128 | ||
| 129 | // Reload reads the branch tip and rebuilds the snapshot when it moved. Every | |
| 130 | // failure keeps the last good snapshot: a broken theme must not take the | |
| 131 | // instance's own pages down. | |
| 132 | func (s *Store) Reload() error { | |
| 133 | if !s.Enabled() { | |
| 134 | return nil | |
| 135 | } | |
| 136 | s.mu.Lock() | |
| 137 | defer s.mu.Unlock() | |
| 138 | ||
| 139 | m, err := s.repos.Open(s.name) | |
| 140 | if err != nil { | |
| 141 | if errors.Is(err, os.ErrNotExist) { | |
| 142 | // A repository nobody has created yet is not a load failure: the | |
| 143 | // instance keeps its built-in presentation, and the Admin page offers | |
| 144 | // to scaffold it. | |
| 145 | s.snap.Store(nil) | |
| 146 | s.lastTip = "" | |
| 147 | s.ok() | |
| 148 | return nil | |
| 149 | } | |
| 150 | s.fail(err) | |
| 151 | return err | |
| 152 | } | |
| 153 | tip, err := m.Git().RevParseRaw("refs/heads/" + m.DefaultBranch) | |
| 154 | if err != nil { | |
| 155 | s.fail(err) | |
| 156 | return err | |
| 157 | } | |
| 158 | s.lastTip = tip | |
| 159 | if tip == "" { | |
| 160 | s.fail(errors.New("the repository has no commits")) | |
| 161 | return nil | |
| 162 | } | |
| 163 | if cur := s.snap.Load(); cur != nil && cur.Commit == tip { | |
| 164 | s.ok() | |
| 165 | return nil | |
| 166 | } | |
| 167 | snap, err := load(m.Git(), tip) | |
| 168 | if err != nil { | |
| 169 | s.fail(err) | |
| 170 | return err | |
| 171 | } | |
| 172 | s.snap.Store(snap) | |
| 173 | s.ok() | |
| 174 | return nil | |
| 175 | } | |
| 176 | ||
| 177 | func (s *Store) fail(err error) { | |
| 178 | s.errMsg = err.Error() | |
| 179 | s.errAt = time.Now().UTC() | |
| 180 | } | |
| 181 | ||
| 182 | func (s *Store) ok() { | |
| 183 | s.errMsg = "" | |
| 184 | s.errAt = time.Time{} | |
| 185 | } | |
| 186 | ||
| 187 | // load builds a snapshot from one commit. Anything outside the fixed paths is | |
| 188 | // ignored rather than rejected, so the repository can also hold a README and | |
| 189 | // notes without surprising the loader. | |
| 190 | func load(g *git.Repo, tip string) (*Snapshot, error) { | |
| 191 | entries, err := g.LsTreeR(tip) | |
| 192 | if err != nil { | |
| 193 | return nil, err | |
| 194 | } | |
| 195 | type pick struct { | |
| 196 | path string | |
| 197 | sha string | |
| 198 | } | |
| 199 | var picks []pick | |
|
f7a34d87
Keenan Nemetz
1d ago
|
200 | assets, themes := 0, 0 |
|
6ce635a8
Keenan Nemetz
1d ago
|
201 | for _, e := range entries { |
| 202 | if e.Type != "blob" { | |
| 203 | continue | |
| 204 | } | |
| 205 | switch { | |
|
d222b0cf
Keenan Nemetz
1d ago
|
206 | case e.Name == ReadmePath, e.Name == PrivateReadmePath, e.Name == CSSPath, e.Name == FaviconPath: |
|
f7a34d87
Keenan Nemetz
1d ago
|
207 | case strings.HasPrefix(e.Name, ThemesDir) && strings.HasSuffix(e.Name, ".css") && |
| 208 | !strings.Contains(strings.TrimPrefix(e.Name, ThemesDir), "/"): | |
| 209 | themes++ | |
| 210 | if themes > maxThemes { | |
| 211 | return nil, fmt.Errorf("%s holds more than %d themes", ThemesDir, maxThemes) | |
| 212 | } | |
|
6ce635a8
Keenan Nemetz
1d ago
|
213 | case strings.HasPrefix(e.Name, AssetsPrefix): |
| 214 | if !allowedAsset(e.Name) { | |
| 215 | continue | |
| 216 | } | |
| 217 | assets++ | |
| 218 | if assets > maxAssets { | |
| 219 | return nil, fmt.Errorf("%s holds more than %d assets", AssetsPrefix, maxAssets) | |
| 220 | } | |
| 221 | default: | |
| 222 | continue | |
| 223 | } | |
| 224 | picks = append(picks, pick{path: e.Name, sha: e.SHA}) | |
| 225 | } | |
| 226 | if len(picks) == 0 { | |
| 227 | return &Snapshot{Commit: tip}, nil | |
| 228 | } | |
| 229 | shas := make([]string, 0, len(picks)) | |
| 230 | for _, p := range picks { | |
| 231 | shas = append(shas, p.sha) | |
| 232 | } | |
| 233 | sizes, err := g.BlobSizes(shas) | |
| 234 | if err != nil { | |
| 235 | return nil, err | |
| 236 | } | |
| 237 | var total int64 | |
| 238 | for _, p := range picks { | |
| 239 | size := sizes[p.sha] | |
| 240 | if size > maxFileBytes { | |
| 241 | return nil, fmt.Errorf("%s is %d bytes, over the %d byte limit", p.path, size, maxFileBytes) | |
| 242 | } | |
| 243 | total += size | |
| 244 | } | |
| 245 | if total > maxThemeBytes { | |
| 246 | return nil, fmt.Errorf("the instance repository holds %d bytes of theme files, over the %d byte limit", total, maxThemeBytes) | |
| 247 | } | |
| 248 | bodies, err := g.BlobsBySHA(shas) | |
| 249 | if err != nil { | |
| 250 | return nil, err | |
| 251 | } | |
|
f7a34d87
Keenan Nemetz
1d ago
|
252 | byPath := make(map[string][]byte, len(picks)) |
| 253 | for _, p := range picks { | |
| 254 | byPath[p.path] = bodies[p.sha] | |
| 255 | } | |
|
6ce635a8
Keenan Nemetz
1d ago
|
256 | snap := &Snapshot{Commit: tip, Assets: map[string]Asset{}} |
| 257 | for _, p := range picks { | |
|
f7a34d87
Keenan Nemetz
1d ago
|
258 | switch { |
| 259 | case p.path == ReadmePath: | |
| 260 | snap.Readme = byPath[p.path] | |
| 261 | case p.path == PrivateReadmePath: | |
| 262 | snap.PrivateReadme = byPath[p.path] | |
| 263 | case p.path == CSSPath: | |
| 264 | snap.CSS = byPath[p.path] | |
| 265 | case p.path == FaviconPath: | |
| 266 | snap.Favicon = byPath[p.path] | |
|
d222b0cf
Keenan Nemetz
1d ago
|
267 | case strings.HasPrefix(p.path, ThemesDir): |
| 268 | // Assembled from the file name below. | |
|
6ce635a8
Keenan Nemetz
1d ago
|
269 | default: |
|
f7a34d87
Keenan Nemetz
1d ago
|
270 | snap.Assets[strings.TrimPrefix(p.path, AssetsPrefix)] = Asset{Body: byPath[p.path]} |
|
6ce635a8
Keenan Nemetz
1d ago
|
271 | } |
| 272 | } | |
|
d222b0cf
Keenan Nemetz
1d ago
|
273 | for _, p := range picks { |
| 274 | if !strings.HasPrefix(p.path, ThemesDir) { | |
| 275 | continue | |
| 276 | } | |
| 277 | id := strings.TrimSuffix(strings.TrimPrefix(p.path, ThemesDir), ".css") | |
| 278 | if err := validThemeID(id); err != nil { | |
|
f7a34d87
Keenan Nemetz
1d ago
|
279 | return nil, err |
| 280 | } | |
|
d222b0cf
Keenan Nemetz
1d ago
|
281 | snap.Themes = append(snap.Themes, Theme{ID: id, Name: themeName(id), CSS: byPath[p.path]}) |
|
f7a34d87
Keenan Nemetz
1d ago
|
282 | } |
|
d222b0cf
Keenan Nemetz
1d ago
|
283 | sort.Slice(snap.Themes, func(i, j int) bool { |
| 284 | if snap.Themes[i].Name != snap.Themes[j].Name { | |
| 285 | return snap.Themes[i].Name < snap.Themes[j].Name | |
| 286 | } | |
| 287 | return snap.Themes[i].ID < snap.Themes[j].ID | |
| 288 | }) | |
|
6ce635a8
Keenan Nemetz
1d ago
|
289 | return snap, nil |
| 290 | } | |
| 291 | ||
|
d222b0cf
Keenan Nemetz
1d ago
|
292 | // validThemeID rejects a file name that cannot be a theme id. The file name is |
| 293 | // the whole declaration, so a bad one is a load error the Admin page shows; the | |
| 294 | // previous theme stays in use. | |
| 295 | func validThemeID(id string) error { | |
| 296 | if !themeIDRE.MatchString(id) { | |
| 297 | return fmt.Errorf("%s%s.css: theme id must use lowercase letters, digits, and dashes (max 41 characters)", ThemesDir, id) | |
|
f7a34d87
Keenan Nemetz
1d ago
|
298 | } |
|
97b3b022
Keenan Nemetz
1d ago
|
299 | if id == "system" || id == "nord" || id == "dracula" || id == "tokyo-night" || |
|
0584bb70
Keenan Nemetz
1d ago
|
300 | strings.HasPrefix(id, "everforest-") || |
| 301 | strings.HasPrefix(id, "catppuccin-") || | |
|
97b3b022
Keenan Nemetz
1d ago
|
302 | strings.HasPrefix(id, "solarized-") || |
| 303 | strings.HasPrefix(id, "rose-pine-") || | |
| 304 | strings.HasPrefix(id, "kanagawa-") || | |
| 305 | strings.HasPrefix(id, "ayu-") { | |
|
0584bb70
Keenan Nemetz
1d ago
|
306 | return fmt.Errorf("%s%s.css: theme id %q is reserved for a built-in theme", ThemesDir, id, id) |
|
f7a34d87
Keenan Nemetz
1d ago
|
307 | } |
|
d222b0cf
Keenan Nemetz
1d ago
|
308 | return nil |
| 309 | } | |
| 310 | ||
| 311 | // themeName turns a file name into the label the picker shows: acme-ocean.css | |
| 312 | // becomes "Acme Ocean". There is no second place to keep a name. | |
| 313 | func themeName(id string) string { | |
| 314 | parts := strings.Split(id, "-") | |
| 315 | for i, part := range parts { | |
| 316 | if part == "" { | |
| 317 | continue | |
|
f7a34d87
Keenan Nemetz
1d ago
|
318 | } |
|
d222b0cf
Keenan Nemetz
1d ago
|
319 | runes := []rune(part) |
| 320 | runes[0] = unicode.ToUpper(runes[0]) | |
| 321 | parts[i] = string(runes) | |
|
f7a34d87
Keenan Nemetz
1d ago
|
322 | } |
|
d222b0cf
Keenan Nemetz
1d ago
|
323 | return strings.Join(parts, " ") |
|
f7a34d87
Keenan Nemetz
1d ago
|
324 | } |
| 325 | ||
|
6ce635a8
Keenan Nemetz
1d ago
|
326 | // allowedAsset is the extension whitelist. HTML and scripts are deliberately |
| 327 | // absent: an asset is served from the forge's origin, and a same-origin document | |
| 328 | // or script is the whole session, not a theme. | |
| 329 | func allowedAsset(name string) bool { | |
| 330 | switch strings.ToLower(path.Ext(name)) { | |
| 331 | case ".css", ".png", ".jpg", ".jpeg", ".webp", ".gif", ".svg", ".ico", ".woff", ".woff2", ".ttf", ".otf": | |
| 332 | return true | |
| 333 | } | |
| 334 | return false | |
| 335 | } |