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/web.go +10−484

@@ -4,21 +4,21 @@
4 4 //
5 5 // Anonymous readers still get 404 for a private repository rather than 403, so
6 6 // the web never confirms that a private repository exists.
7 //
8 // Files are grouped by what they answer, not by layer: web.go is the Web type and
9 // the request plumbing, page.go is the data every page starts from, and there is
10 // one file per destination (issues.go, requests.go, codebrowser.go, home.go, ...).
11 // Read surfaces answer a page or an HTMX fragment; writes.go and the *_actions
12 // files answer a form POST. docs/ui-plan.md has the full map.
7 13 package web
8 14
9 15 import (
10 "fmt"
11 16 "html/template"
12 17 "net/http"
13 "net/url"
14 "sort"
15 "strings"
16 18 "sync"
17 19 "time"
18 "unicode/utf8"
19 20
20 21 "github.com/rodent-software/vole/ci/runs"
21 "github.com/rodent-software/vole/core/git"
22 22 "github.com/rodent-software/vole/core/lfs"
23 23 "github.com/rodent-software/vole/forge/access"
24 24 "github.com/rodent-software/vole/forge/config"
@@ -27,7 +27,6 @@ import (
27 27 "github.com/rodent-software/vole/forge/pageview"
28 28 "github.com/rodent-software/vole/forge/repo"
29 29 "github.com/rodent-software/vole/forge/session"
30 "github.com/rodent-software/vole/internal/format"
31 30 )
32 31
33 32 type Web struct {
@@ -76,33 +75,10 @@ func New(repos *repo.Store, ix *index.Store, ident *identity.Store, rs *runs.Sto
76 75 w := &Web{Repos: repos, Index: ix, Ident: ident, Runs: rs, Cfg: cfg,
77 76 Auth: auth, Ceremonies: newCeremonyStore(), Started: time.Now().UTC(),
78 77 sem: make(chan struct{}, archiveConc)}
79 // The templates are parsed after the Web exists so the roster-backed helper
80 // below can be a method: metadata files carry account names and approvals are
81 // keyed by names, and a page should show the account name for either.
82 w.Tmpl, err = template.New("vole").Funcs(template.FuncMap{
83 "since": humanSince,
84 "short": format.SHA,
85 "esc": url.PathEscape,
86 "bytes": format.Bytes,
87 "credid": identity.CredentialIDString,
88 "who": w.who,
89 "nav": w.repoNav,
90 "inc": func(i int) int { return i + 1 },
91 "initial": initial,
92 "tone": tone,
93 "urlquery": url.QueryEscape,
94 "join": strings.Join,
95 "tokenPicker": tokenPicker,
96 "rolePicker": newRolePicker,
97 "icon": icon,
98 "treeContext": treeContext,
99 "reviewTreeContext": reviewTreeContext,
100 "pathurl": pathURL,
101 "pathjoin": joinPath,
102 "parentpath": parentPath,
103 "fileicon": fileIcon,
104 "fileclass": fileClass,
105 }).ParseFS(templateFS, "templates/*.html")
78 // The templates are parsed after the Web exists because two of the functions
79 // they call (who, nav) read the roster through the Web: metadata files carry
80 // account names, and a page should show the account name for either.
81 w.Tmpl, err = template.New("vole").Funcs(w.templateFuncs()).ParseFS(templateFS, "templates/*.html")
106 82 if err != nil {
107 83 return nil, err
108 84 }
@@ -113,90 +89,6 @@ func New(repos *repo.Store, ix *index.Store, ident *identity.Store, rs *runs.Sto
113 89 return w, nil
114 90 }
115 91
116 // who renders an account name for a page. It is a presentation helper only:
117 // nothing is authorized by what this returns.
118 func (w *Web) who(name string) string {
119 if w.Ident == nil || name == "" {
120 return name
121 }
122 a, err := w.Ident.Find(name)
123 if err != nil {
124 return name
125 }
126 return a.Name
127 }
128
129 // repoNav is the repository header's data: which repository, which tab is
130 // current, and whether anonymous readers can see it. Visibility comes from the
131 // same ACL rule the handlers use, so the lock/globe icon cannot disagree with
132 // whether an anonymous clone works. Templates call it as
133 // {{template "repohead" (nav .Name "issues")}}.
134 func (w *Web) repoNav(name, tab string, canAdmin bool) repoNavData {
135 n := repoNavData{Name: name, Tab: tab, Private: true, CanAdmin: canAdmin}
136 m, err := w.Repos.Open(name)
137 if err != nil {
138 return n
139 }
140 n.Description = m.Description
141 if acl, err := m.ACL(); err == nil {
142 n.Private = !acl.Can("", access.Read)
143 }
144 return n
145 }
146
147 type repoNavData struct {
148 Name string
149 Description string
150 Tab string
151 Private bool
152 CanAdmin bool
153 }
154
155 // Page is embedded by every view. Exported so html/template can promote its
156 // fields; an unexported embedded type is not reachable from a template.
157 type Page struct {
158 Title string
159 Error string
160 LoggedIn bool
161 User string
162 AccountName string
163 // CSRF is derived from the session token and must be on every state-changing
164 // form or fetch.
165 CSRF string
166 // DevLogin names the account a loopback browser is signed in as. Empty on a
167 // normal instance; the header uses it to make the development state visible.
168 DevLogin string
169 // CanAdmin reports whether the viewer may administer the repository named in
170 // the request path. The repository header uses it to show Settings.
171 CanAdmin bool
172 // InstanceAdmin reports whether the viewer may use the instance-wide admin
173 // surface. The global bar uses it to show Admin.
174 InstanceAdmin bool
175 // Query is the repository search on the index page. The global search box
176 // echoes it; other pages leave it empty.
177 Query string
178 // Section is the global destination the current page belongs to, used by the
179 // rail to mark the current item. It is presentation only.
180 Section string
181 }
182
183 // pageSection maps a request path to the global destination it belongs to. A
184 // repository page belongs to Repositories: the repository's own tabs carry the
185 // finer context.
186 func pageSection(path string) string {
187 switch {
188 case path == "/" || path == "/repos/new" || strings.HasPrefix(path, "/r/"):
189 return "repos"
190 case strings.HasPrefix(path, "/activity"):
191 return "activity"
192 case path == "/users" || strings.HasPrefix(path, "/users/"):
193 return "users"
194 case strings.HasPrefix(path, "/account"):
195 return "account"
196 }
197 return ""
198 }
199
200 92 // canRead reports whether the viewer may see the repository. An anonymous
201 93 // viewer is the empty principal, which is the same rule the git HTTP transport
202 94 // uses; a logged-in account gets its ACL role, and an instance admin may
@@ -252,8 +144,6 @@ func (w *Web) render(rw http.ResponseWriter, name string, data any, status int)
252 144 }
253 145 }
254 146
255 // ---------- handlers ----------
256
257 147 // open resolves a repository for the viewer, answering 404 when it is missing
258 148 // or not readable. A private repository is indistinguishable from a missing one.
259 149 func (w *Web) open(rw http.ResponseWriter, r *http.Request) (*repo.Meta, bool) {
@@ -264,367 +154,3 @@ func (w *Web) open(rw http.ResponseWriter, r *http.Request) (*repo.Meta, bool) {
264 154 }
265 155 return m, true
266 156 }
267
268 // Code is the file browser: a persistent tree beside the file it has open. The
269 // repository overview (Overview) is the page that summarises the repository.
270 func (w *Web) Code(rw http.ResponseWriter, r *http.Request) {
271 m, ok := w.open(rw, r)
272 if !ok {
273 return
274 }
275 ref := r.URL.Query().Get("ref")
276 if ref == "" {
277 ref = m.DefaultBranch
278 }
279 g := m.Git()
280 commit, _ := g.RevParse(ref)
281 var readme template.HTML
282 readmeName := ""
283 if commit != "" {
284 readme, readmeName = w.readmeAt(m, commit)
285 }
286 // The README is what the main pane opens with, so it is also the tree row
287 // that should look current when no file has been selected yet.
288 full := !htmxRequest(r)
289 view := w.codeShell(r, m, ref, commit, readmeName, full)
290 view.Mode = "overview"
291 if commit != "" {
292 view.Entries, _ = g.LsTree(commit)
293 sortEntries(view.Entries)
294 view.Readme, view.ReadmeName = readme, readmeName
295 if readmeName != "" {
296 view.Path = readmeName
297 view.Crumbs = crumbs(readmeName)
298 }
299 }
300 if !full {
301 w.render(rw, "code_main", view, 0)
302 return
303 }
304 w.render(rw, "code", view, 0)
305 }
306
307 // codeShell gathers everything the full code page keeps outside the main pane:
308 // the branch list, the file tree, the latest commit, and contributor-branch
309 // state. HTMX fragment requests skip the work they will not render.
310 func (w *Web) codeShell(r *http.Request, m *repo.Meta, ref, commit, active string, full bool) codeView {
311 g := m.Git()
312 v := codeView{
313 Page: w.page(r, m.Name+" · code"),
314 Name: m.Name,
315 Ref: ref,
316 Commit: commit,
317 CloneURL: cloneURL(r, m.Name),
318 SSHURL: sshURL(w.Cfg.SSHAddr, m.Name),
319 }
320 if w.Cfg.PagesDomain != "" {
321 v.Site = "http://" + m.Name + "." + w.Cfg.PagesDomain
322 }
323 if !full {
324 return v
325 }
326 if commit != "" {
327 v.Latest, _ = g.Log(commit, 1)
328 v.Count, _ = g.CountCommits(commit)
329 v.Tree, v.TreeTruncated = buildFileTree(g, commit, active)
330 }
331 branches, _ := g.ListRefs("refs/heads/")
332 names := make([]string, 0, len(branches))
333 for k := range branches {
334 names = append(names, strings.TrimPrefix(k, "refs/heads/"))
335 }
336 sort.Strings(names)
337 v.Branches = names
338 return v
339 }
340
341 func (w *Web) Tree(rw http.ResponseWriter, r *http.Request) {
342 m, ok := w.open(rw, r)
343 if !ok {
344 return
345 }
346 ref, p := r.PathValue("ref"), r.PathValue("path")
347 if !safePath(p) {
348 http.Error(rw, "invalid path", http.StatusBadRequest)
349 return
350 }
351 g := m.Git()
352 commit, _ := g.RevParse(ref)
353 if commit == "" {
354 w.notFound(rw, r)
355 return
356 }
357 treeish := commit
358 if p != "" {
359 treeish = commit + ":" + p
360 }
361 entries, err := g.LsTree(treeish)
362 if err != nil {
363 http.Error(rw, "not a directory", http.StatusBadRequest)
364 return
365 }
366 sortEntries(entries)
367 full := !htmxRequest(r)
368 view := w.codeShell(r, m, ref, commit, p, full)
369 view.Mode = "tree"
370 view.Path = p
371 view.Parent = parentPath(p)
372 view.Entries = entries
373 view.Crumbs = crumbs(p)
374 if !full {
375 w.render(rw, "code_main", view, 0)
376 return
377 }
378 view.Page = w.page(r, m.Name+" · "+ref)
379 w.render(rw, "code", view, 0)
380 }
381
382 func (w *Web) Blob(rw http.ResponseWriter, r *http.Request) { w.blob(rw, r, false) }
383
384 func (w *Web) Raw(rw http.ResponseWriter, r *http.Request) { w.blob(rw, r, true) }
385
386 const maxInlineBytes = 1 << 20
387
388 func (w *Web) blob(rw http.ResponseWriter, r *http.Request, raw bool) {
389 m, ok := w.open(rw, r)
390 if !ok {
391 return
392 }
393 ref, p := r.PathValue("ref"), r.PathValue("path")
394 if !safePath(p) {
395 http.Error(rw, "invalid path", http.StatusBadRequest)
396 return
397 }
398 g := m.Git()
399 commit, _ := g.RevParse(ref)
400 if commit == "" {
401 w.notFound(rw, r)
402 return
403 }
404 data, err := g.BlobAt(commit, p)
405 if err != nil {
406 w.notFound(rw, r)
407 return
408 }
409 if raw {
410 rw.Header().Set("Content-Type", "text/plain; charset=utf-8")
411 rw.Write(data)
412 return
413 }
414 content := string(data)
415 binary := !utf8.Valid(data)
416 truncated := false
417 if len(data) > maxInlineBytes {
418 content = string(data[:maxInlineBytes])
419 truncated = true
420 }
421 // A table row per line is pleasant to read and cheap for a source file, but a
422 // million blank lines would be neither. Past the cap the page falls back to a
423 // preformatted block.
424 const maxNumberedLines = 5000
425 lines := strings.Split(content, "\n")
426 lineCut := false
427 if len(lines) > maxNumberedLines {
428 lines = lines[:maxNumberedLines]
429 lineCut = true
430 }
431 // Highlight the same bytes the table would show. A language we cannot name
432 // stays plain, which is better than guessing wrong about its grammar.
433 var highlighted template.HTML
434 if !binary {
435 highlighted = highlightCode(p, strings.Join(lines, "\n"))
436 }
437 markdown := !binary && isMarkdown(p)
438 // A markdown file opens as source like any other file. Rendering it is an
439 // explicit choice, so the code browser never hides the bytes by default.
440 preview := markdown && !truncated && r.URL.Query().Get("preview") == "1"
441 var rendered template.HTML
442 if preview {
443 rendered = w.renderMarkdown(m.Name, content)
444 }
445 full := !htmxRequest(r)
446 view := w.codeShell(r, m, ref, commit, p, full)
447 view.Mode = "blob"
448 view.Path = p
449 view.Parent = parentPath(p)
450 view.Crumbs = crumbs(p)
451 view.Content = content
452 view.Markdown = rendered
453 view.Binary = binary
454 view.Truncated = truncated
455 view.LineCut = lineCut
456 view.Lines = lines
457 view.Highlight = highlighted
458 view.Size = int64(len(data))
459 view.IsMarkdown = markdown
460 view.Preview = preview
461 if !full {
462 w.render(rw, "code_main", view, 0)
463 return
464 }
465 view.Page = w.page(r, m.Name+" · "+p)
466 w.render(rw, "code", view, 0)
467 }
468
469 func (w *Web) Commits(rw http.ResponseWriter, r *http.Request) {
470 m, ok := w.open(rw, r)
471 if !ok {
472 return
473 }
474 ref := r.PathValue("ref")
475 g := m.Git()
476 commit, _ := g.RevParse(ref)
477 if commit == "" {
478 w.notFound(rw, r)
479 return
480 }
481 commits, err := g.Log(commit, 100)
482 if err != nil {
483 w.serverError(rw, r, "cannot read commit history: "+err.Error())
484 return
485 }
486 w.render(rw, "commits", struct {
487 Page
488 Name string
489 Ref string
490 Commits []git.Commit
491 }{w.page(r, m.Name+" · history"), m.Name, ref, commits}, 0)
492 }
493
494 // ---------- helpers ----------
495
496 // cloneBase is the read-only HTTP transport root for this instance, as the
497 // browser reached it. It is what the landing page shows an anonymous visitor
498 // who wants to clone without an account.
499 func cloneBase(r *http.Request) string {
500 scheme := "http"
501 if r.TLS != nil {
502 scheme = "https"
503 }
504 if p := r.Header.Get("X-Forwarded-Proto"); p != "" {
505 scheme = p
506 }
507 return fmt.Sprintf("%s://%s/git/", scheme, r.Host)
508 }
509
510 func cloneURL(r *http.Request, name string) string {
511 return cloneBase(r) + name + ".git"
512 }
513
514 func sshURL(addr, name string) string {
515 host, port, ok := strings.Cut(addr, ":")
516 if !ok {
517 host = addr
518 }
519 return fmt.Sprintf("ssh://git@%s/%s.git", netJoin(host, port), name)
520 }
521
522 func netJoin(host, port string) string {
523 if port == "" || port == "22" {
524 return host
525 }
526 return host + ":" + port
527 }
528
529 func safePath(p string) bool {
530 if strings.Contains(p, "\x00") {
531 return false
532 }
533 for _, seg := range strings.Split(p, "/") {
534 if seg == ".." {
535 return false
536 }
537 }
538 return true
539 }
540
541 type crumb struct {
542 Name string
543 Path string
544 }
545
546 func crumbs(p string) []crumb {
547 var out []crumb
548 cur := ""
549 for _, seg := range strings.Split(p, "/") {
550 if seg == "" {
551 continue
552 }
553 if cur == "" {
554 cur = seg
555 } else {
556 cur += "/" + seg
557 }
558 out = append(out, crumb{Name: seg, Path: cur})
559 }
560 return out
561 }
562
563 func humanSince(t int64) string {
564 if t == 0 {
565 return ""
566 }
567 d := time.Since(time.Unix(t, 0))
568 switch {
569 case d < time.Minute:
570 return "just now"
571 case d < time.Hour:
572 return fmt.Sprintf("%dm ago", int(d.Minutes()))
573 case d < 24*time.Hour:
574 return fmt.Sprintf("%dh ago", int(d.Hours()))
575 default:
576 return fmt.Sprintf("%dd ago", int(d.Hours()/24))
577 }
578 }
579
580 // isMarkdown names the extensions the web renders instead of showing as source.
581 func isMarkdown(path string) bool {
582 switch strings.ToLower(path[strings.LastIndexByte(path, '.')+1:]) {
583 case "md", "markdown", "mdown", "mkd":
584 return true
585 }
586 return false
587 }
588
589 // pager is the shared shape for a list split into pages. urlFor builds the URL
590 // for a page number so the handler keeps control of which filters survive.
591 type pager struct {
592 Page int
593 Pages int
594 Total int
595 From int
596 To int
597 PrevURL string
598 NextURL string
599 }
600
601 func newPager(total, per, page int, urlFor func(int) string) pager {
602 if per < 1 {
603 per = 1
604 }
605 pages := (total + per - 1) / per
606 if pages < 1 {
607 pages = 1
608 }
609 if page < 1 {
610 page = 1
611 }
612 if page > pages {
613 page = pages
614 }
615 p := pager{Page: page, Pages: pages, Total: total}
616 if total > 0 {
617 p.From = (page-1)*per + 1
618 p.To = page * per
619 if p.To > total {
620 p.To = total
621 }
622 }
623 if page > 1 {
624 p.PrevURL = urlFor(page - 1)
625 }
626 if page < pages {
627 p.NextURL = urlFor(page + 1)
628 }
629 return p
630 }