~bigbes/sr-ht-dolt

17fa0f16c9e02de640d6be5fce3a1a5237f0e462 — Eugene Blikh 9 days ago f88846a
web: render through ecore's pages and its error page

The page list, the per-page parse loop and the view-template loop are
gone: pages discovers every file in templates/, so a page is registered
by existing, and one that defines no content block is refused at startup
rather than served as chrome around a hole. 404.html and 403.html are
gone with them — the shared error page carries the same body, and its
prose is deliberately the same for a database that is not there and one
the viewer may not see.

The renderer that replaced them closes a leak: the old one wrote
"template render error: "+err.Error() into the response body, handing
the viewer template names and field paths. pages answers a fixed
sentence and returns the error for the log.

reltime and abstime come from chrome.Funcs now; ours called every future
instant "just now", where the shared one says "in 3 weeks".
M web/handlers_browse.go => web/handlers_browse.go +4 -4
@@ 82,7 82,7 @@ func (a *app) handleLog(w http.ResponseWriter, r *http.Request) {
		Commits:  commits,
		NextHash: nextHash,
	}
	a.render(w, http.StatusOK, "log.html", view)
	a.render(w, http.StatusOK, "log", view)
}

// handleCommit renders a single commit's per-table diff summary.


@@ 117,7 117,7 @@ func (a *app) handleCommit(w http.ResponseWriter, r *http.Request) {
		Repo:    repo,
		Summary: summary,
	}
	a.render(w, http.StatusOK, "commit.html", view)
	a.render(w, http.StatusOK, "commit", view)
}

// handleTree renders the tables (with schemas) present at a ref.


@@ 156,7 156,7 @@ func (a *app) handleTree(w http.ResponseWriter, r *http.Request) {
		Tables: tables,
		Views:  applicableViews(a.views, tables),
	}
	a.render(w, http.StatusOK, "tree.html", view)
	a.render(w, http.StatusOK, "tree", view)
}

// handleTable renders a table's schema and a paginated page of its rows. Pages


@@ 232,5 232,5 @@ func (a *app) handleTable(w http.ResponseWriter, r *http.Request) {
	// is the table's, not ours, so the centered container turns a wide table into
	// a narrow strip with a scrollbar under it.
	view.ContainerClass = "container-fluid"
	a.render(w, http.StatusOK, "table.html", view)
	a.render(w, http.StatusOK, "table", view)
}

M web/handlers_keys.go => web/handlers_keys.go +1 -1
@@ 31,7 31,7 @@ func (a *app) renderKeys(w http.ResponseWriter, r *http.Request, ac *authContext
		Error:  errMsg,
		Notice: notice,
	}
	a.render(w, status, "keys.html", view)
	a.render(w, status, "keys", view)
}

// handleKeys renders the dolt-key page: the user's registered keys and the

M web/handlers_repo.go => web/handlers_repo.go +4 -4
@@ 36,7 36,7 @@ func (a *app) handleIndex(w http.ResponseWriter, r *http.Request) {
		}
		view.Repos = repoList(repos)
	}
	a.render(w, http.StatusOK, "index.html", view)
	a.render(w, http.StatusOK, "index", view)
}

// handleCreateForm renders the new-database form. Login is required.


@@ 64,7 64,7 @@ func (a *app) renderCreate(w http.ResponseWriter, r *http.Request, status int, f
		Form:  form,
		Error: errMsg,
	}
	a.render(w, status, "create.html", view)
	a.render(w, status, "create", view)
}

// handleCreate processes the new-database form. It validates the name, creates


@@ 164,7 164,7 @@ func (a *app) handleUser(w http.ResponseWriter, r *http.Request) {
		Owner: owner,
		Repos: repoList(repos),
	}
	a.render(w, http.StatusOK, "user.html", view)
	a.render(w, http.StatusOK, "user", view)
}

// repoList adapts our databases to the shared listing partial


@@ 245,7 245,7 @@ func (a *app) handleOverview(w http.ResponseWriter, r *http.Request) {
		CloneURL:      a.cloneURL(repo),
		BrowseError:   browseErr,
	}
	a.render(w, http.StatusOK, "overview.html", view)
	a.render(w, http.StatusOK, "overview", view)
}

// cloneURL builds the HTTPS clone URL for repo: {self origin}/~owner/name. The

