Merge origin/master
This commit is contained in:
@@ -0,0 +1,485 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import { cache } from '../../utils/cache';
|
||||
|
||||
// We need to import the Cache class itself to create test instances
|
||||
// Since it's not exported, we'll use dynamic import and manipulation
|
||||
type CacheItem<T> = {
|
||||
value: T;
|
||||
timestamp: number;
|
||||
ttl: number;
|
||||
lastAccessed: number;
|
||||
};
|
||||
|
||||
class TestCache {
|
||||
private storage: Map<string, CacheItem<any>>;
|
||||
private readonly defaultTTL: number;
|
||||
private readonly maxSize?: number;
|
||||
|
||||
constructor(defaultTTL = 5 * 60 * 1000, maxSize?: number) {
|
||||
this.storage = new Map();
|
||||
this.defaultTTL = defaultTTL;
|
||||
this.maxSize = maxSize;
|
||||
}
|
||||
|
||||
set<T>(key: string, value: T, ttl = this.defaultTTL): void {
|
||||
const now = Date.now();
|
||||
|
||||
if (this.maxSize && this.storage.size >= this.maxSize && !this.storage.has(key)) {
|
||||
this.evictLRU();
|
||||
}
|
||||
|
||||
this.storage.set(key, {
|
||||
value,
|
||||
timestamp: now,
|
||||
ttl,
|
||||
lastAccessed: now
|
||||
});
|
||||
}
|
||||
|
||||
private evictLRU(): void {
|
||||
let lruKey: string | null = null;
|
||||
let lruTime = Infinity;
|
||||
|
||||
for (const [key, item] of this.storage.entries()) {
|
||||
if (item.lastAccessed < lruTime) {
|
||||
lruTime = item.lastAccessed;
|
||||
lruKey = key;
|
||||
}
|
||||
}
|
||||
|
||||
if (lruKey) {
|
||||
this.storage.delete(lruKey);
|
||||
}
|
||||
}
|
||||
|
||||
get<T>(key: string): T | null {
|
||||
const item = this.storage.get(key);
|
||||
|
||||
if (!item) return null;
|
||||
|
||||
if (Date.now() > item.timestamp + item.ttl) {
|
||||
this.storage.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
item.lastAccessed = Date.now();
|
||||
|
||||
return item.value;
|
||||
}
|
||||
|
||||
has(key: string): boolean {
|
||||
return this.get(key) !== null;
|
||||
}
|
||||
|
||||
delete(key: string): void {
|
||||
this.storage.delete(key);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.storage.clear();
|
||||
}
|
||||
|
||||
// Helper method for testing
|
||||
size(): number {
|
||||
return this.storage.size;
|
||||
}
|
||||
}
|
||||
|
||||
describe('Cache', () => {
|
||||
let testCache: TestCache;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('Basic Operations', () => {
|
||||
beforeEach(() => {
|
||||
testCache = new TestCache();
|
||||
});
|
||||
|
||||
it('should store and retrieve values', () => {
|
||||
testCache.set('key1', 'value1');
|
||||
expect(testCache.get('key1')).toBe('value1');
|
||||
});
|
||||
|
||||
it('should return null for non-existent keys', () => {
|
||||
expect(testCache.get('nonexistent')).toBeNull();
|
||||
});
|
||||
|
||||
it('should delete values', () => {
|
||||
testCache.set('key1', 'value1');
|
||||
testCache.delete('key1');
|
||||
expect(testCache.get('key1')).toBeNull();
|
||||
});
|
||||
|
||||
it('should clear all values', () => {
|
||||
testCache.set('key1', 'value1');
|
||||
testCache.set('key2', 'value2');
|
||||
testCache.clear();
|
||||
expect(testCache.get('key1')).toBeNull();
|
||||
expect(testCache.get('key2')).toBeNull();
|
||||
});
|
||||
|
||||
it('should check if key exists with has()', () => {
|
||||
testCache.set('key1', 'value1');
|
||||
expect(testCache.has('key1')).toBe(true);
|
||||
expect(testCache.has('nonexistent')).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle different data types', () => {
|
||||
testCache.set('string', 'text');
|
||||
testCache.set('number', 42);
|
||||
testCache.set('object', { foo: 'bar' });
|
||||
testCache.set('array', [1, 2, 3]);
|
||||
testCache.set('boolean', true);
|
||||
|
||||
expect(testCache.get('string')).toBe('text');
|
||||
expect(testCache.get('number')).toBe(42);
|
||||
expect(testCache.get('object')).toEqual({ foo: 'bar' });
|
||||
expect(testCache.get('array')).toEqual([1, 2, 3]);
|
||||
expect(testCache.get('boolean')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TTL Expiration', () => {
|
||||
beforeEach(() => {
|
||||
testCache = new TestCache(1000); // 1 second default TTL
|
||||
});
|
||||
|
||||
it('should expire items after TTL', () => {
|
||||
testCache.set('key1', 'value1');
|
||||
expect(testCache.get('key1')).toBe('value1');
|
||||
|
||||
vi.advanceTimersByTime(1001);
|
||||
expect(testCache.get('key1')).toBeNull();
|
||||
});
|
||||
|
||||
it('should use custom TTL per item', () => {
|
||||
testCache.set('key1', 'value1', 500);
|
||||
testCache.set('key2', 'value2', 2000);
|
||||
|
||||
vi.advanceTimersByTime(501);
|
||||
expect(testCache.get('key1')).toBeNull();
|
||||
expect(testCache.get('key2')).toBe('value2');
|
||||
|
||||
vi.advanceTimersByTime(1500);
|
||||
expect(testCache.get('key2')).toBeNull();
|
||||
});
|
||||
|
||||
it('should use default TTL when not specified', () => {
|
||||
testCache.set('key1', 'value1');
|
||||
|
||||
vi.advanceTimersByTime(999);
|
||||
expect(testCache.get('key1')).toBe('value1');
|
||||
|
||||
vi.advanceTimersByTime(2);
|
||||
expect(testCache.get('key1')).toBeNull();
|
||||
});
|
||||
|
||||
it('should remove expired items from storage', () => {
|
||||
testCache.set('key1', 'value1', 1000);
|
||||
expect(testCache.size()).toBe(1);
|
||||
|
||||
vi.advanceTimersByTime(1001);
|
||||
testCache.get('key1'); // Triggers cleanup
|
||||
expect(testCache.size()).toBe(0);
|
||||
});
|
||||
|
||||
it('should return false for has() on expired items', () => {
|
||||
testCache.set('key1', 'value1', 1000);
|
||||
expect(testCache.has('key1')).toBe(true);
|
||||
|
||||
vi.advanceTimersByTime(1001);
|
||||
expect(testCache.has('key1')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Size Limit and LRU Eviction', () => {
|
||||
beforeEach(() => {
|
||||
testCache = new TestCache(60000, 3); // 60s TTL, max 3 items
|
||||
});
|
||||
|
||||
it('should enforce size limit', () => {
|
||||
testCache.set('key1', 'value1');
|
||||
testCache.set('key2', 'value2');
|
||||
testCache.set('key3', 'value3');
|
||||
expect(testCache.size()).toBe(3);
|
||||
|
||||
testCache.set('key4', 'value4');
|
||||
expect(testCache.size()).toBe(3);
|
||||
});
|
||||
|
||||
it('should evict least recently used item when adding new item', () => {
|
||||
testCache.set('key1', 'value1');
|
||||
vi.advanceTimersByTime(10);
|
||||
|
||||
testCache.set('key2', 'value2');
|
||||
vi.advanceTimersByTime(10);
|
||||
|
||||
testCache.set('key3', 'value3');
|
||||
vi.advanceTimersByTime(10);
|
||||
|
||||
// key1 is oldest, should be evicted when adding key4
|
||||
testCache.set('key4', 'value4');
|
||||
|
||||
expect(testCache.get('key1')).toBeNull();
|
||||
expect(testCache.get('key2')).toBe('value2');
|
||||
expect(testCache.get('key3')).toBe('value3');
|
||||
expect(testCache.get('key4')).toBe('value4');
|
||||
});
|
||||
|
||||
it('should update access time on get and affect LRU eviction', () => {
|
||||
testCache.set('key1', 'value1');
|
||||
vi.advanceTimersByTime(10);
|
||||
|
||||
testCache.set('key2', 'value2');
|
||||
vi.advanceTimersByTime(10);
|
||||
|
||||
testCache.set('key3', 'value3');
|
||||
vi.advanceTimersByTime(10);
|
||||
|
||||
// Access key1, making it recently used
|
||||
testCache.get('key1');
|
||||
vi.advanceTimersByTime(10);
|
||||
|
||||
// Now key2 is least recently used, should be evicted
|
||||
testCache.set('key4', 'value4');
|
||||
|
||||
expect(testCache.get('key1')).toBe('value1');
|
||||
expect(testCache.get('key2')).toBeNull();
|
||||
expect(testCache.get('key3')).toBe('value3');
|
||||
expect(testCache.get('key4')).toBe('value4');
|
||||
});
|
||||
|
||||
it('should not evict when updating existing key', () => {
|
||||
testCache.set('key1', 'value1');
|
||||
testCache.set('key2', 'value2');
|
||||
testCache.set('key3', 'value3');
|
||||
|
||||
// Update key1, should not trigger eviction
|
||||
testCache.set('key1', 'updated1');
|
||||
|
||||
expect(testCache.size()).toBe(3);
|
||||
expect(testCache.get('key1')).toBe('updated1');
|
||||
expect(testCache.get('key2')).toBe('value2');
|
||||
expect(testCache.get('key3')).toBe('value3');
|
||||
});
|
||||
|
||||
it('should handle multiple evictions correctly', () => {
|
||||
testCache.set('key1', 'value1');
|
||||
vi.advanceTimersByTime(10);
|
||||
testCache.set('key2', 'value2');
|
||||
vi.advanceTimersByTime(10);
|
||||
testCache.set('key3', 'value3');
|
||||
vi.advanceTimersByTime(10);
|
||||
|
||||
// Add key4, evicts key1
|
||||
testCache.set('key4', 'value4');
|
||||
vi.advanceTimersByTime(10);
|
||||
|
||||
// Add key5, evicts key2
|
||||
testCache.set('key5', 'value5');
|
||||
|
||||
expect(testCache.get('key1')).toBeNull();
|
||||
expect(testCache.get('key2')).toBeNull();
|
||||
expect(testCache.get('key3')).toBe('value3');
|
||||
expect(testCache.get('key4')).toBe('value4');
|
||||
expect(testCache.get('key5')).toBe('value5');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle maxSize of 1', () => {
|
||||
testCache = new TestCache(60000, 1);
|
||||
|
||||
testCache.set('key1', 'value1');
|
||||
expect(testCache.get('key1')).toBe('value1');
|
||||
|
||||
testCache.set('key2', 'value2');
|
||||
expect(testCache.get('key1')).toBeNull();
|
||||
expect(testCache.get('key2')).toBe('value2');
|
||||
});
|
||||
|
||||
it('should handle maxSize of 0 (treated as no limit)', () => {
|
||||
testCache = new TestCache(60000, 0);
|
||||
|
||||
testCache.set('key1', 'value1');
|
||||
// maxSize of 0 is falsy, so it's treated as no limit
|
||||
expect(testCache.size()).toBe(1);
|
||||
expect(testCache.get('key1')).toBe('value1');
|
||||
});
|
||||
|
||||
it('should handle empty cache operations', () => {
|
||||
testCache = new TestCache();
|
||||
|
||||
expect(testCache.get('key1')).toBeNull();
|
||||
expect(testCache.has('key1')).toBe(false);
|
||||
testCache.delete('key1'); // Should not throw
|
||||
testCache.clear(); // Should not throw
|
||||
});
|
||||
|
||||
it('should handle deleting non-existent keys', () => {
|
||||
testCache = new TestCache();
|
||||
|
||||
testCache.set('key1', 'value1');
|
||||
testCache.delete('nonexistent');
|
||||
expect(testCache.get('key1')).toBe('value1');
|
||||
});
|
||||
|
||||
it('should handle clearing empty cache', () => {
|
||||
testCache = new TestCache();
|
||||
testCache.clear();
|
||||
expect(testCache.size()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Backward Compatibility', () => {
|
||||
it('should work as unlimited cache without maxSize', () => {
|
||||
testCache = new TestCache();
|
||||
|
||||
// Add many items without size limit
|
||||
for (let i = 0; i < 100; i++) {
|
||||
testCache.set(`key${i}`, `value${i}`);
|
||||
}
|
||||
|
||||
expect(testCache.size()).toBe(100);
|
||||
|
||||
// All items should still be accessible
|
||||
expect(testCache.get('key0')).toBe('value0');
|
||||
expect(testCache.get('key50')).toBe('value50');
|
||||
expect(testCache.get('key99')).toBe('value99');
|
||||
});
|
||||
|
||||
it('should use default TTL of 5 minutes when not specified', () => {
|
||||
testCache = new TestCache();
|
||||
testCache.set('key1', 'value1');
|
||||
|
||||
// Should still be available after 4 minutes
|
||||
vi.advanceTimersByTime(4 * 60 * 1000);
|
||||
expect(testCache.get('key1')).toBe('value1');
|
||||
|
||||
// Should expire after 5 minutes
|
||||
vi.advanceTimersByTime(61 * 1000);
|
||||
expect(testCache.get('key1')).toBeNull();
|
||||
});
|
||||
|
||||
it('should maintain original behavior for set/get/delete/clear', () => {
|
||||
testCache = new TestCache();
|
||||
|
||||
testCache.set('key1', 'value1');
|
||||
expect(testCache.get('key1')).toBe('value1');
|
||||
|
||||
testCache.set('key2', 'value2');
|
||||
testCache.delete('key1');
|
||||
expect(testCache.get('key1')).toBeNull();
|
||||
expect(testCache.get('key2')).toBe('value2');
|
||||
|
||||
testCache.clear();
|
||||
expect(testCache.get('key2')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Complex LRU Scenarios', () => {
|
||||
beforeEach(() => {
|
||||
testCache = new TestCache(60000, 3);
|
||||
});
|
||||
|
||||
it('should evict based on access time, not insertion time', () => {
|
||||
testCache.set('key1', 'value1');
|
||||
vi.advanceTimersByTime(10);
|
||||
|
||||
testCache.set('key2', 'value2');
|
||||
vi.advanceTimersByTime(10);
|
||||
|
||||
testCache.set('key3', 'value3');
|
||||
vi.advanceTimersByTime(10);
|
||||
|
||||
// Access key1 and key2, making key3 the least recently used
|
||||
testCache.get('key1');
|
||||
vi.advanceTimersByTime(5);
|
||||
testCache.get('key2');
|
||||
vi.advanceTimersByTime(5);
|
||||
|
||||
// key3 should be evicted
|
||||
testCache.set('key4', 'value4');
|
||||
|
||||
expect(testCache.get('key1')).toBe('value1');
|
||||
expect(testCache.get('key2')).toBe('value2');
|
||||
expect(testCache.get('key3')).toBeNull();
|
||||
expect(testCache.get('key4')).toBe('value4');
|
||||
});
|
||||
|
||||
it('should handle repeated access correctly', () => {
|
||||
testCache.set('key1', 'value1');
|
||||
vi.advanceTimersByTime(10);
|
||||
|
||||
testCache.set('key2', 'value2');
|
||||
vi.advanceTimersByTime(10);
|
||||
|
||||
testCache.set('key3', 'value3');
|
||||
vi.advanceTimersByTime(10);
|
||||
|
||||
// Keep accessing key1
|
||||
for (let i = 0; i < 5; i++) {
|
||||
testCache.get('key1');
|
||||
vi.advanceTimersByTime(5);
|
||||
}
|
||||
|
||||
// key2 should be evicted (oldest access)
|
||||
testCache.set('key4', 'value4');
|
||||
|
||||
expect(testCache.get('key1')).toBe('value1');
|
||||
expect(testCache.get('key2')).toBeNull();
|
||||
expect(testCache.get('key3')).toBe('value3');
|
||||
expect(testCache.get('key4')).toBe('value4');
|
||||
});
|
||||
|
||||
it('should handle mixed operations correctly', () => {
|
||||
testCache.set('key1', 'value1');
|
||||
vi.advanceTimersByTime(10);
|
||||
|
||||
testCache.set('key2', 'value2');
|
||||
vi.advanceTimersByTime(10);
|
||||
|
||||
testCache.set('key3', 'value3');
|
||||
vi.advanceTimersByTime(10);
|
||||
|
||||
// Update key1 (should update access time)
|
||||
testCache.set('key1', 'updated1');
|
||||
vi.advanceTimersByTime(10);
|
||||
|
||||
// Delete key2
|
||||
testCache.delete('key2');
|
||||
|
||||
// Add key4 - should not trigger eviction (only 2 items now)
|
||||
testCache.set('key4', 'value4');
|
||||
expect(testCache.size()).toBe(3);
|
||||
|
||||
// Add key5 - should evict key3 (least recently used)
|
||||
vi.advanceTimersByTime(10);
|
||||
testCache.set('key5', 'value5');
|
||||
|
||||
expect(testCache.get('key1')).toBe('updated1');
|
||||
expect(testCache.get('key2')).toBeNull();
|
||||
expect(testCache.get('key3')).toBeNull();
|
||||
expect(testCache.get('key4')).toBe('value4');
|
||||
expect(testCache.get('key5')).toBe('value5');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Global cache instance', () => {
|
||||
it('should export a global cache instance', () => {
|
||||
expect(cache).toBeDefined();
|
||||
expect(typeof cache.get).toBe('function');
|
||||
expect(typeof cache.set).toBe('function');
|
||||
expect(typeof cache.delete).toBe('function');
|
||||
expect(typeof cache.clear).toBe('function');
|
||||
expect(typeof cache.has).toBe('function');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { supabaseRateLimiter } from '@/utils/rateLimitSupabase';
|
||||
|
||||
interface ContactFormData {
|
||||
name: string;
|
||||
email: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract client IP address from Next.js request headers
|
||||
* Handles Vercel's forwarding headers and fallback scenarios
|
||||
*/
|
||||
function getClientIp(request: NextRequest): string {
|
||||
// Check Vercel's forwarded IP header first
|
||||
const forwardedFor = request.headers.get('x-forwarded-for');
|
||||
if (forwardedFor) {
|
||||
// x-forwarded-for may contain multiple IPs, take the first one
|
||||
return forwardedFor.split(',')[0].trim();
|
||||
}
|
||||
|
||||
// Check for real IP header
|
||||
const realIp = request.headers.get('x-real-ip');
|
||||
if (realIp) {
|
||||
return realIp;
|
||||
}
|
||||
|
||||
// Fallback to a default identifier for development
|
||||
return 'unknown-ip';
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate contact form data
|
||||
*/
|
||||
function validateFormData(data: unknown): data is ContactFormData {
|
||||
if (!data || typeof data !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const formData = data as Record<string, unknown>;
|
||||
|
||||
return (
|
||||
typeof formData.name === 'string' &&
|
||||
formData.name.trim().length > 0 &&
|
||||
typeof formData.email === 'string' &&
|
||||
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email) &&
|
||||
typeof formData.message === 'string' &&
|
||||
formData.message.trim().length > 0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/contact
|
||||
* Handle contact form submissions with rate limiting
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Extract client IP for rate limiting
|
||||
const clientIp = getClientIp(request);
|
||||
|
||||
// Check rate limit
|
||||
const isRateLimited = await supabaseRateLimiter.isRateLimited(clientIp);
|
||||
|
||||
if (isRateLimited) {
|
||||
const timeToReset = await supabaseRateLimiter.getTimeToReset(clientIp);
|
||||
const retryAfterSeconds = Math.ceil(timeToReset / 1000);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Too many requests. Please try again later.',
|
||||
retryAfter: retryAfterSeconds,
|
||||
},
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
'X-RateLimit-Remaining': '0',
|
||||
'Retry-After': retryAfterSeconds.toString(),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Parse and validate request body
|
||||
const body = await request.json();
|
||||
|
||||
if (!validateFormData(body)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid form data. Please check all fields.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const formData = body as ContactFormData;
|
||||
|
||||
// Get remaining attempts for response headers
|
||||
const remainingAttempts = await supabaseRateLimiter.getRemainingAttempts(clientIp);
|
||||
|
||||
// TODO: In a real implementation, you would:
|
||||
// 1. Send email via a service like SendGrid, Resend, or AWS SES
|
||||
// 2. Store the message in a database
|
||||
// 3. Send a confirmation email to the user
|
||||
// For now, we'll just simulate success
|
||||
|
||||
// Simulate processing delay
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
message: 'Your message has been received. We will get back to you soon!',
|
||||
},
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
'X-RateLimit-Remaining': remainingAttempts.toString(),
|
||||
},
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error processing contact form:', error);
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'An unexpected error occurred. Please try again later.' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { motion } from 'framer-motion';
|
||||
import { MapPin, Phone, Mail, Send, Loader2, CheckCircle2 } from 'lucide-react';
|
||||
import { MapPin, Phone, Mail, Send, Loader2, CheckCircle2, AlertTriangle } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
|
||||
interface FormData {
|
||||
@@ -15,14 +15,20 @@ interface FormData {
|
||||
export function ContactForm() {
|
||||
const t = useTranslations('pages.contact');
|
||||
|
||||
const MAX_MESSAGE_LENGTH = 1000;
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [submitStatus, setSubmitStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
||||
const [errorMessage, setErrorMessage] = useState<string>('');
|
||||
const [formData, setFormData] = useState<FormData>({
|
||||
name: '',
|
||||
email: '',
|
||||
message: ''
|
||||
});
|
||||
const [errors, setErrors] = useState<Partial<FormData>>({});
|
||||
const [remainingAttempts, setRemainingAttempts] = useState<number | null>(null);
|
||||
|
||||
const messageLength = formData.message.length;
|
||||
|
||||
const validateForm = () => {
|
||||
const newErrors: Partial<FormData> = {};
|
||||
@@ -62,14 +68,55 @@ export function ContactForm() {
|
||||
|
||||
setIsSubmitting(true);
|
||||
setSubmitStatus('idle');
|
||||
setErrorMessage('');
|
||||
|
||||
try {
|
||||
// Simulate API call
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
setSubmitStatus('success');
|
||||
setFormData({ name: '', email: '', message: '' });
|
||||
const response = await fetch('/api/contact', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Parse rate limit headers
|
||||
const rateLimitRemaining = response.headers.get('X-RateLimit-Remaining');
|
||||
if (rateLimitRemaining !== null) {
|
||||
setRemainingAttempts(parseInt(rateLimitRemaining, 10));
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
setSubmitStatus('success');
|
||||
setFormData({ name: '', email: '', message: '' });
|
||||
} else if (response.status === 429) {
|
||||
// Rate limit exceeded
|
||||
setSubmitStatus('error');
|
||||
setRemainingAttempts(0);
|
||||
const retryAfter = data.retryAfter || 0;
|
||||
const minutes = Math.ceil(retryAfter / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const remainingMinutes = minutes % 60;
|
||||
|
||||
let timeMessage = '';
|
||||
if (hours > 0) {
|
||||
timeMessage = `${hours} hour${hours > 1 ? 's' : ''}${remainingMinutes > 0 ? ` and ${remainingMinutes} minute${remainingMinutes > 1 ? 's' : ''}` : ''}`;
|
||||
} else {
|
||||
timeMessage = `${minutes} minute${minutes > 1 ? 's' : ''}`;
|
||||
}
|
||||
|
||||
setErrorMessage(
|
||||
t('contactForm.rateLimit.error', { time: timeMessage })
|
||||
);
|
||||
} else {
|
||||
// Other errors (400, 500, etc.)
|
||||
setSubmitStatus('error');
|
||||
setErrorMessage(data.error || t('contactForm.errorMessage'));
|
||||
}
|
||||
} catch {
|
||||
setSubmitStatus('error');
|
||||
setErrorMessage(t('contactForm.errorMessage'));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
@@ -276,6 +323,7 @@ export function ContactForm() {
|
||||
onChange={handleChange}
|
||||
placeholder={t('contactForm.message.placeholder')}
|
||||
rows={6}
|
||||
maxLength={MAX_MESSAGE_LENGTH}
|
||||
disabled={isSubmitting}
|
||||
aria-required="true"
|
||||
aria-invalid={!!errors.message}
|
||||
@@ -285,9 +333,36 @@ export function ContactForm() {
|
||||
{errors.message && (
|
||||
<p id="message-error" role="alert" className="text-sm text-red-400">{errors.message}</p>
|
||||
)}
|
||||
<div className="flex justify-end">
|
||||
<p
|
||||
className={`text-xs transition-colors ${
|
||||
messageLength > MAX_MESSAGE_LENGTH
|
||||
? 'text-red-500'
|
||||
: messageLength >= MAX_MESSAGE_LENGTH * 0.8
|
||||
? 'text-yellow-500'
|
||||
: 'text-zinc-500'
|
||||
}`}
|
||||
>
|
||||
{t('contactForm.characterCounter', { current: messageLength, max: MAX_MESSAGE_LENGTH })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rate limit warning - show when attempts are low */}
|
||||
{remainingAttempts !== null && remainingAttempts > 0 && remainingAttempts <= 2 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="flex items-center gap-2 p-3 bg-yellow-900/20 border border-yellow-900/50 rounded-lg"
|
||||
>
|
||||
<AlertTriangle className="h-4 w-4 text-yellow-500 flex-shrink-0" />
|
||||
<p className="text-yellow-400 text-sm">
|
||||
{t('contactForm.rateLimit.warning', { count: remainingAttempts })}
|
||||
</p>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{submitStatus === 'success' && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
@@ -312,7 +387,7 @@ export function ContactForm() {
|
||||
className="p-3 bg-red-900/20 border border-red-900/50 rounded-lg"
|
||||
>
|
||||
<p className="text-red-400 text-sm">
|
||||
{t('contactForm.errorMessage')}
|
||||
{errorMessage || t('contactForm.errorMessage')}
|
||||
</p>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
@@ -11,6 +11,7 @@ const LanguageSwitcher = () => {
|
||||
const pathname = usePathname();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [focusedIndex, setFocusedIndex] = useState<number>(-1);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const languages = [
|
||||
@@ -30,8 +31,28 @@ const LanguageSwitcher = () => {
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
setIsOpen(false);
|
||||
if (!isOpen) return;
|
||||
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
setIsOpen(false);
|
||||
setFocusedIndex(-1);
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
setFocusedIndex(prev => (prev + 1) % languages.length);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
setFocusedIndex(prev => (prev - 1 + languages.length) % languages.length);
|
||||
break;
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
if (focusedIndex >= 0) {
|
||||
handleLanguageChange(languages[focusedIndex].code);
|
||||
setFocusedIndex(-1);
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -63,7 +84,10 @@ const LanguageSwitcher = () => {
|
||||
px-4 py-2 rounded-full transition-all duration-200
|
||||
hover:bg-zinc-800/50 border border-transparent hover:border-zinc-800
|
||||
hover:scale-105 active:scale-95"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
onClick={() => {
|
||||
setIsOpen(!isOpen);
|
||||
setFocusedIndex(-1);
|
||||
}}
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="listbox"
|
||||
aria-label="Select language"
|
||||
@@ -79,18 +103,21 @@ const LanguageSwitcher = () => {
|
||||
${isOpen ? 'opacity-100 scale-100' : 'opacity-0 scale-95 pointer-events-none'}`}
|
||||
role="listbox"
|
||||
aria-label="Languages"
|
||||
aria-activedescendant={focusedIndex >= 0 ? `lang-option-${languages[focusedIndex].code}` : undefined}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<div className="py-1">
|
||||
{languages.map((lang) => (
|
||||
{languages.map((lang, index) => (
|
||||
<button
|
||||
key={lang.code}
|
||||
id={`lang-option-${lang.code}`}
|
||||
className={`w-full text-left px-4 py-2 text-sm transition-colors duration-200
|
||||
hover:bg-zinc-800/50
|
||||
${locale === lang.code
|
||||
? 'bg-zinc-800 text-white'
|
||||
: 'text-zinc-400 hover:text-white'
|
||||
}`}
|
||||
}
|
||||
${focusedIndex === index ? 'ring-2 ring-orange-500 ring-inset' : ''}`}
|
||||
onClick={() => handleLanguageChange(lang.code)}
|
||||
role="option"
|
||||
aria-selected={locale === lang.code}
|
||||
|
||||
@@ -27,6 +27,7 @@ export function NavLink({ href, icon, label, onClick, className }: NavLinkProps)
|
||||
className={`
|
||||
relative flex items-center gap-2 rounded-full py-2 px-4
|
||||
transition-all duration-200 ease-in-out
|
||||
focus:outline-none focus:ring-2 focus:ring-zinc-600 focus:ring-offset-2 focus:ring-offset-zinc-950
|
||||
${isActive ? 'text-white bg-zinc-700/60' : 'text-zinc-400 hover:text-white hover:bg-zinc-800/30'}
|
||||
${className || ''}
|
||||
`}
|
||||
|
||||
+13
-1
@@ -394,10 +394,22 @@
|
||||
"label": "Nachricht",
|
||||
"placeholder": "Ihre Nachricht an mich..."
|
||||
},
|
||||
"characterCounter": "{current}/{max} Zeichen",
|
||||
"submit": "Nachricht senden",
|
||||
"submitting": "Nachricht wird gesendet...",
|
||||
"successMessage": "Vielen Dank für Ihre Nachricht! Ich werde mich so schnell wie möglich bei Ihnen melden.",
|
||||
"errorMessage": "Entschuldigung, beim Senden Ihrer Nachricht ist ein Fehler aufgetreten."
|
||||
"errorMessage": "Entschuldigung, beim Senden Ihrer Nachricht ist ein Fehler aufgetreten.",
|
||||
"rateLimit": {
|
||||
"warning": "Sie haben noch {count} {count, plural, one {Versuch} other {Versuche}}",
|
||||
"error": "Zu viele Anfragen. Bitte versuchen Sie es in {time} erneut.",
|
||||
"blockedUntil": "Ratenlimit überschritten. Sie können es in {time} erneut versuchen."
|
||||
},
|
||||
"errors": {
|
||||
"nameRequired": "Name ist erforderlich",
|
||||
"emailRequired": "E-Mail ist erforderlich",
|
||||
"emailInvalid": "Bitte geben Sie eine gültige E-Mail-Adresse ein",
|
||||
"messageRequired": "Nachricht ist erforderlich"
|
||||
}
|
||||
}
|
||||
},
|
||||
"privacy": {
|
||||
|
||||
+13
-1
@@ -409,10 +409,22 @@
|
||||
"label": "Message",
|
||||
"placeholder": "Your message to me..."
|
||||
},
|
||||
"characterCounter": "{current}/{max} characters",
|
||||
"submit": "Send Message",
|
||||
"submitting": "Sending message...",
|
||||
"successMessage": "Thank you for your message! I will get back to you as soon as possible.",
|
||||
"errorMessage": "Sorry, there was an error sending your message."
|
||||
"errorMessage": "Sorry, there was an error sending your message.",
|
||||
"rateLimit": {
|
||||
"warning": "You have {count} {count, plural, one {attempt} other {attempts}} remaining",
|
||||
"error": "Too many requests. Please try again in {time}.",
|
||||
"blockedUntil": "Rate limit exceeded. You can try again in {time}."
|
||||
},
|
||||
"errors": {
|
||||
"nameRequired": "Name is required",
|
||||
"emailRequired": "Email is required",
|
||||
"emailInvalid": "Please enter a valid email address",
|
||||
"messageRequired": "Message is required"
|
||||
}
|
||||
}
|
||||
},
|
||||
"privacy": {
|
||||
|
||||
+13
-1
@@ -416,10 +416,22 @@
|
||||
"label": "Poruka",
|
||||
"placeholder": "Vasa poruka za mene..."
|
||||
},
|
||||
"characterCounter": "{current}/{max} karaktera",
|
||||
"submit": "Posalji poruku",
|
||||
"submitting": "Slanje poruke...",
|
||||
"successMessage": "Hvala vam na poruci! Javicu vam se sto je pre moguce.",
|
||||
"errorMessage": "Izvinite, doslo je do greske prilikom slanja vase poruke."
|
||||
"errorMessage": "Izvinite, doslo je do greske prilikom slanja vase poruke.",
|
||||
"rateLimit": {
|
||||
"warning": "Preostalo vam je {count} {count, plural, one {pokusaj} other {pokusaja}}",
|
||||
"error": "Previse zahteva. Molimo pokusajte ponovo za {time}.",
|
||||
"blockedUntil": "Dostignut limit. Mozete pokusati ponovo za {time}."
|
||||
},
|
||||
"errors": {
|
||||
"nameRequired": "Ime je obavezno",
|
||||
"emailRequired": "E-mail je obavezan",
|
||||
"emailInvalid": "Molimo unesite validnu e-mail adresu",
|
||||
"messageRequired": "Poruka je obavezna"
|
||||
}
|
||||
}
|
||||
},
|
||||
"privacy": {
|
||||
|
||||
+32
-1
@@ -1,17 +1,48 @@
|
||||
<<<<<<< HEAD
|
||||
import { chain, chainMatch, isPageRequest, csp } from '@next-safe/middleware';
|
||||
=======
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
>>>>>>> origin/master
|
||||
import createMiddleware from 'next-intl/middleware';
|
||||
import { locales, defaultLocale } from './i18n/config';
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
<<<<<<< HEAD
|
||||
const handleI18nRouting = createMiddleware({
|
||||
=======
|
||||
const CSRF_TOKEN_COOKIE_NAME = 'csrf_token';
|
||||
const CSRF_TOKEN_LENGTH = 32;
|
||||
|
||||
const intlMiddleware = createMiddleware({
|
||||
>>>>>>> origin/master
|
||||
locales,
|
||||
defaultLocale,
|
||||
localePrefix: 'always',
|
||||
});
|
||||
|
||||
<<<<<<< HEAD
|
||||
// Define CSP using @next-safe/middleware
|
||||
const securityMiddleware = csp({
|
||||
directives: {
|
||||
'default-src': ["'self'"],
|
||||
'script-src': ["'self'", "'unsafe-inline'", "'unsafe-eval'"],
|
||||
'style-src': ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'],
|
||||
'font-src': ["'self'", 'https://fonts.gstatic.com', 'data:'],
|
||||
'img-src': ["'self'", 'data:', 'blob:', 'https://mxadgucxhmstlzsbgmoz.supabase.co'],
|
||||
'connect-src': ["'self'", 'https://mxadgucxhmstlzsbgmoz.supabase.co'],
|
||||
'frame-ancestors': ["'self'"],
|
||||
'base-uri': ["'self'"],
|
||||
'form-action': ["'self'"],
|
||||
},
|
||||
});
|
||||
|
||||
// Use chain to combine i18n middleware with security middleware
|
||||
// First run i18n, then apply CSP only on page requests
|
||||
export default chain(
|
||||
handleI18nRouting,
|
||||
chainMatch(isPageRequest)(securityMiddleware)
|
||||
);
|
||||
=======
|
||||
export default function middleware(request: NextRequest) {
|
||||
// Run the i18n middleware first
|
||||
const response = intlMiddleware(request);
|
||||
@@ -39,11 +70,11 @@ export default function middleware(request: NextRequest) {
|
||||
|
||||
return response;
|
||||
}
|
||||
>>>>>>> origin/master
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
'/',
|
||||
'/(de|en|sr)/:path*',
|
||||
'/((?!api|_next|_vercel|.*\\..*).*)',
|
||||
],
|
||||
};
|
||||
|
||||
+42
-2
@@ -10,6 +10,7 @@ type CacheItem<T> = {
|
||||
value: T;
|
||||
timestamp: number;
|
||||
ttl: number;
|
||||
lastAccessed: number;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -18,14 +19,20 @@ type CacheItem<T> = {
|
||||
class Cache {
|
||||
private storage: Map<string, CacheItem<any>>;
|
||||
private readonly defaultTTL: number;
|
||||
private readonly maxSize?: number;
|
||||
|
||||
<<<<<<< HEAD
|
||||
/**
|
||||
* Create a new cache instance
|
||||
* @param defaultTTL - Default time-to-live in milliseconds (default: 5 minutes)
|
||||
*/
|
||||
constructor(defaultTTL = 5 * 60 * 1000) { // 5 minutes default TTL
|
||||
=======
|
||||
constructor(defaultTTL = 5 * 60 * 1000, maxSize?: number) { // 5 minutes default TTL
|
||||
>>>>>>> origin/master
|
||||
this.storage = new Map();
|
||||
this.defaultTTL = defaultTTL;
|
||||
this.maxSize = maxSize;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -35,19 +42,46 @@ class Cache {
|
||||
* @param ttl - Time-to-live in milliseconds (uses default if not specified)
|
||||
*/
|
||||
set<T>(key: string, value: T, ttl = this.defaultTTL): void {
|
||||
const now = Date.now();
|
||||
|
||||
// If maxSize is set and cache is full, evict least recently used item
|
||||
if (this.maxSize && this.storage.size >= this.maxSize && !this.storage.has(key)) {
|
||||
this.evictLRU();
|
||||
}
|
||||
|
||||
this.storage.set(key, {
|
||||
value,
|
||||
timestamp: Date.now(),
|
||||
ttl
|
||||
timestamp: now,
|
||||
ttl,
|
||||
lastAccessed: now
|
||||
});
|
||||
}
|
||||
|
||||
<<<<<<< HEAD
|
||||
/**
|
||||
* Retrieve a value from cache
|
||||
* Returns null if key doesn't exist or has expired
|
||||
* @param key - Cache key
|
||||
* @returns Cached value or null
|
||||
*/
|
||||
=======
|
||||
private evictLRU(): void {
|
||||
let lruKey: string | null = null;
|
||||
let lruTime = Infinity;
|
||||
|
||||
for (const [key, item] of this.storage.entries()) {
|
||||
if (item.lastAccessed < lruTime) {
|
||||
lruTime = item.lastAccessed;
|
||||
lruKey = key;
|
||||
}
|
||||
}
|
||||
|
||||
if (lruKey) {
|
||||
this.storage.delete(lruKey);
|
||||
}
|
||||
}
|
||||
|
||||
>>>>>>> origin/master
|
||||
get<T>(key: string): T | null {
|
||||
const item = this.storage.get(key);
|
||||
|
||||
@@ -58,6 +92,12 @@ class Cache {
|
||||
return null;
|
||||
}
|
||||
|
||||
<<<<<<< HEAD
|
||||
=======
|
||||
// Update last accessed time for LRU tracking
|
||||
item.lastAccessed = Date.now();
|
||||
|
||||
>>>>>>> origin/master
|
||||
return item.value;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
/**
|
||||
* Extract client IP address from Next.js request headers
|
||||
*
|
||||
* This function handles various proxy and forwarding scenarios:
|
||||
* - Vercel's x-forwarded-for header (may contain multiple IPs)
|
||||
* - x-real-ip header (direct client IP)
|
||||
* - Fallback for development environments
|
||||
*
|
||||
* @param request - Next.js request object
|
||||
* @returns Client IP address as a string, or 'unknown-ip' as fallback
|
||||
*/
|
||||
export function getClientIp(request: NextRequest): string {
|
||||
// Check Vercel's forwarded IP header first
|
||||
// In production, this is the most reliable source
|
||||
const forwardedFor = request.headers.get('x-forwarded-for');
|
||||
if (forwardedFor) {
|
||||
// x-forwarded-for may contain multiple IPs in format: "client, proxy1, proxy2"
|
||||
// The first IP is the original client IP
|
||||
return forwardedFor.split(',')[0].trim();
|
||||
}
|
||||
|
||||
// Check for real IP header (used by some proxies and load balancers)
|
||||
const realIp = request.headers.get('x-real-ip');
|
||||
if (realIp) {
|
||||
return realIp;
|
||||
}
|
||||
|
||||
// Fallback to a default identifier for development environments
|
||||
// or when headers are not available
|
||||
return 'unknown-ip';
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
|
||||
interface RateLimitRecord {
|
||||
identifier: string;
|
||||
count: number;
|
||||
window_start: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
const WINDOW_SIZE_MS = 3600000; // 1 hour
|
||||
const MAX_REQUESTS = 5; // Maximum requests per hour
|
||||
|
||||
class SupabaseRateLimiter {
|
||||
/**
|
||||
* Check if an identifier (IP address) is rate limited
|
||||
* @param identifier - The IP address or unique identifier to check
|
||||
* @returns true if rate limited, false otherwise
|
||||
*/
|
||||
async isRateLimited(identifier: string): Promise<boolean> {
|
||||
try {
|
||||
const supabase = await createClient();
|
||||
const now = Date.now();
|
||||
const windowStart = new Date(now - WINDOW_SIZE_MS).toISOString();
|
||||
// Get the current rate limit record for this identifier
|
||||
const { data: existingRecord, error: fetchError } = await supabase
|
||||
.from('rate_limits')
|
||||
.select('*')
|
||||
.eq('identifier', identifier)
|
||||
.gte('window_start', windowStart)
|
||||
.order('window_start', { ascending: false })
|
||||
.limit(1)
|
||||
.single();
|
||||
|
||||
if (fetchError && fetchError.code !== 'PGRST116') {
|
||||
// PGRST116 is "no rows returned" which is fine
|
||||
console.error('Error fetching rate limit:', fetchError);
|
||||
return false; // Fail open - don't block on errors
|
||||
}
|
||||
|
||||
// No existing record or window expired - create new record
|
||||
if (!existingRecord || new Date(existingRecord.window_start).getTime() < now - WINDOW_SIZE_MS) {
|
||||
const { error: insertError } = await supabase
|
||||
.from('rate_limits')
|
||||
.insert({
|
||||
identifier,
|
||||
count: 1,
|
||||
window_start: new Date(now).toISOString()
|
||||
});
|
||||
|
||||
if (insertError) {
|
||||
console.error('Error inserting rate limit:', insertError);
|
||||
return false; // Fail open
|
||||
}
|
||||
|
||||
return false; // First request in new window
|
||||
}
|
||||
|
||||
// Check if limit exceeded
|
||||
if (existingRecord.count >= MAX_REQUESTS) {
|
||||
return true; // Rate limited
|
||||
}
|
||||
|
||||
// Increment counter
|
||||
const { error: updateError } = await supabase
|
||||
.from('rate_limits')
|
||||
.update({
|
||||
count: existingRecord.count + 1,
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('identifier', identifier)
|
||||
.eq('window_start', existingRecord.window_start);
|
||||
|
||||
if (updateError) {
|
||||
console.error('Error updating rate limit:', updateError);
|
||||
return false; // Fail open
|
||||
}
|
||||
|
||||
return false; // Not rate limited yet
|
||||
} catch (error) {
|
||||
console.error('[RateLimiter] Unexpected error in isRateLimited - FAILING OPEN:', error);
|
||||
return false; // Fail open on ALL errors
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of remaining attempts for an identifier
|
||||
* @param identifier - The IP address or unique identifier to check
|
||||
* @returns Number of remaining attempts (0 if rate limited)
|
||||
*/
|
||||
async getRemainingAttempts(identifier: string): Promise<number> {
|
||||
try {
|
||||
const supabase = await createClient();
|
||||
const now = Date.now();
|
||||
const windowStart = new Date(now - WINDOW_SIZE_MS).toISOString();
|
||||
const { data: record, error } = await supabase
|
||||
.from('rate_limits')
|
||||
.select('count')
|
||||
.eq('identifier', identifier)
|
||||
.gte('window_start', windowStart)
|
||||
.order('window_start', { ascending: false })
|
||||
.limit(1)
|
||||
.single();
|
||||
|
||||
if (error && error.code !== 'PGRST116') {
|
||||
console.error('Error fetching remaining attempts:', error);
|
||||
return MAX_REQUESTS; // Fail open
|
||||
}
|
||||
|
||||
if (!record) {
|
||||
return MAX_REQUESTS; // No record yet
|
||||
}
|
||||
|
||||
return Math.max(0, MAX_REQUESTS - record.count);
|
||||
} catch (error) {
|
||||
console.error('[RateLimiter] Unexpected error in getRemainingAttempts - FAILING OPEN:', error);
|
||||
return MAX_REQUESTS; // Fail open on ALL errors
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the time in milliseconds until the rate limit resets
|
||||
* @param identifier - The IP address or unique identifier to check
|
||||
* @returns Milliseconds until reset (0 if no active limit)
|
||||
*/
|
||||
async getTimeToReset(identifier: string): Promise<number> {
|
||||
try {
|
||||
const supabase = await createClient();
|
||||
const now = Date.now();
|
||||
const windowStart = new Date(now - WINDOW_SIZE_MS).toISOString();
|
||||
const { data: record, error } = await supabase
|
||||
.from('rate_limits')
|
||||
.select('window_start')
|
||||
.eq('identifier', identifier)
|
||||
.gte('window_start', windowStart)
|
||||
.order('window_start', { ascending: false })
|
||||
.limit(1)
|
||||
.single();
|
||||
|
||||
if (error && error.code !== 'PGRST116') {
|
||||
console.error('Error fetching time to reset:', error);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!record) {
|
||||
return 0; // No active window
|
||||
}
|
||||
|
||||
const windowStartTime = new Date(record.window_start).getTime();
|
||||
const resetTime = windowStartTime + WINDOW_SIZE_MS;
|
||||
return Math.max(0, resetTime - now);
|
||||
} catch (error) {
|
||||
console.error('[RateLimiter] Unexpected error in getTimeToReset - FAILING OPEN:', error);
|
||||
return 0; // Fail open on ALL errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const supabaseRateLimiter = new SupabaseRateLimiter();
|
||||
export { WINDOW_SIZE_MS, MAX_REQUESTS };
|
||||
Reference in New Issue
Block a user