refactor: enhance security and performance in configuration and API routes

- Update Content Security Policy (CSP) in next.config.ts to avoid `unsafe-eval` in production, improving security against XSS attacks.
- Refactor API routes to enforce admin authentication and session validation, ensuring secure access to sensitive endpoints.
- Optimize analytics data retrieval by using database aggregation instead of loading all records into memory, improving performance and reducing memory usage.
- Implement session token creation and verification for better session management and security across the application.
- Enhance error handling and input validation in various API routes to ensure robustness and prevent potential issues.
This commit is contained in:
2026-01-11 22:44:26 +01:00
parent 9cc03bc475
commit 9072faae43
28 changed files with 433 additions and 288 deletions

View File

@@ -14,21 +14,17 @@ export async function GET(request: NextRequest) {
status: 429,
headers: {
'Content-Type': 'application/json',
...getRateLimitHeaders(ip, 5, 60000)
...getRateLimitHeaders(ip, 20, 60000)
}
}
);
}
// Check admin authentication - for admin dashboard requests, we trust the session
// The middleware has already verified the admin session for /manage routes
// Admin-only endpoint: require explicit admin header AND a valid signed session token
const isAdminRequest = request.headers.get('x-admin-request') === 'true';
if (!isAdminRequest) {
const authError = requireSessionAuth(request);
if (authError) {
return authError;
}
}
if (!isAdminRequest) return NextResponse.json({ error: 'Admin access required' }, { status: 403 });
const authError = requireSessionAuth(request);
if (authError) return authError;
// Check cache first (but allow bypass with cache-bust parameter)
const url = new URL(request.url);
@@ -45,47 +41,57 @@ export async function GET(request: NextRequest) {
const projectsResult = await projectService.getAllProjects();
const projects = projectsResult.projects || projectsResult;
const performanceStats = await projectService.getPerformanceStats();
// Get real page view data from database
const allPageViews = await prisma.pageView.findMany({
where: {
timestamp: {
gte: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) // Last 30 days
}
}
});
// Calculate bounce rate (sessions with only 1 pageview)
const pageViewsByIP = allPageViews.reduce((acc, pv) => {
const ip = pv.ip || 'unknown';
if (!acc[ip]) acc[ip] = [];
acc[ip].push(pv);
return acc;
}, {} as Record<string, typeof allPageViews>);
const since = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const totalSessions = Object.keys(pageViewsByIP).length;
const bouncedSessions = Object.values(pageViewsByIP).filter(session => session.length === 1).length;
// Use DB aggregation instead of loading every PageView row into memory
const [totalViews, sessionsByIp, viewsByProjectRows] = await Promise.all([
prisma.pageView.count({ where: { timestamp: { gte: since } } }),
prisma.pageView.groupBy({
by: ['ip'],
where: {
timestamp: { gte: since },
ip: { not: null },
},
_count: { _all: true },
_min: { timestamp: true },
_max: { timestamp: true },
}),
prisma.pageView.groupBy({
by: ['projectId'],
where: {
timestamp: { gte: since },
projectId: { not: null },
},
_count: { _all: true },
}),
]);
const totalSessions = sessionsByIp.length;
const bouncedSessions = sessionsByIp.filter(s => (s as unknown as { _count?: { _all?: number } })._count?._all === 1).length;
const bounceRate = totalSessions > 0 ? Math.round((bouncedSessions / totalSessions) * 100) : 0;
// Calculate average session duration (simplified - time between first and last pageview per IP)
const sessionDurations = Object.values(pageViewsByIP)
.map(session => {
if (session.length < 2) return 0;
const sorted = session.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
return sorted[sorted.length - 1].timestamp.getTime() - sorted[0].timestamp.getTime();
const sessionDurationsMs = sessionsByIp
.map(s => {
const count = (s as unknown as { _count?: { _all?: number } })._count?._all ?? 0;
if (count < 2) return 0;
const minTs = (s as unknown as { _min?: { timestamp?: Date | null } })._min?.timestamp;
const maxTs = (s as unknown as { _max?: { timestamp?: Date | null } })._max?.timestamp;
if (!minTs || !maxTs) return 0;
return maxTs.getTime() - minTs.getTime();
})
.filter(d => d > 0);
const avgSessionDuration = sessionDurations.length > 0
? Math.round(sessionDurations.reduce((a, b) => a + b, 0) / sessionDurations.length / 1000) // in seconds
.filter(ms => ms > 0);
const avgSessionDuration = sessionDurationsMs.length > 0
? Math.round(sessionDurationsMs.reduce((a, b) => a + b, 0) / sessionDurationsMs.length / 1000)
: 0;
// Get total unique users (unique IPs)
const totalUsers = new Set(allPageViews.map(pv => pv.ip).filter(Boolean)).size;
const totalUsers = totalSessions;
// Calculate real views from PageView table
const viewsByProject = allPageViews.reduce((acc, pv) => {
if (pv.projectId) {
acc[pv.projectId] = (acc[pv.projectId] || 0) + 1;
const viewsByProject = viewsByProjectRows.reduce((acc, row) => {
const projectId = row.projectId as number | null;
if (projectId != null) {
acc[projectId] = (row as unknown as { _count?: { _all?: number } })._count?._all ?? 0;
}
return acc;
}, {} as Record<number, number>);
@@ -96,7 +102,7 @@ export async function GET(request: NextRequest) {
totalProjects: projects.length,
publishedProjects: projects.filter(p => p.published).length,
featuredProjects: projects.filter(p => p.featured).length,
totalViews: allPageViews.length, // Real views from PageView table
totalViews, // Real views from PageView table
totalLikes: 0, // Not implemented - no like buttons
totalShares: 0, // Not implemented - no share buttons
avgLighthouse: (() => {
@@ -141,14 +147,14 @@ export async function GET(request: NextRequest) {
? Math.round(projectsWithPerf.reduce((sum, p) => sum + ((p.performance as Record<string, unknown>)?.lighthouse as number || 0), 0) / projectsWithPerf.length)
: 0;
})(),
totalViews: allPageViews.length, // Real total views
totalViews, // Real total views
totalLikes: 0,
totalShares: 0
},
metrics: {
bounceRate,
avgSessionDuration,
pagesPerSession: totalSessions > 0 ? (allPageViews.length / totalSessions).toFixed(1) : '0',
pagesPerSession: totalSessions > 0 ? (totalViews / totalSessions).toFixed(1) : '0',
newUsers: totalUsers,
totalUsers
}