OXYGENHUB
# 554ccacfc123 oxygenhub / crates / hub / src / render / markdown.rs
2.6 KiB · Rust · last changed Milestone 0.1: browse 1 month ago source blame history raw
d23464c0 Andy McCall ↶ 1
//! Markdown to sanitised HTML: pulldown-cmark, then ammonia with an
2
//! allow-list (hub spec §15.2). Relative links and images are rebased on
3
//! `base`, so a README's `docs/x.md` opens in the hub.
4
5
use pulldown_cmark::{html, Event, Options, Parser, Tag};
6
7
/// `base_file` and `base_raw` are URL prefixes (ending in `/`) for links to
8
/// files and for images.
9
pub fn render(text: &str, base_file: &str, base_raw: &str) -> String {
10
    let mut opts = Options::empty();
11
    opts.insert(Options::ENABLE_TABLES);
12
    opts.insert(Options::ENABLE_STRIKETHROUGH);
13
    opts.insert(Options::ENABLE_TASKLISTS);
14
    opts.insert(Options::ENABLE_FOOTNOTES);
15
    opts.insert(Options::ENABLE_HEADING_ATTRIBUTES);
16
    let parser = Parser::new_ext(text, opts).map(|ev| match ev {
17
        Event::Start(Tag::Link { link_type, dest_url, title, id }) => {
18
            Event::Start(Tag::Link { link_type, dest_url: rebase(&dest_url, base_file).into(), title, id })
19
        }
20
        Event::Start(Tag::Image { link_type, dest_url, title, id }) => {
21
            Event::Start(Tag::Image { link_type, dest_url: rebase(&dest_url, base_raw).into(), title, id })
22
        }
23
        other => other,
24
    });
25
    let mut out = String::with_capacity(text.len() * 2);
26
    html::push_html(&mut out, parser);
27
    let mut builder = ammonia::Builder::default();
28
    builder.add_generic_attributes(["id", "class"]);
29
    builder.add_tag_attributes("input", ["type", "checked", "disabled"]);
30
    builder.add_tags(["input"]);
31
    builder.link_rel(Some("noopener noreferrer"));
32
    builder.clean(&out).to_string()
33
}
34
35
/// Leave absolute URLs, fragments and mail links alone; rebase the rest.
36
fn rebase(url: &str, base: &str) -> String {
37
    if url.starts_with('#') || url.contains("://") || url.starts_with("mailto:") || url.starts_with('/') || url.starts_with("data:") {
38
        return url.to_string();
39
    }
40
    let url = url.strip_prefix("./").unwrap_or(url);
41
    format!("{base}{url}")
42
}
43
44
#[cfg(test)]
45
mod tests {
46
    use super::*;
47
48
    #[test]
49
    fn renders_and_sanitises() {
50
        let html = render(
51
            "# Hi\n\nSee [docs](docs/x.md) and ![i](img.png) <script>alert(1)</script>\n\n- [ ] task\n",
52
            "/a/b/file/main/",
53
            "/a/b/file/main/",
54
        );
55
        assert!(html.contains("<h1>Hi</h1>"));
56
        assert!(html.contains("href=\"/a/b/file/main/docs/x.md\""));
57
        assert!(html.contains("src=\"/a/b/file/main/img.png\""));
58
        assert!(!html.contains("<script"));
59
        assert!(html.contains("type=\"checkbox\""));
60
        assert!(render("[x](https://e.com) [y](#frag)", "/p/", "/p/").contains("href=\"https://e.com\""));
61
    }
62
}