272 lines
11 KiB
JavaScript
272 lines
11 KiB
JavaScript
const http = require("node:http");
|
|
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
const crypto = require("node:crypto");
|
|
const { createInvites, getInvite, listInvites, recordOpen, saveRsvp } = require("./database");
|
|
|
|
const PORT = Number(process.env.PORT || 3000);
|
|
const HOST = process.env.HOST || "0.0.0.0";
|
|
const ADMIN_KEY = process.env.ADMIN_KEY || "";
|
|
const TRUST_AUTHENTIK = process.env.TRUST_AUTHENTIK === "true";
|
|
const ORIGIN = (process.env.ORIGIN || `http://localhost:${PORT}`).replace(/\/$/, "");
|
|
const PUBLIC_DIR = path.join(__dirname, "public");
|
|
const INVITATION_PDF = process.env.INVITATION_PDF || path.join(__dirname, "Einladung.pdf");
|
|
const INVITATION_IMAGE = process.env.INVITATION_IMAGE || path.join(__dirname, "private-assets", "einladung.jpg");
|
|
|
|
const MIME_TYPES = {
|
|
".html": "text/html; charset=utf-8",
|
|
".css": "text/css; charset=utf-8",
|
|
".js": "text/javascript; charset=utf-8",
|
|
".json": "application/json; charset=utf-8",
|
|
".png": "image/png",
|
|
".pdf": "application/pdf",
|
|
".ico": "image/x-icon",
|
|
".svg": "image/svg+xml"
|
|
};
|
|
|
|
function sendJson(response, status, payload) {
|
|
const body = JSON.stringify(payload);
|
|
response.writeHead(status, {
|
|
"Content-Type": "application/json; charset=utf-8",
|
|
"Content-Length": Buffer.byteLength(body),
|
|
"Cache-Control": "no-store",
|
|
"X-Content-Type-Options": "nosniff"
|
|
});
|
|
response.end(body);
|
|
}
|
|
|
|
function readJson(request) {
|
|
return new Promise((resolve, reject) => {
|
|
let body = "";
|
|
request.on("data", chunk => {
|
|
body += chunk;
|
|
if (body.length > 16_384) request.destroy();
|
|
});
|
|
request.on("end", () => {
|
|
try {
|
|
resolve(body ? JSON.parse(body) : {});
|
|
} catch {
|
|
reject(new Error("Ungültige Anfrage."));
|
|
}
|
|
});
|
|
request.on("error", reject);
|
|
});
|
|
}
|
|
|
|
function isAdmin(request, url) {
|
|
if (TRUST_AUTHENTIK && request.headers["x-authentik-username"]) return true;
|
|
if (!ADMIN_KEY) return false;
|
|
const bearer = request.headers.authorization?.replace(/^Bearer\s+/i, "");
|
|
const supplied = bearer || request.headers["x-admin-key"] || url.searchParams.get("key");
|
|
if (!supplied) return false;
|
|
const expectedBuffer = Buffer.from(ADMIN_KEY);
|
|
const suppliedBuffer = Buffer.from(String(supplied));
|
|
return expectedBuffer.length === suppliedBuffer.length && crypto.timingSafeEqual(expectedBuffer, suppliedBuffer);
|
|
}
|
|
|
|
function createToken() {
|
|
return crypto.randomBytes(18).toString("base64url");
|
|
}
|
|
|
|
function safeInvite(invite) {
|
|
return {
|
|
guestName: invite.guestName || "",
|
|
answer: invite.answer || null,
|
|
companionName: invite.companionName || "",
|
|
respondedAt: invite.respondedAt || null
|
|
};
|
|
}
|
|
|
|
function adminInvite(invite) {
|
|
return {
|
|
guestName: invite.guestName || "",
|
|
token: invite.token,
|
|
link: `${ORIGIN}/i/${invite.token}`,
|
|
answer: invite.answer || null,
|
|
companionName: invite.companionName || "",
|
|
createdAt: invite.createdAt || null,
|
|
firstOpenedAt: invite.firstOpenedAt || null,
|
|
lastOpenedAt: invite.lastOpenedAt || null,
|
|
openCount: Number(invite.openCount || 0),
|
|
lastSavedAt: invite.respondedAt || null
|
|
};
|
|
}
|
|
|
|
function csvCell(value) {
|
|
const text = String(value ?? "");
|
|
return `"${text.replaceAll('"', '""')}"`;
|
|
}
|
|
|
|
function serveFile(response, requestedPath) {
|
|
const relative = requestedPath === "/" ? "index.html" : requestedPath.replace(/^\/+/, "");
|
|
const filePath = path.resolve(PUBLIC_DIR, relative);
|
|
if (!filePath.startsWith(`${PUBLIC_DIR}${path.sep}`)) {
|
|
sendJson(response, 403, { error: "Nicht erlaubt." });
|
|
return;
|
|
}
|
|
|
|
fs.stat(filePath, (error, stats) => {
|
|
if (error || !stats.isFile()) {
|
|
sendJson(response, 404, { error: "Nicht gefunden." });
|
|
return;
|
|
}
|
|
const extension = path.extname(filePath).toLowerCase();
|
|
const headers = {
|
|
"Content-Type": MIME_TYPES[extension] || "application/octet-stream",
|
|
"X-Content-Type-Options": "nosniff",
|
|
"Cache-Control": extension === ".html" ? "no-cache" : "public, max-age=86400"
|
|
};
|
|
if (extension === ".pdf") {
|
|
headers["Content-Disposition"] = 'inline; filename="Einladung-Vicky-und-Daniel.pdf"';
|
|
}
|
|
response.writeHead(200, headers);
|
|
fs.createReadStream(filePath).pipe(response);
|
|
});
|
|
}
|
|
|
|
function servePrivateAsset(response, filePath, contentType, disposition) {
|
|
fs.stat(filePath, (error, stats) => {
|
|
if (error || !stats.isFile()) {
|
|
sendJson(response, 503, { error: "Die Einladung ist momentan nicht verfügbar." });
|
|
return;
|
|
}
|
|
response.writeHead(200, {
|
|
"Content-Type": contentType,
|
|
"Content-Length": stats.size,
|
|
"Content-Disposition": disposition,
|
|
"Cache-Control": "private, max-age=3600",
|
|
"Referrer-Policy": "no-referrer",
|
|
"X-Content-Type-Options": "nosniff"
|
|
});
|
|
fs.createReadStream(filePath).pipe(response);
|
|
});
|
|
}
|
|
|
|
const server = http.createServer(async (request, response) => {
|
|
const url = new URL(request.url, `http://${request.headers.host || "localhost"}`);
|
|
const route = decodeURIComponent(url.pathname);
|
|
|
|
try {
|
|
if (request.method === "GET" && route === "/health") {
|
|
return sendJson(response, 200, { ok: true });
|
|
}
|
|
|
|
const assetMatch = route.match(/^\/api\/invites\/([A-Za-z0-9_-]+)\/assets\/(image|pdf)$/);
|
|
if (request.method === "GET" && assetMatch) {
|
|
if (!getInvite(assetMatch[1])) {
|
|
return sendJson(response, 404, { error: "Dieser Einladungslink ist leider nicht gültig." });
|
|
}
|
|
if (assetMatch[2] === "pdf") {
|
|
return servePrivateAsset(response, INVITATION_PDF, "application/pdf", 'inline; filename="Einladung-Vicky-und-Daniel.pdf"');
|
|
}
|
|
return servePrivateAsset(response, INVITATION_IMAGE, "image/jpeg", 'inline; filename="einladung.jpg"');
|
|
}
|
|
|
|
const inviteMatch = route.match(/^\/api\/invites\/([A-Za-z0-9_-]+)$/);
|
|
if (request.method === "GET" && inviteMatch) {
|
|
const invite = recordOpen(inviteMatch[1], new Date().toISOString());
|
|
if (!invite) return sendJson(response, 404, { error: "Dieser Einladungslink ist leider nicht gültig." });
|
|
return sendJson(response, 200, safeInvite(invite));
|
|
}
|
|
|
|
const rsvpMatch = route.match(/^\/api\/invites\/([A-Za-z0-9_-]+)\/rsvp$/);
|
|
if (request.method === "POST" && rsvpMatch) {
|
|
const payload = await readJson(request);
|
|
const validAnswers = new Set(["not_coming", "alone", "with_someone"]);
|
|
if (!validAnswers.has(payload.answer)) {
|
|
return sendJson(response, 400, { error: "Bitte wähle eine Antwort aus." });
|
|
}
|
|
|
|
const companionName = String(payload.companionName || "").trim().slice(0, 100);
|
|
if (payload.answer === "with_someone" && !companionName) {
|
|
return sendJson(response, 400, { error: "Bitte verrate uns den Namen deiner Begleitung." });
|
|
}
|
|
|
|
const invite = saveRsvp(
|
|
rsvpMatch[1],
|
|
payload.answer,
|
|
payload.answer === "with_someone" ? companionName : "",
|
|
new Date().toISOString()
|
|
);
|
|
if (!invite) {
|
|
return sendJson(response, 404, { error: "Dieser Einladungslink ist leider nicht gültig." });
|
|
}
|
|
return sendJson(response, 200, { ok: true, ...safeInvite(invite) });
|
|
}
|
|
|
|
if (request.method === "POST" && route === "/api/admin/invites") {
|
|
if (!isAdmin(request, url)) return sendJson(response, 401, { error: "Nicht autorisiert." });
|
|
const payload = await readJson(request);
|
|
const names = (Array.isArray(payload.names) ? payload.names : [payload.name || ""])
|
|
.map(name => String(name || "").trim())
|
|
.filter(Boolean);
|
|
if (!names.length) return sendJson(response, 400, { error: "Bitte gib mindestens einen Namen an." });
|
|
if (names.length > 200) return sendJson(response, 400, { error: "Maximal 200 Einladungen auf einmal." });
|
|
const created = createInvites(names.map(name => ({ token: createToken(), guestName: name.slice(0, 100) })))
|
|
.map(invite => ({ name: invite.guestName, token: invite.token, link: `${ORIGIN}/i/${invite.token}` }));
|
|
return sendJson(response, 201, { invites: created });
|
|
}
|
|
|
|
if (request.method === "GET" && route === "/api/admin/invites") {
|
|
if (!isAdmin(request, url)) return sendJson(response, 401, { error: "Nicht autorisiert." });
|
|
const invites = listInvites().map(adminInvite);
|
|
const summary = invites.reduce((totals, invite) => {
|
|
totals.total += 1;
|
|
if (invite.firstOpenedAt) totals.opened += 1;
|
|
if (invite.answer) totals.responded += 1;
|
|
if (invite.answer === "not_coming") totals.notComing += 1;
|
|
if (invite.answer === "alone") totals.attending += 1;
|
|
if (invite.answer === "with_someone") totals.attending += 2;
|
|
return totals;
|
|
}, { total: 0, opened: 0, responded: 0, attending: 0, notComing: 0 });
|
|
return sendJson(response, 200, { summary, invites });
|
|
}
|
|
|
|
if (request.method === "GET" && route === "/api/admin/responses.csv") {
|
|
if (!isAdmin(request, url)) return sendJson(response, 401, { error: "Nicht autorisiert." });
|
|
const labels = { not_coming: "Kommt nicht", alone: "Kommt alleine", with_someone: "Kommt mit Begleitung" };
|
|
const rows = [["Gast", "Antwort", "Begleitung", "Erstmals geöffnet", "Zuletzt geöffnet", "Öffnungen", "Zuletzt gespeichert", "Erstellt", "Link"]];
|
|
for (const invite of listInvites()) {
|
|
rows.push([
|
|
invite.guestName,
|
|
labels[invite.answer] || "Noch offen",
|
|
invite.companionName,
|
|
invite.firstOpenedAt,
|
|
invite.lastOpenedAt,
|
|
invite.openCount || 0,
|
|
invite.respondedAt,
|
|
invite.createdAt,
|
|
`${ORIGIN}/i/${invite.token}`
|
|
]);
|
|
}
|
|
const csv = `\uFEFF${rows.map(row => row.map(csvCell).join(";")).join("\n")}`;
|
|
response.writeHead(200, {
|
|
"Content-Type": "text/csv; charset=utf-8",
|
|
"Content-Disposition": 'attachment; filename="zusagen.csv"',
|
|
"Content-Length": Buffer.byteLength(csv),
|
|
"Cache-Control": "no-store"
|
|
});
|
|
return response.end(csv);
|
|
}
|
|
|
|
if (request.method === "GET" && /^\/i\/[A-Za-z0-9_-]+\/?$/.test(route)) {
|
|
return serveFile(response, "/index.html");
|
|
}
|
|
|
|
if (request.method === "GET" && /^\/admin\/?$/.test(route)) {
|
|
return serveFile(response, "/admin.html");
|
|
}
|
|
|
|
if (request.method === "GET") return serveFile(response, route);
|
|
return sendJson(response, 405, { error: "Methode nicht erlaubt." });
|
|
} catch (error) {
|
|
console.error(error);
|
|
return sendJson(response, 500, { error: "Da ist etwas schiefgegangen. Bitte versuche es noch einmal." });
|
|
}
|
|
});
|
|
|
|
server.listen(PORT, HOST, () => {
|
|
console.log(`Invitation server listening on ${ORIGIN}`);
|
|
if (!ADMIN_KEY && !TRUST_AUTHENTIK) console.warn("No admin authentication is configured; admin endpoints are disabled.");
|
|
});
|