A scss/main.scss => scss/main.scss +91 -0
@@ 0,0 1,91 @@
+// spec.sr.ht stylesheet.
+//
+// Mirrors the SourceHut service pattern (see paste.sr.ht/scss/main.scss and
+// compare.sr.ht/scss/main.scss): pull in the shared `base` partial (Bootstrap 4
+// plus the SourceHut chrome — contrast, variables, nav, events, highlight,
+// dark) and then add only what is unique to the document views.
+//
+// Build: `make css`, which is `sassc -I /usr/share/sourcehut/scss scss/main.scss`
+// plus minify plus a content-hashed filename. The shared partials come from
+// core.sr.ht's `make install`; dart-sass works too for a local build.
+//
+// The output is EMBEDDED INTO THE BINARY (web/templates.go's //go:embed static),
+// so `make css` alone does not restyle a running daemon: build the CSS, then
+// `go build`, then restart.
+
+@import "base";
+
+// Let scheme-aware widgets follow prefers-color-scheme, as compare.sr.ht does.
+:root {
+ color-scheme: light dark;
+}
+
+// ---- Rendered document ----------------------------------------------------
+
+.spec-doc {
+ // Prose, not code: a comfortable measure matters more than filling the
+ // column. The sidebar already takes a quarter of the width, so this only
+ // bites on very wide screens.
+ max-width: 46rem;
+
+ h1, h2, h3, h4, h5, h6 {
+ margin-top: 1.5rem;
+ }
+
+ // The first heading sits directly under the page title; the extra top margin
+ // reads as a gap rather than as structure.
+ > :first-child {
+ margin-top: 0;
+ }
+
+ blockquote {
+ padding-left: 0.75rem;
+ border-left: 3px solid $gray-400;
+ color: $gray-700;
+
+ @media (prefers-color-scheme: dark) {
+ border-left-color: $gray-700;
+ color: $gray-300;
+ }
+ }
+
+ table {
+ @extend .table;
+ width: auto;
+ }
+
+ img {
+ max-width: 100%;
+ }
+}
+
+// A wikilink that resolved to nothing. The renderer emits it as a span rather
+// than dropping it, so the read plane doubles as a link checker — which only
+// works if it is visibly different from a link that resolved.
+.wikilink-missing {
+ color: $danger;
+ text-decoration: line-through dotted;
+}
+
+// ---- Search ---------------------------------------------------------------
+
+.search-hit {
+ margin-bottom: 0.75rem;
+}
+
+.search-snippet {
+ color: $gray-700;
+
+ // bleve wraps matched terms in <mark>; the browser default is a yellow that
+ // is unreadable on the dark chrome.
+ mark {
+ padding: 0;
+ background: transparent;
+ color: inherit;
+ font-weight: 700;
+ }
+
+ @media (prefers-color-scheme: dark) {
+ color: $gray-300;
+ }
+}
A web/chrome.go => web/chrome.go +146 -0
@@ 0,0 1,146 @@
+package web
+
+import (
+ "net/http"
+ "net/url"
+ "sort"
+ "strings"
+
+ "github.com/vaughan0/go-ini"
+ "sourcecraft.dev/bigbes/sr-ht-core/config"
+
+ "sourcecraft.dev/bigbes/sr-ht-spec/authn"
+)
+
+// navCanonical is the SourceHut service-switcher order. Services not listed
+// here (including our own spec) sort alphabetically after these.
+var navCanonical = []string{"hub", "git", "hg", "lists", "todo", "builds", "man", "meta"}
+
+// navExcluded are service sections that never appear in the switcher: paste and
+// pages have no top-level UI worth linking, and hub is rendered as the brand.
+var navExcluded = map[string]bool{"paste": true, "pages": true, "hub": true}
+
+// navItem is one entry in the service switcher.
+type navItem struct {
+ Name string // short service name, e.g. "git"
+ Origin string // external origin URL
+ Active bool // true for spec.sr.ht (this service)
+}
+
+// buildNav derives the service switcher from the shared config: every section
+// whose name ends in ".sr.ht" (with a configured origin) except paste/pages/hub,
+// ordered by navCanonical then alphabetically, with spec.sr.ht marked active.
+//
+// The ".sr.ht" suffix is the whole membership rule — it is what
+// core.sr.ht's own _network does, and it is why our section must be named
+// literally "spec.sr.ht" no matter what host it is served from.
+func buildNav(conf ini.File) []navItem {
+ var items []navItem
+ for section := range conf {
+ if !strings.HasSuffix(section, ".sr.ht") {
+ continue
+ }
+ short := strings.TrimSuffix(section, ".sr.ht")
+ if navExcluded[short] {
+ continue
+ }
+ origin := config.GetOrigin(conf, section, true)
+ if origin == "" {
+ continue
+ }
+ items = append(items, navItem{
+ Name: short,
+ Origin: origin,
+ Active: section == authn.ConfigSection,
+ })
+ }
+ sort.SliceStable(items, func(i, j int) bool {
+ ci, cj := canonIndex(items[i].Name), canonIndex(items[j].Name)
+ if ci != cj {
+ return ci < cj
+ }
+ return items[i].Name < items[j].Name
+ })
+ return items
+}
+
+// canonIndex returns a service's position in navCanonical, or a sentinel past
+// the end for services that are not canonically ordered.
+func canonIndex(name string) int {
+ for i, n := range navCanonical {
+ if n == name {
+ return i
+ }
+ }
+ return len(navCanonical)
+}
+
+// viewData is the root value every template is executed against: the chrome
+// fields are common to all pages; Data carries the page-specific payload.
+type viewData struct {
+ Title string
+ SiteName string
+ HubOrigin string // non-empty ⇒ brand links to hub instead of "/"
+ Nav []navItem
+ Username string // "" for a viewer with no authority
+ LoginURL string
+ LogoutURL string
+ RegisterURL string
+ ProfileURL string
+ CSSHref string // "" when the binary was built without a stylesheet
+ Environment string
+ ShowBanner bool
+
+ // ContainerClass selects the width of the page's content wrapper.
+ ContainerClass string
+
+ Data any
+}
+
+// chrome builds the common chrome fields for a request. Login return_to is the
+// current full URL (so the viewer lands back where they were); logout return_to
+// is this service's origin.
+//
+// Username is the *authoritative* identity, not whatever the cookie said: a
+// logged-in human who is not the instance owner resolves to anonymous, so the
+// nav shows "log in" to them rather than greeting them by a name that grants
+// nothing.
+func (s *Server) chrome(r *http.Request) viewData {
+ p := authn.PrincipalFromContext(r.Context())
+ username := ""
+ if p.IsOwner() {
+ username = p.Owner
+ }
+
+ current := s.origin + r.URL.RequestURI()
+ loginURL := s.metaOrigin + "/login?return_to=" + url.QueryEscape(current)
+ logoutURL := s.metaOrigin + "/logout?return_to=" + url.QueryEscape(s.origin)
+
+ profileURL := s.metaOrigin + "/profile"
+ if s.hubOrigin != "" && username != "" {
+ profileURL = s.hubOrigin + "/~" + username
+ }
+
+ return viewData{
+ ContainerClass: "container",
+ SiteName: s.siteName,
+ HubOrigin: s.hubOrigin,
+ Nav: s.nav,
+ Username: username,
+ LoginURL: loginURL,
+ LogoutURL: logoutURL,
+ RegisterURL: s.metaOrigin,
+ ProfileURL: profileURL,
+ CSSHref: s.cssHref,
+ Environment: strings.ToUpper(s.environment),
+ ShowBanner: s.environment != "" && s.environment != "production",
+ }
+}
+
+// loginRedirect sends a viewer with no read authority to meta.sr.ht's login,
+// with return_to pointing back at what they asked for. There is no login flow
+// of our own — identity is the shared unified-login cookie and nothing else.
+func (s *Server) loginRedirect(w http.ResponseWriter, r *http.Request) {
+ current := s.origin + r.URL.RequestURI()
+ http.Redirect(w, r, s.metaOrigin+"/login?return_to="+url.QueryEscape(current), http.StatusFound)
+}
A web/handlers.go => web/handlers.go +649 -0
@@ 0,0 1,649 @@
+package web
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "html/template"
+ "log"
+ "net/http"
+ "path"
+ "strings"
+
+ "github.com/go-chi/chi/v5"
+
+ "sourcecraft.dev/bigbes/sr-ht-spec/authn"
+ "sourcecraft.dev/bigbes/sr-ht-spec/core"
+ "sourcecraft.dev/bigbes/sr-ht-spec/doc"
+ "sourcecraft.dev/bigbes/sr-ht-spec/search"
+ "sourcecraft.dev/bigbes/sr-ht-spec/service"
+)
+
+// searchLimit bounds one page of results.
+const searchLimit = 25
+
+// ---- format selectors -----------------------------------------------------
+
+// format is which representation of a document was asked for. The extension in
+// the URL selects it; the document's own address carries none.
+type format int
+
+const (
+ formatHTML format = iota
+ formatRaw
+ formatJSON
+)
+
+// splitFormat peels a format selector off the tail of a document address.
+//
+// The design pins this: ".md" is the raw source and ".json" is metadata plus
+// body, so neither is ever part of the address that identifies the document.
+// The remainder is the document's address — its tree path minus core.DocExt.
+func splitFormat(rest string) (string, format) {
+ switch {
+ case strings.HasSuffix(rest, ".json"):
+ return strings.TrimSuffix(rest, ".json"), formatJSON
+ case strings.HasSuffix(rest, core.DocExt):
+ return strings.TrimSuffix(rest, core.DocExt), formatRaw
+ default:
+ return rest, formatHTML
+ }
+}
+
+// ---- authorization --------------------------------------------------------
+
+// mayRead reports whether a request carries authority to read content.
+//
+// One human, no visibility levels: the owner and its agents may read and nobody
+// else may. A logged-in human who is not the instance owner has already been
+// resolved to anonymous by authn, so this is the whole ACL.
+func mayRead(r *http.Request) bool {
+ p := authn.PrincipalFromContext(r.Context())
+ return p.IsOwner() || p.IsAgent()
+}
+
+// denyRead answers a viewer with no read authority in the shape their client
+// can act on: a browser is sent to meta's login, a machine asking for .md or
+// .json gets a 401. Redirecting a bot to an HTML login page would hand it a
+// 200 full of markup it cannot use.
+func (s *Server) denyRead(w http.ResponseWriter, r *http.Request, f format) {
+ if f == formatHTML {
+ s.loginRedirect(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+ http.Error(w, "authentication required", http.StatusUnauthorized)
+}
+
+// ---- error mapping --------------------------------------------------------
+
+// httpStatusFor maps a service error onto a status. service.ErrNotFound already
+// folds "malformed revision" into "absent", so probing cannot tell them apart.
+func httpStatusFor(err error) int {
+ switch {
+ case errors.Is(err, service.ErrNotFound):
+ return http.StatusNotFound
+ case errors.Is(err, core.ErrInvalidName), errors.Is(err, core.ErrInvalidPath):
+ return http.StatusBadRequest
+ default:
+ return http.StatusInternalServerError
+ }
+}
+
+// fail renders the chrome error page for err, logging 5xx causes and telling
+// the viewer nothing about them.
+func (s *Server) fail(w http.ResponseWriter, r *http.Request, err error) {
+ status := httpStatusFor(err)
+ if status >= 500 {
+ log.Printf("web: %s: %v", r.URL.Path, err)
+ s.renderError(w, r, status, "an internal error occurred")
+ return
+ }
+ s.renderError(w, r, status, err.Error())
+}
+
+// failFormat is fail for a request that asked for .md or .json: those callers
+// are machines, so they get a status and a line of text rather than a page.
+func (s *Server) failFormat(w http.ResponseWriter, r *http.Request, f format, err error) {
+ if f == formatHTML {
+ s.fail(w, r, err)
+ return
+ }
+ status := httpStatusFor(err)
+ if status >= 500 {
+ log.Printf("web: %s: %v", r.URL.Path, err)
+ http.Error(w, "internal server error", status)
+ return
+ }
+ http.Error(w, err.Error(), status)
+}
+
+// ---- landing --------------------------------------------------------------
+
+type spaceLink struct {
+ Ref string
+ Href string
+}
+
+type indexData struct {
+ LoggedIn bool
+ Spaces []spaceLink
+}
+
+// handleIndex is the landing page: the spaces you can read.
+//
+// It renders for an anonymous viewer too, because the chrome's login link has
+// to live somewhere reachable — but it lists nothing, so no space name leaks.
+func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
+ vd := s.chrome(r)
+ vd.Title = s.siteName + " spec"
+
+ data := indexData{LoggedIn: mayRead(r)}
+ if data.LoggedIn {
+ refs, err := s.reader.ListSpaces(r.Context())
+ if err != nil {
+ s.fail(w, r, err)
+ return
+ }
+ for _, ref := range refs {
+ data.Spaces = append(data.Spaces, spaceLink{Ref: ref.String(), Href: "/" + ref.String()})
+ }
+ }
+ vd.Data = data
+ s.render(w, http.StatusOK, "index", vd)
+}
+
+// ---- space ----------------------------------------------------------------
+
+// treeItem is one row of the space's document tree, flattened to a depth so the
+// template needs no recursion.
+type treeItem struct {
+ Depth int
+ Title string
+ Href string
+ ID string
+ DocID string
+ Status string
+ Summary string
+ Section string
+}
+
+type spaceData struct {
+ Ref string
+ Rev string
+ Pinned bool
+ RevQuery string
+ Count int
+ Items []treeItem
+}
+
+func (s *Server) handleSpace(w http.ResponseWriter, r *http.Request) {
+ if !mayRead(r) {
+ s.denyRead(w, r, formatHTML)
+ return
+ }
+ ref, err := spaceRefFrom(r)
+ if err != nil {
+ s.fail(w, r, err)
+ return
+ }
+ rev := r.URL.Query().Get("rev")
+ snap, err := s.reader.Snapshot(r.Context(), ref, rev)
+ if err != nil {
+ s.fail(w, r, err)
+ return
+ }
+
+ vd := s.chrome(r)
+ vd.Title = ref.String()
+ vd.Data = spaceData{
+ Ref: ref.String(),
+ Rev: snap.Rev,
+ Pinned: rev != service.ApprovedRev,
+ RevQuery: revQuery(rev),
+ Count: len(snap.Archive.All()),
+ Items: flattenTree(snap, revQuery(rev)),
+ }
+ s.render(w, http.StatusOK, "space", vd)
+}
+
+// flattenTree walks the archive's `parent:` hierarchy into an ordered, depth-
+// tagged list. A space where nobody sets `parent:` degrades to a flat list in
+// path order, which is the common case and reads fine.
+func flattenTree(snap *Snapshot, rq string) []treeItem {
+ var out []treeItem
+ seen := make(map[string]bool, len(snap.Archive.All()))
+
+ var walk func(pages []*doc.Page, depth int)
+ walk = func(pages []*doc.Page, depth int) {
+ for _, p := range pages {
+ if seen[p.ID] {
+ continue // a `parent:` cycle must not hang the page
+ }
+ seen[p.ID] = true
+ out = append(out, treeItem{
+ Depth: depth,
+ Title: p.Title,
+ Href: snap.Archive.DocHref(p) + rq,
+ ID: p.ID,
+ DocID: p.DocID,
+ Status: string(p.Status),
+ Summary: p.Summary,
+ Section: p.Section,
+ })
+ walk(snap.Archive.Children(p.ID), depth+1)
+ }
+ }
+ walk(snap.Archive.Roots(), 0)
+
+ // Anything a cycle kept out of the walk is still a document of this space
+ // and must still be listed; dropping it would make the tree quietly lie
+ // about what the revision contains.
+ for _, p := range snap.Archive.All() {
+ if !seen[p.ID] {
+ out = append(out, treeItem{
+ Title: p.Title,
+ Href: snap.Archive.DocHref(p) + rq,
+ ID: p.ID,
+ DocID: p.DocID,
+ Status: string(p.Status),
+ Summary: p.Summary,
+ Section: p.Section,
+ })
+ }
+ }
+ return out
+}
+
+// ---- document -------------------------------------------------------------
+
+type docLink struct {
+ Title string
+ Href string
+}
+
+type docData struct {
+ SpaceRef string
+ SpaceHref string
+ Path string
+ Address string
+ ID string
+ DocID string
+ Title string
+ Status string
+ Summary string
+ Type string
+ Tags []string
+ Owners []string
+ Props []doc.DocProperty
+ Rev string
+ Blob string
+ Pinned bool
+ RevQuery string
+ Body template.HTML
+ Headings []doc.Heading
+ Children []docLink
+ Backlinks []docLink
+ Missing []string
+ WordCount int
+ RawHref string
+ JSONHref string
+}
+
+// docJSON is the .json representation: metadata plus body.
+type docJSON struct {
+ Space string `json:"space"`
+ Path string `json:"path"`
+ Address string `json:"address"`
+ ID string `json:"id"`
+ DocID string `json:"doc_id,omitempty"`
+ Rev string `json:"rev"`
+ Blob string `json:"blob"`
+ Kind string `json:"kind,omitempty"`
+ Title string `json:"title"`
+ Status string `json:"status,omitempty"`
+ Summary string `json:"summary,omitempty"`
+ Type string `json:"type,omitempty"`
+ Section string `json:"section,omitempty"`
+ Tags []string `json:"tags,omitempty"`
+ Owners []string `json:"owners,omitempty"`
+ Supersedes string `json:"supersedes,omitempty"`
+ Props []doc.DocProperty `json:"props,omitempty"`
+ Body string `json:"body"`
+}
+
+// handleDocument serves all three representations of one document.
+//
+// The three share a single route because they are one resource: the extension
+// selects a format and the remainder is the address. Splitting them into three
+// routes would let the address grammar drift apart between them, which is the
+// exact confusion the pinned grammar exists to prevent.
+func (s *Server) handleDocument(w http.ResponseWriter, r *http.Request) {
+ rest, ok := unescapePath(chi.URLParam(r, "*"))
+ if !ok {
+ s.renderError(w, r, http.StatusBadRequest, "malformed path")
+ return
+ }
+ address, f := splitFormat(rest)
+
+ if !mayRead(r) {
+ s.denyRead(w, r, f)
+ return
+ }
+ ref, err := spaceRefFrom(r)
+ if err != nil {
+ s.failFormat(w, r, f, err)
+ return
+ }
+ rev := r.URL.Query().Get("rev")
+ rq := revQuery(rev)
+
+ // "/~owner/space/" is the space, spelled with a trailing slash.
+ if address == "" {
+ http.Redirect(w, r, "/"+ref.String()+rq, http.StatusFound)
+ return
+ }
+
+ docPath := address + core.DocExt
+ if err := core.ValidateDocPath(docPath); err != nil {
+ s.failFormat(w, r, f, err)
+ return
+ }
+
+ // Raw source needs no archive: it is the bytes of one blob, and building a
+ // whole-revision archive to hand them over would make the cheapest read the
+ // most expensive one.
+ if f == formatRaw {
+ d, err := s.reader.ReadDocument(r.Context(), ref, rev, docPath)
+ if err != nil {
+ s.failFormat(w, r, f, err)
+ return
+ }
+ w.Header().Set("Content-Type", "text/markdown; charset=utf-8")
+ w.Header().Set("X-Spec-Rev", d.Rev)
+ w.Header().Set("X-Spec-Blob", d.Blob)
+ _, _ = w.Write(d.Data)
+ return
+ }
+
+ snap, err := s.reader.Snapshot(r.Context(), ref, rev)
+ if err != nil {
+ s.failFormat(w, r, f, err)
+ return
+ }
+ page, ok := snap.Archive.ByPath(docPath)
+ if !ok {
+ // The address may be a document id rather than a path. Redirecting
+ // rather than serving keeps one document at one canonical URL — and a
+ // duplicated id resolves to neither document, so this cannot silently
+ // pick a winner.
+ if p, found := snap.Archive.Page(address); found {
+ http.Redirect(w, r, snap.Archive.DocHref(p)+rq, http.StatusFound)
+ return
+ }
+ s.failFormat(w, r, f, fmt.Errorf("%w: %s in %s at %s",
+ service.ErrNotFound, docPath, ref, snap.Rev))
+ return
+ }
+
+ body, ok := snap.Bodies[docPath]
+ if !ok {
+ s.failFormat(w, r, f, fmt.Errorf("web: %s is in the archive of %s at %s but has no body",
+ docPath, ref, snap.Rev))
+ return
+ }
+ front, mdBody := doc.ParseFront(body)
+
+ if f == formatJSON {
+ payload := docJSON{
+ Space: ref.String(),
+ Path: page.Path,
+ Address: address,
+ ID: page.ID,
+ DocID: page.DocID,
+ Rev: snap.Rev,
+ Blob: page.Blob,
+ Kind: string(page.Kind),
+ Title: page.Title,
+ Status: string(page.Status),
+ Summary: page.Summary,
+ Type: front.Type,
+ Section: page.Section,
+ Tags: page.Tags,
+ Owners: front.Owners,
+ Supersedes: front.Supersedes,
+ Props: front.Props,
+ Body: string(mdBody),
+ }
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ enc := json.NewEncoder(w)
+ enc.SetIndent("", " ")
+ if err := enc.Encode(payload); err != nil {
+ log.Printf("web: encoding %s.json: %v", docPath, err)
+ }
+ return
+ }
+
+ // Backlinks need the whole revision's link graph, and doc.Page.Links is
+ // filled by a render pass rather than by Scan — so the pass happens here.
+ if err := s.linkPass(snap); err != nil {
+ s.fail(w, r, err)
+ return
+ }
+ res := s.renderer.Render(mdBody, dirOf(docPath), pinned{inner: snap.Archive, rq: rq})
+
+ data := docData{
+ SpaceRef: ref.String(),
+ SpaceHref: "/" + ref.String() + rq,
+ Path: page.Path,
+ Address: address,
+ ID: page.ID,
+ DocID: page.DocID,
+ Title: page.Title,
+ Status: string(page.Status),
+ Summary: page.Summary,
+ Type: front.Type,
+ Tags: page.Tags,
+ Owners: front.Owners,
+ Props: front.Props,
+ Rev: snap.Rev,
+ Blob: page.Blob,
+ Pinned: rev != service.ApprovedRev,
+ RevQuery: rq,
+ Body: template.HTML(res.HTML),
+ Headings: res.Headings,
+ Missing: res.MissingWikilinks,
+ WordCount: res.WordCount,
+ RawHref: snap.Archive.DocHref(page) + core.DocExt + rq,
+ JSONHref: snap.Archive.DocHref(page) + ".json" + rq,
+ }
+ for _, c := range snap.Archive.Children(page.ID) {
+ data.Children = append(data.Children, docLink{Title: c.Title, Href: snap.Archive.DocHref(c) + rq})
+ }
+ for _, b := range snap.Archive.Backlinks(page.ID) {
+ data.Backlinks = append(data.Backlinks, docLink{Title: b.Title, Href: snap.Archive.DocHref(b) + rq})
+ }
+
+ vd := s.chrome(r)
+ vd.Title = page.Title + " — " + ref.String()
+ vd.Data = data
+ s.render(w, http.StatusOK, "document", vd)
+}
+
+// pinned wraps the archive's resolver so that every site-internal link a
+// rendered document emits keeps the revision the reader is on.
+//
+// Without it, following a wikilink out of a page opened at ?rev=<sha> lands on
+// the approved head — silently, and in the middle of reading a pinned
+// revision. The pin is the read contract's whole point, so it has to survive
+// one hop. External and unresolved destinations are handed back untouched: the
+// first is not ours to rewrite, and the second is deliberately the text the
+// author typed.
+type pinned struct {
+ inner doc.Resolver
+ rq string
+}
+
+func (p pinned) Resolve(fromDir, dest string) doc.Target {
+ t := p.inner.Resolve(fromDir, dest)
+ if p.rq == "" || t.IsExternal || t.Missing || !strings.HasPrefix(t.Href, "/") {
+ return t
+ }
+ href, frag, hasFrag := strings.Cut(t.Href, "#")
+ t.Href = href + p.rq
+ if hasFrag {
+ t.Href += "#" + frag
+ }
+ return t
+}
+
+// linkPass renders every document of the revision to fill in doc.Page.Links,
+// which is what Archive.Backlinks reads. Scan deliberately does not do it —
+// links come out of a render, not out of a frontmatter parse.
+//
+// A page with no body is an inconsistency between the archive and the bodies,
+// which cannot happen while both are read at one resolved sha. It is reported
+// rather than skipped: skipping it would silently drop that document's outbound
+// links and quietly under-report backlinks everywhere else.
+func (s *Server) linkPass(snap *Snapshot) error {
+ for _, p := range snap.Archive.All() {
+ raw, ok := snap.Bodies[p.Path]
+ if !ok {
+ return fmt.Errorf("web: %s is in the archive of %s at %s but has no body",
+ p.Path, snap.Ref, snap.Rev)
+ }
+ _, mdBody := doc.ParseFront(raw)
+ res := s.renderer.Render(mdBody, dirOf(p.Path), snap.Archive)
+ p.Links = res.LinkedIDs
+ p.WordCount = res.WordCount
+ }
+ return nil
+}
+
+// ---- search ---------------------------------------------------------------
+
+type searchHit struct {
+ Title string
+ Href string
+ Space string
+ Section string
+ Score float64
+ Snippet template.HTML
+}
+
+type searchData struct {
+ Query string
+ Space string
+ Total uint64
+ Took string
+ Hits []searchHit
+ Spaces []spaceLink
+}
+
+func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
+ if !mayRead(r) {
+ s.denyRead(w, r, formatHTML)
+ return
+ }
+ q := strings.TrimSpace(r.URL.Query().Get("q"))
+ spaceParam := strings.TrimSpace(r.URL.Query().Get("space"))
+
+ data := searchData{Query: q, Space: spaceParam}
+ refs, err := s.reader.ListSpaces(r.Context())
+ if err != nil {
+ s.fail(w, r, err)
+ return
+ }
+ for _, ref := range refs {
+ data.Spaces = append(data.Spaces, spaceLink{Ref: ref.String(), Href: "/" + ref.String()})
+ }
+
+ query := search.Query{Text: q, Limit: searchLimit}
+ if spaceParam != "" {
+ ref, err := core.ParseSpaceRef(spaceParam)
+ if err != nil {
+ s.fail(w, r, err)
+ return
+ }
+ query.Spaces = []core.SpaceRef{ref}
+ }
+
+ if q != "" {
+ res, err := s.searcher.Search(r.Context(), query)
+ if err != nil {
+ s.fail(w, r, err)
+ return
+ }
+ data.Total = res.Total
+ data.Took = res.Took.Round(100000).String()
+ for _, h := range res.Hits {
+ data.Hits = append(data.Hits, searchHit{
+ Title: h.Title,
+ Href: hitHref(h),
+ Space: h.Space.String(),
+ Section: h.Section,
+ Score: h.Score,
+ // bleve's formatter escapes everything around the <mark> tags
+ // it inserts, so this fragment is HTML and must be rendered as
+ // HTML — as text the marks show up literally.
+ Snippet: template.HTML(h.Snippet),
+ })
+ }
+ }
+
+ vd := s.chrome(r)
+ vd.Title = "search — " + s.siteName + " spec"
+ vd.Data = data
+ s.render(w, http.StatusOK, "search", vd)
+}
+
+// hitHref turns a hit into the pinned URL the design specifies for it:
+// /~owner/space/<address>?rev=<sha>#<anchor>, where the address is the tree
+// path minus its extension because that is what a document's address is.
+func hitHref(h search.Hit) string {
+ href := "/" + h.Space.String() + "/" + strings.TrimSuffix(h.Path, core.DocExt)
+ if h.Rev != "" {
+ href += "?rev=" + h.Rev
+ }
+ if h.Anchor != "" {
+ href += "#" + h.Anchor
+ }
+ return href
+}
+
+// ---- helpers --------------------------------------------------------------
+
+// spaceRefFrom builds the space reference out of the route parameters. The '~'
+// is routing decoration and is never part of the stored owner.
+func spaceRefFrom(r *http.Request) (core.SpaceRef, error) {
+ owner := chi.URLParam(r, "owner")
+ name := chi.URLParam(r, "space")
+ ref := core.SpaceRef{Owner: owner, Name: name}
+ if err := core.ValidateOwner(ref.Owner); err != nil {
+ return core.SpaceRef{}, err
+ }
+ if err := core.ValidateSpaceName(ref.Name); err != nil {
+ return core.SpaceRef{}, err
+ }
+ return ref, nil
+}
+
+// revQuery renders the pin a page's own links must carry so that following one
+// stays on the revision the reader is looking at. An unpinned read produces no
+// query at all, which is what makes the approved head the default everywhere.
+func revQuery(rev string) string {
+ if rev == service.ApprovedRev {
+ return ""
+ }
+ return "?rev=" + rev
+}
+
+// dirOf is the directory a document lives in, space-relative, with "" for the
+// space root — the shape doc's resolver expects.
+func dirOf(p string) string {
+ d := path.Dir(p)
+ if d == "." || d == "/" {
+ return ""
+ }
+ return d
+}
A web/reader.go => web/reader.go +109 -0
@@ 0,0 1,109 @@
+package web
+
+import (
+ "context"
+ "fmt"
+
+ "sourcecraft.dev/bigbes/sr-ht-spec/core"
+ "sourcecraft.dev/bigbes/sr-ht-spec/doc"
+ "sourcecraft.dev/bigbes/sr-ht-spec/search"
+ "sourcecraft.dev/bigbes/sr-ht-spec/service"
+)
+
+// Snapshot is one space at one *resolved* revision: the addressable document
+// set plus every document's bytes.
+//
+// Rev is always a commit sha, never a branch name, even when the request
+// carried no ?rev= at all. The read plane resolves first and renders second, so
+// a merge landing mid-request cannot make one page describe two revisions —
+// and the sha it resolved to is the value the page offers as its permalink.
+//
+// Bodies is keyed by tree path, the same key doc.Page.Path carries. It is
+// present because doc.Page.Links is filled by a render pass rather than by
+// Scan, so backlinks require rendering every document in the space; see
+// linkPass.
+type Snapshot struct {
+ Ref core.SpaceRef
+ Rev string
+ Archive *doc.Archive
+ Bodies map[string][]byte
+}
+
+// Reader is the read surface this package needs. *service.Service provides it
+// through NewReader.
+//
+// It is an interface for the same reason compare.sr.ht's authz.Authorizer is:
+// so the handlers can be tested against a document set rather than against a
+// Postgres instance and a tree of bare repositories.
+type Reader interface {
+ // ListSpaces returns every space on the instance. Single-user with no
+ // visibility levels means there is nothing to filter — the list is the
+ // whole corpus.
+ ListSpaces(ctx context.Context) ([]core.SpaceRef, error)
+
+ // Snapshot resolves rev (service.ApprovedRev for the approved head) and
+ // returns the space at that revision.
+ Snapshot(ctx context.Context, ref core.SpaceRef, rev string) (*Snapshot, error)
+
+ // ReadDocument reads one document by tree path at a revision.
+ ReadDocument(ctx context.Context, ref core.SpaceRef, rev, path string) (service.Document, error)
+}
+
+// Searcher is the keyword index. *search.Index satisfies it as declared.
+type Searcher interface {
+ Search(ctx context.Context, q search.Query) (search.Results, error)
+}
+
+// NewReader adapts a *service.Service to Reader.
+func NewReader(svc *service.Service) Reader { return serviceReader{svc: svc} }
+
+// serviceReader is the production Reader: everything goes through service/,
+// which is the layer that is allowed to touch gitx and db.
+type serviceReader struct{ svc *service.Service }
+
+func (r serviceReader) ListSpaces(ctx context.Context) ([]core.SpaceRef, error) {
+ spaces, err := r.svc.ListSpaces(ctx)
+ if err != nil {
+ return nil, err
+ }
+ refs := make([]core.SpaceRef, 0, len(spaces))
+ for _, sp := range spaces {
+ refs = append(refs, sp.Ref)
+ }
+ return refs, nil
+}
+
+func (r serviceReader) Snapshot(ctx context.Context, ref core.SpaceRef, rev string) (*Snapshot, error) {
+ sp, err := r.svc.OpenSpace(ctx, ref)
+ if err != nil {
+ return nil, err
+ }
+ resolved, err := r.svc.ResolveRev(ctx, sp, rev)
+ if err != nil {
+ return nil, err
+ }
+ // Both reads below are issued against the resolved sha rather than against
+ // the caller's rev, so the archive and the bodies are the same revision by
+ // construction and cannot disagree if a merge lands between them.
+ arc, err := doc.Scan(ctx, sp.Repo, ref, resolved)
+ if err != nil {
+ return nil, fmt.Errorf("web: scan %s at %s: %w", ref, resolved, err)
+ }
+ docs, err := r.svc.ListDocuments(ctx, sp, resolved)
+ if err != nil {
+ return nil, err
+ }
+ bodies := make(map[string][]byte, len(docs))
+ for _, d := range docs {
+ bodies[d.Path] = d.Data
+ }
+ return &Snapshot{Ref: ref, Rev: resolved, Archive: arc, Bodies: bodies}, nil
+}
+
+func (r serviceReader) ReadDocument(ctx context.Context, ref core.SpaceRef, rev, p string) (service.Document, error) {
+ sp, err := r.svc.OpenSpace(ctx, ref)
+ if err != nil {
+ return service.Document{}, err
+ }
+ return r.svc.ReadDocument(ctx, sp, rev, p)
+}
A web/router.go => web/router.go +86 -0
@@ 0,0 1,86 @@
+package web
+
+import (
+ "net/http"
+ "net/url"
+ "path"
+ "strings"
+
+ "github.com/go-chi/chi/v5"
+ "github.com/go-chi/chi/v5/middleware"
+)
+
+// Handler returns a router with everything this package needs already
+// installed: panic recovery and the authn principal middleware, then the
+// routes. The daemon mounts it at "/".
+//
+// A caller that owns its own middleware stack — and has already applied
+// authn.Resolver.Middleware to it — uses Register instead. Installing the
+// principal middleware twice is harmless but pointless: it is idempotent.
+func (s *Server) Handler() http.Handler {
+ r := chi.NewRouter()
+ r.Use(middleware.Recoverer)
+ r.Use(s.resolver.Middleware())
+ s.Register(r)
+ return r
+}
+
+// Register mounts every spec.sr.ht read-plane route onto r. It installs no
+// middleware of its own; the router it is handed must already resolve a
+// principal into the request context (authn.Resolver.Middleware), or every
+// viewer looks anonymous.
+//
+// The document route is a single wildcard because the format selector lives in
+// the *extension* and the document's address does not have one: ".md" and
+// ".json" are stripped from the tail by the handler, never routed on, so a
+// document called "notes/2026.json.md" is still reachable and a request for
+// "notes/2026.json" still means "the JSON of notes/2026".
+func (s *Server) Register(r chi.Router) {
+ r.Get("/", s.handleIndex)
+ r.Get("/healthz", s.handleHealthz)
+ r.Get("/static/*", s.handleStatic)
+ r.Get("/search", s.handleSearch)
+
+ r.Get("/~{owner}/{space}", s.handleSpace)
+ r.Get("/~{owner}/{space}/*", s.handleDocument)
+}
+
+// handleHealthz is a dependency-free liveness probe.
+func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+ _, _ = w.Write([]byte("ok\n"))
+}
+
+// handleStatic serves the embedded assets, tagging the content-addressed
+// stylesheet as immutable: its name changes whenever its bytes do, so a browser
+// may keep it forever and a deploy still busts the cache.
+func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) {
+ name := path.Base(r.URL.Path)
+ if hashedCSSRe.MatchString(name) {
+ w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
+ } else {
+ w.Header().Set("Cache-Control", "public, max-age=3600")
+ }
+ s.staticFileServer.ServeHTTP(w, r)
+}
+
+// unescapePath decodes a chi wildcard back into a tree path.
+//
+// chi routes on r.URL.RawPath when the request had one, so the wildcard arrives
+// percent-encoded — which it must, since doc.Archive escapes every href segment
+// so spaces and Cyrillic survive. Decoding is per segment on purpose: a %2F
+// inside a segment is not a path separator and must not become one.
+func unescapePath(raw string) (string, bool) {
+ if raw == "" {
+ return "", true
+ }
+ segs := strings.Split(raw, "/")
+ for i, seg := range segs {
+ dec, err := url.PathUnescape(seg)
+ if err != nil {
+ return "", false
+ }
+ segs[i] = dec
+ }
+ return strings.Join(segs, "/"), true
+}
A web/server.go => web/server.go +186 -0
@@ 0,0 1,186 @@
+// Package web is spec.sr.ht's read plane in a browser: the SourceHut chrome
+// ported to Go html/templates, a space's document tree, a rendered document
+// with its metadata and backlinks, and keyword search — all served from one chi
+// router the daemon mounts.
+//
+// It is compare.sr.ht's web/ package with the diff views replaced by document
+// views: the nav/service-switcher, login block, environment banner, error page,
+// embedded-static pattern and hashed-asset resolution are the same code shaped
+// the same way, because "no upstream SourceHut modification" means every
+// service reimplements that chrome and two of them already agreed on how.
+//
+// # URL grammar
+//
+// The design pins this, so it is spelled out here rather than left to the
+// router: a document's address carries no extension. The extension is a format
+// selector and never part of the document's identity.
+//
+// /~user/space/specs/0007-storage rendered HTML
+// /~user/space/specs/0007-storage.md raw source (frontmatter + body)
+// /~user/space/specs/0007-storage.json metadata + body
+// …?rev=<sha> any of the three, pinned
+//
+// An absent ?rev= means the approved head — service.ApprovedRev — because
+// serving drafts by default would poison every downstream agent context with
+// unreviewed text.
+//
+// # Who may read
+//
+// The instance has one human. There are no visibility levels, so the read ACL
+// is one line: the owner and its agents may read, and everyone else may not.
+// An anonymous browser asking for a page is redirected to meta.sr.ht's login
+// (there is no login flow of our own); an anonymous client asking for .md or
+// .json gets a 401, because redirecting a bot to an HTML login page tells it
+// nothing.
+//
+// # What the cmd layer must wire
+//
+// [Server.Handler] returns a router with everything this package needs already
+// installed, so the daemon can mount it at "/". A caller that owns its own
+// router and middleware stack uses [Server.Register] instead; it installs
+// routes only, and assumes authn.Resolver.Middleware is already applied.
+//
+// # Assets are embedded
+//
+// static/ is compiled into the binary by //go:embed. `make css` rewrites
+// web/static/main.min.<hash>.css on disk and a *running* daemon will not notice
+// — the CSS is baked in at `go build` time, so the build order is css then
+// build then restart. A binary built with no stylesheet present logs a loud
+// warning at startup and renders unstyled rather than refusing to start:
+// missing CSS degrades presentation, not correctness.
+package web
+
+import (
+ "fmt"
+ "io/fs"
+ "log"
+ "net/http"
+ "path"
+ "regexp"
+
+ "github.com/vaughan0/go-ini"
+ "sourcecraft.dev/bigbes/sr-ht-core/config"
+
+ "sourcecraft.dev/bigbes/sr-ht-spec/authn"
+ "sourcecraft.dev/bigbes/sr-ht-spec/doc"
+)
+
+// hashedCSSRe matches the content-addressed stylesheet name so it can be served
+// with an immutable cache lifetime (the hash changes whenever the bytes do).
+var hashedCSSRe = regexp.MustCompile(`^main\.min\.[0-9a-f]{6,}\.css$`)
+
+// Options is everything a Server needs. Every field is required; New says which
+// one is missing rather than failing later inside a handler.
+type Options struct {
+ // Conf is the shared SourceHut config.ini. The nav switcher, the site name,
+ // the environment banner and the meta.sr.ht login origin all come from it,
+ // and it is read once at startup rather than per request.
+ Conf ini.File
+
+ // Reader is the read surface over spaces and documents. NewReader adapts a
+ // *service.Service to it.
+ Reader Reader
+
+ // Searcher is the keyword index. *search.Index satisfies it directly.
+ Searcher Searcher
+
+ // Resolver turns the unified-login cookie or an agent bearer token into a
+ // principal. Handler installs its middleware; Register does not.
+ Resolver *authn.Resolver
+}
+
+// Server holds the immutable configuration a request handler needs. It is built
+// once at startup and is safe for concurrent use.
+type Server struct {
+ reader Reader
+ searcher Searcher
+ resolver *authn.Resolver
+ renderer *doc.Renderer
+
+ siteName string
+ environment string
+ metaOrigin string
+ origin string
+ hubOrigin string
+ cssHref string
+
+ nav []navItem
+ staticFileServer http.Handler
+}
+
+// New assembles a Server from the shared SourceHut config.
+//
+// [spec.sr.ht] origin and [meta.sr.ht] origin are required: without the first
+// there is no return_to to hand meta, and without the second there is no login
+// at all. A missing key is a clear error rather than a panic, so the daemon can
+// fail startup loudly.
+func New(opts Options) (*Server, error) {
+ if opts.Conf == nil {
+ return nil, fmt.Errorf("web: config is required")
+ }
+ if opts.Reader == nil {
+ return nil, fmt.Errorf("web: Reader is required")
+ }
+ if opts.Searcher == nil {
+ return nil, fmt.Errorf("web: Searcher is required")
+ }
+ if opts.Resolver == nil {
+ return nil, fmt.Errorf("web: authn Resolver is required")
+ }
+
+ origin := config.GetOrigin(opts.Conf, authn.ConfigSection, true)
+ if origin == "" {
+ return nil, fmt.Errorf("web: [%s] origin is required", authn.ConfigSection)
+ }
+ metaOrigin := config.GetOrigin(opts.Conf, "meta.sr.ht", true)
+ if metaOrigin == "" {
+ return nil, fmt.Errorf("web: [meta.sr.ht] origin is required")
+ }
+
+ cssHref, err := resolveCSSHref()
+ if err != nil {
+ return nil, err
+ }
+ if cssHref == "" {
+ log.Printf("web: no main.min.*.css embedded in this binary — pages will " +
+ "render unstyled; run `make css` before `go build`")
+ }
+
+ staticSub, err := fs.Sub(staticFS, "static")
+ if err != nil {
+ return nil, fmt.Errorf("web: sub static FS: %w", err)
+ }
+
+ return &Server{
+ reader: opts.Reader,
+ searcher: opts.Searcher,
+ resolver: opts.Resolver,
+ renderer: doc.NewRenderer(),
+ siteName: config.GetString(opts.Conf, "sr.ht", "site-name", "sourcehut"),
+ environment: config.GetString(opts.Conf, "sr.ht", "environment", "production"),
+ metaOrigin: metaOrigin,
+ origin: origin,
+ hubOrigin: config.GetOrigin(opts.Conf, "hub.sr.ht", true),
+ cssHref: cssHref,
+ nav: buildNav(opts.Conf),
+ staticFileServer: http.StripPrefix("/static/", http.FileServer(http.FS(staticSub))),
+ }, nil
+}
+
+// resolveCSSHref globs the embedded static FS for the content-addressed
+// stylesheet and returns its site-absolute URL, or "" when the binary was built
+// without one.
+//
+// Absence is reported rather than substituted: there is no placeholder href to
+// invent, and a link to a stylesheet that is not there would 404 on every page
+// load instead of saying what is wrong once, at startup.
+func resolveCSSHref() (string, error) {
+ matches, err := fs.Glob(staticFS, "static/main.min.*.css")
+ if err != nil {
+ return "", fmt.Errorf("web: glob stylesheet: %w", err)
+ }
+ if len(matches) == 0 {
+ return "", nil
+ }
+ return "/static/" + path.Base(matches[0]), nil
+}
A web/static/logo.svg => web/static/logo.svg +14 -0
@@ 0,0 1,14 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
+ <style>
+ @media (prefers-color-scheme: light) {
+ #outline { display: none }
+ #logo { stroke: black }
+ }
+ @media (prefers-color-scheme: dark) {
+ #outline { display: none }
+ #logo { stroke: white }
+ }
+ </style>
+ <circle id="outline" cx="64" cy="64" r="50" fill="none" stroke-width="14" stroke="#888" />
+ <circle id="logo" cx="64" cy="64" r="50" fill="none" stroke-width="10" stroke="white" />
+</svg>
A web/templates.go => web/templates.go +101 -0
@@ 0,0 1,101 @@
+package web
+
+import (
+ "bytes"
+ "embed"
+ "html/template"
+ "log"
+ "net/http"
+ "strings"
+)
+
+// tmplFS holds the page templates. Each page is parsed together with the shared
+// layout into its own template set so that per-page "content" defines do not
+// collide across pages.
+//
+//go:embed templates/*.html
+var tmplFS embed.FS
+
+// staticFS holds the built assets: the hashed stylesheet produced by `make css`
+// and the logo. It is compiled into the binary, which is why `make css` alone
+// does not restyle a running daemon — see the package doc.
+//
+//go:embed static
+var staticFS embed.FS
+
+// funcMap holds the template helpers shared by every page.
+var funcMap = template.FuncMap{
+ // shortsha abbreviates an object id to 8 hex chars.
+ "shortsha": func(s string) string {
+ if len(s) > 8 {
+ return s[:8]
+ }
+ return s
+ },
+ // indent renders a tree depth as non-breaking space, so the space view's
+ // hierarchy reads as a hierarchy without a nested-list template recursion.
+ // A negative depth (a level-1 heading, once decremented) indents nothing.
+ "indent": func(depth int) template.HTML {
+ if depth <= 0 {
+ return ""
+ }
+ return template.HTML(strings.Repeat(" ", depth))
+ },
+ // dec turns a 1-based heading level into a 0-based indent depth.
+ "dec": func(n int) int { return n - 1 },
+}
+
+// pageNames are the content templates; each is parsed with layout.html.
+var pageNames = []string{"index", "space", "document", "search", "error"}
+
+// pages maps a page name to its parsed template set (layout + that page).
+var pages = func() map[string]*template.Template {
+ m := make(map[string]*template.Template, len(pageNames))
+ for _, name := range pageNames {
+ t := template.New("layout.html").Funcs(funcMap)
+ t = template.Must(t.ParseFS(tmplFS, "templates/layout.html", "templates/"+name+".html"))
+ m[name] = t
+ }
+ return m
+}()
+
+// render executes a page into a buffer first, so a template error yields a
+// clean 500 rather than a half-written response. On success it writes the
+// status and the buffered HTML.
+func (s *Server) render(w http.ResponseWriter, status int, page string, vd viewData) {
+ t, ok := pages[page]
+ if !ok {
+ log.Printf("web: unknown template page %q", page)
+ http.Error(w, "internal server error", http.StatusInternalServerError)
+ return
+ }
+ var buf bytes.Buffer
+ if err := t.ExecuteTemplate(&buf, "layout.html", vd); err != nil {
+ log.Printf("web: executing template %q: %v", page, err)
+ http.Error(w, "internal server error", http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(status)
+ _, _ = buf.WriteTo(w)
+}
+
+// errorData is the payload of the error page.
+type errorData struct {
+ Status int
+ StatusText string
+ Message string
+}
+
+// renderError renders the chrome-wrapped error page. It never recurses into
+// render on failure (render falls back to http.Error itself).
+func (s *Server) renderError(w http.ResponseWriter, r *http.Request, status int, message string) {
+ vd := s.chrome(r)
+ vd.Title = http.StatusText(status)
+ vd.Data = errorData{
+ Status: status,
+ StatusText: http.StatusText(status),
+ Message: message,
+ }
+ s.render(w, status, "error", vd)
+}
A web/templates/document.html => web/templates/document.html +96 -0
@@ 0,0 1,96 @@
+{{define "content"}}
+<div class="row">
+ <div class="col-md-12">
+ <p class="text-muted">
+ <a href="{{.Data.SpaceHref}}">{{.Data.SpaceRef}}</a> / <code>{{.Data.Path}}</code>
+ </p>
+ <h2>{{.Data.Title}}</h2>
+ {{if .Data.Summary}}<p class="lead">{{.Data.Summary}}</p>{{end}}
+ </div>
+</div>
+
+<div class="row">
+ <div class="col-md-9">
+ <article class="spec-doc">
+ {{.Data.Body}}
+ </article>
+
+ {{if .Data.Missing}}
+ <div class="alert alert-warning">
+ Unresolved wikilinks:
+ {{range .Data.Missing}}<code>{{.}}</code> {{end}}
+ </div>
+ {{end}}
+ </div>
+
+ <aside class="col-md-3">
+ <h4>Metadata</h4>
+ <dl>
+ <dt>Address</dt>
+ <dd><code>{{.Data.Address}}</code></dd>
+ <dt>ID</dt>
+ <dd><code>{{if .Data.DocID}}{{.Data.DocID}}{{else}}{{.Data.ID}}{{end}}</code></dd>
+ {{if .Data.Status}}<dt>Status</dt><dd>{{.Data.Status}}</dd>{{end}}
+ {{if .Data.Type}}<dt>Type</dt><dd>{{.Data.Type}}</dd>{{end}}
+ {{if .Data.Tags}}
+ <dt>Tags</dt>
+ <dd>{{range .Data.Tags}}<span class="badge badge-secondary">{{.}}</span> {{end}}</dd>
+ {{end}}
+ {{if .Data.Owners}}<dt>Owners</dt><dd>{{range .Data.Owners}}{{.}} {{end}}</dd>{{end}}
+ <dt>Revision</dt>
+ <dd>
+ <code title="{{.Data.Rev}}">{{shortsha .Data.Rev}}</code>
+ {{if .Data.Pinned}}<span class="badge badge-secondary">pinned</span>
+ {{else}}<span class="badge badge-secondary">approved</span>{{end}}
+ </dd>
+ <dt>Blob</dt>
+ <dd><code title="{{.Data.Blob}}">{{shortsha .Data.Blob}}</code></dd>
+ <dt>Words</dt>
+ <dd>{{.Data.WordCount}}</dd>
+ </dl>
+
+ <h4>Formats</h4>
+ <ul class="list-unstyled">
+ <li><a href="{{.Data.RawHref}}">raw markdown (.md)</a></li>
+ <li><a href="{{.Data.JSONHref}}">metadata + body (.json)</a></li>
+ {{if not .Data.Pinned}}
+ <li><a href="?rev={{.Data.Rev}}">permalink to this revision</a></li>
+ {{else}}
+ <li><a href="{{.Data.SpaceHref}}">back to the approved revision</a></li>
+ {{end}}
+ </ul>
+
+ {{if .Data.Props}}
+ <h4>Frontmatter</h4>
+ <dl>
+ {{range .Data.Props}}<dt>{{.Name}}</dt><dd>{{.Value}}</dd>{{end}}
+ </dl>
+ {{end}}
+
+ {{if .Data.Headings}}
+ <h4>Contents</h4>
+ <ul class="list-unstyled">
+ {{range .Data.Headings}}
+ <li>{{indent (dec .Level)}}<a href="#{{.ID}}">{{.Text}}</a></li>
+ {{end}}
+ </ul>
+ {{end}}
+
+ {{if .Data.Children}}
+ <h4>Children</h4>
+ <ul class="list-unstyled">
+ {{range .Data.Children}}<li><a href="{{.Href}}">{{.Title}}</a></li>{{end}}
+ </ul>
+ {{end}}
+
+ <h4>Backlinks</h4>
+ {{if .Data.Backlinks}}
+ <ul class="list-unstyled">
+ {{range .Data.Backlinks}}<li><a href="{{.Href}}">{{.Title}}</a></li>{{end}}
+ </ul>
+ {{else}}
+ <p class="text-muted">Nothing links here.</p>
+ {{end}}
+ </aside>
+</div>
+{{end}}
A web/templates/error.html => web/templates/error.html +9 -0
@@ 0,0 1,9 @@
+{{define "content"}}
+<div class="row">
+ <div class="col-md-12">
+ <h2>{{.Data.Status}} — {{.Data.StatusText}}</h2>
+ {{if .Data.Message}}<p class="text-muted">{{.Data.Message}}</p>{{end}}
+ <p><a href="/">Return to the landing page</a>.</p>
+ </div>
+</div>
+{{end}}
A web/templates/index.html => web/templates/index.html +54 -0
@@ 0,0 1,54 @@
+{{define "content"}}
+<div class="row">
+ <div class="col-md-12">
+ <h2>{{.SiteName}} <span class="text-danger">spec</span></h2>
+ <p>
+ Reviewable document storage: agents propose, you curate, agents read back
+ the approved text. Reads default to the approved revision; add
+ <code>?rev=<sha></code> to pin any page to an immutable one.
+ </p>
+ </div>
+</div>
+
+{{if .Data.LoggedIn}}
+<div class="row">
+ <div class="col-md-12">
+ <form method="GET" action="/search" class="form-inline">
+ <div class="form-group">
+ <label class="sr-only" for="q">Query</label>
+ <input class="form-control" type="text" id="q" name="q"
+ placeholder="search every space" autocomplete="off">
+ </div>
+ <button class="btn btn-primary" type="submit">Search</button>
+ </form>
+ </div>
+</div>
+
+<div class="row">
+ <div class="col-md-12">
+ <h3>Your spaces</h3>
+ {{if .Data.Spaces}}
+ <ul class="list-unstyled">
+ {{range .Data.Spaces}}
+ <li><a href="{{.Href}}"><strong>{{.Ref}}</strong></a></li>
+ {{end}}
+ </ul>
+ {{else}}
+ <p class="text-muted">
+ No spaces yet. A space is a bare git repository owned by this service;
+ it appears here once it has been created and pushed to.
+ </p>
+ {{end}}
+ </div>
+</div>
+{{else}}
+<div class="row">
+ <div class="col-md-12">
+ <p>
+ <a href="{{.LoginURL}}" rel="nofollow">Log in</a> to browse the spaces on
+ this instance. Agents authenticate with a bearer token instead.
+ </p>
+ </div>
+</div>
+{{end}}
+{{end}}
A web/templates/layout.html => web/templates/layout.html +60 -0
@@ 0,0 1,60 @@
+<!doctype html>
+<html lang="en">
+ <head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title>{{.Title}}</title>
+ <link rel="icon" type="image/svg+xml" href="/static/logo.svg">
+ {{if .CSSHref}}<link rel="stylesheet" href="{{.CSSHref}}">{{end}}
+ {{block "head" .}}{{end}}
+ </head>
+ <body>
+ {{if .ShowBanner}}
+ <div style="background: #228800; color: white; font-weight: bold; width: 100%; text-align: center">
+ {{.Environment}} ENVIRONMENT
+ </div>
+ {{end}}
+ <nav class="container navbar navbar-light navbar-expand-sm">
+ {{if .HubOrigin}}
+ <span class="navbar-brand">
+ <a href="{{.HubOrigin}}">{{.SiteName}}</a>
+ </span>
+ {{else}}
+ <span class="navbar-brand">
+ <a class="navbar-brand" href="/">
+ {{.SiteName}}
+ <span class="text-danger">spec</span>
+ </a>
+ </span>
+ {{end}}
+ <ul class="navbar-nav">
+ {{if .Username}}
+ {{range .Nav}}
+ <li class="nav-item {{if .Active}}active{{end}}">
+ <a class="nav-link" href="{{.Origin}}">{{.Name}}</a>
+ </li>
+ {{end}}
+ {{end}}
+ </ul>
+ <div class="login">
+ {{if .Username}}
+ <span class="navbar-text">
+ Logged in as
+ <a href="{{.ProfileURL}}">{{.Username}}</a>
+ —
+ <a href="{{.LogoutURL}}">Log out</a>
+ </span>
+ {{else}}
+ <span class="navbar-text">
+ <a href="{{.LoginURL}}" rel="nofollow">Log in</a>
+ —
+ <a href="{{.RegisterURL}}">Register</a>
+ </span>
+ {{end}}
+ </div>
+ </nav>
+ <div class="{{.ContainerClass}}">
+ {{template "content" .}}
+ </div>
+ </body>
+</html>
A web/templates/search.html => web/templates/search.html +48 -0
@@ 0,0 1,48 @@
+{{define "content"}}
+<div class="row">
+ <div class="col-md-12">
+ <h2>Search</h2>
+ <form method="GET" action="/search" class="form-inline">
+ <div class="form-group">
+ <label class="sr-only" for="q">Query</label>
+ <input class="form-control" type="text" id="q" name="q"
+ value="{{.Data.Query}}" placeholder="query" autocomplete="off">
+ </div>
+ <div class="form-group">
+ <label class="sr-only" for="space">Space</label>
+ <select class="form-control" id="space" name="space">
+ <option value="">every space</option>
+ {{$sel := .Data.Space}}
+ {{range .Data.Spaces}}
+ <option value="{{.Ref}}" {{if eq .Ref $sel}}selected{{end}}>{{.Ref}}</option>
+ {{end}}
+ </select>
+ </div>
+ <button class="btn btn-primary" type="submit">Search</button>
+ </form>
+ </div>
+</div>
+
+<div class="row">
+ <div class="col-md-12">
+ {{if .Data.Query}}
+ <p class="text-muted">{{.Data.Total}} match{{if ne .Data.Total 1}}es{{end}} in {{.Data.Took}}</p>
+ {{if .Data.Hits}}
+ <ul class="list-unstyled">
+ {{range .Data.Hits}}
+ <li class="search-hit">
+ <a href="{{.Href}}"><strong>{{.Title}}</strong></a>
+ <span class="text-muted">— {{.Space}}{{if .Section}} / {{.Section}}{{end}}</span>
+ {{if .Snippet}}<div class="search-snippet">{{.Snippet}}</div>{{end}}
+ </li>
+ {{end}}
+ </ul>
+ {{else}}
+ <p class="text-muted">No documents matched.</p>
+ {{end}}
+ {{else}}
+ <p class="text-muted">Enter a query.</p>
+ {{end}}
+ </div>
+</div>
+{{end}}
A web/templates/space.html => web/templates/space.html +54 -0
@@ 0,0 1,54 @@
+{{define "content"}}
+<div class="row">
+ <div class="col-md-12">
+ <h2>{{.Data.Ref}}</h2>
+ <p class="text-muted">
+ {{.Data.Count}} document{{if ne .Data.Count 1}}s{{end}} at
+ <code title="{{.Data.Rev}}">{{shortsha .Data.Rev}}</code>
+ {{if .Data.Pinned}}
+ <span class="badge badge-secondary">pinned</span>
+ — <a href="/{{.Data.Ref}}">back to the approved revision</a>
+ {{else}}
+ <span class="badge badge-secondary">approved</span>
+ — <a href="/{{.Data.Ref}}?rev={{.Data.Rev}}">permalink to this revision</a>
+ {{end}}
+ </p>
+ <form method="GET" action="/search" class="form-inline">
+ <input type="hidden" name="space" value="{{.Data.Ref}}">
+ <div class="form-group">
+ <label class="sr-only" for="q">Query</label>
+ <input class="form-control" type="text" id="q" name="q"
+ placeholder="search {{.Data.Ref}}" autocomplete="off">
+ </div>
+ <button class="btn btn-primary" type="submit">Search</button>
+ </form>
+ </div>
+</div>
+
+<div class="row">
+ <div class="col-md-12">
+ {{if .Data.Items}}
+ <table class="table">
+ <thead>
+ <tr><th>Document</th><th>ID</th><th>Status</th><th>Section</th></tr>
+ </thead>
+ <tbody>
+ {{range .Data.Items}}
+ <tr>
+ <td>
+ {{indent .Depth}}<a href="{{.Href}}">{{.Title}}</a>
+ {{if .Summary}}<div class="text-muted"><small>{{indent .Depth}}{{.Summary}}</small></div>{{end}}
+ </td>
+ <td><code>{{if .DocID}}{{.DocID}}{{else}}—{{end}}</code></td>
+ <td>{{if .Status}}<span class="badge badge-secondary">{{.Status}}</span>{{end}}</td>
+ <td class="text-muted">{{.Section}}</td>
+ </tr>
+ {{end}}
+ </tbody>
+ </table>
+ {{else}}
+ <p class="text-muted">This space has no documents at this revision.</p>
+ {{end}}
+ </div>
+</div>
+{{end}}
A web/web_test.go => web/web_test.go +719 -0
@@ 0,0 1,719 @@
+package web
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "sort"
+ "strings"
+ "testing"
+
+ "github.com/fernet/fernet-go"
+ "github.com/go-git/go-git/v5/plumbing"
+ "github.com/vaughan0/go-ini"
+ "sourcecraft.dev/bigbes/sr-ht-core/crypto"
+
+ "sourcecraft.dev/bigbes/sr-ht-spec/authn"
+ "sourcecraft.dev/bigbes/sr-ht-spec/core"
+ "sourcecraft.dev/bigbes/sr-ht-spec/doc"
+ "sourcecraft.dev/bigbes/sr-ht-spec/gitx"
+ "sourcecraft.dev/bigbes/sr-ht-spec/search"
+ "sourcecraft.dev/bigbes/sr-ht-spec/service"
+)
+
+// testConf carries the crypto keys established in TestMain so tests can seal
+// unified-login cookies the way meta.sr.ht does.
+var testConf ini.File
+
+func TestMain(m *testing.M) {
+ var fk fernet.Key
+ if err := fk.Generate(); err != nil {
+ panic("generate fernet key: " + err.Error())
+ }
+ seed := make([]byte, 32)
+ if _, err := rand.Read(seed); err != nil {
+ panic("generate webhook seed: " + err.Error())
+ }
+ testConf = ini.File{
+ "sr.ht": ini.Section{"network-key": fk.Encode()},
+ "webhooks": ini.Section{"private-key": base64.StdEncoding.EncodeToString(seed)},
+ }
+ crypto.InitCrypto(testConf)
+ os.Exit(m.Run())
+}
+
+// ---- fixtures -------------------------------------------------------------
+
+const (
+ headRev = "1111111111111111111111111111111111111111"
+ oldRev = "2222222222222222222222222222222222222222"
+ agentTk = "test-agent-token"
+)
+
+var demoSpace = core.SpaceRef{Owner: "bigbes", Name: "rfcs"}
+
+// headDocs is the space at its approved head. SPEC-0007 links to SPEC-0003, so
+// SPEC-0003 has a backlink; notes/plain.md has no frontmatter at all and is
+// therefore addressed by its path, per the design's addressing rule.
+var headDocs = map[string]string{
+ "specs/0007-storage.md": `---
+id: SPEC-0007
+title: Proposal storage model
+status: draft
+tags: [storage, review]
+summary: How proposals are stored.
+---
+
+# Proposal storage model
+
+Git is authoritative, and this supersedes [[SPEC-0003]].
+`,
+ "specs/0003-old.md": `---
+id: SPEC-0003
+title: Older storage sketch
+status: superseded
+---
+
+# Older storage sketch
+
+Superseded by the storage model.
+`,
+ "notes/plain.md": `# Just a note
+
+No frontmatter here at all.
+`,
+}
+
+// oldDocs is the same space at a pinned, older revision: the title differs, so
+// a test can prove ?rev= actually reached a different tree.
+var oldDocs = map[string]string{
+ "specs/0007-storage.md": `---
+id: SPEC-0007
+title: Storage, first draft
+status: draft
+---
+
+# Storage, first draft
+
+An earlier sketch.
+`,
+}
+
+// fakeReader is an in-memory Reader: a space is a revision-keyed set of
+// documents. It exists so the handlers can be tested against a document set
+// rather than against Postgres plus a tree of bare repositories.
+type fakeReader struct {
+ revs map[string]map[string]string // rev -> path -> content
+ head string
+}
+
+func newFakeReader() *fakeReader {
+ return &fakeReader{
+ revs: map[string]map[string]string{headRev: headDocs, oldRev: oldDocs},
+ head: headRev,
+ }
+}
+
+func (f *fakeReader) ListSpaces(context.Context) ([]core.SpaceRef, error) {
+ return []core.SpaceRef{demoSpace}, nil
+}
+
+// at resolves a revision the way service.ResolveRev does: ApprovedRev means the
+// approved head, anything else must name a revision that exists.
+func (f *fakeReader) at(ref core.SpaceRef, rev string) (string, map[string]string, error) {
+ if ref != demoSpace {
+ return "", nil, fmt.Errorf("%w: space %s", service.ErrNotFound, ref)
+ }
+ if rev == service.ApprovedRev {
+ rev = f.head
+ }
+ docs, ok := f.revs[rev]
+ if !ok {
+ return "", nil, fmt.Errorf("%w: revision %q in %s", service.ErrNotFound, rev, ref)
+ }
+ return rev, docs, nil
+}
+
+func (f *fakeReader) Snapshot(_ context.Context, ref core.SpaceRef, rev string) (*Snapshot, error) {
+ resolved, docs, err := f.at(ref, rev)
+ if err != nil {
+ return nil, err
+ }
+ paths := make([]string, 0, len(docs))
+ for p := range docs {
+ paths = append(paths, p)
+ }
+ sort.Strings(paths)
+
+ gd := make([]gitx.Document, 0, len(paths))
+ bodies := make(map[string][]byte, len(paths))
+ for _, p := range paths {
+ data := []byte(docs[p])
+ gd = append(gd, gitx.Document{
+ Path: p,
+ Blob: plumbing.ComputeHash(plumbing.BlobObject, data),
+ Data: data,
+ })
+ bodies[p] = data
+ }
+ return &Snapshot{
+ Ref: ref,
+ Rev: resolved,
+ Archive: doc.FromDocuments(ref, resolved, gd),
+ Bodies: bodies,
+ }, nil
+}
+
+func (f *fakeReader) ReadDocument(_ context.Context, ref core.SpaceRef, rev, p string) (service.Document, error) {
+ resolved, docs, err := f.at(ref, rev)
+ if err != nil {
+ return service.Document{}, err
+ }
+ content, ok := docs[p]
+ if !ok {
+ return service.Document{}, fmt.Errorf("%w: %s in %s at %s", service.ErrNotFound, p, ref, resolved)
+ }
+ return service.Document{
+ Path: p,
+ Blob: plumbing.ComputeHash(plumbing.BlobObject, []byte(content)).String(),
+ Rev: resolved,
+ Data: []byte(content),
+ }, nil
+}
+
+// fakeSearcher returns one fixed hit whose snippet carries the <mark> tags
+// bleve's highlighter emits.
+type fakeSearcher struct {
+ last search.Query
+ err error
+}
+
+func (s *fakeSearcher) Search(_ context.Context, q search.Query) (search.Results, error) {
+ s.last = q
+ if s.err != nil {
+ return search.Results{}, s.err
+ }
+ return search.Results{
+ Total: 1,
+ Hits: []search.Hit{{
+ Space: demoSpace,
+ ID: "SPEC-0007",
+ Rev: headRev,
+ Path: "specs/0007-storage.md",
+ Title: "Proposal storage model",
+ Section: "specs",
+ Score: 1.5,
+ Snippet: `Git is <mark>authoritative</mark> & boring`,
+ }},
+ }, nil
+}
+
+// stubTokenStore knows exactly one live agent token.
+type stubTokenStore struct{}
+
+func (stubTokenStore) LookupAgentToken(_ context.Context, hash []byte) (authn.AgentToken, error) {
+ want := authn.HashToken(agentTk)
+ if string(hash) != string(want) {
+ return authn.AgentToken{}, authn.ErrUnknownToken
+ }
+ return authn.AgentToken{ID: 1, Name: "test", Hash: want}, nil
+}
+
+// testServer wires a Server with the fake reader/searcher behind the same
+// middleware the daemon installs, and returns the handler.
+func testServer(t *testing.T) (http.Handler, *fakeSearcher) {
+ t.Helper()
+ conf := ini.File{
+ "sr.ht": ini.Section{
+ "network-key": testConf.Section("sr.ht")["network-key"],
+ "site-name": "sourcehut",
+ "environment": "development",
+ "owner-name": "bigbes",
+ },
+ "webhooks": ini.Section{"private-key": testConf.Section("webhooks")["private-key"]},
+ "spec.sr.ht": ini.Section{"origin": "https://spec.example"},
+ "meta.sr.ht": ini.Section{"origin": "https://meta.example"},
+ "git.sr.ht": ini.Section{"origin": "https://git.example"},
+ // Extra service sections to exercise nav ordering/exclusions.
+ "todo.sr.ht": ini.Section{"origin": "https://todo.example"},
+ "paste.sr.ht": ini.Section{"origin": "https://paste.example"},
+ "pages.sr.ht": ini.Section{"origin": "https://pages.example"},
+ "hub.sr.ht": ini.Section{"origin": "https://hub.example"},
+ }
+ resolver, err := authn.NewResolver("bigbes", stubTokenStore{})
+ if err != nil {
+ t.Fatalf("NewResolver: %v", err)
+ }
+ searcher := &fakeSearcher{}
+ srv, err := New(Options{
+ Conf: conf,
+ Reader: newFakeReader(),
+ Searcher: searcher,
+ Resolver: resolver,
+ })
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ return srv.Handler(), searcher
+}
+
+// login seals a unified-login cookie for the given user onto a request — the
+// same shape meta.sr.ht writes, sealed with the shared network key.
+func login(req *http.Request, user string) {
+ payload, _ := json.Marshal(map[string]string{"name": user})
+ req.AddCookie(&http.Cookie{Name: authn.CookieName, Value: string(crypto.Encrypt(payload))})
+}
+
+func get(t *testing.T, h http.Handler, target, user string) *httptest.ResponseRecorder {
+ t.Helper()
+ req := httptest.NewRequest(http.MethodGet, target, nil)
+ if user != "" {
+ login(req, user)
+ }
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ return rec
+}
+
+func getAgent(t *testing.T, h http.Handler, target, token string) *httptest.ResponseRecorder {
+ t.Helper()
+ req := httptest.NewRequest(http.MethodGet, target, nil)
+ req.Header.Set("Authorization", "Bearer "+token)
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ return rec
+}
+
+// ---- URL grammar ----------------------------------------------------------
+
+func TestDocumentAddressHasNoExtension(t *testing.T) {
+ h, _ := testServer(t)
+ rec := get(t, h, "/~bigbes/rfcs/specs/0007-storage", "bigbes")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200\n%s", rec.Code, rec.Body.String())
+ }
+ if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
+ t.Fatalf("content-type = %q, want text/html", ct)
+ }
+ body := rec.Body.String()
+ if !strings.Contains(body, "Proposal storage model") {
+ t.Fatal("rendered page missing the document title")
+ }
+ if !strings.Contains(body, "<h1") {
+ t.Fatalf("body was not rendered as markdown:\n%s", body)
+ }
+ // The rendered wikilink must point at the target document's own
+ // extensionless address.
+ if !strings.Contains(body, `href="/~bigbes/rfcs/specs/0003-old"`) {
+ t.Fatalf("wikilink not resolved to an extensionless address:\n%s", body)
+ }
+}
+
+func TestDocumentRawFormat(t *testing.T) {
+ h, _ := testServer(t)
+ rec := get(t, h, "/~bigbes/rfcs/specs/0007-storage.md", "bigbes")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200", rec.Code)
+ }
+ if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/markdown") {
+ t.Fatalf("content-type = %q, want text/markdown", ct)
+ }
+ body := rec.Body.String()
+ if body != headDocs["specs/0007-storage.md"] {
+ t.Fatalf(".md is not the verbatim source:\n%s", body)
+ }
+ if !strings.HasPrefix(body, "---\n") {
+ t.Fatal(".md must include the frontmatter")
+ }
+ if rec.Header().Get("X-Spec-Rev") != headRev {
+ t.Fatalf("X-Spec-Rev = %q, want %q", rec.Header().Get("X-Spec-Rev"), headRev)
+ }
+}
+
+func TestDocumentJSONFormat(t *testing.T) {
+ h, _ := testServer(t)
+ rec := get(t, h, "/~bigbes/rfcs/specs/0007-storage.json", "bigbes")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200\n%s", rec.Code, rec.Body.String())
+ }
+ if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
+ t.Fatalf("content-type = %q, want application/json", ct)
+ }
+ var payload docJSON
+ if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
+ t.Fatalf("decode: %v\n%s", err, rec.Body.String())
+ }
+ if payload.ID != "SPEC-0007" || payload.DocID != "SPEC-0007" {
+ t.Fatalf("id = %q / doc_id = %q, want SPEC-0007", payload.ID, payload.DocID)
+ }
+ if payload.Address != "specs/0007-storage" {
+ t.Fatalf("address = %q, want the extensionless address", payload.Address)
+ }
+ if payload.Path != "specs/0007-storage.md" {
+ t.Fatalf("path = %q, want the tree path", payload.Path)
+ }
+ if payload.Rev != headRev {
+ t.Fatalf("rev = %q, want %q", payload.Rev, headRev)
+ }
+ if !strings.Contains(payload.Body, "# Proposal storage model") {
+ t.Fatalf("body missing:\n%s", payload.Body)
+ }
+ if strings.Contains(payload.Body, "id: SPEC-0007") {
+ t.Fatal("body must be the body, with the frontmatter lifted into metadata")
+ }
+ if payload.Status != "draft" || len(payload.Tags) != 2 {
+ t.Fatalf("metadata not carried: %+v", payload)
+ }
+}
+
+func TestPinnedRevReachesAnotherTree(t *testing.T) {
+ h, _ := testServer(t)
+
+ head := get(t, h, "/~bigbes/rfcs/specs/0007-storage", "bigbes")
+ if !strings.Contains(head.Body.String(), "Proposal storage model") {
+ t.Fatal("approved head did not render the current title")
+ }
+
+ for _, target := range []string{
+ "/~bigbes/rfcs/specs/0007-storage?rev=" + oldRev,
+ "/~bigbes/rfcs/specs/0007-storage.md?rev=" + oldRev,
+ "/~bigbes/rfcs/specs/0007-storage.json?rev=" + oldRev,
+ "/~bigbes/rfcs?rev=" + oldRev,
+ } {
+ rec := get(t, h, target, "bigbes")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("%s: status = %d\n%s", target, rec.Code, rec.Body.String())
+ }
+ if !strings.Contains(rec.Body.String(), "Storage, first draft") {
+ t.Fatalf("%s: pinned read did not reach the older revision:\n%s", target, rec.Body.String())
+ }
+ }
+}
+
+func TestPinnedPageKeepsThePinOnItsLinks(t *testing.T) {
+ h, _ := testServer(t)
+ rec := get(t, h, "/~bigbes/rfcs?rev="+headRev, "bigbes")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d", rec.Code)
+ }
+ if !strings.Contains(rec.Body.String(), "/~bigbes/rfcs/specs/0007-storage?rev="+headRev) {
+ t.Fatalf("space listing dropped the pin:\n%s", rec.Body.String())
+ }
+
+ rec = get(t, h, "/~bigbes/rfcs/specs/0007-storage?rev="+headRev, "bigbes")
+ if !strings.Contains(rec.Body.String(), `href="/~bigbes/rfcs/specs/0003-old?rev=`+headRev+`"`) {
+ t.Fatalf("a wikilink out of a pinned page dropped the pin:\n%s", rec.Body.String())
+ }
+}
+
+func TestUnknownRevIs404(t *testing.T) {
+ h, _ := testServer(t)
+ rec := get(t, h, "/~bigbes/rfcs/specs/0007-storage?rev=deadbeef", "bigbes")
+ if rec.Code != http.StatusNotFound {
+ t.Fatalf("status = %d, want 404", rec.Code)
+ }
+}
+
+func TestDocumentIDRedirectsToItsPath(t *testing.T) {
+ h, _ := testServer(t)
+ rec := get(t, h, "/~bigbes/rfcs/SPEC-0007", "bigbes")
+ if rec.Code != http.StatusFound {
+ t.Fatalf("status = %d, want 302\n%s", rec.Code, rec.Body.String())
+ }
+ if loc := rec.Header().Get("Location"); loc != "/~bigbes/rfcs/specs/0007-storage" {
+ t.Fatalf("location = %q", loc)
+ }
+}
+
+// ---- addressing rule ------------------------------------------------------
+
+func TestDocumentWithoutFrontmatterIsAddressedByPath(t *testing.T) {
+ h, _ := testServer(t)
+ rec := get(t, h, "/~bigbes/rfcs/notes/plain.json", "bigbes")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d\n%s", rec.Code, rec.Body.String())
+ }
+ var payload docJSON
+ if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
+ t.Fatal(err)
+ }
+ if payload.DocID != "" {
+ t.Fatalf("doc_id = %q, want empty for a document with no id:", payload.DocID)
+ }
+ if payload.ID != "notes/plain" {
+ t.Fatalf("id = %q, want the path minus its extension", payload.ID)
+ }
+ if payload.Title != "Just a note" {
+ t.Fatalf("title = %q, want the first H1", payload.Title)
+ }
+}
+
+func TestBacklinksAreRendered(t *testing.T) {
+ h, _ := testServer(t)
+ rec := get(t, h, "/~bigbes/rfcs/specs/0003-old", "bigbes")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d\n%s", rec.Code, rec.Body.String())
+ }
+ body := rec.Body.String()
+ i := strings.Index(body, "<h4>Backlinks</h4>")
+ if i < 0 {
+ t.Fatal("no backlinks section")
+ }
+ if !strings.Contains(body[i:], "/~bigbes/rfcs/specs/0007-storage") {
+ t.Fatalf("SPEC-0007 links to SPEC-0003 but is not listed as a backlink:\n%s", body[i:])
+ }
+}
+
+// ---- not found ------------------------------------------------------------
+
+func TestMissingDocumentIs404(t *testing.T) {
+ h, _ := testServer(t)
+ for _, target := range []string{
+ "/~bigbes/rfcs/specs/nope",
+ "/~bigbes/rfcs/specs/nope.md",
+ "/~bigbes/rfcs/specs/nope.json",
+ } {
+ rec := get(t, h, target, "bigbes")
+ if rec.Code != http.StatusNotFound {
+ t.Fatalf("%s: status = %d, want 404\n%s", target, rec.Code, rec.Body.String())
+ }
+ }
+}
+
+func TestMissingSpaceIs404(t *testing.T) {
+ h, _ := testServer(t)
+ rec := get(t, h, "/~bigbes/nope", "bigbes")
+ if rec.Code != http.StatusNotFound {
+ t.Fatalf("status = %d, want 404", rec.Code)
+ }
+}
+
+func TestTrailingSlashRedirectsToTheSpace(t *testing.T) {
+ h, _ := testServer(t)
+ rec := get(t, h, "/~bigbes/rfcs/", "bigbes")
+ if rec.Code != http.StatusFound {
+ t.Fatalf("status = %d, want 302", rec.Code)
+ }
+ if loc := rec.Header().Get("Location"); loc != "/~bigbes/rfcs" {
+ t.Fatalf("location = %q", loc)
+ }
+}
+
+// ---- identity and chrome --------------------------------------------------
+
+func TestForgedCookieYieldsLoggedInNav(t *testing.T) {
+ h, _ := testServer(t)
+ rec := get(t, h, "/", "bigbes")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d", rec.Code)
+ }
+ body := rec.Body.String()
+ if !strings.Contains(body, "Logged in as") || !strings.Contains(body, ">bigbes<") {
+ t.Fatalf("cookie did not produce a logged-in nav:\n%s", body)
+ }
+ if !strings.Contains(body, "~bigbes/rfcs") {
+ t.Fatal("logged-in landing page missing the space list")
+ }
+ if !strings.Contains(body, "https://todo.example") {
+ t.Fatal("nav missing an expected service")
+ }
+ if strings.Contains(body, "https://paste.example") || strings.Contains(body, "https://pages.example") {
+ t.Fatal("nav must exclude paste/pages")
+ }
+ nav := body[strings.Index(body, `<ul class="navbar-nav">`):strings.Index(body, "</ul>")]
+ if strings.Contains(nav, "hub.example") {
+ t.Fatal("hub is the brand, never a switcher item")
+ }
+ if !strings.Contains(nav, "nav-item active") {
+ t.Fatal("spec should be the active nav item")
+ }
+ if !strings.Contains(body, "DEVELOPMENT ENVIRONMENT") {
+ t.Fatal("non-production environment banner missing")
+ }
+}
+
+// A cookie sealed for somebody who is not the instance owner carries no
+// authority: authn resolves it to anonymous, and the nav must agree.
+func TestNonOwnerCookieIsAnonymous(t *testing.T) {
+ h, _ := testServer(t)
+ rec := get(t, h, "/", "someoneelse")
+ body := rec.Body.String()
+ if strings.Contains(body, "Logged in as") {
+ t.Fatalf("a non-owner cookie produced a logged-in nav:\n%s", body)
+ }
+ if strings.Contains(body, "~bigbes/rfcs") {
+ t.Fatal("a non-owner must not see the space list")
+ }
+}
+
+func TestAnonymousLandingRendersWithoutContent(t *testing.T) {
+ h, _ := testServer(t)
+ rec := get(t, h, "/", "")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200", rec.Code)
+ }
+ body := rec.Body.String()
+ if !strings.Contains(body, "return_to=") {
+ t.Fatal("login URL missing return_to")
+ }
+ if strings.Contains(body, "~bigbes/rfcs") {
+ t.Fatal("anonymous landing page leaked a space name")
+ }
+}
+
+func TestAnonymousContentRedirectsToLogin(t *testing.T) {
+ h, _ := testServer(t)
+ for _, target := range []string{
+ "/~bigbes/rfcs",
+ "/~bigbes/rfcs/specs/0007-storage",
+ "/search?q=storage",
+ } {
+ rec := get(t, h, target, "")
+ if rec.Code != http.StatusFound {
+ t.Fatalf("%s: status = %d, want 302\n%s", target, rec.Code, rec.Body.String())
+ }
+ loc := rec.Header().Get("Location")
+ if !strings.HasPrefix(loc, "https://meta.example/login?return_to=") {
+ t.Fatalf("%s: location = %q", target, loc)
+ }
+ if !strings.Contains(loc, "spec.example") {
+ t.Fatalf("%s: return_to does not point back at us: %q", target, loc)
+ }
+ }
+}
+
+func TestAnonymousMachineFormatsAre401(t *testing.T) {
+ h, _ := testServer(t)
+ for _, target := range []string{
+ "/~bigbes/rfcs/specs/0007-storage.md",
+ "/~bigbes/rfcs/specs/0007-storage.json",
+ } {
+ rec := get(t, h, target, "")
+ if rec.Code != http.StatusUnauthorized {
+ t.Fatalf("%s: status = %d, want 401", target, rec.Code)
+ }
+ if strings.Contains(rec.Body.String(), "Proposal storage model") {
+ t.Fatalf("%s: content leaked to an anonymous client", target)
+ }
+ }
+}
+
+func TestAgentTokenReads(t *testing.T) {
+ h, _ := testServer(t)
+ rec := getAgent(t, h, "/~bigbes/rfcs/specs/0007-storage.md", agentTk)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200\n%s", rec.Code, rec.Body.String())
+ }
+ if !strings.Contains(rec.Body.String(), "id: SPEC-0007") {
+ t.Fatal("agent read did not return the document")
+ }
+}
+
+func TestUnknownAgentTokenIs401(t *testing.T) {
+ h, _ := testServer(t)
+ rec := getAgent(t, h, "/~bigbes/rfcs/specs/0007-storage.md", "not-a-token")
+ if rec.Code != http.StatusUnauthorized {
+ t.Fatalf("status = %d, want 401", rec.Code)
+ }
+}
+
+// ---- search ---------------------------------------------------------------
+
+func TestSearchRendersSnippetAsHTML(t *testing.T) {
+ h, searcher := testServer(t)
+ rec := get(t, h, "/search?q=authoritative&space=~bigbes/rfcs", "bigbes")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d\n%s", rec.Code, rec.Body.String())
+ }
+ if searcher.last.Text != "authoritative" {
+ t.Fatalf("query text = %q", searcher.last.Text)
+ }
+ if len(searcher.last.Spaces) != 1 || searcher.last.Spaces[0] != demoSpace {
+ t.Fatalf("space filter = %+v", searcher.last.Spaces)
+ }
+ body := rec.Body.String()
+ if !strings.Contains(body, "<mark>authoritative</mark>") {
+ t.Fatalf("snippet was escaped instead of rendered as HTML:\n%s", body)
+ }
+ // The hit's URL is the pinned, extensionless address.
+ if !strings.Contains(body, "/~bigbes/rfcs/specs/0007-storage?rev="+headRev) {
+ t.Fatalf("hit href is not a pinned extensionless address:\n%s", body)
+ }
+}
+
+func TestSearchWithoutQueryDoesNotSearch(t *testing.T) {
+ h, searcher := testServer(t)
+ rec := get(t, h, "/search", "bigbes")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d", rec.Code)
+ }
+ if searcher.last.Text != "" {
+ t.Fatal("an empty query must not reach the index")
+ }
+}
+
+// ---- static and health ----------------------------------------------------
+
+func TestHealthz(t *testing.T) {
+ h, _ := testServer(t)
+ rec := get(t, h, "/healthz", "")
+ if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "ok") {
+ t.Fatalf("healthz = %d %q", rec.Code, rec.Body.String())
+ }
+}
+
+func TestStaticLogoIsServed(t *testing.T) {
+ h, _ := testServer(t)
+ rec := get(t, h, "/static/logo.svg", "")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d", rec.Code)
+ }
+ if cc := rec.Header().Get("Cache-Control"); !strings.Contains(cc, "max-age") {
+ t.Fatalf("cache-control = %q", cc)
+ }
+}
+
+// TestHashedCSSIsImmutable checks the cache policy without depending on a built
+// stylesheet: `make css` needs sassc and the shared sourcehut partials, neither
+// of which a test may assume.
+func TestHashedCSSIsImmutable(t *testing.T) {
+ for _, name := range []string{"main.min.79713f25.css", "main.min.abc123.css"} {
+ if !hashedCSSRe.MatchString(name) {
+ t.Fatalf("%s should be recognised as a hashed stylesheet", name)
+ }
+ }
+ for _, name := range []string{"main.css", "main.min.css", "logo.svg"} {
+ if hashedCSSRe.MatchString(name) {
+ t.Fatalf("%s should not be recognised as a hashed stylesheet", name)
+ }
+ }
+}
+
+// ---- unit-level grammar ---------------------------------------------------
+
+func TestSplitFormat(t *testing.T) {
+ cases := []struct {
+ in string
+ addr string
+ f format
+ }{
+ {"specs/0007-storage", "specs/0007-storage", formatHTML},
+ {"specs/0007-storage.md", "specs/0007-storage", formatRaw},
+ {"specs/0007-storage.json", "specs/0007-storage", formatJSON},
+ // A document whose own name ends in ".json" is still addressable: the
+ // selector is peeled off once, from the tail.
+ {"notes/2026.json.md", "notes/2026.json", formatRaw},
+ {"notes/report", "notes/report", formatHTML},
+ }
+ for _, c := range cases {
+ addr, f := splitFormat(c.in)
+ if addr != c.addr || f != c.f {
+ t.Fatalf("splitFormat(%q) = %q/%v, want %q/%v", c.in, addr, f, c.addr, c.f)
+ }
+ }
+}