From 524cf8e27bb11f7b3814274c0e87f33a42b4b30c Mon Sep 17 00:00:00 2001 From: Ben Visness Date: Sat, 8 Apr 2023 11:14:44 -0500 Subject: [PATCH] Add ability to load templates live from the filesystem See config.go.example. --- src/config/config.go.example | 3 ++ src/config/types.go | 5 +++ src/email/email.go | 5 +-- src/templates/templates.go | 42 +++++++++++++++----- src/utils/dirfs.go | 72 ++++++++++++++++++++++++++++++++++ src/website/requesthandling.go | 6 +-- 6 files changed, 115 insertions(+), 18 deletions(-) create mode 100644 src/utils/dirfs.go diff --git a/src/config/config.go.example b/src/config/config.go.example index 5b2fd79..0a977f9 100644 --- a/src/config/config.go.example +++ b/src/config/config.go.example @@ -82,4 +82,7 @@ var Config = HMNConfig{ CineraOutputPath: "./annotations/", Projects: map[string]string{"hero": "code", "riscy": "riscy", "bitwise": "bitwise"}, }, + DevConfig: DevConfig{ + LiveTemplates: true, + }, } diff --git a/src/config/types.go b/src/config/types.go index 2656665..4b73e79 100644 --- a/src/config/types.go +++ b/src/config/types.go @@ -29,6 +29,7 @@ type HMNConfig struct { Discord DiscordConfig Twitch TwitchConfig EpisodeGuide EpisodeGuide + DevConfig DevConfig } type PostgresConfig struct { @@ -101,6 +102,10 @@ type AdminConfig struct { AtomPassword string } +type DevConfig struct { + LiveTemplates bool // load templates live from the filesystem instead of embedding them +} + func init() { if Config.EpisodeGuide.Projects == nil { Config.EpisodeGuide.Projects = make(map[string]string) diff --git a/src/email/email.go b/src/email/email.go index e11f854..918a589 100644 --- a/src/email/email.go +++ b/src/email/email.go @@ -99,10 +99,7 @@ func IsEmail(address string) bool { func renderTemplate(name string, data interface{}) (string, error) { var buffer bytes.Buffer - template, hasTemplate := templates.Templates[name] - if !hasTemplate { - return "", oops.New(nil, "Template not found: %s", name) - } + template := templates.GetTemplate(name) err := template.Execute(&buffer, data) if err != nil { return "", oops.New(err, "Failed to render template for email") diff --git a/src/templates/templates.go b/src/templates/templates.go index 3afe757..c6adfaa 100644 --- a/src/templates/templates.go +++ b/src/templates/templates.go @@ -4,13 +4,16 @@ import ( "embed" "fmt" "html/template" + "io/fs" "regexp" "strings" "time" "git.handmade.network/hmn/hmn/src/auth" + "git.handmade.network/hmn/hmn/src/config" "git.handmade.network/hmn/hmn/src/hmnurl" "git.handmade.network/hmn/hmn/src/logging" + "git.handmade.network/hmn/hmn/src/oops" "git.handmade.network/hmn/hmn/src/utils" "github.com/Masterminds/sprig" "github.com/google/uuid" @@ -25,22 +28,22 @@ const ( ) //go:embed src -var templateFs embed.FS -var Templates map[string]*template.Template +var embeddedTemplateFs embed.FS +var embeddedTemplates map[string]*template.Template //go:embed src/fishbowls var FishbowlFS embed.FS -func Init() { - Templates = make(map[string]*template.Template) +func getTemplatesFromFS(templateFS fs.ReadDirFS) map[string]*template.Template { + templates := make(map[string]*template.Template) - files := utils.Must1(templateFs.ReadDir("src")) + files := utils.Must1(templateFS.ReadDir("src")) for _, f := range files { if hasSuffix(f.Name(), ".html") { t := template.New(f.Name()) t = t.Funcs(sprig.FuncMap()) t = t.Funcs(HMNTemplateFuncs) - t, err := t.ParseFS(templateFs, + t, err := t.ParseFS(templateFS, "src/layouts/*", "src/include/*", "src/"+f.Name(), @@ -49,19 +52,40 @@ func Init() { logging.Fatal().Str("filename", f.Name()).Err(err).Msg("failed to parse template") } - Templates[f.Name()] = t + templates[f.Name()] = t } else if hasSuffix(f.Name(), ".css", ".js", ".xml") { t := template.New(f.Name()) t = t.Funcs(sprig.FuncMap()) t = t.Funcs(HMNTemplateFuncs) - t, err := t.ParseFS(templateFs, "src/"+f.Name()) + t, err := t.ParseFS(templateFS, "src/"+f.Name()) if err != nil { logging.Fatal().Str("filename", f.Name()).Err(err).Msg("failed to parse template") } - Templates[f.Name()] = t + templates[f.Name()] = t } } + + return templates +} + +func Init() { + embeddedTemplates = getTemplatesFromFS(embeddedTemplateFs) +} + +func GetTemplate(name string) *template.Template { + var templates map[string]*template.Template + if config.Config.DevConfig.LiveTemplates { + templates = getTemplatesFromFS(utils.DirFS("src/templates").(fs.ReadDirFS)) + } else { + templates = embeddedTemplates + } + + template, hasTemplate := templates[name] + if !hasTemplate { + panic(oops.New(nil, "Template not found: %s", name)) + } + return template } func hasSuffix(s string, suffixes ...string) bool { diff --git a/src/utils/dirfs.go b/src/utils/dirfs.go new file mode 100644 index 0000000..06265a6 --- /dev/null +++ b/src/utils/dirfs.go @@ -0,0 +1,72 @@ +package utils + +import ( + "io/fs" + "os" + "runtime" +) + +// DirFS returns a file system (an fs.FS) for the tree of files rooted at the directory dir. +// +// Note that DirFS("/prefix") only guarantees that the Open calls it makes to the +// operating system will begin with "/prefix": DirFS("/prefix").Open("file") is the +// same as os.Open("/prefix/file"). So if /prefix/file is a symbolic link pointing outside +// the /prefix tree, then using DirFS does not stop the access any more than using +// os.Open does. Additionally, the root of the fs.FS returned for a relative path, +// DirFS("prefix"), will be affected by later calls to Chdir. DirFS is therefore not +// a general substitute for a chroot-style security mechanism when the directory tree +// contains arbitrary content. +// +// The result implements fs.StatFS AND fs.ReadDirFS because god dammit why not. +func DirFS(dir string) fs.FS { + return dirFS(dir) +} + +func containsAny(s, chars string) bool { + for i := 0; i < len(s); i++ { + for j := 0; j < len(chars); j++ { + if s[i] == chars[j] { + return true + } + } + } + return false +} + +type dirFS string + +var _ fs.StatFS = dirFS("") +var _ fs.ReadDirFS = dirFS("") + +func (dir dirFS) Open(name string) (fs.File, error) { + if !fs.ValidPath(name) || runtime.GOOS == "windows" && containsAny(name, `\:`) { + return nil, &os.PathError{Op: "open", Path: name, Err: os.ErrInvalid} + } + f, err := os.Open(string(dir) + "/" + name) + if err != nil { + return nil, err // nil fs.File + } + return f, nil +} + +func (dir dirFS) Stat(name string) (fs.FileInfo, error) { + if !fs.ValidPath(name) || runtime.GOOS == "windows" && containsAny(name, `\:`) { + return nil, &os.PathError{Op: "stat", Path: name, Err: os.ErrInvalid} + } + f, err := os.Stat(string(dir) + "/" + name) + if err != nil { + return nil, err + } + return f, nil +} + +func (dir dirFS) ReadDir(name string) ([]fs.DirEntry, error) { + if !fs.ValidPath(name) || runtime.GOOS == "windows" && containsAny(name, `\:`) { + return nil, &os.PathError{Op: "open", Path: name, Err: os.ErrInvalid} + } + d, err := os.ReadDir(string(dir) + "/" + name) + if err != nil { + return nil, err + } + return d, nil +} diff --git a/src/website/requesthandling.go b/src/website/requesthandling.go index fbc2529..8274f2f 100644 --- a/src/website/requesthandling.go +++ b/src/website/requesthandling.go @@ -444,11 +444,7 @@ func (rd *ResponseData) WriteTemplate(name string, data interface{}, rp *perf.Re rp.StartBlock("TEMPLATE", name) defer rp.EndBlock() } - template, hasTemplate := templates.Templates[name] - if !hasTemplate { - panic(oops.New(nil, "Template not found: %s", name)) - } - return template.Execute(rd, data) + return templates.GetTemplate(name).Execute(rd, data) } func (rd *ResponseData) MustWriteTemplate(name string, data interface{}, rp *perf.RequestPerf) {