From 2f7ceb5774c8715688cc7199183291c1b19259e2 Mon Sep 17 00:00:00 2001
From: Matt Holt <mholt@users.noreply.github.com>
Date: Tue, 28 Nov 2023 09:39:14 -0700
Subject: [PATCH] templates: Offically make templates extensible (#5939)

* templates: Offically make templates extensible

This supercedes #4757 (and #4568) by making template extensions
configurable.

The previous implementation was never documented AFAIK and had only
1 consumer, which I'll notify as a courtesy.

* templates: Add 'maybe' function for optional components

* Try to fix lint error
---
 modules/caddyhttp/templates/caddyfile.go  | 26 ++++++++++++
 modules/caddyhttp/templates/templates.go  | 24 ++++++-----
 modules/caddyhttp/templates/tplcontext.go | 50 ++++++++++++++++++++++-
 3 files changed, 89 insertions(+), 11 deletions(-)

diff --git a/modules/caddyhttp/templates/caddyfile.go b/modules/caddyhttp/templates/caddyfile.go
index 06ca3e260..c3039aa89 100644
--- a/modules/caddyhttp/templates/caddyfile.go
+++ b/modules/caddyhttp/templates/caddyfile.go
@@ -15,6 +15,9 @@
 package templates
 
 import (
+	"github.com/caddyserver/caddy/v2"
+	"github.com/caddyserver/caddy/v2/caddyconfig"
+	"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
 	"github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile"
 	"github.com/caddyserver/caddy/v2/modules/caddyhttp"
 )
@@ -49,6 +52,29 @@ func parseCaddyfile(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error)
 				if !h.Args(&t.FileRoot) {
 					return nil, h.ArgErr()
 				}
+			case "extensions":
+				if h.NextArg() {
+					return nil, h.ArgErr()
+				}
+				if t.ExtensionsRaw != nil {
+					return nil, h.Err("extensions already specified")
+				}
+				for nesting := h.Nesting(); h.NextBlock(nesting); {
+					extensionModuleName := h.Val()
+					modID := "http.handlers.templates.functions." + extensionModuleName
+					unm, err := caddyfile.UnmarshalModule(h.Dispenser, modID)
+					if err != nil {
+						return nil, err
+					}
+					cf, ok := unm.(CustomFunctions)
+					if !ok {
+						return nil, h.Errf("module %s (%T) does not provide template functions", modID, unm)
+					}
+					if t.ExtensionsRaw == nil {
+						t.ExtensionsRaw = make(caddy.ModuleMap)
+					}
+					t.ExtensionsRaw[extensionModuleName] = caddyconfig.JSON(cf, nil)
+				}
 			}
 		}
 	}
diff --git a/modules/caddyhttp/templates/templates.go b/modules/caddyhttp/templates/templates.go
index 4da02b580..418f09e53 100644
--- a/modules/caddyhttp/templates/templates.go
+++ b/modules/caddyhttp/templates/templates.go
@@ -23,6 +23,8 @@ import (
 	"strings"
 	"text/template"
 
+	"go.uber.org/zap"
+
 	"github.com/caddyserver/caddy/v2"
 	"github.com/caddyserver/caddy/v2/modules/caddyhttp"
 )
@@ -319,7 +321,12 @@ type Templates struct {
 	// the opening and closing delimiters. Default: `["{{", "}}"]`
 	Delimiters []string `json:"delimiters,omitempty"`
 
+	// Extensions adds functions to the template's func map. These often
+	// act as components on web pages, for example.
+	ExtensionsRaw caddy.ModuleMap `json:"match,omitempty" caddy:"namespace=http.handlers.templates.functions"`
+
 	customFuncs []template.FuncMap
+	logger      *zap.Logger
 }
 
 // Customfunctions is the interface for registering custom template functions.
