Skip to main content

mdhtml/
lib.rs

1use html5ever::{tendril::SliceExt, tokenizer::{BufferQueue, TagKind, Token, TokenSink, TokenSinkResult, Tokenizer}};
2
3const OPTIONS: comrak::Options<'static> = comrak::Options {
4	extension: comrak::ExtensionOptions {
5		strikethrough: true,
6		tagfilter: true,
7		table: true,
8		autolink: true,
9		tasklist: false,
10		superscript: true,
11		header_ids: None,
12		footnotes: false,
13		description_lists: false,
14		front_matter_delimiter: None,
15		multiline_block_quotes: true,
16		math_dollars: true,
17		math_code: true,
18		wikilinks_title_after_pipe: false,
19		wikilinks_title_before_pipe: false,
20		underline: true,
21		subscript: true,
22		spoiler: true,
23		greentext: true,
24		alerts: true,
25		// TODO use these two for cloaking?
26		image_url_rewriter: None,
27		link_url_rewriter: None,
28	},
29
30	parse: comrak::ParseOptions {
31		smart: false,
32		default_info_string: None,
33		relaxed_tasklist_matching: true,
34		relaxed_autolinks: false,
35		broken_link_callback: None,
36	},
37
38	render: comrak::RenderOptions {
39		hardbreaks: true,
40		github_pre_lang: true,
41		full_info_string: false,
42		width: 120,
43		unsafe_: false,
44		escape: true,
45		list_style: comrak::ListStyleType::Dash,
46		sourcepos: false,
47		escaped_char_spans: true,
48		experimental_minimize_commonmark: false,
49		ignore_setext: true,
50		ignore_empty_links: false,
51		gfm_quirks: false,
52		prefer_fenced: true,
53		figure_with_caption: false,
54		tasklist_classes: false,
55		ol_width: 3,
56	},
57};
58
59pub type Cloaker = Box<dyn Fn(&str) -> String>;
60
61#[derive(Default)]
62pub struct Sanitizer {
63	pub cloaker: Option<Cloaker>,
64	pub buffer: String,
65}
66
67pub fn safe_html(text: &str) -> String {
68	Sanitizer::default().html(text)
69}
70
71pub fn safe_markdown(text: &str) -> String {
72	Sanitizer::default().markdown(text)
73}
74
75impl Sanitizer {
76	pub fn new(cloak: Cloaker) -> Self {
77		Self {
78			buffer: String::default(),
79			cloaker: Some(cloak),
80		}
81	}
82
83	pub fn markdown(self, text: &str) -> String {
84		self.html(&comrak::markdown_to_html(text, &OPTIONS))
85	}
86	
87	pub fn html(self, text: &str) -> String {
88		let mut input = BufferQueue::default();
89		input.push_back(text.to_tendril().try_reinterpret().unwrap());
90	
91		let mut tok = Tokenizer::new(self, Default::default());
92		let _ = tok.feed(&mut input);
93	
94		if !input.is_empty() {
95			tracing::warn!("buffer input not empty after processing html");
96		}
97		tok.end();
98	
99		tok.sink.buffer
100	}
101}
102
103impl TokenSink for Sanitizer {
104	type Handle = ();
105
106	/// Each processed token will be handled by this method
107	fn process_token(&mut self, token: Token, _line_number: u64) -> TokenSinkResult<()> {
108		match token {
109			Token::TagToken(tag) => {
110				if !matches!(
111					tag.name.as_ref(),
112					"h1" | "h2" | "h3"               // allow titles, up to 3 depth
113					| "sup" | "sub"                  // allow superscript/subscript
114					| "hr" | "br"                    // allow horizontal rules and linebreaks
115					| "p" | "span"                   // allow placing paragraphs and spans
116					| "b" | "i" | "s"                // allow simple formatting: bold, italic and strikethrough, but not underlined as it can look like a link!
117					| "strong" | "em" | "del"        // alternative ways to do bold, italig and strikethrough
118					| "blockquote" | "pre" | "code"  // allow code blocks
119					| "ul" | "ol" | "li"             // allow lists
120					| "img" | "a"                    // allow images and links, but will get sanitized later
121				) {
122					return TokenSinkResult::Continue; // skip this tag
123				}
124
125				self.buffer.push('<');
126
127				if !tag.self_closing && matches!(tag.kind, TagKind::EndTag) {
128					self.buffer.push('/');
129				}
130
131				self.buffer.push_str(tag.name.as_ref());
132
133				if !matches!(tag.kind, TagKind::EndTag) {
134					match tag.name.as_ref() {
135						"img" => for attr in tag.attrs {
136							match attr.name.local.as_ref() {
137								"src" => {
138									let src = if let Some(ref cloak) = self.cloaker {
139										cloak(attr.value.as_ref())
140									} else {
141										attr.value.to_string()
142									};
143									self.buffer.push_str(&format!(" src=\"{src}\""))
144								},
145								"title" => self.buffer.push_str(&format!(" title=\"{}\"", attr.value.as_ref())),
146								"alt" => self.buffer.push_str(&format!(" alt=\"{}\"", attr.value.as_ref())),
147								_ => {},
148							}
149						},
150						"a" => {
151							let any_attr = !tag.attrs.is_empty();
152							for attr in tag.attrs {
153								match attr.name.local.as_ref() {
154									"href" => self.buffer.push_str(&format!(" href=\"{}\"", attr.value.as_ref())),
155									"title" => self.buffer.push_str(&format!(" title=\"{}\"", attr.value.as_ref())),
156									"class" => if attr.value.as_ref() == "u-url mention" {
157										self.buffer.push_str(" class=\"u-url mention\"")
158									},
159									_ => {},
160								}
161							}
162							if any_attr {
163								self.buffer.push_str(" rel=\"nofollow noreferrer\" target=\"_blank\"");
164							}
165						},
166						_ => {},
167					}
168				}
169
170				if tag.self_closing {
171					self.buffer.push('/');
172				}
173
174				self.buffer.push('>');
175			},
176			Token::CharacterTokens(txt) => self.buffer.push_str(txt.as_ref()),
177			Token::CommentToken(_) => {},
178			Token::DoctypeToken(_) => {},
179			Token::NullCharacterToken => {},
180			Token::EOFToken => {},
181			Token::ParseError(e) => tracing::error!("error parsing html: {e}"),
182		}
183		TokenSinkResult::Continue
184	}
185}