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( cors({ origin: process.env.FRONTEND_URL || "*", credentials: true, }), ); app.use(express.json({ limit: "100mb" })); app.use(express.static("public")); // Serve the official Plausible tracker (npm: @plausible-analytics/tracker) from node_modules. const plausibleTrackerPath = require.resolve("@plausible-analytics/tracker/plausible.js"); app.get("/vendor/plausible.js", (req, res) => { res.sendFile(plausibleTrackerPath); }); // 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"); // 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({ destination: (req, file, cb) => { cb(null, PICS_PATH); }, filename: (req, file, cb) => { // 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 }, // 50MB fileFilter: (req, file, cb) => { if (file.mimetype.startsWith("image/")) { cb(null, true); } else { cb(new Error("Only images allowed")); } }, }); // Utility functions const readData = async () => { const data = await fs.readFile(DATA_PATH, "utf8"); return JSON.parse(data); }; const writeData = async (data) => { await fs.writeFile(DATA_PATH, JSON.stringify(data, null, 2)); }; // Public routes 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" }); } }); 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)); res.json(images); } catch (error) { res.status(500).json({ error: "Failed to read images" }); } }); app.use("/api/pics", express.static(PICS_PATH)); // Admin routes (no auth required) 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" }); } }); app.post("/api/admin/questions", async (req, res) => { try { const data = await readData(); const maxId = Math.max(...data.map((q) => q.id), 0); const newQuestion = { id: maxId + 1, ...req.body }; data.push(newQuestion); await writeData(data); res.status(201).json(newQuestion); } catch (error) { res.status(500).json({ error: "Failed to create question" }); } }); 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)); if (index === -1) { return res.status(404).json({ error: "Question not found" }); } data[index] = { ...data[index], ...req.body }; await writeData(data); res.json(data[index]); } catch (error) { res.status(500).json({ error: "Failed to update question" }); } }); 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)); if (index === -1) { return res.status(404).json({ error: "Question not found" }); } data.splice(index, 1); await writeData(data); res.json({ message: "Question deleted" }); } catch (error) { res.status(500).json({ error: "Failed to delete question" }); } }); app.post("/api/admin/images/upload", upload.single("image"), (req, res) => { if (!req.file) { return res.status(400).json({ error: "No image provided" }); } res.json({ filename: req.file.filename, path: `/api/pics/${req.file.filename}`, }); }); 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" }); } catch (error) { if (error.code === "ENOENT") { return res.status(404).json({ error: "Image not found" }); } 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" }); }); app.use((req, res) => { res.status(404).json({ error: "Not found" }); }); // 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 };