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. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AXMrtWNUV7NU1pCUHBG4t1
This commit is contained in:
@@ -6,10 +6,15 @@ Options -Indexes
|
|||||||
# The video library is served only through stream.php (auth + Range); the
|
# The video library is served only through stream.php (auth + Range); the
|
||||||
# quiz/caption sources only through their endpoints. Direct URLs would
|
# quiz/caption sources only through their endpoints. Direct URLs would
|
||||||
# bypass login entirely.
|
# bypass login entirely.
|
||||||
RedirectMatch 404 ^/videos/
|
#
|
||||||
|
# RedirectMatch always sees the full URL path, even from a .htaccess, so the
|
||||||
|
# pattern must not assume the app lives at "/" — anchored to the root it
|
||||||
|
# silently stops matching (and stops protecting) an install under
|
||||||
|
# "/coursehub/" or any other prefix.
|
||||||
|
RedirectMatch 404 (^|/)videos/
|
||||||
|
|
||||||
# App internals — nothing under these is meant to be fetched.
|
# App internals — nothing under these is meant to be fetched.
|
||||||
RedirectMatch 404 ^/includes/
|
RedirectMatch 404 (^|/)includes/
|
||||||
<FilesMatch "^(config\.php|config\.example\.php|schema\.sql|\.install\.lock|\.gitignore)$">
|
<FilesMatch "^(config\.php|config\.example\.php|schema\.sql|\.install\.lock|\.gitignore)$">
|
||||||
Require all denied
|
Require all denied
|
||||||
</FilesMatch>
|
</FilesMatch>
|
||||||
|
|||||||
@@ -76,6 +76,12 @@ Top-level folders become courses on the home page.
|
|||||||
the web server / PHP user).
|
the web server / PHP user).
|
||||||
- DB credentials (change the password in production).
|
- DB credentials (change the password in production).
|
||||||
- `ALLOW_REGISTRATION`: set `false` once your users have accounts.
|
- `ALLOW_REGISTRATION`: set `false` once your users have accounts.
|
||||||
|
- `SESSION_LIFETIME`: seconds a sign-in lasts (the example config says 30
|
||||||
|
days). Unset or `0` means a browser-session cookie that also dies after
|
||||||
|
PHP's idle limit — 24 minutes by default, which signs people out over a
|
||||||
|
long pause. Installs whose `config.php` predates the setting keep that
|
||||||
|
old behaviour. If other PHP apps share the server's session directory,
|
||||||
|
give this app its own `session.save_path` in the vhost / FPM pool.
|
||||||
- `DEBUG`: include the exception type, `file:line` and stack trace in API
|
- `DEBUG`: include the exception type, `file:line` and stack trace in API
|
||||||
error responses. Errors go to the PHP error log either way, so leave this
|
error responses. Errors go to the PHP error log either way, so leave this
|
||||||
`false` on anything publicly reachable. Installs whose `config.php`
|
`false` on anything publicly reachable. Installs whose `config.php`
|
||||||
@@ -122,6 +128,19 @@ Questions start with `[&]`, options with `- `, and correct options end with
|
|||||||
`-- Correct`. Questions with more than one correct option automatically render
|
`-- Correct`. Questions with more than one correct option automatically render
|
||||||
as checkboxes instead of radios.
|
as checkboxes instead of radios.
|
||||||
|
|
||||||
|
A question that refers to a diagram carries it as a Markdown image on its own
|
||||||
|
line, anywhere between the `[&]` line and the next question:
|
||||||
|
|
||||||
|
```
|
||||||
|
[&] Based on the diagram shown, which of the following are true?
|
||||||
|

|
||||||
|
- PE1 imposes a label -- Correct
|
||||||
|
```
|
||||||
|
|
||||||
|
The image is shown under the question text. It is fetched by the browser from
|
||||||
|
wherever the URL points (usually the course provider's server), so an install
|
||||||
|
with no internet access sees a note and a link instead of the picture.
|
||||||
|
|
||||||
Quizzes are graded server-side (answers are never sent to the browser before
|
Quizzes are graded server-side (answers are never sent to the browser before
|
||||||
submission). A question counts only if the selected set exactly matches the
|
submission). A question counts only if the selected set exactly matches the
|
||||||
correct set. Best score, last score, and attempt count are stored per user;
|
correct set. Best score, last score, and attempt count are stored per user;
|
||||||
@@ -260,8 +279,13 @@ returned as JSON, with detail gated behind `DEBUG`.
|
|||||||
can be spoofed to dodge the per-IP limit.
|
can be spoofed to dodge the per-IP limit.
|
||||||
- The shipped `.htaccess` (Apache) denies direct access to `videos/`,
|
- The shipped `.htaccess` (Apache) denies direct access to `videos/`,
|
||||||
`includes/`, `config.php` and `schema.sql` — without it, video URLs would
|
`includes/`, `config.php` and `schema.sql` — without it, video URLs would
|
||||||
bypass login entirely. Nginx setups need the equivalent `location` blocks,
|
bypass login entirely. The rules match those folder names anywhere in the
|
||||||
or keep the videos and includes outside the docroot.
|
URL path, so they hold whether the app lives at `/` or under a prefix such as
|
||||||
|
`/coursehub/`. Nginx setups need the equivalent `location` blocks, or keep
|
||||||
|
the videos and includes outside the docroot (the safest choice either way).
|
||||||
|
- Only `.mp4` files ever reach the browser, and only through `stream.php`.
|
||||||
|
A quiz's exhibit image is the one thing loaded from elsewhere — its URL is
|
||||||
|
whatever the quiz file says, sent with `referrerpolicy="no-referrer"`.
|
||||||
|
|
||||||
## Production notes
|
## Production notes
|
||||||
|
|
||||||
@@ -269,4 +293,9 @@ returned as JSON, with detail gated behind `DEBUG`.
|
|||||||
a handful of users. For heavier use, serve files with `X-Accel-Redirect`
|
a handful of users. For heavier use, serve files with `X-Accel-Redirect`
|
||||||
(Nginx) or `X-Sendfile` (Apache) instead of streaming through PHP.
|
(Nginx) or `X-Sendfile` (Apache) instead of streaming through PHP.
|
||||||
- Put the app behind HTTPS — the session cookie is then marked `Secure` on its
|
- Put the app behind HTTPS — the session cookie is then marked `Secure` on its
|
||||||
own.
|
own. When TLS ends at a proxy that talks plain HTTP to the origin, the
|
||||||
|
cookie is still marked `Secure` provided `CLIENT_IP_HEADER` is set: the same
|
||||||
|
trust that makes the proxy's client-IP header believable makes its
|
||||||
|
`X-Forwarded-Proto` believable.
|
||||||
|
- A rescan is one-at-a-time: a second **Scan library** while one is running
|
||||||
|
(from any user) answers "already running" instead of racing it.
|
||||||
|
|||||||
+4
-2
@@ -145,13 +145,15 @@ switch ($view) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
$crumbs = crumbList($folder);
|
$crumbs = crumbList($folder);
|
||||||
|
$rollup = folderRollup($userId, $folder['path']);
|
||||||
echo json_encode([
|
echo json_encode([
|
||||||
'folder' => ['id' => (int)$folder['id'], 'name' => $folder['name'],
|
'folder' => ['id' => (int)$folder['id'], 'name' => $folder['name'],
|
||||||
'path' => $folder['path']],
|
'path' => $folder['path']],
|
||||||
'course_id' => $crumbs ? $crumbs[0]['id'] : (int)$folder['id'],
|
'course_id' => $crumbs ? $crumbs[0]['id'] : (int)$folder['id'],
|
||||||
'breadcrumbs' => $crumbs,
|
'breadcrumbs' => $crumbs,
|
||||||
'rollup' => folderRollup($userId, $folder['path']),
|
'rollup' => $rollup,
|
||||||
'progress' => folderProgress($userId, $folder['path']),
|
'progress' => ['total' => $rollup['total'], 'completed' => $rollup['completed'],
|
||||||
|
'percent' => $rollup['percent']],
|
||||||
'subfolders' => array_map(fn($f) => courseSummary($userId, $f),
|
'subfolders' => array_map(fn($f) => courseSummary($userId, $f),
|
||||||
childFolders((int)$folder['id'])),
|
childFolders((int)$folder['id'])),
|
||||||
'videos' => array_map('videoSummary',
|
'videos' => array_map('videoSummary',
|
||||||
|
|||||||
+1
-1
@@ -51,7 +51,7 @@ $stmt = $db->prepare(
|
|||||||
VALUES (?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?)
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
position_seconds = VALUES(position_seconds),
|
position_seconds = VALUES(position_seconds),
|
||||||
duration_seconds = VALUES(duration_seconds),
|
duration_seconds = COALESCE(VALUES(duration_seconds), duration_seconds), -- keep a known length
|
||||||
completed = GREATEST(completed, VALUES(completed))' // once complete, stays complete
|
completed = GREATEST(completed, VALUES(completed))' // once complete, stays complete
|
||||||
);
|
);
|
||||||
$stmt->execute([$userId, $videoId, $position, $duration > 0 ? $duration : null, $completed]);
|
$stmt->execute([$userId, $videoId, $position, $duration > 0 ? $duration : null, $completed]);
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
|||||||
),
|
),
|
||||||
'questions' => array_map(fn($q) => [
|
'questions' => array_map(fn($q) => [
|
||||||
'text' => $q['text'],
|
'text' => $q['text'],
|
||||||
|
'image' => $q['image'],
|
||||||
'multi' => $q['multi'],
|
'multi' => $q['multi'],
|
||||||
'options' => $q['options'],
|
'options' => $q['options'],
|
||||||
], $questions),
|
], $questions),
|
||||||
|
|||||||
+77
-23
@@ -20,7 +20,6 @@
|
|||||||
csrf: null, // session token, echoed back on every POST
|
csrf: null, // session token, echoed back on every POST
|
||||||
meta: null, // totals / shelves / last scan, for the chrome
|
meta: null, // totals / shelves / last scan, for the chrome
|
||||||
suppressSave: false, // set while a reset lands, so teardown doesn't re-save
|
suppressSave: false, // set while a reset lands, so teardown doesn't re-save
|
||||||
searchSeq: 0,
|
|
||||||
scanning: false,
|
scanning: false,
|
||||||
};
|
};
|
||||||
let cleanupView = null;
|
let cleanupView = null;
|
||||||
@@ -166,6 +165,23 @@
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Every route() bumps this. A view that was still waiting on its data when
|
||||||
|
the route moved on must not paint over the newer view — nor, for the
|
||||||
|
player, start a progress heartbeat for a video nobody is watching. */
|
||||||
|
let routeSeq = 0;
|
||||||
|
class StaleRender extends Error {}
|
||||||
|
|
||||||
|
/** api() for a view's data: throws StaleRender if the route changed meanwhile. */
|
||||||
|
async function load(url) {
|
||||||
|
const seq = routeSeq;
|
||||||
|
const data = await api(url);
|
||||||
|
if (seq !== routeSeq) throw new StaleRender('route changed');
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** decodeURIComponent that survives a stray "%" typed into the URL bar. */
|
||||||
|
const safeDecode = (s) => { try { return decodeURIComponent(s); } catch (e) { return s; } };
|
||||||
|
|
||||||
const spinner = () => { view.innerHTML = '<div class="loading"><span class="spinner"></span></div>'; };
|
const spinner = () => { view.innerHTML = '<div class="loading"><span class="spinner"></span></div>'; };
|
||||||
|
|
||||||
/* ---------- toast / dialog / menu ---------- */
|
/* ---------- toast / dialog / menu ---------- */
|
||||||
@@ -457,6 +473,7 @@
|
|||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
const form = document.getElementById('login-form');
|
const form = document.getElementById('login-form');
|
||||||
|
document.getElementById('f-user').focus(); // the autofocus attribute is inert on injected markup
|
||||||
let action = 'login';
|
let action = 'login';
|
||||||
form.querySelectorAll('button[data-action]').forEach((b) =>
|
form.querySelectorAll('button[data-action]').forEach((b) =>
|
||||||
b.addEventListener('click', () => {
|
b.addEventListener('click', () => {
|
||||||
@@ -546,7 +563,7 @@
|
|||||||
|
|
||||||
async function renderHome() {
|
async function renderHome() {
|
||||||
spinner();
|
spinner();
|
||||||
const data = await api('api/library.php?view=home');
|
const data = await load('api/library.php?view=home');
|
||||||
|
|
||||||
const inProgress = data.in_progress.slice(0, 4);
|
const inProgress = data.in_progress.slice(0, 4);
|
||||||
const progressBlock = inProgress.length
|
const progressBlock = inProgress.length
|
||||||
@@ -591,8 +608,9 @@
|
|||||||
/* ---------- library ---------- */
|
/* ---------- library ---------- */
|
||||||
|
|
||||||
async function renderLibrary(filter) {
|
async function renderLibrary(filter) {
|
||||||
|
if (filter && !(filter in SHELF_NAME)) filter = null; // #/library/whatever → every course
|
||||||
spinner();
|
spinner();
|
||||||
const data = await api('api/library.php?view=library');
|
const data = await load('api/library.php?view=library');
|
||||||
|
|
||||||
const layout = filter && filter !== 'all' ? 'catalog' : pref('layout');
|
const layout = filter && filter !== 'all' ? 'catalog' : pref('layout');
|
||||||
const mode = pref('view');
|
const mode = pref('view');
|
||||||
@@ -657,7 +675,7 @@
|
|||||||
|
|
||||||
async function renderFolder(id) {
|
async function renderFolder(id) {
|
||||||
spinner();
|
spinner();
|
||||||
const data = await api(`api/library.php?view=folder&id=${id}`);
|
const data = await load(`api/library.php?view=folder&id=${id}`);
|
||||||
const mode = pref('view');
|
const mode = pref('view');
|
||||||
const hue = hueOf(data.course_id);
|
const hue = hueOf(data.course_id);
|
||||||
|
|
||||||
@@ -714,8 +732,6 @@
|
|||||||
/* ---------- search ---------- */
|
/* ---------- search ---------- */
|
||||||
|
|
||||||
async function renderSearch(query) {
|
async function renderSearch(query) {
|
||||||
const seq = ++state.searchSeq;
|
|
||||||
|
|
||||||
if (!query) {
|
if (!query) {
|
||||||
view.innerHTML = `
|
view.innerHTML = `
|
||||||
<div class="page-mid">
|
<div class="page-mid">
|
||||||
@@ -727,8 +743,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
spinner();
|
spinner();
|
||||||
const data = await api(`api/library.php?view=search&q=${encodeURIComponent(query)}`);
|
const data = await load(`api/library.php?view=search&q=${encodeURIComponent(query)}`);
|
||||||
if (seq !== state.searchSeq) return; // a newer keystroke already won
|
|
||||||
|
|
||||||
const total = data.folders.length + data.videos.length + data.quizzes.length;
|
const total = data.folders.length + data.videos.length + data.quizzes.length;
|
||||||
|
|
||||||
@@ -775,7 +790,7 @@
|
|||||||
|
|
||||||
async function renderWatch(id) {
|
async function renderWatch(id) {
|
||||||
spinner();
|
spinner();
|
||||||
const data = await api(`api/library.php?view=video&id=${id}`);
|
const data = await load(`api/library.php?view=video&id=${id}`);
|
||||||
|
|
||||||
const playlist = data.playlist.map((p) => {
|
const playlist = data.playlist.map((p) => {
|
||||||
const active = p.id === data.video.id;
|
const active = p.id === data.video.id;
|
||||||
@@ -827,6 +842,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
|
// Bring the current entry into the playlist's own scroll box (not the
|
||||||
|
// page's — scrollIntoView would drag the viewport down to it on mobile).
|
||||||
|
const scroller = view.querySelector('.playlist-scroll');
|
||||||
|
const current = scroller && scroller.querySelector('.is-active');
|
||||||
|
if (current) {
|
||||||
|
const offset = current.getBoundingClientRect().top - scroller.getBoundingClientRect().top;
|
||||||
|
scroller.scrollTop += offset - (scroller.clientHeight - current.offsetHeight) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
cleanupView = initPlayer(data);
|
cleanupView = initPlayer(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -834,7 +858,7 @@
|
|||||||
|
|
||||||
async function renderQuiz(id) {
|
async function renderQuiz(id) {
|
||||||
spinner();
|
spinner();
|
||||||
const data = await api(`api/quiz.php?id=${id}`);
|
const data = await load(`api/quiz.php?id=${id}`);
|
||||||
|
|
||||||
const answers = data.questions.map(() => []);
|
const answers = data.questions.map(() => []);
|
||||||
let graded = null; // server results once submitted
|
let graded = null; // server results once submitted
|
||||||
@@ -857,6 +881,10 @@
|
|||||||
<span class="q-num">${qi + 1}</span>
|
<span class="q-num">${qi + 1}</span>
|
||||||
<div style="flex:1">
|
<div style="flex:1">
|
||||||
<div class="q-text">${esc(q.text)}</div>
|
<div class="q-text">${esc(q.text)}</div>
|
||||||
|
${q.image ? `<figure class="q-exhibit">
|
||||||
|
<img src="${esc(q.image.url)}" alt="${esc(q.image.alt)}" loading="lazy" referrerpolicy="no-referrer">
|
||||||
|
<figcaption>${esc(q.image.alt)} · <a href="${esc(q.image.url)}" target="_blank" rel="noopener">open</a></figcaption>
|
||||||
|
</figure>` : ''}
|
||||||
${q.multi && !r ? '<div class="q-note" style="color:var(--ink-3)">Select every answer that applies.</div>' : ''}
|
${q.multi && !r ? '<div class="q-note" style="color:var(--ink-3)">Select every answer that applies.</div>' : ''}
|
||||||
${r ? `<div class="q-note${r.correct ? '' : ' is-wrong'}">${r.correct
|
${r ? `<div class="q-note${r.correct ? '' : ' is-wrong'}">${r.correct
|
||||||
? 'Correct.' : 'Not quite — the right answer is highlighted below.'}</div>` : ''}
|
? 'Correct.' : 'Not quite — the right answer is highlighted below.'}</div>` : ''}
|
||||||
@@ -916,9 +944,17 @@
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
view.onclick = null;
|
|
||||||
draw();
|
draw();
|
||||||
|
|
||||||
|
// Exhibits are external images; when one can't load, say so instead of
|
||||||
|
// leaving a blank box (the caption's link still opens the original).
|
||||||
|
const onImageError = (e) => {
|
||||||
|
const fig = e.target.closest && e.target.closest('.q-exhibit');
|
||||||
|
if (fig) fig.classList.add('is-broken');
|
||||||
|
};
|
||||||
|
view.addEventListener('error', onImageError, true); // error events don't bubble
|
||||||
|
|
||||||
|
let submitting = false;
|
||||||
// One delegated handler for the whole quiz view, re-bound on each draw.
|
// One delegated handler for the whole quiz view, re-bound on each draw.
|
||||||
const onClick = async (e) => {
|
const onClick = async (e) => {
|
||||||
const opt = e.target.closest('.q-option');
|
const opt = e.target.closest('.q-option');
|
||||||
@@ -934,7 +970,11 @@
|
|||||||
draw();
|
draw();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.target.closest('[data-quiz-submit]') && !graded) {
|
const submit = e.target.closest('[data-quiz-submit]');
|
||||||
|
if (submit && !graded) {
|
||||||
|
if (submitting) return; // a second click while the first is in flight
|
||||||
|
submitting = true;
|
||||||
|
submit.disabled = true;
|
||||||
try {
|
try {
|
||||||
graded = await api('api/quiz.php', { json: { quiz_id: id, answers } });
|
graded = await api('api/quiz.php', { json: { quiz_id: id, answers } });
|
||||||
draw();
|
draw();
|
||||||
@@ -943,7 +983,10 @@
|
|||||||
: `Scored ${graded.score}/${graded.total}.`, graded.completed ? 'ok' : 'error');
|
: `Scored ${graded.score}/${graded.total}.`, graded.completed ? 'ok' : 'error');
|
||||||
loadMeta();
|
loadMeta();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
submit.disabled = false;
|
||||||
toast(err.message, 'error');
|
toast(err.message, 'error');
|
||||||
|
} finally {
|
||||||
|
submitting = false;
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -954,14 +997,17 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
view.addEventListener('click', onClick);
|
view.addEventListener('click', onClick);
|
||||||
cleanupView = () => view.removeEventListener('click', onClick);
|
cleanupView = () => {
|
||||||
|
view.removeEventListener('click', onClick);
|
||||||
|
view.removeEventListener('error', onImageError, true);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- settings ---------- */
|
/* ---------- settings ---------- */
|
||||||
|
|
||||||
async function renderSettings() {
|
async function renderSettings() {
|
||||||
spinner();
|
spinner();
|
||||||
const data = await api('api/library.php?view=settings');
|
const data = await load('api/library.php?view=settings');
|
||||||
|
|
||||||
const toggles = [
|
const toggles = [
|
||||||
{ key: 'autoplay', label: 'Autoplay next video',
|
{ key: 'autoplay', label: 'Autoplay next video',
|
||||||
@@ -1074,10 +1120,10 @@
|
|||||||
|
|
||||||
const status = (html) => { if (statusEl) statusEl.innerHTML = html; };
|
const status = (html) => { if (statusEl) statusEl.innerHTML = html; };
|
||||||
|
|
||||||
async function save() {
|
async function save(force = false) {
|
||||||
const p = payload();
|
const p = payload();
|
||||||
if (state.suppressSave) return; // a reset just wiped this row — don't recreate it
|
if (state.suppressSave) return; // a reset just wiped this row — don't recreate it
|
||||||
if (!p.duration || Math.abs(p.position - lastSaved) < 1) return;
|
if (!p.duration || (!force && Math.abs(p.position - lastSaved) < 1)) return;
|
||||||
lastSaved = p.position;
|
lastSaved = p.position;
|
||||||
try {
|
try {
|
||||||
const res = await api('api/progress.php', { json: p, keepalive: true });
|
const res = await api('api/progress.php', { json: p, keepalive: true });
|
||||||
@@ -1095,14 +1141,15 @@
|
|||||||
.map(([k, v]) => [k, String(v)])));
|
.map(([k, v]) => [k, String(v)])));
|
||||||
}
|
}
|
||||||
|
|
||||||
player.on('playing', () => { if (!saveTimer) saveTimer = setInterval(save, 5000); });
|
let nextTimer = null;
|
||||||
player.on('pause', save);
|
player.on('playing', () => { if (!saveTimer) saveTimer = setInterval(() => save(), 5000); });
|
||||||
player.on('seeked', save);
|
player.on('pause', () => save());
|
||||||
|
player.on('seeked', () => save());
|
||||||
player.on('ended', () => {
|
player.on('ended', () => {
|
||||||
save();
|
save(true); // the final position always lands, even within 1 s of the last heartbeat
|
||||||
if (data.next_id && pref('autoplay') !== false) {
|
if (data.next_id && pref('autoplay') !== false) {
|
||||||
status(`${icon('next', 14, 1.7)}Playing next video…`);
|
status(`${icon('next', 14, 1.7)}Playing next video…`);
|
||||||
setTimeout(() => { location.hash = `#/watch/${data.next_id}`; }, 1500);
|
nextTimer = setTimeout(() => { location.hash = `#/watch/${data.next_id}`; }, 1500);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1112,6 +1159,7 @@
|
|||||||
|
|
||||||
return function cleanup() {
|
return function cleanup() {
|
||||||
saveBeacon();
|
saveBeacon();
|
||||||
|
clearTimeout(nextTimer); // leaving during the 1.5 s countdown must not jump ahead later
|
||||||
if (saveTimer) clearInterval(saveTimer);
|
if (saveTimer) clearInterval(saveTimer);
|
||||||
document.removeEventListener('visibilitychange', onHide);
|
document.removeEventListener('visibilitychange', onHide);
|
||||||
window.removeEventListener('pagehide', saveBeacon);
|
window.removeEventListener('pagehide', saveBeacon);
|
||||||
@@ -1195,6 +1243,7 @@
|
|||||||
/* ---------- router ---------- */
|
/* ---------- router ---------- */
|
||||||
|
|
||||||
async function route() {
|
async function route() {
|
||||||
|
routeSeq++; // anything still loading for the previous view is now stale
|
||||||
if (cleanupView) { cleanupView(); cleanupView = null; }
|
if (cleanupView) { cleanupView(); cleanupView = null; }
|
||||||
state.suppressSave = false; // the teardown above is the only thing it guarded
|
state.suppressSave = false; // the teardown above is the only thing it guarded
|
||||||
closeMenu();
|
closeMenu();
|
||||||
@@ -1204,19 +1253,24 @@
|
|||||||
|
|
||||||
const hash = location.hash || '#/';
|
const hash = location.hash || '#/';
|
||||||
const searchMatch = hash.match(/^#\/search\/(.*)$/);
|
const searchMatch = hash.match(/^#\/search\/(.*)$/);
|
||||||
syncSearchInput(searchMatch ? decodeURIComponent(searchMatch[1]) : '');
|
const query = searchMatch ? safeDecode(searchMatch[1]) : '';
|
||||||
|
syncSearchInput(query);
|
||||||
renderChrome();
|
renderChrome();
|
||||||
|
// Views replace each other in the same document, so the page keeps the old
|
||||||
|
// scroll offset unless told otherwise — except while a search is being refined.
|
||||||
|
if (!(searchMatch && document.activeElement === searchInput)) window.scrollTo(0, 0);
|
||||||
|
|
||||||
let m;
|
let m;
|
||||||
try {
|
try {
|
||||||
if ((m = hash.match(/^#\/folder\/(\d+)$/))) await renderFolder(+m[1]);
|
if ((m = hash.match(/^#\/folder\/(\d+)$/))) await renderFolder(+m[1]);
|
||||||
else if ((m = hash.match(/^#\/watch\/(\d+)$/))) await renderWatch(+m[1]);
|
else if ((m = hash.match(/^#\/watch\/(\d+)$/))) await renderWatch(+m[1]);
|
||||||
else if ((m = hash.match(/^#\/quiz\/(\d+)$/))) await renderQuiz(+m[1]);
|
else if ((m = hash.match(/^#\/quiz\/(\d+)$/))) await renderQuiz(+m[1]);
|
||||||
else if (searchMatch) await renderSearch(decodeURIComponent(searchMatch[1]));
|
else if (searchMatch) await renderSearch(query);
|
||||||
else if ((m = hash.match(/^#\/library(?:\/(\w+))?$/))) await renderLibrary(m[1] || null);
|
else if ((m = hash.match(/^#\/library(?:\/(\w+))?$/))) await renderLibrary(m[1] || null);
|
||||||
else if (hash.startsWith('#/settings')) await renderSettings();
|
else if (hash.startsWith('#/settings')) await renderSettings();
|
||||||
else await renderHome();
|
else await renderHome();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (err instanceof StaleRender) return; // a newer route is drawing; nothing to show
|
||||||
if (state.user) {
|
if (state.user) {
|
||||||
view.innerHTML = `<div class="empty">
|
view.innerHTML = `<div class="empty">
|
||||||
<h2>That didn't load</h2>
|
<h2>That didn't load</h2>
|
||||||
|
|||||||
@@ -840,6 +840,19 @@ button, input { font-family: inherit; font-size: inherit; }
|
|||||||
.q-text { font: 600 17px/1.4 var(--font-ui); text-wrap: pretty; }
|
.q-text { font: 600 17px/1.4 var(--font-ui); text-wrap: pretty; }
|
||||||
.q-note { font: 500 15px/1.4 var(--font-ui); margin-top: 6px; color: var(--success); }
|
.q-note { font: 500 15px/1.4 var(--font-ui); margin-top: 6px; color: var(--success); }
|
||||||
.q-note.is-wrong { color: var(--danger); }
|
.q-note.is-wrong { color: var(--danger); }
|
||||||
|
|
||||||
|
/* The diagram a question refers to. It lives on the course provider's server,
|
||||||
|
so it may not load on an offline install — then the caption's link remains. */
|
||||||
|
.q-exhibit { margin: 12px 0 0; }
|
||||||
|
.q-exhibit img {
|
||||||
|
display: block; max-width: 100%; height: auto;
|
||||||
|
border: 1px solid var(--line); border-radius: 8px; background: var(--paper);
|
||||||
|
}
|
||||||
|
.q-exhibit figcaption { margin-top: 6px; font: 500 13px/1.4 var(--font-ui); color: var(--ink-4); }
|
||||||
|
.q-exhibit figcaption a { color: var(--ink-3); }
|
||||||
|
.q-exhibit.is-broken img { display: none; }
|
||||||
|
.q-exhibit.is-broken figcaption::before { content: 'Exhibit could not be loaded — '; color: var(--danger); }
|
||||||
|
|
||||||
.q-options { display: flex; flex-direction: column; gap: 8px; padding-left: 40px; }
|
.q-options { display: flex; flex-direction: column; gap: 8px; padding-left: 40px; }
|
||||||
|
|
||||||
.q-option {
|
.q-option {
|
||||||
|
|||||||
@@ -19,6 +19,15 @@ define('COMPLETE_THRESHOLD', 0.95);
|
|||||||
// Allow new users to self-register from the login page.
|
// Allow new users to self-register from the login page.
|
||||||
define('ALLOW_REGISTRATION', true);
|
define('ALLOW_REGISTRATION', true);
|
||||||
|
|
||||||
|
// How long a sign-in lasts, in seconds (30 days below). The cookie survives
|
||||||
|
// browser restarts, and idle sessions are kept at least this long. 0 = a
|
||||||
|
// browser-session cookie that also expires after php.ini's idle limit
|
||||||
|
// (session.gc_maxlifetime, 24 min by default — pausing a video over lunch
|
||||||
|
// signs you out). If other PHP apps share this server's session directory,
|
||||||
|
// give this app its own session.save_path in the vhost / FPM pool, or their
|
||||||
|
// shorter limit still wins.
|
||||||
|
define('SESSION_LIFETIME', 30 * 86400);
|
||||||
|
|
||||||
// Only when the app sits behind a proxy that hides the visitor's address
|
// Only when the app sits behind a proxy that hides the visitor's address
|
||||||
// (Cloudflare): the header carrying the real client IP, used by the login
|
// (Cloudflare): the header carrying the real client IP, used by the login
|
||||||
// brute-force throttle. Set this ONLY if the origin cannot be reached except
|
// brute-force throttle. Set this ONLY if the origin cannot be reached except
|
||||||
|
|||||||
+35
-12
@@ -1,14 +1,40 @@
|
|||||||
<?php
|
<?php
|
||||||
require_once __DIR__ . '/db.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) {
|
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),
|
// Reject session IDs the server never issued (blocks session fixation),
|
||||||
// and keep the cookie away from scripts and cross-site requests.
|
// and keep the cookie away from scripts and cross-site requests.
|
||||||
ini_set('session.use_strict_mode', '1');
|
ini_set('session.use_strict_mode', '1');
|
||||||
session_set_cookie_params([
|
session_set_cookie_params([
|
||||||
'lifetime' => 0,
|
'lifetime' => $lifetime,
|
||||||
'path' => '/',
|
'path' => '/',
|
||||||
'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
|
'secure' => requestIsHttps(),
|
||||||
'httponly' => true,
|
'httponly' => true,
|
||||||
'samesite' => 'Lax',
|
'samesite' => 'Lax',
|
||||||
]);
|
]);
|
||||||
@@ -52,16 +78,6 @@ function currentUsername(): ?string
|
|||||||
return $_SESSION['username'] ?? null;
|
return $_SESSION['username'] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function requireLogin(): int
|
|
||||||
{
|
|
||||||
$uid = currentUserId();
|
|
||||||
if ($uid === null) {
|
|
||||||
header('Location: login.php');
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
return $uid;
|
|
||||||
}
|
|
||||||
|
|
||||||
function requireLoginApi(): int
|
function requireLoginApi(): int
|
||||||
{
|
{
|
||||||
$uid = currentUserId();
|
$uid = currentUserId();
|
||||||
@@ -198,7 +214,14 @@ function registerUser(string $username, string $password): string|bool
|
|||||||
if ($stmt->fetch()) {
|
if ($stmt->fetch()) {
|
||||||
return 'That username is already taken.';
|
return 'That username is already taken.';
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
$stmt = db()->prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)');
|
$stmt = db()->prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)');
|
||||||
$stmt->execute([$username, password_hash($password, PASSWORD_DEFAULT)]);
|
$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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-2
@@ -32,9 +32,13 @@ if (PHP_SAPI !== 'cli') {
|
|||||||
|
|
||||||
// Uncaught exceptions become JSON errors (the SPA surfaces them) instead
|
// Uncaught exceptions become JSON errors (the SPA surfaces them) instead
|
||||||
// of blank 500 pages.
|
// 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 {
|
set_exception_handler(function (Throwable $e) use ($emit): void {
|
||||||
error_log($e);
|
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),
|
'type' => get_class($e),
|
||||||
'file' => $e->getFile() . ':' . $e->getLine(),
|
'file' => $e->getFile() . ':' . $e->getLine(),
|
||||||
'trace' => explode("\n", $e->getTraceAsString()),
|
'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))) {
|
if ($err === null || !($err['type'] & (E_ERROR | E_PARSE | E_CORE_ERROR | E_COMPILE_ERROR))) {
|
||||||
return;
|
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'],
|
'file' => $err['file'] . ':' . $err['line'],
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
+33
-3
@@ -83,9 +83,13 @@ function mp4Duration(string $path): ?float
|
|||||||
* Quiz file parser
|
* Quiz file parser
|
||||||
* Format ("*_quiz_solved.txt"):
|
* Format ("*_quiz_solved.txt"):
|
||||||
* [&] Question text (Select two)
|
* [&] Question text (Select two)
|
||||||
|
*  <- optional, one per question
|
||||||
* - wrong option
|
* - wrong option
|
||||||
* - right option -- Correct
|
* - 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
|
function parseQuizFile(string $absPath): array
|
||||||
{
|
{
|
||||||
@@ -100,7 +104,9 @@ function parseQuizFile(string $absPath): array
|
|||||||
|
|
||||||
if (str_starts_with($line, '[&]')) {
|
if (str_starts_with($line, '[&]')) {
|
||||||
if ($cur && $cur['options']) $questions[] = $cur;
|
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) {
|
} elseif (str_starts_with($line, '-') && $cur !== null) {
|
||||||
$opt = trim(substr($line, 1));
|
$opt = trim(substr($line, 1));
|
||||||
if (preg_match('/\s*--\s*correct\s*$/i', $opt)) {
|
if (preg_match('/\s*--\s*correct\s*$/i', $opt)) {
|
||||||
@@ -135,6 +141,23 @@ function scanLibrary(): array
|
|||||||
}
|
}
|
||||||
|
|
||||||
$db = db();
|
$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,
|
$stats = ['folders' => 0, 'videos' => 0, 'quizzes' => 0,
|
||||||
'missing_folders' => 0, 'missing_videos' => 0, 'missing_quizzes' => 0];
|
'missing_folders' => 0, 'missing_videos' => 0, 'missing_quizzes' => 0];
|
||||||
$seenFolderIds = [];
|
$seenFolderIds = [];
|
||||||
@@ -148,10 +171,17 @@ function scanLibrary(): array
|
|||||||
depth = VALUES(depth), sort_order = VALUES(sort_order), is_missing = 0,
|
depth = VALUES(depth), sort_order = VALUES(sort_order), is_missing = 0,
|
||||||
id = LAST_INSERT_ID(id)'
|
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(
|
$upsertVideo = $db->prepare(
|
||||||
'INSERT INTO videos (folder_id, name, filename, path, size_bytes, sort_order, is_missing)
|
'INSERT INTO videos (folder_id, name, filename, path, size_bytes, sort_order, is_missing)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, 0)
|
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),
|
filename = VALUES(filename), size_bytes = VALUES(size_bytes),
|
||||||
sort_order = VALUES(sort_order), is_missing = 0,
|
sort_order = VALUES(sort_order), is_missing = 0,
|
||||||
id = LAST_INSERT_ID(id)'
|
id = LAST_INSERT_ID(id)'
|
||||||
|
|||||||
+12
@@ -50,11 +50,23 @@ session_write_close(); // don't block other requests from this use
|
|||||||
ignore_user_abort(false);
|
ignore_user_abort(false);
|
||||||
set_time_limit(0);
|
set_time_limit(0);
|
||||||
|
|
||||||
|
// The bytes must reach the socket as they are read: an output buffer
|
||||||
|
// (php.ini's output_buffering=4096 is common) holds them back, and transparent
|
||||||
|
// compression would change the length Content-Range and Content-Length promise.
|
||||||
|
while (ob_get_level() > 0) {
|
||||||
|
ob_end_clean();
|
||||||
|
}
|
||||||
|
ini_set('zlib.output_compression', '0');
|
||||||
|
|
||||||
header('Content-Type: video/mp4');
|
header('Content-Type: video/mp4');
|
||||||
header('Accept-Ranges: bytes');
|
header('Accept-Ranges: bytes');
|
||||||
header('Content-Length: ' . ($end - $start + 1));
|
header('Content-Length: ' . ($end - $start + 1));
|
||||||
header('Cache-Control: private, max-age=3600');
|
header('Cache-Control: private, max-age=3600');
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'HEAD') {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
$fp = fopen($file, 'rb');
|
$fp = fopen($file, 'rb');
|
||||||
fseek($fp, $start);
|
fseek($fp, $start);
|
||||||
$remaining = $end - $start + 1;
|
$remaining = $end - $start + 1;
|
||||||
|
|||||||
Reference in New Issue
Block a user