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.

Reorganise the web package around one implementation per rule

c717d4eb92b4257b05fa065918b0071f6302f53e · Keenan Nemetz · 13d ago · parent 4506a4d3

Reorganise the web package around one implementation per rule

server/web had grown by feature, so several rules and widgets existed twice: the
comment stack for issues and change requests, the pager, the file tree, the write
preamble, and the ACL predicates. Each pair agreed today and would drift
tomorrow. No route, stored format, or permission rule changes; the few behaviour
differences found along the way are listed at the end.

Files now say what they answer:

  web.go          the Web type, render, open, the error pages
  page.go         Page, pageSection, who, nav
  templatefuncs.go the template function map, with the escaping rules noted
  ui.go           relative time, avatars, breadcrumbs, the pager
  markdown.go     the goldmark and bluemonday boundary
  codebrowser.go  the code browser, out of web.go
  tokens.go       the label/assignee token input
  rolepicker.go   the role control, out of admin.go
  comments.go     the comment view, thread builder, action menus, snippet
  comment_writes.go  add, edit and delete a comment, for both surfaces
  write_context.go   beginWrite/beginPost/beginAdminPost, fail, refuse
  capabilities.go    the ACL rules a page and a handler share
  audit.go        the audit log
  request_view.go the change conversation page, out of requests.go
  admin_access.go the instance-admin access rules, out of settings.go

A comment is one thing, so it has one view and one thread builder: metadata.Comment
is the same type on both surfaces and only a change request can anchor one to a
diff line. Adding, editing and deleting are one implementation each, with a
commentTopic naming what differs per surface. The pager is one control for issues,
changes and the instance feed; the file tree is one widget for the code browser
and a change's Files tab.

Every write starts at beginWrite (session, body bound, CSRF, repository, rules,
form) and asks capabilities.go for the one rule it needs, so the control a page
draws and the check a handler makes cannot disagree. A rejected write answers
through refuse; a write that fails for another reason answers through fail, which
re-renders the section for HTMX and returns text otherwise.

Templates follow the same split: chrome.html, components.html, diff.html and
role-picker.html hold what more than one surface renders, and each page owns its
fragments. The route table moved to routes.go, one function per surface.

Behaviour changes found while unifying:

  - A reply cycle (two comments answering each other, possible through a pushed
    metadata branch) used to drop both comments from the page; anything the walk
    does not reach is now emitted as a root.
  - A rejected anchored comment keeps its "Commenting on path:line" note and
    hidden anchor fields, so the reviewer does not have to pick the line again.
  - A repository whose access rules cannot be read answers 500 rather than 403;
    the account was being blamed for a broken instance.
  - Pager wording is now "Showing a-b of n / page x of y" with Previous/Next
    everywhere, and the instance feed's page links keep their kind filter.
  - The activity feed, issue list and change list share one page control.

Docs: docs/ui-plan.md gains a "Where the code lives" map, the write gate, the
shared comment model, the route table, and rules for rejected writes, shared
template fragments, and the stylesheet's section order. The passkey script is now
an ordinary asset (static/auth.js) under the same CSP as the rest.

server/web/codebrowser.go +317

