- Added next-intl for internationalization with Romanian as default locale - Restructured app directory with [locale] routing (/ro, /en) - Created comprehensive translation files for both languages - Fixed Next.js 15 async params compatibility in layout components - Updated all components to use proper i18n hooks and translations - Configured middleware for locale routing and fallbacks - Fixed FloatingChat component translation array handling - Restored complete home page with internationalized content - Fixed Material-UI Slide component prop error (mountOnExit → unmountOnExit) 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
179 lines
5.7 KiB
TypeScript
179 lines
5.7 KiB
TypeScript
import { NextResponse } from 'next/server'
|
|
import type { NextRequest } from 'next/server'
|
|
import { verifyToken } from '@/lib/auth'
|
|
import { prisma } from '@/lib/db'
|
|
import createIntlMiddleware from 'next-intl/middleware'
|
|
|
|
// Internationalization configuration
|
|
const intlMiddleware = createIntlMiddleware({
|
|
locales: ['ro', 'en'],
|
|
defaultLocale: 'ro'
|
|
})
|
|
|
|
// Rate limiting configuration
|
|
const RATE_LIMIT_WINDOW = 60 * 1000 // 1 minute
|
|
const RATE_LIMITS = {
|
|
general: 100, // 100 requests per minute
|
|
auth: 5, // 5 auth requests per minute
|
|
chat: 10, // 10 chat requests per minute
|
|
search: 20, // 20 search requests per minute
|
|
}
|
|
|
|
async function getRateLimitKey(request: NextRequest, endpoint: string): Promise<string> {
|
|
const forwarded = request.headers.get('x-forwarded-for')
|
|
const ip = forwarded ? forwarded.split(',')[0] : request.ip || 'unknown'
|
|
return `ratelimit:${endpoint}:${ip}`
|
|
}
|
|
|
|
async function checkRateLimit(request: NextRequest, endpoint: string, limit: number): Promise<{ allowed: boolean; remaining: number }> {
|
|
try {
|
|
const key = await getRateLimitKey(request, endpoint)
|
|
const now = Date.now()
|
|
const windowStart = now - RATE_LIMIT_WINDOW
|
|
|
|
// Clean up old entries and count current requests
|
|
await prisma.$executeRaw`
|
|
DELETE FROM verse_cache
|
|
WHERE key LIKE ${key + ':%'}
|
|
AND created_at < ${new Date(windowStart)}
|
|
`
|
|
|
|
const currentCount = await prisma.$queryRaw<{ count: number }[]>`
|
|
SELECT COUNT(*) as count
|
|
FROM verse_cache
|
|
WHERE key LIKE ${key + ':%'}
|
|
`
|
|
|
|
const requestCount = Number(currentCount[0]?.count || 0)
|
|
|
|
if (requestCount >= limit) {
|
|
return { allowed: false, remaining: 0 }
|
|
}
|
|
|
|
// Record this request
|
|
await prisma.$executeRaw`
|
|
INSERT INTO verse_cache (key, value, expires_at, created_at)
|
|
VALUES (${key + ':' + now}, '1', ${new Date(now + RATE_LIMIT_WINDOW)}, ${new Date(now)})
|
|
`
|
|
|
|
return { allowed: true, remaining: limit - requestCount - 1 }
|
|
} catch (error) {
|
|
console.error('Rate limit check error:', error)
|
|
// Allow request on error
|
|
return { allowed: true, remaining: limit }
|
|
}
|
|
}
|
|
|
|
export async function middleware(request: NextRequest) {
|
|
// Handle internationalization for non-API routes
|
|
if (!request.nextUrl.pathname.startsWith('/api')) {
|
|
return intlMiddleware(request)
|
|
}
|
|
|
|
// Determine endpoint type for rate limiting
|
|
let endpoint = 'general'
|
|
let limit = RATE_LIMITS.general
|
|
|
|
if (request.nextUrl.pathname.startsWith('/api/auth')) {
|
|
endpoint = 'auth'
|
|
limit = RATE_LIMITS.auth
|
|
} else if (request.nextUrl.pathname.startsWith('/api/chat')) {
|
|
endpoint = 'chat'
|
|
limit = RATE_LIMITS.chat
|
|
} else if (request.nextUrl.pathname.startsWith('/api/bible/search')) {
|
|
endpoint = 'search'
|
|
limit = RATE_LIMITS.search
|
|
}
|
|
|
|
// Apply rate limiting to API routes
|
|
if (request.nextUrl.pathname.startsWith('/api/')) {
|
|
const rateLimit = await checkRateLimit(request, endpoint, limit)
|
|
|
|
const response = rateLimit.allowed
|
|
? NextResponse.next()
|
|
: new NextResponse('Too Many Requests', { status: 429 })
|
|
|
|
// Add rate limit headers
|
|
response.headers.set('X-RateLimit-Limit', limit.toString())
|
|
response.headers.set('X-RateLimit-Remaining', rateLimit.remaining.toString())
|
|
response.headers.set('X-RateLimit-Reset', (Date.now() + RATE_LIMIT_WINDOW).toString())
|
|
|
|
if (!rateLimit.allowed) {
|
|
return response
|
|
}
|
|
}
|
|
|
|
// Security headers for all responses
|
|
const response = NextResponse.next()
|
|
|
|
// Security headers
|
|
response.headers.set('X-Content-Type-Options', 'nosniff')
|
|
response.headers.set('X-Frame-Options', 'DENY')
|
|
response.headers.set('X-XSS-Protection', '1; mode=block')
|
|
response.headers.set('Referrer-Policy', 'origin-when-cross-origin')
|
|
response.headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()')
|
|
|
|
// CSRF protection for state-changing operations
|
|
if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(request.method)) {
|
|
const origin = request.headers.get('origin')
|
|
const host = request.headers.get('host')
|
|
|
|
if (origin && host && !origin.endsWith(host)) {
|
|
return new NextResponse('Forbidden', { status: 403 })
|
|
}
|
|
}
|
|
|
|
// Authentication check for protected routes
|
|
if (request.nextUrl.pathname.startsWith('/api/bookmarks') ||
|
|
request.nextUrl.pathname.startsWith('/api/notes') ||
|
|
request.nextUrl.pathname.startsWith('/dashboard')) {
|
|
|
|
const token = request.headers.get('authorization')?.replace('Bearer ', '') ||
|
|
request.cookies.get('authToken')?.value
|
|
|
|
if (!token) {
|
|
if (request.nextUrl.pathname.startsWith('/api/')) {
|
|
return new NextResponse('Unauthorized', { status: 401 })
|
|
} else {
|
|
return NextResponse.redirect(new URL('/', request.url))
|
|
}
|
|
}
|
|
|
|
try {
|
|
const payload = await verifyToken(token)
|
|
|
|
// Add user ID to headers for API routes
|
|
if (request.nextUrl.pathname.startsWith('/api/')) {
|
|
const requestHeaders = new Headers(request.headers)
|
|
requestHeaders.set('x-user-id', payload.userId)
|
|
|
|
return NextResponse.next({
|
|
request: {
|
|
headers: requestHeaders,
|
|
},
|
|
})
|
|
}
|
|
} catch (error) {
|
|
if (request.nextUrl.pathname.startsWith('/api/')) {
|
|
return new NextResponse('Invalid token', { status: 401 })
|
|
} else {
|
|
return NextResponse.redirect(new URL('/', request.url))
|
|
}
|
|
}
|
|
}
|
|
|
|
return response
|
|
}
|
|
|
|
export const config = {
|
|
matcher: [
|
|
// Match all pathnames except for
|
|
// - api routes
|
|
// - _next (Next.js internals)
|
|
// - static files (images, etc.)
|
|
'/((?!api|_next|_vercel|.*\\..*).*)',
|
|
// However, match all pathnames within `/api`, except for the Middleware to run there
|
|
'/api/:path*',
|
|
'/dashboard/:path*'
|
|
],
|
|
} |