webserver_base/
axum_plausible_analytics.rs

1use axum::http::{HeaderMap, StatusCode};
2use plausible_rs::{EventHeaders, EventPayload, PAGEVIEW_EVENT, Plausible};
3use reqwest::Client;
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6use std::net::SocketAddr;
7use std::sync::Arc;
8use tracing::{error, info, instrument, warn};
9
10use crate::base_settings::{BaseSettings, Environment};
11
12#[derive(Debug, Serialize, Deserialize)]
13pub struct RequestPayload {
14    pub user_agent: String,
15    pub url: String,
16    pub referrer: String,
17    pub screen_width: usize,
18}
19
20pub struct AxumPlausibleAnalyticsHandler {
21    plausible_client: Plausible,
22}
23
24impl AxumPlausibleAnalyticsHandler {
25    #[must_use]
26    #[instrument(skip_all)]
27    pub fn new_with_client(http_client: Client) -> Self {
28        Self {
29            plausible_client: Plausible::new_with_client(http_client),
30        }
31    }
32
33    #[instrument(skip_all)]
34    pub async fn handle(
35        self: Arc<Self>,
36        headers: HeaderMap,
37        settings: BaseSettings,
38        addr: SocketAddr,
39        incoming_payload: RequestPayload,
40    ) -> StatusCode {
41        // generate payload
42        let domain: String = if settings.environment == Environment::Production {
43            settings.analytics_domain.clone()
44        } else {
45            String::from("test.toddgriffin.me")
46        };
47        let outgoing_payload: EventPayload = EventPayload::builder(
48            domain,
49            PAGEVIEW_EVENT.to_string(),
50            incoming_payload.url.clone(),
51        )
52        .referrer(incoming_payload.referrer.clone())
53        .screen_width(incoming_payload.screen_width)
54        .build();
55
56        // generate headers
57        let real_client_ip: String = Self::resolve_true_client_ip_address(addr, headers);
58        let headers: EventHeaders =
59            EventHeaders::new(incoming_payload.user_agent.clone(), real_client_ip);
60
61        info!(
62            "Making Plausible Analytics calls with headers={:?} and body={:?}",
63            headers.clone(),
64            outgoing_payload.clone()
65        );
66        // post 'pageview' event
67        match self.plausible_client.event(headers, outgoing_payload).await {
68            Ok(bytes) => {
69                info!(
70                    "Plausible Analytics call was a success: {}",
71                    String::from_utf8_lossy(&bytes)
72                );
73                StatusCode::OK
74            }
75            Err(e) => {
76                error!("Failed Plausible Analytics call: {}", e);
77                StatusCode::INTERNAL_SERVER_ERROR
78            }
79        }
80    }
81
82    /// Determine the client's actual IP address (not the IP address of any Proxies).
83    #[instrument(skip_all)]
84    fn resolve_true_client_ip_address(socket_addr: SocketAddr, header_map: HeaderMap) -> String {
85        // prioritized list of HTTP headers that may contain the client's true IP address
86        // (top-most entry is the most trusted)
87        let prioritized_headers: Vec<&str> = vec![
88            // Cloudflare
89            "True-Client-IP",
90            "CF-Connecting-IP",
91            // standard
92            "X-Forwarded-For",
93            "X-Real-IP",
94            "Forwarded",
95            // backup (Axum's 'SocketAddr' IP; definitely a proxy)
96            "socket_addr",
97        ];
98
99        // get the value of each prioritized header (if it exists)
100        let prioritized_headers_values: HashMap<&str, Option<String>> = prioritized_headers.iter().map(|prioritized_header| {
101            if *prioritized_header == "socket_addr" {
102                // this is a backup in the case we fail to get 'true' IP address
103                // (usually a Proxy IP)
104                return (*prioritized_header, Some(socket_addr.ip().to_string()));
105            }
106
107            let header_value: Option<String> = match header_map.get(*prioritized_header) {
108                // prioritized header exists in the HeaderMap
109                Some(header_value) => {
110                    match header_value.to_str() {
111                        // successfully parsed HTTP header value
112                        Ok(header_value) => {
113                            if *prioritized_header == "X-Forwarded-For" {
114                                info!("full HTTP 'X-Forwarded-For' header IP list: {header_value}");
115
116                                // 'X-Forwarded-For' header may contain multiple IP addresses
117                                let parts: Vec<&str> = header_value.split(',').collect();
118
119                                // if there are multiple entries in this list, the left-most entry is the
120                                // client's actual IP address (all other entries are Network Proxies)
121                                let x_forwarded_for_client_ip: Option<&&str> = parts.first();
122                                if let Some(x_forwarded_for_client_ip) = x_forwarded_for_client_ip {
123                                    let x_forwarded_for: String = (*x_forwarded_for_client_ip).to_string();
124                                    Some(x_forwarded_for)
125                                } else {
126                                    // zero IP addresses found in 'X-Forwarded-For' header
127                                    None
128                                }
129                            } else {
130                                // all other headers are single IP addresses
131                                Some(header_value.to_string())
132                            }
133                        }
134                        // failed to parse HTTP header value
135                        Err(to_str_error) => {
136                            error!("failed to parse HTTP '{prioritized_header}' header value: {to_str_error}");
137                            None
138                        }
139                    }
140                }
141                // prioritized header does not exist in the HeaderMap
142                None => {
143                    None
144                },
145            };
146            (*prioritized_header, header_value)
147        }).collect();
148        info!(
149            "Client IP headers: {:?}",
150            prioritized_headers_values.clone()
151        );
152
153        // choose the first prioritized header that exists
154        for prioritized_header in prioritized_headers {
155            if let Some(Some(header_value)) = prioritized_headers_values.get(prioritized_header) {
156                info!(
157                    "chose HTTP '{prioritized_header}' header for true client IP: '{header_value}'"
158                );
159                return header_value.to_string();
160            }
161        }
162
163        // THIS SHOULD NEVER BE HIT because 'socket_addr' always exists
164        error!("failed to find any prioritized HTTP headers for true client IP address");
165        prioritized_headers_values
166            .get("socket_addr")
167            .unwrap()
168            .as_ref()
169            .unwrap()
170            .to_string()
171    }
172}