1//! hooks.lmjtfy.fun: a Cloudflare Worker that serves one page saying what jevhooks is. 2//! 3//! The parts every Jev site's Worker repeats are Aldebaran's (`third-party/rustcrates`): the 4//! address gate, the security headers, the request log, the responses, the embedded files. The 5//! look is `jev-ui`'s. What is here is the routing, and in `content` and `view`, the page. 6 7use aldebaran_axum::{bundle_or_not_found, not_found, respond}; 8use aldebaran_gate::{Canonical, Status, Unrepeatable}; 9use aldebaran_headers::Policy; 10use axum::Router; 11use axum::body::Body; 12use axum::extract::State; 13use axum::http::Response; 14use axum::routing::get; 15use jev_ui::preview::Origin; 16use tower_service::Service; 17use worker::{Context, Env, HttpRequest, event}; 18 19pub mod content; 20pub mod hooks; 21mod log; 22pub mod rules; 23pub mod view; 24 25use content::{FILES, SITE_ORIGIN}; 26 27const NOTHING_HERE: &str = "Nothing here. The page is at /"; 28const ROBOTS: &str = "User-agent: *\nAllow: /\n"; 29 30/// The static assets binding (wrangler.toml): the link preview's card, which is built, not embedded. 31const ASSETS: &str = "ASSETS"; 32 33/// How long a browser may keep the page before asking again. 34const PAGE_MAX_AGE_SECONDS: u32 = 300; 35 36/// What a browser may load on this site: its own files and nothing else. No script runs inline 37/// and none is evaluated (the page has no Datastar); styles may be inline because the shell puts 38/// the site's own sheet in the head; and an image may be a `data:` address, because the shared 39/// script draws the page's dotted background into one (`ui.js` sets `--dither-paper` and 40/// `--dither-pink`). Without that last allowance the page loads and the dots are simply not there. 41pub fn policy() -> Policy { 42 Policy::own_origin_only().with_inline_styles().with_data_images() 43} 44 45#[derive(Clone)] 46struct App { 47 origin: Origin, 48} 49 50#[event(fetch)] 51async fn fetch(request: HttpRequest, env: Env, _context: Context) -> worker::Result<Response<Body>> { 52 let started = worker::Date::now().as_millis(); 53 let route = log::Route::of(request.uri().path()); 54 let method = log::Method::parse(request.method().as_str()); 55 let origin = origin(&env); 56 let mut response = match gate(&request, &origin) { 57 Some(answer) => answer, 58 None if route == log::Route::Card => card(request, &env).await?, 59 None => router(App { origin }).call(request).await?, 60 }; 61 62 aldebaran_axum::secure(response.headers_mut(), &policy()); 63 aldebaran_worker::write(&log::Served { 64 route, 65 method, 66 status: response.status().as_u16(), 67 millis: worker::Date::now().as_millis().saturating_sub(started), 68 }); 69 Ok(response) 70} 71 72/// Any address but the canonical one (the account's `workers.dev` name) sends a reader on to it, 73/// so link previews and old links move. Local addresses are answered where they are. 74fn gate(request: &HttpRequest, origin: &Origin) -> Option<Response<Body>> { 75 let canonical = Canonical::every_other_leads_to(origin.as_str()).ok()?; 76 let asked = aldebaran_axum::asked(request.method(), request.headers(), request.uri()); 77 aldebaran_axum::gated(canonical.gate(&asked, Status::PermanentRedirect, Unrepeatable::Refuse)) 78} 79 80/// The link preview's card, from the static assets. Routed through the Worker so the gate and the 81/// headers apply to it like everything else. 82async fn card(request: HttpRequest, env: &Env) -> worker::Result<Response<Body>> { 83 let Ok(assets) = env.assets(ASSETS) else { return Ok(not_found(NOTHING_HERE)) }; 84 let found = assets.fetch_request(request).await?; 85 Ok(found.map(Body::new)) 86} 87 88fn router(app: App) -> Router { 89 Router::new() 90 .route("/", get(page)) 91 .route("/favicon.svg", get(favicon)) 92 .route("/robots.txt", get(robots)) 93 .fallback(file_or_not_found) 94 .with_state(app) 95} 96 97fn origin(env: &Env) -> Origin { 98 let wanted = aldebaran_worker::var(env, "SITE_ORIGIN").unwrap_or_else(|| SITE_ORIGIN.to_owned()); 99 Origin::parse(&wanted).unwrap_or_else(|why| { 100 aldebaran_worker::failure(&format!("SITE_ORIGIN is not usable, so the default is used: {why}")); 101 Origin::parse(SITE_ORIGIN).expect("the default origin is valid (the_default_origin_is_valid)") 102 }) 103} 104 105async fn page(State(app): State<App>) -> Response<Body> { 106 aldebaran_axum::html(view::page(&app.origin).into_string(), PAGE_MAX_AGE_SECONDS) 107} 108 109/// The site's own pictures, then the shared look's files, then nothing. 110async fn file_or_not_found(uri: axum::http::Uri) -> Response<Body> { 111 if FILES.find(uri.path()).is_some() { 112 return bundle_or_not_found(&FILES, &uri, NOTHING_HERE); 113 } 114 match jev_ui::asset::find(uri.path()) { 115 Some(asset) => respond(asset.content_type, asset.cache_control(uri.query()), asset.body), 116 None => not_found(NOTHING_HERE), 117 } 118} 119 120async fn favicon() -> Response<Body> { 121 respond("image/svg+xml", "public, max-age=86400", view::favicon()) 122} 123 124async fn robots() -> Response<Body> { 125 respond("text/plain; charset=utf-8", "public, max-age=3600", ROBOTS) 126} 127 128#[cfg(test)] 129mod tests { 130 use super::*; 131 use crate::content::CARD_PATH; 132 use axum::http::{Method, StatusCode, header}; 133 134 const CANON: &str = "https://hooks.lmjtfy.fun"; 135 136 fn request(host: &str, method: Method, target: &str) -> HttpRequest { 137 axum::http::Request::builder().method(method).uri(format!("https://{host}{target}")).header(header::HOST, host).body(worker::Body::empty()).unwrap() 138 } 139 140 fn canonical() -> Origin { 141 Origin::parse(CANON).unwrap() 142 } 143 144 /// A link to the account's workers.dev address still works, and lands on the real one with 145 /// its path, carrying the same headers every response has. 146 #[test] 147 fn another_address_is_sent_on_to_the_canonical_one() { 148 let mut response = gate(&request("jevhooks.example.workers.dev", Method::GET, "/shots/band.svg?v=1"), &canonical()).expect("sent on"); 149 aldebaran_axum::secure(response.headers_mut(), &policy()); 150 assert_eq!(response.status(), StatusCode::PERMANENT_REDIRECT); 151 assert_eq!(response.headers()[header::LOCATION], format!("{CANON}/shots/band.svg?v=1")); 152 assert_eq!(response.headers()[header::CONTENT_SECURITY_POLICY], policy().csp().as_str()); 153 assert!(response.headers().get(header::SET_COOKIE).is_none()); 154 } 155 156 /// The site itself, and a developer's machine, are answered where they are. 157 #[test] 158 fn the_canonical_and_local_hosts_are_answered_here() { 159 for host in ["hooks.lmjtfy.fun", "localhost:8790", "127.0.0.1:8790"] { 160 for target in ["/", CARD_PATH, "/robots.txt"] { 161 assert!(gate(&request(host, Method::GET, target), &canonical()).is_none(), "{host}{target}"); 162 } 163 } 164 } 165 166 /// The page runs no script of its own and loads nothing from anywhere else; the policy says 167 /// so in full, so a change to it is a change someone made on purpose. 168 #[test] 169 fn the_policy_allows_the_sites_own_files_and_nothing_else() { 170 let csp = policy().csp(); 171 assert!(csp.starts_with("default-src 'none'"), "{csp}"); 172 assert!(csp.contains("script-src 'self'"), "{csp}"); 173 // The dotted background is a picture the shared script draws and hands over as data. 174 assert!(csp.contains("img-src 'self' data:"), "{csp}"); 175 assert!(!csp.contains("unsafe-eval") && !csp.contains("http"), "{csp}"); 176 } 177 178 #[test] 179 fn the_default_origin_is_valid() { 180 assert!(Origin::parse(SITE_ORIGIN).is_ok()); 181 } 182}