samjeston / openhub Public
Sam Jeston Working vertical 9aa3c45 · History
179 lines· 5.5 KB ·Rust
Raw
1//! GitHub-flavoured Markdown rendering with HTML sanitisation.
2
3use std::borrow::Cow;
4use std::collections::HashMap;
5use std::fmt;
6
7use comrak::adapters::SyntaxHighlighterAdapter;
8use comrak::options::Plugins;
9use comrak::{Options, markdown_to_html_with_plugins};
10
11use crate::highlight;
12
13/// Where relative links and images inside a rendered document should point.
14#[derive(Debug, Clone)]
15pub struct LinkBase {
16 /// e.g. `/alice/project/blob/main`
17 pub blob_root: String,
18 /// e.g. `/alice/project/raw/main`
19 pub raw_root: String,
20 /// Directory of the document inside the repository, e.g. `docs`.
21 pub dir: String,
22}
23
24struct Highlighter;
25
26impl SyntaxHighlighterAdapter for Highlighter {
27 fn write_highlighted(
28 &self,
29 output: &mut dyn fmt::Write,
30 lang: Option<&str>,
31 code: &str,
32 ) -> fmt::Result {
33 let html = lang
34 .and_then(|l| highlight::highlight_block(l, code))
35 .unwrap_or_else(|| highlight::escape(code));
36 output.write_str(&html)
37 }
38
39 fn write_pre_tag(
40 &self,
41 output: &mut dyn fmt::Write,
42 _attributes: HashMap<&'static str, Cow<'_, str>>,
43 ) -> fmt::Result {
44 output.write_str("<pre>")
45 }
46
47 fn write_code_tag(
48 &self,
49 output: &mut dyn fmt::Write,
50 _attributes: HashMap<&'static str, Cow<'_, str>>,
51 ) -> fmt::Result {
52 output.write_str("<code>")
53 }
54}
55
56const TOKEN_CLASSES: &[&str] = &[
57 "tok-c", "tok-sr", "tok-e", "tok-s", "tok-n", "tok-kc", "tok-o", "tok-k", "tok-f", "tok-tag",
58 "tok-attr", "tok-h", "tok-t", "tok-m", "tok-v", "tok-b", "tok-i", "tok-ins", "tok-del",
59 "tok-link",
60];
61
62/// Renders untrusted Markdown to sanitised HTML.
63pub fn render(source: &str, links: Option<&LinkBase>) -> String {
64 let mut options = Options::default();
65 options.extension.strikethrough = true;
66 options.extension.table = true;
67 options.extension.autolink = true;
68 options.extension.tasklist = true;
69 options.extension.footnotes = true;
70 options.render.r#unsafe = true; // raw HTML is allowed through, then sanitised below
71
72 let highlighter = Highlighter;
73 let mut plugins = Plugins::default();
74 plugins.render.codefence_syntax_highlighter = Some(&highlighter);
75 let html = markdown_to_html_with_plugins(source, &options, &plugins);
76
77 let mut builder = ammonia::Builder::default();
78 builder
79 .add_tags(["input"])
80 .add_tag_attributes("input", ["type", "checked", "disabled"])
81 .add_tag_attributes("img", ["width", "height", "align"])
82 .add_generic_attributes(["align"])
83 .add_allowed_classes("span", TOKEN_CLASSES)
84 .link_rel(Some("noopener noreferrer nofollow"))
85 .url_relative(ammonia::UrlRelative::PassThrough);
86 if let Some(base) = links.cloned() {
87 builder.attribute_filter(
88 move |element, attribute, value| match (element, attribute) {
89 ("a", "href") => Some(rewrite_relative(value, &base.blob_root, &base.dir)),
90 ("img", "src") => Some(rewrite_relative(value, &base.raw_root, &base.dir)),
91 _ => Some(Cow::Borrowed(value)),
92 },
93 );
94 }
95 builder.clean(&html).to_string()
96}
97
98fn rewrite_relative<'u>(value: &'u str, root: &str, dir: &str) -> Cow<'u, str> {
99 let is_absolute = value.starts_with('#')
100 || value.starts_with("//")
101 || value
102 .split_once(':')
103 .is_some_and(|(scheme, _)| !scheme.contains('/') && !scheme.is_empty());
104 if is_absolute || value.is_empty() {
105 return Cow::Borrowed(value);
106 }
107 let (path, suffix) = match value.find(['?', '#']) {
108 Some(i) => value.split_at(i),
109 None => (value, ""),
110 };
111 let joined = if let Some(rooted) = path.strip_prefix('/') {
112 rooted.to_string()
113 } else if dir.is_empty() {
114 path.to_string()
115 } else {
116 format!("{dir}/{path}")
117 };
118 let mut parts: Vec<&str> = Vec::new();
119 for seg in joined.split('/') {
120 match seg {
121 "" | "." => {}
122 ".." => {
123 parts.pop();
124 }
125 s => parts.push(s),
126 }
127 }
128 Cow::Owned(format!("{root}/{}{suffix}", parts.join("/")))
129}
130
131#[cfg(test)]
132mod tests {
133 use super::*;
134
135 #[test]
136 fn strips_scripts() {
137 let out = render(
138 "hello <script>alert(1)</script> <img src=x onerror=alert(1)>",
139 None,
140 );
141 assert!(!out.contains("<script"));
142 assert!(!out.contains("onerror"));
143 }
144
145 #[test]
146 fn rewrites_relative_links() {
147 let base = LinkBase {
148 blob_root: "/a/r/blob/main".into(),
149 raw_root: "/a/r/raw/main".into(),
150 dir: "docs".into(),
151 };
152 let out = render(
153 "[x](../src/lib.rs#L3) ![i](img/logo.png) [y](https://e.com) [z](#top)",
154 Some(&base),
155 );
156 assert!(
157 out.contains(r#"href="/a/r/blob/main/src/lib.rs#L3""#),
158 "{out}"
159 );
160 assert!(
161 out.contains(r#"src="/a/r/raw/main/docs/img/logo.png""#),
162 "{out}"
163 );
164 assert!(out.contains(r#"href="https://e.com""#), "{out}");
165 assert!(out.contains(r##"href="#top""##), "{out}");
166 }
167
168 #[test]
169 fn highlights_fenced_code() {
170 let out = render("```rust\nfn main() {}\n```", None);
171 assert!(out.contains(r#"<span class="tok-k">fn</span>"#), "{out}");
172 }
173
174 #[test]
175 fn renders_task_lists() {
176 let out = render("- [x] done\n- [ ] todo", None);
177 assert!(out.contains("checkbox"), "{out}");
178 }
179}