Movie categorization and stats #4

Merged
quoije merged 1 commits from cursor/movie-categorization-and-stats-4456 into 2026 2025-12-20 17:16:18 -05:00
5 changed files with 315 additions and 7 deletions
+2
View File
@@ -6,6 +6,8 @@ const movieSchema = new mongoose.Schema({
description: { type: String, required: false }, description: { type: String, required: false },
rating: { type: String, required: false }, rating: { type: String, required: false },
poster: { type: String, required: false }, poster: { type: String, required: false },
// Oscar pool year / edition (used to segment movies + stats)
year: { type: Number, required: false, index: true },
category: { type: String, required: true }, category: { type: String, required: true },
vod_link: { type: String, required: true }, vod_link: { type: String, required: true },
watchedBy: [{ type: mongoose.Schema.Types.ObjectId, ref: 'User' }] watchedBy: [{ type: mongoose.Schema.Types.ObjectId, ref: 'User' }]
+53
View File
@@ -60,6 +60,11 @@
<div id="response" class="alert d-none" role="alert"></div> <div id="response" class="alert d-none" role="alert"></div>
<form id="add-movie-form" class="card p-3"> <form id="add-movie-form" class="card p-3">
<div class="mb-3">
<label for="oscar_year" class="form-label">Oscar year</label>
<input type="number" class="form-control" id="oscar_year" placeholder="2026" min="1900" max="3000" required>
<div class="form-text">Permet de séparer les films et les stats par année.</div>
</div>
<div class="mb-3"> <div class="mb-3">
<label for="imdb_id" class="form-label">IMDB ID (ex: tt1234567)</label> <label for="imdb_id" class="form-label">IMDB ID (ex: tt1234567)</label>
<input type="text" class="form-control" id="imdb_id" placeholder="tt..." required> <input type="text" class="form-control" id="imdb_id" placeholder="tt..." required>
@@ -77,6 +82,54 @@
<button type="button" id="reset-form" class="btn btn-outline-secondary">Reset</button> <button type="button" id="reset-form" class="btn btn-outline-secondary">Reset</button>
</div> </div>
</form> </form>
<hr class="my-4">
<h2>Admin - Gérer les films</h2>
<p class="text-muted mb-3">
Sélectionne une année, puis coche les films à supprimer.
</p>
<div class="card p-3">
<div class="row g-2 align-items-end">
<div class="col-md-6">
<label for="manage_year" class="form-label">Oscar year</label>
<select id="manage_year" class="form-select">
<option value="">Toutes</option>
</select>
</div>
<div class="col-md-6 d-flex gap-2">
<button type="button" id="refresh-movies" class="btn btn-outline-primary w-100">Rafraîchir</button>
<button type="button" id="delete-selected" class="btn btn-danger w-100" disabled>Supprimer la sélection</button>
</div>
</div>
<div class="mt-3">
<div class="d-flex justify-content-between align-items-center">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="select-all-movies">
<label class="form-check-label" for="select-all-movies">Tout sélectionner</label>
</div>
<small class="text-muted"><span id="selected-count">0</span> sélectionné(s)</small>
</div>
</div>
<div class="table-responsive mt-2">
<table class="table table-sm align-middle mb-0">
<thead>
<tr>
<th style="width: 1%;">Sel.</th>
<th>Titre</th>
<th style="width: 20%;">Catégorie</th>
<th style="width: 12%;">Année</th>
</tr>
</thead>
<tbody id="admin-movies-body">
<tr><td colspan="4" class="text-muted">Charge la liste…</td></tr>
</tbody>
</table>
</div>
</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>
+165 -1
View File
@@ -31,6 +31,14 @@ window.onload = async function () {
const responseEl = document.getElementById('response'); const responseEl = document.getElementById('response');
const form = document.getElementById('add-movie-form'); const form = document.getElementById('add-movie-form');
const yearInput = document.getElementById('oscar_year');
const manageYearSelect = document.getElementById('manage_year');
const adminMoviesBody = document.getElementById('admin-movies-body');
const refreshMoviesBtn = document.getElementById('refresh-movies');
const deleteSelectedBtn = document.getElementById('delete-selected');
const selectAllBox = document.getElementById('select-all-movies');
const selectedCountEl = document.getElementById('selected-count');
function showResponse(kind, message) { function showResponse(kind, message) {
responseEl.classList.remove('d-none', 'alert-success', 'alert-danger', 'alert-warning'); responseEl.classList.remove('d-none', 'alert-success', 'alert-danger', 'alert-warning');
@@ -38,6 +46,13 @@ window.onload = async function () {
responseEl.textContent = message; responseEl.textContent = message;
} }
function parseYear(value) {
const n = Number(value);
if (!Number.isInteger(n)) return null;
if (n < 1900 || n > 3000) return null;
return n;
}
document.getElementById('reset-form').addEventListener('click', function () { document.getElementById('reset-form').addEventListener('click', function () {
form.reset(); form.reset();
responseEl.classList.add('d-none'); responseEl.classList.add('d-none');
@@ -51,10 +66,16 @@ window.onload = async function () {
form.addEventListener('submit', async function (e) { form.addEventListener('submit', async function (e) {
e.preventDefault(); e.preventDefault();
const year = parseYear(yearInput.value);
const imdb_id = document.getElementById('imdb_id').value.trim(); const imdb_id = document.getElementById('imdb_id').value.trim();
const category = document.getElementById('category').value.trim(); const category = document.getElementById('category').value.trim();
const vod_link = document.getElementById('vod_link').value.trim(); const vod_link = document.getElementById('vod_link').value.trim();
if (!year) {
showResponse('warning', 'Année invalide. Exemple attendu: 2026');
return;
}
if (!/^tt\d{5,}$/.test(imdb_id)) { if (!/^tt\d{5,}$/.test(imdb_id)) {
showResponse('warning', 'IMDB ID invalide. Exemple attendu: tt1234567'); showResponse('warning', 'IMDB ID invalide. Exemple attendu: tt1234567');
return; return;
@@ -67,12 +88,14 @@ window.onload = async function () {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Authorization': `Bearer ${token}` 'Authorization': `Bearer ${token}`
}, },
body: JSON.stringify({ imdb_id, category, vod_link }) body: JSON.stringify({ year, imdb_id, category, vod_link })
}); });
if (res.ok) { if (res.ok) {
showResponse('success', 'Film ajouté avec succès.'); showResponse('success', 'Film ajouté avec succès.');
form.reset(); form.reset();
await refreshYears();
await loadMoviesForManagement();
return; return;
} }
@@ -88,5 +111,146 @@ window.onload = async function () {
showResponse('danger', err.message || 'Erreur réseau'); showResponse('danger', err.message || 'Erreur réseau');
} }
}); });
async function refreshYears() {
try {
const yearsRes = await fetch('/api/movies/years');
const years = yearsRes.ok ? await yearsRes.json() : [];
// Populate both the add form (default) and manage dropdown
const existingValues = new Set([...manageYearSelect.options].map((o) => o.value));
years.forEach((y) => {
const value = String(y);
if (!existingValues.has(value)) {
const opt = document.createElement('option');
opt.value = value;
opt.textContent = value;
manageYearSelect.appendChild(opt);
}
});
// Default add-year to latest year if empty
if (years.length > 0 && !yearInput.value) {
yearInput.value = String(years[0]);
}
} catch (_) {
// ignore
}
}
function getSelectedMovieIds() {
return Array.from(adminMoviesBody.querySelectorAll('input[type="checkbox"][data-movie-id]:checked'))
.map((el) => el.getAttribute('data-movie-id'))
.filter(Boolean);
}
function updateSelectionUI() {
const selectedIds = getSelectedMovieIds();
selectedCountEl.textContent = String(selectedIds.length);
deleteSelectedBtn.disabled = selectedIds.length === 0;
const allBoxes = Array.from(adminMoviesBody.querySelectorAll('input[type="checkbox"][data-movie-id]'));
const checked = allBoxes.filter((b) => b.checked).length;
selectAllBox.indeterminate = checked > 0 && checked < allBoxes.length;
selectAllBox.checked = allBoxes.length > 0 && checked === allBoxes.length;
}
async function loadMoviesForManagement() {
const year = manageYearSelect.value;
const url = year ? `/api/movies?year=${encodeURIComponent(year)}` : '/api/movies';
adminMoviesBody.innerHTML = `<tr><td colspan="4" class="text-muted">Chargement…</td></tr>`;
try {
const res = await fetch(url, { method: 'GET' });
if (!res.ok) {
adminMoviesBody.innerHTML = `<tr><td colspan="4" class="text-danger">Erreur lors du chargement (${res.status})</td></tr>`;
return;
}
const movies = await res.json();
movies.sort((a, b) => (a.title || '').localeCompare((b.title || ''), 'fr', { sensitivity: 'base' }));
if (!movies.length) {
adminMoviesBody.innerHTML = `<tr><td colspan="4" class="text-muted">Aucun film.</td></tr>`;
updateSelectionUI();
return;
}
adminMoviesBody.innerHTML = '';
movies.forEach((m) => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>
<input type="checkbox" class="form-check-input" data-movie-id="${m._id}">
</td>
<td>
<div class="fw-semibold">${m.title || '(sans titre)'}</div>
<div class="text-muted small">${m.imdb_id || ''}</div>
</td>
<td>${m.category || ''}</td>
<td>${m.year || ''}</td>
`;
adminMoviesBody.appendChild(tr);
});
adminMoviesBody.querySelectorAll('input[type="checkbox"][data-movie-id]').forEach((cb) => {
cb.addEventListener('change', updateSelectionUI);
});
updateSelectionUI();
} catch (err) {
adminMoviesBody.innerHTML = `<tr><td colspan="4" class="text-danger">${err.message || 'Erreur réseau'}</td></tr>`;
}
}
refreshMoviesBtn.addEventListener('click', loadMoviesForManagement);
manageYearSelect.addEventListener('change', loadMoviesForManagement);
selectAllBox.addEventListener('change', () => {
const checked = selectAllBox.checked;
adminMoviesBody.querySelectorAll('input[type="checkbox"][data-movie-id]').forEach((cb) => {
cb.checked = checked;
});
updateSelectionUI();
});
deleteSelectedBtn.addEventListener('click', async () => {
const selectedIds = getSelectedMovieIds();
if (!selectedIds.length) return;
const yearLabel = manageYearSelect.value ? ` (${manageYearSelect.value})` : '';
const ok = window.confirm(`Supprimer ${selectedIds.length} film(s)${yearLabel} ? Cette action est irréversible.`);
if (!ok) return;
try {
const res = await fetch('/api/movies/delete', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ ids: selectedIds })
});
if (!res.ok) {
let msg = `Erreur (${res.status})`;
try {
const data = await res.json();
msg = data.message || data.error || msg;
} catch (_) {}
showResponse('danger', msg);
return;
}
showResponse('success', 'Film(s) supprimé(s) avec succès.');
await refreshYears();
await loadMoviesForManagement();
} catch (err) {
showResponse('danger', err.message || 'Erreur réseau');
}
});
// Initial load
await refreshYears();
cursor[bot] commented 2025-12-20 17:21:08 -05:00 (Migrated from github.com)
Review

Stored XSS via unescaped movie data in innerHTML

Movie properties like m.title, m.category, and m.imdb_id are inserted directly into the DOM via innerHTML without HTML escaping. The category field in particular is admin-provided and stored in the database with no sanitization. If malicious HTML/JavaScript is stored in these fields, it would execute when any admin views the movie management page. While this is an admin-only page reducing the attack surface, it still represents a stored XSS vulnerability.

Fix in Cursor Fix in Web

### Stored XSS via unescaped movie data in innerHTML <!-- **Low Severity** --> <!-- DESCRIPTION START --> Movie properties like `m.title`, `m.category`, and `m.imdb_id` are inserted directly into the DOM via `innerHTML` without HTML escaping. The `category` field in particular is admin-provided and stored in the database with no sanitization. If malicious HTML/JavaScript is stored in these fields, it would execute when any admin views the movie management page. While this is an admin-only page reducing the attack surface, it still represents a stored XSS vulnerability. <!-- DESCRIPTION END --> <!-- LOCATIONS START public/js/app_control.js#L180-L191 LOCATIONS END --> <a href="https://cursor.com/open?data=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImJ1Z2JvdC12MiJ9.eyJ2ZXJzaW9uIjoxLCJ0eXBlIjoiQlVHQk9UX0ZJWF9JTl9DVVJTT1IiLCJkYXRhIjp7InJlZGlzS2V5IjoiYnVnYm90OjgzZTcxOWE5LTMzYzQtNDE0NC04MGY3LWMxNDBlNTUwZDU4YSIsImVuY3J5cHRpb25LZXkiOiJkYl80aVZjUFRhb0tlWExoblByRDREUGJJMy0tZVpua0dZSmFHTmF1Zl9FIiwiYnJhbmNoIjoiY3Vyc29yL21vdmllLWNhdGVnb3JpemF0aW9uLWFuZC1zdGF0cy00NDU2In0sImlhdCI6MTc2NjI2OTI2OCwiZXhwIjoxNzY4ODYxMjY4fQ.euJZnwunDd4Ux5IT_oHsJ41OYGtB_plw0c9XjTukaGYLxyL9N3LFux3rJiNTARuo4rUxB3mIYYnWnCIcA4CL0LE7UJq3yoxhHpbznFK9I8qgKQWbIiPH3psbs-29OlFQbch8R5LjYdd6v1jlxNtWA6PeMt239HF8TNPgXpfPBXEj8DW6tZB_zhH9uC7uiwq5Iikdy03sKU5bc-efl1uBqkUmiNUnsW5qgCIRR6vvk4LYeURF9fbKMj1mbLVTRWs23HTHZcv19x4fim8o61tqbLzLkRNmc4p6gvqN_ZRFHzbYBR4jtVOi_ASyYSZZWuCOBZ6NUY5AtnoO5grFmfjUqA" target="_blank" rel="noopener noreferrer"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/fix-in-cursor-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/fix-in-cursor-light.svg"><img alt="Fix in Cursor" src="https://cursor.com/fix-in-cursor.svg"></picture></a>&nbsp;<a href="https://cursor.com/agents?data=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImJ1Z2JvdC12MiJ9.eyJ2ZXJzaW9uIjoxLCJ0eXBlIjoiQlVHQk9UX0ZJWF9JTl9XRUIiLCJkYXRhIjp7InJlZGlzS2V5IjoiYnVnYm90OjgzZTcxOWE5LTMzYzQtNDE0NC04MGY3LWMxNDBlNTUwZDU4YSIsImVuY3J5cHRpb25LZXkiOiJkYl80aVZjUFRhb0tlWExoblByRDREUGJJMy0tZVpua0dZSmFHTmF1Zl9FIiwiYnJhbmNoIjoiY3Vyc29yL21vdmllLWNhdGVnb3JpemF0aW9uLWFuZC1zdGF0cy00NDU2IiwicmVwb093bmVyIjoicXVvaWplIiwicmVwb05hbWUiOiJvc2NhcnMtcG9vbCIsInByTnVtYmVyIjo0LCJjb21taXRTaGEiOiJlZWIzMzdmZTA4NmEzZWIzZDQ1NGI0MjdmOTQxNzYzYTQyYjZmMzBkIiwicHJvdmlkZXIiOiJnaXRodWIifSwiaWF0IjoxNzY2MjY5MjY4LCJleHAiOjE3Njg4NjEyNjh9.x-dHcYZ_hrjOaFrDLtxngKjLG1YfENS4hyTqIlOptEnG98GxqVlS4hPrKTAAsMRe3iDsgFCGu7RFNHiLW2sf1V-ZOeNkV2QqirsPZDuoKJxZuJlrfPAjTvex2YNxplcvlslMbw3wvx38t9w37Qg3ejVSP47gzl_4xkTCkOPlUpC_0qDIdy2ZfSTzYGcSFr3TWpdA2W_wQDDKPw2rDIb50OWSuOD6gpV9MFnH9xYv7GuuflwegUJ_7SYqK2nckYNjZ0U07zMhEOOZT8owAgbkJ9TZnPgjjiLemIbxIUYjpY4WesJbWFxf3taFJhC2mbhVP5PJniFMr1rH3wgnEKTriA" target="_blank" rel="noopener noreferrer"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/fix-in-web-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/fix-in-web-light.svg"><img alt="Fix in Web" src="https://cursor.com/fix-in-web.svg"></picture></a>
await loadMoviesForManagement();
}; };
+78 -2
View File
@@ -6,6 +6,14 @@ const jwt = require("jsonwebtoken");
const router = express.Router(); const router = express.Router();
function parseOscarYear(raw) {
if (raw === undefined || raw === null || raw === "") return null;
const n = Number(raw);
if (!Number.isInteger(n)) return null;
if (n < 1900 || n > 3000) return null;
return n;
}
// Fetch movie details from OMDb API // Fetch movie details from OMDb API
async function fetchMovieDetailsFromOmdb(imdb_id) { async function fetchMovieDetailsFromOmdb(imdb_id) {
const apiKey = process.env.OMDB_API; // Replace with your actual OMDb API key const apiKey = process.env.OMDB_API; // Replace with your actual OMDb API key
@@ -28,10 +36,25 @@ async function fetchMovieDetailsFromOmdb(imdb_id) {
} }
} }
// Get available Oscar years (distinct years from movies)
router.get("/years", async (req, res) => {
try {
const years = await Movie.distinct("year");
const cleaned = years
.filter((y) => typeof y === "number" && Number.isFinite(y))
.sort((a, b) => b - a);
res.status(200).json(cleaned);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Get all movies // Get all movies
router.get("/", async (req, res) => { router.get("/", async (req, res) => {
try { try {
const movies = await Movie.find(); const year = parseOscarYear(req.query.year);
const filter = year ? { year } : {};
const movies = await Movie.find(filter);
res.status(200).json(movies); res.status(200).json(movies);
} catch (err) { } catch (err) {
res.status(500).json({ error: err.message }); res.status(500).json({ error: err.message });
@@ -114,7 +137,7 @@ router.patch("/users/updateWatchedMovies", async (req, res) => {
// Add movie // Add movie
router.post("/add", async (req, res) => { router.post("/add", async (req, res) => {
const { imdb_id, category, vod_link } = req.body; const { imdb_id, category, vod_link, year } = req.body;
try { try {
// Get the token from the Authorization header // Get the token from the Authorization header
@@ -132,6 +155,11 @@ router.post("/add", async (req, res) => {
return res.status(403).json({ message: "You do not have admin privileges" }); return res.status(403).json({ message: "You do not have admin privileges" });
} }
const parsedYear = parseOscarYear(year);
if (!parsedYear) {
return res.status(400).json({ message: "Année invalide (ex: 2026)" });
}
// Fetch movie details from OMDb API // Fetch movie details from OMDb API
const movieDetails = await fetchMovieDetailsFromOmdb(imdb_id); const movieDetails = await fetchMovieDetailsFromOmdb(imdb_id);
@@ -142,6 +170,7 @@ router.post("/add", async (req, res) => {
description: movieDetails.description, description: movieDetails.description,
rating: movieDetails.rating, rating: movieDetails.rating,
poster: movieDetails.poster, poster: movieDetails.poster,
year: parsedYear,
category, category,
vod_link vod_link
}); });
@@ -157,4 +186,51 @@ router.post("/add", async (req, res) => {
} }
}); });
// Admin: delete one or more movies (by Mongo _id or imdb_id)
router.delete("/delete", async (req, res) => {
const token = req.headers.authorization?.split(" ")[1];
if (!token) return res.status(401).json({ message: "Authentication token is required" });
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
if (!decoded.admin) {
return res.status(403).json({ message: "You do not have admin privileges" });
}
const { ids, imdb_ids } = req.body || {};
const idList = Array.isArray(ids) ? ids.filter(Boolean) : [];
const imdbList = Array.isArray(imdb_ids) ? imdb_ids.filter(Boolean) : [];
if (idList.length === 0 && imdbList.length === 0) {
return res.status(400).json({ message: "Aucun film sélectionné." });
}
const filter = idList.length
? { _id: { $in: idList } }
: { imdb_id: { $in: imdbList } };
const moviesToDelete = await Movie.find(filter).select("imdb_id");
const deletedImdbIds = moviesToDelete.map((m) => m.imdb_id).filter(Boolean);
const result = await Movie.deleteMany(filter);
if (deletedImdbIds.length > 0) {
await User.updateMany(
{},
{ $pull: { watchedMovies: { imdb_id: { $in: deletedImdbIds } } } }
);
}
return res.status(200).json({
message: "Movies deleted successfully",
deletedCount: result.deletedCount || 0,
});
} catch (err) {
if (err instanceof jwt.JsonWebTokenError) {
return res.status(401).json({ message: "Invalid or expired token" });
}
return res.status(500).json({ error: err.message });
}
});
module.exports = router; module.exports = router;
+16 -3
View File
@@ -49,14 +49,24 @@ function verifyToken(req, res, next) {
}); });
} }
function parseOscarYear(raw) {
if (raw === undefined || raw === null || raw === "") return null;
const n = Number(raw);
if (!Number.isInteger(n)) return null;
if (n < 1900 || n > 3000) return null;
return n;
}
// Get user stats with watched movie titles and details from the movies collection // Get user stats with watched movie titles and details from the movies collection
router.get("/stats", verifyToken, async (req, res) => { router.get("/stats", verifyToken, async (req, res) => {
try { try {
// Fetch all users // Fetch all users
const users = await User.find(); const users = await User.find();
// Fetch all movies from the database const year = parseOscarYear(req.query.year);
const allMovies = await Movie.find();
// Fetch movies (optionally filtered by year)
const allMovies = await Movie.find(year ? { year } : {});
const totalMoviesCount = allMovies.length; const totalMoviesCount = allMovies.length;
if (totalMoviesCount === 0) { if (totalMoviesCount === 0) {
@@ -65,9 +75,11 @@ router.get("/stats", verifyToken, async (req, res) => {
// Build stats for each user // Build stats for each user
const userStats = users.map((user) => { const userStats = users.map((user) => {
const movieByImdbId = new Map(allMovies.map((m) => [m.imdb_id, m]));
// Get watched movies with details from the database // Get watched movies with details from the database
const watchedMovies = user.watchedMovies const watchedMovies = user.watchedMovies
.map((wm) => allMovies.find((m) => m.imdb_id === wm.imdb_id)) // Match movie details .map((wm) => movieByImdbId.get(wm.imdb_id)) // Match movie details
.filter(Boolean); // Remove any `undefined` values (if movie is not found) .filter(Boolean); // Remove any `undefined` values (if movie is not found)
const watchedCount = watchedMovies.length; const watchedCount = watchedMovies.length;
@@ -80,6 +92,7 @@ router.get("/stats", verifyToken, async (req, res) => {
return { return {
name: user.name, name: user.name,
year: year || null,
watchedCount, watchedCount,
totalMoviesCount, totalMoviesCount,
watchedRatio: `${watchedRatio}%`, watchedRatio: `${watchedRatio}%`,
1