From 41f2382f9baaf86be5420d9587d5f7c4d54a81b8 Mon Sep 17 00:00:00 2001 From: Mike Crute Date: Sat, 19 Aug 2023 22:14:54 -0700 Subject: echo: remove mandatory templates --- echo/controller/generic_template.go | 2 +- echo/echo_default.go | 25 +--- echo/error_handler.go | 252 +++++++++++++++++++++------------- echo/go.mod | 8 +- echo/go.sum | 13 +- echo/middleware/recover_middleware.go | 27 +++- echo/template_renderer.go | 34 +++-- 7 files changed, 219 insertions(+), 142 deletions(-) diff --git a/echo/controller/generic_template.go b/echo/controller/generic_template.go index 190182b..bd64304 100644 --- a/echo/controller/generic_template.go +++ b/echo/controller/generic_template.go @@ -80,7 +80,7 @@ func (h *GenericTemplateHandler) canonicalizeUrl(c echo.Context, tr glecho.Templ path = "index.tpl" } else if !strings.HasSuffix(path, ".html") { p := fmt.Sprintf("%s.tpl", path) - if !tr.HaveTemplate(c, p) { + if !tr.HaveTemplate(p) { path = fmt.Sprintf("%s/index.tpl", path) } else { path = p diff --git a/echo/echo_default.go b/echo/echo_default.go index 76f543d..ff3c754 100644 --- a/echo/echo_default.go +++ b/echo/echo_default.go @@ -22,17 +22,8 @@ import ( // Docs: https://echo.labstack.com/guide/ -// TODO: -// - Integrate CSRF -// - Integrate session - const defaultBodySizeLimit = "10M" -var mandatoryTemplates = []string{ - "404.tpl", "40x.tpl", "50x.tpl", - "header.tpl", "footer.tpl", -} - func makeMiddlewareSkipper(c *prometheus.PrometheusConfig) middleware.Skipper { var path string @@ -209,21 +200,17 @@ func (w *EchoWrapper) configureTemplates(c *EchoConfig) error { templates = c.EmbeddedTemplates } - // Only install template handlers if the path and glob are set + // Only install template handlers if the path is set if templates != nil { - w.HTTPErrorHandler = ErrorHandler(templates, c.TemplateFunctions) - - tr, err := NewTemplateRenderer(templates, "*.tpl", c.TemplateFunctions) + tr, err := NewTemplateRenderer(templates, "*.tpl", c.TemplateFunctions, w.Debug) if err != nil { return fmt.Errorf("Error loading template renderer: %w", err) } - w.Renderer = tr - for _, t := range mandatoryTemplates { - if !tr.HaveTemplate(w.NewContext(nil, nil), t) { - return fmt.Errorf("Template renderer is missing required template %s", t) - } - } + w.HTTPErrorHandler = NewDefaultErrorHandler(tr).HandleError + w.Renderer = tr + } else { + w.HTTPErrorHandler = NewNoHTMLErrorHandler().HandleError } w.templateFS = templates diff --git a/echo/error_handler.go b/echo/error_handler.go index 2316ccd..b44155f 100644 --- a/echo/error_handler.go +++ b/echo/error_handler.go @@ -4,135 +4,193 @@ import ( "bytes" "fmt" "html/template" - "io/fs" "net/http" "github.com/elnormous/contenttype" "github.com/labstack/echo/v4" ) -// TODO: This should allow plugging in other content types -// TODO: This should also be refactored into something prettier -func ErrorHandler(templates fs.FS, funcs template.FuncMap) func(error, echo.Context) { - handleHtml := func(c echo.Context, he *echo.HTTPError) error { - t, err := template.New("").Funcs(funcs).ParseFS( - templates, - "404.tpl", - "40x.tpl", - "50x.tpl", - "header.tpl", - "footer.tpl", - ) - if err != nil { - return err - } +// TODO: This should have some kind of HTML formatting of panics in debug mode - path := "50x.tpl" - if he.Code == 404 { - path = "404.tpl" - } else if he.Code >= 400 && he.Code <= 499 { - path = "40x.tpl" - } +type ContentErrorHandler interface { + Handle(echo.Context, *echo.HTTPError) error +} - buf := bytes.Buffer{} - if err = t.ExecuteTemplate(&buf, path, nil); err != nil { - err = c.String(he.Code, fmt.Sprintf("%s", he.Message)) - } +type HTMLErrorHandler struct { + r *TemplateRenderer + fallback *template.Template +} - return c.HTMLBlob(he.Code, buf.Bytes()) +func NewHTMLErrorHandler(r *TemplateRenderer) *HTMLErrorHandler { + t, err := template.New("").Parse("

Error

{{ .Message }}
\n") + if err != nil { + panic("NewHTMLErrorHandler: error parsing fallback template") } + return &HTMLErrorHandler{r: r, fallback: t} +} - handlePlain := func(c echo.Context, he *echo.HTTPError) error { - return c.String(he.Code, fmt.Sprintf("%s", he.Message)) +func (h *HTMLErrorHandler) Handle(c echo.Context, e *echo.HTTPError) error { + path := "50x.tpl" + if e.Code == 404 { + path = "404.tpl" + } else if e.Code >= 400 && e.Code <= 499 { + path = "40x.tpl" } - handleJson := func(c echo.Context, he *echo.HTTPError) error { - code := he.Code - message := he.Message - if m, ok := he.Message.(string); ok { - if c.Echo().Debug { - message = echo.Map{"message": m, "error": he.Error()} - } else { - message = echo.Map{"message": m} - } - } + buf := bytes.Buffer{} + if !h.r.HaveTemplate(path) { + c.Logger().Errorf("Error template %s is missing, using fallback", path) - return c.JSON(code, message) + if err := h.fallback.Execute(&buf, e); err != nil { + c.Logger().Errorf("Error rendering HTML error page: %s", err) + return c.String(e.Code, e.Error()) + } + } else { + if err := h.r.Render(&buf, path, e, c); err != nil { + c.Logger().Errorf("Error rendering HTML error page: %s", err) + return c.String(e.Code, e.Error()) + } } - errorWhileErroring := func(c echo.Context, err interface{}) { - c.Echo().Logger.Error(err) + return c.HTMLBlob(e.Code, buf.Bytes()) +} + +type PlainTextErrorHandler struct{} + +func (h *PlainTextErrorHandler) Handle(c echo.Context, e *echo.HTTPError) error { + return c.String(e.Code, fmt.Sprintf("%s", e.Message)) +} + +type JSONErrorHandler struct{} + +func (h *JSONErrorHandler) Handle(c echo.Context, e *echo.HTTPError) error { + code := e.Code + message := e.Message + if m, ok := e.Message.(string); ok { if c.Echo().Debug { - c.JSON(http.StatusInternalServerError, &echo.HTTPError{ - Code: http.StatusInternalServerError, - Message: fmt.Sprintf("Error while processing error page. %s", err), - }) + message = echo.Map{"message": m, "error": e.Error()} } else { - c.JSON(http.StatusInternalServerError, &echo.HTTPError{ - Code: http.StatusInternalServerError, - Message: http.StatusText(http.StatusInternalServerError), - }) + message = echo.Map{"message": m} } } - handlers := map[string]func(echo.Context, *echo.HTTPError) error{ - "text/plain": handlePlain, - "text/html": handleHtml, - "text/json": handleJson, - "application/json": handleJson, + return c.JSON(code, message) +} + +type failsafeHandler struct{} + +func (h *failsafeHandler) Handle(c echo.Context, e *echo.HTTPError) error { + c.Echo().Logger.Error("Error while processing error page: %s", e) + c.JSON(http.StatusInternalServerError, e) + return nil +} + +type ErrorHandler struct { + types []contenttype.MediaType + typeMap map[string]ContentErrorHandler + failsafeHandler ContentErrorHandler +} + +func NewErrorHandler() *ErrorHandler { + return &ErrorHandler{ + types: []contenttype.MediaType{}, + typeMap: map[string]ContentErrorHandler{}, + failsafeHandler: &failsafeHandler{}, } +} + +func NewDefaultErrorHandler(tr *TemplateRenderer) *ErrorHandler { + h := NewErrorHandler() + + // The order of these is important, especially when negotiating */* + h.AddHandler(&JSONErrorHandler{}, "text/json", "application/json") + h.AddHandler(&PlainTextErrorHandler{}, "text/plain") + h.AddHandler(NewHTMLErrorHandler(tr), "text/html") + + return h +} + +func NewNoHTMLErrorHandler() *ErrorHandler { + h := NewErrorHandler() + + // The order of these is important, especially when negotiating */* + h.AddHandler(&JSONErrorHandler{}, "text/json", "application/json") + h.AddHandler(&PlainTextErrorHandler{}, "text/plain") + + return h +} - // This is hand maintained here because order is important for negotiation, - // especially in the case of */* - hIndex := []contenttype.MediaType{ - contenttype.NewMediaType("text/json"), - contenttype.NewMediaType("application/json"), - contenttype.NewMediaType("text/plain"), - contenttype.NewMediaType("text/html"), +func (h *ErrorHandler) AddHandler(eh ContentErrorHandler, contentTypes ...string) { + for _, ct := range contentTypes { + h.types = append(h.types, contenttype.NewMediaType(ct)) + h.typeMap[ct] = eh } +} - return func(err error, c echo.Context) { - defer func() { - if r := recover(); r != nil { - errorWhileErroring(c, r) - } - }() - - he, ok := err.(*echo.HTTPError) - if ok { - if he.Internal != nil { - if herr, ok := he.Internal.(*echo.HTTPError); ok { - he = herr - } - } - } else { - he = &echo.HTTPError{ - Code: http.StatusInternalServerError, - Message: http.StatusText(http.StatusInternalServerError), +func (h *ErrorHandler) castToHttpError(e error) *echo.HTTPError { + he, ok := e.(*echo.HTTPError) + if ok { + if he.Internal != nil { + if ihe, ok := he.Internal.(*echo.HTTPError); ok { + return ihe } } - - ct, _, err := contenttype.GetAcceptableMediaType(c.Request(), hIndex) - if err != nil { - c.Echo().Logger.Error("Error negotiating content type in error handler, using json") - ct = contenttype.NewMediaType("text/json") + return he + } else { + return &echo.HTTPError{ + Code: http.StatusInternalServerError, + Message: http.StatusText(http.StatusInternalServerError), } + } +} - handle, ok := handlers[ct.String()] - if !ok { - c.Echo().Logger.Errorf("Error handler content type %s is unknown", ct.String()) - handle = handleJson - } +func (h *ErrorHandler) negotiateContentType(r *http.Request) (ContentErrorHandler, error) { + var err error - if !c.Response().Committed { - if c.Request().Method == http.MethodHead { // Issue #608 - err = c.NoContent(he.Code) + ct, _, err := contenttype.GetAcceptableMediaType(r, h.types) + if err != nil { + err = fmt.Errorf("Error negotiating content type in error handler, falling back to JSON: %w", err) + ct = contenttype.NewMediaType("text/json") + } + + handle, found := h.typeMap[ct.String()] + if !found { + err = fmt.Errorf("Unable to find handler for content type: %s", ct) + handle = h.failsafeHandler + } + + return handle, err +} + +func (h *ErrorHandler) HandleError(err error, c echo.Context) { + defer func() { + if r := recover(); r != nil { + if err, ok := r.(error); !ok { + h.failsafeHandler.Handle(c, h.castToHttpError(fmt.Errorf("%s", err))) } else { - err = handle(c, he) - } - if err != nil { - errorWhileErroring(c, err) + h.failsafeHandler.Handle(c, h.castToHttpError(err)) } } + }() + + logger := c.Echo().Logger + he := h.castToHttpError(err) + + handle, cterr := h.negotiateContentType(c.Request()) + if cterr != nil { + logger.Errorf("%s", cterr) + } + + if !c.Response().Committed { + if c.Request().Method == http.MethodHead { // Issue #608 + err = c.NoContent(he.Code) + } else { + err = handle.Handle(c, he) + } + if err != nil { + h.failsafeHandler.Handle(c, h.castToHttpError(err)) + } + } else { + logger.Errorf("Error occurred in committed response: %s", he) } } diff --git a/echo/go.mod b/echo/go.mod index fcedf30..782c6e8 100644 --- a/echo/go.mod +++ b/echo/go.mod @@ -66,10 +66,10 @@ require ( github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.1 // indirect go.uber.org/atomic v1.9.0 // indirect - golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 // indirect - golang.org/x/net v0.0.0-20210913180222-943fd674d43e // indirect - golang.org/x/sys v0.0.0-20211103235746-7861aae1554b // indirect - golang.org/x/text v0.3.7 // indirect + golang.org/x/crypto v0.11.0 // indirect + golang.org/x/net v0.10.0 // indirect + golang.org/x/sys v0.10.0 // indirect + golang.org/x/text v0.11.0 // indirect golang.org/x/time v0.0.0-20201208040808-7e3f01d25324 // indirect google.golang.org/genproto v0.0.0-20200825200019-8632dd797987 // indirect google.golang.org/grpc v1.41.0 // indirect diff --git a/echo/go.sum b/echo/go.sum index fe3ba40..3b58191 100644 --- a/echo/go.sum +++ b/echo/go.sum @@ -370,8 +370,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 h1:7I4JAnoQBe7ZtJcBaYHi5UtiO8tQHbUSXxL+pnGRANg= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.11.0 h1:6Ewdq3tDic1mg5xRO4milcWCfMVQhI4NkqWWvqejpuA= +golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -433,8 +433,9 @@ golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81R golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20210913180222-943fd674d43e h1:+b/22bPvDYt4NPDcy4xAGCmON713ONAWFeY3Z7I3tR8= golang.org/x/net v0.0.0-20210913180222-943fd674d43e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -494,8 +495,9 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210910150752-751e447fb3d0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211103235746-7861aae1554b h1:1VkfZQv42XQlA/jchYumAnv1UPo6RgF9rJFkTgZIxO4= golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA= +golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -503,8 +505,9 @@ golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3 golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.11.0 h1:LAntKIrcmeSKERyiOh0XMV39LXS8IE9UL2yP7+f5ij4= +golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= diff --git a/echo/middleware/recover_middleware.go b/echo/middleware/recover_middleware.go index 310e146..af4259a 100644 --- a/echo/middleware/recover_middleware.go +++ b/echo/middleware/recover_middleware.go @@ -1,9 +1,11 @@ package middleware import ( + "bytes" "fmt" "net/http" "runtime" + "text/template" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" @@ -49,6 +51,16 @@ var ( DisablePrintStack: false, LogLevel: 0, } + defaultErrorTemplate = ` + + Error + + +

Error

{{ . }}
+ + ` ) // Recover returns a middleware which recovers from panics anywhere in the chain @@ -68,6 +80,11 @@ func RecoverWithConfig(config RecoverConfig) echo.MiddlewareFunc { config.StackSize = DefaultRecoverConfig.StackSize } + errorTemplate, err := template.New("").Parse(defaultErrorTemplate) + if err != nil { + panic("RecoverWithConfig: error parsing html template") + } + return func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { if config.Skipper(c) { @@ -100,8 +117,14 @@ func RecoverWithConfig(config RecoverConfig) echo.MiddlewareFunc { } } if c.Echo().Debug { - msg := fmt.Sprintf("%v %s\n", err, stack[:length]) - c.String(http.StatusInternalServerError, msg) + buf := bytes.Buffer{} + msg := fmt.Sprintf("%v\n\n%s", err, stack[:length]) + if err := errorTemplate.Execute(&buf, msg); err != nil { + c.Logger().Errorf("Error rendering HTML error page: %s", err) + c.String(http.StatusInternalServerError, msg) + return + } + c.HTMLBlob(http.StatusInternalServerError, buf.Bytes()) } else { c.Error(err) } diff --git a/echo/template_renderer.go b/echo/template_renderer.go index 7bfd006..8515249 100644 --- a/echo/template_renderer.go +++ b/echo/template_renderer.go @@ -11,7 +11,7 @@ import ( ) type TemplateChecker interface { - HaveTemplate(echo.Context, string) bool + HaveTemplate(string) bool } type TemplateRenderer struct { @@ -19,12 +19,19 @@ type TemplateRenderer struct { glob string funcs template.FuncMap cache *template.Template + debug bool } -func NewTemplateRenderer(templates fs.FS, glob string, funcs template.FuncMap) (*TemplateRenderer, error) { +func NewTemplateRenderer(templates fs.FS, glob string, funcs template.FuncMap, debug bool) (*TemplateRenderer, error) { var err error - r := &TemplateRenderer{templates: templates, glob: glob, funcs: funcs} + r := &TemplateRenderer{ + templates: templates, + glob: glob, + funcs: funcs, + debug: debug, + } + r.cache, err = r.loadTemplates() if err != nil { return nil, err @@ -65,22 +72,21 @@ func (r *TemplateRenderer) loadTemplates() (*template.Template, error) { return t, nil } -func (r *TemplateRenderer) getTemplateCache(ctx echo.Context) (*template.Template, error) { - var err error +func (r *TemplateRenderer) getTemplateCache() (*template.Template, error) { + if !r.debug { + return r.cache, nil + } - tc := r.cache - if ctx.Echo().Debug { - tc, err = r.loadTemplates() - if err != nil { - return nil, err - } + tc, err := r.loadTemplates() + if err != nil { + return nil, err } return tc, nil } func (r *TemplateRenderer) Render(w io.Writer, name string, data interface{}, ctx echo.Context) error { - tc, err := r.getTemplateCache(ctx) + tc, err := r.getTemplateCache() if err != nil { return err } @@ -96,8 +102,8 @@ func (r *TemplateRenderer) Render(w io.Writer, name string, data interface{}, ct return nil } -func (r *TemplateRenderer) HaveTemplate(ctx echo.Context, name string) bool { - tc, err := r.getTemplateCache(ctx) +func (r *TemplateRenderer) HaveTemplate(name string) bool { + tc, err := r.getTemplateCache() if err != nil { return false } -- cgit v1.2.3