1//! Which address a request came to, and whether the site answers it or sends it to its own. 2//! 3//! The site has one canonical address, `config::SITE_ORIGIN` (or the `SITE_ORIGIN` variable the 4//! deploy passes). Every other address is "elsewhere" and leads there for good, except a machine's 5//! own (`localhost`, `127.0.0.1`, `::1`), where a dev server previews itself. "Elsewhere" is 6//! therefore not a list of names (the account's `workers.dev` address is in no tracked file): it 7//! is whatever is neither the canonical host nor a local one, so a host nobody thought of cannot 8//! serve a second copy of the page. 9//! 10//! A page still open on an old address keeps working: the requests a page makes for itself 11//! (`Sec-Fetch-Mode` other than `navigate`, which includes Datastar's `POST /mood`, `/tick`, 12//! `/tour` and `/mascot`, and the page's own scripts, styles and fonts) are answered where they 13//! arrive, and the page moves the next time it is loaded. Link-preview fetchers send no such 14//! header, so they are sent on, which moves a pasted old link's preview to the canonical one. 15//! A method that is not GET or HEAD, from something that is not a page, is told where the site is 16//! (`405`), as a redirect would repeat it somewhere it was never meant to go. 17//! 18//! Pure: strings in, a decision out, tested natively. 19 20use http::{Method, StatusCode}; 21 22/// Which kind of address a `Host` is. 23#[derive(Clone, Copy, Debug, PartialEq, Eq)] 24pub enum Host { 25 /// The site's own address. 26 Canonical, 27 /// This machine: a dev server, which redirects nothing. 28 Local, 29 /// Any other address: it leads to the canonical one. 30 Elsewhere, 31} 32 33/// `https://host[:port]` as its host (and port), lower-cased; `None` if it is not an origin. 34pub fn host_of(origin: &str) -> Option<String> { 35 let (_, host) = origin.split_once("://")?; 36 (!host.is_empty() && !host.contains(['/', '?', '#'])).then(|| host.to_ascii_lowercase()) 37} 38 39/// A host with its port taken off (an IPv6 literal keeps its brackets). 40fn without_port(host: &str) -> &str { 41 if host.starts_with('[') { 42 return host.split_once(']').map_or(host, |(inside, _)| &host[..inside.len() + 1]); 43 } 44 host.split_once(':').map_or(host, |(name, _)| name) 45} 46 47pub fn is_local(host: &str) -> bool { 48 let name = without_port(host); 49 name == "localhost" || name.ends_with(".localhost") || name == "127.0.0.1" || name == "[::1]" 50} 51 52/// Which address `host` (a `Host` header) is, given the canonical origin. 53pub fn classify(host: &str, origin: &str) -> Host { 54 let host = host.trim().to_ascii_lowercase(); 55 if host_of(origin).is_some_and(|canonical| canonical == host) { 56 Host::Canonical 57 } else if is_local(&host) { 58 Host::Local 59 } else { 60 Host::Elsewhere 61 } 62} 63 64/// A request, as far as `gate` reads it. 65pub struct Asked<'a> { 66 /// The `Host` header. 67 pub host: &'a str, 68 pub method: &'a Method, 69 /// The path and the query, as sent. 70 pub target: &'a str, 71 /// A request a page made for itself (`Sec-Fetch-Mode` present and not `navigate`). 72 pub own: bool, 73} 74 75#[derive(Debug, PartialEq, Eq)] 76pub enum Gate { 77 /// Answer it here. 78 Pass, 79 /// Send it on for good, with this status. 80 Moved { to: String, status: StatusCode }, 81 /// Answer `405` with this message. 82 Refused(String), 83} 84 85pub fn gate(origin: &str, asked: &Asked<'_>) -> Gate { 86 if classify(asked.host, origin) != Host::Elsewhere { 87 return Gate::Pass; 88 } 89 // A page that is open here finishes here (see the module's note). 90 if asked.own { 91 return Gate::Pass; 92 } 93 if matches!(*asked.method, Method::GET | Method::HEAD) { 94 // `308`, not `301`: the method is kept. The path and the query are kept as sent. 95 return Gate::Moved { to: format!("{origin}{}", asked.target), status: StatusCode::PERMANENT_REDIRECT }; 96 } 97 Gate::Refused(format!("The site is at {origin}, not here: use {origin}{}", asked.target.split('?').next().unwrap_or("/"))) 98} 99 100#[cfg(test)] 101mod tests { 102 use super::*; 103 104 const CANON: &str = "https://whiskers.lmjtfy.fun"; 105 // Built here so no tracked file carries a real account's name. 106 const OLD: &str = "whiskers.example-account.workers.dev"; 107 108 fn ask<'a>(host: &'a str, method: &'a Method, target: &'a str, own: bool) -> Asked<'a> { 109 Asked { host, method, target, own } 110 } 111 112 fn moved(to: &str) -> Gate { 113 Gate::Moved { to: to.to_owned(), status: StatusCode::PERMANENT_REDIRECT } 114 } 115 116 #[test] 117 fn an_address_is_told_from_the_others() { 118 assert_eq!(classify("whiskers.lmjtfy.fun", CANON), Host::Canonical); 119 assert_eq!(classify("Whiskers.LMJTFY.fun", CANON), Host::Canonical); 120 assert_eq!(classify(OLD, CANON), Host::Elsewhere); 121 assert_eq!(classify("localhost:8789", CANON), Host::Local); 122 assert_eq!(classify("localhost", CANON), Host::Local); 123 assert_eq!(classify("127.0.0.1:8789", CANON), Host::Local); 124 assert_eq!(classify("[::1]:8789", CANON), Host::Local); 125 assert_eq!(classify("app.localhost:1", CANON), Host::Local); 126 assert_eq!(classify("unknown.example", CANON), Host::Elsewhere); 127 assert_eq!(classify("", CANON), Host::Elsewhere); 128 // Not a suffix or prefix match: a name that merely contains ours is no one's. 129 assert_eq!(classify("whiskers.lmjtfy.fun.evil.example", CANON), Host::Elsewhere); 130 assert_eq!(classify("evil-whiskers.lmjtfy.fun", CANON), Host::Elsewhere); 131 assert_eq!(classify("localhost.evil.example", CANON), Host::Elsewhere); 132 assert_eq!(classify("lmjtfy.fun", CANON), Host::Elsewhere); 133 } 134 135 #[test] 136 fn a_dev_server_whose_origin_is_local_is_canonical_at_its_own_port() { 137 assert_eq!(classify("localhost:8788", "http://localhost:8788"), Host::Canonical); 138 assert_eq!(classify("localhost:9", "http://localhost:8788"), Host::Local); 139 } 140 141 #[test] 142 fn the_origin_is_read_for_its_host() { 143 assert_eq!(host_of(CANON).as_deref(), Some("whiskers.lmjtfy.fun")); 144 assert_eq!(host_of("http://localhost:8788").as_deref(), Some("localhost:8788")); 145 assert_eq!(host_of("whiskers.lmjtfy.fun"), None); 146 assert_eq!(host_of("https://a.example/path"), None); 147 } 148 149 #[test] 150 fn the_old_address_is_sent_on_with_its_path_and_query() { 151 for target in ["/", "/?mascot=bunny", "/preview/card.png", "/preview/loop.mp4", "/robots.txt", "/a/b?x=1&y=%20z"] { 152 for method in [Method::GET, Method::HEAD] { 153 assert_eq!(gate(CANON, &ask(OLD, &method, target, false)), moved(&format!("{CANON}{target}")), "{method} {target}"); 154 } 155 } 156 assert_eq!(gate(CANON, &ask("unknown.example", &Method::GET, "/", false)), moved("https://whiskers.lmjtfy.fun/")); 157 } 158 159 #[test] 160 fn other_methods_are_told_where_the_site_is() { 161 for method in [Method::POST, Method::PUT, Method::DELETE, Method::PATCH] { 162 match gate(CANON, &ask(OLD, &method, "/mood?x=1", false)) { 163 Gate::Refused(why) => { 164 assert!(why.contains(CANON), "{why}"); 165 assert!(!why.contains("x=1"), "a query is not echoed: {why}"); 166 } 167 other => panic!("{method}: {other:?}"), 168 } 169 } 170 } 171 172 #[test] 173 fn a_page_open_on_the_old_address_finishes_there() { 174 for (method, target) in [(Method::POST, "/mood"), (Method::POST, "/tick"), (Method::POST, "/tour"), (Method::POST, "/mascot"), (Method::GET, "/cat.css?v=1"), (Method::GET, "/datastar.js")] { 175 assert_eq!(gate(CANON, &ask(OLD, &method, target, true)), Gate::Pass, "{method} {target}"); 176 } 177 // A navigation is not its own request. 178 assert_eq!(gate(CANON, &ask(OLD, &Method::GET, "/", false)), moved("https://whiskers.lmjtfy.fun/")); 179 } 180 181 #[test] 182 fn the_canonical_and_local_hosts_are_never_sent_anywhere() { 183 for host in ["whiskers.lmjtfy.fun", "localhost:8789", "127.0.0.1:8789", "[::1]:8789"] { 184 for method in [Method::GET, Method::HEAD, Method::POST] { 185 for own in [false, true] { 186 assert_eq!(gate(CANON, &ask(host, &method, "/?mascot=grey", own)), Gate::Pass, "{host} {method}"); 187 } 188 } 189 } 190 } 191 192 #[test] 193 fn what_the_redirect_leads_to_does_not_redirect() { 194 // No loop: the target of every move is passed, at the canonical host. 195 let Gate::Moved { to, .. } = gate(CANON, &ask(OLD, &Method::GET, "/x?y=1", false)) else { panic!() }; 196 let host = host_of(CANON).unwrap(); 197 assert!(to.starts_with(&format!("https://{host}/"))); 198 assert_eq!(gate(CANON, &ask(&host, &Method::GET, "/x?y=1", false)), Gate::Pass); 199 } 200}