From 55a6794370d538b2b945ee704e2f59511837406a Mon Sep 17 00:00:00 2001 From: James Elliott Date: Fri, 23 Dec 2022 21:58:54 +1100 Subject: [PATCH] feat(templates): templating functions (#4635) This adds several functions which are available in most areas that use templates. --- cmd/authelia-gen/templates.go | 13 +- ...contributing-development-commitmsg.md.tmpl | 4 +- .../templates/dot_commitlintrc.js.tmpl | 2 +- .../templates/web_i18n_index.ts.tmpl | 2 +- .../content/en/configuration/methods/files.md | 119 +------ .../guides/notification-templates.md | 5 + .../content/en/reference/guides/templating.md | 66 ++++ internal/configuration/const.go | 3 + .../koanf_provider_filtered_file.go | 34 +- internal/templates/funcs.go | 220 ++++++++++-- internal/templates/funcs_test.go | 336 ++++++++++++++++++ internal/templates/util.go | 34 +- internal/templates/util_test.go | 30 ++ 13 files changed, 677 insertions(+), 191 deletions(-) create mode 100644 docs/content/en/reference/guides/templating.md create mode 100644 internal/templates/funcs_test.go create mode 100644 internal/templates/util_test.go diff --git a/cmd/authelia-gen/templates.go b/cmd/authelia-gen/templates.go index 3dd66681c..c3a7fa8fe 100644 --- a/cmd/authelia-gen/templates.go +++ b/cmd/authelia-gen/templates.go @@ -3,7 +3,6 @@ package main import ( "embed" "fmt" - "strings" "text/template" "github.com/authelia/authelia/v4/internal/templates" @@ -24,13 +23,11 @@ var ( ) func newTMPL(name string) (tmpl *template.Template, err error) { - return template.New(name). - Funcs(template.FuncMap{ - "stringsContains": strings.Contains, - "join": strings.Join, - "joinX": templates.StringJoinXFunc, - }). - Parse(mustLoadTmplFS(name)) + funcs := templates.FuncMap() + + funcs["joinX"] = templates.FuncStringJoinX + + return template.New(name).Funcs(funcs).Parse(mustLoadTmplFS(name)) } func mustLoadTmplFS(tmpl string) string { diff --git a/cmd/authelia-gen/templates/docs-contributing-development-commitmsg.md.tmpl b/cmd/authelia-gen/templates/docs-contributing-development-commitmsg.md.tmpl index 28f51882c..732d3aee6 100644 --- a/cmd/authelia-gen/templates/docs-contributing-development-commitmsg.md.tmpl +++ b/cmd/authelia-gen/templates/docs-contributing-development-commitmsg.md.tmpl @@ -50,7 +50,7 @@ for, and the structure it must have. │ │ │ └─⫸ Commit Scope: {{ joinX .Scopes.All "|" 70 "\n │ " }} │ - └─⫸ Commit Type: {{ join .Types.List "|" }} + └─⫸ Commit Type: {{ join "|" .Types.List }} ``` The `` and `` fields are mandatory, the `()` field is optional. @@ -59,7 +59,7 @@ The `` and `` fields are mandatory, the `()` field is opti {{ range .Types.Details }} * __{{ .Name }}__ {{ .Description }} {{- if .Scopes }} - (example scopes: {{ join .Scopes ", " }}) + (example scopes: {{ join ", " .Scopes }}) {{- end }} {{- end }} diff --git a/cmd/authelia-gen/templates/dot_commitlintrc.js.tmpl b/cmd/authelia-gen/templates/dot_commitlintrc.js.tmpl index e1bd35519..0290fa882 100644 --- a/cmd/authelia-gen/templates/dot_commitlintrc.js.tmpl +++ b/cmd/authelia-gen/templates/dot_commitlintrc.js.tmpl @@ -8,7 +8,7 @@ module.exports = { "type-enum": [ 2, "always", - ["{{ join .Types.List "\", \"" }}"], + ["{{ join "\", \"" .Types.List }}"], ], "scope-enum": [ 2, diff --git a/cmd/authelia-gen/templates/web_i18n_index.ts.tmpl b/cmd/authelia-gen/templates/web_i18n_index.ts.tmpl index e7180fb36..10747505b 100644 --- a/cmd/authelia-gen/templates/web_i18n_index.ts.tmpl +++ b/cmd/authelia-gen/templates/web_i18n_index.ts.tmpl @@ -37,7 +37,7 @@ i18n.use(Backend) default: ["{{ .Defaults.Language.Locale }}"], {{- range .Languages }} {{- if and (not (eq .Locale "en")) (not (eq (len .Fallbacks) 0)) }} - {{ if stringsContains .Locale "-" }}"{{ .Locale }}"{{ else }}{{ .Locale }}{{ end }}: [{{ range $i, $value := .Fallbacks }}{{ if eq $i 0 }}"{{ $value }}"{{ else }}, "{{ $value }}"{{ end }}{{ end }}], + {{ if contains "-" .Locale }}"{{ .Locale }}"{{ else }}{{ .Locale }}{{ end }}: [{{ range $i, $value := .Fallbacks }}{{ if eq $i 0 }}"{{ $value }}"{{ else }}, "{{ $value }}"{{ end }}{{ end }}], {{- end }} {{- end }} }, diff --git a/docs/content/en/configuration/methods/files.md b/docs/content/en/configuration/methods/files.md index ea195a15b..7bda7c9dc 100644 --- a/docs/content/en/configuration/methods/files.md +++ b/docs/content/en/configuration/methods/files.md @@ -187,121 +187,6 @@ output at each filter stage as a base64 string when trace logging is enabled. #### Functions -In addition to the standard builtin functions we support several other functions. +In addition to the standard builtin functions we support several other functions which should operate similar. -##### iterate - -The `iterate` function generates a list of numbers from 0 to the input provided. Useful for ranging over a list of -numbers. - -Example: - -```yaml -numbers: -{{- range $i := iterate 5 }} - - {{ $i }} -{{- end }} -``` - -##### env - -The `env` function returns the value of an environment variable or a blank string. - -Example: - -```yaml -default_redirection_url: 'https://{{ env "DOMAIN" }}' -``` - -##### split - -The `split` function splits a string by the separator. - -Example: - -```yaml -access_control: - rules: - - domain: 'app.{{ env "DOMAIN" }}' - policy: bypass - methods: - {{ range _, $method := split "GET,POST" "," }} - - {{ $method }} - {{ end }} -``` - -##### join - -The `join` function is similar to [split](#split) but does the complete oppiste, joining an array of strings with a -separator. - -Example: - -```yaml -access_control: - rules: - - domain: ['app.{{ join (split (env "DOMAINS") ",") "', 'app." }}'] - policy: bypass -``` - -##### contains - -The `contains` function is a test function which checks if one string contains another string. - -Example: - -```yaml -{{ if contains (env "DOMAIN") "https://" }} -default_redirection_url: '{{ env "DOMAIN" }}' -{{ else }} -default_redirection_url: 'https://{{ env "DOMAIN" }}' -{{ end }} -``` - -##### hasPrefix - -The `hasPrefix` function is a test function which checks if one string is prefixed with another string. - -Example: - -```yaml -{{ if hasPrefix (env "DOMAIN") "https://" }} -default_redirection_url: '{{ env "DOMAIN" }}' -{{ else }} -default_redirection_url: 'https://{{ env "DOMAIN" }}' -{{ end }} -``` - -##### hasSuffix - -The `hasSuffix` function is a test function which checks if one string is suffixed with another string. - -Example: - -```yaml -{{ if hasSuffix (env "DOMAIN") "/" }} -default_redirection_url: 'https://{{ env "DOMAIN" }}' -{{ else }} -default_redirection_url: 'https://{{ env "DOMAIN" }}/' -{{ end }} -``` - -##### lower - -The `lower` function is a conversion function which converts a string to all lowercase. - -Example: - -```yaml -default_redirection_url: 'https://{{ env "DOMAIN" | lower }}' -``` - -##### upper - -The `upper` function is a conversion function which converts a string to all uppercase. - -Example: - -```yaml -default_redirection_url: 'https://{{ env "DOMAIN" | upper }}' -``` +See the [Templating Reference Guide](../../reference/guides/templating.md) for more information. diff --git a/docs/content/en/reference/guides/notification-templates.md b/docs/content/en/reference/guides/notification-templates.md index ec1632e01..95a30e9e5 100644 --- a/docs/content/en/reference/guides/notification-templates.md +++ b/docs/content/en/reference/guides/notification-templates.md @@ -73,6 +73,11 @@ Some Additional examples for specific purposes can be found in the The original template content can be found on [GitHub](https://github.com/authelia/authelia/tree/master/internal/templates/src/notification). +## Functions + +Several functions are implemented with the email templates. See the +[Templating Reference Guide](../../reference/guides/templating.md) for more information. + [host]: ../../configuration/notifications/smtp.md#host [server_name]: ../../configuration/notifications/smtp.md#tls [sender]: ../../configuration/notifications/smtp.md#sender diff --git a/docs/content/en/reference/guides/templating.md b/docs/content/en/reference/guides/templating.md new file mode 100644 index 000000000..29a4383e6 --- /dev/null +++ b/docs/content/en/reference/guides/templating.md @@ -0,0 +1,66 @@ +--- +title: "Templating" +description: "A reference guide on the templates system" +lead: "This section contains reference documentation for Authelia's templating capabilities." +date: 2022-12-23T18:31:05+11:00 +draft: false +images: [] +menu: + reference: + parent: "guides" +weight: 220 +toc: true +--- + +Authelia has several methods where users can interact with templates. + +## Functions + +Functions can be used to perform specific actions when executing templates. The following is a simple guide on which +functions exist. + +### Standard Functions + +Go has a set of standard functions which can be used. See the [Go Documentation](https://pkg.go.dev/text/template#hdr-Functions) +for more information. + +### Helm-like Functions + +The following functions which mimic the behaviour of helm exist in most templating areas: + +- env +- expandenv +- split +- splitList +- join +- contains +- hasPrefix +- hasSuffix +- lower +- upper +- title +- trim +- trimAll +- trimSuffix +- trimPrefix +- replace +- quote +- sha1sum +- sha256sum +- sha512sum +- squote +- now + +See the [Helm Documentation](https://helm.sh/docs/chart_template_guide/function_list/) for more information. Please +note that only the functions listed above are supported. + +__*Special Note:* The `env` and `expandenv` function automatically excludes environment variables that start with +`AUTHELIA_` or `X_AUTHELIA_` and end with one of `KEY`, `SECRET`, `PASSWORD`, `TOKEN`, or `CERTIFICATE_CHAIN`.__ + +### Special Functions + +The following is a list of special functions and their syntax. + +#### iterate + +Input is a single uint. Returns a slice of uints from 0 to the provided uint. diff --git a/internal/configuration/const.go b/internal/configuration/const.go index a51fb8cbc..7b6321784 100644 --- a/internal/configuration/const.go +++ b/internal/configuration/const.go @@ -36,4 +36,7 @@ const ( errFmtDecodeHookCouldNotParseEmptyValue = "could not decode an empty value to a %s%s: %w" ) +// IMPORTANT: There is an uppercase copy of this in github.com/authelia/authelia/internal/templates named +// envSecretSuffixes. +// Make sure you update these at the same time. var secretSuffixes = []string{"key", "secret", "password", "token", "certificate_chain"} diff --git a/internal/configuration/koanf_provider_filtered_file.go b/internal/configuration/koanf_provider_filtered_file.go index 94933dc41..17bfc5b1b 100644 --- a/internal/configuration/koanf_provider_filtered_file.go +++ b/internal/configuration/koanf_provider_filtered_file.go @@ -112,32 +112,7 @@ func NewExpandEnvFileFilter() FileFilter { // NewTemplateFileFilter is a FileFilter which passes the bytes through text/template. func NewTemplateFileFilter() FileFilter { - data := &TemplateFileFilterData{ - Env: map[string]string{}, - } - - for _, e := range os.Environ() { - kv := strings.SplitN(e, "=", 2) - - if len(kv) != 2 { - continue - } - - data.Env[kv[0]] = kv[1] - } - - t := template.New("config.template"). - Funcs(template.FuncMap{ - "env": templates.StringMapLookupDefaultEmptyFunc(data.Env), - "split": templates.StringsSplitFunc, - "iterate": templates.IterateFunc, - "join": strings.Join, - "contains": strings.Contains, - "hasPrefix": strings.HasPrefix, - "hasSuffix": strings.HasSuffix, - "lower": strings.ToLower, - "upper": strings.ToUpper, - }) + t := template.New("config.template").Funcs(templates.FuncMap()) log := logging.Logger() @@ -148,7 +123,7 @@ func NewTemplateFileFilter() FileFilter { buf := &bytes.Buffer{} - if err = t.Execute(buf, data); err != nil { + if err = t.Execute(buf, nil); err != nil { return nil, err } @@ -163,8 +138,3 @@ func NewTemplateFileFilter() FileFilter { return out, nil } } - -// TemplateFileFilterData is the data available to the Go Template FileFilter. -type TemplateFileFilterData struct { - Env map[string]string -} diff --git a/internal/templates/funcs.go b/internal/templates/funcs.go index 798bf6ccc..564080d8a 100644 --- a/internal/templates/funcs.go +++ b/internal/templates/funcs.go @@ -1,41 +1,193 @@ package templates import ( + "crypto/sha1" //nolint:gosec + "crypto/sha256" + "crypto/sha512" + "encoding/hex" "fmt" + "hash" + "os" + "reflect" + "strconv" "strings" + "time" ) -// StringMapLookupDefaultEmptyFunc is function which takes a map[string]string and returns a template function which -// takes a string which is used as a key lookup for the map[string]string. If the value isn't found it returns an empty -// string. -func StringMapLookupDefaultEmptyFunc(m map[string]string) func(key string) (value string) { - return func(key string) (value string) { - var ok bool - - if value, ok = m[key]; !ok { - return "" - } - - return value +// FuncMap returns the template FuncMap commonly used in several templates. +func FuncMap() map[string]any { + return map[string]any{ + "iterate": FuncIterate, + "env": FuncGetEnv, + "expandenv": FuncExpandEnv, + "split": FuncStringSplit, + "splitList": FuncStringSplitList, + "join": FuncElemsJoin, + "contains": FuncStringContains, + "hasPrefix": FuncStringHasPrefix, + "hasSuffix": FuncStringHasSuffix, + "lower": strings.ToLower, + "upper": strings.ToUpper, + "title": strings.ToTitle, + "trim": strings.TrimSpace, + "trimAll": FuncStringTrimAll, + "trimSuffix": FuncStringTrimSuffix, + "trimPrefix": FuncStringTrimPrefix, + "replace": FuncStringReplace, + "quote": FuncStringQuote, + "sha1sum": FuncHashSum(sha1.New), + "sha256sum": FuncHashSum(sha256.New), + "sha512sum": FuncHashSum(sha512.New), + "squote": FuncStringSQuote, + "now": time.Now, } } -// StringMapLookupFunc is function which takes a map[string]string and returns a template function which -// takes a string which is used as a key lookup for the map[string]string. If the value isn't found it returns an error. -func StringMapLookupFunc(m map[string]string) func(key string) (value string, err error) { - return func(key string) (value string, err error) { - var ok bool +// FuncExpandEnv is a special version of os.ExpandEnv that excludes secret keys. +func FuncExpandEnv(s string) string { + return os.Expand(s, FuncGetEnv) +} - if value, ok = m[key]; !ok { - return value, fmt.Errorf("failed to lookup key '%s' from map", key) - } +// FuncGetEnv is a special version of os.GetEnv that excludes secret keys. +func FuncGetEnv(key string) string { + if isSecretEnvKey(key) { + return "" + } - return value, nil + return os.Getenv(key) +} + +// FuncHashSum is a helper function that provides similar functionality to helm sum funcs. +func FuncHashSum(new func() hash.Hash) func(data string) string { + hasher := new() + + return func(data string) string { + sum := hasher.Sum([]byte(data)) + + return hex.EncodeToString(sum) } } -// IterateFunc is a template function which takes a single uint returning a slice of units from 0 up to that number. -func IterateFunc(count *uint) (out []uint) { +// FuncStringReplace is a helper function that provides similar functionality to the helm replace func. +func FuncStringReplace(old, new, s string) string { + return strings.ReplaceAll(s, old, new) +} + +// FuncStringContains is a helper function that provides similar functionality to the helm contains func. +func FuncStringContains(substr string, s string) bool { + return strings.Contains(s, substr) +} + +// FuncStringHasPrefix is a helper function that provides similar functionality to the helm hasPrefix func. +func FuncStringHasPrefix(prefix string, s string) bool { + return strings.HasPrefix(s, prefix) +} + +// FuncStringHasSuffix is a helper function that provides similar functionality to the helm hasSuffix func. +func FuncStringHasSuffix(suffix string, s string) bool { + return strings.HasSuffix(s, suffix) +} + +// FuncStringTrimAll is a helper function that provides similar functionality to the helm trimAll func. +func FuncStringTrimAll(cutset, s string) string { + return strings.Trim(s, cutset) +} + +// FuncStringTrimSuffix is a helper function that provides similar functionality to the helm trimSuffix func. +func FuncStringTrimSuffix(suffix, s string) string { + return strings.TrimSuffix(s, suffix) +} + +// FuncStringTrimPrefix is a helper function that provides similar functionality to the helm trimPrefix func. +func FuncStringTrimPrefix(prefix, s string) string { + return strings.TrimPrefix(s, prefix) +} + +// FuncElemsJoin is a helper function that provides similar functionality to the helm join func. +func FuncElemsJoin(sep string, elems any) string { + return strings.Join(strslice(elems), sep) +} + +// FuncStringSQuote is a helper function that provides similar functionality to the helm squote func. +func FuncStringSQuote(in ...any) string { + out := make([]string, 0, len(in)) + + for _, s := range in { + if s != nil { + out = append(out, fmt.Sprintf("%q", strval(s))) + } + } + + return strings.Join(out, " ") +} + +// FuncStringQuote is a helper function that provides similar functionality to the helm quote func. +func FuncStringQuote(in ...any) string { + out := make([]string, 0, len(in)) + + for _, s := range in { + if s != nil { + out = append(out, fmt.Sprintf("%q", strval(s))) + } + } + + return strings.Join(out, " ") +} + +func strval(v interface{}) string { + switch v := v.(type) { + case string: + return v + case []byte: + return string(v) + case fmt.Stringer: + return v.String() + default: + return fmt.Sprintf("%v", v) + } +} + +func strslice(v any) []string { + switch v := v.(type) { + case []string: + return v + case []interface{}: + b := make([]string, 0, len(v)) + + for _, s := range v { + if s != nil { + b = append(b, strval(s)) + } + } + + return b + default: + val := reflect.ValueOf(v) + switch val.Kind() { + case reflect.Array, reflect.Slice: + l := val.Len() + b := make([]string, 0, l) + + for i := 0; i < l; i++ { + value := val.Index(i).Interface() + if value != nil { + b = append(b, strval(value)) + } + } + + return b + default: + if v == nil { + return []string{} + } + + return []string{strval(v)} + } + } +} + +// FuncIterate is a template function which takes a single uint returning a slice of units from 0 up to that number. +func FuncIterate(count *uint) (out []uint) { var i uint for i = 0; i < (*count); i++ { @@ -45,14 +197,26 @@ func IterateFunc(count *uint) (out []uint) { return } -// StringsSplitFunc is a template function which takes sep and value, splitting the value by the sep into a slice. -func StringsSplitFunc(value, sep string) []string { - return strings.Split(value, sep) +// FuncStringSplit is a template function which takes sep and value, splitting the value by the sep into a slice. +func FuncStringSplit(sep, value string) map[string]string { + parts := strings.Split(value, sep) + res := make(map[string]string, len(parts)) + + for i, v := range parts { + res["_"+strconv.Itoa(i)] = v + } + + return res } -// StringJoinXFunc takes a list of string elements, joins them by the sep string, before every int n characters are +// FuncStringSplitList is a special split func that reverses the inputs to match helm templates. +func FuncStringSplitList(sep, s string) []string { + return strings.Split(s, sep) +} + +// FuncStringJoinX takes a list of string elements, joins them by the sep string, before every int n characters are // written it writes string p. This is useful for line breaks mostly. -func StringJoinXFunc(elems []string, sep string, n int, p string) string { +func FuncStringJoinX(elems []string, sep string, n int, p string) string { buf := strings.Builder{} c := 0 diff --git a/internal/templates/funcs_test.go b/internal/templates/funcs_test.go new file mode 100644 index 000000000..aca899b77 --- /dev/null +++ b/internal/templates/funcs_test.go @@ -0,0 +1,336 @@ +package templates + +import ( + "crypto/sha1" //nolint:gosec + "crypto/sha256" + "crypto/sha512" + "hash" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFuncGetEnv(t *testing.T) { + testCases := []struct { + name string + have map[string]string + expected map[string]string + }{ + {"ShouldGetEnv", + map[string]string{ + "AN_ENV": "a", + "ANOTHER_ENV": "b", + }, + map[string]string{ + "AN_ENV": "a", + "ANOTHER_ENV": "b", + }, + }, + {"ShouldNotGetSecretEnv", + map[string]string{ + "AUTHELIA_ENV_SECRET": "a", + "ANOTHER_ENV": "b", + }, + map[string]string{ + "AUTHELIA_ENV_SECRET": "", + "ANOTHER_ENV": "b", + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + for key, value := range tc.have { + assert.NoError(t, os.Setenv(key, value)) + } + + for key, expected := range tc.expected { + assert.Equal(t, expected, FuncGetEnv(key)) + } + + for key := range tc.have { + assert.NoError(t, os.Unsetenv(key)) + } + }) + } +} + +func TestFuncExpandEnv(t *testing.T) { + testCases := []struct { + name string + env map[string]string + have string + expected string + }{ + {"ShouldExpandEnv", + map[string]string{ + "AN_ENV": "a", + "ANOTHER_ENV": "b", + }, + "This is ${AN_ENV} and ${ANOTHER_ENV}", + "This is a and b", + }, + {"ShouldNotExpandSecretEnv", + map[string]string{ + "AUTHELIA_ENV_SECRET": "a", + "X_AUTHELIA_ENV_SECRET": "a", + "ANOTHER_ENV": "b", + }, + "This is ${AUTHELIA_ENV_SECRET} and ${ANOTHER_ENV} without ${X_AUTHELIA_ENV_SECRET}", + "This is and b without ", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + for key, value := range tc.env { + assert.NoError(t, os.Setenv(key, value)) + } + + assert.Equal(t, tc.expected, FuncExpandEnv(tc.have)) + + for key := range tc.env { + assert.NoError(t, os.Unsetenv(key)) + } + }) + } +} + +func TestFuncHashSum(t *testing.T) { + testCases := []struct { + name string + new func() hash.Hash + have []string + expected []string + }{ + {"ShouldHashSHA1", sha1.New, []string{"abc", "123", "authelia"}, []string{"616263da39a3ee5e6b4b0d3255bfef95601890afd80709", "313233da39a3ee5e6b4b0d3255bfef95601890afd80709", "61757468656c6961da39a3ee5e6b4b0d3255bfef95601890afd80709"}}, + {"ShouldHashSHA256", sha256.New, []string{"abc", "123", "authelia"}, []string{"616263e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "313233e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "61757468656c6961e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"}}, + {"ShouldHashSHA512", sha512.New, []string{"abc", "123", "authelia"}, []string{"616263cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e", "313233cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e", "61757468656c6961cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e"}}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, len(tc.have), len(tc.expected)) + + h := FuncHashSum(tc.new) + + for i := 0; i < len(tc.have); i++ { + assert.Equal(t, tc.expected[i], h(tc.have[i])) + } + }) + } +} + +func TestFuncStringReplace(t *testing.T) { + testCases := []struct { + name string + have string + old, new string + expected string + }{ + {"ShouldReplaceSingle", "ABC123", "123", "456", "ABC456"}, + {"ShouldReplaceMultiple", "123ABC123123", "123", "456", "456ABC456456"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.expected, FuncStringReplace(tc.old, tc.new, tc.have)) + }) + } +} + +func TestFuncStringContains(t *testing.T) { + testCases := []struct { + name string + have string + substr string + expected bool + }{ + {"ShouldMatchNormal", "abc123", "c12", true}, + {"ShouldNotMatchWrongCase", "abc123", "C12", false}, + {"ShouldNotMatchNotContains", "abc123", "xyz", false}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.expected, FuncStringContains(tc.substr, tc.have)) + }) + } +} + +func TestFuncStringHasPrefix(t *testing.T) { + testCases := []struct { + name string + have string + substr string + expected bool + }{ + {"ShouldMatchNormal", "abc123", "abc", true}, + {"ShouldNotMatchWrongCase", "abc123", "ABC", false}, + {"ShouldNotMatchNotPrefix", "abc123", "123", false}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.expected, FuncStringHasPrefix(tc.substr, tc.have)) + }) + } +} + +func TestFuncStringHasSuffix(t *testing.T) { + testCases := []struct { + name string + have string + substr string + expected bool + }{ + {"ShouldMatchNormal", "abc123xyz", "xyz", true}, + {"ShouldNotMatchWrongCase", "abc123xyz", "XYZ", false}, + {"ShouldNotMatchNotSuffix", "abc123xyz", "123", false}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.expected, FuncStringHasSuffix(tc.substr, tc.have)) + }) + } +} + +func TestFuncStringTrimAll(t *testing.T) { + testCases := []struct { + name string + have string + cutset string + expected string + }{ + {"ShouldTrimSuffix", "abc123xyz", "xyz", "abc123"}, + {"ShouldTrimPrefix", "xyzabc123", "xyz", "abc123"}, + {"ShouldNotTrimMiddle", "abcxyz123", "xyz", "abcxyz123"}, + {"ShouldNotTrimWrongCase", "xyzabcxyz123xyz", "XYZ", "xyzabcxyz123xyz"}, + {"ShouldNotTrimWrongChars", "abc123xyz", "456", "abc123xyz"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.expected, FuncStringTrimAll(tc.cutset, tc.have)) + }) + } +} + +func TestFuncStringTrimPrefix(t *testing.T) { + testCases := []struct { + name string + have string + cutset string + expected string + }{ + {"ShouldNotTrimSuffix", "abc123xyz", "xyz", "abc123xyz"}, + {"ShouldTrimPrefix", "xyzabc123", "xyz", "abc123"}, + {"ShouldNotTrimMiddle", "abcxyz123", "xyz", "abcxyz123"}, + {"ShouldNotTrimWrongCase", "xyzabcxyz123xyz", "XYZ", "xyzabcxyz123xyz"}, + {"ShouldNotTrimWrongChars", "abc123xyz", "456", "abc123xyz"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.expected, FuncStringTrimPrefix(tc.cutset, tc.have)) + }) + } +} + +func TestFuncStringTrimSuffix(t *testing.T) { + testCases := []struct { + name string + have string + cutset string + expected string + }{ + {"ShouldTrimSuffix", "abc123xyz", "xyz", "abc123"}, + {"ShouldNotTrimPrefix", "xyzabc123", "xyz", "xyzabc123"}, + {"ShouldNotTrimMiddle", "abcxyz123", "xyz", "abcxyz123"}, + {"ShouldNotTrimWrongCase", "xyzabcxyz123xyz", "XYZ", "xyzabcxyz123xyz"}, + {"ShouldNotTrimWrongChars", "abc123xyz", "456", "abc123xyz"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.expected, FuncStringTrimSuffix(tc.cutset, tc.have)) + }) + } +} + +func TestFuncElemsJoin(t *testing.T) { + testCases := []struct { + name string + have any + sep string + expected string + }{ + {"ShouldNotJoinNonElements", "abc123xyz", "xyz", "abc123xyz"}, + {"ShouldJoinStrings", []string{"abc", "123"}, "xyz", "abcxyz123"}, + {"ShouldJoinInts", []int{1, 2, 3}, ",", "1,2,3"}, + {"ShouldJoinBooleans", []bool{true, false, true}, ".", "true.false.true"}, + {"ShouldJoinBytes", [][]byte{[]byte("abc"), []byte("123"), []byte("a")}, "$", "abc$123$a"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.expected, FuncElemsJoin(tc.sep, tc.have)) + }) + } +} + +func TestFuncIterate(t *testing.T) { + testCases := []struct { + name string + have uint + expected []uint + }{ + {"ShouldGiveZeroResults", 0, nil}, + {"ShouldGive10Results", 10, []uint{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.expected, FuncIterate(&tc.have)) + }) + } +} + +func TestFuncStringsSplit(t *testing.T) { + testCases := []struct { + name string + have string + sep string + expected map[string]string + }{ + {"ShouldSplit", "abc,123,456", ",", map[string]string{"_0": "abc", "_1": "123", "_2": "456"}}, + {"ShouldNotSplit", "abc,123,456", "$", map[string]string{"_0": "abc,123,456"}}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.expected, FuncStringSplit(tc.sep, tc.have)) + }) + } +} + +func TestFuncStringSplitList(t *testing.T) { + testCases := []struct { + name string + have string + sep string + expected []string + }{ + {"ShouldSplit", "abc,123,456", ",", []string{"abc", "123", "456"}}, + {"ShouldNotSplit", "abc,123,456", "$", []string{"abc,123,456"}}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.expected, FuncStringSplitList(tc.sep, tc.have)) + }) + } +} diff --git a/internal/templates/util.go b/internal/templates/util.go index e1c26650c..4b91f9979 100644 --- a/internal/templates/util.go +++ b/internal/templates/util.go @@ -6,9 +6,39 @@ import ( "os" "path" "path/filepath" + "strings" tt "text/template" ) +const ( + envPrefix = "AUTHELIA_" + envXPrefix = "X_AUTHELIA_" +) + +// IMPORTANT: This is a copy of github.com/authelia/authelia/internal/configuration's secretSuffixes except all uppercase. +// Make sure you update these at the same time. +var envSecretSuffixes = []string{ + "KEY", "SECRET", "PASSWORD", "TOKEN", "CERTIFICATE_CHAIN", +} + +func isSecretEnvKey(key string) (isSecretEnvKey bool) { + key = strings.ToUpper(key) + + if !strings.HasPrefix(key, envPrefix) && !strings.HasPrefix(key, envXPrefix) { + return false + } + + for _, s := range envSecretSuffixes { + suffix := strings.ToUpper(s) + + if strings.HasSuffix(key, suffix) { + return true + } + } + + return false +} + func templateExists(path string) (exists bool) { info, err := os.Stat(path) if err != nil { @@ -45,7 +75,7 @@ func readTemplate(name, ext, category, overridePath string) (tPath string, embed } func parseTextTemplate(name, tPath string, embed bool, data []byte) (t *tt.Template, err error) { - if t, err = tt.New(name + extText).Parse(string(data)); err != nil { + if t, err = tt.New(name + extText).Funcs(FuncMap()).Parse(string(data)); err != nil { if embed { return nil, fmt.Errorf("failed to parse embedded template '%s': %w", tPath, err) } @@ -57,7 +87,7 @@ func parseTextTemplate(name, tPath string, embed bool, data []byte) (t *tt.Templ } func parseHTMLTemplate(name, tPath string, embed bool, data []byte) (t *th.Template, err error) { - if t, err = th.New(name + extHTML).Parse(string(data)); err != nil { + if t, err = th.New(name + extHTML).Funcs(FuncMap()).Parse(string(data)); err != nil { if embed { return nil, fmt.Errorf("failed to parse embedded template '%s': %w", tPath, err) } diff --git a/internal/templates/util_test.go b/internal/templates/util_test.go new file mode 100644 index 000000000..1260717fa --- /dev/null +++ b/internal/templates/util_test.go @@ -0,0 +1,30 @@ +package templates + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIsSecretEnvKey(t *testing.T) { + testCases := []struct { + name string + have []string + expected bool + }{ + {"ShouldReturnFalseForKeysWithoutPrefix", []string{"A_KEY", "A_SECRET", "A_PASSWORD", "NOT_AUTHELIA_A_PASSWORD"}, false}, + {"ShouldReturnFalseForKeysWithoutSuffix", []string{"AUTHELIA_EXAMPLE", "X_AUTHELIA_EXAMPLE", "X_AUTHELIA_PASSWORD_NOT"}, false}, + {"ShouldReturnTrueForSecretKeys", []string{"AUTHELIA_JWT_SECRET", "AUTHELIA_IDENTITY_PROVIDERS_OIDC_HMAC_SECRET", "AUTHELIA_IDENTITY_PROVIDERS_OIDC_ISSUER_CERTIFICATE_CHAIN", "X_AUTHELIA_JWT_SECRET", "X_AUTHELIA_IDENTITY_PROVIDERS_OIDC_HMAC_SECRET", "X_AUTHELIA_IDENTITY_PROVIDERS_OIDC_ISSUER_CERTIFICATE_CHAIN"}, true}, + {"ShouldReturnTrueForSecretKeysEvenWithMixedCase", []string{"aUTHELIA_JWT_SECRET", "aUTHELIA_IDENTITY_PROVIDERS_OIDC_HMAC_SECRET", "aUTHELIA_IDENTITY_PROVIDERS_OIDC_ISSUER_CERTIFICATE_CHAIN", "X_aUTHELIA_JWT_SECREt", "X_aUTHELIA_IDENTITY_PROVIDERS_OIDC_HMAC_SECRET", "x_AUTHELIA_IDENTITY_PROVIDERS_OIDC_ISSUER_CERTIFICATE_CHAIN"}, true}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + for _, env := range tc.have { + t.Run(env, func(t *testing.T) { + assert.Equal(t, tc.expected, isSecretEnvKey(env)) + }) + } + }) + } +}