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

Coverage
69.0% 20/29 statements
Δ
Blob
9372d1f
1 // Package web is the HTTP layer of diff.sr.ht. It renders the repository
2 // landing, compare (base...head) and single-commit pages server-side, and
3 // embeds a compact JSON payload plus the vendored esbuild bundle so the browser
4 // renders the diff with @pierre/diffs and @pierre/trees.
5 //
6 // The package owns no state of its own: identity comes from ecore's login
7 // middleware, authorization from an authz.Authorizer (git.sr.ht GraphQL), and
8 // git data from gitx over bare repositories on disk. Every request that touches
9 // a repository authorizes first (a not-found or forbidden repo is a 404, never
10 // a 403, so private-repo existence never leaks) and only then reads the disk.
11 //
12 // # The chrome is not ours
13 //
14 // The nav/service-switcher, the brand, the login block and the environment
15 // banner come from sourcecraft.dev/bigbes/sr-ht-ecore/chrome, which every custom
16 // service on the instance shares. This package builds one chrome.Service at
17 // startup, asks it for a chrome.Page per request, and embeds that Page in
18 // viewData so the fields promote into the templates. Nothing here rebuilds the
19 // switcher or re-derives a login URL: the copy that used to live in web/chrome.go
20 // is exactly what ecore exists to have deleted.
21 //
22 // One thing about the chrome remains this service's own, because it is about
23 // what compare renders and not about the instance: the full-bleed
24 // ContainerClass the two diff views set — a side-by-side diff in a centered
25 // "container" is a column of code half the window wide. The vendored bundle's
26 // href is no longer among them: it is a hashed build artefact like the
27 // stylesheet, so it lives in chrome.Service.Assets, which is the slot ecore
28 // grew once three services had each added their own field for it.
29 //
30 // # What the cmd layer must wire
31 //
32 // Register installs the middleware that needs this Server — the private cache
33 // policy, panic recovery through this package's error page, and the same-origin
34 // guard — and assumes the following is already applied to the router it is
35 // handed, in this order (outermost first):
36 //
37 // chi middleware.RequestID
38 // chi middleware.RealIP
39 // chimw.RequestLogger(...) // the request line, as a slog record
40 // chi middleware.Recoverer
41 // config.Middleware(conf, "diff.sr.ht") // required: authz + gitx read it
42 // login.Optional() // required: never 401s; sets the viewer
43 //
44 // login.Optional and not login.Required: every page here is either public or a
45 // 404, and git.sr.ht decides which — a viewer this service refused would be a
46 // viewer git.sr.ht was never asked about.
47 //
48 // config.Middleware must run before login.Optional is irrelevant to login
49 // itself (it only reads the cookie), but the GraphQL authorizer invoked inside
50 // handlers needs config.ForContext(ctx) to resolve git.sr.ht's API origin, so
51 // config.Middleware is mandatory on every request that reaches a handler.
52 package web
53
54 import (
55 "io/fs"
56 "log/slog"
57 "net/http"
58
59 "github.com/vaughan0/go-ini"
60 "go.bigb.es/auxilia/culpa"
61 "sourcecraft.dev/bigbes/sr-ht-ecore/assets"
62 "sourcecraft.dev/bigbes/sr-ht-ecore/chrome"
63 "sourcecraft.dev/bigbes/sr-ht-ecore/login"
64 "sourcecraft.dev/bigbes/sr-ht-ecore/pages"
65
66 "sourcecraft.dev/bigbes/sr-ht-compare/authz"
67 )
68
69 // configSection is this service's literal section in the shared config.ini. It
70 // is what the switcher's "which entry is me" test compares against, so it must
71 // be spelled the same here, in the config file and in the middleware the cmd
72 // layer installs — a service that spelled it two ways would appear in the
73 // instance's navigation and fail to recognise itself in it.
74 const configSection = "diff.sr.ht"
75
76 // bundleAsset is the key compare's layout reads its front-end bundle's href
77 // under, in chrome.Service.Assets. It is spelled once here and once in the
78 // "scripts" block of the two diff pages; a third spelling would render no
79 // script tag at all rather than fail, which is why the two that exist are a
80 // constant and a template guarded on emptiness.
81 const bundleAsset = "bundle.js"
82
83 // Server holds the immutable configuration a request handler needs. It is built
84 // once at startup and is safe for concurrent use.
85 type Server struct {
86 authorizer authz.Authorizer
87 reposRoot string
88
89 // chromeSvc is the shared page frame of sr-ht-ecore: the brand, the service
90 // switcher, the login block and the environment banner, built once from
91 // config.ini and asked for a per-request chrome.Page in view (chrome.go's
92 // job until this service stopped carrying its own copy).
93 chromeSvc *chrome.Service
94
95 // pages is one parsed template set per page, discovered from the embedded
96 // tree by ecore rather than listed here. A page that defines no "content"
97 // never gets this far: pages.Load refuses it at startup, where the
98 // alternative was the chrome around a hole served with a 200.
99 pages pages.Set
100
101 // static serves the embedded asset tree with the cache policy the hashed
102 // name implies, and answers everything that is not a file — a directory
103 // above all — through this service's own 404 page.
104 static http.Handler
105 }
106
107 // New assembles a Server from the shared SourceHut config. It reads
108 // [git.sr.ht] repos, [meta.sr.ht] origin and [diff.sr.ht] origin (all
109 // required), hands the whole file to chrome.NewService — the switcher is a
110 // question about every [*.sr.ht] section the instance defines, not about our own
111 // keys — resolves the hashed stylesheet and bundle through ecore's assets, and
112 // parses the page templates through ecore's pages. A missing required key is a
113 // clear error, not a panic, so the cmd layer can fail startup loudly.
114 //
115 // A missing build artefact is not one of those errors. assets.Resolve answers
116 // "" for a stylesheet or a bundle this binary was built without, because a
117 // checkout that has not run `make css` must still be runnable; the layout
118 // guards both hrefs on emptiness so a bare page is what such a build serves,
119 // rather than a <link href=""> that re-requests the page it is on.
120 24 func New(conf ini.File, authorizer authz.Authorizer) (*Server, error) {
121 24 // Every refusal below carries a hint naming the config key or the build step
122 24 // that fixes it. These are the only errors this package returns, they all
123 24 // arrive at one slog.Error in the cmd layer, and the reader of that record
124 24 // is an operator who wants the remedy rather than the call path.
125 24 reposRoot, ok := conf.Get("git.sr.ht", "repos")
126 24 if !ok || reposRoot == "" {
127 0 return nil, missingKey("git.sr.ht", "repos", "the root directory holding the bare repositories")
128 0 }
129
130 // The two origins are checked through the chrome that will render them
131 // rather than read a second time here, so the startup refusal and the links
132 // on the page cannot disagree about which origins this service has.
133 24 chromeSvc := chrome.NewService(conf, configSection)
134 24 if chromeSvc.MetaOrigin() == "" {
135 0 return nil, missingKey("meta.sr.ht", "origin", "the login and logout links in the nav are built from it")
136 0 }
137 24 if chromeSvc.SelfOrigin() == "" {
138 0 return nil, missingKey(configSection, "origin", "the same-origin guard and every return_to are built from it")
139 0 }
140
141 24 cssHref, err := assets.Resolve(staticFS, cssGlob, assets.DefaultPrefix)
142 24 if err != nil {
143 0 return nil, culpa.WithHint(culpa.Wrap(err, "web: resolve the stylesheet"),
144 0 "the glob is a literal in this package, so this is a bug and not a deployment fault")
145 0 }
146 24 bundleHref, err := assets.Resolve(staticFS, bundleGlob, assets.DefaultPrefix)
147 24 if err != nil {
148 0 return nil, culpa.WithHint(culpa.Wrap(err, "web: resolve the bundle"),
149 0 "the glob is a literal in this package, so this is a bug and not a deployment fault")
150 0 }
151 24 if cssHref == "" || bundleHref == "" {
152 0 slog.Warn("web: built without a front-end artefact; run `make` before `go build`",
153 0 "css", cssHref, "bundle", bundleHref)
154 0 }
155 24 chromeSvc.StyleHref = cssHref
156 24 chromeSvc.Assets = map[string]string{bundleAsset: bundleHref}
157 24
158 24 set, err := pages.Load(tmplFS, pages.Options{Funcs: funcMap})
159 24 if err != nil {
160 0 return nil, culpa.WithHint(culpa.Wrap(err, "web: load the page templates"),
161 0 "a page in web/templates defines no {{define \"content\"}}, or the layout is missing")
162 0 }
163
164 24 staticSub, err := fs.Sub(staticFS, "static")
165 24 if err != nil {
166 0 return nil, culpa.Wrap(err, "web: sub static FS")
167 0 }
168
169 24 s := &Server{
170 24 authorizer: authorizer,
171 24 reposRoot: reposRoot,
172 24 chromeSvc: chromeSvc,
173 24 pages: set,
174 24 }
175 24 // Built after the Server exists because the not-found arm is this service's
176 24 // own error page: an asset URL typed by hand lands on a page with a nav to
177 24 // get out of, and a directory — /static/, which the file server alone would
178 24 // answer with a listing of every artefact in the binary — lands there too.
179 24 s.static = assets.Handler(staticSub, assets.DefaultPrefix, http.HandlerFunc(s.handleNotFound))
180 24 return s, nil
181 }
182
183 // missingKey is the refusal for a config key this service cannot start without:
184 // the key in the message, and what it is for in the hint. why completes the
185 // sentence "it is ...", so it reads as an answer to the question an operator
186 // staring at a failed unit actually has.
187 0 func missingKey(section, key, why string) error {
188 0 return culpa.WithHint(
189 0 culpa.Errorf("web: [%s] %s is required", section, key),
190 0 "it is "+why,
191 0 )
192 0 }
193
194 // viewData is the root value every template is executed against.
195 //
196 // chrome.Page is embedded rather than copied field by field, so the shared
197 // partials — "srht-nav", "srht-env-banner", "srht-repo-list" — find the fields
198 // they need on the dot they are handed, and a field ecore adds later arrives here
199 // without an edit. The page's own payload lives under Data and is reached as
200 // {{.Data.Something}}, which is what keeps a page from shadowing a chrome field.
201 type viewData struct {
202 chrome.Page
203
204 // Data is the page's own payload.
205 Data any
206 }
207
208 // view builds the frame for one request: the shared chrome plus a title.
209 //
210 // The username is whatever login.Optional resolved, which is "" for a viewer
211 // whose cookie is missing, expired, unreadable or carries a name that could not
212 // be one — so the nav offers login to exactly the viewers the handlers treat as
213 // anonymous.
214 32 func (s *Server) view(r *http.Request, title string) viewData {
215 32 return viewData{Page: s.chromeSvc.Page(r, title, login.FromContext(r.Context()))}
216 32 }