Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion frontend/app/[locale]/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useLocale } from 'next-intl';
import { Link } from '@/i18n/routing';
import { useState } from "react";
import { useSearchParams } from "next/navigation";
import { getPendingQuizResult, clearPendingQuizResult } from "@/lib/guest-quiz";
import { getPendingQuizResult, clearPendingQuizResult } from "@/lib/quiz/guest-quiz";
import { Button } from "@/components/ui/button";
import { OAuthButtons } from '@/components/auth/OAuthButtons';

Expand Down
16 changes: 13 additions & 3 deletions frontend/app/[locale]/quiz/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { createEncryptedAnswersBlob } from '@/lib/quiz/quiz-crypto';
import { stripCorrectAnswers } from '@/db/queries/quiz';
import { getQuizBySlug, getQuizQuestionsRandomized } from '@/db/queries/quiz';
import { notFound } from 'next/navigation';
import { getTranslations } from 'next-intl/server';
Expand All @@ -8,11 +10,13 @@ import { getCurrentUser } from '@/lib/auth';

interface QuizPageProps {
params: Promise<{ locale: string; slug: string }>;
searchParams: Promise<{seed?: string}>;
}

export default async function QuizPage({ params }: QuizPageProps) {
export default async function QuizPage({ params, searchParams }: QuizPageProps) {
const { locale, slug } = await params;
const t = await getTranslations({ locale, namespace: 'quiz.page' });
const { seed: seedParam } = await searchParams;

const user = await getCurrentUser();

Expand All @@ -22,9 +26,12 @@ export default async function QuizPage({ params }: QuizPageProps) {
notFound();
}

const seed = Date.now();
const seed = seedParam ? parseInt(seedParam, 10) : Date.now();
const questions = await getQuizQuestionsRandomized(quiz.id, locale, seed);

const encryptedAnswers = createEncryptedAnswersBlob(questions);
const clientQuestions = stripCorrectAnswers(questions);

if (!questions.length) {
return (
<div className="min-h-screen flex items-center justify-center">
Expand Down Expand Up @@ -56,9 +63,12 @@ export default async function QuizPage({ params }: QuizPageProps) {
<QuizContainer
quizSlug={slug}
quizId={quiz.id}
questions={questions}
questions={clientQuestions}
encryptedAnswers={encryptedAnswers}
userId={user?.id ?? null}
timeLimitSeconds={quiz.timeLimitSeconds ?? questions.length * 30}
seed={seed}
categorySlug={quiz.categorySlug}
/>
{user && <PendingResultHandler userId={user.id} />}
</div>
Expand Down
2 changes: 1 addition & 1 deletion frontend/app/[locale]/signup/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useLocale } from 'next-intl';
import { Link } from '@/i18n/routing';
import { useState } from "react";
import { useSearchParams } from "next/navigation";
import { getPendingQuizResult, clearPendingQuizResult } from "@/lib/guest-quiz";
import { getPendingQuizResult, clearPendingQuizResult } from "@/lib/quiz/guest-quiz";
import { Button } from "@/components/ui/button";
import { OAuthButtons } from '@/components/auth/OAuthButtons';

Expand Down
49 changes: 49 additions & 0 deletions frontend/app/api/quiz/verify-answer/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from 'next/server';
import { decryptAnswers } from '@/lib/quiz/quiz-crypto';

interface VerifyRequest {
questionId: string;
answerId: string;
encryptedAnswers: string;
}

export async function POST(request: NextRequest) {
try {
const body: VerifyRequest = await request.json();
const { questionId, answerId, encryptedAnswers } = body;

if (!questionId || !answerId || !encryptedAnswers) {
return NextResponse.json(
{ error: 'Missing required fields' },
{ status: 400 }
);
}

const correctAnswersMap = decryptAnswers(encryptedAnswers);

if (!correctAnswersMap) {
return NextResponse.json(
{ error: 'Invalid encrypted data' },
{ status: 400 }
);
}

const correctAnswerId = correctAnswersMap[questionId];

if (!correctAnswerId) {
return NextResponse.json(
{ error: 'Question not found' },
{ status: 404 }
);
}

return NextResponse.json({
isCorrect: answerId === correctAnswerId,
});
Comment on lines +22 to +42
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n frontend/app/api/quiz/verify-answer/route.ts

Repository: DevLoversTeam/devlovers.net

Length of output: 1595


🏁 Script executed:

fd -name "middleware.ts" -o -name "middleware.js"

Repository: DevLoversTeam/devlovers.net

Length of output: 301


🏁 Script executed:

rg -l "rate" --type ts --type tsx frontend/app/api/ | head -20

Repository: DevLoversTeam/devlovers.net

Length of output: 98


🏁 Script executed:

rg "NextRequest" frontend/app/api/ -A 3 | grep -E "(auth|middleware)" | head -20

Repository: DevLoversTeam/devlovers.net

Length of output: 1637


🏁 Script executed:

find . -name "middleware.ts" -o -name "middleware.js" | head -10

Repository: DevLoversTeam/devlovers.net

Length of output: 53


🏁 Script executed:

rg "rateLimit|Ratelimit|throttle" --type ts | head -30

Repository: DevLoversTeam/devlovers.net

Length of output: 53


🏁 Script executed:

rg "quiz/verify-answer" -A 5 -B 5

Repository: DevLoversTeam/devlovers.net

Length of output: 908


🏁 Script executed:

rg "getCurrentUser|auth" frontend/app/api/quiz/ --type ts

Repository: DevLoversTeam/devlovers.net

Length of output: 338


🏁 Script executed:

cat -n frontend/lib/auth.ts 2>/dev/null | head -50

Repository: DevLoversTeam/devlovers.net

Length of output: 1517


Add authentication and rate limiting to prevent brute-force attempts.

This endpoint accepts unauthenticated requests and directly returns whether an answerId is correct (Line 41). A client can iterate through all possible answer IDs to determine correct answers without any constraints. The codebase has an auth system (getCurrentUser() in /lib/auth.ts) that's used in other quiz endpoints like guest-result, but it's missing here. Implement authentication (via getCurrentUser()) and add per-user/per-attempt rate limiting or constraints to prevent answer enumeration.

🤖 Prompt for AI Agents
In @frontend/app/api/quiz/verify-answer/route.ts around lines 22 - 42, This
endpoint leaks answer correctness and must enforce authentication and rate
limiting: call getCurrentUser() at the start of the handler and return a 401/403
when it returns null, then apply a per-user (fallback to IP) rate limit check
before using decryptAnswers/enforcing logic (use or add a rate limiter that
returns 429 when exceeded); ensure the rate-limit check occurs before returning
the isCorrect response and include the user id (or IP) when recording attempts
so brute-force attempts are blocked for that identity.

} catch {
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
Comment on lines +10 to +48
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# Find and read the file
find . -name "verify-answer" -o -name "route.ts" | grep verify-answer

Repository: DevLoversTeam/devlovers.net

Length of output: 155


🏁 Script executed:

# List files in the quiz API directory
fd route.ts frontend/app/api/quiz/

Repository: DevLoversTeam/devlovers.net

Length of output: 197


🏁 Script executed:

# List the structure of the frontend/app/api directory
find frontend/app/api -type f -name "route.ts" 2>/dev/null | head -20

Repository: DevLoversTeam/devlovers.net

Length of output: 971


🏁 Script executed:

# Read the verify-answer route file
cat -n frontend/app/api/quiz/verify-answer/route.ts

Repository: DevLoversTeam/devlovers.net

Length of output: 1595


🏁 Script executed:

# Check how other API routes in the codebase handle request.json() errors
rg -A 5 "request\.json\(\)" frontend/app/api --type ts

Repository: DevLoversTeam/devlovers.net

Length of output: 1601


🏁 Script executed:

# Check for error handling patterns in other routes
rg -A 10 "catch\s*\(" frontend/app/api --type ts -m 3

Repository: DevLoversTeam/devlovers.net

Length of output: 21134


🏁 Script executed:

# Verify error handling in quiz/guest-result for comparison
cat -n frontend/app/api/quiz/guest-result/route.ts | head -35

Repository: DevLoversTeam/devlovers.net

Length of output: 1371


🏁 Script executed:

# Check if there's a logError utility in the codebase
rg "logError" frontend --type ts | head -5

Repository: DevLoversTeam/devlovers.net

Length of output: 529


Return 400 (not 500) for invalid JSON; log unexpected errors.

request.json() throws on malformed JSON, which is a client error, not a server error. The codebase consistently returns 400 for JSON parsing failures (see quiz/guest-result, auth/signup, auth/login). Also add explicit error logging to distinguish between client-side JSON errors and genuine server failures.

Proposed fix
 export async function POST(request: NextRequest) {
   try {
-    const body: VerifyRequest = await request.json();
+    let body: VerifyRequest;
+    try {
+      body = await request.json();
+    } catch {
+      return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
+    }
     const { questionId, answerId, encryptedAnswers } = body;
@@
-  } catch {
+  } catch (e) {
+    console.error('verify-answer failed', e);
     return NextResponse.json(
       { error: 'Internal server error' },
       { status: 500 }
     );
   }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export async function POST(request: NextRequest) {
try {
const body: VerifyRequest = await request.json();
const { questionId, answerId, encryptedAnswers } = body;
if (!questionId || !answerId || !encryptedAnswers) {
return NextResponse.json(
{ error: 'Missing required fields' },
{ status: 400 }
);
}
const correctAnswersMap = decryptAnswers(encryptedAnswers);
if (!correctAnswersMap) {
return NextResponse.json(
{ error: 'Invalid encrypted data' },
{ status: 400 }
);
}
const correctAnswerId = correctAnswersMap[questionId];
if (!correctAnswerId) {
return NextResponse.json(
{ error: 'Question not found' },
{ status: 404 }
);
}
return NextResponse.json({
isCorrect: answerId === correctAnswerId,
});
} catch {
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
export async function POST(request: NextRequest) {
try {
let body: VerifyRequest;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
}
const { questionId, answerId, encryptedAnswers } = body;
if (!questionId || !answerId || !encryptedAnswers) {
return NextResponse.json(
{ error: 'Missing required fields' },
{ status: 400 }
);
}
const correctAnswersMap = decryptAnswers(encryptedAnswers);
if (!correctAnswersMap) {
return NextResponse.json(
{ error: 'Invalid encrypted data' },
{ status: 400 }
);
}
const correctAnswerId = correctAnswersMap[questionId];
if (!correctAnswerId) {
return NextResponse.json(
{ error: 'Question not found' },
{ status: 404 }
);
}
return NextResponse.json({
isCorrect: answerId === correctAnswerId,
});
} catch (e) {
console.error('verify-answer failed', e);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
🤖 Prompt for AI Agents
In @frontend/app/api/quiz/verify-answer/route.ts around lines 10 - 48, In POST,
treat JSON parse failures from request.json() as a 400: wrap the request.json()
call in a try/catch (or detect SyntaxError) so malformed JSON returns
NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }) and does not fall
through to the generic 500 handler; keep the existing field validation and
decryptAnswers logic (decryptAnswers, correctAnswersMap, correctAnswerId)
unchanged, and add an explicit error log (e.g., console.error or the project
logger) in the generic catch block to record unexpected errors before returning
the 500 response.

}
4 changes: 2 additions & 2 deletions frontend/components/dashboard/StatsCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export function StatsCard({ stats }: StatsCardProps) {
<p className="text-slate-500 dark:text-slate-400 mb-8 max-w-xs mx-auto">
Ready to level up? Challenge yourself with a new React quiz.
</p>
<Link href="/quiz/react-fundamentals" className={primaryBtnStyles}>
<Link href="/quizzes" className={primaryBtnStyles}>
<span className="relative z-10">Start a Quiz</span>
<span
className="absolute inset-0 rounded-full bg-gradient-to-r from-white/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity"
Expand Down Expand Up @@ -81,7 +81,7 @@ export function StatsCard({ stats }: StatsCardProps) {
</div>

<div className="col-span-2 mt-4">
<Link href="/quiz/react-fundamentals" className={primaryBtnStyles}>
<Link href="/q&a" className={primaryBtnStyles}>
<span className="relative z-10">Continue Learning</span>
</Link>
</div>
Expand Down
16 changes: 8 additions & 8 deletions frontend/components/quiz/PendingResultHandler.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client';

import { useEffect } from 'react';
import { getPendingQuizResult, clearPendingQuizResult } from '@/lib/guest-quiz';
import { getPendingQuizResult, clearPendingQuizResult } from '@/lib/quiz/guest-quiz';

interface Props {
userId: string;
Expand All @@ -24,13 +24,13 @@ export function PendingResultHandler({ userId }: Props) {
timeSpentSeconds: pending.timeSpentSeconds,
}),
})
.then(async (res) => {
if (res.ok) {
clearPendingQuizResult();
} else {
console.error("Guest-result API error:", res.status);
}
})
.then(async (res) => {
if (res.ok) {
clearPendingQuizResult();
} else {
console.error("Guest-result API error:", res.status);
}
})
.catch(err => {
console.error("Guest-result fetch error:", err);
});
Expand Down
46 changes: 24 additions & 22 deletions frontend/components/quiz/QuizCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ export function QuizCard({ quiz, userProgress }: QuizCardProps) {
: 0;

return (
<div className="rounded-xl border border-gray-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-5 shadow-sm hover:shadow-md transition-shadow">
<div className="flex flex-col rounded-xl border border-gray-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-5 shadow-sm hover:shadow-md transition-shadow">
<div className="flex-grow">
<div className="flex gap-2 mb-3">
<Badge variant="blue">{quiz.categoryName ?? t('uncategorized')}</Badge>
{userProgress && (
Expand All @@ -39,7 +40,7 @@ export function QuizCard({ quiz, userProgress }: QuizCardProps) {
{quiz.title ?? quiz.slug}
</h2>
{quiz.description && (
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
<p className="line-clamp-2 text-sm text-gray-600 dark:text-gray-400 mb-3">
{quiz.description}
</p>
)}
Expand All @@ -49,27 +50,28 @@ export function QuizCard({ quiz, userProgress }: QuizCardProps) {
⏱️ {Math.floor((quiz.timeLimitSeconds ?? quiz.questionsCount * 30) / 60)} {t('min')}
</span>
</div>
</div>
{userProgress && (
<div className="mb-4">
<div className="flex justify-between text-xs mb-1.5">
<span className="text-gray-600 dark:text-gray-400">
{t('best')} {userProgress.bestScore}/{userProgress.totalQuestions}
</span>
<span className="font-medium text-gray-900 dark:text-gray-100">
{percentage}%
</span>
</div>
<div className="h-1.5 bg-gray-200 dark:bg-neutral-800 rounded-full overflow-hidden">
<div
className="h-full bg-blue-600 rounded-full transition-all"
style={{ width: `${percentage}%` }}
/>
</div>
<p className="text-xs text-gray-500 mt-1">
{userProgress.attemptsCount} {userProgress.attemptsCount === 1 ? t('attempt') : t('attempts')}
</p>
</div>
)}
<div className="mb-6">
<div className="flex justify-between text-xs mb-1.5">
<span className="text-gray-600 dark:text-gray-400">
{t('best')} {userProgress.bestScore}/{userProgress.totalQuestions}
</span>
<span className="text-gray-500">
{userProgress.attemptsCount} {userProgress.attemptsCount === 1 ? t('attempt') : t('attempts')}
</span>
<span className="font-medium text-gray-900 dark:text-gray-100">
{percentage}%
</span>
</div>
<div className="h-1.5 bg-gray-200 dark:bg-neutral-800 rounded-full overflow-hidden">
<div
className="h-full bg-blue-600 rounded-full transition-all"
style={{ width: `${percentage}%` }}
/>
</div>
Comment on lines +55 to +72
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Clamp percentage to [0, 100] before using it in UI.
If bestScore/totalQuestions is ever out of sync, Line 70 could render widths >100% (and negative widths if data is bad).

Proposed fix
-  const percentage = userProgress && userProgress.totalQuestions > 0
-    ? Math.round((userProgress.bestScore / userProgress.totalQuestions) * 100)
-    : 0;
+  const rawPercentage =
+    userProgress && userProgress.totalQuestions > 0
+      ? Math.round((userProgress.bestScore / userProgress.totalQuestions) * 100)
+      : 0;
+  const percentage = Math.min(100, Math.max(0, rawPercentage));
🤖 Prompt for AI Agents
In @frontend/components/quiz/QuizCard.tsx around lines 55 - 72, Clamp the
computed percentage used in the UI to the 0–100 range before rendering: ensure
the value derived from userProgress.bestScore and userProgress.totalQuestions
(used as percentage) is sanitized (e.g., compute a local percentage variable
inside QuizCard component and set percentage = Math.min(100, Math.max(0,
rawPercentage))) and then use that clamped percentage for the text display and
the progress bar width. Update references to percentage in the span and the
inline style to use the clamped variable so widths never go below 0% or above
100%.

</div>
)}
<Link
href={`/quiz/${quiz.slug}`}
className="block w-full text-center rounded-lg bg-blue-600 text-white px-4 py-2.5 text-sm font-medium hover:bg-blue-500 transition-colors"
Expand Down
Loading