lmjtfy.git / apps / lmjtfy / src / host.rs
host.rsannotatedhost.rssource491 lines · 22.0 KB · raw
1//! Which of the site's addresses a request came to, and what each one does.
2//!
3//! There are two homes. `lmjtfy.fun` is the site: the page, `/ask`, the
4//! archive's feed and sockets. `code.lmjtfy.fun` is the code: the clone
5//! routes, the code pages and a front page listing every repository
6//! (`clone::Repo::ALL`), and nothing else, so no question can be asked there
7//! and no socket opened. The old addresses (`www.lmjtfy.fun`, the
8//! `workers.dev` one) lead to the right home for good.
9//!
10//! The split is made twice, so neither half can be forgotten. `gate` decides
11//! what happens to a request before any route sees it: a repository's path
12//! on `lmjtfy.fun` or an old address is sent to the code host. And `fetch`
13//! (`lib.rs`) gives each host its own router, so the routes a host does not
14//! serve are not there to be reached by a path `gate` did not think of.
15//!
16//! Pure: strings and a method in, a decision out, tested natively.
17
18use axum::http::{Method, StatusCode};
19
20use crate::clone::Repo;
21
22/// The site's address, bare.
23pub const HOME: &str = "lmjtfy.fun";
24/// The code's: `git clone https://code.lmjtfy.fun/<repo>.git`.
25pub const CODE: &str = "code.lmjtfy.fun";
26/// The addresses that lead to `HOME`: where the site was first served, and
27/// the same name with `www`.
28const ELSEWHERE: [&str; 2] = ["lmjtfy.deizel.workers.dev", "www.lmjtfy.fun"];
29
30/// Whether the old addresses send the code to the code host: the Worker var
31/// `GIT_REDIRECT` (`wrangler.toml`).
32///
33/// A closed type, so a half-way state cannot be written: `Off` is how the
34/// site was before the code host (the apex serves the clones itself, the
35/// code host serves them too, and no page offers an address that is not yet
36/// known to work); `On` is the end state. One deploy ships the code host
37/// with `Off`; once it is checked, a second turns `On` (README, "Rolling out").
38#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
39pub enum GitRedirect {
40    /// The default, and the fallback for a value that is neither.
41    #[default]
42    Off,
43    On,
44}
45
46/// A `GIT_REDIRECT` that is neither `on` nor `off`.
47#[derive(Debug, PartialEq, Eq)]
48pub struct BadSwitch(pub String);
49
50impl std::fmt::Display for BadSwitch {
51    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52        write!(f, "GIT_REDIRECT is {:?}; it must be exactly \"on\" or \"off\". Treating it as \"off\": the apex keeps serving clones.", self.0)
53    }
54}
55
56impl GitRedirect {
57    /// The var's name.
58    pub const VAR: &'static str = "GIT_REDIRECT";
59
60    /// Unset is `Off`. Anything but exactly `on` or `off` is refused, not
61    /// guessed at ("true", "ON", " on" and "" included): the caller says so
62    /// loudly and runs as `Off` (`resolve`).
63    pub fn parse(value: Option<&str>) -> Result<GitRedirect, BadSwitch> {
64        match value {
65            None | Some("off") => Ok(GitRedirect::Off),
66            Some("on") => Ok(GitRedirect::On),
67            Some(other) => Err(BadSwitch(other.to_owned())),
68        }
69    }
70
71    /// The switch to run with, and the complaint to log if the var was
72    /// refused. `Off` is the fallback because it is the behaviour that
73    /// cannot break a working clone: it never sends anyone to an address
74    /// that may not be live, and a mistyped var then fails safe, visibly
75    /// in the logs, rather than redirecting every clone.
76    pub fn resolve(value: Option<&str>) -> (GitRedirect, Option<String>) {
77        match GitRedirect::parse(value) {
78            Ok(switch) => (switch, None),
79            Err(bad) => (GitRedirect::Off, Some(bad.to_string())),
80        }
81    }
82
83    pub fn is_on(self) -> bool {
84        self == GitRedirect::On
85    }
86}
87
88/// Which address a request's `Host` is.
89#[derive(Clone, Copy, Debug, PartialEq, Eq)]
90pub enum Host {
91    /// `lmjtfy.fun`.
92    Home,
93    /// `code.lmjtfy.fun`.
94    Code,
95    /// An old address, which leads to the right home.
96    Elsewhere,
97    /// Anything else: staging, a dev server. It serves everything, git
98    /// included, and redirects nothing, so a change can be tried where it is.
99    Other,
100}
101
102impl Host {
103    pub fn of(host: &str) -> Host {
104        let host = host.to_ascii_lowercase();
105        match host.as_str() {
106            HOME => Host::Home,
107            CODE => Host::Code,
108            _ if ELSEWHERE.contains(&host.as_str()) => Host::Elsewhere,
109            _ => Host::Other,
110        }
111    }
112
113    /// Whether this address serves the repositories itself. The code host
114    /// and a dev server or staging always do; the site's own addresses only
115    /// until the redirect is on.
116    pub fn serves_git(self, redirect: GitRedirect) -> bool {
117        match self {
118            Host::Code | Host::Other => true,
119            Host::Home | Host::Elsewhere => !redirect.is_on(),
120        }
121    }
122
123    /// Whether this address serves the repositories' release files
124    /// (`release.rs`). Only the code host, and a dev server or staging, which
125    /// have no code host of their own. Never the site's addresses, whatever
126    /// the redirect: downloads are not a thing the site did before the code
127    /// host existed, so there is nothing to keep working there.
128    pub fn serves_downloads(self) -> bool {
129        matches!(self, Host::Code | Host::Other)
130    }
131
132    /// Where a clone is made from, for the commands pages offer: the code
133    /// host for the site's own addresses once the redirect is on (it is
134    /// then known to work), and wherever the page is until then, and for a
135    /// staging or dev server, which serves the clone itself. So a page
136    /// never advertises an address that has not been checked.
137    pub fn code_origin(self, own: &str, redirect: GitRedirect) -> String {
138        match self {
139            Host::Home | Host::Elsewhere if redirect.is_on() => format!("https://{CODE}"),
140            _ => own.to_owned(),
141        }
142    }
143
144    /// Where the site's front page is, for a link from a page that may be on
145    /// the code host.
146    pub fn home_link(self) -> &'static str {
147        match self {
148            Host::Code => "https://lmjtfy.fun/",
149            _ => "/",
150        }
151    }
152
153    /// Whether pages here take part in what the site does live: the online
154    /// count and toasts over `/live`, and the report of a page to `/seen`.
155    /// The code host has neither route.
156    pub fn is_live(self) -> bool {
157        self != Host::Code
158    }
159}
160
161/// A request, as far as `gate` reads it.
162pub struct Asked<'a> {
163    pub host: &'a str,
164    pub method: &'a Method,
165    /// The path and the query, as sent: `/lmjtfy.git/info/refs?service=git-upload-pack`.
166    pub target: &'a str,
167    /// A WebSocket upgrade.
168    pub socket: bool,
169    /// A request a page made for itself (`Sec-Fetch-Mode` other than
170    /// `navigate`), rather than a person or git following a link.
171    pub own: bool,
172}
173
174/// What to do with a request before routing it.
175#[derive(Debug, PartialEq, Eq)]
176pub enum Gate {
177    /// Route it.
178    Pass,
179    /// Send it on for good.
180    Moved { to: String, status: StatusCode },
181    /// Answer `405` with this: a method that was never served at the address
182    /// the repository left.
183    Refused(String),
184}
185
186/// The repository a path is under (`/lmjtfy.git`, `/lmjtfy.git/info/refs`),
187/// if it is one that is served. Only the exact name counts: `/lmjtfy.gitx`
188/// and `/other.git` are not.
189pub fn repository(path: &str) -> Option<Repo> {
190    Repo::named(path.strip_prefix('/')?.split('/').next()?)
191}
192
193pub fn gate(asked: &Asked<'_>, redirect: GitRedirect) -> Gate {
194    let host = Host::of(asked.host);
195    if matches!(host, Host::Code | Host::Other) {
196        return Gate::Pass;
197    }
198    let path = asked.target.split_once('?').map_or(asked.target, |(path, _)| path);
199    let get = matches!(*asked.method, Method::GET | Method::HEAD);
200    // The code left for its own address. Git follows the redirect of its
201    // first request, `GET info/refs?service=…`, and makes every request after
202    // it, the `POST`s of the pack, at the address it was sent to. So a
203    // `POST` that arrives here is not git following the redirect; it is
204    // something that was never told, and is told. `308`, not `301`: the
205    // method and the path are kept, and a client that ever did send a `POST`
206    // would send it again to the new address.
207    // With the redirect off, the code is not moved: the old addresses do what
208    // they did before the code host existed, below.
209    // The release files (`/whiskers/latest/...`) follow the clone: with the
210    // redirect on, a `GET` is sent to the code host, and anything else is
211    // told. With it off they are not served here at all (`serves_downloads`),
212    // and the path is a `404`.
213    if redirect.is_on() && crate::release::under(path).is_some() {
214        return if get {
215            Gate::Moved { to: format!("https://{CODE}{}", asked.target), status: StatusCode::PERMANENT_REDIRECT }
216        } else {
217            Gate::Refused(format!("The downloads are at https://{CODE}, not here: https://{CODE}{path}"))
218        };
219    }
220    if redirect.is_on() && repository(path).is_some() {
221        return if get {
222            Gate::Moved { to: format!("https://{CODE}{}", asked.target), status: StatusCode::PERMANENT_REDIRECT }
223        } else {
224            Gate::Refused(format!("The code is at https://{CODE}, not here: git clone https://{CODE}{path}"))
225        };
226    }
227    // The old addresses of the site itself. A page still open there is left
228    // to finish there: its socket and its own requests would only break if
229    // sent on, and it moves the next time it is loaded.
230    if host == Host::Elsewhere && get && !asked.socket && !asked.own {
231        return Gate::Moved { to: format!("https://{HOME}{}", asked.target), status: StatusCode::MOVED_PERMANENTLY };
232    }
233    Gate::Pass
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239
240    fn ask<'a>(host: &'a str, method: &'a Method, target: &'a str) -> Asked<'a> {
241        Asked { host, method, target, socket: false, own: false }
242    }
243
244    use GitRedirect::{Off, On};
245
246    const OLD: [&str; 3] = [HOME, "www.lmjtfy.fun", "lmjtfy.deizel.workers.dev"];
247
248    #[test]
249    fn an_address_is_told_from_the_others() {
250        assert_eq!(Host::of("lmjtfy.fun"), Host::Home);
251        assert_eq!(Host::of("LMJTFY.fun"), Host::Home);
252        assert_eq!(Host::of("code.lmjtfy.fun"), Host::Code);
253        assert_eq!(Host::of("www.lmjtfy.fun"), Host::Elsewhere);
254        assert_eq!(Host::of("lmjtfy.deizel.workers.dev"), Host::Elsewhere);
255        assert_eq!(Host::of("staging.lmjtfy.fun"), Host::Other);
256        assert_eq!(Host::of("localhost:8787"), Host::Other);
257        // Not a suffix match: a name that merely ends in ours is no one's.
258        assert_eq!(Host::of("evil-code.lmjtfy.fun"), Host::Other);
259        assert_eq!(Host::of("code.lmjtfy.fun.evil.example"), Host::Other);
260        assert_eq!(Host::of(""), Host::Other);
261    }
262
263    #[test]
264    fn only_the_code_host_lacks_the_live_routes() {
265        assert!(!Host::Code.is_live());
266        for host in [Host::Home, Host::Elsewhere, Host::Other] {
267            assert!(host.is_live());
268        }
269        assert_eq!(Host::Code.home_link(), "https://lmjtfy.fun/");
270        assert_eq!(Host::Home.home_link(), "/");
271    }
272
273    #[test]
274    fn clones_are_offered_from_the_code_host_only_once_it_is_on() {
275        assert_eq!(Host::Home.code_origin("https://lmjtfy.fun", On), "https://code.lmjtfy.fun");
276        assert_eq!(Host::Elsewhere.code_origin("https://www.lmjtfy.fun", On), "https://code.lmjtfy.fun");
277        assert_eq!(Host::Code.code_origin("https://code.lmjtfy.fun", On), "https://code.lmjtfy.fun");
278        assert_eq!(Host::Other.code_origin("http://localhost:8787", On), "http://localhost:8787");
279        // Off: nothing is advertised that has not been checked; the page's
280        // own address serves the clone.
281        assert_eq!(Host::Home.code_origin("https://lmjtfy.fun", Off), "https://lmjtfy.fun");
282        assert_eq!(Host::Elsewhere.code_origin("https://www.lmjtfy.fun", Off), "https://www.lmjtfy.fun");
283        assert_eq!(Host::Code.code_origin("https://code.lmjtfy.fun", Off), "https://code.lmjtfy.fun");
284        assert_eq!(Host::Other.code_origin("http://localhost:8787", Off), "http://localhost:8787");
285    }
286
287    #[test]
288    fn the_switch_is_parsed_strictly_and_defaults_off() {
289        assert_eq!(GitRedirect::default(), Off);
290        assert_eq!(GitRedirect::parse(None), Ok(Off));
291        assert_eq!(GitRedirect::parse(Some("off")), Ok(Off));
292        assert_eq!(GitRedirect::parse(Some("on")), Ok(On));
293        for bad in ["", "ON", "Off", "true", "1", " on", "on ", "yes", "enabled"] {
294            assert_eq!(GitRedirect::parse(Some(bad)), Err(BadSwitch(bad.to_owned())), "{bad:?}");
295        }
296    }
297
298    /// A var that is wrong runs as off, and says so; one that is right says
299    /// nothing.
300    #[test]
301    fn a_refused_value_falls_back_to_off_with_a_complaint() {
302        assert_eq!(GitRedirect::resolve(None), (Off, None));
303        assert_eq!(GitRedirect::resolve(Some("on")), (On, None));
304        let (switch, complaint) = GitRedirect::resolve(Some("ON"));
305        assert_eq!(switch, Off);
306        let complaint = complaint.expect("a complaint");
307        assert!(complaint.contains("GIT_REDIRECT") && complaint.contains("\"ON\"") && complaint.contains("off"), "{complaint}");
308    }
309
310    #[test]
311    fn who_serves_the_repositories_in_each_mode() {
312        for (host, off, on) in [(Host::Home, true, false), (Host::Elsewhere, true, false), (Host::Code, true, true), (Host::Other, true, true)] {
313            assert_eq!(host.serves_git(Off), off, "{host:?} off");
314            assert_eq!(host.serves_git(On), on, "{host:?} on");
315        }
316    }
317
318    /// Off is the site as it was before the code host: nothing under a
319    /// repository's name is moved or refused at the apex, www and workers.dev
320    /// get the 301 of everything to the apex (git follows it), and the code
321    /// host passes everything.
322    #[test]
323    fn with_the_redirect_off_the_old_addresses_do_what_they_did_before() {
324        let refs = "/lmjtfy.git/info/refs?service=git-upload-pack";
325        for method in [Method::GET, Method::HEAD, Method::POST] {
326            assert_eq!(gate(&ask(HOME, &method, refs), Off), Gate::Pass, "{method}");
327            assert_eq!(gate(&ask(HOME, &method, "/lmjtfy.git/git-upload-pack"), Off), Gate::Pass, "{method}");
328        }
329        for host in ["www.lmjtfy.fun", "lmjtfy.deizel.workers.dev"] {
330            assert_eq!(
331                gate(&ask(host, &Method::GET, refs), Off),
332                Gate::Moved { to: format!("https://lmjtfy.fun{refs}"), status: StatusCode::MOVED_PERMANENTLY },
333                "{host}"
334            );
335            assert_eq!(gate(&ask(host, &Method::POST, "/lmjtfy.git/git-upload-pack"), Off), Gate::Pass, "{host}");
336        }
337        for target in [refs, "/", "/lmjtfy.git"] {
338            assert_eq!(gate(&ask(CODE, &Method::GET, target), Off), Gate::Pass);
339        }
340    }
341
342    /// Everything that is not about the repositories is the same in both modes.
343    #[test]
344    fn the_modes_differ_only_in_what_is_about_a_repository() {
345        for host in OLD.into_iter().chain([CODE, "staging.lmjtfy.fun"]) {
346            for target in ["/", "/rules", "/ask", "/feed?ms=1", "/nosuchrepo.git/info/refs"] {
347                for method in [Method::GET, Method::POST] {
348                    for (socket, own) in [(false, false), (true, false), (false, true)] {
349                        let asked = Asked { host, method: &method, target, socket, own };
350                        assert_eq!(gate(&asked, Off), gate(&asked, On), "{method} {host}{target}");
351                    }
352                }
353            }
354        }
355    }
356
357    /// Every repository served, at every address that is not the code host,
358    /// with and without a query and a trailing path: the same path and query
359    /// at `code.lmjtfy.fun`, with a `308`.
360    #[test]
361    fn every_repository_leads_to_the_code_host_with_its_path_and_query() {
362        let suffixes = [
363            "",
364            "/",
365            "/info/refs?service=git-upload-pack",
366            "/info/refs",
367            "/git-upload-pack",
368            "/apps/lmjtfy/src/lib.rs",
369            "/apps/lmjtfy/src/lib.rs?raw",
370            "/README.md?view=agents&x=a%20b",
371            "?view=agents",
372        ];
373        for host in OLD {
374            for repo in Repo::ALL {
375                for suffix in suffixes {
376                    for method in [Method::GET, Method::HEAD] {
377                        let target = format!("/{}{suffix}", repo.served());
378                        assert_eq!(
379                            gate(&ask(host, &method, &target), On),
380                            Gate::Moved { to: format!("https://code.lmjtfy.fun{target}"), status: StatusCode::PERMANENT_REDIRECT },
381                            "{method} {host}{target}"
382                        );
383                    }
384                }
385            }
386        }
387    }
388
389    /// git's own first request, which it follows, is among them, and it does
390    /// not matter what else the client sent: git sends neither header the
391    /// site's own pages do.
392    #[test]
393    fn gits_first_request_is_followed_to_the_same_suffix() {
394        let target = "/lmjtfy.git/info/refs?service=git-upload-pack";
395        let mut asked = ask(HOME, &Method::GET, target);
396        asked.own = true;
397        asked.socket = true;
398        assert_eq!(
399            gate(&asked, On),
400            Gate::Moved { to: format!("https://code.lmjtfy.fun{target}"), status: StatusCode::PERMANENT_REDIRECT }
401        );
402    }
403
404    /// A `POST` is not redirected: a client that sends one at the old address
405    /// was not following a redirect of its first request (git sends the rest
406    /// to the address that redirect gave it), and it is told where to go.
407    #[test]
408    fn a_post_to_the_old_address_is_told_where_the_code_is() {
409        for host in OLD {
410            for method in [Method::POST, Method::PUT, Method::DELETE, Method::PATCH] {
411                let got = gate(&ask(host, &method, "/lmjtfy.git/git-upload-pack"), On);
412                let Gate::Refused(why) = got else { panic!("{method} {host}: {got:?}") };
413                assert!(why.contains("https://code.lmjtfy.fun/lmjtfy.git/git-upload-pack"), "{why}");
414            }
415        }
416    }
417
418    #[test]
419    fn what_is_not_a_served_repository_is_not_sent_on() {
420        for target in ["/nosuchrepo.git/info/refs?service=git-upload-pack", "/lmjtfy.gitx", "/lmjtfy", "/x/lmjtfy.git", "/lmjtfy.git.evil/a", "//lmjtfy.git"] {
421            assert_eq!(gate(&ask(HOME, &Method::GET, target), On), Gate::Pass, "{target}");
422        }
423    }
424
425    #[test]
426    fn the_code_host_and_a_dev_server_are_not_redirected() {
427        for host in ["code.lmjtfy.fun", "staging.lmjtfy.fun", "localhost:8787", "127.0.0.1:8787"] {
428            for target in ["/lmjtfy.git/info/refs?service=git-upload-pack", "/", "/rules"] {
429                for method in [Method::GET, Method::POST] {
430                    assert_eq!(gate(&ask(host, &method, target), On), Gate::Pass, "{method} {host}{target}");
431                }
432            }
433        }
434    }
435
436    /// What `www` and the `workers.dev` address did before the code host
437    /// existed, kept: a permanent redirect of everything else to the bare
438    /// address, `301`, except what a page still open there is making.
439    #[test]
440    fn the_old_addresses_still_lead_to_the_site_for_good() {
441        let moved = |host, method: &Method, target, socket, own| {
442            gate(&Asked { host, method, target, socket, own }, On)
443        };
444        let site = |target: &str| Gate::Moved { to: format!("https://lmjtfy.fun{target}"), status: StatusCode::MOVED_PERMANENTLY };
445        assert_eq!(moved("www.lmjtfy.fun", &Method::GET, "/rules", false, false), site("/rules"));
446        assert_eq!(moved("lmjtfy.deizel.workers.dev", &Method::GET, "/?q=is+it%3F", false, false), site("/?q=is+it%3F"));
447        assert_eq!(moved("www.lmjtfy.fun", &Method::HEAD, "/", false, true), Gate::Pass);
448        assert_eq!(moved("www.lmjtfy.fun", &Method::POST, "/ask", false, false), Gate::Pass);
449        assert_eq!(moved("www.lmjtfy.fun", &Method::GET, "/live", true, false), Gate::Pass);
450        assert_eq!(moved("www.lmjtfy.fun", &Method::GET, "/feed?ms=1&q=x", false, true), Gate::Pass);
451        // The apex serves the site: nothing to move.
452        assert_eq!(moved(HOME, &Method::GET, "/", false, false), Gate::Pass);
453        assert_eq!(moved(HOME, &Method::GET, "/rules?a=b", false, false), Gate::Pass);
454    }
455
456    #[test]
457    fn a_repository_is_found_in_a_path_only_by_its_first_segment() {
458        assert_eq!(repository("/jevcrates.git"), Some(Repo::Jevcrates));
459        assert_eq!(repository("/jevcrates.git/info/refs"), Some(Repo::Jevcrates));
460        assert_eq!(repository("/"), None);
461        assert_eq!(repository(""), None);
462        assert_eq!(repository("/a/jevcrates.git"), None);
463    }
464
465    #[test]
466    fn release_files_are_the_code_hosts_alone() {
467        for host in [Host::Home, Host::Elsewhere] {
468            assert!(!host.serves_downloads());
469        }
470        for host in [Host::Code, Host::Other] {
471            assert!(host.serves_downloads());
472        }
473        // Like a clone: sent to the code host once the redirect is on, by a
474        // 308 that keeps the path and the query, from every old address.
475        for host in OLD {
476            let moved = gate(&ask(host, &Method::GET, "/whiskers/latest/arm64.apk"), On);
477            assert_eq!(moved, Gate::Moved { to: format!("https://{CODE}/whiskers/latest/arm64.apk"), status: StatusCode::PERMANENT_REDIRECT }, "{host}");
478            assert!(matches!(gate(&ask(host, &Method::HEAD, "/whiskers/releases/1.0/a.apk"), On), Gate::Moved { .. }), "{host}");
479            assert!(matches!(gate(&ask(host, &Method::POST, "/whiskers/latest/arm64.apk"), On), Gate::Refused(_)), "{host}");
480            // A path that is not a repository's is not the code's.
481            let other = gate(&ask(host, &Method::GET, "/nixos-config/latest/x"), On);
482            assert!(!matches!(other, Gate::Moved { status: StatusCode::PERMANENT_REDIRECT, .. } | Gate::Refused(_)), "{host}: {other:?}");
483        }
484        // With it off the apex does not serve them (no route) and moves nothing.
485        assert_eq!(gate(&ask(HOME, &Method::GET, "/whiskers/latest/arm64.apk"), Off), Gate::Pass);
486        // Where they are served, nothing is moved.
487        for host in [CODE, "staging.lmjtfy.fun", "localhost:8787"] {
488            assert_eq!(gate(&ask(host, &Method::GET, "/whiskers/latest/arm64.apk"), On), Gate::Pass, "{host}");
489        }
490    }
491}