feat: add consent-gated Matomo controller

This commit is contained in:
msa46 2026-08-15 15:03:00 +02:00
parent c9ac8e1e21
commit f007aad4c1
2 changed files with 267 additions and 0 deletions

135
public/matomo-consent.js Normal file
View file

@ -0,0 +1,135 @@
(function initializeAnalyticsConsent(window, document) {
'use strict';
var storageKey = 'incognito.analytics-consent.v1';
var accepted = 'accepted';
var declined = 'declined';
var trackerOrigin = 'https://analytics.msvincognito.nl/';
if (window.__incognitoMatomoConsentController) return;
window.__incognitoMatomoConsentController = true;
function readDecision() {
try {
var value = window.localStorage.getItem(storageKey);
return value === accepted || value === declined ? value : null;
} catch (_error) {
return null;
}
}
function writeDecision(value) {
try {
window.localStorage.setItem(storageKey, value);
} catch (_error) {
// Consent applies to this page load only when browser storage is unavailable.
}
}
function initializeMatomo() {
var queue = window._paq = window._paq || [];
if (window.__incognitoMatomoInitialized) return;
window.__incognitoMatomoInitialized = true;
queue.push(['requireConsent']);
queue.push(['disableCookies']);
queue.push(['setConsentGiven']);
queue.push(['setTrackerUrl', trackerOrigin + 'matomo.php']);
queue.push(['setSiteId', '1']);
queue.push(['trackPageView']);
queue.push(['enableLinkTracking']);
var tracker = document.createElement('script');
tracker.id = 'incognito-matomo-script';
tracker.async = true;
tracker.src = trackerOrigin + 'matomo.js';
document.head.appendChild(tracker);
}
function createButton(id, label) {
var button = document.createElement('button');
button.id = id;
button.className = 'incognito-consent-action';
button.type = 'button';
button.textContent = label;
return button;
}
var banner = document.createElement('section');
banner.id = 'incognito-analytics-consent';
banner.setAttribute('role', 'dialog');
banner.setAttribute('aria-labelledby', 'incognito-analytics-consent-title');
banner.setAttribute('aria-describedby', 'incognito-analytics-consent-description');
banner.innerHTML =
'<div class="incognito-consent-copy">' +
'<h2 id="incognito-analytics-consent-title">Privacy-friendly analytics</h2>' +
'<p id="incognito-analytics-consent-description">MSV Incognito would like to use cookieless Matomo analytics to understand how the wiki is used. Nothing is sent to Matomo unless you accept. You can change your choice at any time.</p>' +
'<a href="https://msvincognito.nl/privacy-policy">Read our privacy policy</a>' +
'</div>';
var actions = document.createElement('div');
actions.className = 'incognito-consent-actions';
var acceptButton = createButton('incognito-analytics-accept', 'Accept analytics');
var declineButton = createButton('incognito-analytics-decline', 'Decline');
actions.appendChild(acceptButton);
actions.appendChild(declineButton);
banner.appendChild(actions);
var settingsButton = document.createElement('button');
settingsButton.id = 'incognito-privacy-settings';
settingsButton.type = 'button';
settingsButton.textContent = 'Privacy settings';
function showBanner(moveFocus) {
banner.hidden = false;
settingsButton.hidden = true;
if (moveFocus) acceptButton.focus();
}
function hideBanner(restoreFocus) {
banner.hidden = true;
settingsButton.hidden = false;
if (restoreFocus) settingsButton.focus();
}
acceptButton.addEventListener('click', function acceptAnalytics() {
if (banner.hidden) return;
writeDecision(accepted);
if (window.__incognitoMatomoInitialized) {
window._paq.push(['setConsentGiven']);
window._paq.push(['trackPageView']);
} else {
initializeMatomo();
}
hideBanner(true);
});
declineButton.addEventListener('click', function declineAnalytics() {
if (banner.hidden) return;
writeDecision(declined);
if (window._paq) {
window._paq.push(['forgetConsentGiven']);
window._paq.push(['deleteCookies']);
}
hideBanner(true);
});
settingsButton.addEventListener('click', function openPrivacySettings() {
showBanner(true);
});
document.body.appendChild(banner);
document.body.appendChild(settingsButton);
var decision = readDecision();
if (decision === accepted) {
banner.hidden = true;
settingsButton.hidden = false;
initializeMatomo();
} else if (decision === declined) {
banner.hidden = true;
settingsButton.hidden = false;
} else {
showBanner(false);
}
})(window, document);

View file

