jevhooks.git / web / src / lib.rs
lib.rsannotatedlib.rssource182 lines · 7.4 KB · raw

hooks.lmjtfy.fun: a Cloudflare Worker that serves one page saying what jevhooks is.

The parts every Jev site's Worker repeats are Aldebaran's (third-party/rustcrates): the address gate, the security headers, the request log, the responses, the embedded files. The look is jev-ui's. What is here is the routing, and in content and view, the page.

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};
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";

The static assets binding (wrangler.toml): the link preview's card, which is built, not embedded.

31const ASSETS: &str = "ASSETS";

How long a browser may keep the page before asking again.

34const PAGE_MAX_AGE_SECONDS: u32 = 300;

What a browser may load on this site: its own files and nothing else. No script runs inline and none is evaluated (the page has no Datastar); styles may be inline because the shell puts the site's own sheet in the head; and an image may be a data: address, because the shared script draws the page's dotted background into one (ui.js sets --dither-paper and --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}
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}

Any address but the canonical one (the account's workers.dev name) sends a reader on to it, 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}

The link preview's card, from the static assets. Routed through the Worker so the gate and the 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}
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}

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}
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    }

A link to the account's workers.dev address still works, and lands on the real one with 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    }

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    }

The page runs no script of its own and loads nothing from anywhere else; the policy says 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    }
178    #[test]
179    fn the_default_origin_is_valid() {
180        assert!(Origin::parse(SITE_ORIGIN).is_ok());
181    }
182}