Files
2026-10-18 19:47:56 +02:00

244 lines
7.8 KiB
JavaScript

// 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}`);
});