diff --git a/backend/public/admin.js b/backend/public/admin.js index 80b2086..a864d8a 100644 --- a/backend/public/admin.js +++ b/backend/public/admin.js @@ -6,6 +6,21 @@ window.plausible = (window.plausible.q = window.plausible.q || []).push(arguments); }; +// Escape user-supplied text before interpolating it into HTML, to prevent XSS. +function escapeHtml(value) { + return String(value).replace( + /[&<>"']/g, + (ch) => + ({ + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + })[ch], + ); +} + document.addEventListener("DOMContentLoaded", function () { loadQuestions(); loadImages(); @@ -13,23 +28,31 @@ document.addEventListener("DOMContentLoaded", function () { }); function setupEventListeners() { - document.querySelectorAll('.tab[data-tab]').forEach(tab => { - tab.addEventListener('click', (e) => { + document.querySelectorAll(".tab[data-tab]").forEach((tab) => { + tab.addEventListener("click", (e) => { switchTab(e.target.dataset.tab); }); }); - document.getElementById('addQuestionBtn').addEventListener('click', showAddQuestionModal); + document + .getElementById("addQuestionBtn") + .addEventListener("click", showAddQuestionModal); - document.getElementById('imageUpload').addEventListener('change', uploadImage); + document + .getElementById("imageUpload") + .addEventListener("change", uploadImage); - document.getElementById('closeModalBtn').addEventListener('click', closeModal); - document.getElementById('cancelBtn').addEventListener('click', closeModal); + document + .getElementById("closeModalBtn") + .addEventListener("click", closeModal); + document.getElementById("cancelBtn").addEventListener("click", closeModal); - document.getElementById('questionForm').addEventListener('submit', saveQuestion); + document + .getElementById("questionForm") + .addEventListener("submit", saveQuestion); - document.getElementById('questionModal').addEventListener('click', (e) => { - if (e.target.id === 'questionModal') { + document.getElementById("questionModal").addEventListener("click", (e) => { + if (e.target.id === "questionModal") { closeModal(); } }); @@ -70,39 +93,37 @@ function displayQuestions(questions) { .map( (q) => `
-

ID: ${q.id} - ${q.source}

-

Kérdés: ${q.description.substring( - 0, - 100 - )}...

-

Típus: ${q.type - } | Helyes válasz: ${["A", "B", "C", "D"][q.correct - 1] - }

+

ID: ${q.id} - ${escapeHtml(q.source)}

+

Kérdés: ${escapeHtml( + q.description.substring(0, 100), + )}...

+

Típus: ${escapeHtml(q.type)} | Helyes válasz: ${ + ["A", "B", "C", "D"][q.correct - 1] + }

