feat: add privacy-safe production player pageviews
This commit is contained in:
@@ -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.
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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);
|
||||
})();
|
||||
@@ -3,7 +3,9 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self'; media-src blob:; img-src 'self' data:;">
|
||||
<meta name="referrer" content="no-referrer">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' https://umami.nenasets.com/script.js; style-src 'self' 'unsafe-inline'; connect-src 'self' https://umami.nenasets.com/api/send; media-src blob:; img-src 'self' data:;">
|
||||
<script src="/static/privacy-pageview.js?v=1" defer></script>
|
||||
<title>Alta Video Player</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect rx='20' width='100' height='100' fill='%23121826'/><polygon points='38,25 38,75 78,50' fill='%23006ED7'/></svg>">
|
||||
<script src="/static/jszip.min.js"></script>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user