~bigbes/sr-ht-spec

ref: 3d811988f9960cf9057e09f30b59ceb71a7623c2 sr-ht-spec/web/server.go -rw-r--r-- 10.3 KiB
3d811988 — Eugene Blikh service: wrap the merge and reject failures with culpa 9 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
// 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.
//
// Four more of ecore's packages carry what used to be local copies of the same
// idea, 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.
//   - assets finds the hashed stylesheet 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.
//
// 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-core/config"
	"sourcecraft.dev/bigbes/sr-ht-ecore/assets"
	"sourcecraft.dev/bigbes/sr-ht-ecore/chrome"
	"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. 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: config.GetOrigin(opts.Conf, tokensSection, true),
	}
	// 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
}