OXYGENHUB
◆ v0.9.21 oxygenhub / crates / hub / src / render / markdown.rs
6.0 KiB · Rust · last changed Images that point at one of this hub's file pages render as the raw file (closes #346); a proposal's diffs over the API, GET …/proposals/N/diff (refs oxygenhub-android#4) 8 days 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
f5c44337 Andy McCall ↶ 7
/// Rendered Markdown by (text, link bases): 32 MiB.
8
fn cache() -> &'static super::cache::Lru<String> {
9
    static C: std::sync::OnceLock<super::cache::Lru<String>> = std::sync::OnceLock::new();
10
    C.get_or_init(|| super::cache::Lru::new(32 << 20))
11
}
12
13
/// (hits, misses, bytes) of the rendered Markdown cache.
14
pub fn cache_stats() -> (u64, u64, usize) {
15
    cache().stats()
16
}
17
d23464c0 Andy McCall ↶ 18
/// `base_file` and `base_raw` are URL prefixes (ending in `/`) for links to
f5c44337 Andy McCall ↶ 19
/// files and for images. Cached by the text and both bases.
d23464c0 Andy McCall ↶ 20
pub fn render(text: &str, base_file: &str, base_raw: &str) -> String {
f5c44337 Andy McCall ↶ 21
    let key = super::cache::key(&[b"md1", text.as_bytes(), base_file.as_bytes(), base_raw.as_bytes()]);
22
    cache().get_or(key, || render_uncached(text, base_file, base_raw)).as_ref().clone()
23
}
24
25
fn render_uncached(text: &str, base_file: &str, base_raw: &str) -> String {
d23464c0 Andy McCall ↶ 26
    let mut opts = Options::empty();
27
    opts.insert(Options::ENABLE_TABLES);
28
    opts.insert(Options::ENABLE_STRIKETHROUGH);
29
    opts.insert(Options::ENABLE_TASKLISTS);
30
    opts.insert(Options::ENABLE_FOOTNOTES);
31
    opts.insert(Options::ENABLE_HEADING_ATTRIBUTES);
32
    let parser = Parser::new_ext(text, opts).map(|ev| match ev {
33
        Event::Start(Tag::Link { link_type, dest_url, title, id }) => {
34
            Event::Start(Tag::Link { link_type, dest_url: rebase(&dest_url, base_file).into(), title, id })
35
        }
36
        Event::Start(Tag::Image { link_type, dest_url, title, id }) => {
70c3d029 Andy McCall ↶ 37
            let src = raw_image(&dest_url, &origin()).unwrap_or_else(|| rebase(&dest_url, base_raw));
38
            Event::Start(Tag::Image { link_type, dest_url: src.into(), title, id })
d23464c0 Andy McCall ↶ 39
        }
40
        other => other,
41
    });
42
    let mut out = String::with_capacity(text.len() * 2);
43
    html::push_html(&mut out, parser);
44
    let mut builder = ammonia::Builder::default();
45
    builder.add_generic_attributes(["id", "class"]);
46
    builder.add_tag_attributes("input", ["type", "checked", "disabled"]);
47
    builder.add_tags(["input"]);
48
    builder.link_rel(Some("noopener noreferrer"));
49
    builder.clean(&out).to_string()
50
}
51
70c3d029 Andy McCall ↶ 52
/// This hub's own origin (`https://oxygenhub.io`), so an image written as
53
/// a full address of one of its file pages is still recognised.
54
static ORIGIN: std::sync::RwLock<String> = std::sync::RwLock::new(String::new());
55
56
/// Set when the hub is built, from its base URL.
57
pub fn set_origin(base_url: &str) {
58
    *ORIGIN.write().unwrap() = base_url.trim_end_matches('/').to_string();
59
}
60
61
fn origin() -> String {
62
    ORIGIN.read().unwrap().clone()
63
}
64
65
/// An image that points at one of this hub's file pages
66
/// (`[ORIGIN]/OWNER/NAME/file/REF/PATH`, the address a file's page shows)
67
/// is that file's raw bytes (`…/raw/REF/PATH`): the page is HTML, which an
68
/// `<img>` cannot show, as GitHub turns `blob/` images into raw ones.
69
fn raw_image(url: &str, origin: &str) -> Option<String> {
70
    let (host, path) = if url.starts_with('/') && !url.starts_with("//") {
71
        ("", url)
72
    } else if !origin.is_empty() && url.strip_prefix(origin).is_some_and(|p| p.starts_with('/')) {
73
        (origin, &url[origin.len()..])
74
    } else {
75
        return None;
76
    };
77
    let mut parts = path[1..].splitn(4, '/');
78
    let (owner, name, kind, rest) = (parts.next()?, parts.next()?, parts.next()?, parts.next()?);
79
    (kind == "file" && !owner.is_empty() && !name.is_empty() && !rest.is_empty()).then(|| format!("{host}/{owner}/{name}/raw/{rest}"))
80
}
81
d23464c0 Andy McCall ↶ 82
/// Leave absolute URLs, fragments and mail links alone; rebase the rest.
83
fn rebase(url: &str, base: &str) -> String {
84
    if url.starts_with('#') || url.contains("://") || url.starts_with("mailto:") || url.starts_with('/') || url.starts_with("data:") {
85
        return url.to_string();
86
    }
87
    let url = url.strip_prefix("./").unwrap_or(url);
88
    format!("{base}{url}")
89
}
90
91
#[cfg(test)]
92
mod tests {
93
    use super::*;
94
95
    #[test]
96
    fn renders_and_sanitises() {
97
        let html = render(
98
            "# Hi\n\nSee [docs](docs/x.md) and ![i](img.png) <script>alert(1)</script>\n\n- [ ] task\n",
99
            "/a/b/file/main/",
100
            "/a/b/file/main/",
101
        );
102
        assert!(html.contains("<h1>Hi</h1>"));
103
        assert!(html.contains("href=\"/a/b/file/main/docs/x.md\""));
104
        assert!(html.contains("src=\"/a/b/file/main/img.png\""));
105
        assert!(!html.contains("<script"));
106
        assert!(html.contains("type=\"checkbox\""));
107
        assert!(render("[x](https://e.com) [y](#frag)", "/p/", "/p/").contains("href=\"https://e.com\""));
70c3d029 Andy McCall ↶ 108
    }
109
110
    /// An image of one of this hub's file pages is its raw file: by full
111
    /// address or by path; another site's, a link, or a raw address stay.
112
    #[test]
113
    fn an_image_of_a_file_page_is_its_raw_file() {
114
        let o = "https://hub.example";
115
        assert_eq!(
116
            raw_image("https://hub.example/doomsdayone/tetriblox/file/main/docs/images/screens/menu.png", o).as_deref(),
117
            Some("https://hub.example/doomsdayone/tetriblox/raw/main/docs/images/screens/menu.png")
118
        );
119
        assert_eq!(raw_image("/a/b/file/v1.0/x.png", o).as_deref(), Some("/a/b/raw/v1.0/x.png"));
120
        assert_eq!(raw_image("https://elsewhere.example/a/b/file/main/x.png", o), None);
121
        assert_eq!(raw_image("https://hub.example.evil/a/b/file/main/x.png", o), None);
122
        assert_eq!(raw_image("/a/b/raw/main/x.png", o), None);
123
        assert_eq!(raw_image("//cdn.example/a/b/file/main/x.png", o), None);
124
        assert_eq!(raw_image("img.png", o), None);
125
        // Through render, with the origin set: only the image changes.
126
        set_origin("https://hub.example/");
127
        let html = render("[page](https://hub.example/a/b/file/main/x.png) ![x](https://hub.example/a/b/file/main/x.png)", "/p/", "/p/");
128
        assert!(html.contains("href=\"https://hub.example/a/b/file/main/x.png\""), "{html}");
129
        assert!(html.contains("src=\"https://hub.example/a/b/raw/main/x.png\""), "{html}");
d23464c0 Andy McCall ↶ 130
    }
131
}