// 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 }