244 lines
7.8 KiB
JavaScript
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}`);
|
|
});
|