Files
coursehub/README.md
T
Harwinder Sekhon 66e4905796 Let the login throttle see real client IPs behind Cloudflare
Behind a proxy REMOTE_ADDR is the proxy, so the per-IP limit would lump
every visitor together. New optional CLIENT_IP_HEADER in config.php
(e.g. 'CF-Connecting-IP') tells the throttle which header carries the
real address; invalid or absent values fall back to REMOTE_ADDR, and
configs that predate the setting behave as before. Only for origins
unreachable except through the proxy — documented in the README, which
also covers the mod_remoteip/real_ip alternative.

Claude-Session: https://claude.ai/code/session_01SePf61iazhCLJBefdRLBDm
2026-07-30 21:17:46 -04:00

273 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.
- **Behind Cloudflare** (or any proxy that hides the visitor), the IP the
throttle sees is the proxy's. Either restore real client IPs at the web
server (Apache `mod_remoteip` / Nginx `real_ip` with `CF-Connecting-IP` —
the better fix, it also corrects your logs), or set
`CLIENT_IP_HEADER` to `'CF-Connecting-IP'` in `config.php`. Only do either
if the origin is unreachable except through the proxy; otherwise the header
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.
## 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.