add video backend server, fix styling and more #54
+10
-1
@@ -418,6 +418,7 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style="width: 1%;">Sel.</th>
|
<th style="width: 1%;">Sel.</th>
|
||||||
|
<th style="width: 1%;">ID</th>
|
||||||
<th>Titre</th>
|
<th>Titre</th>
|
||||||
<th style="width: 12%;">Player</th>
|
<th style="width: 12%;">Player</th>
|
||||||
<th style="width: 20%;">Catégorie</th>
|
<th style="width: 20%;">Catégorie</th>
|
||||||
@@ -426,7 +427,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="admin-movies-body">
|
<tbody id="admin-movies-body">
|
||||||
<tr><td colspan="6" class="text-muted">Charge la liste…</td></tr>
|
<tr><td colspan="7" class="text-muted">Charge la liste…</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -679,6 +680,14 @@
|
|||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<input type="hidden" id="edit_movie_id">
|
<input type="hidden" id="edit_movie_id">
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="text-muted small mb-1">Movie ID</div>
|
||||||
|
<div class="d-flex gap-2 align-items-center">
|
||||||
|
<code class="flex-grow-1" id="edit_movie_id_label">—</code>
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm" id="copy-movie-id">Copier</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label for="edit_imdb_id" class="form-label">IMDB ID (ex: tt1234567)</label>
|
<label for="edit_imdb_id" class="form-label">IMDB ID (ex: tt1234567)</label>
|
||||||
|
|||||||
@@ -98,6 +98,8 @@ window.onload = async function () {
|
|||||||
const saveMovieChangesBtn = document.getElementById('save-movie-changes');
|
const saveMovieChangesBtn = document.getElementById('save-movie-changes');
|
||||||
|
|
||||||
const editMovieIdEl = document.getElementById('edit_movie_id');
|
const editMovieIdEl = document.getElementById('edit_movie_id');
|
||||||
|
const editMovieIdLabelEl = document.getElementById('edit_movie_id_label');
|
||||||
|
const copyMovieIdBtn = document.getElementById('copy-movie-id');
|
||||||
const editImdbIdEl = document.getElementById('edit_imdb_id');
|
const editImdbIdEl = document.getElementById('edit_imdb_id');
|
||||||
const editYearEl = document.getElementById('edit_year');
|
const editYearEl = document.getElementById('edit_year');
|
||||||
const editCategoryEl = document.getElementById('edit_category');
|
const editCategoryEl = document.getElementById('edit_category');
|
||||||
@@ -145,6 +147,17 @@ window.onload = async function () {
|
|||||||
.replace(/'/g, ''');
|
.replace(/'/g, ''');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function copyToClipboard(text) {
|
||||||
|
const t = String(text || '').trim();
|
||||||
|
if (!t) return false;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(t);
|
||||||
|
return true;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function sanitizeHtml(raw) {
|
function sanitizeHtml(raw) {
|
||||||
const html = String(raw || '');
|
const html = String(raw || '');
|
||||||
const withoutScripts = html.replace(/<script\b[\s\S]*?<\/script>/gi, '');
|
const withoutScripts = html.replace(/<script\b[\s\S]*?<\/script>/gi, '');
|
||||||
@@ -2021,6 +2034,7 @@ window.onload = async function () {
|
|||||||
if (!movie) return;
|
if (!movie) return;
|
||||||
|
|
||||||
editMovieIdEl.value = movie._id || '';
|
editMovieIdEl.value = movie._id || '';
|
||||||
|
if (editMovieIdLabelEl) editMovieIdLabelEl.textContent = movie._id || '—';
|
||||||
editImdbIdEl.value = movie.imdb_id || '';
|
editImdbIdEl.value = movie.imdb_id || '';
|
||||||
editYearEl.value = movie.year ? String(movie.year) : '';
|
editYearEl.value = movie.year ? String(movie.year) : '';
|
||||||
editCategoryEl.value = movie.category || '';
|
editCategoryEl.value = movie.category || '';
|
||||||
@@ -2147,12 +2161,12 @@ window.onload = async function () {
|
|||||||
async function loadMoviesForManagement() {
|
async function loadMoviesForManagement() {
|
||||||
const year = manageYearSelect.value;
|
const year = manageYearSelect.value;
|
||||||
const url = year ? `/api/movies?year=${encodeURIComponent(year)}` : '/api/movies';
|
const url = year ? `/api/movies?year=${encodeURIComponent(year)}` : '/api/movies';
|
||||||
adminMoviesBody.innerHTML = `<tr><td colspan="6" class="text-muted">Chargement…</td></tr>`;
|
adminMoviesBody.innerHTML = `<tr><td colspan="7" class="text-muted">Chargement…</td></tr>`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url, { method: 'GET' });
|
const res = await fetch(url, { method: 'GET' });
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
adminMoviesBody.innerHTML = `<tr><td colspan="6" class="text-danger">Erreur lors du chargement (${res.status})</td></tr>`;
|
adminMoviesBody.innerHTML = `<tr><td colspan="7" class="text-danger">Erreur lors du chargement (${res.status})</td></tr>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const movies = await res.json();
|
const movies = await res.json();
|
||||||
@@ -2160,7 +2174,7 @@ window.onload = async function () {
|
|||||||
moviesById = new Map(movies.map((m) => [m._id, m]));
|
moviesById = new Map(movies.map((m) => [m._id, m]));
|
||||||
|
|
||||||
if (!movies.length) {
|
if (!movies.length) {
|
||||||
adminMoviesBody.innerHTML = `<tr><td colspan="6" class="text-muted">Aucun film.</td></tr>`;
|
adminMoviesBody.innerHTML = `<tr><td colspan="7" class="text-muted">Aucun film.</td></tr>`;
|
||||||
updateSelectionUI();
|
updateSelectionUI();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2195,11 +2209,19 @@ window.onload = async function () {
|
|||||||
.map((b) => `<span class="badge ${b.cls} me-1 mb-1">${b.text}</span>`)
|
.map((b) => `<span class="badge ${b.cls} me-1 mb-1">${b.text}</span>`)
|
||||||
.join('');
|
.join('');
|
||||||
|
|
||||||
|
const movieId = String(m._id || '');
|
||||||
|
const shortId = movieId ? `${movieId.slice(0, 6)}…${movieId.slice(-4)}` : '—';
|
||||||
|
|
||||||
const tr = document.createElement('tr');
|
const tr = document.createElement('tr');
|
||||||
tr.innerHTML = `
|
tr.innerHTML = `
|
||||||
<td>
|
<td>
|
||||||
<input type="checkbox" class="form-check-input" data-movie-id="${m._id}">
|
<input type="checkbox" class="form-check-input" data-movie-id="${m._id}">
|
||||||
</td>
|
</td>
|
||||||
|
<td class="text-nowrap">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary font-monospace" data-copy-movie-id="${escapeHtml(movieId)}" title="${escapeHtml(movieId)}">
|
||||||
|
${escapeHtml(shortId)}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="fw-semibold">${m.title || '(sans titre)'}</div>
|
<div class="fw-semibold">${m.title || '(sans titre)'}</div>
|
||||||
<div class="text-muted small">${m.imdb_id || ''}</div>
|
<div class="text-muted small">${m.imdb_id || ''}</div>
|
||||||
@@ -2224,9 +2246,22 @@ window.onload = async function () {
|
|||||||
btn.addEventListener('click', () => openEditModal(btn.getAttribute('data-edit-movie-id')));
|
btn.addEventListener('click', () => openEditModal(btn.getAttribute('data-edit-movie-id')));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
adminMoviesBody.querySelectorAll('button[data-copy-movie-id]').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
const id = btn.getAttribute('data-copy-movie-id');
|
||||||
|
if (!id) return;
|
||||||
|
const ok = await copyToClipboard(id);
|
||||||
|
if (ok) {
|
||||||
|
showResponse('success', 'Movie ID copié.');
|
||||||
|
} else {
|
||||||
|
showResponse('warning', 'Impossible de copier automatiquement. Copie manuellement.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
updateSelectionUI();
|
updateSelectionUI();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
adminMoviesBody.innerHTML = `<tr><td colspan="6" class="text-danger">${err.message || 'Erreur réseau'}</td></tr>`;
|
adminMoviesBody.innerHTML = `<tr><td colspan="7" class="text-danger">${err.message || 'Erreur réseau'}</td></tr>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2281,6 +2316,19 @@ window.onload = async function () {
|
|||||||
saveMovieChangesBtn.addEventListener('click', saveMovieChanges);
|
saveMovieChangesBtn.addEventListener('click', saveMovieChanges);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (copyMovieIdBtn) {
|
||||||
|
copyMovieIdBtn.addEventListener('click', async () => {
|
||||||
|
const id = String(editMovieIdEl?.value || '').trim();
|
||||||
|
if (!id) return;
|
||||||
|
const ok = await copyToClipboard(id);
|
||||||
|
if (ok) {
|
||||||
|
showResponse('success', 'Movie ID copié.');
|
||||||
|
} else {
|
||||||
|
showResponse('warning', 'Impossible de copier automatiquement. Copie manuellement.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Initial load
|
// Initial load
|
||||||
activeYear = await fetchActiveYear();
|
activeYear = await fetchActiveYear();
|
||||||
if (activeYear) {
|
if (activeYear) {
|
||||||
|
|||||||
+149
-21
@@ -32,7 +32,9 @@ async function fetchPlaybackProgress(movieId, token) {
|
|||||||
|
|
||||||
async function savePlaybackProgress(movieId, token, { time, duration, keepalive } = {}) {
|
async function savePlaybackProgress(movieId, token, { time, duration, keepalive } = {}) {
|
||||||
try {
|
try {
|
||||||
await fetch(`/api/movies/${encodeURIComponent(movieId)}/progress`, {
|
// Retry once on rare upsert races (409).
|
||||||
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||||
|
const res = await fetch(`/api/movies/${encodeURIComponent(movieId)}/progress`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -41,8 +43,15 @@ async function savePlaybackProgress(movieId, token, { time, duration, keepalive
|
|||||||
body: JSON.stringify({ time, duration }),
|
body: JSON.stringify({ time, duration }),
|
||||||
keepalive: !!keepalive,
|
keepalive: !!keepalive,
|
||||||
});
|
});
|
||||||
|
if (res.ok) return true;
|
||||||
|
if (res.status === 409) continue;
|
||||||
|
if (res.status === 401) return false;
|
||||||
|
return true; // best-effort; don't break playback on other errors
|
||||||
|
}
|
||||||
|
return true;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// best-effort
|
// best-effort
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,13 +63,16 @@ function setupVideoProgress({ videoEl, movieId, token, userId }) {
|
|||||||
let intervalId = null;
|
let intervalId = null;
|
||||||
let lastSavedAt = 0;
|
let lastSavedAt = 0;
|
||||||
let lastSavedTime = -1;
|
let lastSavedTime = -1;
|
||||||
|
let lastSaveStatus = '—';
|
||||||
|
let seekSaveTimer = null;
|
||||||
|
|
||||||
function getSnapshot() {
|
function getSnapshot() {
|
||||||
const t = Number(videoEl.currentTime);
|
const t = Number(videoEl.currentTime);
|
||||||
const d = Number(videoEl.duration);
|
const d = Number(videoEl.duration);
|
||||||
return {
|
return {
|
||||||
time: Number.isFinite(t) && t >= 0 ? t : 0,
|
// Use whole seconds to keep saved values stable.
|
||||||
duration: Number.isFinite(d) && d > 0 ? d : null,
|
time: Number.isFinite(t) && t >= 0 ? Math.floor(t) : 0,
|
||||||
|
duration: Number.isFinite(d) && d > 0 ? Math.floor(d) : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,19 +98,24 @@ function setupVideoProgress({ videoEl, movieId, token, userId }) {
|
|||||||
const clamped = Math.min(Math.max(targetTime, 0), Math.max(0, dur - 3));
|
const clamped = Math.min(Math.max(targetTime, 0), Math.max(0, dur - 3));
|
||||||
if (clamped > 1) {
|
if (clamped > 1) {
|
||||||
try { videoEl.currentTime = clamped; } catch (_) {}
|
try { videoEl.currentTime = clamped; } catch (_) {}
|
||||||
|
lastSaveStatus = `Reprise à ${formatClock(clamped)}.`;
|
||||||
|
setProgressStatus(lastSaveStatus);
|
||||||
|
} else {
|
||||||
|
lastSaveStatus = 'Lecture depuis le début.';
|
||||||
|
setProgressStatus(lastSaveStatus);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function persist({ keepalive } = {}) {
|
async function persist({ keepalive, force } = {}) {
|
||||||
// Throttle to avoid spamming the API
|
// Throttle to avoid spamming the API
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (!keepalive && now - lastSavedAt < 2000) return;
|
if (!force && !keepalive && now - lastSavedAt < 1500) return;
|
||||||
|
|
||||||
const snap = getSnapshot();
|
const snap = getSnapshot();
|
||||||
if (!Number.isFinite(snap.time)) return;
|
if (!Number.isFinite(snap.time)) return;
|
||||||
|
|
||||||
// Only save if it meaningfully changed (>= 1s)
|
// Only save if it meaningfully changed (>= 1s)
|
||||||
if (lastSavedTime >= 0 && Math.abs(snap.time - lastSavedTime) < 1) return;
|
if (!force && lastSavedTime >= 0 && Math.abs(snap.time - lastSavedTime) < 1) return;
|
||||||
|
|
||||||
lastSavedAt = now;
|
lastSavedAt = now;
|
||||||
lastSavedTime = snap.time;
|
lastSavedTime = snap.time;
|
||||||
@@ -107,12 +124,28 @@ function setupVideoProgress({ videoEl, movieId, token, userId }) {
|
|||||||
localStorage.setItem(key, JSON.stringify({ time: snap.time, duration: snap.duration, at: now }));
|
localStorage.setItem(key, JSON.stringify({ time: snap.time, duration: snap.duration, at: now }));
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|
||||||
await savePlaybackProgress(movieId, token, { ...snap, keepalive: !!keepalive });
|
setProgressStatus('Sauvegarde…');
|
||||||
|
const ok = await savePlaybackProgress(movieId, token, { ...snap, keepalive: !!keepalive });
|
||||||
|
if (ok === false) {
|
||||||
|
// Token expired mid-playback; stop spamming requests and prompt user.
|
||||||
|
stopInterval();
|
||||||
|
try { localStorage.removeItem('auth_token'); } catch (_) {}
|
||||||
|
lastSaveStatus = 'Connexion requise pour sauvegarder.';
|
||||||
|
setProgressStatus(lastSaveStatus);
|
||||||
|
showAlertVariant('Session expirée. Reconnecte-toi pour continuer à sauvegarder la progression.', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastSaveStatus = `Sauvegardé à ${formatClock(snap.time)}.`;
|
||||||
|
setProgressStatus(lastSaveStatus);
|
||||||
}
|
}
|
||||||
|
|
||||||
function startInterval() {
|
function startInterval() {
|
||||||
if (intervalId) return;
|
if (intervalId) return;
|
||||||
intervalId = window.setInterval(() => { void persist(); }, 10000);
|
// Safety-net; primary saving happens via timeupdate throttling.
|
||||||
|
intervalId = window.setInterval(() => {
|
||||||
|
if (videoEl.paused || videoEl.seeking) return;
|
||||||
|
void persist();
|
||||||
|
}, 8000);
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopInterval() {
|
function stopInterval() {
|
||||||
@@ -122,21 +155,32 @@ function setupVideoProgress({ videoEl, movieId, token, userId }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onPlay = () => startInterval();
|
const onPlay = () => startInterval();
|
||||||
const onPause = () => { stopInterval(); void persist(); };
|
const onPause = () => { stopInterval(); void persist({ force: true }); };
|
||||||
const onSeeked = () => { void persist(); };
|
const onSeeking = () => {
|
||||||
|
// While scrubbing, browsers fire many events; debounce a forced save so it lands quickly.
|
||||||
|
if (seekSaveTimer) window.clearTimeout(seekSaveTimer);
|
||||||
|
seekSaveTimer = window.setTimeout(() => { void persist({ force: true }); }, 400);
|
||||||
|
};
|
||||||
|
const onSeeked = () => { void persist({ force: true }); };
|
||||||
const onEnded = () => { stopInterval(); void persist(); };
|
const onEnded = () => { stopInterval(); void persist(); };
|
||||||
const onError = () => { stopInterval(); };
|
const onError = () => { stopInterval(); };
|
||||||
const onPageHide = () => { void persist({ keepalive: true }); };
|
const onTimeUpdate = () => {
|
||||||
|
if (videoEl.paused || videoEl.seeking) return;
|
||||||
|
void persist();
|
||||||
|
};
|
||||||
|
const onPageHide = () => { void persist({ keepalive: true, force: true }); };
|
||||||
const onVisibility = () => {
|
const onVisibility = () => {
|
||||||
if (document.visibilityState === 'hidden') void persist({ keepalive: true });
|
if (document.visibilityState === 'hidden') void persist({ keepalive: true, force: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
videoEl.addEventListener('loadedmetadata', restoreProgressOnce, { once: true });
|
videoEl.addEventListener('loadedmetadata', restoreProgressOnce, { once: true });
|
||||||
videoEl.addEventListener('play', onPlay);
|
videoEl.addEventListener('play', onPlay);
|
||||||
videoEl.addEventListener('pause', onPause);
|
videoEl.addEventListener('pause', onPause);
|
||||||
|
videoEl.addEventListener('seeking', onSeeking);
|
||||||
videoEl.addEventListener('seeked', onSeeked);
|
videoEl.addEventListener('seeked', onSeeked);
|
||||||
videoEl.addEventListener('ended', onEnded);
|
videoEl.addEventListener('ended', onEnded);
|
||||||
videoEl.addEventListener('error', onError);
|
videoEl.addEventListener('error', onError);
|
||||||
|
videoEl.addEventListener('timeupdate', onTimeUpdate);
|
||||||
|
|
||||||
window.addEventListener('pagehide', onPageHide);
|
window.addEventListener('pagehide', onPageHide);
|
||||||
document.addEventListener('visibilitychange', onVisibility);
|
document.addEventListener('visibilitychange', onVisibility);
|
||||||
@@ -146,13 +190,17 @@ function setupVideoProgress({ videoEl, movieId, token, userId }) {
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
stopInterval();
|
stopInterval();
|
||||||
|
if (seekSaveTimer) window.clearTimeout(seekSaveTimer);
|
||||||
videoEl.removeEventListener('play', onPlay);
|
videoEl.removeEventListener('play', onPlay);
|
||||||
videoEl.removeEventListener('pause', onPause);
|
videoEl.removeEventListener('pause', onPause);
|
||||||
|
videoEl.removeEventListener('seeking', onSeeking);
|
||||||
videoEl.removeEventListener('seeked', onSeeked);
|
videoEl.removeEventListener('seeked', onSeeked);
|
||||||
videoEl.removeEventListener('ended', onEnded);
|
videoEl.removeEventListener('ended', onEnded);
|
||||||
videoEl.removeEventListener('error', onError);
|
videoEl.removeEventListener('error', onError);
|
||||||
|
videoEl.removeEventListener('timeupdate', onTimeUpdate);
|
||||||
window.removeEventListener('pagehide', onPageHide);
|
window.removeEventListener('pagehide', onPageHide);
|
||||||
document.removeEventListener('visibilitychange', onVisibility);
|
document.removeEventListener('visibilitychange', onVisibility);
|
||||||
|
if (lastSaveStatus) setProgressStatus(lastSaveStatus);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,6 +238,21 @@ function setSourceLabel(text) {
|
|||||||
el.title = text || '';
|
el.title = text || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setProgressStatus(text) {
|
||||||
|
const el = document.getElementById('progress-status');
|
||||||
|
if (!el) return;
|
||||||
|
el.textContent = text || '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatClock(seconds) {
|
||||||
|
const s = Math.max(0, Math.floor(Number(seconds) || 0));
|
||||||
|
const h = Math.floor(s / 3600);
|
||||||
|
const m = Math.floor((s % 3600) / 60);
|
||||||
|
const sec = s % 60;
|
||||||
|
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
|
||||||
|
return `${m}:${String(sec).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
function setHeader(movie) {
|
function setHeader(movie) {
|
||||||
const titleEl = document.getElementById('movie-title');
|
const titleEl = document.getElementById('movie-title');
|
||||||
const categoryEl = document.getElementById('movie-category');
|
const categoryEl = document.getElementById('movie-category');
|
||||||
@@ -336,6 +399,58 @@ function setOpenOriginal(raw) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isApiVideoUrl(raw) {
|
||||||
|
const s = String(raw || '').trim();
|
||||||
|
if (!s) return false;
|
||||||
|
if (s.startsWith('/api/video/')) return true;
|
||||||
|
try {
|
||||||
|
const u = new URL(s);
|
||||||
|
return u.pathname.startsWith('/api/video/');
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toVideoSessionUrl(videoSrc) {
|
||||||
|
const s = String(videoSrc || '').trim();
|
||||||
|
if (!s) return null;
|
||||||
|
if (s.startsWith('/')) return '/api/video/session';
|
||||||
|
try {
|
||||||
|
const u = new URL(s);
|
||||||
|
return new URL('/api/video/session', u.origin).toString();
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureVideoSessionForSource(videoSrc, token) {
|
||||||
|
// Only needed for our protected /api/video/* streams.
|
||||||
|
if (!token) return;
|
||||||
|
if (!isApiVideoUrl(videoSrc)) return;
|
||||||
|
const sessionUrl = toVideoSessionUrl(videoSrc);
|
||||||
|
if (!sessionUrl) return;
|
||||||
|
|
||||||
|
// If sessionUrl is cross-origin, we need credentials so the cookie is stored for that domain.
|
||||||
|
const isCrossOrigin = (() => {
|
||||||
|
try {
|
||||||
|
const u = new URL(sessionUrl, window.location.origin);
|
||||||
|
return u.origin !== window.location.origin;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch(sessionUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: token ? { 'Authorization': `Bearer ${token}` } : undefined,
|
||||||
|
...(isCrossOrigin ? { credentials: 'include' } : {}),
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
// best-effort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function playVodLink(vodLink) {
|
async function playVodLink(vodLink) {
|
||||||
const raw = normalizeVodLink(vodLink);
|
const raw = normalizeVodLink(vodLink);
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
@@ -362,6 +477,21 @@ async function playVodLink(vodLink) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// App/Python protected video endpoint (no file extension, but it's still a direct <video> stream).
|
||||||
|
if (isApiVideoUrl(raw)) {
|
||||||
|
setSourceLabel('Server video stream (/api/video)');
|
||||||
|
const videoEl = showVideoPlayer();
|
||||||
|
if (!videoEl) return;
|
||||||
|
videoEl.src = raw;
|
||||||
|
videoEl.addEventListener('error', () => {
|
||||||
|
// If the browser can't play it (or CORS blocks), fall back to iframe.
|
||||||
|
setSourceLabel('Embed (fallback)');
|
||||||
|
showEmbedPlayer(raw);
|
||||||
|
}, { once: true });
|
||||||
|
try { videoEl.load(); } catch (_) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Direct video file
|
// Direct video file
|
||||||
if (looksLikeVideoFile(raw)) {
|
if (looksLikeVideoFile(raw)) {
|
||||||
setSourceLabel('Direct video file');
|
setSourceLabel('Direct video file');
|
||||||
@@ -504,15 +634,6 @@ window.onload = async function () {
|
|||||||
|
|
||||||
setHeader(movie);
|
setHeader(movie);
|
||||||
|
|
||||||
// Allow <video> to call /api/video/* without Authorization headers by using a cookie.
|
|
||||||
// Best-effort: if it fails, external video_src URLs can still work.
|
|
||||||
try {
|
|
||||||
await fetch('/api/video/session', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: token ? { 'Authorization': `Bearer ${token}` } : undefined,
|
|
||||||
});
|
|
||||||
} catch (_) {}
|
|
||||||
|
|
||||||
const resolved = resolveMovieSource(movie);
|
const resolved = resolveMovieSource(movie);
|
||||||
if (!resolved?.src) {
|
if (!resolved?.src) {
|
||||||
setOpenOriginal(null);
|
setOpenOriginal(null);
|
||||||
@@ -522,6 +643,10 @@ window.onload = async function () {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Allow <video> to call protected /api/video/* sources without Authorization headers by using a cookie.
|
||||||
|
// If the source is on a different origin (Python video host), we call THAT host’s /api/video/session.
|
||||||
|
await ensureVideoSessionForSource(resolved.src, token);
|
||||||
|
|
||||||
if (resolved.isLegacy) {
|
if (resolved.isLegacy) {
|
||||||
// Legacy source: never embed it in the player.
|
// Legacy source: never embed it in the player.
|
||||||
setOpenOriginal(resolved.src);
|
setOpenOriginal(resolved.src);
|
||||||
@@ -536,7 +661,10 @@ window.onload = async function () {
|
|||||||
// Only the <video> player supports reliable progress tracking (mp4/hls).
|
// Only the <video> player supports reliable progress tracking (mp4/hls).
|
||||||
const videoEl = document.getElementById('video');
|
const videoEl = document.getElementById('video');
|
||||||
if (videoEl && !videoEl.classList.contains('d-none')) {
|
if (videoEl && !videoEl.classList.contains('d-none')) {
|
||||||
|
setProgressStatus('Prépare la reprise…');
|
||||||
setupVideoProgress({ videoEl, movieId: id, token, userId: decoded?.id });
|
setupVideoProgress({ videoEl, movieId: id, token, userId: decoded?.id });
|
||||||
|
} else {
|
||||||
|
setProgressStatus('—');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showAlert(err.message || 'Network error.');
|
showAlert(err.message || 'Network error.');
|
||||||
|
|||||||
@@ -115,6 +115,11 @@
|
|||||||
<span class="badge bg-secondary">Source</span>
|
<span class="badge bg-secondary">Source</span>
|
||||||
<span id="source-label" class="badge bg-light text-dark badge-src">—</span>
|
<span id="source-label" class="badge bg-light text-dark badge-src">—</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2 d-flex flex-wrap gap-2 align-items-center">
|
||||||
|
<span class="badge bg-secondary">Progress</span>
|
||||||
|
<span id="progress-status" class="text-muted small">—</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Python video server (Range streaming + JWT + Mongo)
|
||||||
|
|
||||||
|
This is a small standalone server that mirrors the app’s `/api/video/:id` behavior:
|
||||||
|
|
||||||
|
- Looks up `movies._id == :id` in MongoDB and reads the `video_file` field
|
||||||
|
- Streams the file from `VIDEO_FILES_DIR` with **HTTP Range** support (seeking works)
|
||||||
|
- Auth via the same JWT (`Authorization: Bearer ...`), plus a `video_auth` cookie for `<video>`
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pip install -r python_video_server/requirements.txt
|
||||||
|
export MONGO_URI="..."
|
||||||
|
export JWT_SECRET="..."
|
||||||
|
export VIDEO_FILES_DIR="/absolute/path/to/public/video" # optional (default: ./public/video)
|
||||||
|
export VIDEO_SESSION_MAX_AGE_SECONDS="28800" # optional (default: 8h)
|
||||||
|
uvicorn python_video_server.server:app --host 0.0.0.0 --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
- `POST /api/video/session`
|
||||||
|
Sets `video_auth` cookie (send `Authorization: Bearer <token>`).
|
||||||
|
|
||||||
|
- `GET|HEAD /api/video/{movieId}`
|
||||||
|
Streams the movie file (supports `Range: bytes=...`).
|
||||||
|
|
||||||
|
- `GET /healthz`
|
||||||
|
|
||||||
Binary file not shown.
@@ -0,0 +1,4 @@
|
|||||||
|
fastapi==0.115.6
|
||||||
|
uvicorn[standard]==0.34.0
|
||||||
|
pymongo==4.10.1
|
||||||
|
PyJWT==2.10.1
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
import mimetypes
|
||||||
|
import os
|
||||||
|
import posixpath
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Generator, Optional, Tuple
|
||||||
|
from urllib.parse import quote
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
from bson import ObjectId
|
||||||
|
from fastapi import FastAPI, Header, HTTPException, Request, Response
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import FileResponse, StreamingResponse
|
||||||
|
from pymongo import MongoClient
|
||||||
|
|
||||||
|
|
||||||
|
def _env_str(name: str, default: str = "") -> str:
|
||||||
|
v = os.environ.get(name)
|
||||||
|
return v.strip() if isinstance(v, str) and v.strip() else default
|
||||||
|
|
||||||
|
|
||||||
|
def _jwt_secret() -> str:
|
||||||
|
# Read env at runtime (important on Windows/uvicorn worker reloads).
|
||||||
|
return _env_str("JWT_SECRET")
|
||||||
|
|
||||||
|
|
||||||
|
def _mongo_uri() -> str:
|
||||||
|
# Read env at runtime (important on Windows/uvicorn worker reloads).
|
||||||
|
return _env_str("MONGO_URI")
|
||||||
|
|
||||||
|
def _mongo_db_name() -> str:
|
||||||
|
# Prefer explicit env var, otherwise try to parse from URI path.
|
||||||
|
explicit = _env_str("MONGO_DB_NAME")
|
||||||
|
if explicit:
|
||||||
|
return explicit
|
||||||
|
|
||||||
|
uri = _mongo_uri()
|
||||||
|
if not uri:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed = urlparse(uri)
|
||||||
|
# Path is like "/dbname"
|
||||||
|
db = (parsed.path or "").lstrip("/")
|
||||||
|
# Strip any extra segments just in case
|
||||||
|
db = db.split("/")[0].strip()
|
||||||
|
return db
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _video_files_dir() -> Path:
|
||||||
|
# Read env at runtime (important on Windows/uvicorn worker reloads).
|
||||||
|
return Path(_env_str("VIDEO_FILES_DIR", str(Path.cwd() / "public" / "video"))).resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_bearer(authorization: Optional[str]) -> str:
|
||||||
|
raw = (authorization or "").strip()
|
||||||
|
if raw.lower().startswith("bearer "):
|
||||||
|
return raw[7:].strip()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _is_safe_relative_file(p: str) -> bool:
|
||||||
|
if not isinstance(p, str):
|
||||||
|
return False
|
||||||
|
s = p.strip()
|
||||||
|
if not s or "\x00" in s:
|
||||||
|
return False
|
||||||
|
if s.startswith("/") or s.startswith("\\"):
|
||||||
|
return False
|
||||||
|
norm = posixpath.normpath(s.replace("\\", "/"))
|
||||||
|
if norm == ".." or norm.startswith("../"):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _guess_content_type(file_path: Path) -> str:
|
||||||
|
ctype, _ = mimetypes.guess_type(str(file_path))
|
||||||
|
return ctype or "application/octet-stream"
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_range(range_header: str, file_size: int) -> Optional[Tuple[int, int]]:
|
||||||
|
# Only supports a single bytes range: "bytes=start-end"
|
||||||
|
raw = (range_header or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
if not raw.lower().startswith("bytes="):
|
||||||
|
return None
|
||||||
|
spec = raw[6:].strip()
|
||||||
|
if "," in spec:
|
||||||
|
return None
|
||||||
|
if "-" not in spec:
|
||||||
|
return None
|
||||||
|
start_s, end_s = spec.split("-", 1)
|
||||||
|
start = int(start_s) if start_s else 0
|
||||||
|
end = int(end_s) if end_s else (file_size - 1)
|
||||||
|
if start < 0 or end < 0 or start > end or start >= file_size:
|
||||||
|
return None
|
||||||
|
end = min(end, file_size - 1)
|
||||||
|
return start, end
|
||||||
|
|
||||||
|
|
||||||
|
def _file_iter(path: Path, start: int, end: int, chunk_size: int = 1024 * 1024) -> Generator[bytes, None, None]:
|
||||||
|
with path.open("rb") as f:
|
||||||
|
f.seek(start)
|
||||||
|
remaining = end - start + 1
|
||||||
|
while remaining > 0:
|
||||||
|
n = min(chunk_size, remaining)
|
||||||
|
data = f.read(n)
|
||||||
|
if not data:
|
||||||
|
break
|
||||||
|
remaining -= len(data)
|
||||||
|
yield data
|
||||||
|
|
||||||
|
|
||||||
|
def _get_token(req: Request, authorization: Optional[str]) -> str:
|
||||||
|
# Prefer cookie (for <video>), then Authorization header, then querystring token.
|
||||||
|
cookie_token = (req.cookies.get("video_auth") or "").strip()
|
||||||
|
if cookie_token:
|
||||||
|
return cookie_token
|
||||||
|
header_token = _parse_bearer(authorization)
|
||||||
|
if header_token:
|
||||||
|
return header_token
|
||||||
|
qs = (req.query_params.get("token") or "").strip()
|
||||||
|
return qs
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_token(token: str) -> dict:
|
||||||
|
if not token:
|
||||||
|
raise HTTPException(status_code=401, detail="Missing video auth token")
|
||||||
|
secret = _jwt_secret()
|
||||||
|
if not secret:
|
||||||
|
raise HTTPException(status_code=500, detail="JWT_SECRET is not configured")
|
||||||
|
try:
|
||||||
|
return jwt.decode(token, secret, algorithms=["HS256"])
|
||||||
|
except jwt.PyJWTError:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid or expired video auth token")
|
||||||
|
|
||||||
|
|
||||||
|
def _mongo_client() -> MongoClient:
|
||||||
|
uri = _mongo_uri()
|
||||||
|
if not uri:
|
||||||
|
raise HTTPException(status_code=500, detail="MONGO_URI is not configured")
|
||||||
|
return MongoClient(uri)
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
# CORS: needed so the app can call the Python host's /api/video/session
|
||||||
|
_cors_origins_raw = _env_str("CORS_ALLOW_ORIGINS", "*")
|
||||||
|
_cors_origins = [o.strip() for o in _cors_origins_raw.split(",") if o.strip()]
|
||||||
|
_cors_allow_origin_regex = None
|
||||||
|
if len(_cors_origins) == 0 or (len(_cors_origins) == 1 and _cors_origins[0] == "*"):
|
||||||
|
# With credentials, browsers can't accept Access-Control-Allow-Origin: *
|
||||||
|
# Using a regex makes Starlette reflect the requesting Origin instead.
|
||||||
|
_cors_origins = []
|
||||||
|
_cors_allow_origin_regex = ".*"
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=_cors_origins,
|
||||||
|
allow_origin_regex=_cors_allow_origin_regex,
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/video/session")
|
||||||
|
def create_video_session(request: Request, response: Response, authorization: Optional[str] = Header(default=None)):
|
||||||
|
token = _parse_bearer(authorization)
|
||||||
|
_verify_token(token)
|
||||||
|
|
||||||
|
secure = request.url.scheme == "https" or (request.headers.get("x-forwarded-proto") == "https")
|
||||||
|
encoded = quote(token, safe="")
|
||||||
|
cookie_max_age_seconds = int(_env_str("VIDEO_SESSION_MAX_AGE_SECONDS", "28800")) # 8h
|
||||||
|
cookie_samesite = _env_str("VIDEO_COOKIE_SAMESITE", "Lax") # Lax|Strict|None
|
||||||
|
samesite = (cookie_samesite or "Lax").strip().lower()
|
||||||
|
if samesite not in ("lax", "strict", "none"):
|
||||||
|
samesite = "lax"
|
||||||
|
samesite_attr = "None" if samesite == "none" else ("Strict" if samesite == "strict" else "Lax")
|
||||||
|
cookie_parts = [
|
||||||
|
f"video_auth={encoded}",
|
||||||
|
"Path=/api/video",
|
||||||
|
f"Max-Age={cookie_max_age_seconds}",
|
||||||
|
f"SameSite={samesite_attr}",
|
||||||
|
"HttpOnly",
|
||||||
|
]
|
||||||
|
# Modern browsers require Secure when SameSite=None
|
||||||
|
if secure or samesite == "none":
|
||||||
|
cookie_parts.append("Secure")
|
||||||
|
response.headers["Set-Cookie"] = "; ".join(cookie_parts)
|
||||||
|
response.status_code = 204
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@app.api_route("/api/video/{movie_id}", methods=["GET", "HEAD"])
|
||||||
|
def stream_video(
|
||||||
|
movie_id: str,
|
||||||
|
request: Request,
|
||||||
|
authorization: Optional[str] = Header(default=None),
|
||||||
|
range: Optional[str] = Header(default=None), # noqa: A002
|
||||||
|
):
|
||||||
|
token = _get_token(request, authorization)
|
||||||
|
_verify_token(token)
|
||||||
|
|
||||||
|
try:
|
||||||
|
oid = ObjectId(movie_id)
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid movie id")
|
||||||
|
|
||||||
|
with _mongo_client() as client:
|
||||||
|
db_name = _mongo_db_name()
|
||||||
|
if not db_name:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail="Mongo DB name not configured. Add it to MONGO_URI (…/dbname) or set MONGO_DB_NAME.",
|
||||||
|
)
|
||||||
|
db = client[db_name]
|
||||||
|
movie = db["movies"].find_one({"_id": oid}, {"video_file": 1})
|
||||||
|
|
||||||
|
video_file = (movie or {}).get("video_file") or ""
|
||||||
|
if not isinstance(video_file, str) or not video_file.strip():
|
||||||
|
raise HTTPException(status_code=404, detail="No server video file configured for this movie")
|
||||||
|
video_file = video_file.strip()
|
||||||
|
if not _is_safe_relative_file(video_file):
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid video_file path")
|
||||||
|
|
||||||
|
video_dir = _video_files_dir()
|
||||||
|
full_path = (video_dir / video_file).resolve()
|
||||||
|
if not str(full_path).startswith(str(video_dir) + os.sep) and full_path != video_dir:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid video_file path")
|
||||||
|
if not full_path.exists() or not full_path.is_file():
|
||||||
|
raise HTTPException(status_code=404, detail="Video file not found on server")
|
||||||
|
|
||||||
|
stat = full_path.stat()
|
||||||
|
file_size = stat.st_size
|
||||||
|
content_type = _guess_content_type(full_path)
|
||||||
|
|
||||||
|
# Shared headers (match the Node server behavior closely)
|
||||||
|
base_headers = {
|
||||||
|
"Accept-Ranges": "bytes",
|
||||||
|
"Content-Type": content_type,
|
||||||
|
"Cache-Control": "private, max-age=0, must-revalidate",
|
||||||
|
}
|
||||||
|
|
||||||
|
if request.method == "HEAD":
|
||||||
|
headers = dict(base_headers)
|
||||||
|
headers["Content-Length"] = str(file_size)
|
||||||
|
return Response(status_code=200, headers=headers)
|
||||||
|
|
||||||
|
r = _parse_range(range or "", file_size)
|
||||||
|
if r is None and (range or ""):
|
||||||
|
# Invalid Range
|
||||||
|
headers = dict(base_headers)
|
||||||
|
headers["Content-Range"] = f"bytes */{file_size}"
|
||||||
|
return Response(status_code=416, headers=headers)
|
||||||
|
|
||||||
|
if r is None:
|
||||||
|
# Full file
|
||||||
|
return FileResponse(
|
||||||
|
path=str(full_path),
|
||||||
|
media_type=content_type,
|
||||||
|
headers=base_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
start, end = r
|
||||||
|
chunk_size = end - start + 1
|
||||||
|
headers = dict(base_headers)
|
||||||
|
headers["Content-Range"] = f"bytes {start}-{end}/{file_size}"
|
||||||
|
headers["Content-Length"] = str(chunk_size)
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
_file_iter(full_path, start, end),
|
||||||
|
status_code=206,
|
||||||
|
media_type=content_type,
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/healthz")
|
||||||
|
def healthz():
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"time": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"video_files_dir": str(_video_files_dir()),
|
||||||
|
"mongo_db_name": _mongo_db_name() or None,
|
||||||
|
}
|
||||||
|
|
||||||
@@ -648,9 +648,12 @@ router.put("/:id/progress", authenticate, async (req, res) => {
|
|||||||
|
|
||||||
let time = Number(rawTime);
|
let time = Number(rawTime);
|
||||||
if (!Number.isFinite(time) || time < 0) time = 0;
|
if (!Number.isFinite(time) || time < 0) time = 0;
|
||||||
|
// Store whole seconds for stable, consistent resume behavior.
|
||||||
|
time = Math.floor(time);
|
||||||
|
|
||||||
let duration = rawDuration === undefined || rawDuration === null ? null : Number(rawDuration);
|
let duration = rawDuration === undefined || rawDuration === null ? null : Number(rawDuration);
|
||||||
if (duration !== null && (!Number.isFinite(duration) || duration <= 0)) duration = null;
|
if (duration !== null && (!Number.isFinite(duration) || duration <= 0)) duration = null;
|
||||||
|
if (duration !== null) duration = Math.floor(duration);
|
||||||
|
|
||||||
if (duration !== null) {
|
if (duration !== null) {
|
||||||
// Clamp into [0, duration]
|
// Clamp into [0, duration]
|
||||||
|
|||||||
+4
-1
@@ -76,6 +76,9 @@ function isAdminFromDecoded(decoded) {
|
|||||||
|
|
||||||
function signUserToken(user) {
|
function signUserToken(user) {
|
||||||
const isAdmin = user.role === 69;
|
const isAdmin = user.role === 69;
|
||||||
|
const expiresIn = typeof process.env.JWT_EXPIRES_IN === "string" && process.env.JWT_EXPIRES_IN.trim()
|
||||||
|
? process.env.JWT_EXPIRES_IN.trim()
|
||||||
|
: "8h";
|
||||||
return jwt.sign(
|
return jwt.sign(
|
||||||
{
|
{
|
||||||
id: user._id,
|
id: user._id,
|
||||||
@@ -84,7 +87,7 @@ function signUserToken(user) {
|
|||||||
mustChangePassword: !!user.mustChangePassword,
|
mustChangePassword: !!user.mustChangePassword,
|
||||||
},
|
},
|
||||||
process.env.JWT_SECRET,
|
process.env.JWT_SECRET,
|
||||||
{ expiresIn: "1h" }
|
{ expiresIn }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -89,7 +89,10 @@ router.post("/session", (req, res) => {
|
|||||||
jwt.verify(token, process.env.JWT_SECRET);
|
jwt.verify(token, process.env.JWT_SECRET);
|
||||||
// Cookie scoped to /api/video so it doesn't leak broadly.
|
// Cookie scoped to /api/video so it doesn't leak broadly.
|
||||||
// httpOnly helps a bit, even though the JWT originally lives in localStorage.
|
// httpOnly helps a bit, even though the JWT originally lives in localStorage.
|
||||||
const maxAgeSeconds = 60 * 60 * 8; // 8h
|
const maxAgeSecondsRaw = typeof process.env.VIDEO_SESSION_MAX_AGE_SECONDS === "string" ? process.env.VIDEO_SESSION_MAX_AGE_SECONDS.trim() : "";
|
||||||
|
const maxAgeSeconds = Number.isFinite(Number(maxAgeSecondsRaw)) && Number(maxAgeSecondsRaw) > 0
|
||||||
|
? Math.floor(Number(maxAgeSecondsRaw))
|
||||||
|
: 60 * 60 * 8; // 8h
|
||||||
const cookie = [
|
const cookie = [
|
||||||
`video_auth=${encodeURIComponent(token)}`,
|
`video_auth=${encodeURIComponent(token)}`,
|
||||||
"Path=/api/video",
|
"Path=/api/video",
|
||||||
|
|||||||
Reference in New Issue
Block a user