use crate::http::Url;
use crate::js::{self, Object, Value};
#[derive(Clone, Debug, PartialEq)]
pub struct Payload {
pub kind: &'static str,
pub site: String,
pub url: Url,
pub referrer: String,
pub title: String,
pub screen_width: Option<i64>,
pub screen_height: Option<i64>,
pub language: String,
pub name: String,
pub props: Option<Object>,
pub pageview_id: String,
pub engaged_ms: i64,
pub scroll: Option<i64>,
}
pub const MAX_BODY: usize = 8 * 1024;
const MAX_ENGAGED_MS: i64 = 30 * 60 * 1000;
const MAX_PROPS: usize = 30;
fn str_(value: Option<&Value>, max: usize) -> String {
match value {
Some(Value::String(s)) => js::head16(s, max),
_ => String::new(),
}
}
fn int(value: Option<&Value>, min: i64, max: i64) -> Option<i64> {
let n = value?.finite()?;
Some((js::round(n) as i64).clamp(min, max).min(max).max(min))
}
const LINK_EVENTS: [&str; 2] = ["Outbound link", "File download"];
fn link_props(name: &str, given: Option<Object>) -> Option<Object> {
let mut given = given?;
if LINK_EVENTS.contains(&name)
&& let Some(Value::String(url)) = given.get("url")
{
let cut = url.split(['?', '#']).next().unwrap_or("").to_string();
given.set("url", cut);
}
Some(given)
}
fn props(value: Option<&Value>) -> Option<Object> {
let Some(Value::Object(o)) = value else { return None };
let mut out = Object::new();
let mut count = 0;
for (key, raw) in o.iter() {
if count >= MAX_PROPS {
break;
}
let k = js::head16(js::trim(key), 60);
if k.is_empty() {
continue;
}
let text = match raw {
Value::String(s) => js::head16(s, 500),
Value::Number(n) if n.is_finite() => js::format_number(*n),
Value::Bool(b) => b.to_string(),
_ => continue,
};
out.set(k, text);
count += 1;
}
(count > 0).then_some(out)
}
pub fn parse_payload(text: &str) -> Option<Payload> {
if js::len16(text) > MAX_BODY {
return None;
}
let body = js::parse(text).ok()?;
if !body.is_object() {
return None;
}
let kind = match body.get("k") {
Some(Value::String(k)) if k == "pageview" => "pageview",
Some(Value::String(k)) if k == "event" => "event",
Some(Value::String(k)) if k == "engagement" => "engagement",
_ => return None,
};
let url = Url::parse(&str_(body.get("u"), 2048))?;
let protocol = url.protocol();
if protocol != "http:" && protocol != "https:" {
return None;
}
let name = js::trim(&str_(body.get("n"), 120)).to_string();
if kind == "event" && name.is_empty() {
return None;
}
let pageview_id = str_(body.get("i"), 32);
if !pageview_id.is_empty() && !pageview_id.bytes().all(|b| b.is_ascii_alphanumeric()) {
return None;
}
if kind == "engagement" && pageview_id.is_empty() {
return None;
}
let props = if kind == "event" { link_props(&name, props(body.get("p"))) } else { None };
Some(Payload {
kind,
site: str_(body.get("s"), 64),
url,
referrer: str_(body.get("r"), 2048),
title: str_(body.get("t"), 500),
screen_width: int(body.get("w"), 0, 20000),
screen_height: int(body.get("h"), 0, 20000),
language: str_(body.get("l"), 35),
name,
props,
pageview_id,
engaged_ms: if kind == "engagement" { int(body.get("e"), 0, MAX_ENGAGED_MS).unwrap_or(0) } else { 0 },
scroll: int(body.get("d"), 0, 100),
})
}