builder / app /api /analytics /track /route.ts
Leon4gr45's picture
Upload folder using huggingface_hub
eeb9404 verified
Raw
History Blame Contribute Delete
7.71 kB
/**
* Analytics Tracking API
* POST /api/analytics/track - Record a pageview
*
* Security Features:
* - Origin/Referer validation (primary defense - browser-enforced)
* - Rate limiting per IP (100 requests/minute)
* - Bot detection (blocks automated tools)
* - Anomaly detection (SQL injection, suspicious patterns)
*
* Note: Same-origin hosting provides stronger security than Google Analytics.
* Tokens not required due to browser CORS protections.
*/
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;
// token field removed - origin validation provides sufficient security
}
export async function POST(request: NextRequest) {
try {
const body: PageviewData = await request.json();
const { deploymentId, pagePath, referrer, userAgent, deviceType } = body;
// 1. Rate Limiting Check
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',
},
}
);
}
// 2. Validate required fields
if (!deploymentId || !pagePath) {
return NextResponse.json(
{ error: 'Missing required fields: deploymentId, pagePath' },
{ status: 400 }
);
}
// 3. Anomaly Detection - Check for suspicious data
if (isSuspiciousRequest({ pagePath, referrer, userAgent })) {
console.warn('[Analytics] Suspicious request detected:', {
deploymentId,
pagePath,
ip: identifier,
});
return NextResponse.json(
{ error: 'Invalid request' },
{ status: 400 }
);
}
// 4. Bot Detection
if (isLikelyBot(userAgent)) {
// Silently ignore bot requests (don't return error to avoid detection)
return NextResponse.json({ success: true });
}
const adapter = getSQLiteAdapter();
await adapter.init();
// 5. Verify deployment exists (from core database)
const deployment = await adapter.getDeployment(deploymentId);
if (!deployment) {
return NextResponse.json(
{ error: 'Deployment not found' },
{ status: 404 }
);
}
// 6. Check if analytics is enabled for this deployment
if (!deployment.analytics.enabled || deployment.analytics.provider !== 'builtin') {
return NextResponse.json(
{ error: 'Built-in analytics not enabled for this deployment' },
{ status: 403 }
);
}
// 6b. Check if deployment database is enabled (created when deployment is published)
const deploymentDb = adapter.getAnalyticsDatabaseInstance(deploymentId);
if (!deploymentDb) {
return NextResponse.json(
{ error: 'Deployment database not enabled' },
{ status: 404 }
);
}
// 7. CORS/Origin Validation (Primary Security Layer)
// This is our main defense against abuse. Unlike Google Analytics (which must accept
// requests from any domain), we only accept requests from our own domain paths.
// Browser security prevents attackers from spoofing Origin/Referer across domains.
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 }
);
}
// Note: Token-based authentication removed in favor of origin validation.
// Origin validation is stronger for same-origin hosting and avoids token expiration issues.
// Additional protection provided by: rate limiting, bot detection, and anomaly detection.
// Generate session ID from user agent + anonymized IP
const sessionId = generateSessionId(userAgent, request);
// Extract country from IP (anonymized - no storing IP addresses)
const country = await getCountryFromIP(request);
// Normalize path for consistent tracking
const normalizedPath = normalizePath(pagePath);
// Record pageview using DeploymentDatabase
deploymentDb.recordPageview({
pagePath: normalizedPath,
referrer: referrer || undefined,
country: country || undefined,
userAgent,
deviceType: deviceType || undefined,
sessionId,
});
// Upsert session record
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 }
);
}
}
/**
* Generate anonymous session ID from user agent and IP
* No cookies, no personal data - just a hash for unique visitor counting
*/
function generateSessionId(userAgent: string, request: NextRequest): string {
// Get anonymized IP (only first 2 octets for IPv4, first 4 groups for IPv6)
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()}`;
// Simple hash
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);
}
/**
* Anonymize IP address
*/
function anonymizeIP(ip: string): string {
if (!ip) return '';
if (ip.includes(':')) {
// IPv6 - keep first 4 groups
const parts = ip.split(':');
return parts.slice(0, 4).join(':') + '::';
} else {
// IPv4 - keep first 2 octets
const parts = ip.split('.');
return parts.slice(0, 2).join('.') + '.0.0';
}
}
/**
* Get country from IP address
* For now, just return null - can integrate with a geo-IP service later
*/
async function getCountryFromIP(request: NextRequest): Promise<string | null> {
// Future: integrate with geo-IP library (geoip-lite, maxmind, etc.)
// For MVP, we'll skip country detection
return null;
}
/**
* Normalize page path for consistent tracking
* - Strips trailing slashes
* - Converts directory paths to index.html
* Example: /deployments/abc/ -> /deployments/abc/index.html
*/
function normalizePath(path: string): string {
if (!path || path === '/') return '/index.html';
// Remove trailing slash
let normalized = path.replace(/\/$/, '');
// If path doesn't have an extension, it's likely a directory - add /index.html
if (!normalized.includes('.') || normalized.split('/').pop()?.indexOf('.') === -1) {
normalized += '/index.html';
}
return normalized;
}