diff --git a/docs/production-pageviews.md b/docs/production-pageviews.md new file mode 100644 index 0000000..9c087c5 --- /dev/null +++ b/docs/production-pageviews.md @@ -0,0 +1,38 @@ +# Production pageview privacy contract + +`static/privacy-pageview.js` loads the current Umami script only on HTTPS, +exact hostname `player.nenasets.com`, default port, and known application document routes. +It never loads on loopback, desktop protocol, DEV, previous, rollback, API, +admin, download, or unknown routes. Website: `dc1b14a6-d1ba-4802-b895-553b3c81c677`. + +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, +query/fragment, filenames, upload contents, forms and application state. +No application data is read. Both script requests and collection requests must +have no Cookie, Authorization or Referer headers. Tracker credentials are omit. + +The only CSP additions are the exact script and endpoint paths: +`https://umami.nenasets.com/script.js` in script-src and +`https://umami.nenasets.com/api/send` in connect-src. Existing restrictions stay. +No gateway/auth/deployment/registry configuration is changed by this commit. + +## Repeatable gates + +- `node --test tests/analytics.test.js`: hostname/path/protocol/port allowlist, + single pageview, automatic tracking off, reconstruction and identity/event denial. +- `node scripts/check.js`: native utility/server checks plus analytics suite. +- Root is the only native application document route. No synthetic media fixture + is provided; local acceptance exercised shell/layout controls, not footage playback. + +Built-image Chromium desktop/mobile acceptance and request evidence live outside +this source checkout in the rollout project worker reports. The audited real +tracker was served locally and its POSTs intercepted with a204 sink, not sent to +production Umami. This does not prove organic ingestion. ONVIF acceptance uses +unchanged documented real bundled catalogue records, independent local signing +secrets and a test-only employee verifier; it does not prove a Google login. + +No push or deployment is part of this commit. Inspect CI and deployment bindings +before publishing; the coordinator owns exact-image promotion and rollback. diff --git a/scripts/check.js b/scripts/check.js index 4eb6bfc..dd888c0 100644 --- a/scripts/check.js +++ b/scripts/check.js @@ -27,6 +27,7 @@ for (const file of ['electron/main.js', 'electron/preload.js', 'static/webavp-ut run(process.execPath, ['--check', file]); } run(process.execPath, ['tests/webavp-utils.test.js']); +run(process.execPath, ['--test', 'tests/analytics.test.js']); const html = fs.readFileSync(path.join(ROOT_DIR, 'templates/index.html'), 'utf8'); const required = [ diff --git a/static/privacy-pageview.js b/static/privacy-pageview.js new file mode 100644 index 0000000..bf33ee0 --- /dev/null +++ b/static/privacy-pageview.js @@ -0,0 +1,39 @@ +/* Production pageviews only. Never pass application/browser DOM data to Umami. */ +(() => { + 'use strict'; + const hostname = "player.nenasets.com"; + const website = "dc1b14a6-d1ba-4802-b895-553b3c81c677"; + const url = "/"; + const title = "Alta Export Player"; + const paths = ["/"]; + const eligible = () => window.location.protocol === 'https:' + && window.location.hostname === hostname && window.location.port === '' + && paths.includes(window.location.pathname); + if (!eligible() || window.__altaPageviewLoaded) return; + window.__altaPageviewLoaded = true; + // Rebuild, don't spread the input: no identity, event 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 }; + }; + const tracker = document.createElement('script'); + tracker.src = 'https://umami.nenasets.com/script.js'; + tracker.defer = true; + tracker.referrerPolicy = 'no-referrer'; + for (const [key, value] of Object.entries({ + 'website-id': website, domains: hostname, 'auto-track': 'false', + 'exclude-search': 'true', 'exclude-hash': 'true', + 'fetch-credentials': 'omit', 'before-send': 'altaSafePageview' + })) tracker.setAttribute('data-' + key, value); + let sent = false; + tracker.onload = () => { + if (!sent && eligible() && window.umami) { + sent = true; + window.umami.track(); + } + }; + document.head.appendChild(tracker); +})(); diff --git a/templates/index.html b/templates/index.html index 58c4d06..674ceb1 100644 --- a/templates/index.html +++ b/templates/index.html @@ -3,7 +3,9 @@ - + + + Alta Video Player diff --git a/tests/analytics.test.js b/tests/analytics.test.js new file mode 100644 index 0000000..9e747ee --- /dev/null +++ b/tests/analytics.test.js @@ -0,0 +1,63 @@ +'use strict'; +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const root = path.resolve(__dirname, '..'); +const player = fs.existsSync(path.join(root, 'package.json')); +const site = player ? 'dc1b14a6-d1ba-4802-b895-553b3c81c677' : 'e00bb594-4c6e-4471-911d-be3f7fdd9fae'; +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 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}; +} +test('production pageviews use one manually loaded no-referrer tracker', () => { + const b = boot('https://' + host + route + '?customer=PRIVATE#PRIVATE'); + assert.equal(b.scripts.length, 1); + const s = b.scripts[0]; + assert.equal(s.src, 'https://umami.nenasets.com/script.js'); + assert.equal(s.referrerPolicy, 'no-referrer'); + assert.equal(s['data-auto-track'], 'false'); + assert.equal(s['data-exclude-search'], 'true'); + assert.equal(s['data-exclude-hash'], 'true'); + assert.equal(s['data-fetch-credentials'], 'omit'); + assert.equal(s['data-domains'], host); + assert.equal(s['data-website-id'], site); + s.onload(); s.onload(); + vm.runInContext(b.source, b.context); + assert.equal(b.scripts.length, 1); + assert.equal(b.tracked.length, 1); +}); +test('before-send rebuilds a minimal static pageview; no identity, events or arbitrary data', () => { + const b = boot('https://' + host + route); + const guard = b.window[b.scripts[0]['data-before-send']]; + const dirty = {website:'PRIVATE',url:'PRIVATE',title:'PRIVATE',hostname:'PRIVATE',referrer:'PRIVATE',name:'PRIVATE',data:{filename:'PRIVATE'},id:'PRIVATE',tag:'PRIVATE',screen:'1920x1080',language:'en-US'}; + assert.equal(guard('identify', dirty), null); + assert.equal(guard('event', dirty), null); + delete dirty.name; + const safe = JSON.parse(JSON.stringify(guard('event', dirty))); + assert.deepEqual(safe, {website:site,hostname:host,url:route,title:player?'Alta Export Player':'ONVIF Tool',referrer:'',screen:'1920x1080',language:'en-US'}); + assert.ok(!JSON.stringify(safe).includes('PRIVATE')); + b.window.location = new URL('https://' + host + '/private/previous/'); + assert.equal(guard('event', dirty), null); +}); +test('fail closed on DEV, previous, unknown, API, port, alternate host and HTTP routes', () => { + const excluded = ['http://' + host + route, 'https://' + host + ':8443' + route, + 'https://dev.' + host + route, 'https://127.0.0.1' + route, + 'https://' + host + '/private/onvif-dev/', 'https://' + host + '/private/onvif-previous/', + 'https://' + host + '/private/onvif/previous/', 'https://' + host + '/private/onvif/api/lookup', + 'https://' + host + '/private/onvif/download', 'https://' + host + '/private/onvif/admin', + 'https://' + host + '/customer-name', 'https://' + host + '/api/verify-cert']; + for (const url of excluded) assert.equal(boot(url).scripts.length, 0, url); +}); +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); +});