| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { NextRequest, NextResponse } from 'next/server'; |
| import { getSQLiteAdapter } from '@/lib/vfs/adapters/server'; |
| import { |
| pageviewRateLimiter, |
| RATE_LIMIT_CONFIG, |
| getIdentifier, |
| } from '@/lib/analytics/rate-limiter'; |
| import { |
| validateOrigin, |
| getAllowedOrigins, |
| isLikelyBot, |
| isSuspiciousRequest, |
| } from '@/lib/analytics/security'; |
|
|
| interface PageviewData { |
| deploymentId: string; |
| pagePath: string; |
| referrer: string; |
| userAgent: string; |
| deviceType?: string; |
| |
| } |
|
|
| export async function POST(request: NextRequest) { |
| try { |
| const body: PageviewData = await request.json(); |
| const { deploymentId, pagePath, referrer, userAgent, deviceType } = body; |
|
|
| |
| const identifier = getIdentifier(request); |
| const rateLimitAllowed = pageviewRateLimiter.check( |
| identifier, |
| RATE_LIMIT_CONFIG.pageview |
| ); |
|
|
| if (!rateLimitAllowed) { |
| const resetTime = pageviewRateLimiter.getResetTime( |
| identifier, |
| RATE_LIMIT_CONFIG.pageview |
| ); |
|
|
| return NextResponse.json( |
| { error: 'Rate limit exceeded' }, |
| { |
| status: 429, |
| headers: { |
| 'Retry-After': resetTime.toString(), |
| 'X-RateLimit-Limit': RATE_LIMIT_CONFIG.pageview.limit.toString(), |
| 'X-RateLimit-Remaining': '0', |
| }, |
| } |
| ); |
| } |
|
|
| |
| if (!deploymentId || !pagePath) { |
| return NextResponse.json( |
| { error: 'Missing required fields: deploymentId, pagePath' }, |
| { status: 400 } |
| ); |
| } |
|
|
| |
| if (isSuspiciousRequest({ pagePath, referrer, userAgent })) { |
| console.warn('[Analytics] Suspicious request detected:', { |
| deploymentId, |
| pagePath, |
| ip: identifier, |
| }); |
| return NextResponse.json( |
| { error: 'Invalid request' }, |
| { status: 400 } |
| ); |
| } |
|
|
| |
| if (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 allowedOrigins = getAllowedOrigins(deploymentId, deployment.customDomain); |
| if (!validateOrigin(request, allowedOrigins)) { |
| console.warn('[Analytics] 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); |
|
|
| |
| const country = await getCountryFromIP(request); |
|
|
| |
| const normalizedPath = normalizePath(pagePath); |
|
|
| |
| deploymentDb.recordPageview({ |
| pagePath: normalizedPath, |
| referrer: referrer || undefined, |
| country: country || undefined, |
| userAgent, |
| deviceType: deviceType || undefined, |
| sessionId, |
| }); |
|
|
| |
| deploymentDb.upsertSession(sessionId, normalizedPath); |
|
|
| return NextResponse.json({ success: true }); |
| } catch (error) { |
| console.error('[Analytics API] Error tracking pageview:', error); |
| return NextResponse.json( |
| { error: 'Failed to track pageview' }, |
| { 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'; |
| } |
| } |
|
|
| |
| |
| |
| |
| async function getCountryFromIP(request: NextRequest): Promise<string | null> { |
| |
| |
| return null; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| 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; |
| } |
|
|