import express from "express"; import cors from "cors"; import { fileURLToPath } from "url"; import { promises as fs } from "fs"; import path from "path"; import crypto from "crypto"; import multer from "multer"; const app = express(); const port = process.env.PORT || 3070; const adminToken = process.env.ADMIN_TOKEN || "Driver1"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const dataDir = path.join(__dirname, "data"); const dataPath = path.join(dataDir, "events.json"); const uploadsDir = path.join(__dirname, "uploads"); await fs.mkdir(uploadsDir, { recursive: true }); const storage = multer.diskStorage({ destination: (req, file, cb) => cb(null, uploadsDir), filename: (req, file, cb) => { const ext = path.extname(file.originalname || "") || ".png"; const base = (file.originalname || "upload") .replace(/[^a-z0-9]+/gi, "-") .replace(/^-+|-+$/g, "") .toLowerCase() || "upload"; const unique = `${base}-${Date.now()}${ext}`; cb(null, unique); }, }); const allowedMimeTypes = new Set([ "image/png", "image/jpeg", "image/gif", "image/webp", "image/svg+xml", ]); const upload = multer({ storage, limits: { fileSize: 5 * 1024 * 1024 }, fileFilter: (req, file, cb) => { if (allowedMimeTypes.has(file.mimetype)) { cb(null, true); } else { cb(new Error("Only image uploads are allowed")); } }, }); async function ensureDataFile() { try { await fs.access(dataPath); } catch { await fs.mkdir(dataDir, { recursive: true }); await fs.writeFile(dataPath, "[]", "utf-8"); } } function slugify(text) { return text .toString() .toLowerCase() .trim() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, ""); } async function readEvents() { await ensureDataFile(); const raw = await fs.readFile(dataPath, "utf-8"); try { return JSON.parse(raw); } catch (error) { console.error("Failed to parse events file, resetting to []", error); await fs.writeFile(dataPath, "[]", "utf-8"); return []; } } async function writeEvents(events) { await fs.writeFile(dataPath, JSON.stringify(events, null, 2), "utf-8"); } function requireAuth(req, res, next) { const token = req.header("x-admin-token"); if (!token || token !== adminToken) { return res.status(401).json({ error: "Unauthorized" }); } return next(); } function asyncHandler(fn) { return (req, res, next) => { Promise.resolve(fn(req, res, next)).catch(next); }; } function buildEventPayload(input, base = {}) { const allowed = [ "title", "description", "date", "time", "location", "category", "image", ]; const payload = { ...base }; for (const key of allowed) { if (key in input && input[key] !== undefined) { payload[key] = typeof input[key] === "string" ? input[key].trim() : input[key]; } } return payload; } app.use(cors()); app.use(express.json({ limit: "1mb" })); app.use("/uploads", express.static(uploadsDir)); app.get("/api/health", (req, res) => { res.json({ status: "ok" }); }); app.get( "/api/events", asyncHandler(async (req, res) => { const events = await readEvents(); const sorted = events .slice() .sort((a, b) => new Date(a.date) - new Date(b.date)); res.json(sorted); }) ); app.get( "/api/events/:slug", asyncHandler(async (req, res) => { const events = await readEvents(); const event = events.find((e) => e.slug === req.params.slug); if (!event) { return res.status(404).json({ error: "Event not found" }); } res.json(event); }) ); app.post( "/api/events", requireAuth, asyncHandler(async (req, res) => { const data = buildEventPayload(req.body); if (!data.title || !data.date) { return res.status(400).json({ error: "title and date are required" }); } const events = await readEvents(); const baseSlug = slugify(req.body.slug || data.title || "") || `event-${Date.now()}`; let uniqueSlug = baseSlug; let suffix = 1; while (events.some((event) => event.slug === uniqueSlug)) { uniqueSlug = `${baseSlug}-${suffix++}`; } const now = new Date().toISOString(); const newEvent = { id: crypto.randomUUID(), slug: uniqueSlug, createdAt: now, updatedAt: now, ...data, }; events.push(newEvent); await writeEvents(events); res.status(201).json(newEvent); }) ); app.patch( "/api/events/:slug", requireAuth, asyncHandler(async (req, res) => { const events = await readEvents(); const index = events.findIndex((event) => event.slug === req.params.slug); if (index === -1) { return res.status(404).json({ error: "Event not found" }); } const event = events[index]; const updated = buildEventPayload(req.body, event); let slugToUse = event.slug; if (req.body.slug && req.body.slug !== event.slug) { const requestedSlug = slugify(req.body.slug); let uniqueSlug = requestedSlug || event.slug; let suffix = 1; while (events.some((e, i) => i !== index && e.slug === uniqueSlug)) { uniqueSlug = `${requestedSlug}-${suffix++}`; } slugToUse = uniqueSlug; } const merged = { ...event, ...updated, slug: slugToUse, updatedAt: new Date().toISOString(), }; events[index] = merged; await writeEvents(events); res.json(merged); }) ); app.delete( "/api/events/:slug", requireAuth, asyncHandler(async (req, res) => { const events = await readEvents(); const index = events.findIndex((event) => event.slug === req.params.slug); if (index === -1) { return res.status(404).json({ error: "Event not found" }); } const [removed] = events.splice(index, 1); await writeEvents(events); res.json({ success: true, removed }); }) ); app.post("/api/uploads", requireAuth, upload.single("file"), (req, res) => { if (!req.file) { return res.status(400).json({ error: "No file uploaded" }); } const relativeUrl = `/uploads/${req.file.filename}`; const absoluteUrl = `${req.protocol}://${req.get("host")}${relativeUrl}`; res.status(201).json({ url: absoluteUrl, path: relativeUrl }); }); app.get("/api/admin/verify", requireAuth, (req, res) => { res.json({ ok: true }); }); app.use((err, req, res, next) => { if (err instanceof multer.MulterError) { return res.status(400).json({ error: err.message }); } if (err && err.message === "Only image uploads are allowed") { return res.status(400).json({ error: err.message }); } console.error(err); res.status(500).json({ error: "Internal server error" }); }); app.listen(port, () => { console.log(`Events API listening on port ${port}`); });