Files
feedc0de edacc21bea
Publish and deploy birthday invitation / Check JavaScript (push) Successful in 10s
Publish and deploy birthday invitation / Build and push (push) Successful in 34s
Publish and deploy birthday invitation / Deploy to Kubernetes (push) Failing after 12s
import existing sources & deployment
2026-09-08 18:30:41 +02:00

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.");
});