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 | } | ||