| 1 |
|
| 2 |
|
| 3 |
use axum::{ |
| 4 |
http::StatusCode, |
| 5 |
response::{IntoResponse, Response}, |
| 6 |
}; |
| 7 |
|
| 8 |
|
| 9 |
|
| 10 |
|
| 11 |
|
| 12 |
|
| 13 |
pub(crate) fn render_markdown(input: &str) -> String { |
| 14 |
docengine::render_strict(input) |
| 15 |
} |
| 16 |
|
| 17 |
|
| 18 |
|
| 19 |
|
| 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 |
|
| 28 |
|
| 29 |
|
| 30 |
|
| 31 |
|
| 32 |
|
| 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: ®ex_lite::Captures| { |
| 39 |
let url = caps[2].replace("&", "&"); |
| 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 |
|
| 51 |
|
| 52 |
|
| 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 |
|
| 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 |
|
| 86 |
|
| 87 |
|
| 88 |
|
| 89 |
|
| 90 |
|
| 91 |
|
| 92 |
|
| 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 |
|
| 134 |
|
| 135 |
let html = r#"<img src="https://x.test/i?w=1&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("&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 |
|