//! The site adapter: everything the host needs to know about the one site an app extends. //! //! An app declares one `static SITE: SiteSpec` and passes it to the host helpers. Nothing in the //! host hard-codes a site; the security boundary (which webviews count as "site tabs" and may call //! site-only commands) is derived from `hosts`. use url::Url; /// How the account signed in inside a profile is validated before it is stored. The page runtime /// reads it from the site's own UI; native code accepts only values that pass these rules. #[derive(Clone, Copy, Debug)] pub struct AccountRules { /// A normalized handle (no leading `@`) the site could really have. pub valid_handle: fn(&str) -> bool, /// Avatar URLs must start with one of these (the site's own image CDN), else they are dropped. pub avatar_prefixes: &'static [&'static str], /// Display names are cut to this many characters. pub max_name_chars: usize, /// Avatar URLs longer than this are dropped. pub max_avatar_len: usize, } impl AccountRules { pub fn avatar_allowed(&self, url: &str) -> bool { url.len() <= self.max_avatar_len && self.avatar_prefixes.iter().any(|prefix| url.starts_with(prefix)) && !url.contains(|ch: char| ch == '"' || ch == '\'' || ch == '\\' || ch.is_whitespace()) } } #[derive(Clone, Copy, Debug)] pub struct SiteSpec { /// Product name for logs and native notifications, e.g. "wayBetter for X". pub product: &'static str, /// Exact HTTPS hosts (port 443, no credentials) whose pages are site tabs. pub hosts: &'static [&'static str], /// The page runtime's window global, e.g. `BetterTwttr` (`window.BetterTwttr`). pub runtime_global: &'static str, pub accounts: AccountRules, } impl SiteSpec { /// Whether `url` is exactly one of the site's origins (https, default port, no credentials). pub fn is_origin(&self, url: &Url) -> bool { crate::policy::plain_origin(url) && url.scheme() == "https" && url.host_str().is_some_and(|host| self.hosts.contains(&host)) && url.port_or_known_default() == Some(443) } /// `https://`, the origin the page runtime checks before it runs. pub fn primary_origin(&self) -> String { format!("https://{}", self.hosts.first().copied().unwrap_or_default()) } } #[cfg(test)] pub(crate) mod testing { use super::*; fn handle(value: &str) -> bool { (1..=15).contains(&value.len()) && value.bytes().all(|ch| ch.is_ascii_alphanumeric() || ch == b'_') } /// An X-shaped site for the host's own tests. pub static SITE: SiteSpec = SiteSpec { product: "wayBetter for Test", hosts: &["x.com"], runtime_global: "BetterTwttr", accounts: AccountRules { valid_handle: handle, avatar_prefixes: &["https://pbs.twimg.com/"], max_name_chars: 50, max_avatar_len: 512, }, }; } #[cfg(test)] mod tests { use super::testing::SITE; use url::Url; #[test] fn site_origin_is_exact() { assert!(SITE.is_origin(&Url::parse("https://x.com/home").unwrap())); for bad in [ "http://x.com/", "https://x.com:444/", "https://user@x.com/", "https://www.x.com/", "https://x.com.evil.test/", ] { assert!(!SITE.is_origin(&Url::parse(bad).unwrap()), "{bad}"); } assert_eq!(SITE.primary_origin(), "https://x.com"); } #[test] fn avatars_must_come_from_the_site_cdn() { assert!(SITE.accounts.avatar_allowed("https://pbs.twimg.com/profile_images/1/a.jpg")); assert!(!SITE.accounts.avatar_allowed("https://evil.test/a.jpg")); assert!(!SITE.accounts.avatar_allowed("https://pbs.twimg.com/a b.jpg")); assert!(!SITE.accounts.avatar_allowed(&format!("https://pbs.twimg.com/{}", "a".repeat(600)))); } }