/** * Keep In Touch — subscribe capture service. * * A tiny dependency-light HTTP service (Node core http + better-sqlite3) that * receives JSON submissions from the docs site "Keep In Touch" widget and * upserts them into a SQLite database. Designed to run in a single container * on Coolify with a persistent volume, mirroring the Cusdis deployment. * * Endpoints: * POST /api/subscribe capture/upsert a subscriber -> { ok: true } * GET /api/health liveness probe -> { ok: true } * GET /api/subscribers list (requires ADMIN_TOKEN) -> [ ... ] * * A submission is keyed by whatsapp OR email — whichever is present. As the * widget collects more fields across submissions (email after whatsapp, then * name), each POST sends the full known set, and we merge into the existing * row rather than creating duplicates. * * Environment: * PORT listen port (default 8080) * DB_PATH SQLite file path (default ./data/subscribers.db) * ALLOW_ORIGIN CORS allow-origin (default "*"; set to your site origin) * ADMIN_TOKEN if set, required as `Authorization: Bearer ` for * GET /api/subscribers */ import http from "node:http"; import { dirname } from "node:path"; import { mkdirSync } from "node:fs"; import Database from "better-sqlite3"; const PORT = parseInt(process.env.PORT || "8080", 10); const DB_PATH = process.env.DB_PATH || "./data/subscribers.db"; const ALLOW_ORIGIN = process.env.ALLOW_ORIGIN || "*"; const ADMIN_TOKEN = process.env.ADMIN_TOKEN || ""; const MAX_BODY = 16 * 1024; // 16 KB cap — these payloads are tiny. // ── Database ──────────────────────────────────────────────────────────── mkdirSync(dirname(DB_PATH), { recursive: true }); const db = new Database(DB_PATH); db.pragma("journal_mode = WAL"); db.pragma("busy_timeout = 5000"); db.exec(` CREATE TABLE IF NOT EXISTS subscribers ( id INTEGER PRIMARY KEY AUTOINCREMENT, whatsapp TEXT, email TEXT, name TEXT, first_page TEXT, first_url TEXT, user_agent TEXT, ip TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE UNIQUE INDEX IF NOT EXISTS idx_subscribers_email ON subscribers(email) WHERE email IS NOT NULL AND email <> ''; CREATE UNIQUE INDEX IF NOT EXISTS idx_subscribers_whatsapp ON subscribers(whatsapp) WHERE whatsapp IS NOT NULL AND whatsapp <> ''; `); const findByEmail = db.prepare( "SELECT * FROM subscribers WHERE email = ? LIMIT 1" ); const findByWhatsApp = db.prepare( "SELECT * FROM subscribers WHERE whatsapp = ? LIMIT 1" ); const insertRow = db.prepare(` INSERT INTO subscribers (whatsapp, email, name, first_page, first_url, user_agent, ip) VALUES (@whatsapp, @email, @name, @first_page, @first_url, @user_agent, @ip) `); const updateRow = db.prepare(` UPDATE subscribers SET whatsapp = COALESCE(NULLIF(@whatsapp, ''), whatsapp), email = COALESCE(NULLIF(@email, ''), email), name = COALESCE(NULLIF(@name, ''), name), updated_at = datetime('now') WHERE id = @id `); const listRows = db.prepare( "SELECT * FROM subscribers ORDER BY updated_at DESC LIMIT 1000" ); // ── Validation / normalization ────────────────────────────────────────── function normEmail(v) { return typeof v === "string" ? v.trim().toLowerCase() : ""; } function normWhatsApp(v) { if (typeof v !== "string") return ""; const cleaned = v.trim().replace(/[^\d+]/g, ""); const digits = cleaned.replace(/[^\d]/g, ""); if (digits.length < 7 || digits.length > 15) return ""; return cleaned; } function isValidEmail(v) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v); } function clean(v, max = 200) { return typeof v === "string" ? v.trim().slice(0, max) : ""; } // Upsert: find an existing row by email or whatsapp, merge; else insert. const upsert = db.transaction((rec) => { let existing = null; if (rec.email) existing = findByEmail.get(rec.email); if (!existing && rec.whatsapp) existing = findByWhatsApp.get(rec.whatsapp); if (existing) { updateRow.run({ id: existing.id, whatsapp: rec.whatsapp, email: rec.email, name: rec.name, }); return { id: existing.id, created: false }; } const info = insertRow.run(rec); return { id: info.lastInsertRowid, created: true }; }); // ── HTTP helpers ────────────────────────────────────────────────────────── function cors(res) { res.setHeader("Access-Control-Allow-Origin", ALLOW_ORIGIN); res.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS"); res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization"); res.setHeader("Vary", "Origin"); } function json(res, status, obj) { cors(res); res.writeHead(status, { "Content-Type": "application/json" }); res.end(JSON.stringify(obj)); } function clientIp(req) { const xff = req.headers["x-forwarded-for"]; if (typeof xff === "string" && xff.length) return xff.split(",")[0].trim(); return req.socket.remoteAddress || ""; } function readBody(req) { return new Promise((resolve, reject) => { let size = 0; const chunks = []; req.on("data", (c) => { size += c.length; if (size > MAX_BODY) { reject(new Error("payload too large")); req.destroy(); return; } chunks.push(c); }); req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); req.on("error", reject); }); } // ── Server ───────────────────────────────────────────────────────────── const server = http.createServer(async (req, res) => { const url = new URL(req.url, `http://${req.headers.host}`); if (req.method === "OPTIONS") { cors(res); res.writeHead(204); res.end(); return; } if (req.method === "GET" && url.pathname === "/api/health") { return json(res, 200, { ok: true }); } if (req.method === "GET" && url.pathname === "/api/subscribers") { if (ADMIN_TOKEN) { const auth = req.headers["authorization"] || ""; if (auth !== `Bearer ${ADMIN_TOKEN}`) { return json(res, 401, { ok: false, error: "unauthorized" }); } } else { // No admin token configured — refuse rather than leak data. return json(res, 403, { ok: false, error: "admin token not configured" }); } return json(res, 200, { ok: true, subscribers: listRows.all() }); } if (req.method === "POST" && url.pathname === "/api/subscribe") { let raw; try { raw = await readBody(req); } catch (e) { return json(res, 413, { ok: false, error: "payload too large" }); } let data; try { data = JSON.parse(raw || "{}"); } catch (e) { return json(res, 400, { ok: false, error: "invalid JSON" }); } const email = normEmail(data.email); const whatsapp = normWhatsApp(data.whatsapp); if (email && !isValidEmail(email)) { return json(res, 422, { ok: false, error: "invalid email" }); } if (!email && !whatsapp) { return json(res, 422, { ok: false, error: "provide a valid email or whatsapp number", }); } const rec = { whatsapp, email, name: clean(data.name, 120), first_page: clean(data.page, 300), first_url: clean(data.url, 500), user_agent: clean(req.headers["user-agent"], 400), ip: clientIp(req), }; try { const result = upsert(rec); return json(res, 200, { ok: true, id: result.id, created: result.created }); } catch (e) { console.error("upsert failed:", e); return json(res, 500, { ok: false, error: "server error" }); } } json(res, 404, { ok: false, error: "not found" }); }); server.listen(PORT, () => { console.log(`[subscribe] listening on :${PORT} (db: ${DB_PATH})`); }); // Graceful shutdown so SQLite WAL checkpoints cleanly. function shutdown() { server.close(() => { try { db.close(); } catch {} process.exit(0); }); } process.on("SIGTERM", shutdown); process.on("SIGINT", shutdown);