~bigbes/sr-ht-dolt

f88846acf59598f9d235819608ba4d9ac7cc26c8 — Eugene Blikh 9 days ago bf7897c
web: serve the static tree through ecore's assets

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.
5 files changed, 120 insertions(+), 29 deletions(-)

M go.mod
M go.sum
M web/router.go
M web/templates.go
M web/web_test.go
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})