A go template renderer based on Perl's Template Toolkit
at main 13 kB view raw
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}