mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
feat: Implement secure email/password authentication with user registration, API rate limiting, and dynamic homepage navigation.
This commit is contained in:
+11
-1
@@ -1,7 +1,17 @@
|
|||||||
export default function AuthLayout({
|
import { redirect } from 'next/navigation';
|
||||||
|
import { auth } from '@/lib/auth';
|
||||||
|
|
||||||
|
export default async function AuthLayout({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
// If already logged in, redirect to dashboard
|
||||||
|
if (session?.user) {
|
||||||
|
redirect('/dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|||||||
+121
-115
@@ -1,51 +1,146 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useEffect, Suspense } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { Video, Loader2, Github, Mail } from 'lucide-react';
|
import { Video, Loader2 } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { signIn } from 'next-auth/react';
|
||||||
|
|
||||||
export default function LoginPage() {
|
function LoginForm() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
|
const [showSuccess, setShowSuccess] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchParams.get('registered') === 'true') {
|
||||||
|
setShowSuccess(true);
|
||||||
|
}
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
const handleEmailLogin = async (e: React.FormEvent) => {
|
const handleEmailLogin = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// TODO: Implement actual sign in
|
const result = await signIn('credentials', {
|
||||||
// await signIn('credentials', { email, password, redirect: false });
|
email,
|
||||||
await new Promise(resolve => setTimeout(resolve, 500));
|
password,
|
||||||
|
redirect: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result?.error) {
|
||||||
|
setError('Invalid email or password');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
router.push('/dashboard');
|
router.push('/dashboard');
|
||||||
} catch (error) {
|
router.refresh();
|
||||||
console.error('Login failed:', error);
|
} catch {
|
||||||
|
setError('Something went wrong. Please try again.');
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOAuthLogin = async (provider: string) => {
|
return (
|
||||||
setIsLoading(true);
|
<Card>
|
||||||
try {
|
<CardHeader className="text-center">
|
||||||
// TODO: Implement OAuth
|
<CardTitle>Welcome back</CardTitle>
|
||||||
// await signIn(provider, { callbackUrl: '/dashboard' });
|
<CardDescription>
|
||||||
await new Promise(resolve => setTimeout(resolve, 500));
|
Sign in to your account to continue
|
||||||
router.push('/dashboard');
|
</CardDescription>
|
||||||
} catch (error) {
|
</CardHeader>
|
||||||
console.error('OAuth login failed:', error);
|
<CardContent>
|
||||||
} finally {
|
{showSuccess && (
|
||||||
setIsLoading(false);
|
<div className="p-3 rounded-md bg-green-500/10 text-green-600 text-sm mb-4">
|
||||||
}
|
Account created successfully! Please sign in.
|
||||||
};
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Email Form */}
|
||||||
|
<form onSubmit={handleEmailLogin} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">Email</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="[email protected]"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => {
|
||||||
|
setEmail(e.target.value);
|
||||||
|
setError('');
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">Password</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => {
|
||||||
|
setPassword(e.target.value);
|
||||||
|
setError('');
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 rounded-md bg-destructive/10 text-destructive text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||||
|
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||||
|
Sign in
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="text-center text-sm text-muted-foreground mt-6">
|
||||||
|
Don't have an account?{' '}
|
||||||
|
<Link href="/register" className="text-primary hover:underline">
|
||||||
|
Sign up
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoginFormSkeleton() {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<CardTitle>Welcome back</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Sign in to your account to continue
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="h-10 bg-muted animate-pulse rounded-md" />
|
||||||
|
<div className="h-10 bg-muted animate-pulse rounded-md" />
|
||||||
|
<div className="h-10 bg-primary/20 animate-pulse rounded-md" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center p-4 bg-background">
|
<div className="min-h-screen flex items-center justify-center p-4 bg-background">
|
||||||
<div className="w-full max-w-md">
|
<div className="w-full max-w-md">
|
||||||
@@ -55,98 +150,9 @@ export default function LoginPage() {
|
|||||||
<span className="font-bold text-2xl">OpenFrame</span>
|
<span className="font-bold text-2xl">OpenFrame</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Card>
|
<Suspense fallback={<LoginFormSkeleton />}>
|
||||||
<CardHeader className="text-center">
|
<LoginForm />
|
||||||
<CardTitle>Welcome back</CardTitle>
|
</Suspense>
|
||||||
<CardDescription>
|
|
||||||
Sign in to your account to continue
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{/* OAuth Buttons */}
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => handleOAuthLogin('google')}
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
<svg className="h-4 w-4 mr-2" viewBox="0 0 24 24">
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Continue with Google
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => handleOAuthLogin('github')}
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
<Github className="h-4 w-4 mr-2" />
|
|
||||||
Continue with GitHub
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative my-6">
|
|
||||||
<Separator />
|
|
||||||
<span className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-card px-2 text-xs text-muted-foreground">
|
|
||||||
or continue with email
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Email Form */}
|
|
||||||
<form onSubmit={handleEmailLogin} className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="email">Email</Label>
|
|
||||||
<Input
|
|
||||||
id="email"
|
|
||||||
type="email"
|
|
||||||
placeholder="[email protected]"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
required
|
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="password">Password</Label>
|
|
||||||
<Input
|
|
||||||
id="password"
|
|
||||||
type="password"
|
|
||||||
placeholder="••••••••"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
required
|
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
|
||||||
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
|
||||||
Sign in
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<p className="text-center text-sm text-muted-foreground mt-6">
|
|
||||||
Don't have an account?{' '}
|
|
||||||
<Link href="/register" className="text-primary hover:underline">
|
|
||||||
Sign up
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<p className="text-center text-xs text-muted-foreground mt-4">
|
<p className="text-center text-xs text-muted-foreground mt-4">
|
||||||
By continuing, you agree to our Terms of Service and Privacy Policy
|
By continuing, you agree to our Terms of Service and Privacy Policy
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Video, Loader2, KeyRound, UserPlus } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
|
||||||
|
export default function RegisterPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
inviteCode: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
[e.target.name]: e.target.value,
|
||||||
|
}));
|
||||||
|
setError('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRegister = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
// Client-side validation
|
||||||
|
if (formData.password !== formData.confirmPassword) {
|
||||||
|
setError('Passwords do not match');
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formData.password.length < 8) {
|
||||||
|
setError('Password must be at least 8 characters');
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/register', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: formData.name,
|
||||||
|
email: formData.email,
|
||||||
|
password: formData.password,
|
||||||
|
inviteCode: formData.inviteCode,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
setError(data.error || 'Registration failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect to login on success
|
||||||
|
router.push('/login?registered=true');
|
||||||
|
} catch {
|
||||||
|
setError('Something went wrong. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center p-4 bg-background">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
{/* Logo */}
|
||||||
|
<Link href="/" className="flex items-center justify-center gap-2 mb-8">
|
||||||
|
<Video className="h-8 w-8 text-primary" />
|
||||||
|
<span className="font-bold text-2xl">OpenFrame</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<CardTitle className="flex items-center justify-center gap-2">
|
||||||
|
<UserPlus className="h-5 w-5" />
|
||||||
|
Create Account
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Join OpenFrame to collaborate on video projects
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleRegister} className="space-y-4">
|
||||||
|
{/* Invite Code - First and prominent */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="inviteCode" className="flex items-center gap-2">
|
||||||
|
<KeyRound className="h-4 w-4 text-amber-500" />
|
||||||
|
Invite Code
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="inviteCode"
|
||||||
|
name="inviteCode"
|
||||||
|
type="text"
|
||||||
|
placeholder="Enter your invite code"
|
||||||
|
value={formData.inviteCode}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
className="border-amber-500/30 focus:border-amber-500"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
An invite code is required to create an account
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-px bg-border my-4" />
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="name">Full Name</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
placeholder="John Doe"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
minLength={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">Email</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="[email protected]"
|
||||||
|
value={formData.email}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">Password</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
value={formData.password}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
minLength={8}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="confirmPassword">Confirm Password</Label>
|
||||||
|
<Input
|
||||||
|
id="confirmPassword"
|
||||||
|
name="confirmPassword"
|
||||||
|
type="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
value={formData.confirmPassword}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 rounded-md bg-destructive/10 text-destructive text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||||
|
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||||
|
Create Account
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="text-center text-sm text-muted-foreground mt-6">
|
||||||
|
Already have an account?{' '}
|
||||||
|
<Link href="/login" className="text-primary hover:underline">
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<p className="text-center text-xs text-muted-foreground mt-4">
|
||||||
|
By continuing, you agree to our Terms of Service and Privacy Policy
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import { checkRateLimit, getClientIp, rateLimitHeaders, RATE_LIMIT_CONFIGS } from '@/lib/rate-limit';
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// Rate limiting by IP
|
||||||
|
const clientIp = getClientIp(request);
|
||||||
|
const rateLimitKey = `register:${clientIp}`;
|
||||||
|
const rateLimit = await checkRateLimit(rateLimitKey, 'register');
|
||||||
|
|
||||||
|
if (!rateLimit.allowed) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Too many registration attempts. Please try again later.' },
|
||||||
|
{
|
||||||
|
status: 429,
|
||||||
|
headers: rateLimitHeaders(rateLimit, RATE_LIMIT_CONFIGS.register.maxRequests),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { name, email, password, inviteCode } = body;
|
||||||
|
|
||||||
|
// Validate invite code using constant-time comparison to prevent timing attacks
|
||||||
|
const validInviteCode = process.env.INVITE_CODE;
|
||||||
|
if (!validInviteCode || !inviteCode) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid invite code' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Constant-time comparison
|
||||||
|
const { timingSafeEqual } = await import('crypto');
|
||||||
|
const validBuffer = Buffer.from(validInviteCode);
|
||||||
|
const providedBuffer = Buffer.from(String(inviteCode));
|
||||||
|
|
||||||
|
// Ensure same length for comparison (prevents length-based timing leak)
|
||||||
|
const isValidLength = validBuffer.length === providedBuffer.length;
|
||||||
|
const compareBuffer = isValidLength ? providedBuffer : validBuffer;
|
||||||
|
const isValidCode = isValidLength && timingSafeEqual(validBuffer, compareBuffer);
|
||||||
|
|
||||||
|
if (!isValidCode) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid invite code' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!name || typeof name !== 'string' || name.trim().length < 2) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Name must be at least 2 characters' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!email || typeof email !== 'string') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Email is required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic email validation
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
if (!emailRegex.test(email)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid email format' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!password || typeof password !== 'string' || password.length < 8) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Password must be at least 8 characters' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if email already exists
|
||||||
|
const existingUser = await db.user.findUnique({
|
||||||
|
where: { email: email.toLowerCase() },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'An account with this email already exists' },
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash password
|
||||||
|
const hashedPassword = await bcrypt.hash(password, 12);
|
||||||
|
|
||||||
|
// Create user
|
||||||
|
const user = await db.user.create({
|
||||||
|
data: {
|
||||||
|
name: name.trim(),
|
||||||
|
email: email.toLowerCase(),
|
||||||
|
password: hashedPassword,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = NextResponse.json(
|
||||||
|
{ message: 'Account created successfully', user },
|
||||||
|
{ status: 201 }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add rate limit headers to successful response
|
||||||
|
const headers = rateLimitHeaders(rateLimit, RATE_LIMIT_CONFIGS.register.maxRequests);
|
||||||
|
Object.entries(headers).forEach(([key, value]) => {
|
||||||
|
response.headers.set(key, value);
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Registration error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to create account' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+41
-15
@@ -1,8 +1,12 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Video, MessageSquare, Mic, Share2, ArrowRight, Play } from 'lucide-react';
|
import { Video, MessageSquare, Mic, Share2, ArrowRight, Play } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { auth } from '@/lib/auth';
|
||||||
|
|
||||||
|
export default async function HomePage() {
|
||||||
|
const session = await auth();
|
||||||
|
const isLoggedIn = !!session?.user;
|
||||||
|
|
||||||
export default function HomePage() {
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -13,12 +17,25 @@ export default function HomePage() {
|
|||||||
<span className="font-bold text-xl">OpenFrame</span>
|
<span className="font-bold text-xl">OpenFrame</span>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Button asChild variant="ghost">
|
{isLoggedIn ? (
|
||||||
<Link href="/login">Sign in</Link>
|
<>
|
||||||
</Button>
|
<Button asChild variant="ghost">
|
||||||
<Button asChild>
|
<Link href="/dashboard">Dashboard</Link>
|
||||||
<Link href="/dashboard">Get Started</Link>
|
</Button>
|
||||||
</Button>
|
<Button asChild>
|
||||||
|
<Link href="/projects/new">New Project</Link>
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button asChild variant="ghost">
|
||||||
|
<Link href="/login">Sign in</Link>
|
||||||
|
</Button>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/register">Get Started</Link>
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -35,12 +52,21 @@ export default function HomePage() {
|
|||||||
Share with your team and clients, iterate faster.
|
Share with your team and clients, iterate faster.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-col sm:flex-row gap-4">
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
<Button asChild size="lg">
|
{isLoggedIn ? (
|
||||||
<Link href="/dashboard">
|
<Button asChild size="lg">
|
||||||
Start for free
|
<Link href="/dashboard">
|
||||||
<ArrowRight className="h-4 w-4 ml-2" />
|
Go to Dashboard
|
||||||
</Link>
|
<ArrowRight className="h-4 w-4 ml-2" />
|
||||||
</Button>
|
</Link>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button asChild size="lg">
|
||||||
|
<Link href="/register">
|
||||||
|
Start for free
|
||||||
|
<ArrowRight className="h-4 w-4 ml-2" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button asChild variant="outline" size="lg">
|
<Button asChild variant="outline" size="lg">
|
||||||
<Link href="#features">
|
<Link href="#features">
|
||||||
<Play className="h-4 w-4 mr-2" />
|
<Play className="h-4 w-4 mr-2" />
|
||||||
@@ -88,8 +114,8 @@ export default function HomePage() {
|
|||||||
Join teams who have already switched to OpenFrame for faster, clearer video feedback.
|
Join teams who have already switched to OpenFrame for faster, clearer video feedback.
|
||||||
</p>
|
</p>
|
||||||
<Button asChild size="lg">
|
<Button asChild size="lg">
|
||||||
<Link href="/dashboard">
|
<Link href={isLoggedIn ? '/dashboard' : '/register'}>
|
||||||
Get started for free
|
{isLoggedIn ? 'Go to Dashboard' : 'Get started for free'}
|
||||||
<ArrowRight className="h-4 w-4 ml-2" />
|
<ArrowRight className="h-4 w-4 ml-2" />
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"@base-ui/react": "^1.1.0",
|
"@base-ui/react": "^1.1.0",
|
||||||
"@prisma/adapter-pg": "^7.3.0",
|
"@prisma/adapter-pg": "^7.3.0",
|
||||||
"@prisma/client": "^7.3.0",
|
"@prisma/client": "^7.3.0",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
@@ -29,6 +30,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/bcryptjs": "^3.0.0",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/pg": "^8.16.0",
|
"@types/pg": "^8.16.0",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
@@ -489,6 +491,8 @@
|
|||||||
|
|
||||||
"@tybys/wasm-util": ["@tybys/[email protected]", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
"@tybys/wasm-util": ["@tybys/[email protected]", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||||
|
|
||||||
|
"@types/bcryptjs": ["@types/[email protected]", "", { "dependencies": { "bcryptjs": "*" } }, "sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg=="],
|
||||||
|
|
||||||
"@types/estree": ["@types/[email protected]", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
"@types/estree": ["@types/[email protected]", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||||
|
|
||||||
"@types/json-schema": ["@types/[email protected]", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
"@types/json-schema": ["@types/[email protected]", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
||||||
@@ -623,6 +627,8 @@
|
|||||||
|
|
||||||
"baseline-browser-mapping": ["[email protected]", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="],
|
"baseline-browser-mapping": ["[email protected]", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="],
|
||||||
|
|
||||||
|
"bcryptjs": ["[email protected]", "", { "bin": { "bcrypt": "bin/bcrypt" } }, "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g=="],
|
||||||
|
|
||||||
"body-parser": ["[email protected]", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
|
"body-parser": ["[email protected]", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
|
||||||
|
|
||||||
"brace-expansion": ["[email protected]", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
|
"brace-expansion": ["[email protected]", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
|
||||||
|
|||||||
+30
-25
@@ -1,26 +1,16 @@
|
|||||||
import NextAuth from 'next-auth';
|
import NextAuth from 'next-auth';
|
||||||
import { PrismaAdapter } from '@auth/prisma-adapter';
|
|
||||||
import Google from 'next-auth/providers/google';
|
|
||||||
import GitHub from 'next-auth/providers/github';
|
|
||||||
import Credentials from 'next-auth/providers/credentials';
|
import Credentials from 'next-auth/providers/credentials';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
|
// Dummy hash for timing-safe comparison when user doesn't exist
|
||||||
|
// This prevents user enumeration via timing attacks
|
||||||
|
const DUMMY_HASH = '$2a$12$000000000000000000000uGG3k3xK2CVTxXrT7VW2sGd1XrY6Ky';
|
||||||
|
|
||||||
export const { handlers, signIn, signOut, auth } = NextAuth({
|
export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||||
adapter: PrismaAdapter(db),
|
// Note: We don't use PrismaAdapter with Credentials + JWT strategy
|
||||||
|
// The adapter is for OAuth providers that need to store accounts/sessions in DB
|
||||||
providers: [
|
providers: [
|
||||||
// Google OAuth - uncomment and add credentials when ready
|
|
||||||
// Google({
|
|
||||||
// clientId: process.env.GOOGLE_CLIENT_ID,
|
|
||||||
// clientSecret: process.env.GOOGLE_CLIENT_SECRET,
|
|
||||||
// }),
|
|
||||||
|
|
||||||
// GitHub OAuth - uncomment and add credentials when ready
|
|
||||||
// GitHub({
|
|
||||||
// clientId: process.env.GITHUB_ID,
|
|
||||||
// clientSecret: process.env.GITHUB_SECRET,
|
|
||||||
// }),
|
|
||||||
|
|
||||||
// Email/Password - for development, add proper provider in production
|
|
||||||
Credentials({
|
Credentials({
|
||||||
name: 'credentials',
|
name: 'credentials',
|
||||||
credentials: {
|
credentials: {
|
||||||
@@ -28,28 +18,43 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
|||||||
password: { label: 'Password', type: 'password' },
|
password: { label: 'Password', type: 'password' },
|
||||||
},
|
},
|
||||||
async authorize(credentials) {
|
async authorize(credentials) {
|
||||||
// TODO: Implement proper credential validation
|
if (!credentials?.email || !credentials?.password) {
|
||||||
// This is a placeholder for development
|
|
||||||
if (!credentials?.email) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// In production, verify password hash here
|
const email = credentials.email as string;
|
||||||
|
const password = credentials.password as string;
|
||||||
|
|
||||||
|
// Find user by email
|
||||||
const user = await db.user.findUnique({
|
const user = await db.user.findUnique({
|
||||||
where: { email: credentials.email as string },
|
where: { email: email.toLowerCase() },
|
||||||
});
|
});
|
||||||
|
|
||||||
return user;
|
// Always perform bcrypt comparison to prevent timing attacks
|
||||||
|
// If user doesn't exist, compare against dummy hash
|
||||||
|
const hashToCompare = user?.password || DUMMY_HASH;
|
||||||
|
const isValidPassword = await bcrypt.compare(password, hashToCompare);
|
||||||
|
|
||||||
|
// Only return user if they exist AND password is valid
|
||||||
|
if (!user || !user.password || !isValidPassword) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
image: user.image,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
session: {
|
session: {
|
||||||
strategy: 'jwt',
|
strategy: 'jwt',
|
||||||
|
maxAge: 30 * 24 * 60 * 60, // 30 days
|
||||||
},
|
},
|
||||||
pages: {
|
pages: {
|
||||||
signIn: '/login',
|
signIn: '/login',
|
||||||
// signUp: '/register',
|
|
||||||
// error: '/auth/error',
|
|
||||||
},
|
},
|
||||||
callbacks: {
|
callbacks: {
|
||||||
async session({ session, token }) {
|
async session({ session, token }) {
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
|
interface RateLimitConfig {
|
||||||
|
windowMs: number; // Time window in milliseconds
|
||||||
|
maxRequests: number; // Max requests per window
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RateLimitResult {
|
||||||
|
allowed: boolean;
|
||||||
|
remaining: number;
|
||||||
|
resetAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default configs for different actions
|
||||||
|
export const RATE_LIMIT_CONFIGS: Record<string, RateLimitConfig> = {
|
||||||
|
register: { windowMs: 60 * 60 * 1000, maxRequests: 5 }, // 5 per hour
|
||||||
|
login: { windowMs: 15 * 60 * 1000, maxRequests: 10 }, // 10 per 15 min
|
||||||
|
api: { windowMs: 60 * 1000, maxRequests: 100 }, // 100 per minute
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check and update rate limit for a given key and action
|
||||||
|
* Uses PostgreSQL UNLOGGED table for performance
|
||||||
|
*/
|
||||||
|
export async function checkRateLimit(
|
||||||
|
key: string,
|
||||||
|
action: string,
|
||||||
|
config?: RateLimitConfig
|
||||||
|
): Promise<RateLimitResult> {
|
||||||
|
const { windowMs, maxRequests } = config || RATE_LIMIT_CONFIGS[action] || RATE_LIMIT_CONFIGS.api;
|
||||||
|
const windowSeconds = Math.floor(windowMs / 1000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Atomic upsert with window check
|
||||||
|
// If window expired, reset count; otherwise increment
|
||||||
|
const result = await db.$queryRaw<Array<{
|
||||||
|
count: number;
|
||||||
|
window_start: Date;
|
||||||
|
is_new_window: boolean;
|
||||||
|
}>>`
|
||||||
|
INSERT INTO rate_limits (key, action, count, window_start)
|
||||||
|
VALUES (${key}, ${action}, 1, NOW())
|
||||||
|
ON CONFLICT (key, action) DO UPDATE SET
|
||||||
|
count = CASE
|
||||||
|
WHEN rate_limits.window_start < NOW() - (${windowSeconds} || ' seconds')::INTERVAL
|
||||||
|
THEN 1
|
||||||
|
ELSE rate_limits.count + 1
|
||||||
|
END,
|
||||||
|
window_start = CASE
|
||||||
|
WHEN rate_limits.window_start < NOW() - (${windowSeconds} || ' seconds')::INTERVAL
|
||||||
|
THEN NOW()
|
||||||
|
ELSE rate_limits.window_start
|
||||||
|
END
|
||||||
|
RETURNING count, window_start,
|
||||||
|
(window_start = NOW()) as is_new_window
|
||||||
|
`;
|
||||||
|
|
||||||
|
const record = result[0];
|
||||||
|
const resetAt = new Date(record.window_start.getTime() + windowMs);
|
||||||
|
const remaining = Math.max(0, maxRequests - record.count);
|
||||||
|
const allowed = record.count <= maxRequests;
|
||||||
|
|
||||||
|
return { allowed, remaining, resetAt };
|
||||||
|
} catch (error) {
|
||||||
|
// If table doesn't exist, allow the request but log warning
|
||||||
|
console.error('Rate limit check failed (table may not exist):', error);
|
||||||
|
return {
|
||||||
|
allowed: true,
|
||||||
|
remaining: maxRequests,
|
||||||
|
resetAt: new Date(Date.now() + windowMs),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get client IP from request headers
|
||||||
|
* Handles common proxy headers
|
||||||
|
*/
|
||||||
|
export function getClientIp(request: Request): string {
|
||||||
|
const forwardedFor = request.headers.get('x-forwarded-for');
|
||||||
|
if (forwardedFor) {
|
||||||
|
return forwardedFor.split(',')[0].trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const realIp = request.headers.get('x-real-ip');
|
||||||
|
if (realIp) {
|
||||||
|
return realIp;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for local development
|
||||||
|
return '127.0.0.1';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create rate limit headers for response
|
||||||
|
*/
|
||||||
|
export function rateLimitHeaders(result: RateLimitResult, maxRequests: number): HeadersInit {
|
||||||
|
return {
|
||||||
|
'X-RateLimit-Limit': maxRequests.toString(),
|
||||||
|
'X-RateLimit-Remaining': result.remaining.toString(),
|
||||||
|
'X-RateLimit-Reset': Math.floor(result.resetAt.getTime() / 1000).toString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup old rate limit entries (call periodically)
|
||||||
|
*/
|
||||||
|
export async function cleanupRateLimits(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await db.$executeRaw`SELECT cleanup_rate_limits()`;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Rate limit cleanup failed:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
"@base-ui/react": "^1.1.0",
|
"@base-ui/react": "^1.1.0",
|
||||||
"@prisma/adapter-pg": "^7.3.0",
|
"@prisma/adapter-pg": "^7.3.0",
|
||||||
"@prisma/client": "^7.3.0",
|
"@prisma/client": "^7.3.0",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
@@ -33,6 +34,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/bcryptjs": "^3.0.0",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/pg": "^8.16.0",
|
"@types/pg": "^8.16.0",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
-- Rate Limiting Table (UNLOGGED for performance)
|
||||||
|
-- Run this migration manually: psql $DATABASE_URL -f prisma/migrations/rate_limit.sql
|
||||||
|
|
||||||
|
-- Drop if exists (for re-running)
|
||||||
|
DROP TABLE IF EXISTS rate_limits;
|
||||||
|
|
||||||
|
-- Create UNLOGGED table for rate limiting
|
||||||
|
-- UNLOGGED = no WAL writes = faster, but data lost on crash (acceptable for rate limits)
|
||||||
|
CREATE UNLOGGED TABLE rate_limits (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
key VARCHAR(255) NOT NULL, -- e.g., "register:192.168.1.1" or "login:[email protected]"
|
||||||
|
action VARCHAR(50) NOT NULL, -- e.g., "register", "login", "api"
|
||||||
|
count INTEGER NOT NULL DEFAULT 1,
|
||||||
|
window_start TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
|
|
||||||
|
-- Unique constraint for upsert operations
|
||||||
|
UNIQUE(key, action)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Index for fast lookups
|
||||||
|
CREATE INDEX idx_rate_limits_key_action ON rate_limits(key, action);
|
||||||
|
|
||||||
|
-- Index for cleanup operations
|
||||||
|
CREATE INDEX idx_rate_limits_window_start ON rate_limits(window_start);
|
||||||
|
|
||||||
|
-- Auto-cleanup function: removes expired entries
|
||||||
|
CREATE OR REPLACE FUNCTION cleanup_rate_limits() RETURNS void AS $$
|
||||||
|
BEGIN
|
||||||
|
DELETE FROM rate_limits WHERE window_start < NOW() - INTERVAL '1 hour';
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
@@ -18,6 +18,7 @@ model User {
|
|||||||
email String? @unique
|
email String? @unique
|
||||||
emailVerified DateTime?
|
emailVerified DateTime?
|
||||||
image String?
|
image String?
|
||||||
|
password String? // Hashed password for email/password auth
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -248,7 +249,7 @@ model ShareLink {
|
|||||||
expiresAt DateTime? // Link expiration
|
expiresAt DateTime? // Link expiration
|
||||||
maxUses Int? // Maximum number of uses
|
maxUses Int? // Maximum number of uses
|
||||||
useCount Int @default(0)
|
useCount Int @default(0)
|
||||||
password String? // Optional password protection
|
passwordHash String? // Bcrypt hash of optional password protection
|
||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
allowGuests Boolean @default(true) // Allow comments without account
|
allowGuests Boolean @default(true) // Allow comments without account
|
||||||
|
|||||||
Reference in New Issue
Block a user