From f88846acf59598f9d235819608ba4d9ac7cc26c8 Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Sat, 8 Aug 2026 22:37:38 +0300 Subject: [PATCH] web: serve the static tree through ecore's assets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit discoverStyleHref and the bare http.FileServer are sr-ht-ecore's assets package now: one hashed-name pattern, the cache policy the hash implies (immutable for a content-addressed name, an hour for the rest), and a refusal to publish a directory listing of the build. The unhashed fallback survives, but only when static/main.css is really there — an href to a file this deployment does not ship is a 404 per page load, which is what an empty Resolve exists to avoid. --- go.mod | 2 +- go.sum | 2 ++ web/router.go | 61 ++++++++++++++++++++++++++++++++++++++++++++++-- web/templates.go | 25 -------------------- web/web_test.go | 59 +++++++++++++++++++++++++++++++++++++++++++++- 5 files changed, 120 insertions(+), 29 deletions(-) diff --git a/go.mod b/go.mod index e18c639e0ccadb11f9500d2d5c14167dd5dbcd25..df86422446810c58a4814434592f21fc6c78fd59 100644 --- a/go.mod +++ b/go.mod @@ -15,7 +15,7 @@ require ( 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+tDO 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 @@ import ( // 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 @@ type app struct { // 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 @@ func newApp(cfg Config) (*app, error) { 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 @@ func (a *app) mount(r chi.Router) { 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 @@ import ( "io/fs" "net/http" "net/url" - "os" - "sort" "strings" "time" @@ -217,23 +215,6 @@ func humanizeSize(n uint64) string { 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 @@ func (a *app) render(w http.ResponseWriter, status int, page string, data any) { 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 @@ import ( "net/http" "net/http/httptest" "net/url" + "os" + "path/filepath" "strings" "testing" "time" @@ -308,6 +310,13 @@ type harness struct { } 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{} @@ -317,7 +326,7 @@ func newHarness(t *testing.T) *harness { cfg := Config{ Conf: testConfig(), ReposRoot: "/var/lib/dolt", - StaticDir: "", + StaticDir: staticDir, Stores: stores, Repos: store, Browse: fb, @@ -629,6 +638,54 @@ func TestPagesAreDrawnThroughTheSharedChrome(t *testing.T) { "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(), `