| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { NextRequest, NextResponse } from 'next/server'; |
| import { getSQLiteAdapter } from '@/lib/vfs/adapters/server'; |
| import { |
| interactionRateLimiter, |
| RATE_LIMIT_CONFIG, |
| getIdentifier, |
| } from '@/lib/analytics/rate-limiter'; |
| import { |
| validateOrigin, |
| getAllowedOrigins, |
| isLikelyBot, |
| isSuspiciousRequest, |
| } from '@/lib/analytics/security'; |
|
|
| interface InteractionData { |
| deploymentId: string; |
| pagePath: string; |
| interactionType: 'click' | 'scroll' | 'exit' | 'custom'; |
| elementSelector?: string; |
| coordinates?: { |
| x: number; |
| y: number; |
| scrollY?: number; |
| viewportWidth?: number; |
| viewportHeight?: number; |
| documentHeight?: number; |
| }; |
| scrollDepth?: number; |
| timeOnPage?: number; |
| customData?: Record<string, unknown>; |
| userAgent?: string; |
| |
| } |
|
|
| interface BatchInteractionData { |
| batch: boolean; |
| interactions: InteractionData[]; |
| } |
|
|
| export async function POST(request: NextRequest) { |
| try { |
| const body: InteractionData | BatchInteractionData = await request.json(); |
|
|
| |
| if ('batch' in body && body.batch === true) { |
| return handleBatchInteractions(request, body); |
| } |
|
|
| |
| const { |
| deploymentId, |
| pagePath, |
| interactionType, |
| elementSelector, |
| coordinates, |
| scrollDepth, |
| timeOnPage, |
| userAgent, |
| } = body as InteractionData; |
|
|
| |
| const identifier = getIdentifier(request); |
| const rateLimitAllowed = interactionRateLimiter.check( |
| identifier, |
| RATE_LIMIT_CONFIG.interaction |
| ); |
|
|
| if (!rateLimitAllowed) { |
| const resetTime = interactionRateLimiter.getResetTime( |
| identifier, |
| RATE_LIMIT_CONFIG.interaction |
| ); |
|
|
| return NextResponse.json( |
| { error: 'Rate limit exceeded' }, |
| { |
| status: 429, |
| headers: { |
| 'Retry-After': resetTime.toString(), |
| 'X-RateLimit-Limit': RATE_LIMIT_CONFIG.interaction.limit.toString(), |
| 'X-RateLimit-Remaining': '0', |
| }, |
| } |
| ); |
| } |
|
|
| |
| if (!deploymentId || !pagePath || !interactionType) { |
| return NextResponse.json( |
| { error: 'Missing required fields: deploymentId, pagePath, interactionType' }, |
| { status: 400 } |
| ); |
| } |
|
|
| |
| if (isSuspiciousRequest({ pagePath, userAgent })) { |
| console.warn('[Analytics Interaction] Suspicious request detected:', { |
| deploymentId, |
| pagePath, |
| ip: identifier, |
| }); |
| return NextResponse.json( |
| { error: 'Invalid request' }, |
| { status: 400 } |
| ); |
| } |
|
|
| |
| if (userAgent && isLikelyBot(userAgent)) { |
| return NextResponse.json({ success: true }); |
| } |
|
|
| const adapter = getSQLiteAdapter(); |
| await adapter.init(); |
|
|
| |
| const deployment = await adapter.getDeployment(deploymentId); |
| if (!deployment) { |
| return NextResponse.json( |
| { error: 'Deployment not found' }, |
| { status: 404 } |
| ); |
| } |
|
|
| |
| if (!deployment.analytics.enabled || deployment.analytics.provider !== 'builtin') { |
| return NextResponse.json( |
| { error: 'Built-in analytics not enabled for this deployment' }, |
| { status: 403 } |
| ); |
| } |
|
|
| |
| const deploymentDb = adapter.getAnalyticsDatabaseInstance(deploymentId); |
| if (!deploymentDb) { |
| return NextResponse.json( |
| { error: 'Deployment database not enabled' }, |
| { status: 404 } |
| ); |
| } |
|
|
| |
| const features = deployment.analytics.features || {}; |
| if (interactionType === 'click' && !features.heatmaps) { |
| return NextResponse.json( |
| { error: 'Heatmaps feature not enabled' }, |
| { status: 403 } |
| ); |
| } |
|
|
| if (interactionType === 'scroll' && !features.engagementTracking && !features.heatmaps) { |
| return NextResponse.json( |
| { error: 'Engagement tracking not enabled' }, |
| { status: 403 } |
| ); |
| } |
|
|
| if (interactionType === 'exit' && !features.engagementTracking) { |
| return NextResponse.json( |
| { error: 'Engagement tracking not enabled' }, |
| { status: 403 } |
| ); |
| } |
|
|
| |
| const allowedOrigins = getAllowedOrigins(deploymentId, deployment.customDomain); |
| if (!validateOrigin(request, allowedOrigins)) { |
| console.warn('[Analytics Interaction] Invalid origin (rejected):', { |
| origin: request.headers.get('origin'), |
| referer: request.headers.get('referer'), |
| allowedOrigins, |
| deploymentId, |
| ip: identifier, |
| }); |
| return NextResponse.json( |
| { error: 'Origin not allowed' }, |
| { status: 403 } |
| ); |
| } |
|
|
| |
| const sessionId = generateSessionId(userAgent || request.headers.get('user-agent') || '', request); |
|
|
| |
| const normalizedPath = normalizePath(pagePath); |
|
|
| |
| deploymentDb.recordInteraction({ |
| sessionId, |
| pagePath: normalizedPath, |
| interactionType, |
| elementSelector, |
| coordinates: coordinates ? { |
| x: coordinates.x, |
| y: coordinates.y, |
| scrollY: coordinates.scrollY, |
| viewportWidth: coordinates.viewportWidth, |
| viewportHeight: coordinates.viewportHeight, |
| documentHeight: coordinates.documentHeight, |
| } : undefined, |
| scrollDepth, |
| timeOnPage, |
| }); |
|
|
| return NextResponse.json({ success: true }); |
| } catch (error) { |
| console.error('[Analytics Interaction API] Error:', error); |
| return NextResponse.json( |
| { error: 'Failed to track interaction' }, |
| { status: 500 } |
| ); |
| } |
| } |
|
|
| |
| |
| |
| function generateSessionId(userAgent: string, request: NextRequest): string { |
| const forwarded = request.headers.get('x-forwarded-for'); |
| const ip = forwarded ? forwarded.split(',')[0] : ''; |
| const anonymizedIP = anonymizeIP(ip); |
|
|
| const fingerprint = `${userAgent}|${anonymizedIP}|${new Date().toDateString()}`; |
|
|
| let hash = 0; |
| for (let i = 0; i < fingerprint.length; i++) { |
| const char = fingerprint.charCodeAt(i); |
| hash = ((hash << 5) - hash) + char; |
| hash = hash & hash; |
| } |
|
|
| return Math.abs(hash).toString(36); |
| } |
|
|
| function anonymizeIP(ip: string): string { |
| if (!ip) return ''; |
|
|
| if (ip.includes(':')) { |
| const parts = ip.split(':'); |
| return parts.slice(0, 4).join(':') + '::'; |
| } else { |
| const parts = ip.split('.'); |
| return parts.slice(0, 2).join('.') + '.0.0'; |
| } |
| } |
|
|
| |
| |
| |
| function normalizePath(path: string): string { |
| if (!path || path === '/') return '/index.html'; |
|
|
| |
| let normalized = path.replace(/\/$/, ''); |
|
|
| |
| if (!normalized.includes('.') || normalized.split('/').pop()?.indexOf('.') === -1) { |
| normalized += '/index.html'; |
| } |
|
|
| return normalized; |
| } |
|
|
| |
| |
| |
| |
| async function handleBatchInteractions( |
| request: NextRequest, |
| body: BatchInteractionData |
| ): Promise<NextResponse> { |
| const { interactions } = body; |
|
|
| if (!interactions || interactions.length === 0) { |
| return NextResponse.json( |
| { error: 'No interactions provided in batch' }, |
| { status: 400 } |
| ); |
| } |
|
|
| |
| if (interactions.length > 100) { |
| return NextResponse.json( |
| { error: 'Batch size exceeds maximum of 100 interactions' }, |
| { status: 400 } |
| ); |
| } |
|
|
| |
| const identifier = getIdentifier(request); |
| const rateLimitAllowed = interactionRateLimiter.check( |
| identifier, |
| RATE_LIMIT_CONFIG.interaction |
| ); |
|
|
| if (!rateLimitAllowed) { |
| const resetTime = interactionRateLimiter.getResetTime( |
| identifier, |
| RATE_LIMIT_CONFIG.interaction |
| ); |
|
|
| return NextResponse.json( |
| { error: 'Rate limit exceeded' }, |
| { |
| status: 429, |
| headers: { |
| 'Retry-After': resetTime.toString(), |
| 'X-RateLimit-Limit': RATE_LIMIT_CONFIG.interaction.limit.toString(), |
| 'X-RateLimit-Remaining': '0', |
| }, |
| } |
| ); |
| } |
|
|
| |
| const firstInteraction = interactions[0]; |
| const { deploymentId, userAgent } = firstInteraction; |
|
|
| if (!deploymentId) { |
| return NextResponse.json( |
| { error: 'Missing required field: deploymentId' }, |
| { status: 400 } |
| ); |
| } |
|
|
| |
| if (userAgent && isLikelyBot(userAgent)) { |
| return NextResponse.json({ success: true }); |
| } |
|
|
| const adapter = getSQLiteAdapter(); |
| await adapter.init(); |
|
|
| try { |
| |
| const deployment = await adapter.getDeployment(deploymentId); |
| if (!deployment) { |
| return NextResponse.json( |
| { error: 'Deployment not found' }, |
| { status: 404 } |
| ); |
| } |
|
|
| |
| if (!deployment.analytics.enabled || deployment.analytics.provider !== 'builtin') { |
| return NextResponse.json( |
| { error: 'Built-in analytics not enabled for this deployment' }, |
| { status: 403 } |
| ); |
| } |
|
|
| |
| const deploymentDb = adapter.getAnalyticsDatabaseInstance(deploymentId); |
| if (!deploymentDb) { |
| return NextResponse.json( |
| { error: 'Deployment database not enabled' }, |
| { status: 404 } |
| ); |
| } |
|
|
| |
| const allowedOrigins = getAllowedOrigins(deploymentId, deployment.customDomain); |
| if (!validateOrigin(request, allowedOrigins)) { |
| console.warn('[Analytics Batch] Invalid origin (rejected):', { |
| origin: request.headers.get('origin'), |
| referer: request.headers.get('referer'), |
| allowedOrigins, |
| deploymentId, |
| ip: identifier, |
| }); |
| return NextResponse.json( |
| { error: 'Origin not allowed' }, |
| { status: 403 } |
| ); |
| } |
|
|
| |
| const defaultUserAgent = request.headers.get('user-agent') || ''; |
|
|
| let successCount = 0; |
| let skipCount = 0; |
|
|
| for (const interaction of interactions) { |
| const { |
| pagePath, |
| interactionType, |
| elementSelector, |
| coordinates, |
| scrollDepth, |
| timeOnPage, |
| userAgent: interactionUserAgent, |
| } = interaction; |
|
|
| |
| if (!pagePath || !interactionType) { |
| skipCount++; |
| continue; |
| } |
|
|
| |
| const features = deployment.analytics.features || {}; |
| if (interactionType === 'click' && !features.heatmaps) { |
| skipCount++; |
| continue; |
| } |
|
|
| if (interactionType === 'scroll' && !features.engagementTracking && !features.heatmaps) { |
| skipCount++; |
| continue; |
| } |
|
|
| if (interactionType === 'exit' && !features.engagementTracking) { |
| skipCount++; |
| continue; |
| } |
|
|
| |
| if (isSuspiciousRequest({ pagePath, userAgent: interactionUserAgent })) { |
| skipCount++; |
| continue; |
| } |
|
|
| |
| const sessionId = generateSessionId( |
| interactionUserAgent || defaultUserAgent, |
| request |
| ); |
|
|
| |
| const normalizedPath = normalizePath(pagePath); |
|
|
| |
| try { |
| deploymentDb.recordInteraction({ |
| sessionId, |
| pagePath: normalizedPath, |
| interactionType, |
| elementSelector, |
| coordinates: coordinates ? { |
| x: coordinates.x, |
| y: coordinates.y, |
| scrollY: coordinates.scrollY, |
| viewportWidth: coordinates.viewportWidth, |
| viewportHeight: coordinates.viewportHeight, |
| documentHeight: coordinates.documentHeight, |
| } : undefined, |
| scrollDepth, |
| timeOnPage, |
| }); |
| successCount++; |
| } catch (error) { |
| console.error('[Analytics Batch] Error inserting interaction:', error); |
| skipCount++; |
| } |
| } |
|
|
| return NextResponse.json({ |
| success: true, |
| processed: successCount, |
| skipped: skipCount, |
| total: interactions.length, |
| }); |
| } catch (error) { |
| console.error('[Analytics Batch] Error processing batch:', error); |
| return NextResponse.json( |
| { error: 'Failed to process batch interactions' }, |
| { status: 500 } |
| ); |
| } |
| } |
|
|