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}