Which address a request came to, and whether the site answers it or sends it to its own.
The site has one canonical address, config::SITE_ORIGIN (or the SITE_ORIGIN variable the
deploy passes). Every other address is "elsewhere" and leads there for good, except a machine's
own (localhost, 127.0.0.1, ::1), where a dev server previews itself. "Elsewhere" is
therefore not a list of names (the account's workers.dev address is in no tracked file): it
is whatever is neither the canonical host nor a local one, so a host nobody thought of cannot
serve a second copy of the page.
A page still open on an old address keeps working: the requests a page makes for itself
(Sec-Fetch-Mode other than navigate, which includes Datastar's POST /mood, /tick,
/tour and /mascot, and the page's own scripts, styles and fonts) are answered where they
arrive, and the page moves the next time it is loaded. Link-preview fetchers send no such
header, so they are sent on, which moves a pasted old link's preview to the canonical one.
A method that is not GET or HEAD, from something that is not a page, is told where the site is
(405), as a redirect would repeat it somewhere it was never meant to go.
Pure: strings in, a decision out, tested natively.
20use http::{Method, StatusCode};
The site's own address.
26 Canonical,
This machine: a dev server, which redirects nothing.
28 Local,
https://host[:port] as its host (and port), lower-cased; None if it is not an origin.
A host with its port taken off (an IPv6 literal keeps its brackets).
Which address host (a Host header) is, given the canonical origin.
A request, as far as gate reads it.
65pub struct Asked<'a> {
The path and the query, as sent.
70 pub target: &'a str,
Answer it here.
78 Pass,
Send it on for good, with this status.
80 Moved { to: String, status: StatusCode },
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}