~bigbes/sr-ht-compare

ref: 2569ec406bf421320b30e404ccfa358179490bc3 sr-ht-compare/web/server.go -rw-r--r-- 10.1 KiB
2569ec40 — bigbes ci: publish the apk into artifacts.sr.ht as well 4 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
// Package web is the HTTP layer of diff.sr.ht. It renders the repository
// landing, compare (base...head) and single-commit pages server-side, and
// embeds a compact JSON payload plus the vendored esbuild bundle so the browser
// renders the diff with @pierre/diffs and @pierre/trees.
//
// The package owns no state of its own: identity comes from ecore's login
// middleware, authorization from an authz.Authorizer (git.sr.ht GraphQL), and
// git data from gitx over bare repositories on disk. Every request that touches
// a repository authorizes first (a not-found or forbidden repo is a 404, never
// a 403, so private-repo existence never leaks) and only then reads the disk.
//
// # The chrome is not ours
//
// The nav/service-switcher, the brand, the login block and the environment
// banner come from sourcecraft.dev/bigbes/sr-ht-ecore/chrome, which every custom
// service on the instance shares. This package builds one chrome.Service at
// startup, asks it for a chrome.Page per request, and embeds that Page in
// viewData so the fields promote into the templates. Nothing here rebuilds the
// switcher or re-derives a login URL: the copy that used to live in web/chrome.go
// is exactly what ecore exists to have deleted.
//
// One thing about the chrome remains this service's own, because it is about
// what compare renders and not about the instance: the full-bleed
// ContainerClass the two diff views set — a side-by-side diff in a centered
// "container" is a column of code half the window wide. The vendored bundle's
// href is no longer among them: it is a hashed build artefact like the
// stylesheet, so it lives in chrome.Service.Assets, which is the slot ecore
// grew once three services had each added their own field for it.
//
// # What the cmd layer must wire
//
// Register installs the middleware that needs this Server — the private cache
// policy, panic recovery through this package's error page, and the same-origin
// guard — and assumes the following is already applied to the router it is
// handed, in this order (outermost first):
//
//	chi middleware.RequestID
//	chi middleware.RealIP
//	chimw.RequestLogger(...)     // the request line, as a slog record
//	chi middleware.Recoverer
//	config.Middleware(conf, "diff.sr.ht")   // required: authz + gitx read it
//	login.Optional()             // required: never 401s; sets the viewer
//
// login.Optional and not login.Required: every page here is either public or a
// 404, and git.sr.ht decides which — a viewer this service refused would be a
// viewer git.sr.ht was never asked about.
//
// config.Middleware must run before login.Optional is irrelevant to login
// itself (it only reads the cookie), but the GraphQL authorizer invoked inside
// handlers needs config.ForContext(ctx) to resolve git.sr.ht's API origin, so
// config.Middleware is mandatory on every request that reaches a handler.
package web

import (
	"io/fs"
	"log/slog"
	"net/http"

	"github.com/vaughan0/go-ini"
	"go.bigb.es/auxilia/culpa"
	"sourcecraft.dev/bigbes/sr-ht-ecore/assets"
	"sourcecraft.dev/bigbes/sr-ht-ecore/chrome"
	"sourcecraft.dev/bigbes/sr-ht-ecore/login"
	"sourcecraft.dev/bigbes/sr-ht-ecore/pages"

	"sourcecraft.dev/bigbes/sr-ht-compare/authz"
)

// configSection is this service's literal section in the shared config.ini. It
// is what the switcher's "which entry is me" test compares against, so it must
// be spelled the same here, in the config file and in the middleware the cmd
// layer installs — a service that spelled it two ways would appear in the
// instance's navigation and fail to recognise itself in it.
const configSection = "diff.sr.ht"

// bundleAsset is the key compare's layout reads its front-end bundle's href
// under, in chrome.Service.Assets. It is spelled once here and once in the
// "scripts" block of the two diff pages; a third spelling would render no
// script tag at all rather than fail, which is why the two that exist are a
// constant and a template guarded on emptiness.
const bundleAsset = "bundle.js"

// Server holds the immutable configuration a request handler needs. It is built
// once at startup and is safe for concurrent use.
type Server struct {
	authorizer authz.Authorizer
	reposRoot  string

	// 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 chrome.Page in view (chrome.go's
	// job until this service stopped carrying its own copy).
	chromeSvc *chrome.Service

	// pages is one parsed template set per page, discovered from the embedded
	// tree by ecore rather than listed here. A page that defines no "content"
	// never gets this far: pages.Load refuses it at startup, where the
	// alternative was the chrome around a hole served with a 200.
	pages pages.Set

	// static serves the embedded asset tree with the cache policy the hashed
	// name implies, and answers everything that is not a file — a directory
	// above all — through this service's own 404 page.
	static http.Handler
}

