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
37 changes: 11 additions & 26 deletions frontend/actions/quiz.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,19 +49,6 @@ function calculateIntegrityScore(violations: ViolationEvent[]): number {
return Math.max(0, 100 - penalty);
}

function validateTimeSpent(
startedAt: Date,
completedAt: Date,
questionCount: number
): boolean {
const MIN_SECONDS_PER_QUESTION = 1;
const timeSpentSeconds = Math.floor(
(completedAt.getTime() - startedAt.getTime()) / 1000
);
const minRequiredTime = questionCount * MIN_SECONDS_PER_QUESTION;

return timeSpentSeconds >= minRequiredTime;
}

async function getQuizQuestionIds(quizId: string): Promise<string[]> {
const rows = await db
Expand Down Expand Up @@ -176,18 +163,6 @@ export async function submitQuizAttempt(
return { success: false, error: 'Invalid time values' };
}

const isValidTime = validateTimeSpent(
startedAtDate,
completedAtDate,
questionIds.length
);
if (!isValidTime) {
return {
success: false,
error: 'Invalid time spent: quiz completed too quickly',
};
}

const percentage = (
(correctAnswersCount / questionIds.length) *
100
Expand Down Expand Up @@ -260,8 +235,18 @@ export async function initializeQuizCache(
quizId: string
): Promise<{ success: boolean; error?: string }> {
try {
const { getOrCreateQuizAnswersCache } =
const { getOrCreateQuizAnswersCache, clearVerifiedQuestions } =
await import('@/lib/quiz/quiz-answers-redis');

const { resolveRequestIdentifier } = await import('@/lib/quiz/resolve-identifier');
const { headers } = await import('next/headers');
const headersList = await headers();
const identifier = resolveRequestIdentifier(headersList);

if (identifier) {
await clearVerifiedQuestions(quizId, identifier);
}

const success = await getOrCreateQuizAnswersCache(quizId);

if (!success) {
Expand Down
4 changes: 2 additions & 2 deletions frontend/app/[locale]/q&a/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { getTranslations } from 'next-intl/server';
import { Suspense } from 'react';

import QaSection from '@/components/q&a/QaSection';
import { QaLoader } from '@/components/shared/QaLoader';
import { DynamicGridBackground } from '@/components/shared/DynamicGridBackground';
import { Loader } from '@/components/shared/Loader';

export async function generateMetadata({
params,
Expand Down Expand Up @@ -40,7 +40,7 @@ export default async function QAPage({
<Suspense
fallback={
<div className="flex justify-center py-16">
<QaLoader className="mx-auto" size={260} />
<Loader className="mx-auto" size={260} />
</div>
}
>
Expand Down
9 changes: 9 additions & 0 deletions frontend/app/[locale]/quiz/[slug]/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Loader } from '@/components/shared/Loader';

export default function QuizLoading() {
return (
<div className="flex min-h-screen items-center justify-center bg-white dark:bg-black">
<Loader size={200} />
</div>
);
}
16 changes: 5 additions & 11 deletions frontend/app/[locale]/quiz/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Metadata } from 'next';
import { notFound, redirect } from 'next/navigation';
import { notFound } from 'next/navigation';
import { getTranslations } from 'next-intl/server';

import { QuizContainer } from '@/components/quiz/QuizContainer';
Expand Down Expand Up @@ -49,16 +49,10 @@ export default async function QuizPage({
notFound();
}

if (!seedParam) {
// eslint-disable-next-line react-hooks/purity -- redirect throws, value never used in render
redirect(`/${locale}/quiz/${slug}?seed=${Date.now()}`);
}

const seed = Number.parseInt(seedParam, 10);
if (Number.isNaN(seed)) {
// eslint-disable-next-line react-hooks/purity -- redirect throws, value never used in render
redirect(`/${locale}/quiz/${slug}?seed=${Date.now()}`);
}
const parsedSeed = seedParam ? Number.parseInt(seedParam, 10) : Number.NaN;
const seed = Number.isFinite(parsedSeed)
? parsedSeed
: crypto.getRandomValues(new Uint32Array(1))[0]!;

const questions = await getQuizQuestionsRandomized(quiz.id, locale, seed);

Expand Down
36 changes: 34 additions & 2 deletions frontend/app/api/quiz/verify-answer/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { headers } from 'next/headers';
import { NextResponse } from 'next/server';

import { getCorrectAnswer, getOrCreateQuizAnswersCache } from '@/lib/quiz/quiz-answers-redis';
import {
getCorrectAnswer,
getOrCreateQuizAnswersCache,
isQuestionAlreadyVerified,
markQuestionVerified,
} from '@/lib/quiz/quiz-answers-redis';
import { resolveRequestIdentifier } from '@/lib/quiz/resolve-identifier'

export const runtime = 'nodejs';

Expand All @@ -15,7 +22,24 @@ export async function POST(req: Request) {
);
}

const { quizId, questionId, selectedAnswerId } = body;
const { quizId, questionId, selectedAnswerId, timeLimitSeconds } = body;

// Identify user: userId for authenticated, IP for guests
const headersList = await headers();
const identifier = resolveRequestIdentifier(headersList);
if (identifier) {
const alreadyVerified = await isQuestionAlreadyVerified(
quizId,
questionId,
identifier
);
if (alreadyVerified) {
return NextResponse.json(
{ success: false, error: 'Question already answered' },
{ status: 409 }
);
}
}
Comment on lines +30 to +42
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

No bot protection when identifier is null.

When resolveRequestIdentifier returns null (e.g., no auth cookie and no IP headers, or an invalid auth cookie — see related comment in resolve-identifier.ts), both the duplicate check (line 30) and the mark (line 65) are skipped. This means those requests bypass bot protection entirely. Consider returning a 400 when the identifier cannot be resolved, rather than silently proceeding without any tracking.

Suggested approach
     const identifier = resolveRequestIdentifier(headersList);
-     if (identifier) {
-      const alreadyVerified = await isQuestionAlreadyVerified(
+    if (!identifier) {
+      return NextResponse.json(
+        { success: false, error: 'Unable to identify request' },
+        { status: 400 }
+      );
+    }
+
+    const alreadyVerified = await isQuestionAlreadyVerified(
         quizId,
         questionId,
         identifier
       );
       if (alreadyVerified) {
         return NextResponse.json(
           { success: false, error: 'Question already answered' },
           { status: 409 }
         );
       }
-    }

And similarly remove the if (identifier) guard around markQuestionVerified.

Also applies to: 65-67

🤖 Prompt for AI Agents
In `@frontend/app/api/quiz/verify-answer/route.ts` around lines 30 - 42, The
request currently skips bot-protection when resolveRequestIdentifier returns
null; change the route handler to reject requests with a missing identifier by
returning a 400 JSON response (use NextResponse.json({ success: false, error:
'Missing identifier' }, { status: 400 })) when resolveRequestIdentifier(...)
yields null, and remove the conditional guards around both
isQuestionAlreadyVerified(...) and markQuestionVerified(...) so these functions
always run with a validated identifier (or never run because the handler already
returned 400). Reference resolveRequestIdentifier, isQuestionAlreadyVerified,
markQuestionVerified, and the route handler's response logic when making the
change.


let correctAnswerId = await getCorrectAnswer(quizId, questionId);

Expand All @@ -33,6 +57,14 @@ export async function POST(req: Request) {
);
}

const MAX_TTL = 3600;
const ttl = typeof timeLimitSeconds === 'number' && timeLimitSeconds > 0
? Math.min(timeLimitSeconds + 60, MAX_TTL)
: 900;

if (identifier) {
await markQuestionVerified(quizId, questionId, identifier, ttl);
}
const isCorrect = selectedAnswerId === correctAnswerId;

return NextResponse.json({
Expand Down
4 changes: 2 additions & 2 deletions frontend/components/q&a/QaSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ import { useTranslations } from 'next-intl';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';

import AccordionList from '@/components/q&a/AccordionList';
import { QaLoader } from '@/components/shared/QaLoader';
import { Pagination } from '@/components/q&a/Pagination';
import type { CategorySlug } from '@/components/q&a/types';
import { useQaTabs } from '@/components/q&a/useQaTabs';
import { CategoryTabButton } from '@/components/shared/CategoryTabButton';
import { Loader } from '@/components/shared/Loader';
import { Tabs, TabsContent, TabsList } from '@/components/ui/tabs';
import { categoryData } from '@/data/category';
import { categoryTabStyles } from '@/data/categoryStyles';
Expand Down Expand Up @@ -103,7 +103,7 @@ export default function TabsSection() {
<TabsContent key={category.slug} value={category.slug}>
{isLoading && (
<div className="flex justify-center py-12">
<QaLoader className="mx-auto" size={240} />
<Loader className="mx-auto" size={240} />
</div>
)}
<div
Expand Down
22 changes: 17 additions & 5 deletions frontend/components/quiz/QuizCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { useTranslations } from 'next-intl';

import { Badge } from '@/components/ui/badge';
import { categoryTabStyles } from '@/data/categoryStyles';
import { Link } from '@/i18n/routing';
import { useRouter } from '@/i18n/routing';

interface QuizCardProps {
quiz: {
Expand All @@ -25,18 +25,30 @@ interface QuizCardProps {
} | null;
}

function makeSeed(): number {
const buf = new Uint32Array(1);
crypto.getRandomValues(buf);
return buf[0]!;
}

export function QuizCard({ quiz, userProgress }: QuizCardProps) {
const router = useRouter();
const t = useTranslations('quiz.card');
const slug = quiz.categorySlug as keyof typeof categoryTabStyles | null;
const style =
slug && categoryTabStyles[slug] ? categoryTabStyles[slug] : null;
const accentColor = style?.accent ?? '#3B82F6'; // fallback blue
const accentColor = style?.accent ?? '#3B82F6';

const percentage =
userProgress && userProgress.totalQuestions > 0
? Math.round((userProgress.bestScore / userProgress.totalQuestions) * 100)
: 0;

const handleStart = () => {
const seed = makeSeed(); // runs on click, not render
router.push(`/quiz/${quiz.slug}?seed=${seed}`);
};

return (
<div
className="group/card relative flex flex-col overflow-hidden rounded-xl border border-black/10 bg-white p-5 shadow-sm transition-all duration-300 hover:-translate-y-1 hover:!border-[var(--accent)] hover:shadow-xl dark:border-white/10 dark:bg-neutral-900"
Expand Down Expand Up @@ -103,8 +115,8 @@ export function QuizCard({ quiz, userProgress }: QuizCardProps) {
</div>
</div>
)}
<Link
href={`/quiz/${quiz.slug}`}
<button
type="button" onClick={handleStart}
className="group relative block w-full overflow-hidden rounded-xl border px-4 py-2.5 text-center text-sm font-semibold transition-all duration-300"
style={{
borderColor: `${accentColor}50`,
Expand All @@ -117,7 +129,7 @@ export function QuizCard({ quiz, userProgress }: QuizCardProps) {
className="pointer-events-none absolute top-1/2 left-1/2 h-[150%] w-[80%] -translate-x-1/2 -translate-y-1/2 rounded-full opacity-0 blur-[20px] transition-opacity duration-300 group-hover:opacity-30"
style={{ backgroundColor: accentColor }}
/>
</Link>
</button>
</div>
);
}
63 changes: 50 additions & 13 deletions frontend/components/quiz/QuizContainer.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
'use client';
import { Ban, Clock, FileText, TriangleAlert } from 'lucide-react';
import { Ban, FileText, TriangleAlert, UserRound } from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import {
Expand All @@ -19,6 +19,7 @@ import type { QuizQuestionClient } from '@/db/queries/quiz';
import { useAntiCheat } from '@/hooks/useAntiCheat';
import { useQuizGuards } from '@/hooks/useQuizGuards';
import { useQuizSession } from '@/hooks/useQuizSession';
import { Link } from '@/i18n/routing';
import { savePendingQuizResult } from '@/lib/quiz/guest-quiz';
import {
clearQuizSession,
Expand Down Expand Up @@ -160,12 +161,14 @@ export function QuizContainer({
onBackToTopics,
}: QuizContainerProps) {
const tRules = useTranslations('quiz.rules');
const tResult = useTranslations('quiz.result');
const tExit = useTranslations('quiz.exitModal');
const tQuestion = useTranslations('quiz.question');
const categoryStyle = categorySlug
? categoryTabStyles[categorySlug as keyof typeof categoryTabStyles]
: null;
const accentColor = categoryStyle?.accent ?? '#3B82F6';
const [isStarting, setIsStarting] = useState(false);
const [isPending, startTransition] = useTransition();
const [state, dispatch] = useReducer(quizReducer, {
status: 'rules',
Expand Down Expand Up @@ -219,18 +222,21 @@ export function QuizContainer({
}, [seed, searchParams, router]);

const handleStart = async () => {
setIsStarting(true);
try {
const result = await initializeQuizCache(quizId);

if (!result.success) {
toast.error('Failed to start quiz session');
setIsStarting(false);
return;
}

window.history.pushState({ quizGuard: true }, '');
dispatch({ type: 'START_QUIZ' });
} catch {
toast.error('Failed to start quiz session');
setIsStarting(false);
}
};

Expand All @@ -242,6 +248,7 @@ export function QuizContainer({
questionId: currentQuestion.id,
selectedAnswerId: answerId,
quizId,
timeLimitSeconds,
}),
});

Expand Down Expand Up @@ -370,6 +377,7 @@ export function QuizContainer({
const handleRestart = () => {
clearQuizSession(quizId);
resetViolations();
setIsStarting(false);
dispatch({ type: 'RESTART' });
};

Expand Down Expand Up @@ -446,22 +454,50 @@ export function QuizContainer({
</p>
</div>
</div>
<div className="flex gap-3">
<Clock
className="mt-0.5 h-5 w-5 shrink-0 text-blue-500 dark:text-blue-400"
aria-hidden="true"
/>
<div>
<p className="font-medium">{tRules('time.title')}</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
{tRules('time.description', { seconds: totalQuestions * 3 })}
</div>
{isGuest ? (
<>
<div className="flex gap-3 rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-900/20">
<UserRound
className="mt-0.5 h-5 w-5 shrink-0 text-amber-500 dark:text-amber-400"
aria-hidden="true"
/>
<p className="text-sm font-medium text-amber-800 dark:text-amber-200">
{tRules('guestWarning')}
</p>
</div>
</div>
</div>
<div className="flex flex-col gap-3 sm:flex-row">
<Link
href={`/login?returnTo=/${locale}/quiz/${quizSlug}`}
className="flex-1 inline-flex items-center justify-center rounded-xl font-medium transition-colors px-6 py-3 text-base bg-[var(--accent-primary)] text-white hover:bg-[var(--accent-hover)] active:brightness-90 text-center"
>
{tResult('loginButton')}
</Link>
<Link
href={`/signup?returnTo=/${locale}/quiz/${quizSlug}`}
className="flex-1 inline-flex items-center justify-center rounded-xl font-medium transition-colors px-6 py-3 text-base bg-gray-200 text-gray-900 hover:bg-gray-300 dark:bg-gray-800 dark:text-gray-100 dark:hover:bg-gray-700 text-center"
>
{tResult('signupButton')}
</Link>
<button
onClick={handleStart}
disabled={isStarting}
className="disabled:opacity-50 disabled:cursor-not-allowed flex-1 rounded-xl border px-6 py-3 text-center text-base font-semibold transition-all duration-300"
style={{
borderColor: `${accentColor}50`,
backgroundColor: `${accentColor}15`,
color: accentColor,
}}
>
{tRules('continueAsGuest')}
</button>
</div>
</>
) : (
<button
onClick={handleStart}
className="group relative w-full overflow-hidden rounded-xl border px-6 py-3 text-center text-base font-semibold transition-all duration-300"
disabled={isStarting}
className="disabled:opacity-50 disabled:cursor-not-allowed group relative w-full overflow-hidden rounded-xl border px-6 py-3 text-center text-base font-semibold transition-all duration-300"
style={{
borderColor: `${accentColor}50`,
backgroundColor: `${accentColor}15`,
Expand All @@ -474,6 +510,7 @@ export function QuizContainer({
style={{ backgroundColor: accentColor }}
/>
</button>
)}
</div>
);
}
Expand Down
Loading