use axum::{
Router,
body::{self, Body},
extract::{Request, State},
handler::HandlerWithoutStateExt,
http::{StatusCode, header},
response::{Html, IntoResponse, Response},
};
use site::{env::Env, state::AppState};
use std::{fs, path::PathBuf, time::Duration};
use tokio::net::TcpListener;
use tower::util::ServiceExt;
use tower_http::{
compression::CompressionLayer, services::ServeDir, timeout::TimeoutLayer, trace::TraceLayer,
};
const ERR: (StatusCode, Html<&'static str>) =
(StatusCode::NOT_FOUND, Html("
404 Not Found
"));
async fn not_found() -> impl IntoResponse {
ERR
}
async fn inject(
res: Response,
component_name: &'static str,
after: &'static str,
state: AppState,
) -> impl IntoResponse {
let (parts, body) = res.into_parts();
let Ok(body) = body::to_bytes(body, 5_000_000).await else {
return ERR.into_response();
};
let Ok(mut string) = String::from_utf8(body.to_vec()) else {
return ERR.into_response();
};
let Some(index) = string.find(after) else {
return ERR.into_response();
};
let path = state
.get_dist_dir()
.as_ref()
.join("components")
.join(component_name);
let Ok(component) = fs::read_to_string(path) else {
return ERR.into_response();
};
string.insert_str(index + after.len(), component.as_str());
(parts, Body::from(string)).into_response()
}
async fn get_file(State(state): State, mut req: Request) -> impl IntoResponse {
if req.uri().path() == "/" {
*req.uri_mut() = "/index.html".parse().map_err(|_| ERR)?;
return ServeDir::new(state.get_dist_dir().as_ref())
.precompressed_br()
.precompressed_gzip()
.fallback(not_found.into_service())
.oneshot(req)
.await
.map_err(|_| ERR);
}
let mut file_path = PathBuf::from(req.uri().path());
if file_path.extension().is_none() {
file_path.set_extension("html");
}
*req.uri_mut() = file_path
.to_str()
// in case this were to run on a windows machine
.map(|s| s.replace('\\', "/"))
.ok_or(ERR)?
.parse()
.map_err(|_| ERR)?;
ServeDir::new(state.get_dist_dir().as_ref())
.precompressed_br()
.precompressed_gzip()
.fallback(not_found.into_service())
.oneshot(req)
.await
.map_err(|_| ERR)
}
async fn get_try_inject_head_nav_footer(
State(state): State,
req: Request,
) -> impl IntoResponse {
let res = get_file(State(state.clone()), req).await.into_response();
if !res
.headers()
.get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value.contains("text/html"))
{
return res;
}
let res = inject(res, "head.html", "", state.clone())
.await
.into_response();
let res = inject(res, "nav.html", "", state.clone())
.await
.into_response();
inject(res, "footer.html", "", state.clone()).await.into_response()
}
#[tokio::main]
async fn main() -> Result<(), Box> {
tracing_subscriber::fmt()
.with_max_level(tracing::Level::TRACE)
.init();
let Env {
site_addr,
dist_dir,
} = Env::get_or_default();
let listener = TcpListener::bind(&site_addr).await?;
tracing::info!("Listening on http://{site_addr}/");
let router = Router::new()
// every route is handled through fallback
.fallback(get_try_inject_head_nav_footer)
.layer(CompressionLayer::new().gzip(true))
.layer(TimeoutLayer::with_status_code(
StatusCode::REQUEST_TIMEOUT,
Duration::from_secs(30),
))
.layer(TraceLayer::new_for_http())
.with_state(AppState::new(dist_dir));
axum::serve(listener, router).await?;
Ok(())
}