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:
@@ -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 ' *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
@@ -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]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
@@ -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 |
@@ -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);
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user