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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user