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

Code

392 commit(s) on main

Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys keenan · 1h ago 4b8c9e1d
history blame raw 9.7 KiB
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.
12package instancerepo
13
14import (
15 "errors"
16 "fmt"
17 "os"
18 "path"
19 "regexp"
20 "sort"
21 "strings"
22 "sync"
23 "sync/atomic"
24 "time"
25 "unicode"
26
27 "rodent.software/vole/core/git"
28 "rodent.software/vole/forge/repo"
29)
30
31const (
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"
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.
40 ThemesDir = "profile/themes/"
41 // FaviconPath replaces the built-in favicon links when it exists.
42 FaviconPath = "profile/favicon.svg"
43 // AssetsPrefix is served under /-/static/custom/assets/.
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
51 maxThemes = 24
52)
53
54var themeIDRE = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,40}$`)
55
56// Asset is one file under assets/.
57type Asset struct {
58 Body []byte
59}
60
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>"].
64type Theme struct {
65 ID string
66 Name string
67 CSS []byte
68}
69
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.
72type Snapshot struct {
73 Commit string
74 Readme []byte
75 PrivateReadme []byte
76 CSS []byte
77 Favicon []byte
78 // Themes are the selectable theme stylesheets, in manifest order.
79 Themes []Theme
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.
85type 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.
98func New(repos *repo.Store, name string) *Store {
99 return &Store{repos: repos, name: strings.TrimSpace(name)}
100}
101
102func (s *Store) Name() string { return s.name }
103
104func (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.
107func (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.
115func (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.
119func (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.
132func (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
177func (s *Store) fail(err error) {
178 s.errMsg = err.Error()
179 s.errAt = time.Now().UTC()
180}
181
182func (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.
190func 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
200 assets, themes := 0, 0
201 for _, e := range entries {
202 if e.Type != "blob" {
203 continue
204 }
205 switch {
206 case e.Name == ReadmePath, e.Name == PrivateReadmePath, e.Name == CSSPath, e.Name == FaviconPath:
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 }
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 }
252 byPath := make(map[string][]byte, len(picks))
253 for _, p := range picks {
254 byPath[p.path] = bodies[p.sha]
255 }
256 snap := &Snapshot{Commit: tip, Assets: map[string]Asset{}}
257 for _, p := range picks {
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]
267 case strings.HasPrefix(p.path, ThemesDir):
268 // Assembled from the file name below.
269 default:
270 snap.Assets[strings.TrimPrefix(p.path, AssetsPrefix)] = Asset{Body: byPath[p.path]}
271 }
272 }
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 {
279 return nil, err
280 }
281 snap.Themes = append(snap.Themes, Theme{ID: id, Name: themeName(id), CSS: byPath[p.path]})
282 }
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 })
289 return snap, nil
290}
291
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.
295func 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)
298 }
299 if id == "system" || id == "nord" || id == "dracula" || id == "tokyo-night" ||
300 strings.HasPrefix(id, "everforest-") ||
301 strings.HasPrefix(id, "catppuccin-") ||
302 strings.HasPrefix(id, "solarized-") ||
303 strings.HasPrefix(id, "rose-pine-") ||
304 strings.HasPrefix(id, "kanagawa-") ||
305 strings.HasPrefix(id, "ayu-") {
306 return fmt.Errorf("%s%s.css: theme id %q is reserved for a built-in theme", ThemesDir, id, id)
307 }
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.
313func themeName(id string) string {
314 parts := strings.Split(id, "-")
315 for i, part := range parts {
316 if part == "" {
317 continue
318 }
319 runes := []rune(part)
320 runes[0] = unicode.ToUpper(runes[0])
321 parts[i] = string(runes)
322 }
323 return strings.Join(parts, " ")
324}
325
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.
329func 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}
336