feedback formatter
This commit is contained in:
@@ -11,6 +11,7 @@ import (
|
|||||||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||||
"github.com/prabhupadasays/spsays-bff/pkg/mailer"
|
"github.com/prabhupadasays/spsays-bff/pkg/mailer"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/pkg/richtext"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -335,8 +336,9 @@ func (s *AdminService) ReplyFeedback(ctx context.Context, id, message string) (f
|
|||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if err := feedbackReplyEmailTpl.Execute(&buf, map[string]any{
|
if err := feedbackReplyEmailTpl.Execute(&buf, map[string]any{
|
||||||
"RecipientName": name,
|
"RecipientName": name,
|
||||||
"Reply": message,
|
// Rendered markers; escaped by richtext, newlines become <br>.
|
||||||
"URL": s.frontendURL,
|
"Reply": richtext.ToHTML(message),
|
||||||
|
"URL": s.frontendURL,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
s.logger.Warn("render feedback reply email failed", zap.Error(err))
|
s.logger.Warn("render feedback reply email failed", zap.Error(err))
|
||||||
return true, false, nil
|
return true, false, nil
|
||||||
@@ -435,7 +437,7 @@ var feedbackReplyEmailTpl = template.Must(template.New("feedbackReply").Parse(`<
|
|||||||
|
|
||||||
<p>Thank you for writing to us. Here's our reply to your feedback:</p>
|
<p>Thank you for writing to us. Here's our reply to your feedback:</p>
|
||||||
|
|
||||||
<blockquote style="white-space:pre-line;border-left:3px solid #9a6b2f;padding-left:1em;margin:1em 0;color:#3a3024">{{.Reply}}</blockquote>
|
<blockquote style="border-left:3px solid #9a6b2f;padding-left:1em;margin:1em 0;color:#3a3024">{{.Reply}}</blockquote>
|
||||||
|
|
||||||
<p style="margin:1.25em 0">
|
<p style="margin:1.25em 0">
|
||||||
<a href="{{.URL}}" style="display:inline-block;background:#9a6b2f;color:#fff;text-decoration:none;padding:0.6em 1.3em;border-radius:8px;font-weight:600">Open Prabhupāda Says</a>
|
<a href="{{.URL}}" style="display:inline-block;background:#9a6b2f;color:#fff;text-decoration:none;padding:0.6em 1.3em;border-radius:8px;font-weight:600">Open Prabhupāda Says</a>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import (
|
|||||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||||
"github.com/prabhupadasays/spsays-bff/pkg/mailer"
|
"github.com/prabhupadasays/spsays-bff/pkg/mailer"
|
||||||
"github.com/prabhupadasays/spsays-bff/pkg/realtime"
|
"github.com/prabhupadasays/spsays-bff/pkg/realtime"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/pkg/richtext"
|
||||||
"go.uber.org/zap"
|
"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) {
|
if s.push != nil && !prefOff(off, NotifPostLike, repository.ChannelPush) {
|
||||||
s.push.SendToUser(ctx, authorID, PushPayload{
|
s.push.SendToUser(ctx, authorID, PushPayload{
|
||||||
Title: "New Like",
|
Title: "New Like",
|
||||||
Body: body,
|
Body: richtext.Plain(body),
|
||||||
URL: url,
|
URL: url,
|
||||||
Icon: s.iconURL(),
|
Icon: s.iconURL(),
|
||||||
Tag: NotifPostLike + ":" + postID,
|
Tag: NotifPostLike + ":" + postID,
|
||||||
@@ -642,7 +643,7 @@ func (s *NotificationService) NotifyAdminMessage(ctx context.Context, recipientI
|
|||||||
if s.rt != nil {
|
if s.rt != nil {
|
||||||
if err := s.rt.TriggerUser(ctx, recipientID, "notification", map[string]any{
|
if err := s.rt.TriggerUser(ctx, recipientID, "notification", map[string]any{
|
||||||
"kind": "admin_message",
|
"kind": "admin_message",
|
||||||
"body": body,
|
"body": richtext.Plain(body),
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
s.logger.Warn("realtime admin message publish failed",
|
s.logger.Warn("realtime admin message publish failed",
|
||||||
zap.String("recipient", recipientID), zap.Error(err))
|
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{
|
if err := adminMessageEmailTpl.Execute(&buf, map[string]any{
|
||||||
"RecipientName": recipient.Name,
|
"RecipientName": recipient.Name,
|
||||||
"Title": strings.TrimSpace(title),
|
"Title": strings.TrimSpace(title),
|
||||||
"Body": body,
|
// Rendered markers (bold, links…). Already escaped by richtext,
|
||||||
"URL": url,
|
// and its <br>s replace the old white-space:pre-line.
|
||||||
|
"Body": richtext.ToHTML(body),
|
||||||
|
"URL": url,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
s.logger.Warn("render admin message email failed", zap.Error(err))
|
s.logger.Warn("render admin message email failed", zap.Error(err))
|
||||||
} else {
|
} else {
|
||||||
@@ -882,7 +885,7 @@ var adminMessageEmailTpl = template.Must(template.New("adminMessage").Parse(`<di
|
|||||||
|
|
||||||
{{if .Title}}<p style="font-weight:600;font-size:19px;margin-bottom:0.5em">{{.Title}}</p>{{end}}
|
{{if .Title}}<p style="font-weight:600;font-size:19px;margin-bottom:0.5em">{{.Title}}</p>{{end}}
|
||||||
|
|
||||||
<p style="white-space:pre-line">{{.Body}}</p>
|
<p>{{.Body}}</p>
|
||||||
|
|
||||||
<p style="margin:1.25em 0">
|
<p style="margin:1.25em 0">
|
||||||
<a href="{{.URL}}" style="display:inline-block;background:#9a6b2f;color:#fff;text-decoration:none;padding:0.6em 1.3em;border-radius:8px;font-weight:600">Open Prabhupāda Says</a>
|
<a href="{{.URL}}" style="display:inline-block;background:#9a6b2f;color:#fff;text-decoration:none;padding:0.6em 1.3em;border-radius:8px;font-weight:600">Open Prabhupāda Says</a>
|
||||||
|
|||||||
@@ -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(
|
||||||
|
`<a href="%s" style="color:#9a6b2f;font-weight:600">%s</a>`, href, label))
|
||||||
|
return placeholder(len(links)-1) + tail
|
||||||
|
})
|
||||||
|
|
||||||
|
out := underlineRe.ReplaceAllString(withHolders, `<u>$1</u>`)
|
||||||
|
out = boldRe.ReplaceAllString(out, `<strong>$1</strong>`)
|
||||||
|
out = strikeRe.ReplaceAllString(out, `<s>$1</s>`)
|
||||||
|
out = italicRe.ReplaceAllString(out, `$1<em>$2</em>$3`)
|
||||||
|
|
||||||
|
for i, l := range links {
|
||||||
|
out = strings.Replace(out, placeholder(i), l, 1)
|
||||||
|
}
|
||||||
|
out = strings.ReplaceAll(out, "\n", "<br>")
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package richtext
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestToHTML_Markers(t *testing.T) {
|
||||||
|
cases := []struct{ in, want string }{
|
||||||
|
{"*bold*", "<strong>bold</strong>"},
|
||||||
|
{"_italic_", "<em>italic</em>"},
|
||||||
|
{"__under__", "<u>under</u>"},
|
||||||
|
{"~gone~", "<s>gone</s>"},
|
||||||
|
{"a *b* and _c_", "a <strong>b</strong> and <em>c</em>"},
|
||||||
|
{"line one\nline two", "line one<br>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ā</a>") {
|
||||||
|
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, "</a>.") {
|
||||||
|
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, "<em>") || strings.Contains(got, "<s>") {
|
||||||
|
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{
|
||||||
|
`<script>alert(1)</script>`,
|
||||||
|
`<img src=x onerror=alert(1)>`,
|
||||||
|
`*<b>bold</b>*`,
|
||||||
|
`[click](javascript:alert(1))`,
|
||||||
|
`[x](https://ok.example/" onmouseover="alert(1))`,
|
||||||
|
}
|
||||||
|
for _, in := range cases {
|
||||||
|
got := string(ToHTML(in))
|
||||||
|
if strings.Contains(got, "<script") || strings.Contains(got, "<img") ||
|
||||||
|
strings.Contains(got, "<b>") || 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, "<a ") {
|
||||||
|
t.Errorf("javascript: URL was linked: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlain(t *testing.T) {
|
||||||
|
cases := []struct{ in, want string }{
|
||||||
|
{"*New:* _chat_ is __here__ ~soon~", "New: chat is here soon"},
|
||||||
|
{"read [the gītā](https://x.example)", "read the gītā"},
|
||||||
|
{"see https://prabhupadasays.org", "see https://prabhupadasays.org"},
|
||||||
|
{"snake_case stays", "snake_case stays"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := Plain(c.in); got != c.want {
|
||||||
|
t.Errorf("Plain(%q) = %q, want %q", c.in, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user