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.

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.

server/web/admin_instance.go +156

@@ -0,0 +1,156 @@
1 package web
2
3 // The instance repository's admin surface. It is a section of the Configuration
4 // tab: the values there are read-only host configuration, and this is the one
5 // repository the instance reads as data. Creating it from here means an operator
6 // does not have to remember the name or the starter files.
7
8 import (
9 "net/http"
10 "strings"
11 "time"
12
13 "github.com/rodent-software/vole/core/git"
14 )
15
16 // instanceRepoView is the Instance repository panel on the Configuration tab.
17 type instanceRepoView struct {
18 Name string
19 Enabled bool
20 Ready bool
21 Commit string
22 Err string
23 ErrAt time.Time
24 CloneURL string
25 Created bool
26 }
27
28 func (w *Web) instanceRepoView(r *http.Request) instanceRepoView {
29 name := ""
30 if w.Cfg != nil {
31 name = strings.TrimSpace(w.Cfg.InstanceRepo)
32 }
33 created := false
34 if r != nil {
35 created = r.URL.Query().Get("created") == "1"
36 }
37 v := instanceRepoView{
38 Name: name,
39 Enabled: name != "",
40 Created: created,
41 }
42 if w.InstanceRepo != nil {
43 _, commit, errMsg, errAt := w.InstanceRepo.Status()
44 v.Name = w.InstanceRepo.Name()
45 v.Ready = w.InstanceRepo.Ready()
46 v.Commit, v.Err, v.ErrAt = commit, errMsg, errAt
47 }
48 if w.Cfg != nil && name != "" {
49 v.CloneURL = sshURL(w.Cfg.SSHAddr, name)
50 }
51 return v
52 }
53
54 // CreateInstanceRepo scaffolds the configured repository. If it already exists
55 // the action only reloads it, so a repository created by hand and an empty one
56 // are both recoverable from the same button.
57 func (w *Web) CreateInstanceRepo(rw http.ResponseWriter, r *http.Request) {
58 pr, ok := w.beginAdminPost(rw, r, maxSmallForm)
59 if !ok {
60 return
61 }
62 name := ""
63 if w.Cfg != nil {
64 name = strings.TrimSpace(w.Cfg.InstanceRepo)
65 }
66 if name == "" {
67 w.renderAdminTab(rw, r, "configuration", "Set instance_repo before creating the repository.", http.StatusBadRequest)
68 return
69 }
70 if w.Repos == nil {
71 w.serverError(rw, r, "the repository store is unavailable")
72 return
73 }
74 created := false
75 m, err := w.Repos.Meta(name)
76 if err != nil {
77 m, err = w.Repos.Create(name, pr.account.Name, "Instance customization: profile README, custom CSS, favicon, and assets")
78 if err != nil {
79 w.renderAdminTab(rw, r, "configuration", err.Error(), http.StatusBadRequest)
80 return
81 }
82 created = true
83 }
84 // An existing repository with no commits is still a repository to finish, so
85 // the button seeds it rather than failing with "already exists".
86 if tip, _ := m.Git().RevParseRaw("refs/heads/" + m.DefaultBranch); tip == "" {
87 g := m.Git()
88 tree, err := git.WriteFlatTree(g, instanceRepoTemplate(w.instanceName()))
89 if err != nil {
90 w.serverError(rw, r, "starter files could not be written: "+err.Error())
91 return
92 }
93 commit, err := g.CommitTreeAs(tree, "", "Create the instance repository", pr.account.Name)
94 if err != nil {
95 w.serverError(rw, r, "starter files could not be committed: "+err.Error())
96 return
97 }
98 if err := g.UpdateRef("refs/heads/"+m.DefaultBranch, commit); err != nil {
99 w.serverError(rw, r, "the branch could not be moved: "+err.Error())
100 return
101 }
102 created = true
103 }
104 if created && w.Index != nil {
105 if err := w.Index.Upsert(m); err != nil {
106 w.serverError(rw, r, "repository created but the index was not updated: "+err.Error())
107 return
108 }
109 }
110 if w.InstanceRepo != nil {
111 if err := w.InstanceRepo.Reload(); err != nil && w.Logger != nil {
112 w.Logger("instance repository reload after create: %v", err)
113 }
114 }
115 if !created {
116 http.Redirect(rw, r, "/admin?tab=configuration", http.StatusSeeOther)
117 return
118 }
119 w.audit("web.admin.instance.repo", pr.account.Name, name, "created")
120 http.Redirect(rw, r, "/admin?tab=configuration&created=1", http.StatusSeeOther)
121 }
122
123 // instanceRepoTemplate is the starter tree. The root README explains the fixed
124 // paths; the profile files give the admin something to edit rather than a blank
125 // page, and the CSS names the variables worth overriding.
126 func instanceRepoTemplate(name string) map[string][]byte {
127 return map[string][]byte{
128 "README.md": []byte(`# Instance customization
129
130 This repository customizes the Vole instance. Only these paths are read; other
131 files are ignored, so this README and any notes can live here too.
132
133 profile/README.md the landing page introduction (markdown)
134 profile/private/README.md replaces it for signed-in viewers
135 profile/custom.css appended after the built-in stylesheets
136 profile/favicon.svg replaces the built-in favicon
137 assets/ served at /static/custom/assets/
138
139 Pushing to ` + "`main`" + ` updates the instance within a couple of seconds. The
140 repository is private to the instance's admins; the files above are served to
141 everyone (the private README only to signed-in viewers).
142 `),
143 "profile/README.md": []byte("# " + name + "\n\nTell people what this forge is for. This file is the landing page introduction.\n"),
144 "profile/custom.css": []byte(`/* Custom styles, loaded after the built-in stylesheets.
145 *
146 * The palette is CSS variables; override them here rather than copying rules:
147 * :root {
148 * --accent: #7fbbb3;
149 * --bg: #1e2326;
150 * }
151 *
152 * The layout is grid and flex, so most re-skins need only values, not selectors.
153 */
154 `),
155 }
156 }