e3a054e109
- 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
257 lines
10 KiB
PHP
257 lines
10 KiB
PHP
<?php
|
|
// GET api/library.php?view=meta -> sidebar/header chrome: totals, shelves, last scan
|
|
// GET api/library.php?view=home -> resume card, stats, in progress, recent additions
|
|
// GET api/library.php?view=library -> every course, grouped into shelves
|
|
// GET api/library.php?view=folder&id=N -> folder contents + progress
|
|
// GET api/library.php?view=video&id=N -> video + playlist + resume info
|
|
// GET api/library.php?view=search&q=text -> matching folders, videos, quizzes
|
|
// GET api/library.php?view=settings -> library paths, thresholds, progress summary
|
|
require_once __DIR__ . '/../includes/auth.php';
|
|
require_once __DIR__ . '/../includes/functions.php';
|
|
|
|
header('Content-Type: application/json');
|
|
$userId = requireLoginApi();
|
|
|
|
function folderSummary(array $f, int $userId): array
|
|
{
|
|
return [
|
|
'id' => (int)$f['id'],
|
|
'name' => $f['name'],
|
|
'progress' => folderProgress($userId, $f['path']),
|
|
];
|
|
}
|
|
|
|
function videoSummary(array $v): array
|
|
{
|
|
$duration = $v['duration_seconds'] ?? $v['watched_duration'] ?? null;
|
|
$position = (float)($v['position_seconds'] ?? 0);
|
|
return [
|
|
'id' => (int)$v['id'],
|
|
'name' => $v['name'],
|
|
'filename' => $v['filename'],
|
|
'duration' => $duration !== null ? (float)$duration : null,
|
|
'position' => $position,
|
|
'completed' => (bool)($v['completed'] ?? false),
|
|
'percent' => ($duration ?? 0) > 0 ? min(100, (int)round(100 * $position / $duration)) : 0,
|
|
];
|
|
}
|
|
|
|
function quizSummary(array $q): array
|
|
{
|
|
return [
|
|
'id' => (int)$q['id'],
|
|
'name' => $q['name'],
|
|
'filename' => $q['filename'],
|
|
'questions' => (int)$q['num_questions'],
|
|
'attempts' => (int)($q['attempts'] ?? 0),
|
|
'best_score' => (int)($q['best_score'] ?? 0),
|
|
'completed' => (bool)($q['completed'] ?? false),
|
|
];
|
|
}
|
|
|
|
function crumbList(array $folder): array
|
|
{
|
|
return array_map(
|
|
fn($c) => ['id' => (int)$c['id'], 'name' => $c['name']],
|
|
folderBreadcrumbs($folder)
|
|
);
|
|
}
|
|
|
|
/** The three shelves the library groups courses into. */
|
|
function shelveCourses(array $courses): array
|
|
{
|
|
$shelves = [
|
|
['key' => 'progress', 'name' => 'In progress', 'courses' => []],
|
|
['key' => 'finished', 'name' => 'Finished', 'courses' => []],
|
|
['key' => 'fresh', 'name' => 'Not started', 'courses' => []],
|
|
];
|
|
foreach ($courses as $c) {
|
|
foreach ($shelves as $i => $shelf) {
|
|
if ($shelf['key'] === $c['state']) {
|
|
$shelves[$i]['courses'][] = $c;
|
|
}
|
|
}
|
|
}
|
|
return array_values(array_filter($shelves, fn($s) => $s['courses']));
|
|
}
|
|
|
|
$view = $_GET['view'] ?? 'home';
|
|
|
|
switch ($view) {
|
|
case 'meta':
|
|
$courses = courseCards($userId);
|
|
$counts = ['progress' => 0, 'finished' => 0, 'fresh' => 0];
|
|
foreach ($courses as $c) {
|
|
$counts[$c['state']]++;
|
|
}
|
|
echo json_encode([
|
|
'totals' => libraryTotals(),
|
|
'shelves' => [
|
|
['key' => 'progress', 'name' => 'In progress', 'count' => $counts['progress']],
|
|
['key' => 'finished', 'name' => 'Finished', 'count' => $counts['finished']],
|
|
['key' => 'fresh', 'name' => 'Not started', 'count' => $counts['fresh']],
|
|
],
|
|
'last_scan' => appMetaGet('last_scan_at'),
|
|
'username' => currentUsername(),
|
|
]);
|
|
break;
|
|
|
|
case 'home':
|
|
$courses = courseCards($userId);
|
|
$resume = continueWatching($userId, 1);
|
|
$resumeCourseId = null;
|
|
if ($resume) {
|
|
$resumeFolder = getFolder((int)$resume[0]['folder_id']);
|
|
if ($resumeFolder) $resumeCourseId = courseIdOf($resumeFolder);
|
|
}
|
|
echo json_encode([
|
|
'stats' => userStats($userId, $courses),
|
|
'resume' => $resume ? [
|
|
'id' => (int)$resume[0]['id'],
|
|
'course_id' => $resumeCourseId,
|
|
'name' => $resume[0]['name'],
|
|
'folder_path' => $resume[0]['folder_path'],
|
|
'position' => (float)$resume[0]['position_seconds'],
|
|
'duration' => $resume[0]['duration_seconds'] !== null
|
|
? (float)$resume[0]['duration_seconds'] : null,
|
|
] : null,
|
|
'continue' => array_map(fn($r) => [
|
|
'id' => (int)$r['id'],
|
|
'name' => $r['name'],
|
|
'folder_path' => $r['folder_path'],
|
|
'position' => (float)$r['position_seconds'],
|
|
'duration' => $r['duration_seconds'] !== null ? (float)$r['duration_seconds'] : null,
|
|
], continueWatching($userId)),
|
|
'in_progress' => array_values(array_filter($courses, fn($c) => $c['state'] === 'progress')),
|
|
'recent' => recentCourses($userId),
|
|
'courses' => $courses,
|
|
]);
|
|
break;
|
|
|
|
case 'library':
|
|
$courses = courseCards($userId);
|
|
echo json_encode([
|
|
'courses' => $courses,
|
|
'shelves' => shelveCourses($courses),
|
|
'totals' => libraryTotals(),
|
|
]);
|
|
break;
|
|
|
|
case 'folder':
|
|
$folder = getFolder((int)($_GET['id'] ?? 0));
|
|
if (!$folder) {
|
|
http_response_code(404);
|
|
echo json_encode(['error' => 'Folder not found']);
|
|
break;
|
|
}
|
|
$crumbs = crumbList($folder);
|
|
echo json_encode([
|
|
'folder' => ['id' => (int)$folder['id'], 'name' => $folder['name'],
|
|
'path' => $folder['path']],
|
|
'course_id' => $crumbs ? $crumbs[0]['id'] : (int)$folder['id'],
|
|
'breadcrumbs' => $crumbs,
|
|
'rollup' => folderRollup($userId, $folder['path']),
|
|
'progress' => folderProgress($userId, $folder['path']),
|
|
'subfolders' => array_map(fn($f) => courseSummary($userId, $f),
|
|
childFolders((int)$folder['id'])),
|
|
'videos' => array_map('videoSummary',
|
|
folderVideos((int)$folder['id'], $userId)),
|
|
'quizzes' => array_map('quizSummary',
|
|
folderQuizzes((int)$folder['id'], $userId)),
|
|
]);
|
|
break;
|
|
|
|
case 'video':
|
|
$video = getVideo((int)($_GET['id'] ?? 0));
|
|
if (!$video) {
|
|
http_response_code(404);
|
|
echo json_encode(['error' => 'Video not found']);
|
|
break;
|
|
}
|
|
$folder = getFolder((int)$video['folder_id']);
|
|
$playlist = folderVideos((int)$folder['id'], $userId);
|
|
$crumbs = crumbList($folder);
|
|
|
|
$stmt = db()->prepare(
|
|
'SELECT position_seconds, completed FROM video_progress
|
|
WHERE user_id = ? AND video_id = ?'
|
|
);
|
|
$stmt->execute([$userId, (int)$video['id']]);
|
|
$saved = $stmt->fetch() ?: null;
|
|
$resumeAt = ($saved && !$saved['completed'] && $saved['position_seconds'] > 5)
|
|
? (float)$saved['position_seconds'] : 0;
|
|
|
|
$prevId = $nextId = null;
|
|
foreach ($playlist as $i => $p) {
|
|
if ((int)$p['id'] === (int)$video['id']) {
|
|
$prevId = isset($playlist[$i - 1]) ? (int)$playlist[$i - 1]['id'] : null;
|
|
$nextId = isset($playlist[$i + 1]) ? (int)$playlist[$i + 1]['id'] : null;
|
|
break;
|
|
}
|
|
}
|
|
|
|
echo json_encode([
|
|
'video' => ['id' => (int)$video['id'], 'name' => $video['name'],
|
|
'duration' => $video['duration_seconds'] !== null
|
|
? (float)$video['duration_seconds'] : null],
|
|
'folder' => ['id' => (int)$folder['id'], 'name' => $folder['name']],
|
|
'course_id' => $crumbs ? $crumbs[0]['id'] : (int)$folder['id'],
|
|
'breadcrumbs' => $crumbs,
|
|
'playlist' => array_map('videoSummary', $playlist),
|
|
'quizzes' => array_map('quizSummary', folderQuizzes((int)$folder['id'], $userId)),
|
|
'resume_at' => $resumeAt,
|
|
'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;
|
|
|
|
case 'search':
|
|
$q = trim((string)($_GET['q'] ?? ''));
|
|
if ($q === '') {
|
|
echo json_encode(['query' => '', 'folders' => [], 'videos' => [], 'quizzes' => []]);
|
|
break;
|
|
}
|
|
$withPath = fn(array $row, array $summary) =>
|
|
$summary + ['folder_path' => $row['folder_path']];
|
|
|
|
echo json_encode([
|
|
'query' => $q,
|
|
'folders' => array_map(
|
|
fn($f) => courseSummary($userId, $f),
|
|
searchFolders($q)
|
|
),
|
|
'videos' => array_map(
|
|
fn($v) => $withPath($v, videoSummary($v)),
|
|
searchVideos($userId, $q)
|
|
),
|
|
'quizzes' => array_map(
|
|
fn($q2) => $withPath($q2, quizSummary($q2)),
|
|
searchQuizzes($userId, $q)
|
|
),
|
|
]);
|
|
break;
|
|
|
|
case 'settings':
|
|
$courses = courseCards($userId);
|
|
$withSome = array_values(array_filter($courses, fn($c) => $c['progress']['percent'] > 0));
|
|
echo json_encode([
|
|
'root' => rtrim(VIDEOS_ROOT, '/'),
|
|
'root_exists' => is_dir(VIDEOS_ROOT),
|
|
'totals' => libraryTotals(),
|
|
'last_scan' => appMetaGet('last_scan_at'),
|
|
'complete_threshold' => (int)round(100 * COMPLETE_THRESHOLD),
|
|
'registration_open' => (bool)ALLOW_REGISTRATION,
|
|
'username' => currentUsername(),
|
|
'progress_courses' => count($withSome),
|
|
'courses' => count($courses),
|
|
]);
|
|
break;
|
|
|
|
default:
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'Unknown view']);
|
|
}
|