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.

Claude-Session: https://claude.ai/code/session_01SePf61iazhCLJBefdRLBDm
This commit is contained in:
Harwinder Sekhon
2026-07-30 21:14:07 -04:00
parent 3d14573b8c
commit e3a054e109
22 changed files with 433 additions and 102 deletions
@@ -0,0 +1,44 @@
{
"permissions": {
"allow": [
"Read(//usr/bin/**)",
"Bash(mysql --version)",
"Bash(mysql -h127.0.0.1 -ucoursehub -pcoursehub coursehub -e \"SELECT \\(SELECT COUNT\\(*\\) FROM users\\) users, \\(SELECT COUNT\\(*\\) FROM folders\\) folders, \\(SELECT COUNT\\(*\\) FROM videos\\) videos, \\(SELECT COUNT\\(*\\) FROM quizzes\\) quizzes, \\(SELECT COUNT\\(*\\) FROM video_progress\\) vp, \\(SELECT COUNT\\(*\\) FROM quiz_progress\\) qp;\")",
"Bash(python3 *)",
"Bash(mysql -N -h127.0.0.1 -ucoursehub -pcoursehub coursehub -e \"SELECT id FROM users WHERE username='claude_ui'\")",
"Bash(mysql -h127.0.0.1 -ucoursehub -pcoursehub coursehub -e 'UPDATE video_progress SET position_seconds=6 WHERE user_id=__TRACKED_VAR__ AND video_id=27')",
"Bash(curl -s -b jar2.txt __TRACKED_VAR__/api/library.php?view=home)",
"Bash(mysql -h127.0.0.1 -ucoursehub -pcoursehub coursehub -e \"DELETE FROM users WHERE username='claude_ui';\")",
"Bash(pkill -f \"php -S 127.0.0.1:8199\")",
"Bash(mysql -h127.0.0.1 -ucoursehub -pcoursehub coursehub -e \"SELECT u.username, \\(SELECT COUNT\\(*\\) FROM video_progress vp WHERE vp.user_id=u.id\\) vp, \\(SELECT COUNT\\(*\\) FROM quiz_progress qp WHERE qp.user_id=u.id\\) qp FROM users u; SELECT k, v FROM app_meta;\")",
"Bash(mysql -h127.0.0.1 -ucoursehub -pcoursehub coursehub -e \"SELECT u.username, \\(SELECT COUNT\\(*\\) FROM video_progress vp WHERE vp.user_id=u.id\\) vp, \\(SELECT COUNT\\(*\\) FROM quiz_progress qp WHERE qp.user_id=u.id\\) qp FROM users u;\")",
"Bash(mysql -h127.0.0.1 -ucoursehub -pcoursehub coursehub -e \"SELECT k, v FROM app_meta;\")",
"Bash(ps -o pid,args -p 36288)",
"Bash(/home/harwinder/.local/lib/python3.12/site-packages/nodejs/bin/node --check assets/app.js)",
"Bash(awk '/^:root/,/^}/{next} /#[0-9A-Fa-f]{6}/{print NR\": \"$0}' assets/style.css)",
"Bash(mysql -h127.0.0.1 -ucoursehub -pcoursehub coursehub -e \"DELETE FROM users WHERE username='claude_colors';\")",
"Bash(cd *)",
"Bash(ffprobe -v error -show_entries format=duration -of csv=p=0 \"videos/Core_MPLS/01_Course/01_Core_MPLS/01_Core_MPLS_Switching_Part_1.mp4\")",
"Bash(mysql -h127.0.0.1 -ucoursehub -pcoursehub coursehub -e \"SELECT id,parent_id,name,path,depth FROM folders WHERE parent_id IS NULL;\")",
"Bash(mysql -h127.0.0.1 -ucoursehub -pcoursehub coursehub -e \"SHOW COLUMNS FROM folders;\")",
"Bash(mysql -h127.0.0.1 -ucoursehub -pcoursehub coursehub -e \"SELECT id, username FROM users;\")",
"Bash(mysql -h127.0.0.1 -ucoursehub -pcoursehub coursehub -e \"SHOW COLUMNS FROM folders LIKE 'thumbnail%';\")",
"Bash(php -r ' *)",
"Read(//tmp/**)",
"Bash(php -l index.php)",
"Bash(curl *)",
"Bash(php -l includes/functions.php)",
"Bash(node --check assets/app.js)",
"Bash(command -v deno bun qjs js d8)",
"Bash(git push *)",
"Bash(file assets/vendor/*.woff2 assets/vendor/plyr.svg)",
"Bash(gjs assets/app.js)",
"Bash(php -S 127.0.0.1:8099 -t /home/harwinder/Development/coursehub)",
"Bash(mysql -ucoursehub -pcoursehub coursehub -e \"SELECT id, duration_seconds FROM videos WHERE id=61; SELECT COUNT\\(*\\) AS attempts FROM login_attempts; SELECT id, username FROM users WHERE username='claude_smoketest';\")",
"Bash(mysql -ucoursehub -pcoursehub coursehub -e \"DELETE FROM users WHERE username='claude_smoketest'; DELETE FROM login_attempts; SELECT COUNT\\(*\\) AS users_left FROM users;\")",
"Bash(kill %1)",
"Bash(git add *)",
"Bash(git commit -m ' *)"
]
}
}
+15
View File
@@ -0,0 +1,15 @@
# Apache: keep non-public files out of reach. Nginx setups need the
# equivalent location blocks (see README, Production notes).
Options -Indexes
# The video library is served only through stream.php (auth + Range); the
# quiz/caption sources only through their endpoints. Direct URLs would
# bypass login entirely.
RedirectMatch 404 ^/videos/
# App internals — nothing under these is meant to be fetched.
RedirectMatch 404 ^/includes/
<FilesMatch "^(config\.php|config\.example\.php|schema\.sql|\.install\.lock|\.gitignore)$">
Require all denied
</FilesMatch>
+36 -13
View File
@@ -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.
+15 -2
View File
@@ -11,6 +11,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
'logged_in' => currentUserId() !== null,
'username' => currentUsername(),
'allow_registration' => ALLOW_REGISTRATION,
'csrf' => csrfToken(),
]);
exit;
}
@@ -22,15 +23,27 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
}
$data = json_decode(file_get_contents('php://input'), true) ?: [];
verifyCsrfApi($data);
$action = $data['action'] ?? '';
$username = trim($data['username'] ?? '');
$password = $data['password'] ?? '';
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
switch ($action) {
case 'login':
$wait = loginRetryAfter($username, $ip);
if ($wait > 0) {
http_response_code(429);
header('Retry-After: ' . $wait);
echo json_encode(['error' => 'Too many failed sign-in attempts. '
. 'Try again in ' . max(1, (int)ceil($wait / 60)) . ' min.']);
break;
}
if (attemptLogin($username, $password)) {
echo json_encode(['ok' => true, 'username' => currentUsername()]);
clearLoginFailures($username);
echo json_encode(['ok' => true, 'username' => currentUsername(), 'csrf' => csrfToken()]);
} else {
recordLoginFailure($username, $ip);
http_response_code(401);
echo json_encode(['error' => 'Invalid username or password.']);
}
@@ -45,7 +58,7 @@ switch ($action) {
$result = registerUser($username, $password);
if ($result === true) {
attemptLogin($username, $password);
echo json_encode(['ok' => true, 'username' => currentUsername()]);
echo json_encode(['ok' => true, 'username' => currentUsername(), 'csrf' => csrfToken()]);
} else {
http_response_code(422);
echo json_encode(['error' => $result]);
+2
View File
@@ -203,6 +203,8 @@ switch ($view) {
'prev_id' => $prevId,
'next_id' => $nextId,
'stream_url' => 'stream.php?id=' . (int)$video['id'],
'captions_url' => srtFilePath($video) !== null
? 'captions.php?id=' . (int)$video['id'] : null,
]);
break;
+1
View File
@@ -31,6 +31,7 @@ $data = json_decode($raw, true);
if (!is_array($data)) {
parse_str($raw, $data); // sendBeacon posts form-encoded
}
verifyCsrfApi($data);
$videoId = (int)($data['video_id'] ?? 0);
$position = max(0, (float)($data['position'] ?? 0));
+1
View File
@@ -67,6 +67,7 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
}
$data = json_decode(file_get_contents('php://input'), true) ?: [];
verifyCsrfApi($data);
$quiz = getQuiz((int)($data['quiz_id'] ?? 0));
$answers = $data['answers'] ?? null;
if (!$quiz || !is_array($answers)) {
+1
View File
@@ -15,6 +15,7 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
}
$data = json_decode(file_get_contents('php://input'), true) ?: [];
verifyCsrfApi($data);
$scope = (string)($data['scope'] ?? '');
$id = (int)($data['id'] ?? 0);
+1
View File
@@ -11,6 +11,7 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['error' => 'Method not allowed']);
exit;
}
verifyCsrfApi();
try {
echo json_encode(['ok' => true, 'stats' => scanLibrary()]);
+25 -81
View File
@@ -17,6 +17,7 @@
const view = document.getElementById('view');
const state = {
user: null,
csrf: null, // session token, echoed back on every POST
meta: null, // totals / shelves / last scan, for the chrome
suppressSave: false, // set while a reset lands, so teardown doesn't re-save
searchSeq: 0,
@@ -127,7 +128,7 @@
/* ---------- preferences ---------- */
const PREF_DEFAULTS = { view: 'grid', layout: 'shelves', autoplay: true, motion: true };
const PREF_DEFAULTS = { view: 'grid', layout: 'shelves', autoplay: true };
function pref(key) {
try {
@@ -146,9 +147,11 @@
/* ---------- api ---------- */
async function api(url, options = {}) {
options.headers = Object.assign({}, options.headers);
if (state.csrf) options.headers['X-CSRF-Token'] = state.csrf;
if (options.json) {
options.method = options.method || 'POST';
options.headers = { 'Content-Type': 'application/json' };
options.headers['Content-Type'] = 'application/json';
options.body = JSON.stringify(options.json);
delete options.json;
}
@@ -165,41 +168,6 @@
const spinner = () => { view.innerHTML = '<div class="loading"><span class="spinner"></span></div>'; };
/* ---------- motion (anime.js) ----------
* Skipped when anime.js is unavailable, when the OS asks for reduced motion,
* or when the user turned animations off in settings. */
const canAnimate = () =>
typeof anime !== 'undefined' && pref('motion') !== false &&
!window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const clearInline = (els) => els.forEach((el) => { el.style.opacity = ''; el.style.transform = ''; });
function reveal(root = view) {
if (!canAnimate()) return;
const items = [...root.querySelectorAll('.course-card, .row, .row-card, .card, .q-card, .hero, .settings-section')];
items.forEach((el) => { el.style.opacity = '0'; }); // before the frame paints
anime({ targets: root, opacity: [0, 1], translateY: [8, 0], duration: 260,
easing: 'easeOutQuad', complete: () => clearInline([root]) });
if (items.length) {
anime({ targets: items, opacity: [0, 1], translateY: [10, 0], duration: 420,
delay: anime.stagger(26, { start: 70 }), easing: 'easeOutCubic',
complete: () => clearInline(items) });
}
growBars(root);
}
/** Progress bars grow from zero, so a reset is visible as movement. */
function growBars(root = view) {
if (!canAnimate()) return;
const bars = [...root.querySelectorAll('.bar > span, .thumb-bar > span')];
if (!bars.length) return;
bars.forEach((b) => { b.dataset.pct = b.style.width || '0%'; b.style.width = '0%'; });
anime({ targets: bars, width: (el) => el.dataset.pct, duration: 700,
delay: anime.stagger(28, { start: 110 }), easing: 'easeOutQuart' });
}
/* ---------- toast / dialog / menu ---------- */
function toast(message, kind = 'ok') {
@@ -208,14 +176,7 @@
el.innerHTML = `<span class="glyph">${icon(kind === 'error' ? 'alert' : 'check', 16, 2)}</span>
<span>${esc(message)}</span>`;
document.getElementById('toasts').appendChild(el);
setTimeout(() => {
if (canAnimate()) {
anime({ targets: el, opacity: 0, translateY: 8, duration: 200, easing: 'easeInQuad',
complete: () => el.remove() });
} else {
el.remove();
}
}, 3200);
setTimeout(() => el.remove(), 3200);
}
/** Resolves true only if the user confirms. */
@@ -507,9 +468,16 @@
e.preventDefault();
const fd = new FormData(form);
try {
// The session (and its CSRF token) may have expired while the tab sat
// on this form — fetch a fresh token so the POST is never rejected.
try {
const me = await fetch('api/auth.php').then((r) => r.json());
state.csrf = me.csrf || state.csrf;
} catch (e) { /* the POST below will surface the real problem */ }
const data = await api('api/auth.php', {
json: { action, username: fd.get('username'), password: fd.get('password') },
});
state.csrf = data.csrf || state.csrf;
state.user = data.username;
showShell();
toast(action === 'register' ? 'Account created — welcome.' : `Welcome back, ${data.username}.`);
@@ -618,7 +586,6 @@
${recentBlock}
</div>`;
reveal();
}
/* ---------- library ---------- */
@@ -684,7 +651,6 @@
${body}
</div>`;
reveal();
}
/* ---------- folder ---------- */
@@ -743,7 +709,6 @@
${sections}${content}
</div>`;
reveal();
}
/* ---------- search ---------- */
@@ -757,7 +722,6 @@
<h1>Search the library</h1>
<p class="page-sub">Type to match course titles, folder paths, and individual video names.</p>
</div>`;
reveal();
document.getElementById('search-input').focus();
return;
}
@@ -805,7 +769,6 @@
+ section('Quizzes', data.quizzes.length, quizzes)}
</div>`;
reveal();
}
/* ---------- watch ---------- */
@@ -838,6 +801,8 @@
<div class="player-frame">
<video id="player" playsinline controls>
<source src="${data.stream_url}" type="video/mp4">
${data.captions_url
? `<track kind="captions" label="English" srclang="en" src="${data.captions_url}">` : ''}
</video>
</div>
<div class="watch-head">
@@ -862,7 +827,6 @@
</div>
</div>`;
reveal();
cleanupView = initPlayer(data);
}
@@ -950,7 +914,6 @@
${data.progress.attempts ? resetBtn('quiz', data.quiz.id, data.quiz.name, 'Reset attempts') : ''}
</div>
</div>`;
reveal();
}
view.onclick = null;
@@ -975,7 +938,6 @@
try {
graded = await api('api/quiz.php', { json: { quiz_id: id, answers } });
draw();
markGraded();
toast(graded.completed
? `Perfect score — ${graded.score}/${graded.total}.`
: `Scored ${graded.score}/${graded.total}.`, graded.completed ? 'ok' : 'error');
@@ -993,23 +955,6 @@
};
view.addEventListener('click', onClick);
cleanupView = () => view.removeEventListener('click', onClick);
/** Correct cards settle into place; wrong ones shake their heads. */
function markGraded() {
if (!canAnimate()) return;
graded.results.forEach((r, qi) => {
const card = view.querySelector(`[data-qi="${qi}"].q-card`);
if (!card) return;
if (r.correct) {
anime({ targets: card, scale: [0.98, 1], duration: 420, delay: qi * 45,
easing: 'easeOutBack', complete: () => clearInline([card]) });
} else {
anime({ targets: card, duration: 440, delay: qi * 45, easing: 'easeInOutSine',
keyframes: [{ translateX: -7 }, { translateX: 6 }, { translateX: -4 }, { translateX: 0 }],
complete: () => clearInline([card]) });
}
});
}
}
/* ---------- settings ---------- */
@@ -1021,8 +966,6 @@
const toggles = [
{ key: 'autoplay', label: 'Autoplay next video',
sub: 'Starts the next lesson a moment after one ends.' },
{ key: 'motion', label: 'Animate transitions',
sub: 'Fades views in and grows progress bars. Off is always honoured when your system asks for reduced motion.' },
{ key: 'list-default', label: 'Open the library in list view',
sub: 'Otherwise courses open as a grid of cards.' },
];
@@ -1093,7 +1036,6 @@
</section>
</div>`;
reveal();
}
/* ---------- player + progress tracking ---------- */
@@ -1102,12 +1044,17 @@
const el = document.getElementById('player');
const statusEl = document.getElementById('save-status');
const player = new Plyr(el, {
// Plyr fetches its icon sprite and blank video from its CDN unless told
// otherwise — everything ships locally now.
iconUrl: 'assets/vendor/plyr.svg',
blankVideo: 'assets/vendor/blank.mp4',
keyboard: { focused: true, global: true },
seekTime: 10,
speed: { selected: 1, options: [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2] },
captions: { active: false, update: true },
controls: ['play-large', 'rewind', 'play', 'fast-forward', 'progress',
'current-time', 'duration', 'mute', 'volume', 'settings',
'pip', 'fullscreen'],
'current-time', 'duration', 'mute', 'volume', 'captions',
'settings', 'pip', 'fullscreen'],
});
let resumed = data.resume_at <= 0;
@@ -1144,7 +1091,8 @@
const p = payload();
if (state.suppressSave || !p.duration) return;
navigator.sendBeacon('api/progress.php',
new URLSearchParams(Object.entries(p).map(([k, v]) => [k, String(v)])));
new URLSearchParams(Object.entries({ ...p, csrf: state.csrf || '' })
.map(([k, v]) => [k, String(v)])));
}
player.on('playing', () => { if (!saveTimer) saveTimer = setInterval(save, 5000); });
@@ -1396,6 +1344,7 @@
try {
const me = await fetch('api/auth.php').then((r) => r.json());
state.user = me.logged_in ? me.username : null;
state.csrf = me.csrf || null;
} catch (e) {
state.user = null;
}
@@ -1404,10 +1353,5 @@
showShell();
await loadMeta();
route();
if (canAnimate()) {
anime({ targets: '.brand-mark', scale: [0.5, 1], rotate: [-24, 0], opacity: [0, 1],
duration: 700, easing: 'easeOutElastic(1, 0.7)' });
}
})();
})();
BIN
View File
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
+73
View File
@@ -0,0 +1,73 @@
/* Inter — self-hosted (subset: latin, latin-ext), from Google Fonts v20 */
/* latin-ext */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(inter-latin-ext.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(inter-latin.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* latin-ext */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(inter-latin-ext.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(inter-latin.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* latin-ext */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(inter-latin-ext.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(inter-latin.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* latin-ext */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(inter-latin-ext.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(inter-latin.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
+1
View File
File diff suppressed because one or more lines are too long
+2
View File
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.6 KiB

+29
View File
@@ -0,0 +1,29 @@
<?php
// Serves a video's subtitles as WebVTT, converted on the fly from the .srt
// file sitting next to the .mp4. Like stream.php, the file is addressed by
// database ID only — the client never supplies a filesystem path.
require_once __DIR__ . '/includes/auth.php';
require_once __DIR__ . '/includes/functions.php';
if (currentUserId() === null) {
http_response_code(403);
exit('Forbidden');
}
$video = getVideo((int)($_GET['id'] ?? 0));
$file = $video ? srtFilePath($video) : null;
if ($file === null) {
http_response_code(404);
exit('No subtitles for this video');
}
$srt = file_get_contents($file);
if ($srt === false) {
http_response_code(404);
exit('No subtitles for this video');
}
session_write_close();
header('Content-Type: text/vtt; charset=utf-8');
header('Cache-Control: private, max-age=3600');
echo srtToVtt($srt);
+114
View File
@@ -2,6 +2,16 @@
require_once __DIR__ . '/db.php';
if (session_status() === PHP_SESSION_NONE) {
// Reject session IDs the server never issued (blocks session fixation),
// and keep the cookie away from scripts and cross-site requests.
ini_set('session.use_strict_mode', '1');
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
'httponly' => true,
'samesite' => 'Lax',
]);
session_start();
}
@@ -10,6 +20,33 @@ function currentUserId(): ?int
return isset($_SESSION['user_id']) ? (int)$_SESSION['user_id'] : null;
}
/* ---------------------------------------------------------------------------
* CSRF
* The token lives in the session and reaches the SPA via GET api/auth.php
* (and again in the login/register response, since login rotates it). Every
* state-changing request must echo it back — fetch() sends the X-CSRF-Token
* header; sendBeacon() can't set headers, so a `csrf` body field counts too.
* ------------------------------------------------------------------------- */
function csrfToken(): string
{
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(16));
}
return $_SESSION['csrf_token'];
}
/** 403s the request unless it carries the session's CSRF token. */
function verifyCsrfApi(?array $body = null): void
{
$sent = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? $body['csrf'] ?? '';
if (!is_string($sent) || $sent === '' || !hash_equals(csrfToken(), $sent)) {
http_response_code(403);
header('Content-Type: application/json');
echo json_encode(['error' => 'Invalid session token — reload the page and try again.']);
exit;
}
}
function currentUsername(): ?string
{
return $_SESSION['username'] ?? null;
@@ -44,6 +81,7 @@ function attemptLogin(string $username, string $password): bool
$user = $stmt->fetch();
if ($user && password_verify($password, $user['password_hash'])) {
session_regenerate_id(true);
unset($_SESSION['csrf_token']); // a fresh token for the fresh session id
$_SESSION['user_id'] = (int)$user['id'];
$_SESSION['username'] = $user['username'];
return true;
@@ -51,6 +89,82 @@ function attemptLogin(string $username, string $password): bool
return false;
}
/* ---------------------------------------------------------------------------
* Login brute-force throttle
* Failed attempts are recorded per username + IP. Once a username collects
* LOGIN_MAX_USER_FAILS failures (or an IP LOGIN_MAX_IP_FAILS, across any
* usernames) inside LOGIN_FAIL_WINDOW, sign-in answers 429 until the window
* has passed since the last failure. Retrying while blocked never verifies a
* password, so it doesn't extend the block. A successful login clears the
* username's failures.
* ------------------------------------------------------------------------- */
const LOGIN_FAIL_WINDOW = 900; // seconds
const LOGIN_MAX_USER_FAILS = 5;
const LOGIN_MAX_IP_FAILS = 20;
/** Seconds until this username/IP may try again — 0 means go ahead. */
function loginRetryAfter(string $username, string $ip): int
{
// The clock math stays inside MySQL: TIMESTAMP values come back in the
// connection's time zone, so comparing them against PHP's clock would be
// off by the server's UTC offset.
$recent = function (string $col, string $value): array {
$stmt = db()->prepare(
"SELECT COUNT(*) AS fails,
COALESCE(TIMESTAMPDIFF(SECOND, NOW(),
MAX(attempted_at) + INTERVAL " . LOGIN_FAIL_WINDOW . " SECOND), 0) AS retry
FROM login_attempts
WHERE $col = ? AND attempted_at > NOW() - INTERVAL " . LOGIN_FAIL_WINDOW . " SECOND"
);
$stmt->execute([$value]);
return $stmt->fetch();
};
try {
$user = $recent('username', $username);
$byIp = $recent('ip', $ip);
} catch (Throwable $e) {
return 0; // table not created yet — no failures recorded so far
}
$retry = 0;
if ((int)$user['fails'] >= LOGIN_MAX_USER_FAILS) {
$retry = max($retry, (int)$user['retry']);
}
if ((int)$byIp['fails'] >= LOGIN_MAX_IP_FAILS) {
$retry = max($retry, (int)$byIp['retry']);
}
return max(0, $retry);
}
function recordLoginFailure(string $username, string $ip): void
{
$db = db();
// Lazy creation keeps installs made before this table existed working
// (same pattern as app_meta).
$db->exec(
'CREATE TABLE IF NOT EXISTS login_attempts (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) NOT NULL,
ip VARCHAR(45) NOT NULL,
attempted_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_la_user_time (username, attempted_at),
INDEX idx_la_ip_time (ip, attempted_at)
) ENGINE=InnoDB'
);
$db->prepare('DELETE FROM login_attempts WHERE attempted_at < NOW() - INTERVAL 1 HOUR')->execute();
$db->prepare('INSERT INTO login_attempts (username, ip) VALUES (?, ?)')->execute([$username, $ip]);
}
function clearLoginFailures(string $username): void
{
try {
db()->prepare('DELETE FROM login_attempts WHERE username = ?')->execute([$username]);
} catch (Throwable $e) {
// table doesn't exist yet — nothing to clear
}
}
/** Returns true on success, or an error-message string. */
function registerUser(string $username, string $password): string|bool
{
+56
View File
@@ -692,6 +692,62 @@ function quizFilePath(array $quiz): ?string
return $file;
}
/**
* Absolute, containment-checked path of a video's sibling subtitle file
* ("Lesson.mp4" -> "Lesson.srt"), or null if there isn't one.
*/
function srtFilePath(array $video): ?string
{
$rel = preg_replace('/\.mp4$/i', '.srt', $video['path']);
if ($rel === null || $rel === $video['path']) {
return null;
}
$file = realpath(rtrim(VIDEOS_ROOT, '/') . '/' . $rel);
$root = realpath(VIDEOS_ROOT);
if ($file === false || $root === false
|| !str_starts_with($file, $root . DIRECTORY_SEPARATOR) || !is_file($file)) {
return null;
}
return $file;
}
/**
* SubRip -> WebVTT (the only caption format browsers take in <track>).
* The formats differ by a header line and the decimal separator in
* timestamps; cue numbers are legal VTT cue identifiers and pass through.
*/
function srtToVtt(string $srt): string
{
if (str_starts_with($srt, "\xEF\xBB\xBF")) {
$srt = substr($srt, 3);
}
if (!preg_match('//u', $srt)) { // not valid UTF-8 — legacy single-byte file
$recoded = function_exists('iconv')
? @iconv('Windows-1252', 'UTF-8//TRANSLIT', $srt)
: (function_exists('mb_convert_encoding')
? mb_convert_encoding($srt, 'UTF-8', 'Windows-1252') : false);
if (is_string($recoded)) {
$srt = $recoded;
}
}
$srt = str_replace(["\r\n", "\r"], "\n", $srt);
// Normalise timestamps to HH:MM:SS.mmm. SRT uses a comma; generated files
// in the wild also carry single-digit hours and six-digit fractions, and
// the WebVTT parsing algorithm rejects any fraction that isn't exactly
// three digits — such cues would silently drop.
$srt = preg_replace_callback(
'/(\d+):(\d{2}):(\d{2})[.,](\d+)/',
fn($m) => sprintf('%02d:%s:%s.%s', $m[1], $m[2], $m[3],
substr(str_pad($m[4], 3, '0', STR_PAD_RIGHT), 0, 3)),
$srt
);
// Some ".srt" files are already WebVTT — don't stack a second header.
$srt = trim($srt);
return (str_starts_with($srt, 'WEBVTT') ? $srt : "WEBVTT\n\n" . $srt) . "\n";
}
/** Breadcrumb chain root -> ... -> folder. */
function folderBreadcrumbs(array $folder): array
{
+4 -6
View File
@@ -23,10 +23,9 @@ function asset(string $path): string
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#F2F0EA">
<title>CourseHub</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/plyr@3.7.8/dist/plyr.css" rel="stylesheet" data-boot-critical>
<!-- All assets are local (assets/vendor/) — the app runs with no internet access. -->
<link href="<?= asset('assets/vendor/inter.css') ?>" rel="stylesheet">
<link href="<?= asset('assets/vendor/plyr.css') ?>" rel="stylesheet" data-boot-critical>
<link href="<?= asset('assets/style.css') ?>" rel="stylesheet" data-boot-critical>
<!-- Installed before every other script so it can report failures in them
@@ -137,8 +136,7 @@ function asset(string $path): string
<div class="toasts" id="toasts"></div>
<div id="overlay-root"></div>
<script src="https://cdn.jsdelivr.net/npm/plyr@3.7.8/dist/plyr.min.js" data-boot-critical></script>
<script src="https://cdn.jsdelivr.net/npm/animejs@3.2.2/lib/anime.min.js" data-boot-critical></script>
<script src="<?= asset('assets/vendor/plyr.min.js') ?>" data-boot-critical></script>
<script src="<?= asset('assets/app.js') ?>" data-boot-critical></script>
</body>
</html>
+12
View File
@@ -9,6 +9,18 @@ CREATE TABLE IF NOT EXISTS users (
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB;
-- Failed sign-in attempts, for the login brute-force throttle. Rows older
-- than an hour are purged as new failures are recorded. Existing installs
-- get this table lazily on the first failed login.
CREATE TABLE IF NOT EXISTS login_attempts (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) NOT NULL,
ip VARCHAR(45) NOT NULL,
attempted_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_la_user_time (username, attempted_at),
INDEX idx_la_ip_time (ip, attempted_at)
) ENGINE=InnoDB;
-- Small key/value store for app-wide facts (currently: when the last scan ran).
CREATE TABLE IF NOT EXISTS app_meta (
k VARCHAR(64) PRIMARY KEY,