Add first-party analytics tracker with encrypted transmissions
Core system: - AnalyticsUniqId entity (visitor identity with device/os/browser parsing) - AnalyticsEvent entity (page views linked to visitor) - POST /t endpoint with AES-256-GCM encrypted payloads - HMAC-SHA256 visitor hash for anti-tampering - Async processing via Messenger - JS module: auto page_view tracking, setAuth for logged users - Encryption key shared via data-k attribute on body - setAuth only triggers when cookie consent is accepted - Clean CSP: remove old tracker domains (Cloudflare, Umami) 100% first-party, no cookies, invisible to adblockers, RGPD-friendly. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
114
assets/modules/analytics.js
Normal file
114
assets/modules/analytics.js
Normal file
@@ -0,0 +1,114 @@
|
||||
const ENDPOINT = '/t'
|
||||
const SK_UID = '_u'
|
||||
const SK_HASH = '_h'
|
||||
|
||||
let encKey = null
|
||||
|
||||
async function importKey(b64) {
|
||||
const raw = Uint8Array.from(atob(b64), c => c.charCodeAt(0))
|
||||
return globalThis.crypto.subtle.importKey('raw', raw, 'AES-GCM', false, ['encrypt', 'decrypt'])
|
||||
}
|
||||
|
||||
async function encrypt(data) {
|
||||
if (!encKey) return null
|
||||
const json = new TextEncoder().encode(JSON.stringify(data))
|
||||
const iv = globalThis.crypto.getRandomValues(new Uint8Array(12))
|
||||
const encrypted = await globalThis.crypto.subtle.encrypt({ name: 'AES-GCM', iv, tagLength: 128 }, encKey, json)
|
||||
const buf = new Uint8Array(encrypted)
|
||||
const combined = new Uint8Array(12 + buf.length)
|
||||
combined.set(iv)
|
||||
combined.set(buf, 12)
|
||||
return btoa(String.fromCharCode(...combined))
|
||||
}
|
||||
|
||||
async function decrypt(b64) {
|
||||
if (!encKey) return null
|
||||
const raw = Uint8Array.from(atob(b64), c => c.charCodeAt(0))
|
||||
const iv = raw.slice(0, 12)
|
||||
const data = raw.slice(12)
|
||||
try {
|
||||
const decrypted = await globalThis.crypto.subtle.decrypt({ name: 'AES-GCM', iv, tagLength: 128 }, encKey, data)
|
||||
return JSON.parse(new TextDecoder().decode(decrypted))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function send(data, expectResponse = false) {
|
||||
const d = await encrypt(data)
|
||||
if (!d) return null
|
||||
try {
|
||||
if (!expectResponse && navigator.sendBeacon) {
|
||||
navigator.sendBeacon(ENDPOINT, JSON.stringify({ d }))
|
||||
return null
|
||||
}
|
||||
const res = await fetch(ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ d }),
|
||||
keepalive: true,
|
||||
})
|
||||
if (!res.ok || res.status === 204) return null
|
||||
const json = await res.json()
|
||||
return json.d ? await decrypt(json.d) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function getOrCreateVisitor() {
|
||||
let uid = sessionStorage.getItem(SK_UID)
|
||||
let hash = sessionStorage.getItem(SK_HASH)
|
||||
if (uid && hash) return { uid, hash }
|
||||
|
||||
const resp = await send({
|
||||
sw: screen.width,
|
||||
sh: screen.height,
|
||||
l: navigator.language || null,
|
||||
}, true)
|
||||
|
||||
if (!resp || !resp.uid || !resp.h) return null
|
||||
|
||||
sessionStorage.setItem(SK_UID, resp.uid)
|
||||
sessionStorage.setItem(SK_HASH, resp.h)
|
||||
return { uid: resp.uid, hash: resp.h }
|
||||
}
|
||||
|
||||
async function trackPageView(visitor) {
|
||||
await send({
|
||||
uid: visitor.uid,
|
||||
h: visitor.hash,
|
||||
u: location.pathname + location.search,
|
||||
t: document.title,
|
||||
r: document.referrer || null,
|
||||
})
|
||||
}
|
||||
|
||||
export async function initAnalytics() {
|
||||
const keyB64 = document.body.dataset.k
|
||||
if (!keyB64 || document.body.dataset.env === 'dev') return
|
||||
|
||||
try {
|
||||
encKey = await importKey(keyB64)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
const visitor = await getOrCreateVisitor()
|
||||
if (!visitor) return
|
||||
|
||||
await trackPageView(visitor)
|
||||
|
||||
const authUserId = document.body.dataset.uid
|
||||
if (authUserId) {
|
||||
await setAuth(parseInt(authUserId, 10))
|
||||
}
|
||||
}
|
||||
|
||||
export async function setAuth(userId) {
|
||||
const uid = sessionStorage.getItem(SK_UID)
|
||||
const hash = sessionStorage.getItem(SK_HASH)
|
||||
if (!uid || !hash || !encKey) return
|
||||
|
||||
await send({ uid, h: hash, setUser: userId })
|
||||
}
|
||||
Reference in New Issue
Block a user