// Package web is spec.sr.ht's read plane in a browser: a space's document
// tree, a rendered document with its metadata and backlinks, the proposal
// review page and keyword search — all served from one chi router the daemon
// mounts.
//
// # The web tier is not ours
//
// The brand, the service switcher, the login block and the environment banner
// come from sourcecraft.dev/bigbes/sr-ht-ecore/chrome, which every custom
// service on this instance shares. This package builds one chrome.Service at
// startup, asks it for a chrome.Page per request, and embeds that Page in its
// own view struct so the fields promote into the templates (view.go). Nothing
// here rebuilds the switcher or re-derives a login URL: this service's copy of
// that code — inherited from compare.sr.ht, which had inherited it from
// somewhere else — is what ecore exists to have deleted.
//
// Five more of ecore's packages carry what used to be local copies of the same
// idea, or what no copy here ever had, and the pattern is the same every time —
// the rule lives in one place and this package supplies only what is genuinely
// spec.sr.ht's:
//
// - pages discovers the page templates, refuses at startup a page that
// defines no "content", renders into a buffer before touching the response
// and ships the shared error body. What stays here is renderError, which
// wraps that body in this service's view struct, and fail, which maps this
// service's own sentinels onto statuses. FormValues is its other half: a
// bounded read that answers r.PostForm and never r.Form.
// - assets finds the hashed stylesheet and the favicon and serves the static
// tree with the cache policy each name implies.
// - csrf is the same-origin guard, installed on the router rather than called
// by three handlers — see [Server.Handler].
// - middleware is the private-cache policy and the panic guard.
// - chimw is the chi-shaped half: the request line as a slog record, the read
// routes registered for GET and HEAD both, and the two refusals chi answers
// when routing fails, which this surface previously did not answer at all.
//
// pages.Render answers the response itself and returns an error only for the
// log. It must never be handed to fail: that would either write a second
// response over a committed one or recurse through the page that just broke.
//
// # URL grammar
//
// The design pins this, so it is spelled out here rather than left to the
// router: a document's address carries no extension. The extension is a format
// selector and never part of the document's identity.
//
// /~user/space/specs/0007-storage rendered HTML
// /~user/space/specs/0007-storage.md raw source (frontmatter + body)
// /~user/space/specs/0007-storage.json metadata + body
// …?rev=<sha> any of the three, pinned
//
// An absent ?rev= means the approved head — service.ApprovedRev — because
// serving drafts by default would poison every downstream agent context with
// unreviewed text.
//
// Two routes sit outside the space grammar: /inbox is the review queue, and
// /tokens redirects to tokens.sr.ht, which issues every agent credential on the
// instance. /tokens was spec's own mint-list-revoke page until that credential
// stopped being spec's to mint.
//
// # 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 both authn.Resolver.Middleware and csrf.Require are
// already applied — the second is a security rule, not a convenience.
//
// # Assets are embedded
//
// static/ is compiled into the binary by //go:embed. `make css` rewrites
// web/static/main.min.<hash>.css on disk and a *running* daemon will not notice
// — the CSS is baked in at `go build` time, so the build order is css then
// build then restart. A binary built with no stylesheet present logs a loud
// warning at startup and renders unstyled rather than refusing to start:
// missing CSS degrades presentation, not correctness.
package web
import (
"fmt"
"io/fs"
"log/slog"
"net/http"
"github.com/vaughan0/go-ini"
"sourcecraft.dev/bigbes/sr-ht-ecore/assets"
"sourcecraft.dev/bigbes/sr-ht-ecore/chrome"
"sourcecraft.dev/bigbes/sr-ht-ecore/instconf"
"sourcecraft.dev/bigbes/sr-ht-ecore/pages"
"sourcecraft.dev/bigbes/sr-ht-spec/authn"
"sourcecraft.dev/bigbes/sr-ht-spec/doc"
)
// tokensSection is tokens.sr.ht's config section, spelled the way the instance's
// config.ini spells it. service.TokensSection is the same string read for the
// internal origin; this package cannot import service/ (the dependency arrow
// runs the other way), so the constant is here rather than shared.
const tokensSection = "tokens.sr.ht"
// 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, and it is here for one reason:
// the chrome is built from it. The switcher is a question about every
// [*.sr.ht] section the instance defines and not about our own keys, so
// chrome.NewService reads the whole file once at startup; nothing here
// parses it again 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
// chromeSvc is the shared page frame of sr-ht-ecore: the brand, the service
// switcher, the login block and the environment banner, built once from
// config.ini and asked for a per-request Page in view (view.go). It is also
// this package's only reader of our own and meta's origins — the CSRF guard
// and the login redirect ask it rather than keeping a second copy that could
// disagree with the links on the page.
chromeSvc *chrome.Service
// pages is the page set of sr-ht-ecore: one template set per file in
// templates/, discovered at startup. Adding a page is adding a file — there
// is no list here to forget to edit — and a page that defines no "content"
// fails New rather than serving the chrome around a hole.
pages pages.Set
// tokensOrigin is [tokens.sr.ht] origin in its *external* form —
// instconf.ExternalOrigin, named rather than a bool, because the wrong
// reading here sends a browser to an address only the daemon can reach and
// nothing at the call site would say so. The only thing this package does
// with it is redirect a browser there, and a browser cannot reach the
// internal origin the bearer validator uses. Empty when the
// instance config has no such section, which handleTokens answers rather
// than papers over with a redirect to nowhere.
tokensOrigin string
// static serves the embedded asset tree with the cache policy each name
// implies — see sr-ht-ecore/assets.
static 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")
}
// The section is authn.ConfigSection and not a literal, because that
// constant is what the daemon, the config file and the switcher's "which
// entry is me" test all have to agree on. A service that spelled its section
// differently in two places would appear in the instance's navigation and
// fail to recognise itself in it.
chromeSvc := chrome.NewService(opts.Conf, authn.ConfigSection)
if chromeSvc.SelfOrigin() == "" {
return nil, fmt.Errorf("web: [%s] origin is required", authn.ConfigSection)
}
if chromeSvc.MetaOrigin() == "" {
return nil, fmt.Errorf("web: [meta.sr.ht] origin is required")
}
// The error is a malformed glob — a mistake in this line — and not a missing
// stylesheet, which resolves to "" and is a warning: a service that will not
// boot without a build artefact cannot be run from a checkout.
cssHref, err := assets.Resolve(staticFS, "static/main.min.*.css", assets.DefaultPrefix)
if err != nil {
return nil, fmt.Errorf("web: %w", err)
}
if cssHref == "" {
slog.Warn("no stylesheet is embedded in this binary, so pages will render unstyled",
"glob", "static/main.min.*.css", "remedy", "run `make css` before `go build`")
}
// chrome.Page renders a bare page for an empty StyleHref rather than an
// empty <link>, so an unstyled build stays a presentation failure.
chromeSvc.StyleHref = cssHref
// A page that defines no "content" is refused here rather than serving a
// 200 around a hole, so this error is a startup failure and not a warning.
set, err := pages.Load(tmplFS, pages.Options{Funcs: funcMap})
if err != nil {
return nil, fmt.Errorf("web: %w", err)
}
staticSub, err := fs.Sub(staticFS, "static")
if err != nil {
return nil, fmt.Errorf("web: sub static FS: %w", err)
}
s := &Server{
reader: opts.Reader,
searcher: opts.Searcher,
resolver: opts.Resolver,
renderer: doc.NewRenderer(),
chromeSvc: chromeSvc,
pages: set,
tokensOrigin: instconf.ExternalOrigin(opts.Conf, tokensSection),
}
// The 404 of the asset tree is this service's own page and not net/http's
// plaintext one: an asset URL typed by hand is a dead end without a nav to
// get out of. It is wired after the Server exists because it renders through
// it.
s.static = assets.Handler(staticSub, assets.DefaultPrefix,
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
s.renderError(w, r, http.StatusNotFound, "")
}))
return s, nil
}