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.
Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys
keenan · 1h ago
4b8c9e1d
| 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 | "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 | |
| 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" |
| 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 | |
| 54 | var themeIDRE = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,40}$`) |
| 55 | |
| 56 | // Asset is one file under assets/. |
| 57 | type 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>"]. |
| 64 | type 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. |
| 72 | type 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. |
| 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 |
| 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. |
| 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) |
| 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. |
| 313 | func 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. |
| 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 | } |
| 336 |