A go template renderer based on Perl's Template Toolkit
1package gott
2
3import (
4 "crypto/sha256"
5 "encoding/hex"
6 "encoding/json"
7 "errors"
8 "html"
9 "io/fs"
10 "net/http"
11 "net/url"
12 "os"
13 "os/signal"
14 "path"
15 "strings"
16 "sync"
17 "syscall"
18)
19
20// Renderer is the main template engine. It processes TT2-style templates
21// with support for blocks, includes, filters, and virtual methods.
22type Renderer struct {
23 mu sync.RWMutex
24 includePaths []fs.FS // filesystems for INCLUDE lookups, searched in order
25 filters map[string]func(string, ...string) string // text transformation filters
26 virtualMethods map[string]func(any) (any, bool) // custom virtual methods for variables
27 cache *astCache // in-memory LRU cache for parsed templates
28 diskCache *diskCache // optional disk-based cache (nil if not configured)
29 stopSIGHUP chan struct{} // channel to stop SIGHUP handler goroutine
30}
31
32// Config holds initialization options for creating a new Renderer.
33type Config struct {
34 IncludePaths []fs.FS // filesystems for template includes (e.g., os.DirFS, embed.FS)
35 Filters map[string]func(string, ...string) string // custom filters to register
36 CachePath string // directory for disk-based cache (optional, empty = memory only)
37 MaxCacheSize int // max entries in memory cache (0 = unlimited)
38 EnableSIGHUP bool // if true, clears cache on SIGHUP signal
39}
40
41var ErrTemplateNotFound = errors.New("template not found")
42var ErrPathTraversal = errors.New("path traversal attempt detected")
43
44// sanitizePath cleans a template path and ensures it cannot escape the include directories.
45// It rejects any path containing ".." components to prevent directory traversal attacks.
46// Leading slashes are stripped so paths are always relative to the configured IncludePaths.
47func sanitizePath(name string) (string, error) {
48 // Check for path traversal attempts BEFORE cleaning
49 // This catches ".." even if path.Clean would normalize it away
50 if strings.Contains(name, "..") {
51 return "", ErrPathTraversal
52 }
53
54 // Clean the path to resolve any . or redundant separators
55 cleaned := path.Clean(name)
56
57 // Strip leading slash to make it relative
58 cleaned = strings.TrimPrefix(cleaned, "/")
59
60 // Reject empty paths
61 if cleaned == "" || cleaned == "." {
62 return "", ErrTemplateNotFound
63 }
64
65 return cleaned, nil
66}
67
68// New creates a Renderer with the given config. Registers default filters
69// (upper, lower, html, uri) unless overridden in config.
70// Returns an error if disk cache path is specified but cannot be created.
71func New(config *Config) (*Renderer, error) {
72 r := &Renderer{
73 filters: make(map[string]func(string, ...string) string),
74 virtualMethods: make(map[string]func(any) (any, bool)),
75 }
76
77 maxCacheSize := 0
78 if config != nil {
79 r.includePaths = config.IncludePaths
80 maxCacheSize = config.MaxCacheSize
81 if config.Filters != nil {
82 for k, v := range config.Filters {
83 r.filters[k] = v
84 }
85 }
86
87 // Initialize disk cache if path is specified
88 if config.CachePath != "" {
89 dc, err := newDiskCache(config.CachePath)
90 if err != nil {
91 return nil, err
92 }
93 r.diskCache = dc
94 }
95
96 // Set up SIGHUP handler if enabled
97 if config.EnableSIGHUP {
98 r.stopSIGHUP = make(chan struct{})
99 go r.handleSIGHUP()
100 }
101 }
102
103 // Initialize in-memory cache
104 r.cache = newCache(maxCacheSize)
105
106 // Default filters
107 if r.filters["upper"] == nil {
108 r.filters["upper"] = func(s string, args ...string) string {
109 return strings.ToUpper(s)
110 }
111 }
112 if r.filters["lower"] == nil {
113 r.filters["lower"] = func(s string, args ...string) string {
114 return strings.ToLower(s)
115 }
116 }
117 if r.filters["html"] == nil {
118 r.filters["html"] = func(s string, args ...string) string {
119 return html.EscapeString(s)
120 }
121 }
122 if r.filters["uri"] == nil {
123 r.filters["uri"] = func(s string, args ...string) string {
124 return url.QueryEscape(s)
125 }
126 }
127
128 return r, nil
129}
130
131// handleSIGHUP listens for SIGHUP signals and clears the cache.
132func (r *Renderer) handleSIGHUP() {
133 sigChan := make(chan os.Signal, 1)
134 signal.Notify(sigChan, syscall.SIGHUP)
135
136 for {
137 select {
138 case <-sigChan:
139 r.ClearCache()
140 case <-r.stopSIGHUP:
141 signal.Stop(sigChan)
142 return
143 }
144 }
145}
146
147// ClearCache removes all entries from both memory and disk caches.
148func (r *Renderer) ClearCache() {
149 if r.cache != nil {
150 r.cache.Clear()
151 }
152 if r.diskCache != nil {
153 r.diskCache.Clear()
154 }
155}
156
157// Close stops background goroutines and releases resources.
158// Should be called when the Renderer is no longer needed.
159func (r *Renderer) Close() {
160 if r.stopSIGHUP != nil {
161 close(r.stopSIGHUP)
162 }
163}
164
165// AddFilter registers a custom filter function. Returns the Renderer for chaining.
166// Note: This should only be called during initialization, not concurrently.
167func (r *Renderer) AddFilter(name string, fn func(string, ...string) string) *Renderer {
168 r.mu.Lock()
169 defer r.mu.Unlock()
170 r.filters[name] = fn
171 return r
172}
173
174// AddVirtualMethod registers a custom virtual method for variable access.
175// The function receives a value and returns (result, ok).
176// Note: This should only be called during initialization, not concurrently.
177func (r *Renderer) AddVirtualMethod(name string, fn func(any) (any, bool)) *Renderer {
178 r.mu.Lock()
179 defer r.mu.Unlock()
180 r.virtualMethods[name] = fn
181 return r
182}
183
184// getFilter safely retrieves a filter
185func (r *Renderer) getFilter(name string) (func(string, ...string) string, bool) {
186 r.mu.RLock()
187 defer r.mu.RUnlock()
188 fn, ok := r.filters[name]
189 return fn, ok
190}
191
192// getVirtualMethod safely retrieves a virtual method
193func (r *Renderer) getVirtualMethod(name string) (func(any) (any, bool), bool) {
194 r.mu.RLock()
195 defer r.mu.RUnlock()
196 fn, ok := r.virtualMethods[name]
197 return fn, ok
198}
199
200// loadFile attempts to load a template file from the configured filesystems.
201// Searches each fs.FS in IncludePaths order, returning the first match.
202// Returns ErrTemplateNotFound if not found in any filesystem.
203// Returns ErrPathTraversal if the path attempts directory traversal.
204func (r *Renderer) loadFile(name string) (string, error) {
205 // Sanitize the path to prevent directory traversal attacks
206 safeName, err := sanitizePath(name)
207 if err != nil {
208 return "", err
209 }
210
211 r.mu.RLock()
212 paths := r.includePaths
213 r.mu.RUnlock()
214
215 for _, fsys := range paths {
216 data, err := fs.ReadFile(fsys, safeName)
217 if err == nil {
218 return string(data), nil
219 }
220 }
221 return "", ErrTemplateNotFound
222}
223
224// ProcessFile loads a template file and processes it with the given variables.
225// Uses cached AST if available, otherwise parses and caches.
226func (r *Renderer) ProcessFile(filename string, vars map[string]any) (string, error) {
227 // Sanitize the path first to get a consistent cache key
228 safeName, err := sanitizePath(filename)
229 if err != nil {
230 return "", err
231 }
232
233 // Use file path as cache key (prefixed to distinguish from content hashes)
234 cacheKey := "file:" + safeName
235
236 tmpl, err := r.getOrParseCached(cacheKey, func() (string, error) {
237 return r.loadFile(filename)
238 })
239 if err != nil {
240 return "", err
241 }
242
243 // Evaluate the AST
244 eval := NewEvaluator(r, vars)
245 return eval.Eval(tmpl)
246}
247
248// Process processes a template string with the given variables.
249// Uses an AST-based parser and evaluator for template processing.
250// Caches the parsed AST using a hash of the input content.
251func (r *Renderer) Process(input string, vars map[string]any) (string, error) {
252 // Use content hash as cache key
253 cacheKey := "content:" + hashContent(input)
254
255 tmpl, err := r.getOrParseCached(cacheKey, func() (string, error) {
256 return input, nil
257 })
258 if err != nil {
259 return "", err
260 }
261
262 // Evaluate the AST
263 eval := NewEvaluator(r, vars)
264 return eval.Eval(tmpl)
265}
266
267// hashContent creates a SHA256 hash of the template content for use as a cache key.
268func hashContent(content string) string {
269 h := sha256.Sum256([]byte(content))
270 return hex.EncodeToString(h[:])
271}
272
273// getOrParseCached retrieves a cached template or parses and caches it.
274// The loadContent function is called only if the template is not in cache.
275func (r *Renderer) getOrParseCached(cacheKey string, loadContent func() (string, error)) (*Template, error) {
276 // Check in-memory cache first
277 if tmpl, ok := r.cache.Get(cacheKey); ok {
278 return tmpl, nil
279 }
280
281 // Check disk cache if available
282 if r.diskCache != nil {
283 tmpl, err := r.diskCache.Get(cacheKey)
284 if err != nil {
285 return nil, err
286 }
287 if tmpl != nil {
288 // Found on disk, add to memory cache
289 r.cache.Put(cacheKey, tmpl)
290 return tmpl, nil
291 }
292 }
293
294 // Load and parse the template
295 content, err := loadContent()
296 if err != nil {
297 return nil, err
298 }
299
300 parser := NewParser(content)
301 tmpl, parseErrs := parser.Parse()
302 if len(parseErrs) > 0 {
303 return nil, parseErrs[0]
304 }
305
306 // Store in both caches
307 r.cache.Put(cacheKey, tmpl)
308 if r.diskCache != nil {
309 // Best effort - don't fail if disk write fails
310 r.diskCache.Put(cacheKey, tmpl)
311 }
312
313 return tmpl, nil
314}
315
316// parseTemplate parses a template for use by includes/wrappers with caching.
317// This is used internally by the evaluator.
318func (r *Renderer) parseTemplate(name string, content string) (*Template, error) {
319 // Use the include/wrapper path as the cache key
320 cacheKey := "file:" + name
321
322 return r.getOrParseCached(cacheKey, func() (string, error) {
323 return content, nil
324 })
325}
326
327// Render processes a template file and writes the result to an http.ResponseWriter.
328// Sets Content-Type to text/html and handles 400/404/500 errors appropriately.
329func (r *Renderer) Render(w http.ResponseWriter, filename string, vars map[string]any) {
330 output, err := r.ProcessFile(filename, vars)
331 if err != nil {
332 if errors.Is(err, ErrTemplateNotFound) {
333 http.Error(w, "404 Not Found", http.StatusNotFound)
334 } else if errors.Is(err, ErrPathTraversal) {
335 http.Error(w, "400 Bad Request", http.StatusBadRequest)
336 } else {
337 http.Error(w, "500 Internal Server Error", http.StatusInternalServerError)
338 }
339 return
340 }
341
342 w.Header().Set("Content-Type", "text/html; charset=utf-8")
343 w.WriteHeader(http.StatusOK)
344 w.Write([]byte(output))
345}
346
347// RenderWithStatus processes a template file and writes it with a custom status code.
348func (r *Renderer) RenderWithStatus(w http.ResponseWriter, statusCode int, filename string, vars map[string]any) {
349 output, err := r.ProcessFile(filename, vars)
350 if err != nil {
351 if errors.Is(err, ErrTemplateNotFound) {
352 http.Error(w, "404 Not Found", http.StatusNotFound)
353 } else if errors.Is(err, ErrPathTraversal) {
354 http.Error(w, "400 Bad Request", http.StatusBadRequest)
355 } else {
356 http.Error(w, "500 Internal Server Error", http.StatusInternalServerError)
357 }
358 return
359 }
360
361 w.Header().Set("Content-Type", "text/html; charset=utf-8")
362 w.WriteHeader(statusCode)
363 w.Write([]byte(output))
364}
365
366// Render204 writes a 204 No Content response.
367func (r *Renderer) Render204(w http.ResponseWriter) {
368 w.WriteHeader(204)
369 w.Write([]byte(""))
370}
371
372// RenderJSON serializes json_data to JSON and writes it to the http.ResponseWriter.
373// Sets Content-Type to application/json. Returns 500 error on serialization failure.
374func (r *Renderer) RenderJSON(w http.ResponseWriter, json_data any) {
375 data, err := json.Marshal(json_data)
376 if err != nil {
377 http.Error(w, "500 Internal Server Error", http.StatusInternalServerError)
378 return
379 }
380
381 w.Header().Set("Content-Type", "application/json; charset=utf-8")
382 w.WriteHeader(http.StatusOK)
383 w.Write(data)
384}
385
386// RenderJSONWithStatus serializes json_data and writes it with a custom status code.
387func (r *Renderer) RenderJSONWithStatus(w http.ResponseWriter, statusCode int, json_data any) {
388 data, err := json.Marshal(json_data)
389 if err != nil {
390 http.Error(w, "500 Internal Server Error", http.StatusInternalServerError)
391 return
392 }
393
394 w.Header().Set("Content-Type", "application/json; charset=utf-8")
395 w.WriteHeader(statusCode)
396 w.Write(data)
397}