Hi, I'm using whatsapp.
This commit is contained in:
commit
be01fa5299
12 files changed
+2462
No files matched your search
@@ -0,0 +1,243 @@
|
||||
// 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}`);
|
||||
});
|
||||
Reference in new issue
Block a user