readysite / pkg / application / views.go
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
}
← Back