~bigbes/sr-ht-spec

ref: 53e56db27ab35117d0c2a91f15533f7dd528612c sr-ht-spec/web/server.go -rw-r--r-- 8.6 KiB
53e56db2 — Eugene Blikh web: draw the chrome from sr-ht-ecore 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
// 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 chrome 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.
//
// # 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 authn.Resolver.Middleware is already applied.
//
// # 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"
	"net/http"
	"path"
	"regexp"

	"github.com/vaughan0/go-ini"
	"sourcecraft.dev/bigbes/sr-ht-core/config"
	"sourcecraft.dev/bigbes/sr-ht-ecore/chrome"

	"sourcecraft.dev/bigbes/sr-ht-spec/authn"
	"sourcecraft.dev/bigbes/sr-ht-spec/doc"
)

// hashedCSSRe matches the content-addressed stylesheet name so it can be served
// with an immutable cache lifetime (the hash changes whenever the bytes do).
var hashedCSSRe = regexp.MustCompile(`^main\.min\.[0-9a-f]{6,}\.css$`)

// 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 — sameOrigin and
	// the login redirect ask it rather than keeping a second copy that could
	// disagree with the links on the page.
	chromeSvc *chrome.Service

	// 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

	staticFileServer 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")
	}

	cssHref, err := resolveCSSHref()
	if err != nil {
		return nil, err
	}
	if cssHref == "" {
		log.Printf("web: no main.min.*.css embedded in this binary — pages will " +
			"render unstyled; 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

	staticSub, err := fs.Sub(staticFS, "static")
	if err != nil {
		return nil, fmt.Errorf("web: sub static FS: %w", err)
	}

	return &Server{
		reader:           opts.Reader,
		searcher:         opts.Searcher,
		resolver:         opts.Resolver,
		renderer:         doc.NewRenderer(),
		chromeSvc:        chromeSvc,
		tokensOrigin:     config.GetOrigin(opts.Conf, tokensSection, true),
		staticFileServer: http.StripPrefix("/static/", http.FileServer(http.FS(staticSub))),
	}, nil
}

// resolveCSSHref globs the embedded static FS for the content-addressed
// stylesheet and returns its site-absolute URL, or "" when the binary was built
// without one.
//
// Absence is reported rather than substituted: there is no placeholder href to
// invent, and a link to a stylesheet that is not there would 404 on every page
// load instead of saying what is wrong once, at startup.
func resolveCSSHref() (string, error) {
	matches, err := fs.Glob(staticFS, "static/main.min.*.css")
	if err != nil {
		return "", fmt.Errorf("web: glob stylesheet: %w", err)
	}
	if len(matches) == 0 {
		return "", nil
	}
	return "/static/" + path.Base(matches[0]), nil
}