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, clicks on links to other
5//! sites (event `outbound`, `{to: host}`, 18.36.1) and custom events
6//! (`rws.track("signup", {plan: "pro"})`). Nothing is stored in the
7//! browser; the visitor is the same daily hash as for every request.
8//!
9//! [`TrafficScriptLayer`] answers only those two paths and passes every
10//! other request straight through ([`Middleware::is_passthrough`]), so it
11//! costs nothing elsewhere and doesn't take proxy routes off the async
12//! forwarder.
13
14use crate::application::Application;
15use crate::middleware::Middleware;
16use crate::request::Request;
17use crate::response::{Response, STATUS_CODE_REASON_PHRASE};
18use crate::server::ConnectionInfo;
19
20/// The script's URL path.
21pub const SCRIPT_PATH: &str = "/_rws/t.js";
22/// Where the script reports.
23pub const BEACON_PATH: &str = "/_rws/t";
24/// The largest report accepted.
25pub const MAX_BEACON: usize = 2048;
26/// Where the site's apps send events (18.35, [`super::record::app_event`]).
27pub const APP_EVENTS_PATH: &str = "/_rws/e";
28
29/// The script. No dependencies, no storage, ~2 KB (1 KB compressed). The first page view is
30/// already counted by the server, so it reports only what happens after:
31/// leaving a page (time, scroll, width), route changes, events.
32pub const SCRIPT: &str = r#"(()=>{"use strict";const w=window,d=document,n=navigator,E="/_rws/t";if(n.webdriver&&!w.rwsTrackAutomation)return;
33let p=location.pathname+location.search,s=Date.now(),m=0,on=d.visibilityState!=="hidden",shown=on?s:0,acc=0;
34const 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){}};
35const depth=()=>{const h=d.documentElement,y=(w.scrollY||0)+w.innerHeight;m=Math.max(m,Math.min(1,y/Math.max(h.scrollHeight,1)))};
36const seen=()=>acc+(on?Date.now()-shown:0);
37const leave=()=>{if(!p)return;depth();send({t:"leave",p,ms:seen(),sd:Math.round(m*100),w:w.innerWidth||0});p=null};
38const start=np=>{p=np;s=Date.now();m=0;acc=0;shown=s};
39addEventListener("scroll",depth,{passive:true});
40d.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()}});
41addEventListener("pagehide",leave);
42const nav=()=>{const np=location.pathname+location.search;if(np===p)return;const from=p;leave();start(np);send({t:"view",p:np,r:from})};
43for(const k of["pushState","replaceState"]){const o=history[k];history[k]=function(){const r=o.apply(this,arguments);nav();return r}}
44addEventListener("popstate",nav);
45w.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})};
46const out=e=>{const a=e.target&&e.target.closest&&e.target.closest("a[href]");if(!a)return;let u;try{u=new URL(a.href,location.href)}catch(x){return}if(!/^https?:$/.test(u.protocol))return;const h=u.hostname.replace(/^www\./,"");if(h===location.hostname.replace(/^www\./,""))return;w.rws.track("outbound",{to:h})};
47d.addEventListener("click",out,true);d.addEventListener("auxclick",out,true);
48depth()})();
49"#;
50
51/// Answers `/_rws/t.js` and `/_rws/t` when `[traffic] script` is on (it
52/// reads the setting when built); otherwise, and for every other path,
53/// passes the request on.
54#[derive(Clone, Copy, Debug, Default)]
55pub struct TrafficScriptLayer {
56    on: bool,
57}
58
59impl TrafficScriptLayer {
60    /// The layer, on when `[traffic] script = true`.
61    pub fn from_env() -> Self {
62        Self { on: super::config::script() }
63    }
64
65    /// The layer, on or off regardless of the setting.
66    pub fn new(on: bool) -> Self {
67        Self { on }
68    }
69
70    fn mine(&self, request: &Request) -> bool {
71        // Every other path: one byte compare.
72        if !request.request_uri.starts_with("/_rws/") {
73            return false;
74        }
75        let path = request.request_uri.split(['?', '#']).next().unwrap_or("");
76        if self.on && (path == SCRIPT_PATH || path == BEACON_PATH) {
77            return true;
78        }
79        // App events and the owner link work whenever traffic is counted.
80        (path == APP_EVENTS_PATH || path == super::owner::PATH) && super::record::enabled()
81    }
82}
83
84/// The client as the access log sees it (behind a trusted proxy, the
85/// forwarded address), so the visitor hash matches its other requests.
86fn client(request: &Request, connection: &ConnectionInfo) -> Option<std::net::IpAddr> {
87    let peer = &connection.client.ip;
88    let forwarded = request.header("x-forwarded-for").is_some() || request.header("forwarded").is_some();
89    let ip = if forwarded { crate::ip_filter::client_ip(request, peer).into_owned() } else { peer.clone() };
90    ip.trim_matches(['[', ']']).parse::<std::net::IpAddr>().ok()
91}
92
93/// `POST /_rws/e`: an app's event. Any origin (an extension's is its own,
94/// a native app sends none); `OPTIONS` answers a preflight.
95fn app_event(request: &Request, connection: &ConnectionInfo) -> Response {
96    let cors = |mut r: Response| {
97        r.headers.push(crate::header::Header::new("Access-Control-Allow-Origin", "*"));
98        r
99    };
100    if request.method == "OPTIONS" {
101        let mut r = cors(plain(STATUS_CODE_REASON_PHRASE.n204_no_content));
102        r.headers.push(crate::header::Header::new("Access-Control-Allow-Methods", "POST"));
103        r.headers.push(crate::header::Header::new("Access-Control-Allow-Headers", "content-type"));
104        r.headers.push(crate::header::Header::new("Access-Control-Max-Age", "86400"));
105        return r;
106    }
107    if request.method != "POST" {
108        return plain(STATUS_CODE_REASON_PHRASE.n405_method_not_allowed);
109    }
110    if request.body.len() > MAX_BEACON {
111        return cors(plain(STATUS_CODE_REASON_PHRASE.n413_payload_too_large));
112    }
113    let header = |name: &str| request.header(name).map(|h| h.value.as_ref()).filter(|v: &&str| !v.is_empty());
114    let Some(ip) = client(request, connection) else { return cors(plain(STATUS_CODE_REASON_PHRASE.n400_bad_request)) };
115    let body = String::from_utf8_lossy(&request.body);
116    match super::record::app_event(ip, header("host").unwrap_or(""), header("user-agent"), &body, super::record::now()) {
117        Ok(()) => cors(plain(STATUS_CODE_REASON_PHRASE.n204_no_content)),
118        Err(super::record::AppEventError::Malformed) => cors(plain(STATUS_CODE_REASON_PHRASE.n400_bad_request)),
119        Err(super::record::AppEventError::UnknownApp) => cors(plain(STATUS_CODE_REASON_PHRASE.n403_forbidden)),
120    }
121}
122
123/// `GET /_rws/me?k=<key>`: this browser is the owner's (a cookie, for a
124/// year); `?off`: not any more.
125fn owner_link(request: &Request, connection: &ConnectionInfo) -> Response {
126    if request.method != "GET" && request.method != "HEAD" {
127        return plain(STATUS_CODE_REASON_PHRASE.n405_method_not_allowed);
128    }
129    let query = request.request_uri.split_once('?').map_or("", |(_, q)| q.split('#').next().unwrap_or(q));
130    let off = query.split('&').any(|p| p == "off" || p.starts_with("off="));
131    let key = query.split('&').find_map(|p| p.strip_prefix("k=")).unwrap_or("");
132    let host = request.header("host").map(|h| h.value.as_ref()).unwrap_or("");
133    // From the request: escaped, it lands in HTML.
134    let host: String = host.split(':').next().unwrap_or(host).chars().take(253).map(|c| match c {
135        '&' => "&amp;".to_string(),
136        '<' => "&lt;".to_string(),
137        '>' => "&gt;".to_string(),
138        '"' => "&quot;".to_string(),
139        '\'' => "&#39;".to_string(),
140        c => c.to_string(),
141    }).collect();
142    let secure = connection.sni_hostname.is_some() || request.header("x-forwarded-proto").is_some_and(|h| h.value.eq_ignore_ascii_case("https"));
143    let attrs = format!("Path=/; HttpOnly; SameSite=Lax{}", if secure { "; Secure" } else { "" });
144    let (status, cookie, text) = if off {
145        (STATUS_CODE_REASON_PHRASE.n200_ok, Some(format!("{}=; Max-Age=0; {attrs}", super::owner::COOKIE)), format!("This browser's visits to {host} are counted like anyone's again."))
146    } else if super::owner::key_matches(key) {
147        if let Some(ip) = client(request, connection) {
148            super::record::mark_owner(ip, request.header("user-agent").map(|h| h.value.as_ref()), super::record::now());
149        }
150        (
151            STATUS_CODE_REASON_PHRASE.n200_ok,
152            Some(format!("{}={}; Max-Age=31536000; {attrs}", super::owner::COOKIE, super::owner::key())),
153            format!("This browser's visits to {host} are now counted as yours, apart from everyone else's. To undo, open /_rws/me?off."),
154        )
155    } else {
156        (STATUS_CODE_REASON_PHRASE.n403_forbidden, None, "That link isn't this site's owner link.".to_string())
157    };
158    let html = format!("<!doctype html><meta charset=utf-8><meta name=viewport content=\"width=device-width\"><meta name=robots content=noindex><title>rws traffic</title><p style=\"font:16px system-ui;margin:2rem\">{text}</p>");
159    let mut response = Response::text(status, &html);
160    if let Some(range) = response.content_range_list.first_mut() {
161        range.content_type = "text/html; charset=utf-8".to_string();
162    }
163    response.headers.retain(|h| !h.name.eq_ignore_ascii_case("cache-control"));
164    response.headers.push(crate::header::Header::new("Cache-Control", "no-store"));
165    response.headers.push(crate::header::Header::new("Referrer-Policy", "no-referrer"));
166    response.headers.push(crate::header::Header::new("X-Robots-Tag", "noindex"));
167    if let Some(cookie) = cookie {
168        response.headers.push(crate::header::Header::new("Set-Cookie", cookie));
169    }
170    response
171}
172
173fn plain(status: &crate::response::StatusCodeReasonPhrase) -> Response {
174    let mut response = Response::with_status(status);
175    response.headers.push(crate::header::Header::new("Cache-Control", "no-store"));
176    response
177}
178
179impl Middleware for TrafficScriptLayer {
180    fn handle(&self, request: &Request, connection: &ConnectionInfo, next: &dyn Application) -> Result<Response, String> {
181        if !self.mine(request) {
182            return next.execute(request, connection);
183        }
184        let path = request.request_uri.split(['?', '#']).next().unwrap_or("");
185        if path == APP_EVENTS_PATH {
186            return Ok(app_event(request, connection));
187        }
188        if path == super::owner::PATH {
189            return Ok(owner_link(request, connection));
190        }
191        if path == SCRIPT_PATH {
192            if request.method != "GET" && request.method != "HEAD" {
193                return Ok(plain(STATUS_CODE_REASON_PHRASE.n405_method_not_allowed));
194            }
195            return Ok(script_response(request));
196        }
197        if request.method != "POST" {
198            return Ok(plain(STATUS_CODE_REASON_PHRASE.n405_method_not_allowed));
199        }
200        if request.body.len() > MAX_BEACON {
201            return Ok(plain(STATUS_CODE_REASON_PHRASE.n413_payload_too_large));
202        }
203        let header = |name: &str| request.header(name).map(|h| h.value.as_ref()).filter(|v: &&str| !v.is_empty());
204        let host = header("host").unwrap_or("");
205        // Same origin only: a page elsewhere can't post reports for this site.
206        if let Some(origin) = header("origin") {
207            let origin_host = origin.split_once("://").map_or(origin, |(_, rest)| rest);
208            if !origin_host.eq_ignore_ascii_case(host) {
209                return Ok(plain(STATUS_CODE_REASON_PHRASE.n403_forbidden));
210            }
211        }
212        if let Some(ip) = client(request, connection) {
213            let body = String::from_utf8_lossy(&request.body);
214            super::record::beacon(ip, host, header("user-agent"), &body, super::record::now());
215        }
216        Ok(plain(STATUS_CODE_REASON_PHRASE.n204_no_content))
217    }
218
219    fn runs_inline(&self, _request: &Request, _connection: &ConnectionInfo) -> bool {
220        true
221    }
222
223    fn is_passthrough(&self, request: &Request, _connection: &ConnectionInfo) -> bool {
224        !self.mine(request)
225    }
226
227    fn accepts_streamed_body(&self, request: &Request, _connection: &ConnectionInfo) -> bool {
228        !self.mine(request)
229    }
230
231    fn layer_name(&self) -> &'static str {
232        "TrafficScriptLayer"
233    }
234}
235
236/// A weak, content-derived ETag for the script.
237fn etag() -> &'static str {
238    static ETAG: std::sync::OnceLock<String> = std::sync::OnceLock::new();
239    ETAG.get_or_init(|| {
240        use std::hash::{Hash, Hasher};
241        let mut h = std::collections::hash_map::DefaultHasher::new();
242        SCRIPT.hash(&mut h);
243        format!("\"rws-t-{:016x}\"", h.finish())
244    })
245}
246
247fn script_response(request: &Request) -> Response {
248    let not_modified = request.header("if-none-match").is_some_and(|v| v.value.split(',').any(|t| t.trim() == etag()));
249    let mut response = if not_modified {
250        plain(STATUS_CODE_REASON_PHRASE.n304_not_modified)
251    } else {
252        Response::text(STATUS_CODE_REASON_PHRASE.n200_ok, SCRIPT)
253    };
254    if !not_modified {
255        if let Some(range) = response.content_range_list.first_mut() {
256            range.content_type = "text/javascript; charset=utf-8".to_string();
257        }
258    }
259    response.headers.retain(|h| !h.name.eq_ignore_ascii_case("cache-control"));
260    response.headers.push(crate::header::Header::new("Cache-Control", "public, max-age=3600"));
261    response.headers.push(crate::header::Header::new("ETag", etag()));
262    response
263}