Skip to main content

codoseo_web/
lib.rs

1//! The CodoSEO web app: axum routes, askama templates and htmx partials, in one crate the
2//! `codoseo` binary serves as its `web` role.
3
4pub mod abuse;
5pub mod agent;
6pub mod assets;
7pub mod auth;
8pub mod billing;
9pub mod config;
10pub mod crawl_policy;
11pub mod error;
12pub mod fmt;
13pub mod health;
14pub mod layout;
15pub mod metrics;
16pub mod rankorg;
17pub mod render;
18pub mod routes;
19pub mod serp;
20pub mod state;
21pub mod turnstile;
22
23use std::net::SocketAddr;
24
25use axum::Router;
26use axum::extract::Request;
27use axum::http::{HeaderValue, header};
28use axum::middleware::{self, Next};
29use axum::response::Response;
30use axum::routing::get;
31use tokio::net::TcpListener;
32
33pub use config::{Config, Mode};
34pub use state::AppState;
35
36/// The whole app as a router, ready to serve or to drive in tests with `oneshot`.
37pub fn app(state: AppState) -> Router {
38    Router::new()
39        .merge(auth::router())
40        .merge(routes::router())
41        .merge(routes::mcp::routes(&state))
42        .route("/healthz", get(health::healthz))
43        .route("/readyz", get(health::readyz))
44        .route("/assets/{file}", get(assets::serve))
45        .fallback(error::not_found)
46        .layer(middleware::from_fn_with_state(
47            state.clone(),
48            auth::origin::check_origin,
49        ))
50        .layer(middleware::from_fn(error::error_pages))
51        .layer(middleware::from_fn(security_headers))
52        .with_state(state)
53}
54
55/// Serves the app until `shutdown` resolves.
56pub async fn serve(
57    state: AppState,
58    listener: TcpListener,
59    shutdown: impl Future<Output = ()> + Send + 'static,
60) -> std::io::Result<()> {
61    // The MCP service runs each call in its own task, which graceful shutdown can't see; its
62    // token is cancelled as soon as shutdown begins so open calls finish instead of holding it.
63    let token = state.shutdown.clone();
64    let shutdown = async move {
65        shutdown.await;
66        token.cancel();
67    };
68    // Connect info gives the abuse limits the socket's address when no proxy header applies.
69    axum::serve(
70        listener,
71        app(state).into_make_service_with_connect_info::<SocketAddr>(),
72    )
73    .with_graceful_shutdown(shutdown)
74    .await
75}
76
77/// Headers every response gets: no framing, no MIME sniffing, a strict referrer policy, and
78/// no caching of signed-in HTML.
79async fn security_headers(req: Request, next: Next) -> Response {
80    let mut res = next.run(req).await;
81    let h = res.headers_mut();
82    h.insert(
83        header::X_CONTENT_TYPE_OPTIONS,
84        HeaderValue::from_static("nosniff"),
85    );
86    h.insert(header::X_FRAME_OPTIONS, HeaderValue::from_static("DENY"));
87    h.insert(
88        header::REFERRER_POLICY,
89        HeaderValue::from_static("strict-origin-when-cross-origin"),
90    );
91    let is_html = h
92        .get(header::CONTENT_TYPE)
93        .and_then(|v| v.to_str().ok())
94        .is_some_and(|v| v.starts_with("text/html"));
95    if is_html && !h.contains_key(header::CACHE_CONTROL) {
96        h.insert(
97            header::CACHE_CONTROL,
98            HeaderValue::from_static("private, no-cache"),
99        );
100    }
101    res
102}