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
+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)' });
}
})();
})();