Artifact d12d315d0ceb1587c15a55ebf6369d7172b9f9899b2350c163dface9dd016271:
- File
internal/web/web.go
— part of check-in
[9c7b4cff5b]
at
2026-08-17 18:02:31
on branch trunk
— fleet: mirror upstreams on a schedule
Adds mirror.list/set/delete/run ops (admin), a mirror table in OLTP, and a scheduler that pulls each configured store on its own interval. Pulls run under the store's single-writer lease and re-ingest only when artifacts actually arrived.
zitcli.Pull is the one call that cannot pass -R: zit pull refuses the flag and works by working directory. pullChanged reads zit's result positively and errors on an unrecognized line rather than defaulting to 'nothing changed', which would leave a mirror green while it silently stopped tracking. (user: dmestas, size: 96605) [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/highlight" "craftdesign.group/quarry/internal/ops" ) // 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. var base = template.Must(template.New("base").Funcs(template.FuncMap{ "pathesc": url.PathEscape, }).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; } 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; } } .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="site" href="/">quarry</a> {{if .Repo}}<a href="/r/{{.Repo}}/timeline">{{.Repo}}</a> <nav><a href="/r/{{.Repo}}/tree">files</a> <a href="/r/{{.Repo}}/timeline">timeline</a> <a href="/r/{{.Repo}}/wiki">wiki</a> <a href="/r/{{.Repo}}/ticket">tickets</a> <a href="/r/{{.Repo}}/forum">forum</a> <a href="/r/{{.Repo}}/technote">technotes</a> <a href="/r/{{.Repo}}/ci">ci</a> <a href="/r/{{.Repo}}/chat">chat</a></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> <h1>{{.Title}}</h1> {{block "content" .}}{{end}} </main> <footer>quarry {{.Version}} · <a href="/docs">API</a></footer> </body> </html> `)) var fleetPage = template.Must(template.Must(base.Clone()).Parse(`{{define "fleet-body"}} {{if .Fleet}}<div class="tw"><table> <thead><tr><th scope="col">Repository</th><th scope="col">Check-ins</th><th scope="col">Tip</th></tr></thead> <tbody>{{range .Fleet}} <tr><td><a href="/r/{{.Name}}/timeline">{{.Name}}</a></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}}{{.TipDate}} — {{.TipComment}}{{end}}</td></tr> {{end}}</tbody> </table></div>{{else}}<p>No repositories are being served. Start with <code>quarry serve --repo name=path</code>.</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 {{.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>{{.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"}} <div class="tl-controls"> {{if .Branches}}<form method="get" action="/r/{{.Repo}}/tree"><label>Branch <select name="v" onchange="this.form.submit()">{{range .Branches}} <option value="{{.Tip}}"{{if eq .Tip $.TreeVersion}} selected{{end}}>{{.Name}} · {{printf "%.10s" .Tip}}</option> {{end}}</select></label>{{if .MoreBranches}} <small class="tl-meta-inline">+{{.MoreBranches}} older</small>{{end}} <noscript><button type="submit">go</button></noscript></form>{{end}} <span class="tl-meta-inline">{{.CloneURL}}</span> </div> <p class="tl-meta-inline">clone: <code>zit clone {{.CloneURL}} {{.Repo}}</code> · <a href="/r/{{.Repo}}/archive/{{.TreeVersion}}">download .tar.gz</a></p> <p><a href="/r/{{.Repo}}/tree">{{.Repo}}</a>{{range .Crumbs}} / <a href="/r/{{$.Repo}}/tree/{{$.TreeVersion}}/{{.Path}}">{{.Name}}</a>{{end}}</p> <div class="tw"><table> <thead><tr><th scope="col">Name</th><th scope="col">Size</th><th scope="col">History</th></tr></thead> <tbody>{{range .Tree}} <tr><td>{{if .Dir}}<a href="/r/{{$.Repo}}/tree/{{$.TreeVersion}}/{{.Path}}">{{.Name}}/</a>{{else}}<a href="/r/{{$.Repo}}/file/{{$.TreeVersion}}/{{.Path}}">{{.Name}}</a>{{.Mark}}{{if .Binary}} <small class="tl-meta-inline">binary</small>{{end}}{{end}}</td> <td class="tl-meta-inline">{{if .Dir}}{{.Files}} files{{else}}{{.Human}}{{end}}</td> <td class="tl-meta-inline">{{if not .Dir}}<a href="/r/{{$.Repo}}/history/{{$.TreeVersion}}/{{.Path}}">history</a>{{end}}</td></tr> {{end}}</tbody> </table></div> {{if .Readme}}<h3>{{.ReadmeIn}}</h3> <div class="tkt-body">{{.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>{{.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}}`)) 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 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 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 } // 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"` 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]) } // Mark is the permission marker beside a name, empty for a regular // file. A symlink is called out because its content is a target path, // not the file a reader might assume. func (t treeRow) Mark() string { switch { case t.Link: return "→" case t.Exec: return "*" } return "" } // 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) bool // 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() if !verify(repo, user, pass, level) { 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. // 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) { out, err := invoke(req.Context(), r, "repo.list", nil) if err != nil { fail(r, w, req, err) return } var fl struct { Repos []repoInfo `json:"repos"` } remarshal(out, &fl) data := pageData{Title: "quarry", Fleet: fl.Repos} if n != nil { data.Live = true data.LiveAttr = template.HTMLAttr(`data-init="@get('/fleet.sse')"`) } render(r, w, req, fleetPage, data) }) // 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 { 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 } } } } } 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 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 // 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). func fail(r *ops.Registry, w http.ResponseWriter, req *http.Request, err error) { if errors.Is(err, ops.ErrNotFound) { renderStatus(r, w, req, notFoundPage, pageData{Title: "not found"}, http.StatusNotFound) return } 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 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() } } }