b232b4b3d3
- Mentions now insert a span carrying the user id (handles names with spaces): the sanitizer permits <span class="mention" data-user-card="ULID">, comment and chat notifications resolve by id and only fire for users with access, and mentions render as clickable chips with the shared hover user-card. - Messages composer inserts an atomic, non-editable mention chip so the trailing space no longer collapses while typing. - Links inside chat messages and task comments are underlined/accented so they read as clickable. - Atomization: hold Ctrl/Cmd while dragging an effort slider to rebalance the sibling coefficients proportionally (sum stays ~1.00); added a hint. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
238 lines
5.9 KiB
Go
238 lines
5.9 KiB
Go
// Package chat implements messaging primitives. sanitize.go is the
|
|
// server-side rich-text whitelist sanitizer (§2.2): allowed tags are
|
|
// b i u s a code pre ul ol li br p blockquote; only <a> keeps an attribute
|
|
// (href, http/https/mailto only). Everything else is escaped as text.
|
|
// Implemented with a small hand-written tokenizer — no parser dependency.
|
|
package chat
|
|
|
|
import (
|
|
"html"
|
|
"strings"
|
|
)
|
|
|
|
var allowedTags = map[string]bool{
|
|
"b": true, "i": true, "u": true, "s": true, "a": true, "code": true,
|
|
"pre": true, "ul": true, "ol": true, "li": true, "br": true, "p": true,
|
|
"blockquote": true,
|
|
}
|
|
|
|
var voidTags = map[string]bool{"br": true}
|
|
|
|
// SanitizeHTML rewrites untrusted rich text into safe HTML. Unknown tags are
|
|
// dropped (their text content kept), attributes are stripped (except a safe
|
|
// href on <a>), tags are re-balanced so output never leaks open elements.
|
|
func SanitizeHTML(in string) string {
|
|
var out strings.Builder
|
|
var stack []string
|
|
|
|
i := 0
|
|
for i < len(in) {
|
|
c := in[i]
|
|
if c != '<' {
|
|
j := strings.IndexByte(in[i:], '<')
|
|
if j < 0 {
|
|
out.WriteString(html.EscapeString(in[i:]))
|
|
break
|
|
}
|
|
out.WriteString(html.EscapeString(in[i : i+j]))
|
|
i += j
|
|
continue
|
|
}
|
|
// find the end of the tag
|
|
end := strings.IndexByte(in[i:], '>')
|
|
if end < 0 {
|
|
out.WriteString(html.EscapeString(in[i:]))
|
|
break
|
|
}
|
|
raw := in[i+1 : i+end] // between < and >
|
|
i += end + 1
|
|
|
|
closing := strings.HasPrefix(raw, "/")
|
|
raw = strings.TrimPrefix(raw, "/")
|
|
raw = strings.TrimSuffix(strings.TrimSpace(raw), "/")
|
|
name, attrs := splitTag(raw)
|
|
name = strings.ToLower(name)
|
|
|
|
// @-mention tokens: <span class="mention" data-user-card="ULID">@Name</span>.
|
|
// Only this exact shape is allowed; any other span is dropped.
|
|
if name == "span" {
|
|
if closing {
|
|
for n := len(stack) - 1; n >= 0; n-- {
|
|
if stack[n] == "span" {
|
|
for len(stack) > n {
|
|
top := stack[len(stack)-1]
|
|
stack = stack[:len(stack)-1]
|
|
out.WriteString("</" + top + ">")
|
|
}
|
|
break
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
uid := mentionUID(attrs)
|
|
if uid == "" {
|
|
continue // drop the opening span, keep its text
|
|
}
|
|
out.WriteString(`<span class="mention" data-user-card="` + html.EscapeString(uid) + `">`)
|
|
stack = append(stack, "span")
|
|
continue
|
|
}
|
|
|
|
if !allowedTags[name] {
|
|
continue // drop the tag entirely, keep surrounding text
|
|
}
|
|
if closing {
|
|
// close up to the matching open tag, if any
|
|
for n := len(stack) - 1; n >= 0; n-- {
|
|
if stack[n] == name {
|
|
for len(stack) > n {
|
|
top := stack[len(stack)-1]
|
|
stack = stack[:len(stack)-1]
|
|
out.WriteString("</" + top + ">")
|
|
}
|
|
break
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
if voidTags[name] {
|
|
out.WriteString("<" + name + ">")
|
|
continue
|
|
}
|
|
if name == "a" {
|
|
href := safeHref(attrs)
|
|
if href == "" {
|
|
out.WriteString("<a>")
|
|
} else {
|
|
out.WriteString(`<a href="` + html.EscapeString(href) + `" rel="noopener noreferrer" target="_blank">`)
|
|
}
|
|
} else {
|
|
out.WriteString("<" + name + ">")
|
|
}
|
|
stack = append(stack, name)
|
|
}
|
|
// balance anything left open
|
|
for n := len(stack) - 1; n >= 0; n-- {
|
|
out.WriteString("</" + stack[n] + ">")
|
|
}
|
|
return out.String()
|
|
}
|
|
|
|
// splitTag separates the tag name from its raw attribute string.
|
|
func splitTag(raw string) (string, string) {
|
|
for i := 0; i < len(raw); i++ {
|
|
switch raw[i] {
|
|
case ' ', '\t', '\n', '\r':
|
|
return raw[:i], raw[i+1:]
|
|
}
|
|
}
|
|
return raw, ""
|
|
}
|
|
|
|
// mentionUID validates a mention span's attributes and returns the referenced
|
|
// user id (alphanumeric, ULID-shaped), or "" if the span is not a valid
|
|
// mention.
|
|
func mentionUID(attrs string) string {
|
|
low := strings.ToLower(attrs)
|
|
if !strings.Contains(low, "mention") {
|
|
return ""
|
|
}
|
|
idx := strings.Index(low, "data-user-card")
|
|
if idx < 0 {
|
|
return ""
|
|
}
|
|
rest := strings.TrimSpace(attrs[idx+len("data-user-card"):])
|
|
if !strings.HasPrefix(rest, "=") {
|
|
return ""
|
|
}
|
|
rest = strings.TrimSpace(rest[1:])
|
|
var val string
|
|
switch {
|
|
case strings.HasPrefix(rest, `"`):
|
|
if e := strings.IndexByte(rest[1:], '"'); e >= 0 {
|
|
val = rest[1 : 1+e]
|
|
}
|
|
case strings.HasPrefix(rest, "'"):
|
|
if e := strings.IndexByte(rest[1:], '\''); e >= 0 {
|
|
val = rest[1 : 1+e]
|
|
}
|
|
default:
|
|
val = rest
|
|
if sp := strings.IndexAny(val, " \t"); sp >= 0 {
|
|
val = val[:sp]
|
|
}
|
|
}
|
|
if val == "" || len(val) > 32 {
|
|
return ""
|
|
}
|
|
for _, r := range val {
|
|
if !((r >= '0' && r <= '9') || (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z')) {
|
|
return ""
|
|
}
|
|
}
|
|
return val
|
|
}
|
|
|
|
// safeHref extracts href and accepts only http, https, or mailto schemes.
|
|
func safeHref(attrs string) string {
|
|
lower := strings.ToLower(attrs)
|
|
idx := strings.Index(lower, "href")
|
|
if idx < 0 {
|
|
return ""
|
|
}
|
|
rest := strings.TrimSpace(attrs[idx+4:])
|
|
if !strings.HasPrefix(rest, "=") {
|
|
return ""
|
|
}
|
|
rest = strings.TrimSpace(rest[1:])
|
|
var val string
|
|
switch {
|
|
case strings.HasPrefix(rest, `"`):
|
|
if end := strings.IndexByte(rest[1:], '"'); end >= 0 {
|
|
val = rest[1 : 1+end]
|
|
}
|
|
case strings.HasPrefix(rest, "'"):
|
|
if end := strings.IndexByte(rest[1:], '\''); end >= 0 {
|
|
val = rest[1 : 1+end]
|
|
}
|
|
default:
|
|
val = rest
|
|
if sp := strings.IndexAny(val, " \t\n"); sp >= 0 {
|
|
val = val[:sp]
|
|
}
|
|
}
|
|
val = strings.TrimSpace(val)
|
|
low := strings.ToLower(val)
|
|
if strings.HasPrefix(low, "http://") || strings.HasPrefix(low, "https://") ||
|
|
strings.HasPrefix(low, "mailto:") {
|
|
return val
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// SanitizePlain strips ALL tags, returning escaped plain text (used where
|
|
// rich text is not allowed).
|
|
func SanitizePlain(in string) string {
|
|
var out strings.Builder
|
|
i := 0
|
|
for i < len(in) {
|
|
if in[i] != '<' {
|
|
j := strings.IndexByte(in[i:], '<')
|
|
if j < 0 {
|
|
out.WriteString(html.EscapeString(in[i:]))
|
|
break
|
|
}
|
|
out.WriteString(html.EscapeString(in[i : i+j]))
|
|
i += j
|
|
continue
|
|
}
|
|
end := strings.IndexByte(in[i:], '>')
|
|
if end < 0 {
|
|
out.WriteString(html.EscapeString(in[i:]))
|
|
break
|
|
}
|
|
i += end + 1
|
|
}
|
|
return out.String()
|
|
}
|