Artifact 71860eda04c51a611eea08630b8928ab3378c9b02fb12679a55fc3edbef82814:
- File
internal/web/web.go
— part of check-in
[7bdf7a189b]
at
2026-08-17 00:10:56
on branch trunk
— web: cookie sessions and a login page
Basic auth works and stays for the API, but a browser has no way to log out of it and the credential lives in the browser's keeping. A gated page now redirects to /login instead of raising a Basic prompt, the header names who is signed in and offers sign-out, and Basic remains for scripts.
Session tokens are stored as SHA-256 digests, so the table cannot be read back into working cookies. Cookies are HttpOnly, Path=/, SameSite=Lax — Lax being what stops another origin POSTing quarry's write forms on a reader's behalf — and Secure under TLS.
A password change or an account deletion ends that account's sessions; otherwise 'change your password' would leave the browser already holding one still signed in. Authorization for a cookie runs the same RoleOf lookup the password path uses, so a cookie is never worth more than a password.
safeNext keeps the post-login redirect on this site: a reader is at their most trusting on the hop straight after authenticating, which is exactly when an open redirect pays. (user: dmestas, size: 66175) [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; } } .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}} {{if .SignedIn}}<form class="who" action="/logout" method="post"><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>{{.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 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 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> {{if .Attachments}}<h2>Attachments</h2> <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}} <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 Attachments []attachmentRow 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 CanSignIn bool // sessions are configured, so offer the 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 } 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 } // attachmentRow is one live attachment on a ticket. Fossil keeps the // content as its own artifact, which the download route serves by hash. 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"` 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 // 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 } // 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: HTTP Basic against verify, the // authenticated user carried to the op via context. func gate(verify Verify, sess Sessions, repo string, w http.ResponseWriter, req *http.Request) (*http.Request, bool) { if verify == nil { return req, true } // 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 sess != nil { if user, ok := sess.Resolve(cookieToken(req)); ok { if !sess.Allow(user, repo, ops.Token) { // Signed in, but without write on THIS repository. A // challenge would be a lie — better credentials are not // what is missing. forbid(w, user, repo) return nil, false } return req.WithContext(ops.WithUser(req.Context(), user)), true } } user, pass, _ := req.BasicAuth() if !verify(repo, user, pass, ops.Token) { 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 } func forbid(w http.ResponseWriter, user, repo string) { w.WriteHeader(http.StatusForbidden) 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 } // 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 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) }) } 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, 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"` } remarshal(out, &td) render(r, w, req, ticketPage, pageData{ Title: "ticket " + td.Ticket.ID[:10], Repo: repo, Ticket: td.Ticket, Attachments: td.Attachments, }) }) 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), }) }) // 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, }) }) 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, 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 = id.user, id.canSignIn } 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 } func withIdentity(sessions Sessions, next http.Handler) http.Handler { if sessions == nil { return next } return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { id := identity{canSignIn: true} if user, ok := sessions.Resolve(cookieToken(req)); ok { id.user = user } 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 }