diff --git a/.htaccess b/.htaccess index 8cbde36..f544c07 100644 --- a/.htaccess +++ b/.htaccess @@ -6,10 +6,15 @@ 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/ +# +# RedirectMatch always sees the full URL path, even from a .htaccess, so the +# pattern must not assume the app lives at "/" — anchored to the root it +# silently stops matching (and stops protecting) an install under +# "/coursehub/" or any other prefix. +RedirectMatch 404 (^|/)videos/ # App internals — nothing under these is meant to be fetched. -RedirectMatch 404 ^/includes/ +RedirectMatch 404 (^|/)includes/ Require all denied diff --git a/README.md b/README.md index 8b63d18..e23827e 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,12 @@ Top-level folders become courses on the home page. the web server / PHP user). - DB credentials (change the password in production). - `ALLOW_REGISTRATION`: set `false` once your users have accounts. + - `SESSION_LIFETIME`: seconds a sign-in lasts (the example config says 30 + days). Unset or `0` means a browser-session cookie that also dies after + PHP's idle limit — 24 minutes by default, which signs people out over a + long pause. Installs whose `config.php` predates the setting keep that + old behaviour. If other PHP apps share the server's session directory, + give this app its own `session.save_path` in the vhost / FPM pool. - `DEBUG`: include the exception type, `file:line` and stack trace in API error responses. Errors go to the PHP error log either way, so leave this `false` on anything publicly reachable. Installs whose `config.php` @@ -122,6 +128,19 @@ Questions start with `[&]`, options with `- `, and correct options end with `-- Correct`. Questions with more than one correct option automatically render as checkboxes instead of radios. +A question that refers to a diagram carries it as a Markdown image on its own +line, anywhere between the `[&]` line and the next question: + +``` +[&] Based on the diagram shown, which of the following are true? +![quiz image](https://example.com/quiz-images/switching1.png) +- PE1 imposes a label -- Correct +``` + +The image is shown under the question text. It is fetched by the browser from +wherever the URL points (usually the course provider's server), so an install +with no internet access sees a note and a link instead of the picture. + Quizzes are graded server-side (answers are never sent to the browser before submission). A question counts only if the selected set exactly matches the correct set. Best score, last score, and attempt count are stored per user; @@ -260,8 +279,13 @@ returned as JSON, with detail gated behind `DEBUG`. can be spoofed to dodge the per-IP limit. - 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. + bypass login entirely. The rules match those folder names anywhere in the + URL path, so they hold whether the app lives at `/` or under a prefix such as + `/coursehub/`. Nginx setups need the equivalent `location` blocks, or keep + the videos and includes outside the docroot (the safest choice either way). +- Only `.mp4` files ever reach the browser, and only through `stream.php`. + A quiz's exhibit image is the one thing loaded from elsewhere — its URL is + whatever the quiz file says, sent with `referrerpolicy="no-referrer"`. ## Production notes @@ -269,4 +293,9 @@ returned as JSON, with detail gated behind `DEBUG`. 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 — the session cookie is then marked `Secure` on its - own. + own. When TLS ends at a proxy that talks plain HTTP to the origin, the + cookie is still marked `Secure` provided `CLIENT_IP_HEADER` is set: the same + trust that makes the proxy's client-IP header believable makes its + `X-Forwarded-Proto` believable. +- A rescan is one-at-a-time: a second **Scan library** while one is running + (from any user) answers "already running" instead of racing it. diff --git a/api/library.php b/api/library.php index f4f1e9e..dfb7439 100644 --- a/api/library.php +++ b/api/library.php @@ -145,13 +145,15 @@ switch ($view) { break; } $crumbs = crumbList($folder); + $rollup = folderRollup($userId, $folder['path']); 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']), + 'rollup' => $rollup, + 'progress' => ['total' => $rollup['total'], 'completed' => $rollup['completed'], + 'percent' => $rollup['percent']], 'subfolders' => array_map(fn($f) => courseSummary($userId, $f), childFolders((int)$folder['id'])), 'videos' => array_map('videoSummary', diff --git a/api/progress.php b/api/progress.php index 3b6263d..0b2e4e4 100644 --- a/api/progress.php +++ b/api/progress.php @@ -51,7 +51,7 @@ $stmt = $db->prepare( VALUES (?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE position_seconds = VALUES(position_seconds), - duration_seconds = VALUES(duration_seconds), + duration_seconds = COALESCE(VALUES(duration_seconds), duration_seconds), -- keep a known length completed = GREATEST(completed, VALUES(completed))' // once complete, stays complete ); $stmt->execute([$userId, $videoId, $position, $duration > 0 ? $duration : null, $completed]); diff --git a/api/quiz.php b/api/quiz.php index 872f74d..555ce08 100644 --- a/api/quiz.php +++ b/api/quiz.php @@ -46,6 +46,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') { ), 'questions' => array_map(fn($q) => [ 'text' => $q['text'], + 'image' => $q['image'], 'multi' => $q['multi'], 'options' => $q['options'], ], $questions), diff --git a/assets/app.js b/assets/app.js index c31ec22..d972be7 100644 --- a/assets/app.js +++ b/assets/app.js @@ -20,7 +20,6 @@ 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, scanning: false, }; let cleanupView = null; @@ -166,6 +165,23 @@ return data; } + /* Every route() bumps this. A view that was still waiting on its data when + the route moved on must not paint over the newer view — nor, for the + player, start a progress heartbeat for a video nobody is watching. */ + let routeSeq = 0; + class StaleRender extends Error {} + + /** api() for a view's data: throws StaleRender if the route changed meanwhile. */ + async function load(url) { + const seq = routeSeq; + const data = await api(url); + if (seq !== routeSeq) throw new StaleRender('route changed'); + return data; + } + + /** decodeURIComponent that survives a stray "%" typed into the URL bar. */ + const safeDecode = (s) => { try { return decodeURIComponent(s); } catch (e) { return s; } }; + const spinner = () => { view.innerHTML = '
'; }; /* ---------- toast / dialog / menu ---------- */ @@ -457,6 +473,7 @@ `; const form = document.getElementById('login-form'); + document.getElementById('f-user').focus(); // the autofocus attribute is inert on injected markup let action = 'login'; form.querySelectorAll('button[data-action]').forEach((b) => b.addEventListener('click', () => { @@ -546,7 +563,7 @@ async function renderHome() { spinner(); - const data = await api('api/library.php?view=home'); + const data = await load('api/library.php?view=home'); const inProgress = data.in_progress.slice(0, 4); const progressBlock = inProgress.length @@ -591,8 +608,9 @@ /* ---------- library ---------- */ async function renderLibrary(filter) { + if (filter && !(filter in SHELF_NAME)) filter = null; // #/library/whatever → every course spinner(); - const data = await api('api/library.php?view=library'); + const data = await load('api/library.php?view=library'); const layout = filter && filter !== 'all' ? 'catalog' : pref('layout'); const mode = pref('view'); @@ -657,7 +675,7 @@ async function renderFolder(id) { spinner(); - const data = await api(`api/library.php?view=folder&id=${id}`); + const data = await load(`api/library.php?view=folder&id=${id}`); const mode = pref('view'); const hue = hueOf(data.course_id); @@ -714,8 +732,6 @@ /* ---------- search ---------- */ async function renderSearch(query) { - const seq = ++state.searchSeq; - if (!query) { view.innerHTML = `
@@ -727,8 +743,7 @@ } spinner(); - const data = await api(`api/library.php?view=search&q=${encodeURIComponent(query)}`); - if (seq !== state.searchSeq) return; // a newer keystroke already won + const data = await load(`api/library.php?view=search&q=${encodeURIComponent(query)}`); const total = data.folders.length + data.videos.length + data.quizzes.length; @@ -775,7 +790,7 @@ async function renderWatch(id) { spinner(); - const data = await api(`api/library.php?view=video&id=${id}`); + const data = await load(`api/library.php?view=video&id=${id}`); const playlist = data.playlist.map((p) => { const active = p.id === data.video.id; @@ -827,6 +842,15 @@
`; + // Bring the current entry into the playlist's own scroll box (not the + // page's — scrollIntoView would drag the viewport down to it on mobile). + const scroller = view.querySelector('.playlist-scroll'); + const current = scroller && scroller.querySelector('.is-active'); + if (current) { + const offset = current.getBoundingClientRect().top - scroller.getBoundingClientRect().top; + scroller.scrollTop += offset - (scroller.clientHeight - current.offsetHeight) / 2; + } + cleanupView = initPlayer(data); } @@ -834,7 +858,7 @@ async function renderQuiz(id) { spinner(); - const data = await api(`api/quiz.php?id=${id}`); + const data = await load(`api/quiz.php?id=${id}`); const answers = data.questions.map(() => []); let graded = null; // server results once submitted @@ -857,6 +881,10 @@ ${qi + 1}
${esc(q.text)}
+ ${q.image ? `
+ ${esc(q.image.alt)} +
${esc(q.image.alt)} · open
+
` : ''} ${q.multi && !r ? '
Select every answer that applies.
' : ''} ${r ? `
${r.correct ? 'Correct.' : 'Not quite — the right answer is highlighted below.'}
` : ''} @@ -916,9 +944,17 @@
`; } - view.onclick = null; draw(); + // Exhibits are external images; when one can't load, say so instead of + // leaving a blank box (the caption's link still opens the original). + const onImageError = (e) => { + const fig = e.target.closest && e.target.closest('.q-exhibit'); + if (fig) fig.classList.add('is-broken'); + }; + view.addEventListener('error', onImageError, true); // error events don't bubble + + let submitting = false; // One delegated handler for the whole quiz view, re-bound on each draw. const onClick = async (e) => { const opt = e.target.closest('.q-option'); @@ -934,7 +970,11 @@ draw(); return; } - if (e.target.closest('[data-quiz-submit]') && !graded) { + const submit = e.target.closest('[data-quiz-submit]'); + if (submit && !graded) { + if (submitting) return; // a second click while the first is in flight + submitting = true; + submit.disabled = true; try { graded = await api('api/quiz.php', { json: { quiz_id: id, answers } }); draw(); @@ -943,7 +983,10 @@ : `Scored ${graded.score}/${graded.total}.`, graded.completed ? 'ok' : 'error'); loadMeta(); } catch (err) { + submit.disabled = false; toast(err.message, 'error'); + } finally { + submitting = false; } return; } @@ -954,14 +997,17 @@ } }; view.addEventListener('click', onClick); - cleanupView = () => view.removeEventListener('click', onClick); + cleanupView = () => { + view.removeEventListener('click', onClick); + view.removeEventListener('error', onImageError, true); + }; } /* ---------- settings ---------- */ async function renderSettings() { spinner(); - const data = await api('api/library.php?view=settings'); + const data = await load('api/library.php?view=settings'); const toggles = [ { key: 'autoplay', label: 'Autoplay next video', @@ -1074,10 +1120,10 @@ const status = (html) => { if (statusEl) statusEl.innerHTML = html; }; - async function save() { + async function save(force = false) { const p = payload(); if (state.suppressSave) return; // a reset just wiped this row — don't recreate it - if (!p.duration || Math.abs(p.position - lastSaved) < 1) return; + if (!p.duration || (!force && Math.abs(p.position - lastSaved) < 1)) return; lastSaved = p.position; try { const res = await api('api/progress.php', { json: p, keepalive: true }); @@ -1095,14 +1141,15 @@ .map(([k, v]) => [k, String(v)]))); } - player.on('playing', () => { if (!saveTimer) saveTimer = setInterval(save, 5000); }); - player.on('pause', save); - player.on('seeked', save); + let nextTimer = null; + player.on('playing', () => { if (!saveTimer) saveTimer = setInterval(() => save(), 5000); }); + player.on('pause', () => save()); + player.on('seeked', () => save()); player.on('ended', () => { - save(); + save(true); // the final position always lands, even within 1 s of the last heartbeat if (data.next_id && pref('autoplay') !== false) { status(`${icon('next', 14, 1.7)}Playing next video…`); - setTimeout(() => { location.hash = `#/watch/${data.next_id}`; }, 1500); + nextTimer = setTimeout(() => { location.hash = `#/watch/${data.next_id}`; }, 1500); } }); @@ -1112,6 +1159,7 @@ return function cleanup() { saveBeacon(); + clearTimeout(nextTimer); // leaving during the 1.5 s countdown must not jump ahead later if (saveTimer) clearInterval(saveTimer); document.removeEventListener('visibilitychange', onHide); window.removeEventListener('pagehide', saveBeacon); @@ -1195,6 +1243,7 @@ /* ---------- router ---------- */ async function route() { + routeSeq++; // anything still loading for the previous view is now stale if (cleanupView) { cleanupView(); cleanupView = null; } state.suppressSave = false; // the teardown above is the only thing it guarded closeMenu(); @@ -1204,19 +1253,24 @@ const hash = location.hash || '#/'; const searchMatch = hash.match(/^#\/search\/(.*)$/); - syncSearchInput(searchMatch ? decodeURIComponent(searchMatch[1]) : ''); + const query = searchMatch ? safeDecode(searchMatch[1]) : ''; + syncSearchInput(query); renderChrome(); + // Views replace each other in the same document, so the page keeps the old + // scroll offset unless told otherwise — except while a search is being refined. + if (!(searchMatch && document.activeElement === searchInput)) window.scrollTo(0, 0); let m; try { if ((m = hash.match(/^#\/folder\/(\d+)$/))) await renderFolder(+m[1]); else if ((m = hash.match(/^#\/watch\/(\d+)$/))) await renderWatch(+m[1]); else if ((m = hash.match(/^#\/quiz\/(\d+)$/))) await renderQuiz(+m[1]); - else if (searchMatch) await renderSearch(decodeURIComponent(searchMatch[1])); + else if (searchMatch) await renderSearch(query); else if ((m = hash.match(/^#\/library(?:\/(\w+))?$/))) await renderLibrary(m[1] || null); else if (hash.startsWith('#/settings')) await renderSettings(); else await renderHome(); } catch (err) { + if (err instanceof StaleRender) return; // a newer route is drawing; nothing to show if (state.user) { view.innerHTML = `

