e3a054e109
- Session cookies are HttpOnly + SameSite=Lax (Secure on HTTPS), with strict session-ID mode; the CSRF token from GET api/auth.php must now accompany every state-changing request (X-CSRF-Token header, or a csrf body field for the sendBeacon progress save), rotated at login. - Failed sign-ins are throttled per username and IP via login_attempts (created lazily, so existing installs need no migration): 5 failures a username / 20 an IP inside 15 minutes answer 429 with Retry-After. - A video's sibling .srt now appears as a CC button in the player; captions.php converts it to WebVTT (comma timestamps, legacy encodings, and the already-VTT files with out-of-spec 6-digit fractions). - Plyr and Inter ship in assets/vendor/ instead of coming from CDNs, and anime.js is gone along with all scripted animations and the motion setting — the app now runs with no internet access at all. - .htaccess denies direct access to videos/, includes/, config.php and schema.sql, so video URLs cannot bypass login on Apache. Claude-Session: https://claude.ai/code/session_01SePf61iazhCLJBefdRLBDm
124 lines
4.1 KiB
PHP
124 lines
4.1 KiB
PHP
<?php
|
|
// GET api/quiz.php?id=N -> quiz questions + options (no answers) + user's results
|
|
// POST api/quiz.php -> grade an attempt, save the result
|
|
// JSON body: {quiz_id, answers: [[optIdx,...], ...]} (one array per question)
|
|
// Response includes the correct options so the UI can mark the attempt.
|
|
require_once __DIR__ . '/../includes/auth.php';
|
|
require_once __DIR__ . '/../includes/functions.php';
|
|
|
|
header('Content-Type: application/json');
|
|
$userId = requireLoginApi();
|
|
|
|
function quizProgressRow(int $userId, int $quizId): array
|
|
{
|
|
$stmt = db()->prepare(
|
|
'SELECT best_score, last_score, total_questions, attempts, completed
|
|
FROM quiz_progress WHERE user_id = ? AND quiz_id = ?'
|
|
);
|
|
$stmt->execute([$userId, $quizId]);
|
|
return $stmt->fetch()
|
|
?: ['best_score' => 0, 'last_score' => 0, 'total_questions' => 0,
|
|
'attempts' => 0, 'completed' => 0];
|
|
}
|
|
|
|
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
|
$quiz = getQuiz((int)($_GET['id'] ?? 0));
|
|
if (!$quiz) {
|
|
http_response_code(404);
|
|
echo json_encode(['error' => 'Quiz not found']);
|
|
exit;
|
|
}
|
|
$file = quizFilePath($quiz);
|
|
$questions = $file ? parseQuizFile($file) : [];
|
|
if (!$questions) {
|
|
http_response_code(404);
|
|
echo json_encode(['error' => 'Quiz file is missing — rescan the library']);
|
|
exit;
|
|
}
|
|
$folder = getFolder((int)$quiz['folder_id']);
|
|
$p = quizProgressRow($userId, (int)$quiz['id']);
|
|
echo json_encode([
|
|
'quiz' => ['id' => (int)$quiz['id'], 'name' => $quiz['name']],
|
|
'folder' => ['id' => (int)$folder['id'], 'name' => $folder['name']],
|
|
'breadcrumbs' => array_map(
|
|
fn($c) => ['id' => (int)$c['id'], 'name' => $c['name']],
|
|
folderBreadcrumbs($folder)
|
|
),
|
|
'questions' => array_map(fn($q) => [
|
|
'text' => $q['text'],
|
|
'multi' => $q['multi'],
|
|
'options' => $q['options'],
|
|
], $questions),
|
|
'progress' => [
|
|
'attempts' => (int)$p['attempts'],
|
|
'best_score' => (int)$p['best_score'],
|
|
'last_score' => (int)$p['last_score'],
|
|
'total' => count($questions),
|
|
'completed' => (bool)$p['completed'],
|
|
],
|
|
]);
|
|
exit;
|
|
}
|
|
|
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
|
http_response_code(405);
|
|
echo json_encode(['error' => 'Method not allowed']);
|
|
exit;
|
|
}
|
|
|
|
$data = json_decode(file_get_contents('php://input'), true) ?: [];
|
|
verifyCsrfApi($data);
|
|
$quiz = getQuiz((int)($data['quiz_id'] ?? 0));
|
|
$answers = $data['answers'] ?? null;
|
|
if (!$quiz || !is_array($answers)) {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'Bad request']);
|
|
exit;
|
|
}
|
|
|
|
$file = quizFilePath($quiz);
|
|
$questions = $file ? parseQuizFile($file) : [];
|
|
if (!$questions) {
|
|
http_response_code(404);
|
|
echo json_encode(['error' => 'Quiz file is missing — rescan the library']);
|
|
exit;
|
|
}
|
|
|
|
$score = 0;
|
|
$results = [];
|
|
foreach ($questions as $i => $q) {
|
|
$selected = array_values(array_unique(array_map('intval', (array)($answers[$i] ?? []))));
|
|
sort($selected);
|
|
$correct = $q['correct'];
|
|
sort($correct);
|
|
$isCorrect = $selected === $correct;
|
|
if ($isCorrect) $score++;
|
|
$results[] = [
|
|
'correct' => $isCorrect,
|
|
'correct_options' => $correct,
|
|
'selected' => $selected,
|
|
];
|
|
}
|
|
|
|
$total = count($questions);
|
|
$completed = ($score === $total) ? 1 : 0;
|
|
|
|
db()->prepare(
|
|
'INSERT INTO quiz_progress
|
|
(user_id, quiz_id, best_score, last_score, total_questions, attempts, completed)
|
|
VALUES (?, ?, ?, ?, ?, 1, ?)
|
|
ON DUPLICATE KEY UPDATE
|
|
last_score = VALUES(last_score),
|
|
best_score = GREATEST(best_score, VALUES(best_score)),
|
|
total_questions = VALUES(total_questions),
|
|
attempts = attempts + 1,
|
|
completed = GREATEST(completed, VALUES(completed))'
|
|
)->execute([$userId, (int)$quiz['id'], $score, $score, $total, $completed]);
|
|
|
|
echo json_encode([
|
|
'score' => $score,
|
|
'total' => $total,
|
|
'completed' => (bool)$completed,
|
|
'results' => $results,
|
|
]);
|