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.
| 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 | "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 | |
| 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 |
| 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. |
| 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 != "" { |
| 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. |
| 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{ |
| 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 | |
| 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 | } |
| 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 | |
| 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") |
| 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. |
| 373 | func (w *Web) cloneBase(r *http.Request) string { |
| 374 | return w.canonicalBase(r) + "/" |
| 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 { |
| 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. |
| 411 | func (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. |
| 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 |
| 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. |
| 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 | } |
| 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. |
| 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 | |
| 456 | func 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 |