diff --git a/.htaccess b/.htaccess
index 8cbde36..f544c07 100644
--- a/.htaccess
+++ b/.htaccess
@@ -6,10 +6,15 @@ Options -Indexes
# The video library is served only through stream.php (auth + Range); the
# quiz/caption sources only through their endpoints. Direct URLs would
# 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.
-RedirectMatch 404 ^/includes/
+RedirectMatch 404 (^|/)includes/
Require all denied
diff --git a/README.md b/README.md
index 8b63d18..e23827e 100644
--- a/README.md
+++ b/README.md
@@ -76,6 +76,12 @@ Top-level folders become courses on the home page.
the web server / PHP user).
- DB credentials (change the password in production).
- `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
error responses. Errors go to the PHP error log either way, so leave this
`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
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
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;
@@ -260,8 +279,13 @@ returned as JSON, with detail gated behind `DEBUG`.
can be spoofed to dodge the per-IP limit.
- The shipped `.htaccess` (Apache) denies direct access to `videos/`,
`includes/`, `config.php` and `schema.sql` — without it, video URLs would
- bypass login entirely. Nginx setups need the equivalent `location` blocks,
- or keep the videos and includes outside the docroot.
+ bypass login entirely. The rules match those folder names anywhere in the
+ 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
@@ -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`
(Nginx) or `X-Sendfile` (Apache) instead of streaming through PHP.
- 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.
diff --git a/api/library.php b/api/library.php
index f4f1e9e..dfb7439 100644
--- a/api/library.php
+++ b/api/library.php
@@ -145,13 +145,15 @@ switch ($view) {
break;
}
$crumbs = crumbList($folder);
+ $rollup = folderRollup($userId, $folder['path']);
echo json_encode([
'folder' => ['id' => (int)$folder['id'], 'name' => $folder['name'],
'path' => $folder['path']],
'course_id' => $crumbs ? $crumbs[0]['id'] : (int)$folder['id'],
'breadcrumbs' => $crumbs,
- 'rollup' => folderRollup($userId, $folder['path']),
- 'progress' => folderProgress($userId, $folder['path']),
+ 'rollup' => $rollup,
+ 'progress' => ['total' => $rollup['total'], 'completed' => $rollup['completed'],
+ 'percent' => $rollup['percent']],
'subfolders' => array_map(fn($f) => courseSummary($userId, $f),
childFolders((int)$folder['id'])),
'videos' => array_map('videoSummary',
diff --git a/api/progress.php b/api/progress.php
index 3b6263d..0b2e4e4 100644
--- a/api/progress.php
+++ b/api/progress.php
@@ -51,7 +51,7 @@ $stmt = $db->prepare(
VALUES (?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
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
);
$stmt->execute([$userId, $videoId, $position, $duration > 0 ? $duration : null, $completed]);
diff --git a/api/quiz.php b/api/quiz.php
index 872f74d..555ce08 100644
--- a/api/quiz.php
+++ b/api/quiz.php
@@ -46,6 +46,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
),
'questions' => array_map(fn($q) => [
'text' => $q['text'],
+ 'image' => $q['image'],
'multi' => $q['multi'],
'options' => $q['options'],
], $questions),
diff --git a/assets/app.js b/assets/app.js
index c31ec22..d972be7 100644
--- a/assets/app.js
+++ b/assets/app.js
@@ -20,7 +20,6 @@
csrf: null, // session token, echoed back on every POST
meta: null, // totals / shelves / last scan, for the chrome
suppressSave: false, // set while a reset lands, so teardown doesn't re-save
- searchSeq: 0,
scanning: false,
};
let cleanupView = null;
@@ -166,6 +165,23 @@
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 = '
'; };
/* ---------- toast / dialog / menu ---------- */
@@ -457,6 +473,7 @@
`;
const form = document.getElementById('login-form');
+ document.getElementById('f-user').focus(); // the autofocus attribute is inert on injected markup
let action = 'login';
form.querySelectorAll('button[data-action]').forEach((b) =>
b.addEventListener('click', () => {
@@ -546,7 +563,7 @@
async function renderHome() {
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 progressBlock = inProgress.length
@@ -591,8 +608,9 @@
/* ---------- library ---------- */
async function renderLibrary(filter) {
+ if (filter && !(filter in SHELF_NAME)) filter = null; // #/library/whatever → every course
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 mode = pref('view');
@@ -657,7 +675,7 @@
async function renderFolder(id) {
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 hue = hueOf(data.course_id);
@@ -714,8 +732,6 @@
/* ---------- search ---------- */
async function renderSearch(query) {
- const seq = ++state.searchSeq;
-
if (!query) {
view.innerHTML = `
@@ -727,8 +743,7 @@
}
spinner();
- const data = await api(`api/library.php?view=search&q=${encodeURIComponent(query)}`);
- if (seq !== state.searchSeq) return; // a newer keystroke already won
+ const data = await load(`api/library.php?view=search&q=${encodeURIComponent(query)}`);
const total = data.folders.length + data.videos.length + data.quizzes.length;
@@ -775,7 +790,7 @@
async function renderWatch(id) {
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 active = p.id === data.video.id;
@@ -827,6 +842,15 @@
`;
+ // 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);
}
@@ -834,7 +858,7 @@
async function renderQuiz(id) {
spinner();
- const data = await api(`api/quiz.php?id=${id}`);
+ const data = await load(`api/quiz.php?id=${id}`);
const answers = data.questions.map(() => []);
let graded = null; // server results once submitted
@@ -857,6 +881,10 @@
${qi + 1}