//! The HTML atgc emits, and the helpers every page of it shares. //! //! Two kinds of page live here. [`pages`] is what a browser lands on when //! `atgc auth login` finishes, which is the only part of atgc a person sees //! rendered rather than printed; `site` is atgc.codes, which `atgc www` //! writes out and nothing at runtime ever asks for. That one is behind the //! off-by-default `www` feature, so an installed atgc has neither the module //! nor the command — see the `[features]` note in `Cargo.toml`. //! //! The first of them used to live inside the OAuth client, next to the //! socket that serves it, which put markup and SVG in the middle of a module //! whose subject is tokens, and made the field of DNA it hangs on an import //! from a *command* module. Both are the same mistake in different //! directions: a page is neither a client nor a command, it is a document. //! //! So documents live here, and what they have in common lives in [`brand`]: //! one palette, one mark, one favicon, one field. Two pages that each //! declared their own `--a` is exactly the drift `brand/build.py --check` //! catches on the raster side and nothing was catching here. //! //! # What is not here //! //! Writing a page to a socket. `respond_html` stays in //! [`crate::clients::atproto::oauth`], because putting bytes on a `TcpStream` //! is talking to a counterpart and that is what `clients/` is for. This //! module builds strings and opens nothing. //! //! The art is not here either — [`crate::art`] draws the field for both the //! terminal and the browser, and [`field`] is only its markup rendering. pub(crate) mod brand; pub(crate) mod field; pub(crate) mod pages; #[cfg(any(feature = "www", test))] pub(crate) mod site; /// Text bound for a page. /// /// A handle and a DID are constrained enough to be safe as they stand, but a /// display name is whatever the account put in its Bluesky profile and the /// avatar is a URL from the same place, so both arrive as somebody else's /// string. Escaped rather than trusted: the page is served from a loopback /// port this process opened while holding fresh tokens, and markup smuggled /// through a profile has no business running there. /// /// Named `escape` and not `escape_html` now that it lives in a module called /// `html` — `html::escape` says it once. pub(crate) fn escape(text: &str) -> String { let mut out = String::with_capacity(text.len()); for ch in text.chars() { match ch { '&' => out.push_str("&"), '<' => out.push_str("<"), '>' => out.push_str(">"), '"' => out.push_str("""), '\'' => out.push_str("'"), _ => out.push(ch), } } out } #[cfg(test)] mod tests { use super::escape; /// All five, including the two that only matter inside an attribute: /// the avatar URL is interpolated into one. #[test] fn every_character_that_can_end_a_context_is_escaped() { assert_eq!( escape(r#"&"#), "<img src="x" onerror='alert(1)'>&" ); } /// Ordinary text is returned unchanged, display names included: a /// person whose name is written in another script must not arrive as /// entities. #[test] fn ordinary_text_passes_through() { assert_eq!(escape("Ada Lovelace"), "Ada Lovelace"); assert_eq!(escape("さくら"), "さくら"); } }