diff --git a/docs/production-pageviews.md b/docs/production-pageviews.md index 9c087c5..4607cff 100644 --- a/docs/production-pageviews.md +++ b/docs/production-pageviews.md @@ -9,8 +9,16 @@ Automatic Umami tracking is disabled, including history and click hooks. One manual pageview is sent per loaded document. The before-send hook reconstructs a strict projection: website, hostname, static title `Alta Export Player`, static path `/`, empty referrer, validated ordinary browser screen/language. It drops -identity, named/custom events, tags, arbitrary properties, user title, URL, +identity, unknown/custom events, tags, arbitrary properties, user title, URL, query/fragment, filenames, upload contents, forms and application state. +Only fixed download controls emit fixed event names/properties: `#releaseAction` → `download_desktop_app` (`tool: alta-export-player`, `format: desktop`). Native desktop/update modes are excluded. +These are click intents, not successful-download reports. A capture listener does +not read the link URL or filename, cancel navigation, or await analytics. Unknown events and +caller-provided event data are discarded; whitelisted event data is reconstructed. + +The hook is installed before creating/loading the external script, auto tracking +is disabled, and an unavailable bootstrap produces no tracker GET/POST. The script +uses anonymous CORS and no-referrer; collection fetch credentials are omit. No application data is read. Both script requests and collection requests must have no Cookie, Authorization or Referer headers. Tracker credentials are omit. diff --git a/static/privacy-pageview.js b/static/privacy-pageview.js index bf33ee0..85b1b9f 100644 --- a/static/privacy-pageview.js +++ b/static/privacy-pageview.js @@ -1,4 +1,4 @@ -/* Production pageviews only. Never pass application/browser DOM data to Umami. */ +/* Static production pageviews/download intent. No application data to Umami. */ (() => { 'use strict'; const hostname = "player.nenasets.com"; @@ -11,18 +11,25 @@ && paths.includes(window.location.pathname); if (!eligible() || window.__altaPageviewLoaded) return; window.__altaPageviewLoaded = true; - // Rebuild, don't spread the input: no identity, event properties, referrer, + const controls = {releaseAction: 'download_desktop_app'}; + const events = {download_desktop_app: {tool: 'alta-export-player', format: 'desktop'}}; + const own = (object, key) => Object.prototype.hasOwnProperty.call(object, key); + // Rebuild, don't spread the input: no identity, arbitrary properties, referrer, // customer title, query, fragment, form, filename or upload data can survive. window.altaSafePageview = (type, payload) => { - if (!eligible() || type !== 'event' || !payload || payload.name) return null; - return { website, hostname, url, title, referrer: '', - screen: /^\d{1,5}x\d{1,5}$/.test(payload.screen) ? payload.screen : undefined, - language: /^[a-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/.test(payload.language) ? payload.language : undefined }; + if (!eligible() || type !== 'event' || !payload) return null; + if (payload.name && (typeof payload.name !== 'string' || !own(events, payload.name))) return null; + const safe = { website, hostname, url, title, referrer: '', + screen: typeof payload.screen === 'string' && /^\d{1,5}x\d{1,5}$/.test(payload.screen) ? payload.screen : undefined, + language: typeof payload.language === 'string' && /^[a-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/.test(payload.language) ? payload.language : undefined }; + if (payload.name) { safe.name = payload.name; safe.data = {...events[payload.name]}; } + return safe; }; const tracker = document.createElement('script'); tracker.src = 'https://umami.nenasets.com/script.js'; tracker.defer = true; tracker.referrerPolicy = 'no-referrer'; + tracker.crossOrigin = 'anonymous'; for (const [key, value] of Object.entries({ 'website-id': website, domains: hostname, 'auto-track': 'false', 'exclude-search': 'true', 'exclude-hash': 'true', @@ -35,5 +42,15 @@ window.umami.track(); } }; + // Capture intent once, before application handlers; never cancel/await navigation. + // The web download anchor and native desktop/update behavior stay unchanged. + document.addEventListener('click', event => { + if (!eligible() || window.webavpNative || event.button !== 0) return; + const control = event.target.closest?.('a[id]'); + if (!control || control.disabled || control.getAttribute('aria-disabled') === 'true' + || !own(controls, control.id) || !window.umami) return; + const name = controls[control.id]; + try { window.umami.track(name, {...events[name]}); } catch (_) { /* Analytics is optional. */ } + }, true); document.head.appendChild(tracker); })(); diff --git a/templates/index.html b/templates/index.html index 674ceb1..a1212a4 100644 --- a/templates/index.html +++ b/templates/index.html @@ -5,7 +5,7 @@ - + Alta Video Player diff --git a/tests/analytics.test.js b/tests/analytics.test.js index 9e747ee..37d6c0c 100644 --- a/tests/analytics.test.js +++ b/tests/analytics.test.js @@ -10,13 +10,15 @@ const site = player ? 'dc1b14a6-d1ba-4802-b895-553b3c81c677' : 'e00bb594-4c6e-44 const host = player ? 'player.nenasets.com' : 'tools.nenasets.com'; const route = player ? '/' : '/private/onvif/'; function boot(url) { - const scripts = [], tracked = []; - const window = { location: new URL(url), umami: {track: () => tracked.push(true)} }; - const document = { createElement: () => ({setAttribute(k,v) { this[k] = v; }}), head: {appendChild: s => scripts.push(s)} }; + const scripts = [], tracked = [], listeners = []; + const window = { location: new URL(url), umami: {track: (...args) => tracked.push(args)} }; + const document = { addEventListener: (type, fn, capture) => listeners.push({type, fn, capture}), + createElement: () => ({setAttribute(k,v) { this[k] = v; }}), + head: {appendChild: s => { assert.equal(typeof window.altaSafePageview, 'function', 'hook installed before load'); scripts.push(s); }} }; const context = vm.createContext({window, document}); const source = fs.readFileSync(path.join(root, 'static/privacy-pageview.js'), 'utf8'); vm.runInContext(source, context); - return {window, scripts, tracked, context, source}; + return {window, scripts, tracked, listeners, context, source}; } test('production pageviews use one manually loaded no-referrer tracker', () => { const b = boot('https://' + host + route + '?customer=PRIVATE#PRIVATE'); @@ -24,6 +26,7 @@ test('production pageviews use one manually loaded no-referrer tracker', () => { const s = b.scripts[0]; assert.equal(s.src, 'https://umami.nenasets.com/script.js'); assert.equal(s.referrerPolicy, 'no-referrer'); + assert.equal(s.crossOrigin, 'anonymous'); assert.equal(s['data-auto-track'], 'false'); assert.equal(s['data-exclude-search'], 'true'); assert.equal(s['data-exclude-hash'], 'true'); @@ -57,6 +60,55 @@ test('fail closed on DEV, previous, unknown, API, port, alternate host and HTTP 'https://' + host + '/customer-name', 'https://' + host + '/api/verify-cert']; for (const url of excluded) assert.equal(boot(url).scripts.length, 0, url); }); + +const downloads = player ? [['releaseAction', 'download_desktop_app', {tool:'alta-export-player',format:'desktop'}]] + : [['download-template', 'download_csv_template', {tool:'onvif',format:'csv',kind:'template'}], + ['download-report', 'download_csv_report', {tool:'onvif',format:'csv',kind:'report'}]]; +test('static download clicks count once without changing native navigation or reading user data', () => { + const b = boot('https://' + host + route); + b.scripts[0].onload(); + vm.runInContext(b.source, b.context); + assert.equal(b.listeners.length, 1); + assert.equal(b.listeners[0].type, 'click'); + assert.equal(b.listeners[0].capture, true); + for (const [id, name, data] of downloads) { + const control = {id,tagName:'A',disabled:false,getAttribute:()=>null, + form:{checkValidity:()=>false},closest:()=>control}; + const event = {target:{closest:()=>control},button:0,preventDefault:()=>assert.fail('navigation intercepted')}; + const n=b.tracked.length; + b.listeners[0].fn(event); + assert.equal(b.tracked.length,n+1); + assert.equal(b.tracked.at(-1)[0],name); + assert.deepEqual(JSON.parse(JSON.stringify(b.tracked.at(-1)[1])),data); + const safe=b.window.altaSafePageview('event',{name,data:{url:'PRIVATE',filename:'PRIVATE'},id:'PRIVATE',tag:'PRIVATE'}); + assert.equal(safe.name,name); + assert.deepEqual(JSON.parse(JSON.stringify(safe.data)),data); + assert.ok(!JSON.stringify(safe).includes('PRIVATE')); + control.disabled=true;b.listeners[0].fn(event);assert.equal(b.tracked.length,n+1); + } + b.window.location=new URL('https://'+host+'/private/previous/'); + const n=b.tracked.length; + b.listeners[0].fn({target:{closest:()=>({id:downloads[0][0],tagName:'A'})},button:0}); + assert.equal(b.tracked.length,n); +}); +test('analytics missing or throwing does not prevent download clicks', () => { + const b=boot('https://'+host+route); + const e={target:{closest:()=>({id:downloads[0][0],tagName:'A',getAttribute:()=>null})},button:0}; + delete b.window.umami;assert.doesNotThrow(()=>b.listeners[0].fn(e)); + b.window.umami={track:()=>{throw Error('offline');}};assert.doesNotThrow(()=>b.listeners[0].fn(e)); +}); +test('known events use an own-property whitelist, native desktop modes are excluded', () => { + const b=boot('https://'+host+route); + for(const name of ['__proto__','constructor','toString','PRIVATE', {toString:()=>downloads[0][1]}]) + assert.equal(b.window.altaSafePageview('event',{name}),null); + if(player){ + b.window.webavpNative={checkForUpdates:()=>{}}; + const n=b.tracked.length; + b.listeners[0].fn({target:{closest:()=>({id:'releaseAction',tagName:'A',getAttribute:()=>null})},button:0}); + assert.equal(b.tracked.length,n); + } +}); + test('only native application document routes are eligible', () => { for(const suffix of (player ? [''] : ['', 'upload', 'results', 'process', 'clear'])) assert.equal(boot('https://' + host + route + suffix).scripts.length, 1, suffix);