Skip to main content

yggdrasil/api/comments/
markdown.rs

1//! 评论 Markdown 渲染与 HTML 清洗。
2//!
3//! 对评论内容做轻量 Markdown 解析,限制标签白名单并转义危险字符。
4//! 仅在 `feature = "server"` 启用的服务端构建中实际执行渲染。
5
6#![allow(clippy::unused_unit, deprecated)]
7
8/// 清洗评论 HTML,移除危险标签与属性。
9///
10/// 实际委托给 `crate::api::sanitizer::clean_comment_html` 实现。
11#[cfg(feature = "server")]
12pub(crate) fn clean_comment_html(input: &str) -> String {
13    crate::api::sanitizer::clean_comment_html(input)
14}
15
16/// 将评论 Markdown 渲染为安全的 HTML。
17///
18/// 支持表格与删除线;标题统一渲染为 `<strong>` 以避免层级混乱;
19/// 代码块若指定语言则调用服务端高亮,否则转义 HTML;
20/// 最终调用 `clean_comment_html` 过滤危险内容。
21#[cfg(feature = "server")]
22pub fn render_comment_markdown(md: &str) -> String {
23    use pulldown_cmark::{CodeBlockKind, Event, Options, Tag, TagEnd};
24
25    let opts = Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH | Options::ENABLE_MATH;
26    let parser = pulldown_cmark::Parser::new_ext(md, opts);
27
28    let mut events: Vec<Event> = Vec::new();
29    let mut in_codeblock = false;
30    let mut code_lang: Option<String> = None;
31    let mut code_buffer = String::new();
32
33    // 逐事件处理 Markdown AST,转换标题并收集代码块内容。
34    for event in parser {
35        match event {
36            Event::InlineMath(tex) => {
37                // 内联公式直接渲染成 HTML 注入事件流(评论不使用块级段落包裹)。
38                let html = crate::api::katex::render_inline(&tex);
39                events.push(Event::Html(html.into()));
40            }
41            Event::DisplayMath(tex) => {
42                // 评论里的块级公式:KaTeX 输出本身已含 .katex-display 居中样式,
43                // 无需额外 <p>(评论渲染较紧凑,避免引入多余段间距)。
44                let html = crate::api::katex::render_display(&tex);
45                events.push(Event::Html(html.into()));
46            }
47            Event::Start(Tag::Heading { .. }) => {
48                // 评论中不保留标题层级,统一加粗。
49                events.push(Event::Start(Tag::Strong));
50            }
51            Event::End(TagEnd::Heading(_)) => {
52                events.push(Event::End(TagEnd::Strong));
53            }
54            Event::Start(Tag::CodeBlock(kind)) => {
55                in_codeblock = true;
56                code_lang = match kind {
57                    CodeBlockKind::Fenced(lang) if !lang.is_empty() => Some(lang.to_string()),
58                    _ => None,
59                };
60                code_buffer.clear();
61            }
62            Event::Text(text) if in_codeblock => {
63                code_buffer.push_str(&text);
64            }
65            Event::End(TagEnd::CodeBlock) => {
66                // 根据是否有语言信息决定高亮或转义。
67                let html = if let Some(ref lang) = code_lang {
68                    let highlighted =
69                        crate::highlight::server::highlight_code(&code_buffer, Some(lang));
70                    format!("<pre><code>{}</code></pre>", highlighted)
71                } else {
72                    format!(
73                        "<pre><code>{}</code></pre>",
74                        crate::utils::html::escape_html(&code_buffer)
75                    )
76                };
77                events.push(Event::Html(html.into()));
78                in_codeblock = false;
79            }
80            _ if !in_codeblock => {
81                events.push(event);
82            }
83            _ => {}
84        }
85    }
86
87    let mut html = String::new();
88    pulldown_cmark::html::push_html(&mut html, events.into_iter());
89    clean_comment_html(&html)
90}
91
92#[cfg(all(test, feature = "server"))]
93mod tests {
94    use super::*;
95
96    #[test]
97    fn render_comment_heading_converted_to_strong() {
98        let result = render_comment_markdown("## Hello World");
99        assert!(result.contains("<strong>Hello World</strong>"));
100        assert!(!result.contains("<h2>"));
101    }
102
103    #[test]
104    fn render_comment_heading_all_levels() {
105        for md in &[
106            "# H1",
107            "## H2",
108            "### H3",
109            "#### H4",
110            "##### H5",
111            "###### H6",
112        ] {
113            let result = render_comment_markdown(md);
114            assert!(
115                result.contains("<strong>"),
116                "heading not converted for: {}",
117                md
118            );
119        }
120    }
121
122    #[test]
123    fn render_comment_paragraph() {
124        let result = render_comment_markdown("Hello **world**");
125        assert!(result.contains("<strong>world</strong>"));
126    }
127
128    #[test]
129    fn render_comment_code_block_with_language() {
130        let result = render_comment_markdown("```rust\nfn main() {}\n```");
131        assert!(result.contains("<pre><code>"));
132        assert!(result.contains("main"));
133    }
134
135    #[test]
136    fn render_comment_code_block_without_language() {
137        let result = render_comment_markdown("```\nplain text\n```");
138        assert!(result.contains("<pre><code>"));
139        assert!(result.contains("plain text"));
140    }
141
142    #[test]
143    fn render_comment_code_block_without_language_escapes_html() {
144        let result = render_comment_markdown("```\n<div>alert('xss')</div>\n```");
145        assert!(result.contains("&lt;div&gt;"));
146        assert!(!result.contains("<div>"));
147    }
148
149    #[test]
150    fn render_comment_strips_script() {
151        let result = render_comment_markdown("<script>alert('xss')</script>");
152        assert!(!result.contains("script"));
153    }
154
155    #[test]
156    fn render_comment_no_img_tags() {
157        let result = render_comment_markdown("![alt](https://example.com/img.png)");
158        assert!(!result.contains("<img"));
159    }
160
161    #[test]
162    fn render_comment_link_has_nofollow() {
163        let result = render_comment_markdown("[link](https://example.com)");
164        assert!(result.contains("nofollow"));
165        assert!(result.contains("noopener"));
166    }
167
168    #[test]
169    fn render_comment_link_javascript_removed() {
170        let result = render_comment_markdown("[click](javascript:alert(1))");
171        assert!(result.contains("click"));
172        assert!(!result.contains("javascript:"));
173    }
174
175    #[test]
176    fn render_comment_onerror_attribute_removed() {
177        let result = render_comment_markdown("<div onerror=\"alert(1)\">text</div>");
178        assert!(result.contains("text"));
179        assert!(!result.contains("onerror"));
180    }
181
182    #[test]
183    fn render_comment_link_data_uri_removed() {
184        let result = render_comment_markdown("[click](data:text/html,<script>alert(1)</script>)");
185        assert!(result.contains("click"));
186        assert!(!result.contains("data:"));
187    }
188
189    #[test]
190    fn render_comment_code_block_escapes_html_entities() {
191        let result = render_comment_markdown("```\n&amp;\n```");
192        assert!(result.contains("&amp;amp;"));
193    }
194
195    #[test]
196    fn render_comment_no_id_attribute() {
197        let result = render_comment_markdown("<div id=\"test\">text</div>");
198        assert!(!result.contains("id="));
199    }
200
201    #[test]
202    fn render_comment_table() {
203        let result = render_comment_markdown("| a | b |\n|---|---|\n| 1 | 2 |");
204        assert!(result.contains("<table>"));
205    }
206
207    #[test]
208    fn render_comment_strikethrough() {
209        let result = render_comment_markdown("~~deleted~~");
210        assert!(result.contains("<del>deleted</del>"));
211    }
212
213    #[test]
214    fn render_comment_inline_code() {
215        let result = render_comment_markdown("Use `println!` to print");
216        assert!(result.contains("<code>println!</code>"));
217    }
218
219    #[test]
220    fn clean_comment_html_removes_details_summary() {
221        let result =
222            clean_comment_html("<details><summary>Click</summary><p>Content</p></details>");
223        assert!(!result.contains("details"));
224        assert!(!result.contains("summary"));
225    }
226
227    #[test]
228    fn clean_comment_html_removes_data_uri() {
229        let result =
230            clean_comment_html("<a href=\"data:text/html,<script>alert(1)</script>\">click</a>");
231        assert!(!result.contains("data:"));
232    }
233
234    #[test]
235    fn render_comment_empty() {
236        let result = render_comment_markdown("");
237        assert!(result.is_empty());
238    }
239
240    #[test]
241    fn render_comment_heading_with_inline_code() {
242        let result = render_comment_markdown("## Using `foo()`");
243        assert!(result.contains("<strong>"));
244        assert!(result.contains("<code>foo()</code>"));
245        assert!(!result.contains("<h2>"));
246    }
247
248    #[test]
249    fn render_comment_inline_math() {
250        // 评论里的 $...$ 内联公式:ENABLE_MATH 解析 → katex 渲染 → sanitizer 放行 span。
251        let result = render_comment_markdown("方程 $a^2 + b^2 = c^2$ 是勾股定理");
252        assert!(
253            result.contains("katex"),
254            "评论内联公式应渲染为 katex span, got: {}",
255            result
256        );
257        assert!(result.contains("方程"));
258        assert!(result.contains("勾股定理"));
259    }
260
261    #[test]
262    fn render_comment_display_math() {
263        // 评论里的 $$...$$ 块级公式。
264        let result = render_comment_markdown("$$\\int_0^1 x\\,dx$$");
265        assert!(
266            result.contains("katex-display"),
267            "评论块级公式应含 katex-display, got: {}",
268            result
269        );
270    }
271
272    #[test]
273    fn render_comment_math_span_style_preserved() {
274        // KaTeX 内联 style(垂直对齐)必须经 clean_comment_html 保留,
275        // 否则公式在评论里排版错位。验证 span 的 style 属性未被剥离。
276        let result = render_comment_markdown("$x^2$");
277        assert!(
278            result.contains("style=\""),
279            "评论 katex span 的 style 应保留, got: {}",
280            result
281        );
282    }
283}