- ` + `, ) .join(""); - container.querySelectorAll('[data-edit-id]').forEach(btn => { - btn.addEventListener('click', (e) => { + container.querySelectorAll("[data-edit-id]").forEach((btn) => { + btn.addEventListener("click", (e) => { editQuestion(parseInt(e.target.dataset.editId)); }); }); - container.querySelectorAll('[data-delete-id]').forEach(btn => { - btn.addEventListener('click', (e) => { + container.querySelectorAll("[data-delete-id]").forEach((btn) => { + btn.addEventListener("click", (e) => { deleteQuestion(parseInt(e.target.dataset.deleteId)); }); }); } function showAddQuestionModal() { - document.getElementById("modalTitle").textContent = - "Új kérdés hozzáadása"; + document.getElementById("modalTitle").textContent = "Új kérdés hozzáadása"; document.getElementById("questionForm").reset(); document.getElementById("questionId").value = ""; document.getElementById("questionModal").style.display = "block"; @@ -126,11 +147,9 @@ async function editQuestion(id) { document.getElementById("questionB").value = question.b; document.getElementById("questionC").value = question.c; document.getElementById("questionD").value = question.d; - document.getElementById("questionCorrect").value = - question.correct; + document.getElementById("questionCorrect").value = question.correct; document.getElementById("questionType").value = question.type; - document.getElementById("questionImage").value = - question.image || ""; + document.getElementById("questionImage").value = question.image || ""; document.getElementById("questionModal").style.display = "block"; } } @@ -166,7 +185,7 @@ async function saveQuestion(event) { method: isEdit ? "PUT" : "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data), - } + }, ); if (response.ok) { @@ -178,7 +197,7 @@ async function saveQuestion(event) { showAlert( "questionsAlert", error.error || "Hiba a mentés során", - "danger" + "danger", ); } } catch (error) { @@ -190,10 +209,9 @@ async function deleteQuestion(id) { if (!confirm("Biztosan törölni szeretnéd ezt a kérdést?")) return; try { - const response = await fetch( - `${API_BASE}/api/admin/questions/${id}`, - { method: "DELETE" } - ); + const response = await fetch(`${API_BASE}/api/admin/questions/${id}`, { + method: "DELETE", + }); if (response.ok) { loadQuestions(); showAlert("questionsAlert", "Kérdés törölve!", "success"); @@ -226,16 +244,20 @@ function displayImages(images) { .map( (image) => `
- ${image} -

${image}

- + ${escapeHtml(image)} +

${escapeHtml(image)}

+
- ` + `, ) .join(""); - container.querySelectorAll('[data-delete-image]').forEach(btn => { - btn.addEventListener('click', (e) => { + container.querySelectorAll("[data-delete-image]").forEach((btn) => { + btn.addEventListener("click", (e) => { deleteImage(e.target.dataset.deleteImage); }); }); @@ -265,7 +287,7 @@ async function uploadImage() { showAlert( "imagesAlert", error.error || "Hiba a feltöltés során", - "danger" + "danger", ); } } catch (error) { @@ -281,7 +303,7 @@ async function deleteImage(filename) { `${API_BASE}/api/admin/images/${encodeURIComponent(filename)}`, { method: "DELETE", - } + }, ); if (response.ok) { @@ -308,4 +330,4 @@ function showAlert(elementId, message, type) { setTimeout(() => { alertDiv.style.display = "none"; }, 5000); -} \ No newline at end of file +} diff --git a/backend/server.js b/backend/server.js index 4f976d3..a12823e 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1,41 +1,58 @@ -const express = require('express'); -const cors = require('cors'); -const multer = require('multer'); -const path = require('path'); -const fs = require('fs').promises; -const helmet = require('helmet'); +const express = require("express"); +const cors = require("cors"); +const multer = require("multer"); +const path = require("path"); +const fs = require("fs").promises; +const helmet = require("helmet"); const app = express(); const PORT = process.env.PORT || 3001; // Security middleware -app.use(helmet({ - contentSecurityPolicy: { - directives: { - defaultSrc: [ - "'self'", - "https://stats.schmelczer.dev", - "'unsafe-inline'", - ], - scriptSrc: [ - "'self'", - "https://stats.schmelczer.dev", - "'unsafe-inline'", - ], +app.use( + helmet({ + contentSecurityPolicy: { + directives: { + defaultSrc: [ + "'self'", + "https://stats.schmelczer.dev", + "'unsafe-inline'", + ], + scriptSrc: [ + "'self'", + "https://stats.schmelczer.dev", + "'unsafe-inline'", + ], + }, }, - }, -})); -app.use(cors({ - origin: process.env.FRONTEND_URL || '*', - credentials: true -})); + }), +); +app.use( + cors({ + origin: process.env.FRONTEND_URL || "*", + credentials: true, + }), +); -app.use(express.json({ limit: '100mb' })); -app.use(express.static('public')); +app.use(express.json({ limit: "100mb" })); +app.use(express.static("public")); // File paths -const DATA_PATH = process.env.DATA_PATH || path.join(__dirname, '../frontend/fizika.json'); -const PICS_PATH = process.env.PICS_PATH || path.join(__dirname, '../frontend/pics'); +const DATA_PATH = + process.env.DATA_PATH || path.join(__dirname, "../frontend/fizika.json"); +const PICS_PATH = + process.env.PICS_PATH || path.join(__dirname, "../frontend/pics"); + +// Reject any name that could escape the target directory (path traversal). +const isUnsafeFilename = (name) => + typeof name !== "string" || + name.length === 0 || + name.includes("/") || + name.includes("\\") || + name.includes("\0") || + name === "." || + name === ".." || + path.isAbsolute(name); // Multer configuration for image uploads const storage = multer.diskStorage({ @@ -43,25 +60,31 @@ const storage = multer.diskStorage({ cb(null, PICS_PATH); }, filename: (req, file, cb) => { - cb(null, file.originalname); - } + // Strip any directory components the client may have sent so an upload + // named e.g. "../../server.js" can never escape PICS_PATH. + const name = path.basename(file.originalname); + if (isUnsafeFilename(name)) { + return cb(new Error("Invalid filename")); + } + cb(null, name); + }, }); const upload = multer({ storage: storage, - limits: { fileSize: 50 * 1024 * 1024 }, // 5MB + limits: { fileSize: 50 * 1024 * 1024 }, // 50MB fileFilter: (req, file, cb) => { - if (file.mimetype.startsWith('image/')) { + if (file.mimetype.startsWith("image/")) { cb(null, true); } else { - cb(new Error('Only images allowed')); + cb(new Error("Only images allowed")); } - } + }, }); // Utility functions const readData = async () => { - const data = await fs.readFile(DATA_PATH, 'utf8'); + const data = await fs.readFile(DATA_PATH, "utf8"); return JSON.parse(data); }; @@ -69,44 +92,42 @@ const writeData = async (data) => { await fs.writeFile(DATA_PATH, JSON.stringify(data, null, 2)); }; - - // Public routes -app.get('/api/fizika', async (req, res) => { +app.get("/api/fizika", async (req, res) => { try { const data = await readData(); res.json(data); } catch (error) { - res.status(500).json({ error: 'Failed to read data' }); + res.status(500).json({ error: "Failed to read data" }); } }); -app.get('/api/images', async (req, res) => { +app.get("/api/images", async (req, res) => { try { const files = await fs.readdir(PICS_PATH); - const images = files.filter(f => /\.(jpg|jpeg|png|gif|bmp)$/i.test(f)); + const images = files.filter((f) => /\.(jpg|jpeg|png|gif|bmp)$/i.test(f)); res.json(images); } catch (error) { - res.status(500).json({ error: 'Failed to read images' }); + res.status(500).json({ error: "Failed to read images" }); } }); -app.use('/api/pics', express.static(PICS_PATH)); +app.use("/api/pics", express.static(PICS_PATH)); // Admin routes (no auth required) -app.get('/api/admin/questions', async (req, res) => { +app.get("/api/admin/questions", async (req, res) => { try { const data = await readData(); res.json(data); } catch (error) { - res.status(500).json({ error: 'Failed to read questions' }); + res.status(500).json({ error: "Failed to read questions" }); } }); -app.post('/api/admin/questions', async (req, res) => { +app.post("/api/admin/questions", async (req, res) => { try { const data = await readData(); - const maxId = Math.max(...data.map(q => q.id), 0); + const maxId = Math.max(...data.map((q) => q.id), 0); const newQuestion = { id: maxId + 1, ...req.body }; data.push(newQuestion); @@ -114,17 +135,17 @@ app.post('/api/admin/questions', async (req, res) => { res.status(201).json(newQuestion); } catch (error) { - res.status(500).json({ error: 'Failed to create question' }); + res.status(500).json({ error: "Failed to create question" }); } }); -app.put('/api/admin/questions/:id', async (req, res) => { +app.put("/api/admin/questions/:id", async (req, res) => { try { const data = await readData(); - const index = data.findIndex(q => q.id === parseInt(req.params.id)); + const index = data.findIndex((q) => q.id === parseInt(req.params.id)); if (index === -1) { - return res.status(404).json({ error: 'Question not found' }); + return res.status(404).json({ error: "Question not found" }); } data[index] = { ...data[index], ...req.body }; @@ -132,61 +153,70 @@ app.put('/api/admin/questions/:id', async (req, res) => { res.json(data[index]); } catch (error) { - res.status(500).json({ error: 'Failed to update question' }); + res.status(500).json({ error: "Failed to update question" }); } }); -app.delete('/api/admin/questions/:id', async (req, res) => { +app.delete("/api/admin/questions/:id", async (req, res) => { try { const data = await readData(); - const index = data.findIndex(q => q.id === parseInt(req.params.id)); + const index = data.findIndex((q) => q.id === parseInt(req.params.id)); if (index === -1) { - return res.status(404).json({ error: 'Question not found' }); + return res.status(404).json({ error: "Question not found" }); } data.splice(index, 1); await writeData(data); - res.json({ message: 'Question deleted' }); + res.json({ message: "Question deleted" }); } catch (error) { - res.status(500).json({ error: 'Failed to delete question' }); + res.status(500).json({ error: "Failed to delete question" }); } }); -app.post('/api/admin/images/upload', upload.single('image'), (req, res) => { +app.post("/api/admin/images/upload", upload.single("image"), (req, res) => { if (!req.file) { - return res.status(400).json({ error: 'No image provided' }); + return res.status(400).json({ error: "No image provided" }); } res.json({ filename: req.file.filename, - path: `/api/pics/${req.file.filename}` + path: `/api/pics/${req.file.filename}`, }); }); -app.delete('/api/admin/images/:filename', async (req, res) => { +app.delete("/api/admin/images/:filename", async (req, res) => { + if (isUnsafeFilename(req.params.filename)) { + return res.status(400).json({ error: "Invalid filename" }); + } + try { await fs.unlink(path.join(PICS_PATH, req.params.filename)); - res.json({ message: 'Image deleted' }); + res.json({ message: "Image deleted" }); } catch (error) { - if (error.code === 'ENOENT') { - return res.status(404).json({ error: 'Image not found' }); + if (error.code === "ENOENT") { + return res.status(404).json({ error: "Image not found" }); } - res.status(500).json({ error: 'Failed to delete image' }); + res.status(500).json({ error: "Failed to delete image" }); } }); // Error handling app.use((error, req, res, next) => { - console.error('Error:', error.message); - res.status(500).json({ error: 'Server error' }); + console.error("Error:", error.message); + res.status(500).json({ error: "Server error" }); }); app.use((req, res) => { - res.status(404).json({ error: 'Not found' }); + res.status(404).json({ error: "Not found" }); }); -app.listen(PORT, () => { - console.log(`Fizika Admin Backend running on port ${PORT}`); -}); \ No newline at end of file +// Only start listening when run directly, so tests can import the app. +if (require.main === module) { + app.listen(PORT, () => { + console.log(`Fizika Admin Backend running on port ${PORT}`); + }); +} + +module.exports = { app, isUnsafeFilename }; diff --git a/frontend/js/load.js b/frontend/js/load.js index 1da2bef..0d36dfa 100644 --- a/frontend/js/load.js +++ b/frontend/js/load.js @@ -6,56 +6,69 @@ const getApiBase = () => { const hostname = window.location.hostname; // If running on localhost, assume backend is on port 3001 - if (hostname === 'localhost' || hostname === '127.0.0.1') { + if (hostname === "localhost" || hostname === "127.0.0.1") { return `${protocol}//${hostname}:3001`; } // For production, assume backend is on same origin - return "https://fizika-backend.schmelczer.dev" + return "https://fizika-backend.schmelczer.dev"; }; const API_BASE = getApiBase(); +// Fetch the question bank from the backend, which is the single source of +// truth. Cached after the first successful load. +const ensureQuestionsLoaded = async () => { + if (questions !== null) return questions; + + const response = await fetch(`${API_BASE}/api/fizika`); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + questions = await response.json(); + return questions; +}; + +const escapeHtml = (value) => + String(value).replace( + /[&<>"']/g, + (ch) => + ({ + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + })[ch], + ); + +// Question text is trusted but contains light markup (, ,
) and +// pre-encoded entities. Escape everything first, then restore only that +// allowlist, so any other injected markup (e.g. `; - } + }, ); resultHtml += @@ -126,34 +143,15 @@ function shuffleArray(array) { // Initialize year dropdown with dynamic years from question data const initializeYearDropdown = async () => { try { - // Load questions if not already loaded - if (questions === null) { - try { - const response = await fetch(`${API_BASE}/api/fizika`); - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - questions = await response.json(); - console.log('Questions loaded for year dropdown initialization'); - } catch (error) { - console.warn('Failed to load questions from API, falling back to local file:', error); - try { - const fallbackResponse = await fetch("fizika.json"); - if (!fallbackResponse.ok) { - throw new Error(`Local file not available: ${fallbackResponse.status}`); - } - questions = await fallbackResponse.json(); - console.log('Questions loaded from local fallback file for year dropdown'); - } catch (fallbackError) { - console.error('Both API and local file failed:', fallbackError); - return; // Don't update dropdown if data unavailable - } - } - } + await ensureQuestionsLoaded(); + + // Reflect the live question count on the info page. + const countEl = document.getElementById("questionCount"); + if (countEl) countEl.textContent = questions.length; // Extract unique years from question sources const yearSet = new Set(); - questions.forEach(q => { + questions.forEach((q) => { const yearMatch = q.source.match(/^(\d{4})\//); if (yearMatch) { yearSet.add(parseInt(yearMatch[1])); @@ -164,21 +162,18 @@ const initializeYearDropdown = async () => { const uniqueYears = Array.from(yearSet).sort((a, b) => b - a); // Get existing dropdown - const yearDropdown = document.getElementById('evszam'); + const yearDropdown = document.getElementById("evszam"); if (!yearDropdown) return; // Preserve the "Összes év" option and add dynamic years const allYearsOption = ''; - const yearOptions = uniqueYears.map(year => - `` - ).join(''); + const yearOptions = uniqueYears + .map((year) => ``) + .join(""); yearDropdown.innerHTML = allYearsOption + yearOptions; - - console.log('Year dropdown initialized with years:', uniqueYears); - } catch (error) { - console.error('Failed to initialize year dropdown:', error); + console.error("Failed to initialize year dropdown:", error); } }; @@ -186,16 +181,16 @@ const initializeYearDropdown = async () => { const initializeMonthDropdown = (selectedYear) => { if (!questions) return; - const monthDropdown = document.getElementById('honap'); + const monthDropdown = document.getElementById("honap"); if (!monthDropdown) return; // Always include "Összes feladatsora" option let monthOptions = ''; - if (selectedYear === 'all/') { + if (selectedYear === "all/") { // Show all possible month patterns const monthSet = new Set(); - questions.forEach(q => { + questions.forEach((q) => { const sourceMatch = q.source.match(/^(\d{4})\/(.+?)\//); if (sourceMatch) { monthSet.add(sourceMatch[2]); @@ -204,17 +199,16 @@ const initializeMonthDropdown = (selectedYear) => { // Convert to sorted array and create options const uniqueMonths = Array.from(monthSet).sort(); - uniqueMonths.forEach(month => { + uniqueMonths.forEach((month) => { monthOptions += ``; }); - } else { // Extract year from selected value (e.g., "2024/" -> "2024") - const year = selectedYear.replace('/', ''); + const year = selectedYear.replace("/", ""); // Get unique months for this specific year const monthSet = new Set(); - questions.forEach(q => { + questions.forEach((q) => { const sourceMatch = q.source.match(`^${year}\/(.+?)\/`); if (sourceMatch) { monthSet.add(sourceMatch[1]); @@ -224,50 +218,52 @@ const initializeMonthDropdown = (selectedYear) => { const uniqueMonths = Array.from(monthSet).sort(); // Special handling for known year patterns - if (year === '2006') { + if (year === "2006") { // Preserve existing 2006 logic but add any new months found - monthOptions += ''; + monthOptions += + ''; monthOptions += ''; - monthOptions += ''; + monthOptions += + ''; // Add any dynamic months not covered by the standard ones - uniqueMonths.forEach(month => { - if (!['1', '2', '3'].includes(month)) { + uniqueMonths.forEach((month) => { + if (!["1", "2", "3"].includes(month)) { monthOptions += ``; } }); - - } else if (year === '2016') { - // Preserve existing 2016 logic but add any new months found + } else if (year === "2016") { + // Preserve existing 2016 logic but add any new months found monthOptions += ''; monthOptions += ''; - monthOptions += ''; - monthOptions += ''; - monthOptions += ''; + monthOptions += + ''; + monthOptions += + ''; + monthOptions += + ''; // Add any dynamic months not covered - uniqueMonths.forEach(month => { - if (!['1', '2', 'm1', 'm2', 'm3'].includes(month)) { + uniqueMonths.forEach((month) => { + if (!["1", "2", "m1", "m2", "m3"].includes(month)) { monthOptions += ``; } }); - - } else if (year === '2017') { + } else if (year === "2017") { // Preserve existing 2017 logic but add any new months found monthOptions += ''; monthOptions += ''; // Add any dynamic months - uniqueMonths.forEach(month => { - if (!['1', '2'].includes(month)) { + uniqueMonths.forEach((month) => { + if (!["1", "2"].includes(month)) { monthOptions += ``; } }); - } else { // For other years, use standard logic plus dynamic months - const hasStandard1 = uniqueMonths.includes('1'); - const hasStandard2 = uniqueMonths.includes('2'); + const hasStandard1 = uniqueMonths.includes("1"); + const hasStandard2 = uniqueMonths.includes("2"); if (hasStandard1) { monthOptions += ''; @@ -277,8 +273,8 @@ const initializeMonthDropdown = (selectedYear) => { } // Add any non-standard months - uniqueMonths.forEach(month => { - if (!['1', '2'].includes(month)) { + uniqueMonths.forEach((month) => { + if (!["1", "2"].includes(month)) { monthOptions += ``; } }); @@ -286,19 +282,18 @@ const initializeMonthDropdown = (selectedYear) => { } monthDropdown.innerHTML = monthOptions; - console.log(`Month dropdown initialized for year: ${selectedYear}`); }; // Helper function to get readable month labels const getMonthLabel = (monthValue) => { // Handle known patterns const knownLabels = { - '1': 'Május-Június', - '2': 'Október-November', - '3': 'Harmadik időszak', - 'm1': '1. Mintafeladatsor', - 'm2': '2. Mintafeladatsor', - 'm3': '3. Mintafeladatsor' + 1: "Május-Június", + 2: "Október-November", + 3: "Harmadik időszak", + m1: "1. Mintafeladatsor", + m2: "2. Mintafeladatsor", + m3: "3. Mintafeladatsor", }; return knownLabels[monthValue] || monthValue;