Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b75fcc9bea | ||
|
|
8bd15d9d03 | ||
|
|
87261c6b92 | ||
|
|
b1e89b64c5 |
@@ -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/
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,8 +29,6 @@ const Skills = () => {
|
|||||||
const [skills, setSkills] = useState<Skill[]>([]);
|
const [skills, setSkills] = useState<Skill[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Temporary delay to see skeleton loading state
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
try {
|
try {
|
||||||
const translatedSkills = t.raw('skills') as Skill[];
|
const translatedSkills = t.raw('skills') as Skill[];
|
||||||
if (Array.isArray(translatedSkills)) {
|
if (Array.isArray(translatedSkills)) {
|
||||||
@@ -80,13 +38,10 @@ const Skills = () => {
|
|||||||
console.error('Error loading skills:', error);
|
console.error('Error loading skills:', error);
|
||||||
setSkills([]);
|
setSkills([]);
|
||||||
}
|
}
|
||||||
}, 2000);
|
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [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 (
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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: [
|
||||||
'/',
|
'/',
|
||||||
|
|||||||
Reference in New Issue
Block a user