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