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.

Blame · server/web/codebrowser.go

Line attribution on main · 464 line(s)

c717d4eb
Keenan Nemetz
13d ago
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"
4581690d
Keenan Nemetz
13d ago
13 "io"
268b9368
Keenan Nemetz
17h ago
14 "net"
c717d4eb
Keenan Nemetz
13d ago
15 "net/http"
16 "sort"
4581690d
Keenan Nemetz
13d ago
17 "strconv"
c717d4eb
Keenan Nemetz
13d ago
18 "strings"
19 "unicode/utf8"
20
d37d301f
Keenan Nemetz
15h ago
21 "rodent.software/vole/core/git"
22 "rodent.software/vole/forge/config"
23 "rodent.software/vole/forge/repo"
c717d4eb
Keenan Nemetz
13d ago
24 )
25
26 const 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.
31 type codeView struct {
32 Page
33 Name string
34 Ref string
35 Commit string
36 Count int
37 Branches []string
55615c7a
Keenan Nemetz
11d ago
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
c717d4eb
Keenan Nemetz
13d ago
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
306f36fc
Keenan Nemetz
12d ago
60 Language string
c717d4eb
Keenan Nemetz
13d ago
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.
68 func (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 != "" {
796ccd90
Keenan Nemetz
13d ago
82 readme, readmeName = w.readmeAt(m, ref, commit)
c717d4eb
Keenan Nemetz
13d ago
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:
55615c7a
Keenan Nemetz
11d ago
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.
c717d4eb
Keenan Nemetz
13d ago
109 func (w *Web) codeShell(r *http.Request, m *repo.Meta, ref, commit, active string, full bool) codeView {
110 g := m.Git()
111 v := codeView{
816cc543
Keenan Nemetz
11d ago
112 Page: w.page(r, m.Name+" · code"),
113 Name: m.Name,
114 Ref: ref,
115 Commit: commit,
c717d4eb
Keenan Nemetz
13d ago
116 }
117 if !full {
118 return v
119 }
120 if commit != "" {
121 v.Latest, _ = g.Log(commit, 1)
73ec800b
Keenan Nemetz
10d ago
122 v.Count, _ = w.countCommits(g, commit)
c717d4eb
Keenan Nemetz
13d ago
123 v.Tree, v.TreeTruncated = buildFileTree(g, commit, active)
124 }
55615c7a
Keenan Nemetz
11d ago
125 // Tags share the ref picker's target: a tag names a commit, so browsing a
34cbc99f
Keenan Nemetz
10d ago
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 {
55615c7a
Keenan Nemetz
11d ago
141 v.OnTag = true
142 }
143 }
144 }
34cbc99f
Keenan Nemetz
10d ago
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 }
c717d4eb
Keenan Nemetz
13d ago
156 return v
157 }
158
159 func (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
200 func (w *Web) Blob(rw http.ResponseWriter, r *http.Request) { w.blob(rw, r, false) }
201
202 func (w *Web) Raw(rw http.ResponseWriter, r *http.Request) { w.blob(rw, r, true) }
203
204 func (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 }
4581690d
Keenan Nemetz
13d ago
220 stream, err := g.OpenBlobAt(commit, p)
c717d4eb
Keenan Nemetz
13d ago
221 if err != nil {
222 w.notFound(rw, r)
223 return
224 }
4581690d
Keenan Nemetz
13d ago
225 defer stream.Close()
c717d4eb
Keenan Nemetz
13d ago
226 if raw {
4581690d
Keenan Nemetz
13d ago
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
26c9be33
Keenan Nemetz
9d ago
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.
4581690d
Keenan Nemetz
13d ago
232 h := rw.Header()
233 h.Set("Content-Type", "text/plain; charset=utf-8")
306f36fc
Keenan Nemetz
12d ago
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.
25343521
Keenan Nemetz
2d ago
236 h.Set("Cache-Control", w.downloadCacheControl(m)+", no-transform")
26c9be33
Keenan Nemetz
9d ago
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 }
4581690d
Keenan Nemetz
13d ago
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 }
306f36fc
Keenan Nemetz
12d ago
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.
4581690d
Keenan Nemetz
13d ago
275 data, truncated, err := stream.ReadPrefix(maxInlineBytes)
276 if err != nil {
277 w.serverError(rw, r, "cannot read "+p+": "+err.Error())
c717d4eb
Keenan Nemetz
13d ago
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 }
306f36fc
Keenan Nemetz
12d ago
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 := ""
c717d4eb
Keenan Nemetz
13d ago
296 if !binary {
306f36fc
Keenan Nemetz
12d ago
297 language = prismLanguage(p)
c717d4eb
Keenan Nemetz
13d ago
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 {
796ccd90
Keenan Nemetz
13d ago
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)
c717d4eb
Keenan Nemetz
13d ago
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
306f36fc
Keenan Nemetz
12d ago
321 view.Language = language
4581690d
Keenan Nemetz
13d ago
322 view.Size = stream.Size
c717d4eb
Keenan Nemetz
13d ago
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
333 func (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")
701232cf
Keenan Nemetz
12d ago
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()
c717d4eb
Keenan Nemetz
13d ago
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
701232cf
Keenan Nemetz
12d ago
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.
373 func (w *Web) cloneBase(r *http.Request) string {
ed03b3d1
Keenan Nemetz
2h ago
374 return w.canonicalBase(r) + "/"
701232cf
Keenan Nemetz
12d ago
375 }
376
377 func (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.
382 func (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.
395 func requestOrigin(r *http.Request, trustForwarded bool) string {
c717d4eb
Keenan Nemetz
13d ago
396 scheme := "http"
397 if r.TLS != nil {
398 scheme = "https"
399 }
701232cf
Keenan Nemetz
12d ago
400 if trustForwarded {
401 if p := strings.ToLower(strings.TrimSpace(r.Header.Get("X-Forwarded-Proto"))); p == "https" || p == "http" {
402 scheme = p
403 }
c717d4eb
Keenan Nemetz
13d ago
404 }
701232cf
Keenan Nemetz
12d ago
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.
411 func (w *Web) trustsProxy() bool {
412 return w.Cfg != nil && strings.TrimSpace(w.Cfg.GitHTTPClientIPHeader) != ""
c717d4eb
Keenan Nemetz
13d ago
413 }
414
701232cf
Keenan Nemetz
12d ago
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.
418 func (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
c717d4eb
Keenan Nemetz
13d ago
427 }
428
268b9368
Keenan Nemetz
17h ago
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.
435 func (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 }
c717d4eb
Keenan Nemetz
13d ago
442 }
443 return fmt.Sprintf("ssh://git@%s/%s.git", netJoin(host, port), name)
444 }
445
268b9368
Keenan Nemetz
17h ago
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.
449 func 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
c717d4eb
Keenan Nemetz
13d ago
456 func netJoin(host, port string) string {
457 if port == "" || port == "22" {
268b9368
Keenan Nemetz
17h ago
458 if strings.Contains(host, ":") {
459 return "[" + host + "]"
460 }
c717d4eb
Keenan Nemetz
13d ago
461 return host
462 }
268b9368
Keenan Nemetz
17h ago
463 return net.JoinHostPort(host, port)
c717d4eb
Keenan Nemetz
13d ago
464 }