diff --git a/src/html.rs b/src/html.rs index 421458f5..b7c1edf5 100644 --- a/src/html.rs +++ b/src/html.rs @@ -864,7 +864,11 @@ impl<'o, 'c: 'o> HtmlFormatter<'o, 'c> { self.output.write_all(b" href=\"")?; let url = nl.url.as_bytes(); if self.options.render.unsafe_ || !dangerous_url(url) { - self.escape_href(url)?; + if let Some(rewriter) = &self.options.extension.link_url_rewriter { + self.escape_href(rewriter.to_html(&nl.url).as_bytes())?; + } else { + self.escape_href(url)?; + } } if !nl.title.is_empty() { self.output.write_all(b"\" title=\"")?; @@ -889,7 +893,11 @@ impl<'o, 'c: 'o> HtmlFormatter<'o, 'c> { self.output.write_all(b" src=\"")?; let url = nl.url.as_bytes(); if self.options.render.unsafe_ || !dangerous_url(url) { - self.escape_href(url)?; + if let Some(rewriter) = &self.options.extension.image_url_rewriter { + self.escape_href(rewriter.to_html(&nl.url).as_bytes())?; + } else { + self.escape_href(url)?; + } } self.output.write_all(b"\" alt=\"")?; return Ok(true); diff --git a/src/lib.rs b/src/lib.rs index a5eb4407..16f0abe6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -92,7 +92,7 @@ pub use parser::{ parse_document, BrokenLinkCallback, BrokenLinkReference, ExtensionOptions, ExtensionOptionsBuilder, ListStyleType, Options, ParseOptions, ParseOptionsBuilder, Plugins, PluginsBuilder, RenderOptions, RenderOptionsBuilder, RenderPlugins, RenderPluginsBuilder, - ResolvedReference, + ResolvedReference, URLRewriter, }; pub use typed_arena::Arena; pub use xml::format_document as format_xml; diff --git a/src/parser/mod.rs b/src/parser/mod.rs index cf74cbd8..95bd649c 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -152,6 +152,18 @@ pub struct Options<'c> { pub render: RenderOptions, } +/// Trait for link and image URL rewrite extensions. +pub trait URLRewriter: Sync { + /// Converts the given URL from Markdown to its representation when output as HTML. + fn to_html(&self, url: &str) -> String; +} + +impl Debug for dyn URLRewriter { + fn fmt(&self, formatter: &mut Formatter<'_>) -> Result<(), std::fmt::Error> { + formatter.write_str("") + } +} + #[non_exhaustive] #[derive(Default, Debug, Clone, Builder)] #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] @@ -521,6 +533,50 @@ pub struct ExtensionOptions { /// ``` #[builder(default)] pub greentext: bool, + + /// Wraps embedded image URLs using a custom trait object. + /// + /// ``` + /// # use std::sync::Arc; + /// # use comrak::{markdown_to_html, ComrakOptions, URLRewriter}; + /// let mut options = ComrakOptions::default(); + /// + /// struct Rewriter {} + /// impl URLRewriter for Rewriter { + /// fn to_html(&self, url: &str) -> String { + /// format!("https://safe.example.com?url={}", url) + /// } + /// } + /// + /// options.extension.image_url_rewriter = Some(Arc::new(Rewriter{})); + /// + /// assert_eq!(markdown_to_html("![](http://unsafe.example.com/bad.png)", &options), + /// "

\"\"

\n"); + /// ``` + #[cfg_attr(feature = "arbitrary", arbitrary(value = None))] + pub image_url_rewriter: Option>, + + /// Wraps link URLs using a custom trait object. + /// + /// ``` + /// # use std::sync::Arc; + /// # use comrak::{markdown_to_html, ComrakOptions, URLRewriter}; + /// let mut options = ComrakOptions::default(); + /// + /// struct Rewriter {} + /// impl URLRewriter for Rewriter { + /// fn to_html(&self, url: &str) -> String { + /// format!("https://safe.example.com/norefer?url={}", url) + /// } + /// } + /// + /// options.extension.link_url_rewriter = Some(Arc::new(Rewriter{})); + /// + /// assert_eq!(markdown_to_html("[my link](http://unsafe.example.com/bad)", &options), + /// "

my link

\n"); + /// ``` + #[cfg_attr(feature = "arbitrary", arbitrary(value = None))] + pub link_url_rewriter: Option>, } #[non_exhaustive] diff --git a/src/tests.rs b/src/tests.rs index 64ffee1a..560bc5d4 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -2,7 +2,7 @@ use crate::nodes::{AstNode, NodeValue, Sourcepos}; use crate::*; use std::collections::HashMap; use std::io::{self, Write}; -use std::panic; +use std::panic::{self, AssertUnwindSafe}; mod api; mod autolink; @@ -21,6 +21,7 @@ mod options; mod pathological; mod plugins; mod regressions; +mod rewriter; mod shortcodes; mod spoiler; mod strikethrough; @@ -273,12 +274,12 @@ where options.render.sourcepos = true; opts(&mut options); - let result = panic::catch_unwind(|| { + let result = panic::catch_unwind(AssertUnwindSafe(|| { let arena = Arena::new(); let root = parse_document(&arena, md, &options); amt.assert_match(root); - }); + })); if let Err(err) = result { let arena = Arena::new(); diff --git a/src/tests/rewriter.rs b/src/tests/rewriter.rs new file mode 100644 index 00000000..1eff325b --- /dev/null +++ b/src/tests/rewriter.rs @@ -0,0 +1,36 @@ +use std::sync::Arc; + +use super::*; + +const IMAGE_PROXY: &'static str = "https://safe.example.com?url="; +const LINK_NOREFER: &'static str = "https://safe.example.com/norefer?url="; + +struct Rewriter { + prefix: &'static str, +} + +impl URLRewriter for Rewriter { + fn to_html(&self, url: &str) -> String { + format!("{}{}", self.prefix, url) + } +} + +#[test] +fn image_url_rewriter() { + html_opts_i( + "![](http://unsafe.example.com/bad.png)", + "

\"\"

\n", + true, + |opts| opts.extension.image_url_rewriter = Some(Arc::new(Rewriter{prefix: IMAGE_PROXY})) + ); +} + +#[test] +fn link_url_rewriter() { + html_opts_i( + "[my link](http://unsafe.example.com/bad)", + "

my link

\n", + true, + |opts| opts.extension.link_url_rewriter = Some(Arc::new(Rewriter{prefix: LINK_NOREFER})) + ); +}