Skip to content

Commit 511c99f

Browse files
committed
feature(blog): fix
2 parents 25fc1da + 387cc55 commit 511c99f

19 files changed

Lines changed: 743 additions & 229 deletions

frontend/.env.example

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,23 @@ CSRF_SECRET=
7070
CHECKOUT_RATE_LIMIT_MAX=10
7171
CHECKOUT_RATE_LIMIT_WINDOW_SECONDS=300
7272

73+
# Stripe webhook rate limit envs (applied per reason; reason-specific overrides generic).
74+
# Missing signature has its own envs with fallback to generic, then legacy invalid_sig.
75+
STRIPE_WEBHOOK_MISSING_SIG_RL_MAX=30
76+
STRIPE_WEBHOOK_MISSING_SIG_RL_WINDOW_SECONDS=60
77+
78+
# Generic Stripe webhook rate limit fallback (applies to missing_sig and invalid_sig).
79+
STRIPE_WEBHOOK_RL_MAX=30
80+
STRIPE_WEBHOOK_RL_WINDOW_SECONDS=60
81+
82+
# Invalid signature envs (canonical for invalid_sig, legacy fallback for missing_sig).
7383
STRIPE_WEBHOOK_INVALID_SIG_RL_MAX=30
7484
STRIPE_WEBHOOK_INVALID_SIG_RL_WINDOW_SECONDS=60
7585

86+
# SECURITY: If true, trust x-real-ip / x-forwarded-for headers for rate limiting.
87+
# Enable ONLY behind Cloudflare or a trusted reverse proxy that overwrites these headers.
88+
# Default: false (empty/0/false).
89+
TRUST_FORWARDED_HEADERS=0
90+
7691
# emergency switch
7792
RATE_LIMIT_DISABLED=0

frontend/app/[locale]/dashboard/page.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { getUserQuizStats } from '@/db/queries/quiz';
77
import { ProfileCard } from '@/components/dashboard/ProfileCard';
88
import { StatsCard } from '@/components/dashboard/StatsCard';
99
import { QuizSavedBanner } from '@/components/dashboard/QuizSavedBanner';
10+
import { PostAuthQuizSync } from "@/components/auth/PostAuthQuizSync";
1011

