~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/knotless

git that lives in your Atmosphere account

Forked from chadtmiller.com/knotless

0
0
202 lines · 7.6 KB Raw History
//! The pages: every request this server does not answer itself, handed to
//! the SvelteKit app that draws them, which reads back its XRPC queries.

use axum::Router;
use axum::body::Body;
use axum::extract::Request;
use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
use axum::response::{IntoResponse, Response};
use knotless::telemetry::send;
use std::sync::LazyLock;
use std::time::Duration;

/// The largest request body handed on: the pages take none to speak of.
const BODY_LIMIT: usize = 1 << 20;

/// How long an idle connection to the app is kept: less than the 5 seconds
/// node keeps one, so that it is not used just as node closes it.
const IDLE: Duration = Duration::from_secs(4);

/// Headers about one hop of a connection, which are not handed on.
const HOP: [header::HeaderName; 6] = [
    header::CONNECTION,
    header::HOST,
    header::TRANSFER_ENCODING,
    header::CONTENT_LENGTH,
    header::UPGRADE,
    header::TE,
];

static CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
    reqwest::Client::builder()
        .redirect(reqwest::redirect::Policy::none())
        .pool_idle_timeout(IDLE)
        .build()
        .expect("a client builds")
});

/// A router that hands every request to the app at `web`: what it answers
/// compressed as this server's own answers are, brotli to a browser that
/// takes it, but for what the app compressed itself, as its scripts. But
/// `/favicon.ico`, which crawlers and old browsers ask for whatever a page
/// names, goes to the pages' icon: handed on, it would be drawn as the
/// profile of a handle `favicon.ico`, looked up at every handle resolver.
pub fn pages(web: String) -> Router {
    let web: &'static str = web.trim_end_matches('/').to_string().leak();
    Router::new()
        .route(
            "/favicon.ico",
            axum::routing::get(|| async { axum::response::Redirect::permanent("/favicon.svg") }),
        )
        .fallback(move |req: Request| page(web, req))
        .layer(tower_http::compression::CompressionLayer::new())
}

/// The pages' `robots.txt`, as served: what it keeps crawlers off is held
/// here too, for those that do not read it again for a day or more, or
/// at all.
static ROBOTS: LazyLock<Vec<(bool, String)>> =
    LazyLock::new(|| rules(include_str!("../../../../web/static/robots.txt")));

/// `robots`'s rules, each whether it allows and its path, escapes
/// decoded. It has one group, for every crawler.
fn rules(robots: &str) -> Vec<(bool, String)> {
    let rule = |line: &str| {
        let (field, path) = line.split_once(':')?;
        let allows = match field.trim().to_ascii_lowercase().as_str() {
            "allow" => true,
            "disallow" => false,
            _ => return None,
        };
        Some((allows, super::unescaped(path.trim())))
    };
    robots.lines().filter_map(rule).collect()
}

/// Whether `path`, a robots.txt rule's, with `*` for any run and a last
/// `$` for the end, matches the start of `at`, or all of it.
fn matches(path: &str, at: &str) -> bool {
    let (path, whole) = match path.strip_suffix('$') {
        Some(path) => (path, true),
        None => (path, false),
    };
    let mut parts = path.split('*');
    let first = parts.next().unwrap_or_default();
    let Some(mut left) = at.strip_prefix(first) else {
        return false;
    };
    let mut parts = parts.peekable();
    while let Some(part) = parts.next() {
        if whole && parts.peek().is_none() {
            return left.ends_with(part);
        }
        let Some(found) = left.find(part) else {
            return false;
        };
        left = &left[found + part.len()..];
    }
    !whole || left.is_empty()
}

/// Whether robots.txt lets crawlers read `at`, a path and query: by the
/// longest rule that matches it, an allowing one if two are as long.
fn allowed(at: &str) -> bool {
    let at = super::unescaped(at);
    let matching = ROBOTS.iter().filter(|(_, path)| matches(path, &at));
    let rule = matching.max_by_key(|(allows, path)| (path.len(), *allows));
    rule.is_none_or(|(allows, _)| *allows)
}

