whiskers.git / web / worker / src / lib.rs
lib.rsannotatedlib.rssource305 lines · 12.7 KB · raw

whiskers.lmjtfy.fun, the Worker: one page, a cat that moods are asked of, and the few small files a page needs. It keeps nothing, asks nobody anything and knows nothing about who is looking. See ../README.md.

Datastar drives the demo: a button posts the page's signals, and the answer is the cat, its caption and the stages it is busy with, as HTML to morph in. The link preview's PNG and MP4 are built assets. The PNG is served by wrangler before this code runs; a request for it that reached here means it was never built, which is logged. The MP4 is routed here first (run_worker_first) so it can answer byte ranges, which link-preview players rely on and plain assets do not give (media.rs).

12use std::sync::OnceLock;
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 view;
31
32const ROBOTS: &str = "User-agent: *\nAllow: /\n";
33
34#[derive(Clone)]
35struct App {
36    origin: Origin,
37}
38
39#[event(fetch)]
40async fn fetch(request: HttpRequest, env: Env, _context: Context) -> worker::Result<Response<Body>> {
41    let started = worker::Date::now().as_millis();
42    let route = log::Route::of(request.uri().path());
43    let method = log::method(request.method());
44    let origin = origin(&env);
45    let mut response = match gate(&request, &origin) {
46        Some(answer) => answer,
47        None if route == log::Route::Video => video(request, &env).await,
48        None if route == log::Route::Image => image(request, &env).await?,
49        None => router(App { origin }).call(request).await?,
50    };
51
52    headers::secure(response.headers_mut());
53    log::write(&log::Served {
54        route,
55        method,
56        status: response.status().as_u16(),
57        millis: worker::Date::now().as_millis().saturating_sub(started),
58    });
59    Ok(response)
60}

The canonical address is the one SITE_ORIGIN names; a request to any other non-local host is sent there (site::host). None means this request is answered here.

64fn gate(request: &HttpRequest, origin: &Origin) -> Option<Response<Body>> {
65    let host = request.headers().get(header::HOST).and_then(|h| h.to_str().ok()).or_else(|| request.uri().host()).unwrap_or_default();
66    let target = request.uri().path_and_query().map_or("/", |t| t.as_str());
67    let own = request.headers().get("sec-fetch-mode").is_some_and(|mode| mode != "navigate");
68    let asked = host::Asked { host, method: request.method(), target, own };
69    match host::gate(origin.as_str(), &asked) {
70        host::Gate::Pass => None,
71        host::Gate::Moved { to, status } => Some(moved(&to, status)),
72        host::Gate::Refused(why) => {
73            let mut response = respond("text/plain; charset=utf-8", "no-store", why);
74            *response.status_mut() = StatusCode::METHOD_NOT_ALLOWED;
75            response.headers_mut().insert(header::ALLOW, HeaderValue::from_static("GET, HEAD"));
76            Some(response)
77        }
78    }
79}

A permanent redirect. Kept a day, as lmjtfy's: long enough to spare the old address its traffic, short enough that a mistake is not stuck in caches.

83fn moved(to: &str, status: StatusCode) -> Response<Body> {
84    let mut response = respond("text/plain; charset=utf-8", "public, max-age=86400", format!("Moved to {to}\n"));
85    *response.status_mut() = status;
86    response.headers_mut().insert(header::LOCATION, HeaderValue::from_str(to).expect("a target of a valid request is a valid header"));
87    response
88}

The link preview's picture, from the assets. It runs through the Worker (run_worker_first in wrangler.toml) only so the old address can send it on; here it is passed to the assets as it came, headers included, so its ETag and conditional requests work as before.

93async fn image(request: HttpRequest, env: &Env) -> worker::Result<Response<Body>> {
94    let Ok(assets) = env.assets(media::BINDING) else { return Ok(not_found().await) };
95    let found = assets.fetch_request(request).await?;
96    Ok(found.map(Body::new))
97}

/preview/loop.mp4: the built file, from the assets binding, answered with byte ranges.

