Harwinder Sekhon 6a4c972c47 Show quiz exhibits, keep sign-ins alive, and close a batch of edge cases
- A quiz question can now carry the diagram it refers to: a Markdown
  image line anywhere inside the question block is parsed out of the
  text and rendered under it (referrerpolicy="no-referrer", lazy). The
  URL points at the course provider, so an offline install gets a note
  and a link instead of an empty box.
- New optional SESSION_LIFETIME (30 days in the example config) replaces
  the browser-session cookie plus php.ini's 24-minute idle limit, which
  signed people out over a long pause. Configs that predate it behave as
  before. Behind a proxy trusted enough for CLIENT_IP_HEADER, the cookie
  is also marked Secure from X-Forwarded-Proto — TLS ending at the proxy
  no longer costs the flag.
- Error responses no longer echo the exception message unless DEBUG: a
  PDOException names the DB user, host and tables, and that handler also
  runs for visitors who never signed in.
- The .htaccess denials are anchored to a path segment rather than the
  root, so videos/ and includes/ stay unreachable under a prefix such as
  /coursehub/ instead of silently opening up.
- A rescan takes a MySQL advisory lock; a second one answers "already
  running" rather than racing the first and flagging its rows missing. A
  file swapped in at the same path with a different size drops its
  cached duration so the probe reads the new length.
- stream.php discards output buffering and zlib compression (both break
  the promised Content-Length) and answers HEAD with headers alone.
- A progress save with no duration keeps the length already stored
  instead of nulling it, and the folder view builds its rollup once.
- In the SPA, a view whose data arrives after the route moved on is
  discarded — previously a slow load could paint over a newer screen or
  start a heartbeat for a video nobody was watching. Also: the quiz
  submit button can't be double-fired, leaving a video during the
  autoplay countdown no longer jumps ahead later, the final position is
  always saved on 'ended', the playlist scrolls the current entry into
  its own box, the login field takes focus, views start at the top, and
  a stray "%" in the hash no longer throws.
- Dropped the unused requireLogin(); registerUser() answers "taken"
  instead of a 500 when two people race for the same name.

Claude-Session: https://claude.ai/code/session_01AXMrtWNUV7NU1pCUHBG4t1
2026-09-06 12:31:21 -04:00

CourseHub

Self-hosted course video player: PHP 8.0+ + MySQL/MariaDB, its own CSS design system and 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:

    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:

    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:

    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.

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?
![quiz image](https://example.com/quiz-images/switching1.png)
- 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.

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. 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).
  • 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.
S
Description
No description provided
Readme 1.1 MiB
Languages
PHP 57.1%
JavaScript 28.4%
CSS 14.5%