5.9 KB
views.go
package application
import (
"fmt"
"html/template"
"io/fs"
"log"
"maps"
"net/http"
"os"
"path/filepath"
"strings"
)
// View is an http.Handler that renders a template
type View struct {
*App
template string
bouncer Bouncer
}
// ServeHTTP implements http.Handler
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 renders a template with optional data
func (c *BaseController) Render(w http.ResponseWriter, r *http.Request, templateName string, data any) {
c.App.render(w, r, templateName, data)
}
// render executes a template with controllers injected as template functions
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
}
// Inject controllers as template functions (accessible as {{home.Method}})
funcs := template.FuncMap{}
for ctrlName, ctrl := range app.controllers {
c := ctrl
funcs[ctrlName] = func() Controller {
return c.Handle(r)
}
}
// Buffer template execution to prevent partial writes on error
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()))
}
// RenderError renders an error message (returns 200 for HTMX compatibility).
// In production (ENV=production), internal error details are hidden to prevent
// information leakage. Full errors are logged for debugging.
func (c *BaseController) 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()
// In production, sanitize error messages to hide internal details
if os.Getenv("ENV") == "production" {
// Log the full error for debugging
log.Printf("[error] %s %s: %v", r.Method, r.URL.Path, err)
// Check if error contains sensitive patterns
lower := strings.ToLower(message)
if strings.Contains(lower, "sql") || strings.Contains(lower, "database") ||
strings.Contains(lower, "connection") || strings.Contains(lower, "file:") ||
strings.Contains(lower, "panic") || strings.Contains(lower, "/home/") ||
strings.Contains(lower, "runtime error") {
message = "An error occurred. Please try again."
}
}
fmt.Fprintf(w, `<div class="alert alert-error">%s</div>`, template.HTMLEscapeString(message))
}
// RenderToString renders a template to a string.
// For partials (already in base templates), use just the filename: "live-time.html"
// For views, use the path relative to views/: "index.html" or "admin/dashboard.html"
func (app *App) RenderToString(name string, data any) (string, error) {
// Clone base templates (required - executing marks templates as used)
tmpl, err := app.base.Clone()
if err != nil {
return "", err
}
var buf strings.Builder
// Check if template exists in base (partials, layouts)
if t := tmpl.Lookup(name); t != nil {
if err := t.Execute(&buf, data); err != nil {
return "", err
}
return buf.String(), nil
}
// Otherwise load as a view file
path := "views/" + name
content, err := fs.ReadFile(app.viewsFS, path)
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
}
// parseBaseTemplates parses only layouts and partials from the filesystem.
// These are loaded once at startup and cloned for each request.
func (app *App) parseBaseTemplates(views fs.FS) *template.Template {
funcs := app.templateFuncs()
// Register placeholder functions for controllers (replaced per-request in render)
for name := range app.controllers {
funcs[name] = func() Controller { return nil }
}
tmpl := template.New("").Funcs(funcs)
// Only load from layouts/ and partials/ directories
for _, dir := range []string{"views/layouts", "views/partials"} {
fs.WalkDir(views, dir, func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() || !strings.HasSuffix(path, ".html") {
return nil
}
content, err := fs.ReadFile(views, path)
if err != nil {
return nil
}
name := filepath.Base(path)
if _, err := tmpl.New(name).Parse(string(content)); err != nil {
log.Printf("Failed to parse template %s: %v", name, err)
}
return nil
})
}
return tmpl
}
// loadView loads a view file on-demand, cloning base templates (layouts + partials)
// and parsing the specific view file into the clone.
func (app *App) loadView(name string) (*template.Template, error) {
// Clone base templates (layouts + partials)
tmpl, err := app.base.Clone()
if err != nil {
return nil, err
}
// Try to load the view file
path := "views/" + name
content, err := fs.ReadFile(app.viewsFS, path)
if err != nil {
return nil, fmt.Errorf("view not found: %s", name)
}
// Parse view into cloned template
_, err = tmpl.New(name).Parse(string(content))
return tmpl, err
}
// templateFuncs returns the custom template functions available to all views and emails
func (app *App) 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
},
}
// Merge user-defined funcs (globally available in views and emails)
maps.Copy(funcs, app.funcs)
return funcs
}