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 VALID_ANSWERS = new Set(["not_coming", "alone", "with_someone"]); 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 normalizeRsvp(payload) { if (!VALID_ANSWERS.has(payload.answer)) { return { error: "Bitte wähle eine Antwort aus." }; } const companionName = String(payload.companionName || "").trim().slice(0, 100); if (payload.answer === "with_someone" && !companionName) { return { error: "Bitte verrate uns den Namen der Begleitung." }; } return { answer: payload.answer, companionName: payload.answer === "with_someone" ? companionName : "" }; } 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 rsvp = normalizeRsvp(payload); if (rsvp.error) return sendJson(response, 400, { error: rsvp.error }); const invite = saveRsvp( rsvpMatch[1], rsvp.answer, rsvp.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 }); } const adminRsvpMatch = route.match(/^\/api\/admin\/invites\/([A-Za-z0-9_-]+)\/rsvp$/); if (request.method === "PATCH" && adminRsvpMatch) { if (!isAdmin(request, url)) return sendJson(response, 401, { error: "Nicht autorisiert." }); const payload = await readJson(request); const rsvp = normalizeRsvp(payload); if (rsvp.error) return sendJson(response, 400, { error: rsvp.error }); const invite = saveRsvp( adminRsvpMatch[1], rsvp.answer, rsvp.companionName, new Date().toISOString() ); if (!invite) return sendJson(response, 404, { error: "Einladung nicht gefunden." }); return sendJson(response, 200, { ok: true, invite: adminInvite(invite) }); } 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." }); } }); if (require.main === module) { 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."); }); } module.exports = { server };