From d0bf23f62b617efa021cf7f3583bbac0d460bcc1 Mon Sep 17 00:00:00 2001 From: Nitesh Agarwal Date: Fri, 29 May 2026 16:45:53 +0530 Subject: [PATCH 1/2] Fix WebGL/canvas resource leaks on unmount across Three.js and canvas components --- src/components/3d/HeaderScene.tsx | 34 ++++++++++++++++++++++++---- src/components/ui/ParticleSystem.tsx | 8 ++++--- 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/src/components/3d/HeaderScene.tsx b/src/components/3d/HeaderScene.tsx index 4e82c169..728902d1 100644 --- a/src/components/3d/HeaderScene.tsx +++ b/src/components/3d/HeaderScene.tsx @@ -48,7 +48,6 @@ function Model({ color }: { color: string }) { const box = mesh.geometry.boundingBox!; const size = new THREE.Vector3(); box.getSize(size); - // Use diagonal length as a proxy for size to handle flat objects better const diagonal = size.length(); if (diagonal > maxVolume) { @@ -58,18 +57,24 @@ function Model({ color }: { color: string }) { } }); + // Collect created materials so we can dispose on cleanup + const createdMaterials: THREE.MeshStandardMaterial[] = []; + // Second pass: apply materials scene.traverse((child) => { if ((child as THREE.Mesh).isMesh) { const mesh = child as THREE.Mesh; const isBackground = mesh.uuid === largestMeshId; - // Background: Dark Blue Front (#0B1120), Gold Side (#FFB800) - // D/Content: White Front (#FFFFFF), White Side (#FFFFFF) const frontColor = isBackground ? '#0B1120' : '#FFFFFF'; const sideColor = isBackground ? color : '#FFFFFF'; - // Clone material to avoid affecting other instances if any + // Dispose old material before replacing to free GPU memory + if (mesh.material) { + const old = mesh.material as THREE.Material; + old.dispose(); + } + const material = new THREE.MeshStandardMaterial({ color: new THREE.Color(sideColor), roughness: 0.2, @@ -111,8 +116,14 @@ function Model({ color }: { color: string }) { }; mesh.material = material; + createdMaterials.push(material); } }); + + // Cleanup: dispose all materials we created when color/scene changes or on unmount + return () => { + createdMaterials.forEach((mat) => mat.dispose()); + }; }, [scene, color]); return ( @@ -154,6 +165,21 @@ function FallbackGeometry({ color }: { color: string }) { } }); + // Dispose geometry and material on unmount to free WebGL resources + useEffect(() => { + const mesh = meshRef.current; + return () => { + if (mesh) { + mesh.geometry.dispose(); + if (Array.isArray(mesh.material)) { + mesh.material.forEach((m) => m.dispose()); + } else { + mesh.material.dispose(); + } + } + }; + }, []); + return ( {/* Elegant futuristic floating metallic Torus Knot */} diff --git a/src/components/ui/ParticleSystem.tsx b/src/components/ui/ParticleSystem.tsx index 30577aff..7916e5b6 100644 --- a/src/components/ui/ParticleSystem.tsx +++ b/src/components/ui/ParticleSystem.tsx @@ -69,17 +69,19 @@ export default function ParticleSystem() { animationFrameId = requestAnimationFrame(drawParticles); }; - window.addEventListener('resize', () => { + const handleResize = () => { resizeCanvas(); createParticles(); - }); + }; + + window.addEventListener('resize', handleResize); resizeCanvas(); createParticles(); drawParticles(); return () => { - window.removeEventListener('resize', resizeCanvas); + window.removeEventListener('resize', handleResize); cancelAnimationFrame(animationFrameId); }; }, []); From a9fca201dea912b473ff3ae566f5c8c9a79fe4dc Mon Sep 17 00:00:00 2001 From: Nitesh Agarwal Date: Fri, 29 May 2026 19:22:08 +0530 Subject: [PATCH 2/2] feat(security): Add in-memory sliding-window rate limiting to auth endpoint --- src/app/api/auth/verify-admin/route.ts | 98 ++++++++++++------- src/lib/rateLimiter.ts | 125 +++++++++++++++++++++++++ 2 files changed, 187 insertions(+), 36 deletions(-) create mode 100644 src/lib/rateLimiter.ts diff --git a/src/app/api/auth/verify-admin/route.ts b/src/app/api/auth/verify-admin/route.ts index 0cb98d4f..b38b00d7 100644 --- a/src/app/api/auth/verify-admin/route.ts +++ b/src/app/api/auth/verify-admin/route.ts @@ -1,36 +1,62 @@ -import { NextResponse } from 'next/server'; -import { doc, getDoc } from 'firebase/firestore'; -import { db } from '@/lib/firebase'; - -export async function POST(request: Request) { - try { - const body = await request.json(); - const { key } = body; - - if (!key) { - return NextResponse.json({ success: false, message: 'Key is required' }, { status: 400 }); - } - - // Fetch the key securely on the server side - const docRef = doc(db, 'admin_keys', 'config'); - const docSnap = await getDoc(docRef); - - if (!docSnap.exists()) { - console.error('Admin key config not found in Firestore'); - return NextResponse.json({ success: false, message: 'Server configuration error' }, { status: 500 }); - } - - const actualKey = docSnap.data().value; - - // Perform the verification securely away from the browser - if (key === actualKey) { - return NextResponse.json({ success: true }); - } else { - return NextResponse.json({ success: false, message: 'Invalid Admin Key. Please try again.' }, { status: 401 }); - } - - } catch (error) { - console.error('Error verifying admin key:', error); - return NextResponse.json({ success: false, message: 'Internal server error' }, { status: 500 }); - } -} \ No newline at end of file +import { NextResponse } from 'next/server'; +import { doc, getDoc } from 'firebase/firestore'; +import { db } from '@/lib/firebase'; +import { rateLimit } from '@/lib/rateLimiter'; + +/** + * POST /api/auth/verify-admin + * + * Verifies the submitted admin key against the value stored in Firestore. + * Rate limited to 5 attempts per IP per minute to prevent brute-force attacks. + */ +export async function POST(request: Request) { + // Rate limit: 5 attempts per IP per 60 seconds + const limit = rateLimit(request, { + limit: 5, + windowMs: 60_000, + message: 'Too many key attempts. Please wait before trying again.', + }); + + if (!limit.success) { + return limit.response; + } + + try { + const body = await request.json(); + const { key } = body; + + if (!key) { + return NextResponse.json({ success: false, message: 'Key is required' }, { status: 400 }); + } + + // Fetch the key securely on the server side + const docRef = doc(db, 'admin_keys', 'config'); + const docSnap = await getDoc(docRef); + + if (!docSnap.exists()) { + console.error('Admin key config not found in Firestore'); + return NextResponse.json({ success: false, message: 'Server configuration error' }, { status: 500 }); + } + + const actualKey = docSnap.data().value; + + // Perform the verification securely away from the browser + if (key === actualKey) { + return NextResponse.json({ success: true }); + } else { + return NextResponse.json( + { success: false, message: 'Invalid Admin Key. Please try again.' }, + { + status: 401, + headers: { + 'X-RateLimit-Remaining': String(limit.remaining - 1), + }, + } + ); + } + + } catch (error) { + console.error('Error verifying admin key:', error); + return NextResponse.json({ success: false, message: 'Internal server error' }, { status: 500 }); + } +} \ No newline at end of file diff --git a/src/lib/rateLimiter.ts b/src/lib/rateLimiter.ts new file mode 100644 index 00000000..8700247c --- /dev/null +++ b/src/lib/rateLimiter.ts @@ -0,0 +1,125 @@ +/** + * rateLimiter.ts + * + * Zero-dependency, in-memory sliding-window rate limiter for Next.js Route Handlers. + * Keyed by IP address. Safe for use in serverless/edge-adjacent environments — each + * warm instance maintains its own store, which is the correct behaviour for Next.js + * server-side route handlers. + * + * Usage: + * const result = rateLimit(request, { limit: 10, windowMs: 60_000 }); + * if (!result.success) return result.response; + */ + +interface RateLimitEntry { + /** Timestamps of requests within the current window (ms). */ + timestamps: number[]; +} + +/** Module-level store — persists across requests within the same server instance. */ +const store = new Map(); + +/** Purge entries that have no timestamps left to keep memory bounded. */ +function prune() { + for (const [key, entry] of store.entries()) { + if (entry.timestamps.length === 0) store.delete(key); + } +} + +export interface RateLimitOptions { + /** Maximum number of requests allowed within the window. Default: 10 */ + limit?: number; + /** Window duration in milliseconds. Default: 60_000 (1 minute) */ + windowMs?: number; + /** Human-readable message included in the 429 response body. */ + message?: string; +} + +export interface RateLimitResult { + /** True when the request is within the allowed limit. */ + success: true; + /** Remaining requests allowed in the current window. */ + remaining: number; +} + +export interface RateLimitBlocked { + success: false; + /** Ready-to-return NextResponse with status 429. */ + response: Response; +} + +/** + * Extracts the real client IP from a Next.js Request, respecting common + * reverse-proxy headers (Vercel, Cloudflare, standard X-Forwarded-For). + */ +function getIp(request: Request): string { + const headers = request.headers; + return ( + headers.get('x-real-ip') ?? + headers.get('cf-connecting-ip') ?? + headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? + 'unknown' + ); +} + +/** + * Sliding-window rate limiter. Call at the top of any Route Handler POST function. + * + * @returns `{ success: true, remaining }` if allowed, or + * `{ success: false, response }` with a 429 response to return immediately. + */ +export function rateLimit( + request: Request, + options: RateLimitOptions = {} +): RateLimitResult | RateLimitBlocked { + const { limit = 10, windowMs = 60_000, message = 'Too many requests. Please try again later.' } = options; + + const ip = getIp(request); + const now = Date.now(); + const windowStart = now - windowMs; + + // Retrieve or initialise the entry for this IP + if (!store.has(ip)) { + store.set(ip, { timestamps: [] }); + } + + const entry = store.get(ip)!; + + // Slide the window — discard timestamps older than windowStart + entry.timestamps = entry.timestamps.filter((t) => t > windowStart); + + if (entry.timestamps.length >= limit) { + // Compute retry-after in seconds (time until oldest timestamp leaves the window) + const oldestInWindow = entry.timestamps[0]; + const retryAfterMs = oldestInWindow + windowMs - now; + const retryAfterSec = Math.ceil(retryAfterMs / 1000); + + return { + success: false, + response: new Response( + JSON.stringify({ success: false, message }), + { + status: 429, + headers: { + 'Content-Type': 'application/json', + 'Retry-After': String(retryAfterSec), + 'X-RateLimit-Limit': String(limit), + 'X-RateLimit-Remaining': '0', + 'X-RateLimit-Reset': String(Math.ceil((oldestInWindow + windowMs) / 1000)), + }, + } + ), + }; + } + + // Record this request + entry.timestamps.push(now); + + // Occasionally prune the store to prevent unbounded memory growth + if (Math.random() < 0.01) prune(); + + return { + success: true, + remaining: limit - entry.timestamps.length, + }; +}