view.go

240 lines
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240
package application

import (
	"context"
	"fmt"
	"html/template"
	"io/fs"
	"log"
	"maps"
	"net/http"
	"os"
	"path/filepath"
	"strings"
)

// ViewEngine owns Congo's template machinery: the embedded views filesystem, the
// base templates (layouts + partials) parsed once at boot, the registered
// template functions, and the per-request nonce source. App embeds a
// *ViewEngine, so all of this stays out of app.go and the application core stays
// thin — App is the router and controller registry; ViewEngine is the renderer.
//
// Rendering is pure memory work. Templates are parsed once at boot and held in
// memory, so a render never touches disk; combined with an embedded, in-memory
// LibSQL read path, a full page render — layout plus controller method calls
// plus their queries — is CPU and RAM only, with no IO. That is what makes the
// lazy "{{auth.CurrentUser}}" pattern cheap even when it fans out into several
// reads per page.
type ViewEngine struct {
	fs    fs.FS
	base  *template.Template
	funcs template.FuncMap

	// nonce returns the per-request CSP nonce from a context. It defaults to
	// returning "" and is set via WithNonce (whose signature matches
	// security.NonceFrom), so application does not import security to read it.
	nonce func(context.Context) string

	// ScriptFunc renders the frontend <script> tags for a nonce. The frontend
	// package sets it; it stays nil when no bundler is wired.
	ScriptFunc func(nonce string) template.HTML

	// parseErrors collects templates that failed to parse at boot — for example a
	// layout that references a controller which has since been deleted, so its
	// {{gone.Method}} no longer resolves to a known function. Validate surfaces
	// them alongside reference issues so a broken build fails at boot rather than
	// silently dropping a template.
	parseErrors []error
}

// newViewEngine creates an empty engine over a views filesystem. Base templates
// are parsed later, in New, once options have registered every func and
// controller, so the placeholder function set the parser sees is complete.
func newViewEngine(views fs.FS) *ViewEngine {
	return &ViewEngine{
		fs:    views,
		funcs: template.FuncMap{},
		nonce: func(context.Context) string { return "" },
	}
}

// templateFuncs returns the built-in funcs plus every user-registered func. It
// seeds the FuncMap the base templates are parsed with, so parsing never fails on
// an unknown function name.
func (ve *ViewEngine) templateFuncs() template.FuncMap {
	funcs := template.FuncMap{
		"dict": func(values ...any) map[string]any {
			if len(values)%2 != 0 {
				return nil
			}
			m := make(map[string]any, len(values)/2)
			for i := 0; i < len(values); i += 2 {
				key, ok := values[i].(string)
				if !ok {
					continue
				}
				m[key] = values[i+1]
			}
			return m
		},
	}
	maps.Copy(funcs, ve.funcs)
	return funcs
}

// parse loads layouts and partials from the filesystem into the base template.
// controllerNames seed placeholder accessor funcs so templates that call
// {{home.Method}} parse; the real, request-bound versions are injected per
// render. Called once, at boot.
func (ve *ViewEngine) parse(controllerNames []string) {
	funcs := ve.templateFuncs()
	for _, name := range controllerNames {
		funcs[name] = func() Controller { return nil }
	}
	funcs["nonce"] = func() string { return "" }
	funcs["frontend_script"] = func() template.HTML { return "" }

	tmpl := template.New("").Funcs(funcs)
	for _, dir := range []string{"views/layouts", "views/partials"} {
		if err := fs.WalkDir(ve.fs, dir, func(path string, d fs.DirEntry, err error) error {
			if err != nil {
				return err
			}
			if d.IsDir() || !strings.HasSuffix(path, ".html") {
				return nil
			}
			content, err := fs.ReadFile(ve.fs, path)
			if err != nil {
				return fmt.Errorf("read %s: %w", path, err)
			}
			name := filepath.Base(path)
			if _, err := tmpl.New(name).Parse(string(content)); err != nil {
				ve.parseErrors = append(ve.parseErrors, fmt.Errorf("failed to parse %q: %w", name, err))
			}
			return nil
		}); err != nil {
			log.Printf("Warning: could not load templates from %s: %v", dir, err)
		}
	}
	ve.base = tmpl
}

