diff --git a/README.md b/README.md index b791dc9..8a31c39 100644 --- a/README.md +++ b/README.md @@ -63,3 +63,17 @@ SITE=https://example.github.io BASE=/Incognito-Wiki npm run build Netlify reads `netlify.toml`, runs `npm run build`, and publishes `dist`. Configure `SITE` and `BASE` as environment values for the selected production hostname and path. The removable workflow at `.github/workflows/deploy.yml` optionally deploys the GitHub mirror to Pages after a push to `main` or a manual dispatch. Set the public repository variables `SITE_URL` and `BASE_PATH` on GitHub. The workflow is GitHub-specific; Forgejo ignores it, and removing the workflow or `netlify.toml` does not affect local development or verification. + +## Matomo analytics + +The wiki uses self-hosted Matomo only after a visitor explicitly accepts analytics. The client is loaded from `https://analytics.msvincognito.nl/`, uses site ID `1`, and disables analytics cookies. Visitors can decline without losing functionality and can reopen **Privacy settings** to withdraw consent. + +Before deploying analytics changes, the Matomo administrator must verify: + +- IP anonymization is enabled; +- raw logs and analytics reports use documented, proportionate retention periods; +- administrator access is restricted and reviewed; +- analytics data is not reused for advertising or cross-site profiling; and +- the [MSV Incognito privacy policy](https://msvincognito.nl/privacy-policy) accurately states the controller, purpose, data categories, retention, withdrawal process, and data-subject rights. + +The frontend consent gate is only one part of GDPR and ePrivacy compliance. Revisit the legal and server configuration when Matomo features or processing purposes change. diff --git a/astro.config.mjs b/astro.config.mjs index 9b5d23d..3c70b31 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -4,6 +4,10 @@ import { sidebar } from './src/config/sidebar.mjs'; const site = process.env.SITE || 'http://localhost:4321'; const base = process.env.BASE || '/'; +const normalizedBase = base === '/' + ? '' + : `/${base.replace(/^\/+|\/+$/g, '')}`; +const matomoConsentScript = `${normalizedBase}/matomo-consent.js`; export default defineConfig({ site, @@ -22,6 +26,15 @@ export default defineConfig({ }, favicon: '/favicon.ico', customCss: ['./src/styles/incognito.css'], + head: [ + { + tag: 'script', + attrs: { + src: matomoConsentScript, + defer: true, + }, + }, + ], social: [ { icon: 'external', diff --git a/src/styles/incognito.css b/src/styles/incognito.css index 2544204..c122d18 100644 --- a/src/styles/incognito.css +++ b/src/styles/incognito.css @@ -47,3 +47,88 @@ background: color-mix(in srgb, var(--incognito-violet) 22%, transparent); color: var(--sl-color-gray-1); } + +#incognito-analytics-consent { + position: fixed; + z-index: 1000; + inset-inline: max(1rem, env(safe-area-inset-left)) max(1rem, env(safe-area-inset-right)); + inset-block-end: max(1rem, env(safe-area-inset-bottom)); + display: grid; + gap: 1rem; + width: min(44rem, calc(100% - 2rem)); + margin-inline: auto; + padding: 1.25rem; + color: #fff; + background: var(--incognito-navy); + border: 1px solid color-mix(in srgb, var(--incognito-bright) 45%, transparent); + border-radius: 0.75rem; + box-shadow: 0 1rem 3rem rgb(0 0 0 / 30%); +} + +#incognito-analytics-consent[hidden], +#incognito-privacy-settings[hidden] { + display: none; +} + +.incognito-consent-copy h2 { + margin: 0 0 0.5rem; + color: inherit; + font-size: 1.125rem; +} + +.incognito-consent-copy p { + margin: 0 0 0.5rem; + line-height: 1.55; +} + +.incognito-consent-copy a { + color: var(--incognito-light); +} + +.incognito-consent-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75rem; +} + +.incognito-consent-action, +#incognito-privacy-settings { + min-height: 2.75rem; + border: 2px solid var(--incognito-light); + border-radius: 0.5rem; + color: var(--incognito-navy); + background: var(--incognito-light); + font: inherit; + font-weight: 700; + cursor: pointer; +} + +.incognito-consent-action:focus-visible, +#incognito-privacy-settings:focus-visible { + outline: 3px solid var(--incognito-pink); + outline-offset: 3px; +} + +#incognito-privacy-settings { + position: fixed; + z-index: 999; + inset-inline-end: max(1rem, env(safe-area-inset-right)); + inset-block-end: max(1rem, env(safe-area-inset-bottom)); + min-height: 2.25rem; + padding-inline: 0.75rem; + border-width: 1px; + font-size: 0.8125rem; +} + +@media (min-width: 50rem) { + #incognito-analytics-consent { + grid-template-columns: minmax(0, 1fr) 18rem; + align-items: end; + } +} + +@media (max-width: 30rem) { + .incognito-consent-actions { + grid-template-columns: 1fr; + } +} diff --git a/tests/matomo-build.test.mjs b/tests/matomo-build.test.mjs new file mode 100644 index 0000000..334893b --- /dev/null +++ b/tests/matomo-build.test.mjs @@ -0,0 +1,50 @@ +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { access, mkdtemp, readFile, readdir, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; +import test from 'node:test'; +import { parseHTML } from 'linkedom'; + +const execFileAsync = promisify(execFile); + +async function collectCss(directory) { + const entries = await readdir(directory, { withFileTypes: true }); + const chunks = []; + for (const entry of entries) { + const path = join(directory, entry.name); + if (entry.isDirectory()) chunks.push(await collectCss(path)); + if (entry.isFile() && entry.name.endsWith('.css')) chunks.push(await readFile(path, 'utf8')); + } + return chunks.join('\n'); +} + +test('subpath production build includes the local consent controller and its UI styles', async () => { + const output = await mkdtemp(join(process.cwd(), '.matomo-build-')); + try { + await execFileAsync('node_modules/.bin/astro', ['build', '--outDir', output], { + env: { + ...process.env, + SITE: 'https://example.github.io', + BASE: '/Incognito-Wiki', + }, + maxBuffer: 10 * 1024 * 1024, + }); + + const html = await readFile(join(output, 'index.html'), 'utf8'); + const { document } = parseHTML(html); + const scripts = document.querySelectorAll( + 'script[src="/Incognito-Wiki/matomo-consent.js"]', + ); + assert.equal(scripts.length, 1, 'every page should load one base-aware local controller'); + assert.equal(scripts[0].hasAttribute('defer'), true); + await access(join(output, 'matomo-consent.js')); + + const css = await collectCss(output); + assert.match(css, /#incognito-analytics-consent/); + assert.match(css, /\.incognito-consent-action/); + assert.match(css, /#incognito-privacy-settings/); + } finally { + await rm(output, { recursive: true, force: true }); + } +});