// New assembles a Server from the shared SourceHut config. It reads
// [git.sr.ht] repos, [meta.sr.ht] origin and [diff.sr.ht] origin (all
// required), hands the whole file to chrome.NewService — the switcher is a
// question about every [*.sr.ht] section the instance defines, not about our own
// keys — resolves the hashed stylesheet and bundle through ecore's assets, and
// parses the page templates through ecore's pages. A missing required key is a
// clear error, not a panic, so the cmd layer can fail startup loudly.
//
// A missing build artefact is not one of those errors. assets.Resolve answers
// "" for a stylesheet or a bundle this binary was built without, because a
// checkout that has not run `make css` must still be runnable; the layout
// guards both hrefs on emptiness so a bare page is what such a build serves,
// rather than a <link href=""> that re-requests the page it is on.
func New(conf ini.File, authorizer authz.Authorizer) (*Server, error) {
	// Every refusal below carries a hint naming the config key or the build step
	// that fixes it. These are the only errors this package returns, they all
	// arrive at one slog.Error in the cmd layer, and the reader of that record
	// is an operator who wants the remedy rather than the call path.
	reposRoot, ok := conf.Get("git.sr.ht", "repos")
	if !ok || reposRoot == "" {
		return nil, missingKey("git.sr.ht", "repos", "the root directory holding the bare repositories")
	}

	// The two origins are checked through the chrome that will render them
	// rather than read a second time here, so the startup refusal and the links
	// on the page cannot disagree about which origins this service has.
	chromeSvc := chrome.NewService(conf, configSection)
	if chromeSvc.MetaOrigin() == "" {
		return nil, missingKey("meta.sr.ht", "origin", "the login and logout links in the nav are built from it")
	}
	if chromeSvc.SelfOrigin() == "" {
		return nil, missingKey(configSection, "origin", "the same-origin guard and every return_to are built from it")
	}

	cssHref, err := assets.Resolve(staticFS, cssGlob, assets.DefaultPrefix)
	if err != nil {
		return nil, culpa.WithHint(culpa.Wrap(err, "web: resolve the stylesheet"),
			"the glob is a literal in this package, so this is a bug and not a deployment fault")
	}
	bundleHref, err := assets.Resolve(staticFS, bundleGlob, assets.DefaultPrefix)
	if err != nil {
		return nil, culpa.WithHint(culpa.Wrap(err, "web: resolve the bundle"),
			"the glob is a literal in this package, so this is a bug and not a deployment fault")
	}
	if cssHref == "" || bundleHref == "" {
		slog.Warn("web: built without a front-end artefact; run `make` before `go build`",
			"css", cssHref, "bundle", bundleHref)
	}
	chromeSvc.StyleHref = cssHref
	chromeSvc.Assets = map[string]string{bundleAsset: bundleHref}

	set, err := pages.Load(tmplFS, pages.Options{Funcs: funcMap})
	if err != nil {
		return nil, culpa.WithHint(culpa.Wrap(err, "web: load the page templates"),
			"a page in web/templates defines no {{define \"content\"}}, or the layout is missing")
	}

	staticSub, err := fs.Sub(staticFS, "static")
	if err != nil {
		return nil, culpa.Wrap(err, "web: sub static FS")
	}

	s := &Server{
		authorizer: authorizer,
		reposRoot:  reposRoot,
		chromeSvc:  chromeSvc,
		pages:      set,
	}
	// Built after the Server exists because the not-found arm is this service's
	// own error page: an asset URL typed by hand lands on a page with a nav to
	// get out of, and a directory — /static/, which the file server alone would
	// answer with a listing of every artefact in the binary — lands there too.
	s.static = assets.Handler(staticSub, assets.DefaultPrefix, http.HandlerFunc(s.handleNotFound))
	return s, nil
}

// missingKey is the refusal for a config key this service cannot start without:
// the key in the message, and what it is for in the hint. why completes the
// sentence "it is ...", so it reads as an answer to the question an operator
// staring at a failed unit actually has.
func missingKey(section, key, why string) error {
	return culpa.WithHint(
		culpa.Errorf("web: [%s] %s is required", section, key),
		"it is "+why,
	)
}

// viewData is the root value every template is executed against.
//
// chrome.Page is embedded rather than copied field by field, so the shared
// partials — "srht-nav", "srht-env-banner", "srht-repo-list" — find the fields
// they need on the dot they are handed, and a field ecore adds later arrives here
// without an edit. The page's own payload lives under Data and is reached as
// {{.Data.Something}}, which is what keeps a page from shadowing a chrome field.
type viewData struct {
	chrome.Page

	// Data is the page's own payload.
	Data any
}

// view builds the frame for one request: the shared chrome plus a title.
//
// The username is whatever login.Optional resolved, which is "" for a viewer
// whose cookie is missing, expired, unreadable or carries a name that could not
// be one — so the nav offers login to exactly the viewers the handlers treat as
// anonymous.
func (s *Server) view(r *http.Request, title string) viewData {
	return viewData{Page: s.chromeSvc.Page(r, title, login.FromContext(r.Context()))}
}