//! 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 ``. Raw HTML `
` /
/// `