package services
import (
"html"
"regexp"
"strings"
)
var (
mdImageRe = regexp.MustCompile(`!\[([^\]]*)\]\(([^)]+)\)`)
mdLinkRe = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`)
mdBoldRe = regexp.MustCompile(`\*\*([^*]+)\*\*`)
mdStrikeRe = regexp.MustCompile(`~~([^~]+)~~`)
mdItalicRe = regexp.MustCompile(`\*([^*\n]+)\*`)
mdInlineCodeRe = regexp.MustCompile("`([^`\n]+)`")
mdLangRe = regexp.MustCompile(`^[a-zA-Z0-9_+-]+$`)
)
// ComposeBodyToHTML 将发帖/评论表单正文转为可消毒 HTML。
// 若已含块级 HTML / 门控标签则原样交 Sanitize;否则按轻量 Markdown 转换。
func ComposeBodyToHTML(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
if looksLikeRichHTML(raw) {
return raw
}
return mdDocumentToHTML(raw)
}
func looksLikeRichHTML(raw string) bool {
lower := strings.ToLower(raw)
markers := []string{
"
")
b.WriteString(html.EscapeString(strings.Join(code, "\n")))
b.WriteString("\n")
continue
}
// 空行跳过
if trim == "" {
i++
continue
}
// 标题:#–###### → h2–h6(禁止 h1)
if level, title, ok := parseMDHeading(trim); ok {
b.WriteString("")
b.WriteString(mdInlineToHTML(title))
b.WriteString("\n")
i++
continue
}
// 引用
if strings.HasPrefix(trim, "> ") || trim == ">" {
var quote []string
for i < len(lines) {
t := strings.TrimSpace(lines[i])
if t == ">" {
quote = append(quote, "")
i++
continue
}
if strings.HasPrefix(t, "> ") {
quote = append(quote, strings.TrimPrefix(t, "> "))
i++
continue
}
break
}
b.WriteString("
")
b.WriteString(mdInlineToHTML(strings.Join(quote, "
\n")))
b.WriteString("
\n")
continue
}
// 无序列表
if isMDUL(trim) {
b.WriteString("\n")
for i < len(lines) {
t := strings.TrimSpace(lines[i])
if !isMDUL(t) {
break
}
item := strings.TrimSpace(t[1:])
if strings.HasPrefix(t, "- ") || strings.HasPrefix(t, "* ") || strings.HasPrefix(t, "+ ") {
item = strings.TrimSpace(t[2:])
}
b.WriteString("- ")
b.WriteString(mdInlineToHTML(item))
b.WriteString("
\n")
i++
}
b.WriteString("
\n")
continue
}
// 有序列表
if isMDOL(trim) {
b.WriteString("\n")
for i < len(lines) {
t := strings.TrimSpace(lines[i])
if !isMDOL(t) {
break
}
idx := strings.Index(t, ". ")
item := strings.TrimSpace(t[idx+2:])
b.WriteString("- ")
b.WriteString(mdInlineToHTML(item))
b.WriteString("
\n")
i++
}
b.WriteString("
\n")
continue
}
// 普通段落(连续非空、非特殊行)
var para []string
for i < len(lines) {
t := strings.TrimSpace(lines[i])
if t == "" || strings.HasPrefix(t, "```") || isMDUL(t) || isMDOL(t) ||
strings.HasPrefix(t, "> ") || t == ">" {
break
}
if _, _, ok := parseMDHeading(t); ok {
break
}
para = append(para, lines[i])
i++
}
joined := strings.Join(para, "\n")
// 整段已是图片行则单独输出
if m := mdImageRe.FindStringSubmatch(strings.TrimSpace(joined)); len(m) == 3 && strings.TrimSpace(joined) == m[0] && safeComposeURL(m[2]) {
b.WriteString(`![`)
b.WriteString(m[1])
b.WriteString(`](`)
b.WriteString(html.EscapeString(m[2]))
b.WriteString(`)
` + "\n")
continue
}
b.WriteString("")
b.WriteString(mdInlineToHTML(strings.ReplaceAll(joined, "\n", "
\n")))
b.WriteString("
\n")
}
return b.String()
}
func parseMDHeading(trim string) (level int, title string, ok bool) {
n := 0
for n < len(trim) && trim[n] == '#' {
n++
}
if n < 1 || n > 6 {
return 0, "", false
}
if n >= len(trim) || trim[n] != ' ' {
return 0, "", false
}
title = strings.TrimSpace(trim[n+1:])
if title == "" {
return 0, "", false
}
level = n
if level == 1 {
level = 2 // 帖正文禁止 h1
}
return level, title, true
}
func isMDUL(trim string) bool {
return strings.HasPrefix(trim, "- ") || strings.HasPrefix(trim, "* ") || strings.HasPrefix(trim, "+ ")
}
var mdOLRe = regexp.MustCompile(`^\d+\. `)
func isMDOL(trim string) bool {
return mdOLRe.MatchString(trim)
}
func mdInlineToHTML(s string) string {
// 先抽出行内代码,避免其它规则干扰
type ph struct{ key, html string }
var phs []ph
s = mdInlineCodeRe.ReplaceAllStringFunc(s, func(m string) string {
sub := mdInlineCodeRe.FindStringSubmatch(m)
if len(sub) != 2 {
return m
}
key := "\x00CODE" + string(rune('A'+len(phs))) + "\x00"
phs = append(phs, ph{key: key, html: "" + html.EscapeString(sub[1]) + ""})
return key
})
s = html.EscapeString(s)
// 占位符被 escape 了,还原 key
for i := range phs {
escKey := html.EscapeString(phs[i].key)
s = strings.ReplaceAll(s, escKey, phs[i].key)
}
s = mdImageRe.ReplaceAllStringFunc(s, func(m string) string {
sub := mdImageRe.FindStringSubmatch(m)
if len(sub) != 3 || !safeComposeURL(sub[2]) {
return m
}
return `
`
})
s = mdLinkRe.ReplaceAllStringFunc(s, func(m string) string {
sub := mdLinkRe.FindStringSubmatch(m)
if len(sub) != 3 || !safeComposeURL(sub[2]) {
return m
}
return `` + sub[1] + ``
})
s = mdBoldRe.ReplaceAllString(s, `$1`)
s = mdStrikeRe.ReplaceAllString(s, `$1`)
s = mdItalicRe.ReplaceAllString(s, `$1`)
for _, p := range phs {
s = strings.ReplaceAll(s, p.key, p.html)
}
return s
}
func safeComposeURL(u string) bool {
u = strings.TrimSpace(u)
if strings.HasPrefix(u, "/uploads/") || strings.HasPrefix(u, "/media/") {
return true
}
return strings.HasPrefix(u, "https://") || strings.HasPrefix(u, "http://")
}
// HTMLToComposePlain 编辑页回显:尽力还原为 Markdown / 纯文本;含门控时保留 HTML。
func HTMLToComposePlain(htmlBody string) string {
s := strings.TrimSpace(htmlBody)
if s == "" {
return ""
}
lower := strings.ToLower(s)
if strings.Contains(lower, "]*>\s*]*>([\s\S]*?)\s*`).ReplaceAllStringFunc(s, func(m string) string {
sub := regexp.MustCompile(`(?is)]*>\s*]*>([\s\S]*?)\s*
`).FindStringSubmatch(m)
if len(sub) != 3 {
return m
}
lang, body := sub[1], html.UnescapeString(sub[2])
return "```" + lang + "\n" + body + "\n```\n\n"
})
s = regexp.MustCompile(`(?i)]*>([\s\S]*?)`).ReplaceAllStringFunc(s, func(m string) string {
sub := regexp.MustCompile(`(?i)]*>([\s\S]*?)`).FindStringSubmatch(m)
if len(sub) != 3 {
return m
}
level := sub[1][0] - '0'
inner := stripTagsKeepText(sub[2])
return strings.Repeat("#", int(level)) + " " + inner + "\n\n"
})
s = regexp.MustCompile(`(?i)]*>([\s\S]*?)`).ReplaceAllString(s, "- $1\n")
s = regexp.MustCompile(`(?i)?[uo]l[^>]*>`).ReplaceAllString(s, "\n")
s = regexp.MustCompile(`(?i)]*>([\s\S]*?)
`).ReplaceAllStringFunc(s, func(m string) string {
sub := regexp.MustCompile(`(?i)]*>([\s\S]*?)
`).FindStringSubmatch(m)
if len(sub) != 2 {
return m
}
inner := stripTagsKeepText(sub[1])
lines := strings.Split(inner, "\n")
for i := range lines {
lines[i] = "> " + strings.TrimSpace(lines[i])
}
return strings.Join(lines, "\n") + "\n\n"
})
s = regexp.MustCompile(`(?i)]*>([\s\S]*?)`).ReplaceAllString(s, "**$1**")
s = regexp.MustCompile(`(?i)]*>([\s\S]*?)`).ReplaceAllString(s, "**$1**")
s = regexp.MustCompile(`(?i)]*>([\s\S]*?)`).ReplaceAllString(s, "*$1*")
s = regexp.MustCompile(`(?i)]*>([\s\S]*?)`).ReplaceAllString(s, "*$1*")
s = regexp.MustCompile(`(?i)]*>([\s\S]*?)`).ReplaceAllString(s, "~~$1~~")
s = regexp.MustCompile(`(?i)]*>([\s\S]*?)`).ReplaceAllString(s, "~~$1~~")
s = regexp.MustCompile(`(?i)]*>([\s\S]*?)`).ReplaceAllString(s, "`$1`")
s = regexp.MustCompile(`(?i)
]+src="([^"]+)"[^>]*>`).ReplaceAllString(s, "")
s = regexp.MustCompile(`(?i)]+href="([^"]+)"[^>]*>([\s\S]*?)`).ReplaceAllString(s, "[$2]($1)")
s = regexp.MustCompile(`(?i)\s*`).ReplaceAllString(s, "\n\n")
s = regexp.MustCompile(`(?i)
`).ReplaceAllString(s, "\n")
s = regexp.MustCompile(`(?i)?p[^>]*>`).ReplaceAllString(s, "")
s = regexp.MustCompile(`(?i)<[^>]+>`).ReplaceAllString(s, "")
return strings.TrimSpace(html.UnescapeString(s))
}
func stripTagsKeepText(s string) string {
s = regexp.MustCompile(`(?i)
`).ReplaceAllString(s, "\n")
s = regexp.MustCompile(`(?i)<[^>]+>`).ReplaceAllString(s, "")
return strings.TrimSpace(html.UnescapeString(s))
}