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 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 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" | "sup" | "sub" | "hr" | "br" | "p" | "span" | "b" | "i" | "s" | "strong" | "em" | "del" | "blockquote" | "pre" | "code" | "ul" | "ol" | "li" | "img" | "a" ) {
122 return TokenSinkResult::Continue; }
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}