Artifact fdd1460965f19ab92e1ff6eafd8fa55e83a693b71932f2ea248bb3ab685cefeb:
- File
internal/web/web.go
— part of check-in
[9ac012f7f2]
at
2026-08-17 00:57:10
on branch trunk
— tickets: display them the way fossil models them
The page dumped every folded field into one table, which read as noise and lost most of the ticket. Fossil's model has three parts and quarry was collapsing all of them into one:
- Scalar state (status, priority, resolution…) folds last-write-wins. - A leading + is the APPEND form: 'J +comment more' grows the field rather than replacing it. quarry stored a separate field literally named '+comment' — sqlite's tickets use it 255 times. - icomment belongs to the CHANGE, never to the ticket. Folding it left one comment standing where a conversation was: the busiest tcl ticket showed 1 of its 84.
Per-change plumbing (icomment, mimetype, login, username) no longer appears as ticket state. The page now leads with the title, shows fossil's header order, renders the description and every comment through their own recorded mimetype, and attributes each — including fossil's 'claiming to be' when the typed name differs from the login, which on an open tracker it may.
Verified on tcl 408840ff: 11 clean fields, 84 comments, 5 attachments. (user: dmestas, size: 71897) [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; } } .tkt-title { margin-bottom: 0.3rem; } .tkt-head th { text-align: left; color: var(--muted); font-weight: normal; width: 10rem; } .tkt-body { margin: 0.5rem 0 1rem; } .tkt-comment { border-top: 1px solid var(--muted); padding: 0.6rem 0; } .tkt-comment:first-of-type { border-top: 0; } .tl-badge { border: 1px solid var(--muted); border-radius: 3px; padding: 0 0.25rem; font-size: 0.72rem; } .tl-pill { display: inline-block; border: 1px solid var(--muted); border-radius: 6px; padding: 0.05rem 0.5rem; font-weight: bold; margin: 0.55rem 0 0.25rem; } .tl-controls { color: var(--muted); font-size: 0.9rem; display: flex; gap: 0.8rem; align-items: baseline; margin-bottom: 0.4rem; } pre { overflow-x: auto; background: var(--code-bg); padding: 0.5rem; border-radius: 4px; } pre.diff .add { color: #116329; } pre.diff .del { color: #a40e26; } pre.diff .hunk { color: var(--muted); } pre.diff .head { font-weight: bold; } mark { background: #fff3b8; color: inherit; } @media (prefers-color-scheme: dark) { pre.diff .add { color: #56d364; } pre.diff .del { color: #ff7b72; } mark { background: #5c4d00; } } </style> <style>` + highlight.CSS() + `</style> <script type="module" src="/assets/datastar.js" defer></script> </head> <body> <header> <a class="site" href="/">quarry</a> {{if .Repo}}<a href="/r/{{.Repo}}/timeline">{{.Repo}}</a> <nav><a href="/r/{{.Repo}}/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">{{if $.HasInbox}}<small><a href="/inbox">inbox</a></small> {{end}}<small class="tl-meta-inline">{{.SignedIn}}</small> <button type="submit">sign out</button></form>{{else if .CanSignIn}}<small class="who"><a href="/login">sign in</a></small>{{end}} </header> <main> <h1>{{.Title}}</h1> {{block "content" .}}{{end}} </main> <footer>quarry {{.Version}} · <a href="/docs">API</a></footer> </body> </html> `)) var fleetPage = template.Must(template.Must(base.Clone()).Parse(`{{define "fleet-body"}} {{if .Fleet}}<div class="tw"><table> <thead><tr><th scope="col">Repository</th><th scope="col">Check-ins</th><th scope="col">Tip</th></tr></thead> <tbody>{{range .Fleet}} <tr><td><a href="/r/{{.Name}}/timeline">{{.Name}}</a></td> <td>{{.Checkins}}{{if .Indexing}} <span class="tl-badge">indexing…</span>{{end}}</td> <td>{{if .Indexing}}<small class="tl-meta-inline">first index in progress; counts climb as it folds</small>{{else}}{{.TipDate}} — {{.TipComment}}{{end}}</td></tr> {{end}}</tbody> </table></div>{{else}}<p>No repositories are being served. Start with <code>quarry serve --repo name=path</code>.</p>{{end}} {{end}} {{define "content"}}<div{{if .Live}} {{.LiveAttr}}{{end}}><div id="fleet">{{template "fleet-body" .}}</div></div>{{end}}`)) var wikiListPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}} {{if .WikiPages}}<ul>{{range .WikiPages}} <li><a href="/r/{{$.Repo}}/wiki/{{pathesc .Title}}">{{.Title}}</a> <small>{{.Date}} by {{.User}}</small></li> {{end}}</ul>{{else}}<p>No wiki pages.</p>{{end}} <form method="get" action="/r/{{.Repo}}/wiki-new"><input name="name" placeholder="new page name" required> <button type="submit">Create</button></form> {{end}}`)) var forumListPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}} {{if .Forum}}<ul>{{range .Forum}} <li><a href="/r/{{$.Repo}}/forum/{{printf "%.16s" .Root}}">{{if .Title}}{{.Title}}{{else}}(untitled thread){{end}}</a> <small>{{.Posts}} post{{if ne .Posts 1}}s{{end}} · last {{.Updated}}{{if .User}} · started by {{.User}}{{end}}</small></li> {{end}}</ul>{{else}}<p>No forum threads.</p>{{end}} <p><a href="/r/{{.Repo}}/forum-new">new thread</a></p> {{end}}`)) var forumThreadPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}} <p><a href="/r/{{.Repo}}/forum">← forum</a></p> {{range .ForumPosts}}<article style="border-top:1px solid var(--muted); padding-top:0.5rem; margin-top:0.5rem"> <p><small>{{.Date}} by {{.User}}{{if .Parent}} · in reply to <code>{{printf "%.12s" .Parent}}</code>{{end}} · <code>{{printf "%.12s" .Hash}}</code></small></p> {{.HTML}} </article>{{end}} <form method="post" action="/r/{{.Repo}}/forum/{{.ForumRoot}}/reply" style="margin-top:1rem"> <p><textarea name="body" rows="6" style="width:100%" placeholder="Reply (Markdown)" required></textarea></p> <p><button type="submit">Post reply</button></p> </form> {{end}}`)) var forumNewPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}} <form method="post" action="/r/{{.Repo}}/forum-new"> <p><label>Title<br><input name="title" required style="width:100%"></label></p> <p><label>Post<br><textarea name="body" rows="14" style="width:100%" placeholder="Markdown" required></textarea></label></p> <p><button type="submit">Open thread</button> <a href="/r/{{.Repo}}/forum">cancel</a></p> </form> {{end}}`)) var technoteEditPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}} <form method="post" action="{{if .Technote.Event}}/r/{{.Repo}}/technote/{{printf "%.16s" .Technote.Event}}/edit{{else}}/r/{{.Repo}}/technote-new{{end}}"> <p><label>Title<br><input name="comment" style="width:100%" maxlength="200" value="{{.Technote.Comment}}"></label> <small class="tl-meta-inline">shown in the timeline; a note without one shows its event id</small></p> <p><label>Note<br><textarea name="body" rows="16" style="width:100%" placeholder="Markdown" required>{{.EditSource}}</textarea></label></p> <p><button type="submit">Save</button> <a href="{{if .Technote.Event}}/r/{{.Repo}}/technote/{{printf "%.16s" .Technote.Event}}{{else}}/r/{{.Repo}}/technote{{end}}">cancel</a></p> </form> {{end}}`)) var technoteListPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}} {{if .Technotes}}<ul>{{range .Technotes}} <li><a href="/r/{{$.Repo}}/technote/{{printf "%.16s" .Event}}">{{if .Comment}}{{.Comment}}{{else}}{{.Date}}{{end}}</a> <small>{{.Date}} by {{.User}}</small></li> {{end}}</ul>{{else}}<p>No technotes.</p>{{end}} <p><a href="/r/{{.Repo}}/technote-new">new technote</a></p> {{end}}`)) var technotePage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}} <article>{{.WikiHTML}}</article> <footer><small>technote {{.Technote.Date}} by {{.Technote.User}} · <a href="/r/{{.Repo}}/technote/{{printf "%.16s" .Technote.Event}}/edit">edit</a></small></footer> {{end}}`)) var chatPage = template.Must(template.Must(base.Clone()).Parse(`{{define "chat-log"}}<div id="chatlog" class="chatlog"> {{range .ChatMsgs}}<p class="chatmsg"><b>{{.User}}</b> <small class="tl-meta-inline">{{.At}}</small><br>{{.Text}}</p> {{end}}</div>{{end}} {{define "content"}} <style>.chatlog{max-height:60vh;overflow-y:auto;border:1px solid var(--muted);border-radius:6px;padding:0.5rem;margin-bottom:0.5rem} .chatmsg{margin:0.3rem 0}.chatform{display:flex;gap:0.5rem}.chatform input[name=text]{flex:1}</style> <div{{if .Live}} {{.LiveAttr}}{{end}}>{{template "chat-log" .}}</div> <form class="chatform" method="post" action="/r/{{.Repo}}/chat"> <input name="text" placeholder="message" maxlength="4096" required autocomplete="off"> <button type="submit">Send</button> </form> <p><small class="tl-meta-inline">Messages persist for 7 days; durable forum-thread archival lands with zit forum verbs.</small></p> {{end}}`)) var docsPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}} <article>{{.WikiHTML}}</article> {{end}}`)) var wikiEditPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}} <form method="post" action="/r/{{.Repo}}/wiki/{{pathesc .Wiki.Title}}/edit"> <p><textarea name="body" rows="20" style="width:100%" placeholder="Markdown">{{.EditSource}}</textarea></p> <p><button type="submit">Save</button> <a href="/r/{{.Repo}}/wiki/{{pathesc .Wiki.Title}}">cancel</a></p> </form> {{end}}`)) var ticketNewPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}} <form method="post" action="/r/{{.Repo}}/ticket/new"> <p><label>Title<br><input name="title" required style="width:100%"></label></p> <p><label>Status<br><input name="status" value="Open"></label></p> <p><label>Comment<br><textarea name="comment" rows="8" style="width:100%"></textarea></label></p> <p><button type="submit">Open ticket</button></p> </form> {{end}}`)) var ticketEditPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}} <form method="post" action="/r/{{.Repo}}/ticket/{{printf "%.10s" .Ticket.ID}}/edit"> <p><label>Title<br><input name="title" value="{{.Ticket.Field "title"}}" style="width:100%"></label></p> <p><label>Status<br><input name="status" value="{{.Ticket.Field "status"}}"></label></p> <p><label>Resolution<br><input name="resolution" value="{{.Ticket.Field "resolution"}}"></label></p> <p><label>Comment<br><textarea name="comment" rows="6" style="width:100%" placeholder="add a comment"></textarea></label></p> <p><button type="submit">Save change</button> <a href="/r/{{.Repo}}/ticket/{{printf "%.10s" .Ticket.ID}}">cancel</a></p> </form> {{end}}`)) var checkinPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}} <p>{{.Checkin.Comment}}</p> <p><small>{{.Checkin.Date}} by {{.Checkin.User}} on <a href="/r/{{.Repo}}/timeline?branch={{.Checkin.Branch}}">{{.Checkin.Branch}}</a> — <code>{{.Checkin.Hash}}</code></small></p> {{if .Checkin.Parents}}<p>parent{{if gt (len .Checkin.Parents) 1}}s{{end}}: {{range .Checkin.Parents}} <a href="/r/{{$.Repo}}/checkin/{{.}}"><code>{{printf "%.12s" .}}</code></a>{{end}}</p>{{end}} <p><a href="/r/{{.Repo}}/checkin/{{.Checkin.Hash}}/diff">view diff</a></p> {{if .Checkin.Files}}<div class="tw"><table> <thead><tr><th scope="col">File</th><th scope="col">Blob</th></tr></thead> <tbody>{{range .Checkin.Files}} <tr><td><a href="/r/{{$.Repo}}/file/{{$.Checkin.Hash}}/{{.Name}}">{{.Name}}</a></td><td><code>{{printf "%.12s" .Blob}}</code></td></tr> {{end}}</tbody> </table></div>{{end}} {{end}}`)) var ticketListPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}} {{if .Tickets}}<div class="tw"><table> <thead><tr><th scope="col">Ticket</th><th scope="col">Status</th><th scope="col">Title</th><th scope="col">Updated</th></tr></thead> <tbody>{{range .Tickets}} <tr><td><a href="/r/{{$.Repo}}/ticket/{{.ID}}"><code>{{printf "%.10s" .ID}}</code></a></td><td>{{.Status}}</td><td>{{.Title}}</td><td>{{.Updated}}</td></tr> {{end}}</tbody> </table></div>{{else}}<p>No tickets.</p>{{end}} {{if .Next}}<p><a href="/r/{{.Repo}}/ticket?before={{.Next}}">older →</a></p>{{end}} <p><a href="/r/{{.Repo}}/ticket/new">new ticket</a></p> {{end}}`)) var inboxPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}} {{if .Error}}<p><strong>{{.Error}}</strong></p>{{end}} {{if .Inbox}}<ul>{{range .Inbox}} <li>{{if not .Read}}<strong>{{.Subject}}</strong>{{else}}{{.Subject}}{{end}} <small class="tl-meta-inline">{{.CreatedAt}}</small> {{if .Content}}<br><small>{{.Content}}</small>{{end}}</li> {{end}}</ul>{{else if not .Error}}<p>No notifications.</p>{{end}} {{end}}`)) var loginPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}} {{if .Error}}<p><strong>{{.Error}}</strong></p>{{end}} <form method="post" action="/login"> <input type="hidden" name="next" value="{{.LoginNext}}"> <p><label>Account<br><input name="user" autocomplete="username" required autofocus></label></p> <p><label>Password<br><input name="password" type="password" autocomplete="current-password" required></label></p> <p><button type="submit">Sign in</button></p> </form> {{end}}`)) var ticketPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}} {{if .Ticket.Title}}<h2 class="tkt-title">{{.Ticket.Title}}</h2>{{end}} <div class="tw"><table class="tkt-head"> <tbody>{{range .Ticket.Header}} <tr><th scope="row">{{.Name}}</th><td>{{.Value}}</td></tr> {{end}}{{range .Ticket.Extra}} <tr><th scope="row">{{.Name}}</th><td>{{.Value}}</td></tr> {{end}} <tr><th scope="row">created</th><td>{{.Ticket.Created}}</td></tr> <tr><th scope="row">last change</th><td>{{.Ticket.Updated}}</td></tr> <tr><th scope="row">ticket</th><td><code>{{printf "%.20s" .Ticket.ID}}</code></td></tr> </tbody> </table></div> <p><a href="/r/{{.Repo}}/ticket/{{printf "%.10s" .Ticket.ID}}/edit">edit ticket</a></p> {{if .DescriptionHTML}}<h3>Description</h3> <div class="tkt-body">{{.DescriptionHTML}}</div>{{end}} {{if .Attachments}}<h3>Attachments</h3> <ul>{{range .Attachments}} <li><a href="/r/{{$.Repo}}/attachment/{{.Src}}">{{.Name}}</a> <small class="tl-meta-inline">{{.Date}} by {{.User}}</small></li> {{end}}</ul>{{end}} {{if .TicketComments}}<h3>Comments ({{len .TicketComments}})</h3> {{range .TicketComments}}<div class="tkt-comment"> <div class="tl-meta-inline">{{.Login}}{{if .DifferentUsername}} (claiming to be {{.Username}}){{end}} · {{.Date}}</div> {{.HTML}} </div> {{end}}{{end}} {{end}}`)) var filePage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}} <p><small>at <a href="/r/{{.Repo}}/checkin/{{.File.Hash}}"><code>{{printf "%.12s" .File.Hash}}</code></a> — {{.File.Size}} bytes</small></p> {{if .File.Binary}}<p>Binary file.</p>{{else if .FileHTML}}{{.FileHTML}}{{else}}<pre>{{.File.Content}}</pre>{{end}} {{end}}`)) 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 // DescriptionHTML and TicketComments are pre-rendered by the op; the // sanitizer already ran, which is why they are template.HTML here. DescriptionHTML template.HTML TicketComments []ticketComment Forum []forumThreadRow ForumRoot string // reply-form target on the thread page ForumPosts []forumPostRow LoginNext string // post-login redirect target (Next is the page cursor) SignedIn string // the signed-in account, for the header Inbox []InboxMessage HasInbox bool // an inbox is configured, so link to it CanSignIn bool // sessions are configured, so offer the link 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"` Created string `json:"created"` Updated string `json:"updated"` Fields []ticketField `json:"fields"` Changes []struct { Date string `json:"date"` User string `json:"user"` Fields []ticketField `json:"fields"` } `json:"changes"` } // ticketComment is one rendered discussion entry. type ticketComment struct { Date string `json:"date"` Login string `json:"login"` Username string `json:"username"` HTML template.HTML `json:"html"` } // DifferentUsername reports whether the typed name disagrees with the // authenticated one. Fossil surfaces that ("claiming to be …") because // on an open tracker the two need not match, and hiding the difference // would attribute words to the wrong person. func (c ticketComment) DifferentUsername() bool { return c.Username != "" && c.Username != c.Login } // Title is the ticket's headline, promoted out of the field table the // way fossil's view does — a ticket is known by its title, not its hash. func (t ticketDetail) Title() string { return t.Field("title") } // Header is the ordered subset fossil shows above the description. The // order is fossil's own (status/type, severity/priority, // subsystem/resolution), and unset fields are dropped rather than // rendered as blank rows. func (t ticketDetail) Header() []ticketField { order := []string{"status", "type", "severity", "priority", "subsystem", "resolution", "assignee", "foundin", "submitter", "closer"} var out []ticketField for _, name := range order { if v := t.Field(name); v != "" { out = append(out, ticketField{Name: name, Value: v}) } } return out } // Extra is everything else a repository's ticket schema carries — trackers // define their own columns, so a fixed list would hide them. func (t ticketDetail) Extra() []ticketField { known := map[string]bool{"title": true, "status": true, "type": true, "severity": true, "priority": true, "subsystem": true, "resolution": true, "assignee": true, "foundin": true, "submitter": true, "closer": true} var out []ticketField for _, f := range t.Fields { if !known[f.Name] && f.Value != "" { out = append(out, f) } } return out } // Field folds the current value of one named field (for form prefill). func (t ticketDetail) Field(name string) string { for _, f := range t.Fields { if f.Name == name { return f.Value } } return "" } type checkinDetail struct { Hash string `json:"hash"` Branch string `json:"branch"` Date string `json:"date"` User string `json:"user"` Comment string `json:"comment"` Parents []string `json:"parents"` Files []struct { Blob string `json:"blob"` Name string `json:"name"` } `json:"files"` } type fileDetail struct { Hash string `json:"hash"` Path string `json:"path"` Size int `json:"size"` Binary bool `json:"binary"` Content string `json:"content"` HTML string `json:"html"` } type diffDetail struct { From string `json:"from"` To string `json:"to"` Note string `json:"note"` Files []struct { Status string `json:"status"` Name string `json:"name"` } `json:"files"` Unified string `json:"unified"` } type diffLine struct { Class string Text string } // classifyDiff maps unified-diff lines to display classes; the template // escapes Text, so content stays inert. func classifyDiff(unified string) []diffLine { if unified == "" { return nil } var out []diffLine for _, line := range strings.Split(strings.TrimSuffix(unified, "\n"), "\n") { class := "ctx" switch { case strings.HasPrefix(line, "+++"), strings.HasPrefix(line, "---"): class = "head" case strings.HasPrefix(line, "@@"): class = "hunk" case strings.HasPrefix(line, "+"): class = "add" case strings.HasPrefix(line, "-"): class = "del" } out = append(out, diffLine{Class: class, Text: line}) } return out } type searchHit struct { Kind string Label string URL string Snippet template.HTML } // hitOf turns one search result into its page link. The label is the // human-readable name the op provides (ticket title, wiki name, // check-in comment) with the ref as fallback; the snippet's ⟦…⟧ match // markers become <mark> AFTER escaping, so content stays inert. func hitOf(repo, kind, ref, label, snippet string) searchHit { h := searchHit{Kind: kind, Label: label} prefix := "/r/" + repo switch kind { case "checkin": h.URL = prefix + "/checkin/" + ref if h.Label == "" { h.Label = ref[:min(12, len(ref))] } case "wiki": h.URL = prefix + "/wiki/" + url.PathEscape(ref) if h.Label == "" { h.Label = ref } case "ticket": h.URL = prefix + "/ticket/" + ref if h.Label == "" { h.Label = ref[:min(10, len(ref))] } } esc := template.HTMLEscapeString(snippet) esc = strings.ReplaceAll(esc, "⟦", "<mark>") esc = strings.ReplaceAll(esc, "⟧", "</mark>") h.Snippet = template.HTML(esc) return h } // Handler mounts every HTML page over the registry. n may be nil (no // live updates; pages are unchanged otherwise). // Verify checks one repo credential pair against a required capability; // nil leaves writes open (single-user local serving, author "anonymous"). type Verify func(repo, user, password string, need ops.Auth) bool // Sessions is the browser half of the gate: a cookie a person can drop. // HTTP Basic keeps working alongside it — that is what the API and every // script uses — but a browser needs a way to log OUT, which Basic does // not give it. A nil Sessions disables the login page and leaves Basic // as the only path. type Sessions interface { // Login issues a token for correct credentials. Login(user, password string) (token string, expires time.Time, ok bool) // Resolve names the account a token belongs to. Resolve(token string) (user string, ok bool) // Logout drops one token. Logout(token string) // Allow authorizes an ALREADY-authenticated user, so a cookie is // checked against the same roles a password would be. Allow(user, repo string, need ops.Auth) bool } // InboxMessage is one notification as the inbox page shows it. quarry // does not model notifications — notify owns them (0001 §5) — so this is // only what the page renders. type InboxMessage struct { Subject string Content string CreatedAt string Read bool } // Inbox reads a subscriber's notifications from the notify companion. // nil disables the inbox page, which is the default: notifications are // off unless --notify is configured. type Inbox interface { Messages(ctx context.Context, subscriber string) ([]InboxMessage, error) } // WithInbox attaches the notify-backed inbox. func WithInbox(in Inbox) Option { return func(o *handlerOpts) { o.inbox = in } } // sessionCookie is the browser's session key. Host-only, path-wide, // unreadable to scripts, and not sent on cross-site requests — the last // of which is what keeps a POST form from being submitted by another // origin on the reader's behalf. const sessionCookie = "quarry_session" func setSession(w http.ResponseWriter, req *http.Request, token string, expires time.Time) { http.SetCookie(w, &http.Cookie{ Name: sessionCookie, Value: token, Path: "/", Expires: expires, HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: req.TLS != nil, }) } func clearSession(w http.ResponseWriter, req *http.Request) { http.SetCookie(w, &http.Cookie{ Name: sessionCookie, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: req.TLS != nil, }) } func cookieToken(req *http.Request) string { if c, err := req.Cookie(sessionCookie); err == nil { return c.Value } return "" } // gate runs the write-page auth check: 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 inbox Inbox } // Option configures the handler beyond its required planes. type Option func(*handlerOpts) func Handler(r *ops.Registry, n Notifier, live ChatLive, v ...Verify) http.Handler { return HandlerWith(r, n, live, nil, v...) } // HandlerWith is Handler plus options; Handler is the common case. func HandlerWith(r *ops.Registry, n Notifier, live ChatLive, opts []Option, v ...Verify) http.Handler { var verify Verify if len(v) > 0 { verify = v[0] } o := &handlerOpts{} for _, fn := range opts { fn(o) } sessions := o.sessions inbox := o.inbox mux := http.NewServeMux() if sessions != nil { mux.HandleFunc("GET /login", func(w http.ResponseWriter, req *http.Request) { render(r, w, req, loginPage, pageData{ Title: "sign in", LoginNext: safeNext(req.URL.Query().Get("next")), }) }) mux.HandleFunc("POST /login", func(w http.ResponseWriter, req *http.Request) { user, password := req.FormValue("user"), req.FormValue("password") next := safeNext(req.FormValue("next")) token, expires, ok := sessions.Login(user, password) if !ok { // One message for a bad name and a bad password: naming // which was wrong tells an attacker which accounts exist. w.WriteHeader(http.StatusUnauthorized) render(r, w, req, loginPage, pageData{ Title: "sign in", LoginNext: next, Error: "That account and password do not match.", }) return } setSession(w, req, token, expires) http.Redirect(w, req, next, http.StatusSeeOther) }) mux.HandleFunc("POST /logout", func(w http.ResponseWriter, req *http.Request) { sessions.Logout(cookieToken(req)) clearSession(w, req) http.Redirect(w, req, "/", http.StatusSeeOther) }) } if inbox != nil && sessions != nil { mux.HandleFunc("GET /inbox", func(w http.ResponseWriter, req *http.Request) { user, ok := sessions.Resolve(cookieToken(req)) if !ok { http.Redirect(w, req, "/login?next=%2Finbox", http.StatusSeeOther) return } data := pageData{Title: "inbox"} msgs, err := inbox.Messages(req.Context(), user) if err != nil { // A notify outage must not look like "you have nothing"; // an empty list and a failure are different answers. log.Printf("web: inbox for %s: %v", user, err) data.Error = "Notifications are unavailable right now." } data.Inbox = msgs render(r, w, req, inboxPage, data) }) } mux.HandleFunc("GET /assets/datastar.js", func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "text/javascript; charset=utf-8") w.Header().Set("Cache-Control", "public, max-age=86400") w.Write(datastarJS) }) mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, req *http.Request) { out, err := invoke(req.Context(), r, "repo.list", nil) if err != nil { fail(r, w, req, err) return } var fl struct { Repos []repoInfo `json:"repos"` } remarshal(out, &fl) data := pageData{Title: "quarry", Fleet: fl.Repos} if n != nil { data.Live = true data.LiveAttr = template.HTMLAttr(`data-init="@get('/fleet.sse')"`) } render(r, w, req, fleetPage, data) }) 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"` DescriptionHTML string `json:"descriptionHtml"` Comments []ticketComment `json:"comments"` } remarshal(out, &td) // A ticket is known by its title, not its hash; fall back to the // id only when the tracker recorded no title. title := td.Ticket.Title() if title == "" { title = "ticket " + td.Ticket.ID[:10] } render(r, w, req, ticketPage, pageData{ Title: title, Repo: repo, Ticket: td.Ticket, Attachments: td.Attachments, DescriptionHTML: template.HTML(td.DescriptionHTML), TicketComments: td.Comments, }) }) mux.HandleFunc("GET /r/{repo}/search", func(w http.ResponseWriter, req *http.Request) { repo := req.PathValue("repo") q := req.URL.Query().Get("q") data := pageData{Title: "search", Repo: repo, Query: q} if q != "" { vals := req.URL.Query() vals.Set("repo", repo) out, err := invoke(req.Context(), r, "search.query", vals) if err != nil { fail(r, w, req, err) return } var sr struct { Results []struct { Kind string `json:"kind"` Ref string `json:"ref"` Label string `json:"label"` Snippet string `json:"snippet"` } `json:"results"` } remarshal(out, &sr) for _, res := range sr.Results { data.Results = append(data.Results, hitOf(repo, res.Kind, res.Ref, res.Label, res.Snippet)) } } render(r, w, req, searchPage, data) }) mux.HandleFunc("GET /r/{repo}/checkin/{hash}/diff", func(w http.ResponseWriter, req *http.Request) { repo := req.PathValue("repo") out, err := invoke(req.Context(), r, "checkin.diff", url.Values{"repo": {repo}, "hash": {req.PathValue("hash")}}) if err != nil { fail(r, w, req, err) return } var dd diffDetail remarshal(out, &dd) render(r, w, req, diffPage, pageData{ Title: "diff " + dd.To[:12], Repo: repo, Diff: dd, DiffLines: classifyDiff(dd.Unified), }) }) // 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, inbox != nil, mux) } // liveTimeline streams Datastar element patches: on every change tick // (debounced), the timeline body re-renders through the same op path // the page uses and morphs into #tl. func liveTimeline(r *ops.Registry, n Notifier, w http.ResponseWriter, req *http.Request) { repo := req.PathValue("repo") streamPatches(n, repo, w, req, func() (string, error) { return timelineFragment(r, repo, req.URL.Query()) }) } // liveFleet does the same for the home page: any repo's change re-renders // the fleet table. func liveFleet(r *ops.Registry, n Notifier, w http.ResponseWriter, req *http.Request) { fl, ok := w.(http.Flusher) if !ok { http.Error(w, "streaming unsupported", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-store") fmt.Fprint(w, ": live\n\n") fl.Flush() ticks, cancel := n.Watch("*") defer cancel() // A first index emits events continuously, so the event tick alone // would show progress — but the "indexing" flag clears AFTER the // last event, so the badge would stick until something else // happened. A slow poll alongside the events closes that gap, and // sending only on change keeps an idle fleet silent. poll := time.NewTicker(2 * time.Second) defer poll.Stop() heartbeat := time.NewTicker(20 * time.Second) defer heartbeat.Stop() last := "" repaint := func() bool { frag, err := fleetFragment(r) if err != nil { log.Printf("web: live fleet: %v", err) return true } if frag == last { return true // nothing changed; stay quiet } last = frag fmt.Fprint(w, "event: datastar-patch-elements\n") for _, line := range strings.Split(frag, "\n") { fmt.Fprintf(w, "data: elements %s\n", line) } fmt.Fprint(w, "\n") fl.Flush() return true } repaint() for { select { case <-req.Context().Done(): return case <-heartbeat.C: fmt.Fprint(w, ": ping\n\n") fl.Flush() case <-poll.C: repaint() case _, open := <-ticks: if !open { return } time.Sleep(200 * time.Millisecond) // debounce a burst select { case <-ticks: default: } repaint() } } } // streamPatches is the SSE loop both live pages share: subscribe to one // repo's changes ("*" = the whole fleet), debounce bursts, and send each // repaint as a Datastar element patch that morphs in place. // liveChat streams new chat messages as Datastar append patches into // #chatlog — each message an appended paragraph, so the log grows // without re-rendering history. Without JS the page still shows the // on-load window and posts via the plain form (Principle 3). func liveChat(live ChatLive, repo string, w http.ResponseWriter, req *http.Request) { fl, ok := w.(http.Flusher) if !ok { http.Error(w, "streaming unsupported", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-store") fmt.Fprint(w, ": live\n\n") fl.Flush() msgs, cancel := live.SubscribeChat(repo) defer cancel() heartbeat := time.NewTicker(20 * time.Second) defer heartbeat.Stop() for { select { case <-req.Context().Done(): return case <-heartbeat.C: fmt.Fprint(w, ": ping\n\n") fl.Flush() case m, open := <-msgs: if !open { return } el := `<p class="chatmsg"><b>` + template.HTMLEscapeString(m.User) + `</b> <small class="tl-meta-inline">` + template.HTMLEscapeString(m.At) + `</small><br>` + template.HTMLEscapeString(m.Text) + `</p>` fmt.Fprint(w, "event: datastar-patch-elements\n") fmt.Fprint(w, "data: mode append\n") fmt.Fprint(w, "data: selector #chatlog\n") fmt.Fprintf(w, "data: elements %s\n\n", el) fl.Flush() } } } func streamPatches(n Notifier, repo string, w http.ResponseWriter, req *http.Request, fragment func() (string, error)) { fl, ok := w.(http.Flusher) if !ok { http.Error(w, "streaming unsupported", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-store") fmt.Fprint(w, ": live\n\n") fl.Flush() ticks, cancel := n.Watch(repo) defer cancel() heartbeat := time.NewTicker(20 * time.Second) defer heartbeat.Stop() for { select { case <-req.Context().Done(): return case <-heartbeat.C: fmt.Fprint(w, ": ping\n\n") fl.Flush() case _, open := <-ticks: if !open { return } // Debounce: a burst of artifacts is one repaint. time.Sleep(200 * time.Millisecond) select { case <-ticks: default: } frag, err := fragment() if err != nil { log.Printf("web: live %s: %v", repo, err) continue } // Datastar v1 element patch: morphs the fragment in place. fmt.Fprint(w, "event: datastar-patch-elements\n") for _, line := range strings.Split(frag, "\n") { fmt.Fprintf(w, "data: elements %s\n", line) } fmt.Fprint(w, "\n") fl.Flush() } } } // fleetFragment renders the #fleet body through repo.list — shared by // the page render and the live patches. func fleetFragment(r *ops.Registry) (string, error) { out, err := invoke(context.Background(), r, "repo.list", nil) if err != nil { return "", err } var fl struct { Repos []repoInfo `json:"repos"` } remarshal(out, &fl) var buf bytes.Buffer buf.WriteString(`<div id="fleet">`) if err := fleetPage.ExecuteTemplate(&buf, "fleet-body", pageData{Fleet: fl.Repos}); err != nil { return "", err } buf.WriteString(`</div>`) return buf.String(), nil } // timelineFragment renders the #tl body for the given query — shared by // the page render and the live patches, so both always agree. func timelineFragment(r *ops.Registry, repo string, q url.Values) (string, error) { vals := url.Values{} for k, v := range q { vals[k] = v } vals.Set("repo", repo) if nn := vals.Get("n"); nn != "" { vals.Set("limit", nn) } out, err := invoke(context.Background(), r, "timeline.list", vals) if err != nil { return "", err } var tl struct { Entries []timelineEntry `json:"entries"` Next string `json:"next"` } remarshal(out, &tl) limit := 50 if v, err := strconv.Atoi(vals.Get("limit")); err == nil && v > 0 { limit = v } data := pageData{ Repo: repo, Days: buildTimeline(repo, tl.Entries), Next: tl.Next, Branch: q.Get("branch"), Y: q.Get("y"), Limit: limit, } var buf bytes.Buffer buf.WriteString(`<div id="tl">`) if err := timelinePage.ExecuteTemplate(&buf, "tl-body", data); err != nil { return "", err } buf.WriteString(`</div>`) return buf.String(), nil } // render executes a page template with the version footer served by the // meta.version op — every page reports it (0001 Principle 1). func render(r *ops.Registry, w http.ResponseWriter, req *http.Request, page *template.Template, data pageData) { renderStatus(r, w, req, page, data, http.StatusOK) } func renderStatus(r *ops.Registry, w http.ResponseWriter, req *http.Request, page *template.Template, data pageData, status int) { if id, ok := req.Context().Value(ctxIdentity{}).(identity); ok { data.SignedIn, data.CanSignIn, data.HasInbox = id.user, id.canSignIn, id.hasInbox } out, err := invoke(req.Context(), r, "meta.version", nil) if err != nil { http.Error(w, "version op failed", http.StatusInternalServerError) return } var v struct { Version string `json:"version"` } remarshal(out, &v) data.Version = v.Version // Render to a buffer first: a template error mid-stream would // otherwise emit a silently truncated page (a bug class this exact // code once shipped) — buffered, it becomes a loud, logged 500. var buf bytes.Buffer if err := page.Execute(&buf, data); err != nil { log.Printf("web: template %s: %v", req.URL.Path, err) http.Error(w, "template error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(status) w.Write(buf.Bytes()) } // fail renders the themed error page for an op failure: not-found gets // the 404 page, everything else logs and gets the 500 page — never // Go's bare plain-text defaults (every page keeps theme, nav, version). func fail(r *ops.Registry, w http.ResponseWriter, req *http.Request, err error) { if errors.Is(err, ops.ErrNotFound) { renderStatus(r, w, req, notFoundPage, pageData{Title: "not found"}, http.StatusNotFound) return } log.Printf("web: %s: %v", req.URL.Path, err) renderStatus(r, w, req, errorPage, pageData{Title: "error"}, http.StatusInternalServerError) } // invoke runs an op through its descriptor with form-shaped input — the // same decode path the API router uses, so HTML pages and API calls hit // identical op semantics. Results come back as opaque values; pages pick // the fields they render via remarshal. func invoke(ctx context.Context, r *ops.Registry, name string, form url.Values) (any, error) { d, ok := r.Lookup(name) if !ok { return nil, fmt.Errorf("web: unknown op %q", name) } inPtr := d.NewIn() if err := ops.DecodeValues(form, inPtr); err != nil { return nil, err } in := reflect.ValueOf(inPtr).Elem().Interface() if err := ops.Validate(in); err != nil { return nil, err } return r.Invoke(ctx, name, in) } // remarshal projects an op result onto the fields a page renders — the // JSON round-trip keeps web coupled to op output *shapes* (the API // contract) rather than to concrete Go types in other packages. func remarshal(from any, to any) { b, err := json.Marshal(from) if err == nil { json.Unmarshal(b, to) } } type ctxIdentity struct{} type identity struct { user string canSignIn bool hasInbox bool } func withIdentity(sessions Sessions, hasInbox bool, next http.Handler) http.Handler { if sessions == nil { return next } return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { id := identity{canSignIn: true, hasInbox: hasInbox} if user, ok := sessions.Resolve(cookieToken(req)); ok { id.user = user } 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 }