maincopy-server 0.1.0

Self-hosted publishing server with exact previews and explicit release approval
Documentation
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;

/// Shared readiness state for the public health endpoint.
///
/// Startup keeps the service unready until its required components are
/// available. Any critical component can make the service unready again.
#[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)
    }
}

/// Explicit request-facing dependencies for the public listener.
#[derive(Clone, Debug)]
pub struct PublicState {
    pub snapshots: SiteSnapshotReader,
    pub readiness: Readiness,
}

/// Builds the public router without binding a listener.
pub fn public_router(state: PublicState) -> Router {
    public_router_with_routes(state, Router::new())
}

/// Compose enabled public features before applying the listener's admission and
/// response policies. The ordinary public router remains useful without mail.
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"));
        }
    }
}