// 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= 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..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" "html/template" "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 , so an unstyled build stays a presentation failure. chromeSvc.StyleHref = cssHref // This service ships its own icon, so it overrides chrome's built-in data: // URI with it — resolved rather than spelled, so that the day logo.svg is // hashed or renamed the href follows and an absent one is "" (no ) // instead of a 404 on every page load. The glob is exact today; it is a glob // so that a hashed name needs no second edit here. iconHref, err := assets.Resolve(staticFS, "static/logo.*svg", assets.DefaultPrefix) if err != nil { return nil, fmt.Errorf("web: %w", err) } if iconHref != "" { chromeSvc.FaviconHref = template.URL(iconHref) } // 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 }