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/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.

Code

395 commit(s) on main

update readme Keenan Nemetz · 23m ago 32290114
history blame raw 13.8 KiB
1package 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
10import (
11 "fmt"
12 "html/template"
13 "io"
14 "net"
15 "net/http"
16 "sort"
17 "strconv"
18 "strings"
19 "unicode/utf8"
20
21 "rodent.software/vole/core/git"
22 "rodent.software/vole/forge/config"
23 "rodent.software/vole/forge/repo"
24)
25
26const maxInlineBytes = 1 << 20
27
28// codeView is the data shared by the full code page and the HTMX fragment that
29// replaces its main pane. Fields under the blob section are only populated for
30// blob views; the tree and shell fields are only populated for full pages.
31type codeView struct {
32 Page
33 Name string
34 Ref string
35 Commit string
36 Count int
37 Branches []string
38 Tags []string
39 OnBranch bool // ref names a branch, so the branch picker shows it
40 OnTag bool // ref names a tag, so the tag picker shows it
41 Latest []git.Commit
42 Tree []*treeNode
43 TreeTruncated bool
44
45 Mode string
46 Path string
47 Parent string
48 Crumbs []crumb
49 Entries []git.Entry
50
51 Readme template.HTML
52 ReadmeName string
53
54 Content string
55 Markdown template.HTML
56 Binary bool
57 Truncated bool
58 LineCut bool
59 Lines []string
60 Language string
61 Size int64
62 IsMarkdown bool
63 Preview bool
64}
65
66// Code is the file browser: a persistent tree beside the file it has open. The
67// repository overview (Overview) is the page that summarises the repository.
68func (w *Web) Code(rw http.ResponseWriter, r *http.Request) {
69 m, ok := w.open(rw, r)
70 if !ok {
71 return
72 }
73 ref := r.URL.Query().Get("ref")
74 if ref == "" {
75 ref = m.DefaultBranch
76 }
77 g := m.Git()
78 commit, _ := g.RevParse(ref)
79 var readme template.HTML
80 readmeName := ""
81 if commit != "" {
82 readme, readmeName = w.readmeAt(m, ref, commit)
83 }
84 // The README is what the main pane opens with, so it is also the tree row
85 // that should look current when no file has been selected yet.
86 full := !htmxRequest(r)
87 view := w.codeShell(r, m, ref, commit, readmeName, full)
88 view.Mode = "overview"
89 if commit != "" {
90 view.Entries, _ = g.LsTree(commit)
91 sortEntries(view.Entries)
92 view.Readme, view.ReadmeName = readme, readmeName
93 if readmeName != "" {
94 view.Path = readmeName
95 view.Crumbs = crumbs(readmeName)
96 }
97 }
98 if !full {
99 w.render(rw, "code_main", view, 0)
100 return
101 }
102 w.render(rw, "code", view, 0)
103}
104
105// codeShell gathers everything the full code page keeps outside the main pane:
106// the branch and tag lists, the file tree, the latest commit, and
107// contributor-branch state. HTMX fragment requests skip the work they will not
108// render.
109func (w *Web) codeShell(r *http.Request, m *repo.Meta, ref, commit, active string, full bool) codeView {
110 g := m.Git()
111 v := codeView{
112 Page: w.page(r, m.Name+" · code"),
113 Name: m.Name,
114 Ref: ref,
115 Commit: commit,
116 }
117 if !full {
118 return v
119 }
120 if commit != "" {
121 v.Latest, _ = g.Log(commit, 1)
122 v.Count, _ = w.countCommits(g, commit)
123 v.Tree, v.TreeTruncated = buildFileTree(g, commit, active)
124 }
125 // Tags share the ref picker's target: a tag names a commit, so browsing a
126 // tag is the same page as browsing a branch. One listing answers both pickers;
127 // branches stay alphabetical and tags stay newest first.
128 refs, _ := g.Refs("refs/heads/", "refs/tags/")
129 var tags []git.RefEntry
130 for _, rf := range refs {
131 switch {
132 case strings.HasPrefix(rf.Name, "refs/heads/"):
133 name := strings.TrimPrefix(rf.Name, "refs/heads/")
134 v.Branches = append(v.Branches, name)
135 if name == ref {
136 v.OnBranch = true
137 }
138 case strings.HasPrefix(rf.Name, "refs/tags/"):
139 tags = append(tags, rf)
140 if strings.TrimPrefix(rf.Name, "refs/tags/") == ref {
141 v.OnTag = true
142 }
143 }
144 }
145 sort.Strings(v.Branches)
146 sort.SliceStable(tags, func(i, j int) bool {
147 if tags[i].When != tags[j].When {
148 return tags[i].When > tags[j].When
149 }
150 return tags[i].Name < tags[j].Name
151 })
152 v.Tags = make([]string, 0, len(tags))
153 for _, tag := range tags {
154 v.Tags = append(v.Tags, strings.TrimPrefix(tag.Name, "refs/tags/"))
155 }
156 return v
157}
158
159func (w *Web) Tree(rw http.ResponseWriter, r *http.Request) {
160 m, ok := w.open(rw, r)
161 if !ok {
162 return
163 }
164 ref, p := r.PathValue("ref"), r.PathValue("path")
165 if !safePath(p) {
166 http.Error(rw, "invalid path", http.StatusBadRequest)
167 return
168 }
169 g := m.Git()
170 commit, _ := g.RevParse(ref)
171 if commit == "" {
172 w.notFound(rw, r)
173 return
174 }
175 treeish := commit
176 if p != "" {
177 treeish = commit + ":" + p
178 }
179 entries, err := g.LsTree(treeish)
180 if err != nil {
181 http.Error(rw, "not a directory", http.StatusBadRequest)
182 return
183 }
184 sortEntries(entries)
185 full := !htmxRequest(r)
186 view := w.codeShell(r, m, ref, commit, p, full)
187 view.Mode = "tree"
188 view.Path = p
189 view.Parent = parentPath(p)
190 view.Entries = entries
191 view.Crumbs = crumbs(p)
192 if !full {
193 w.render(rw, "code_main", view, 0)
194 return
195 }
196 view.Page = w.page(r, m.Name+" · "+ref)
197 w.render(rw, "code", view, 0)
198}
199
200func (w *Web) Blob(rw http.ResponseWriter, r *http.Request) { w.blob(rw, r, false) }
201
202func (w *Web) Raw(rw http.ResponseWriter, r *http.Request) { w.blob(rw, r, true) }
203
204func (w *Web) blob(rw http.ResponseWriter, r *http.Request, raw bool) {
205 m, ok := w.open(rw, r)
206 if !ok {
207 return
208 }
209 ref, p := r.PathValue("ref"), r.PathValue("path")
210 if !safePath(p) {
211 http.Error(rw, "invalid path", http.StatusBadRequest)
212 return
213 }
214 g := m.Git()
215 commit, _ := g.RevParse(ref)
216 if commit == "" {
217 w.notFound(rw, r)
218 return
219 }
220 stream, err := g.OpenBlobAt(commit, p)
221 if err != nil {
222 w.notFound(rw, r)
223 return
224 }
225 defer stream.Close()
226 if raw {
227 // The download is the one place that should carry every byte, so it is
228 // streamed rather than read and written: a 100 MiB asset costs one copy
229 // buffer per request, not 100 MiB of heap. A byte range skips the bytes
230 // before it in the stream (git cannot seek) and copies only the requested
231 // part, so the win is what does not cross the network.
232 h := rw.Header()
233 h.Set("Content-Type", "text/plain; charset=utf-8")
234 // The announced length is part of the contract, so a text blob must not be
235 // re-encoded on the way out; a compressed body has no such length.
236 h.Set("Cache-Control", w.downloadCacheControl(m)+", no-transform")
237 h.Set("Accept-Ranges", "bytes")
238 br, partial, unsatisfiable := parseByteRange(r.Header.Get("Range"), stream.Size)
239 if unsatisfiable {
240 h.Set("Content-Range", fmt.Sprintf("bytes */%d", stream.Size))
241 rw.WriteHeader(http.StatusRequestedRangeNotSatisfiable)
242 return
243 }
244 if partial {
245 h.Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", br.start, br.start+br.length-1, stream.Size))
246 h.Set("Content-Length", strconv.FormatInt(br.length, 10))
247 rw.WriteHeader(http.StatusPartialContent)
248 if r.Method == http.MethodHead {
249 return
250 }
251 if br.start > 0 {
252 if _, err := io.CopyN(io.Discard, stream, br.start); err != nil {
253 w.Logger("raw %s %s: skip: %v", m.Name, p, err)
254 return
255 }
256 }
257 if _, err := io.CopyN(rw, stream, br.length); err != nil {
258 // The client hung up mid-download; headers are already out.
259 w.Logger("raw %s %s: %v", m.Name, p, err)
260 }
261 return
262 }
263 h.Set("Content-Length", strconv.FormatInt(stream.Size, 10))
264 if r.Method == http.MethodHead {
265 return
266 }
267 if _, err := io.Copy(rw, stream); err != nil {
268 // The client hung up mid-download; headers are already out.
269 w.Logger("raw %s %s: %v", m.Name, p, err)
270 }
271 return
272 }
273 // Everything the page shows is a preview: the tree and the line numbers are
274 // bounded, so there is no reason to hold the whole file.
275 data, truncated, err := stream.ReadPrefix(maxInlineBytes)
276 if err != nil {
277 w.serverError(rw, r, "cannot read "+p+": "+err.Error())
278 return
279 }
280 content := string(data)
281 binary := !utf8.Valid(data)
282 // A table row per line is pleasant to read and cheap for a source file, but a
283 // million blank lines would be neither. Past the cap the page falls back to a
284 // preformatted block.
285 const maxNumberedLines = 5000
286 lines := strings.Split(content, "\n")
287 lineCut := false
288 if len(lines) > maxNumberedLines {
289 lines = lines[:maxNumberedLines]
290 lineCut = true
291 }
292 // The browser colours the same bytes the table shows. All the server does is
293 // name the language, and a language it cannot name stays plain, which is
294 // better than guessing wrong about its grammar.
295 language := ""
296 if !binary {
297 language = prismLanguage(p)
298 }
299 markdown := !binary && isMarkdown(p)
300 // A markdown file opens as source like any other file. Rendering it is an
301 // explicit choice, so the code browser never hides the bytes by default.
302 preview := markdown && !truncated && r.URL.Query().Get("preview") == "1"
303 var rendered template.HTML
304 if preview {
305 // The document knows where it lives, so a relative link in it can be
306 // resolved against its directory instead of the URL it is being read at.
307 rendered = w.renderTreeMarkdown(treeDoc{Repo: m.Name, Ref: ref, Path: p}, content)
308 }
309 full := !htmxRequest(r)
310 view := w.codeShell(r, m, ref, commit, p, full)
311 view.Mode = "blob"
312 view.Path = p
313 view.Parent = parentPath(p)
314 view.Crumbs = crumbs(p)
315 view.Content = content
316 view.Markdown = rendered
317 view.Binary = binary
318 view.Truncated = truncated
319 view.LineCut = lineCut
320 view.Lines = lines
321 view.Language = language
322 view.Size = stream.Size
323 view.IsMarkdown = markdown
324 view.Preview = preview
325 if !full {
326 w.render(rw, "code_main", view, 0)
327 return
328 }
329 view.Page = w.page(r, m.Name+" · "+p)
330 w.render(rw, "code", view, 0)
331}
332
333func (w *Web) Commits(rw http.ResponseWriter, r *http.Request) {
334 m, ok := w.open(rw, r)
335 if !ok {
336 return
337 }
338 ref := r.PathValue("ref")
339 release, ok := w.gitSlot(rw, r)
340 if !ok {
341 return
342 }
343 defer release()
344 g, cancel := gitRepo(r, m)
345 defer cancel()
346 commit, _ := g.RevParse(ref)
347 if commit == "" {
348 w.notFound(rw, r)
349 return
350 }
351 commits, err := g.Log(commit, 100)
352 if err != nil {
353 w.serverError(rw, r, "cannot read commit history: "+err.Error())
354 return
355 }
356 w.render(rw, "commits", struct {
357 Page
358 Name string
359 Ref string
360 Commits []git.Commit
361 }{w.page(r, m.Name+" · history"), m.Name, ref, commits}, 0)
362}
363
364// cloneBase is the read-only HTTP transport root this instance advertises. It is
365// what a visitor who wants to clone without an account is shown.
366//
367// The configured public_url wins whenever it is set: a download or clone URL that
368// follows the request's Host header is a URL an attacker can choose, and the
369// browser is not the only consumer — a copied clone URL ends up in somebody's
370// terminal. Without a public_url the origin the browser used is the only
371// information available, and forwarded headers are honoured only when the instance
372// declares a trusted proxy.
373func (w *Web) cloneBase(r *http.Request) string {
374 return w.canonicalBase(r) + "/"
375}
376
377func (w *Web) cloneURL(r *http.Request, name string) string {
378 return w.cloneBase(r) + name + ".git"
379}
380
381// canonicalBase is the origin generated links use, without a trailing slash.
382func (w *Web) canonicalBase(r *http.Request) string {
383 if w.Cfg != nil {
384 if base := w.Cfg.BaseURL(); base != "" {
385 return base
386 }
387 }
388 return requestOrigin(r, w.trustsProxy())
389}
390
391// requestOrigin reconstructs the origin a request reached, using the forwarding
392// header only when the operator has declared a trusted proxy. A proxy that does not
393// set it is not evidence for anything, and a client that sets it must not be
394// believed.
395func requestOrigin(r *http.Request, trustForwarded bool) string {
396 scheme := "http"
397 if r.TLS != nil {
398 scheme = "https"
399 }
400 if trustForwarded {
401 if p := strings.ToLower(strings.TrimSpace(r.Header.Get("X-Forwarded-Proto"))); p == "https" || p == "http" {
402 scheme = p
403 }
404 }
405 return scheme + "://" + r.Host
406}
407
408// trustsProxy reports whether the instance is configured to believe forwarded
409// headers. The client-address header is the instance's statement that something in
410// front of it rewrites them.
411func (w *Web) trustsProxy() bool {
412 return w.Cfg != nil && strings.TrimSpace(w.Cfg.GitHTTPClientIPHeader) != ""
413}
414
415// siteURL is where a repository's site is served. A forge that is reached over
416// HTTPS links to its sites over HTTPS: a link that downgrades the scheme is a
417// mixed-content warning at best and a broken site at worst.
418func (w *Web) siteURL(r *http.Request, name string) string {
419 if w.Cfg == nil || w.Cfg.PagesDomain == "" {
420 return ""
421 }
422 scheme := "http"
423 if base := w.canonicalBase(r); strings.HasPrefix(base, "https://") {
424 scheme = "https"
425 }
426 return scheme + "://" + name + "." + w.Cfg.PagesDomain
427}
428
429// sshURL is the SSH transport the repository header offers. The host is the
430// instance's configured name, from public_url, when there is one: ssh_addr is
431// the address the SSH listener binds, and a bind address such as 0.0.0.0 is not
432// something a person can connect to. The port always comes from ssh_addr,
433// because that is the port the listener answers on. Without a public_url there
434// is no configured name, so the listener's own address is all there is.
435func (w *Web) sshURL(name string) string {
436 host, port := "", ""
437 if w.Cfg != nil {
438 host, port = splitSSHAddr(w.Cfg.SSHAddr)
439 if u, err := config.ParsePublicURL(w.Cfg.PublicURL); err == nil && u != nil {
440 host = u.Hostname()
441 }
442 }
443 return fmt.Sprintf("ssh://git@%s/%s.git", netJoin(host, port), name)
444}
445
446// splitSSHAddr separates a listen address into the host and the port, tolerating
447// a bare address with no port the way config validation does. An IPv6 host is
448// returned bare; netJoin puts the brackets back.
449func splitSSHAddr(addr string) (host, port string) {
450 if h, p, err := net.SplitHostPort(addr); err == nil {
451 return h, p
452 }
453 return addr, ""
454}
455
456func netJoin(host, port string) string {
457 if port == "" || port == "22" {
458 if strings.Contains(host, ":") {
459 return "[" + host + "]"
460 }
461 return host
462 }
463 return net.JoinHostPort(host, port)
464}
465