From e050544d2142b6efeaf22dbd9486d4a06d7e5f24 Mon Sep 17 00:00:00 2001 From: qwqcode <22412567+qwqcode@users.noreply.github.com> Date: Wed, 20 Sep 2023 12:10:20 +0800 Subject: [PATCH] refactor(renderer): abstract func of template renderer for multi-cases (#585) * refactor(email_renderer): abstract function of template renderer for multi-cases * abstract template loader * add render test * update docs * chore: move pkg to template * abstract template pkg --- conf/artalk.example.simple.yml | 1 + docs/guide/backend/email.md | 2 +- internal/core/service_email.go | 43 +++--- internal/core/service_notify.go | 8 -- internal/email/render.go | 98 -------------- internal/email/render_utils.go | 124 ------------------ internal/notify_pusher/base.go | 8 +- internal/notify_pusher/multi.go | 18 +-- .../email_tpl/default.html | 0 .../notify_tpl/default.html | 0 internal/template/render.go | 67 ++++++++++ internal/template/render_test.go | 98 ++++++++++++++ internal/template/strategy_email.go | 18 +++ internal/template/strategy_notify.go | 21 +++ internal/template/template_loader.go | 103 +++++++++++++++ internal/template/template_render.go | 119 +++++++++++++++++ .../template_test.go} | 4 +- 17 files changed, 459 insertions(+), 273 deletions(-) delete mode 100644 internal/email/render.go delete mode 100644 internal/email/render_utils.go rename internal/{email => template}/email_tpl/default.html (100%) rename internal/{email => template}/notify_tpl/default.html (100%) create mode 100644 internal/template/render.go create mode 100644 internal/template/render_test.go create mode 100644 internal/template/strategy_email.go create mode 100644 internal/template/strategy_notify.go create mode 100644 internal/template/template_loader.go create mode 100644 internal/template/template_render.go rename internal/{email/render_test.go => template/template_test.go} (90%) diff --git a/conf/artalk.example.simple.yml b/conf/artalk.example.simple.yml index 9959242ab..4253c39f0 100644 --- a/conf/artalk.example.simple.yml +++ b/conf/artalk.example.simple.yml @@ -79,6 +79,7 @@ email: send_type: "smtp" send_name: "{{reply_nick}}" send_addr: "noreply@example.com" + mail_subject: "[{{site_name}}] You got a reply from @{{reply_nick}}" mail_tpl: "default" smtp: host: "smtp.qq.com" diff --git a/docs/guide/backend/email.md b/docs/guide/backend/email.md index 634934ddd..096983433 100644 --- a/docs/guide/backend/email.md +++ b/docs/guide/backend/email.md @@ -310,7 +310,7 @@ email: ``` -Artalk 内置许多预设的邮件模板,例如 `mail_tpl: "default"` 使用的就是:[/email-tpl/default.html](https://github.com/ArtalkJS/Artalk/blob/master/email-tpl/default.html) +Artalk 内置许多预设的邮件模板,例如 `mail_tpl: "default"` 使用的就是:[@ArtalkJS/Artalk:/internal/email/email_tpl/default.html](https://github.com/ArtalkJS/Artalk/blob/master/internal/email/email_tpl/default.html) ## 发向管理员的邮件 diff --git a/internal/core/service_email.go b/internal/core/service_email.go index ff8441b79..cee172193 100644 --- a/internal/core/service_email.go +++ b/internal/core/service_email.go @@ -6,6 +6,7 @@ import ( "github.com/ArtalkJS/Artalk/internal/email" "github.com/ArtalkJS/Artalk/internal/entity" "github.com/ArtalkJS/Artalk/internal/log" + "github.com/ArtalkJS/Artalk/internal/template" ) var _ Service = (*EmailService)(nil) @@ -45,21 +46,7 @@ func (e *EmailService) Dispose() error { return nil } -func (e *EmailService) AsyncSendTo(subject string, body string, toAddr string) { - if !e.app.Conf().Email.Enabled { - return - } - - e.queue.Push(&email.Email{ - FromAddr: e.app.Conf().Email.SendAddr, - FromName: e.app.Conf().Email.SendName, - ToAddr: toAddr, - Subject: subject, - Body: body, - }) -} - -func (e *EmailService) GetRender(useAdminTplParam ...bool) *email.Render { +func (e *EmailService) GetRenderer(useAdminTplParam ...bool) *template.Renderer { useAdminTpl := false if len(useAdminTplParam) > 0 { useAdminTpl = useAdminTplParam[0] @@ -74,9 +61,21 @@ func (e *EmailService) GetRender(useAdminTplParam ...bool) *email.Render { } // create new email render instance - render := email.NewRender(e.app.Dao(), mailTplName) + return template.NewRenderer(e.app.Dao(), template.TYPE_EMAIL, template.NewFileLoader(mailTplName)) +} - return render +func (e *EmailService) AsyncSendTo(subject string, body string, toAddr string) { + if !e.app.Conf().Email.Enabled { + return + } + + e.queue.Push(&email.Email{ + FromAddr: e.app.Conf().Email.SendAddr, + FromName: e.app.Conf().Email.SendName, + ToAddr: toAddr, + Subject: subject, + Body: body, + }) } func (e *EmailService) AsyncSend(notify *entity.Notify) { @@ -85,15 +84,15 @@ func (e *EmailService) AsyncSend(notify *entity.Notify) { } receiveUser := e.app.Dao().FetchUserForNotify(notify) - render := e.GetRender(receiveUser.IsAdmin) + renderer := e.GetRenderer(receiveUser.IsAdmin) // render email body - mailBody := render.RenderEmailBody(notify) + mailBody := renderer.Render(notify) mailSubject := "" if !receiveUser.IsAdmin { - mailSubject = render.RenderCommon(e.app.Conf().Email.MailSubject, notify) + mailSubject = renderer.Render(notify, e.app.Conf().Email.MailSubject) } else { - mailSubject = render.RenderCommon(e.app.Conf().AdminNotify.Email.MailSubject, notify) + mailSubject = renderer.Render(notify, e.app.Conf().AdminNotify.Email.MailSubject) } log.Debug(time.Now(), " "+receiveUser.Email) @@ -101,7 +100,7 @@ func (e *EmailService) AsyncSend(notify *entity.Notify) { // add email send task to queue e.queue.Push(&email.Email{ FromAddr: e.app.Conf().Email.SendAddr, - FromName: render.RenderCommon(e.app.Conf().Email.SendName, notify), + FromName: renderer.Render(notify, e.app.Conf().Email.SendName), ToAddr: receiveUser.Email, Subject: mailSubject, Body: mailBody, diff --git a/internal/core/service_notify.go b/internal/core/service_notify.go index c3fc33092..a1c9eb1eb 100644 --- a/internal/core/service_notify.go +++ b/internal/core/service_notify.go @@ -1,7 +1,6 @@ package core import ( - "github.com/ArtalkJS/Artalk/internal/email" "github.com/ArtalkJS/Artalk/internal/entity" "github.com/ArtalkJS/Artalk/internal/notify_pusher" ) @@ -29,13 +28,6 @@ func (s *NotifyService) Init() error { emailService.AsyncSend(notify) return nil }, - EmailRender: func() (*email.Render, error) { - emailService, err := AppService[*EmailService](s.app) - if err != nil { - return nil, err - } - return emailService.GetRender(), nil - }, }) return nil diff --git a/internal/email/render.go b/internal/email/render.go deleted file mode 100644 index 66804de78..000000000 --- a/internal/email/render.go +++ /dev/null @@ -1,98 +0,0 @@ -package email - -import ( - "github.com/ArtalkJS/Artalk/internal/dao" - "github.com/ArtalkJS/Artalk/internal/entity" - "github.com/ArtalkJS/Artalk/internal/utils" -) - -type Render struct { - dao *dao.Dao - tplName string -} - -func NewRender(dao *dao.Dao, tplName string) *Render { - return &Render{ - dao: dao, - tplName: tplName, - } -} - -type TplFields struct { - From entity.CookedCommentForEmail `json:"from"` - To entity.CookedCommentForEmail `json:"to"` - Comment entity.CookedCommentForEmail `json:"comment"` - ParentComment entity.CookedCommentForEmail `json:"parent_comment"` - - Nick string `json:"nick"` - Content string `json:"content"` - ReplyNick string `json:"reply_nick"` - ReplyContent string `json:"reply_content"` - - PageTitle string `json:"page_title"` - PageURL string `json:"page_url"` - SiteName string `json:"site_name"` - SiteURL string `json:"site_url"` - - LinkToReply string `json:"link_to_reply"` -} - -func (r *Render) RenderCommon(str string, notify *entity.Notify, _renderType ...string) string { - // 渲染类型 - renderType := "email" // 默认为邮件发送渲染 - if len(_renderType) > 0 { - renderType = _renderType[0] - } - - fromComment := r.dao.FetchCommentForNotify(notify) - from := r.dao.CookCommentForEmail(&fromComment) - toComment := r.dao.FindNotifyParentComment(notify) - to := r.dao.CookCommentForEmail(&toComment) - - toUser := r.dao.FetchUserForNotify(notify) // 发送目标用户 - - content := to.Content - replyContent := from.Content - if renderType == "notify" { // 多元推送内容 - content = HandleEmoticonsImgTagsForNotify(to.ContentRaw) - replyContent = HandleEmoticonsImgTagsForNotify(from.ContentRaw) - } - - cf := TplFields{ - From: from, - To: to, - Comment: from, - ParentComment: to, - - Nick: toUser.Name, - Content: content, - ReplyNick: from.Nick, - ReplyContent: replyContent, - PageTitle: from.Page.Title, - PageURL: from.Page.URL, - SiteName: from.SiteName, - SiteURL: from.Site.FirstUrl, - - LinkToReply: r.dao.GetReadLinkByNotify(notify), - } - - flat := utils.StructToFlatDotMap(&cf) - - return ReplaceAllMustache(str, flat) -} - -// 渲染邮件 Body 内容 -func (r *Render) RenderEmailBody(notify *entity.Notify) string { - tpl := GetMailTpl(r.tplName) - result := r.RenderCommon(tpl, notify) - - return result -} - -// 渲染管理员推送 Body 内容 -func (r *Render) RenderNotifyBody(notify *entity.Notify) string { - tpl := GetNotifyTpl(r.tplName) - result := r.RenderCommon(tpl, notify, "notify") - - return result -} diff --git a/internal/email/render_utils.go b/internal/email/render_utils.go deleted file mode 100644 index 74a972182..000000000 --- a/internal/email/render_utils.go +++ /dev/null @@ -1,124 +0,0 @@ -package email - -import ( - "bytes" - "embed" - "fmt" - "html" - "os" - "regexp" - "strings" - - "github.com/ArtalkJS/Artalk/internal/utils" -) - -//go:embed email_tpl/* -//go:embed notify_tpl/* -var internalTpl embed.FS - -// 替换 {{ key }} 为 val -func ReplaceAllMustache(data string, dict map[string]interface{}) string { - return utils.RenderMustaches(data, dict, func(k string, v interface{}) string { - return GetPurifiedValue(k, v) - }) -} - -// 净化文本,防止 XSS -func GetPurifiedValue(k string, v interface{}) string { - val := fmt.Sprintf("%v", v) - - // 白名单 - ignoreEscapeKeys := []string{"reply_content", "content", "link_to_reply"} - if utils.ContainsStr(ignoreEscapeKeys, k) || - strings.HasSuffix(k, ".content") || // 排除 entity.CookedComment.content - strings.HasSuffix(k, ".content_raw") { - return val - } - - val = html.EscapeString(val) - return val -} - -func HandleEmoticonsImgTagsForNotify(str string) string { - r := regexp.MustCompile(`]*?atk-emoticon=["]([^"]*?)["][^>]*?>`) - return r.ReplaceAllStringFunc(str, func(m string) string { - ms := r.FindStringSubmatch(m) - if len(ms) < 2 { - return m - } - if ms[1] == "" { - return "[表情]" - } - return "[" + ms[1] + "]" - }) -} - -func GetMailTpl(tplName string) string { - // 配置文件未指定邮件模板路径,使用内置默认模板 - if tplName == "" { - tplName = "default" - } - - var tpl string - if !utils.CheckFileExist(tplName) { - tpl = GetInternalEmailTpl(tplName) - } else { - // TODO 反复文件 IO 操作会导致性能下降, - // 之后优化可以改成程序启动时加载模板文件到内存中 - tpl = GetExternalTpl(tplName) - } - - return tpl -} - -func GetNotifyTpl(tplName string) string { - if tplName == "" { - tplName = "default" - } - - var tpl string - if !utils.CheckFileExist(tplName) { - tpl = GetInternalNotifyTpl(tplName) - } else { - tpl = GetExternalTpl(tplName) - } - - return tpl -} - -// 获取内建邮件模版 -func GetInternalEmailTpl(tplName string) string { - return GetInternalTpl("email_tpl", tplName) -} - -// 获取内建通知模版 -func GetInternalNotifyTpl(tplName string) string { - return GetInternalTpl("notify_tpl", tplName) -} - -// 获取内建模版 -func GetInternalTpl(basePath string, tplName string) string { - filename := fmt.Sprintf("%s/%s.html", basePath, tplName) - f, err := internalTpl.Open(filename) - if err != nil { - return "" - } - - buf := new(bytes.Buffer) - if _, err := buf.ReadFrom(f); err != nil { - return "" - } - contents := buf.String() - - return contents -} - -// 获取外置模版 -func GetExternalTpl(filename string) string { - buf, err := os.ReadFile(filename) - if err != nil { - return "" - } - - return string(buf) -} diff --git a/internal/notify_pusher/base.go b/internal/notify_pusher/base.go index 3fabea287..121bc59b0 100644 --- a/internal/notify_pusher/base.go +++ b/internal/notify_pusher/base.go @@ -5,7 +5,6 @@ import ( "github.com/ArtalkJS/Artalk/internal/config" "github.com/ArtalkJS/Artalk/internal/dao" - "github.com/ArtalkJS/Artalk/internal/email" "github.com/ArtalkJS/Artalk/internal/entity" "github.com/nikoksr/notify" "github.com/nikoksr/notify/service/dingding" @@ -17,9 +16,9 @@ import ( type NotifyPusherConf struct { config.AdminNotifyConf Dao *dao.Dao - // bridge func to email push - EmailPush func(notify *entity.Notify) error - EmailRender func() (*email.Render, error) + + // Provide a custom function to bridge the gap between Notify pusher and Email pusher + EmailPush func(notify *entity.Notify) error } type NotifyPusher struct { @@ -30,7 +29,6 @@ type NotifyPusher struct { } func NewNotifyPusher(conf *NotifyPusherConf) *NotifyPusher { - // 初始化 Notify pusher := &NotifyPusher{ conf: conf, dao: conf.Dao, diff --git a/internal/notify_pusher/multi.go b/internal/notify_pusher/multi.go index 11c300358..18d073abc 100644 --- a/internal/notify_pusher/multi.go +++ b/internal/notify_pusher/multi.go @@ -7,6 +7,7 @@ import ( "github.com/ArtalkJS/Artalk/internal/entity" "github.com/ArtalkJS/Artalk/internal/log" "github.com/ArtalkJS/Artalk/internal/notify_pusher/sender" + "github.com/ArtalkJS/Artalk/internal/template" ) func (pusher *NotifyPusher) multiPush(comment *entity.Comment, pComment *entity.Comment) { @@ -48,24 +49,15 @@ func (pusher *NotifyPusher) getAdminNotifySubjectBody(comment *entity.Comment, t // coContent = coContent + "..." // } + render := template.NewRenderer(pusher.dao, template.TYPE_NOTIFY, template.NewFileLoader(pusher.conf.NotifyTpl)) notify := pusher.dao.FindCreateNotify(toUserID, comment.ID) - render, err := pusher.conf.EmailRender() - if err != nil { - log.Error("pusher.conf.EmailRender err,", err) - return "", "" - } - if render == nil { - log.Error("pusher.conf.EmailRender cannot be nil") - return "", "" - } - - subject := "" + var subject string if pusher.conf.NotifySubject != "" { - subject = render.RenderCommon(pusher.conf.NotifySubject, ¬ify, "notify") + subject = render.Render(¬ify, pusher.conf.NotifySubject) } - body := render.RenderNotifyBody(¬ify) + body := render.Render(¬ify) if comment.IsPending { body = "[待审状态评论]\n\n" + body } diff --git a/internal/email/email_tpl/default.html b/internal/template/email_tpl/default.html similarity index 100% rename from internal/email/email_tpl/default.html rename to internal/template/email_tpl/default.html diff --git a/internal/email/notify_tpl/default.html b/internal/template/notify_tpl/default.html similarity index 100% rename from internal/email/notify_tpl/default.html rename to internal/template/notify_tpl/default.html diff --git a/internal/template/render.go b/internal/template/render.go new file mode 100644 index 000000000..2e2b036fa --- /dev/null +++ b/internal/template/render.go @@ -0,0 +1,67 @@ +package template + +import ( + "github.com/ArtalkJS/Artalk/internal/dao" + "github.com/ArtalkJS/Artalk/internal/entity" +) + +type RenderType string + +const ( + TYPE_EMAIL RenderType = "email" + TYPE_NOTIFY RenderType = "notify" +) + +type RenderStrategy interface { + Render(tpl string, p tplParams, notify *entity.Notify, extra notifyExtraData) string +} + +type Renderer struct { + dao *dao.Dao + strategy RenderStrategy + defaultTpl string +} + +func (r *Renderer) Render(notify *entity.Notify, customTpl ...string) string { + var tpl string + if len(customTpl) > 0 { + tpl = customTpl[0] + } else { + tpl = r.defaultTpl + } + + extra := getNotifyExtraData(r.dao, notify) + params := getCommonParams(r.dao, notify, extra) + + return r.strategy.Render(tpl, params, notify, extra) +} + +func NewRenderer(dao *dao.Dao, renderType RenderType, defaultTemplateLoader TemplateLoader) *Renderer { + r := &Renderer{ + dao: dao, + } + + // load default template + if defaultTemplateLoader != nil { + r.defaultTpl = defaultTemplateLoader.Load(renderType) + } + + // Render type + var renderStrategies = map[RenderType]func() RenderStrategy{ + TYPE_EMAIL: func() RenderStrategy { + return NewEmailRenderStrategy() + }, + TYPE_NOTIFY: func() RenderStrategy { + return NewNotifyRenderer() + }, + } + + // Set render strategy + if strategyFunc, ok := renderStrategies[renderType]; ok { + r.strategy = strategyFunc() + } else { + r.strategy = NewEmailRenderStrategy() // Default + } + + return r +} diff --git a/internal/template/render_test.go b/internal/template/render_test.go new file mode 100644 index 000000000..1f1e72a18 --- /dev/null +++ b/internal/template/render_test.go @@ -0,0 +1,98 @@ +package template_test + +import ( + "fmt" + "os" + "strings" + "testing" + + "github.com/ArtalkJS/Artalk/internal/template" + "github.com/ArtalkJS/Artalk/test" + "github.com/stretchr/testify/assert" +) + +func TestNewRenderer(t *testing.T) { + app, _ := test.NewTestApp() + defer app.Cleanup() + + t.Run("DefaultTemplateRender", func(t *testing.T) { + customTplFile := fmt.Sprintf("%s/%s", t.TempDir(), "test_tpl_file") + customTplContent := "[{{site_name}}] You got a reply from @{{reply_nick}}: {{reply_content}}" + _ = os.WriteFile(customTplFile, []byte(customTplContent), 0644) + defer os.Remove(customTplFile) + + tests := []struct { + name string + renderType template.RenderType + defaultTemplateLoader func() template.TemplateLoader + expectedDefaultResult string + }{ + { + name: "EmailDefaultTplFile", + renderType: template.TYPE_EMAIL, + defaultTemplateLoader: func() template.TemplateLoader { return template.NewFileLoader("") }, + expectedDefaultResult: "", + }, + { + name: "NotifyDefaultTplFile", + renderType: template.TYPE_NOTIFY, + defaultTemplateLoader: func() template.TemplateLoader { return template.NewFileLoader("") }, + expectedDefaultResult: "", + }, + { + name: "CustomEmailTplByFileLoader", + renderType: template.TYPE_EMAIL, + defaultTemplateLoader: func() template.TemplateLoader { return template.NewFileLoader(customTplFile) }, + expectedDefaultResult: "[Site A] You got a reply from @admin:

Hello Artalk, 你好 Artalk!

", + }, + { + name: "CustomNotifyTplByFileLoader", + renderType: template.TYPE_NOTIFY, + defaultTemplateLoader: func() template.TemplateLoader { return template.NewFileLoader(customTplFile) }, + expectedDefaultResult: "[Site A] You got a reply from @admin: Hello Artalk, 你好 Artalk!", + }, + } + + for _, tt := range tests { + t.Run(string(tt.renderType), func(t *testing.T) { + renderer := template.NewRenderer(app.Dao(), tt.renderType, tt.defaultTemplateLoader()) + tNotify := app.Dao().FindNotify(1000, 1000) + + // Test render default tpl with template loader + result := renderer.Render(&tNotify) + + assert.NotEmpty(t, result, "default tpl should not be empty") + if tt.expectedDefaultResult != "" { + assert.Equal(t, tt.expectedDefaultResult, strings.TrimSpace(result), "default tpl should be rendered") + } + }) + } + }) + + t.Run("CustomTemplateRender", func(t *testing.T) { + tests := []struct { + name string + renderType template.RenderType + customTpl string + expectedCustomResult string + }{ + { + name: "CustomTplByString", + renderType: template.TYPE_EMAIL, + customTpl: "[{{site_name}}] You got a reply from @{{reply_nick}}", + expectedCustomResult: "[Site A] You got a reply from @admin", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + renderer := template.NewRenderer(app.Dao(), tt.renderType, nil) + tNotify := app.Dao().FindNotify(1000, 1000) + + // Test custom tpl + result := renderer.Render(&tNotify, tt.customTpl) + assert.Equal(t, tt.expectedCustomResult, result, "custom tpl should be rendered") + }) + } + }) +} diff --git a/internal/template/strategy_email.go b/internal/template/strategy_email.go new file mode 100644 index 000000000..aadbc81a0 --- /dev/null +++ b/internal/template/strategy_email.go @@ -0,0 +1,18 @@ +package template + +import ( + "github.com/ArtalkJS/Artalk/internal/entity" +) + +var _ RenderStrategy = (*EmailRenderStrategy)(nil) + +type EmailRenderStrategy struct { +} + +func NewEmailRenderStrategy() *EmailRenderStrategy { + return &EmailRenderStrategy{} +} + +func (r *EmailRenderStrategy) Render(tpl string, p tplParams, notify *entity.Notify, extra notifyExtraData) string { + return renderCommon(tpl, p) +} diff --git a/internal/template/strategy_notify.go b/internal/template/strategy_notify.go new file mode 100644 index 000000000..84741da74 --- /dev/null +++ b/internal/template/strategy_notify.go @@ -0,0 +1,21 @@ +package template + +import ( + "github.com/ArtalkJS/Artalk/internal/entity" +) + +var _ RenderStrategy = (*NotifyRenderStrategy)(nil) + +type NotifyRenderStrategy struct { +} + +func NewNotifyRenderer() *NotifyRenderStrategy { + return &NotifyRenderStrategy{} +} + +func (r *NotifyRenderStrategy) Render(tpl string, p tplParams, notify *entity.Notify, extra notifyExtraData) string { + p.Content = handleEmoticonsImgTagsForNotify(extra.to.ContentRaw) + p.ReplyContent = handleEmoticonsImgTagsForNotify(extra.from.ContentRaw) + + return renderCommon(tpl, p) +} diff --git a/internal/template/template_loader.go b/internal/template/template_loader.go new file mode 100644 index 000000000..73b977506 --- /dev/null +++ b/internal/template/template_loader.go @@ -0,0 +1,103 @@ +package template + +import ( + "bytes" + "embed" + "fmt" + "os" +) + +// ------------------------------------------------------------------- +// Template Loader +// ------------------------------------------------------------------- + +type TemplateLoader interface { + Load(tplType RenderType) string +} + +// Creates a new default template loader +// +// tplName parameter is the template name, +// if tplName is empty, it will load the default template, +// or you can specify a custom template name. +// it will load the template from internal or external, first look up external, then internal. +func NewFileLoader(tplName string) TemplateLoader { + return &DefaultTemplateLoader{tplName: tplName} +} + +// The default template loader +var _ TemplateLoader = (*DefaultTemplateLoader)(nil) + +type DefaultTemplateLoader struct { + tplName string +} + +func (l *DefaultTemplateLoader) Load(tplType RenderType) string { + // retrieve external template + if tpl, err := getExternalTpl(l.tplName); err == nil { + return tpl + } + + // retrieve internal template + return getInternalTpl(tplType, l.tplName) +} + +// ------------------------------------------------------------------- +// Internal Template Loader +// ------------------------------------------------------------------- + +//go:embed email_tpl/* +//go:embed notify_tpl/* +var internalTpl embed.FS + +// Template base path for different render type +var tplType2BasePath = map[RenderType]string{ + TYPE_EMAIL: "email_tpl", + TYPE_NOTIFY: "notify_tpl", +} + +// Get internal template +func getInternalTpl(tplType RenderType, tplName string) string { + if tplName == "" { + tplName = "default" + } + + var basePath string + if path, ok := tplType2BasePath[tplType]; ok { + basePath = path + } else { + basePath = tplType2BasePath[TYPE_EMAIL] // default + } + + filename := fmt.Sprintf("%s/%s.html", basePath, tplName) + f, err := internalTpl.Open(filename) + if err != nil { + return "" + } + + buf := new(bytes.Buffer) + if _, err := buf.ReadFrom(f); err != nil { + return "" + } + contents := buf.String() + + return contents +} + +// ------------------------------------------------------------------- +// External Template Loader +// ------------------------------------------------------------------- + +// Get external template +func getExternalTpl(filename string) (string, error) { + // TODO 反复文件 IO 操作会导致性能下降,之后优化可以改成程序启动时加载模板文件到内存中 + // TODO 安全问题:注意这里允许离开工作目录读取文件, + // 由于 filename 是动态可配置项,若管理员凭证泄露,可能会造成文件泄露 + // (需注意检测 filename 的合法性,建议程序在低权限账户下运行,或丢到 docker 运行) + buf, err := os.ReadFile(filename) + if err != nil { + return "", err + } + + return string(buf), nil +} diff --git a/internal/template/template_render.go b/internal/template/template_render.go new file mode 100644 index 000000000..5b4a5627e --- /dev/null +++ b/internal/template/template_render.go @@ -0,0 +1,119 @@ +package template + +import ( + "fmt" + "html" + "regexp" + "strings" + + "github.com/ArtalkJS/Artalk/internal/dao" + "github.com/ArtalkJS/Artalk/internal/entity" + "github.com/ArtalkJS/Artalk/internal/utils" +) + +// ------------------------------------------------------------------- +// Template Render +// ------------------------------------------------------------------- + +type tplParams struct { + From entity.CookedCommentForEmail `json:"from"` + To entity.CookedCommentForEmail `json:"to"` + Comment entity.CookedCommentForEmail `json:"comment"` + ParentComment entity.CookedCommentForEmail `json:"parent_comment"` + + Nick string `json:"nick"` + Content string `json:"content"` + ReplyNick string `json:"reply_nick"` + ReplyContent string `json:"reply_content"` + + PageTitle string `json:"page_title"` + PageURL string `json:"page_url"` + SiteName string `json:"site_name"` + SiteURL string `json:"site_url"` + + LinkToReply string `json:"link_to_reply"` +} + +type notifyExtraData struct { + from, to entity.CookedCommentForEmail + toUser entity.User +} + +// Get extra data for notify +func getNotifyExtraData(dao *dao.Dao, notify *entity.Notify) (data notifyExtraData) { + fromComment := dao.FetchCommentForNotify(notify) + toComment := dao.FindNotifyParentComment(notify) + + data.from = dao.CookCommentForEmail(&fromComment) + data.to = dao.CookCommentForEmail(&toComment) + + data.toUser = dao.FetchUserForNotify(notify) // email receiver + + return +} + +// Get common params for template +func getCommonParams(dao *dao.Dao, notify *entity.Notify, atd notifyExtraData) tplParams { + return tplParams{ + From: atd.from, + To: atd.to, + Comment: atd.from, + ParentComment: atd.to, + + Nick: atd.toUser.Name, + Content: atd.to.Content, + ReplyNick: atd.from.Nick, + ReplyContent: atd.from.Content, + PageTitle: atd.from.Page.Title, + PageURL: atd.from.Page.URL, + SiteName: atd.from.SiteName, + SiteURL: atd.from.Site.FirstUrl, + + LinkToReply: dao.GetReadLinkByNotify(notify), + } +} + +// Replace {{ key }} with values in dict +func replaceAllMustache(data string, dict map[string]interface{}) string { + return utils.RenderMustaches(data, dict, func(k string, v interface{}) string { + return getPurifiedValue(k, v) + }) +} + +// Purify text to prevent XSS +func getPurifiedValue(k string, v interface{}) string { + val := fmt.Sprintf("%v", v) + + // whitelist + ignoreEscapeKeys := []string{"reply_content", "content", "link_to_reply"} + if utils.ContainsStr(ignoreEscapeKeys, k) || + strings.HasSuffix(k, ".content") || // exclude `entity.CookedComment.content` + strings.HasSuffix(k, ".content_raw") { + return val + } + + val = html.EscapeString(val) + return val +} + +// Transform emoticons img tags to plain text +func handleEmoticonsImgTagsForNotify(str string) string { + r := regexp.MustCompile(`]*?atk-emoticon=["]([^"]*?)["][^>]*?>`) + return r.ReplaceAllStringFunc(str, func(m string) string { + ms := r.FindStringSubmatch(m) + if len(ms) < 2 { + return m + } + if ms[1] == "" { + return "[表情]" + } + return "[" + ms[1] + "]" + }) +} + +// Common render function +func renderCommon(tpl string, params tplParams) string { + flat := utils.StructToFlatDotMap(¶ms) + + return replaceAllMustache(tpl, flat) +} diff --git a/internal/email/render_test.go b/internal/template/template_test.go similarity index 90% rename from internal/email/render_test.go rename to internal/template/template_test.go index 22171aa61..80dbe8583 100644 --- a/internal/email/render_test.go +++ b/internal/template/template_test.go @@ -1,4 +1,4 @@ -package email +package template import "testing" @@ -17,7 +17,7 @@ func TestHandleEmoticonsImgTagsForNotify(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := HandleEmoticonsImgTagsForNotify(tt.give); got != tt.want { + if got := handleEmoticonsImgTagsForNotify(tt.give); got != tt.want { t.Errorf("HandleEmoticonsImgTagsForNotify() = %v, want %v", got, tt.want) } })