coverage~bigbes/sr-ht-spec3cb1c03dweb/server.go

Coverage
70.6% 24/34 statements
Δ
Blob
0586024
1 // Package web is spec.sr.ht's read plane in a browser: a space's document
2 // tree, a rendered document with its metadata and backlinks, the proposal
3 // review page and keyword search — all served from one chi router the daemon
4 // mounts.
5 //
6 // # The web tier is not ours
7 //
8 // The brand, the service switcher, the login block and the environment banner
9 // come from sourcecraft.dev/bigbes/sr-ht-ecore/chrome, which every custom
10 // service on this instance shares. This package builds one chrome.Service at
11 // startup, asks it for a chrome.Page per request, and embeds that Page in its
12 // own view struct so the fields promote into the templates (view.go). Nothing
13 // here rebuilds the switcher or re-derives a login URL: this service's copy of
14 // that code — inherited from compare.sr.ht, which had inherited it from
15 // somewhere else — is what ecore exists to have deleted.
16 //
17 // Five more of ecore's packages carry what used to be local copies of the same
18 // idea, or what no copy here ever had, and the pattern is the same every time —
19 // the rule lives in one place and this package supplies only what is genuinely
20 // spec.sr.ht's:
21 //
22 // - pages discovers the page templates, refuses at startup a page that
23 // defines no "content", renders into a buffer before touching the response
24 // and ships the shared error body. What stays here is renderError, which
25 // wraps that body in this service's view struct, and fail, which maps this
26 // service's own sentinels onto statuses. FormValues is its other half: a
27 // bounded read that answers r.PostForm and never r.Form.
28 // - assets finds the hashed stylesheet and the favicon and serves the static
29 // tree with the cache policy each name implies.
30 // - csrf is the same-origin guard, installed on the router rather than called
31 // by three handlers — see [Server.Handler].
32 // - middleware is the private-cache policy and the panic guard.
33 // - chimw is the chi-shaped half: the request line as a slog record, the read
34 // routes registered for GET and HEAD both, and the two refusals chi answers
35 // when routing fails, which this surface previously did not answer at all.
36 //
37 // pages.Render answers the response itself and returns an error only for the
38 // log. It must never be handed to fail: that would either write a second
39 // response over a committed one or recurse through the page that just broke.
40 //
41 // # URL grammar
42 //
43 // The design pins this, so it is spelled out here rather than left to the
44 // router: a document's address carries no extension. The extension is a format
45 // selector and never part of the document's identity.
46 //
47 // /~user/space/specs/0007-storage rendered HTML
48 // /~user/space/specs/0007-storage.md raw source (frontmatter + body)
49 // /~user/space/specs/0007-storage.json metadata + body
50 // …?rev=<sha> any of the three, pinned
51 //
52 // An absent ?rev= means the approved head — service.ApprovedRev — because
53 // serving drafts by default would poison every downstream agent context with
54 // unreviewed text.
55 //
56 // Two routes sit outside the space grammar: /inbox is the review queue, and
57 // /tokens redirects to tokens.sr.ht, which issues every agent credential on the
58 // instance. /tokens was spec's own mint-list-revoke page until that credential
59 // stopped being spec's to mint.
60 //
61 // # Who may read
62 //
63 // The instance has one human. There are no visibility levels, so the read ACL
64 // is one line: the owner and its agents may read, and everyone else may not.
65 // An anonymous browser asking for a page is redirected to meta.sr.ht's login
66 // (there is no login flow of our own); an anonymous client asking for .md or
67 // .json gets a 401, because redirecting a bot to an HTML login page tells it
68 // nothing.
69 //
70 // # What the cmd layer must wire
71 //
72 // [Server.Handler] returns a router with everything this package needs already
73 // installed, so the daemon can mount it at "/". A caller that owns its own
74 // router and middleware stack uses [Server.Register] instead; it installs
75 // routes only, and assumes both authn.Resolver.Middleware and csrf.Require are
76 // already applied — the second is a security rule, not a convenience.
77 //
78 // # Assets are embedded
79 //
80 // static/ is compiled into the binary by //go:embed. `make css` rewrites
81 // web/static/main.min.<hash>.css on disk and a *running* daemon will not notice
82 // — the CSS is baked in at `go build` time, so the build order is css then
83 // build then restart. A binary built with no stylesheet present logs a loud
84 // warning at startup and renders unstyled rather than refusing to start:
85 // missing CSS degrades presentation, not correctness.
86 package web
87
88 import (
89 "fmt"
90 "html/template"
91 "io/fs"
92 "log/slog"
93 "net/http"
94
95 "github.com/vaughan0/go-ini"
96 "sourcecraft.dev/bigbes/sr-ht-ecore/assets"
97 "sourcecraft.dev/bigbes/sr-ht-ecore/chrome"
98 "sourcecraft.dev/bigbes/sr-ht-ecore/instconf"
99 "sourcecraft.dev/bigbes/sr-ht-ecore/pages"
100
101 "sourcecraft.dev/bigbes/sr-ht-spec/authn"
102 "sourcecraft.dev/bigbes/sr-ht-spec/doc"
103 )
104
105 // tokensSection is tokens.sr.ht's config section, spelled the way the instance's
106 // config.ini spells it. service.TokensSection is the same string read for the
107 // internal origin; this package cannot import service/ (the dependency arrow
108 // runs the other way), so the constant is here rather than shared.
109 const tokensSection = "tokens.sr.ht"
110
111 // Options is everything a Server needs. Every field is required; New says which
112 // one is missing rather than failing later inside a handler.
113 type Options struct {
114 // Conf is the shared SourceHut config.ini, and it is here for one reason:
115 // the chrome is built from it. The switcher is a question about every
116 // [*.sr.ht] section the instance defines and not about our own keys, so
117 // chrome.NewService reads the whole file once at startup; nothing here
118 // parses it again per request.
119 Conf ini.File
120
121 // Reader is the read surface over spaces and documents. NewReader adapts a
122 // *service.Service to it.
123 Reader Reader
124
125 // Searcher is the keyword index. *search.Index satisfies it directly.
126 Searcher Searcher
127
128 // Resolver turns the unified-login cookie or an agent bearer token into a
129 // principal. Handler installs its middleware; Register does not.
130 Resolver *authn.Resolver
131 }
132
133 // Server holds the immutable configuration a request handler needs. It is built
134 // once at startup and is safe for concurrent use.
135 type Server struct {
136 reader Reader
137 searcher Searcher
138 resolver *authn.Resolver
139 renderer *doc.Renderer
140
141 // chromeSvc is the shared page frame of sr-ht-ecore: the brand, the service
142 // switcher, the login block and the environment banner, built once from
143 // config.ini and asked for a per-request Page in view (view.go). It is also
144 // this package's only reader of our own and meta's origins — the CSRF guard
145 // and the login redirect ask it rather than keeping a second copy that could
146 // disagree with the links on the page.
147 chromeSvc *chrome.Service
148
149 // pages is the page set of sr-ht-ecore: one template set per file in
150 // templates/, discovered at startup. Adding a page is adding a file — there
151 // is no list here to forget to edit — and a page that defines no "content"
152 // fails New rather than serving the chrome around a hole.
153 pages pages.Set
154
155 // tokensOrigin is [tokens.sr.ht] origin in its *external* form —
156 // instconf.ExternalOrigin, named rather than a bool, because the wrong
157 // reading here sends a browser to an address only the daemon can reach and
158 // nothing at the call site would say so. The only thing this package does
159 // with it is redirect a browser there, and a browser cannot reach the
160 // internal origin the bearer validator uses. Empty when the
161 // instance config has no such section, which handleTokens answers rather
162 // than papers over with a redirect to nowhere.
163 tokensOrigin string
164
165 // static serves the embedded asset tree with the cache policy each name
166 // implies — see sr-ht-ecore/assets.
167 static http.Handler
168 }
169
170 // New assembles a Server from the shared SourceHut config.
171 //
172 // [spec.sr.ht] origin and [meta.sr.ht] origin are required: without the first
173 // there is no return_to to hand meta, and without the second there is no login
174 // at all. A missing key is a clear error rather than a panic, so the daemon can
175 // fail startup loudly.
176 76 func New(opts Options) (*Server, error) {
177 76 if opts.Conf == nil {
178 0 return nil, fmt.Errorf("web: config is required")
179 0 }
180 76 if opts.Reader == nil {
181 0 return nil, fmt.Errorf("web: Reader is required")
182 0 }
183 76 if opts.Searcher == nil {
184 0 return nil, fmt.Errorf("web: Searcher is required")
185 0 }
186 76 if opts.Resolver == nil {
187 0 return nil, fmt.Errorf("web: authn Resolver is required")
188 0 }
189
190 // The section is authn.ConfigSection and not a literal, because that
191 // constant is what the daemon, the config file and the switcher's "which
192 // entry is me" test all have to agree on. A service that spelled its section
193 // differently in two places would appear in the instance's navigation and
194 // fail to recognise itself in it.
195 76 chromeSvc := chrome.NewService(opts.Conf, authn.ConfigSection)
196 76 if chromeSvc.SelfOrigin() == "" {
197 0 return nil, fmt.Errorf("web: [%s] origin is required", authn.ConfigSection)
198 0 }
199 76 if chromeSvc.MetaOrigin() == "" {
200 0 return nil, fmt.Errorf("web: [meta.sr.ht] origin is required")
201 0 }
202
203 // The error is a malformed glob — a mistake in this line — and not a missing
204 // stylesheet, which resolves to "" and is a warning: a service that will not
205 // boot without a build artefact cannot be run from a checkout.
206 76 cssHref, err := assets.Resolve(staticFS, "static/main.min.*.css", assets.DefaultPrefix)
207 76 if err != nil {
208 0 return nil, fmt.Errorf("web: %w", err)
209 0 }
210 76 if cssHref == "" {
211 76 slog.Warn("no stylesheet is embedded in this binary, so pages will render unstyled",
212 76 "glob", "static/main.min.*.css", "remedy", "run `make css` before `go build`")
213 76 }
214 // chrome.Page renders a bare page for an empty StyleHref rather than an
215 // empty <link>, so an unstyled build stays a presentation failure.
216 76 chromeSvc.StyleHref = cssHref
217 76
218 76 // This service ships its own icon, so it overrides chrome's built-in data:
219 76 // URI with it — resolved rather than spelled, so that the day logo.svg is
220 76 // hashed or renamed the href follows and an absent one is "" (no <link>)
221 76 // instead of a 404 on every page load. The glob is exact today; it is a glob
222 76 // so that a hashed name needs no second edit here.
223 76 iconHref, err := assets.Resolve(staticFS, "static/logo.*svg", assets.DefaultPrefix)
224 76 if err != nil {
225 0 return nil, fmt.Errorf("web: %w", err)
226 0 }
227 76 if iconHref != "" {
228 76 chromeSvc.FaviconHref = template.URL(iconHref)
229 76 }
230
231 // A page that defines no "content" is refused here rather than serving a
232 // 200 around a hole, so this error is a startup failure and not a warning.
233 76 set, err := pages.Load(tmplFS, pages.Options{Funcs: funcMap})
234 76 if err != nil {
235 0 return nil, fmt.Errorf("web: %w", err)
236 0 }
237
238 76 staticSub, err := fs.Sub(staticFS, "static")
239 76 if err != nil {
240 0 return nil, fmt.Errorf("web: sub static FS: %w", err)
241 0 }
242
243 76 s := &Server{
244 76 reader: opts.Reader,
245 76 searcher: opts.Searcher,
246 76 resolver: opts.Resolver,
247 76 renderer: doc.NewRenderer(),
248 76 chromeSvc: chromeSvc,
249 76 pages: set,
250 76 tokensOrigin: instconf.ExternalOrigin(opts.Conf, tokensSection),
251 76 }
252 76 // The 404 of the asset tree is this service's own page and not net/http's
253 76 // plaintext one: an asset URL typed by hand is a dead end without a nav to
254 76 // get out of. It is wired after the Server exists because it renders through
255 76 // it.
256 76 s.static = assets.Handler(staticSub, assets.DefaultPrefix,
257 76 http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
258 1 s.renderError(w, r, http.StatusNotFound, "")
259 1 }))
260 76 return s, nil
261 }