@ -0,0 +1,132 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import vm from 'node:vm';
import { parseHTML } from 'linkedom';
const controllerSource = await readFile('public/matomo-consent.js', 'utf8').catch(() => '');
const storageKey = 'incognito.analytics-consent.v1';
function queuedCommands(window) {
return JSON.parse(JSON.stringify(window._paq));
}
function runController({ decision, storageError = false } = {}) {
const { document } = parseHTML('<!doctype html><html><head></head><body></body></html>');
const values = new Map(decision ? [[storageKey, decision]] : []);
const localStorage = {
getItem(key) {
if (storageError) throw new Error('storage unavailable');
return values.get(key) ?? null;
},
setItem(key, value) {
if (storageError) throw new Error('storage unavailable');
values.set(key, value);
},
};
const window = { localStorage };
vm.runInNewContext(controllerSource, { window, document });
return { window, document, values };
}
test('first visit offers equal choices without initializing Matomo', () => {
const { window, document } = runController();
const banner = document.getElementById('incognito-analytics-consent');
const accept = document.getElementById('incognito-analytics-accept');
const decline = document.getElementById('incognito-analytics-decline');
assert.ok(banner, 'consent banner should exist');
assert.ok(accept, 'accept button should exist');
assert.ok(decline, 'decline button should exist');
assert.equal(window._paq, undefined);
assert.equal(document.querySelector('script[src*="analytics.msvincognito.nl"]'), null);
assert.equal(banner.hidden, false);
assert.equal(accept.className, decline.className);
assert.equal(
document.querySelector('#incognito-analytics-consent a').href,
'https://msvincognito.nl/privacy-policy',
);
});
test('accepting persists consent and initializes cookieless Matomo once', () => {
const { window, document, values } = runController();
const accept = document.getElementById('incognito-analytics-accept');
assert.ok(accept, 'accept button should exist');
accept.click();
accept.click();
assert.equal(values.get(storageKey), 'accepted');
assert.deepEqual(queuedCommands(window), [
['requireConsent'],
['disableCookies'],
['setConsentGiven'],
['setTrackerUrl', 'https://analytics.msvincognito.nl/matomo.php'],
['setSiteId', '1'],
['trackPageView'],
['enableLinkTracking'],
]);
const scripts = document.querySelectorAll('#incognito-matomo-script');
assert.equal(scripts.length, 1);
assert.equal(scripts[0].src, 'https://analytics.msvincognito.nl/matomo.js');
assert.equal(scripts[0].async, true);
});
test('declining persists the choice without creating Matomo state', () => {
const { window, document, values } = runController();
document.getElementById('incognito-analytics-decline').click();
assert.equal(values.get(storageKey), 'declined');
assert.equal(window._paq, undefined);
assert.equal(document.getElementById('incognito-matomo-script'), null);
assert.equal(document.getElementById('incognito-privacy-settings').hidden, false);
});
test('remembered acceptance tracks on a later page load', () => {
const { window, document } = runController({ decision: 'accepted' });
assert.ok(window._paq.some(([method]) => method === 'trackPageView'));
assert.equal(document.querySelectorAll('#incognito-matomo-script').length, 1);
assert.equal(document.getElementById('incognito-analytics-consent').hidden, true);
});
test('remembered decline never initializes Matomo', () => {
const { window, document } = runController({ decision: 'declined' });
assert.equal(window._paq, undefined);
assert.equal(document.getElementById('incognito-matomo-script'), null);
assert.equal(document.getElementById('incognito-privacy-settings').hidden, false);
});
test('storage failure defaults to no tracking and keeps consent available', () => {
const { window, document } = runController({ storageError: true });
assert.equal(window._paq, undefined);
assert.equal(document.getElementById('incognito-analytics-consent').hidden, false);
});
test('privacy settings allow an accepted visitor to withdraw consent', () => {
const { window, document, values } = runController({ decision: 'accepted' });
document.getElementById('incognito-privacy-settings').click();
document.getElementById('incognito-analytics-decline').click();
assert.equal(values.get(storageKey), 'declined');
assert.deepEqual(queuedCommands(window).slice(-2), [
['forgetConsentGiven'],
['deleteCookies'],
]);
const later = runController({ decision: values.get(storageKey) });
assert.equal(later.window._paq, undefined);
assert.equal(later.document.getElementById('incognito-matomo-script'), null);
});
test('running the local controller twice does not duplicate UI or tracking', () => {
const state = runController({ decision: 'accepted' });
vm.runInNewContext(controllerSource, { window: state.window, document: state.document });
assert.equal(state.document.querySelectorAll('#incognito-analytics-consent').length, 1);
assert.equal(state.document.querySelectorAll('#incognito-matomo-script').length, 1);
assert.equal(state.window._paq.filter(([method]) => method === 'trackPageView').length, 1);
});