Test / test (pull_request) Successful in 1m9s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
185 lines
7.4 KiB
Rust
185 lines
7.4 KiB
Rust
//! Inline markdown for descriptions: links on a short allow-list of
|
|
//! schemes, images gated and framed, with layout hints read from the
|
|
//! image title. Never raw HTML.
|
|
|
|
/// Descriptions are inline markdown: `[text](https://…)` links,
|
|
/// `*emphasis*`, `**strong**`, `` `code` ``. Block structure is
|
|
/// flattened (a description is one paragraph) and raw HTML in the
|
|
/// source is dropped, so content can link out without being able to
|
|
/// inject markup. Link targets are limited to https, mailto and
|
|
/// site-relative paths; anything else renders as plain text.
|
|
pub fn render_inline_markdown(text: &str) -> String {
|
|
use pulldown_cmark::{html, Event, Options, Parser, Tag, TagEnd};
|
|
let parser = Parser::new_ext(text, Options::empty());
|
|
let mut in_link = 0usize;
|
|
let mut in_dropped_image = 0usize;
|
|
let filtered = parser.filter_map(|event| match event {
|
|
Event::Html(_) | Event::InlineHtml(_) => None,
|
|
Event::Start(Tag::Paragraph) | Event::End(TagEnd::Paragraph) => None,
|
|
Event::SoftBreak => Some(Event::Text(" ".into())),
|
|
Event::Start(Tag::Link { dest_url, .. })
|
|
if !(dest_url.starts_with("https://")
|
|
|| dest_url.starts_with("mailto:")
|
|
|| dest_url.starts_with("tel:")
|
|
|| dest_url.starts_with('/')) =>
|
|
{
|
|
in_link += 1;
|
|
None
|
|
}
|
|
Event::End(TagEnd::Link) if in_link > 0 => {
|
|
in_link -= 1;
|
|
None
|
|
}
|
|
// Images take the same gate as links: https or same-origin
|
|
// only - no data:, no plain http. Unlike a dropped link (whose
|
|
// label is real text worth keeping), a dropped image's alt
|
|
// text is a caption for something that isn't there - swallow it.
|
|
Event::Start(Tag::Image { dest_url, .. })
|
|
if !(dest_url.starts_with("https://") || dest_url.starts_with('/')) =>
|
|
{
|
|
in_dropped_image += 1;
|
|
None
|
|
}
|
|
Event::End(TagEnd::Image) if in_dropped_image > 0 => {
|
|
in_dropped_image -= 1;
|
|
None
|
|
}
|
|
Event::Text(_) if in_dropped_image > 0 => None,
|
|
other => Some(other),
|
|
});
|
|
let mut out = String::new();
|
|
html::push_html(&mut out, filtered);
|
|
apply_image_hints(out.trim())
|
|
}
|
|
|
|
/// Translate a markdown image's title (``)
|
|
/// into layout: `left`/`right` float via a class, a bare number
|
|
/// (optionally with `rem`) into a validated `max-width`. Unknown
|
|
/// tokens are ignored; the title attribute is dropped either way.
|
|
/// Only touches `<img>` tags in our own render output, and only ever
|
|
/// emits a numeric max-width - no arbitrary CSS reaches the page.
|
|
pub(crate) fn apply_image_hints(html: &str) -> String {
|
|
let mut out = String::new();
|
|
let mut rest = html;
|
|
while let Some(pos) = rest.find("<img ") {
|
|
out.push_str(&rest[..pos]);
|
|
let after = &rest[pos..];
|
|
let end = after.find('>').map(|e| e + 1).unwrap_or(after.len());
|
|
out.push_str(&rewrite_img_tag(&after[..end]));
|
|
rest = &after[end..];
|
|
}
|
|
out.push_str(rest);
|
|
out
|
|
}
|
|
|
|
pub(crate) fn rewrite_img_tag(tag: &str) -> String {
|
|
// Pull the title value, if any.
|
|
let title = tag
|
|
.find("title=\"")
|
|
.map(|i| &tag[i + 7..])
|
|
.and_then(|r| r.find('"').map(|e| &r[..e]))
|
|
.unwrap_or("");
|
|
|
|
let mut class = String::new();
|
|
let mut max_rem: Option<f32> = None;
|
|
for tok in title.split_whitespace() {
|
|
match tok {
|
|
"left" => class = "md-float-left".into(),
|
|
"right" => class = "md-float-right".into(),
|
|
other => {
|
|
if let Ok(n) = other.trim_end_matches("rem").parse::<f32>() {
|
|
if n > 0.0 && n <= 60.0 {
|
|
max_rem = Some(n);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Strip the title attribute from the tag.
|
|
let mut cleaned = tag.to_string();
|
|
if let Some(i) = cleaned.find(" title=\"") {
|
|
if let Some(e) = cleaned[i + 8..].find('"') {
|
|
cleaned.replace_range(i..i + 8 + e + 1, "");
|
|
}
|
|
}
|
|
|
|
// Inject class/style right after `<img`.
|
|
let mut attrs = String::new();
|
|
if !class.is_empty() {
|
|
attrs.push_str(&format!(" class=\"{class}\""));
|
|
}
|
|
if let Some(n) = max_rem {
|
|
attrs.push_str(&format!(" style=\"max-width:{n}rem\""));
|
|
}
|
|
if attrs.is_empty() {
|
|
return cleaned;
|
|
}
|
|
cleaned.replacen("<img", &format!("<img{attrs}"), 1)
|
|
}
|
|
|
|
|
|
/// Markdown that a person wrote into a record - a bio on a card - as
|
|
/// opposed to the site's own content. Paragraphs, line breaks as they
|
|
/// were typed, lists, emphasis and links; no images (a picture has its
|
|
/// own field, and an image in text is a way to load something from
|
|
/// elsewhere into every visitor's page), no raw HTML, headings as plain
|
|
/// paragraphs. Links go to https, mailto or tel only, and say they are
|
|
/// the writer's, not the site's (`rel="nofollow ugc"`).
|
|
pub fn render_written_markdown(text: &str) -> String {
|
|
use pulldown_cmark::{html, Event, Options, Parser, Tag, TagEnd};
|
|
let parser = Parser::new_ext(text, Options::ENABLE_STRIKETHROUGH);
|
|
let mut in_dropped_link = 0usize;
|
|
let mut in_image = 0usize;
|
|
let filtered = parser.filter_map(|event| match event {
|
|
Event::Html(_) | Event::InlineHtml(_) => None,
|
|
Event::Start(Tag::Image { .. }) => {
|
|
in_image += 1;
|
|
None
|
|
}
|
|
Event::End(TagEnd::Image) => {
|
|
in_image = in_image.saturating_sub(1);
|
|
None
|
|
}
|
|
_ if in_image > 0 => None,
|
|
Event::Start(Tag::Heading { .. }) => Some(Event::Start(Tag::Paragraph)),
|
|
Event::End(TagEnd::Heading(_)) => Some(Event::End(TagEnd::Paragraph)),
|
|
// What someone typed on a new line is on a new line.
|
|
Event::SoftBreak => Some(Event::HardBreak),
|
|
Event::Start(Tag::Link { dest_url, .. })
|
|
if !(dest_url.starts_with("https://") || dest_url.starts_with("mailto:") || dest_url.starts_with("tel:")) =>
|
|
{
|
|
in_dropped_link += 1;
|
|
None
|
|
}
|
|
Event::End(TagEnd::Link) if in_dropped_link > 0 => {
|
|
in_dropped_link -= 1;
|
|
None
|
|
}
|
|
other => Some(other),
|
|
});
|
|
let mut out = String::new();
|
|
html::push_html(&mut out, filtered);
|
|
out.trim().replace("<a href=", "<a rel=\"nofollow ugc\" href=")
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod written_tests {
|
|
use super::render_written_markdown as md;
|
|
|
|
#[test]
|
|
fn a_bio_keeps_what_a_person_would_write_and_nothing_that_reaches_out() {
|
|
assert_eq!(md("Line one\nline two"), "<p>Line one<br />\nline two</p>");
|
|
assert!(md("- NS 3424\n- NS 3423").contains("<ul>\n<li>NS 3424</li>"));
|
|
assert!(md("**Sivilingeniør**, *NTNU*").contains("<strong>Sivilingeniør</strong>, <em>NTNU</em>"));
|
|
assert_eq!(md("[Westra](https://westra.no)"), "<p><a rel=\"nofollow ugc\" href=\"https://westra.no\">Westra</a></p>");
|
|
// Everything that would put something else in the page is gone.
|
|
assert_eq!(md("<script>alert(1)</script>"), "");
|
|
assert_eq!(md("hi <img src=x onerror=alert(1)>"), "<p>hi </p>");
|
|
assert_eq!(md("[click](javascript:alert(1))"), "<p>click</p>");
|
|
assert_eq!(md("[home](/kontor)"), "<p>home</p>", "a site path is the site's to link, not a bio's");
|
|
assert_eq!(md("before  after"), "<p>before after</p>");
|
|
assert_eq!(md("# Shouting"), "<p>Shouting</p>");
|
|
}
|
|
}
|