Show quiz exhibits, keep sign-ins alive, and close a batch of edge cases
- 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. Claude-Session: https://claude.ai/code/session_01AXMrtWNUV7NU1pCUHBG4t1
This commit is contained in:
+37
-14
@@ -1,14 +1,40 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/db.php';
|
||||
|
||||
/**
|
||||
* Whether the visitor reached us over HTTPS. Behind the proxy that
|
||||
* CLIENT_IP_HEADER already declares trustworthy (Cloudflare), the origin
|
||||
* connection may be plain HTTP while the visitor's is TLS; the proxy says so in
|
||||
* X-Forwarded-Proto. Without that setting only the connection itself counts —
|
||||
* the header is spoofable by anyone who can reach the origin directly.
|
||||
*/
|
||||
function requestIsHttps(): bool
|
||||
{
|
||||
if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
|
||||
return true;
|
||||
}
|
||||
if (defined('CLIENT_IP_HEADER') && CLIENT_IP_HEADER !== '') {
|
||||
$proto = strtolower(trim(explode(',', $_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '')[0]));
|
||||
return $proto === 'https';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
// SESSION_LIFETIME (seconds) keeps people signed in across browser restarts
|
||||
// and idle stretches. 0 / undefined = a browser-session cookie plus the
|
||||
// php.ini idle limit (24 min by default — short for a video app).
|
||||
$lifetime = defined('SESSION_LIFETIME') ? max(0, (int)SESSION_LIFETIME) : 0;
|
||||
if ($lifetime > 0) {
|
||||
ini_set('session.gc_maxlifetime', (string)$lifetime);
|
||||
}
|
||||
// Reject session IDs the server never issued (blocks session fixation),
|
||||
// and keep the cookie away from scripts and cross-site requests.
|
||||
ini_set('session.use_strict_mode', '1');
|
||||
session_set_cookie_params([
|
||||
'lifetime' => 0,
|
||||
'lifetime' => $lifetime,
|
||||
'path' => '/',
|
||||
'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
|
||||
'secure' => requestIsHttps(),
|
||||
'httponly' => true,
|
||||
'samesite' => 'Lax',
|
||||
]);
|
||||
@@ -52,16 +78,6 @@ function currentUsername(): ?string
|
||||
return $_SESSION['username'] ?? null;
|
||||
}
|
||||
|
||||
function requireLogin(): int
|
||||
{
|
||||
$uid = currentUserId();
|
||||
if ($uid === null) {
|
||||
header('Location: login.php');
|
||||
exit;
|
||||
}
|
||||
return $uid;
|
||||
}
|
||||
|
||||
function requireLoginApi(): int
|
||||
{
|
||||
$uid = currentUserId();
|
||||
@@ -198,7 +214,14 @@ function registerUser(string $username, string $password): string|bool
|
||||
if ($stmt->fetch()) {
|
||||
return 'That username is already taken.';
|
||||
}
|
||||
$stmt = db()->prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)');
|
||||
$stmt->execute([$username, password_hash($password, PASSWORD_DEFAULT)]);
|
||||
try {
|
||||
$stmt = db()->prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)');
|
||||
$stmt->execute([$username, password_hash($password, PASSWORD_DEFAULT)]);
|
||||
} catch (PDOException $e) {
|
||||
if ($e->getCode() === '23000') { // lost the race to the same name
|
||||
return 'That username is already taken.';
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
+7
-2
@@ -32,9 +32,13 @@ if (PHP_SAPI !== 'cli') {
|
||||
|
||||
// Uncaught exceptions become JSON errors (the SPA surfaces them) instead
|
||||
// of blank 500 pages.
|
||||
// The message itself is DEBUG-only too: a PDOException spells out the DB
|
||||
// user, host and table names, and this handler also runs for visitors who
|
||||
// never logged in (the sign-in call touches the database).
|
||||
set_exception_handler(function (Throwable $e) use ($emit): void {
|
||||
error_log($e);
|
||||
$emit('Server error: ' . $e->getMessage(), [
|
||||
$emit(DEBUG ? 'Server error: ' . $e->getMessage()
|
||||
: 'Server error — the details are in the PHP error log.', [
|
||||
'type' => get_class($e),
|
||||
'file' => $e->getFile() . ':' . $e->getLine(),
|
||||
'trace' => explode("\n", $e->getTraceAsString()),
|
||||
@@ -49,7 +53,8 @@ if (PHP_SAPI !== 'cli') {
|
||||
if ($err === null || !($err['type'] & (E_ERROR | E_PARSE | E_CORE_ERROR | E_COMPILE_ERROR))) {
|
||||
return;
|
||||
}
|
||||
$emit('Fatal error: ' . $err['message'], [
|
||||
$emit(DEBUG ? 'Fatal error: ' . $err['message']
|
||||
: 'Server error — the details are in the PHP error log.', [
|
||||
'file' => $err['file'] . ':' . $err['line'],
|
||||
]);
|
||||
});
|
||||
|
||||
+33
-3
@@ -83,9 +83,13 @@ function mp4Duration(string $path): ?float
|
||||
* Quiz file parser
|
||||
* Format ("*_quiz_solved.txt"):
|
||||
* [&] Question text (Select two)
|
||||
*  <- optional, one per question
|
||||
* - wrong option
|
||||
* - right option -- Correct
|
||||
* Returns [['text', 'options' => [...], 'correct' => [optIndex,...], 'multi'], ...]
|
||||
* A Markdown image line anywhere inside a question is its exhibit (the
|
||||
* diagram the question refers to); it is kept out of the question text and
|
||||
* returned as ['url', 'alt'] so the UI can show the picture.
|
||||
* Returns [['text', 'image' => ?[...], 'options' => [...], 'correct' => [optIndex,...], 'multi'], ...]
|
||||
* ------------------------------------------------------------------------- */
|
||||
function parseQuizFile(string $absPath): array
|
||||
{
|
||||
@@ -100,7 +104,9 @@ function parseQuizFile(string $absPath): array
|
||||
|
||||
if (str_starts_with($line, '[&]')) {
|
||||
if ($cur && $cur['options']) $questions[] = $cur;
|
||||
$cur = ['text' => trim(substr($line, 3)), 'options' => [], 'correct' => []];
|
||||
$cur = ['text' => trim(substr($line, 3)), 'image' => null, 'options' => [], 'correct' => []];
|
||||
} elseif ($cur !== null && preg_match('/^!\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)$/i', $line, $m)) {
|
||||
$cur['image'] = ['url' => $m[2], 'alt' => trim($m[1]) !== '' ? trim($m[1]) : 'Exhibit'];
|
||||
} elseif (str_starts_with($line, '-') && $cur !== null) {
|
||||
$opt = trim(substr($line, 1));
|
||||
if (preg_match('/\s*--\s*correct\s*$/i', $opt)) {
|
||||
@@ -135,6 +141,23 @@ function scanLibrary(): array
|
||||
}
|
||||
|
||||
$db = db();
|
||||
|
||||
// One scan at a time. Two overlapping scans would upsert the same rows
|
||||
// inside two transactions and then each try to flag the other's work
|
||||
// missing; the lock is per-connection and released below whatever happens.
|
||||
if ((int)$db->query("SELECT GET_LOCK('coursehub_scan', 0)")->fetchColumn() !== 1) {
|
||||
throw new RuntimeException('A scan is already running — try again in a moment.');
|
||||
}
|
||||
try {
|
||||
return scanLibraryLocked($db, $root);
|
||||
} finally {
|
||||
$db->query("SELECT RELEASE_LOCK('coursehub_scan')");
|
||||
}
|
||||
}
|
||||
|
||||
/** The scan proper — only ever called by scanLibrary() while it holds the lock. */
|
||||
function scanLibraryLocked(PDO $db, string $root): array
|
||||
{
|
||||
$stats = ['folders' => 0, 'videos' => 0, 'quizzes' => 0,
|
||||
'missing_folders' => 0, 'missing_videos' => 0, 'missing_quizzes' => 0];
|
||||
$seenFolderIds = [];
|
||||
@@ -148,10 +171,17 @@ function scanLibrary(): array
|
||||
depth = VALUES(depth), sort_order = VALUES(sort_order), is_missing = 0,
|
||||
id = LAST_INSERT_ID(id)'
|
||||
);
|
||||
// A file replaced by a different one at the same path (re-encoded, a
|
||||
// corrected upload) is a different length: forget the cached duration so
|
||||
// the probe below reads it afresh. The assignment order matters — MySQL
|
||||
// evaluates ON DUPLICATE KEY UPDATE left to right, so the comparison has
|
||||
// to happen before size_bytes is overwritten.
|
||||
$upsertVideo = $db->prepare(
|
||||
'INSERT INTO videos (folder_id, name, filename, path, size_bytes, sort_order, is_missing)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 0)
|
||||
ON DUPLICATE KEY UPDATE folder_id = VALUES(folder_id), name = VALUES(name),
|
||||
ON DUPLICATE KEY UPDATE
|
||||
duration_seconds = IF(size_bytes <> VALUES(size_bytes), NULL, duration_seconds),
|
||||
folder_id = VALUES(folder_id), name = VALUES(name),
|
||||
filename = VALUES(filename), size_bytes = VALUES(size_bytes),
|
||||
sort_order = VALUES(sort_order), is_missing = 0,
|
||||
id = LAST_INSERT_ID(id)'
|
||||
|
||||
Reference in New Issue
Block a user