76d8f259b2
The advisor was a card inside the home page, so reaching it meant navigating
back to #/ and it was rebuilt on every render of that view. It is now a bubble
in the bottom-right corner of every screen bar the player and a quiz, opening
a chat panel that stays put as you move around — ask what to take next, follow
a recommendation, keep asking. It is built once into #overlay-root, outside
the view that gets replaced, and syncAdvisor() called from renderChrome() is
the single path that decides whether it is on screen; that covers every route
change and every loadMeta(), and makes the Settings toggle work with no change
to its handler.
Being permanent removes the reason for the routeSeq guards in ask(). An answer
that arrived after the user navigated was written to sessionStorage and then
dropped unrendered; now it lands, and the bubble carries an unread dot if the
panel is closed. showAuth() wipes the transcript and hides the dock, both of
which are new requirements: the dock is a sibling of #app-shell, so hiding the
shell no longer hides the advisor, and the widget now outlives a sign-out.
Courses named in the answer itself are links. advisorResolveCourses() is
lifted out of advisorValidateRecommendations() so an id written inline goes
through the same existence check and the same redirect to whichever duplicate
copy the learner has progress on — a sentence and the card under it can never
point at different copies. The map is keyed by the id as the model wrote it,
since that is what the browser has to find in the text. The client splits the
raw line before escaping rather than linkifying escaped output: esc() writes
an apostrophe as ', and the course-id pattern matches the #39 inside it.
Under 900px the panel is a full-height sheet, and the mobile toast strip lifts
clear of the bubble only while the bubble is actually on screen. The z-index
sits on the dock rather than #overlay-root, which would otherwise become a
stacking context and trap the confirm dialog behind the panel.
README's advisor section is brought back in line, including the parts left
stale by 05f0abc about sections and the whole library going out per question.
Claude-Session: https://claude.ai/code/session_01LPZRrwVj6L9ckoRtK1yHAN
486 lines
24 KiB
Markdown
486 lines
24 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>` | categories, shelves or a flat catalog of every course |
|
|
| `#/folder/N` | a category (its courses), or a course/section (its contents) |
|
|
| `#/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 (categories → courses → sections → videos, any nesting depth), and
|
|
works out **where a course starts** rather than assuming a 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 — its play button opens the player *and* starts the
|
|
video at the saved position — 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 — and a
|
|
course's catalogue tags and blurb, once the catalogue is seeded.
|
|
- **Course details** (difficulty, tags, a blurb, the learning paths a course
|
|
belongs to) from the vendor's catalogue or the downloader's sidecar file —
|
|
see *Course catalogue* below.
|
|
- An optional **AI course advisor**, a bubble in the corner of every screen:
|
|
"I want to learn SD-WAN, where do I start?" answered with courses from your
|
|
own library, in order, with links. Off unless you switch it on — see
|
|
*Course advisor* below.
|
|
- **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 **categories, shelves and catalog**; both choices are remembered.
|
|
- **Categories** are the folders that sit above the courses — the library opens
|
|
on them, and each one totals the courses beneath it.
|
|
- **Shelves** group courses by how far you are: in progress, finished, not
|
|
started. The sidebar links straight to each one.
|
|
- Seekable streaming via HTTP Range requests; files are addressed by DB id only
|
|
(no path traversal).
|
|
|
|
## Layout on disk
|
|
|
|
Put whatever shape your material already has under `VIDEOS_ROOT`. There is no
|
|
convention to follow and nothing to rename: the scanner works out which folder
|
|
is a course, per branch, and everything else follows from that.
|
|
|
|
```
|
|
videos/
|
|
├── Cloud/ category
|
|
│ └── AWS Networking/ course (children are numbered)
|
|
│ ├── 01 - Welcome/
|
|
│ │ └── Intro.mp4
|
|
│ └── 02 - Creating a VPC/
|
|
│ └── Subnets.mp4
|
|
├── Firewalls/ category
|
|
│ └── Palo-Alto/ category
|
|
│ └── Palo_Alto_Firewall_Essentials/ course
|
|
│ └── 01_Welcome/…
|
|
└── System Admin/ category
|
|
└── Red Hat … EX457/ course (videos sit right in it)
|
|
├── 01 Introduction.mp4
|
|
└── 02 Inventories.mp4
|
|
```
|
|
|
|
### Where a course starts
|
|
|
|
Course roots do not sit at a fixed depth — one library can hold
|
|
`Category/<course>`, `Category/Vendor/<course>` and
|
|
`Category/Vendor/Exam/<course>` at once — so the scanner decides per branch,
|
|
walking down from the top:
|
|
|
|
| The folder… | is a |
|
|
| --- | --- |
|
|
| has no videos or quizzes anywhere under it | neither — it is skipped |
|
|
| holds videos or quizzes directly | **course** |
|
|
| has children whose names read as chapters (`01_`, `1. `, `Module 2`, `Week 3`) | **course** |
|
|
| anything else | **category** — look one level deeper |
|
|
|
|
Everything under a course root is one of its sections. The result is stored as
|
|
`folders.is_course` / `folders.course_id` at scan time, so the screens are a
|
|
lookup rather than a guess, and re-running the scan re-derives it.
|
|
|
|
### Overriding it
|
|
|
|
The rule reads chapter numbering, so it gets a course wrong when the sections
|
|
carry plain names (`Course/Introduction`, `Course/Advanced` looks like two
|
|
courses). Drop an **empty marker file** in the folder and the next scan obeys it
|
|
instead of guessing:
|
|
|
|
| File | Effect |
|
|
| --- | --- |
|
|
| `.course` | this folder is a course; everything under it is a section |
|
|
| `.category` | the courses are further down — keep looking one level deeper |
|
|
|
|
```bash
|
|
touch "videos/Cat/Unnumbered Course/.course" # one course, not two
|
|
touch "videos/Cat/Exam Bundle/.category" # each child is its own course
|
|
```
|
|
|
|
A marker settles only the folder it sits on. Once a course root is found,
|
|
everything below it is a section, so to push a course root *deeper*, mark the
|
|
folder above it `.category`. A `.category` on a folder that holds videos itself
|
|
is ignored — those videos have to belong to something.
|
|
|
|
Two consequences worth knowing:
|
|
|
|
- **Progress means something.** Shelves, the home page's "in progress" strip and
|
|
every percentage are per course. Counting a whole category as one course
|
|
instead buries twenty watched videos in a denominator of three thousand, and
|
|
everything reads 0 % forever.
|
|
- **A folder of PDFs is not a course.** Anything with no `.mp4` and no quiz file
|
|
anywhere beneath it never appears in the library at all.
|
|
|
|
### Folder names are case-sensitive
|
|
|
|
`CCNP Notes` and `CCNP NOtes` are two directories on a Linux filesystem and the
|
|
app treats them as two, because `folders.path`, `videos.path` and `quizzes.path`
|
|
are stored `utf8mb4_bin`. Under the default case-insensitive collation the
|
|
`UNIQUE` key on `path` merged them, and the second tree's videos were never
|
|
indexed. The search box still matches without regard to case.
|
|
|
|
## 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.
|
|
- `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`
|
|
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. Each scan also
|
|
re-derives which folders are courses, and brings an older install's tables up
|
|
to date first, so there is no separate migration step.
|
|
|
|
5. **Seed the course catalogue** (optional, once) — see *Course catalogue*
|
|
below. It gives courses a description, difficulty and tags, and it is what
|
|
the advisor reasons over.
|
|
|
|
6. 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.
|
|
|
|
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;
|
|
a quiz is *completed* once every question is answered correctly in one
|
|
attempt, and quiz scores count toward folder progress alongside videos.
|
|
|
|
## Course catalogue
|
|
|
|
Folder names are all the scanner can see. Two things add more:
|
|
|
|
- **A sidecar file.** The INE downloader (`ine.py`) drops a `<slug>.json` into a
|
|
course folder once every file of the course has been fetched. It carries the
|
|
course's title, difficulty, tags, description and learning paths, and the
|
|
scanner reads it.
|
|
- **The catalogue seed.** The downloader can also export the whole catalogue
|
|
you have access to (`all_courses_with_access.json`). Turn it into an SQL
|
|
patch and apply it to any install, dev or live, before or after a code
|
|
deploy — it creates its own table and upserts by id, so re-running it is
|
|
harmless:
|
|
|
|
```bash
|
|
php tools/build-catalog-seed.php ~/ine-dl/all_courses_with_access.json > seeds/ine_catalog.sql
|
|
mysql coursehub < seeds/ine_catalog.sql # or: sudo mysql coursehub < …
|
|
php scan.php # matches the library to it
|
|
```
|
|
|
|
`seeds/` is git-ignored (the file holds the vendor's course descriptions);
|
|
ship it alongside the code. Rebuild it whenever the export is refreshed.
|
|
|
|
Every scan then resolves one `course_meta` row per course: the sidecar when
|
|
there is one, otherwise the catalogue entry whose **folder key** matches the
|
|
course folder's name. The key is the folder name the downloader would have
|
|
created — spaces and hyphens become underscores, other punctuation is dropped
|
|
(`folderKeyFor()` in `includes/functions.php`) — so `Cisco SD-WAN Use Design,
|
|
Cloud & Advanced Features` finds `Cisco_SD_WAN_Use_Design_Cloud_Advanced_Features`,
|
|
and a folder renamed by hand still matches as long as the words are the same.
|
|
Courses from elsewhere (CBT Nuggets, your own notes) simply have no row and
|
|
look as they always did. The scan summary and the Settings page say how many
|
|
courses got their details from a sidecar, from the catalogue, or from neither.
|
|
|
|
Any other `.json` under the library (the downloader also writes one per lab
|
|
lesson) is ignored.
|
|
|
|
## Course advisor
|
|
|
|
A bubble in the bottom-right corner of every screen — bar the player and a
|
|
quiz, where it would be in the way — opening a chat panel that answers
|
|
questions like *"I want to learn SD-WAN, where do I start?"* or *"what should
|
|
I know before Core MPLS?"* with courses from your own library, in a suggested
|
|
order, each linking to its course page with your progress on it. Courses named
|
|
in the answer itself are links too. When something you need is not on the
|
|
server but is in the seeded catalogue, it says so and links to the vendor's
|
|
page; topics covered nowhere are listed too.
|
|
|
|
The panel stays open as you move around, so you can follow a recommendation
|
|
and keep asking. The conversation lives in the browser tab and ends with it.
|
|
|
|
It is **off by default** and the app never depends on it. To turn it on, set
|
|
in `config.php`:
|
|
|
|
```php
|
|
define('AI_ENABLED', true);
|
|
define('AI_API_KEY', 'sk-…'); // https://platform.deepseek.com/
|
|
define('AI_BASE_URL', 'https://api.deepseek.com'); // any OpenAI-format endpoint
|
|
define('AI_MODEL', 'deepseek-v4-flash');
|
|
define('AI_DAILY_LIMIT', 50); // questions per user per day
|
|
```
|
|
|
|
With `AI_ENABLED` false (or no key) the bubble does not appear,
|
|
`api/advisor.php` answers 503, and the app makes **no outbound requests at
|
|
all**. The one extra requirement when it is on is PHP's curl extension. Each
|
|
user can also hide the button for their browser from Settings.
|
|
|
|
What leaves the server when it is on: the name, category, difficulty, vendor
|
|
and size of every course, a short blurb, tags and learning paths for the
|
|
handful judged relevant to the question, the catalogue titles of courses you
|
|
have access to but have not downloaded, your progress percentage per course,
|
|
and the conversation you type. Never video files, section or lesson names,
|
|
subtitles, or quiz contents.
|
|
|
|
How it works: every course goes out as a one-line index, which is the same
|
|
bytes for every user and every question, so the provider's prefix cache covers
|
|
it and a question costs a fraction of a cent; only the courses that match the
|
|
question are described in any depth. The model replies in JSON; the server
|
|
drops any course id it made up before the browser sees it, and links are built
|
|
only from ids that exist. That applies to the prose as well — an id the model
|
|
writes inline as `#412` is resolved against the same check and becomes a link
|
|
to that course, and anything that does not resolve is left as plain text. Each question (including one that fails) counts toward the
|
|
per-user daily limit, kept in `advisor_requests` together with the token
|
|
counts, so you can see what it costs. A conversation lives in the browser tab
|
|
and is resent with each turn, so follow-ups ("I already know MPLS") work.
|
|
|
|
Any OpenAI-format chat endpoint can stand in for DeepSeek — point `AI_BASE_URL`
|
|
and `AI_MODEL` at it; a local model server keeps even the advisor offline. If
|
|
the server rejects DeepSeek's `thinking` / `reasoning_effort` fields, add
|
|
`define('AI_REASONING_PARAM', false);` to leave them out.
|
|
|
|
Timeouts: a reply takes a few seconds to tens of seconds. PHP's own limit is
|
|
raised for the call, but PHP-FPM's `request_terminate_timeout`, Nginx's
|
|
`fastcgi_read_timeout` (60 s by default) and Cloudflare's ~100 s cap still
|
|
apply — leave them at a minute or more.
|
|
|
|
## 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.
|
|
- Every course is rolled up in **two queries for the whole library**, grouped by
|
|
`folders.course_id`, and a folder's children in two more — not two per card.
|
|
A library of a few hundred courses would otherwise spend a page load on
|
|
several hundred prefix-matched queries.
|
|
- 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/`, `seeds/`, `tools/`, `config.php` and `schema.sql` — without it, video URLs would
|
|
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).
|
|
- The server makes no outbound requests unless the AI advisor is switched on,
|
|
and then only to `AI_BASE_URL` (see *Course advisor*).
|
|
- 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
|
|
|
|
- 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. 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.
|