whiskers.git / web / site / src / host.rs
host.rsannotatedhost.rssource200 lines · 8.6 KB · raw

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};

Which kind of address a Host is.

23#[derive(Clone, Copy, Debug, PartialEq, Eq)]
24pub enum Host {

The site's own address.

26    Canonical,

This machine: a dev server, which redirects nothing.

28    Local,

Any other address: it leads to the canonical one.

30    Elsewhere,
31}

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}

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}
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}

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}

A request, as far as gate reads it.

65pub struct Asked<'a> {

The Host header.

67    pub host: &'a str,
68    pub method: &'a Method,

The path and the query, as sent.

70    pub target: &'a str,

A request a page made for itself (Sec-Fetch-Mode present and not navigate).

72    pub own: bool,
73}
75#[derive(Debug, PartialEq, Eq)]
76pub enum Gate {

Answer it here.

78    Pass,

Send it on for good, with this status.

80    Moved { to: String, status: StatusCode },

Answer 405 with this message.

82    Refused(String),
83}
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}