From 7bddc5e29c9d103effd2a9fe002b396c2fd1bf23 Mon Sep 17 00:00:00 2001 From: Lesia Soloviova Date: Sun, 8 Feb 2026 18:55:38 +0200 Subject: [PATCH 1/3] feat(quiz): add guest warning before start and bot protection Guest warning: show login/signup/continue buttons for unauthenticated users on quiz rules screen before starting. Bot protection: multi-attempt verification via Redis - each question can only be verified once per user per attempt. Keys use dynamic TTL matching quiz time limit and are cleared on retake. Additional fixes: - Footer flash on quiz navigation (added loading.tsx, eliminated redirect) - Renamed QaLoader to Loader for reuse across pages - React compiler purity errors (crypto.getRandomValues in handlers) - Start button disabled after retake (isStarting not reset) --- frontend/actions/quiz.ts | 52 +++++++-------- frontend/app/[locale]/q&a/page.tsx | 4 +- frontend/app/[locale]/quiz/[slug]/loading.tsx | 9 +++ frontend/app/[locale]/quiz/[slug]/page.tsx | 16 ++--- frontend/app/api/quiz/verify-answer/route.ts | 48 +++++++++++++- frontend/components/q&a/QaSection.tsx | 4 +- frontend/components/quiz/QuizCard.tsx | 22 +++++-- frontend/components/quiz/QuizContainer.tsx | 65 +++++++++++++++---- .../shared/{QaLoader.tsx => Loader.tsx} | 4 +- frontend/lib/quiz/quiz-answers-redis.ts | 55 ++++++++++++++++ frontend/messages/en.json | 10 ++- frontend/messages/pl.json | 10 ++- frontend/messages/uk.json | 10 ++- 13 files changed, 228 insertions(+), 81 deletions(-) create mode 100644 frontend/app/[locale]/quiz/[slug]/loading.tsx rename frontend/components/shared/{QaLoader.tsx => Loader.tsx} (97%) diff --git a/frontend/actions/quiz.ts b/frontend/actions/quiz.ts index 58ef6432..4f5b2097 100644 --- a/frontend/actions/quiz.ts +++ b/frontend/actions/quiz.ts @@ -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 { const rows = await db @@ -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 @@ -260,8 +235,33 @@ export async function initializeQuizCache( quizId: string ): Promise<{ success: boolean; error?: string }> { try { - const { getOrCreateQuizAnswersCache } = + const { getOrCreateQuizAnswersCache, clearVerifiedQuestions } = await import('@/lib/quiz/quiz-answers-redis'); + + // Resolve identifier (same logic as verify-answer route) + const { headers } = await import('next/headers'); + const { verifyAuthToken } = await import('@/lib/auth'); + const headersList = await headers(); + let identifier: string; + + const cookieHeader = headersList.get('cookie') ?? ''; + const authCookie = cookieHeader + .split(';') + .find(c => c.trim().startsWith('auth_session=')); + + if (authCookie) { + const token = authCookie.split('=').slice(1).join('=').trim(); + const payload = verifyAuthToken(token); + identifier = payload?.userId ?? 'unknown'; + } else { + identifier = + headersList.get('x-forwarded-for')?.split(',')[0]?.trim() ?? + headersList.get('x-real-ip') ?? + 'unknown'; + } + + await clearVerifiedQuestions(quizId, identifier); + const success = await getOrCreateQuizAnswersCache(quizId); if (!success) { diff --git a/frontend/app/[locale]/q&a/page.tsx b/frontend/app/[locale]/q&a/page.tsx index cd67a173..30a52030 100644 --- a/frontend/app/[locale]/q&a/page.tsx +++ b/frontend/app/[locale]/q&a/page.tsx @@ -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, @@ -40,7 +40,7 @@ export default async function QAPage({ - + } > diff --git a/frontend/app/[locale]/quiz/[slug]/loading.tsx b/frontend/app/[locale]/quiz/[slug]/loading.tsx new file mode 100644 index 00000000..69203c53 --- /dev/null +++ b/frontend/app/[locale]/quiz/[slug]/loading.tsx @@ -0,0 +1,9 @@ +import { Loader } from '@/components/shared/Loader'; + +export default function QuizLoading() { + return ( +
+ +
+ ); +} diff --git a/frontend/app/[locale]/quiz/[slug]/page.tsx b/frontend/app/[locale]/quiz/[slug]/page.tsx index 49ca8c65..111f4e88 100644 --- a/frontend/app/[locale]/quiz/[slug]/page.tsx +++ b/frontend/app/[locale]/quiz/[slug]/page.tsx @@ -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'; @@ -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); diff --git a/frontend/app/api/quiz/verify-answer/route.ts b/frontend/app/api/quiz/verify-answer/route.ts index 5d4485c8..687f29ed 100644 --- a/frontend/app/api/quiz/verify-answer/route.ts +++ b/frontend/app/api/quiz/verify-answer/route.ts @@ -1,6 +1,13 @@ +import { headers } from 'next/headers'; import { NextResponse } from 'next/server'; -import { getCorrectAnswer, getOrCreateQuizAnswersCache } from '@/lib/quiz/quiz-answers-redis'; +import { verifyAuthToken } from '@/lib/auth'; +import { + getCorrectAnswer, + getOrCreateQuizAnswersCache, + isQuestionAlreadyVerified, + markQuestionVerified, +} from '@/lib/quiz/quiz-answers-redis'; export const runtime = 'nodejs'; @@ -15,7 +22,40 @@ 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(); + let identifier: string; + + const cookieHeader = headersList.get('cookie') ?? ''; + const authCookie = cookieHeader + .split(';') + .find(c => c.trim().startsWith('auth_session=')); + + if (authCookie) { + const token = authCookie.split('=').slice(1).join('=').trim(); + const payload = verifyAuthToken(token); + identifier = payload?.userId ?? 'unknown'; + } else { + identifier = + headersList.get('x-forwarded-for')?.split(',')[0]?.trim() ?? + headersList.get('x-real-ip') ?? + 'unknown'; + } + + // Reject duplicate verification (bot protection) + const alreadyVerified = await isQuestionAlreadyVerified( + quizId, + questionId, + identifier + ); + if (alreadyVerified) { + return NextResponse.json( + { success: false, error: 'Question already answered' }, + { status: 409 } + ); + } let correctAnswerId = await getCorrectAnswer(quizId, questionId); @@ -33,6 +73,10 @@ export async function POST(req: Request) { ); } + const ttl = typeof timeLimitSeconds === 'number' && timeLimitSeconds > 0 + ? timeLimitSeconds + 60 + : 900; // 15min fallback + await markQuestionVerified(quizId, questionId, identifier, ttl); const isCorrect = selectedAnswerId === correctAnswerId; return NextResponse.json({ diff --git a/frontend/components/q&a/QaSection.tsx b/frontend/components/q&a/QaSection.tsx index 221b1d0c..d4b8d9bc 100644 --- a/frontend/components/q&a/QaSection.tsx +++ b/frontend/components/q&a/QaSection.tsx @@ -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'; @@ -103,7 +103,7 @@ export default function TabsSection() { {isLoading && (
- +
)}
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 (
)} - - +
); } diff --git a/frontend/components/quiz/QuizContainer.tsx b/frontend/components/quiz/QuizContainer.tsx index 4f963a9a..862ac144 100644 --- a/frontend/components/quiz/QuizContainer.tsx +++ b/frontend/components/quiz/QuizContainer.tsx @@ -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 { @@ -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, @@ -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', @@ -219,11 +222,13 @@ 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; } @@ -231,6 +236,7 @@ export function QuizContainer({ dispatch({ type: 'START_QUIZ' }); } catch { toast.error('Failed to start quiz session'); + setIsStarting(false); } }; @@ -242,6 +248,7 @@ export function QuizContainer({ questionId: currentQuestion.id, selectedAnswerId: answerId, quizId, + timeLimitSeconds, }), }); @@ -370,6 +377,7 @@ export function QuizContainer({ const handleRestart = () => { clearQuizSession(quizId); resetViolations(); + setIsStarting(false); dispatch({ type: 'RESTART' }); }; @@ -446,22 +454,52 @@ export function QuizContainer({

-
-
- +
+ + + + + + + +
+ + ) : ( + )} ); } diff --git a/frontend/components/shared/QaLoader.tsx b/frontend/components/shared/Loader.tsx similarity index 97% rename from frontend/components/shared/QaLoader.tsx rename to frontend/components/shared/Loader.tsx index 320c106a..2982dd4e 100644 --- a/frontend/components/shared/QaLoader.tsx +++ b/frontend/components/shared/Loader.tsx @@ -4,7 +4,7 @@ import { useEffect, useRef } from 'react'; import { cn } from '@/lib/utils'; -interface QaLoaderProps { +interface LoaderProps { className?: string; size?: number; } @@ -22,7 +22,7 @@ interface ParticleState { const TWO_PI = Math.PI * 2; -export function QaLoader({ className, size = 240 }: QaLoaderProps) { +export function Loader({ className, size = 240 }: LoaderProps) { const canvasRef = useRef(null); const particlesRef = useRef([]); const animationRef = useRef(null); diff --git a/frontend/lib/quiz/quiz-answers-redis.ts b/frontend/lib/quiz/quiz-answers-redis.ts index 58b47fe0..32e12061 100644 --- a/frontend/lib/quiz/quiz-answers-redis.ts +++ b/frontend/lib/quiz/quiz-answers-redis.ts @@ -228,3 +228,58 @@ export async function getOrCreateQuestionsCache( return questions; } + +export async function isQuestionAlreadyVerified( + quizId: string, + questionId: string, + clientIp: string +): Promise { + const redis = getRedisClient(); + if (!redis) return false; + + const key = `quiz:verified:${quizId}:${clientIp}:${questionId}`; + try { + const exists = await redis.get(key); + return exists !== null; + } catch { + return false; + } +} + +export async function markQuestionVerified( + quizId: string, + questionId: string, + clientIp: string, + ttlSeconds: number = 900 +): Promise { + const redis = getRedisClient(); + if (!redis) return; + + const key = `quiz:verified:${quizId}:${clientIp}:${questionId}`; + try { + await redis.set(key, 1, { ex: ttlSeconds }); + } catch { + // silent fail — verification still works without tracking + } +} +export async function clearVerifiedQuestions( + quizId: string, + identifier: string +): Promise { + const redis = getRedisClient(); + if (!redis) return; + + const pattern = `quiz:verified:${quizId}:${identifier}:*`; + try { + let cursor = '0'; + do { + const [nextCursor, keys] = await redis.scan(cursor, { match: pattern, count: 100 }); + cursor = nextCursor; + if (keys.length > 0) { + await redis.del(...keys); + } + } while (cursor !== '0'); + } catch (err) { + console.warn('Failed to clear verified questions:', err); + } +} diff --git a/frontend/messages/en.json b/frontend/messages/en.json index a91376e9..b9595c6a 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -117,13 +117,11 @@ }, "control": { "title": "Control System", - "description": "Rule violations are automatically detected. With 3+ violations, the result will not count towards the leaderboard." + "description": "Rule violations are automatically detected. For registered users, results are always saved to your profile, but with 4+ violations points will not count towards the leaderboard." }, - "time": { - "title": "Time Requirements", - "description": "Minimum time: {seconds} seconds (3 seconds per question). Results that are too fast will not be counted." - }, - "startButton": "Start Quiz" + "startButton": "Start Quiz", + "guestWarning": "You are playing as a guest. Your result will not be saved.", + "continueAsGuest": "Continue as Guest" }, "question": { "correct": "Correct", diff --git a/frontend/messages/pl.json b/frontend/messages/pl.json index 0c5a6540..d1aff35d 100644 --- a/frontend/messages/pl.json +++ b/frontend/messages/pl.json @@ -117,13 +117,11 @@ }, "control": { "title": "System Kontroli", - "description": "Naruszenia zasad są wykrywane automatycznie. Przy 3+ naruszeniach wynik nie będzie liczony do rankingu." + "description": "Naruszenia zasad są wykrywane automatycznie. Dla zarejestrowanych użytkowników wynik jest zawsze zapisywany w profilu, ale przy 4+ naruszeniach punkty nie będą liczone do rankingu." }, - "time": { - "title": "Wymagania Czasowe", - "description": "Minimalny czas: {seconds} sekund (3 sekundy na pytanie). Wyniki zbyt szybkie nie będą liczone." - }, - "startButton": "Rozpocznij Quiz" + "startButton": "Rozpocznij Quiz", + "guestWarning": "Grasz jako gość. Twój wynik nie zostanie zapisany.", + "continueAsGuest": "Kontynuuj jako gość" }, "question": { "correct": "Poprawnie", diff --git a/frontend/messages/uk.json b/frontend/messages/uk.json index a5172539..fd61da70 100644 --- a/frontend/messages/uk.json +++ b/frontend/messages/uk.json @@ -117,13 +117,11 @@ }, "control": { "title": "Система контролю", - "description": "Порушення правил фіксуються автоматично. При 3+ порушеннях результат не зараховується до рейтингу." + "description": "Порушення фіксуються автоматично. Для зареєстрованих користувачів результат завжди зберігається в кабінеті, але при 4+ порушеннях бали не зараховуються до рейтингу." }, - "time": { - "title": "Час проходження", - "description": "Мінімальний час: {seconds} секунд (по 3 секунди на питання). Занадто швидке проходження не зараховується." - }, - "startButton": "Почати квіз" + "startButton": "Почати квіз", + "guestWarning": "Ви граєте як гість. Ваш результат не буде збережено.", + "continueAsGuest": "Продовжити як гість" }, "question": { "correct": "Правильно", From c5a74e8346de639122bed8a92f7795156b489cd0 Mon Sep 17 00:00:00 2001 From: Lesia Soloviova Date: Sun, 8 Feb 2026 20:31:06 +0200 Subject: [PATCH 2/3] refactor(quiz): PR review feedback - Extract shared resolveRequestIdentifier() helper to eliminate duplicated auth/IP resolution logic in route.ts and actions/quiz.ts - Return null instead of 'unknown' when identifier unresolvable, skip verification tracking for unidentifiable users - Cap Redis TTL with MAX_TTL (3600s) to prevent client-supplied timeLimitSeconds from persisting keys indefinitely - Add locale prefix to returnTo paths in guest warning links - Replace nested Button inside Link with styled Link to fix invalid HTML (interactive element nesting) --- frontend/actions/quiz.ts | 25 ++------- frontend/app/api/quiz/verify-answer/route.ts | 54 ++++++++------------ frontend/components/quiz/QuizContainer.tsx | 14 +++-- frontend/lib/quiz/resolve-identifier.ts | 20 ++++++++ 4 files changed, 52 insertions(+), 61 deletions(-) create mode 100644 frontend/lib/quiz/resolve-identifier.ts diff --git a/frontend/actions/quiz.ts b/frontend/actions/quiz.ts index 4f5b2097..d89c74d2 100644 --- a/frontend/actions/quiz.ts +++ b/frontend/actions/quiz.ts @@ -238,29 +238,14 @@ export async function initializeQuizCache( const { getOrCreateQuizAnswersCache, clearVerifiedQuestions } = await import('@/lib/quiz/quiz-answers-redis'); - // Resolve identifier (same logic as verify-answer route) + const { resolveRequestIdentifier } = await import('@/lib/quiz/resolve-identifier'); const { headers } = await import('next/headers'); - const { verifyAuthToken } = await import('@/lib/auth'); const headersList = await headers(); - let identifier: string; - - const cookieHeader = headersList.get('cookie') ?? ''; - const authCookie = cookieHeader - .split(';') - .find(c => c.trim().startsWith('auth_session=')); - - if (authCookie) { - const token = authCookie.split('=').slice(1).join('=').trim(); - const payload = verifyAuthToken(token); - identifier = payload?.userId ?? 'unknown'; - } else { - identifier = - headersList.get('x-forwarded-for')?.split(',')[0]?.trim() ?? - headersList.get('x-real-ip') ?? - 'unknown'; - } + const identifier = resolveRequestIdentifier(headersList); - await clearVerifiedQuestions(quizId, identifier); + if (identifier) { + await clearVerifiedQuestions(quizId, identifier); + } const success = await getOrCreateQuizAnswersCache(quizId); diff --git a/frontend/app/api/quiz/verify-answer/route.ts b/frontend/app/api/quiz/verify-answer/route.ts index 687f29ed..bbe81a60 100644 --- a/frontend/app/api/quiz/verify-answer/route.ts +++ b/frontend/app/api/quiz/verify-answer/route.ts @@ -1,7 +1,7 @@ import { headers } from 'next/headers'; import { NextResponse } from 'next/server'; -import { verifyAuthToken } from '@/lib/auth'; +import { resolveRequestIdentifier } from '@/lib/quiz/resolve-identifier' import { getCorrectAnswer, getOrCreateQuizAnswersCache, @@ -26,35 +26,19 @@ export async function POST(req: Request) { // Identify user: userId for authenticated, IP for guests const headersList = await headers(); - let identifier: string; - - const cookieHeader = headersList.get('cookie') ?? ''; - const authCookie = cookieHeader - .split(';') - .find(c => c.trim().startsWith('auth_session=')); - - if (authCookie) { - const token = authCookie.split('=').slice(1).join('=').trim(); - const payload = verifyAuthToken(token); - identifier = payload?.userId ?? 'unknown'; - } else { - identifier = - headersList.get('x-forwarded-for')?.split(',')[0]?.trim() ?? - headersList.get('x-real-ip') ?? - 'unknown'; - } - - // Reject duplicate verification (bot protection) - const alreadyVerified = await isQuestionAlreadyVerified( - quizId, - questionId, - identifier - ); - if (alreadyVerified) { - return NextResponse.json( - { success: false, error: 'Question already answered' }, - { status: 409 } + 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 } + ); + } } let correctAnswerId = await getCorrectAnswer(quizId, questionId); @@ -73,10 +57,14 @@ export async function POST(req: Request) { ); } - const ttl = typeof timeLimitSeconds === 'number' && timeLimitSeconds > 0 - ? timeLimitSeconds + 60 - : 900; // 15min fallback - await markQuestionVerified(quizId, questionId, identifier, ttl); + 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({ diff --git a/frontend/components/quiz/QuizContainer.tsx b/frontend/components/quiz/QuizContainer.tsx index 862ac144..a4718ed4 100644 --- a/frontend/components/quiz/QuizContainer.tsx +++ b/frontend/components/quiz/QuizContainer.tsx @@ -468,18 +468,16 @@ export function QuizContainer({
- + {tResult('loginButton')} - + {tResult('signupButton')}