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") 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("
  1. ") b.WriteString(mdInlineToHTML(item)) b.WriteString("
  2. \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(`

` + "\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 `` + sub[1] + `` }) 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)]*>`).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, "![]($1)") 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)]*>`).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)) }