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
use crate::{BaseSettings, Environment};
use axum::headers::HeaderMap;
use axum::http::StatusCode;
use plausible_rs::{EventHeaders, EventPayload, Plausible, PAGEVIEW_EVENT};
use reqwest::Client;
use serde::{Deserialize, Serialize};
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,
}

#[allow(clippy::module_name_repetitions)]
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(addr: SocketAddr, headers: HeaderMap) -> String {
        if let Some(forwarded) = headers.get("Forwarded") {
            match forwarded.to_str() {
                Ok(forwarded) => {
                    error!("TODO - handle the HTTP 'Forwarded' header: {forwarded}");
                }
                Err(to_str_error) => {
                    error!("failed to parse HTTP 'Forwarded' header value: {to_str_error}");
                }
            }
        }

        if let Some(x_forwarded_for) = headers.get("X-Forwarded-For") {
            match x_forwarded_for.to_str() {
                Ok(x_forwarded_for) => {
                    let parts: Vec<&str> = x_forwarded_for.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 {
                        return (*x_forwarded_for_client_ip).to_string();
                    };
                }
                Err(to_str_error) => {
                    error!("failed to parse HTTP 'X-Forwarded-For' header value: {to_str_error}");
                }
            }
        }

        if let Some(x_real_ip) = headers.get("X-Real-IP") {
            match x_real_ip.to_str() {
                Ok(x_real_ip) => {
                    error!("TODO - handle the HTTP 'X-Real-IP' header: {x_real_ip}");
                }
                Err(to_str_error) => {
                    error!("failed to parse HTTP 'X-Real-IP' header value: {to_str_error}");
                }
            }
        }

        // failed to get 'true' IP address - default to the SocketAddr (proxy IP)
        warn!("failed to resolve client's true IP address - defaulting to Axum's 'SocketAddr' (which is usually the IP address of an intermediary Network Proxy)");
        addr.ip().to_string()
    }
}