Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 62 additions & 36 deletions src/app/api/auth/verify-admin/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
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 });
}
}
34 changes: 30 additions & 4 deletions src/components/3d/HeaderScene.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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,
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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 (
<mesh ref={meshRef} position={[0, 0.4, 0]}>
{/* Elegant futuristic floating metallic Torus Knot */}
Expand Down
8 changes: 5 additions & 3 deletions src/components/ui/ParticleSystem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
};
}, []);
Expand Down
125 changes: 125 additions & 0 deletions src/lib/rateLimiter.ts
Original file line number Diff line number Diff line change
@@ -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<string, RateLimitEntry>();

/** 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,
};
}