//! Markdown rendering + Fan+ image handling for route handlers. use axum::{ http::StatusCode, response::{IntoResponse, Response}, }; /// Render markdown to HTML, stripping raw HTML events to prevent XSS. /// /// Strict preset: no images, no raw HTML, dangerous-scheme filtering. Use this /// for content from non-Fan+ users. Fan+ subscribers get image embeds via /// [`render_markdown_plus`]. pub(crate) fn render_markdown(input: &str) -> String { docengine::render_strict(input) } /// Render markdown to HTML with image embeds permitted. Otherwise identical to /// the strict preset, raw HTML is still stripped, dangerous schemes filtered, /// links get `nofollow`. Use for Fan+ subscriber content. pub(crate) fn render_markdown_plus(input: &str) -> String { let html = docengine::Renderer::strict() .with_strip_images(false) .render(input); proxy_external_images(&html) } /// Rewrite external `` in rendered Fan+ HTML to load /// through the same-origin image proxy (`/img-proxy?u=…`), so the page CSP can /// stay `img-src 'self'` while still showing embedded external images. Uploaded /// images are served from same-origin `/uploads/…` (no scheme), so they don't /// match and aren't proxied. docengine has already attribute-escaped the URL, so /// `&` in a query string is unescaped back to `&` before percent-encoding. fn proxy_external_images(html: &str) -> String { static IMG_SRC_RE: std::sync::LazyLock = std::sync::LazyLock::new(|| { regex_lite::Regex::new(r#"(]*?\bsrc=")(https?://[^"]+)(")"#).unwrap() }); IMG_SRC_RE .replace_all(html, |caps: ®ex_lite::Captures| { let url = caps[2].replace("&", "&"); format!( "{}/img-proxy?u={}{}", &caps[1], percent_encode_query(&url), &caps[3] ) }) .into_owned() } /// RFC 3986 percent-encode a string for use as a URL query value. Keeps the /// unreserved set (`A-Za-z0-9-_.~`) and escapes everything else, so the proxied /// URL round-trips intact through axum's query decoder. fn percent_encode_query(s: &str) -> String { use std::fmt::Write as _; let mut out = String::with_capacity(s.len()); for &b in s.as_bytes() { match b { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { out.push(b as char); } _ => { let _ = write!(out, "%{b:02X}"); } } } out } /// Render markdown to HTML, resolving `@mentions` to profile links for valid community members. pub(crate) fn render_markdown_with_mentions( input: &str, community_slug: &str, valid_usernames: &std::collections::HashSet, allow_images: bool, ) -> String { let template = format!("/p/{community_slug}/u/{{username}}"); let resolved = docengine::resolve_mentions(input, valid_usernames, &template); if allow_images { render_markdown_plus(&resolved) } else { docengine::render_strict(&resolved) } } /// Reject submissions that contain image embeds. Used to give non-Fan+ users /// a clear error rather than silently stripping their image at render time. /// /// Only matches the markdown image syntax `![alt](url)`. Raw HTML `` / /// `