// Merz-hater Club Friendbook — backend server // Plain Express + Multer + a JSON-file "database". // No native modules, no external services — designed to be easy to self-host. const express = require("express"); const multer = require("multer"); const path = require("path"); const fs = require("fs"); const crypto = require("crypto"); const PORT = process.env.PORT || 3030; // Entries now require manual approval before they show up in the public // gallery. The moderation endpoints below are protected by this password. const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || "changeme"; if (!process.env.ADMIN_PASSWORD) { console.warn( '⚠️ ADMIN_PASSWORD is not set — using the default "changeme". ' + "Set ADMIN_PASSWORD in your environment before deploying anywhere public." ); } const DATA_DIR = path.join(__dirname, "data"); const UPLOADS_DIR = path.join(__dirname, "uploads"); const DB_FILE = path.join(DATA_DIR, "entries.json"); // ---- make sure our storage folders/files exist ---- for (const dir of [DATA_DIR, UPLOADS_DIR]) { if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); } if (!fs.existsSync(DB_FILE)) { fs.writeFileSync(DB_FILE, "[]", "utf8"); } // ---- tiny write queue so concurrent submissions can't corrupt the JSON file ---- let writeChain = Promise.resolve(); function readEntries() { const raw = fs.readFileSync(DB_FILE, "utf8"); let entries; try { entries = JSON.parse(raw || "[]"); } catch (err) { console.error("entries.json was corrupted, starting fresh:", err); return []; } // Entries saved before moderation existed have no `status`. Treat them // as already-approved so upgrading doesn't hide pages that were live. return entries.map((e) => (e.status ? e : { ...e, status: "approved" })); } function saveEntries(entries) { writeChain = writeChain.then( () => new Promise((resolve, reject) => { fs.writeFile(DB_FILE, JSON.stringify(entries, null, 2), "utf8", (err) => { if (err) return reject(err); resolve(); }); }) ); return writeChain; } // ---- image upload handling ---- const ALLOWED_MIME = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]); const MAX_FILE_BYTES = 3 * 1024 * 1024; // 3MB per badge image const MAX_BADGES = 5; const storage = multer.diskStorage({ destination: (req, file, cb) => cb(null, UPLOADS_DIR), filename: (req, file, cb) => { const ext = path.extname(file.originalname).toLowerCase(); const safeExt = [".png", ".jpg", ".jpeg", ".gif", ".webp"].includes(ext) ? ext : ""; cb(null, `${Date.now()}-${crypto.randomBytes(6).toString("hex")}${safeExt}`); }, }); const upload = multer({ storage, limits: { fileSize: MAX_FILE_BYTES, files: MAX_BADGES }, fileFilter: (req, file, cb) => { if (!ALLOWED_MIME.has(file.mimetype)) { return cb(new Error("Only PNG, JPG, GIF, or WEBP images are allowed for badges.")); } cb(null, true); }, }); // ---- helpers ---- function escapeAndTrim(value, maxLen) { if (typeof value !== "string") return ""; return value.trim().slice(0, maxLen); } const ALLOWED_MOODS = ["😎", "🔥", "💀", "😭", "🥲", "✨", "😤", "🖤", "🫠", "👾"]; // ---- admin auth ---- function passwordsMatch(supplied, expected) { const suppliedBuf = Buffer.from(String(supplied || "")); const expectedBuf = Buffer.from(String(expected || "")); if (suppliedBuf.length !== expectedBuf.length) { // still run a compare of equal length so wrong-length guesses don't // return noticeably faster than right-length ones. crypto.timingSafeEqual(expectedBuf, expectedBuf); return false; } return crypto.timingSafeEqual(suppliedBuf, expectedBuf); } function requireAdmin(req, res, next) { const supplied = req.get("x-admin-password"); if (!passwordsMatch(supplied, ADMIN_PASSWORD)) { return res.status(401).json({ error: "Incorrect admin password." }); } next(); } // ---- app ---- const app = express(); app.use(express.static(path.join(__dirname, "public"))); app.use("/uploads", express.static(UPLOADS_DIR)); // List every APPROVED entry, newest first. Pending/denied entries never // show up here — only in the admin endpoints below. app.get("/api/entries", (req, res) => { const entries = readEntries() .filter((e) => e.status === "approved") .sort((a, b) => b.createdAt - a.createdAt); res.json(entries); }); // Create a new entry (multipart/form-data because of the file uploads) app.post("/api/entries", (req, res) => { upload.array("badges", MAX_BADGES)(req, res, async (err) => { if (err) { return res.status(400).json({ error: err.message || "Upload failed." }); } const body = req.body || {}; const name = escapeAndTrim(body.name, 60); if (!name) { return res.status(400).json({ error: "Your Name is required." }); } const mood = ALLOWED_MOODS.includes(body.mood) ? body.mood : "🖤"; const entry = { id: crypto.randomUUID(), name, funFact: escapeAndTrim(body.funFact, 500), favColor: escapeAndTrim(body.favColor, 40), petPeeves: escapeAndTrim(body.petPeeves, 300), favGenre: escapeAndTrim(body.favGenre, 100), merzHate: escapeAndTrim(body.merzHate, 400), socialLink: escapeAndTrim(body.socialLink, 300), mood, badges: (req.files || []).map((f) => `/uploads/${f.filename}`), status: "pending", createdAt: Date.now(), }; const entries = readEntries(); entries.push(entry); try { await saveEntries(entries); } catch (e) { console.error(e); return res.status(500).json({ error: "Could not save your page. Try again." }); } res.status(201).json(entry); }); }); // ---- admin/moderation routes ---- // Just checks the password — used by the admin login form. app.post("/api/admin/login", requireAdmin, (req, res) => { res.json({ ok: true }); }); // Every entry (pending + approved), newest first. app.get("/api/admin/entries", requireAdmin, (req, res) => { const entries = readEntries().sort((a, b) => b.createdAt - a.createdAt); res.json(entries); }); // Approve a pending entry so it shows up in the public gallery. app.post("/api/admin/entries/:id/approve", requireAdmin, async (req, res) => { const entries = readEntries(); const entry = entries.find((e) => e.id === req.params.id); if (!entry) { return res.status(404).json({ error: "Entry not found." }); } entry.status = "approved"; try { await saveEntries(entries); } catch (e) { console.error(e); return res.status(500).json({ error: "Could not save that change. Try again." }); } res.json(entry); }); // Deny (or remove) an entry entirely, cleaning up any uploaded badge images. app.post("/api/admin/entries/:id/deny", requireAdmin, async (req, res) => { const entries = readEntries(); const idx = entries.findIndex((e) => e.id === req.params.id); if (idx === -1) { return res.status(404).json({ error: "Entry not found." }); } const [removed] = entries.splice(idx, 1); for (const badgePath of removed.badges || []) { const filePath = path.join(UPLOADS_DIR, path.basename(badgePath)); fs.unlink(filePath, (err) => { if (err && err.code !== "ENOENT") console.error("Could not remove badge file:", err); }); } try { await saveEntries(entries); } catch (e) { console.error(e); return res.status(500).json({ error: "Could not save that change. Try again." }); } res.json({ ok: true }); }); // Convenience alias so /admin works without typing the filename. app.get("/admin", (req, res) => { res.sendFile(path.join(__dirname, "public", "admin.html")); }); // Fallback: send index.html for the root route app.get("/", (req, res) => { res.sendFile(path.join(__dirname, "public", "index.html")); }); app.listen(PORT, () => { console.log(`Merz-hater Club Friendbook running at http://localhost:${PORT}`); });