Initial MaalFlows live chat scaffold
This commit is contained in:
@@ -0,0 +1,666 @@
|
||||
const http = require("http");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const crypto = require("crypto");
|
||||
const Database = require("better-sqlite3");
|
||||
|
||||
const ROOT = __dirname;
|
||||
const DATA_DIR = path.join(ROOT, "data");
|
||||
const PUBLIC_DIR = path.join(ROOT, "public");
|
||||
const UPLOAD_DIR = path.join(DATA_DIR, "uploads");
|
||||
const DB_PATH = path.join(DATA_DIR, "maalflows.sqlite");
|
||||
|
||||
loadDotEnv(path.join(ROOT, ".env"));
|
||||
|
||||
const PORT = Number(process.env.PORT || 3400);
|
||||
const PUBLIC_BASE_URL = (process.env.PUBLIC_BASE_URL || `http://localhost:${PORT}`).replace(/\/$/, "");
|
||||
const AUTH_SECRET = process.env.AUTH_SECRET || "dev-secret-change-me";
|
||||
const COOKIE_NAME = "mf_session";
|
||||
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||
|
||||
const db = new Database(DB_PATH);
|
||||
db.pragma("journal_mode = WAL");
|
||||
db.pragma("foreign_keys = ON");
|
||||
initDb();
|
||||
seedDefaults();
|
||||
|
||||
const streams = new Map();
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
await route(req, res);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
json(res, 500, { error: "internal_error" });
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`MaalFlows running on http://localhost:${PORT}`);
|
||||
});
|
||||
|
||||
async function route(req, res) {
|
||||
const url = new URL(req.url, PUBLIC_BASE_URL);
|
||||
setCommonHeaders(res);
|
||||
|
||||
if (req.method === "OPTIONS") return endCors(res);
|
||||
if (req.method === "GET" && url.pathname === "/health") return json(res, 200, { ok: true });
|
||||
if (req.method === "GET" && url.pathname === "/admin") return file(res, path.join(PUBLIC_DIR, "admin.html"), "text/html; charset=utf-8");
|
||||
if (req.method === "GET" && url.pathname === "/preview") return file(res, path.join(PUBLIC_DIR, "preview.html"), "text/html; charset=utf-8");
|
||||
if (req.method === "GET" && url.pathname.startsWith("/uploads/")) return serveUpload(res, url.pathname);
|
||||
if (req.method === "GET" && url.pathname === "/widget.js") return file(res, path.join(PUBLIC_DIR, "widget.js"), "application/javascript; charset=utf-8");
|
||||
if (req.method === "GET" && url.pathname.startsWith("/admin.")) return servePublic(res, url.pathname);
|
||||
|
||||
if (url.pathname === "/api/admin/login" && req.method === "POST") return adminLogin(req, res);
|
||||
if (url.pathname === "/api/admin/logout" && req.method === "POST") return adminLogout(res);
|
||||
if (url.pathname.startsWith("/api/admin/")) return requireAdmin(req, res, () => adminApi(req, res, url));
|
||||
|
||||
if (url.pathname === "/api/widget/config" && req.method === "GET") return widgetConfig(res, url);
|
||||
if (url.pathname === "/api/widget/conversations" && req.method === "POST") return createConversation(req, res);
|
||||
if (url.pathname.match(/^\/api\/widget\/conversations\/[^/]+\/messages$/) && req.method === "POST") return createVisitorMessage(req, res, url);
|
||||
if (url.pathname.match(/^\/api\/widget\/conversations\/[^/]+\/events$/) && req.method === "GET") return eventStream(req, res, url, "visitor");
|
||||
if (url.pathname.match(/^\/api\/widget\/conversations\/[^/]+\/typing$/) && req.method === "POST") return visitorTyping(req, res, url);
|
||||
|
||||
return json(res, 404, { error: "not_found" });
|
||||
}
|
||||
|
||||
async function adminApi(req, res, url) {
|
||||
if (url.pathname === "/api/admin/me" && req.method === "GET") return json(res, 200, { user: req.adminUser });
|
||||
if (url.pathname === "/api/admin/bootstrap" && req.method === "GET") return adminBootstrap(res);
|
||||
if (url.pathname === "/api/admin/events" && req.method === "GET") return eventStream(req, res, url, "admin");
|
||||
if (url.pathname === "/api/admin/site" && req.method === "PATCH") return updateSite(req, res);
|
||||
if (url.pathname === "/api/admin/conversations" && req.method === "GET") return adminConversations(res);
|
||||
if (url.pathname.match(/^\/api\/admin\/conversations\/[^/]+$/) && req.method === "GET") return adminConversation(res, url);
|
||||
if (url.pathname.match(/^\/api\/admin\/conversations\/[^/]+\/messages$/) && req.method === "POST") return createAdminMessage(req, res, url);
|
||||
if (url.pathname.match(/^\/api\/admin\/conversations\/[^/]+\/status$/) && req.method === "PATCH") return updateConversationStatus(req, res, url);
|
||||
if (url.pathname.match(/^\/api\/admin\/conversations\/[^/]+\/typing$/) && req.method === "POST") return adminTyping(req, res, url);
|
||||
return json(res, 404, { error: "not_found" });
|
||||
}
|
||||
|
||||
function initDb() {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS companies (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sites (
|
||||
id INTEGER PRIMARY KEY,
|
||||
company_id INTEGER NOT NULL REFERENCES companies(id),
|
||||
site_key TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
domain TEXT,
|
||||
is_online INTEGER NOT NULL DEFAULT 1,
|
||||
settings_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS admin_users (
|
||||
id INTEGER PRIMARY KEY,
|
||||
company_id INTEGER NOT NULL REFERENCES companies(id),
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
display_name TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS conversations (
|
||||
id INTEGER PRIMARY KEY,
|
||||
public_id TEXT NOT NULL UNIQUE,
|
||||
company_id INTEGER NOT NULL REFERENCES companies(id),
|
||||
site_id INTEGER NOT NULL REFERENCES sites(id),
|
||||
status TEXT NOT NULL DEFAULT 'new',
|
||||
visitor_token TEXT NOT NULL,
|
||||
visitor_name TEXT,
|
||||
visitor_email TEXT,
|
||||
visitor_phone TEXT,
|
||||
current_url TEXT,
|
||||
referrer TEXT,
|
||||
browsing_history_json TEXT NOT NULL DEFAULT '[]',
|
||||
device_json TEXT NOT NULL DEFAULT '{}',
|
||||
language TEXT,
|
||||
timezone TEXT,
|
||||
ip TEXT,
|
||||
country_code TEXT,
|
||||
vpn_risk INTEGER NOT NULL DEFAULT 0,
|
||||
last_message_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id INTEGER PRIMARY KEY,
|
||||
conversation_id INTEGER NOT NULL REFERENCES conversations(id),
|
||||
sender_type TEXT NOT NULL,
|
||||
sender_name TEXT,
|
||||
body TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS attachments (
|
||||
id INTEGER PRIMARY KEY,
|
||||
conversation_id INTEGER NOT NULL REFERENCES conversations(id),
|
||||
message_id INTEGER REFERENCES messages(id),
|
||||
original_name TEXT NOT NULL,
|
||||
mime_type TEXT NOT NULL,
|
||||
byte_size INTEGER NOT NULL,
|
||||
storage_path TEXT NOT NULL,
|
||||
public_url TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_conversations_site_status ON conversations(site_id, status, last_message_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_conversation ON messages(conversation_id, created_at);
|
||||
`);
|
||||
}
|
||||
|
||||
function seedDefaults() {
|
||||
const companyName = process.env.DEFAULT_COMPANY_NAME || "MAAL";
|
||||
const siteKey = process.env.DEFAULT_SITE_KEY || "9b-plus";
|
||||
const existingCompany = db.prepare("SELECT id FROM companies LIMIT 1").get();
|
||||
const companyId = existingCompany?.id || db.prepare("INSERT INTO companies (name) VALUES (?)").run(companyName).lastInsertRowid;
|
||||
|
||||
const settings = defaultSettings();
|
||||
const existingSite = db.prepare("SELECT id FROM sites WHERE site_key = ?").get(siteKey);
|
||||
if (!existingSite) {
|
||||
db.prepare(`
|
||||
INSERT INTO sites (company_id, site_key, name, domain, settings_json)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
companyId,
|
||||
siteKey,
|
||||
process.env.DEFAULT_SITE_NAME || "9b-plus support",
|
||||
process.env.DEFAULT_SITE_DOMAIN || "https://www.9b-plus.com",
|
||||
JSON.stringify(settings)
|
||||
);
|
||||
}
|
||||
|
||||
for (const user of configuredUsers()) {
|
||||
db.prepare(`
|
||||
INSERT OR IGNORE INTO admin_users (company_id, username, display_name)
|
||||
VALUES (?, ?, ?)
|
||||
`).run(companyId, user.username, user.username);
|
||||
}
|
||||
}
|
||||
|
||||
function defaultSettings() {
|
||||
return {
|
||||
title: { cs: "Potrebujete poradit?", en: "Need help?" },
|
||||
intro: { cs: "Napiste nam. Odpovime co nejdrive.", en: "Message us and we will reply as soon as possible." },
|
||||
offlineIntro: { cs: "Ted nejsme online, ale zpravu nam muzete nechat.", en: "We are offline, but you can leave us a message." },
|
||||
placeholder: { cs: "Napiste zpravu...", en: "Write a message..." },
|
||||
sendLabel: { cs: "Odeslat", en: "Send" },
|
||||
detailsLabel: { cs: "Kontaktni udaje", en: "Contact details" },
|
||||
colors: {
|
||||
brand: "#0f8f6f",
|
||||
brandText: "#ffffff",
|
||||
surface: "#ffffff",
|
||||
text: "#17211d",
|
||||
muted: "#647067"
|
||||
},
|
||||
desktop: { side: "right", bottomPx: 22, sideOffsetPx: 22, widthPx: 370 },
|
||||
mobile: { side: "right", bottomPx: 14, sideOffsetPx: 12, widthPx: 340 }
|
||||
};
|
||||
}
|
||||
|
||||
function configuredUsers() {
|
||||
const users = [];
|
||||
for (let i = 1; i < 50; i += 1) {
|
||||
const username = process.env[`USER_${i}`];
|
||||
const password = process.env[`PASS_${i}`];
|
||||
if (!username && !password) break;
|
||||
if (username && password) users.push({ username, password });
|
||||
}
|
||||
return users;
|
||||
}
|
||||
|
||||
async function adminLogin(req, res) {
|
||||
const body = await readJson(req);
|
||||
const match = configuredUsers().find((u) => u.username === body.username && u.password === body.password);
|
||||
if (!match) return json(res, 401, { error: "bad_credentials" });
|
||||
const token = signSession(match.username);
|
||||
res.setHeader("Set-Cookie", `${COOKIE_NAME}=${token}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${60 * 60 * 24 * 14}`);
|
||||
return json(res, 200, { ok: true, user: { username: match.username } });
|
||||
}
|
||||
|
||||
function adminLogout(res) {
|
||||
res.setHeader("Set-Cookie", `${COOKIE_NAME}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0`);
|
||||
return json(res, 200, { ok: true });
|
||||
}
|
||||
|
||||
function requireAdmin(req, res, next) {
|
||||
const token = parseCookies(req.headers.cookie || "")[COOKIE_NAME];
|
||||
const username = token && verifySession(token);
|
||||
if (!username) return json(res, 401, { error: "unauthorized" });
|
||||
req.adminUser = { username };
|
||||
return next();
|
||||
}
|
||||
|
||||
function adminBootstrap(res) {
|
||||
const site = getDefaultSite();
|
||||
return json(res, 200, {
|
||||
publicBaseUrl: PUBLIC_BASE_URL,
|
||||
site: formatSite(site),
|
||||
snippet: snippetFor(site)
|
||||
});
|
||||
}
|
||||
|
||||
function adminConversations(res) {
|
||||
const rows = db.prepare(`
|
||||
SELECT c.*, s.site_key,
|
||||
(SELECT body FROM messages WHERE conversation_id = c.id ORDER BY id DESC LIMIT 1) AS last_body,
|
||||
(SELECT COUNT(*) FROM messages WHERE conversation_id = c.id) AS message_count
|
||||
FROM conversations c
|
||||
JOIN sites s ON s.id = c.site_id
|
||||
ORDER BY datetime(c.last_message_at) DESC, c.id DESC
|
||||
LIMIT 200
|
||||
`).all();
|
||||
return json(res, 200, { conversations: rows.map(formatConversationSummary) });
|
||||
}
|
||||
|
||||
function adminConversation(res, url) {
|
||||
const conversation = findConversation(publicIdFrom(url));
|
||||
if (!conversation) return json(res, 404, { error: "conversation_not_found" });
|
||||
return json(res, 200, conversationDetails(conversation));
|
||||
}
|
||||
|
||||
async function updateSite(req, res) {
|
||||
const body = await readJson(req);
|
||||
const site = getDefaultSite();
|
||||
const settings = { ...JSON.parse(site.settings_json), ...(body.settings || {}) };
|
||||
db.prepare(`
|
||||
UPDATE sites
|
||||
SET is_online = ?, settings_json = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`).run(body.isOnline ? 1 : 0, JSON.stringify(settings), site.id);
|
||||
const updated = getDefaultSite();
|
||||
publish("admin", { type: "site:update", site: formatSite(updated) });
|
||||
return json(res, 200, { site: formatSite(updated), snippet: snippetFor(updated) });
|
||||
}
|
||||
|
||||
async function updateConversationStatus(req, res, url) {
|
||||
const body = await readJson(req);
|
||||
const status = ["new", "open", "resolved", "spam"].includes(body.status) ? body.status : null;
|
||||
if (!status) return json(res, 400, { error: "bad_status" });
|
||||
const conversation = findConversation(publicIdFrom(url));
|
||||
if (!conversation) return json(res, 404, { error: "conversation_not_found" });
|
||||
db.prepare("UPDATE conversations SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(status, conversation.id);
|
||||
const updated = findConversation(conversation.public_id);
|
||||
publishConversation(updated, { type: "conversation:status", conversation: formatConversationSummary(updated) });
|
||||
return json(res, 200, { conversation: formatConversationSummary(updated) });
|
||||
}
|
||||
|
||||
function widgetConfig(res, url) {
|
||||
const siteKey = url.searchParams.get("siteKey") || process.env.DEFAULT_SITE_KEY || "9b-plus";
|
||||
const site = db.prepare("SELECT * FROM sites WHERE site_key = ?").get(siteKey);
|
||||
if (!site) return json(res, 404, { error: "site_not_found" });
|
||||
return json(res, 200, { site: formatSite(site), publicBaseUrl: PUBLIC_BASE_URL });
|
||||
}
|
||||
|
||||
async function createConversation(req, res) {
|
||||
const body = await readJson(req, 2_000_000);
|
||||
if (!body.message || !String(body.message).trim()) return json(res, 400, { error: "message_required" });
|
||||
const site = db.prepare("SELECT * FROM sites WHERE site_key = ?").get(body.siteKey || "9b-plus");
|
||||
if (!site) return json(res, 404, { error: "site_not_found" });
|
||||
|
||||
const visitorToken = body.visitorToken || id("vis");
|
||||
const publicId = id("cnv");
|
||||
const info = body.visitorInfo || {};
|
||||
const ip = clientIp(req);
|
||||
|
||||
const insertConversation = db.transaction(() => {
|
||||
const conversationId = db.prepare(`
|
||||
INSERT INTO conversations (
|
||||
public_id, company_id, site_id, visitor_token, visitor_name, visitor_email, visitor_phone,
|
||||
current_url, referrer, browsing_history_json, device_json, language, timezone, ip, country_code
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
publicId,
|
||||
site.company_id,
|
||||
site.id,
|
||||
visitorToken,
|
||||
nullish(info.name),
|
||||
nullish(info.email),
|
||||
nullish(info.phone),
|
||||
nullish(body.currentUrl),
|
||||
nullish(body.referrer),
|
||||
JSON.stringify(body.browsingHistory || []),
|
||||
JSON.stringify(body.device || {}),
|
||||
nullish(body.language),
|
||||
nullish(body.timezone),
|
||||
ip,
|
||||
nullish(countryFromHeaders(req))
|
||||
).lastInsertRowid;
|
||||
|
||||
const messageId = insertMessage(conversationId, "visitor", info.name || "Visitor", String(body.message).trim());
|
||||
saveAttachments(conversationId, messageId, body.attachments || []);
|
||||
return conversationId;
|
||||
});
|
||||
const insert = insertConversation();
|
||||
|
||||
const conversation = db.prepare("SELECT * FROM conversations WHERE id = ?").get(insert);
|
||||
publish("admin", { type: "conversation:new", conversation: formatConversationSummary(conversation) });
|
||||
publishConversation(conversation, { type: "message:new", payload: conversationDetails(conversation) });
|
||||
return json(res, 201, { conversation: conversationDetails(conversation), visitorToken });
|
||||
}
|
||||
|
||||
async function createVisitorMessage(req, res, url) {
|
||||
const body = await readJson(req, 2_000_000);
|
||||
if (!body.message || !String(body.message).trim()) return json(res, 400, { error: "message_required" });
|
||||
const conversation = findConversation(publicIdFrom(url));
|
||||
if (!conversation) return json(res, 404, { error: "conversation_not_found" });
|
||||
const messageId = insertMessage(conversation.id, "visitor", conversation.visitor_name || "Visitor", String(body.message).trim());
|
||||
saveAttachments(conversation.id, messageId, body.attachments || []);
|
||||
bumpConversation(conversation.id, conversation.status === "resolved" ? "open" : conversation.status);
|
||||
const updated = findConversation(conversation.public_id);
|
||||
publishConversation(updated, { type: "message:new", payload: conversationDetails(updated) });
|
||||
return json(res, 201, conversationDetails(updated));
|
||||
}
|
||||
|
||||
async function createAdminMessage(req, res, url) {
|
||||
const body = await readJson(req, 2_000_000);
|
||||
if (!body.message || !String(body.message).trim()) return json(res, 400, { error: "message_required" });
|
||||
const conversation = findConversation(publicIdFrom(url));
|
||||
if (!conversation) return json(res, 404, { error: "conversation_not_found" });
|
||||
insertMessage(conversation.id, "admin", req.adminUser.username, String(body.message).trim());
|
||||
bumpConversation(conversation.id, conversation.status === "new" ? "open" : conversation.status);
|
||||
const updated = findConversation(conversation.public_id);
|
||||
publishConversation(updated, { type: "message:new", payload: conversationDetails(updated) });
|
||||
return json(res, 201, conversationDetails(updated));
|
||||
}
|
||||
|
||||
async function visitorTyping(req, res, url) {
|
||||
const conversation = findConversation(publicIdFrom(url));
|
||||
if (!conversation) return json(res, 404, { error: "conversation_not_found" });
|
||||
publishConversation(conversation, { type: "typing", actor: "visitor", conversationId: conversation.public_id });
|
||||
return json(res, 200, { ok: true });
|
||||
}
|
||||
|
||||
async function adminTyping(req, res, url) {
|
||||
const conversation = findConversation(publicIdFrom(url));
|
||||
if (!conversation) return json(res, 404, { error: "conversation_not_found" });
|
||||
publishConversation(conversation, { type: "typing", actor: "admin", conversationId: conversation.public_id });
|
||||
return json(res, 200, { ok: true });
|
||||
}
|
||||
|
||||
function eventStream(req, res, url, channel) {
|
||||
const key = channel === "admin" ? "admin" : `conversation:${publicIdFrom(url)}`;
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
"Access-Control-Allow-Origin": req.headers.origin || "*",
|
||||
"Access-Control-Allow-Credentials": "true"
|
||||
});
|
||||
res.write(`event: hello\ndata: ${JSON.stringify({ ok: true })}\n\n`);
|
||||
const client = { res };
|
||||
if (!streams.has(key)) streams.set(key, new Set());
|
||||
streams.get(key).add(client);
|
||||
req.on("close", () => streams.get(key)?.delete(client));
|
||||
}
|
||||
|
||||
function publishConversation(conversation, event) {
|
||||
publish("admin", event);
|
||||
publish(`conversation:${conversation.public_id}`, event);
|
||||
}
|
||||
|
||||
function publish(key, event) {
|
||||
const listeners = streams.get(key);
|
||||
if (!listeners) return;
|
||||
for (const client of listeners) {
|
||||
client.res.write(`event: message\ndata: ${JSON.stringify(event)}\n\n`);
|
||||
}
|
||||
}
|
||||
|
||||
function insertMessage(conversationId, senderType, senderName, body) {
|
||||
return db.prepare(`
|
||||
INSERT INTO messages (conversation_id, sender_type, sender_name, body)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run(conversationId, senderType, senderName, body).lastInsertRowid;
|
||||
}
|
||||
|
||||
function saveAttachments(conversationId, messageId, attachments) {
|
||||
for (const attachment of attachments.slice(0, 3)) {
|
||||
if (!attachment.dataBase64 || !attachment.name) continue;
|
||||
const buffer = Buffer.from(String(attachment.dataBase64).split(",").pop(), "base64");
|
||||
if (!buffer.length || buffer.length > 3_000_000) continue;
|
||||
const safeName = path.basename(attachment.name).replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 80);
|
||||
const filename = `${Date.now()}-${crypto.randomBytes(6).toString("hex")}-${safeName}`;
|
||||
const storagePath = path.join(UPLOAD_DIR, filename);
|
||||
fs.writeFileSync(storagePath, buffer);
|
||||
db.prepare(`
|
||||
INSERT INTO attachments (conversation_id, message_id, original_name, mime_type, byte_size, storage_path, public_url)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
conversationId,
|
||||
messageId,
|
||||
attachment.name,
|
||||
attachment.type || "application/octet-stream",
|
||||
buffer.length,
|
||||
storagePath,
|
||||
`/uploads/${filename}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function bumpConversation(conversationId, status) {
|
||||
db.prepare(`
|
||||
UPDATE conversations
|
||||
SET status = ?, last_message_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`).run(status, conversationId);
|
||||
}
|
||||
|
||||
function conversationDetails(conversation) {
|
||||
const messages = db.prepare("SELECT * FROM messages WHERE conversation_id = ? ORDER BY id ASC").all(conversation.id);
|
||||
const attachments = db.prepare("SELECT * FROM attachments WHERE conversation_id = ? ORDER BY id ASC").all(conversation.id);
|
||||
return {
|
||||
conversation: formatConversationSummary(conversation),
|
||||
messages: messages.map((message) => ({
|
||||
id: message.id,
|
||||
senderType: message.sender_type,
|
||||
senderName: message.sender_name,
|
||||
body: message.body,
|
||||
createdAt: message.created_at,
|
||||
attachments: attachments
|
||||
.filter((item) => item.message_id === message.id)
|
||||
.map(formatAttachment)
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
function formatConversationSummary(row) {
|
||||
return {
|
||||
id: row.public_id,
|
||||
siteId: row.site_id,
|
||||
siteKey: row.site_key,
|
||||
status: row.status,
|
||||
visitor: {
|
||||
name: row.visitor_name,
|
||||
email: row.visitor_email,
|
||||
phone: row.visitor_phone
|
||||
},
|
||||
currentUrl: row.current_url,
|
||||
referrer: row.referrer,
|
||||
browsingHistory: parseJson(row.browsing_history_json, []),
|
||||
device: parseJson(row.device_json, {}),
|
||||
language: row.language,
|
||||
timezone: row.timezone,
|
||||
ip: row.ip,
|
||||
countryCode: row.country_code,
|
||||
vpnRisk: Boolean(row.vpn_risk),
|
||||
lastBody: row.last_body,
|
||||
messageCount: row.message_count,
|
||||
lastMessageAt: row.last_message_at,
|
||||
createdAt: row.created_at
|
||||
};
|
||||
}
|
||||
|
||||
function formatAttachment(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.original_name,
|
||||
type: row.mime_type,
|
||||
size: row.byte_size,
|
||||
url: row.public_url
|
||||
};
|
||||
}
|
||||
|
||||
function formatSite(site) {
|
||||
return {
|
||||
id: site.id,
|
||||
siteKey: site.site_key,
|
||||
name: site.name,
|
||||
domain: site.domain,
|
||||
isOnline: Boolean(site.is_online),
|
||||
settings: parseJson(site.settings_json, defaultSettings())
|
||||
};
|
||||
}
|
||||
|
||||
function snippetFor(site) {
|
||||
return `<script>
|
||||
window.MaalFlows = {
|
||||
siteKey: "${site.site_key}",
|
||||
lang: "cs",
|
||||
apiBase: "${PUBLIC_BASE_URL}"
|
||||
};
|
||||
</script>
|
||||
<script async src="${PUBLIC_BASE_URL}/widget.js"></script>`;
|
||||
}
|
||||
|
||||
function getDefaultSite() {
|
||||
return db.prepare("SELECT * FROM sites ORDER BY id LIMIT 1").get();
|
||||
}
|
||||
|
||||
function findConversation(publicId) {
|
||||
return db.prepare(`
|
||||
SELECT c.*, s.site_key
|
||||
FROM conversations c
|
||||
JOIN sites s ON s.id = c.site_id
|
||||
WHERE c.public_id = ?
|
||||
`).get(publicId);
|
||||
}
|
||||
|
||||
function publicIdFrom(url) {
|
||||
const parts = url.pathname.split("/").filter(Boolean);
|
||||
return parts[parts.indexOf("conversations") + 1];
|
||||
}
|
||||
|
||||
function servePublic(res, pathname) {
|
||||
const target = path.join(PUBLIC_DIR, path.basename(pathname));
|
||||
const type = pathname.endsWith(".css") ? "text/css; charset=utf-8" : "application/javascript; charset=utf-8";
|
||||
return file(res, target, type);
|
||||
}
|
||||
|
||||
function serveUpload(res, pathname) {
|
||||
const target = path.join(UPLOAD_DIR, path.basename(pathname));
|
||||
return file(res, target, "application/octet-stream");
|
||||
}
|
||||
|
||||
function file(res, target, contentType) {
|
||||
if (!fs.existsSync(target)) return json(res, 404, { error: "not_found" });
|
||||
res.writeHead(200, { "Content-Type": contentType });
|
||||
fs.createReadStream(target).pipe(res);
|
||||
}
|
||||
|
||||
function json(res, status, payload) {
|
||||
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
function setCommonHeaders(res) {
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET,POST,PATCH,OPTIONS");
|
||||
}
|
||||
|
||||
function endCors(res) {
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
}
|
||||
|
||||
function readJson(req, limit = 500_000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let raw = "";
|
||||
req.on("data", (chunk) => {
|
||||
raw += chunk;
|
||||
if (raw.length > limit) {
|
||||
reject(new Error("payload_too_large"));
|
||||
req.destroy();
|
||||
}
|
||||
});
|
||||
req.on("end", () => {
|
||||
if (!raw) return resolve({});
|
||||
try {
|
||||
resolve(JSON.parse(raw));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function signSession(username) {
|
||||
const payload = Buffer.from(JSON.stringify({ username, exp: Date.now() + 14 * 86400_000 })).toString("base64url");
|
||||
const sig = crypto.createHmac("sha256", AUTH_SECRET).update(payload).digest("base64url");
|
||||
return `${payload}.${sig}`;
|
||||
}
|
||||
|
||||
function verifySession(token) {
|
||||
const [payload, sig] = String(token).split(".");
|
||||
if (!payload || !sig) return null;
|
||||
const expected = crypto.createHmac("sha256", AUTH_SECRET).update(payload).digest("base64url");
|
||||
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return null;
|
||||
const data = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
||||
return data.exp > Date.now() ? data.username : null;
|
||||
}
|
||||
|
||||
function parseCookies(header) {
|
||||
return Object.fromEntries(header.split(";").filter(Boolean).map((part) => {
|
||||
const [key, ...value] = part.trim().split("=");
|
||||
return [key, value.join("=")];
|
||||
}));
|
||||
}
|
||||
|
||||
function parseJson(value, fallback) {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function id(prefix) {
|
||||
return `${prefix}_${crypto.randomBytes(12).toString("hex")}`;
|
||||
}
|
||||
|
||||
function nullish(value) {
|
||||
return value === undefined || value === "" ? null : value;
|
||||
}
|
||||
|
||||
function clientIp(req) {
|
||||
return (req.headers["x-forwarded-for"] || req.socket.remoteAddress || "").split(",")[0].trim();
|
||||
}
|
||||
|
||||
function countryFromHeaders(req) {
|
||||
return req.headers["cf-ipcountry"] || req.headers["x-vercel-ip-country"] || null;
|
||||
}
|
||||
|
||||
function loadDotEnv(filePath) {
|
||||
if (!fs.existsSync(filePath)) return;
|
||||
const lines = fs.readFileSync(filePath, "utf8").split(/\r?\n/);
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
const index = trimmed.indexOf("=");
|
||||
if (index === -1) continue;
|
||||
const key = trimmed.slice(0, index).trim();
|
||||
const value = trimmed.slice(index + 1).trim();
|
||||
if (!(key in process.env)) process.env[key] = value;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user