From 4bf6f6432284e6bb9d368cae51179d26a306617d Mon Sep 17 00:00:00 2001 From: codexy Date: Mon, 26 Oct 2026 18:35:19 +0900 Subject: [PATCH 1/2] Add opt-in website analytics with isolated execution frames --- scripts/build-site.js | 10 ++ site/usage-analytics.css | 56 ++++++++++ site/usage-analytics.js | 181 ++++++++++++++++++++++++++++++++ test/analytics-security.test.js | 14 +++ vercel.json | 2 +- 5 files changed, 262 insertions(+), 1 deletion(-) create mode 100644 site/usage-analytics.css create mode 100644 site/usage-analytics.js create mode 100644 test/analytics-security.test.js diff --git a/scripts/build-site.js b/scripts/build-site.js index dbbd0f5..9cc6a99 100644 --- a/scripts/build-site.js +++ b/scripts/build-site.js @@ -20,3 +20,13 @@ await writeFile('site-dist/type-libs.js', 'export default ' + JSON.stringify(awa await writeFile('site-dist/en/index.html', (await readFile('site/en/index.html', 'utf8')).replaceAll('https://hangulscript.vercel.app', origin)); await writeFile('site-dist/playground/index.html', (await readFile('site/playground/index.html', 'utf8')).replaceAll('https://hangulscript.vercel.app', origin)); + +const escapeUsagePath=path=>Array.from(path,char=>'.?+*^$[]\\(){}|'.includes(char)?'\\'+char:char).join(''); +const usageConfig={service:'koreanscript',origin,hosts:[new URL(origin).hostname],routes:['/',...docRoutes].map(path=>({match:path==='/'?'^/$':`^${escapeUsagePath(path.slice(0,-1))}/?$`,path,name:path.split('/').filter(Boolean).join('_').replaceAll('-','_')||'home'})),sections:{features:'features',start:'start'},outbound:[{url:'https://github.com/uiwwsw/hangulscript',name:'github'},{url:'https://www.npmjs.com/package/@uiwwsw/hangulscript',name:'npm'}]}; +const usageAttr=JSON.stringify(usageConfig).replaceAll('&','&').replaceAll('"','"').replaceAll('<','<'); +const usageTag=``; +// Only authored page routes receive analytics; the execution frame stays isolated. +for(const path of ['/',...docRoutes]) { + const file=`site-dist${path}index.html`; + await writeFile(file,(await readFile(file,'utf8')).replace('',usageTag+'')); +} diff --git a/site/usage-analytics.css b/site/usage-analytics.css new file mode 100644 index 0000000..579ebc4 --- /dev/null +++ b/site/usage-analytics.css @@ -0,0 +1,56 @@ +#movv-usage-choice { + position: fixed; + z-index: 90; + bottom: calc(16px + env(safe-area-inset-bottom, 0px)); + right: 16px; + max-width: min(440px, calc(100vw - 32px)); + max-height: calc(100dvh - 48px); + overflow: auto; + font: inherit; + color: CanvasText; +} +#movv-usage-choice.usage-above-nav { + bottom: calc(80px + env(safe-area-inset-bottom, 0px)); + max-height: calc(100dvh - 112px); +} +#movv-usage-choice .usage-panel { + background: Canvas; + border: 1px solid #888; + border-radius: 16px; + padding: 20px; + box-shadow: 0 8px 32px #0003; +} +#movv-usage-choice p { + font: inherit; + line-height: 1.6; + margin: 0 0 16px; +} +#movv-usage-choice button { + font: inherit; + cursor: pointer; + border: 1px solid #888; + border-radius: 8px; + background: Canvas; + color: CanvasText; + padding: 8px 12px; + min-height: 40px; +} +#movv-usage-choice .usage-allow { + margin-left: 8px; + background: #193f36; + color: #fff; +} +#movv-usage-choice .usage-preference { + font-size: 12px; + box-shadow: 0 2px 8px #0002; +} +#movv-usage-choice a { + font: inherit; + color: inherit; + text-decoration: underline; +} +#movv-usage-choice .usage-detail { + font-size: 12px; + margin-top: 16px; + margin-bottom: 0; +} diff --git a/site/usage-analytics.js b/site/usage-analytics.js new file mode 100644 index 0000000..a29e791 --- /dev/null +++ b/site/usage-analytics.js @@ -0,0 +1,181 @@ +// Google Tag Manager loads only after an explicit, browser-local choice. +export function screenForPath(config, input) { + const path = String(input).split(/[?#]/, 1)[0] || '/'; + return config.routes.find(route => new RegExp(route.match).test(path)) ?? null; +} + +export function usagePayload(config, screen, event, destination, placement) { + if (!screen || !config.routes.includes(screen)) return null; + return { + event, + service_name: config.service, + screen_name: screen.name, + page_location: config.origin + screen.path, + page_title: `${config.service} | ${screen.name}`, + page_referrer: '', + navigation_target: destination ?? '', + navigation_location: placement ?? '', + }; +} + +export function navigationDestination(config, url, currentPath, currentOrigin = config.origin) { + if (url.origin !== currentOrigin) return config.outbound?.find(link => link.url === url.href)?.name ?? null; + const screen = screenForPath(config, url.pathname); + const section = url.pathname === currentPath && url.hash ? config.sections?.[url.hash.slice(1)] : undefined; + if (!screen || (url.hash && !section && url.pathname === currentPath)) return null; + return section ?? screen.name; +} + +export function controlDestination(config, value) { + return config.controls?.includes(value) ? value : null; +} + +function start(config) { + if (!config.hosts.includes(location.hostname) || navigator.doNotTrack === '1') return; + const key = `${config.service}.usage-consent.v1`; + const container = 'GTM-5Q34H7QJ'; + let lastPath = ''; + let historyPath = location.pathname; + let currentScreen = screenForPath(config, location.pathname); + const consent = () => { + try { return localStorage.getItem(key); } catch { return 'denied'; } + }; + const choose = value => { + try { + localStorage.setItem(key, value); + applyConsent(); + host.querySelector('button')?.focus({preventScroll:true}); + } catch { /* Collection remains off. */ } + }; + const track = (screen = screenForPath(config, location.pathname), path = location.pathname) => { + currentScreen = screen; + const allowed = consent() === 'granted' && !!screen; + window['ga-disable-G-TRYJR3PLGB'] = !allowed; + if (!allowed) { lastPath = ''; return; } + if (lastPath === path) return; + const payload = usagePayload(config, screen, 'page_view'); + window.dataLayer ??= []; + if (!document.getElementById('movv-usage-gtm')) { + const { event, ...context } = payload; + window.dataLayer.push(context, { 'gtm.start': Date.now(), event: 'gtm.js' }); + const script = document.createElement('script'); + script.id = 'movv-usage-gtm'; + script.async = true; + script.src = `https://www.googletagmanager.com/gtm.js?id=${container}`; + document.head.append(script); + } + lastPath = path; + window.dataLayer.push(payload); + }; + + document.addEventListener('click', event => { + if (consent() !== 'granted' || !currentScreen || event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; + const control = event.target instanceof Element ? event.target.closest('[data-usage-target]') : null; + const target = controlDestination(config, control?.getAttribute('data-usage-target')); + if (target) { + window.dataLayer?.push(usagePayload(config, currentScreen, 'navigation_select', target, 'control')); + return; + } + const anchor = event.target instanceof Element ? event.target.closest('a[href]') : null; + if (!anchor) return; + const url = new URL(anchor.href, location.href); + const destination = navigationDestination(config, url, location.pathname, location.origin); + if (!destination) return; + const placement = anchor.closest('header') ? 'header' : anchor.closest('footer') ? 'footer' : anchor.closest('nav,[role="navigation"]') ? 'navigation' : 'content'; + window.dataLayer?.push(usagePayload(config, currentScreen, 'navigation_select', destination, placement)); + }, true); + + const routeChanged = () => { + if (historyPath === location.pathname) return; + historyPath = location.pathname; + track(); + }; + for (const method of ['pushState', 'replaceState']) { + const original = history[method]; + history[method] = function (...args) { + const result = original.apply(this, args); + queueMicrotask(routeChanged); + return result; + }; + } + window.addEventListener('popstate', routeChanged); + window.addEventListener('storage', event => { + if (event.key === key || event.key === null) applyConsent(); + }); + // Canvas-based web apps may select a registered screen without sending route IDs. + window.movvUsage = { + screen(name) { + const screen = config.routes.find(route => route.name === name); + if (screen) track(screen, `screen:${name}`); + }, + }; + + const english = document.documentElement.lang.startsWith('en'); + const host = document.createElement('aside'); + host.id = 'movv-usage-choice'; + host.setAttribute('aria-label', english ? 'Usage statistics' : '이용 통계'); + if (Number(config.bottom) >= 64) host.className = 'usage-above-nav'; + const stylesheet = document.createElement('link'); + stylesheet.rel = 'stylesheet'; + stylesheet.href = config.stylesheet || '/usage-analytics.css'; + host.hidden = true; + stylesheet.onload = stylesheet.onerror = () => { host.hidden = false; }; + document.head.append(stylesheet); + const panel = () => { + host.replaceChildren(); + const box = document.createElement('div'); + box.className = 'usage-panel'; + const text = document.createElement('p'); + text.textContent = english ? 'Allow Google Analytics to measure page visits, navigation and engagement time? Names, email addresses, search terms and form values are excluded from event parameters.' : '페이지 방문·메뉴 이동과 참여시간을 Google Analytics로 집계해도 될까요? 이름·이메일·검색어·입력 내용은 이벤트에 담지 않습니다.'; + const deny = document.createElement('button'); + deny.type = 'button'; + deny.textContent = english ? 'Do not allow' : '허용 안 함'; + deny.onclick = () => choose('denied'); + const allow = document.createElement('button'); + allow.type = 'button'; + allow.className = 'usage-allow'; + allow.textContent = english ? 'Allow' : '허용'; + allow.onclick = () => choose('granted'); + const detail = document.createElement('p'); + detail.className = 'usage-detail'; + detail.append(english ? 'Google processes visitor cookies and device information. Change this choice using Usage stats. ' : '방문 구분 쿠키와 기기 정보가 처리됩니다. 이용 통계 버튼에서 선택을 바꿀 수 있습니다. '); + const policy = document.createElement('a'); + policy.href = 'https://policies.google.com/privacy'; + policy.target = '_blank'; + policy.rel = 'noopener noreferrer'; + policy.textContent = english ? 'Google privacy policy' : 'Google 개인정보처리방침'; + detail.append(policy); + box.append(text, deny, allow, detail); + host.append(box); + }; + const renderChoice = () => { + if (consent() === null) { panel(); return; } + host.replaceChildren(); + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'usage-preference'; + button.textContent = english ? 'Usage stats' : '이용 통계'; + button.onclick = panel; + host.append(button); + }; + function applyConsent() { + const allowed = consent() === 'granted' && !!currentScreen; + window['ga-disable-G-TRYJR3PLGB'] = !allowed; + renderChoice(); + if (allowed) { + const path = lastPath || location.pathname; + lastPath = ''; + track(currentScreen, path); + } + } + document.body.append(host); + applyConsent(); + window.dispatchEvent(new Event('movv-usage-ready')); +} + +if (typeof document !== 'undefined') { + const script = document.querySelector('script[data-usage-config]'); + if (script) { + try { start(JSON.parse(script.dataset.usageConfig)); } catch { /* Invalid configuration never enables collection. */ } + } +} diff --git a/test/analytics-security.test.js b/test/analytics-security.test.js new file mode 100644 index 0000000..f0ce6c0 --- /dev/null +++ b/test/analytics-security.test.js @@ -0,0 +1,14 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {readFile} from 'node:fs/promises'; + +test('analytics network access does not enter the code execution frame',async()=>{ + const config=JSON.parse(await readFile('vercel.json','utf8')); + const parent=config.headers[0].headers.find(header=>header.key==='Content-Security-Policy').value; + const runner=config.headers.find(rule=>rule.source==='/runner.html').headers.find(header=>header.key==='Content-Security-Policy').value; + assert.match(parent,/script-src 'self' https:\/\/www\.googletagmanager\.com;/); + assert.doesNotMatch(parent,/'unsafe-inline'|'unsafe-eval'/); + assert.match(runner,/connect-src 'none'/); + assert.match(runner,/default-src 'none'/); + assert.doesNotMatch(runner,/google/); +}); diff --git a/vercel.json b/vercel.json index d2600fd..7a98339 100644 --- a/vercel.json +++ b/vercel.json @@ -21,7 +21,7 @@ }, { "key": "Content-Security-Policy", - "value": "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; frame-src 'self'; worker-src 'self' blob:; connect-src 'none'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'" + "value": "default-src 'self'; script-src 'self' https://www.googletagmanager.com; style-src 'self'; img-src 'self' data: https://www.googletagmanager.com https://*.google-analytics.com; frame-src 'self'; worker-src 'self' blob:; connect-src https://www.googletagmanager.com https://*.google-analytics.com https://*.google.com; object-src 'none'; base-uri 'self'; frame-ancestors 'none'" } ] }, From a30414ddde39c05a1d5f6eac30384ddbc1aeda85 Mon Sep 17 00:00:00 2001 From: codexy Date: Mon, 26 Oct 2026 18:48:41 +0900 Subject: [PATCH 2/2] Keep route patterns intact when injecting analytics settings --- scripts/build-site.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/build-site.js b/scripts/build-site.js index 9cc6a99..7e267c7 100644 --- a/scripts/build-site.js +++ b/scripts/build-site.js @@ -28,5 +28,5 @@ const usageTag=`