Deploy ready

This commit is contained in:
Timo Knuth 2025-10-03 18:50:40 +02:00
parent d64695225c
commit e378ce4a97
1 changed files with 190 additions and 159 deletions

View File

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