Skip to main content

maincopy_server/web/
mod.rs

1use std::sync::{
2    Arc,
3    atomic::{AtomicBool, Ordering},
4};
5
6use axum::{
7    Router,
8    extract::{Request, State},
9    http::HeaderValue,
10    http::header::{
11        CACHE_CONTROL, CONTENT_SECURITY_POLICY, REFERRER_POLICY as REFERRER_HEADER,
12        X_CONTENT_TYPE_OPTIONS,
13    },
14    middleware::{self, Next},
15    response::Response,
16};
17
18mod connection;
19mod health;
20mod request_limits;
21mod server;
22
23use crate::{
24    domain::publication::web::router as publication_router,
25    render::{REFERRER_POLICY, SiteSnapshotReader},
26};
27pub(crate) use connection::PublicListener as ConnectionListener;
28use health::router as health_router;
29pub(crate) use server::PublicServer;
30
31/// Shared readiness state for the public health endpoint.
32///
33/// Startup keeps the service unready until its required components are
34/// available. Any critical component can make the service unready again.
35#[derive(Clone, Debug, Default)]
36pub struct Readiness {
37    ready: Arc<AtomicBool>,
38}
39
40impl Readiness {
41    pub fn new(ready: bool) -> Self {
42        Self {
43            ready: Arc::new(AtomicBool::new(ready)),
44        }
45    }
46
47    pub fn mark_ready(&self) {
48        self.ready.store(true, Ordering::Release);
49    }
50
51    pub fn mark_not_ready(&self) {
52        self.ready.store(false, Ordering::Release);
53    }
54
55    pub fn is_ready(&self) -> bool {
56        self.ready.load(Ordering::Acquire)
57    }
58}
59
60/// Explicit request-facing dependencies for the public listener.
61#[derive(Clone, Debug)]
62pub struct PublicState {
63    pub snapshots: SiteSnapshotReader,
64    pub readiness: Readiness,
65}
66
67/// Builds the public router without binding a listener.
68pub fn public_router(state: PublicState) -> Router {
69    public_router_with_routes(state, Router::new())
70}
71
72/// Compose enabled public features before applying the listener's admission and
73/// response policies. The ordinary public router remains useful without mail.
74pub(crate) fn public_router_with_routes(state: PublicState, routes: Router) -> Router {
75    request_limits::apply(
76        Router::new()
77            .merge(publication_router(state.snapshots.clone()))
78            .merge(health_router(state.readiness))
79            .merge(routes),
80    )
81    .layer(middleware::from_fn_with_state(
82        state.snapshots,
83        public_response_policy,
84    ))
85}
86
87async fn public_response_policy(
88    State(snapshots): State<SiteSnapshotReader>,
89    mut request: Request,
90    next: Next,
91) -> Response {
92    let snapshot = snapshots.load_full();
93    let private_mail_path = request.uri().path().starts_with("/email/");
94    request.extensions_mut().insert(snapshot.clone());
95    let mut response = next.run(request).await;
96    let headers = response.headers_mut();
97    headers
98        .entry(CONTENT_SECURITY_POLICY)
99        .or_insert_with(|| snapshot.response_policy.content_security_policy.clone());
100    if private_mail_path {
101        headers.insert(REFERRER_HEADER, HeaderValue::from_static("no-referrer"));
102        headers.insert(CACHE_CONTROL, HeaderValue::from_static("private, no-store"));
103    } else {
104        headers.entry(REFERRER_HEADER).or_insert(REFERRER_POLICY);
105    }
106    headers.insert(X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff"));
107    response
108}
109
110#[cfg(test)]
111mod tests {
112    use axum::{
113        body::{Body, to_bytes},
114        http::Request as HttpRequest,
115    };
116    use markdown_compiler::prepare_content;
117    use tokio::sync::Mutex;
118    use tower::ServiceExt as _;
119
120    use super::*;
121    use crate::{
122        content_fixtures::{content_tree, publication},
123        domain::publication::PublicLedgerProjection,
124        frontend_assets::embedded_manifest,
125        render::{SiteSnapshot, compile_content_catalog, render_site_shell, snapshot_store},
126    };
127
128    fn snapshot(title: &str, origin: &str) -> SiteSnapshot {
129        let source = format!(
130            "[site]\ntitle = {title:?}\nbase_url = \"https://example.com/\"\ndescription = \"Policy fixture.\"\n[author]\nname = \"Author\"\n[assets]\nallowed_https_origins = [{origin:?}]\n"
131        );
132        let tree = content_tree(publication("publication.toml", source), vec![], vec![], 0);
133        let catalog = Arc::new(compile_content_catalog(&prepare_content(&tree).unwrap()).unwrap());
134        render_site_shell(
135            catalog,
136            embedded_manifest(),
137            &PublicLedgerProjection::empty(),
138        )
139        .unwrap()
140        .into_snapshot()
141        .unwrap()
142    }
143
144    #[tokio::test]
145    async fn activation_during_dispatch_keeps_body_and_policy_from_one_snapshot() {
146        let original = snapshot("Original", "https://original.example");
147        let expected = original.digest.clone();
148        let original_policy = original.response_policy.content_security_policy.clone();
149        let replacement = snapshot("Replacement", "https://replacement.example");
150        let (snapshots, activator) = snapshot_store(original);
151        let activation = Arc::new(Mutex::new((activator, Some(replacement))));
152        let app = Router::new()
153            .merge(publication_router(snapshots.clone()))
154            .layer(middleware::from_fn(move |request: Request, next: Next| {
155                let activation = Arc::clone(&activation);
156                let expected = expected.clone();
157                async move {
158                    let mut state = activation.lock().await;
159                    let replacement = state.1.take().unwrap();
160                    state.0.activate(&expected, replacement).unwrap();
161                    drop(state);
162                    next.run(request).await
163                }
164            }))
165            .layer(middleware::from_fn_with_state(
166                snapshots.clone(),
167                public_response_policy,
168            ));
169        let response = app
170            .oneshot(HttpRequest::builder().uri("/").body(Body::empty()).unwrap())
171            .await
172            .unwrap();
173        assert_eq!(response.headers()[CONTENT_SECURITY_POLICY], original_policy);
174        let body = to_bytes(response.into_body(), 1024 * 1024).await.unwrap();
175        let body = std::str::from_utf8(&body).unwrap();
176        assert!(body.contains("Original"));
177        assert!(!body.contains("Replacement"));
178        assert!(snapshots.load_full().index_page().contains("Replacement"));
179    }
180
181    #[tokio::test]
182    async fn mail_controls_keep_private_headers_when_admission_rejects_before_the_handler() {
183        let (snapshots, _) = snapshot_store(snapshot("Mail", "https://assets.example"));
184        let routes = Router::new().route(
185            "/email/unsubscribe/{token}",
186            axum::routing::get(|| async { "control page" }),
187        );
188        let app = public_router_with_routes(
189            PublicState {
190                snapshots,
191                readiness: Readiness::new(true),
192            },
193            routes,
194        );
195        for (body, expected) in [
196            (Body::empty(), axum::http::StatusCode::OK),
197            (
198                Body::from(vec![b'x'; 8193]),
199                axum::http::StatusCode::PAYLOAD_TOO_LARGE,
200            ),
201        ] {
202            let response = app
203                .clone()
204                .oneshot(
205                    HttpRequest::builder()
206                        .uri("/email/unsubscribe/private-control-marker")
207                        .body(body)
208                        .unwrap(),
209                )
210                .await
211                .unwrap();
212            assert_eq!(response.status(), expected);
213            assert_eq!(response.headers()[REFERRER_HEADER], "no-referrer");
214            assert_eq!(response.headers()[CACHE_CONTROL], "private, no-store");
215            let bytes = to_bytes(response.into_body(), 1024).await.unwrap();
216            assert!(!String::from_utf8_lossy(&bytes).contains("private-control-marker"));
217        }
218    }
219}