@@ -338,17 +345,14 @@ func (Templates) CaddyModule() caddy.ModuleInfo {
 
 // Provision provisions t.
 func (t *Templates) Provision(ctx caddy.Context) error {
-	fnModInfos := caddy.GetModules("http.handlers.templates.functions")
-	customFuncs := make([]template.FuncMap, 0, len(fnModInfos))
-	for _, modInfo := range fnModInfos {
-		mod := modInfo.New()
-		fnMod, ok := mod.(CustomFunctions)
-		if !ok {
-			return fmt.Errorf("module %q does not satisfy the CustomFunctions interface", modInfo.ID)
-		}
-		customFuncs = append(customFuncs, fnMod.CustomTemplateFunctions())
+	t.logger = ctx.Logger()
+	mods, err := ctx.LoadModule(t, "ExtensionsRaw")
+	if err != nil {
+		return fmt.Errorf("loading template extensions: %v", err)
+	}
+	for _, modIface := range mods.(map[string]any) {
+		t.customFuncs = append(t.customFuncs, modIface.(CustomFunctions).CustomTemplateFunctions())
 	}
-	t.customFuncs = customFuncs
 
 	if t.MIMETypes == nil {
 		t.MIMETypes = defaultMIMETypes
diff --git a/modules/caddyhttp/templates/tplcontext.go b/modules/caddyhttp/templates/tplcontext.go
index 8b3d6bfc4..a66a0c305 100644
--- a/modules/caddyhttp/templates/tplcontext.go
+++ b/modules/caddyhttp/templates/tplcontext.go
@@ -23,6 +23,7 @@ import (
 	"net/http"
 	"os"
 	"path"
+	"reflect"
 	"strconv"
 	"strings"
 	"sync"
@@ -37,6 +38,7 @@ import (
 	"github.com/yuin/goldmark/extension"
 	"github.com/yuin/goldmark/parser"
 	gmhtml "github.com/yuin/goldmark/renderer/html"
+	"go.uber.org/zap"
 
 	"github.com/caddyserver/caddy/v2"
 	"github.com/caddyserver/caddy/v2/modules/caddyhttp"
@@ -57,7 +59,7 @@ type TemplateContext struct {
 // NewTemplate returns a new template intended to be evaluated with this
 // context, as it is initialized with configuration from this context.
 func (c *TemplateContext) NewTemplate(tplName string) *template.Template {
-	c.tpl = template.New(tplName)
+	c.tpl = template.New(tplName).Option("missingkey=zero")
 
 	// customize delimiters, if applicable
 	if c.config != nil && len(c.config.Delimiters) == 2 {
@@ -88,6 +90,7 @@ func (c *TemplateContext) NewTemplate(tplName string) *template.Template {
 		"fileExists":       c.funcFileExists,
 		"httpError":        c.funcHTTPError,
 		"humanize":         c.funcHumanize,
+		"maybe":            c.funcMaybe,
 	})
 	return c.tpl
 }
@@ -492,6 +495,51 @@ func (c TemplateContext) funcHumanize(formatType, data string) (string, error) {
 	return "", fmt.Errorf("no know function was given")
 }
 
+// funcMaybe invokes the plugged-in function named functionName if it is plugged in
+// (is a module in the 'http.handlers.templates.functions' namespace). If it is not
+// available, a log message is emitted.
+//
+// The first argument is the function name, and the rest of the arguments are
+// passed on to the actual function.
+//
+// This function is useful for executing templates that use components that may be
+// considered as optional in some cases (like during local development) where you do
+// not want to require everyone to have a custom Caddy build to be able to execute
+// your template.
+//
+// NOTE: This function is EXPERIMENTAL and subject to change or removal.
+func (c TemplateContext) funcMaybe(functionName string, args ...any) (any, error) {
+	for _, funcMap := range c.CustomFuncs {
+		if fn, ok := funcMap[functionName]; ok {
+			val := reflect.ValueOf(fn)
+			if val.Kind() != reflect.Func {
+				continue
+			}
+			argVals := make([]reflect.Value, len(args))
+			for i, arg := range args {
+				argVals[i] = reflect.ValueOf(arg)
+			}
+			returnVals := val.Call(argVals)
+			switch len(returnVals) {
+			case 0:
+				return "", nil
+			case 1:
+				return returnVals[0].Interface(), nil
+			case 2:
+				var err error
+				if !returnVals[1].IsNil() {
+					err = returnVals[1].Interface().(error)
+				}
+				return returnVals[0].Interface(), err
+			default:
+				return nil, fmt.Errorf("maybe %s: invalid number of return values: %d", functionName, len(returnVals))
+			}
+		}
+	}
+	c.config.logger.Named("maybe").Warn("template function could not be found; ignoring invocation", zap.String("name", functionName))
+	return "", nil
+}
+
 // WrappedHeader wraps niladic functions so that they
 // can be used in templates. (Template functions must
 // return a value.)