Skip to main content

privatus_analytics/
actix.rs

1//! An actix-web middleware that adds the tag to the `<head>` of HTML pages.
2//!
3//! ```no_run
4//! use actix_web::{web, App, HttpResponse};
5//! use privatus_analytics::{actix::PrivatusMiddleware, Config};
6//!
7//! let config = Config::new().site_id("pa_YOURSITEID");
8//! let app = App::new()
9//!     .wrap(PrivatusMiddleware::new(config))
10//!     .route("/", web::get().to(|| async {
11//!         HttpResponse::Ok()
12//!             .content_type("text/html; charset=utf-8")
13//!             .body("<html><head></head><body>Hi</body></html>")
14//!     }));
15//! # let _ = app;
16//! ```
17//!
18//! The middleware adds the tag just before `</head>` in uncompressed
19//! `text/html` responses with status 200. It skips Ajax requests
20//! (`X-Requested-With: XMLHttpRequest`), pages without a `</head>`, and pages
21//! that already have the tag. Put it inside (after) `Compress` in your
22//! `wrap` calls, or it sees compressed pages and leaves them alone.
23//!
24//! It reads a [`CspNonce`] and page [`Props`] from the request extensions or
25//! the response extensions, so your own middleware or a handler can set them.
26
27use crate::config::{Config, CspNonce};
28use crate::tag::{inject_tag, should_inject};
29use crate::value::Props;
30use actix_web::body::{to_bytes, EitherBody, MessageBody};
31use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform};
32use actix_web::http::header;
33use actix_web::web::Bytes;
34use actix_web::{Error, HttpMessage, HttpRequest};
35use std::fmt;
36use std::future::{ready, Future, Ready};
37use std::pin::Pin;
38use std::rc::Rc;
39use std::sync::Arc;
40use std::task::{Context, Poll};
41
42type PropsFn = dyn Fn(&HttpRequest) -> Props;
43
44/// The middleware that adds the tag. Add it with `App::wrap`.
45#[derive(Clone)]
46pub struct PrivatusMiddleware {
47    config: Arc<Config>,
48    props_fn: Option<Rc<PropsFn>>,
49}
50
51impl PrivatusMiddleware {
52    /// A middleware with these settings.
53    pub fn new(config: Config) -> Self {
54        Self::from_arc(Arc::new(config))
55    }
56
57    /// A middleware that shares settings you already keep in an `Arc`.
58    pub fn from_arc(config: Arc<Config>) -> Self {
59        PrivatusMiddleware {
60            config,
61            props_fn: None,
62        }
63    }
64
65    /// Page props worked out for each request. The function gets the
66    /// request. It runs only when the tag is added to the page.
67    ///
68    /// Never return names, emails, user ids or anything else that points to
69    /// one person.
70    pub fn props_fn<F>(mut self, props_fn: F) -> Self
71    where
72        F: Fn(&HttpRequest) -> Props + 'static,
73    {
74        self.props_fn = Some(Rc::new(props_fn));
75        self
76    }
77}
78
79impl fmt::Debug for PrivatusMiddleware {
80    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81        f.debug_struct("PrivatusMiddleware")
82            .field("config", &self.config)
83            .field("props_fn", &self.props_fn.is_some())
84            .finish()
85    }
86}
87
88impl<S, B> Transform<S, ServiceRequest> for PrivatusMiddleware
89where
90    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
91    B: MessageBody + 'static,
92{
93    type Response = ServiceResponse<EitherBody<B>>;
94    type Error = Error;
95    type Transform = PrivatusMiddlewareService<S>;
96    type InitError = ();
97    type Future = Ready<Result<Self::Transform, Self::InitError>>;
98
99    fn new_transform(&self, service: S) -> Self::Future {
100        ready(Ok(PrivatusMiddlewareService {
101            service,
102            config: self.config.clone(),
103            props_fn: self.props_fn.clone(),
104        }))
105    }
106}
107
108/// The service made by [`PrivatusMiddleware`].
109pub struct PrivatusMiddlewareService<S> {
110    service: S,
111    config: Arc<Config>,
112    props_fn: Option<Rc<PropsFn>>,
113}
114
115impl<S> fmt::Debug for PrivatusMiddlewareService<S> {
116    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
117        f.debug_struct("PrivatusMiddlewareService")
118            .field("config", &self.config)
119            .finish()
120    }
121}
122
123impl<S, B> Service<ServiceRequest> for PrivatusMiddlewareService<S>
124where
125    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
126    B: MessageBody + 'static,
127{
128    type Response = ServiceResponse<EitherBody<B>>;
129    type Error = Error;
130    type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>>>>;
131
132    fn poll_ready(&self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
133        self.service.poll_ready(cx)
134    }
135
136    fn call(&self, request: ServiceRequest) -> Self::Future {
137        let config = self.config.clone();
138        let props_fn = self.props_fn.clone();
139        let xhr = request
140            .headers()
141            .get("x-requested-with")
142            .and_then(|v| v.to_str().ok())
143            .map(str::to_owned);
144        let future = self.service.call(request);
145
146        Box::pin(async move {
147            let response = future.await?;
148            let headers = response.headers();
149            let content_type = headers
150                .get(header::CONTENT_TYPE)
151                .and_then(|v| v.to_str().ok());
152            let encoded = headers.contains_key(header::CONTENT_ENCODING);
153            let inject = config.is_auto_inject()
154                && should_inject(
155                    response.status().as_u16(),
156                    content_type,
157                    encoded,
158                    xhr.as_deref(),
159                )
160                && config.tag().will_render();
161            if !inject {
162                return Ok(response.map_into_left_body());
163            }
164
165            let (request, response) = response.into_parts();
166            let mut tag = config.tag();
167            if let Some(props_fn) = &props_fn {
168                tag = tag.props(props_fn(&request));
169            }
170            if let Some(props) = request.extensions().get::<Props>() {
171                tag = tag.props(props);
172            }
173            if let Some(props) = response.extensions().get::<Props>() {
174                tag = tag.props(props);
175            }
176            let nonce = response
177                .extensions()
178                .get::<CspNonce>()
179                .cloned()
180                .or_else(|| request.extensions().get::<CspNonce>().cloned());
181            if let Some(nonce) = nonce {
182                tag = tag.nonce(nonce.0);
183            }
184            let tag = tag.to_html();
185
186            let (mut response, body) = response.into_parts();
187            let bytes = to_bytes(body)
188                .await
189                .map_err(|e| actix_web::error::ErrorInternalServerError(e.into().to_string()))?;
190            let bytes = std::str::from_utf8(&bytes)
191                .ok()
192                .and_then(|page| inject_tag(page, tag.as_str()))
193                .map_or(bytes, Bytes::from);
194            response.headers_mut().remove(header::CONTENT_LENGTH);
195            let response = response.set_body(bytes).map_into_boxed_body();
196            Ok(ServiceResponse::new(request, response).map_into_right_body())
197        })
198    }
199}