Harden auth, add subtitles, vendor all assets locally

- Session cookies are HttpOnly + SameSite=Lax (Secure on HTTPS), with
  strict session-ID mode; the CSRF token from GET api/auth.php must now
  accompany every state-changing request (X-CSRF-Token header, or a csrf
  body field for the sendBeacon progress save), rotated at login.
- Failed sign-ins are throttled per username and IP via login_attempts
  (created lazily, so existing installs need no migration): 5 failures a
  username / 20 an IP inside 15 minutes answer 429 with Retry-After.
- A video's sibling .srt now appears as a CC button in the player;
  captions.php converts it to WebVTT (comma timestamps, legacy encodings,
  and the already-VTT files with out-of-spec 6-digit fractions).
- Plyr and Inter ship in assets/vendor/ instead of coming from CDNs, and
  anime.js is gone along with all scripted animations and the motion
  setting — the app now runs with no internet access at all.
- .htaccess denies direct access to videos/, includes/, config.php and
  schema.sql, so video URLs cannot bypass login on Apache.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SePf61iazhCLJBefdRLBDm
This commit is contained in:
Harwinder Sekhon
2026-07-30 21:14:07 -04:00
parent 0bd11f6ab3
commit 17a401c313
22 changed files with 433 additions and 102 deletions
+36 -13
View File
@@ -1,8 +1,9 @@
# CourseHub
Self-hosted course video player: PHP 8.0+ + MySQL/MariaDB, its own CSS design
system, [Plyr](https://github.com/sampotts/plyr) and
[anime.js](https://animejs.com/). No CSS framework.
system and [Plyr](https://github.com/sampotts/plyr). No CSS framework, no build
step, and no CDN — Plyr and the Inter font are vendored in `assets/vendor/`,
so the app works with no internet access at all.
Single-page app: `index.php` is a static shell (sidebar, search header, mobile
tab bar); all navigation, login, browsing, playback, scanning and settings
@@ -20,7 +21,8 @@ views switch without page reloads and browser back/forward still work:
| `#/search/Q` | courses, videos and quizzes matching `Q` |
| `#/settings` | library folder, preferences, reset |
Only `stream.php` (the mp4 bytes) is a plain HTTP resource.
Only `stream.php` (the mp4 bytes) and `captions.php` (subtitles as WebVTT) are
plain HTTP resources.
- Recursively scans a videos directory for `.mp4` files and mirrors the folder
hierarchy (courses → sections → videos, any nesting depth).
@@ -29,6 +31,9 @@ Only `stream.php` (the mp4 bytes) is a plain HTTP resource.
percentage bar computed over its whole subtree.
- "Continue watching" strip, auto-play next video, playback speed, keyboard
shortcuts (Plyr), completed checkmarks, natural sorting (`Video2` before `Video10`).
- **Subtitles**: a video with a sibling `.srt` file (`Lesson.mp4` +
`Lesson.srt`) gets a CC button in the player — the file is converted to
WebVTT on the fly by `captions.php`, addressed by DB id like the stream.
- **Search** courses, sections, videos and quizzes from the top bar.
- **Reset progress** for one video, one quiz, a whole course/section, or the
entire account.
@@ -168,6 +173,7 @@ from the design rather than an icon font.
Type: **Inter** throughout — headings, interface text, and counts/durations/
paths (the design's own 'Elms Sans' ships only inside the design document).
The font is self-hosted from `assets/vendor/` (latin + latin-ext subsets).
Numeric text uses `tabular-nums` so columns of counts and durations still
line up without a separate monospace face.
@@ -176,11 +182,9 @@ set (clay, sage, slate, sand, plum, sky), picked from its folder id so it stays
the same across renders and reloads. That hue drives its card art, its initial
badge and its progress bar.
anime.js drives the motion: views fade and stagger in, progress bars grow from
zero (so a reset is visible), correct quiz answers settle and wrong ones shake.
Everything is skipped when the CDN script is unavailable, when the visitor has
`prefers-reduced-motion: reduce` set, or when **Animate transitions** is turned
off in settings — the views then render in their final state.
There is no scripted motion — views render directly in their final state; the
only movement is the CSS the components themselves carry (hover states, the
loading spinner).
Below 900 px the layout follows the design's mobile frame: the sidebar gives way
to a bottom tab bar, the search field becomes a pill with a round scan button
@@ -193,8 +197,8 @@ remembers:
- **Library folder** — `VIDEOS_ROOT`, whether it is readable, indexed counts,
total running time, the watched threshold, and when the last scan ran.
- **Preferences**, stored per browser: autoplay next video, animate
transitions, open the library in list view.
- **Preferences**, stored per browser: autoplay next video, open the library
in list view.
- **Reset progress** — the account-wide reset, with a count of how many courses
currently hold progress.
@@ -232,11 +236,30 @@ rather than silent. Server-side, uncaught exceptions *and* fatals (via
`register_shutdown_function`, which `set_exception_handler` never sees) are
returned as JSON, with detail gated behind `DEBUG`.
## Security
- **Sessions**: cookies are set `HttpOnly` + `SameSite=Lax` in code, `Secure`
automatically when the request came over HTTPS, and strict session-ID mode
is on (fixation protection).
- **CSRF**: every state-changing request must echo the session's token —
`fetch()` sends it as an `X-CSRF-Token` header; the `sendBeacon` progress
save carries it as a `csrf` body field. The token is issued by
`GET api/auth.php` and rotated at login.
- **Login throttling**: failed sign-ins are recorded per username + IP
(`login_attempts`, created automatically on first failure, so existing
installs need no migration). Five failures for a username, or twenty for an
IP, inside 15 minutes answer `429` until 15 minutes have passed since the
last failure. A successful login clears the username's failures; stale rows
are purged as new ones are recorded.
- 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.
## Production notes
- PHP streams the video in 512 KB chunks with Range support, which is fine for
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 and set `session.cookie_secure=1`.
- Deny direct web access to `includes/`, `config.php`, and `schema.sql`
(or place the videos and includes outside the docroot).
- Put the app behind HTTPS — the session cookie is then marked `Secure` on its
own.