e3a054e109
- 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. Claude-Session: https://claude.ai/code/session_01SePf61iazhCLJBefdRLBDm
266 lines
12 KiB
Markdown
266 lines
12 KiB
Markdown
# CourseHub
|
|
|
|
Self-hosted course video player: PHP 8.0+ + MySQL/MariaDB, its own CSS design
|
|
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
|
|
happen over AJAX against the JSON API in `api/` (`auth.php`, `library.php`,
|
|
`progress.php`, `scan.php`, `quiz.php`, `reset.php`), with hash-based routes so
|
|
views switch without page reloads and browser back/forward still work:
|
|
|
|
| Route | Screen |
|
|
| --- | --- |
|
|
| `#/` | home — resume card, stats, in progress, last scan |
|
|
| `#/library`, `#/library/<shelf>` | every course, as shelves or a filtered catalog |
|
|
| `#/folder/N` | a course or section: sections + content |
|
|
| `#/watch/N` | player, playlist, section quizzes |
|
|
| `#/quiz/N` | take and grade a quiz |
|
|
| `#/search/Q` | courses, videos and quizzes matching `Q` |
|
|
| `#/settings` | library folder, preferences, reset |
|
|
|
|
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).
|
|
- Per-user **resume**: log in from any device and continue where you left off.
|
|
- **Progress tracking at every level** — each folder shows completed/total and a
|
|
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.
|
|
- Course and section listings switch between **grid and list** layout, and the
|
|
library between **shelves and catalog**; both choices are remembered.
|
|
- **Shelves** group courses by how far you are: in progress, finished, not
|
|
started. The app has no course categories, so progress is what sorts them.
|
|
- Seekable streaming via HTTP Range requests; files are addressed by DB id only
|
|
(no path traversal).
|
|
|
|
## Layout on disk
|
|
|
|
```
|
|
videos/
|
|
├── Course 1/
|
|
│ ├── Intro/
|
|
│ │ ├── Video1.mp4
|
|
│ │ └── Video2.mp4
|
|
│ └── Module 2/
|
|
│ └── Advanced/
|
|
│ └── lesson.mp4
|
|
└── Course 2/
|
|
└── ...
|
|
```
|
|
|
|
Top-level folders become courses on the home page.
|
|
|
|
## Setup
|
|
|
|
1. **Database** — create the schema and an app user:
|
|
|
|
```bash
|
|
sudo mysql < schema.sql
|
|
sudo mysql -e "CREATE USER IF NOT EXISTS 'coursehub'@'localhost' IDENTIFIED BY 'coursehub';
|
|
GRANT ALL ON coursehub.* TO 'coursehub'@'localhost';"
|
|
```
|
|
|
|
2. **Configure** — edit `config.php`:
|
|
- `VIDEOS_ROOT`: absolute path to your course folders (must be readable by
|
|
the web server / PHP user).
|
|
- DB credentials (change the password in production).
|
|
- `ALLOW_REGISTRATION`: set `false` once your users have accounts.
|
|
- `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`
|
|
predates the setting behave as if it were `false`.
|
|
|
|
No external binaries are needed — PHP with PDO/MySQL is the whole
|
|
dependency list.
|
|
|
|
3. **Run** — with the PHP built-in server for testing:
|
|
|
|
```bash
|
|
php -S 0.0.0.0:8080 -t /path/to/coursehub
|
|
```
|
|
|
|
or point an Apache/Nginx + PHP-FPM vhost at the directory.
|
|
|
|
4. **Index the library** — click **Rescan library** in the top bar, or:
|
|
|
|
```bash
|
|
php scan.php
|
|
```
|
|
|
|
Rescan whenever you add/remove files. Re-scans are idempotent: existing
|
|
videos keep their IDs (and everyone's progress); files that disappeared are
|
|
hidden, not deleted, so progress survives if they come back.
|
|
|
|
5. Open the site, create an account, and watch.
|
|
|
|
## Quizzes
|
|
|
|
Course folders may contain quiz text files next to the videos. Files named
|
|
`*_quiz_solved.txt` are indexed (a `*_quiz_notsolved.txt` twin is ignored —
|
|
it's the same content without answers). Format:
|
|
|
|
```
|
|
[&] Which two are supported CLI connectivity methods? (Select two)
|
|
- RDP
|
|
- rlogin
|
|
- GUI widget -- Correct
|
|
- FortiExplorer -- Correct
|
|
```
|
|
|
|
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.
|
|
|
|
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;
|
|
a quiz is *completed* once every question is answered correctly in one
|
|
attempt, and quiz scores count toward folder progress alongside videos.
|
|
|
|
## Search
|
|
|
|
The top-bar box searches as you type (from two characters, debounced) and also
|
|
on Enter. It matches folder, video and quiz **names** and their relative
|
|
**paths**, so `Course 1/Intro` lists everything under that section. Results are
|
|
grouped into courses/sections, videos and quizzes, with your progress shown on
|
|
each row. Searches live at `#/search/<query>`, so a result page can be
|
|
bookmarked or shared.
|
|
|
|
## Resetting progress
|
|
|
|
`api/reset.php` deletes progress rows for the logged-in user only — never for
|
|
anyone else, and never any video, quiz or folder itself. Four scopes:
|
|
|
|
| Where | Scope | Clears |
|
|
| --- | --- | --- |
|
|
| Player page → **Reset** | `video` | that video's position + completion |
|
|
| Quiz page → **Reset attempts** | `quiz` | that quiz's attempts, best/last score |
|
|
| Folder page → **Reset progress** | `folder` | everything in that folder's subtree |
|
|
| Library list row → ↺ | `folder` | that one course's subtree |
|
|
| Settings / avatar menu → **Reset all progress** | `all` | every video and quiz on the account |
|
|
|
|
Each one asks for confirmation first, and the reply reports how many video and
|
|
quiz rows were removed. Resetting the video you are watching also suppresses
|
|
the player's teardown save, so the position you just cleared isn't written back.
|
|
|
|
## Look and feel
|
|
|
|
The UI implements the **Course Library** design (a Claude Design document).
|
|
`assets/style.css` is the whole design system — tokens first, then layout, then
|
|
components. There is no CSS framework under it, and the icons are inline SVG
|
|
from the design rather than an icon font.
|
|
|
|
| Token | Hex | Used for |
|
|
| --- | --- | --- |
|
|
| Paper | `#EFEAE1` | outermost ground |
|
|
| Surface | `#FAF8F5` | app background |
|
|
| Sidebar | `#F5F1EA` | left rail |
|
|
| Card | `#FFFFFF` | cards, rows, dialogs |
|
|
| Ink | `#1A1814` → `#B8B0A2` | four text weights (`--ink` … `--ink-4`) |
|
|
| Accent | `#1F6F5C` | primary action, links, progress, completion |
|
|
| Danger | `#B84A3C` | reset and errors |
|
|
|
|
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.
|
|
|
|
Courses have no cover art, so each one gets a hue from the design's six-colour
|
|
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.
|
|
|
|
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
|
|
next to it, and grids drop to two columns.
|
|
|
|
## Settings
|
|
|
|
`#/settings` shows what the server actually knows and what this browser
|
|
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, open the library
|
|
in list view.
|
|
- **Reset progress** — the account-wide reset, with a count of how many courses
|
|
currently hold progress.
|
|
|
|
Scan time lives in an `app_meta` key/value table. Reads tolerate the table not
|
|
existing, so an install created before it was added keeps working until its
|
|
next scan creates it.
|
|
|
|
## How progress works
|
|
|
|
- The player POSTs `{video_id, position, duration}` to `api/progress.php`
|
|
every 5 s while playing, plus on pause/seek, via `navigator.sendBeacon`
|
|
when the tab is hidden or closed, and when navigating to another view
|
|
in the SPA.
|
|
- A video is marked **completed** at ≥ 95 % watched (`COMPLETE_THRESHOLD`).
|
|
Completion is sticky — rewatching doesn't un-complete it.
|
|
- Folder progress is computed over the folder's entire subtree using a path
|
|
prefix match, with partial credit for videos in progress.
|
|
- Video duration is read at scan time by parsing the mp4's `mvhd` header in
|
|
pure PHP, so listings show durations before anyone plays the video. If a file
|
|
can't be parsed, the browser reports its duration on first play instead.
|
|
|
|
## When something breaks
|
|
|
|
`index.php` stamps `assets/*.css|js` with the file's mtime (`asset()`), so a
|
|
deploy is always a fresh URL. Without that, a browser or CDN holding a stale
|
|
`app.js` runs it against a newer shell, it grabs element IDs that no longer
|
|
exist, and it throws before unhiding the shell — a blank page whose only clue
|
|
is in the console. If you ever do see a blank page, hard-refresh and purge the
|
|
CDN cache first.
|
|
|
|
A small handler installed ahead of every other script catches uncaught errors,
|
|
unhandled rejections and failed script/stylesheet loads, and paints them into a
|
|
`#boot-error` panel — so a failure that stops the app is visible on the page
|
|
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 — the session cookie is then marked `Secure` on its
|
|
own.
|