Skip to main content

max / makenotwork

5.5 KB · 153 lines History Blame Raw
1 //! Markdown rendering + Fan+ image handling for route handlers.
2
3 use axum::{
4 http::StatusCode,
5 response::{IntoResponse, Response},
6 };
7
8 /// Render markdown to HTML, stripping raw HTML events to prevent XSS.
9 ///
10 /// Strict preset: no images, no raw HTML, dangerous-scheme filtering. Use this
11 /// for content from non-Fan+ users. Fan+ subscribers get image embeds via
12 /// [`render_markdown_plus`].
13 pub(crate) fn render_markdown(input: &str) -> String {
14 docengine::render_strict(input)
15 }
16
17 /// Render markdown to HTML with image embeds permitted. Otherwise identical to
18 /// the strict preset, raw HTML is still stripped, dangerous schemes filtered,
19 /// links get `nofollow`. Use for Fan+ subscriber content.
20 pub(crate) fn render_markdown_plus(input: &str) -> String {
21 let html = docengine::Renderer::strict()
22 .with_strip_images(false)
23 .render(input);
24 proxy_external_images(&html)
25 }
26
27 /// Rewrite external `<img src="http(s)://…">` in rendered Fan+ HTML to load
28 /// through the same-origin image proxy (`/img-proxy?u=…`), so the page CSP can
29 /// stay `img-src 'self'` while still showing embedded external images. Uploaded
30 /// images are served from same-origin `/uploads/…` (no scheme), so they don't
31 /// match and aren't proxied. docengine has already attribute-escaped the URL, so
32 /// `&amp;` in a query string is unescaped back to `&` before percent-encoding.
33 fn proxy_external_images(html: &str) -> String {
34 static IMG_SRC_RE: std::sync::LazyLock<regex_lite::Regex> = std::sync::LazyLock::new(|| {
35 regex_lite::Regex::new(r#"(<img\b[^>]*?\bsrc=")(https?://[^"]+)(")"#).unwrap()
36 });
37 IMG_SRC_RE
38 .replace_all(html, |caps: &regex_lite::Captures| {
39 let url = caps[2].replace("&amp;", "&");
40 format!(
41 "{}/img-proxy?u={}{}",
42 &caps[1],
43 percent_encode_query(&url),
44 &caps[3]
45 )
46 })
47 .into_owned()
48 }
49
50 /// RFC 3986 percent-encode a string for use as a URL query value. Keeps the
51 /// unreserved set (`A-Za-z0-9-_.~`) and escapes everything else, so the proxied
52 /// URL round-trips intact through axum's query decoder.
53 fn percent_encode_query(s: &str) -> String {
54 use std::fmt::Write as _;
55 let mut out = String::with_capacity(s.len());
56 for &b in s.as_bytes() {
57 match b {
58 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
59 out.push(b as char);
60 }
61 _ => {
62 let _ = write!(out, "%{b:02X}");
63 }
64 }
65 }
66 out
67 }
68
69 /// Render markdown to HTML, resolving `@mentions` to profile links for valid community members.
70 pub(crate) fn render_markdown_with_mentions(
71 input: &str,
72 community_slug: &str,
73 valid_usernames: &std::collections::HashSet<String>,
74 allow_images: bool,
75 ) -> String {
76 let template = format!("/p/{community_slug}/u/{{username}}");
77 let resolved = docengine::resolve_mentions(input, valid_usernames, &template);
78 if allow_images {
79 render_markdown_plus(&resolved)
80 } else {
81 docengine::render_strict(&resolved)
82 }
83 }
84
85 /// Reject submissions that contain image embeds. Used to give non-Fan+ users
86 /// a clear error rather than silently stripping their image at render time.
87 ///
88 /// Only matches the markdown image syntax `![alt](url)`. Raw HTML `<img>` /
89 /// `<video>` / `<iframe>` are stripped by all renderers (`strip_raw_html` is
90 /// true in both `render_strict` and `render_markdown_plus`), so we don't need
91 /// to reject them here, and rejecting them would surprise users pasting code
92 /// blocks containing HTML.
93 #[allow(clippy::result_large_err)]
94 pub(crate) fn reject_embeds_for_free_user(body: &str) -> Result<(), Response> {
95 static EMBED_RE: std::sync::LazyLock<regex_lite::Regex> =
96 std::sync::LazyLock::new(|| regex_lite::Regex::new(r"!\[[^\]]*\]\([^\)]+\)").unwrap());
97 if EMBED_RE.is_match(body) {
98 return Err((
99 StatusCode::UNPROCESSABLE_ENTITY,
100 "Image embeds are a Fan+ feature.",
101 )
102 .into_response());
103 }
104 Ok(())
105 }
106
107 #[cfg(test)]
108 mod proxy_image_tests {
109 use super::{percent_encode_query, proxy_external_images};
110
111 #[test]
112 fn external_image_is_routed_through_proxy() {
113 let html = r#"<p><img src="https://example.com/a.png" alt="x"></p>"#;
114 let out = proxy_external_images(html);
115 assert!(
116 out.contains(r#"src="/img-proxy?u=https%3A%2F%2Fexample.com%2Fa.png""#),
117 "got: {out}"
118 );
119 }
120
121 #[test]
122 fn same_origin_upload_is_not_proxied() {
123 let html = r#"<img src="/uploads/abc-123" alt="x">"#;
124 assert_eq!(
125 proxy_external_images(html),
126 html,
127 "relative uploads must be left alone"
128 );
129 }
130
131 #[test]
132 fn query_string_ampersands_round_trip() {
133 // docengine attribute-escapes `&` to `&amp;`; the proxied value must carry
134 // the real `&` (percent-encoded), not the entity.
135 let html = r#"<img src="https://x.test/i?w=1&amp;h=2">"#;
136 let out = proxy_external_images(html);
137 assert!(
138 out.contains("u=https%3A%2F%2Fx.test%2Fi%3Fw%3D1%26h%3D2"),
139 "got: {out}"
140 );
141 assert!(
142 !out.contains("&amp;h=2"),
143 "the entity must not leak into the query"
144 );
145 }
146
147 #[test]
148 fn percent_encode_keeps_unreserved() {
149 assert_eq!(percent_encode_query("aZ0-_.~"), "aZ0-_.~");
150 assert_eq!(percent_encode_query("a/b?c=d&e"), "a%2Fb%3Fc%3Dd%26e");
151 }
152 }
153