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}
-
+
})
+
${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;