Compare commits

..
6 changed files with 248 additions and 59 deletions
-3
View File
@@ -82,6 +82,3 @@ supabase/.temp/
# Source images (originals before optimization) # Source images (originals before optimization)
source-images/ source-images/
# Auto Claude data directory
.auto-claude/
+93
View File
@@ -0,0 +1,93 @@
'use server';
import { validateCsrfToken } from '@/lib/csrf/server';
interface ContactFormData {
name: string;
email: string;
message: string;
}
interface ContactFormResult {
success: boolean;
error?: string;
errors?: Partial<Record<keyof ContactFormData, string>>;
}
/**
* Server action to handle contact form submissions
* Validates CSRF token and form data before processing
*/
export async function submitContactForm(
formData: FormData
): Promise<ContactFormResult> {
// Extract CSRF token from form data
const csrfToken = formData.get('csrfToken');
// Validate CSRF token
if (!csrfToken || typeof csrfToken !== 'string') {
return {
success: false,
error: 'CSRF token is missing',
};
}
const isValidToken = await validateCsrfToken(csrfToken);
if (!isValidToken) {
return {
success: false,
error: 'Invalid CSRF token',
};
}
// Extract and validate form fields
const name = formData.get('name')?.toString() || '';
const email = formData.get('email')?.toString() || '';
const message = formData.get('message')?.toString() || '';
// Validation
const errors: Partial<Record<keyof ContactFormData, string>> = {};
if (!name.trim()) {
errors.name = 'Name is required';
}
if (!email.trim()) {
errors.email = 'Email is required';
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
errors.email = 'Invalid email format';
}
if (!message.trim()) {
errors.message = 'Message is required';
}
if (Object.keys(errors).length > 0) {
return {
success: false,
errors,
};
}
try {
// TODO: Implement actual email sending logic here
// For now, we'll simulate successful submission
// In production, this would integrate with an email service like:
// - Supabase Edge Functions
// - SendGrid
// - AWS SES
// - Resend
// Simulate processing delay
await new Promise(resolve => setTimeout(resolve, 500));
return {
success: true,
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to send message',
};
}
}
+9 -54
View File
@@ -11,46 +11,6 @@ interface Skill {
description: string; description: string;
} }
interface SkeletonProps {
className?: string;
}
const Skeleton: React.FC<SkeletonProps> = ({ className }) => (
<div className={`animate-pulse bg-zinc-700/50 rounded ${className}`}></div>
);
const SkillsSkeleton = () => (
<section id="skills" className="py-24 relative" aria-label="Loading skills" aria-busy="true">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<Skeleton className="h-8 w-48 mb-12" />
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Skills Progress Bars Skeleton */}
<div className="space-y-6">
{[1, 2, 3, 4, 5, 6, 7, 8].map((i) => (
<div key={i} className="space-y-2">
<div className="flex justify-between items-center">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-12" />
</div>
<Skeleton className="h-2 w-full rounded-full" />
</div>
))}
</div>
{/* Skills Icons Grid Skeleton */}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
{[1, 2, 3, 4, 5, 6].map((i) => (
<div key={i} className="p-6 rounded-xl bg-zinc-800/30 backdrop-blur-sm">
<Skeleton className="w-8 h-8 mx-auto mb-3" />
<Skeleton className="h-4 w-16 mx-auto" />
</div>
))}
</div>
</div>
</div>
</section>
);
const iconMap: Record<string, React.ReactNode> = { const iconMap: Record<string, React.ReactNode> = {
'AI & LLMs': <Bot className="w-8 h-8" />, 'AI & LLMs': <Bot className="w-8 h-8" />,
'Automation': <Workflow className="w-8 h-8" />, 'Automation': <Workflow className="w-8 h-8" />,
@@ -69,24 +29,19 @@ const Skills = () => {
const [skills, setSkills] = useState<Skill[]>([]); const [skills, setSkills] = useState<Skill[]>([]);
useEffect(() => { useEffect(() => {
// Temporary delay to see skeleton loading state try {
const timer = setTimeout(() => { const translatedSkills = t.raw('skills') as Skill[];
try { if (Array.isArray(translatedSkills)) {
const translatedSkills = t.raw('skills') as Skill[]; setSkills(translatedSkills);
if (Array.isArray(translatedSkills)) {
setSkills(translatedSkills);
}
} catch (error) {
console.error('Error loading skills:', error);
setSkills([]);
} }
}, 2000); } catch (error) {
console.error('Error loading skills:', error);
return () => clearTimeout(timer); setSkills([]);
}
}, [t]); }, [t]);
if (skills.length === 0) { if (skills.length === 0) {
return <SkillsSkeleton />; return <div className="py-24 text-center text-zinc-400">Loading skills...</div>;
} }
return ( return (
+17
View File
@@ -0,0 +1,17 @@
const CSRF_META_TAG_NAME = 'csrf-token';
/**
* Get the CSRF token from the meta tag in the document head
* The server should render: <meta name="csrf-token" content="..." />
*/
export function getCsrfToken(): string | null {
if (typeof document === 'undefined') {
return null;
}
const metaTag = document.querySelector<HTMLMetaElement>(
`meta[name="${CSRF_META_TAG_NAME}"]`
);
return metaTag?.content || null;
}
+94
View File
@@ -0,0 +1,94 @@
import { cookies } from 'next/headers';
import { randomBytes } from 'crypto';
const CSRF_TOKEN_COOKIE_NAME = 'csrf_token';
const CSRF_TOKEN_LENGTH = 32;
/**
* Generate a cryptographically secure CSRF token
*/
export function generateCsrfToken(): string {
return randomBytes(CSRF_TOKEN_LENGTH).toString('base64url');
}
/**
* Get the current CSRF token from cookies or generate a new one
*/
export async function getCsrfToken(): Promise<string> {
const cookieStore = await cookies();
const existingToken = cookieStore.get(CSRF_TOKEN_COOKIE_NAME);
if (existingToken?.value) {
return existingToken.value;
}
const newToken = generateCsrfToken();
await setCsrfToken(newToken);
return newToken;
}
/**
* Set the CSRF token in an httpOnly cookie
*/
export async function setCsrfToken(token: string): Promise<void> {
const cookieStore = await cookies();
try {
cookieStore.set(CSRF_TOKEN_COOKIE_NAME, token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 60 * 60 * 24, // 24 hours
path: '/',
});
} catch {
// Handle server component context where cookies can't be set
}
}
/**
* Validate a CSRF token against the stored token in cookies
*/
export async function validateCsrfToken(token: string): Promise<boolean> {
const cookieStore = await cookies();
const storedToken = cookieStore.get(CSRF_TOKEN_COOKIE_NAME);
if (!storedToken?.value || !token) {
return false;
}
// Constant-time comparison to prevent timing attacks
return timingSafeEqual(
Buffer.from(storedToken.value),
Buffer.from(token)
);
}
/**
* Timing-safe string comparison to prevent timing attacks
*/
function timingSafeEqual(a: Buffer, b: Buffer): boolean {
if (a.length !== b.length) {
return false;
}
let result = 0;
for (let i = 0; i < a.length; i++) {
result |= a[i] ^ b[i];
}
return result === 0;
}
/**
* Delete the CSRF token cookie
*/
export async function deleteCsrfToken(): Promise<void> {
const cookieStore = await cookies();
try {
cookieStore.delete(CSRF_TOKEN_COOKIE_NAME);
} catch {
// Handle server component context
}
}
+34 -1
View File
@@ -1,12 +1,45 @@
import { NextRequest, NextResponse } from 'next/server';
import createMiddleware from 'next-intl/middleware'; import createMiddleware from 'next-intl/middleware';
import { locales, defaultLocale } from './i18n/config'; import { locales, defaultLocale } from './i18n/config';
import { randomBytes } from 'crypto';
export default createMiddleware({ const CSRF_TOKEN_COOKIE_NAME = 'csrf_token';
const CSRF_TOKEN_LENGTH = 32;
const intlMiddleware = createMiddleware({
locales, locales,
defaultLocale, defaultLocale,
localePrefix: 'always', localePrefix: 'always',
}); });
export default function middleware(request: NextRequest) {
// Run the i18n middleware first
const response = intlMiddleware(request);
// Check if CSRF token exists in cookies
const existingToken = request.cookies.get(CSRF_TOKEN_COOKIE_NAME);
// Generate and set CSRF token if it doesn't exist
if (!existingToken) {
const newToken = randomBytes(CSRF_TOKEN_LENGTH).toString('base64url');
// Create a new response or clone the existing one
const finalResponse = response || NextResponse.next();
finalResponse.cookies.set(CSRF_TOKEN_COOKIE_NAME, newToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 60 * 60 * 24, // 24 hours
path: '/',
});
return finalResponse;
}
return response;
}
export const config = { export const config = {
matcher: [ matcher: [
'/', '/',