yggdrasil/src/api/comments/markdown.rs
xfy 65a7b1226f style: 全项目格式规范化(cargo fmt)+ Docker daemon 断连容错
- 全项目 .rs 统一 cargo fmt 排版(换行/缩进/参数分行/导入排序)
- infra/docker: DOCKER_CLIENT 连接失败不 panic,降级返回 None
  (博客不依赖 Docker,panic=abort 下会导致整个进程崩溃)
- infra/mod: 去除多余空行
- database/mod: 模块声明按字母序重新排序
2026-07-15 17:22:06 +08:00

237 lines
7.8 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 评论 Markdown 渲染与 HTML 清洗。
//!
//! 对评论内容做轻量 Markdown 解析,限制标签白名单并转义危险字符。
//! 仅在 `feature = "server"` 启用的服务端构建中实际执行渲染。
#![allow(clippy::unused_unit, deprecated)]
/// 清洗评论 HTML移除危险标签与属性。
///
/// 实际委托给 `crate::api::sanitizer::clean_comment_html` 实现。
#[cfg(feature = "server")]
pub fn clean_comment_html(input: &str) -> String {
crate::api::sanitizer::clean_comment_html(input)
}
/// 将评论 Markdown 渲染为安全的 HTML。
///
/// 支持表格与删除线;标题统一渲染为 `<strong>` 以避免层级混乱;
/// 代码块若指定语言则调用服务端高亮,否则转义 HTML
/// 最终调用 `clean_comment_html` 过滤危险内容。
#[cfg(feature = "server")]
pub fn render_comment_markdown(md: &str) -> String {
use pulldown_cmark::{CodeBlockKind, Event, Options, Tag, TagEnd};
let opts = Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH;
let parser = pulldown_cmark::Parser::new_ext(md, opts);
let mut events: Vec<Event> = Vec::new();
let mut in_codeblock = false;
let mut code_lang: Option<String> = None;
let mut code_buffer = String::new();
// 逐事件处理 Markdown AST转换标题并收集代码块内容。
for event in parser {
match event {
Event::Start(Tag::Heading { .. }) => {
// 评论中不保留标题层级,统一加粗。
events.push(Event::Start(Tag::Strong));
}
Event::End(TagEnd::Heading(_)) => {
events.push(Event::End(TagEnd::Strong));
}
Event::Start(Tag::CodeBlock(kind)) => {
in_codeblock = true;
code_lang = match kind {
CodeBlockKind::Fenced(lang) if !lang.is_empty() => Some(lang.to_string()),
_ => None,
};
code_buffer.clear();
}
Event::Text(text) if in_codeblock => {
code_buffer.push_str(&text);
}
Event::End(TagEnd::CodeBlock) => {
// 根据是否有语言信息决定高亮或转义。
let html = if let Some(ref lang) = code_lang {
let highlighted =
crate::highlight::server::highlight_code(&code_buffer, Some(lang));
format!("<pre><code>{}</code></pre>", highlighted)
} else {
format!(
"<pre><code>{}</code></pre>",
crate::utils::html::escape_html(&code_buffer)
)
};
events.push(Event::Html(html.into()));
in_codeblock = false;
}
_ if !in_codeblock => {
events.push(event);
}
_ => {}
}
}
let mut html = String::new();
pulldown_cmark::html::push_html(&mut html, events.into_iter());
clean_comment_html(&html)
}
#[cfg(all(test, feature = "server"))]
mod tests {
use super::*;
#[test]
fn render_comment_heading_converted_to_strong() {
let result = render_comment_markdown("## Hello World");
assert!(result.contains("<strong>Hello World</strong>"));
assert!(!result.contains("<h2>"));
}
#[test]
fn render_comment_heading_all_levels() {
for md in &[
"# H1",
"## H2",
"### H3",
"#### H4",
"##### H5",
"###### H6",
] {
let result = render_comment_markdown(md);
assert!(
result.contains("<strong>"),
"heading not converted for: {}",
md
);
}
}
#[test]
fn render_comment_paragraph() {
let result = render_comment_markdown("Hello **world**");
assert!(result.contains("<strong>world</strong>"));
}
#[test]
fn render_comment_code_block_with_language() {
let result = render_comment_markdown("```rust\nfn main() {}\n```");
assert!(result.contains("<pre><code>"));
assert!(result.contains("main"));
}
#[test]
fn render_comment_code_block_without_language() {
let result = render_comment_markdown("```\nplain text\n```");
assert!(result.contains("<pre><code>"));
assert!(result.contains("plain text"));
}
#[test]
fn render_comment_code_block_without_language_escapes_html() {
let result = render_comment_markdown("```\n<div>alert('xss')</div>\n```");
assert!(result.contains("&lt;div&gt;"));
assert!(!result.contains("<div>"));
}
#[test]
fn render_comment_strips_script() {
let result = render_comment_markdown("<script>alert('xss')</script>");
assert!(!result.contains("script"));
}
#[test]
fn render_comment_no_img_tags() {
let result = render_comment_markdown("![alt](https://example.com/img.png)");
assert!(!result.contains("<img"));
}
#[test]
fn render_comment_link_has_nofollow() {
let result = render_comment_markdown("[link](https://example.com)");
assert!(result.contains("nofollow"));
assert!(result.contains("noopener"));
}
#[test]
fn render_comment_link_javascript_removed() {
let result = render_comment_markdown("[click](javascript:alert(1))");
assert!(result.contains("click"));
assert!(!result.contains("javascript:"));
}
#[test]
fn render_comment_onerror_attribute_removed() {
let result = render_comment_markdown("<div onerror=\"alert(1)\">text</div>");
assert!(result.contains("text"));
assert!(!result.contains("onerror"));
}
#[test]
fn render_comment_link_data_uri_removed() {
let result = render_comment_markdown("[click](data:text/html,<script>alert(1)</script>)");
assert!(result.contains("click"));
assert!(!result.contains("data:"));
}
#[test]
fn render_comment_code_block_escapes_html_entities() {
let result = render_comment_markdown("```\n&amp;\n```");
assert!(result.contains("&amp;amp;"));
}
#[test]
fn render_comment_no_id_attribute() {
let result = render_comment_markdown("<div id=\"test\">text</div>");
assert!(!result.contains("id="));
}
#[test]
fn render_comment_table() {
let result = render_comment_markdown("| a | b |\n|---|---|\n| 1 | 2 |");
assert!(result.contains("<table>"));
}
#[test]
fn render_comment_strikethrough() {
let result = render_comment_markdown("~~deleted~~");
assert!(result.contains("<del>deleted</del>"));
}
#[test]
fn render_comment_inline_code() {
let result = render_comment_markdown("Use `println!` to print");
assert!(result.contains("<code>println!</code>"));
}
#[test]
fn clean_comment_html_removes_details_summary() {
let result =
clean_comment_html("<details><summary>Click</summary><p>Content</p></details>");
assert!(!result.contains("details"));
assert!(!result.contains("summary"));
}
#[test]
fn clean_comment_html_removes_data_uri() {
let result =
clean_comment_html("<a href=\"data:text/html,<script>alert(1)</script>\">click</a>");
assert!(!result.contains("data:"));
}
#[test]
fn render_comment_empty() {
let result = render_comment_markdown("");
assert!(result.is_empty());
}
#[test]
fn render_comment_heading_with_inline_code() {
let result = render_comment_markdown("## Using `foo()`");
assert!(result.contains("<strong>"));
assert!(result.contains("<code>foo()</code>"));
assert!(!result.contains("<h2>"));
}
}