From 2a7569f43a772345c1bc739089cbad2efae0e278 Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Wed, 22 Jul 2026 14:40:03 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20web=20=E2=80=94=20the=20Phase=202=20rea?= =?UTF-8?q?d=20plane=20UI=20and=20its=20SCSS=20entry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SourceHut chrome (nav service-switcher, login block, environment banner, error page, embedded hashed static assets) ported from compare.sr.ht, plus the read-plane pages: landing, space document tree, rendered document with frontmatter/backlinks, and search. The URL grammar is the design's pinned one: a document's address carries no extension, ".md" is raw source, ".json" is metadata plus body, and ?rev= pins any of the three to an immutable revision. Links rendered inside a pinned page keep the pin, so following one does not silently land on the approved head. One human and no visibility levels, so the read ACL is one line: the owner and its agents read, everyone else is redirected to meta's login (a browser) or refused with 401 (a client asking for .md/.json). --- scss/main.scss | 91 +++++ web/chrome.go | 146 ++++++++ web/handlers.go | 649 ++++++++++++++++++++++++++++++++ web/reader.go | 109 ++++++ web/router.go | 86 +++++ web/server.go | 186 ++++++++++ web/static/logo.svg | 14 + web/templates.go | 101 +++++ web/templates/document.html | 96 +++++ web/templates/error.html | 9 + web/templates/index.html | 54 +++ web/templates/layout.html | 60 +++ web/templates/search.html | 48 +++ web/templates/space.html | 54 +++ web/web_test.go | 719 ++++++++++++++++++++++++++++++++++++ 15 files changed, 2422 insertions(+) create mode 100644 scss/main.scss create mode 100644 web/chrome.go create mode 100644 web/handlers.go create mode 100644 web/reader.go create mode 100644 web/router.go create mode 100644 web/server.go create mode 100644 web/static/logo.svg create mode 100644 web/templates.go create mode 100644 web/templates/document.html create mode 100644 web/templates/error.html create mode 100644 web/templates/index.html create mode 100644 web/templates/layout.html create mode 100644 web/templates/search.html create mode 100644 web/templates/space.html create mode 100644 web/web_test.go diff --git a/scss/main.scss b/scss/main.scss new file mode 100644 index 0000000000000000000000000000000000000000..4082c216728dbda04309ac07b9c6b3a18ef47c66 --- /dev/null +++ b/scss/main.scss @@ -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 ; 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; + } +} diff --git a/web/chrome.go b/web/chrome.go new file mode 100644 index 0000000000000000000000000000000000000000..e9c339679ac55b319b2134c818aed3bbc669d035 --- /dev/null +++ b/web/chrome.go @@ -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) +} diff --git a/web/handlers.go b/web/handlers.go new file mode 100644 index 0000000000000000000000000000000000000000..5dcbbdf7cc7d478ffe1f6e8eb13d30b877ac898c --- /dev/null +++ b/web/handlers.go @@ -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= 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 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/
?rev=#, 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 +} diff --git a/web/reader.go b/web/reader.go new file mode 100644 index 0000000000000000000000000000000000000000..99852ccc38326a18ac7cac1ba04354a803b3a933 --- /dev/null +++ b/web/reader.go @@ -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) +} diff --git a/web/router.go b/web/router.go new file mode 100644 index 0000000000000000000000000000000000000000..40f7d9b27830f6530c414982595b6bcf568d6482 --- /dev/null +++ b/web/router.go @@ -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 +} diff --git a/web/server.go b/web/server.go new file mode 100644 index 0000000000000000000000000000000000000000..750b1661e871d260bca7db7cb73856e50154d71c --- /dev/null +++ b/web/server.go @@ -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= 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..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 +} diff --git a/web/static/logo.svg b/web/static/logo.svg new file mode 100644 index 0000000000000000000000000000000000000000..953592414791428a927096b765e30eac02fe096a --- /dev/null +++ b/web/static/logo.svg @@ -0,0 +1,14 @@ + + + + + diff --git a/web/templates.go b/web/templates.go new file mode 100644 index 0000000000000000000000000000000000000000..3d1b86f252d0560be6f5855a64287615bab921c7 --- /dev/null +++ b/web/templates.go @@ -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) +} diff --git a/web/templates/document.html b/web/templates/document.html new file mode 100644 index 0000000000000000000000000000000000000000..b91dea4b7b84e455f8a44110bdd2e1e70caaea0e --- /dev/null +++ b/web/templates/document.html @@ -0,0 +1,96 @@ +{{define "content"}} +
+
+

+ {{.Data.SpaceRef}} / {{.Data.Path}} +

+

{{.Data.Title}}

+ {{if .Data.Summary}}

{{.Data.Summary}}

