diff --git a/customcontent.go b/customcontent.go index b0f4932..54fcccb 100644 --- a/customcontent.go +++ b/customcontent.go @@ -372,9 +372,11 @@ var customContent = map[string]CustomContentInfo{ }, Variables: []string{ "myAccountURL", + "profile", }, Placeholders: map[string]any{ "myAccountURL": "https://example.url/my/account", + "profile": "Default User Profile", }, }, } diff --git a/email.go b/email.go index 10aaf46..7d64d6a 100644 --- a/email.go +++ b/email.go @@ -19,6 +19,8 @@ import ( textTemplate "text/template" "time" + sTemplate "github.com/hrfee/simple-template" + "github.com/gomarkdown/markdown" "github.com/gomarkdown/markdown/html" "github.com/hrfee/jfa-go/easyproxy" @@ -251,13 +253,13 @@ func (emailer *Emailer) construct(contentInfo CustomContentInfo, cc CustomConten Subject: contentInfo.Subject(emailer.config, &emailer.lang), } // Template the subject for bonus points - if subject, err := templateEmail(msg.Subject, contentInfo.Variables, contentInfo.Conditionals, data); err == nil { + if subject, err := sTemplate.Template(msg.Subject, data); err == nil { msg.Subject = subject } if cc.Enabled { // Use template email, rather than the built-in's email file. contentInfo.SourceFile = customContent["TemplateEmail"].SourceFile - content, err := templateEmail(cc.Content, contentInfo.Variables, contentInfo.Conditionals, data) + content, err := sTemplate.Template(cc.Content, data) if err != nil { emailer.err.Printf(lm.FailedConstructCustomContent, msg.Subject, err) return msg, err diff --git a/go.mod b/go.mod index ff8b61c..6f492a5 100644 --- a/go.mod +++ b/go.mod @@ -95,6 +95,7 @@ require ( github.com/google/flatbuffers v25.9.23+incompatible // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect + github.com/hrfee/simple-template v1.1.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.18.1 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect diff --git a/go.sum b/go.sum index 8936c7b..6ad636c 100644 --- a/go.sum +++ b/go.sum @@ -199,6 +199,10 @@ github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/ad github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hrfee/mediabrowser v0.3.33 h1:kjUFZc46hNhbOEU4xZNyhGVNjfZ5lENmX95Md1thxiA= github.com/hrfee/mediabrowser v0.3.33/go.mod h1:PnHZbdxmbv1wCVdAQyM7nwPwpVj9fdKx2EcET7sAk+U= +github.com/hrfee/simple-template v1.0.0 h1:Yyq36RDZF9fT5SFpgFwgzk6ywQy2fdYa/Tl+jkcnF7c= +github.com/hrfee/simple-template v1.0.0/go.mod h1:s9a5QgfqbmT7j9WCC3GD5JuEqvihBEohyr+oYZmr4bA= +github.com/hrfee/simple-template v1.1.0 h1:PNQDTgc2H0s19/pWuhRh4bncuNJjPrW0fIX77YtY78M= +github.com/hrfee/simple-template v1.1.0/go.mod h1:s9a5QgfqbmT7j9WCC3GD5JuEqvihBEohyr+oYZmr4bA= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/itchyny/timefmt-go v0.1.7 h1:xyftit9Tbw+Dc/huSSPJaEmX1TVL8lw5vxjJLK4GMMA= github.com/itchyny/timefmt-go v0.1.7/go.mod h1:5E46Q+zj7vbTgWY8o5YkMeYb4I6GeWLFnetPy5oBrAI= diff --git a/template.go b/template.go deleted file mode 100644 index 76ce7df..0000000 --- a/template.go +++ /dev/null @@ -1,147 +0,0 @@ -package main - -import ( - "fmt" - "slices" -) - -func truthy(val interface{}) bool { - switch v := val.(type) { - case string: - return v != "" - case bool: - return v - case int: - return v != 0 - } - return false -} - -// Templater for custom emails. -// Slices "variables", "conditionals", and map "values" should NOT wrap names in { and }. -// Variables should be written as {varName}. -// If statements should be written as {if (!)varName}...{endif}. -// Strings are true if != "", ints are true if != 0. -// Errors returned are likely warnings only. -func templateEmail(content string, variables []string, conditionals []string, values map[string]interface{}) (string, error) { - // minimum length for templatable content (albeit just "{}" -> "") - if len(content) < 2 { - return content, nil - } - ifStart, ifEnd := -1, -1 - ifTrue := false - invalidIf := false - previousEnd := -2 - blockRawStart := -1 - blockContentStart, blockContentEnd := -1, -1 - varStart, varEnd := -1, -1 - varName := "" - out := "" - var err error = nil - - oob := func(i int) bool { return i < 0 || i >= len(content) } - - for i, c := range content { - if c == '{' { - blockContentStart = i + 1 - blockRawStart = i - if content[i+1] == '{' { - err = fmt.Errorf(`double braces ("{{") at position %d, use single brace only`, i) - blockContentStart++ - } - for !oob(blockContentStart) && content[blockContentStart] == ' ' { - blockContentStart++ - } - if oob(blockContentStart) { - continue - } - if !oob(blockContentStart+3) && content[blockContentStart:blockContentStart+3] == "if " { - varStart = blockContentStart + 3 - for content[varStart] == ' ' { - varStart++ - } - } - if ifStart == -1 && (oob(i-1) || content[i-1] != '{') { - out += content[previousEnd+2 : i] - } - if invalidIf || oob(blockContentStart+5) || content[blockContentStart:blockContentStart+5] != "endif" { - continue - } - ifEnd = i - 1 - if ifTrue { - toAppend, subErr := templateEmail(content[ifStart:ifEnd+1], variables, conditionals, values) - out += toAppend - if subErr != nil { - err = subErr - } - ifTrue = false - } - } else if c == '}' { - doubleBraced := !oob(i+1) && content[i+1] == '}' - if doubleBraced { - err = fmt.Errorf(`double braces ("}}") at position %d, use single brace only`, i) - } - if !oob(i-1) && content[i-1] == '}' { - continue - } - if varStart != -1 { - ifStart = i + 1 - varEnd = i - 1 - for !oob(varEnd) && content[varEnd] == ' ' { - varEnd-- - } - varName = content[varStart : varEnd+1] - positive := true - if varName[0] == '!' { - positive = false - varName = varName[1:] - } - validVar := slices.Contains(conditionals, varName) - if validVar { - ifTrue = positive == truthy(values[varName]) - } else { - invalidIf = true - ifStart, ifEnd = -1, -1 - } - varStart, varEnd = -1, -1 - } - blockContentEnd = i - 1 - for content[blockContentEnd] == ' ' { - blockContentEnd-- - } - previousEnd = i - 1 - // Skip the extra brace - if doubleBraced { - previousEnd++ - } - if !oob(blockContentEnd-4) && !oob(blockContentEnd+1) && content[blockContentEnd-4:blockContentEnd+1] == "endif" && !invalidIf { - continue - } - varName = content[blockContentStart : blockContentEnd+1] - blockContentStart, blockContentEnd = -1, -1 - blockRawStart = -1 - if ifStart != -1 { - continue - } - validVar := slices.Contains(variables, varName) - if !validVar { - out += "{" + varName + "}" - continue - } - out += fmt.Sprint(values[varName]) - } - } - if blockContentStart != -1 && blockContentEnd == -1 { - err = fmt.Errorf(`incomplete block (single "{") near position %d`, blockContentStart) - // Include the brace, maybe the user wants it. - previousEnd = blockRawStart - 2 - } - if previousEnd+1 != len(content)-1 { - out += content[previousEnd+2:] - - } - if out == "" { - return content, err - } - return out, err -} diff --git a/template_test.go b/template_test.go deleted file mode 100644 index b6bf1d0..0000000 --- a/template_test.go +++ /dev/null @@ -1,128 +0,0 @@ -package main - -import ( - "strings" - "testing" -) - -// In == Out when nothing is meant to be templated. -func TestBlankTemplate(t *testing.T) { - in := `Success, user! Your account has been created. Log in at myAccountURL with your username to get started.` - - out, err := templateEmail(in, []string{}, []string{}, map[string]any{}) - - if err != nil { - t.Fatalf("error: %+v", err) - } - - if out != in { - t.Fatalf(`returned string doesn't match input: "%+v" != "%+v"`, out, in) - } -} - -func testConditional(isTrue bool, t *testing.T) { - in := `Success, {username}! Your account has been created. {if myCondition}Log in at {myAccountURL} with username {username} to get started.{endif}` - - vars := []string{"username", "myAccountURL", "myCondition"} - conds := vars - vals := map[string]any{ - "username": "TemplateUsername", - "myAccountURL": "TemplateURL", - "myCondition": isTrue, - } - - out, err := templateEmail(in, vars, conds, vals) - - target := "" - if isTrue { - target = `Success, {username}! Your account has been created. Log in at {myAccountURL} with username {username} to get started.` - } else { - target = `Success, {username}! Your account has been created. ` - } - - target = strings.ReplaceAll(target, "{username}", vals["username"].(string)) - target = strings.ReplaceAll(target, "{myAccountURL}", vals["myAccountURL"].(string)) - - if err != nil { - t.Fatalf("error: %+v", err) - } - - if out != target { - t.Fatalf(`returned string doesn't match desired output: "%+v" != "%+v"`, out, target) - } -} - -func TestConditionalTrue(t *testing.T) { - testConditional(true, t) -} - -func TestConditionalFalse(t *testing.T) { - testConditional(false, t) -} - -// Template mistakenly double-braced values, but return a warning. -func TestTemplateDoubleBraceGracefulHandling(t *testing.T) { - in := `Success, {{username}}! Your account has been created. Log in at {myAccountURL} with username {username} to get started.` - - vars := []string{"username", "myAccountURL"} - vals := map[string]any{ - "username": "TemplateUsername", - "myAccountURL": "TemplateURL", - } - - target := strings.ReplaceAll(in, "{{username}}", vals["username"].(string)) - target = strings.ReplaceAll(target, "{username}", vals["username"].(string)) - target = strings.ReplaceAll(target, "{myAccountURL}", vals["myAccountURL"].(string)) - - out, err := templateEmail(in, vars, []string{}, vals) - - if err == nil { - t.Fatal("no error when given double-braced variable") - } - - if out != target { - t.Fatalf(`returned string doesn't match desired output: "%+v" != "%+v"`, out, target) - } -} - -func TestVarAtAnyPosition(t *testing.T) { - in := `Success, user! Your account has been created. Log in at myAccountURL with your username to get started.` - vars := []string{"username", "myAccountURL"} - vals := map[string]any{ - "username": "TemplateUsername", - "myAccountURL": "TemplateURL", - } - - for i := range in { - newIn := in[0:i] + "{" + vars[0] + "}" + in[i:] - - target := strings.ReplaceAll(newIn, "{"+vars[0]+"}", vals["username"].(string)) - - out, err := templateEmail(newIn, vars, []string{}, vals) - - if err != nil { - t.Fatalf("error: %+v", err) - } - - if out != target { - t.Fatalf(`returned string doesn't match desired output: "%+v" != "%+v, from "%+v""`, out, target, newIn) - } - } -} - -func TestIncompleteBlock(t *testing.T) { - in := `Success, user! Your account has been created. Log in at myAccountURL with your username to get started.` - for i := range in { - newIn := in[0:i] + "{" + in[i:] - - out, err := templateEmail(newIn, []string{"a"}, []string{"a"}, map[string]any{"a": "a"}) - - if out != newIn { - t.Fatalf(`returned string for position %d/%d doesn't match desired output: "%+v" != "%+v"`, i+1, len(newIn), out, newIn) - } - if err == nil { - t.Fatalf("no error when given incomplete block with brace at position %d/%d", i+1, len(newIn)) - } - - } -} diff --git a/views.go b/views.go index 10c7f7c..86b0521 100644 --- a/views.go +++ b/views.go @@ -20,6 +20,7 @@ import ( "github.com/hrfee/jfa-go/common" lm "github.com/hrfee/jfa-go/logmessages" "github.com/hrfee/mediabrowser" + sTemplate "github.com/hrfee/simple-template" "github.com/lithammer/shortuuid/v3" "github.com/steambap/captcha" ) @@ -812,13 +813,10 @@ func (app *appContext) InviteProxy(gc *gin.Context) { data["discordInviteLink"] = app.discord.InviteChannel.Name != "" } if msg, ok := app.storage.GetCustomContentKey("PostSignupCard"); ok && msg.Enabled { - cci := customContent["PostSignupCard"] data["customSuccessCard"] = true // We don't template here, since the username is only known after login. - templated, err := templateEmail( + templated, err := sTemplate.Template( msg.Content, - cci.Variables, - cci.Conditionals, map[string]any{ "username": "{username}", // Value is subbed by webpage "myAccountURL": userPageAddress, @@ -832,15 +830,13 @@ func (app *appContext) InviteProxy(gc *gin.Context) { ) } if msg, ok := app.storage.GetCustomContentKey("PreSignupCard"); ok && msg.Enabled { - cci := customContent["PreSignupCard"] data["preSignupCard"] = true // We don't template here, since the username is only known after login. - templated, err := templateEmail( + templated, err := sTemplate.Template( msg.Content, - cci.Variables, - cci.Conditionals, map[string]any{ "myAccountURL": userPageAddress, + "profile": invite.Profile, }, ) if err != nil {