2026-03-26 21:11:15 +01:00
|
|
|
const { sessionStorage, navigator, document, location, screen, fetch } = globalThis
|
|
|
|
|
|
2026-03-26 12:22:59 +01:00
|
|
|
let ENDPOINT = '/t'
|
2026-03-26 11:52:07 +01:00
|
|
|
const SK_UID = '_u'
|
|
|
|
|
const SK_HASH = '_h'
|
|
|
|
|
|
|
|
|
|
let encKey = null
|
|
|
|
|
|
|
|
|
|
async function importKey(b64) {
|
2026-03-26 21:07:26 +01:00
|
|
|
const raw = Uint8Array.from(globalThis.atob(b64), c => c.codePointAt(0))
|
2026-03-26 11:52:07 +01:00
|
|
|
return globalThis.crypto.subtle.importKey('raw', raw, 'AES-GCM', false, ['encrypt', 'decrypt'])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function encrypt(data) {
|
2026-04-02 13:13:26 +02:00
|
|
|
/* c8 ignore next */ /* istanbul ignore next */ if (!encKey) return null
|
2026-03-26 21:08:37 +01:00
|
|
|
const json = new globalThis.TextEncoder().encode(JSON.stringify(data))
|
2026-03-26 11:52:07 +01:00
|
|
|
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)
|
2026-03-26 21:34:22 +01:00
|
|
|
return globalThis.btoa(String.fromCodePoint(...combined))
|
2026-03-26 11:52:07 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function decrypt(b64) {
|
2026-04-02 13:13:26 +02:00
|
|
|
/* c8 ignore next */ /* istanbul ignore next */ if (!encKey) return null
|
2026-03-26 21:07:26 +01:00
|
|
|
const raw = Uint8Array.from(globalThis.atob(b64), c => c.codePointAt(0))
|
2026-03-26 11:52:07 +01:00
|
|
|
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)
|
2026-03-26 21:08:37 +01:00
|
|
|
return JSON.parse(new globalThis.TextDecoder().decode(decrypted))
|
2026-03-26 11:52:07 +01:00
|
|
|
} catch {
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-26 13:41:34 +01:00
|
|
|
function clearSession() {
|
|
|
|
|
sessionStorage.removeItem(SK_UID)
|
|
|
|
|
sessionStorage.removeItem(SK_HASH)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-26 11:52:07 +01:00
|
|
|
async function send(data, expectResponse = false) {
|
|
|
|
|
const d = await encrypt(data)
|
2026-04-02 13:13:26 +02:00
|
|
|
/* c8 ignore next */ /* istanbul ignore next */ if (!d) return null
|
2026-03-26 11:52:07 +01:00
|
|
|
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,
|
|
|
|
|
})
|
2026-03-26 13:41:34 +01:00
|
|
|
if (res.status === 403) {
|
|
|
|
|
clearSession()
|
|
|
|
|
return null
|
|
|
|
|
}
|
2026-03-26 11:52:07 +01:00
|
|
|
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() {
|
2026-03-26 21:34:22 +01:00
|
|
|
const uid = sessionStorage.getItem(SK_UID)
|
|
|
|
|
const hash = sessionStorage.getItem(SK_HASH)
|
2026-03-26 11:52:07 +01:00
|
|
|
if (uid && hash) return { uid, hash }
|
|
|
|
|
|
|
|
|
|
const resp = await send({
|
|
|
|
|
sw: screen.width,
|
|
|
|
|
sh: screen.height,
|
|
|
|
|
l: navigator.language || null,
|
|
|
|
|
}, true)
|
|
|
|
|
|
2026-03-26 21:34:22 +01:00
|
|
|
if (!resp?.uid || !resp?.h) return null
|
2026-03-26 11:52:07 +01:00
|
|
|
|
|
|
|
|
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
|
2026-03-26 12:22:59 +01:00
|
|
|
const ep = document.body.dataset.e
|
|
|
|
|
if (!keyB64 || !ep) return
|
|
|
|
|
ENDPOINT = ep
|
2026-03-26 11:52:07 +01:00
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
encKey = await importKey(keyB64)
|
|
|
|
|
} catch {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-26 13:41:34 +01:00
|
|
|
let visitor = await getOrCreateVisitor()
|
2026-03-26 11:52:07 +01:00
|
|
|
if (!visitor) return
|
|
|
|
|
|
|
|
|
|
await trackPageView(visitor)
|
|
|
|
|
|
2026-03-26 13:41:34 +01:00
|
|
|
// If trackPageView got 403 (stale session), retry with fresh visitor
|
|
|
|
|
if (!sessionStorage.getItem(SK_UID)) {
|
|
|
|
|
visitor = await getOrCreateVisitor()
|
|
|
|
|
if (!visitor) return
|
|
|
|
|
await trackPageView(visitor)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-26 11:52:07 +01:00
|
|
|
const authUserId = document.body.dataset.uid
|
|
|
|
|
if (authUserId) {
|
2026-03-26 21:34:22 +01:00
|
|
|
await setAuth(Number.parseInt(authUserId, 10))
|
2026-03-26 11:52:07 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 })
|
|
|
|
|
}
|