diff --git a/README.md b/README.md index a8f860c..de6d9a1 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,8 @@ site = "smol" HTTP and Spartan route by the hostname the request carries, falling back to `default_site`; Nex and Gopher carry none, so their listener names one site outright. Every format a listener serves must be compiled in, which `--check` reports. A `.itsybitsy.toml` in a content directory sets rendering options for that directory — `[defaults]` for all its Markdown files, `[page."name.md"]` for one — with no inheritance from parent directories. Editing it re-renders on the next request. +A site can set `mews_profile = true` to render its `xhtmlmp` and `html` output as [Mews Profile](https://mews.page/spec/) pages: the XHTML-MP 1.2 doctype, the conformance marker, the viewport tag and the default stylesheet link, with raw HTML and off-site or `data:` images dropped rather than passed through. Sites sharing one root must agree on this setting, since they share one render cache. + ### Formats and protocols | Format | Served as | Behind feature | diff --git a/core/src/config.rs b/core/src/config.rs index c9b7c7e..fd3a320 100644 --- a/core/src/config.rs +++ b/core/src/config.rs @@ -48,6 +48,13 @@ pub struct SiteSpec { /// `default_site`. #[serde(default)] pub hosts: Vec, + /// Render this site's `xhtmlmp`/`html` output as a Mews Profile page + /// (mews.page/spec): the XHTML-MP 1.2 doctype, the conformance marker, the + /// viewport tag and the default stylesheet link, with raw HTML and + /// off-site or `data:` images dropped rather than passed through, since the + /// profile's permitted-element list cannot otherwise be guaranteed. + #[serde(default)] + pub mews_profile: bool, } #[derive(Debug, Deserialize)] @@ -169,13 +176,27 @@ impl ServerConfig { self.reject_config_inside_root(config_path, &roots)?; let hosts = self.build_host_map()?; let formats = self.validate_listeners(available_formats)?; + let shared_roots = group_shared_roots(&roots); + self.validate_shared_mews_profile(&shared_roots)?; - Ok(Checked { - hosts, - roots: roots.clone(), - shared_roots: group_shared_roots(&roots), - formats, - }) + Ok(Checked { hosts, roots: roots.clone(), shared_roots, formats }) + } + + /// Sites sharing a root share one [`crate::site::Site`] and so one render + /// cache; a page rendered once there cannot differ by which name served the + /// request, so `mews_profile` must agree within a group. + fn validate_shared_mews_profile(&self, shared_roots: &[Vec]) -> Result<(), Error> { + for group in shared_roots { + let mut settings = group.iter().map(|name| self.site[name].mews_profile); + let first = settings.next().unwrap_or(false); + if settings.any(|value| value != first) { + return Err(Error::config(format!( + "sites {} share one root, so mews_profile must agree between them", + group.join(", ") + ))); + } + } + Ok(()) } /// Canonicalise every content root, which also proves it exists and is a @@ -915,6 +936,19 @@ mod tests { assert_eq!(checked.shared_roots, vec![vec!["one".to_string(), "two".to_string()]]); } + #[test] + fn rejects_sites_sharing_one_root_with_differing_mews_profile() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("content"); + fs::create_dir_all(&root).unwrap(); + let two = format!( + "{HTTP}\n[site.two]\nroot = {:?}\nhosts = [\"two.test\"]\nmews_profile = true\n", + root.to_str().unwrap() + ); + let err = check(dir.path(), &two).unwrap_err(); + assert!(err.to_string().contains("mews_profile must agree"), "{err}"); + } + #[test] fn rejects_a_config_file_inside_a_content_root() { let dir = tempfile::tempdir().unwrap(); diff --git a/core/src/render.rs b/core/src/render.rs index 08a7686..3915cb8 100644 --- a/core/src/render.rs +++ b/core/src/render.rs @@ -48,6 +48,11 @@ pub struct RenderCtx<'a> { pub title: &'a str, pub settings: &'a PageSettings, pub width: Option, + /// The site's own hostnames, present when it has opted into Mews Profile + /// compliance (mews.page/spec) and absent otherwise. Threaded through so a + /// renderer that cares can switch its markup and judge an image `src` as + /// same-site, without reaching into site configuration itself. + pub mews_hosts: Option<&'a [String]>, } pub struct Rendered { @@ -112,6 +117,7 @@ impl Registry { url: &str, settings: &PageSettings, fallback_title: &str, + mews_hosts: Option<&[String]>, ) -> Result { let title = settings .title @@ -131,8 +137,13 @@ impl Registry { // caller passing an unknown id is a bug, not bad input. Error::config(format!("no renderer provides the format '{id}'")) })?; - let ctx = - RenderCtx { url, title: &page.title, settings, width: renderer.default_width() }; + let ctx = RenderCtx { + url, + title: &page.title, + settings, + width: renderer.default_width(), + mews_hosts, + }; // Format gates are resolved here, so no renderer meets one and a // gated run costs nothing extra in the cache: bodies are already // kept per format. @@ -209,8 +220,11 @@ mod tests { Ok(Rendered::body( // The blocks are printed too, so what a format was handed — // after gates — is visible in the body. - format!("{} {} {:?} {} {:?}", self.id, ctx.url, ctx.width, ctx.title, doc.blocks) - .into_bytes(), + format!( + "{} {} {:?} {} {:?} {:?}", + self.id, ctx.url, ctx.width, ctx.title, doc.blocks, ctx.mews_hosts + ) + .into_bytes(), )) } } @@ -238,7 +252,7 @@ mod tests { #[test] fn renders_only_the_formats_asked_for() { let page = registry() - .page(&["one".to_string()], &doc(None), "/x", &PageSettings::default(), "x") + .page(&["one".to_string()], &doc(None), "/x", &PageSettings::default(), "x", None) .unwrap(); assert!(page.body("one").is_some()); assert!(page.body("two").is_none(), "a format no listener serves is not rendered"); @@ -247,26 +261,44 @@ mod tests { #[test] fn each_renderer_gets_its_own_declared_width() { let formats = vec!["one".to_string(), "two".to_string()]; - let page = - registry().page(&formats, &doc(None), "/x", &PageSettings::default(), "x").unwrap(); + let page = registry() + .page(&formats, &doc(None), "/x", &PageSettings::default(), "x", None) + .unwrap(); assert!(String::from_utf8_lossy(page.body("one").unwrap()).contains("None")); assert!(String::from_utf8_lossy(page.body("two").unwrap()).contains("Some(80)")); } + #[test] + fn mews_hosts_reaches_the_renderer_when_the_site_opted_in() { + let formats = vec!["one".to_string()]; + let hosts = vec!["example.test".to_string()]; + let page = registry() + .page(&formats, &doc(None), "/x", &PageSettings::default(), "x", Some(&hosts)) + .unwrap(); + assert!(String::from_utf8_lossy(page.body("one").unwrap()).contains("example.test")); + + let unset = registry() + .page(&formats, &doc(None), "/x", &PageSettings::default(), "x", None) + .unwrap(); + assert!(String::from_utf8_lossy(unset.body("one").unwrap()).contains("None")); + } + #[test] fn the_title_falls_back_from_config_to_heading_to_file_name() { let configured = PageSettings { title: Some("Configured".into()), ..Default::default() }; let formats = vec!["one".to_string()]; let reg = registry(); - let from_config = reg.page(&formats, &doc(Some("Heading")), "/x", &configured, "stem"); + let from_config = + reg.page(&formats, &doc(Some("Heading")), "/x", &configured, "stem", None); assert_eq!(from_config.unwrap().title, "Configured"); let from_heading = - reg.page(&formats, &doc(Some("Heading")), "/x", &PageSettings::default(), "stem"); + reg.page(&formats, &doc(Some("Heading")), "/x", &PageSettings::default(), "stem", None); assert_eq!(from_heading.unwrap().title, "Heading"); - let from_stem = reg.page(&formats, &doc(None), "/x", &PageSettings::default(), "stem"); + let from_stem = + reg.page(&formats, &doc(None), "/x", &PageSettings::default(), "stem", None); assert_eq!(from_stem.unwrap().title, "stem"); } @@ -281,7 +313,8 @@ mod tests { first_h1: None, }; let formats = vec!["one".to_string(), "two".to_string()]; - let page = registry().page(&formats, &gated, "/x", &PageSettings::default(), "x").unwrap(); + let page = + registry().page(&formats, &gated, "/x", &PageSettings::default(), "x", None).unwrap(); // The stub prints the blocks it was handed, so presence is visible. assert!(String::from_utf8_lossy(page.body("one").unwrap()).contains("secret")); assert!(!String::from_utf8_lossy(page.body("two").unwrap()).contains("secret")); @@ -290,7 +323,7 @@ mod tests { #[test] fn an_unknown_format_is_a_bug_not_bad_input() { let err = registry() - .page(&["absent".to_string()], &doc(None), "/x", &PageSettings::default(), "x") + .page(&["absent".to_string()], &doc(None), "/x", &PageSettings::default(), "x", None) .unwrap_err(); assert!(err.to_string().contains("no renderer provides"), "{err}"); } diff --git a/core/src/site.rs b/core/src/site.rs index 5a53c2c..3eda4ab 100644 --- a/core/src/site.rs +++ b/core/src/site.rs @@ -59,12 +59,28 @@ pub struct Site { /// Formats every page here is rendered into: the union of what the enabled /// listeners can serve, so a site with no HTTP listener never renders HTML. formats: Vec, + /// Whether this site's `xhtmlmp`/`html` output is rendered as Mews Profile + /// pages (`SiteSpec::mews_profile`). Validation requires every site sharing + /// this root to agree, so one value is correct for all of them. + mews_profile: bool, + /// This site's own hostnames, used to judge an image `src` as same-site + /// when `mews_profile` is set. Taken from whichever configured site name + /// opened this root first; a root reached under several names with + /// differing host lists keeps only that one's, which is an accepted + /// simplification rather than a union of them all. + hosts: Vec, } impl Site { /// Open a content root, canonicalising it so containment checks have a /// stable base and a missing root fails now rather than per request. - pub fn new(root: &Path, registry: Arc, formats: Vec) -> Result { + pub fn new( + root: &Path, + registry: Arc, + formats: Vec, + mews_profile: bool, + hosts: Vec, + ) -> Result { let root = root.canonicalize().map_err(|cause| Error::Io { path: root.to_path_buf(), cause })?; if !root.is_dir() { @@ -77,6 +93,8 @@ impl Site { built_in: Arc::new(DirConfig::default()), registry, formats, + mews_profile, + hosts, }) } @@ -213,7 +231,15 @@ impl Site { let settings = self.dir_config(dir)?.settings_for(name)?; let doc = parse::document(source, &self.root)?; let stem = source.file_stem().and_then(|s| s.to_str()).unwrap_or_default(); - self.registry.page(&self.formats, &doc, url, &settings, &title_from_stem(stem)) + let mews_hosts = self.mews_profile.then_some(self.hosts.as_slice()); + self.registry.page( + &self.formats, + &doc, + url, + &settings, + &title_from_stem(stem), + mews_hosts, + ) }) } @@ -249,7 +275,7 @@ mod tests { } fn render(&self, doc: &Doc, ctx: &RenderCtx<'_>) -> Result { - let mut body = format!("title={}\n", ctx.title); + let mut body = format!("title={}\nmews_hosts={:?}\n", ctx.title, ctx.mews_hosts); for block in &doc.blocks { body.push_str(&format!("{block:?}\n")); } @@ -264,7 +290,7 @@ mod tests { } fn open(root: &Path) -> Site { - Site::new(root, registry(), vec!["stub".to_string()]).unwrap() + Site::new(root, registry(), vec!["stub".to_string()], false, Vec::new()).unwrap() } /// One of each kind of thing a request can land on, shared by the resolution @@ -550,11 +576,25 @@ mod tests { assert!(matches!(site.resolve("/about"), Err(Error::Toml { .. }))); } + #[test] + fn mews_profile_and_hosts_reach_the_render_context() { + let (dir, _site) = fixture(); + let hosts = vec!["example.test".to_string()]; + let mews_site = + Site::new(dir.path(), registry(), vec!["stub".to_string()], true, hosts).unwrap(); + let (_, page) = document(&mews_site, "/about"); + let body = String::from_utf8(page.body("stub").unwrap().to_vec()).unwrap(); + assert!(body.contains("mews_hosts=Some([\"example.test\"])"), "{body}"); + } + #[test] fn a_missing_root_is_rejected_at_construction() { let dir = tempfile::tempdir().unwrap(); let absent = dir.path().join("absent"); - assert!(matches!(Site::new(&absent, registry(), vec![]), Err(Error::Io { .. }))); + assert!(matches!( + Site::new(&absent, registry(), vec![], false, Vec::new()), + Err(Error::Io { .. }) + )); } #[test] @@ -562,7 +602,10 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let file = dir.path().join("not-a-dir"); fs::write(&file, "x").unwrap(); - assert!(matches!(Site::new(&file, registry(), vec![]), Err(Error::Config { .. }))); + assert!(matches!( + Site::new(&file, registry(), vec![], false, Vec::new()), + Err(Error::Config { .. }) + )); } } @@ -622,7 +665,9 @@ mod part_tests { let mut registry = Registry::new(); registry.insert(Arc::new(Paginating)).unwrap(); - let site = Site::new(root, Arc::new(registry), vec!["paginating".to_string()]).unwrap(); + let site = + Site::new(root, Arc::new(registry), vec!["paginating".to_string()], false, Vec::new()) + .unwrap(); (dir, site) } diff --git a/core/src/siteset.rs b/core/src/siteset.rs index cc0ac68..986b799 100644 --- a/core/src/siteset.rs +++ b/core/src/siteset.rs @@ -38,7 +38,9 @@ impl SiteSet { SiteSet { sites: Vec::new(), by_host: HashMap::new(), by_name: HashMap::new() }; for (name, spec) in &config.site { - let site = Site::new(&spec.root, registry.clone(), formats.clone())?; + let hosts = spec.hosts.iter().filter_map(|host| normalize_host(host)).collect(); + let site = + Site::new(&spec.root, registry.clone(), formats.clone(), spec.mews_profile, hosts)?; // One `Site` per distinct root, so sites sharing a folder share its // caches rather than each building their own. let index = match set.sites.iter().position(|open| open.root() == site.root()) { diff --git a/gemtext/src/lib.rs b/gemtext/src/lib.rs index 6dc969b..7d1fa3c 100644 --- a/gemtext/src/lib.rs +++ b/gemtext/src/lib.rs @@ -295,7 +295,8 @@ mod tests_support { pub fn render(markdown: &str) -> String { let doc = parse::markdown(markdown).unwrap(); let settings = PageSettings::default(); - let ctx = RenderCtx { url: "/x", title: "T", settings: &settings, width: None }; + let ctx = + RenderCtx { url: "/x", title: "T", settings: &settings, width: None, mews_hosts: None }; let out = Gemtext.render(&doc, &ctx).unwrap(); String::from_utf8(out.body).unwrap() } @@ -398,7 +399,8 @@ mod tests { // Pagination is a WML concern; a Gemini client scrolls one document. let doc = parse::markdown("a\n\nb\n").unwrap(); let settings = PageSettings::default(); - let ctx = RenderCtx { url: "/x", title: "T", settings: &settings, width: None }; + let ctx = + RenderCtx { url: "/x", title: "T", settings: &settings, width: None, mews_hosts: None }; let plain = String::from_utf8(Gemtext.render(&doc, &ctx).unwrap().body).unwrap(); assert_eq!(plain, "a\n\nb\n"); } diff --git a/text/src/lib.rs b/text/src/lib.rs index 3fcb949..e6951af 100644 --- a/text/src/lib.rs +++ b/text/src/lib.rs @@ -58,7 +58,8 @@ mod tests { /// assertions read as the content itself. fn render_with(settings: &PageSettings, width: u16, markdown: &str) -> String { let doc = parse::markdown(markdown).unwrap(); - let ctx = RenderCtx { url: "/x", title: "T", settings, width: Some(width) }; + let ctx = + RenderCtx { url: "/x", title: "T", settings, width: Some(width), mews_hosts: None }; String::from_utf8(Text.render(&doc, &ctx).unwrap().body).unwrap() } @@ -213,7 +214,8 @@ mod tests { /// `parse::document`, which needs a file, so the unit tests build them. fn render_blocks(settings: &PageSettings, width: u16, blocks: Vec) -> String { let doc = Doc { blocks, first_h1: None }; - let ctx = RenderCtx { url: "/x", title: "T", settings, width: Some(width) }; + let ctx = + RenderCtx { url: "/x", title: "T", settings, width: Some(width), mews_hosts: None }; String::from_utf8(Text.render(&doc, &ctx).unwrap().body).unwrap() } @@ -267,7 +269,13 @@ mod separation_tests { fn render(markdown: &str) -> String { let doc = parse::markdown(markdown).unwrap(); let settings = PageSettings { margin_left: 0, margin_right: 0, ..Default::default() }; - let ctx = RenderCtx { url: "/x", title: "T", settings: &settings, width: Some(40) }; + let ctx = RenderCtx { + url: "/x", + title: "T", + settings: &settings, + width: Some(40), + mews_hosts: None, + }; String::from_utf8(Text.render(&doc, &ctx).unwrap().body).unwrap() } @@ -299,7 +307,8 @@ mod optional_feature_tests { fn render_with(settings: &PageSettings, width: u16, markdown: &str) -> String { let doc = parse::markdown(markdown).unwrap(); - let ctx = RenderCtx { url: "/x", title: "T", settings, width: Some(width) }; + let ctx = + RenderCtx { url: "/x", title: "T", settings, width: Some(width), mews_hosts: None }; String::from_utf8(Text.render(&doc, &ctx).unwrap().body).unwrap() } diff --git a/wap/src/lib.rs b/wap/src/lib.rs index 06b921a..78e53a6 100644 --- a/wap/src/lib.rs +++ b/wap/src/lib.rs @@ -29,7 +29,7 @@ impl Renderer for XhtmlMp { } fn render(&self, doc: &Doc, ctx: &RenderCtx<'_>) -> Result { - Ok(Rendered::body(xhtmlmp::document(doc, ctx.title, true).into_bytes())) + Ok(Rendered::body(xhtmlmp::document(doc, ctx.title, true, ctx.mews_hosts).into_bytes())) } } @@ -46,7 +46,7 @@ impl Renderer for Html { } fn render(&self, doc: &Doc, ctx: &RenderCtx<'_>) -> Result { - Ok(Rendered::body(xhtmlmp::document(doc, ctx.title, false).into_bytes())) + Ok(Rendered::body(xhtmlmp::document(doc, ctx.title, false, ctx.mews_hosts).into_bytes())) } } @@ -82,7 +82,13 @@ mod tests { fn render(renderer: &dyn Renderer, markdown: &str) -> String { let doc = parse::markdown(markdown).unwrap(); let settings = PageSettings::default(); - let ctx = RenderCtx { url: "/x", title: "The Title", settings: &settings, width: None }; + let ctx = RenderCtx { + url: "/x", + title: "The Title", + settings: &settings, + width: None, + mews_hosts: None, + }; String::from_utf8(renderer.render(&doc, &ctx).unwrap().body).unwrap() } @@ -178,7 +184,8 @@ mod tests { first_h1: None, }; let settings = PageSettings::default(); - let ctx = RenderCtx { url: "/x", title: "T", settings: &settings, width: None }; + let ctx = + RenderCtx { url: "/x", title: "T", settings: &settings, width: None, mews_hosts: None }; let out = String::from_utf8(Html.render(&doc, &ctx).unwrap().body).unwrap(); assert!(out.contains("
/\\
"), "{out}"); } @@ -195,7 +202,8 @@ mod tests { first_h1: None, }; let settings = PageSettings::default(); - let ctx = RenderCtx { url: "/x", title: "T", settings: &settings, width: None }; + let ctx = + RenderCtx { url: "/x", title: "T", settings: &settings, width: None, mews_hosts: None }; let out = String::from_utf8(Html.render(&doc, &ctx).unwrap().body).unwrap(); assert!(out.contains("\n"), "{out}"); } @@ -218,7 +226,8 @@ mod deck_tests { // Card dividers are comment directives, so the IR is built by hand here // the way `parse::document` would produce it. let doc = with_card_breaks(markdown); - let ctx = RenderCtx { url: "/trail", title: "Trail", settings, width: None }; + let ctx = + RenderCtx { url: "/trail", title: "Trail", settings, width: None, mews_hosts: None }; Wml.render(&doc, &ctx).unwrap() } @@ -474,7 +483,13 @@ mod oracle_dump { ]; for (name, settings) in cases { let doc = with_card_breaks(TRAIL); - let ctx = RenderCtx { url: "/trail", title: "Trail", settings: &settings, width: None }; + let ctx = RenderCtx { + url: "/trail", + title: "Trail", + settings: &settings, + width: None, + mews_hosts: None, + }; let out = Wml.render(&doc, &ctx).unwrap(); std::fs::write(format!("/tmp/mine-{name}.wml"), out.body).unwrap(); } diff --git a/wap/src/xhtmlmp.rs b/wap/src/xhtmlmp.rs index 57183a2..cdafcb0 100644 --- a/wap/src/xhtmlmp.rs +++ b/wap/src/xhtmlmp.rs @@ -6,6 +6,7 @@ //! logs a warning for it. So the HTML form is the same markup with that one line //! removed. +use itsybitsy_core::config::normalize_host; use itsybitsy_core::ir::{Block, Doc, Inline}; use crate::escape; @@ -13,37 +14,62 @@ use crate::escape; /// The XML declaration, which only the XHTML-MP form carries. const PROLOG: &str = "\n"; -const DOCTYPE: &str = "\n"; +/// The ordinary doctype, used whenever a site has not opted into Mews Profile +/// compliance. +const DOCTYPE_1_0: &str = "\n"; + +/// Mews Profile pages carry the XHTML-MP 1.2 doctype instead (mews.page/spec +/// 3.1), which is what a Mews DTD or validator checks a page against. +const DOCTYPE_1_2: &str = "\n"; + +/// The conformance marker (3.2), viewport tag (3.3) and default stylesheet +/// link (5.2), pinned to spec version 0.1: a site opts into the profile as a +/// whole, not into picking its own version or stylesheet path. +const MEWS_HEAD_EXTRA: &str = "\n\ + \n\ + \n"; /// Build the whole document. `prolog` distinguishes the two media types. -pub fn document(doc: &Doc, title: &str, prolog: bool) -> String { +/// `mews_hosts` is `Some` with the site's own hostnames when it has opted into +/// Mews Profile compliance, and `None` otherwise. +pub fn document(doc: &Doc, title: &str, prolog: bool, mews_hosts: Option<&[String]>) -> String { let mut out = String::new(); if prolog { out.push_str(PROLOG); } - out.push_str(DOCTYPE); + out.push_str(if mews_hosts.is_some() { DOCTYPE_1_2 } else { DOCTYPE_1_0 }); out.push_str("\n"); - out.push_str(&format!("{}\n", escape::text(title))); + if mews_hosts.is_some() { + out.push_str(&format!( + "\n{}\n{MEWS_HEAD_EXTRA}\n", + escape::text(title) + )); + } else { + out.push_str(&format!("{}\n", escape::text(title))); + } out.push_str("\n"); - blocks(&doc.blocks, &mut out); + blocks(&doc.blocks, mews_hosts, &mut out); out.push_str("\n\n"); out } -fn blocks(blocks: &[Block], out: &mut String) { +fn blocks(blocks: &[Block], mews_hosts: Option<&[String]>, out: &mut String) { for block in blocks { - self_block(block, out); + self_block(block, mews_hosts, out); } } -fn self_block(block: &Block, out: &mut String) { +fn self_block(block: &Block, mews_hosts: Option<&[String]>, out: &mut String) { match block { Block::Heading { level, inline } => { let level = (*level).clamp(1, 6); - out.push_str(&format!("{}\n", inlines(inline))); + out.push_str(&format!("{}\n", inlines(inline, mews_hosts))); + } + Block::Paragraph(inline) => { + out.push_str(&format!("

{}

\n", inlines(inline, mews_hosts))) } - Block::Paragraph(inline) => out.push_str(&format!("

{}

\n", inlines(inline))), Block::CodeBlock { lines, .. } => { out.push_str("
");
             out.push_str(&escape::text(&lines.join("\n")));
@@ -58,7 +84,7 @@ fn self_block(block: &Block, out: &mut String) {
         }
         Block::BlockQuote(inner) => {
             out.push_str("
\n"); - blocks(inner, out); + blocks(inner, mews_hosts, out); out.push_str("
\n"); } Block::List { ordered, start, items } => { @@ -74,10 +100,10 @@ fn self_block(block: &Block, out: &mut String) { out.push_str("
  • "); // A single paragraph needs no block wrapper inside the item. match item.as_slice() { - [Block::Paragraph(inline)] => out.push_str(&inlines(inline)), + [Block::Paragraph(inline)] => out.push_str(&inlines(inline, mews_hosts)), blocks_in_item => { out.push('\n'); - blocks(blocks_in_item, out); + blocks(blocks_in_item, mews_hosts, out); } } out.push_str("
  • \n"); @@ -89,47 +115,55 @@ fn self_block(block: &Block, out: &mut String) { if !head.is_empty() { out.push_str(""); for cell in head { - out.push_str(&format!("{}", inlines(cell))); + out.push_str(&format!("{}", inlines(cell, mews_hosts))); } out.push_str("\n"); } for row in rows { out.push_str(""); for cell in row { - out.push_str(&format!("{}", inlines(cell))); + out.push_str(&format!("{}", inlines(cell, mews_hosts))); } out.push_str("\n"); } out.push_str("\n"); } Block::Rule => out.push_str("
    \n"), - // Raw HTML is passed through: this is the one family of formats where it - // is already in the right language. + // Raw HTML is ordinarily passed through, since this is the one family + // of formats where it is already in the right language. A Mews page + // cannot make that guarantee — the markup might use an element or + // attribute the profile excludes — so it is dropped instead. Block::Html(html) => { - out.push_str(html.trim_end()); - out.push('\n'); + if mews_hosts.is_none() { + out.push_str(html.trim_end()); + out.push('\n'); + } } // Alignment reaches only the fixed-width text formats. Pagination is a // WML concern; a browser scrolls one document. - Block::Aligned { block, .. } => self_block(block, out), + Block::Aligned { block, .. } => self_block(block, mews_hosts, out), // Gates are filtered out before rendering; keeping the content is the // harmless reading if one ever arrives here. - Block::Gated { block, .. } => self_block(block, out), + Block::Gated { block, .. } => self_block(block, mews_hosts, out), Block::CardBreak { .. } => {} } } -fn inlines(inline: &[Inline]) -> String { +fn inlines(inline: &[Inline], mews_hosts: Option<&[String]>) -> String { let mut out = String::new(); for item in inline { match item { Inline::Text(text) => out.push_str(&escape::text(text)), Inline::Code(code) => out.push_str(&format!("{}", escape::text(code))), - Inline::Emph(inner) => out.push_str(&format!("{}", inlines(inner))), - Inline::Strong(inner) => out.push_str(&format!("{}", inlines(inner))), + Inline::Emph(inner) => { + out.push_str(&format!("{}", inlines(inner, mews_hosts))) + } + Inline::Strong(inner) => { + out.push_str(&format!("{}", inlines(inner, mews_hosts))) + } // XHTML-MP 1.0 has no , and is not in the profile, so // the text survives without its markup rather than being dropped. - Inline::Strike(inner) => out.push_str(&inlines(inner)), + Inline::Strike(inner) => out.push_str(&inlines(inner, mews_hosts)), Inline::Link { href, title, label } => { let title = title .as_deref() @@ -138,10 +172,17 @@ fn inlines(inline: &[Inline]) -> String { out.push_str(&format!( "{}", escape::attr(href), - inlines(label) + inlines(label, mews_hosts) )); } Inline::Image { src, title, alt } => { + // A Mews page's image must point at the same site and never be + // a `data:` URI (SPEC.md 4.2); one that does not is dropped to + // its alt text rather than rendered non-conformant. + if mews_hosts.is_some_and(|hosts| !same_site_image(src, hosts)) { + out.push_str(&escape::text(&Doc::plain_text(alt))); + continue; + } let title = title .as_deref() .map(|t| format!(" title=\"{}\"", escape::attr(t))) @@ -154,20 +195,124 @@ fn inlines(inline: &[Inline]) -> String { } Inline::SoftBreak => out.push('\n'), Inline::HardBreak => out.push_str("
    \n"), - Inline::Html(html) => out.push_str(html), + // See the Block::Html arm above: unverifiable in a Mews page. + Inline::Html(html) => { + if mews_hosts.is_none() { + out.push_str(html); + } + } } } out } +/// Whether an image `src` satisfies the Mews same-site rule (SPEC.md 4.2). A +/// `data:` URI never qualifies. A relative or root-relative URL always does, +/// since it resolves against the page's own origin by definition. An absolute +/// or protocol-relative URL qualifies when its host equals one of the site's +/// own hostnames or is a subdomain of one — a looser stand-in for "the same +/// registered domain" that needs no public-suffix list, adequate for hosts +/// the site itself configured. +fn same_site_image(src: &str, hosts: &[String]) -> bool { + if src.len() >= 5 && src.as_bytes()[..5].eq_ignore_ascii_case(b"data:") { + return false; + } + let Some(raw_host) = url_host(src) else { return true }; + let Some(host) = normalize_host(raw_host) else { return false }; + hosts.iter().any(|allowed| host == *allowed || host.ends_with(&format!(".{allowed}"))) +} + +/// The host component of an absolute (`scheme://host/...`) or +/// protocol-relative (`//host/...`) URL, or `None` for anything else: a bare +/// or root-relative path has no host of its own, so it always resolves +/// against the page's own site. +fn url_host(src: &str) -> Option<&str> { + let rest = match src.split_once("://") { + Some((_scheme, rest)) => rest, + None => src.strip_prefix("//")?, + }; + Some(rest.split(['/', '?', '#']).next().unwrap_or(rest)) +} + #[cfg(test)] mod tests { use super::*; + fn empty() -> Doc { + Doc { blocks: Vec::new(), first_h1: None } + } + #[test] fn the_prolog_is_present_only_when_asked_for() { - let doc = Doc { blocks: Vec::new(), first_h1: None }; - assert!(document(&doc, "T", true).starts_with(""), "{out}"); + assert!(out.contains(""), "{out}"); + assert!( + out.contains(""), + "{out}" + ); + } + + #[test] + fn a_mews_page_drops_raw_html_it_cannot_vouch_for() { + let hosts = vec!["example.test".to_string()]; + let doc = Doc { blocks: vec![Block::Html("
    x
    ".into())], first_h1: None }; + let out = document(&doc, "T", false, Some(&hosts)); + assert!(!out.contains("
    "), "{out}"); + + let plain = document(&doc, "T", false, None); + assert!(plain.contains("
    x
    "), "{plain}"); + } + + fn image_doc(src: &str) -> Doc { + Doc { + blocks: vec![Block::Paragraph(vec![Inline::Image { + src: src.into(), + title: None, + alt: vec![Inline::Text("a photo".into())], + }])], + first_h1: None, + } + } + + #[test] + fn a_mews_page_keeps_a_relative_or_same_site_image() { + let hosts = vec!["example.test".to_string()]; + for src in ["/i.png", "i.png", "https://example.test/i.png", "//img.example.test/i.png"] { + let out = document(&image_doc(src), "T", false, Some(&hosts)); + assert!(out.contains(&format!("src=\"{src}\"")), "{src}: {out}"); + } + } + + #[test] + fn a_mews_page_drops_an_off_site_or_data_image_to_its_alt_text() { + let hosts = vec!["example.test".to_string()]; + for src in ["https://elsewhere.test/i.png", "data:image/png;base64,AAAA"] { + let out = document(&image_doc(src), "T", false, Some(&hosts)); + assert!(!out.contains("