2fa7452253
- A quiz question can now carry the diagram it refers to: a Markdown image line anywhere inside the question block is parsed out of the text and rendered under it (referrerpolicy="no-referrer", lazy). The URL points at the course provider, so an offline install gets a note and a link instead of an empty box. - New optional SESSION_LIFETIME (30 days in the example config) replaces the browser-session cookie plus php.ini's 24-minute idle limit, which signed people out over a long pause. Configs that predate it behave as before. Behind a proxy trusted enough for CLIENT_IP_HEADER, the cookie is also marked Secure from X-Forwarded-Proto — TLS ending at the proxy no longer costs the flag. - Error responses no longer echo the exception message unless DEBUG: a PDOException names the DB user, host and tables, and that handler also runs for visitors who never signed in. - The .htaccess denials are anchored to a path segment rather than the root, so videos/ and includes/ stay unreachable under a prefix such as /coursehub/ instead of silently opening up. - A rescan takes a MySQL advisory lock; a second one answers "already running" rather than racing the first and flagging its rows missing. A file swapped in at the same path with a different size drops its cached duration so the probe reads the new length. - stream.php discards output buffering and zlib compression (both break the promised Content-Length) and answers HEAD with headers alone. - A progress save with no duration keeps the length already stored instead of nulling it, and the folder view builds its rollup once. - In the SPA, a view whose data arrives after the route moved on is discarded — previously a slow load could paint over a newer screen or start a heartbeat for a video nobody was watching. Also: the quiz submit button can't be double-fired, leaving a video during the autoplay countdown no longer jumps ahead later, the final position is always saved on 'ended', the playlist scrolls the current entry into its own box, the login field takes focus, views start at the top, and a stray "%" in the hash no longer throws. - Dropped the unused requireLogin(); registerUser() answers "taken" instead of a 500 when two people race for the same name. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AXMrtWNUV7NU1pCUHBG4t1
125 lines
4.1 KiB
PHP
125 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'],
|
|
'image' => $q['image'],
|
|
'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,
|
|
]);
|