From 9e12ce2272c6b5a646b290b796cfa3317b85b142 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Wed, 7 Oct 2026 12:37:07 -0700 Subject: [PATCH] Track Whistlegraph app referrals and access-link visits --- lith/Caddyfile | 2 +- .../aesthetic.computer/lib/visit-model.mjs | 19 +++++ .../aesthetic.computer/lib/visit-tracker.mjs | 15 +++- system/public/whistlegraph.app/index.html | 1 + system/public/whistlegraph.org/index.html | 2 +- system/tests/visit-tracking-browser.test.mjs | 78 +++++++++++++++++++ system/tests/visit-tracking.test.mjs | 18 ++++- toolchain/analytics/VISITS.md | 27 +++++++ toolchain/analytics/whistlegraph-report.mjs | 36 +++++++++ 9 files changed, 191 insertions(+), 7 deletions(-) create mode 100644 toolchain/analytics/whistlegraph-report.mjs diff --git a/lith/Caddyfile b/lith/Caddyfile index 0f98017f80..e2bb824823 100644 --- a/lith/Caddyfile +++ b/lith/Caddyfile @@ -1522,7 +1522,7 @@ whistlegraph.app { root * /opt/ac/system/public/whistlegraph.app header X-Content-Type-Options nosniff header Referrer-Policy strict-origin-when-cross-origin - header Content-Security-Policy "default-src 'none'; img-src 'self'; style-src 'self'; font-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'" + header Content-Security-Policy "default-src 'none'; script-src https://aesthetic.computer/aesthetic.computer/lib/; connect-src https://aesthetic.computer/api/visit-track; img-src 'self'; style-src 'self'; font-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'" header Cache-Control "no-cache" file_server } diff --git a/system/public/aesthetic.computer/lib/visit-model.mjs b/system/public/aesthetic.computer/lib/visit-model.mjs index 17e423b6e8..50ea720d14 100644 --- a/system/public/aesthetic.computer/lib/visit-model.mjs +++ b/system/public/aesthetic.computer/lib/visit-model.mjs @@ -9,6 +9,7 @@ export const VISIT_PROPERTIES = Object.freeze({ "oskiewar.com": ["www.oskiewar.com", "midi.oskiewar.com"], "nopaint.art": ["www.nopaint.art"], "whistlegraph.org": ["www.whistlegraph.org", "tv.whistlegraph.org"], + "whistlegraph.app": ["www.whistlegraph.app"], "jas.life": ["www.jas.life", "rdp.jas.life"], "kidlisp.com": ["www.kidlisp.com", "learn.kidlisp.com", "keep.kidlisp.com", "buy.kidlisp.com", "pj.kidlisp.com", "top.kidlisp.com", "calm.kidlisp.com"], "notepat.com": ["www.notepat.com"], @@ -39,7 +40,22 @@ export const VISIT_ACTIONS = Object.freeze([ "mime_interact", "mime_scroll_feed", "mime_original_open", "note_played", "painting_edited", "recording_started", "painting_saved", "tape_saved", + "whistlegraph_app_clicked", "whistlegraph_access_clicked", ]); + +// Reviewed link milestones only; never retain destinations or mailto contents. +export function visitLinkAction(hostname, href) { + let url; + try { url = new URL(href); } catch { return null; } + const property = visitProperty(hostname); + if (property === "whistlegraph.org" && url.protocol === "https:" && + !url.port && !url.username && !url.password && visitProperty(url.hostname) === "whistlegraph.app") + return "whistlegraph_app_clicked"; + if (property === "whistlegraph.app" && url.protocol === "mailto:" && + url.pathname.toLowerCase() === "mail@aesthetic.computer") + return "whistlegraph_access_clicked"; + return null; +} export const VISIT_DEPTHS = Object.freeze([30, 60, 180, 600]); // Public upload milestones require a returned record code, never a click. export function visitMediaAction(extension, result) { @@ -113,6 +129,7 @@ export function validateVisit(body, origin, userAgent = "") { !/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i.test(body.id || "") || !SURFACES.includes(body.surface) || !ACTIVE_BUCKETS.includes(body.activeSeconds) || typeof body.interacted !== "boolean" || typeof body.automated !== "boolean" || + (body.linkVersion !== undefined && body.linkVersion !== 1) || !Array.isArray(body.inputs) || body.inputs.length > INPUTS.length || body.inputs.some(value => !INPUTS.includes(value)) || !Array.isArray(body.actions) || body.actions.length > VISIT_ACTIONS.length || @@ -124,12 +141,14 @@ export function validateVisit(body, origin, userAgent = "") { automated: body.automated || automatedVisit({ userAgent }), inputs: [...new Set(body.inputs)], actions: [...new Set(body.actions)], referrerHost: typeof body.referrerHost === "string" ? visitReferrer(body.referrerHost) : null, + ...(body.linkVersion === 1 ? { linkVersion: 1 } : {}), }; } export function visitUpdate(visit, now = new Date()) { const max = { activeSeconds: visit.activeSeconds, interacted: visit.interacted, automated: visit.automated, engaged: visit.interacted && visit.activeSeconds >= 10 }; + if (visit.linkVersion === 1) max.linkVersion = 1; for (const input of visit.inputs) max[`inputs.${input}`] = true; for (const action of visit.actions) max[`actions.${action}`] = true; return { diff --git a/system/public/aesthetic.computer/lib/visit-tracker.mjs b/system/public/aesthetic.computer/lib/visit-tracker.mjs index 314a0dba90..8c4845a7ad 100644 --- a/system/public/aesthetic.computer/lib/visit-tracker.mjs +++ b/system/public/aesthetic.computer/lib/visit-tracker.mjs @@ -1,4 +1,4 @@ -import { ACTIVE_BUCKETS, VISIT_ACTIONS, automatedVisit, visitProperty, visitSurface, visitReferrer } from "./visit-model.mjs"; +import { ACTIVE_BUCKETS, VISIT_ACTIONS, automatedVisit, visitProperty, visitSurface, visitReferrer, visitLinkAction } from "./visit-model.mjs?v=20261007-whistlegraph"; const ENDPOINT = "https://aesthetic.computer/api/visit-track"; @@ -22,8 +22,10 @@ export function startVisitTracker(win = window, doc = document) { const reset = () => { path = win.location.pathname; const surface = visitSurface(path); + const linkMeasured = ["whistlegraph.org", "whistlegraph.app"].includes(visitProperty(win.location.hostname)) && + [...doc.querySelectorAll("a[href]")].some(link => visitLinkAction(win.location.hostname, link.href)); state = surface === null ? null : { - version: 1, id: win.crypto.randomUUID(), surface, + version: 1, ...(linkMeasured ? { linkVersion: 1 } : {}), id: win.crypto.randomUUID(), surface, automated: automatedVisit(nav, win.location.search, win.acAutomation === true), interacted: false, activeSeconds: 0, inputs: [], actions: [], referrerHost: visitReferrer(doc.referrer), @@ -108,17 +110,22 @@ export function startVisitTracker(win = window, doc = document) { } }, true); } - on(win, "click", e => { + const followLink = e => { if (!e.isTrusted || e.target?.closest?.("[data-ac-no-track]")) return; + if (e.type === "auxclick" && e.button !== 1) return; const link = e.target?.closest?.("a[href]"); if (!link) return; interact(e.detail === 0 ? "keyboard" : "pointer"); let url; try { url = new URL(link.href, win.location.href); } catch { return; } + const milestone = visitLinkAction(win.location.hostname, url.href); + if (milestone) action(milestone); if (!/^https?:$/.test(url.protocol)) return; action(link.hasAttribute("download") || /\.(?:dmg|zip|pdf|amxd|exe|apk)$/i.test(url.pathname) ? "download_clicked" : "link_followed"); - }, true); + }; + on(win, "click", followLink, true); + on(win, "auxclick", followLink, true); on(doc, "playing", e => { if (["AUDIO", "VIDEO"].includes(e.target?.tagName)) action("media_started"); }, true); diff --git a/system/public/whistlegraph.app/index.html b/system/public/whistlegraph.app/index.html index 4108a1c413..f9cea40341 100644 --- a/system/public/whistlegraph.app/index.html +++ b/system/public/whistlegraph.app/index.html @@ -18,6 +18,7 @@ +
diff --git a/system/public/whistlegraph.org/index.html b/system/public/whistlegraph.org/index.html index 297b9706ac..d1a8e5087a 100644 --- a/system/public/whistlegraph.org/index.html +++ b/system/public/whistlegraph.org/index.html @@ -317,7 +317,7 @@ .sectionbar button, .sectionbar a.tab{padding:9px 13px 8px} } - + Whistlegraph for iPhone — early access at whistlegraph.app → diff --git a/system/tests/visit-tracking-browser.test.mjs b/system/tests/visit-tracking-browser.test.mjs index debc8611f1..fe3227e6e3 100644 --- a/system/tests/visit-tracking-browser.test.mjs +++ b/system/tests/visit-tracking-browser.test.mjs @@ -3,6 +3,84 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import { chromium } from "playwright"; +test("Whistlegraph org-to-app link, referral and mail draft work under the deployed CSP", async () => { + const browser = await chromium.launch({ headless: true, channel: process.env.PLAYWRIGHT_CHANNEL }); + try { + const context = await browser.newContext(); + const received = []; + const orgHtml = await readFile(new URL("../public/whistlegraph.org/index.html", import.meta.url), "utf8"); + const appHtml = await readFile(new URL("../public/whistlegraph.app/index.html", import.meta.url), "utf8"); + const caddy = await readFile(new URL("../../lith/Caddyfile", import.meta.url), "utf8"); + const csp = caddy.split("\nwhistlegraph.app {")[1].match(/header Content-Security-Policy "([^"]+)"/)[1]; + const bumper = orgHtml.match(/]*>[\s\S]*?<\/a>/)[0]; + const tracker = orgHtml.match(/