Artifact 7ac99e23e1c49bddb2766d9be7003c3546432edcce5f2013b918b937d8fa239e:
- File
internal/web/web.go
— part of check-in
[bf5ba9d2fc]
at
2026-08-16 17:07:31
on branch trunk
— oltp: accounts and per-repo roles
0001 §8's v2 tables. Accounts are the web/API identity (argon2id verifier, site-admin flag, read/write/admin per repo); the RFC §6.3 shared secret stays the sync credential, since the protocol fixes its derivation and the server must recompute it.
ops.Auth becomes the required role, so the Admin level that was already declared now means something. The gate stays open until the first account exists, mirroring the sync-user policy, so existing deployments and local single-user serving are unaffected. (user: dmestas, size: 56855) [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/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; } } .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}}/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}}/chat">chat</a></nav> <form action="/r/{{.Repo}}/search" method="get"><input type="search" name="q" placeholder="search" aria-label="search"></form> {{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>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>{{.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 ticketPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}} <table> <tbody>{{range .Ticket.Fields}} <tr><th scope="row">{{.Name}}</th><td>{{.Value}}</td></tr> {{end}}</tbody> </table> <p><a href="/r/{{.Repo}}/ticket/{{printf "%.10s" .Ticket.ID}}/edit">edit ticket</a></p> <h2>History</h2> <ul>{{range .Ticket.Changes}} <li>{{.Date}} by {{.User}}: {{range .Fields}}{{.Name}}={{.Value}} {{end}}</li> {{end}}</ul> {{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}}`)) 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">{{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 Forum []forumThreadRow ForumRoot string // reply-form target on the thread page ForumPosts []forumPostRow 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 } 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"` } 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 } 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"` } 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"` Fields []ticketField `json:"fields"` Changes []struct { Date string `json:"date"` User string `json:"user"` Fields []ticketField `json:"fields"` } `json:"changes"` } // 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"` 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 // gate runs the write-page auth check: HTTP Basic against verify, the // authenticated user carried to the op via context. func gate(verify Verify, repo string, w http.ResponseWriter, req *http.Request) (*http.Request, bool) { if verify == nil { return req, true } user, pass, _ := req.BasicAuth() // Every gated HTML page is a write page, so they all ask for the // same capability; the API router is where per-op levels differ. if !verify(repo, user, pass, ops.Token) { 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 } func Handler(r *ops.Registry, n Notifier, live ChatLive, v ...Verify) http.Handler { var verify Verify if len(v) > 0 { verify = v[0] } mux := http.NewServeMux() 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) }) 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) }) 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, 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, 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, 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, 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, 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, repo, w, req) if !ok { return } out, err := invoke(req.Context(), r, "technote.add", url.Values{ "repo": {repo}, "body": {req.FormValue("body")}, }) 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, 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"` } remarshal(out, &tg) render(r, w, req, technoteEditPage, pageData{ Title: "edit technote", Repo: repo, Technote: technoteDetail{Event: tg.Event}, 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, repo, w, req) if !ok { return } if _, err := invoke(req.Context(), r, "technote.add", url.Values{ "repo": {repo}, "body": {req.FormValue("body")}, "event": {event}, }); 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"` } remarshal(out, &td) render(r, w, req, ticketPage, pageData{ Title: "ticket " + td.Ticket.ID[:10], Repo: repo, Ticket: td.Ticket, }) }) 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), }) }) 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, }) }) 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, 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, 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, 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, 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, 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, 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, }) }) return 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) { 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) } }