{{end}} +
+
+ +
+
+
+ {{.Data.Body}} +
+ + {{if .Data.Missing}} +
+ Unresolved wikilinks: + {{range .Data.Missing}}{{.}} {{end}} +
+ {{end}} +
+ + +
+{{end}} diff --git a/web/templates/error.html b/web/templates/error.html new file mode 100644 index 0000000000000000000000000000000000000000..52baa57535cda3a941aa121a6ea817cc23b11aed --- /dev/null +++ b/web/templates/error.html @@ -0,0 +1,9 @@ +{{define "content"}} +
+
+

{{.Data.Status}} — {{.Data.StatusText}}

+ {{if .Data.Message}}

{{.Data.Message}}

{{end}} +

Return to the landing page.

+
+
+{{end}} diff --git a/web/templates/index.html b/web/templates/index.html new file mode 100644 index 0000000000000000000000000000000000000000..ae41e091736a16150dfe600388b961e5f43f6200 --- /dev/null +++ b/web/templates/index.html @@ -0,0 +1,54 @@ +{{define "content"}} +
+
+

{{.SiteName}} spec

+

+ Reviewable document storage: agents propose, you curate, agents read back + the approved text. Reads default to the approved revision; add + ?rev=<sha> to pin any page to an immutable one. +

+
+
+ +{{if .Data.LoggedIn}} +
+
+
+
+ + +
+ +
+
+
+ +
+
+

Your spaces

+ {{if .Data.Spaces}} +
    + {{range .Data.Spaces}} +
  • {{.Ref}}
  • + {{end}} +
+ {{else}} +

+ 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. +

+ {{end}} +
+
+{{else}} +
+
+

+ Log in to browse the spaces on + this instance. Agents authenticate with a bearer token instead. +

+
+
+{{end}} +{{end}} diff --git a/web/templates/layout.html b/web/templates/layout.html new file mode 100644 index 0000000000000000000000000000000000000000..a636c78c2ba0fc8409cf7deb392b7a8ae050dab3 --- /dev/null +++ b/web/templates/layout.html @@ -0,0 +1,60 @@ + + + + + + {{.Title}} + + {{if .CSSHref}}{{end}} + {{block "head" .}}{{end}} + + + {{if .ShowBanner}} +
+ {{.Environment}} ENVIRONMENT +
+ {{end}} + +
+ {{template "content" .}} +
+ + diff --git a/web/templates/search.html b/web/templates/search.html new file mode 100644 index 0000000000000000000000000000000000000000..1aaaccb491a56f53103dd404a16f41ab540b86bd --- /dev/null +++ b/web/templates/search.html @@ -0,0 +1,48 @@ +{{define "content"}} +
+
+

Search

+
+
+ + +
+
+ + +
+ +
+
+
+ +
+
+ {{if .Data.Query}} +

{{.Data.Total}} match{{if ne .Data.Total 1}}es{{end}} in {{.Data.Took}}

+ {{if .Data.Hits}} +
    + {{range .Data.Hits}} +
  • + {{.Title}} + — {{.Space}}{{if .Section}} / {{.Section}}{{end}} + {{if .Snippet}}
    {{.Snippet}}
    {{end}} +
  • + {{end}} +
+ {{else}} +

No documents matched.

+ {{end}} + {{else}} +

Enter a query.

+ {{end}} +
+
+{{end}} diff --git a/web/templates/space.html b/web/templates/space.html new file mode 100644 index 0000000000000000000000000000000000000000..8f4b33714c60c296add592900d0bac9db5dafada --- /dev/null +++ b/web/templates/space.html @@ -0,0 +1,54 @@ +{{define "content"}} +
+
+

{{.Data.Ref}}

+

+ {{.Data.Count}} document{{if ne .Data.Count 1}}s{{end}} at + {{shortsha .Data.Rev}} + {{if .Data.Pinned}} + pinned + — back to the approved revision + {{else}} + approved + — permalink to this revision + {{end}} +

+
+ +
+ + +
+ +
+
+
+ +
+
+ {{if .Data.Items}} + + + + + + {{range .Data.Items}} + + + + + + + {{end}} + +
DocumentIDStatusSection
+ {{indent .Depth}}{{.Title}} + {{if .Summary}}
{{indent .Depth}}{{.Summary}}
{{end}} +
{{if .DocID}}{{.DocID}}{{else}}—{{end}}{{if .Status}}{{.Status}}{{end}}{{.Section}}
+ {{else}} +

This space has no documents at this revision.

+ {{end}} +
+
+{{end}} diff --git a/web/web_test.go b/web/web_test.go new file mode 100644 index 0000000000000000000000000000000000000000..3fb79b0e491ce6b3dcfea84c5487c6ce800703e7 --- /dev/null +++ b/web/web_test.go @@ -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 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 authoritative & 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, "Backlinks") + 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, `")] + 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, "authoritative") { + 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) + } + } +}