This commit is contained in:
parent
1de4aa798c
commit
09ba589a00
5 changed files with 302 additions and 1 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
4
subscribe-service/.dockerignore
Normal file
4
subscribe-service/.dockerignore
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
node_modules
|
||||
data
|
||||
.git
|
||||
*.md
|
||||
33
subscribe-service/Dockerfile
Normal file
33
subscribe-service/Dockerfile
Normal file
|
|
@ -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"]
|
||||
16
subscribe-service/package.json
Normal file
16
subscribe-service/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
248
subscribe-service/server.js
Normal file
248
subscribe-service/server.js
Normal file
|
|
@ -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 <token>` 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);
|
||||
Loading…
Reference in a new issue