diff --git a/go.mod b/go.mod index e18c639e0ccadb11f9500d2d5c14167dd5dbcd25..df86422446810c58a4814434592f21fc6c78fd59 100644 --- a/go.mod +++ b/go.mod @@ -15,7 +15,7 @@ github.com/vaughan0/go-ini v0.0.0-20130923145212-a98ad7ee00ec google.golang.org/grpc v1.79.3 gopkg.in/go-jose/go-jose.v2 v2.6.3 sourcecraft.dev/bigbes/sr-ht-core v0.0.0-20260718185800-dd418a200152 - sourcecraft.dev/bigbes/sr-ht-ecore v0.0.0-20260808143603-174115990895 + sourcecraft.dev/bigbes/sr-ht-ecore v0.0.0-20260808192241-9377c43a02ca ) require ( diff --git a/go.sum b/go.sum index 3036fc21eac6b21912c465e06348527b078bbb4d..e0b1fbf272580af510224dca6168b1dfa1e84910 100644 --- a/go.sum +++ b/go.sum @@ -668,3 +668,5 @@ sourcecraft.dev/bigbes/sr-ht-core v0.0.0-20260718185800-dd418a200152 h1:9kQC+tDO2CO8avlKadb9Z0if4a6vJuEK80+4zcb6/fU= sourcecraft.dev/bigbes/sr-ht-core v0.0.0-20260718185800-dd418a200152/go.mod h1:Mu1Vx39ws/OTKWGoVERXvkdRSPLBdhuFTYv0ftVV31c= sourcecraft.dev/bigbes/sr-ht-ecore v0.0.0-20260808143603-174115990895 h1:OGZrtBtMoXhyZGXrPqMzmrNQnStoCLBVuegGo7yF1Us= sourcecraft.dev/bigbes/sr-ht-ecore v0.0.0-20260808143603-174115990895/go.mod h1:KeoZjm+/nnsdtc1WxB7X/0EeC+Rggt2OkwJDEc6XWnw= +sourcecraft.dev/bigbes/sr-ht-ecore v0.0.0-20260808192241-9377c43a02ca h1:LCfxvF1VJl7djl7noAeXObfuY1csJr2OOFExwU4Y/N4= +sourcecraft.dev/bigbes/sr-ht-ecore v0.0.0-20260808192241-9377c43a02ca/go.mod h1:KeoZjm+/nnsdtc1WxB7X/0EeC+Rggt2OkwJDEc6XWnw= diff --git a/web/router.go b/web/router.go index 16d9d08af69a076cf260a6f08641e41f4818d924..c548e4e047e27a64efeec2f02a79fc3e6fb28db4 100644 --- a/web/router.go +++ b/web/router.go @@ -2,10 +2,13 @@ package web import ( "fmt" + "io/fs" "net/http" + "os" "github.com/go-chi/chi/v5" + "sourcecraft.dev/bigbes/sr-ht-ecore/assets" "sourcecraft.dev/bigbes/sr-ht-ecore/chrome" "sourcecraft.dev/bigbes/sr-ht-dolt/authn" @@ -19,6 +22,14 @@ // places would appear in the instance's navigation and fail to find itself in // it. const serviceName = "dolt.sr.ht" +// The two names the static tree is searched for at startup: the hashed artefact +// `make css` produces, and the unhashed stylesheet `make static/main.css` leaves +// in a working copy. +const ( + hashedStyleGlob = "main.min.*.css" + devStyleFile = "main.css" +) + // app bundles the parsed templates, the shared chrome and the injected config. // Handlers are methods on *app so they share this state without a global. type app struct { @@ -28,6 +39,9 @@ // chrome is sr-ht-ecore's shared page frame: the brand, the service // switcher, the login block and the environment banner, built once from the // instance config and asked for a per-request chrome.Page (see page below). chrome *chrome.Service + // static is the built asset tree, mounted under /static/ by ecore's assets + // handler and globbed once at startup for the hashed stylesheet. + static fs.FS // views is a snapshot of the global registeredViews taken at Register time. // Handlers read this (never the global) so tests can inject their own set. views []View @@ -82,16 +96,35 @@ if err != nil { return nil, err } + // The static tree ships beside the binary rather than inside it (the Makefile + // installs it into $SHAREDIR), so the asset FS is an os.DirFS; assets takes + // an fs.FS precisely so both shapes work. + static := staticFS(cfg.StaticDir) + styleHref, err := assets.Resolve(static, hashedStyleGlob, assets.DefaultPrefix) + if err != nil { + return nil, fmt.Errorf("web: resolve the stylesheet: %w", err) + } + if styleHref == "" { + // The unhashed stylesheet of a working copy, linked only when it is + // really there: an href to a file this deployment does not ship would + // 404 once per page load, which is what an empty Resolve avoids. An + // empty href is guarded by the layout. + if _, err := fs.Stat(static, devStyleFile); err == nil { + styleHref = assets.NormalizePrefix(assets.DefaultPrefix) + devStyleFile + } + } + // The switcher, the brand and the login links come from the shared config // read once here; the stylesheet is discovered separately because its name // carries a build hash, which no config file can know. chromeSvc := chrome.NewService(cfg.Conf, serviceName) - chromeSvc.StyleHref = discoverStyleHref(cfg.StaticDir) + chromeSvc.StyleHref = styleHref return &app{ cfg: cfg, templates: templates, chrome: chromeSvc, + static: static, // Snapshot the registry so all handlers see a stable set and tests can // override it per-app without mutating the global. views: append([]View{}, registeredViews...), @@ -123,7 +156,31 @@ r.Get("/~{user}/{db}/view/{view}", a.handleView) r.Get("/~{user}/{db}/settings", a.handleSettings) r.Post("/~{user}/{db}/settings", a.handleSettingsPost) - r.Handle("/static/*", httpStaticHandler(a.cfg.StaticDir)) + // The static tree, with the cache policy the hashed names imply and no + // directory listing — a listing of /static/ would publish this build's + // stylesheet hash, which nothing else on the surface discloses. An asset URL + // typed by hand lands on our own 404 rather than net/http's plain text. + r.Handle(assets.DefaultPrefix+"*", + assets.Handler(a.static, assets.DefaultPrefix, http.HandlerFunc(a.notFound))) +} + +// staticFS is the asset tree for a configured static directory. +// +// An unconfigured one yields an FS with nothing in it rather than os.DirFS(""), +// which resolves every name against the filesystem root: that is not "this +// build ships no assets" but "this build serves all of them". +func staticFS(dir string) fs.FS { + if dir == "" { + return emptyFS{} + } + return os.DirFS(dir) +} + +// emptyFS is an fs.FS in which nothing exists. +type emptyFS struct{} + +func (emptyFS) Open(name string) (fs.File, error) { + return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist} } // --- shared response helpers ------------------------------------------------- diff --git a/web/templates.go b/web/templates.go index b129ac77675e568e3baaf81e9b3dafaef0da8b27..423cfecfd021536d93c8c989a582a06eeebe8929 100644 --- a/web/templates.go +++ b/web/templates.go @@ -7,8 +7,6 @@ "html/template" "io/fs" "net/http" "net/url" - "os" - "sort" "strings" "time" @@ -217,23 +215,6 @@ } return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp]) } -// discoverStyleHref returns the stylesheet href for the layout: the hashed -// production asset if one is present in staticDir (main.min..css, served -// under /static/), else the dev fallback /static/main.css. Globbing at startup -// keeps the cache-busting filename out of the templates. -func discoverStyleHref(staticDir string) string { - const fallback = "/static/main.css" - if staticDir == "" { - return fallback - } - matches, err := fs.Glob(os.DirFS(staticDir), "main.min.*.css") - if err != nil || len(matches) == 0 { - return fallback - } - sort.Strings(matches) - return "/static/" + matches[len(matches)-1] -} - // render executes the named page template with the layout, writing an HTML // response with the given status. A template execution error is a programming // error (bad template or view struct); it is logged and a 500 is written, but @@ -253,9 +234,3 @@ w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(status) _, _ = w.Write([]byte(buf.String())) } - -// httpStaticHandler serves files from staticDir under the /static/ prefix. It -// is mounted by the router; in dev (empty staticDir) it 404s every asset. -func httpStaticHandler(staticDir string) http.Handler { - return http.StripPrefix("/static/", http.FileServer(http.Dir(staticDir))) -} diff --git a/web/web_test.go b/web/web_test.go index 2c093582638ca6fdaaddd338f913962472611af8..4ce2f3caf0b1147f02071b0d57af07c984797220 100644 --- a/web/web_test.go +++ b/web/web_test.go @@ -8,6 +8,8 @@ "fmt" "net/http" "net/http/httptest" "net/url" + "os" + "path/filepath" "strings" "testing" "time" @@ -309,6 +311,13 @@ } func newHarness(t *testing.T) *harness { t.Helper() + return newHarnessWithStatic(t, "") +} + +// newHarnessWithStatic is newHarness for the tests that need a real static +// tree on disk — the asset routes, and the stylesheet the layout links. +func newHarnessWithStatic(t *testing.T, staticDir string) *harness { + t.Helper() store := newFakeStore() stores := &fakeStoreManager{} fb := &fakeBrowse{} @@ -317,7 +326,7 @@ cfg := Config{ Conf: testConfig(), ReposRoot: "/var/lib/dolt", - StaticDir: "", + StaticDir: staticDir, Stores: stores, Repos: store, Browse: fb, @@ -627,6 +636,54 @@ rows := h.do("GET", "/~alice/db/table/main/things", nil, nil) require.Equal(t, http.StatusOK, rows.Code) assert.Contains(t, rows.Body.String(), `
`, "the row browser must be full-bleed") +} + +// The static tree is served by sr-ht-ecore's assets handler, which is tested +// there. What is ours is that we mounted it: that the hashed stylesheet this +// build produced is the one the layout links, that a name whose bytes cannot +// change under it is cacheable and one whose bytes can is not, and that +// /static/ answers a page rather than an inventory of the build. +func TestStaticTreeIsServedWithACachePolicyAndNoListing(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "main.min.0badc0de.css"), []byte("body{}"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "logo.svg"), []byte(""), 0o644)) + + h := newHarnessWithStatic(t, dir) + + hashed := h.do("GET", "/static/main.min.0badc0de.css", nil, nil) + require.Equal(t, http.StatusOK, hashed.Code) + assert.Equal(t, "public, max-age=31536000, immutable", hashed.Header().Get("Cache-Control")) + assert.Empty(t, hashed.Header().Get("Vary"), "an immutable asset must not vary on the cookie") + + unhashed := h.do("GET", "/static/logo.svg", nil, nil) + require.Equal(t, http.StatusOK, unhashed.Code) + assert.Equal(t, "public, max-age=3600", unhashed.Header().Get("Cache-Control")) + + listing := h.do("GET", "/static/", nil, nil) + assert.Equal(t, http.StatusNotFound, listing.Code, "the static tree must not publish a listing") + assert.NotContains(t, listing.Body.String(), `