M go.mod => go.mod +1 -1
@@ 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 (
M go.sum => go.sum +2 -0
@@ 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=
M web/router.go => web/router.go +59 -2
@@ 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 -------------------------------------------------
M web/templates.go => web/templates.go +0 -25
@@ 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.<sha>.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)))
-}
M web/web_test.go => web/web_test.go +58 -1
@@ 8,6 8,8 @@ import (
"net/http"
"net/http/httptest"
"net/url"
+ "os"
+ "path/filepath"
"strings"
"testing"
"time"
@@ 309,6 311,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{}
fb := &fakeBrowse{}
@@ 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("<svg/>"), 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(), `<a href="logo.svg"`, "no directory entries")
+
+ // The hashed name reaches the layout; the dev fallback does not, because
+ // this tree has a hashed stylesheet.
+ page := h.do("GET", "/", nil, nil)
+ require.Equal(t, http.StatusOK, page.Code)
+ assert.Contains(t, page.Body.String(), `href="/static/main.min.0badc0de.css"`)
+}
+
+// A working copy that has only run `make static/main.css` still gets a
+// stylesheet; one that has built nothing links none at all rather than an href
+// that 404s on every page load.
+func TestStylesheetFallsBackToTheUnhashedBuildOnlyWhenItExists(t *testing.T) {
+ dir := t.TempDir()
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "main.css"), []byte("body{}"), 0o644))
+
+ dev := newHarnessWithStatic(t, dir).do("GET", "/", nil, nil)
+ require.Equal(t, http.StatusOK, dev.Code)
+ assert.Contains(t, dev.Body.String(), `href="/static/main.css"`)
+
+ bare := newHarnessWithStatic(t, t.TempDir()).do("GET", "/", nil, nil)
+ require.Equal(t, http.StatusOK, bare.Code)
+ assert.NotContains(t, bare.Body.String(), `rel="stylesheet"`)
+}
+
func TestLogAndTablePages(t *testing.T) {
h := newHarness(t)
h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})