-
-
Notifications
You must be signed in to change notification settings - Fork 4
feat(quiz): add guest warning before start and bot protection #297
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
@@ -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'; | ||
| } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fallback identifier When both the auth cookie is absent and IP headers are missing, Consider rejecting the request or generating a per-request ephemeral identifier instead of silently proceeding with a shared sentinel. 🤖 Prompt for AI Agents |
||
|
|
||
| await clearVerifiedQuestions(quizId, identifier); | ||
|
|
||
| const success = await getOrCreateQuizAnswersCache(quizId); | ||
|
|
||
| if (!success) { | ||
|
|
||
| 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> | ||
| ); | ||
| } |
| 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 { 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 } | ||
| ); | ||
| } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. TOCTOU gap between duplicate check and mark — concurrent requests can slip through.
🔒 Atomic check-and-set approachReplace the two-step check+set with a single const key = `quiz:verified:${quizId}:${identifier}:${questionId}`;
const wasSet = await redis.set(key, 1, { ex: ttl, nx: true });
if (!wasSet) {
return NextResponse.json(
{ success: false, error: 'Question already answered' },
{ status: 409 }
);
}This eliminates the race window entirely. You could encapsulate this in a single Also applies to: 76-79 🤖 Prompt for AI Agents
Comment on lines
+30
to
+42
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No bot protection when When 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 Also applies to: 65-67 🤖 Prompt for AI Agents |
||
|
|
||
| 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 | ||
|
coderabbitai[bot] marked this conversation as resolved.
Outdated
|
||
| await markQuestionVerified(quizId, questionId, identifier, ttl); | ||
| const isCorrect = selectedAnswerId === correctAnswerId; | ||
|
|
||
| return NextResponse.json({ | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.