Improve security
All checks were successful
Test / test (push) Successful in 7s
Deploy to Pages / deploy (push) Successful in 10s
Build and Publish Docker Image / build-and-push (push) Successful in 23s

This commit is contained in:
Andras Schmelczer 2026-06-06 19:39:24 +01:00
parent 0fde080ab8
commit 853f88c66b
3 changed files with 275 additions and 228 deletions

View file

@ -6,6 +6,21 @@ window.plausible =
(window.plausible.q = window.plausible.q || []).push(arguments); (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) =>
({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
})[ch],
);
}
document.addEventListener("DOMContentLoaded", function () { document.addEventListener("DOMContentLoaded", function () {
loadQuestions(); loadQuestions();
loadImages(); loadImages();
@ -13,23 +28,31 @@ document.addEventListener("DOMContentLoaded", function () {
}); });
function setupEventListeners() { function setupEventListeners() {
document.querySelectorAll('.tab[data-tab]').forEach(tab => { document.querySelectorAll(".tab[data-tab]").forEach((tab) => {
tab.addEventListener('click', (e) => { tab.addEventListener("click", (e) => {
switchTab(e.target.dataset.tab); 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
document.getElementById('cancelBtn').addEventListener('click', closeModal); .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) => { document.getElementById("questionModal").addEventListener("click", (e) => {
if (e.target.id === 'questionModal') { if (e.target.id === "questionModal") {
closeModal(); closeModal();
} }
}); });
@ -70,39 +93,37 @@ function displayQuestions(questions) {
.map( .map(
(q) => ` (q) => `
<div class="question-item"> <div class="question-item">
<h4>ID: ${q.id} - ${q.source}</h4> <h4>ID: ${q.id} - ${escapeHtml(q.source)}</h4>
<p><strong>Kérdés:</strong> ${q.description.substring( <p><strong>Kérdés:</strong> ${escapeHtml(
0, q.description.substring(0, 100),
100 )}...</p>
)}...</p> <p><strong>Típus:</strong> ${escapeHtml(q.type)} | <strong>Helyes válasz:</strong> ${
<p><strong>Típus:</strong> ${q.type ["A", "B", "C", "D"][q.correct - 1]
} | <strong>Helyes válasz:</strong> ${["A", "B", "C", "D"][q.correct - 1] }</p>
}</p>
<div class="question-actions"> <div class="question-actions">
<button data-edit-id="${q.id}">Szerkesztés</button> <button data-edit-id="${q.id}">Szerkesztés</button>
<button class="danger" data-delete-id="${q.id}">Törlés</button> <button class="danger" data-delete-id="${q.id}">Törlés</button>
</div> </div>
</div> </div>
` `,
) )
.join(""); .join("");
container.querySelectorAll('[data-edit-id]').forEach(btn => { container.querySelectorAll("[data-edit-id]").forEach((btn) => {
btn.addEventListener('click', (e) => { btn.addEventListener("click", (e) => {
editQuestion(parseInt(e.target.dataset.editId)); editQuestion(parseInt(e.target.dataset.editId));
}); });
}); });
container.querySelectorAll('[data-delete-id]').forEach(btn => { container.querySelectorAll("[data-delete-id]").forEach((btn) => {
btn.addEventListener('click', (e) => { btn.addEventListener("click", (e) => {
deleteQuestion(parseInt(e.target.dataset.deleteId)); deleteQuestion(parseInt(e.target.dataset.deleteId));
}); });
}); });
} }
function showAddQuestionModal() { function showAddQuestionModal() {
document.getElementById("modalTitle").textContent = document.getElementById("modalTitle").textContent = "Új kérdés hozzáadása";
"Új kérdés hozzáadása";
document.getElementById("questionForm").reset(); document.getElementById("questionForm").reset();
document.getElementById("questionId").value = ""; document.getElementById("questionId").value = "";
document.getElementById("questionModal").style.display = "block"; document.getElementById("questionModal").style.display = "block";
@ -126,11 +147,9 @@ async function editQuestion(id) {
document.getElementById("questionB").value = question.b; document.getElementById("questionB").value = question.b;
document.getElementById("questionC").value = question.c; document.getElementById("questionC").value = question.c;
document.getElementById("questionD").value = question.d; document.getElementById("questionD").value = question.d;
document.getElementById("questionCorrect").value = document.getElementById("questionCorrect").value = question.correct;
question.correct;
document.getElementById("questionType").value = question.type; document.getElementById("questionType").value = question.type;
document.getElementById("questionImage").value = document.getElementById("questionImage").value = question.image || "";
question.image || "";
document.getElementById("questionModal").style.display = "block"; document.getElementById("questionModal").style.display = "block";
} }
} }
@ -166,7 +185,7 @@ async function saveQuestion(event) {
method: isEdit ? "PUT" : "POST", method: isEdit ? "PUT" : "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(data), body: JSON.stringify(data),
} },
); );
if (response.ok) { if (response.ok) {
@ -178,7 +197,7 @@ async function saveQuestion(event) {
showAlert( showAlert(
"questionsAlert", "questionsAlert",
error.error || "Hiba a mentés során", error.error || "Hiba a mentés során",
"danger" "danger",
); );
} }
} catch (error) { } catch (error) {
@ -190,10 +209,9 @@ async function deleteQuestion(id) {
if (!confirm("Biztosan törölni szeretnéd ezt a kérdést?")) return; if (!confirm("Biztosan törölni szeretnéd ezt a kérdést?")) return;
try { try {
const response = await fetch( const response = await fetch(`${API_BASE}/api/admin/questions/${id}`, {
`${API_BASE}/api/admin/questions/${id}`, method: "DELETE",
{ method: "DELETE" } });
);
if (response.ok) { if (response.ok) {
loadQuestions(); loadQuestions();
showAlert("questionsAlert", "Kérdés törölve!", "success"); showAlert("questionsAlert", "Kérdés törölve!", "success");
@ -226,16 +244,20 @@ function displayImages(images) {
.map( .map(
(image) => ` (image) => `
<div class="image-item"> <div class="image-item">
<img src="${API_BASE}/api/pics/${image}" alt="${image}"> <img src="${API_BASE}/api/pics/${encodeURIComponent(
<p>${image}</p> image,
<button class="danger" data-delete-image="${image}">Törlés</button> )}" alt="${escapeHtml(image)}">
<p>${escapeHtml(image)}</p>
<button class="danger" data-delete-image="${escapeHtml(
image,
)}">Törlés</button>
</div> </div>
` `,
) )
.join(""); .join("");
container.querySelectorAll('[data-delete-image]').forEach(btn => { container.querySelectorAll("[data-delete-image]").forEach((btn) => {
btn.addEventListener('click', (e) => { btn.addEventListener("click", (e) => {
deleteImage(e.target.dataset.deleteImage); deleteImage(e.target.dataset.deleteImage);
}); });
}); });
@ -265,7 +287,7 @@ async function uploadImage() {
showAlert( showAlert(
"imagesAlert", "imagesAlert",
error.error || "Hiba a feltöltés során", error.error || "Hiba a feltöltés során",
"danger" "danger",
); );
} }
} catch (error) { } catch (error) {
@ -281,7 +303,7 @@ async function deleteImage(filename) {
`${API_BASE}/api/admin/images/${encodeURIComponent(filename)}`, `${API_BASE}/api/admin/images/${encodeURIComponent(filename)}`,
{ {
method: "DELETE", method: "DELETE",
} },
); );
if (response.ok) { if (response.ok) {
@ -308,4 +330,4 @@ function showAlert(elementId, message, type) {
setTimeout(() => { setTimeout(() => {
alertDiv.style.display = "none"; alertDiv.style.display = "none";
}, 5000); }, 5000);
} }

View file

@ -1,41 +1,58 @@
const express = require('express'); const express = require("express");
const cors = require('cors'); const cors = require("cors");
const multer = require('multer'); const multer = require("multer");
const path = require('path'); const path = require("path");
const fs = require('fs').promises; const fs = require("fs").promises;
const helmet = require('helmet'); const helmet = require("helmet");
const app = express(); const app = express();
const PORT = process.env.PORT || 3001; const PORT = process.env.PORT || 3001;
// Security middleware // Security middleware
app.use(helmet({ app.use(
contentSecurityPolicy: { helmet({
directives: { contentSecurityPolicy: {
defaultSrc: [ directives: {
"'self'", defaultSrc: [
"https://stats.schmelczer.dev", "'self'",
"'unsafe-inline'", "https://stats.schmelczer.dev",
], "'unsafe-inline'",
scriptSrc: [ ],
"'self'", scriptSrc: [
"https://stats.schmelczer.dev", "'self'",
"'unsafe-inline'", "https://stats.schmelczer.dev",
], "'unsafe-inline'",
],
},
}, },
}, }),
})); );
app.use(cors({ app.use(
origin: process.env.FRONTEND_URL || '*', cors({
credentials: true origin: process.env.FRONTEND_URL || "*",
})); credentials: true,
}),
);
app.use(express.json({ limit: '100mb' })); app.use(express.json({ limit: "100mb" }));
app.use(express.static('public')); app.use(express.static("public"));
// File paths // File paths
const DATA_PATH = process.env.DATA_PATH || path.join(__dirname, '../frontend/fizika.json'); const DATA_PATH =
const PICS_PATH = process.env.PICS_PATH || path.join(__dirname, '../frontend/pics'); 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 // Multer configuration for image uploads
const storage = multer.diskStorage({ const storage = multer.diskStorage({
@ -43,25 +60,31 @@ const storage = multer.diskStorage({
cb(null, PICS_PATH); cb(null, PICS_PATH);
}, },
filename: (req, file, cb) => { 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({ const upload = multer({
storage: storage, storage: storage,
limits: { fileSize: 50 * 1024 * 1024 }, // 5MB limits: { fileSize: 50 * 1024 * 1024 }, // 50MB
fileFilter: (req, file, cb) => { fileFilter: (req, file, cb) => {
if (file.mimetype.startsWith('image/')) { if (file.mimetype.startsWith("image/")) {
cb(null, true); cb(null, true);
} else { } else {
cb(new Error('Only images allowed')); cb(new Error("Only images allowed"));
} }
} },
}); });
// Utility functions // Utility functions
const readData = async () => { const readData = async () => {
const data = await fs.readFile(DATA_PATH, 'utf8'); const data = await fs.readFile(DATA_PATH, "utf8");
return JSON.parse(data); return JSON.parse(data);
}; };
@ -69,44 +92,42 @@ const writeData = async (data) => {
await fs.writeFile(DATA_PATH, JSON.stringify(data, null, 2)); await fs.writeFile(DATA_PATH, JSON.stringify(data, null, 2));
}; };
// Public routes // Public routes
app.get('/api/fizika', async (req, res) => { app.get("/api/fizika", async (req, res) => {
try { try {
const data = await readData(); const data = await readData();
res.json(data); res.json(data);
} catch (error) { } 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 { try {
const files = await fs.readdir(PICS_PATH); 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); res.json(images);
} catch (error) { } 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) // Admin routes (no auth required)
app.get('/api/admin/questions', async (req, res) => { app.get("/api/admin/questions", async (req, res) => {
try { try {
const data = await readData(); const data = await readData();
res.json(data); res.json(data);
} catch (error) { } 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 { try {
const data = await readData(); 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 }; const newQuestion = { id: maxId + 1, ...req.body };
data.push(newQuestion); data.push(newQuestion);
@ -114,17 +135,17 @@ app.post('/api/admin/questions', async (req, res) => {
res.status(201).json(newQuestion); res.status(201).json(newQuestion);
} catch (error) { } 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 { try {
const data = await readData(); 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) { 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 }; data[index] = { ...data[index], ...req.body };
@ -132,61 +153,70 @@ app.put('/api/admin/questions/:id', async (req, res) => {
res.json(data[index]); res.json(data[index]);
} catch (error) { } 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 { try {
const data = await readData(); 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) { 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); data.splice(index, 1);
await writeData(data); await writeData(data);
res.json({ message: 'Question deleted' }); res.json({ message: "Question deleted" });
} catch (error) { } 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) { if (!req.file) {
return res.status(400).json({ error: 'No image provided' }); return res.status(400).json({ error: "No image provided" });
} }
res.json({ res.json({
filename: req.file.filename, 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 { try {
await fs.unlink(path.join(PICS_PATH, req.params.filename)); await fs.unlink(path.join(PICS_PATH, req.params.filename));
res.json({ message: 'Image deleted' }); res.json({ message: "Image deleted" });
} catch (error) { } catch (error) {
if (error.code === 'ENOENT') { if (error.code === "ENOENT") {
return res.status(404).json({ error: 'Image not found' }); 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 // Error handling
app.use((error, req, res, next) => { app.use((error, req, res, next) => {
console.error('Error:', error.message); console.error("Error:", error.message);
res.status(500).json({ error: 'Server error' }); res.status(500).json({ error: "Server error" });
}); });
app.use((req, res) => { app.use((req, res) => {
res.status(404).json({ error: 'Not found' }); res.status(404).json({ error: "Not found" });
}); });
app.listen(PORT, () => { // Only start listening when run directly, so tests can import the app.
console.log(`Fizika Admin Backend running on port ${PORT}`); if (require.main === module) {
}); app.listen(PORT, () => {
console.log(`Fizika Admin Backend running on port ${PORT}`);
});
}
module.exports = { app, isUnsafeFilename };

View file

@ -6,56 +6,69 @@ const getApiBase = () => {
const hostname = window.location.hostname; const hostname = window.location.hostname;
// If running on localhost, assume backend is on port 3001 // 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`; return `${protocol}//${hostname}:3001`;
} }
// For production, assume backend is on same origin // For production, assume backend is on same origin
return "https://fizika-backend.schmelczer.dev" return "https://fizika-backend.schmelczer.dev";
}; };
const API_BASE = getApiBase(); 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) =>
({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
})[ch],
);
// Question text is trusted but contains light markup (<sub>, <sup>, <br>) and
// pre-encoded entities. Escape everything first, then restore only that
// allowlist, so any other injected markup (e.g. <script>) stays inert.
const sanitizeQuestionHtml = (value) =>
escapeHtml(value)
.replace(/&lt;(\/?)(sub|sup)&gt;/g, "<$1$2>")
.replace(/&lt;br\s*\/?&gt;/g, "<br>")
.replace(/&amp;(lt|gt|amp|quot|#\d+|#x[0-9a-fA-F]+);/g, "&$1;");
const loadQuestions = async ( const loadQuestions = async (
isSearch, isSearch,
categories, categories,
sourceScheme, sourceScheme,
questionCount questionCount,
) => { ) => {
if (questions === null) { await ensureQuestionsLoaded();
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 from backend API');
} 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');
} catch (fallbackError) {
console.error('Both API and local file failed:', fallbackError);
throw new Error('Unable to load quiz data from either backend API or local file');
}
}
}
let currentQuestions = questions.slice(); let currentQuestions = questions.slice();
if (isSearch) { if (isSearch) {
currentQuestions = currentQuestions.filter((q) => currentQuestions = currentQuestions.filter((q) =>
q.source.match(sourceScheme) q.source.match(sourceScheme),
); );
} else { } else {
shuffleArray(currentQuestions); shuffleArray(currentQuestions);
currentQuestions = currentQuestions.filter((q) => currentQuestions = currentQuestions.filter((q) =>
categories.includes(q.type) categories.includes(q.type),
); );
} }
@ -64,45 +77,49 @@ const loadQuestions = async (
currentQuestions = currentQuestions.slice(0, questionCount); currentQuestions = currentQuestions.slice(0, questionCount);
currentQuestions.forEach( currentQuestions.forEach(
({ id, source, description, a, b, c, d, correct, image }, i) => { ({ id, source, description, a, b, c, d, correct, image }, i) => {
const qid = Number(id);
const correctAnswer = Number(correct);
const safeImage = image ? encodeURIComponent(image) : "";
resultHtml += ` resultHtml += `
<div class="feladat card" id="feladat${id}"> <div class="feladat card" id="feladat${qid}">
<h2 style="float: left;">${i + 1}.</h2><h2>${source}</h2> <h2 style="float: left;">${i + 1}.</h2><h2>${sanitizeQuestionHtml(source)}</h2>
<pre>${description}</pre> <pre>${sanitizeQuestionHtml(description)}</pre>
${image ? `<img src="${API_BASE}/api/pics/${image}" crossorigin="anonymous" onerror="this.src='pics/${image}'"><br>` : ""} ${image ? `<img src="${API_BASE}/api/pics/${safeImage}" crossorigin="anonymous" onerror="this.src='pics/${safeImage}'"><br>` : ""}
<form id="form${id}""> <form id="form${qid}">
<input type="radio" id="rad1" name="group"> <input type="radio" id="rad1" name="group">
<label id="label${id}" class="rad1">${a}</label> <label id="label${qid}" class="rad1">${sanitizeQuestionHtml(a)}</label>
<br> <br>
<input type="radio" id="rad2" name="group"> <input type="radio" id="rad2" name="group">
<label id="label${id}" class="rad2">${b}</label> <label id="label${qid}" class="rad2">${sanitizeQuestionHtml(b)}</label>
<br> <br>
<input type="radio" id="rad3" name="group"> <input type="radio" id="rad3" name="group">
<label id="label${id}" class="rad3">${c}</label> <label id="label${qid}" class="rad3">${sanitizeQuestionHtml(c)}</label>
<br> <br>
${d ${
? ` d
? `
<input type="radio" id="rad4" name="group"> <input type="radio" id="rad4" name="group">
<label id="label${id}" class="rad4">${d}</label> <label id="label${qid}" class="rad4">${sanitizeQuestionHtml(d)}</label>
<br>` <br>`
: "" : ""
} }
</form> </form>
<script type="text/javascript"> <script type="text/javascript">
$(document).ready(function(){ $(document).ready(function(){
totalPoints++; totalPoints++;
$("#ans").click(function(event){ $("#ans").click(function(event){
event.preventDefault(); event.preventDefault();
teszt(${id}, ${correct}); teszt(${qid}, ${correctAnswer});
}); });
$("#cAns").click(function(event){ $("#cAns").click(function(event){
event.preventDefault(); event.preventDefault();
showCorrect(${id}, ${correct}); showCorrect(${qid}, ${correctAnswer});
}); });
}); });
</script> </script>
</div> </div>
`; `;
} },
); );
resultHtml += resultHtml +=
@ -126,34 +143,15 @@ function shuffleArray(array) {
// Initialize year dropdown with dynamic years from question data // Initialize year dropdown with dynamic years from question data
const initializeYearDropdown = async () => { const initializeYearDropdown = async () => {
try { try {
// Load questions if not already loaded await ensureQuestionsLoaded();
if (questions === null) {
try { // Reflect the live question count on the info page.
const response = await fetch(`${API_BASE}/api/fizika`); const countEl = document.getElementById("questionCount");
if (!response.ok) { if (countEl) countEl.textContent = questions.length;
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
}
}
}
// Extract unique years from question sources // Extract unique years from question sources
const yearSet = new Set(); const yearSet = new Set();
questions.forEach(q => { questions.forEach((q) => {
const yearMatch = q.source.match(/^(\d{4})\//); const yearMatch = q.source.match(/^(\d{4})\//);
if (yearMatch) { if (yearMatch) {
yearSet.add(parseInt(yearMatch[1])); yearSet.add(parseInt(yearMatch[1]));
@ -164,21 +162,18 @@ const initializeYearDropdown = async () => {
const uniqueYears = Array.from(yearSet).sort((a, b) => b - a); const uniqueYears = Array.from(yearSet).sort((a, b) => b - a);
// Get existing dropdown // Get existing dropdown
const yearDropdown = document.getElementById('evszam'); const yearDropdown = document.getElementById("evszam");
if (!yearDropdown) return; if (!yearDropdown) return;
// Preserve the "Összes év" option and add dynamic years // Preserve the "Összes év" option and add dynamic years
const allYearsOption = '<option value="all/">Összes év</option>'; const allYearsOption = '<option value="all/">Összes év</option>';
const yearOptions = uniqueYears.map(year => const yearOptions = uniqueYears
`<option value="${year}/">${year}</option>` .map((year) => `<option value="${year}/">${year}</option>`)
).join(''); .join("");
yearDropdown.innerHTML = allYearsOption + yearOptions; yearDropdown.innerHTML = allYearsOption + yearOptions;
console.log('Year dropdown initialized with years:', uniqueYears);
} catch (error) { } 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) => { const initializeMonthDropdown = (selectedYear) => {
if (!questions) return; if (!questions) return;
const monthDropdown = document.getElementById('honap'); const monthDropdown = document.getElementById("honap");
if (!monthDropdown) return; if (!monthDropdown) return;
// Always include "Összes feladatsora" option // Always include "Összes feladatsora" option
let monthOptions = '<option value="all">Összes feladatsora</option>'; let monthOptions = '<option value="all">Összes feladatsora</option>';
if (selectedYear === 'all/') { if (selectedYear === "all/") {
// Show all possible month patterns // Show all possible month patterns
const monthSet = new Set(); const monthSet = new Set();
questions.forEach(q => { questions.forEach((q) => {
const sourceMatch = q.source.match(/^(\d{4})\/(.+?)\//); const sourceMatch = q.source.match(/^(\d{4})\/(.+?)\//);
if (sourceMatch) { if (sourceMatch) {
monthSet.add(sourceMatch[2]); monthSet.add(sourceMatch[2]);
@ -204,17 +199,16 @@ const initializeMonthDropdown = (selectedYear) => {
// Convert to sorted array and create options // Convert to sorted array and create options
const uniqueMonths = Array.from(monthSet).sort(); const uniqueMonths = Array.from(monthSet).sort();
uniqueMonths.forEach(month => { uniqueMonths.forEach((month) => {
monthOptions += `<option value="${month}" class="fdynamic">${getMonthLabel(month)}</option>`; monthOptions += `<option value="${month}" class="fdynamic">${getMonthLabel(month)}</option>`;
}); });
} else { } else {
// Extract year from selected value (e.g., "2024/" -> "2024") // Extract year from selected value (e.g., "2024/" -> "2024")
const year = selectedYear.replace('/', ''); const year = selectedYear.replace("/", "");
// Get unique months for this specific year // Get unique months for this specific year
const monthSet = new Set(); const monthSet = new Set();
questions.forEach(q => { questions.forEach((q) => {
const sourceMatch = q.source.match(`^${year}\/(.+?)\/`); const sourceMatch = q.source.match(`^${year}\/(.+?)\/`);
if (sourceMatch) { if (sourceMatch) {
monthSet.add(sourceMatch[1]); monthSet.add(sourceMatch[1]);
@ -224,50 +218,52 @@ const initializeMonthDropdown = (selectedYear) => {
const uniqueMonths = Array.from(monthSet).sort(); const uniqueMonths = Array.from(monthSet).sort();
// Special handling for known year patterns // Special handling for known year patterns
if (year === '2006') { if (year === "2006") {
// Preserve existing 2006 logic but add any new months found // Preserve existing 2006 logic but add any new months found
monthOptions += '<option value="1" class="f2006">Február-Március</option>'; monthOptions +=
'<option value="1" class="f2006">Február-Március</option>';
monthOptions += '<option value="2" class="f2006">Május-Június</option>'; monthOptions += '<option value="2" class="f2006">Május-Június</option>';
monthOptions += '<option value="3" class="f2006">Október-November</option>'; monthOptions +=
'<option value="3" class="f2006">Október-November</option>';
// Add any dynamic months not covered by the standard ones // Add any dynamic months not covered by the standard ones
uniqueMonths.forEach(month => { uniqueMonths.forEach((month) => {
if (!['1', '2', '3'].includes(month)) { if (!["1", "2", "3"].includes(month)) {
monthOptions += `<option value="${month}" class="f2006">${getMonthLabel(month)}</option>`; monthOptions += `<option value="${month}" class="f2006">${getMonthLabel(month)}</option>`;
} }
}); });
} else if (year === "2016") {
} else if (year === '2016') { // Preserve existing 2016 logic but add any new months found
// Preserve existing 2016 logic but add any new months found
monthOptions += '<option value="1" class="f">Május-Június</option>'; monthOptions += '<option value="1" class="f">Május-Június</option>';
monthOptions += '<option value="2" class="f">Október-November</option>'; monthOptions += '<option value="2" class="f">Október-November</option>';
monthOptions += '<option value="m1" class="f2016">1. Mintafeladatsor</option>'; monthOptions +=
monthOptions += '<option value="m2" class="f2016">2. Mintafeladatsor</option>'; '<option value="m1" class="f2016">1. Mintafeladatsor</option>';
monthOptions += '<option value="m3" class="f2016">3. Mintafeladatsor</option>'; monthOptions +=
'<option value="m2" class="f2016">2. Mintafeladatsor</option>';
monthOptions +=
'<option value="m3" class="f2016">3. Mintafeladatsor</option>';
// Add any dynamic months not covered // Add any dynamic months not covered
uniqueMonths.forEach(month => { uniqueMonths.forEach((month) => {
if (!['1', '2', 'm1', 'm2', 'm3'].includes(month)) { if (!["1", "2", "m1", "m2", "m3"].includes(month)) {
monthOptions += `<option value="${month}" class="f">${getMonthLabel(month)}</option>`; monthOptions += `<option value="${month}" class="f">${getMonthLabel(month)}</option>`;
} }
}); });
} else if (year === "2017") {
} else if (year === '2017') {
// Preserve existing 2017 logic but add any new months found // Preserve existing 2017 logic but add any new months found
monthOptions += '<option value="1" class="f">Május-Június</option>'; monthOptions += '<option value="1" class="f">Május-Június</option>';
monthOptions += '<option value="2" class="f">Október-November</option>'; monthOptions += '<option value="2" class="f">Október-November</option>';
// Add any dynamic months // Add any dynamic months
uniqueMonths.forEach(month => { uniqueMonths.forEach((month) => {
if (!['1', '2'].includes(month)) { if (!["1", "2"].includes(month)) {
monthOptions += `<option value="${month}" class="f">${getMonthLabel(month)}</option>`; monthOptions += `<option value="${month}" class="f">${getMonthLabel(month)}</option>`;
} }
}); });
} else { } else {
// For other years, use standard logic plus dynamic months // For other years, use standard logic plus dynamic months
const hasStandard1 = uniqueMonths.includes('1'); const hasStandard1 = uniqueMonths.includes("1");
const hasStandard2 = uniqueMonths.includes('2'); const hasStandard2 = uniqueMonths.includes("2");
if (hasStandard1) { if (hasStandard1) {
monthOptions += '<option value="1" class="f">Május-Június</option>'; monthOptions += '<option value="1" class="f">Május-Június</option>';
@ -277,8 +273,8 @@ const initializeMonthDropdown = (selectedYear) => {
} }
// Add any non-standard months // Add any non-standard months
uniqueMonths.forEach(month => { uniqueMonths.forEach((month) => {
if (!['1', '2'].includes(month)) { if (!["1", "2"].includes(month)) {
monthOptions += `<option value="${month}" class="f">${getMonthLabel(month)}</option>`; monthOptions += `<option value="${month}" class="f">${getMonthLabel(month)}</option>`;
} }
}); });
@ -286,19 +282,18 @@ const initializeMonthDropdown = (selectedYear) => {
} }
monthDropdown.innerHTML = monthOptions; monthDropdown.innerHTML = monthOptions;
console.log(`Month dropdown initialized for year: ${selectedYear}`);
}; };
// Helper function to get readable month labels // Helper function to get readable month labels
const getMonthLabel = (monthValue) => { const getMonthLabel = (monthValue) => {
// Handle known patterns // Handle known patterns
const knownLabels = { const knownLabels = {
'1': 'Május-Június', 1: "Május-Június",
'2': 'Október-November', 2: "Október-November",
'3': 'Harmadik időszak', 3: "Harmadik időszak",
'm1': '1. Mintafeladatsor', m1: "1. Mintafeladatsor",
'm2': '2. Mintafeladatsor', m2: "2. Mintafeladatsor",
'm3': '3. Mintafeladatsor' m3: "3. Mintafeladatsor",
}; };
return knownLabels[monthValue] || monthValue; return knownLabels[monthValue] || monthValue;