whiskers.git / web / worker / src / lib.rs
lib.rsannotatedlib.rssource306 lines · 12.7 KB · raw
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}