Artifact 08c8e82c0266065ef013ef9939ed38b6c56494f5cf198f027ca4f28ee40741c8:
- File
internal/web/web.go
— part of check-in
[4a3165db34]
at
2026-08-18 11:17:05
on branch trunk
— repo.list: filter, sort and page the listing
The front page rendered every repository unsorted and unbounded — the same shape that made the timeline unusable, and it is at 23 now. Adds a name filter, sort by name, recent activity or size, and a limit, with Total alongside Count so a page can say '5 of 23'.
Sorting happens BEFORE the limit. The other order gives an arbitrary subset in the right sequence, which reads as correct and is not.
Activity comes from the event table's index — one walk stopping at the first row — so the column that makes a forge listing worth reading costs nothing.
Adds repo.get, so the settings page and the CLI stop scanning the whole listing to read one repository's settings. (user: dmestas, size: 121435) [annotate] [blame] [check-ins using]
// Package web is the HTML consumer of the registry (0001 §9). Pages are // server-rendered html/template over op results — the same ops the API // serves, so every page capability is API-visible by construction. // Repo-scoped pages live under /r/{repo}/; the home page is the fleet. package web import ( "bytes" "context" _ "embed" "encoding/base64" "encoding/json" "errors" "fmt" "html/template" "log" "net/http" "net/url" "reflect" "strconv" "strings" "time" "craftdesign.group/quarry/internal/api" "craftdesign.group/quarry/internal/highlight" "craftdesign.group/quarry/internal/ops" "craftdesign.group/quarry/internal/timing" ) // datastarJS is the vendored Datastar bundle (MIT; see the provenance // ledger) — the one allowed frontend framework (0001 Principle 2), // served self-contained. Everything works without it (Principle 3); // it only adds live updates. // //go:embed assets/datastar.js var datastarJS []byte // Notifier is the change signal live pages subscribe to. The // composition root supplies an events-backed implementation; web never // touches the event plane directly. nil disables liveness. repo "*" // watches every repo (the fleet page's signal). type Notifier interface { Watch(repo string) (<-chan struct{}, func()) } // ChatMessage is one rendered chat line (mirrors chat.Message without // importing the data plane into a view type). type ChatMessage struct { User string `json:"user"` Text string `json:"text"` At string `json:"at"` } // ChatLive delivers a repo channel's live messages to the SSE handler. // nil disables the live chat stream (history + form still work). The // composition root's adapter maps the repo name to its project code. type ChatLive interface { SubscribeChat(repo string) (<-chan ChatMessage, func()) } // base is the layout every page renders through. Both themes come from // prefers-color-scheme; no JavaScript anywhere (0001 Principle 3 — the // no-JS baseline is the baseline). The header carries the fleet crumb // and, inside a repo, that repo's nav and search. // pathesc escapes one path segment — wiki titles carry slashes in real // stores (tcl's "(Initi/Fin)alize"), which must ride as %2F to stay one // segment for the mux. // artifactTimeLayouts are the shapes a D card's timestamp arrives in. // Fossil writes UTC with millisecond precision and no zone; imported // corpora also carry the second-precision form, and quarry's own OLTP // stamps are RFC3339. All are UTC, so none carries an offset to honour. var artifactTimeLayouts = []string{ "2006-01-02T15:04:05.000", "2006-01-02T15:04:05", time.RFC3339, "2006-01-02 15:04:05", "2006-01-02", } func parseArtifactTime(s string) (time.Time, bool) { for _, l := range artifactTimeLayouts { if t, err := time.Parse(l, s); err == nil { return t.UTC(), true } } return time.Time{}, false } // ago renders a timestamp the way a person reads one. Recent times go // relative ("3 hours ago"), older ones go to a plain date; the exact // value is kept in the title attribute either way, because a relative // time is friendlier but an absolute one is what you quote in a bug // report. // // An unparseable stamp is returned VERBATIM rather than blanked or // guessed at: it came out of a real artifact, and showing it is how the // layout gap gets noticed. func ago(s string) template.HTML { if s == "" { return "" } t, ok := parseArtifactTime(s) if !ok { return template.HTML(template.HTMLEscapeString(s)) } return template.HTML(fmt.Sprintf(`<time datetime="%s" title="%s">%s</time>`, template.HTMLEscapeString(t.Format(time.RFC3339)), template.HTMLEscapeString(t.Format("2006-01-02 15:04:05 UTC")), template.HTMLEscapeString(relative(t, time.Now().UTC())))) } // relative words a duration at one significant unit. Fossil's forum uses // the same shape ("2.0 hours ago", "13.4 days ago"); this rounds to // whole units instead, because a tenth of a day is not a quantity anyone // reasons in. func relative(t, now time.Time) string { d := now.Sub(t) if d < 0 { // Clock skew between a writer and this server. "in 3 minutes" is // noise; the timestamp itself is the honest answer. return t.Format("2006-01-02 15:04") } switch { case d < time.Minute: return "just now" case d < time.Hour: return plural(int(d.Minutes()), "minute") + " ago" case d < 24*time.Hour: return plural(int(d.Hours()), "hour") + " ago" case d < 30*24*time.Hour: return plural(int(d.Hours()/24), "day") + " ago" default: // Past a month, a date is more useful than a count of days, and // past a year the year is the part that matters. if t.Year() == now.Year() { return t.Format("2 Jan") } return t.Format("2 Jan 2006") } } func plural(n int, unit string) string { if n == 1 { return "1 " + unit } return fmt.Sprintf("%d %ss", n, unit) } // navlink renders one repository-nav entry. It is a function rather // than inline template markup because an entry carries two independent // classes — "here" and "empty" — and expressing that inline produced // two class attributes on the same anchor, of which a browser honours // only the first. // // count is `any` so a section with nothing to count (files, ci, chat) // can pass false and get no badge, rather than a misleading zero. func navlink(repo, section, label, current string, hasCounts bool, count any) template.HTML { var class []string if section == current { class = append(class, "here") } n, counted := count.(int) if hasCounts && counted && n == 0 { // Dimmed, never hidden: a nav whose SHAPE changes per repository // makes a reader relearn it on every store. An honest dead end // costs one click; a moving nav costs orientation. class = append(class, "empty") } var b strings.Builder fmt.Fprintf(&b, `<a href="/r/%s/%s"`, url.PathEscape(repo), section) if len(class) > 0 { fmt.Fprintf(&b, ` class="%s"`, strings.Join(class, " ")) } if section == current { b.WriteString(` aria-current="page"`) } fmt.Fprintf(&b, `>%s`, template.HTMLEscapeString(label)) if hasCounts && counted { fmt.Fprintf(&b, ` <span class="n">%d</span>`, n) } b.WriteString(`</a>`) // Every part is escaped or generated above; section and label are // compile-time constants from the template. return template.HTML(b.String()) } var base = template.Must(template.New("base").Funcs(template.FuncMap{ "pathesc": url.PathEscape, "navlink": navlink, "ago": ago, }).Parse(`<!doctype html> <html lang="en"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>{{.Title}} — quarry</title> <style> :root { --fg: #1a1a1a; --bg: #ffffff; --muted: #6a6a6a; --code-bg: #f4f5f7; } @media (prefers-color-scheme: dark) { :root { --fg: #e8e8e8; --bg: #14161a; --muted: #9a9a9a; --code-bg: #1c2026; } } body { color: var(--fg); background: var(--bg); font: 16px/1.5 system-ui, sans-serif; max-width: 60rem; margin: 0 auto; padding: 1rem; } footer { color: var(--muted); border-top: 1px solid var(--muted); margin-top: 2rem; padding-top: 0.5rem; font-size: 0.85rem; } table { border-collapse: collapse; width: 100%; } th, td { text-align: left; padding: 0.25rem 0.75rem 0.25rem 0; } th { border-bottom: 1px solid var(--muted); } a { color: inherit; } header { display: flex; gap: 1rem; align-items: baseline; flex-wrap: wrap; border-bottom: 1px solid var(--muted); padding-bottom: 0.5rem; } header .site { font-weight: bold; } /* The repository is the subject of every page under it, so it reads as the heading and "quarry" recedes to the crumb it is. Without this the two sit at equal weight and the forge name — identical on every page — competes with the one word that changes. */ header .repo { font-weight: bold; font-size: 1.15rem; } header .crumb { color: var(--muted); } header form { margin-left: auto; } .tw { overflow-x: auto; } /* Timeline card/detail metrics adapted from fossil src/default.css (BSD-2-Clause; see third_party/PROVENANCE.md). The graph tiles seamlessly: a fixed 36px head band (node + curves) and a stretching tail fill each row edge-to-edge at identical pixel x positions, and date rows carry the active rails through. */ table.tl td { border: 0; padding: 0; vertical-align: top; } table.tl td.tl-graph { position: relative; } svg.tl-head { position: absolute; top: 0; left: 0; display: block; } svg.tl-tail { position: absolute; top: 36px; left: 0; height: calc(100% - 36px); display: block; } tr.tl-date svg.tl-tail, tr.tl-flat svg.tl-tail { top: 0; height: 100%; } .tl-card-flat { padding: 0.45em 0.75em; font-size: 0.95em; } .tl-meta-inline { color: var(--muted); font-size: 85%; } table.tl td.tl-time { white-space: nowrap; width: 3.4rem; padding: 13px 0.5rem 0 0; text-align: right; } table.tl td.tl-time a { color: var(--muted); text-decoration: none; } table.tl td.tl-time a:hover { text-decoration: underline; } table.tl td.tl-slot { width: 100%; padding: 0 0 5px 0.6rem; } table.tl td.tl-datecell { padding: 6px 0 6px 0.6rem; } .tl-card { background: var(--code-bg); border-radius: 1em; padding: 0.75em; } .tl-title { font-weight: 550; } .tl-p { margin: 0.35em 0 0; color: color-mix(in srgb, var(--fg) 78%, var(--muted)); } .tl-meta { color: var(--muted); font-size: 80%; text-align: right; opacity: 0.85; margin-top: 0.5em; } .tl-meta a { text-decoration: none; } .tl-meta a:hover { text-decoration: underline; } @media (max-width: 640px) { body { padding: 0.5rem; } table.tl td.tl-time { width: 2.6rem; font-size: 0.8rem; padding-top: 15px; } .tl-card { padding: 0.75em 0.55em; border-radius: 0.6em; } .tl-meta { font-size: 72%; } /* Phone widths: the card's widest line is the metadata run, and its max-content is what pushed the table past the viewport (auto table layout prefers it, and .tw then scrolls sideways, hiding the right-aligned meta). Stacking the items drops that preference to the longest single item, so the table fits with the cards still wide. */ .tl-metaitem { display: block; } .tl-card { overflow-wrap: anywhere; } } /* File listing. The old one spent ~52px of height and three columns on one short filename; fossil fits roughly 60 entries in the space that showed 25. Density here comes from tight rows and dropping the repeated per-row "history" link, not from shrinking the type. */ table.tree { table-layout: auto; } table.tree td { padding: 0.15rem 0; border: 0; line-height: 1.45; } table.tree tr:hover { background: var(--code-bg); } /* Thirty underlined names in a column is a wall of rules, and the underline carries no information here — every name in the listing is a link, so marking them all marks nothing. Hover restores it. */ table.tree a, .tree-bar .crumbs a { text-decoration: none; } table.tree a:hover, .tree-bar .crumbs a:hover { text-decoration: underline; } td.t-name { white-space: nowrap; } /* Sizes are only comparable when their digits line up, which means right-aligned and tabular figures. The column takes only what it needs so the name column is not pushed across the viewport. */ td.t-size { text-align: right; white-space: nowrap; width: 1%; padding-left: 1.5rem; color: var(--muted); font-variant-numeric: tabular-nums; } /* Directory and file marks. Drawn in CSS rather than shipped as image assets: two shapes are not worth a sprite, and these inherit the text colour so both themes work without a second copy. */ .ic { display: inline-block; width: 0.85em; height: 0.85em; margin-right: 0.45rem; vertical-align: -0.05em; background: currentColor; opacity: 0.55; } .ic-dir { -webkit-mask: var(--m-dir) center/contain no-repeat; mask: var(--m-dir) center/contain no-repeat; } .ic-file { -webkit-mask: var(--m-file) center/contain no-repeat; mask: var(--m-file) center/contain no-repeat; } :root { --m-dir: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M1.75 2h3.6c.5 0 .96.26 1.23.68L7.4 4h6.85c.97 0 1.75.78 1.75 1.75v7.5c0 .97-.78 1.75-1.75 1.75H1.75C.78 15 0 14.22 0 13.25V3.75C0 2.78.78 2 1.75 2Z"/></svg>'); --m-file: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M2 1.75C2 .78 2.78 0 3.75 0h5.19c.46 0 .9.18 1.23.51l3.32 3.32c.33.33.51.77.51 1.23v9.19c0 .97-.78 1.75-1.75 1.75h-8.5C2.78 16 2 15.22 2 14.25Zm7.5-.19v3.19c0 .14.11.25.25.25h3.19Z"/></svg>'); } /* Flags replace ls(1)'s trailing punctuation. "compile*" reads as part of the filename; a labelled chip does not. */ .flag { font-size: 0.7rem; color: var(--muted); border: 1px solid var(--muted); border-radius: 3px; padding: 0 0.25rem; vertical-align: 0.05em; } /* Tree chrome: one row carrying branch, crumbs and clone. */ .tree-bar { display: flex; gap: 0.75rem; align-items: center; flex-wrap: wrap; margin-bottom: 0.6rem; } .tree-bar .crumbs { font-weight: 600; } .tree-bar .tree-acts { margin-left: auto; display: flex; gap: 0.75rem; align-items: center; font-size: 0.85rem; } .tree-bar .clone { color: var(--muted); background: var(--code-bg); padding: 0.1rem 0.4rem; border-radius: 4px; } @media (max-width: 40rem) { /* The clone command is the first thing worth losing on a phone: it is long, and it is not what a reader came to the page for. */ .tree-bar .clone { display: none; } .tree-bar .tree-acts { margin-left: 0; } } .readme-head { margin-top: 1.5rem; padding-bottom: 0.3rem; border-bottom: 1px solid var(--muted); font-size: 1rem; } /* Nav state. "here" is an underline rather than a colour so it survives both themes and does not depend on hue perception; "empty" is dimmed but still a link. The count badge is deliberately quiet — it is orientation, not emphasis. */ nav a.here { font-weight: 600; box-shadow: inset 0 -2px 0 currentColor; } nav a.empty { color: var(--muted); } nav .n { color: var(--muted); font-size: 0.8rem; font-variant-numeric: tabular-nums; } nav a.here .n { color: inherit; } .repo-sub { color: var(--muted); font-size: 0.9rem; margin: -0.6rem 0 1rem; } .newrepo { margin: 0 0 0.8rem; } .about { margin: -0.3rem 0 0.8rem; color: var(--fg); } /* The listing's numeric and date columns take only what they need, so the description and tip get the width instead of "Check-ins" wrapping into two lines beside a mostly-empty row. */ table.fleet th, table.fleet td { vertical-align: top; } table.fleet th:nth-child(2), table.fleet td:nth-child(2) { text-align: right; white-space: nowrap; width: 1%; padding-right: 1.2rem; font-variant-numeric: tabular-nums; } table.fleet th:nth-child(3) { white-space: nowrap; } .newrepo-form fieldset { border: 1px solid var(--muted); border-radius: 4px; padding: 0.5rem 0.8rem; } .newrepo-form legend { color: var(--muted); font-size: 0.85rem; } .ci-pass { border-color: #116329; color: #116329; } .ci-fail { border-color: #a40e26; color: #a40e26; } @media (prefers-color-scheme: dark) { .ci-pass { border-color: #56d364; color: #56d364; } .ci-fail { border-color: #ff7b72; color: #ff7b72; } } .tkt-title { margin-bottom: 0.3rem; } .tkt-head th { text-align: left; color: var(--muted); font-weight: normal; width: 10rem; } .tkt-body { margin: 0.5rem 0 1rem; } .tkt-comment { border-top: 1px solid var(--muted); padding: 0.6rem 0; } .tkt-comment:first-of-type { border-top: 0; } .tl-badge { border: 1px solid var(--muted); border-radius: 3px; padding: 0 0.25rem; font-size: 0.72rem; } .tl-pill { display: inline-block; border: 1px solid var(--muted); border-radius: 6px; padding: 0.05rem 0.5rem; font-weight: bold; margin: 0.55rem 0 0.25rem; } .tl-controls { color: var(--muted); font-size: 0.9rem; display: flex; gap: 0.8rem; align-items: baseline; margin-bottom: 0.4rem; } pre { overflow-x: auto; background: var(--code-bg); padding: 0.5rem; border-radius: 4px; } pre.diff .add { color: #116329; } pre.diff .del { color: #a40e26; } pre.diff .hunk { color: var(--muted); } pre.diff .head { font-weight: bold; } mark { background: #fff3b8; color: inherit; } @media (prefers-color-scheme: dark) { pre.diff .add { color: #56d364; } pre.diff .del { color: #ff7b72; } mark { background: #5c4d00; } } </style> <style>` + highlight.CSS() + `</style> <script type="module" src="/assets/datastar.js" defer></script> </head> <body> <header> <a class="{{if .Repo}}crumb{{else}}site{{end}}" href="/">quarry</a> {{if .Repo}}<a class="repo" href="/r/{{.Repo}}">{{.Repo}}</a> <nav> {{- /* Counts tell a reader where a repository's content actually IS. Across the corpus that varies wildly — pikchr is 1,260 forum posts and one ticket, tk is 5,809 tickets and one post — and without them every store looks alike. An empty section stays LINKED but is dimmed: hiding it would make the nav's shape change per repo, which is worse than an honest dead end. */ -}} {{$s := .Section}}{{$c := .HasCounts}} {{navlink .Repo "tree" "files" $s false false}} {{navlink .Repo "timeline" "timeline" $s $c .Counts.Checkins}} {{navlink .Repo "wiki" "wiki" $s $c .Counts.WikiPages}} {{navlink .Repo "ticket" "tickets" $s $c .Counts.Tickets}} {{navlink .Repo "forum" "forum" $s $c .Counts.ForumThreads}} {{navlink .Repo "technote" "technotes" $s $c .Counts.Technotes}} {{navlink .Repo "ci" "ci" $s false false}} {{navlink .Repo "chat" "chat" $s false false}} </nav> <form action="/r/{{.Repo}}/search" method="get"><input type="search" name="q" placeholder="search" aria-label="search"></form> {{end}} {{if .SignedIn}}<form class="who" action="/logout" method="post">{{if $.IsAdmin}}<small><a href="/fleet">fleet</a></small> {{end}}{{if $.HasInbox}}<small><a href="/inbox">inbox</a></small> {{end}}<small class="tl-meta-inline">{{.SignedIn}}</small> <button type="submit">sign out</button></form>{{else if .CanSignIn}}<small class="who"><a href="/login">sign in</a></small>{{end}} </header> <main> {{if not .HideH1}}<h1>{{.Title}}</h1>{{end}} {{block "content" .}}{{end}} </main> <footer>quarry {{.Version}} · <a href="/docs">API</a>{{if .Elapsed}} · <span data-volatile>rendered in {{.Elapsed}}{{if .Breakdown}} ({{.Breakdown}}){{end}}</span>{{end}}</footer> </body> </html> `)) var fleetPage = template.Must(template.Must(base.Clone()).Parse(`{{define "fleet-body"}} <div class="tl-controls"> <form method="get" action="/"><input type="search" name="q" value="{{.Query}}" placeholder="filter" aria-label="filter repositories"> <select name="sort" onchange="this.form.submit()" aria-label="sort"> <option value="name"{{if eq .Sort "name"}} selected{{end}}>name</option> <option value="active"{{if eq .Sort "active"}} selected{{end}}>recently active</option> <option value="size"{{if eq .Sort "size"}} selected{{end}}>size</option> </select> <noscript><button type="submit">go</button></noscript></form> <span class="tl-meta-inline">{{if .FleetTotal}}{{len .Fleet}} of {{.FleetTotal}}{{end}}</span> {{if .IsAdmin}}<span class="tree-acts"><a href="/new">New repository</a></span>{{end}} </div> {{if .Fleet}}<div class="tw"><table class="fleet"> <thead><tr><th scope="col">Repository</th><th scope="col">Check-ins</th><th scope="col">Last activity</th></tr></thead> <tbody>{{range .Fleet}} <tr><td><a href="/r/{{.Name}}/timeline">{{.Name}}</a>{{if .Private}} <span class="tl-badge">private</span>{{end}} {{if .Description}}<br><small class="tl-meta-inline">{{.Description}}</small>{{end}}</td> <td>{{.Checkins}}{{if .Indexing}} <span class="tl-badge">indexing…</span>{{end}}</td> <td>{{if .Indexing}}<small class="tl-meta-inline">first index in progress; counts climb as it folds</small> {{- else}}{{if .LastActive}}{{ago .LastActive}}{{end}} {{- if .TipComment}}{{if .LastActive}}<br>{{end}}<small class="tl-meta-inline">{{.TipComment}}</small>{{end}}{{end}}</td></tr> {{end}}</tbody> </table></div>{{else}}<p>{{if .Query}}No repository matches “{{.Query}}”.{{else}}No repositories are being served yet.{{if .IsAdmin}} <a href="/new">Create one</a>.{{end}}{{end}}</p>{{end}} {{end}} {{define "content"}}<div{{if .Live}} {{.LiveAttr}}{{end}}><div id="fleet">{{template "fleet-body" .}}</div></div>{{end}}`)) var wikiListPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}} {{if .WikiPages}}<ul>{{range .WikiPages}} <li><a href="/r/{{$.Repo}}/wiki/{{pathesc .Title}}">{{.Title}}</a> <small>{{.Date}} by {{.User}}</small></li> {{end}}</ul>{{else}}<p>No wiki pages.</p>{{end}} <form method="get" action="/r/{{.Repo}}/wiki-new"><input name="name" placeholder="new page name" required> <button type="submit">Create</button></form> {{end}}`)) var forumListPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}} {{if .Forum}}<ul>{{range .Forum}} <li><a href="/r/{{$.Repo}}/forum/{{printf "%.16s" .Root}}">{{if .Title}}{{.Title}}{{else}}(untitled thread){{end}}</a> <small>{{.Posts}} post{{if ne .Posts 1}}s{{end}} · last {{ago .Updated}}{{if .User}} · started by {{.User}}{{end}}</small></li> {{end}}</ul>{{else}}<p>No forum threads.</p>{{end}} <p><a href="/r/{{.Repo}}/forum-new">new thread</a></p> {{end}}`)) var forumThreadPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}} <p><a href="/r/{{.Repo}}/forum">← forum</a></p> {{range .ForumPosts}}<article style="border-top:1px solid var(--muted); padding-top:0.5rem; margin-top:0.5rem"> <p><small>{{.Date}} by {{.User}}{{if .Parent}} · in reply to <code>{{printf "%.12s" .Parent}}</code>{{end}} · <code>{{printf "%.12s" .Hash}}</code></small></p> {{.HTML}} </article>{{end}} <form method="post" action="/r/{{.Repo}}/forum/{{.ForumRoot}}/reply" style="margin-top:1rem"> <p><textarea name="body" rows="6" style="width:100%" placeholder="Reply (Markdown)" required></textarea></p> <p><button type="submit">Post reply</button></p> </form> {{end}}`)) var forumNewPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}} <form method="post" action="/r/{{.Repo}}/forum-new"> <p><label>Title<br><input name="title" required style="width:100%"></label></p> <p><label>Post<br><textarea name="body" rows="14" style="width:100%" placeholder="Markdown" required></textarea></label></p> <p><button type="submit">Open thread</button> <a href="/r/{{.Repo}}/forum">cancel</a></p> </form> {{end}}`)) var technoteEditPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}} <form method="post" action="{{if .Technote.Event}}/r/{{.Repo}}/technote/{{printf "%.16s" .Technote.Event}}/edit{{else}}/r/{{.Repo}}/technote-new{{end}}"> <p><label>Title<br><input name="comment" style="width:100%" maxlength="200" value="{{.Technote.Comment}}"></label> <small class="tl-meta-inline">shown in the timeline; a note without one shows its event id</small></p> <p><label>Note<br><textarea name="body" rows="16" style="width:100%" placeholder="Markdown" required>{{.EditSource}}</textarea></label></p> <p><button type="submit">Save</button> <a href="{{if .Technote.Event}}/r/{{.Repo}}/technote/{{printf "%.16s" .Technote.Event}}{{else}}/r/{{.Repo}}/technote{{end}}">cancel</a></p> </form> {{end}}`)) var technoteListPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}} {{if .Technotes}}<ul>{{range .Technotes}} <li><a href="/r/{{$.Repo}}/technote/{{printf "%.16s" .Event}}">{{if .Comment}}{{.Comment}}{{else}}{{.Date}}{{end}}</a> <small>{{.Date}} by {{.User}}</small></li> {{end}}</ul>{{else}}<p>No technotes.</p>{{end}} <p><a href="/r/{{.Repo}}/technote-new">new technote</a></p> {{end}}`)) var technotePage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}} <article>{{.WikiHTML}}</article> <footer><small>technote {{.Technote.Date}} by {{.Technote.User}} · <a href="/r/{{.Repo}}/technote/{{printf "%.16s" .Technote.Event}}/edit">edit</a></small></footer> {{end}}`)) var chatPage = template.Must(template.Must(base.Clone()).Parse(`{{define "chat-log"}}<div id="chatlog" class="chatlog"> {{range .ChatMsgs}}<p class="chatmsg"><b>{{.User}}</b> <small class="tl-meta-inline">{{.At}}</small><br>{{.Text}}</p> {{end}}</div>{{end}} {{define "content"}} <style>.chatlog{max-height:60vh;overflow-y:auto;border:1px solid var(--muted);border-radius:6px;padding:0.5rem;margin-bottom:0.5rem} .chatmsg{margin:0.3rem 0}.chatform{display:flex;gap:0.5rem}.chatform input[name=text]{flex:1}</style> <div{{if .Live}} {{.LiveAttr}}{{end}}>{{template "chat-log" .}}</div> <form class="chatform" method="post" action="/r/{{.Repo}}/chat"> <input name="text" placeholder="message" maxlength="4096" required autocomplete="off"> <button type="submit">Send</button> </form> <p><small class="tl-meta-inline">Messages persist for 7 days; durable forum-thread archival lands with zit forum verbs.</small></p> {{end}}`)) var docsPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}} <article>{{.WikiHTML}}</article> {{end}}`)) var wikiEditPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}} <form method="post" action="/r/{{.Repo}}/wiki/{{pathesc .Wiki.Title}}/edit"> <p><textarea name="body" rows="20" style="width:100%" placeholder="Markdown">{{.EditSource}}</textarea></p> <p><button type="submit">Save</button> <a href="/r/{{.Repo}}/wiki/{{pathesc .Wiki.Title}}">cancel</a></p> </form> {{end}}`)) var ticketNewPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}} <form method="post" action="/r/{{.Repo}}/ticket/new"> <p><label>Title<br><input name="title" required style="width:100%"></label></p> <p><label>Status<br><input name="status" value="Open"></label></p> <p><label>Comment<br><textarea name="comment" rows="8" style="width:100%"></textarea></label></p> <p><button type="submit">Open ticket</button></p> </form> {{end}}`)) var ticketEditPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}} <form method="post" action="/r/{{.Repo}}/ticket/{{printf "%.10s" .Ticket.ID}}/edit"> <p><label>Title<br><input name="title" value="{{.Ticket.Field "title"}}" style="width:100%"></label></p> <p><label>Status<br><input name="status" value="{{.Ticket.Field "status"}}"></label></p> <p><label>Resolution<br><input name="resolution" value="{{.Ticket.Field "resolution"}}"></label></p> <p><label>Comment<br><textarea name="comment" rows="6" style="width:100%" placeholder="add a comment"></textarea></label></p> <p><button type="submit">Save change</button> <a href="/r/{{.Repo}}/ticket/{{printf "%.10s" .Ticket.ID}}">cancel</a></p> </form> {{end}}`)) var checkinPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}} <p>{{.Checkin.Comment}}</p> <p><small>{{if .CIBadge}}{{.CIBadge}} {{end}}{{.Checkin.Date}} by {{.Checkin.User}} on <a href="/r/{{.Repo}}/timeline?branch={{.Checkin.Branch}}">{{.Checkin.Branch}}</a> — <code>{{.Checkin.Hash}}</code></small></p> {{if .Checkin.Parents}}<p>parent{{if gt (len .Checkin.Parents) 1}}s{{end}}: {{range .Checkin.Parents}} <a href="/r/{{$.Repo}}/checkin/{{.}}"><code>{{printf "%.12s" .}}</code></a>{{end}}</p>{{end}} <p><a href="/r/{{.Repo}}/checkin/{{.Checkin.Hash}}/diff">view diff</a></p> {{if .Checkin.Files}}<div class="tw"><table> <thead><tr><th scope="col">File</th><th scope="col">Blob</th></tr></thead> <tbody>{{range .Checkin.Files}} <tr><td><a href="/r/{{$.Repo}}/file/{{$.Checkin.Hash}}/{{.Name}}">{{.Name}}</a></td><td><code>{{printf "%.12s" .Blob}}</code></td></tr> {{end}}</tbody> </table></div>{{end}} {{end}}`)) var ticketListPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}} {{if .Tickets}}<div class="tw"><table> <thead><tr><th scope="col">Ticket</th><th scope="col">Status</th><th scope="col">Title</th><th scope="col">Updated</th></tr></thead> <tbody>{{range .Tickets}} <tr><td><a href="/r/{{$.Repo}}/ticket/{{.ID}}"><code>{{printf "%.10s" .ID}}</code></a></td><td>{{.Status}}</td><td>{{.Title}}</td><td>{{ago .Updated}}</td></tr> {{end}}</tbody> </table></div>{{else}}<p>No tickets.</p>{{end}} {{if .Next}}<p><a href="/r/{{.Repo}}/ticket?before={{.Next}}">older →</a></p>{{end}} <p><a href="/r/{{.Repo}}/ticket/new">new ticket</a></p> {{end}}`)) var inboxPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}} {{if .Error}}<p><strong>{{.Error}}</strong></p>{{end}} {{if .Inbox}}<ul>{{range .Inbox}} <li>{{if not .Read}}<strong>{{.Subject}}</strong>{{else}}{{.Subject}}{{end}} <small class="tl-meta-inline">{{.CreatedAt}}</small> {{if .Content}}<br><small>{{.Content}}</small>{{end}}</li> {{end}}</ul>{{else if not .Error}}<p>No notifications.</p>{{end}} {{end}}`)) var loginPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}} {{if .Error}}<p><strong>{{.Error}}</strong></p>{{end}} <form method="post" action="/login"> <input type="hidden" name="next" value="{{.LoginNext}}"> <p><label>Account<br><input name="user" autocomplete="username" required autofocus></label></p> <p><label>Password<br><input name="password" type="password" autocomplete="current-password" required></label></p> <p><button type="submit">Sign in</button></p> </form> {{end}}`)) var historyPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}} <p><a href="/r/{{.Repo}}/file/{{.TreeVersion}}/{{.HistoryPath}}"><code>{{.HistoryPath}}</code></a></p> {{if .History}}<div class="tw"><table> <thead><tr><th scope="col">Check-in</th><th scope="col">When</th><th scope="col">Who</th><th scope="col">Comment</th></tr></thead> <tbody>{{range .History}} <tr><td><a href="/r/{{$.Repo}}/checkin/{{printf "%.16s" .Hash}}"><code>{{printf "%.12s" .Hash}}</code></a></td> <td class="tl-meta-inline">{{.Date}}</td><td>{{.User}}</td><td>{{.Comment}}</td></tr> {{end}}</tbody> </table></div> {{else}}<p>No recorded changes to this file — it has existed in one state for its whole life here.</p>{{end}} {{end}}`)) var ciPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}} <div{{if .Live}} {{.LiveAttr}}{{end}}><div id="ci">{{template "ci-body" .}}</div></div> {{end}} {{define "ci-body"}} {{if .CIRuns}}<div class="tw"><table> <thead><tr><th scope="col">Status</th><th scope="col">Check-in</th><th scope="col">Pipeline</th><th scope="col">Started</th></tr></thead> <tbody>{{range .CIRuns}} <tr><td><span class="tl-badge">{{.Status}}</span></td> <td><a href="/r/{{$.Repo}}/checkin/{{printf "%.16s" .Checkin}}"><code>{{printf "%.12s" .Checkin}}</code></a></td> <td>{{.Pipeline}} <small class="tl-meta-inline">{{.Workflow}}</small></td> <td class="tl-meta-inline">{{.Started}}</td></tr> {{end}}</tbody> </table></div> {{else}}<p>No CI runs. A pipeline is <code>ci/*.json</code> in the repository, in dagnats' workflow format; it runs on the check-ins made after it lands.</p>{{end}} {{end}}`)) var treePage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}} {{- /* One line of chrome, not four. The branch picker, the crumb trail and the clone affordances all belong to "which tree am I looking at", so they share a row and the listing starts near the top. */ -}} <div class="tree-bar"> {{if .Branches}}<form method="get" action="/r/{{.Repo}}/tree"><select name="v" onchange="this.form.submit()" aria-label="branch">{{range .Branches}} <option value="{{.Tip}}"{{if eq .Tip $.TreeVersion}} selected{{end}}>{{.Name}} · {{printf "%.10s" .Tip}}</option> {{end}}</select>{{if .MoreBranches}} <small class="tl-meta-inline">+{{.MoreBranches}} older</small>{{end}} <noscript><button type="submit">go</button></noscript></form>{{end}} <span class="crumbs"><a href="/r/{{.Repo}}/tree">{{.Repo}}</a>{{range .Crumbs}} / <a href="/r/{{$.Repo}}/tree/{{$.TreeVersion}}/{{.Path}}">{{.Name}}</a>{{end}}</span> <span class="tree-acts"><code class="clone">zit clone {{.CloneURL}} {{.Repo}}</code> <a href="/r/{{.Repo}}/archive/{{.TreeVersion}}">.tar.gz</a> {{if .IsAdmin}}<a href="/r/{{.Repo}}/settings">settings</a>{{end}}</span> </div> {{- /* The description belongs where the repository is, not only in the listing: a reader arriving from a link otherwise sees nav and a file tree and no statement of what the project is. */ -}} {{if or .RepoDescription .RepoPrivate}}<p class="about">{{if .RepoPrivate}}<span class="tl-badge">private</span> {{end}}{{.RepoDescription}}</p>{{end}} {{- /* No History column: it was one identical link per row, thirty of them, and the file page already links its own history. GitHub and Forgejo both spend that space on the last change instead, which is what goes here once the index carries per-file data. */ -}} <div class="tw"><table class="tree"> <tbody>{{range .Tree}} <tr> <td class="t-name">{{if .Dir}}<span class="ic ic-dir" aria-hidden="true"></span><a href="/r/{{$.Repo}}/tree/{{$.TreeVersion}}/{{.Path}}">{{.Name}}</a> {{- else}}<span class="ic ic-file" aria-hidden="true"></span><a href="/r/{{$.Repo}}/file/{{$.TreeVersion}}/{{.Path}}">{{.Name}}</a> {{- if .Exec}} <span class="flag" title="executable">exe</span>{{end}} {{- if .Link}} <span class="flag" title="symbolic link">link</span>{{end}} {{- if .Binary}} <span class="flag" title="binary">bin</span>{{end}}{{end}}</td> <td class="t-size">{{if .Dir}}{{.Files}} files{{else}}{{.Human}}{{end}}</td> </tr> {{end}}</tbody> </table></div> {{if .Readme}}<h3 class="readme-head">{{.ReadmeIn}}</h3> <div class="tkt-body readme">{{.Readme}}</div>{{end}} {{end}}`)) var ticketPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}} {{if .Ticket.Title}}<h2 class="tkt-title">{{.Ticket.Title}}</h2>{{end}} <div class="tw"><table class="tkt-head"> <tbody>{{range .Ticket.Header}} <tr><th scope="row">{{.Name}}</th><td>{{.Value}}</td></tr> {{end}}{{range .Ticket.Extra}} <tr><th scope="row">{{.Name}}</th><td>{{.Value}}</td></tr> {{end}} <tr><th scope="row">created</th><td>{{.Ticket.Created}}</td></tr> <tr><th scope="row">last change</th><td>{{ago .Ticket.Updated}}</td></tr> <tr><th scope="row">ticket</th><td><code>{{printf "%.20s" .Ticket.ID}}</code></td></tr> </tbody> </table></div> <p><a href="/r/{{.Repo}}/ticket/{{printf "%.10s" .Ticket.ID}}/edit">edit ticket</a></p> {{if .DescriptionHTML}}<h3>Description</h3> <div class="tkt-body">{{.DescriptionHTML}}</div>{{end}} {{if .Attachments}}<h3>Attachments</h3> <ul>{{range .Attachments}} <li><a href="/r/{{$.Repo}}/attachment/{{.Src}}">{{.Name}}</a> <small class="tl-meta-inline">{{.Date}} by {{.User}}</small></li> {{end}}</ul>{{end}} {{if .TicketComments}}<h3>Comments ({{len .TicketComments}})</h3> {{range .TicketComments}}<div class="tkt-comment"> <div class="tl-meta-inline">{{.Login}}{{if .DifferentUsername}} (claiming to be {{.Username}}){{end}} · {{.Date}}</div> {{.HTML}} </div> {{end}}{{end}} {{end}}`)) var filePage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}} <p><small>at <a href="/r/{{.Repo}}/checkin/{{.File.Hash}}"><code>{{printf "%.12s" .File.Hash}}</code></a> — {{.File.Size}} bytes</small></p> {{if .File.Binary}}<p>Binary file.</p>{{else if .FileHTML}}{{.FileHTML}}{{else}}<pre>{{.File.Content}}</pre>{{end}} {{end}}`)) // fleetAdminPage is the operator's view of the fleet. The ordering is // deliberate: failures first and loud, because the whole reason this // page exists is that with eleven stores a wedged one is invisible — // the fleet listing shows counts, and a store that has failed its round // every two seconds for an hour has exactly the counts it had when it // broke. var fleetAdminPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}} <h2>Fleet</h2> {{if .SyncResults}}<div class="status"><p><strong>Sync run.</strong></p> <ul>{{range .SyncResults}}<li><code>{{.Repo}}</code> — {{if .Err}}<strong>failed:</strong> {{.Err}}{{else}}{{.Artifacts}} artifacts{{end}}</li>{{end}}</ul></div>{{end}} <p>{{len .FleetHealth}} stores served{{if .Unhealthy}} — <strong>{{.Unhealthy}} failing</strong>{{else}} — all healthy{{end}}.</p> <form method="post" action="/fleet/sync"><button type="submit">Sync all now</button></form> <div class="tw"><table> <thead><tr><th scope="col">Store</th><th scope="col">Check-ins</th><th scope="col">Last round</th> <th scope="col">Artifacts</th><th scope="col">Parse fallbacks</th><th scope="col">State</th><th scope="col"></th></tr></thead> <tbody>{{range .FleetHealth}} <tr> <td><a href="/r/{{.Name}}/timeline">{{.Name}}</a><br><small class="tl-meta-inline"><code>{{printf "%.12s" .Code}}</code></small></td> <td>{{.Checkins}}</td> <td>{{if .Health.LastOK}}{{.Health.LastOK}}<br><small class="tl-meta-inline">{{printf "%.3f" .Health.RoundSeconds}}s</small>{{else}}<em>never</em>{{end}}</td> <td>{{.Health.Total}}{{if .Health.LastCount}} <small class="tl-meta-inline">(+{{.Health.LastCount}})</small>{{end}}</td> <td>{{if .Health.Fallbacks}}<span class="tl-badge">{{.Health.Fallbacks}}</span>{{else}}0{{end}}</td> <td>{{if .Health.Failures}}<strong>failing ({{.Health.Failures}} in a row)</strong><br><small class="tl-meta-inline">{{.Health.LastErrAt}} — {{.Health.LastErr}}</small> {{else if .Indexing}}<span class="tl-badge">indexing…</span>{{else}}ok{{end}}</td> <td><form method="post" action="/fleet/sync"><input type="hidden" name="repo" value="{{.Name}}"><button type="submit">sync</button></form></td> </tr> {{end}}</tbody> </table></div> <h3>Mirrors</h3> {{if .MirrorResults}}<div class="status"><p><strong>Pull run.</strong></p> <ul>{{range .MirrorResults}}<li><code>{{.Repo}}</code> — {{if .Err}}<strong>failed:</strong> {{.Err}}{{else if .Changed}}new artifacts{{else}}already up to date{{end}}</li>{{end}}</ul></div>{{end}} {{if .Mirrors}}<div class="tw"><table> <thead><tr><th scope="col">Store</th><th scope="col">Upstream</th><th scope="col">Every</th> <th scope="col">Last pull</th><th scope="col">State</th><th scope="col"></th></tr></thead> <tbody>{{range .Mirrors}} <tr> <td><a href="/r/{{.Repo}}/timeline">{{.Repo}}</a></td> <td><small>{{.URL}}</small></td> <td>{{.Every}}s</td> <td>{{if .LastAt}}{{.LastAt}}{{else}}<em>never</em>{{end}}</td> <td>{{if .Failures}}<strong>failing ({{.Failures}} in a row)</strong><br><small class="tl-meta-inline">{{.LastErr}}</small> {{else if not .Enabled}}disabled{{else if .LastOK}}ok{{else}}<em>pending</em>{{end}}</td> <td><form method="post" action="/fleet/mirror/run"><input type="hidden" name="repo" value="{{.Repo}}"><button type="submit">pull</button></form> <form method="post" action="/fleet/mirror/delete"><input type="hidden" name="repo" value="{{.Repo}}"><button type="submit">forget</button></form></td> </tr> {{end}}</tbody> </table></div> <form method="post" action="/fleet/mirror/run"><button type="submit">Pull all now</button></form> {{else}}<p>No upstreams configured. A mirrored store tracks its origin — quarry pulls, it never pushes.</p>{{end}} <form method="post" action="/fleet/mirror"> <label>store <input name="repo" required></label> <label>upstream <input name="url" type="url" placeholder="https://fossil-scm.org/home" required size="40"></label> <label>every <input name="every" type="number" value="3600" min="60" size="6"> seconds</label> <button type="submit">Add mirror</button></form> {{end}}`)) // newRepoPage is the forge's "create a repository" form. Deliberately // the same three questions every forge asks — name, description, where // it comes from — and NOT a filesystem path: the server places it. var newRepoPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}} <h2>New repository</h2> {{if .Error}}<p class="status"><strong>{{.Error}}</strong></p>{{end}} <form method="post" action="/new" class="newrepo-form"> <p><label>Name<br><input name="name" required autofocus pattern="[A-Za-z0-9][A-Za-z0-9._-]*" title="letters, digits, dot, dash, underscore"></label></p> <p><label>Description <small class="tl-meta-inline">optional</small><br> <input name="description" size="52" maxlength="300"></label></p> <p><label><input type="checkbox" name="private" value="true"> Private <small class="tl-meta-inline">— only accounts with a role on it can see that it exists</small></label></p> <fieldset><legend>Contents</legend> <p><label><input type="radio" name="kind" value="create" checked> Start empty</label></p> <p><label><input type="radio" name="kind" value="clone"> Clone an existing repository</label><br> <input name="from" size="52" placeholder="https://fossil-scm.org/home" aria-label="repository URL"></p> </fieldset> <p><button type="submit">Create repository</button></p> </form> {{end}}`)) // quickStartPage is what an empty repository shows instead of an empty // timeline: a repository with no check-ins is not broken, it is waiting, // and the reader's next move is to push to it. var quickStartPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}} {{if or .RepoDescription .RepoPrivate}}<p class="about">{{if .RepoPrivate}}<span class="tl-badge">private</span> {{end}}{{.RepoDescription}}</p>{{end}} <p>This repository is empty.</p> <h3>Push an existing repository</h3> <pre>zit remote {{.CloneURL}} zit push</pre> <h3>Start from scratch</h3> <pre>zit clone {{.CloneURL}} {{.Repo}} cd {{.Repo}} echo "# {{.Repo}}" > README.md zit add README.md zit commit -m "first check-in" zit push</pre> {{end}}`)) // settingsPage is the repository's own admin screen. Description and // visibility for now; the destructive actions belong here too when they // exist, kept visually apart from the settings that are reversible. var settingsPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}} <h2>Settings</h2> {{if .Error}}<p class="status"><strong>{{.Error}}</strong></p>{{end}} {{if .Saved}}<p class="status">Saved.</p>{{end}} <form method="post" action="/r/{{.Repo}}/settings"> <p><label>Description<br> <input name="description" size="52" maxlength="300" value="{{.RepoDescription}}"></label></p> <p><label><input type="checkbox" name="private" value="true"{{if .RepoPrivate}} checked{{end}}> Private <small class="tl-meta-inline">— only accounts with a role on it can see that it exists</small></label></p> <p><button type="submit">Save</button></p> </form> <h3>Clone</h3> <pre>zit clone {{.CloneURL}} {{.Repo}}</pre> {{end}}`)) var notFoundPage = template.Must(template.Must(base.Clone()).Parse( `{{define "content"}}<p>Nothing lives at this address. Start from the <a href="/">repository list</a>.</p>{{end}}`)) var busyPage = template.Must(template.Must(base.Clone()).Parse( `{{define "content"}}<p>The server is at capacity for this kind of request. Nothing is wrong — this is the limit doing its job — and the same request should succeed shortly.</p>{{end}}`)) var errorPage = template.Must(template.Must(base.Clone()).Parse( `{{define "content"}}<p>Something went wrong serving this page. The details are in the server log.</p>{{end}}`)) var searchPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}} <form action="/r/{{.Repo}}/search" method="get"> <input type="search" name="q" value="{{.Query}}" placeholder="search check-ins, wiki, tickets" aria-label="search"> <button type="submit">Search</button> </form> {{if .Results}}<ul>{{range .Results}} <li><code>{{.Kind}}</code> <a href="{{.URL}}">{{.Label}}</a> — <small>{{.Snippet}}</small></li> {{end}}</ul>{{else if .Query}}<p>No results.</p>{{end}} {{end}}`)) var diffPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}} <p><small>{{if .Diff.From}}<a href="/r/{{.Repo}}/checkin/{{.Diff.From}}"><code>{{printf "%.12s" .Diff.From}}</code></a> →{{else}}root check-in →{{end}} <a href="/r/{{.Repo}}/checkin/{{.Diff.To}}"><code>{{printf "%.12s" .Diff.To}}</code></a></small></p> {{if .Diff.Note}}<p><em>{{.Diff.Note}}</em></p>{{end}} {{if .Diff.Files}}<ul>{{range .Diff.Files}}<li><code>{{.Status}}</code> {{.Name}}</li>{{end}}</ul>{{end}} {{if .DiffLines}}<pre class="diff">{{range .DiffLines}}<span class="{{.Class}}">{{.Text}}</span> {{end}}</pre>{{end}} {{end}}`)) var wikiPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}} {{if .WikiRev}}<p class="status"><em>Historical revision <code>{{printf "%.12s" .Wiki.Hash}}</code> ({{.Wiki.Date}}) — <a href="/r/{{.Repo}}/wiki/{{pathesc .Wiki.Title}}">view latest</a></em></p>{{end}} <article>{{.WikiHTML}}</article> <footer><small>edited {{.Wiki.Date}} by {{.Wiki.User}} · <a href="/r/{{.Repo}}/wiki/{{pathesc .Wiki.Title}}/edit">edit</a> · <a href="/r/{{.Repo}}/wiki/{{pathesc .Wiki.Title}}/history">history</a></small></footer> {{end}}`)) var wikiHistoryPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}} <p><a href="/r/{{.Repo}}/wiki/{{pathesc .Wiki.Title}}">← {{.Wiki.Title}}</a></p> <ul>{{$top := true}}{{range .WikiRevs}} <li>{{.Date}} <a href="/r/{{$.Repo}}/wiki/{{pathesc $.Wiki.Title}}{{if not $top}}?rev={{printf "%.12s" .Hash}}{{end}}"><code>{{printf "%.12s" .Hash}}</code></a> by {{.User}}{{if $top}} <small>(current)</small>{{end}}{{$top = false}}</li>{{end}} </ul> {{end}}`)) var timelinePage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}} <form class="tl-controls" action="/r/{{.Repo}}/timeline" method="get"> {{if .Branch}}<input type="hidden" name="branch" value="{{.Branch}}"> <span>branch <a href="/r/{{.Repo}}/timeline?n={{.Limit}}"><b>{{.Branch}}</b> ✕</a></span>{{end}} <label>Show <select name="y"> <option value="all"{{if or (eq .Y "") (eq .Y "all")}} selected{{end}}>everything</option> <option value="ci"{{if eq .Y "ci"}} selected{{end}}>check-ins</option> <option value="w"{{if eq .Y "w"}} selected{{end}}>wiki</option> <option value="t"{{if eq .Y "t"}} selected{{end}}>tickets</option> <option value="e"{{if eq .Y "e"}} selected{{end}}>technotes</option> <option value="f"{{if eq .Y "f"}} selected{{end}}>forum</option> </select></label> <label>Max <select name="n">{{range .LimitChoices}}<option{{if eq . $.Limit}} selected{{end}}>{{.}}</option>{{end}}</select></label> <button type="submit">Apply</button> </form> <div{{if .Live}} {{.LiveAttr}}{{end}}><div id="tl">{{template "tl-body" .}}</div></div> {{end}} {{define "tl-body"}} {{if .Days}}<div class="tw"><table class="tl"> <tbody>{{range .Days}} <tr class="tl-date"><td></td><td class="tl-graph" style="min-width:{{.Width}}px">{{.Carry}}</td><td class="tl-datecell"><span class="tl-pill">{{.Date}}</span></td></tr> {{range .Rows}}<tr{{if ne .Kind "ci"}} class="tl-flat"{{end}}> <td class="tl-time"><a href="{{.URL}}">{{.Time}}</a></td> <td class="tl-graph" style="min-width:{{.Width}}px">{{.Head}}{{.Tail}}</td> <td class="tl-slot"><div class="tl-card{{if ne .Kind "ci"}} tl-card-flat{{end}}"{{with .Tint}} style="{{.}}"{{end}}> {{if eq .Kind "ci"}}<span class="tl-title">{{.Title}}</span> {{range .Paras}}<p class="tl-p">{{.}}</p>{{end}} <div class="tl-meta">{{.CI}}{{if .CI}} {{end}}{{if .IsMerge}}<span class="tl-badge">Merge</span> {{end}}{{if .IsLeaf}}<span class="tl-badge">Leaf</span> {{end}}<span class="tl-metaitem">check-in: <a href="{{.URL}}"><b>{{.Short}}</b></a></span> <span class="tl-metaitem">user: <b>{{.User}}</b></span> <span class="tl-metaitem">branch: <a href="/r/{{$.Repo}}/timeline?branch={{.Branch}}"><b>{{.Branch}}</b></a></span></div> {{else}}<span class="tl-badge">{{if eq .Kind "w"}}wiki{{else}}ticket{{end}}</span> <a href="{{.URL}}">{{.Title}}</a> <span class="tl-meta-inline">by <b>{{.User}}</b></span>{{end}} </div></td> </tr> {{end}}{{end}}</tbody> </table></div> {{if .Next}}<p><a href="/r/{{.Repo}}/timeline?before={{.Next}}{{if .Branch}}&branch={{.Branch}}{{end}}{{with .Y}}&y={{.}}{{end}}&n={{.Limit}}">older →</a></p>{{end}} {{else}}<p>No check-ins.</p>{{end}} {{end}}`)) type pageData struct { Title string Version string Repo string // repo context; empty on fleet-level pages Branch string // active timeline branch filter Limit int LimitChoices []int Y string Live bool // LiveAttr carries the data-init that opens the SSE stream. It MUST // sit on a wrapper OUTSIDE the element the stream patches: a patch // morphs the target to match the fragment, and the fragment has no // data-init, so an attribute on the target is deleted by the first // patch — taking Datastar's own connection with it. The failure is // quiet and easy to miss, because the first patch still applies. LiveAttr template.HTMLAttr Fleet []repoInfo FleetTotal int Sort string Entries []timelineEntry Days []tlDay Next string WikiPages []wikiTitle Wiki wikiDetail WikiRev string // non-empty when viewing a historical revision WikiRevs []wikiRev // the history page's edit list EditSource string // wiki edit form's prefilled markdown Checkin checkinDetail Tickets []ticketSummary Ticket ticketDetail Attachments []attachmentRow // DescriptionHTML and TicketComments are pre-rendered by the op; the // sanitizer already ran, which is why they are template.HTML here. DescriptionHTML template.HTML TicketComments []ticketComment History []fileChange HistoryPath string CIRuns []ciRunRow CIBadge template.HTML Tree []treeRow TreeVersion string Crumbs []crumb Branches []branchRow MoreBranches int CloneURL string Readme template.HTML ReadmeIn string Forum []forumThreadRow ForumRoot string // reply-form target on the thread page ForumPosts []forumPostRow LoginNext string // post-login redirect target (Next is the page cursor) SignedIn string // the signed-in account, for the header Inbox []InboxMessage HasInbox bool // an inbox is configured, so link to it CanSignIn bool // sessions are configured, so offer the link IsAdmin bool // site admin, so show the fleet link Error string // form-level failure, shown above the fields Technotes []technoteRow Technote technoteDetail ChatMsgs []ChatMessage File fileDetail Diff diffDetail DiffLines []diffLine Query string Results []searchHit // WikiHTML and FileHTML are op-rendered, sanitizer-gated markup — // the trusted HTML injection points (0002 §2: the sanitizer is the // gate). WikiHTML template.HTML FileHTML template.HTML // FleetHealth is the admin surface's per-store ingest state, and // Unhealthy the count an operator scans for. SyncResults is non-nil // only just after a manual round. FleetHealth []fleetHealthRow Unhealthy int SyncResults []fleetSyncRow // Mirrors is the configured upstream tracking; MirrorResults is // non-nil only just after a manual pull. Mirrors []mirrorRow MirrorResults []mirrorRunRow // Section is the nav entry the current page belongs to, so the nav // can say where the reader is. Counts is the per-section census; // HasCounts distinguishes "no content" from "counts unavailable", // which otherwise both render as zero. Section string Counts navCounts HasCounts bool // RepoDescription and RepoPrivate are this repository's settings, // for the settings form and the About panel. RepoDescription string RepoPrivate bool Saved bool // Elapsed is how long this request took to render, reported the way // fossil reports it ("This page was generated in about 0.003s"). // Publishing it is what made the timeline regression measurable // against fossil at all, so it stays on the page rather than in a // header only a developer would look at. Elapsed string // Breakdown says which layer the time went to ("zit 147ms, sql 3ms"). // One number tells a reader the page is slow; it does not say whether // to look at the store, the index, or the template. Breakdown string // HideH1 lets a page that already names itself suppress the layout's // heading. The file listing does: its crumb trail starts with the // repository, so an h1 above it repeats the same word and costs a // heading's worth of vertical space to say nothing. HideH1 bool } // navCounts mirrors the repo.counts op. type navCounts struct { Checkins int `json:"checkins"` WikiPages int `json:"wikiPages"` Tickets int `json:"tickets"` ForumThreads int `json:"forumThreads"` Technotes int `json:"technotes"` } // sectionOf names the nav entry a path belongs to. It reads the segment // after /r/{repo}/, so every page under a section — a ticket, a wiki // revision, a file's history — marks its section, not just the section's // index page. func sectionOf(path string) string { rest, ok := strings.CutPrefix(path, "/r/") if !ok { return "" } _, rest, ok = strings.Cut(rest, "/") if !ok || rest == "" { return "tree" // the repository home is the file tree } seg, _, _ := strings.Cut(rest, "/") switch seg { // Pages that live under a section without sharing its path segment. case "file", "history", "archive", "tree": return "tree" case "checkin", "diff", "timeline": return "timeline" default: return seg } } // mirrorRow mirrors the mirror.list op's row. type mirrorRow struct { Repo string `json:"repo"` URL string `json:"url"` Every int `json:"every"` Enabled bool `json:"enabled"` LastAt string `json:"lastAt"` LastOK string `json:"lastOk"` LastErr string `json:"lastErr"` Failures int `json:"failures"` } type mirrorRunRow struct { Repo string `json:"repo"` Changed bool `json:"changed"` Err string `json:"err"` } // fleetHealthRow mirrors the fleet.status op's row. Durations arrive as // seconds and are formatted for reading here rather than in the op: the // JSON surface stays machine-shaped, the page stays human-shaped. type fleetHealthRow struct { Name string `json:"name"` Code string `json:"code"` Checkins int `json:"checkins"` Indexing bool `json:"indexing"` Health struct { LastOK string `json:"lastOk"` RoundSeconds float64 `json:"roundSeconds"` LastCount int `json:"lastCount"` Total int `json:"total"` Fallbacks int `json:"fallbacks"` LastErr string `json:"lastErr"` LastErrAt string `json:"lastErrAt"` Failures int `json:"failures"` } `json:"health"` } type fleetSyncRow struct { Repo string `json:"repo"` Artifacts int `json:"artifacts"` Err string `json:"err"` } type repoInfo struct { Name string `json:"name"` Description string `json:"description"` Private bool `json:"private"` LastActive string `json:"lastActive"` Checkins int `json:"checkins"` TipDate string `json:"tipDate"` TipComment string `json:"tipComment"` Indexing bool `json:"indexing"` } type timelineEntry struct { Kind string `json:"kind"` Ref string `json:"ref"` Hash string `json:"hash"` Date string `json:"date"` User string `json:"user"` Comment string `json:"comment"` Branch string `json:"branch"` IsLeaf bool `json:"isLeaf"` IsMerge bool `json:"isMerge"` Parents []string `json:"parents"` CI string `json:"ci"` } type wikiTitle struct { Title string `json:"title"` Date string `json:"date"` User string `json:"user"` } type wikiDetail struct { Hash string `json:"hash"` Title string `json:"title"` Date string `json:"date"` User string `json:"user"` HTML string `json:"html"` } type wikiRev struct { Hash string `json:"hash"` Date string `json:"date"` User string `json:"user"` } type forumThreadRow struct { Root string `json:"root"` Title string `json:"title"` Posts int `json:"posts"` Updated string `json:"updated"` User string `json:"user"` } type forumPostRow struct { Hash string `json:"hash"` Parent string `json:"parent"` Date string `json:"date"` User string `json:"user"` HTML template.HTML } // attachmentRow is one live attachment on a ticket. Fossil keeps the // content as its own artifact, which the download route serves by hash. // CIBadge renders a check-in's verdict for a listing. Empty for a // check-in with no verdict: a repository without CI, or a check-in made // before its pipeline landed, should show nothing at all rather than a // neutral badge that reads as a result. func ciBadge(verdict string) template.HTML { switch verdict { case "ci-pass": return template.HTML(`<span class="tl-badge ci-pass" title="CI passed">pass</span>`) case "ci-fail": return template.HTML(`<span class="tl-badge ci-fail" title="CI failed">fail</span>`) } return "" } // fileChange is one check-in that touched a file. type fileChange struct { Hash string `json:"hash"` Date string `json:"date"` User string `json:"user"` Comment string `json:"comment"` } // ciRunRow is one CI run for the display. type ciRunRow struct { RunID string `json:"runId"` Checkin string `json:"checkin"` Workflow string `json:"workflow"` Pipeline string `json:"pipeline"` Status string `json:"status"` Started string `json:"started"` } // treeRow is one entry of a directory listing. type treeRow struct { Name string `json:"name"` Path string `json:"path"` Dir bool `json:"dir"` Blob string `json:"blob"` Files int `json:"files"` Size int64 `json:"size"` Exec bool `json:"exec"` Link bool `json:"link"` Binary bool `json:"binary"` } // Human renders a size the way a file listing should: bytes are noise // past a few thousand, and the reader wants magnitude. func (t treeRow) Human() string { const unit = 1024 if t.Size < unit { return fmt.Sprintf("%d B", t.Size) } div, exp := int64(unit), 0 for n := t.Size / unit; n >= unit; n /= unit { div *= unit exp++ } return fmt.Sprintf("%.1f %cB", float64(t.Size)/float64(div), "KMGT"[exp]) } // branchRow is one branch for the selector. Tip is carried because zit // allows two branches to share a name, so the name alone cannot select. type branchRow struct { Name string `json:"name"` Tip string `json:"tip"` } // crumb is one path segment of the breadcrumb trail. type crumb struct { Name string Path string } type attachmentRow struct { Name string `json:"name"` Src string `json:"src"` // content artifact — the download target Date string `json:"date"` User string `json:"user"` } type technoteRow struct { Event string `json:"event"` Date string `json:"date"` User string `json:"user"` Comment string `json:"comment"` } type technoteDetail struct { Event string `json:"event"` Date string `json:"date"` User string `json:"user"` Comment string `json:"comment"` } type ticketSummary struct { ID string `json:"id"` Title string `json:"title"` Status string `json:"status"` Updated string `json:"updated"` } type ticketField struct { Name string `json:"name"` Value string `json:"value"` } type ticketDetail struct { ID string `json:"id"` Created string `json:"created"` Updated string `json:"updated"` Fields []ticketField `json:"fields"` Changes []struct { Date string `json:"date"` User string `json:"user"` Fields []ticketField `json:"fields"` } `json:"changes"` } // ticketComment is one rendered discussion entry. type ticketComment struct { Date string `json:"date"` Login string `json:"login"` Username string `json:"username"` HTML template.HTML `json:"html"` } // DifferentUsername reports whether the typed name disagrees with the // authenticated one. Fossil surfaces that ("claiming to be …") because // on an open tracker the two need not match, and hiding the difference // would attribute words to the wrong person. func (c ticketComment) DifferentUsername() bool { return c.Username != "" && c.Username != c.Login } // Title is the ticket's headline, promoted out of the field table the // way fossil's view does — a ticket is known by its title, not its hash. func (t ticketDetail) Title() string { return t.Field("title") } // Header is the ordered subset fossil shows above the description. The // order is fossil's own (status/type, severity/priority, // subsystem/resolution), and unset fields are dropped rather than // rendered as blank rows. func (t ticketDetail) Header() []ticketField { order := []string{"status", "type", "severity", "priority", "subsystem", "resolution", "assignee", "foundin", "submitter", "closer"} var out []ticketField for _, name := range order { if v := t.Field(name); v != "" { out = append(out, ticketField{Name: name, Value: v}) } } return out } // Extra is everything else a repository's ticket schema carries — trackers // define their own columns, so a fixed list would hide them. func (t ticketDetail) Extra() []ticketField { known := map[string]bool{"title": true, "status": true, "type": true, "severity": true, "priority": true, "subsystem": true, "resolution": true, "assignee": true, "foundin": true, "submitter": true, "closer": true} var out []ticketField for _, f := range t.Fields { if !known[f.Name] && f.Value != "" { out = append(out, f) } } return out } // Field folds the current value of one named field (for form prefill). func (t ticketDetail) Field(name string) string { for _, f := range t.Fields { if f.Name == name { return f.Value } } return "" } type checkinDetail struct { Hash string `json:"hash"` CI string `json:"ci"` Branch string `json:"branch"` Date string `json:"date"` User string `json:"user"` Comment string `json:"comment"` Parents []string `json:"parents"` Files []struct { Blob string `json:"blob"` Name string `json:"name"` } `json:"files"` } type fileDetail struct { Hash string `json:"hash"` Path string `json:"path"` Size int `json:"size"` Binary bool `json:"binary"` Content string `json:"content"` HTML string `json:"html"` } type diffDetail struct { From string `json:"from"` To string `json:"to"` Note string `json:"note"` Files []struct { Status string `json:"status"` Name string `json:"name"` } `json:"files"` Unified string `json:"unified"` } type diffLine struct { Class string Text string } // classifyDiff maps unified-diff lines to display classes; the template // escapes Text, so content stays inert. func classifyDiff(unified string) []diffLine { if unified == "" { return nil } var out []diffLine for _, line := range strings.Split(strings.TrimSuffix(unified, "\n"), "\n") { class := "ctx" switch { case strings.HasPrefix(line, "+++"), strings.HasPrefix(line, "---"): class = "head" case strings.HasPrefix(line, "@@"): class = "hunk" case strings.HasPrefix(line, "+"): class = "add" case strings.HasPrefix(line, "-"): class = "del" } out = append(out, diffLine{Class: class, Text: line}) } return out } type searchHit struct { Kind string Label string URL string Snippet template.HTML } // hitOf turns one search result into its page link. The label is the // human-readable name the op provides (ticket title, wiki name, // check-in comment) with the ref as fallback; the snippet's ⟦…⟧ match // markers become <mark> AFTER escaping, so content stays inert. func hitOf(repo, kind, ref, label, snippet string) searchHit { h := searchHit{Kind: kind, Label: label} prefix := "/r/" + repo switch kind { case "checkin": h.URL = prefix + "/checkin/" + ref if h.Label == "" { h.Label = ref[:min(12, len(ref))] } case "wiki": h.URL = prefix + "/wiki/" + url.PathEscape(ref) if h.Label == "" { h.Label = ref } case "ticket": h.URL = prefix + "/ticket/" + ref if h.Label == "" { h.Label = ref[:min(10, len(ref))] } } esc := template.HTMLEscapeString(snippet) esc = strings.ReplaceAll(esc, "⟦", "<mark>") esc = strings.ReplaceAll(esc, "⟧", "</mark>") h.Snippet = template.HTML(esc) return h } // Handler mounts every HTML page over the registry. n may be nil (no // live updates; pages are unchanged otherwise). // Verify checks one repo credential pair against a required capability; // nil leaves writes open (single-user local serving, author "anonymous"). type Verify func(repo, user, password string, need ops.Auth) api.Decision // Sessions is the browser half of the gate: a cookie a person can drop. // HTTP Basic keeps working alongside it — that is what the API and every // script uses — but a browser needs a way to log OUT, which Basic does // not give it. A nil Sessions disables the login page and leaves Basic // as the only path. type Sessions interface { // Login issues a token for correct credentials. Login(user, password string) (token string, expires time.Time, ok bool) // Resolve names the account a token belongs to. Resolve(token string) (user string, ok bool) // Logout drops one token. Logout(token string) // Allow authorizes an ALREADY-authenticated user, so a cookie is // checked against the same roles a password would be. Allow(user, repo string, need ops.Auth) bool } // InboxMessage is one notification as the inbox page shows it. quarry // does not model notifications — notify owns them (0001 §5) — so this is // only what the page renders. type InboxMessage struct { Subject string Content string CreatedAt string Read bool } // Inbox reads a subscriber's notifications from the notify companion. // nil disables the inbox page, which is the default: notifications are // off unless --notify is configured. type Inbox interface { Messages(ctx context.Context, subscriber string) ([]InboxMessage, error) } // WithInbox attaches the notify-backed inbox. func WithInbox(in Inbox) Option { return func(o *handlerOpts) { o.inbox = in } } // sessionCookie is the browser's session key. Host-only, path-wide, // unreadable to scripts, and not sent on cross-site requests — the last // of which is what keeps a POST form from being submitted by another // origin on the reader's behalf. const sessionCookie = "quarry_session" func setSession(w http.ResponseWriter, req *http.Request, token string, expires time.Time) { http.SetCookie(w, &http.Cookie{ Name: sessionCookie, Value: token, Path: "/", Expires: expires, HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: req.TLS != nil, }) } func clearSession(w http.ResponseWriter, req *http.Request) { http.SetCookie(w, &http.Cookie{ Name: sessionCookie, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: req.TLS != nil, }) } func cookieToken(req *http.Request) string { if c, err := req.Cookie(sessionCookie); err == nil { return c.Value } return "" } // gate runs the write-page auth check at Token: HTTP Basic against // verify, the authenticated user carried to the op via context. Almost // every gated HTML page is a write page and wants exactly this. func gate(verify Verify, sess Sessions, repo string, w http.ResponseWriter, req *http.Request) (*http.Request, bool) { return gateAt(verify, sess, repo, ops.Token, w, req) } // gateAt is gate at an explicit capability. The fleet admin pages are // the reason it exists: they are not scoped to one repository and they // expose operational detail, so Token is not the right bar for them. func gateAt(verify Verify, sess Sessions, repo string, level ops.Auth, w http.ResponseWriter, req *http.Request) (*http.Request, bool) { if verify == nil { return req, true } if sess != nil { if user, ok := sess.Resolve(cookieToken(req)); ok { if !sess.Allow(user, repo, level) { // Signed in, but without the capability on THIS // repository. A challenge would be a lie — better // credentials are not what is missing. forbid(w, user, repo, level) return nil, false } return req.WithContext(ops.WithUser(req.Context(), user)), true } } user, pass, _ := req.BasicAuth() switch verify(repo, user, pass, level) { case api.Allowed: case api.Insufficient: // Authenticated and short of the capability: a challenge would // invite the reader to type the same password again. forbid(w, user, repo, level) return nil, false default: if sess != nil && user == "" { // A browser with no credential gets the login page, not a // Basic prompt it cannot log out of. http.Redirect(w, req, "/login?next="+url.QueryEscape(req.URL.Path), http.StatusSeeOther) return nil, false } w.Header().Set("WWW-Authenticate", `Basic realm="quarry"`) w.WriteHeader(http.StatusUnauthorized) w.Write([]byte("authentication required")) return nil, false } return req.WithContext(ops.WithUser(req.Context(), user)), true } // forbid states what the signed-in user actually lacks. The fleet pages // are not scoped to a repository, so a message naming one would be // nonsense ("no write access to ”"); the level and the empty repo pick // the honest wording. // isEmptyRepo reports whether a registered repository simply has no // check-ins yet, as opposed to not existing. func isEmptyRepo(r *ops.Registry, req *http.Request, repo string) bool { out, err := invoke(req.Context(), r, "repo.counts", url.Values{"repo": {repo}}) if err != nil { return false // unknown repository: a real not-found } var c struct { Checkins int `json:"checkins"` } remarshal(out, &c) return c.Checkins == 0 } // fleetHealth renders the admin page, optionally above the results of a // sync or a pull the operator just ran. func fleetHealth(r *ops.Registry, w http.ResponseWriter, req *http.Request, sync []fleetSyncRow, pulls []mirrorRunRow) { out, err := invoke(req.Context(), r, "fleet.status", nil) if err != nil { fail(r, w, req, err) return } var fs struct { Repos []fleetHealthRow `json:"repos"` Unhealthy int `json:"unhealthy"` } remarshal(out, &fs) data := pageData{ Title: "fleet", FleetHealth: fs.Repos, Unhealthy: fs.Unhealthy, SyncResults: sync, MirrorResults: pulls, } // mirror.list is absent when no mirror store is configured, which is // a forge that mirrors nothing rather than an error — the section // simply renders its empty state. if mo, err := invoke(req.Context(), r, "mirror.list", nil); err == nil { var ml struct { Mirrors []mirrorRow `json:"mirrors"` } remarshal(mo, &ml) data.Mirrors = ml.Mirrors } render(r, w, req, fleetAdminPage, data) } func forbid(w http.ResponseWriter, user, repo string, level ops.Auth) { w.WriteHeader(http.StatusForbidden) switch { case level >= ops.Admin && repo == "": fmt.Fprintf(w, "%s is not a site admin", user) case level >= ops.Admin: fmt.Fprintf(w, "%s has no admin access to %s", user, repo) default: fmt.Fprintf(w, "%s has no write access to %s", user, repo) } } // WithSessions attaches the cookie plane to a handler built by Handler. // Kept separate from Handler's signature so a caller that only needs the // Basic gate — every test, and single-user local serving — is unchanged. func WithSessions(s Sessions) func(*handlerOpts) { return func(o *handlerOpts) { o.sessions = s } } type handlerOpts struct { sessions Sessions inbox Inbox } // Option configures the handler beyond its required planes. type Option func(*handlerOpts) func Handler(r *ops.Registry, n Notifier, live ChatLive, v ...Verify) http.Handler { return HandlerWith(r, n, live, nil, v...) } // HandlerWith is Handler plus options; Handler is the common case. func HandlerWith(r *ops.Registry, n Notifier, live ChatLive, opts []Option, v ...Verify) http.Handler { var verify Verify if len(v) > 0 { verify = v[0] } o := &handlerOpts{} for _, fn := range opts { fn(o) } sessions := o.sessions inbox := o.inbox mux := http.NewServeMux() if sessions != nil { mux.HandleFunc("GET /login", func(w http.ResponseWriter, req *http.Request) { render(r, w, req, loginPage, pageData{ Title: "sign in", LoginNext: safeNext(req.URL.Query().Get("next")), }) }) mux.HandleFunc("POST /login", func(w http.ResponseWriter, req *http.Request) { user, password := req.FormValue("user"), req.FormValue("password") next := safeNext(req.FormValue("next")) token, expires, ok := sessions.Login(user, password) if !ok { // One message for a bad name and a bad password: naming // which was wrong tells an attacker which accounts exist. w.WriteHeader(http.StatusUnauthorized) render(r, w, req, loginPage, pageData{ Title: "sign in", LoginNext: next, Error: "That account and password do not match.", }) return } setSession(w, req, token, expires) http.Redirect(w, req, next, http.StatusSeeOther) }) mux.HandleFunc("POST /logout", func(w http.ResponseWriter, req *http.Request) { sessions.Logout(cookieToken(req)) clearSession(w, req) http.Redirect(w, req, "/", http.StatusSeeOther) }) } if inbox != nil && sessions != nil { mux.HandleFunc("GET /inbox", func(w http.ResponseWriter, req *http.Request) { user, ok := sessions.Resolve(cookieToken(req)) if !ok { http.Redirect(w, req, "/login?next=%2Finbox", http.StatusSeeOther) return } data := pageData{Title: "inbox"} msgs, err := inbox.Messages(req.Context(), user) if err != nil { // A notify outage must not look like "you have nothing"; // an empty list and a failure are different answers. log.Printf("web: inbox for %s: %v", user, err) data.Error = "Notifications are unavailable right now." } data.Inbox = msgs render(r, w, req, inboxPage, data) }) } mux.HandleFunc("GET /assets/datastar.js", func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "text/javascript; charset=utf-8") w.Header().Set("Cache-Control", "public, max-age=86400") w.Write(datastarJS) }) mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, req *http.Request) { q, sortBy := req.URL.Query().Get("q"), req.URL.Query().Get("sort") if sortBy == "" { sortBy = "name" } out, err := invoke(req.Context(), r, "repo.list", url.Values{"q": {q}, "sort": {sortBy}, "limit": {"100"}}) if err != nil { fail(r, w, req, err) return } var fl struct { Repos []repoInfo `json:"repos"` Total int `json:"total"` } remarshal(out, &fl) data := pageData{Title: "quarry", Fleet: fl.Repos, FleetTotal: fl.Total, Query: q, Sort: sortBy} if n != nil { data.Live = true data.LiveAttr = template.HTMLAttr(`data-init="@get('/fleet.sse')"`) } render(r, w, req, fleetPage, data) }) settingsFor := func(req *http.Request, repo string) (desc string, private bool) { out, err := invoke(req.Context(), r, "repo.get", url.Values{"repo": {repo}}) if err != nil { return "", false } var info repoInfo remarshal(out, &info) return info.Description, info.Private } mux.HandleFunc("GET /r/{repo}/settings", func(w http.ResponseWriter, req *http.Request) { repo := req.PathValue("repo") req, ok := gateAt(verify, sessions, repo, ops.Admin, w, req) if !ok { return } desc, private := settingsFor(req, repo) render(r, w, req, settingsPage, pageData{ Title: repo, Repo: repo, RepoDescription: desc, RepoPrivate: private, CloneURL: cloneURL(req, repo), }) }) mux.HandleFunc("POST /r/{repo}/settings", func(w http.ResponseWriter, req *http.Request) { repo := req.PathValue("repo") req, ok := gateAt(verify, sessions, repo, ops.Admin, w, req) if !ok { return } form := url.Values{"name": {repo}, "description": {req.FormValue("description")}} if req.FormValue("private") == "true" { form.Set("private", "true") } data := pageData{Title: repo, Repo: repo, CloneURL: cloneURL(req, repo), RepoDescription: req.FormValue("description"), RepoPrivate: req.FormValue("private") == "true"} if _, err := invoke(req.Context(), r, "repo.update", form); err != nil { // The form comes back holding what the reader typed, with // the reason — not an error page that discards it. data.Error = err.Error() renderStatus(r, w, req, settingsPage, data, http.StatusBadRequest) return } data.Saved = true render(r, w, req, settingsPage, data) }) mux.HandleFunc("GET /new", func(w http.ResponseWriter, req *http.Request) { req, ok := gateAt(verify, sessions, "", ops.Admin, w, req) if !ok { return } render(r, w, req, newRepoPage, pageData{Title: "new repository"}) }) mux.HandleFunc("POST /new", func(w http.ResponseWriter, req *http.Request) { req, ok := gateAt(verify, sessions, "", ops.Admin, w, req) if !ok { return } form := url.Values{ "name": {req.FormValue("name")}, "description": {req.FormValue("description")}, } if req.FormValue("private") == "true" { form.Set("private", "true") } if req.FormValue("kind") == "clone" { form.Set("clone", req.FormValue("from")) } else { form.Set("create", "true") } out, err := invoke(req.Context(), r, "repo.add", form) if err != nil { // A rejected form comes BACK as a form with the reason on it, // not as an error page: the reader has a correction to make, // and an error page throws their input away. renderStatus(r, w, req, newRepoPage, pageData{Title: "new repository", Error: err.Error()}, http.StatusBadRequest) return } var added struct { Name string `json:"name"` } remarshal(out, &added) http.Redirect(w, req, "/r/"+added.Name, http.StatusSeeOther) }) // The fleet admin pages are gated at Admin with an EMPTY repo: they // are not scoped to a store, and a site admin is the only role for // which "every store's health" is a meaningful question. mux.HandleFunc("GET /fleet", func(w http.ResponseWriter, req *http.Request) { req, ok := gateAt(verify, sessions, "", ops.Admin, w, req) if !ok { return } fleetHealth(r, w, req, nil, nil) }) mux.HandleFunc("POST /fleet/sync", func(w http.ResponseWriter, req *http.Request) { req, ok := gateAt(verify, sessions, "", ops.Admin, w, req) if !ok { return } out, err := invoke(req.Context(), r, "fleet.sync", url.Values{"repo": {req.FormValue("repo")}}) if err != nil { fail(r, w, req, err) return } var fs struct { Results []fleetSyncRow `json:"results"` } remarshal(out, &fs) // Rendered in place rather than redirected: the results ARE the // answer to pressing the button, and a redirect would drop them. fleetHealth(r, w, req, fs.Results, nil) }) mux.HandleFunc("POST /fleet/mirror", func(w http.ResponseWriter, req *http.Request) { req, ok := gateAt(verify, sessions, "", ops.Admin, w, req) if !ok { return } if _, err := invoke(req.Context(), r, "mirror.set", url.Values{ "repo": {req.FormValue("repo")}, "url": {req.FormValue("url")}, "every": {req.FormValue("every")}, }); err != nil { fail(r, w, req, err) return } http.Redirect(w, req, "/fleet", http.StatusSeeOther) }) mux.HandleFunc("POST /fleet/mirror/delete", func(w http.ResponseWriter, req *http.Request) { req, ok := gateAt(verify, sessions, "", ops.Admin, w, req) if !ok { return } if _, err := invoke(req.Context(), r, "mirror.delete", url.Values{"repo": {req.FormValue("repo")}}); err != nil { fail(r, w, req, err) return } http.Redirect(w, req, "/fleet", http.StatusSeeOther) }) mux.HandleFunc("POST /fleet/mirror/run", func(w http.ResponseWriter, req *http.Request) { req, ok := gateAt(verify, sessions, "", ops.Admin, w, req) if !ok { return } out, err := invoke(req.Context(), r, "mirror.run", url.Values{"repo": {req.FormValue("repo")}}) if err != nil { fail(r, w, req, err) return } var mr struct { Results []mirrorRunRow `json:"results"` } remarshal(out, &mr) fleetHealth(r, w, req, nil, mr.Results) }) mux.HandleFunc("GET /docs", func(w http.ResponseWriter, req *http.Request) { out, err := invoke(req.Context(), r, "meta.docs", nil) if err != nil { fail(r, w, req, err) return } var d struct { HTML string `json:"html"` } remarshal(out, &d) render(r, w, req, docsPage, pageData{ Title: "API", WikiHTML: template.HTML(d.HTML), }) }) mux.HandleFunc("GET /fleet.sse", func(w http.ResponseWriter, req *http.Request) { if n == nil { http.NotFound(w, req) return } liveFleet(r, n, w, req) }) // Everything unmatched gets the themed 404, never Go's bare // plain-text default. mux.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) { fail(r, w, req, ops.ErrNotFound) }) // The repository's front door. A forge lands on code — fossil's own // default is the timeline, and departing from it is deliberate: a // repository with no file view has no front door at all, and every // GitHub habit misses. treeHandler := func(w http.ResponseWriter, req *http.Request) { repo := req.PathValue("repo") version, path := req.PathValue("version"), req.PathValue("path") if v := req.URL.Query().Get("v"); v != "" { // The branch selector posts here; redirect so the chosen // version lives in the path and the page is linkable. http.Redirect(w, req, "/r/"+repo+"/tree/"+url.PathEscape(v)+"/", http.StatusSeeOther) return } if version == "" { version = "trunk" } out, err := invoke(req.Context(), r, "tree.list", url.Values{ "repo": {repo}, "version": {version}, "path": {path}, }) if err != nil { // A repository with no check-ins has an unborn trunk, so the // listing legitimately finds nothing — and answering 404 // tells its owner the repository does not exist, moments // after they created it. Distinguish the two by asking the // index whether this repository is merely empty. if isEmptyRepo(r, req, repo) { desc, private := settingsFor(req, repo) render(r, w, req, quickStartPage, pageData{ Title: repo, Repo: repo, CloneURL: cloneURL(req, repo), RepoDescription: desc, RepoPrivate: private, }) return } fail(r, w, req, err) return } var tl struct { Version string `json:"version"` Path string `json:"path"` Entries []treeRow `json:"entries"` Readme string `json:"readme"` ReadmeIn string `json:"readmeIn"` } remarshal(out, &tl) data := pageData{ Title: repo, Repo: repo, Tree: tl.Entries, TreeVersion: version, Readme: template.HTML(tl.Readme), ReadmeIn: tl.ReadmeIn, CloneURL: cloneURL(req, repo), } if tl.Path != "" { var acc string for _, seg := range strings.Split(tl.Path, "/") { acc = strings.TrimPrefix(acc+"/"+seg, "/") data.Crumbs = append(data.Crumbs, crumb{Name: seg, Path: acc}) } } if bo, err := invoke(req.Context(), r, "branch.list", url.Values{"repo": {repo}}); err == nil { var bl struct { Branches []branchRow `json:"branches"` } remarshal(bo, &bl) // tcl has 1,855 branches and sqlite 1,747. Rendering every // one is both an unusable control and 90% of the page's // weight — 239KB of <option>. Show the recently active ones, // which branch.list already returns first. const shown = 25 data.Branches = bl.Branches if len(bl.Branches) > shown { data.Branches = bl.Branches[:shown] data.MoreBranches = len(bl.Branches) - shown // The branch being VIEWED must appear even when it is // not recently active, or the selector silently shows // the wrong thing for anyone browsing older work. found := false for _, b := range data.Branches { if b.Tip == version { found = true break } } if !found { for _, b := range bl.Branches { if b.Tip == version { data.Branches = append([]branchRow{b}, data.Branches...) break } } } } } // An empty repository is not a broken one — it is waiting for a // first push. Showing a bare file table says nothing about what // to do next, which is the only thing its reader wants to know. data.RepoDescription, data.RepoPrivate = settingsFor(req, repo) if len(data.Tree) == 0 && len(data.Crumbs) == 0 { render(r, w, req, quickStartPage, pageData{ Title: repo, Repo: repo, CloneURL: cloneURL(req, repo), RepoDescription: data.RepoDescription, RepoPrivate: data.RepoPrivate, }) return } // The crumb trail is this page's heading. data.HideH1 = true render(r, w, req, treePage, data) } mux.HandleFunc("GET /r/{repo}", treeHandler) mux.HandleFunc("GET /r/{repo}/tree", treeHandler) mux.HandleFunc("GET /r/{repo}/tree/{version}/{path...}", treeHandler) mux.HandleFunc("GET /r/{repo}/history/{version}/{path...}", func(w http.ResponseWriter, req *http.Request) { repo := req.PathValue("repo") version, path := req.PathValue("version"), req.PathValue("path") out, err := invoke(req.Context(), r, "file.history", url.Values{ "repo": {repo}, "version": {version}, "path": {path}, }) if err != nil { fail(r, w, req, err) return } var fh struct { Changes []fileChange `json:"changes"` } remarshal(out, &fh) render(r, w, req, historyPage, pageData{ Title: path, Repo: repo, TreeVersion: version, HistoryPath: path, History: fh.Changes, }) }) mux.HandleFunc("GET /r/{repo}/ci", func(w http.ResponseWriter, req *http.Request) { repo := req.PathValue("repo") out, err := invoke(req.Context(), r, "ci.list", url.Values{"repo": {repo}}) if err != nil { fail(r, w, req, err) return } var cl struct { Runs []ciRunRow `json:"runs"` } remarshal(out, &cl) data := pageData{Title: "ci", Repo: repo, CIRuns: cl.Runs} if n != nil { // A run's status changes while nobody is clicking, so the // page follows it. data-init sits on a WRAPPER, outside the // element the stream patches — a patch morphs its target to // match the fragment, and the fragment has no data-init. data.Live = true data.LiveAttr = template.HTMLAttr(`data-init="@get('/r/` + repo + `/ci.sse')"`) } render(r, w, req, ciPage, data) }) mux.HandleFunc("GET /r/{repo}/ci.sse", func(w http.ResponseWriter, req *http.Request) { if n == nil { http.NotFound(w, req) return } repo := req.PathValue("repo") liveCI(r, repo, w, req) }) mux.HandleFunc("GET /r/{repo}/timeline", func(w http.ResponseWriter, req *http.Request) { repo := req.PathValue("repo") vals := req.URL.Query() vals.Set("repo", repo) // The page's Max control uses fossil's n= name; the op's field // is limit. limit := 50 if n := vals.Get("n"); n != "" { vals.Set("limit", n) if v, err := strconv.Atoi(n); err == nil && v > 0 && v <= 200 { limit = v } } out, err := invoke(req.Context(), r, "timeline.list", vals) if err != nil { fail(r, w, req, err) return } var tl struct { Entries []timelineEntry `json:"entries"` Next string `json:"next"` } remarshal(out, &tl) data := pageData{ Title: "timeline", Repo: repo, Days: buildTimeline(repo, tl.Entries), Next: tl.Next, Branch: req.URL.Query().Get("branch"), Y: req.URL.Query().Get("y"), Limit: limit, LimitChoices: []int{20, 50, 100, 200}, } // Live updates enhance the first page only; history pages are // static by nature. Without the notifier (or JS) the page is // simply itself — Principle 3's refresh-fallback. if n != nil && req.URL.Query().Get("before") == "" { data.Live = true // Built as a trusted attribute: html/template would JS-escape // data-on-* values (slashes become \/). data.LiveAttr = template.HTMLAttr( `data-init="@get('/r/` + repo + `/timeline.sse?` + template.HTMLEscapeString(req.URL.RawQuery) + `')"`) } render(r, w, req, timelinePage, data) }) mux.HandleFunc("GET /r/{repo}/timeline.sse", func(w http.ResponseWriter, req *http.Request) { if n == nil { http.NotFound(w, req) return } liveTimeline(r, n, w, req) }) mux.HandleFunc("GET /r/{repo}/wiki", func(w http.ResponseWriter, req *http.Request) { repo := req.PathValue("repo") out, err := invoke(req.Context(), r, "wiki.list", url.Values{"repo": {repo}}) if err != nil { fail(r, w, req, err) return } var wl struct { Pages []wikiTitle `json:"pages"` } remarshal(out, &wl) render(r, w, req, wikiListPage, pageData{Title: "wiki", Repo: repo, WikiPages: wl.Pages}) }) mux.HandleFunc("GET /r/{repo}/chat", func(w http.ResponseWriter, req *http.Request) { repo := req.PathValue("repo") out, err := invoke(req.Context(), r, "chat.history", url.Values{"repo": {repo}}) if err != nil { fail(r, w, req, err) return } var ch struct { Messages []ChatMessage `json:"messages"` } remarshal(out, &ch) data := pageData{Title: "chat", Repo: repo, ChatMsgs: ch.Messages} if live != nil { data.Live = true data.LiveAttr = template.HTMLAttr(`data-init="@get('/r/` + repo + `/chat.sse')"`) } render(r, w, req, chatPage, data) }) mux.HandleFunc("POST /r/{repo}/chat", func(w http.ResponseWriter, req *http.Request) { repo := req.PathValue("repo") req, ok := gate(verify, sessions, repo, w, req) if !ok { return } if _, err := invoke(req.Context(), r, "chat.post", url.Values{"repo": {repo}, "text": {req.FormValue("text")}}); err != nil { fail(r, w, req, err) return } http.Redirect(w, req, "/r/"+repo+"/chat", http.StatusSeeOther) }) mux.HandleFunc("GET /r/{repo}/chat.sse", func(w http.ResponseWriter, req *http.Request) { if live == nil { http.NotFound(w, req) return } liveChat(live, req.PathValue("repo"), w, req) }) mux.HandleFunc("GET /r/{repo}/forum", func(w http.ResponseWriter, req *http.Request) { repo := req.PathValue("repo") out, err := invoke(req.Context(), r, "forum.list", url.Values{"repo": {repo}}) if err != nil { fail(r, w, req, err) return } var fl struct { Threads []forumThreadRow `json:"threads"` } remarshal(out, &fl) render(r, w, req, forumListPage, pageData{Title: "forum", Repo: repo, Forum: fl.Threads}) }) mux.HandleFunc("GET /r/{repo}/forum-new", func(w http.ResponseWriter, req *http.Request) { repo := req.PathValue("repo") if _, ok := gate(verify, sessions, repo, w, req); !ok { return } render(r, w, req, forumNewPage, pageData{Title: "new thread", Repo: repo}) }) mux.HandleFunc("POST /r/{repo}/forum-new", func(w http.ResponseWriter, req *http.Request) { repo := req.PathValue("repo") req, ok := gate(verify, sessions, repo, w, req) if !ok { return } out, err := invoke(req.Context(), r, "forum.post", url.Values{ "repo": {repo}, "title": {req.FormValue("title")}, "body": {req.FormValue("body")}, }) if err != nil { fail(r, w, req, err) return } var fp struct { Hash string `json:"hash"` } remarshal(out, &fp) http.Redirect(w, req, "/r/"+repo+"/forum/"+fp.Hash[:min(16, len(fp.Hash))], http.StatusSeeOther) }) mux.HandleFunc("POST /r/{repo}/forum/{root}/reply", func(w http.ResponseWriter, req *http.Request) { repo, root := req.PathValue("repo"), req.PathValue("root") req, ok := gate(verify, sessions, repo, w, req) if !ok { return } if _, err := invoke(req.Context(), r, "forum.reply", url.Values{ "repo": {repo}, "root": {root}, "body": {req.FormValue("body")}, }); err != nil { fail(r, w, req, err) return } http.Redirect(w, req, "/r/"+repo+"/forum/"+root, http.StatusSeeOther) }) mux.HandleFunc("GET /r/{repo}/technote-new", func(w http.ResponseWriter, req *http.Request) { repo := req.PathValue("repo") if _, ok := gate(verify, sessions, repo, w, req); !ok { return } render(r, w, req, technoteEditPage, pageData{Title: "new technote", Repo: repo}) }) mux.HandleFunc("POST /r/{repo}/technote-new", func(w http.ResponseWriter, req *http.Request) { repo := req.PathValue("repo") req, ok := gate(verify, sessions, repo, w, req) if !ok { return } out, err := invoke(req.Context(), r, "technote.add", url.Values{ "repo": {repo}, "body": {req.FormValue("body")}, "comment": {req.FormValue("comment")}, }) if err != nil { fail(r, w, req, err) return } var ta struct { Event string `json:"event"` } remarshal(out, &ta) http.Redirect(w, req, "/r/"+repo+"/technote/"+ta.Event[:min(16, len(ta.Event))], http.StatusSeeOther) }) mux.HandleFunc("GET /r/{repo}/technote/{event}/edit", func(w http.ResponseWriter, req *http.Request) { repo, event := req.PathValue("repo"), req.PathValue("event") if _, ok := gate(verify, sessions, repo, w, req); !ok { return } out, err := invoke(req.Context(), r, "technote.get", url.Values{"repo": {repo}, "event": {event}}) if err != nil { fail(r, w, req, err) return } var tg struct { Event string `json:"event"` Body string `json:"body"` Comment string `json:"comment"` } remarshal(out, &tg) render(r, w, req, technoteEditPage, pageData{ Title: "edit technote", Repo: repo, Technote: technoteDetail{Event: tg.Event, Comment: tg.Comment}, EditSource: tg.Body, }) }) mux.HandleFunc("POST /r/{repo}/technote/{event}/edit", func(w http.ResponseWriter, req *http.Request) { repo, event := req.PathValue("repo"), req.PathValue("event") req, ok := gate(verify, sessions, repo, w, req) if !ok { return } if _, err := invoke(req.Context(), r, "technote.add", url.Values{ "repo": {repo}, "body": {req.FormValue("body")}, "event": {event}, "comment": {req.FormValue("comment")}, }); err != nil { fail(r, w, req, err) return } http.Redirect(w, req, "/r/"+repo+"/technote/"+event, http.StatusSeeOther) }) mux.HandleFunc("GET /r/{repo}/forum/{root}", func(w http.ResponseWriter, req *http.Request) { repo := req.PathValue("repo") out, err := invoke(req.Context(), r, "forum.get", url.Values{"repo": {repo}, "root": {req.PathValue("root")}}) if err != nil { fail(r, w, req, err) return } var fg struct { Title string `json:"title"` Posts []forumPostRow `json:"posts"` } remarshal(out, &fg) title := fg.Title if title == "" { title = "thread" } render(r, w, req, forumThreadPage, pageData{ Title: title, Repo: repo, ForumPosts: fg.Posts, ForumRoot: req.PathValue("root"), }) }) mux.HandleFunc("GET /r/{repo}/technote", func(w http.ResponseWriter, req *http.Request) { repo := req.PathValue("repo") out, err := invoke(req.Context(), r, "technote.list", url.Values{"repo": {repo}}) if err != nil { fail(r, w, req, err) return } var tl struct { Notes []technoteRow `json:"notes"` } remarshal(out, &tl) render(r, w, req, technoteListPage, pageData{Title: "technotes", Repo: repo, Technotes: tl.Notes}) }) mux.HandleFunc("GET /r/{repo}/technote/{event}", func(w http.ResponseWriter, req *http.Request) { repo := req.PathValue("repo") out, err := invoke(req.Context(), r, "technote.get", url.Values{"repo": {repo}, "event": {req.PathValue("event")}}) if err != nil { fail(r, w, req, err) return } var tg struct { Event string `json:"event"` Date string `json:"date"` User string `json:"user"` HTML string `json:"html"` } remarshal(out, &tg) render(r, w, req, technotePage, pageData{ Title: "technote " + tg.Date, Repo: repo, Technote: technoteDetail{Event: tg.Event, Date: tg.Date, User: tg.User}, WikiHTML: template.HTML(tg.HTML), }) }) mux.HandleFunc("GET /r/{repo}/ticket", func(w http.ResponseWriter, req *http.Request) { repo := req.PathValue("repo") out, err := invoke(req.Context(), r, "ticket.list", url.Values{"repo": {repo}, "before": {req.URL.Query().Get("before")}}) if err != nil { fail(r, w, req, err) return } var tl struct { Tickets []ticketSummary `json:"tickets"` Next string `json:"next"` } remarshal(out, &tl) render(r, w, req, ticketListPage, pageData{ Title: "tickets", Repo: repo, Tickets: tl.Tickets, Next: tl.Next, }) }) mux.HandleFunc("GET /r/{repo}/ticket/{id}", func(w http.ResponseWriter, req *http.Request) { repo := req.PathValue("repo") out, err := invoke(req.Context(), r, "ticket.get", url.Values{"repo": {repo}, "id": {req.PathValue("id")}}) if err != nil { fail(r, w, req, err) return } var td struct { Ticket ticketDetail `json:"ticket"` Attachments []attachmentRow `json:"attachments"` DescriptionHTML string `json:"descriptionHtml"` Comments []ticketComment `json:"comments"` } remarshal(out, &td) // A ticket is known by its title, not its hash; fall back to the // id only when the tracker recorded no title. title := td.Ticket.Title() if title == "" { title = "ticket " + td.Ticket.ID[:10] } render(r, w, req, ticketPage, pageData{ Title: title, Repo: repo, Ticket: td.Ticket, Attachments: td.Attachments, DescriptionHTML: template.HTML(td.DescriptionHTML), TicketComments: td.Comments, }) }) mux.HandleFunc("GET /r/{repo}/search", func(w http.ResponseWriter, req *http.Request) { repo := req.PathValue("repo") q := req.URL.Query().Get("q") data := pageData{Title: "search", Repo: repo, Query: q} if q != "" { vals := req.URL.Query() vals.Set("repo", repo) out, err := invoke(req.Context(), r, "search.query", vals) if err != nil { fail(r, w, req, err) return } var sr struct { Results []struct { Kind string `json:"kind"` Ref string `json:"ref"` Label string `json:"label"` Snippet string `json:"snippet"` } `json:"results"` } remarshal(out, &sr) for _, res := range sr.Results { data.Results = append(data.Results, hitOf(repo, res.Kind, res.Ref, res.Label, res.Snippet)) } } render(r, w, req, searchPage, data) }) mux.HandleFunc("GET /r/{repo}/checkin/{hash}/diff", func(w http.ResponseWriter, req *http.Request) { repo := req.PathValue("repo") out, err := invoke(req.Context(), r, "checkin.diff", url.Values{"repo": {repo}, "hash": {req.PathValue("hash")}}) if err != nil { fail(r, w, req, err) return } var dd diffDetail remarshal(out, &dd) render(r, w, req, diffPage, pageData{ Title: "diff " + dd.To[:12], Repo: repo, Diff: dd, DiffLines: classifyDiff(dd.Unified), }) }) // Archive download. Same posture as an attachment: a download, never // an inline render, with the stored content type discarded — a // tarball is bytes a reader saves, not a document a browser should // try to interpret. mux.HandleFunc("GET /r/{repo}/archive/{version}", func(w http.ResponseWriter, req *http.Request) { repo := req.PathValue("repo") out, err := invoke(req.Context(), r, "archive.get", url.Values{ "repo": {repo}, "version": {req.PathValue("version")}, }) if err != nil { fail(r, w, req, err) return } var ad struct { Name string `json:"name"` Content string `json:"content"` } remarshal(out, &ad) raw, err := base64.StdEncoding.DecodeString(ad.Content) if err != nil { fail(r, w, req, err) return } w.Header().Set("Content-Type", "application/gzip") w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("Content-Disposition", "attachment; filename*=UTF-8''"+url.PathEscape(ad.Name)) w.Header().Set("Content-Length", strconv.Itoa(len(raw))) w.Write(raw) }) // Attachment download. This is the only route that hands a reader // bytes somebody else uploaded, so it is deliberately hostile to its // own payload: always a download, never an inline render; the stored // content type is discarded in favour of octet-stream; nosniff stops // the browser guessing its way back to HTML; and the CSP denies // everything in case a viewer renders it anyway. mux.HandleFunc("GET /r/{repo}/attachment/{hash}", func(w http.ResponseWriter, req *http.Request) { repo := req.PathValue("repo") out, err := invoke(req.Context(), r, "attachment.get", url.Values{ "repo": {repo}, "hash": {req.PathValue("hash")}, }) if err != nil { fail(r, w, req, err) return } var ad struct { Name string `json:"name"` Content string `json:"content"` } remarshal(out, &ad) raw, err := base64.StdEncoding.DecodeString(ad.Content) if err != nil { fail(r, w, req, err) return } w.Header().Set("Content-Type", "application/octet-stream") w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("Content-Security-Policy", "default-src 'none'; sandbox") w.Header().Set("Content-Disposition", "attachment; filename*=UTF-8''"+url.PathEscape(ad.Name)) w.Header().Set("Content-Length", strconv.Itoa(len(raw))) w.Write(raw) }) mux.HandleFunc("GET /r/{repo}/file/{hash}/{path...}", func(w http.ResponseWriter, req *http.Request) { repo := req.PathValue("repo") out, err := invoke(req.Context(), r, "file.get", url.Values{ "repo": {repo}, "hash": {req.PathValue("hash")}, "path": {req.PathValue("path")}, }) if err != nil { fail(r, w, req, err) return } var fd fileDetail remarshal(out, &fd) render(r, w, req, filePage, pageData{ Title: fd.Path, Repo: repo, File: fd, FileHTML: template.HTML(fd.HTML), }) }) mux.HandleFunc("GET /r/{repo}/checkin/{hash}", func(w http.ResponseWriter, req *http.Request) { repo := req.PathValue("repo") out, err := invoke(req.Context(), r, "checkin.get", url.Values{"repo": {repo}, "hash": {req.PathValue("hash")}}) if err != nil { fail(r, w, req, err) return } var cd checkinDetail remarshal(out, &cd) render(r, w, req, checkinPage, pageData{ Title: "check-in " + cd.Hash[:12], Repo: repo, Checkin: cd, CIBadge: ciBadge(cd.CI), }) }) mux.HandleFunc("GET /r/{repo}/wiki/{name}", func(w http.ResponseWriter, req *http.Request) { repo := req.PathValue("repo") rev := req.URL.Query().Get("rev") out, err := invoke(req.Context(), r, "wiki.get", url.Values{"repo": {repo}, "name": {req.PathValue("name")}, "rev": {rev}}) if err != nil { fail(r, w, req, err) return } var wd wikiDetail remarshal(out, &wd) render(r, w, req, wikiPage, pageData{ Title: wd.Title, Repo: repo, Wiki: wd, WikiRev: rev, WikiHTML: template.HTML(wd.HTML), }) }) mux.HandleFunc("GET /r/{repo}/wiki/{name}/edit", func(w http.ResponseWriter, req *http.Request) { repo, name := req.PathValue("repo"), req.PathValue("name") if _, ok := gate(verify, sessions, repo, w, req); !ok { return } source := "" if out, err := invoke(req.Context(), r, "wiki.get", url.Values{"repo": {repo}, "name": {name}}); err == nil { var wd struct { Source string `json:"source"` } remarshal(out, &wd) source = wd.Source } render(r, w, req, wikiEditPage, pageData{ Title: "edit " + name, Repo: repo, Wiki: wikiDetail{Title: name}, EditSource: source, }) }) mux.HandleFunc("POST /r/{repo}/wiki/{name}/edit", func(w http.ResponseWriter, req *http.Request) { repo, name := req.PathValue("repo"), req.PathValue("name") req, ok := gate(verify, sessions, repo, w, req) if !ok { return } if _, err := invoke(req.Context(), r, "wiki.save", url.Values{ "repo": {repo}, "name": {name}, "body": {req.FormValue("body")}, }); err != nil { fail(r, w, req, err) return } http.Redirect(w, req, "/r/"+repo+"/wiki/"+url.PathEscape(name), http.StatusSeeOther) }) mux.HandleFunc("GET /r/{repo}/wiki-new", func(w http.ResponseWriter, req *http.Request) { repo := req.PathValue("repo") name := req.URL.Query().Get("name") if name == "" { http.Redirect(w, req, "/r/"+repo+"/wiki", http.StatusSeeOther) return } http.Redirect(w, req, "/r/"+repo+"/wiki/"+url.PathEscape(name)+"/edit", http.StatusSeeOther) }) mux.HandleFunc("GET /r/{repo}/ticket/new", func(w http.ResponseWriter, req *http.Request) { repo := req.PathValue("repo") if _, ok := gate(verify, sessions, repo, w, req); !ok { return } render(r, w, req, ticketNewPage, pageData{Title: "new ticket", Repo: repo}) }) mux.HandleFunc("POST /r/{repo}/ticket/new", func(w http.ResponseWriter, req *http.Request) { repo := req.PathValue("repo") req, ok := gate(verify, sessions, repo, w, req) if !ok { return } out, err := invoke(req.Context(), r, "ticket.new", url.Values{ "repo": {repo}, "title": {req.FormValue("title")}, "status": {req.FormValue("status")}, "comment": {req.FormValue("comment")}, }) if err != nil { fail(r, w, req, err) return } var tn struct { ID string `json:"id"` } remarshal(out, &tn) http.Redirect(w, req, "/r/"+repo+"/ticket/"+tn.ID, http.StatusSeeOther) }) mux.HandleFunc("GET /r/{repo}/ticket/{id}/edit", func(w http.ResponseWriter, req *http.Request) { repo, id := req.PathValue("repo"), req.PathValue("id") if _, ok := gate(verify, sessions, repo, w, req); !ok { return } out, err := invoke(req.Context(), r, "ticket.get", url.Values{"repo": {repo}, "id": {id}}) if err != nil { fail(r, w, req, err) return } var tg struct { Ticket ticketDetail `json:"ticket"` } remarshal(out, &tg) render(r, w, req, ticketEditPage, pageData{ Title: "edit ticket", Repo: repo, Ticket: tg.Ticket, }) }) mux.HandleFunc("POST /r/{repo}/ticket/{id}/edit", func(w http.ResponseWriter, req *http.Request) { repo, id := req.PathValue("repo"), req.PathValue("id") req, ok := gate(verify, sessions, repo, w, req) if !ok { return } if _, err := invoke(req.Context(), r, "ticket.set", url.Values{ "repo": {repo}, "id": {id}, "title": {req.FormValue("title")}, "status": {req.FormValue("status")}, "resolution": {req.FormValue("resolution")}, "comment": {req.FormValue("comment")}, }); err != nil { fail(r, w, req, err) return } http.Redirect(w, req, "/r/"+repo+"/ticket/"+id, http.StatusSeeOther) }) mux.HandleFunc("GET /r/{repo}/wiki/{name}/history", func(w http.ResponseWriter, req *http.Request) { repo := req.PathValue("repo") name := req.PathValue("name") out, err := invoke(req.Context(), r, "wiki.history", url.Values{"repo": {repo}, "name": {name}}) if err != nil { fail(r, w, req, err) return } var wh struct { Title string `json:"title"` Edits []wikiRev `json:"edits"` } remarshal(out, &wh) render(r, w, req, wikiHistoryPage, pageData{ Title: wh.Title + " history", Repo: repo, Wiki: wikiDetail{Title: wh.Title}, WikiRevs: wh.Edits, }) }) // Identity is resolved ONCE per request and carried in the context, // so every page header can show who is signed in without each route // knowing about the session plane. return withTimer(withIdentity(sessions, inbox != nil, mux)) } // liveTimeline streams Datastar element patches: on every change tick // (debounced), the timeline body re-renders through the same op path // the page uses and morphs into #tl. func liveTimeline(r *ops.Registry, n Notifier, w http.ResponseWriter, req *http.Request) { repo := req.PathValue("repo") streamPatches(n, repo, w, req, func() (string, error) { return timelineFragment(r, repo, req.URL.Query()) }) } // liveFleet does the same for the home page: any repo's change re-renders // the fleet table. func liveFleet(r *ops.Registry, n Notifier, w http.ResponseWriter, req *http.Request) { fl, ok := w.(http.Flusher) if !ok { http.Error(w, "streaming unsupported", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-store") fmt.Fprint(w, ": live\n\n") fl.Flush() ticks, cancel := n.Watch("*") defer cancel() // A first index emits events continuously, so the event tick alone // would show progress — but the "indexing" flag clears AFTER the // last event, so the badge would stick until something else // happened. A slow poll alongside the events closes that gap, and // sending only on change keeps an idle fleet silent. poll := time.NewTicker(2 * time.Second) defer poll.Stop() heartbeat := time.NewTicker(20 * time.Second) defer heartbeat.Stop() last := "" repaint := func() bool { frag, err := fleetFragment(r) if err != nil { log.Printf("web: live fleet: %v", err) return true } if frag == last { return true // nothing changed; stay quiet } last = frag fmt.Fprint(w, "event: datastar-patch-elements\n") for _, line := range strings.Split(frag, "\n") { fmt.Fprintf(w, "data: elements %s\n", line) } fmt.Fprint(w, "\n") fl.Flush() return true } repaint() for { select { case <-req.Context().Done(): return case <-heartbeat.C: fmt.Fprint(w, ": ping\n\n") fl.Flush() case <-poll.C: repaint() case _, open := <-ticks: if !open { return } time.Sleep(200 * time.Millisecond) // debounce a burst select { case <-ticks: default: } repaint() } } } // streamPatches is the SSE loop both live pages share: subscribe to one // repo's changes ("*" = the whole fleet), debounce bursts, and send each // repaint as a Datastar element patch that morphs in place. // liveChat streams new chat messages as Datastar append patches into // #chatlog — each message an appended paragraph, so the log grows // without re-rendering history. Without JS the page still shows the // on-load window and posts via the plain form (Principle 3). func liveChat(live ChatLive, repo string, w http.ResponseWriter, req *http.Request) { fl, ok := w.(http.Flusher) if !ok { http.Error(w, "streaming unsupported", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-store") fmt.Fprint(w, ": live\n\n") fl.Flush() msgs, cancel := live.SubscribeChat(repo) defer cancel() heartbeat := time.NewTicker(20 * time.Second) defer heartbeat.Stop() for { select { case <-req.Context().Done(): return case <-heartbeat.C: fmt.Fprint(w, ": ping\n\n") fl.Flush() case m, open := <-msgs: if !open { return } el := `<p class="chatmsg"><b>` + template.HTMLEscapeString(m.User) + `</b> <small class="tl-meta-inline">` + template.HTMLEscapeString(m.At) + `</small><br>` + template.HTMLEscapeString(m.Text) + `</p>` fmt.Fprint(w, "event: datastar-patch-elements\n") fmt.Fprint(w, "data: mode append\n") fmt.Fprint(w, "data: selector #chatlog\n") fmt.Fprintf(w, "data: elements %s\n\n", el) fl.Flush() } } } func streamPatches(n Notifier, repo string, w http.ResponseWriter, req *http.Request, fragment func() (string, error)) { fl, ok := w.(http.Flusher) if !ok { http.Error(w, "streaming unsupported", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-store") fmt.Fprint(w, ": live\n\n") fl.Flush() ticks, cancel := n.Watch(repo) defer cancel() heartbeat := time.NewTicker(20 * time.Second) defer heartbeat.Stop() for { select { case <-req.Context().Done(): return case <-heartbeat.C: fmt.Fprint(w, ": ping\n\n") fl.Flush() case _, open := <-ticks: if !open { return } // Debounce: a burst of artifacts is one repaint. time.Sleep(200 * time.Millisecond) select { case <-ticks: default: } frag, err := fragment() if err != nil { log.Printf("web: live %s: %v", repo, err) continue } // Datastar v1 element patch: morphs the fragment in place. fmt.Fprint(w, "event: datastar-patch-elements\n") for _, line := range strings.Split(frag, "\n") { fmt.Fprintf(w, "data: elements %s\n", line) } fmt.Fprint(w, "\n") fl.Flush() } } } // fleetFragment renders the #fleet body through repo.list — shared by // the page render and the live patches. func fleetFragment(r *ops.Registry) (string, error) { out, err := invoke(context.Background(), r, "repo.list", nil) if err != nil { return "", err } var fl struct { Repos []repoInfo `json:"repos"` } remarshal(out, &fl) var buf bytes.Buffer buf.WriteString(`<div id="fleet">`) if err := fleetPage.ExecuteTemplate(&buf, "fleet-body", pageData{Fleet: fl.Repos}); err != nil { return "", err } buf.WriteString(`</div>`) return buf.String(), nil } // timelineFragment renders the #tl body for the given query — shared by // the page render and the live patches, so both always agree. func timelineFragment(r *ops.Registry, repo string, q url.Values) (string, error) { vals := url.Values{} for k, v := range q { vals[k] = v } vals.Set("repo", repo) if nn := vals.Get("n"); nn != "" { vals.Set("limit", nn) } out, err := invoke(context.Background(), r, "timeline.list", vals) if err != nil { return "", err } var tl struct { Entries []timelineEntry `json:"entries"` Next string `json:"next"` } remarshal(out, &tl) limit := 50 if v, err := strconv.Atoi(vals.Get("limit")); err == nil && v > 0 { limit = v } data := pageData{ Repo: repo, Days: buildTimeline(repo, tl.Entries), Next: tl.Next, Branch: q.Get("branch"), Y: q.Get("y"), Limit: limit, } var buf bytes.Buffer buf.WriteString(`<div id="tl">`) if err := timelinePage.ExecuteTemplate(&buf, "tl-body", data); err != nil { return "", err } buf.WriteString(`</div>`) return buf.String(), nil } // render executes a page template with the version footer served by the // meta.version op — every page reports it (0001 Principle 1). func render(r *ops.Registry, w http.ResponseWriter, req *http.Request, page *template.Template, data pageData) { renderStatus(r, w, req, page, data, http.StatusOK) } func renderStatus(r *ops.Registry, w http.ResponseWriter, req *http.Request, page *template.Template, data pageData, status int) { if id, ok := req.Context().Value(ctxIdentity{}).(identity); ok { data.SignedIn, data.CanSignIn, data.HasInbox = id.user, id.canSignIn, id.hasInbox data.IsAdmin = id.isAdmin } out, err := invoke(req.Context(), r, "meta.version", nil) if err != nil { http.Error(w, "version op failed", http.StatusInternalServerError) return } var v struct { Version string `json:"version"` } remarshal(out, &v) data.Version = v.Version if start, ok := req.Context().Value(ctxStart{}).(time.Time); ok { data.Elapsed = timing.Format(time.Since(start)) } // Read the breakdown LAST: the ops this page invoked have all run by // now, so the accumulator is complete. data.Breakdown = timing.From(req.Context()).Breakdown() // The nav belongs to the layout, not to any one handler, so its // counts and its current-section marker are filled in here rather // than in each of the thirty-odd places that render a repo page. // A counts failure is NOT fatal: the nav degrades to bare labels, // which is what it showed before counts existed. if data.Repo != "" { data.Section = sectionOf(req.URL.Path) if co, err := invoke(req.Context(), r, "repo.counts", url.Values{"repo": {data.Repo}}); err == nil { remarshal(co, &data.Counts) data.HasCounts = true } else { log.Printf("web: counts for %s: %v", data.Repo, err) } } // Render to a buffer first: a template error mid-stream would // otherwise emit a silently truncated page (a bug class this exact // code once shipped) — buffered, it becomes a loud, logged 500. var buf bytes.Buffer if err := page.Execute(&buf, data); err != nil { log.Printf("web: template %s: %v", req.URL.Path, err) http.Error(w, "template error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(status) w.Write(buf.Bytes()) } // fail renders the themed error page for an op failure: not-found gets // the 404 page, everything else logs and gets the 500 page — never // Go's bare plain-text defaults (every page keeps theme, nav, version). // fail renders an op error, mapping the op layer's error kinds the way // the API router already does. Only ErrNotFound was mapped, so every // other kind rendered as 500 — and a 500 tells a client the request // failed, not that it should try again. Two paths depended on the // distinction and lost it: a single-writer lease refusal (0001 §3), // which is explicitly retryable, and a shed from a saturated work lane, // which is retryable by construction. func fail(r *ops.Registry, w http.ResponseWriter, req *http.Request, err error) { switch { case errors.Is(err, ops.ErrNotFound): renderStatus(r, w, req, notFoundPage, pageData{Title: "not found"}, http.StatusNotFound) case errors.Is(err, ops.ErrInvalid): renderStatus(r, w, req, errorPage, pageData{Title: "bad request"}, http.StatusBadRequest) case errors.Is(err, ops.ErrUnavailable): // Retry-After makes the refusal actionable rather than merely // polite: a client that backs off is what keeps a shed from // becoming a retry storm. w.Header().Set("Retry-After", "5") renderStatus(r, w, req, busyPage, pageData{Title: "busy"}, http.StatusServiceUnavailable) default: log.Printf("web: %s: %v", req.URL.Path, err) renderStatus(r, w, req, errorPage, pageData{Title: "error"}, http.StatusInternalServerError) } } // invoke runs an op through its descriptor with form-shaped input — the // same decode path the API router uses, so HTML pages and API calls hit // identical op semantics. Results come back as opaque values; pages pick // the fields they render via remarshal. func invoke(ctx context.Context, r *ops.Registry, name string, form url.Values) (any, error) { d, ok := r.Lookup(name) if !ok { return nil, fmt.Errorf("web: unknown op %q", name) } inPtr := d.NewIn() if err := ops.DecodeValues(form, inPtr); err != nil { return nil, err } in := reflect.ValueOf(inPtr).Elem().Interface() if err := ops.Validate(in); err != nil { return nil, err } return r.Invoke(ctx, name, in) } // remarshal projects an op result onto the fields a page renders — the // JSON round-trip keeps web coupled to op output *shapes* (the API // contract) rather than to concrete Go types in other packages. func remarshal(from any, to any) { b, err := json.Marshal(from) if err == nil { json.Unmarshal(b, to) } } type ctxStart struct{} // withTimer stamps request start so the layout can report render time. // Outermost of the middlewares, so the number covers everything the // server does for the request, not just the part after auth. func withTimer(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { ctx, _ := timing.With(req.Context()) next.ServeHTTP(w, req.WithContext( context.WithValue(ctx, ctxStart{}, time.Now()))) }) } type ctxIdentity struct{} type identity struct { user string canSignIn bool hasInbox bool // isAdmin drives the fleet link in the header. It is a DISPLAY // decision only — /fleet gates itself, so a wrong answer here hides // or shows a link, never grants access. isAdmin bool } func withIdentity(sessions Sessions, hasInbox bool, next http.Handler) http.Handler { if sessions == nil { return next } return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { id := identity{canSignIn: true, hasInbox: hasInbox} if user, ok := sessions.Resolve(cookieToken(req)); ok { id.user = user // Empty repo at Admin is the site-admin question — the same // one /fleet asks. id.isAdmin = sessions.Allow(user, "", ops.Admin) } next.ServeHTTP(w, req.WithContext(context.WithValue(req.Context(), ctxIdentity{}, id))) }) } // safeNext keeps the post-login redirect on this site. An attacker who // can choose ?next= can otherwise land a freshly-signed-in reader on // their own page, which is how a login form becomes an open redirect. // Only a rooted, single-slash path is allowed; anything else goes home. func safeNext(next string) string { if next == "" || !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") { return "/" } if strings.Contains(next, "\n") || strings.Contains(next, "\r") { return "/" } return next } // cloneURL is the address a reader clones from: quarry's own sync // fronting for this repository (0001 §7), on whatever host they reached // us by — a hardcoded base would be wrong for every deployment but one. func cloneURL(req *http.Request, repo string) string { scheme := "http" if req.TLS != nil { scheme = "https" } if p := req.Header.Get("X-Forwarded-Proto"); p != "" { scheme = p } return scheme + "://" + req.Host + "/r/" + repo } // ciFragment renders the CI table for a repo — shared by the page and // the stream, so a patch cannot drift from what a reload would draw. func ciFragment(r *ops.Registry, repo string) (string, error) { out, err := invoke(context.Background(), r, "ci.list", url.Values{"repo": {repo}}) if err != nil { return "", err } var cl struct { Runs []ciRunRow `json:"runs"` } remarshal(out, &cl) var buf bytes.Buffer if err := ciPage.ExecuteTemplate(&buf, "ci-body", pageData{Repo: repo, CIRuns: cl.Runs}); err != nil { return "", err } return `<div id="ci">` + buf.String() + `</div>`, nil } // liveCI streams the CI table. Unlike the timeline, a CI page changes // without any artifact arriving — a run finishes inside dagnats — so // this polls rather than waiting on the event plane, and sends only // when the rendered fragment actually differs. func liveCI(r *ops.Registry, repo string, w http.ResponseWriter, req *http.Request) { fl, ok := w.(http.Flusher) if !ok { http.Error(w, "streaming unsupported", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-store") fmt.Fprint(w, ": live\n\n") fl.Flush() poll := time.NewTicker(3 * time.Second) defer poll.Stop() heartbeat := time.NewTicker(20 * time.Second) defer heartbeat.Stop() last := "" for { select { case <-req.Context().Done(): return case <-heartbeat.C: fmt.Fprint(w, ": ping\n\n") fl.Flush() case <-poll.C: frag, err := ciFragment(r, repo) if err != nil || frag == last { continue } last = frag fmt.Fprint(w, "event: datastar-patch-elements\n") for _, line := range strings.Split(frag, "\n") { fmt.Fprintf(w, "data: elements %s\n", line) } fmt.Fprint(w, "\n") fl.Flush() } } }