100async fn video(request: HttpRequest, env: &Env) -> Response<Body> {
101    if !matches!(*request.method(), Method::GET | Method::HEAD) {
102        let mut response = respond("text/plain; charset=utf-8", "no-store", "Only GET and HEAD.");
103        *response.status_mut() = StatusCode::METHOD_NOT_ALLOWED;
104        response.headers_mut().insert(header::ALLOW, HeaderValue::from_static("GET, HEAD"));
105        return response;
106    }
107    match read_video(env).await {
108        Some(bytes) => {
109            let text = |name| request.headers().get(name).and_then(|v| v.to_str().ok());
110            let asked = media::Asked { range: text(header::RANGE), if_range: text(header::IF_RANGE), if_none_match: text(header::IF_NONE_MATCH) };
111            media::respond(request.method(), &asked, bytes).map(Body::from)
112        }
113        None => {
114            log::failure("the link preview's video is not in the assets (run web/tools/preview)");
115            not_found().await
116        }
117    }
118}

The file's bytes, or None if the binding or the file is not there.

121async fn read_video(env: &Env) -> Option<Vec<u8>> {
122    let assets = env.assets(media::BINDING).ok()?;
123    let found = assets.fetch(format!("https://assets.invalid{}", media::PATH), None).await.ok()?;
124    if found.status() != StatusCode::OK {
125        return None;
126    }
127    let bytes = axum::body::to_bytes(Body::new(found.into_body()), usize::MAX).await.ok()?;
128    Some(bytes.to_vec())
129}
131fn router(app: App) -> Router {
132    Router::new()
133        .route("/", get(page))
134        .route("/cat.css", get(cat_css))
135        .route("/favicon.svg", get(favicon))
136        .route("/robots.txt", get(robots))
137        .route("/mood", post(mood))
138        .route("/mascot", post(mascot))
139        .route("/tick", post(tick))
140        .route("/tour", post(tour))
141        .fallback(shared_or_not_found)
142        .with_state(app)
143}

SITE_ORIGIN from the Worker's variables, else the configured address. A variable that is not an origin is a mistake in wrangler.toml: it is reported and the default is used, so a bad value cannot put an invalid address in the link preview.

148fn origin(env: &Env) -> Origin {
149    let wanted = env.var("SITE_ORIGIN").map(|v| v.to_string()).unwrap_or_else(|_| SITE_ORIGIN.to_owned());
150    Origin::parse(&wanted).unwrap_or_else(|why| {
151        log::failure(&format!("SITE_ORIGIN is not usable, so the default is used: {why}"));
152        Origin::parse(SITE_ORIGIN).expect("the configured origin is valid (checked by a test)")
153    })
154}

The stylesheet's version: it changes when the cat's motion does.

157fn css() -> &'static (String, String) {
158    static CSS: OnceLock<(String, String)> = OnceLock::new();
159    CSS.get_or_init(|| {
160        let sheet = cat::css::stylesheet();
161        let version = jev_ui::asset::hash(sheet.as_bytes());
162        (sheet, version)
163    })
164}
166fn respond(content_type: &'static str, cache: &'static str, body: impl Into<Body>) -> Response<Body> {
167    Response::builder()
168        .header(header::CONTENT_TYPE, content_type)
169        .header(header::CACHE_CONTROL, cache)
170        .body(body.into())
171        .expect("static headers are valid")
172}

What a link can ask of the page. The value is checked against the closed set of mascots and is never echoed: anything else is the default.

176#[derive(serde::Deserialize, Default)]
177struct PageQuery {
178    mascot: Option<String>,
179}
181async fn page(State(app): State<App>, Query(query): Query<PageQuery>) -> Response<Body> {
182    let mascot = cat::Mascot::parse_or_default(query.mascot.as_deref());
183    let page = view::page(&view::Page { origin: &app.origin, css_version: &css().1, mascot });
184    respond("text/html; charset=utf-8", headers::HTML_CACHE, page.into_string())
185}

Every file the shared look brings (jev_ui::asset: its stylesheet and script, Datastar, the fonts) is answered from the crate's own bytes; any other path is not found.

189async fn shared_or_not_found(uri: axum::http::Uri) -> Response<Body> {
190    match jev_ui::asset::find(uri.path()) {
191        Some(asset) => respond(asset.content_type, asset.cache_control(uri.query()), asset.body),
192        None => not_found().await,
193    }
194}
196async fn cat_css() -> Response<Body> {
197    respond("text/css; charset=utf-8", "public, max-age=31536000, immutable", css().0.clone())
198}
199
200async fn favicon() -> Response<Body> {
201    respond("image/svg+xml", "public, max-age=86400", view::favicon())
202}
203
204async fn robots() -> Response<Body> {
205    respond("text/plain; charset=utf-8", "public, max-age=3600", ROBOTS)
206}

