1//! whiskers.lmjtfy.fun, the Worker: one page, a cat that moods are asked of, and the few small 2//! files a page needs. It keeps nothing, asks nobody anything and knows nothing about who is 3//! looking. See ../README.md. 4//! 5//! Datastar drives the demo: a button posts the page's signals, and the answer is the cat, 6//! its caption and the stages it is busy with, as HTML to morph in. The link preview's PNG and 7//! MP4 are built assets. The PNG is served by wrangler before this code runs; a request for it 8//! that reached here means it was never built, which is logged. The MP4 is routed here first 9//! (`run_worker_first`) so it can answer byte ranges, which link-preview players rely on and 10//! plain assets do not give (`media.rs`). 11 12use std::sync::OnceLock; 13 14use axum::Router; 15use axum::body::Body; 16use axum::extract::{Json, Query, State}; 17use axum::http::{HeaderValue, Method, Response, StatusCode, header}; 18use axum::routing::{get, post}; 19use site::config::SITE_ORIGIN; 20use site::demo::{self, Signals}; 21use site::host; 22use jev_ui::preview::Origin; 23use tower_service::Service; 24use worker::{Context, Env, HttpRequest, event}; 25 26mod explain; 27mod headers; 28mod log; 29mod media; 30mod pic; 31mod view; 32 33const ROBOTS: &str = "User-agent: *\nAllow: /\n"; 34 35#[derive(Clone)] 36struct App { 37 origin: Origin, 38} 39 40#[event(fetch)] 41async fn fetch(request: HttpRequest, env: Env, _context: Context) -> worker::Result<Response<Body>> { 42 let started = worker::Date::now().as_millis(); 43 let route = log::Route::of(request.uri().path()); 44 let method = log::method(request.method()); 45 let origin = origin(&env); 46 let mut response = match gate(&request, &origin) { 47 Some(answer) => answer, 48 None if route == log::Route::Video => video(request, &env).await, 49 None if route == log::Route::Image => image(request, &env).await?, 50 None => router(App { origin }).call(request).await?, 51 }; 52 53 headers::secure(response.headers_mut()); 54 log::write(&log::Served { 55 route, 56 method, 57 status: response.status().as_u16(), 58 millis: worker::Date::now().as_millis().saturating_sub(started), 59 }); 60 Ok(response) 61} 62 63/// The canonical address is the one `SITE_ORIGIN` names; a request to any other non-local host is 64/// sent there (`site::host`). `None` means this request is answered here. 65fn gate(request: &HttpRequest, origin: &Origin) -> Option<Response<Body>> { 66 let host = request.headers().get(header::HOST).and_then(|h| h.to_str().ok()).or_else(|| request.uri().host()).unwrap_or_default(); 67 let target = request.uri().path_and_query().map_or("/", |t| t.as_str()); 68 let own = request.headers().get("sec-fetch-mode").is_some_and(|mode| mode != "navigate"); 69 let asked = host::Asked { host, method: request.method(), target, own }; 70 match host::gate(origin.as_str(), &asked) { 71 host::Gate::Pass => None, 72 host::Gate::Moved { to, status } => Some(moved(&to, status)), 73 host::Gate::Refused(why) => { 74 let mut response = respond("text/plain; charset=utf-8", "no-store", why); 75 *response.status_mut() = StatusCode::METHOD_NOT_ALLOWED; 76 response.headers_mut().insert(header::ALLOW, HeaderValue::from_static("GET, HEAD")); 77 Some(response) 78 } 79 } 80} 81 82/// A permanent redirect. Kept a day, as lmjtfy's: long enough to spare the old address its 83/// traffic, short enough that a mistake is not stuck in caches. 84fn moved(to: &str, status: StatusCode) -> Response<Body> { 85 let mut response = respond("text/plain; charset=utf-8", "public, max-age=86400", format!("Moved to {to}\n")); 86 *response.status_mut() = status; 87 response.headers_mut().insert(header::LOCATION, HeaderValue::from_str(to).expect("a target of a valid request is a valid header")); 88 response 89} 90 91/// The link preview's picture, from the assets. It runs through the Worker (`run_worker_first` 92/// in `wrangler.toml`) only so the old address can send it on; here it is passed to the assets 93/// as it came, headers included, so its `ETag` and conditional requests work as before. 94async fn image(request: HttpRequest, env: &Env) -> worker::Result<Response<Body>> { 95 let Ok(assets) = env.assets(media::BINDING) else { return Ok(not_found().await) }; 96 let found = assets.fetch_request(request).await?; 97 Ok(found.map(Body::new)) 98} 99 100/// `/preview/loop.mp4`: the built file, from the assets binding, answered with byte ranges. 101async fn video(request: HttpRequest, env: &Env) -> Response<Body> { 102 if !matches!(*request.method(), Method::GET | Method::HEAD) { 103 let mut response = respond("text/plain; charset=utf-8", "no-store", "Only GET and HEAD."); 104 *response.status_mut() = StatusCode::METHOD_NOT_ALLOWED; 105 response.headers_mut().insert(header::ALLOW, HeaderValue::from_static("GET, HEAD")); 106 return response; 107 } 108 match read_video(env).await { 109 Some(bytes) => { 110 let text = |name| request.headers().get(name).and_then(|v| v.to_str().ok()); 111 let asked = media::Asked { range: text(header::RANGE), if_range: text(header::IF_RANGE), if_none_match: text(header::IF_NONE_MATCH) }; 112 media::respond(request.method(), &asked, bytes).map(Body::from) 113 } 114 None => { 115 log::failure("the link preview's video is not in the assets (run web/tools/preview)"); 116 not_found().await 117 } 118 } 119} 120 121/// The file's bytes, or `None` if the binding or the file is not there. 122async fn read_video(env: &Env) -> Option<Vec<u8>> { 123 let assets = env.assets(media::BINDING).ok()?; 124 let found = assets.fetch(format!("https://assets.invalid{}", media::PATH), None).await.ok()?; 125 if found.status() != StatusCode::OK { 126 return None; 127 } 128 let bytes = axum::body::to_bytes(Body::new(found.into_body()), usize::MAX).await.ok()?; 129 Some(bytes.to_vec()) 130} 131 132fn router(app: App) -> Router { 133 Router::new() 134 .route("/", get(page)) 135 .route("/cat.css", get(cat_css)) 136 .route("/favicon.svg", get(favicon)) 137 .route("/robots.txt", get(robots)) 138 .route("/mood", post(mood)) 139 .route("/mascot", post(mascot)) 140 .route("/tick", post(tick)) 141 .route("/tour", post(tour)) 142 .fallback(shared_or_not_found) 143 .with_state(app) 144} 145 146/// `SITE_ORIGIN` from the Worker's variables, else the configured address. A variable that is 147/// not an origin is a mistake in `wrangler.toml`: it is reported and the default is used, so a 148/// bad value cannot put an invalid address in the link preview. 149fn origin(env: &Env) -> Origin { 150 let wanted = env.var("SITE_ORIGIN").map(|v| v.to_string()).unwrap_or_else(|_| SITE_ORIGIN.to_owned()); 151 Origin::parse(&wanted).unwrap_or_else(|why| { 152 log::failure(&format!("SITE_ORIGIN is not usable, so the default is used: {why}")); 153 Origin::parse(SITE_ORIGIN).expect("the configured origin is valid (checked by a test)") 154 }) 155} 156 157/// The stylesheet's version: it changes when the cat's motion does. 158fn css() -> &'static (String, String) { 159 static CSS: OnceLock<(String, String)> = OnceLock::new(); 160 CSS.get_or_init(|| { 161 let sheet = cat::css::stylesheet(); 162 let version = jev_ui::asset::hash(sheet.as_bytes()); 163 (sheet, version) 164 }) 165} 166 167fn respond(content_type: &'static str, cache: &'static str, body: impl Into<Body>) -> Response<Body> { 168 Response::builder() 169 .header(header::CONTENT_TYPE, content_type) 170 .header(header::CACHE_CONTROL, cache) 171 .body(body.into()) 172 .expect("static headers are valid") 173} 174 175/// What a link can ask of the page. The value is checked against the closed set of mascots and 176/// is never echoed: anything else is the default. 177#[derive(serde::Deserialize, Default)] 178struct PageQuery { 179 mascot: Option<String>, 180} 181 182async fn page(State(app): State<App>, Query(query): Query<PageQuery>) -> Response<Body> { 183 let mascot = cat::Mascot::parse_or_default(query.mascot.as_deref()); 184 let page = view::page(&view::Page { origin: &app.origin, css_version: &css().1, mascot }); 185 respond("text/html; charset=utf-8", headers::HTML_CACHE, page.into_string()) 186} 187 188/// Every file the shared look brings (`jev_ui::asset`: its stylesheet and script, Datastar, the 189/// fonts) is answered from the crate's own bytes; any other path is not found. 190async fn shared_or_not_found(uri: axum::http::Uri) -> Response<Body> { 191 match jev_ui::asset::find(uri.path()) { 192 Some(asset) => respond(asset.content_type, asset.cache_control(uri.query()), asset.body), 193 None => not_found().await, 194 } 195} 196 197async fn cat_css() -> Response<Body> { 198 respond("text/css; charset=utf-8", "public, max-age=31536000, immutable", css().0.clone()) 199} 200 201async fn favicon() -> Response<Body> { 202 respond("image/svg+xml", "public, max-age=86400", view::favicon()) 203} 204 205async fn robots() -> Response<Body> { 206 respond("text/plain; charset=utf-8", "public, max-age=3600", ROBOTS) 207} 208 209/// The visitor chose a mood. A word that is not one is refused, not guessed at. 210async fn mood(Json(signals): Json<Signals>) -> Response<Body> { 211 match demo::pick(&signals) { 212 Some(shown) => events(shown, signals.mascot()), 213 None => refused("That is not a mood the cat has."), 214 } 215} 216 217/// The visitor chose who to meet. The mood and the tour carry on as they were; a word that is 218/// not a mascot is refused, not guessed at. 219async fn mascot(Json(signals): Json<Signals>) -> Response<Body> { 220 match demo::pick_mascot(&signals) { 221 Some(mascot) => events(demo::stay(&signals), mascot), 222 None => refused("That is not a mascot."), 223 } 224} 225 226/// A second's worth of the page's counting reached the beat's end. 227async fn tick(Json(signals): Json<Signals>) -> Response<Body> { 228 events(demo::tick(&signals), signals.mascot()) 229} 230 231async fn tour(Json(signals): Json<Signals>) -> Response<Body> { 232 events(demo::toggle(&signals), signals.mascot()) 233} 234 235fn events(shown: demo::Shown, mascot: cat::Mascot) -> Response<Body> { 236 respond("text/event-stream", "no-cache", view::patches(shown, mascot)) 237} 238 239fn refused(why: &'static str) -> Response<Body> { 240 Response::builder().status(StatusCode::UNPROCESSABLE_ENTITY).header(header::CONTENT_TYPE, "text/plain; charset=utf-8").body(Body::from(why)).expect("static headers are valid") 241} 242 243async fn not_found() -> Response<Body> { 244 let mut response = respond("text/plain; charset=utf-8", "no-store", "Nothing here. The cat is at /"); 245 *response.status_mut() = StatusCode::NOT_FOUND; 246 response.headers_mut().insert("x-content-type-options", HeaderValue::from_static("nosniff")); 247 response 248} 249 250#[cfg(test)] 251mod tests { 252 use super::*; 253 254 fn request(host: &str, method: Method, target: &str, own: bool) -> HttpRequest { 255 let mut builder = axum::http::Request::builder().method(method).uri(format!("https://{host}{target}")).header(header::HOST, host); 256 if own { 257 builder = builder.header("sec-fetch-mode", "cors"); 258 } 259 builder.body(worker::Body::empty()).unwrap() 260 } 261 262 fn canonical() -> Origin { 263 Origin::parse(SITE_ORIGIN).unwrap() 264 } 265 266 #[test] 267 fn the_old_address_gets_a_308_with_the_headers_every_response_has() { 268 let mut response = gate(&request("old.example", Method::GET, "/?mascot=bunny", false), &canonical()).expect("sent on"); 269 headers::secure(response.headers_mut()); 270 assert_eq!(response.status(), StatusCode::PERMANENT_REDIRECT); 271 assert_eq!(response.headers()[header::LOCATION], format!("{SITE_ORIGIN}/?mascot=bunny")); 272 assert_eq!(response.headers()[header::CACHE_CONTROL], "public, max-age=86400"); 273 assert_eq!(response.headers()[header::CONTENT_SECURITY_POLICY], headers::CSP); 274 assert_eq!(response.headers()[header::X_CONTENT_TYPE_OPTIONS], "nosniff"); 275 assert!(response.headers().get(header::SET_COOKIE).is_none()); 276 } 277 278 #[test] 279 fn the_old_address_refuses_a_post_that_is_not_a_page_and_lets_a_page_finish() { 280 let refused = gate(&request("old.example", Method::POST, "/mood", false), &canonical()).expect("told"); 281 assert_eq!(refused.status(), StatusCode::METHOD_NOT_ALLOWED); 282 assert_eq!(refused.headers()[header::ALLOW], "GET, HEAD"); 283 assert!(gate(&request("old.example", Method::POST, "/mood", true), &canonical()).is_none()); 284 } 285 286 #[test] 287 fn the_canonical_and_local_hosts_are_answered_here() { 288 for host in ["whiskers.lmjtfy.fun", "localhost:8789", "127.0.0.1:8789"] { 289 for target in ["/", "/preview/card.png", "/robots.txt"] { 290 assert!(gate(&request(host, Method::GET, target, false), &canonical()).is_none(), "{host}{target}"); 291 } 292 } 293 } 294 295 #[test] 296 fn the_default_origin_is_valid() { 297 assert!(Origin::parse(SITE_ORIGIN).is_ok()); 298 } 299 300 #[test] 301 fn the_stylesheet_is_named_by_its_content() { 302 let (sheet, version) = css(); 303 assert!(sheet.contains("@keyframes")); 304 assert_eq!(version.len(), 8); 305 } 306}