Improve security
This commit is contained in:
parent
0fde080ab8
commit
853f88c66b
3 changed files with 275 additions and 228 deletions
|
|
@ -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) =>
|
||||||
|
({
|
||||||
|
"&": "&",
|
||||||
|
"<": "<",
|
||||||
|
">": ">",
|
||||||
|
'"': """,
|
||||||
|
"'": "'",
|
||||||
|
})[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> ${q.type
|
<p><strong>Típus:</strong> ${escapeHtml(q.type)} | <strong>Helyes válasz:</strong> ${
|
||||||
} | <strong>Helyes válasz:</strong> ${["A", "B", "C", "D"][q.correct - 1]
|
["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) {
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,16 @@
|
||||||
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(
|
||||||
|
helmet({
|
||||||
contentSecurityPolicy: {
|
contentSecurityPolicy: {
|
||||||
directives: {
|
directives: {
|
||||||
defaultSrc: [
|
defaultSrc: [
|
||||||
|
|
@ -24,18 +25,34 @@ app.use(helmet({
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}));
|
}),
|
||||||
app.use(cors({
|
);
|
||||||
origin: process.env.FRONTEND_URL || '*',
|
app.use(
|
||||||
credentials: true
|
cors({
|
||||||
}));
|
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.
|
||||||
|
if (require.main === module) {
|
||||||
|
app.listen(PORT, () => {
|
||||||
console.log(`Fizika Admin Backend running on port ${PORT}`);
|
console.log(`Fizika Admin Backend running on port ${PORT}`);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { app, isUnsafeFilename };
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
||||||
const loadQuestions = async (
|
// Fetch the question bank from the backend, which is the single source of
|
||||||
isSearch,
|
// truth. Cached after the first successful load.
|
||||||
categories,
|
const ensureQuestionsLoaded = async () => {
|
||||||
sourceScheme,
|
if (questions !== null) return questions;
|
||||||
questionCount
|
|
||||||
) => {
|
|
||||||
if (questions === null) {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_BASE}/api/fizika`);
|
const response = await fetch(`${API_BASE}/api/fizika`);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||||
}
|
}
|
||||||
questions = await response.json();
|
questions = await response.json();
|
||||||
console.log('Questions loaded from backend API');
|
return questions;
|
||||||
} catch (error) {
|
};
|
||||||
console.warn('Failed to load questions from API, falling back to local file:', error);
|
|
||||||
try {
|
const escapeHtml = (value) =>
|
||||||
const fallbackResponse = await fetch("fizika.json");
|
String(value).replace(
|
||||||
if (!fallbackResponse.ok) {
|
/[&<>"']/g,
|
||||||
throw new Error(`Local file not available: ${fallbackResponse.status}`);
|
(ch) =>
|
||||||
}
|
({
|
||||||
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');
|
"'": "'",
|
||||||
}
|
})[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(/<(\/?)(sub|sup)>/g, "<$1$2>")
|
||||||
|
.replace(/<br\s*\/?>/g, "<br>")
|
||||||
|
.replace(/&(lt|gt|amp|quot|#\d+|#x[0-9a-fA-F]+);/g, "&$1;");
|
||||||
|
|
||||||
|
const loadQuestions = async (
|
||||||
|
isSearch,
|
||||||
|
categories,
|
||||||
|
sourceScheme,
|
||||||
|
questionCount,
|
||||||
|
) => {
|
||||||
|
await ensureQuestionsLoaded();
|
||||||
|
|
||||||
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,25 +77,29 @@ 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>`
|
||||||
: ""
|
: ""
|
||||||
}
|
}
|
||||||
|
|
@ -92,17 +109,17 @@ const loadQuestions = async (
|
||||||
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;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue