use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
use axum::{
Router,
extract::{Request, State},
http::HeaderValue,
http::header::{
CACHE_CONTROL, CONTENT_SECURITY_POLICY, REFERRER_POLICY as REFERRER_HEADER,
X_CONTENT_TYPE_OPTIONS,
},
middleware::{self, Next},
response::Response,
};
mod connection;
mod health;
mod request_limits;
mod server;
use crate::{
domain::publication::web::router as publication_router,
render::{REFERRER_POLICY, SiteSnapshotReader},
};
pub(crate) use connection::PublicListener as ConnectionListener;
use health::router as health_router;
pub(crate) use server::PublicServer;
#[derive(Clone, Debug, Default)]
pub struct Readiness {
ready: Arc<AtomicBool>,
}
impl Readiness {
pub fn new(ready: bool) -> Self {
Self {
ready: Arc::new(AtomicBool::new(ready)),
}
}
pub fn mark_ready(&self) {
self.ready.store(true, Ordering::Release);
}
pub fn mark_not_ready(&self) {
self.ready.store(false, Ordering::Release);
}
pub fn is_ready(&self) -> bool {
self.ready.load(Ordering::Acquire)
}
}
#[derive(Clone, Debug)]
pub struct PublicState {
pub snapshots: SiteSnapshotReader,
pub readiness: Readiness,
}
pub fn public_router(state: PublicState) -> Router {
public_router_with_routes(state, Router::new())
}
pub(crate) fn public_router_with_routes(state: PublicState, routes: Router) -> Router {
request_limits::apply(
Router::new()
.merge(publication_router(state.snapshots.clone()))
.merge(health_router(state.readiness))
.merge(routes),
)
.layer(middleware::from_fn_with_state(
state.snapshots,
public_response_policy,
))
}
async fn public_response_policy(
State(snapshots): State<SiteSnapshotReader>,
mut request: Request,
next: Next,
) -> Response {
let snapshot = snapshots.load_full();
let private_mail_path = request.uri().path().starts_with("/email/");
request.extensions_mut().insert(snapshot.clone());
let mut response = next.run(request).await;
let headers = response.headers_mut();
headers
.entry(CONTENT_SECURITY_POLICY)
.or_insert_with(|| snapshot.response_policy.content_security_policy.clone());
if private_mail_path {
headers.insert(REFERRER_HEADER, HeaderValue::from_static("no-referrer"));
headers.insert(CACHE_CONTROL, HeaderValue::from_static("private, no-store"));
} else {
headers.entry(REFERRER_HEADER).or_insert(REFERRER_POLICY);
}
headers.insert(X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff"));
response
}
#[cfg(test)]
mod tests {
use axum::{
body::{Body, to_bytes},
http::Request as HttpRequest,
};
use markdown_compiler::prepare_content;
use tokio::sync::Mutex;
use tower::ServiceExt as _;
use super::*;
use crate::{
content_fixtures::{content_tree, publication},
domain::publication::PublicLedgerProjection,
frontend_assets::embedded_manifest,
render::{SiteSnapshot, compile_content_catalog, render_site_shell, snapshot_store},
};
fn snapshot(title: &str, origin: &str) -> SiteSnapshot {
let source = format!(
"[site]\ntitle = {title:?}\nbase_url = \"https://example.com/\"\ndescription = \"Policy fixture.\"\n[author]\nname = \"Author\"\n[assets]\nallowed_https_origins = [{origin:?}]\n"
);
let tree = content_tree(publication("publication.toml", source), vec![], vec![], 0);
let catalog = Arc::new(compile_content_catalog(&prepare_content(&tree).unwrap()).unwrap());
render_site_shell(
catalog,
embedded_manifest(),
&PublicLedgerProjection::empty(),
)
.unwrap()
.into_snapshot()
.unwrap()
}
#[tokio::test]
async fn activation_during_dispatch_keeps_body_and_policy_from_one_snapshot() {
let original = snapshot("Original", "https://original.example");
let expected = original.digest.clone();
let original_policy = original.response_policy.content_security_policy.clone();
let replacement = snapshot("Replacement", "https://replacement.example");
let (snapshots, activator) = snapshot_store(original);
let activation = Arc::new(Mutex::new((activator, Some(replacement))));
let app = Router::new()
.merge(publication_router(snapshots.clone()))
.layer(middleware::from_fn(move |request: Request, next: Next| {
let activation = Arc::clone(&activation);
let expected = expected.clone();
async move {
let mut state = activation.lock().await;
let replacement = state.1.take().unwrap();
state.0.activate(&expected, replacement).unwrap();
drop(state);
next.run(request).await
}
}))
.layer(middleware::from_fn_with_state(
snapshots.clone(),
public_response_policy,
));
let response = app
.oneshot(HttpRequest::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.headers()[CONTENT_SECURITY_POLICY], original_policy);
let body = to_bytes(response.into_body(), 1024 * 1024).await.unwrap();
let body = std::str::from_utf8(&body).unwrap();
assert!(body.contains("Original"));
assert!(!body.contains("Replacement"));
assert!(snapshots.load_full().index_page().contains("Replacement"));
}
#[tokio::test]
async fn mail_controls_keep_private_headers_when_admission_rejects_before_the_handler() {
let (snapshots, _) = snapshot_store(snapshot("Mail", "https://assets.example"));
let routes = Router::new().route(
"/email/unsubscribe/{token}",
axum::routing::get(|| async { "control page" }),
);
let app = public_router_with_routes(
PublicState {
snapshots,
readiness: Readiness::new(true),
},
routes,
);
for (body, expected) in [
(Body::empty(), axum::http::StatusCode::OK),
(
Body::from(vec![b'x'; 8193]),
axum::http::StatusCode::PAYLOAD_TOO_LARGE,
),
] {
let response = app
.clone()
.oneshot(
HttpRequest::builder()
.uri("/email/unsubscribe/private-control-marker")
.body(body)
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), expected);
assert_eq!(response.headers()[REFERRER_HEADER], "no-referrer");
assert_eq!(response.headers()[CACHE_CONTROL], "private, no-store");
let bytes = to_bytes(response.into_body(), 1024).await.unwrap();
assert!(!String::from_utf8_lossy(&bytes).contains("private-control-marker"));
}
}
}