package web import ( "encoding/json" "errors" "fmt" "html/template" "log" "net/http" "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 // The write-plane sentinels the review page's approve/reject can return. A // base that moved under a proposal, a proposal already resolved, and an // already-merged one are all 409; the owner-only refusal is 403. case errors.Is(err, service.ErrForbidden): return http.StatusForbidden case errors.Is(err, service.ErrStale), errors.Is(err, service.ErrAlreadyMerged), errors.Is(err, service.ErrProposalNotOpen): return http.StatusConflict case errors.Is(err, service.ErrInvalid): return http.StatusUnprocessableEntity 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 } // The link graph backlinks are read from is filled in by the reader, at the // same revision as the archive itself. This handler used to render every // document of the space here to build it, once per page view. res := s.renderer.Render(mdBody, doc.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 } // ---- 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()}) } // No space parameter is a search of every space, said in as many words: // the filter carries its polarity, so "the viewer named no space" and "the // viewer named a project with no spaces" cannot collapse into each other. query := search.Query{Text: q, Limit: searchLimit, Spaces: core.EverythingFilter()} if spaceParam != "" { ref, err := core.ParseSpaceRef(spaceParam) if err != nil { s.fail(w, r, err) return } query.Spaces = core.SpacesFilter([]core.SpaceRef{ref}, nil) } 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 }