// loadView clones the base templates (layouts + partials) and parses the named
// view file into the clone. Cloning is required because executing a template
// marks it used and it cannot then be re-parsed.
func (ve *ViewEngine) loadView(name string) (*template.Template, error) {
	tmpl, err := ve.base.Clone()
	if err != nil {
		return nil, err
	}
	if tmpl.Lookup(name) != nil {
		return tmpl, nil
	}
	content, err := fs.ReadFile(ve.fs, "views/"+name)
	if err != nil {
		return nil, fmt.Errorf("view not found: %s", name)
	}
	_, err = tmpl.New(name).Parse(string(content))
	return tmpl, err
}

// RenderToString renders a template to a string with no request context, so it
// injects no controller accessors — use it for partials and SSE fragments that
// take their data explicitly. For request-bound rendering with {{ctrl.Method}}
// accessors, controllers call Render.
func (ve *ViewEngine) RenderToString(name string, data any) (string, error) {
	tmpl, err := ve.base.Clone()
	if err != nil {
		return "", err
	}
	var buf strings.Builder
	if t := tmpl.Lookup(name); t != nil {
		if err := t.Execute(&buf, data); err != nil {
			return "", err
		}
		return buf.String(), nil
	}
	content, err := fs.ReadFile(ve.fs, "views/"+name)
	if err != nil {
		return "", fmt.Errorf("template not found: %s", name)
	}
	if _, err := tmpl.New(name).Parse(string(content)); err != nil {
		return "", err
	}
	if err := tmpl.ExecuteTemplate(&buf, name, data); err != nil {
		return "", err
	}
	return buf.String(), nil
}

// render executes a view for a request, injecting the request-bound controller
// accessors ({{auth.CurrentUser}}), the CSP nonce, and the frontend script tag,
// then writes the buffered HTML. Buffering means a template error never emits a
// half-written page. This lives on App because it needs the controller registry;
// the template mechanics it calls live on ViewEngine.
func (app *App) render(w http.ResponseWriter, r *http.Request, name string, data any) {
	tmpl, err := app.loadView(name)
	if err != nil {
		log.Printf("Template load error: %v", err)
		http.Error(w, "Template error", http.StatusInternalServerError)
		return
	}

	funcs := template.FuncMap{}
	for ctrlName := range app.controllers {
		name := ctrlName
		funcs[name] = func() Controller { return app.use(name, r) }
	}
	nonce := app.nonce(r.Context())
	funcs["nonce"] = func() string { return nonce }
	if app.ScriptFunc != nil {
		funcs["frontend_script"] = func() template.HTML { return app.ScriptFunc(nonce) }
	}

	var buf strings.Builder
	if err := tmpl.Funcs(funcs).ExecuteTemplate(&buf, name, data); err != nil {
		log.Printf("Template error: %v", err)
		http.Error(w, "Template error", http.StatusInternalServerError)
		return
	}
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	w.Write([]byte(buf.String()))
}

// View is an http.Handler that renders a template, optionally gated by a Bouncer.
type View struct {
	*App
	template string
	bouncer  Bouncer
}

// ServeHTTP renders the view.
func (v *View) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	if v.bouncer != nil && !v.bouncer(v.App, w, r) {
		return
	}
	v.App.render(w, r, v.template, nil)
}

// Render executes a view template, writing HTML to the response.
func (c *BaseController) Render(w http.ResponseWriter, r *http.Request, templateName string, data any) {
	c.App.render(w, r, templateName, data)
}

// RenderError renders an error message as an HTMX-friendly alert (status 200). In
// production the detail is logged and hidden so internal errors never leak.
func RenderError(w http.ResponseWriter, r *http.Request, err error) {
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	w.WriteHeader(http.StatusOK)
	message := err.Error()
	if os.Getenv("ENV") == "production" {
		log.Printf("[error] %s %s: %v", r.Method, r.URL.Path, err)
		message = "An error occurred. Please try again."
	}
	fmt.Fprintf(w, `<div class="alert alert-error">%s</div>`, template.HTMLEscapeString(message))
}

// RenderError delegates to the package-level RenderError.
func (c *BaseController) RenderError(w http.ResponseWriter, r *http.Request, err error) {
	RenderError(w, r, err)
}