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:
+25
-81
@@ -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)' });
|
||||
}
|
||||
})();
|
||||
})();
|
||||
|
||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
+73
@@ -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;
|
||||
}
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+2
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 5.6 KiB |
Reference in New Issue
Block a user