M web/handlers_settings.go => web/handlers_settings.go +1 -1
@@ 68,7 68,7 @@ func (a *app) renderSettings(w http.ResponseWriter, r *http.Request, status int,
		Error:  errMsg,
		Notice: notice,
	}
	a.render(w, status, "settings.html", view)
	a.render(w, status, "settings", view)
}

// handleSettings renders the settings page (description/visibility, ACLs, danger

M web/handlers_view.go => web/handlers_view.go +1 -1
@@ 93,5 93,5 @@ func (a *app) handleView(w http.ResponseWriter, r *http.Request) {
		Views:    applicableViews(a.views, tables),
		Data:     data,
	}
	a.render(w, http.StatusOK, view.Template(), envelope)
	a.render(w, http.StatusOK, pageName(view.Template()), envelope)
}

M web/router.go => web/router.go +32 -16
@@ 10,6 10,7 @@ import (

	"sourcecraft.dev/bigbes/sr-ht-ecore/assets"
	"sourcecraft.dev/bigbes/sr-ht-ecore/chrome"
	"sourcecraft.dev/bigbes/sr-ht-ecore/pages"

	"sourcecraft.dev/bigbes/sr-ht-dolt/authn"
	"sourcecraft.dev/bigbes/sr-ht-dolt/core"


@@ 33,8 34,10 @@ const (
// 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 {
	cfg       Config
	templates templateSet
	cfg Config
	// pages is sr-ht-ecore's page-template machinery: one parsed set per page in
	// templates/, plus the shared error page.
	pages pages.Set
	// 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).


@@ 91,7 94,7 @@ func newApp(cfg Config) (*app, error) {
		return nil, fmt.Errorf("web: Register requires Conf (the chrome and the origins are built from it)")
	}

	templates, err := loadTemplates()
	set, err := loadPages()
	if err != nil {
		return nil, err
	}


@@ 121,10 124,10 @@ func newApp(cfg Config) (*app, error) {
	chromeSvc.StyleHref = styleHref

	return &app{
		cfg:       cfg,
		templates: templates,
		chrome:    chromeSvc,
		static:    static,
		cfg:    cfg,
		pages:  set,
		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...),


@@ 185,22 188,35 @@ func (emptyFS) Open(name string) (fs.File, error) {

// --- shared response helpers -------------------------------------------------

// errorView is the dot of the shared error page: the chrome, and the payload in
// the .Data field the page reads.
type errorView struct {
	chrome.Page
	Data pages.ErrorData
}

// fail renders the shared error page. An empty message takes the standard
// sentence for the status, which is one of the reasons the page is shared: the
// visibility rules here require "somebody else's private database" and "no such
// database" to be indistinguishable, and two 404s whose prose differed would
// rebuild the distinction the status code was chosen to erase.
func (a *app) fail(w http.ResponseWriter, r *http.Request, status int, msg string) {
	view := errorView{
		Page: a.page(r, http.StatusText(status)+" — "+serviceName),
		Data: pages.Error(status, msg).BackTo("/", "Return to the dashboard"),
	}
	a.render(w, status, pages.ErrorPage, view)
}

// notFound renders the 404 page. Used both for genuinely missing repos and to
// hide the existence of PRIVATE repos the caller may not browse.
func (a *app) notFound(w http.ResponseWriter, r *http.Request) {
	view := struct {
		chrome.Page
	}{Page: a.page(r, "Not found — "+serviceName)}
	a.render(w, http.StatusNotFound, "404.html", view)
	a.fail(w, r, http.StatusNotFound, "")
}

// forbidden renders the 403 page for a denied but non-hidden request.
func (a *app) forbidden(w http.ResponseWriter, r *http.Request, msg string) {
	view := struct {
		chrome.Page
		Message string
	}{Page: a.page(r, "Forbidden — "+serviceName), Message: msg}
	a.render(w, http.StatusForbidden, "403.html", view)
	a.fail(w, r, http.StatusForbidden, msg)
}

// redirectLogin sends an unauthenticated caller to meta's login, returning them

M web/templates.go => web/templates.go +43 -134
@@ 5,96 5,43 @@ import (
	"fmt"
	"html/template"
	"io/fs"
	"log"
	"net/http"
	"net/url"
	"strings"
	"time"

	"sourcecraft.dev/bigbes/sr-ht-ecore/chrome"
	"sourcecraft.dev/bigbes/sr-ht-ecore/pages"
)

//go:embed templates/*.html templates/icons/*.svg
var templateFS embed.FS

// pageTemplates lists every content page. Each is parsed together with the
// shared layout, nav and partials into its own *template.Template, so the
// per-page {{define "content"}} blocks never collide.
var pageTemplates = []string{
	"index.html",
	"create.html",
	"user.html",
	"overview.html",
	"log.html",
	"commit.html",
	"tree.html",
	"table.html",
	"settings.html",
	"keys.html",
	"404.html",
	"403.html",
}

// sharedTemplates are parsed into every page: the outer layout and this
// service's own reusable partials (badges, tab bars). The chrome partials —
// the brand, the switcher, the login block, the environment banner, the
// listing — are NOT here: they come from sr-ht-ecore and are attached to every
// set by chrome.Attach (loadTemplates), which is the copy every custom service
// on the instance draws from.
var sharedTemplates = []string{
	"templates/layout.html",
	"templates/partials.html",
}

// templateSet maps a page name to its fully-parsed template (execute "layout").
type templateSet map[string]*template.Template

// loadTemplates parses every page template with the shared chrome and the
// funcmap. It fails loudly (returns an error) on any parse problem so a broken
// template surfaces at startup, never as a blank page at request time.
func loadTemplates() (templateSet, error) {
// loadPages parses one template set per page in templates/, through
// sr-ht-ecore's pages: the layout, the shared chrome partials, our own
// _partials.html and that page's content.
//
// There is no list of pages here any more. Pages are discovered from the
// directory, so a new page — or a new View's beads.html — is registered by
// existing as a file, and the loader a registration used to have to remember is
// gone with it. A page that defines no "content" block, and a missing layout,
// are startup errors: the first would otherwise serve the chrome around a hole
// under a 200.
//
// The error page is not ours: pages ships one (registered as pages.ErrorPage)
// and parses its "srht-error" body into every set, so the 404 and 403 templates
// this service used to carry are gone rather than reworded.
func loadPages() (pages.Set, error) {
	icons, err := loadIcons()
	if err != nil {
		return nil, err
	}
	funcs := templateFuncs(icons)

	set := make(templateSet, len(pageTemplates))
	for _, page := range pageTemplates {
		t, err := chrome.Attach(template.New("layout").Funcs(funcs))
		if err != nil {
			return nil, fmt.Errorf("web: attach the shared chrome partials for %s: %w", page, err)
		}
		files := append(append([]string{}, sharedTemplates...), "templates/"+page)
		if _, err := t.ParseFS(templateFS, files...); err != nil {
			return nil, fmt.Errorf("web: parse template %s: %w", page, err)
		}
		set[page] = t
	}
	return pages.Load(templateFS, pages.Options{Funcs: templateFuncs(icons)})
}

	// Parse the page template of every registered view the same way, so a
	// concrete view (e.g. the future "beads" view) becomes renderable by adding
	// only its own web/beads.go + templates/beads.html — the embed.FS glob picks
	// the new file up at compile time and this loop parses it, with no edit to
	// this loader. The registry is fully populated by the init()s that ran before
	// Register called us. A view whose Template() is already a known page (which
	// a test may deliberately reuse, e.g. "tree.html") is skipped rather than
	// re-parsed, so registration never clobbers a page template.
	for _, v := range registeredViews {
		name := v.Template()
		if _, ok := set[name]; ok {
			continue
		}
		t, err := chrome.Attach(template.New("layout").Funcs(funcs))
		if err != nil {
			return nil, fmt.Errorf("web: attach the shared chrome partials for %s: %w", name, err)
		}
		files := append(append([]string{}, sharedTemplates...), "templates/"+name)
		if _, err := t.ParseFS(templateFS, files...); err != nil {
			return nil, fmt.Errorf("web: parse view template %s: %w", name, err)
		}
		set[name] = t
	}
	return set, nil
// pageName maps a template file name ("beads.html", what a View declares) to
// the name pages registers it under ("beads", what Render takes).
func pageName(file string) string {
	return strings.TrimSuffix(file, ".html")
}

// loadIcons reads every embedded icon SVG into a name→markup map for the icon


@@ 120,24 67,19 @@ func loadIcons() (map[string]template.HTML, error) {
	return icons, nil
}

// templateFuncs is the funcmap available in every template.
//
// It starts from chrome.Funcs — "dict" and "shortsha", which the shared
// partials and half this family's pages were written against — and adds this
// service's own on top. Adding after is deliberate: a name may then be shadowed
// on purpose rather than by accident of map ordering. Nothing here re-defines a
// shared helper; the local copies of dict and the hash abbreviator are gone.
// templateFuncs is this service's own funcmap. pages merges it over
// chrome.Funcs — "dict", "shortsha", "reltime" and "abstime", which the shared
// partials and half this family's pages were written against — so only the
// helpers nobody else has are listed here. The local copies of the relative and
// absolute time formatters are gone with the rest; chrome's reltime also faces
// forward ("in 3 weeks"), where ours called every future instant "just now".
func templateFuncs(icons map[string]template.HTML) template.FuncMap {
	m := chrome.Funcs()
	m := template.FuncMap{}

	// icon renders a named inline SVG (from templates/icons). An unknown name
	// yields empty output rather than a hard error, so a missing icon never
	// crashes a page.
	m["icon"] = func(name string) template.HTML { return icons[name] }
	// reltime renders a humanized relative time ("3 hours ago"), no deps.
	m["reltime"] = humanizeTime
	// abstime renders an absolute UTC timestamp for tooltips/detail.
	m["abstime"] = func(t time.Time) string { return t.UTC().Format("2006-01-02 15:04:05 UTC") }
	// humansize renders a byte count as a human-readable size.
	m["humansize"] = humanizeSize
	// "upper" and "lower" used to be here for the environment banner and the


@@ 171,36 113,6 @@ func doltHost(origin string) string {
	return u.Host + ":443"
}

// humanizeTime renders t as a coarse relative time in the past. It is a small
// self-contained helper (no new dependency) covering seconds→years.
func humanizeTime(t time.Time) string {
	d := time.Since(t)
	if d < 0 {
		return "just now"
	}
	switch {
	case d < time.Minute:
		return "just now"
	case d < time.Hour:
		return plural(int(d/time.Minute), "minute")
	case d < 24*time.Hour:
		return plural(int(d/time.Hour), "hour")
	case d < 30*24*time.Hour:
		return plural(int(d/(24*time.Hour)), "day")
	case d < 365*24*time.Hour:
		return plural(int(d/(30*24*time.Hour)), "month")
	default:
		return plural(int(d/(365*24*time.Hour)), "year")
	}
}

func plural(n int, unit string) string {
	if n == 1 {
		return "1 " + unit + " ago"
	}
	return fmt.Sprintf("%d %ss ago", n, unit)
}

// humanizeSize renders a byte count with binary (1024) units.
func humanizeSize(n uint64) string {
	const unit = 1024


@@ 215,22 127,19 @@ func humanizeSize(n uint64) string {
	return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
}

// 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
// never a partially-flushed page — we render into a buffer first.
// render is pages.Set.Render with this service's log line on the end.
//
// It is the whole of what is left of the old renderer, and the deleted half is
// the point: the previous one wrote "template render error: "+err.Error() into
// the response body, publishing template names, field paths and whatever the
// payload's String method produced to whoever asked for the page. pages answers
// a fixed sentence and hands the error back for the log, which is where a
// broken template belongs.
//
// A returned error means the response is already answered; there is nothing to
// do with it here but say so.
func (a *app) render(w http.ResponseWriter, status int, page string, data any) {
	t, ok := a.templates[page]
	if !ok {
		http.Error(w, "template not found", http.StatusInternalServerError)
		return
	}
	var buf strings.Builder
	if err := t.ExecuteTemplate(&buf, "layout", data); err != nil {
		http.Error(w, "template render error: "+err.Error(), http.StatusInternalServerError)
		return
	if err := a.pages.Render(w, status, page, data); err != nil {
		log.Printf("web: %v", err)
	}
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	w.WriteHeader(status)
	_, _ = w.Write([]byte(buf.String()))
}

D web/templates/403.html => web/templates/403.html +0 -6
@@ 1,6 0,0 @@
{{define "content" -}}
<div class="header-extension"></div>
<h2>403 &mdash; Forbidden</h2>
<p>{{if .Message}}{{.Message}}{{else}}You do not have access to this resource.{{end}}</p>
<p><a href="/">Return to the dashboard</a></p>
{{- end}}

D web/templates/404.html => web/templates/404.html +0 -6
@@ 1,6 0,0 @@
{{define "content" -}}
<div class="header-extension"></div>
<h2>404 &mdash; Not found</h2>
<p>The page or database you requested does not exist.</p>
<p><a href="/">Return to the dashboard</a></p>
{{- end}}

R web/templates/partials.html => web/templates/_partials.html +0 -0
M web/templates/layout.html => web/templates/layout.html +9 -3
@@ 9,8 9,15 @@
  width is .ContainerClass rather than a literal "container": the row browser
  hands out "container-fluid" so a wide table gets the whole viewport, and every
  other page gets the centered default chrome.Page already carries.
*/}}
{{define "layout" -}}

  The file is the layout's body rather than a {{define "layout"}} block: pages
  names each set after this file and executes the set itself, so a body wrapped
  in a define would leave the template that is actually executed empty.

  The content hole is {{template}} and never {{block}}: a block would define an
  empty default for "content" on every page at once, which is exactly what
  pages.Load refuses a page for at startup.
*/ -}}
<!doctype html>
<html lang="en">
  <head>


@@ 32,4 39,3 @@
    </div>
  </body>
</html>
{{- end}}

M web/views.go => web/views.go +11 -8
@@ 28,17 28,20 @@ type View interface {
}

// registeredViews is the package-global registry populated at init time by
// RegisterView. It is read once per process: loadTemplates parses each view's
// Template() at startup, and Register snapshots it into app.views so handlers
// (and tests, which set app.views directly) never touch the global at request
// time.
// RegisterView. It is read once per process: Register snapshots it into
// app.views so handlers (and tests, which set app.views directly) never touch
// the global at request time.
//
// Nothing here parses a view's Template() any more: pages discovers every page
// in templates/, so a view's page is registered by its file existing. A view
// whose Template() names no such file renders as a 500 with a log line, which
// is what it is.
var registeredViews []View

// RegisterView adds v to the global registry. It is meant to be called from an
// init() in the file that defines a concrete view, so the registry is fully
// populated before loadTemplates runs and the first request arrives. If a view
// with the same Name() is already registered it is replaced, so double
// registration (e.g. from a duplicated init) is safe.
// init() in the file that defines a concrete view. If a view with the same
// Name() is already registered it is replaced, so double registration (e.g.
// from a duplicated init) is safe.
func RegisterView(v View) {
	for i, existing := range registeredViews {
		if existing.Name() == v.Name() {

M web/views_test.go => web/views_test.go +12 -10
@@ 2,29 2,31 @@ package web

import (
	"context"
	"fmt"
	"net/http"
	"net/url"
	"regexp"
	"strings"
	"testing"

	"sourcecraft.dev/bigbes/sr-ht-ecore/pages"

	"sourcecraft.dev/bigbes/sr-ht-dolt/browse"
	"sourcecraft.dev/bigbes/sr-ht-dolt/core"
)

// dummyView is a test-only View that fingerprints repos containing a table
// named "issues". It renders through an EXISTING page template ("404.html") so
// the test needs no newly embedded file. That template references only the
// shared chrome (basePage), so it renders cleanly with the fixed view envelope
// (which does not carry a .Tables field the way tree.html would demand). It
// records whether Build ran.
// named "issues". It renders through the shared error page, which is the one
// page every service's set is guaranteed to carry and the only one that asks
// nothing of the envelope beyond the .Data field every view already has. Its
// Build therefore returns a pages.ErrorData. It records whether Build ran.
type dummyView struct {
	built bool
}

func (d *dummyView) Name() string     { return "issues" }
func (d *dummyView) Label() string    { return "Issues" }
func (d *dummyView) Template() string { return "404.html" }
func (d *dummyView) Template() string { return pages.ErrorPage + ".html" }

func (d *dummyView) Applies(tables []browse.TableInfo) bool {
	for _, t := range tables {


@@ 38,12 40,12 @@ func (d *dummyView) Applies(tables []browse.TableInfo) bool {
func (d *dummyView) Build(_ context.Context, sess BrowseSession, _ *core.Repo, ref string, _ url.Values) (any, error) {
	d.built = true
	// Pull data through the BrowseSession surface only, mirroring how a real
	// view works, and return the tables so tree.html renders something.
	// view works, then hand the page the payload it reads.
	tables, err := sess.Tables(context.Background(), ref)
	if err != nil {
		return nil, err
	}
	return tables, nil
	return pages.Error(http.StatusOK, fmt.Sprintf("%d tables", len(tables))), nil
}

func issueTables() []browse.TableInfo {


@@ 84,10 86,10 @@ type alwaysView struct{}

func (alwaysView) Name() string                    { return "always" }
func (alwaysView) Label() string                   { return "Always" }
func (alwaysView) Template() string                { return "404.html" }
func (alwaysView) Template() string                { return pages.ErrorPage + ".html" }
func (alwaysView) Applies([]browse.TableInfo) bool { return true }
func (alwaysView) Build(context.Context, BrowseSession, *core.Repo, string, url.Values) (any, error) {
	return nil, nil
	return pages.Error(http.StatusOK, ""), nil
}

func TestHandleViewSelectedAndBuilt(t *testing.T) {

M web/web_test.go => web/web_test.go +58 -0
@@ 21,6 21,8 @@ import (
	"github.com/vaughan0/go-ini"
	"sourcecraft.dev/bigbes/sr-ht-core/auth"

	"sourcecraft.dev/bigbes/sr-ht-ecore/pages"

	"sourcecraft.dev/bigbes/sr-ht-dolt/authn"
	"sourcecraft.dev/bigbes/sr-ht-dolt/browse"
	"sourcecraft.dev/bigbes/sr-ht-dolt/core"


@@ 638,6 640,62 @@ func TestPagesAreDrawnThroughTheSharedChrome(t *testing.T) {
		"the row browser must be full-bleed")
}

// The 404 and 403 templates this service carried are sr-ht-ecore's error page
// now: one body, drawn through our own chrome, with the shared sentence. The
// wording matters here — a 404 that described the missing thing would tell an
// anonymous viewer which private databases exist.
func TestRefusalsRenderTheSharedErrorPage(t *testing.T) {
	h := newHarness(t)
	h.store.add(&core.Repo{Name: "db", OwnerID: 10, OwnerName: "owner", Path: "/d", Visibility: core.VisibilityPublic})

	missing := h.do("GET", "/~owner/nosuch", nil, nil)
	require.Equal(t, http.StatusNotFound, missing.Code)
	assert.Contains(t, missing.Body.String(), pages.NotFoundMessage)
	assert.Contains(t, missing.Body.String(), "404 &mdash; Not Found")
	assert.Contains(t, missing.Body.String(), `<span class="text-danger">dolt</span>`,
		"the error page is drawn through our chrome")

	denied := h.do("GET", "/~owner/db/settings", testCaller(99, "intruder"), nil)
	require.Equal(t, http.StatusForbidden, denied.Code)
	assert.Contains(t, denied.Body.String(), "Only the owner may change database settings.")
}

// leakyView renders a page whose content block reads a field its envelope does
// not carry, so executing it fails halfway. It is the shape of the bug the old
// renderer turned into a disclosure.
type leakyView struct{}

func (leakyView) Name() string                    { return "leaky" }
func (leakyView) Label() string                   { return "Leaky" }
func (leakyView) Template() string                { return "keys.html" }
func (leakyView) Applies([]browse.TableInfo) bool { return true }
func (leakyView) Build(context.Context, BrowseSession, *core.Repo, string, url.Values) (any, error) {
	return nil, nil
}

// A template that fails halfway answers a fixed sentence. The previous renderer
// wrote "template render error: "+err.Error() into the body, which hands the
// viewer the template's name and the field path that was not there; the error
// belongs in the log and nowhere else.
func TestATemplateFailureTellsTheViewerNothing(t *testing.T) {
	h := newHarness(t)
	h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
	h.browse.sess = &fakeSession{
		branches: []browse.Branch{{Name: "main", Head: "abcdef1234567890"}},
		tables:   issueTables(),
	}
	setViews(t, h, leakyView{})

	rec := h.do("GET", "/~alice/db/view/leaky", nil, nil)
	require.Equal(t, http.StatusInternalServerError, rec.Code)

	body := rec.Body.String()
	assert.Equal(t, "internal server error\n", body)
	assert.NotContains(t, body, "keys.html", "the template name must not reach the viewer")
	assert.NotContains(t, body, "Keys", "the field path must not reach the viewer")
	assert.NotContains(t, body, "can't evaluate")
}

// 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