Skip to content

Commit cd6fcd5

Browse files
committed
feat: Phase 2 — Gemini migration, OAuth expansion, validation, rate limiting, security headers
1 parent 8826c03 commit cd6fcd5

16 files changed

Lines changed: 449 additions & 119 deletions

File tree

app/(auth)/signup/page.tsx

Lines changed: 57 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,47 @@
11
"use client";
22

3-
import { signIn } from "next-auth/react";
4-
import { useState } from "react";
3+
import { signIn, getProviders } from "next-auth/react";
4+
import { useState, useEffect } from "react";
55
import Link from "next/link";
66
import { useLocale } from "@/components/LocaleProvider";
77

8+
type Providers = Record<string, { id: string; name: string }>;
9+
10+
const providerIcons: Record<string, JSX.Element> = {
11+
google: (
12+
<svg className="w-5 h-5" viewBox="0 0 24 24">
13+
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 01-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" />
14+
<path fill="#34A853" 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" />
15+
<path fill="#FBBC05" 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" />
16+
<path fill="#EA4335" 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" />
17+
</svg>
18+
),
19+
twitter: (
20+
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
21+
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
22+
</svg>
23+
),
24+
linkedin: (
25+
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
26+
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.064 2.064 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z" />
27+
</svg>
28+
),
29+
};
30+
831
export default function SignupPage() {
932
const { t } = useLocale();
1033
const [email, setEmail] = useState("");
1134
const [name, setName] = useState("");
1235
const [password, setPassword] = useState("");
1336
const [loading, setLoading] = useState(false);
1437
const [error, setError] = useState("");
38+
const [providers, setProviders] = useState<Providers>({});
39+
40+
useEffect(() => {
41+
getProviders().then((p) => {
42+
if (p) setProviders(p as Providers);
43+
});
44+
}, []);
1545

1646
async function handleSignup(e: React.FormEvent) {
1747
e.preventDefault();
@@ -46,6 +76,10 @@ export default function SignupPage() {
4676
}
4777
}
4878

79+
const oauthProviders = Object.values(providers).filter(
80+
(p) => p.id !== "credentials"
81+
);
82+
4983
return (
5084
<div className="min-h-screen bg-bg flex items-center justify-center p-4">
5185
<div className="max-w-sm w-full">
@@ -98,24 +132,28 @@ export default function SignupPage() {
98132
</button>
99133
</form>
100134

101-
<div className="mt-6 flex items-center gap-3">
102-
<div className="flex-1 h-px bg-white/[0.06]" />
103-
<span className="text-xs text-[#5a5870]">or</span>
104-
<div className="flex-1 h-px bg-white/[0.06]" />
105-
</div>
135+
{oauthProviders.length > 0 && (
136+
<>
137+
<div className="mt-6 flex items-center gap-3">
138+
<div className="flex-1 h-px bg-white/[0.06]" />
139+
<span className="text-xs text-[#5a5870]">or</span>
140+
<div className="flex-1 h-px bg-white/[0.06]" />
141+
</div>
106142

107-
<button
108-
onClick={() => signIn("google", { callbackUrl: "/dashboard" })}
109-
className="w-full flex items-center justify-center gap-3 rounded-xl border border-white/[0.1] bg-white/[0.03] px-4 py-3 text-sm text-[#e8e6f0] hover:bg-white/[0.06] transition-colors mt-4"
110-
>
111-
<svg className="w-5 h-5" viewBox="0 0 24 24">
112-
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 01-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" />
113-
<path fill="#34A853" 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" />
114-
<path fill="#FBBC05" 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" />
115-
<path fill="#EA4335" 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" />
116-
</svg>
117-
{t("signup.google")}
118-
</button>
143+
<div className="mt-4 space-y-3">
144+
{oauthProviders.map((provider) => (
145+
<button
146+
key={provider.id}
147+
onClick={() => signIn(provider.id, { callbackUrl: "/dashboard" })}
148+
className="w-full flex items-center justify-center gap-3 rounded-xl border border-white/[0.1] bg-white/[0.03] px-4 py-3 text-sm text-[#e8e6f0] hover:bg-white/[0.06] transition-colors"
149+
>
150+
{providerIcons[provider.id]}
151+
{t("login.continueWith").replace("{provider}", provider.name)}
152+
</button>
153+
))}
154+
</div>
155+
</>
156+
)}
119157

120158
<div className="mt-8 text-center text-xs text-[#5a5870] space-y-2">
121159
<p>

app/api/auth/reset-confirm/route.ts

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,34 @@ import { NextResponse } from "next/server";
22
import { prisma } from "@/lib/prisma";
33
import { hashPassword } from "@/lib/password";
44
import { createAuditLog } from "@/lib/session";
5+
import { isAllowedOrigin } from "@/lib/origin";
6+
import { resetConfirmSchema } from "@/lib/validation";
7+
import { rateLimitMiddleware } from "@/lib/rateLimit";
58

69
export async function POST(req: Request) {
710
try {
8-
const body = await req.json();
9-
const { token, email, password } = body;
11+
if (!isAllowedOrigin(req)) {
12+
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
13+
}
1014

11-
if (!token || !email || !password) {
12-
return NextResponse.json({ error: "All fields are required" }, { status: 400 });
15+
const rl = rateLimitMiddleware(req, "reset-confirm", 5, 60_000);
16+
if (!rl.allowed) {
17+
return NextResponse.json(
18+
{ error: "Too many requests. Try again later." },
19+
{ status: 429, headers: { "Retry-After": String(Math.ceil((rl.resetAt - Date.now()) / 1000)) } }
20+
);
1321
}
1422

15-
if (password.length < 6) {
16-
return NextResponse.json({ error: "Password must be at least 6 characters" }, { status: 400 });
23+
const body = await req.json();
24+
const parsed = resetConfirmSchema.safeParse(body);
25+
if (!parsed.success) {
26+
return NextResponse.json({ error: "Invalid request data" }, { status: 400 });
1727
}
1828

19-
const normalizedEmail = email.trim().toLowerCase();
29+
const { token, email, password } = parsed.data;
30+
const ip = req.headers.get("x-forwarded-for") ?? undefined;
2031

21-
const user = await prisma.user.findUnique({ where: { email: normalizedEmail } });
32+
const user = await prisma.user.findUnique({ where: { email } });
2233
if (!user) {
2334
return NextResponse.json({ error: "Invalid or expired reset link" }, { status: 400 });
2435
}
@@ -49,7 +60,7 @@ export async function POST(req: Request) {
4960
}),
5061
]);
5162

52-
await createAuditLog(user.id, "password-reset-completed", {}, req.headers.get("x-forwarded-for") ?? undefined);
63+
await createAuditLog(user.id, "password-reset-completed", {}, ip);
5364

5465
return NextResponse.json({ success: true });
5566
} catch (error) {

app/api/auth/reset-password/route.ts

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,34 @@ import { NextResponse } from "next/server";
22
import { prisma } from "@/lib/prisma";
33
import { hashPassword } from "@/lib/password";
44
import { createAuditLog } from "@/lib/session";
5+
import { isAllowedOrigin } from "@/lib/origin";
6+
import { resetConfirmSchema } from "@/lib/validation";
7+
import { rateLimitMiddleware } from "@/lib/rateLimit";
58

69
export async function POST(req: Request) {
710
try {
8-
const body = await req.json();
9-
const { token, email, password } = body;
11+
if (!isAllowedOrigin(req)) {
12+
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
13+
}
1014

11-
if (!token || !email || !password) {
12-
return NextResponse.json({ error: "All fields are required" }, { status: 400 });
15+
const rl = rateLimitMiddleware(req, "reset-confirm", 5, 60_000);
16+
if (!rl.allowed) {
17+
return NextResponse.json(
18+
{ error: "Too many requests. Try again later." },
19+
{ status: 429, headers: { "Retry-After": String(Math.ceil((rl.resetAt - Date.now()) / 1000)) } }
20+
);
1321
}
1422

15-
if (password.length < 6) {
16-
return NextResponse.json({ error: "Password must be at least 6 characters" }, { status: 400 });
23+
const body = await req.json();
24+
const parsed = resetConfirmSchema.safeParse(body);
25+
if (!parsed.success) {
26+
return NextResponse.json({ error: "Invalid request data" }, { status: 400 });
1727
}
1828

19-
const normalizedEmail = email.trim().toLowerCase();
29+
const { token, email, password } = parsed.data;
30+
const ip = req.headers.get("x-forwarded-for") ?? undefined;
2031

21-
const user = await prisma.user.findUnique({ where: { email: normalizedEmail } });
32+
const user = await prisma.user.findUnique({ where: { email } });
2233
if (!user) {
2334
return NextResponse.json({ error: "Invalid or expired reset link" }, { status: 400 });
2435
}
@@ -49,7 +60,7 @@ export async function POST(req: Request) {
4960
}),
5061
]);
5162

52-
await createAuditLog(user.id, "password-reset-completed", {}, req.headers.get("x-forwarded-for") ?? undefined);
63+
await createAuditLog(user.id, "password-reset-completed", {}, ip);
5364

5465
return NextResponse.json({ success: true });
5566
} catch (error) {

app/api/auth/reset-request/route.ts

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,33 @@ import { NextResponse } from "next/server";
22
import crypto from "crypto";
33
import { prisma } from "@/lib/prisma";
44
import { createAuditLog } from "@/lib/session";
5+
import { isAllowedOrigin } from "@/lib/origin";
6+
import { resetRequestSchema } from "@/lib/validation";
7+
import { rateLimitMiddleware } from "@/lib/rateLimit";
58

69
export async function POST(req: Request) {
710
try {
8-
const body = await req.json();
9-
const email = String(body?.email || "").trim().toLowerCase();
11+
if (!isAllowedOrigin(req)) {
12+
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
13+
}
14+
15+
const rl = rateLimitMiddleware(req, "reset-request", 5, 60_000);
16+
if (!rl.allowed) {
17+
return NextResponse.json(
18+
{ error: "Too many requests. Try again later." },
19+
{ status: 429, headers: { "Retry-After": String(Math.ceil((rl.resetAt - Date.now()) / 1000)) } }
20+
);
21+
}
1022

11-
if (!email) {
12-
return NextResponse.json({ error: "Email is required" }, { status: 400 });
23+
const body = await req.json();
24+
const parsed = resetRequestSchema.safeParse(body);
25+
if (!parsed.success) {
26+
return NextResponse.json({ error: "Invalid email" }, { status: 400 });
1327
}
1428

29+
const { email } = parsed.data;
30+
const ip = req.headers.get("x-forwarded-for") ?? undefined;
31+
1532
const user = await prisma.user.findUnique({ where: { email } });
1633
if (!user) {
1734
return NextResponse.json({ ok: true });
@@ -29,7 +46,7 @@ export async function POST(req: Request) {
2946
data: { userId: user.id, token, expiresAt },
3047
});
3148

32-
await createAuditLog(user.id, "password-reset-requested", { email }, req.headers.get("x-forwarded-for") ?? undefined);
49+
await createAuditLog(user.id, "password-reset-requested", { email }, ip);
3350

3451
const resetUrl = `${process.env.NEXTAUTH_URL || "http://localhost:3000"}/reset-password?token=${token}&email=${encodeURIComponent(email)}`;
3552

app/api/generate/route.ts

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,54 @@
11
import { NextRequest, NextResponse } from 'next/server';
22
import { getServerSession } from 'next-auth';
33
import { authOptions } from '@/lib/auth';
4-
import { generatePost } from '@/lib/claude';
4+
import { generatePost } from '@/lib/ai';
55
import { canUserPost, appendPost, ensureUserExists } from '@/lib/db';
6+
import { generatePostSchema } from '@/lib/validation';
7+
import { rateLimitMiddleware } from '@/lib/rateLimit';
8+
import { isAllowedOrigin } from '@/lib/origin';
69

710
export async function POST(req: NextRequest) {
811
try {
12+
if (!isAllowedOrigin(req)) {
13+
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
14+
}
15+
16+
const rl = rateLimitMiddleware(req, "generate", 20, 60_000);
17+
if (!rl.allowed) {
18+
return NextResponse.json(
19+
{ error: "Rate limit exceeded. Try again later." },
20+
{ status: 429, headers: { "Retry-After": String(Math.ceil((rl.resetAt - Date.now()) / 1000)) } }
21+
);
22+
}
23+
924
const session = await getServerSession(authOptions);
1025
if (!session?.user?.email) {
1126
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
1227
}
1328

14-
const { prompt, platform } = await req.json();
15-
if (!prompt) {
16-
return NextResponse.json({ error: "Prompt is required" }, { status: 400 });
29+
const body = await req.json();
30+
const parsed = generatePostSchema.safeParse(body);
31+
if (!parsed.success) {
32+
return NextResponse.json(
33+
{ error: "Invalid input. Prompt must be 10-500 characters." },
34+
{ status: 400 }
35+
);
1736
}
1837

19-
// Ensure user exists and check limits
38+
const { prompt, platform } = parsed.data;
39+
2040
const user = await ensureUserExists(session.user.email, session.user.name || undefined);
21-
41+
2242
const check = await canUserPost(user.id, user.plan);
2343
if (!check.allowed) {
24-
return NextResponse.json({
44+
return NextResponse.json({
2545
error: check.reason,
26-
limitReached: true
46+
limitReached: true
2747
}, { status: 429 });
2848
}
2949

30-
// Generate post using Claude
31-
const result = await generatePost(prompt, platform);
50+
const result = await generatePost({ prompt, platform });
3251

33-
// Save post and update usage
3452
await appendPost({
3553
userId: user.id,
3654
content: result.post,

0 commit comments

Comments
 (0)