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
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=<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
}
// ---- 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 <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
}