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 @@ +