That didn't load

diff --git a/assets/style.css b/assets/style.css index 1ecff2e..30d2b8e 100644 --- a/assets/style.css +++ b/assets/style.css @@ -840,6 +840,19 @@ button, input { font-family: inherit; font-size: inherit; } .q-text { font: 600 17px/1.4 var(--font-ui); text-wrap: pretty; } .q-note { font: 500 15px/1.4 var(--font-ui); margin-top: 6px; color: var(--success); } .q-note.is-wrong { color: var(--danger); } + +/* The diagram a question refers to. It lives on the course provider's server, + so it may not load on an offline install — then the caption's link remains. */ +.q-exhibit { margin: 12px 0 0; } +.q-exhibit img { + display: block; max-width: 100%; height: auto; + border: 1px solid var(--line); border-radius: 8px; background: var(--paper); +} +.q-exhibit figcaption { margin-top: 6px; font: 500 13px/1.4 var(--font-ui); color: var(--ink-4); } +.q-exhibit figcaption a { color: var(--ink-3); } +.q-exhibit.is-broken img { display: none; } +.q-exhibit.is-broken figcaption::before { content: 'Exhibit could not be loaded — '; color: var(--danger); } + .q-options { display: flex; flex-direction: column; gap: 8px; padding-left: 40px; } .q-option { diff --git a/config.example.php b/config.example.php index 98bc155..ce5ba13 100644 --- a/config.example.php +++ b/config.example.php @@ -19,6 +19,15 @@ define('COMPLETE_THRESHOLD', 0.95); // Allow new users to self-register from the login page. define('ALLOW_REGISTRATION', true); +// How long a sign-in lasts, in seconds (30 days below). The cookie survives +// browser restarts, and idle sessions are kept at least this long. 0 = a +// browser-session cookie that also expires after php.ini's idle limit +// (session.gc_maxlifetime, 24 min by default — pausing a video over lunch +// signs you out). If other PHP apps share this server's session directory, +// give this app its own session.save_path in the vhost / FPM pool, or their +// shorter limit still wins. +define('SESSION_LIFETIME', 30 * 86400); + // Only when the app sits behind a proxy that hides the visitor's address // (Cloudflare): the header carrying the real client IP, used by the login // brute-force throttle. Set this ONLY if the origin cannot be reached except diff --git a/includes/auth.php b/includes/auth.php index 460ceb0..b012326 100644 --- a/includes/auth.php +++ b/includes/auth.php @@ -1,14 +1,40 @@ 0) { + ini_set('session.gc_maxlifetime', (string)$lifetime); + } // 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, + 'lifetime' => $lifetime, 'path' => '/', - 'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off', + 'secure' => requestIsHttps(), 'httponly' => true, 'samesite' => 'Lax', ]); @@ -52,16 +78,6 @@ function currentUsername(): ?string return $_SESSION['username'] ?? null; } -function requireLogin(): int -{ - $uid = currentUserId(); - if ($uid === null) { - header('Location: login.php'); - exit; - } - return $uid; -} - function requireLoginApi(): int { $uid = currentUserId(); @@ -198,7 +214,14 @@ function registerUser(string $username, string $password): string|bool if ($stmt->fetch()) { return 'That username is already taken.'; } - $stmt = db()->prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)'); - $stmt->execute([$username, password_hash($password, PASSWORD_DEFAULT)]); + try { + $stmt = db()->prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)'); + $stmt->execute([$username, password_hash($password, PASSWORD_DEFAULT)]); + } catch (PDOException $e) { + if ($e->getCode() === '23000') { // lost the race to the same name + return 'That username is already taken.'; + } + throw $e; + } return true; } diff --git a/includes/db.php b/includes/db.php index ace8025..a90a916 100644 --- a/includes/db.php +++ b/includes/db.php @@ -32,9 +32,13 @@ if (PHP_SAPI !== 'cli') { // Uncaught exceptions become JSON errors (the SPA surfaces them) instead // of blank 500 pages. + // The message itself is DEBUG-only too: a PDOException spells out the DB + // user, host and table names, and this handler also runs for visitors who + // never logged in (the sign-in call touches the database). set_exception_handler(function (Throwable $e) use ($emit): void { error_log($e); - $emit('Server error: ' . $e->getMessage(), [ + $emit(DEBUG ? 'Server error: ' . $e->getMessage() + : 'Server error — the details are in the PHP error log.', [ 'type' => get_class($e), 'file' => $e->getFile() . ':' . $e->getLine(), 'trace' => explode("\n", $e->getTraceAsString()), @@ -49,7 +53,8 @@ if (PHP_SAPI !== 'cli') { if ($err === null || !($err['type'] & (E_ERROR | E_PARSE | E_CORE_ERROR | E_COMPILE_ERROR))) { return; } - $emit('Fatal error: ' . $err['message'], [ + $emit(DEBUG ? 'Fatal error: ' . $err['message'] + : 'Server error — the details are in the PHP error log.', [ 'file' => $err['file'] . ':' . $err['line'], ]); }); diff --git a/includes/functions.php b/includes/functions.php index 11dd098..90040b3 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -83,9 +83,13 @@ function mp4Duration(string $path): ?float * Quiz file parser * Format ("*_quiz_solved.txt"): * [&] Question text (Select two) + * ![exhibit](https://example.com/diagram.png) <- optional, one per question * - wrong option * - right option -- Correct - * Returns [['text', 'options' => [...], 'correct' => [optIndex,...], 'multi'], ...] + * A Markdown image line anywhere inside a question is its exhibit (the + * diagram the question refers to); it is kept out of the question text and + * returned as ['url', 'alt'] so the UI can show the picture. + * Returns [['text', 'image' => ?[...], 'options' => [...], 'correct' => [optIndex,...], 'multi'], ...] * ------------------------------------------------------------------------- */ function parseQuizFile(string $absPath): array { @@ -100,7 +104,9 @@ function parseQuizFile(string $absPath): array if (str_starts_with($line, '[&]')) { if ($cur && $cur['options']) $questions[] = $cur; - $cur = ['text' => trim(substr($line, 3)), 'options' => [], 'correct' => []]; + $cur = ['text' => trim(substr($line, 3)), 'image' => null, 'options' => [], 'correct' => []]; + } elseif ($cur !== null && preg_match('/^!\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)$/i', $line, $m)) { + $cur['image'] = ['url' => $m[2], 'alt' => trim($m[1]) !== '' ? trim($m[1]) : 'Exhibit']; } elseif (str_starts_with($line, '-') && $cur !== null) { $opt = trim(substr($line, 1)); if (preg_match('/\s*--\s*correct\s*$/i', $opt)) { @@ -135,6 +141,23 @@ function scanLibrary(): array } $db = db(); + + // One scan at a time. Two overlapping scans would upsert the same rows + // inside two transactions and then each try to flag the other's work + // missing; the lock is per-connection and released below whatever happens. + if ((int)$db->query("SELECT GET_LOCK('coursehub_scan', 0)")->fetchColumn() !== 1) { + throw new RuntimeException('A scan is already running — try again in a moment.'); + } + try { + return scanLibraryLocked($db, $root); + } finally { + $db->query("SELECT RELEASE_LOCK('coursehub_scan')"); + } +} + +/** The scan proper — only ever called by scanLibrary() while it holds the lock. */ +function scanLibraryLocked(PDO $db, string $root): array +{ $stats = ['folders' => 0, 'videos' => 0, 'quizzes' => 0, 'missing_folders' => 0, 'missing_videos' => 0, 'missing_quizzes' => 0]; $seenFolderIds = []; @@ -148,10 +171,17 @@ function scanLibrary(): array depth = VALUES(depth), sort_order = VALUES(sort_order), is_missing = 0, id = LAST_INSERT_ID(id)' ); + // A file replaced by a different one at the same path (re-encoded, a + // corrected upload) is a different length: forget the cached duration so + // the probe below reads it afresh. The assignment order matters — MySQL + // evaluates ON DUPLICATE KEY UPDATE left to right, so the comparison has + // to happen before size_bytes is overwritten. $upsertVideo = $db->prepare( 'INSERT INTO videos (folder_id, name, filename, path, size_bytes, sort_order, is_missing) VALUES (?, ?, ?, ?, ?, ?, 0) - ON DUPLICATE KEY UPDATE folder_id = VALUES(folder_id), name = VALUES(name), + ON DUPLICATE KEY UPDATE + duration_seconds = IF(size_bytes <> VALUES(size_bytes), NULL, duration_seconds), + folder_id = VALUES(folder_id), name = VALUES(name), filename = VALUES(filename), size_bytes = VALUES(size_bytes), sort_order = VALUES(sort_order), is_missing = 0, id = LAST_INSERT_ID(id)' diff --git a/stream.php b/stream.php index fbc6ac6..85c514a 100644 --- a/stream.php +++ b/stream.php @@ -50,11 +50,23 @@ session_write_close(); // don't block other requests from this use ignore_user_abort(false); set_time_limit(0); +// The bytes must reach the socket as they are read: an output buffer +// (php.ini's output_buffering=4096 is common) holds them back, and transparent +// compression would change the length Content-Range and Content-Length promise. +while (ob_get_level() > 0) { + ob_end_clean(); +} +ini_set('zlib.output_compression', '0'); + header('Content-Type: video/mp4'); header('Accept-Ranges: bytes'); header('Content-Length: ' . ($end - $start + 1)); header('Cache-Control: private, max-age=3600'); +if ($_SERVER['REQUEST_METHOD'] === 'HEAD') { + exit; +} + $fp = fopen($file, 'rb'); fseek($fp, $start); $remaining = $end - $start + 1;