The visitor chose a mood. A word that is not one is refused, not guessed at.

209async fn mood(Json(signals): Json<Signals>) -> Response<Body> {
210    match demo::pick(&signals) {
211        Some(shown) => events(shown, signals.mascot()),
212        None => refused("That is not a mood the cat has."),
213    }
214}

The visitor chose who to meet. The mood and the tour carry on as they were; a word that is not a mascot is refused, not guessed at.

218async fn mascot(Json(signals): Json<Signals>) -> Response<Body> {
219    match demo::pick_mascot(&signals) {
220        Some(mascot) => events(demo::stay(&signals), mascot),
221        None => refused("That is not a mascot."),
222    }
223}

A second's worth of the page's counting reached the beat's end.

226async fn tick(Json(signals): Json<Signals>) -> Response<Body> {
227    events(demo::tick(&signals), signals.mascot())
228}
230async fn tour(Json(signals): Json<Signals>) -> Response<Body> {
231    events(demo::toggle(&signals), signals.mascot())
232}
233
234fn events(shown: demo::Shown, mascot: cat::Mascot) -> Response<Body> {
235    respond("text/event-stream", "no-cache", view::patches(shown, mascot))
236}
237
238fn refused(why: &'static str) -> Response<Body> {
239    Response::builder().status(StatusCode::UNPROCESSABLE_ENTITY).header(header::CONTENT_TYPE, "text/plain; charset=utf-8").body(Body::from(why)).expect("static headers are valid")
240}
241
242async fn not_found() -> Response<Body> {
243    let mut response = respond("text/plain; charset=utf-8", "no-store", "Nothing here. The cat is at /");
244    *response.status_mut() = StatusCode::NOT_FOUND;
245    response.headers_mut().insert("x-content-type-options", HeaderValue::from_static("nosniff"));
246    response
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252
253    fn request(host: &str, method: Method, target: &str, own: bool) -> HttpRequest {
254        let mut builder = axum::http::Request::builder().method(method).uri(format!("https://{host}{target}")).header(header::HOST, host);
255        if own {
256            builder = builder.header("sec-fetch-mode", "cors");
257        }
258        builder.body(worker::Body::empty()).unwrap()
259    }
260
261    fn canonical() -> Origin {
262        Origin::parse(SITE_ORIGIN).unwrap()
263    }
264
265    #[test]
266    fn the_old_address_gets_a_308_with_the_headers_every_response_has() {
267        let mut response = gate(&request("old.example", Method::GET, "/?mascot=bunny", false), &canonical()).expect("sent on");
268        headers::secure(response.headers_mut());
269        assert_eq!(response.status(), StatusCode::PERMANENT_REDIRECT);
270        assert_eq!(response.headers()[header::LOCATION], format!("{SITE_ORIGIN}/?mascot=bunny"));
271        assert_eq!(response.headers()[header::CACHE_CONTROL], "public, max-age=86400");
272        assert_eq!(response.headers()[header::CONTENT_SECURITY_POLICY], headers::CSP);
273        assert_eq!(response.headers()[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
274        assert!(response.headers().get(header::SET_COOKIE).is_none());
275    }
276
277    #[test]
278    fn the_old_address_refuses_a_post_that_is_not_a_page_and_lets_a_page_finish() {
279        let refused = gate(&request("old.example", Method::POST, "/mood", false), &canonical()).expect("told");
280        assert_eq!(refused.status(), StatusCode::METHOD_NOT_ALLOWED);
281        assert_eq!(refused.headers()[header::ALLOW], "GET, HEAD");
282        assert!(gate(&request("old.example", Method::POST, "/mood", true), &canonical()).is_none());
283    }
284
285    #[test]
286    fn the_canonical_and_local_hosts_are_answered_here() {
287        for host in ["whiskers.lmjtfy.fun", "localhost:8789", "127.0.0.1:8789"] {
288            for target in ["/", "/preview/card.png", "/robots.txt"] {
289                assert!(gate(&request(host, Method::GET, target, false), &canonical()).is_none(), "{host}{target}");
290            }
291        }
292    }
293
294    #[test]
295    fn the_default_origin_is_valid() {
296        assert!(Origin::parse(SITE_ORIGIN).is_ok());
297    }
298
299    #[test]
300    fn the_stylesheet_is_named_by_its_content() {
301        let (sheet, version) = css();
302        assert!(sheet.contains("@keyframes"));
303        assert_eq!(version.len(), 8);
304    }
305}