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:
Harwinder Sekhon
2026-07-30 21:14:07 -04:00
parent 0bd11f6ab3
commit 17a401c313
22 changed files with 433 additions and 102 deletions
+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
{