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.
Offer instance theme packs in the appearance picker
f7a34d8749054b7ff9e1a304a3fc90ccf3786877 · Keenan Nemetz · 1d ago · parent bc95767f
Offer instance theme packs in the appearance picker Add profile/themes/index.json and one scoped stylesheet per theme. The loader validates ids and names, pairs each manifest entry with profile/themes/<id>.css, and exposes the list to the account picker and to theme.js before first paint. Theme stylesheets are linked before profile/custom.css, so an instance-wide override still wins. The starter repository now includes an empty manifest and documents the format in its root README. The configuration docs and the Admin Configuration path table describe the same convention.
forge/instancerepo/instancerepo.go +97−13
| @@ -12,14 +12,17 @@ | |||
| 12 | 12 | package instancerepo | |
| 13 | 13 | ||
| 14 | 14 | import ( | |
| 15 | "encoding/json" | ||
| 15 | 16 | "errors" | |
| 16 | 17 | "fmt" | |
| 17 | 18 | "os" | |
| 18 | 19 | "path" | |
| 20 | "regexp" | ||
| 19 | 21 | "strings" | |
| 20 | 22 | "sync" | |
| 21 | 23 | "sync/atomic" | |
| 22 | 24 | "time" | |
| 25 | "unicode/utf8" | ||
| 23 | 26 | ||
| 24 | 27 | "github.com/rodent-software/vole/core/git" | |
| 25 | 28 | "github.com/rodent-software/vole/forge/repo" | |
| @@ -32,6 +35,10 @@ const ( | |||
| 32 | 35 | PrivateReadmePath = "profile/private/README.md" | |
| 33 | 36 | // CSSPath is appended after the built-in stylesheets on every page. | |
| 34 | 37 | CSSPath = "profile/custom.css" | |
| 38 | // ThemesDir holds one stylesheet per selectable theme. | ||
| 39 | ThemesDir = "profile/themes/" | ||
| 40 | // ThemesIndexPath lists the themes the Appearance picker offers. | ||
| 41 | ThemesIndexPath = ThemesDir + "index.json" | ||
| 35 | 42 | // FaviconPath replaces the built-in favicon links when it exists. | |
| 36 | 43 | FaviconPath = "profile/favicon.svg" | |
| 37 | 44 | // AssetsPrefix is served under /static/custom/assets/. | |
| @@ -42,13 +49,25 @@ const ( | |||
| 42 | 49 | maxThemeBytes = 8 << 20 | |
| 43 | 50 | maxFileBytes = 1 << 20 | |
| 44 | 51 | maxAssets = 100 | |
| 52 | maxThemes = 24 | ||
| 53 | maxThemeName = 60 | ||
| 45 | 54 | ) | |
| 46 | 55 | ||
| 56 | var themeIDRE = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,40}$`) | ||
| 57 | |||
| 47 | 58 | // Asset is one file under assets/. | |
| 48 | 59 | type Asset struct { | |
| 49 | 60 | Body []byte | |
| 50 | 61 | } | |
| 51 | 62 | ||
| 63 | // Theme is one selectable stylesheet from the instance repository. The | ||
| 64 | // stylesheet is expected to scope its tokens to :root[data-theme="<ID>"]. | ||
| 65 | type Theme struct { | ||
| 66 | ID string | ||
| 67 | Name string | ||
| 68 | CSS []byte | ||
| 69 | } | ||
| 70 | |||
| 52 | 71 | // Snapshot is one commit's contribution to the instance. It is immutable after | |
| 53 | 72 | // it is published, so a request can hold it without a lock. | |
| 54 | 73 | type Snapshot struct { | |
| @@ -57,6 +76,8 @@ type Snapshot struct { | |||
| 57 | 76 | PrivateReadme []byte | |
| 58 | 77 | CSS []byte | |
| 59 | 78 | Favicon []byte | |
| 79 | // Themes are the selectable theme stylesheets, in manifest order. | ||
| 80 | Themes []Theme | ||
| 60 | 81 | // Assets maps the path below assets/ to its contents. | |
| 61 | 82 | Assets map[string]Asset | |
| 62 | 83 | } | |
| @@ -177,13 +198,19 @@ func load(g *git.Repo, tip string) (*Snapshot, error) { | |||
| 177 | 198 | sha string | |
| 178 | 199 | } | |
| 179 | 200 | var picks []pick | |
| 180 | assets := 0 | ||
| 201 | assets, themes := 0, 0 | ||
| 181 | 202 | for _, e := range entries { | |
| 182 | 203 | if e.Type != "blob" { | |
| 183 | 204 | continue | |
| 184 | 205 | } | |
| 185 | 206 | switch { | |
| 186 | case e.Name == ReadmePath, e.Name == PrivateReadmePath, e.Name == CSSPath, e.Name == FaviconPath: | ||
| 207 | case e.Name == ReadmePath, e.Name == PrivateReadmePath, e.Name == CSSPath, e.Name == FaviconPath, e.Name == ThemesIndexPath: | ||
| 208 | case strings.HasPrefix(e.Name, ThemesDir) && strings.HasSuffix(e.Name, ".css") && | ||
| 209 | !strings.Contains(strings.TrimPrefix(e.Name, ThemesDir), "/"): | ||
| 210 | themes++ | ||
| 211 | if themes > maxThemes { | ||
| 212 | return nil, fmt.Errorf("%s holds more than %d themes", ThemesDir, maxThemes) | ||
| 213 | } | ||
| 187 | 214 | case strings.HasPrefix(e.Name, AssetsPrefix): | |
| 188 | 215 | if !allowedAsset(e.Name) { | |
| 189 | 216 | continue | |
| @@ -223,25 +250,82 @@ func load(g *git.Repo, tip string) (*Snapshot, error) { | |||
| 223 | 250 | if err != nil { | |
| 224 | 251 | return nil, err | |
| 225 | 252 | } | |
| 253 | byPath := make(map[string][]byte, len(picks)) | ||
| 254 | for _, p := range picks { | ||
| 255 | byPath[p.path] = bodies[p.sha] | ||
| 256 | } | ||
| 226 | 257 | snap := &Snapshot{Commit: tip, Assets: map[string]Asset{}} | |
| 227 | 258 | 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 | ||
| 259 | switch { | ||
| 260 | case p.path == ReadmePath: | ||
| 261 | snap.Readme = byPath[p.path] | ||
| 262 | case p.path == PrivateReadmePath: | ||
| 263 | snap.PrivateReadme = byPath[p.path] | ||
| 264 | case p.path == CSSPath: | ||
| 265 | snap.CSS = byPath[p.path] | ||
| 266 | case p.path == FaviconPath: | ||
| 267 | snap.Favicon = byPath[p.path] | ||
| 268 | case p.path == ThemesIndexPath, strings.HasPrefix(p.path, ThemesDir): | ||
| 269 | // Assembled from the manifest below. | ||
| 238 | 270 | default: | |
| 239 | snap.Assets[strings.TrimPrefix(p.path, AssetsPrefix)] = Asset{Body: body} | ||
| 271 | snap.Assets[strings.TrimPrefix(p.path, AssetsPrefix)] = Asset{Body: byPath[p.path]} | ||
| 240 | 272 | } | |
| 241 | 273 | } | |
| 274 | if manifest, ok := byPath[ThemesIndexPath]; ok { | ||
| 275 | themes, err := parseThemes(manifest, byPath) | ||
| 276 | if err != nil { | ||
| 277 | return nil, err | ||
| 278 | } | ||
| 279 | snap.Themes = themes | ||
| 280 | } | ||
| 242 | 281 | return snap, nil | |
| 243 | 282 | } | |
| 244 | 283 | ||
| 284 | // parseThemes reads profile/themes/index.json and pairs each entry with the | ||
| 285 | // stylesheet named after its id. A bad manifest is a load error, not a silent | ||
| 286 | // omission: the Admin page shows the message and the previous theme stays in | ||
| 287 | // use, which is how an admin finds the typo. | ||
| 288 | func parseThemes(manifest []byte, byPath map[string][]byte) ([]Theme, error) { | ||
| 289 | var doc struct { | ||
| 290 | Themes []struct { | ||
| 291 | ID string `json:"id"` | ||
| 292 | Name string `json:"name"` | ||
| 293 | } `json:"themes"` | ||
| 294 | } | ||
| 295 | if err := json.Unmarshal(manifest, &doc); err != nil { | ||
| 296 | return nil, fmt.Errorf("%s: %w", ThemesIndexPath, err) | ||
| 297 | } | ||
| 298 | if len(doc.Themes) > maxThemes { | ||
| 299 | return nil, fmt.Errorf("%s lists %d themes, more than the %d allowed", ThemesIndexPath, len(doc.Themes), maxThemes) | ||
| 300 | } | ||
| 301 | seen := map[string]bool{} | ||
| 302 | out := make([]Theme, 0, len(doc.Themes)) | ||
| 303 | for _, entry := range doc.Themes { | ||
| 304 | id := strings.TrimSpace(entry.ID) | ||
| 305 | name := strings.TrimSpace(entry.Name) | ||
| 306 | if !themeIDRE.MatchString(id) { | ||
| 307 | return nil, fmt.Errorf("%s: theme id %q must use lowercase letters, digits, and dashes (max 41 characters)", ThemesIndexPath, id) | ||
| 308 | } | ||
| 309 | if id == "system" || strings.HasPrefix(id, "everforest-") { | ||
| 310 | return nil, fmt.Errorf("%s: theme id %q is reserved", ThemesIndexPath, id) | ||
| 311 | } | ||
| 312 | if seen[id] { | ||
| 313 | return nil, fmt.Errorf("%s: theme id %q is listed twice", ThemesIndexPath, id) | ||
| 314 | } | ||
| 315 | seen[id] = true | ||
| 316 | if name == "" || utf8.RuneCountInString(name) > maxThemeName { | ||
| 317 | return nil, fmt.Errorf("%s: theme %q needs a name of 1-%d characters", ThemesIndexPath, id, maxThemeName) | ||
| 318 | } | ||
| 319 | cssPath := ThemesDir + id + ".css" | ||
| 320 | body, ok := byPath[cssPath] | ||
| 321 | if !ok { | ||
| 322 | return nil, fmt.Errorf("%s lists theme %q but %s is missing", ThemesIndexPath, id, cssPath) | ||
| 323 | } | ||
| 324 | out = append(out, Theme{ID: id, Name: name, CSS: body}) | ||
| 325 | } | ||
| 326 | return out, nil | ||
| 327 | } | ||
| 328 | |||
| 245 | 329 | // allowedAsset is the extension whitelist. HTML and scripts are deliberately | |
| 246 | 330 | // absent: an asset is served from the forge's origin, and a same-origin document | |
| 247 | 331 | // or script is the whole session, not a theme. | |