Skip to main content

rws_proxy/traffic/
script.rs

1//! The optional first-party script (`[traffic] script = true`, 18.31.0):
2//! rws serves `/_rws/t.js` itself, and the script reports to `/_rws/t` on
3//! the same origin what the server can't see — time on page, scroll depth,
4//! screen size, single-page-app route changes and custom events
5//! (`rws.track("signup", {plan: "pro"})`). Nothing is stored in the
6//! browser; the visitor is the same daily hash as for every request.
7//!
8//! [`TrafficScriptLayer`] answers only those two paths and passes every
9//! other request straight through ([`Middleware::is_passthrough`]), so it
10//! costs nothing elsewhere and doesn't take proxy routes off the async
11//! forwarder.
12
13use crate::application::Application;
14use crate::middleware::Middleware;
15use crate::request::Request;
16use crate::response::{Response, STATUS_CODE_REASON_PHRASE};
17use crate::server::ConnectionInfo;
18
19/// The script's URL path.
20pub const SCRIPT_PATH: &str = "/_rws/t.js";
21/// Where the script reports.
22pub const BEACON_PATH: &str = "/_rws/t";
23/// The largest report accepted.
24pub const MAX_BEACON: usize = 2048;
25
26/// The script. No dependencies, no storage, ~1.6 KB (0.8 KB compressed). The first page view is
27/// already counted by the server, so it reports only what happens after:
28/// leaving a page (time, scroll, width), route changes, events.
29pub const SCRIPT: &str = r#"(()=>{"use strict";const w=window,d=document,n=navigator,E="/_rws/t";if(n.webdriver&&!w.rwsTrackAutomation)return;
30let p=location.pathname+location.search,s=Date.now(),m=0,on=d.visibilityState!=="hidden",shown=on?s:0,acc=0;
31const send=b=>{const t=JSON.stringify(b);try{if(n.sendBeacon&&n.sendBeacon(E,t))return}catch(e){}try{fetch(E,{method:"POST",body:t,keepalive:true,credentials:"omit"})}catch(e){}};
32const depth=()=>{const h=d.documentElement,y=(w.scrollY||0)+w.innerHeight;m=Math.max(m,Math.min(1,y/Math.max(h.scrollHeight,1)))};
33const seen=()=>acc+(on?Date.now()-shown:0);
34const leave=()=>{if(!p)return;depth();send({t:"leave",p,ms:seen(),sd:Math.round(m*100),w:w.innerWidth||0});p=null};
35const start=np=>{p=np;s=Date.now();m=0;acc=0;shown=s};
36addEventListener("scroll",depth,{passive:true});
37d.addEventListener("visibilitychange",()=>{if(d.visibilityState==="hidden"){if(on){acc+=Date.now()-shown;on=false}leave()}else{if(!p)start(location.pathname+location.search);on=true;shown=Date.now()}});
38addEventListener("pagehide",leave);
39const nav=()=>{const np=location.pathname+location.search;if(np===p)return;const from=p;leave();start(np);send({t:"view",p:np,r:from})};
40for(const k of["pushState","replaceState"]){const o=history[k];history[k]=function(){const r=o.apply(this,arguments);nav();return r}}
41addEventListener("popstate",nav);
42w.rws=w.rws||{};w.rws.track=(name,props)=>{const q={};if(props&&typeof props==="object")for(const k of Object.keys(props).slice(0,4))q[String(k).slice(0,32)]=String(props[k]).slice(0,64);send({t:"event",p:location.pathname+location.search,n:String(name).slice(0,64),q})};
43depth()})();
44"#;
45
46/// Answers `/_rws/t.js` and `/_rws/t` when `[traffic] script` is on (it
47/// reads the setting when built); otherwise, and for every other path,
48/// passes the request on.
49#[derive(Clone, Copy, Debug, Default)]
50pub struct TrafficScriptLayer {
51    on: bool,
52}
53
54impl TrafficScriptLayer {
55    /// The layer, on when `[traffic] script = true`.
56    pub fn from_env() -> Self {
57        Self { on: super::config::script() }
58    }
59
60    /// The layer, on or off regardless of the setting.
61    pub fn new(on: bool) -> Self {
62        Self { on }
63    }
64
65    fn mine(&self, request: &Request) -> bool {
66        if !self.on {
67            return false;
68        }
69        let path = request.request_uri.split(['?', '#']).next().unwrap_or("");
70        path == SCRIPT_PATH || path == BEACON_PATH
71    }
72}
73
74fn plain(status: &crate::response::StatusCodeReasonPhrase) -> Response {
75    let mut response = Response::with_status(status);
76    response.headers.push(crate::header::Header::new("Cache-Control", "no-store"));
77    response
78}
79
80impl Middleware for TrafficScriptLayer {
81    fn handle(&self, request: &Request, connection: &ConnectionInfo, next: &dyn Application) -> Result<Response, String> {
82        if !self.mine(request) {
83            return next.execute(request, connection);
84        }
85        let path = request.request_uri.split(['?', '#']).next().unwrap_or("");
86        if path == SCRIPT_PATH {
87            if request.method != "GET" && request.method != "HEAD" {
88                return Ok(plain(STATUS_CODE_REASON_PHRASE.n405_method_not_allowed));
89            }
90            return Ok(script_response(request));
91        }
92        if request.method != "POST" {
93            return Ok(plain(STATUS_CODE_REASON_PHRASE.n405_method_not_allowed));
94        }
95        if request.body.len() > MAX_BEACON {
96            return Ok(plain(STATUS_CODE_REASON_PHRASE.n413_payload_too_large));
97        }
98        let header = |name: &str| request.header(name).map(|h| h.value.as_ref()).filter(|v: &&str| !v.is_empty());
99        let host = header("host").unwrap_or("");
100        // Same origin only: a page elsewhere can't post reports for this site.
101        if let Some(origin) = header("origin") {
102            let origin_host = origin.split_once("://").map_or(origin, |(_, rest)| rest);
103            if !origin_host.eq_ignore_ascii_case(host) {
104                return Ok(plain(STATUS_CODE_REASON_PHRASE.n403_forbidden));
105            }
106        }
107        // The client as the access log sees it (behind a trusted proxy, the
108        // forwarded address), so the visitor hash matches its page views.
109        let peer = &connection.client.ip;
110        let forwarded = request.header("x-forwarded-for").is_some() || request.header("forwarded").is_some();
111        let ip = if forwarded { crate::ip_filter::client_ip(request, peer).into_owned() } else { peer.clone() };
112        if let Ok(ip) = ip.trim_matches(['[', ']']).parse::<std::net::IpAddr>() {
113            let body = String::from_utf8_lossy(&request.body);
114            super::record::beacon(ip, host, header("user-agent"), &body, super::record::now());
115        }
116        Ok(plain(STATUS_CODE_REASON_PHRASE.n204_no_content))
117    }
118
119    fn runs_inline(&self, _request: &Request, _connection: &ConnectionInfo) -> bool {
120        true
121    }
122
123    fn is_passthrough(&self, request: &Request, _connection: &ConnectionInfo) -> bool {
124        !self.mine(request)
125    }
126
127    fn accepts_streamed_body(&self, request: &Request, _connection: &ConnectionInfo) -> bool {
128        !self.mine(request)
129    }
130
131    fn layer_name(&self) -> &'static str {
132        "TrafficScriptLayer"
133    }
134}
135
136/// A weak, content-derived ETag for the script.
137fn etag() -> &'static str {
138    static ETAG: std::sync::OnceLock<String> = std::sync::OnceLock::new();
139    ETAG.get_or_init(|| {
140        use std::hash::{Hash, Hasher};
141        let mut h = std::collections::hash_map::DefaultHasher::new();
142        SCRIPT.hash(&mut h);
143        format!("\"rws-t-{:016x}\"", h.finish())
144    })
145}
146
147fn script_response(request: &Request) -> Response {
148    let not_modified = request.header("if-none-match").is_some_and(|v| v.value.split(',').any(|t| t.trim() == etag()));
149    let mut response = if not_modified {
150        plain(STATUS_CODE_REASON_PHRASE.n304_not_modified)
151    } else {
152        Response::text(STATUS_CODE_REASON_PHRASE.n200_ok, SCRIPT)
153    };
154    if !not_modified {
155        if let Some(range) = response.content_range_list.first_mut() {
156            range.content_type = "text/javascript; charset=utf-8".to_string();
157        }
158    }
159    response.headers.retain(|h| !h.name.eq_ignore_ascii_case("cache-control"));
160    response.headers.push(crate::header::Header::new("Cache-Control", "public, max-age=3600"));
161    response.headers.push(crate::header::Header::new("ETag", etag()));
162    response
163}