webserver_base/
axum_plausible_analytics.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
use crate::{BaseSettings, Environment};
use axum::http::{HeaderMap, StatusCode};
use plausible_rs::{EventHeaders, EventPayload, Plausible, PAGEVIEW_EVENT};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::Arc;
use tracing::{error, info, instrument, warn};

#[derive(Debug, Serialize, Deserialize)]
pub struct RequestPayload {
    pub user_agent: String,
    pub url: String,
    pub referrer: String,
    pub screen_width: usize,
}

pub struct AxumPlausibleAnalyticsHandler {
    plausible_client: Plausible,
}

impl AxumPlausibleAnalyticsHandler {
    #[must_use]
    pub fn new_with_client(http_client: Client) -> Self {
        Self {
            plausible_client: Plausible::new_with_client(http_client),
        }
    }

    #[instrument(skip_all)]
    pub async fn handle(
        self: Arc<Self>,
        headers: HeaderMap,
        settings: BaseSettings,
        addr: SocketAddr,
        incoming_payload: RequestPayload,
    ) -> StatusCode {
        // generate payload
        let domain: String = if settings.environment == Environment::Production {
            settings.analytics_domain.clone()
        } else {
            String::from("test.toddgriffin.me")
        };
        let outgoing_payload: EventPayload = EventPayload::builder(
            domain,
            PAGEVIEW_EVENT.to_string(),
            incoming_payload.url.clone(),
        )
        .referrer(incoming_payload.referrer.clone())
        .screen_width(incoming_payload.screen_width)
        .build();

        // generate headers
        let real_client_ip: String = Self::resolve_true_client_ip_address(addr, headers);
        let headers: EventHeaders =
            EventHeaders::new(incoming_payload.user_agent.clone(), real_client_ip);

        info!(
            "Making Plausible Analytics calls with headers={:?} and body={:?}",
            headers.clone(),
            outgoing_payload.clone()
        );
        // post 'pageview' event
        match self.plausible_client.event(headers, outgoing_payload).await {
            Ok(bytes) => {
                info!(
                    "Plausible Analytics call was a success: {}",
                    String::from_utf8_lossy(&bytes)
                );
                StatusCode::OK
            }
            Err(e) => {
                error!("Failed Plausible Analytics call: {}", e);
                StatusCode::INTERNAL_SERVER_ERROR
            }
        }
    }

    /// Determine the client's actual IP address (not the IP address of any Proxies).
    #[instrument(skip_all)]
    fn resolve_true_client_ip_address(socket_addr: SocketAddr, header_map: HeaderMap) -> String {
        // prioritized list of HTTP headers that may contain the client's true IP address
        // (top-most entry is the most trusted)
        let prioritized_headers: Vec<&str> = vec![
            // Cloudflare
            "True-Client-IP",
            "CF-Connecting-IP",
            // standard
            "X-Forwarded-For",
            "X-Real-IP",
            "Forwarded",
            // backup (Axum's 'SocketAddr' IP; definitely a proxy)
            "socket_addr",
        ];

        // get the value of each prioritized header (if it exists)
        let prioritized_headers_values: HashMap<&str, Option<String>> = prioritized_headers.iter().map(|prioritized_header| {
            if *prioritized_header == "socket_addr" {
                // this is a backup in the case we fail to get 'true' IP address
                // (usually a Proxy IP)
                return (*prioritized_header, Some(socket_addr.ip().to_string()));
            }

            let header_value: Option<String> = match header_map.get(*prioritized_header) {
                // prioritized header exists in the HeaderMap
                Some(header_value) => {
                    match header_value.to_str() {
                        // successfully parsed HTTP header value
                        Ok(header_value) => {
                            if *prioritized_header == "X-Forwarded-For" {
                                info!("full HTTP 'X-Forwarded-For' header IP list: {header_value}");

                                // 'X-Forwarded-For' header may contain multiple IP addresses
                                let parts: Vec<&str> = header_value.split(',').collect();

                                // if there are multiple entries in this list, the left-most entry is the
                                // client's actual IP address (all other entries are Network Proxies)
                                let x_forwarded_for_client_ip: Option<&&str> = parts.first();
                                if let Some(x_forwarded_for_client_ip) = x_forwarded_for_client_ip {
                                    let x_forwarded_for: String = (*x_forwarded_for_client_ip).to_string();
                                    Some(x_forwarded_for)
                                } else {
                                    // zero IP addresses found in 'X-Forwarded-For' header
                                    None
                                }
                            } else {
                                // all other headers are single IP addresses
                                Some(header_value.to_string())
                            }
                        }
                        // failed to parse HTTP header value
                        Err(to_str_error) => {
                            error!("failed to parse HTTP '{prioritized_header}' header value: {to_str_error}");
                            None
                        }
                    }
                }
                // prioritized header does not exist in the HeaderMap
                None => {
                    None
                },
            };
            (*prioritized_header, header_value)
        }).collect();
        info!(
            "Client IP headers: {:?}",
            prioritized_headers_values.clone()
        );

        // choose the first prioritized header that exists
        for prioritized_header in prioritized_headers {
            if let Some(Some(header_value)) = prioritized_headers_values.get(prioritized_header) {
                info!(
                    "chose HTTP '{prioritized_header}' header for true client IP: '{header_value}'"
                );
                return header_value.to_string();
            }
        }

        // THIS SHOULD NEVER BE HIT because 'socket_addr' always exists
        error!("failed to find any prioritized HTTP headers for true client IP address");
        prioritized_headers_values
            .get("socket_addr")
            .unwrap()
            .as_ref()
            .unwrap()
            .to_string()
    }
}