1use crate::application::Application;
14use crate::middleware::Middleware;
15use crate::request::Request;
16use crate::response::{Response, STATUS_CODE_REASON_PHRASE};
17use crate::server::ConnectionInfo;
18
19pub const SCRIPT_PATH: &str = "/_rws/t.js";
21pub const BEACON_PATH: &str = "/_rws/t";
23pub const MAX_BEACON: usize = 2048;
25pub const APP_EVENTS_PATH: &str = "/_rws/e";
27
28pub const SCRIPT: &str = r#"(()=>{"use strict";const w=window,d=document,n=navigator,E="/_rws/t";if(n.webdriver&&!w.rwsTrackAutomation)return;
32let p=location.pathname+location.search,s=Date.now(),m=0,on=d.visibilityState!=="hidden",shown=on?s:0,acc=0;
33const 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){}};
34const depth=()=>{const h=d.documentElement,y=(w.scrollY||0)+w.innerHeight;m=Math.max(m,Math.min(1,y/Math.max(h.scrollHeight,1)))};
35const seen=()=>acc+(on?Date.now()-shown:0);
36const leave=()=>{if(!p)return;depth();send({t:"leave",p,ms:seen(),sd:Math.round(m*100),w:w.innerWidth||0});p=null};
37const start=np=>{p=np;s=Date.now();m=0;acc=0;shown=s};
38addEventListener("scroll",depth,{passive:true});
39d.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()}});
40addEventListener("pagehide",leave);
41const nav=()=>{const np=location.pathname+location.search;if(np===p)return;const from=p;leave();start(np);send({t:"view",p:np,r:from})};
42for(const k of["pushState","replaceState"]){const o=history[k];history[k]=function(){const r=o.apply(this,arguments);nav();return r}}
43addEventListener("popstate",nav);
44w.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})};
45depth()})();
46"#;
47
48#[derive(Clone, Copy, Debug, Default)]
52pub struct TrafficScriptLayer {
53 on: bool,
54}
55
56impl TrafficScriptLayer {
57 pub fn from_env() -> Self {
59 Self { on: super::config::script() }
60 }
61
62 pub fn new(on: bool) -> Self {
64 Self { on }
65 }
66
67 fn mine(&self, request: &Request) -> bool {
68 if !request.request_uri.starts_with("/_rws/") {
70 return false;
71 }
72 let path = request.request_uri.split(['?', '#']).next().unwrap_or("");
73 if self.on && (path == SCRIPT_PATH || path == BEACON_PATH) {
74 return true;
75 }
76 (path == APP_EVENTS_PATH || path == super::owner::PATH) && super::record::enabled()
78 }
79}
80
81fn client(request: &Request, connection: &ConnectionInfo) -> Option<std::net::IpAddr> {
84 let peer = &connection.client.ip;
85 let forwarded = request.header("x-forwarded-for").is_some() || request.header("forwarded").is_some();
86 let ip = if forwarded { crate::ip_filter::client_ip(request, peer).into_owned() } else { peer.clone() };
87 ip.trim_matches(['[', ']']).parse::<std::net::IpAddr>().ok()
88}
89
90fn app_event(request: &Request, connection: &ConnectionInfo) -> Response {
93 let cors = |mut r: Response| {
94 r.headers.push(crate::header::Header::new("Access-Control-Allow-Origin", "*"));
95 r
96 };
97 if request.method == "OPTIONS" {
98 let mut r = cors(plain(STATUS_CODE_REASON_PHRASE.n204_no_content));
99 r.headers.push(crate::header::Header::new("Access-Control-Allow-Methods", "POST"));
100 r.headers.push(crate::header::Header::new("Access-Control-Allow-Headers", "content-type"));
101 r.headers.push(crate::header::Header::new("Access-Control-Max-Age", "86400"));
102 return r;
103 }
104 if request.method != "POST" {
105 return plain(STATUS_CODE_REASON_PHRASE.n405_method_not_allowed);
106 }
107 if request.body.len() > MAX_BEACON {
108 return cors(plain(STATUS_CODE_REASON_PHRASE.n413_payload_too_large));
109 }
110 let header = |name: &str| request.header(name).map(|h| h.value.as_ref()).filter(|v: &&str| !v.is_empty());
111 let Some(ip) = client(request, connection) else { return cors(plain(STATUS_CODE_REASON_PHRASE.n400_bad_request)) };
112 let body = String::from_utf8_lossy(&request.body);
113 match super::record::app_event(ip, header("host").unwrap_or(""), header("user-agent"), &body, super::record::now()) {
114 Ok(()) => cors(plain(STATUS_CODE_REASON_PHRASE.n204_no_content)),
115 Err(super::record::AppEventError::Malformed) => cors(plain(STATUS_CODE_REASON_PHRASE.n400_bad_request)),
116 Err(super::record::AppEventError::UnknownApp) => cors(plain(STATUS_CODE_REASON_PHRASE.n403_forbidden)),
117 }
118}
119
120fn owner_link(request: &Request, connection: &ConnectionInfo) -> Response {
123 if request.method != "GET" && request.method != "HEAD" {
124 return plain(STATUS_CODE_REASON_PHRASE.n405_method_not_allowed);
125 }
126 let query = request.request_uri.split_once('?').map_or("", |(_, q)| q.split('#').next().unwrap_or(q));
127 let off = query.split('&').any(|p| p == "off" || p.starts_with("off="));
128 let key = query.split('&').find_map(|p| p.strip_prefix("k=")).unwrap_or("");
129 let host = request.header("host").map(|h| h.value.as_ref()).unwrap_or("");
130 let host: String = host.split(':').next().unwrap_or(host).chars().take(253).map(|c| match c {
132 '&' => "&".to_string(),
133 '<' => "<".to_string(),
134 '>' => ">".to_string(),
135 '"' => """.to_string(),
136 '\'' => "'".to_string(),
137 c => c.to_string(),
138 }).collect();
139 let secure = connection.sni_hostname.is_some() || request.header("x-forwarded-proto").is_some_and(|h| h.value.eq_ignore_ascii_case("https"));
140 let attrs = format!("Path=/; HttpOnly; SameSite=Lax{}", if secure { "; Secure" } else { "" });
141 let (status, cookie, text) = if off {
142 (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."))
143 } else if super::owner::key_matches(key) {
144 if let Some(ip) = client(request, connection) {
145 super::record::mark_owner(ip, request.header("user-agent").map(|h| h.value.as_ref()), super::record::now());
146 }
147 (
148 STATUS_CODE_REASON_PHRASE.n200_ok,
149 Some(format!("{}={}; Max-Age=31536000; {attrs}", super::owner::COOKIE, super::owner::key())),
150 format!("This browser's visits to {host} are now counted as yours, apart from everyone else's. To undo, open /_rws/me?off."),
151 )
152 } else {
153 (STATUS_CODE_REASON_PHRASE.n403_forbidden, None, "That link isn't this site's owner link.".to_string())
154 };
155 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>");
156 let mut response = Response::text(status, &html);
157 if let Some(range) = response.content_range_list.first_mut() {
158 range.content_type = "text/html; charset=utf-8".to_string();
159 }
160 response.headers.retain(|h| !h.name.eq_ignore_ascii_case("cache-control"));
161 response.headers.push(crate::header::Header::new("Cache-Control", "no-store"));
162 response.headers.push(crate::header::Header::new("Referrer-Policy", "no-referrer"));
163 response.headers.push(crate::header::Header::new("X-Robots-Tag", "noindex"));
164 if let Some(cookie) = cookie {
165 response.headers.push(crate::header::Header::new("Set-Cookie", cookie));
166 }
167 response
168}
169
170fn plain(status: &crate::response::StatusCodeReasonPhrase) -> Response {
171 let mut response = Response::with_status(status);
172 response.headers.push(crate::header::Header::new("Cache-Control", "no-store"));
173 response
174}
175
176impl Middleware for TrafficScriptLayer {
177 fn handle(&self, request: &Request, connection: &ConnectionInfo, next: &dyn Application) -> Result<Response, String> {
178 if !self.mine(request) {
179 return next.execute(request, connection);
180 }
181 let path = request.request_uri.split(['?', '#']).next().unwrap_or("");
182 if path == APP_EVENTS_PATH {
183 return Ok(app_event(request, connection));
184 }
185 if path == super::owner::PATH {
186 return Ok(owner_link(request, connection));
187 }
188 if path == SCRIPT_PATH {
189 if request.method != "GET" && request.method != "HEAD" {
190 return Ok(plain(STATUS_CODE_REASON_PHRASE.n405_method_not_allowed));
191 }
192 return Ok(script_response(request));
193 }
194 if request.method != "POST" {
195 return Ok(plain(STATUS_CODE_REASON_PHRASE.n405_method_not_allowed));
196 }
197 if request.body.len() > MAX_BEACON {
198 return Ok(plain(STATUS_CODE_REASON_PHRASE.n413_payload_too_large));
199 }
200 let header = |name: &str| request.header(name).map(|h| h.value.as_ref()).filter(|v: &&str| !v.is_empty());
201 let host = header("host").unwrap_or("");
202 if let Some(origin) = header("origin") {
204 let origin_host = origin.split_once("://").map_or(origin, |(_, rest)| rest);
205 if !origin_host.eq_ignore_ascii_case(host) {
206 return Ok(plain(STATUS_CODE_REASON_PHRASE.n403_forbidden));
207 }
208 }
209 if let Some(ip) = client(request, connection) {
210 let body = String::from_utf8_lossy(&request.body);
211 super::record::beacon(ip, host, header("user-agent"), &body, super::record::now());
212 }
213 Ok(plain(STATUS_CODE_REASON_PHRASE.n204_no_content))
214 }
215
216 fn runs_inline(&self, _request: &Request, _connection: &ConnectionInfo) -> bool {
217 true
218 }
219
220 fn is_passthrough(&self, request: &Request, _connection: &ConnectionInfo) -> bool {
221 !self.mine(request)
222 }
223
224 fn accepts_streamed_body(&self, request: &Request, _connection: &ConnectionInfo) -> bool {
225 !self.mine(request)
226 }
227
228 fn layer_name(&self) -> &'static str {
229 "TrafficScriptLayer"
230 }
231}
232
233fn etag() -> &'static str {
235 static ETAG: std::sync::OnceLock<String> = std::sync::OnceLock::new();
236 ETAG.get_or_init(|| {
237 use std::hash::{Hash, Hasher};
238 let mut h = std::collections::hash_map::DefaultHasher::new();
239 SCRIPT.hash(&mut h);
240 format!("\"rws-t-{:016x}\"", h.finish())
241 })
242}
243
244fn script_response(request: &Request) -> Response {
245 let not_modified = request.header("if-none-match").is_some_and(|v| v.value.split(',').any(|t| t.trim() == etag()));
246 let mut response = if not_modified {
247 plain(STATUS_CODE_REASON_PHRASE.n304_not_modified)
248 } else {
249 Response::text(STATUS_CODE_REASON_PHRASE.n200_ok, SCRIPT)
250 };
251 if !not_modified {
252 if let Some(range) = response.content_range_list.first_mut() {
253 range.content_type = "text/javascript; charset=utf-8".to_string();
254 }
255 }
256 response.headers.retain(|h| !h.name.eq_ignore_ascii_case("cache-control"));
257 response.headers.push(crate::header::Header::new("Cache-Control", "public, max-age=3600"));
258 response.headers.push(crate::header::Header::new("ETag", etag()));
259 response
260}