diff --git a/docs/practices/daily-sadhana.md b/docs/practices/daily-sadhana.md index 2be75a1..0bafe5f 100644 --- a/docs/practices/daily-sadhana.md +++ b/docs/practices/daily-sadhana.md @@ -11,7 +11,7 @@ icon: material/meditation Drinking water on waking helps re-hydrate, as the body hasn't drunk anything for several hours. Warm water, or at least room temperature (not cold) is best. -Juice of one lemon or lime, 2/3 fingers of water, half spoon of bicarbonate of soda/baking soda (food grade, aluminium free) mixed in tall glass, first thing in the morning, to regulate any excess acidity and re-hydrate +Juice of one lemon or lime, 2 or 3 fingers of water, half spoon of bicarbonate of soda/baking soda (food grade, aluminium free) mixed in a tall glass, first thing in the morning helps regulate any excess acidity and re-hydrate. ## Meditation diff --git a/subscribe-service/.dockerignore b/subscribe-service/.dockerignore new file mode 100644 index 0000000..b2b0697 --- /dev/null +++ b/subscribe-service/.dockerignore @@ -0,0 +1,4 @@ +node_modules +data +.git +*.md diff --git a/subscribe-service/Dockerfile b/subscribe-service/Dockerfile new file mode 100644 index 0000000..beed8f3 --- /dev/null +++ b/subscribe-service/Dockerfile @@ -0,0 +1,33 @@ +# ── Subscribe Service ──────────────────────────────────────────────────── +# Keep In Touch — subscribe capture endpoint for Tree of Ascension docs. +# +# Deploy on Coolify: +# 1. Point the service to this repo (or a subfolder if supported) +# 2. Set Build Pack: Dockerfile +# 3. Add a persistent volume at /app/data +# 4. Set env vars (PORT, ALLOW_ORIGIN, ADMIN_TOKEN) +# 5. Map your domain (e.g. subscribe.krystl.org) + +FROM node:22-alpine + +# Create unprivileged user +RUN addgroup -S app && adduser -S -G app app + +WORKDIR /app + +# Dependencies first for layer caching +COPY package.json ./ +RUN npm install --omit=dev && npm cache clean --force + +# Application +COPY server.js ./ + +# Persistent data volume + ownership +RUN mkdir -p /app/data && chown -R app:app /app + +USER app + +EXPOSE 8080 +ENV PORT=8080 + +CMD ["node", "server.js"] diff --git a/subscribe-service/package.json b/subscribe-service/package.json new file mode 100644 index 0000000..fdb243f --- /dev/null +++ b/subscribe-service/package.json @@ -0,0 +1,16 @@ +{ + "name": "toa-subscribe-service", + "version": "1.0.0", + "description": "Keep In Touch — subscribe capture endpoint for Tree of Ascension docs. Captures WhatsApp / email / name into SQLite.", + "type": "module", + "main": "server.js", + "scripts": { + "start": "node server.js" + }, + "engines": { + "node": ">=20" + }, + "dependencies": { + "better-sqlite3": "^11.8.1" + } +} diff --git a/subscribe-service/server.js b/subscribe-service/server.js new file mode 100644 index 0000000..a4bf91a --- /dev/null +++ b/subscribe-service/server.js @@ -0,0 +1,248 @@ +/** + * 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);