'/',
'domain' => '', // Empty for localhost compatibility
'secure' => false, // ✅ HTTP compatible for localhost
'httponly' => true,
'samesite' => 'Lax',
'lifetime' => 2592000 // 30 days cookie lifetime
]);
session_start();
}
// Generate CSRF token if not exists
if (!isset($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
require_once __DIR__ . '/../db.php';
// ✅ PERFORMANCE: Override SimpleCache to use pages/cache directory for better organization
class LocalSimpleCache extends SimpleCache {
private static $cacheDir = __DIR__ . '/cache';
private static $defaultTTL = 300;
public static function init() {
if (!file_exists(self::$cacheDir)) {
@mkdir(self::$cacheDir, 0755, true);
}
}
public static function get($key) {
self::init();
$file = self::$cacheDir . '/' . md5($key) . '.cache';
if (!file_exists($file)) {
return false;
}
$data = @unserialize(file_get_contents($file));
if ($data === false) return false;
if (time() > $data['expires']) {
@unlink($file);
return false;
}
return $data['value'];
}
public static function set($key, $value, $ttl = null) {
self::init();
$ttl = $ttl ?? self::$defaultTTL;
$file = self::$cacheDir . '/' . md5($key) . '.cache';
$data = [
'value' => $value,
'expires' => time() + $ttl,
'created' => time()
];
// ✅ FIXED: Better file locking with timeout to prevent deadlocks
$serialized = serialize($data);
$fp = @fopen($file, 'c+');
if ($fp === false) {
return false;
}
// Try to acquire lock with timeout (5 seconds)
$lockAcquired = false;
$attempts = 0;
while ($attempts < 10 && !$lockAcquired) {
if (flock($fp, LOCK_EX | LOCK_NB)) {
$lockAcquired = true;
} else {
usleep(500000); // Wait 0.5 seconds
$attempts++;
}
}
if ($lockAcquired) {
ftruncate($fp, 0);
fwrite($fp, $serialized);
flock($fp, LOCK_UN);
fclose($fp);
return true;
} else {
fclose($fp);
// Fallback to simple write if lock fails (better than nothing)
return @file_put_contents($file, $serialized) !== false;
}
}
public static function delete($key) {
self::init();
$file = self::$cacheDir . '/' . md5($key) . '.cache';
return @unlink($file);
}
}
// Check if user is logged in
if (!isset($_SESSION['user_logged_in']) || $_SESSION['user_logged_in'] !== true) {
header("Location: ../student");
exit;
}
// Check if database connection is working
if (!isset($pdo) || !$pdo) {
// UX-safe error handling (replacing die() with proper redirect)
header("Location: ../quiz_error.php?code=DB_CONNECTION_FAILED");
exit;
}
$userId = $_SESSION['user_id'];
$userName = $_SESSION['user_name'] ?? 'Student';
// ✅ PERFORMANCE: Cache user profile check to reduce DB queries
$profileCacheKey = 'user_profile_status_' . $userId;
$applicant = LocalSimpleCache::get($profileCacheKey);
if ($applicant === false) {
$stmt = $pdo->prepare("
SELECT a.status
FROM users u
JOIN applicants a ON u.applicant_id = a.applicant_id
WHERE u.user_id = ?
");
$stmt->execute([$userId]);
$applicant = $stmt->fetch(PDO::FETCH_ASSOC);
LocalSimpleCache::set($profileCacheKey, $applicant ?: ['status' => null], 300); // Cache for 5 minutes
}
if (!$applicant || $applicant['status'] !== 'Approved') {
// UX-safe error handling (replacing die() with proper message)
echo "
Access Denied
You must have an approved application to take the daily quiz. Please complete your profile first.
";
exit;
}
/* =======================
DEVICE HASH
======================= */
$ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
$lang = $_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? '';
$deviceHash = hash('sha256', $ua . $lang); // ✅ PRODUCTION SAFE device hash
/* =======================
FETCH TODAY ACTIVE ATTEMPT
======================= */
// ✅ PERFORMANCE: Cache attempt check to reduce database load
// Initialize $attempt to null to prevent undefined variable warnings
$attempt = null;
$attemptCacheKey = 'quiz_attempt_' . $userId . '_' . date('Y-m-d');
$cachedAttempt = LocalSimpleCache::get($attemptCacheKey);
if ($cachedAttempt === false) {
$stmt = $pdo->prepare("
SELECT * FROM quiz_attempts
WHERE user_id = ?
AND quiz_date = CURDATE()
ORDER BY id DESC
LIMIT 1
");
$stmt->execute([$userId]);
$attempt = $stmt->fetch(PDO::FETCH_ASSOC);
LocalSimpleCache::set($attemptCacheKey, $attempt ?: false, 60); // 1 minute cache for localhost
} else {
$attempt = $cachedAttempt ?: null;
}
// ✅ ADD ATTEMPT OWNERSHIP VALIDATION
if ($attempt && (int)$attempt['user_id'] !== (int)$userId) {
error_log("Attempt ownership violation | user=$userId attempt={$attempt['id']}");
header("Location: ../quiz_error.php?code=UNAUTHORIZED");
exit;
}
/* =======================
START QUIZ
======================= */
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['start_quiz'])) {
// ✅ IMPLEMENT DATABASE-BASED RATE LIMITING (instead of session-based)
$stmt = $pdo->prepare("
SELECT COUNT(*) FROM quiz_attempts
WHERE user_id = ?
AND started_at > NOW() - INTERVAL 1 MINUTE
");
$stmt->execute([$userId]);
if ($stmt->fetchColumn() > 0) {
echo "
Rate Limit Exceeded
Please wait 1 minute before starting another quiz.
";
exit;
}
// Validate CSRF token with hash_equals for timing attack protection
if (!isset($_POST['csrf_token']) || !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
// UX-safe error handling
echo "
Error
Invalid request. Please try again.
";
exit;
}
// ✅ CLEAR DEVICE WARNING SHOWN FLAG ON NEW QUIZ START
// Reset the device warning shown flag for new quiz attempts
unset($_SESSION['device_warning_shown']);
if ($attempt) {
// UX-safe error handling
echo "
Quiz Already Started
Quiz already started today.
";
exit;
}
$questions = [];
// ✅ PERFORMANCE: Cache question pool to reduce DB load
$questionCacheKey = 'quiz_questions_pool_' . date('Y-m-d');
$allQuestions = LocalSimpleCache::get($questionCacheKey);
if ($allQuestions === false) {
// REPLACED ORDER BY RAND() with deterministic shuffle approach
// First try to get today's questions
$stmt = $pdo->prepare("
SELECT id FROM quiz_questions
WHERE quiz_date = CURDATE() AND is_active = 1
LIMIT 200
");
$stmt->execute();
$todaysQuestions = $stmt->fetchAll(PDO::FETCH_COLUMN);
// If not enough questions for today, get from any date
if (count($todaysQuestions) < 15) {
$needed = 15 - count($todaysQuestions);
$stmt = $pdo->prepare("
SELECT id FROM quiz_questions
WHERE is_active = 1 AND quiz_date != CURDATE()
LIMIT 200
");
$stmt->execute();
$additionalQuestions = $stmt->fetchAll(PDO::FETCH_COLUMN);
$allQuestions = array_merge($todaysQuestions, $additionalQuestions);
} else {
$allQuestions = $todaysQuestions;
}
// ✅ OPTIMIZED: Cache for 10 minutes - good for localhost testing
LocalSimpleCache::set($questionCacheKey, $allQuestions, 600);
}
// Shuffle and select exactly 15 questions
shuffle($allQuestions);
$questions = array_slice($allQuestions, 0, 15);
// Ensure we have exactly 15 questions
if (count($questions) < 15) {
// ✅ FIXED: Proper error page with full HTML layout instead of just exit
// This prevents blank screen and provides better UX
?>
Daily Quiz - Error
⚠️ Not Enough Questions
Sorry, there are not enough questions available in the database to start the quiz.
Please contact the administrator or try again later.
beginTransaction();
// deactivate old attempts
$pdo->prepare("
UPDATE quiz_attempts
SET is_active_attempt = 0
WHERE user_id = ? AND quiz_date = CURDATE()
")->execute([$userId]);
// create attempt with race condition protection
$stmt = $pdo->prepare("
INSERT INTO quiz_attempts
(user_id, quiz_date, started_at, expires_at, device_hash)
VALUES (?, CURDATE(), NOW(), DATE_ADD(NOW(), INTERVAL 10 MINUTE), ?)
");
$stmt->execute([$userId, $deviceHash]);
$attemptId = $pdo->lastInsertId();
// Bulk insert for better performance - N+1 insert problem fix
if (!empty($questions)) {
$values = [];
$params = [];
foreach ($questions as $i => $qid) {
$values[] = "(?, ?, ?)";
$params[] = $attemptId;
$params[] = $qid;
$params[] = $i + 1;
}
$sql = "
INSERT INTO quiz_attempt_questions
(attempt_id, question_id, question_order)
VALUES " . implode(',', $values);
$pdo->prepare($sql)->execute($params);
}
$pdo->commit();
// ✅ Clear attempt cache when new quiz starts
LocalSimpleCache::delete('quiz_attempt_' . $userId . '_' . date('Y-m-d'));
} catch (PDOException $e) {
$pdo->rollBack();
// Check if it's a duplicate entry error
if (in_array($e->getCode(), ['23000', '1062'], true)) {
// UX-safe error handling
echo "
Quiz Already Started
Quiz already started. Please refresh the page.
";
exit;
}
// Log the error and show a generic message
error_log("Quiz start transaction failed: " . $e->getMessage());
echo "
Error
Failed to start quiz. Please try again.
";
exit;
}
header("Location: daily_quiz");
exit;
}
/* =======================
TAB SWITCH (AJAX)
======================= */
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'tab_switch') {
// ✅ SECURITY: Validate CSRF token for AJAX requests
if (!isset($_POST['csrf_token']) || !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
http_response_code(403);
exit;
}
// ✅ SECURITY: Re-fetch attempt from database to prevent stale data attacks (AJAX bypass prevention)
$stmt = $pdo->prepare("
SELECT * FROM quiz_attempts
WHERE user_id = ? AND quiz_date = CURDATE() AND status = 'started'
ORDER BY id DESC LIMIT 1
");
$stmt->execute([$userId]);
$currentAttempt = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$currentAttempt || (int)$currentAttempt['user_id'] !== (int)$userId) {
http_response_code(403);
exit;
}
// ✅ SECURITY: Server-side validation - Check if quiz expired
if ($currentAttempt['expires_at'] && strtotime($currentAttempt['expires_at']) < time()) {
$pdo->prepare("
UPDATE quiz_attempts
SET status='disqualified',
completed_at=NOW(),
disqualification_reason='Quiz expired',
is_active_attempt = 0
WHERE id = ?
")->execute([$currentAttempt['id']]);
LocalSimpleCache::delete('quiz_attempt_' . $userId . '_' . date('Y-m-d'));
if (isset($_SESSION['device_warning'])) {
unset($_SESSION['device_warning']);
}
header('Content-Type: application/json');
echo json_encode(['status' => 'expired']);
exit;
}
// ✅ SECURITY: Server-side activity validation - Ignore first visibility change (mobile noise filter)
if ($currentAttempt['tab_switch_count'] == 0) {
// Just update last_activity, don't increment count
$pdo->prepare("UPDATE quiz_attempts SET last_activity = NOW() WHERE id = ?")
->execute([$currentAttempt['id']]);
header('Content-Type: application/json');
echo json_encode(['status' => 'ok', 'ignored' => true]);
exit;
}
// ✅ SECURITY: Increment tab switch count with server-side validation (prevents AJAX blocking bypass)
$pdo->prepare("
UPDATE quiz_attempts
SET tab_switch_count = tab_switch_count + 1,
last_activity = NOW()
WHERE id = ? AND status = 'started'
")->execute([$currentAttempt['id']]);
// ✅ OPTIMIZED: Single query to get updated count
$stmt = $pdo->prepare("SELECT tab_switch_count FROM quiz_attempts WHERE id = ?");
$stmt->execute([$currentAttempt['id']]);
$updatedAttempt = $stmt->fetch(PDO::FETCH_ASSOC);
// ✅ SECURITY: Disqualify if tab switch count >= 5 (server-side enforcement)
if ($updatedAttempt && $updatedAttempt['tab_switch_count'] >= 5) {
$disqualificationReason = 'Multiple tab switches detected (' . $updatedAttempt['tab_switch_count'] . ' times)';
$pdo->prepare("
UPDATE quiz_attempts
SET status='disqualified',
completed_at=NOW(),
disqualification_reason=?,
is_active_attempt = 0
WHERE id = ?
")->execute([$disqualificationReason, $currentAttempt['id']]);
LocalSimpleCache::delete('quiz_attempt_' . $userId . '_' . date('Y-m-d'));
header('Content-Type: application/json');
echo json_encode(['status' => 'disqualified', 'reason' => $disqualificationReason]);
exit;
}
header('Content-Type: application/json');
echo json_encode(['status' => 'ok', 'count' => $updatedAttempt['tab_switch_count'] ?? 0]);
exit;
}
/* =======================
PROCTORING EVENT BATCH (AJAX) - Ultra-Optimized for Shared Hosting
======================= */
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'proctor_event_batch') {
// Validate CSRF token for AJAX requests
if (!isset($_POST['csrf_token']) || !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
http_response_code(403);
exit;
}
$eventsJson = $_POST['events'] ?? '[]';
$events = json_decode($eventsJson, true);
if (!is_array($events) || empty($events)) {
echo json_encode(['status' => 'ok']);
exit;
}
if ($attempt && $attempt['status'] === 'started') {
$attemptId = $attempt['id'];
$disqualificationTriggered = false;
$reason = '';
// ✅ PERFORMANCE: Fetch current counts once to reduce queries
$stmt = $pdo->prepare("
SELECT face_absence_count, noise_spike_count, fullscreen_exit_count, suspicious_activity_count
FROM quiz_attempts WHERE id = ?
");
$stmt->execute([$attemptId]);
$counts = $stmt->fetch(PDO::FETCH_ASSOC);
$faceCount = (int)($counts['face_absence_count'] ?? 0);
$noiseCount = (int)($counts['noise_spike_count'] ?? 0);
$fullscreenCount = (int)($counts['fullscreen_exit_count'] ?? 0);
$suspiciousCount = (int)($counts['suspicious_activity_count'] ?? 0);
// Prepare bulk insert for proctoring events
$eventInsertValues = [];
$eventInsertParams = [];
// Process all events in batch
foreach ($events as $eventType) {
$shouldDisqualify = false;
switch ($eventType) {
case 'face_missing':
$faceCount++;
if ($faceCount >= 3) {
$reason = 'Face absent from camera multiple times (' . $faceCount . ' events)';
$shouldDisqualify = true;
}
break;
case 'loud_audio':
$noiseCount++;
if ($noiseCount >= 3) {
$reason = 'Excessive microphone noise detected multiple times (' . $noiseCount . ' events)';
$shouldDisqualify = true;
}
break;
case 'dev_tools_open':
$reason = 'Developer tools opened during quiz';
$shouldDisqualify = true;
break;
case 'fullscreen_exit':
$fullscreenCount++;
if ($fullscreenCount >= 3) {
$reason = 'Exited fullscreen mode multiple times (' . $fullscreenCount . ' times)';
$shouldDisqualify = true;
}
break;
case 'suspicious_resize':
$suspiciousCount++;
break;
}
// Prepare event for bulk insert
$eventInsertValues[] = "(?, ?, NOW())";
$eventInsertParams[] = $attemptId;
$eventInsertParams[] = $eventType;
if ($shouldDisqualify && !$disqualificationTriggered) {
$disqualificationTriggered = true;
break; // Stop processing more events if disqualified
}
}
// ✅ PERFORMANCE: Single UPDATE query for all counters
$pdo->prepare("
UPDATE quiz_attempts
SET face_absence_count = ?,
noise_spike_count = ?,
fullscreen_exit_count = ?,
suspicious_activity_count = ?
WHERE id = ?
")->execute([$faceCount, $noiseCount, $fullscreenCount, $suspiciousCount, $attemptId]);
// ✅ PERFORMANCE: Bulk insert all proctoring events at once
if (!empty($eventInsertValues)) {
$sql = "INSERT INTO proctoring_events (attempt_id, event_type, created_at) VALUES "
. implode(',', $eventInsertValues);
$pdo->prepare($sql)->execute($eventInsertParams);
}
// Handle disqualification if triggered
if ($disqualificationTriggered) {
$pdo->prepare("
UPDATE quiz_attempts
SET status='disqualified',
completed_at=NOW(),
disqualification_reason=?,
is_active_attempt = 0
WHERE id = ?
")->execute([$reason, $attemptId]);
LocalSimpleCache::delete('quiz_attempt_' . $userId . '_' . date('Y-m-d'));
}
echo json_encode([
'status' => 'ok',
'disqualified' => $disqualificationTriggered,
'reason' => $reason
]);
exit;
}
echo json_encode(['status' => 'ok']);
exit;
}
/* =======================
PROCTORING EVENT (AJAX) - Legacy Handler (for backward compatibility)
======================= */
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'proctor_event') {
// Validate CSRF token for AJAX requests
if (!isset($_POST['csrf_token']) || !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
http_response_code(403);
exit;
}
$eventType = $_POST['event_type'] ?? '';
if ($attempt && $attempt['status'] === 'started') {
$attemptId = $attempt['id'];
$disqualificationTriggered = false;
$reason = '';
$shouldDisqualify = false;
switch ($eventType) {
case 'face_missing':
// Log face absence and check if it has occurred too many times
$pdo->prepare("
UPDATE quiz_attempts
SET face_absence_count = COALESCE(face_absence_count, 0) + 1
WHERE id = ?
")->execute([$attemptId]);
// Fetch updated count
$stmt = $pdo->prepare("SELECT face_absence_count FROM quiz_attempts WHERE id = ?");
$stmt->execute([$attemptId]);
$updatedCount = $stmt->fetchColumn();
// Disqualify if face is absent 3 times or more
if ($updatedCount >= 3) {
$reason = 'Face absent from camera multiple times (' . $updatedCount . ' events)';
$shouldDisqualify = true;
}
break;
case 'loud_audio':
// Log high noise event
$pdo->prepare("
UPDATE quiz_attempts
SET noise_spike_count = COALESCE(noise_spike_count, 0) + 1
WHERE id = ?
")->execute([$attemptId]);
// Fetch updated count
$stmt = $pdo->prepare("SELECT noise_spike_count FROM quiz_attempts WHERE id = ?");
$stmt->execute([$attemptId]);
$updatedCount = $stmt->fetchColumn();
// Disqualify if noise spike detected 3 times or more
if ($updatedCount >= 3) {
$reason = 'Excessive microphone noise detected multiple times (' . $updatedCount . ' events)';
$shouldDisqualify = true;
}
break;
case 'dev_tools_open':
$reason = 'Developer tools opened during quiz';
$shouldDisqualify = true;
break;
case 'fullscreen_exit':
// Log fullscreen exit and check count
$pdo->prepare("
UPDATE quiz_attempts
SET fullscreen_exit_count = COALESCE(fullscreen_exit_count, 0) + 1
WHERE id = ?
")->execute([$attemptId]);
// Fetch updated count
$stmt = $pdo->prepare("SELECT fullscreen_exit_count FROM quiz_attempts WHERE id = ?");
$stmt->execute([$attemptId]);
$updatedCount = $stmt->fetchColumn();
// Disqualify if exited fullscreen 3+ times
if ($updatedCount >= 3) {
$reason = 'Exited fullscreen mode multiple times (' . $updatedCount . ' times)';
$shouldDisqualify = true;
}
break;
case 'suspicious_resize':
// Log suspicious window resize (potential screen sharing)
$pdo->prepare("
UPDATE quiz_attempts
SET suspicious_activity_count = COALESCE(suspicious_activity_count, 0) + 1
WHERE id = ?
")->execute([$attemptId]);
// Don't auto-disqualify, just log for admin review
break;
default:
// Ignore unknown events
http_response_code(400);
exit;
}
// Insert the event into proctoring_events table for admin review
$pdo->prepare("
INSERT INTO proctoring_events (attempt_id, event_type, created_at)
VALUES (?, ?, NOW())
")->execute([$attemptId, $eventType]);
if ($shouldDisqualify) {
$pdo->prepare("
UPDATE quiz_attempts
SET status='disqualified',
completed_at=NOW(),
disqualification_reason=?,
is_active_attempt = 0
WHERE id = ?
")->execute([$reason, $attemptId]);
// ✅ Clear attempt cache when disqualified
LocalSimpleCache::delete('quiz_attempt_' . $userId . '_' . date('Y-m-d'));
$disqualificationTriggered = true;
}
// Return JSON response for client-side action
header('Content-Type: application/json');
echo json_encode([
'status' => 'success',
'disqualified' => $disqualificationTriggered,
'reason' => $reason
]);
}
exit;
}
/* =======================
ANSWER SUBMIT (AJAX Flow Implemented)
======================= */
if ($_SERVER['REQUEST_METHOD'] === 'POST' && (isset($_POST['answer']) || isset($_POST['auto_answer']))) {
$isAjax = (isset($_SERVER['HTTP_X_REQUESTED_WITH']) && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest');
// Setup JSON response header for AJAX requests
if ($isAjax) {
header('Content-Type: application/json');
}
// Validate CSRF token with hash_equals for timing attack protection
if (!isset($_POST['csrf_token']) || !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
error_log("Quiz answer submit failed: Invalid CSRF token");
if ($isAjax) {
echo json_encode(['status' => 'error', 'message' => 'Invalid request.']);
exit;
}
// Non-AJAX fallback: redirect to refresh
header("Location: daily_quiz");
exit;
}
// Check if quiz has expired before processing answer
if ($attempt['expires_at'] && strtotime($attempt['expires_at']) < time()) {
$pdo->prepare("
UPDATE quiz_attempts
SET status='disqualified',
completed_at=NOW(),
disqualification_reason='Quiz expired',
is_active_attempt = 0
WHERE id=?
")->execute([$attempt['id']]);
// ✅ Clear attempt cache when expired
LocalSimpleCache::delete('quiz_attempt_' . $userId . '_' . date('Y-m-d'));
if ($isAjax) {
echo json_encode(['status' => 'expired', 'message' => 'Quiz expired due to inactivity.']);
exit;
}
header("Location: daily_quiz");
exit;
}
if ( empty($_POST['aqid']) ) {
if ($isAjax) {
echo json_encode(['status' => 'error', 'message' => 'Missing question ID.']);
exit;
}
header("Location: daily_quiz");
exit;
}
$aqid = (int) $_POST['aqid'];
// Determine the answer
if (!empty($_POST['auto_answer']) && $_POST['auto_answer'] === 'TIME') {
$answer = 'TIME';
} elseif (!empty($_POST['answer'])) {
$answer = strtoupper($_POST['answer']);
} else {
// If it's a manual submit (not auto_answer) but no answer is selected
if ($isAjax) {
echo json_encode(['status' => 'no_answer_selected', 'message' => 'Please select an option.']);
exit;
}
// Fallback for non-AJAX user: treat as timed out (TIME)
$answer = 'TIME';
}
// Validate answer input
if (!in_array($answer, ['A','B','C','D','TIME'], true)) {
if ($isAjax) {
echo json_encode(['status' => 'error', 'message' => 'Invalid answer value.']);
exit;
}
header("Location: daily_quiz");
exit;
}
// Fetch question data
$stmt = $pdo->prepare("
SELECT aq.question_started_at, q.correct_answer
FROM quiz_attempt_questions aq
JOIN quiz_questions q ON q.id = aq.question_id
WHERE aq.id = ? AND aq.attempt_id = ?
");
$stmt->execute([$aqid, $attempt['id']]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if ($row && $row['question_started_at']) {
$taken = time() - strtotime($row['question_started_at']);
$isTimedOut = ($answer === 'TIME');
$correct = (!$isTimedOut && $answer === $row['correct_answer']) ? 1 : 0;
try {
$pdo->beginTransaction();
// ❌ ADD RACE CONDITION PROTECTION - Check if answer already submitted
$stmt = $pdo->prepare("
UPDATE quiz_attempt_questions
SET selected_answer = ?, is_correct = ?, time_taken = ?
WHERE id = ? AND selected_answer IS NULL
");
$stmt->execute([$answer, $correct, min($taken, 15), $aqid]);
// ✅ PERFORMANCE: Clear question count cache after answer submission
LocalSimpleCache::delete('question_count_' . $attempt['id']);
$rowsAffected = $stmt->rowCount();
// ✅ FIXED: If no rows were affected, the answer was already submitted
if ($rowsAffected == 0) {
$pdo->commit();
// ✅ FIXED: Proper response for double submit - prevent user confusion
if ($isAjax) {
header('Content-Type: application/json');
echo json_encode([
'status' => 'already_submitted',
'message' => 'This answer was already submitted. Loading next question...'
]);
} else {
header("Location: daily_quiz");
}
exit;
}
// ✅ INCREMENT answered_count IN quiz_attempts
$pdo->prepare("
UPDATE quiz_attempts
SET answered_count = answered_count + 1
WHERE id = ?
")->execute([$attempt['id']]);
// Check if there are any unanswered questions left
$stmt = $pdo->prepare("
SELECT COUNT(*) as unanswered
FROM quiz_attempt_questions
WHERE attempt_id = ? AND selected_answer IS NULL
");
$stmt->execute([$attempt['id']]);
$unanswered = $stmt->fetch(PDO::FETCH_ASSOC)['unanswered'];
$quizStatus = 'started';
// If no unanswered questions, complete the quiz
if ($unanswered == 0) {
$quizStatus = 'completed';
// finish quiz
$score = $pdo->prepare("
SELECT COUNT(*) FROM quiz_attempt_questions
WHERE attempt_id = ? AND is_correct = 1
");
$score->execute([$attempt['id']]);
$score = $score->fetchColumn();
$pdo->prepare("
UPDATE quiz_attempts
SET status='completed',
completed_at=NOW(),
score=?,
is_active_attempt = 0
WHERE id = ?
")->execute([$score, $attempt['id']]);
// ✅ Clear attempt cache when quiz completes
LocalSimpleCache::delete('quiz_attempt_' . $userId . '_' . date('Y-m-d'));
LocalSimpleCache::delete('question_count_' . $attempt['id']); // Clear question count cache
// Update leaderboard
$currentMonthYear = date('Y-m');
$stmt = $pdo->prepare("
SELECT id, total_score, quizzes_taken
FROM quiz_leaderboard_monthly
WHERE user_id = ? AND month_year = ?
");
$stmt->execute([$userId, $currentMonthYear]);
$leaderboardEntry = $stmt->fetch(PDO::FETCH_ASSOC);
if ($leaderboardEntry) {
$newTotalScore = $leaderboardEntry['total_score'] + $score;
$newQuizzesTaken = $leaderboardEntry['quizzes_taken'] + 1;
$pdo->prepare("
UPDATE quiz_leaderboard_monthly
SET total_score = ?, quizzes_taken = ?
WHERE id = ?
")->execute([$newTotalScore, $newQuizzesTaken, $leaderboardEntry['id']]);
} else {
$pdo->prepare("
INSERT INTO quiz_leaderboard_monthly
(user_id, month_year, total_score, quizzes_taken)
VALUES (?, ?, ?, 1)
")->execute([$userId, $currentMonthYear, $score]);
}
// ✅ Clear device warning after quiz completion
if (isset($_SESSION['device_warning'])) {
unset($_SESSION['device_warning']);
}
}
$pdo->commit();
// AJAX RESPONSE (for manual submit)
if ($isAjax) {
// ✅ FIXED: Set JSON header before output to prevent page refresh
header('Content-Type: application/json');
echo json_encode([
'status' => 'success',
'message' => ($isTimedOut ? 'Time expired. Answer recorded.' : 'Answer submitted successfully.'),
'quiz_status' => $quizStatus
]);
exit;
} else {
// Non-AJAX response (for auto-submit 'TIME') - needs a redirect
header("Location: daily_quiz");
exit;
}
} catch (Exception $e) {
$pdo->rollBack();
error_log("Quiz answer submit transaction failed: " . $e->getMessage());
if ($isAjax) {
echo json_encode(['status' => 'error', 'message' => 'Failed to process answer. Please try again.']);
exit;
} else {
// Non-AJAX fallback
header("Location: daily_quiz");
exit;
}
}
} else {
if ($isAjax) {
echo json_encode(['status' => 'error', 'message' => 'Question not found or already started.']);
exit;
}
header("Location: daily_quiz");
exit;
}
}
/* =======================
FETCH CURRENT QUESTION
======================= */
$currentQuestion = null;
if ($attempt && $attempt['status'] === 'started') {
// Check if quiz has expired
if ($attempt['expires_at'] && strtotime($attempt['expires_at']) < time()) {
$pdo->prepare("
UPDATE quiz_attempts
SET status='disqualified',
completed_at=NOW(),
disqualification_reason='Quiz expired',
is_active_attempt = 0
WHERE id=?
")->execute([$attempt['id']]);
// ✅ Clear attempt cache when expired
LocalSimpleCache::delete('quiz_attempt_' . $userId . '_' . date('Y-m-d'));
// ✅ Clear device warning after quiz expiry
if (isset($_SESSION['device_warning'])) {
unset($_SESSION['device_warning']);
}
// UX-safe error handling
echo "
Quiz Expired
Quiz expired due to inactivity.
";
exit;
}
// Enhanced device check with warning and optional disqualification
if ($attempt['device_hash'] !== $deviceHash) {
// Log the device change
error_log("Quiz Device Change | user={$userId} | attempt={$attempt['id']}");
// Store device change in database for admin review
$pdo->prepare("
UPDATE quiz_attempts
SET device_changes = device_changes + 1
WHERE id = ?
")->execute([$attempt['id']]);
// ✅ OPTIMIZED: Single query to get device_changes (was 2 queries)
$stmt = $pdo->prepare("SELECT device_changes FROM quiz_attempts WHERE id = ?");
$stmt->execute([$attempt['id']]);
$updatedAttempt = $stmt->fetch(PDO::FETCH_ASSOC);
// Set session warning for first device change
if ($updatedAttempt && $updatedAttempt['device_changes'] == 1) {
$_SESSION['device_warning'] = true;
}
// Disqualify if device changed more than 1 time (2nd change triggers disqualification)
if ($updatedAttempt && $updatedAttempt['device_changes'] >= 2) {
$pdo->prepare("
UPDATE quiz_attempts
SET status='disqualified',
completed_at=NOW(),
disqualification_reason='Multiple device changes detected',
is_active_attempt = 0
WHERE id = ?
")->execute([$attempt['id']]);
// ✅ Clear attempt cache when disqualified
LocalSimpleCache::delete('quiz_attempt_' . $userId . '_' . date('Y-m-d'));
// ✅ Clear device warning after device change disqualification
if (isset($_SESSION['device_warning'])) {
unset($_SESSION['device_warning']);
}
// UX-safe error handling
echo "
Disqualified
You have been disqualified due to multiple device changes during the quiz.
";
exit;
}
}
$stmt = $pdo->prepare("
SELECT q.*, aq.id AS aqid, aq.question_started_at
FROM quiz_attempt_questions aq
JOIN quiz_questions q ON q.id = aq.question_id
WHERE aq.attempt_id = ?
AND aq.selected_answer IS NULL
ORDER BY aq.question_order ASC
LIMIT 1
");
$stmt->execute([$attempt['id']]);
$currentQuestion = $stmt->fetch(PDO::FETCH_ASSOC);
// set question start time
if ($currentQuestion && !$currentQuestion['question_started_at']) {
$pdo->prepare("
UPDATE quiz_attempt_questions
SET question_started_at = NOW()
WHERE id = ?
")->execute([$currentQuestion['aqid']]);
// ✅ OPTIMIZED: Update question_started_at directly without re-fetch (1 less query)
// Just update the local variable instead of re-fetching
$currentQuestion['question_started_at'] = date('Y-m-d H:i:s');
}
// Server-side timer validation - check if current question has exceeded time limit
if ($currentQuestion && $currentQuestion['question_started_at']) {
$timeTaken = time() - strtotime($currentQuestion['question_started_at']);
// Add grace period to prevent unfair disqualification due to network latency
$buffer = 3; // seconds
if ($timeTaken > (15 + $buffer)) {
// Auto-submit the question with no points if time exceeded
$stmt = $pdo->prepare("
UPDATE quiz_attempt_questions
SET selected_answer = 'TIME', is_correct = 0, time_taken = 15
WHERE id = ? AND selected_answer IS NULL
");
$stmt->execute([$currentQuestion['aqid']]);
// Only redirect if the update was successful
if ($stmt->rowCount() > 0) {
// Redirect to reload the page and get the next question
header("Location: daily_quiz");
exit;
}
}
}
}
?>
Daily Quiz
= htmlspecialchars($userName) ?> • = $userId ?>
REC | DO NOT TRY TO CHEAT
Loading...
⚠ Device change detected. Further changes may disqualify you.
Click the button above to start today's quiz. You'll have 15 questions to answer, with 15 seconds per question.
Please keep this tab focused and avoid switching to other tabs or applications during the quiz.
🎉 Congratulations! 🎉
Your quiz has been submitted successfully
You have successfully completed today's quiz.
Check the leaderboard to see how you rank!