//! 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("さくら"), "さくら");
}
}