Sam Jeston
History
179 lines·
5.5 KB
·Rust
| 1 | //! GitHub-flavoured Markdown rendering with HTML sanitisation. |
| 2 | |
| 3 | use std::borrow::Cow; |
| 4 | use std::collections::HashMap; |
| 5 | use std::fmt; |
| 6 | |
| 7 | use comrak::adapters::SyntaxHighlighterAdapter; |
| 8 | use comrak::options::Plugins; |
| 9 | use comrak::{Options, markdown_to_html_with_plugins}; |
| 10 | |
| 11 | use crate::highlight; |
| 12 | |
| 13 | /// Where relative links and images inside a rendered document should point. |
| 14 | #[derive(Debug, Clone)] |
| 15 | pub 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 | |
| 24 | struct Highlighter; |
| 25 | |
| 26 | impl 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 | |
| 56 | const 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. |
| 63 | pub 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 | |
| 98 | fn 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)] |
| 132 | mod 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)  [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 | } |