1112
export const metadata = {
1213
title: 'Dashboard | DevLovers',
@@ -28,9 +29,9 @@ export default async function DashboardPage({ params }: { params: Promise<{ loca
2829
const averageScore =
2930
totalAttempts > 0
3031
? Math.round(
31-
attempts.reduce((acc, curr) => acc + Number(curr.percentage), 0) /
32-
totalAttempts
33-
)
32+
attempts.reduce((acc, curr) => acc + Number(curr.percentage), 0) /
33+
totalAttempts
34+
)
3435
: 0;
3536

3637
const lastActiveDate =
@@ -57,6 +58,7 @@ export default async function DashboardPage({ params }: { params: Promise<{ loca
5758

5859
return (
5960
<main className="relative min-h-[calc(100vh-80px)] overflow-hidden">
61+
<PostAuthQuizSync />
6062
<div
6163
className="absolute inset-0 pointer-events-none -z-10"
6264
aria-hidden="true"

frontend/app/[locale]/login/page.tsx

Lines changed: 0 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,6 @@ import { useLocale } from "next-intl";
44
import { Link } from "@/i18n/routing";
55
import { useState } from "react";
66
import { useSearchParams } from "next/navigation";
7-
import {
8-
getPendingQuizResult,
9-
clearPendingQuizResult,
10-
} from "@/lib/quiz/guest-quiz";
117
import { Button } from "@/components/ui/button";
128
import { OAuthButtons } from "@/components/auth/OAuthButtons";
139

@@ -74,50 +70,6 @@ export default function LoginPage() {
7470
return;
7571
}
7672

77-
const pendingResult = getPendingQuizResult();
78-
79-
if (pendingResult && data?.userId) {
80-
try {
81-
const quizRes = await fetch("/api/quiz/guest-result", {
82-
method: "POST",
83-
headers: { "Content-Type": "application/json" },
84-
body: JSON.stringify({
85-
userId: data.userId,
86-
quizId: pendingResult.quizId,
87-
answers: pendingResult.answers,
88-
violations: pendingResult.violations,
89-
timeSpentSeconds:
90-
pendingResult.timeSpentSeconds,
91-
}),
92-
});
93-
94-
if (!quizRes.ok) {
95-
throw new Error(
96-
`Failed to save quiz result: ${quizRes.status}`
97-
);
98-
}
99-
100-
const result = await quizRes.json();
101-
102-
if (result.success) {
103-
sessionStorage.setItem(
104-
"quiz_just_saved",
105-
JSON.stringify({
106-
score: result.score,
107-
total: result.totalQuestions,
108-
percentage: result.percentage,
109-
pointsAwarded: result.pointsAwarded,
110-
quizSlug: pendingResult.quizSlug,
111-
})
112-
);
113-
}
114-
} catch (err) {
115-
console.error("Failed to save quiz result:", err);
116-
} finally {
117-
clearPendingQuizResult();
118-
}
119-
}
120-
12173
const redirectTarget =
12274
returnTo && isSafeRedirectUrl(returnTo)
12375
? returnTo

frontend/app/api/auth/google/callback/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,5 +152,5 @@ export async function GET(req: NextRequest) {
152152

153153
await setAuthCookie(token);
154154

155-
return NextResponse.redirect(new URL("/", req.url));
155+
return NextResponse.redirect(new URL("/dashboard", req.url));
156156
}

frontend/app/api/quiz/guest-result/route.ts

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import { NextResponse } from "next/server";
2-
import { getCurrentUser } from '@/lib/auth';
2+
import { getCurrentUser } from "@/lib/auth";
33
import { db } from "@/db";
4-
import { quizAttempts, quizAttemptAnswers, quizQuestions, quizAnswers } from "@/db/schema/quiz";
4+
import {
5+
quizAttempts,
6+
quizAttemptAnswers,
7+
quizQuestions,
8+
quizAnswers,
9+
} from "@/db/schema/quiz";
510
import { awardQuizPoints, calculateQuizPoints } from "@/db/queries/points";
611
import { eq, inArray } from "drizzle-orm";
712

@@ -17,23 +22,25 @@ export async function POST(req: Request) {
1722
);
1823
}
1924

20-
const { userId, quizId, answers, violations, timeSpentSeconds } = body;
25+
const { quizId, answers, violations, timeSpentSeconds } = body;
2126

22-
if (!userId || !quizId || !Array.isArray(answers) || answers.length === 0) {
27+
if (!quizId || !Array.isArray(answers) || answers.length === 0) {
2328
return NextResponse.json(
2429
{ success: false, error: "Invalid input" },
2530
{ status: 400 }
2631
);
2732
}
2833

2934
const session = await getCurrentUser();
30-
if (!session || session.id !== userId) {
35+
if (!session) {
3136
return NextResponse.json(
3237
{ success: false, error: "Unauthorized" },
3338
{ status: 401 }
3439
);
3540
}
3641

42+
const userId = session.id;
43+
3744
const questionRows = await db
3845
.select({ id: quizQuestions.id })
3946
.from(quizQuestions)
@@ -114,14 +121,13 @@ export async function POST(req: Request) {
114121
);
115122
}
116123

117-
const isCorrect = record.isCorrect;
118-
if (isCorrect) correctAnswersCount++;
124+
if (record.isCorrect) correctAnswersCount++;
119125

120126
attemptAnswers.push({
121127
attemptId: "",
122128
quizQuestionId: answer.questionId,
123129
selectedAnswerId: answer.selectedAnswerId,
124-
isCorrect,
130+
isCorrect: record.isCorrect,
125131
answeredAt: now,
126132
});
127133
}
@@ -132,10 +138,12 @@ export async function POST(req: Request) {
132138
const integrityScore = Math.max(0, 100 - violationsArray.length * 10);
133139
const safeTimeSpentSeconds = Math.max(0, Number(timeSpentSeconds) || 0);
134140
const startedAt = new Date(now.getTime() - safeTimeSpentSeconds * 1000);
141+
135142
const pointsEarned = calculateQuizPoints({
136143
score: correctAnswersCount,
137144
integrityScore,
138145
});
146+
139147
try {
140148
const [attempt] = await db
141149
.insert(quizAttempts)
@@ -185,4 +193,4 @@ export async function POST(req: Request) {
185193
{ status: 500 }
186194
);
187195
}
188-
}
196+
}

frontend/app/api/shop/checkout/route.ts

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { NextRequest, NextResponse } from 'next/server';
22
import {
33
enforceRateLimit,
4-
getClientIp,
4+
getRateLimitSubject,
55
rateLimitResponse,
66
} from '@/lib/security/rate-limit';
77
import { getCurrentUser } from '@/lib/auth';
@@ -233,14 +233,26 @@ export async function POST(request: NextRequest) {
233233
}
234234
// P1: rate limit checkout (cross-instance, DB-backed)
235235
// Policy: allow reasonable retries; block abusive burst.
236-
const checkoutSubject = sessionUserId ?? getClientIp(request) ?? 'anon';
236+
const checkoutSubject = sessionUserId ?? getRateLimitSubject(request);
237+
238+
const limitParsed = Number.parseInt(
239+
process.env.CHECKOUT_RATE_LIMIT_MAX ?? '',
240+
10
241+
);
242+
const windowParsed = Number.parseInt(
243+
process.env.CHECKOUT_RATE_LIMIT_WINDOW_SECONDS ?? '',
244+
10
245+
);
246+
247+
const limit =
248+
Number.isFinite(limitParsed) && limitParsed > 0 ? limitParsed : 10;
249+
const windowSeconds =
250+
Number.isFinite(windowParsed) && windowParsed > 0 ? windowParsed : 300;
237251

238252
const decision = await enforceRateLimit({
239253
key: `checkout:${checkoutSubject}`,
240-
limit: Number(process.env.CHECKOUT_RATE_LIMIT_MAX ?? 10),
241-
windowSeconds: Number(
242-
process.env.CHECKOUT_RATE_LIMIT_WINDOW_SECONDS ?? 300
243-
),
254+
limit,
255+
windowSeconds,
244256
});
245257

246258
if (!decision.ok) {

frontend/app/api/shop/webhooks/stripe/route.ts

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,10 @@ import {
1515
import { markStripeAttemptFinal } from '@/lib/services/orders/payment-attempts';
1616
import {
1717
enforceRateLimit,
18-
getClientIp,
18+
getRateLimitSubject,
1919
rateLimitResponse,
2020
} from '@/lib/security/rate-limit';
21+
import { resolveStripeWebhookRateLimit } from '@/lib/security/stripe-webhook-rate-limit';
2122

2223
const REFUND_FULLNESS_UNDETERMINED = 'REFUND_FULLNESS_UNDETERMINED' as const;
2324

@@ -317,13 +318,12 @@ export async function POST(request: NextRequest) {
317318

318319
const signature = request.headers.get('stripe-signature');
319320
if (!signature) {
320-
const ip = getClientIp(request) ?? 'anon';
321+
const subject = getRateLimitSubject(request);
322+
const rateLimit = resolveStripeWebhookRateLimit('missing_sig');
321323
const decision = await enforceRateLimit({
322-
key: `stripe_webhook:missing_sig:${ip}`,
323-
limit: Number(process.env.STRIPE_WEBHOOK_INVALID_SIG_RL_MAX ?? 30),
324-
windowSeconds: Number(
325-
process.env.STRIPE_WEBHOOK_INVALID_SIG_RL_WINDOW_SECONDS ?? 60
326-
),
324+
key: `stripe_webhook:missing_sig:${subject}`,
325+
limit: rateLimit.max,
326+
windowSeconds: rateLimit.windowSeconds,
327327
});
328328

329329
if (!decision.ok) {
@@ -353,13 +353,12 @@ export async function POST(request: NextRequest) {
353353
error instanceof Error &&
354354
error.message === 'STRIPE_INVALID_SIGNATURE'
355355
) {
356-
const ip = getClientIp(request) ?? 'anon';
356+
const subject = getRateLimitSubject(request);
357+
const rateLimit = resolveStripeWebhookRateLimit('invalid_sig');
357358
const decision = await enforceRateLimit({
358-
key: `stripe_webhook:invalid_sig:${ip}`,
359-
limit: Number(process.env.STRIPE_WEBHOOK_INVALID_SIG_RL_MAX ?? 30),
360-
windowSeconds: Number(
361-
process.env.STRIPE_WEBHOOK_INVALID_SIG_RL_WINDOW_SECONDS ?? 60
362-
),
359+
key: `stripe_webhook:invalid_sig:${subject}`,
360+
limit: rateLimit.max,
361+
windowSeconds: rateLimit.windowSeconds,
363362
});
364363

365364
if (!decision.ok) {
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
"use client";
2+
3+
import { useEffect } from "react";
4+
import { useRouter } from "next/navigation";
5+
import {
6+
getPendingQuizResult,
7+
clearPendingQuizResult,
8+
} from "@/lib/quiz/guest-quiz";
9+
10+
11+
export function PostAuthQuizSync() {
12+
const router = useRouter();
13+
14+
useEffect(() => {
15+
16+
queueMicrotask(() => {
17+
const pendingResult = getPendingQuizResult();
18+
if (!pendingResult) return;
19+
20+
(async () => {
21+
try {
22+
const res = await fetch("/api/quiz/guest-result", {
23+
method: "POST",
24+
headers: { "Content-Type": "application/json" },
25+
body: JSON.stringify({
26+
quizId: pendingResult.quizId,
27+
answers: pendingResult.answers,
28+
violations: pendingResult.violations,
29+
timeSpentSeconds: pendingResult.timeSpentSeconds,
30+
}),
31+
});
32+
33+
if (!res.ok) {
34+
throw new Error(`Failed to save quiz result (${res.status})`);
35+
}
36+
37+
const result = await res.json();
38+
39+
if (!result?.success) {
40+
throw new Error("Quiz save did not succeed");
41+
}
42+
43+
sessionStorage.setItem(
44+
"quiz_just_saved",
45+
JSON.stringify({
46+
score: result.score,
47+
total: result.totalQuestions,
48+
percentage: result.percentage,
49+
pointsAwarded: result.pointsAwarded,
50+
quizSlug: pendingResult.quizSlug,
51+
})
52+
);
53+
clearPendingQuizResult();
54+
router.refresh();
55+
} catch (error) {
56+
console.error("Failed to sync guest quiz result:", error);
57+
}
58+
})();
59+
});
60+
}, [router]);
61+
62+
return null;
63+
}

0 commit comments

Comments
 (0)