|
| 1 | +// Anonymous page-load beacon |
| 2 | +// Fires from the client at App module load, before any user interaction. |
| 3 | +// Counts arrivals (vs. the existing playback/chat events which only fire |
| 4 | +// post-engagement), so we can measure true bounce rate per channel. |
| 5 | +// |
| 6 | +// Privacy: aggregate counter only. No user dimension. No IP retention. |
| 7 | +// Same legal posture as the rest of analytics — see docs/MEASUREMENT.md. |
| 8 | + |
| 9 | +import { trackPageView, readMarketingSource, readCountry } from '../utils/analytics'; |
| 10 | +import type { Env } from '../utils/types'; |
| 11 | + |
| 12 | +interface PagePayload { |
| 13 | + path?: string; |
| 14 | + language?: string; |
| 15 | +} |
| 16 | + |
| 17 | +// Path is sanitized server-side to a safe shape. Anything else becomes empty |
| 18 | +// in blob2 so the row is still recorded but the path slot stays clean. |
| 19 | +const PATH_RE = /^\/[A-Za-z0-9/_-]{0,60}$/; |
| 20 | +const VALID_LANGS = new Set(['en', 'de']); |
| 21 | + |
| 22 | +// Rate limit: 100 page beacons per IP per hour. A real user generates 1-10 |
| 23 | +// hard page loads per session; this caps abuse without blocking real use. |
| 24 | +const RATE_LIMIT_WINDOW = 3600; |
| 25 | +const RATE_LIMIT_MAX = 100; |
| 26 | + |
| 27 | +export async function handlePage(request: Request, env: Env): Promise<Response> { |
| 28 | + if (request.method !== 'POST') { |
| 29 | + return new Response('Method not allowed', { status: 405 }); |
| 30 | + } |
| 31 | + |
| 32 | + let payload: PagePayload; |
| 33 | + try { |
| 34 | + payload = await request.json(); |
| 35 | + } catch { |
| 36 | + // Be quiet on malformed beacons — the client doesn't get to know. |
| 37 | + return new Response(null, { status: 204 }); |
| 38 | + } |
| 39 | + |
| 40 | + // Rate limit per IP. The IP itself is hashed via KV key namespace and |
| 41 | + // never retained beyond the 1-hour window. |
| 42 | + const ip = request.headers.get('CF-Connecting-IP') || 'unknown'; |
| 43 | + const rateLimitKey = `page_rl:${ip}`; |
| 44 | + const currentCount = parseInt(await env.RATE_LIMITS.get(rateLimitKey) || '0', 10); |
| 45 | + if (currentCount >= RATE_LIMIT_MAX) { |
| 46 | + return new Response(null, { status: 204 }); |
| 47 | + } |
| 48 | + await env.RATE_LIMITS.put(rateLimitKey, String(currentCount + 1), { expirationTtl: RATE_LIMIT_WINDOW }); |
| 49 | + |
| 50 | + const path = (typeof payload.path === 'string' && PATH_RE.test(payload.path)) |
| 51 | + ? payload.path |
| 52 | + : ''; |
| 53 | + const lang = (typeof payload.language === 'string') && VALID_LANGS.has(payload.language.slice(0, 2).toLowerCase()) |
| 54 | + ? payload.language.slice(0, 2).toLowerCase() |
| 55 | + : ''; |
| 56 | + |
| 57 | + trackPageView(env, { |
| 58 | + path, |
| 59 | + language: lang, |
| 60 | + marketingSource: readMarketingSource(request), |
| 61 | + country: readCountry(request), |
| 62 | + }); |
| 63 | + |
| 64 | + return new Response(null, { status: 204 }); |
| 65 | +} |
0 commit comments