use std::path::{Path, PathBuf};
use axum::body::Body;
use axum::extract::State;
use axum::http::{Request, StatusCode, Uri, header};
use axum::response::{IntoResponse, Response};
use serde::Deserialize;
use crate::error::AppError;
use crate::website::cache::WebsiteCache;
use crate::website::paths::{PathError, canonical_within_root};
use crate::website::storage::WebsiteRoot;
#[derive(Debug, Clone)]
pub struct WebsiteState {
pub root: WebsiteRoot,
pub cache: WebsiteCache,
pub executable_blocklist: Vec<String>,
pub cache_control: String,
pub csp_override_file: String,
}
#[derive(Debug, Deserialize, Default)]
pub struct WebsiteOverride {
pub csp: Option<String>,
}
pub async fn serve(State(state): State<WebsiteState>, req: Request<Body>) -> Response {
match serve_inner(&state, req.uri()).await {
Ok(resp) => resp,
Err(err) => err.into_response(),
}
}
async fn serve_inner(state: &WebsiteState, uri: &Uri) -> Result<Response, AppError> {
let raw_path = uri.path();
let req_path = if raw_path == "/" || raw_path.ends_with('/') {
format!("{raw_path}index.html")
} else {
raw_path.to_string()
};
let serve_root = state.root.serve_root();
let resolved = match canonical_within_root(&serve_root, &req_path, &state.executable_blocklist)
{
Ok(p) => p,
Err(PathError::NotFound) => {
return Err(AppError::NotFound(format!("no such resource: {raw_path}")));
}
Err(PathError::Hidden) => {
return Err(AppError::NotFound(format!("no such resource: {raw_path}")));
}
Err(PathError::BlockedExtension(ext)) => {
return Err(AppError::Forbidden(format!(
"extension {ext} is blocked by website.executable_blocklist"
)));
}
Err(PathError::Escape | PathError::ControlChars | PathError::NonNfc) => {
return Err(AppError::Validation(format!(
"request path rejected by website path-safety: {raw_path}"
)));
}
Err(PathError::ExecBit) => {
return Err(AppError::Forbidden(
"file has executable bit set; refusing to serve".into(),
));
}
};
if let Ok(meta) = tokio::fs::metadata(&resolved).await
&& meta.is_dir()
{
return Err(AppError::NotFound("path resolves to a directory".into()));
}
let cached = state
.cache
.get(&resolved)
.await
.map_err(|e| AppError::Internal(format!("failed to read website file: {e}")))?;
let etag = format!("\"{}\"", cached.digest_hex);
let mime = mime_guess::from_path(&resolved)
.first_or_octet_stream()
.to_string();
let csp_override = read_csp_override(&serve_root, &state.csp_override_file).await;
let mut builder = Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, mime)
.header(header::ETAG, etag.clone())
.header(header::CACHE_CONTROL, state.cache_control.clone());
if let Some(csp) = csp_override {
builder = builder.header(header::CONTENT_SECURITY_POLICY, csp);
}
builder
.body(Body::from((*cached.body).clone()))
.map_err(|e| AppError::Internal(format!("response build: {e}")))
}
async fn read_csp_override(serve_root: &Path, override_file: &str) -> Option<String> {
let path: PathBuf = serve_root.join(override_file);
let bytes = tokio::fs::read(&path).await.ok()?;
let parsed: WebsiteOverride = toml::from_slice(&bytes).ok()?;
parsed.csp
}
#[cfg(test)]
mod tests {
use super::*;
use http_body_util::BodyExt;
fn block() -> Vec<String> {
vec![".cgi".into(), ".php".into(), ".exe".into()]
}
async fn make_state(root: &Path) -> WebsiteState {
WebsiteState {
root: WebsiteRoot::new(root, "live").unwrap(),
cache: WebsiteCache::new(60),
executable_blocklist: block(),
cache_control: "public, max-age=300".into(),
csp_override_file: ".vtc-website.toml".into(),
}
}
#[tokio::test]
async fn serves_existing_file_with_etag() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("hello.html"), "<p>hi</p>").unwrap();
let state = make_state(dir.path()).await;
let uri: Uri = "/hello.html".parse().unwrap();
let resp = serve_inner(&state, &uri).await.expect("ok");
assert_eq!(resp.status(), StatusCode::OK);
assert!(resp.headers().get(header::ETAG).is_some());
assert_eq!(
resp.headers()
.get(header::CACHE_CONTROL)
.map(|h| h.to_str().unwrap()),
Some("public, max-age=300"),
);
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
assert_eq!(bytes.as_ref(), b"<p>hi</p>");
}
#[tokio::test]
async fn serves_index_for_root_request() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("index.html"), "<title>home</title>").unwrap();
let state = make_state(dir.path()).await;
let uri: Uri = "/".parse().unwrap();
let resp = serve_inner(&state, &uri).await.expect("ok");
assert_eq!(resp.status(), StatusCode::OK);
assert!(
resp.headers()
.get(header::CONTENT_TYPE)
.and_then(|h| h.to_str().ok())
.unwrap_or("")
.starts_with("text/html"),
"got {:?}",
resp.headers().get(header::CONTENT_TYPE)
);
}
#[tokio::test]
async fn rejects_hidden_with_404() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join(".secrets"), "shh").unwrap();
let state = make_state(dir.path()).await;
let uri: Uri = "/.secrets".parse().unwrap();
let err = serve_inner(&state, &uri).await.expect_err("must reject");
assert!(matches!(err, AppError::NotFound(_)), "got {err:?}");
}
#[tokio::test]
async fn rejects_blocked_extension_with_403() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("evil.cgi"), "#!/bin/sh\n").unwrap();
let state = make_state(dir.path()).await;
let uri: Uri = "/evil.cgi".parse().unwrap();
let err = serve_inner(&state, &uri).await.expect_err("must reject");
assert!(matches!(err, AppError::Forbidden(_)), "got {err:?}");
}
#[tokio::test]
async fn rejects_dotdot_escape_with_validation_error() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("index.html"), "ok").unwrap();
let state = make_state(dir.path()).await;
let uri: Uri = "/../../etc/passwd".parse().unwrap();
let err = serve_inner(&state, &uri).await.expect_err("must reject");
assert!(
matches!(err, AppError::NotFound(_) | AppError::Validation(_)),
"got {err:?}"
);
}
#[tokio::test]
async fn directory_request_404s() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join("assets")).unwrap();
let state = make_state(dir.path()).await;
let uri: Uri = "/assets".parse().unwrap();
let err = serve_inner(&state, &uri).await.expect_err("must reject");
assert!(matches!(err, AppError::NotFound(_)), "got {err:?}");
}
#[tokio::test]
async fn per_site_csp_override_wins() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("index.html"), "<title>home</title>").unwrap();
std::fs::write(
dir.path().join(".vtc-website.toml"),
r#"csp = "default-src https:; script-src 'self' 'unsafe-inline'""#,
)
.unwrap();
let state = make_state(dir.path()).await;
let uri: Uri = "/".parse().unwrap();
let resp = serve_inner(&state, &uri).await.expect("ok");
let csp = resp
.headers()
.get(header::CONTENT_SECURITY_POLICY)
.and_then(|h| h.to_str().ok())
.unwrap_or("");
assert!(csp.contains("'unsafe-inline'"), "got CSP: {csp}");
}
}