/// Whether `agent` is a crawler, which robots.txt is for: one that says
/// it is a bot, a crawler or a spider, as they do, but not one drawing a
/// preview of a link someone shared, which that person asked for.
fn crawler(agent: &str) -> bool {
    const CRAWLERS: [&str; 5] = ["bot", "crawl", "spider", "slurp", "googleother"];
    const PREVIEWS: [&str; 7] = [
        "slack", "discord", "twitter", "telegram", "whatsapp", "linkedin", "embed",
    ];
    let agent = agent.to_ascii_lowercase();
    let says = |words: &[&str]| words.iter().any(|w| agent.contains(w));
    says(&CRAWLERS) && !says(&PREVIEWS)
}

async fn page(web: &str, req: Request) -> Response {
    // A crawler that reads what robots.txt keeps it off is turned away
    // here, rather than the pages drawing what it should not have asked.
    let agent = req.headers().get(header::USER_AGENT);
    let agent = agent.and_then(|a| a.to_str().ok()).unwrap_or_default();
    let at = req.uri().path_and_query().map_or("/", |p| p.as_str());
    if crawler(agent) && !allowed(at) {
        let why = "robots.txt keeps crawlers off this page.";
        return (StatusCode::FORBIDDEN, why).into_response();
    }
    match handed(web, req).await {
        Ok(res) => res,
        Err(e) => crate::telemetry::failed(
            StatusCode::BAD_GATEWAY,
            format!("The pages are not being drawn: {e:#}"),
        ),
    }
}

/// `req`, as the app at `web` answers it, its body sent on as it comes.
/// The app sees the host it was
/// asked for, as `x-forwarded-host`. A read that finds the connection gone,
/// as when the app is being replaced, is asked once more; a write may have
/// been done, so is not.
async fn handed(web: &str, req: Request) -> anyhow::Result<Response> {
    let (parts, body) = req.into_parts();
    let at = parts.uri.path_and_query().map_or("/", |p| p.as_str());
    let mut headers = kept(&parts.headers);
    if let Some(host) = parts.headers.get(header::HOST) {
        headers.insert("x-forwarded-host", host.clone());
    }
    headers
        .entry("x-forwarded-proto")
        .or_insert(HeaderValue::from_static("http"));
    let body = axum::body::to_bytes(body, BODY_LIMIT).await?;
    let url = format!("{web}{at}");
    let ask = || {
        CLIENT
            .request(parts.method.clone(), &url)
            .headers(headers.clone())
            .body(body.clone())
    };
    let answer = match send(ask(), 0).await {
        Err(_) if parts.method.is_safe() => send(ask(), 1).await?,
        answer => answer?,
    };
    let mut res = Response::builder().status(answer.status());
    let mut headers = kept(answer.headers());
    for (name, value) in [
        (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
        (header::REFERRER_POLICY, "same-origin"),
    ] {
        headers
            .entry(name)
            .or_insert(HeaderValue::from_static(value));
    }
    *res.headers_mut().unwrap() = headers;
    Ok(res.body(Body::from_stream(streamed(answer)))?)
}

/// `answer`'s body as it comes, each part sent on as it is read: what a
/// page streams, as a directory's last commits, reaches the browser as the
/// app sends it, not once it has sent the last of it.
fn streamed(
    answer: reqwest::Response,
) -> impl futures::Stream<Item = reqwest::Result<axum::body::Bytes>> {
    futures::stream::try_unfold(answer, |mut answer| async move {
        let part = answer.chunk().await?;
        Ok(part.map(|part| (part, answer)))
    })
}

/// `headers`, but for those about one hop.
fn kept(headers: &HeaderMap) -> HeaderMap {
    let mut out = headers.clone();
    for name in HOP {
        out.remove(name);
    }
    out
}