waybetter/crates/waybetter-host/src/site.rs
CRT b2a91ff7b3 Extract the wayBetter framework core from wayBetter for X
Site-agnostic parts of the X app, behind a small site adapter:

- crates/waybetter-host: webview-label security policy and tab rect
  clamp, account profiles with native-owned accounts (AccountRules),
  plugin store with bundled-plugin upgrades (PluginHost), signed
  data-only hotfix channel, update scheduling and crash handshake, tab
  payloads/link routing/tab-chrome validation, and (feature "tauri")
  webview helpers and the Windows system menu.
- packages/runtime: DOM hub, request hub, plugin API core, OKLab theme
  color model, split-view rail hiding, link routing and account
  reporting, each configured by a SiteAdapter.
- packages/shell: address bar, pane, link-drop, profile, palette and
  update-chip models, and window controls with the Tauri API injected.

Tests moved/adapted from the app: 45 Rust (headless), 40 TypeScript.
2026-09-28 03:01:33 +07:00

106 lines
3.9 KiB
Rust

//! 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://<first host>`, 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))));
}
}