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:
Harwinder Sekhon
2026-09-06 12:31:21 -04:00
parent 66e4905796
commit 6a4c972c47
12 changed files with 233 additions and 50 deletions
+32 -3
View File
@@ -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?
![quiz image](https://example.com/quiz-images/switching1.png)
- 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.