@@ -0,0 +1,317 @@
1 package web
2
3 // The code browser: a persistent file tree beside the file, directory or README
4 // it has open, plus the commit history for a ref. Only the main pane is replaced
5 // on navigation; the tree, branch list and latest commit belong to the full page.
6 //
7 // The repository overview (overview.go) is the page that summarises a repository;
8 // this is the page that reads it.
9
10 import (
11 "fmt"
12 "html/template"
13 "net/http"
14 "sort"
15 "strings"
16 "unicode/utf8"
17
18 "github.com/rodent-software/vole/core/git"
19 "github.com/rodent-software/vole/forge/repo"
20 )
21
22 const maxInlineBytes = 1 << 20
23
24 // codeView is the data shared by the full code page and the HTMX fragment that
25 // replaces its main pane. Fields under the blob section are only populated for
26 // blob views; the tree and shell fields are only populated for full pages.
27 type codeView struct {
28 Page
29 Name string
30 Ref string
31 Commit string
32 CloneURL string
33 SSHURL string
34 Site string
35 Count int
36 Branches []string
37 Latest []git.Commit
38 Tree []*treeNode
39 TreeTruncated bool
40
41 Mode string
42 Path string
43 Parent string
44 Crumbs []crumb
45 Entries []git.Entry
46
47 Readme template.HTML
48 ReadmeName string
49
50 Content string
51 Markdown template.HTML
52 Binary bool
53 Truncated bool
54 LineCut bool
55 Lines []string
56 Highlight template.HTML
57 Size int64
58 IsMarkdown bool
59 Preview bool
60 }
61
62 // Code is the file browser: a persistent tree beside the file it has open. The
63 // repository overview (Overview) is the page that summarises the repository.
64 func (w *Web) Code(rw http.ResponseWriter, r *http.Request) {
65 m, ok := w.open(rw, r)
66 if !ok {
67 return
68 }
69 ref := r.URL.Query().Get("ref")
70 if ref == "" {
71 ref = m.DefaultBranch
72 }
73 g := m.Git()
74 commit, _ := g.RevParse(ref)
75 var readme template.HTML
76 readmeName := ""
77 if commit != "" {
78 readme, readmeName = w.readmeAt(m, commit)
79 }
80 // The README is what the main pane opens with, so it is also the tree row
81 // that should look current when no file has been selected yet.
82 full := !htmxRequest(r)
83 view := w.codeShell(r, m, ref, commit, readmeName, full)
84 view.Mode = "overview"
85 if commit != "" {
86 view.Entries, _ = g.LsTree(commit)
87 sortEntries(view.Entries)
88 view.Readme, view.ReadmeName = readme, readmeName
89 if readmeName != "" {
90 view.Path = readmeName
91 view.Crumbs = crumbs(readmeName)
92 }
93 }
94 if !full {
95 w.render(rw, "code_main", view, 0)
96 return
97 }
98 w.render(rw, "code", view, 0)
99 }
100
101 // codeShell gathers everything the full code page keeps outside the main pane:
102 // the branch list, the file tree, the latest commit, and contributor-branch
103 // state. HTMX fragment requests skip the work they will not render.
104 func (w *Web) codeShell(r *http.Request, m *repo.Meta, ref, commit, active string, full bool) codeView {
105 g := m.Git()
106 v := codeView{
107 Page: w.page(r, m.Name+" · code"),
108 Name: m.Name,
109 Ref: ref,
110 Commit: commit,
111 CloneURL: cloneURL(r, m.Name),
112 SSHURL: sshURL(w.Cfg.SSHAddr, m.Name),
113 }
114 if w.Cfg.PagesDomain != "" {
115 v.Site = "http://" + m.Name + "." + w.Cfg.PagesDomain
116 }
117 if !full {
118 return v
119 }
120 if commit != "" {
121 v.Latest, _ = g.Log(commit, 1)
122 v.Count, _ = g.CountCommits(commit)
123 v.Tree, v.TreeTruncated = buildFileTree(g, commit, active)
124 }
125 branches, _ := g.ListRefs("refs/heads/")
126 names := make([]string, 0, len(branches))
127 for k := range branches {
128 names = append(names, strings.TrimPrefix(k, "refs/heads/"))
129 }
130 sort.Strings(names)
131 v.Branches = names
132 return v
133 }
134
135 func (w *Web) Tree(rw http.ResponseWriter, r *http.Request) {
136 m, ok := w.open(rw, r)
137 if !ok {
138 return
139 }
140 ref, p := r.PathValue("ref"), r.PathValue("path")
141 if !safePath(p) {
142 http.Error(rw, "invalid path", http.StatusBadRequest)
143 return
144 }
145 g := m.Git()
146 commit, _ := g.RevParse(ref)
147 if commit == "" {
148 w.notFound(rw, r)
149 return
150 }
151 treeish := commit
152 if p != "" {
153 treeish = commit + ":" + p
154 }
155 entries, err := g.LsTree(treeish)
156 if err != nil {
157 http.Error(rw, "not a directory", http.StatusBadRequest)
158 return
159 }
160 sortEntries(entries)
161 full := !htmxRequest(r)
162 view := w.codeShell(r, m, ref, commit, p, full)
163 view.Mode = "tree"
164 view.Path = p
165 view.Parent = parentPath(p)
166 view.Entries = entries
167 view.Crumbs = crumbs(p)
168 if !full {
169 w.render(rw, "code_main", view, 0)
170 return
171 }
172 view.Page = w.page(r, m.Name+" · "+ref)
173 w.render(rw, "code", view, 0)
174 }
175
176 func (w *Web) Blob(rw http.ResponseWriter, r *http.Request) { w.blob(rw, r, false) }
177
178 func (w *Web) Raw(rw http.ResponseWriter, r *http.Request) { w.blob(rw, r, true) }
179
180 func (w *Web) blob(rw http.ResponseWriter, r *http.Request, raw bool) {
181 m, ok := w.open(rw, r)
182 if !ok {
183 return
184 }
185 ref, p := r.PathValue("ref"), r.PathValue("path")
186 if !safePath(p) {
187 http.Error(rw, "invalid path", http.StatusBadRequest)
188 return
189 }
190 g := m.Git()
191 commit, _ := g.RevParse(ref)
192 if commit == "" {
193 w.notFound(rw, r)
194 return
195 }
196 data, err := g.BlobAt(commit, p)
197 if err != nil {
198 w.notFound(rw, r)
199 return
200 }
201 if raw {
202 rw.Header().Set("Content-Type", "text/plain; charset=utf-8")
203 rw.Write(data)
204 return
205 }
206 content := string(data)
207 binary := !utf8.Valid(data)
208 truncated := false
209 if len(data) > maxInlineBytes {
210 content = string(data[:maxInlineBytes])
211 truncated = true
212 }
213 // A table row per line is pleasant to read and cheap for a source file, but a
214 // million blank lines would be neither. Past the cap the page falls back to a
215 // preformatted block.
216 const maxNumberedLines = 5000
217 lines := strings.Split(content, "\n")
218 lineCut := false
219 if len(lines) > maxNumberedLines {
220 lines = lines[:maxNumberedLines]
221 lineCut = true
222 }
223 // Highlight the same bytes the table would show. A language we cannot name
224 // stays plain, which is better than guessing wrong about its grammar.
225 var highlighted template.HTML
226 if !binary {
227 highlighted = highlightCode(p, strings.Join(lines, "\n"))
228 }
229 markdown := !binary && isMarkdown(p)
230 // A markdown file opens as source like any other file. Rendering it is an
231 // explicit choice, so the code browser never hides the bytes by default.
232 preview := markdown && !truncated && r.URL.Query().Get("preview") == "1"
233 var rendered template.HTML
234 if preview {
235 rendered = w.renderMarkdown(m.Name, content)
236 }
237 full := !htmxRequest(r)
238 view := w.codeShell(r, m, ref, commit, p, full)
239 view.Mode = "blob"
240 view.Path = p
241 view.Parent = parentPath(p)
242 view.Crumbs = crumbs(p)
243 view.Content = content
244 view.Markdown = rendered
245 view.Binary = binary
246 view.Truncated = truncated
247 view.LineCut = lineCut
248 view.Lines = lines
249 view.Highlight = highlighted
250 view.Size = int64(len(data))
251 view.IsMarkdown = markdown
252 view.Preview = preview
253 if !full {
254 w.render(rw, "code_main", view, 0)
255 return
256 }
257 view.Page = w.page(r, m.Name+" · "+p)
258 w.render(rw, "code", view, 0)
259 }
260
261 func (w *Web) Commits(rw http.ResponseWriter, r *http.Request) {
262 m, ok := w.open(rw, r)
263 if !ok {
264 return
265 }
266 ref := r.PathValue("ref")
267 g := m.Git()
268 commit, _ := g.RevParse(ref)
269 if commit == "" {
270 w.notFound(rw, r)
271 return
272 }
273 commits, err := g.Log(commit, 100)
274 if err != nil {
275 w.serverError(rw, r, "cannot read commit history: "+err.Error())
276 return
277 }
278 w.render(rw, "commits", struct {
279 Page
280 Name string
281 Ref string
282 Commits []git.Commit
283 }{w.page(r, m.Name+" · history"), m.Name, ref, commits}, 0)
284 }
285
286 // cloneBase is the read-only HTTP transport root for this instance, as the
287 // browser reached it. It is what the landing page shows an anonymous visitor
288 // who wants to clone without an account.
289 func cloneBase(r *http.Request) string {
290 scheme := "http"
291 if r.TLS != nil {
292 scheme = "https"
293 }
294 if p := r.Header.Get("X-Forwarded-Proto"); p != "" {
295 scheme = p
296 }
297 return fmt.Sprintf("%s://%s/git/", scheme, r.Host)
298 }
299
300 func cloneURL(r *http.Request, name string) string {
301 return cloneBase(r) + name + ".git"
302 }
303
304 func sshURL(addr, name string) string {
305 host, port, ok := strings.Cut(addr, ":")
306 if !ok {
307 host = addr
308 }
309 return fmt.Sprintf("ssh://git@%s/%s.git", netJoin(host, port), name)
310 }
311
312 func netJoin(host, port string) string {
313 if port == "" || port == "22" {
314 return host
315 }
316 return host + ":" + port
317 }