diff --git a/internal/service/admin_service.go b/internal/service/admin_service.go index a34b9d7..c05eec7 100644 --- a/internal/service/admin_service.go +++ b/internal/service/admin_service.go @@ -11,6 +11,7 @@ import ( "github.com/prabhupadasays/spsays-bff/internal/model" "github.com/prabhupadasays/spsays-bff/internal/repository" "github.com/prabhupadasays/spsays-bff/pkg/mailer" + "github.com/prabhupadasays/spsays-bff/pkg/richtext" "go.uber.org/zap" ) @@ -335,8 +336,9 @@ func (s *AdminService) ReplyFeedback(ctx context.Context, id, message string) (f var buf bytes.Buffer if err := feedbackReplyEmailTpl.Execute(&buf, map[string]any{ "RecipientName": name, - "Reply": message, - "URL": s.frontendURL, + // Rendered markers; escaped by richtext, newlines become
. + "Reply": richtext.ToHTML(message), + "URL": s.frontendURL, }); err != nil { s.logger.Warn("render feedback reply email failed", zap.Error(err)) return true, false, nil @@ -435,7 +437,7 @@ var feedbackReplyEmailTpl = template.Must(template.New("feedbackReply").Parse(`<

Thank you for writing to us. Here's our reply to your feedback:

-
{{.Reply}}
+
{{.Reply}}

Open Prabhupāda Says diff --git a/internal/service/notification_service.go b/internal/service/notification_service.go index 3ea4fc4..37b60ea 100644 --- a/internal/service/notification_service.go +++ b/internal/service/notification_service.go @@ -26,6 +26,7 @@ import ( "github.com/prabhupadasays/spsays-bff/internal/repository" "github.com/prabhupadasays/spsays-bff/pkg/mailer" "github.com/prabhupadasays/spsays-bff/pkg/realtime" + "github.com/prabhupadasays/spsays-bff/pkg/richtext" "go.uber.org/zap" ) @@ -300,7 +301,7 @@ func (s *NotificationService) NotifyPostLike(ctx context.Context, postID, author if s.push != nil && !prefOff(off, NotifPostLike, repository.ChannelPush) { s.push.SendToUser(ctx, authorID, PushPayload{ Title: "New Like", - Body: body, + Body: richtext.Plain(body), URL: url, Icon: s.iconURL(), Tag: NotifPostLike + ":" + postID, @@ -642,7 +643,7 @@ func (s *NotificationService) NotifyAdminMessage(ctx context.Context, recipientI if s.rt != nil { if err := s.rt.TriggerUser(ctx, recipientID, "notification", map[string]any{ "kind": "admin_message", - "body": body, + "body": richtext.Plain(body), }); err != nil { s.logger.Warn("realtime admin message publish failed", zap.String("recipient", recipientID), zap.Error(err)) @@ -670,8 +671,10 @@ func (s *NotificationService) NotifyAdminMessage(ctx context.Context, recipientI if err := adminMessageEmailTpl.Execute(&buf, map[string]any{ "RecipientName": recipient.Name, "Title": strings.TrimSpace(title), - "Body": body, - "URL": url, + // Rendered markers (bold, links…). Already escaped by richtext, + // and its
s replace the old white-space:pre-line. + "Body": richtext.ToHTML(body), + "URL": url, }); err != nil { s.logger.Warn("render admin message email failed", zap.Error(err)) } else { @@ -882,7 +885,7 @@ var adminMessageEmailTpl = template.Must(template.New("adminMessage").Parse(`{{.Title}}

{{end}} -

{{.Body}}

+

{{.Body}}

Open Prabhupāda Says diff --git a/pkg/richtext/richtext.go b/pkg/richtext/richtext.go new file mode 100644 index 0000000..06a47ce --- /dev/null +++ b/pkg/richtext/richtext.go @@ -0,0 +1,121 @@ +// Package richtext renders the light formatting the admin panel's message +// fields allow, for the two places that text leaves the app: +// +// - ToHTML, for email bodies. +// - Plain, for push notifications and toasts, which can't render markup. +// +// The format is a handful of markers typed into a plain textarea: +// +// *bold* _italic_ __underline__ ~strike~ [label](https://…) +// +// plus bare http(s) URLs, which become links on their own. *bold* and ~strike~ +// match what chat already uses, so the two read the same. +// +// Safety comes from the order of operations: the input is HTML-escaped FIRST, +// and only then are the markers turned into tags. Nothing an author types can +// therefore produce markup of its own — the only tags in the output are the +// ones this package writes. Links are limited to http and https, so a +// `javascript:` URL is never made clickable. +package richtext + +import ( + "fmt" + "html" + "html/template" + "regexp" + "strings" +) + +var ( + // A labelled link, or a bare URL. One alternation so a URL is never + // linked twice — once as the label's target and once on its own. + linkRe = regexp.MustCompile(`\[([^\]\n]+)\]\((https?://[^\s)]+)\)|(https?://[^\s<]+)`) + + underlineRe = regexp.MustCompile(`__([^_\n]+?)__`) + boldRe = regexp.MustCompile(`\*([^*\n]+?)\*`) + strikeRe = regexp.MustCompile(`~([^~\n]+?)~`) + // Italic needs a non-word character (or an edge) on each side, so the + // underscores inside snake_case or a handle aren't read as markers. RE2 + // has no lookbehind, so the neighbours are captured and put back. + italicRe = regexp.MustCompile(`(^|[^\w])_([^_\n]+?)_($|[^\w])`) + + // Punctuation that ends a sentence rather than belonging to the URL. + trailingPunct = ".,;:!?)" +) + +// placeholder marks where a finished link sits while markers are applied, so +// the underscores and tildes common in URLs aren't read as formatting. +func placeholder(i int) string { return fmt.Sprintf("\x00%d\x00", i) } + +// splitTrailing separates sentence punctuation from the end of a bare URL. +func splitTrailing(u string) (url, tail string) { + end := len(u) + for end > 0 && strings.ContainsRune(trailingPunct, rune(u[end-1])) { + end-- + } + return u[:end], u[end:] +} + +// ToHTML renders the markers as HTML for an email body. +func ToHTML(s string) template.HTML { + escaped := html.EscapeString(strings.ReplaceAll(s, "\r\n", "\n")) + + var links []string + withHolders := linkRe.ReplaceAllStringFunc(escaped, func(m string) string { + parts := linkRe.FindStringSubmatch(m) + var label, href, tail string + if parts[1] != "" { + label, href = parts[1], parts[2] + } else { + href, tail = splitTrailing(parts[3]) + label = href + } + // href is already escaped (it came from the escaped input), so it is + // safe inside the attribute as-is. + links = append(links, fmt.Sprintf( + `%s`, href, label)) + return placeholder(len(links)-1) + tail + }) + + out := underlineRe.ReplaceAllString(withHolders, `$1`) + out = boldRe.ReplaceAllString(out, `$1`) + out = strikeRe.ReplaceAllString(out, `$1`) + out = italicRe.ReplaceAllString(out, `$1$2$3`) + + for i, l := range links { + out = strings.Replace(out, placeholder(i), l, 1) + } + out = strings.ReplaceAll(out, "\n", "
") + + // Every tag here was written by this function, and all author text was + // escaped before any of them were added. + return template.HTML(out) //nolint:gosec // see above +} + +// Plain strips the markers for surfaces that can't render them — a push +// notification or a toast. A labelled link keeps only its label; a bare URL +// stays as written, since it is still useful to see. +func Plain(s string) string { + s = strings.ReplaceAll(s, "\r\n", "\n") + + var links []string + withHolders := linkRe.ReplaceAllStringFunc(s, func(m string) string { + parts := linkRe.FindStringSubmatch(m) + if parts[1] != "" { + links = append(links, parts[1]) + } else { + links = append(links, parts[3]) + } + return placeholder(len(links) - 1) + }) + + out := underlineRe.ReplaceAllString(withHolders, `$1`) + out = boldRe.ReplaceAllString(out, `$1`) + out = strikeRe.ReplaceAllString(out, `$1`) + out = italicRe.ReplaceAllString(out, `$1$2$3`) + + for i, l := range links { + out = strings.Replace(out, placeholder(i), l, 1) + } + return out +} diff --git a/pkg/richtext/richtext_test.go b/pkg/richtext/richtext_test.go new file mode 100644 index 0000000..d0aa0f3 --- /dev/null +++ b/pkg/richtext/richtext_test.go @@ -0,0 +1,85 @@ +package richtext + +import ( + "strings" + "testing" +) + +func TestToHTML_Markers(t *testing.T) { + cases := []struct{ in, want string }{ + {"*bold*", "bold"}, + {"_italic_", "italic"}, + {"__under__", "under"}, + {"~gone~", "gone"}, + {"a *b* and _c_", "a b and c"}, + {"line one\nline two", "line one
line two"}, + // Underscores inside words are not italics. + {"snake_case_name", "snake_case_name"}, + {"@some_handle here", "@some_handle here"}, + } + for _, c := range cases { + if got := string(ToHTML(c.in)); got != c.want { + t.Errorf("ToHTML(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestToHTML_Links(t *testing.T) { + got := string(ToHTML("see [the gītā](https://prabhupadasays.org/books/bg)")) + if !strings.Contains(got, `href="https://prabhupadasays.org/books/bg"`) || + !strings.Contains(got, ">the gītā") { + t.Errorf("labelled link: %q", got) + } + + // A bare URL becomes a link, but sentence punctuation stays outside it. + got = string(ToHTML("Visit https://prabhupadasays.org.")) + if !strings.Contains(got, `href="https://prabhupadasays.org"`) || + !strings.HasSuffix(got, ".") { + t.Errorf("bare link with trailing period: %q", got) + } + + // Underscores and tildes inside a URL are not formatting. + got = string(ToHTML("https://example.com/a_b_c~d")) + if strings.Contains(got, "") || strings.Contains(got, "") { + t.Errorf("markers applied inside a URL: %q", got) + } +} + +// The security property: nothing typed can become markup of its own. +func TestToHTML_EscapesAuthorInput(t *testing.T) { + cases := []string{ + ``, + ``, + `*bold*`, + `[click](javascript:alert(1))`, + `[x](https://ok.example/" onmouseover="alert(1))`, + } + for _, in := range cases { + got := string(ToHTML(in)) + if strings.Contains(got, "") || strings.Contains(got, "javascript:alert") && strings.Contains(got, "href=\"javascript") { + t.Errorf("ToHTML(%q) produced author markup: %q", in, got) + } + if strings.Contains(got, `" onmouseover=`) { + t.Errorf("ToHTML(%q) let a quote break out of an attribute: %q", in, got) + } + } + // A javascript: URL is never made a link at all. + if got := string(ToHTML(`[click](javascript:alert(1))`)); strings.Contains(got, "