package web
import (
"encoding/json"
"errors"
"fmt"
"html/template"
"log"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"sourcecraft.dev/bigbes/sr-ht-ecore/chrome"
"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. The ACL
// itself — owner and its agents, nobody else — is authn.Principal.CanRead, the
// one spelling every read surface shares.
func mayRead(r *http.Request) bool {
return authn.PrincipalFromContext(r.Context()).CanRead()
}
// readGrant reports whether the credential behind this request was minted for
// reading. It is a no-op for the owner's cookie and for spec's own agent token,
// neither of which carries grants; it refuses a tokens.sr.ht working token
// without spec:read.
//
// Kept apart from mayRead because the two questions have different answers when
// they fail: "who are you" ends in a login, "what may this token do" does not.
func readGrant(r *http.Request) error {
return authn.PrincipalFromContext(r.Context()).Authorize(authn.ActionRead)
}
// allowRead is the whole read gate: identity, then grant. It answers the request
// itself when either refuses and reports whether the handler may go on.
func (s *Server) allowRead(w http.ResponseWriter, r *http.Request, f format) bool {
if !mayRead(r) {
s.denyRead(w, r, f)
return false
}
if err := readGrant(r); err != nil {
s.denyGrant(w, r, f)
return false
}
return true
}
// 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)
}
// denyGrant answers a caller whose credential is good but was not minted for
// reading. 403 in both shapes, and pointedly no login redirect: the caller is
// already authenticated, so sending them to meta would loop them back here with
// the same token and the same answer.
func (s *Server) denyGrant(w http.ResponseWriter, r *http.Request, f format) {
msg := "this token does not grant " + authn.ActionRead
if f == formatHTML {
s.renderError(w, r, http.StatusForbidden, msg)
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
http.Error(w, msg, http.StatusForbidden)
}
// ---- 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.
//
// The 5xx message is left empty so the page takes ecore's shared sentence for
// the status: a bug here and a panic recovered by the router are the same event
// to a viewer, and two house phrases for it would only say that they came out
// of different code. Below 500 the error's own text is the message — those name
// a revision that does not parse or a document that is not there, which is what
// the viewer needs.
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, "")
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 --------------------------------------------------------------
// emptySpaces is what the landing page says when the owner has no spaces yet.
// It is a sentence and not "nothing here" because the remedy is not obvious:
// a space is created by pushing to it, not by a button on this page.
const emptySpaces = "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."
type indexData struct {
LoggedIn bool
// Spaces is rendered by ecore's "srht-repo-list" partial, which is the
// listing markup every service on this instance converged on. A space has no
// visibility to show — this service has exactly one reader — so only the
// title and the href are filled in.
Spaces chrome.RepoList
}
// 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.view(r, "")
// The title is built from the chrome's own fields rather than from a second
// read of [sr.ht]site-name: the tab and the brand must name the same site.
vd.Title = vd.SiteName + " " + vd.SiteLabel
data := indexData{LoggedIn: mayRead(r), Spaces: chrome.RepoList{Empty: emptySpaces}}
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.Items = append(data.Spaces.Items, chrome.ListItem{
Href: "/" + ref.String(),
Title: ref.String(),
})
}
}
vd.Data = data
if err := s.pages.Render(w, http.StatusOK, "index", vd); err != nil {
log.Printf("web: render the landing page for %s: %v", r.URL.Path, err)
}
}
// ---- 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 !s.allowRead(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.view(r, 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)),
}
if err := s.pages.Render(w, http.StatusOK, "space", vd); err != nil {
log.Printf("web: render the space page for %s: %v", r.URL.Path, err)
}
}
// 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 !s.allowRead(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.view(r, page.Title+" — "+ref.String())
vd.Data = data
if err := s.pages.Render(w, http.StatusOK, "document", vd); err != nil {
log.Printf("web: render the document page for %s: %v", r.URL.Path, err)
}
}
// 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
}
// spaceLink is one option of the search form's space filter. It is not a
// chrome.ListItem: a filter option is a <option>, not a card, and the listing
// on the landing page is the only thing on this service shaped like the
// family's event list.
type spaceLink struct {
Ref string
Href string
}
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 !s.allowRead(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.view(r, "")
vd.Title = "search — " + vd.SiteName + " " + vd.SiteLabel
vd.Data = data
if err := s.pages.Render(w, http.StatusOK, "search", vd); err != nil {
log.Printf("web: render the search page for %s: %v", r.URL.Path, err)
}
}
// 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
}