coverage~bigbes/sr-ht-dolt3523280cweb/router.go

Coverage
87.4% 76/87 statements
Δ
+0.0
Blob
74403c1
1 package web
2
3 import (
4 "errors"
5 "fmt"
6 "html/template"
7 "io/fs"
8 "log/slog"
9 "net/http"
10 "os"
11
12 "github.com/go-chi/chi/v5"
13
14 "go.bigb.es/auxilia/scribe"
15
16 "sourcecraft.dev/bigbes/sr-ht-ecore/assets"
17 "sourcecraft.dev/bigbes/sr-ht-ecore/chimw"
18 "sourcecraft.dev/bigbes/sr-ht-ecore/chrome"
19 "sourcecraft.dev/bigbes/sr-ht-ecore/csrf"
20 "sourcecraft.dev/bigbes/sr-ht-ecore/internalauth"
21 "sourcecraft.dev/bigbes/sr-ht-ecore/middleware"
22 "sourcecraft.dev/bigbes/sr-ht-ecore/pages"
23
24 "sourcecraft.dev/bigbes/sr-ht-dolt/authn"
25 "sourcecraft.dev/bigbes/sr-ht-dolt/beads"
26 "sourcecraft.dev/bigbes/sr-ht-dolt/core"
27 "sourcecraft.dev/bigbes/sr-ht-dolt/db"
28 )
29
30 // serviceName is our own service key: the config section, the JWT audience and
31 // the entry the shared switcher has to recognise as the current service. One
32 // constant, because a service that spelled its section differently in two
33 // places would appear in the instance's navigation and fail to find itself in
34 // it.
35 const serviceName = "dolt.sr.ht"
36
37 // The two names the static tree is searched for at startup: the hashed artefact
38 // `make css` produces, and the unhashed stylesheet `make static/main.css` leaves
39 // in a working copy.
40 const (
41 hashedStyleGlob = "main.min.*.css"
42 devStyleFile = "main.css"
43 )
44
45 // faviconFile is our own icon in the static tree. Unhashed, so it is linked by
46 // name and not through assets.Resolve — it changes about as often as the
47 // service is renamed.
48 const faviconFile = "logo.svg"
49
50 // app bundles the parsed templates, the shared chrome and the injected config.
51 // Handlers are methods on *app so they share this state without a global.
52 type app struct {
53 cfg Config
54 // pages is sr-ht-ecore's page-template machinery: one parsed set per page in
55 // templates/, plus the shared error page.
56 pages pages.Set
57 // chrome is sr-ht-ecore's shared page frame: the brand, the service
58 // switcher, the login block and the environment banner, built once from the
59 // instance config and asked for a per-request chrome.Page (see page below).
60 chrome *chrome.Service
61 // static is the built asset tree, mounted under /static/ by ecore's assets
62 // handler and globbed once at startup for the hashed stylesheet.
63 static fs.FS
64 // views is a snapshot of the global registeredViews taken at Register time.
65 // Handlers read this (never the global) so tests can inject their own set.
66 views []View
67 // ready is the /ready page's projection cache: one ready set per database,
68 // gated on the database's head hash and expiring on beads.ReadyCacheTTL. It
69 // lives on the app because it is the one piece of state that outlives a
70 // request here, and it holds projections rather than open stores — see
71 // beads.ReadyCache.
72 ready *beads.ReadyCache
73 }
74
75 // page builds the chrome for one request: the shared frame plus the per-page
76 // <title>. The caller sets any page-specific fields on its own view struct,
77 // which embeds the returned chrome.Page.
78 //
79 // The username handed over is the resolved caller's and not whatever the cookie
80 // said — an unreadable or expired cookie has already become anonymity by the
81 // time a handler runs — so the nav and the page content cannot disagree about
82 // who is looking.
83 158 func (a *app) page(r *http.Request, title string) chrome.Page {
84 158 var username string
85 158 if ac := authn.CallerFromContext(r.Context()); ac != nil {
86 41 username = ac.Username
87 41 }
88 158 return a.chrome.Page(r, title, username)
89 }
90
91 // Register mounts every dolt.sr.ht web route onto r. The caller (the Phase-3
92 // main) installs the config/database/cookie middleware upstream on the router
93 // group it passes here, then calls Register with the assembled Config.
94 //
95 // It parses templates and discovers the stylesheet once, at registration time,
96 // so a broken template fails startup loudly rather than a request later. A
97 // parse failure returns an error the caller must surface.
98 0 func Register(r chi.Router, cfg Config) error {
99 0 a, err := newApp(cfg)
100 0 if err != nil {
101 0 return err
102 0 }
103 0 a.mount(r)
104 0 return nil
105 }
106
107 // newApp validates cfg, parses templates and snapshots the view registry into a
108 // ready *app. Register uses it; tests build an *app directly so they can inspect
109 // and override its fields (e.g. app.views) before mounting.
110 141 func newApp(cfg Config) (*app, error) {
111 141 if cfg.Repos == nil || cfg.Stores == nil || cfg.Browse == nil ||
112 141 cfg.Users == nil || cfg.RepoDiskPath == nil {
113 0 return nil, fmt.Errorf("web: Register requires Repos, Stores, Browse, Users and RepoDiskPath")
114 0 }
115 141 if cfg.Conf == nil {
116 0 return nil, fmt.Errorf("web: Register requires Conf (the chrome and the origins are built from it)")
117 0 }
118
119 141 set, err := loadPages()
120 141 if err != nil {
121 0 return nil, err
122 0 }
123
124 // The static tree ships beside the binary rather than inside it (the Makefile
125 // installs it into $SHAREDIR), so the asset FS is an os.DirFS; assets takes
126 // an fs.FS precisely so both shapes work.
127 141 static := staticFS(cfg.StaticDir)
128 141 styleHref, err := assets.Resolve(static, hashedStyleGlob, assets.DefaultPrefix)
129 141 if err != nil {
130 0 return nil, fmt.Errorf("web: resolve the stylesheet: %w", err)
131 0 }
132 141 if styleHref == "" {
133 140 // The unhashed stylesheet of a working copy, linked only when it is
134 140 // really there: an href to a file this deployment does not ship would
135 140 // 404 once per page load, which is what an empty Resolve avoids. An
136 140 // empty href is guarded by the layout.
137 140 if _, err := fs.Stat(static, devStyleFile); err == nil {
138 1 styleHref = assets.NormalizePrefix(assets.DefaultPrefix) + devStyleFile
139 1 }
140 }
141
142 // The switcher, the brand and the login links come from the shared config
143 // read once here; the stylesheet is discovered separately because its name
144 // carries a build hash, which no config file can know.
145 141 chromeSvc := chrome.NewService(cfg.Conf, serviceName)
146 141 chromeSvc.StyleHref = styleHref
147 141
148 141 // Our own logo when this build ships one, and NewService's built-in data:
149 141 // URI when it does not. The href used to be written into the layout, which
150 141 // meant a deployment without a static tree — a test, a binary run out of a
151 141 // working copy — requested a file that was not there once per page. The
152 141 // existence check is the stylesheet's, for the same reason.
153 141 if _, err := fs.Stat(static, faviconFile); err == nil {
154 2 chromeSvc.FaviconHref = template.URL(assets.NormalizePrefix(assets.DefaultPrefix) + faviconFile)
155 2 }
156
157 141 return &app{
158 141 cfg: cfg,
159 141 pages: set,
160 141 chrome: chromeSvc,
161 141 static: static,
162 141 // Snapshot the registry so all handlers see a stable set and tests can
163 141 // override it per-app without mutating the global.
164 141 views: append([]View{}, registeredViews...),
165 141 ready: beads.NewReadyCache(),
166 141 }, nil
167 }
168
169 // mount installs every dolt.sr.ht web route onto r. Split from Register so tests
170 // can mount an *app they retain a handle to.
171 141 func (a *app) mount(r chi.Router) {
172 141 // Every page here is rendered for one viewer behind meta's unified-login
173 141 // cookie at a URL that says nothing about who that is, so nothing this
174 141 // service answers may be reused for the next viewer. The static handler
175 141 // overrides this per asset, once it has found the file.
176 141 r.Use(middleware.PrivateCache)
177 141 // A panic is a bug like any other and is owed the same page. One that
178 141 // arrives after the response has started aborts the connection instead,
179 141 // because there is no status line left to send and half a page with an
180 141 // error appended to it is neither document.
181 141 r.Use(middleware.RecoverPanics(func(w http.ResponseWriter, r *http.Request, _ any) {
182 1 a.fail(w, r, http.StatusInternalServerError, "")
183 1 }))
184
185 // Service-to-service companion provisioning (git.sr.ht post-update hook).
186 // Guarded by internal-network + network-key auth, and deliberately mounted
187 // outside the same-origin group below: it is not a browser route, it carries
188 // no Origin or Referer, and the CSRF guard would refuse every call.
189 //
190 // The caller is pinned rather than left open. core-go's own check only asks
191 // that a token name *some* client and node, which on this endpoint would
192 // mean any program holding the instance's network key may provision a
193 // database for any user — and exactly one program on the instance has any
194 // business doing that, cmd/dolt-git-hook, whose ids these are. Widening this
195 // to a second caller should be a line changed here, not something that
196 // starts working by accident.
197 //
198 // The refusal is internalauth's own plain-text Deny (the nil handler): this
199 // endpoint answers a hook and not a browser, so the error page the rest of
200 // web/ renders would only be something for a Go client to discard.
201 141 r.With(internalauth.Guard(core.InternalClientID, core.InternalNodeID, nil)).
202 141 Post("/internal/repos", a.handleInternalCreate)
203 141
204 141 // A URL this router does not serve, and a method it does not allow, are
205 141 // answered by the same page every other refusal here is. chi's own pair is
206 141 // net/http's plain text — no chrome, no nav, and no way out for a viewer who
207 141 // mistyped an address. It is a registration on the tree rather than a link
208 141 // in a chain, so it is installed once and inherited by everything below.
209 141 chimw.RenderRefusals(r, a.fail)
210 141
211 141 // Everything a browser reaches. The same-origin guard is the group's, not
212 141 // each mutating handler's: a predicate spelled per handler is protection
213 141 // somebody has to remember, and the form added next year is the one that
214 141 // goes out unguarded.
215 141 r.Group(func(r chi.Router) {
216 141 r.Use(csrf.Require(a.chrome.SelfOrigin(), a.denyCSRF))
217 141
218 141 // Read routes are registered for GET and HEAD both. Nothing on this
219 141 // surface reads r.Method, so a HEAD is the same query answered by the
220 141 // same handler and can never say 200 where the GET says 404 — which on
221 141 // these pages would be the visibility leak the 404 exists to prevent.
222 141 // Registered rather than rewritten per request, so the routing tree
223 141 // stays the one record of what this service serves. The mutating half
224 141 // of a form page is registered beside it with r.Post: a HEAD that
225 141 // writes is not a HEAD.
226 141 chimw.GetHead(r, "/", a.handleIndex)
227 141 // The cross-database ready page. It is a page of its own and not a View:
228 141 // a View is a rendering of one repository, and this is the question no
229 141 // single repository can answer.
230 141 chimw.GetHead(r, "/ready", a.handleReady)
231 141 chimw.GetHead(r, "/create", a.handleCreateForm)
232 141 r.Post("/create", a.handleCreate)
233 141
234 141 chimw.GetHead(r, "/settings/keys", a.handleKeys)
235 141 r.Post("/settings/keys", a.handleKeysPost)
236 141
237 141 chimw.GetHead(r, "/~{user}", a.handleUser)
238 141 chimw.GetHead(r, "/~{user}/{db}", a.handleOverview)
239 141 chimw.GetHead(r, "/~{user}/{db}/log", a.handleLog)
240 141 chimw.GetHead(r, "/~{user}/{db}/commit/{hash}", a.handleCommit)
241 141 chimw.GetHead(r, "/~{user}/{db}/tree/{ref}", a.handleTree)
242 141 chimw.GetHead(r, "/~{user}/{db}/table/{ref}/{table}", a.handleTable)
243 141 chimw.GetHead(r, "/~{user}/{db}/view/{view}", a.handleView)
244 141 chimw.GetHead(r, "/~{user}/{db}/settings", a.handleSettings)
245 141 r.Post("/~{user}/{db}/settings", a.handleSettingsPost)
246 141
247 141 // The static tree, with the cache policy the hashed names imply and no
248 141 // directory listing — a listing of /static/ would publish this build's
249 141 // stylesheet hash, which nothing else on the surface discloses. An asset
250 141 // URL typed by hand lands on our own 404 rather than net/http's plain
251 141 // text.
252 141 r.Handle(assets.DefaultPrefix+"*",
253 141 assets.Handler(a.static, assets.DefaultPrefix, http.HandlerFunc(a.notFound)))
254 141 })
255 }
256
257 // denyCSRF renders the refusal of a mutation that cannot show it came from this
258 // site. It is a 403 and never a redirect: a redirect after a POST drops the
259 // body and would make a refused mutation look like one that worked.
260 6 func (a *app) denyCSRF(w http.ResponseWriter, r *http.Request) {
261 6 a.fail(w, r, http.StatusForbidden, csrf.Message)
262 6 }
263
264 // staticFS is the asset tree for a configured static directory.
265 //
266 // An unconfigured one yields an FS with nothing in it rather than os.DirFS(""),
267 // which resolves every name against the filesystem root: that is not "this
268 // build ships no assets" but "this build serves all of them".
269 141 func staticFS(dir string) fs.FS {
270 141 if dir == "" {
271 136 return emptyFS{}
272 136 }
273 5 return os.DirFS(dir)
274 }
275
276 // emptyFS is an fs.FS in which nothing exists.
277 type emptyFS struct{}
278
279 408 func (emptyFS) Open(name string) (fs.File, error) {
280 408 return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
281 408 }
282
283 // --- shared response helpers -------------------------------------------------
284
285 // errorView is the dot of the shared error page: the chrome, and the payload in
286 // the .Data field the page reads.
287 type errorView struct {
288 chrome.Page
289 Data pages.ErrorData
290 }
291
292 // fail renders the shared error page. An empty message takes the standard
293 // sentence for the status, which is one of the reasons the page is shared: the
294 // visibility rules here require "somebody else's private database" and "no such
295 // database" to be indistinguishable, and two 404s whose prose differed would
296 // rebuild the distinction the status code was chosen to erase.
297 29 func (a *app) fail(w http.ResponseWriter, r *http.Request, status int, msg string) {
298 29 view := errorView{
299 29 Page: a.page(r, http.StatusText(status)+" — "+serviceName),
300 29 Data: pages.Error(status, msg).BackTo("/", "Return to the dashboard"),
301 29 }
302 29 a.render(w, status, pages.ErrorPage, view)
303 29 }
304
305 // notFound renders the 404 page. Used both for genuinely missing repos and to
306 // hide the existence of PRIVATE repos the caller may not browse.
307 11 func (a *app) notFound(w http.ResponseWriter, r *http.Request) {
308 11 a.fail(w, r, http.StatusNotFound, "")
309 11 }
310
311 // forbidden renders the 403 page for a denied but non-hidden request.
312 3 func (a *app) forbidden(w http.ResponseWriter, r *http.Request, msg string) {
313 3 a.fail(w, r, http.StatusForbidden, msg)
314 3 }
315
316 // internalError renders the shared 500 page and logs the cause. The split is the
317 // whole point: the reason a request could not be answered names hosts, queries
318 // and on-disk paths, so it goes to the operator's log, and the reader gets the
319 // same fixed sentence every other bug on this surface produces.
320 //
321 // scribe.Err rather than %v, as the newer handlers here do: it expands a culpa
322 // chain into err.msg, err.code and err.hint instead of flattening it.
323 6 func (a *app) internalError(w http.ResponseWriter, r *http.Request, what string, err error) {
324 6 slog.Error(what, "component", "web", "path", r.URL.Path, scribe.Err(err))
325 6 a.fail(w, r, http.StatusInternalServerError, "")
326 6 }
327
328 // redirectLogin sends an unauthenticated caller to meta's login, returning them
329 // to the current URL afterwards.
330 //
331 // LoginURLFor rather than a whole Page for one field off it: building the page
332 // resolves the nav, the profile link and the brand for a response that is a
333 // Location header and nothing else.
334 2 func (a *app) redirectLogin(w http.ResponseWriter, r *http.Request) {
335 2 http.Redirect(w, r, a.chrome.LoginURLFor(r), http.StatusSeeOther)
336 2 }
337
338 // loadRepoForBrowse loads the repo named by the {user}/{db} URL params and
339 // enforces read (OpBrowse) authorization. On any denial it writes the response
340 // (404 for hidden PRIVATE repos, 403 otherwise) and returns ok=false. On
341 // success it returns the repo, the (possibly nil) caller and the caller's ACL
342 // grant for reuse by the handler.
343 //
344 // "Not there" and "could not be looked up" are two answers and not one; see
345 // repoLookupFailed.
346 92 func (a *app) loadRepoForBrowse(w http.ResponseWriter, r *http.Request) (repo *core.Repo, caller *core.Caller, aclMode *core.AccessMode, ok bool) {
347 92 owner := chi.URLParam(r, "user")
348 92 name := chi.URLParam(r, "db")
349 92
350 92 _, caller = callerOf(r.Context())
351 92
352 92 repo, err := a.cfg.Repos.GetRepoByOwnerAndName(r.Context(), owner, name)
353 92 if err != nil {
354 9 a.repoLookupFailed(w, r, err)
355 9 return nil, nil, nil, false
356 9 }
357
358 83 aclMode = a.effectiveACL(r, caller, repo)
359 83 if !core.Allowed(caller, repo, aclMode, core.OpBrowse) {
360 3 if core.NotFoundForPrivate(caller, repo, aclMode) {
361 3 a.notFound(w, r)
362 3 } else {
363 0 a.forbidden(w, r, "You do not have access to this database.")
364 0 }
365 3 return nil, nil, nil, false
366 }
367 80 return repo, caller, aclMode, true
368 }
369
370 // repoLookupFailed answers a failed {user}/{db} lookup, and is the one place
371 // this surface decides which of the two failures it was.
372 //
373 // - db.ErrNotFound is an answer about the data: there is no such row. It is a
374 // 404, and it has to stay the *same* 404 the visibility rule renders for a
375 // PRIVATE database the caller may not see (core.NotFoundForPrivate, in the
376 // callers below) — a database somebody may not look at must be
377 // indistinguishable from one that is not there, which is the whole reason
378 // the status was chosen.
379 // - Anything else is not an answer at all: the metadata store could not be
380 // read. Reporting that as a 404 tells a reader their database does not
381 // exist when the truth is that we cannot say right now, and it does it on
382 // every page of every database on the instance for as long as Postgres is
383 // down. It is a 500, with the cause logged and never rendered.
384 //
385 // The default arm is the 500 on purpose: an unmapped error is a bug in a layer
386 // below, and rendering it as "not found" would report that bug to the reader as
387 // a fact about their data.
388 10 func (a *app) repoLookupFailed(w http.ResponseWriter, r *http.Request, err error) {
389 10 if errors.Is(err, db.ErrNotFound) {
390 4 a.notFound(w, r)
391 4 return
392 4 }
393 6 a.internalError(w, r, "looking up a database failed", err)
394 }
395
396 // effectiveACL resolves the caller's ACL grant on repo, or nil for an anonymous
397 // caller or a caller with no grant. A lookup error degrades to nil (no grant):
398 // access then falls back to visibility, which never over-grants.
399 240 func (a *app) effectiveACL(r *http.Request, caller *core.Caller, repo *core.Repo) *core.AccessMode {
400 240 if caller == nil {
401 228 return nil
402 228 }
403 12 mode, err := a.cfg.Repos.EffectiveAccess(r.Context(), caller.UserID, repo.ID)
404 12 if err != nil {
405 0 return nil
406 0 }
407 12 return mode
408 }