Files
MaalFlows/server.js
T

1081 lines
44 KiB
JavaScript

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";
const ALLOWED_IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp", "image/gif", "image/avif"]);
const ALLOWED_IMAGE_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".webp", ".gif", ".avif"]);
const MAX_ATTACHMENT_BYTES = 3_000_000;
const ACTIVE_VISITOR_SECONDS = 90;
const PRESENT_VISITOR_SECONDS = 300;
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 === "/favicon.svg") return file(res, path.join(PUBLIC_DIR, "favicon.svg"), "image/svg+xml; charset=utf-8");
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/visitors/ping" && req.method === "POST") return visitorPing(req, res);
if (url.pathname === "/api/widget/conversations" && req.method === "POST") return createConversation(req, res);
if (url.pathname.match(/^\/api\/widget\/conversations\/[^/]+$/) && req.method === "GET") return getVisitorConversation(res, url);
if (url.pathname.match(/^\/api\/widget\/conversations\/[^/]+\/messages$/) && req.method === "POST") return createVisitorMessage(req, res, url);
if (url.pathname.match(/^\/api\/widget\/conversations\/[^/]+\/seen$/) && req.method === "POST") return markVisitorSeen(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 === "/api/admin/visitors" && req.method === "GET") return adminVisitors(res);
if (url.pathname.match(/^\/api\/admin\/attachments\/\d+$/) && req.method === "DELETE") return deleteAttachment(res, url);
if (url.pathname.match(/^\/api\/admin\/conversations\/[^/]+$/) && req.method === "GET") return adminConversation(res, url);
if (url.pathname.match(/^\/api\/admin\/conversations\/[^/]+$/) && req.method === "DELETE") return deleteConversation(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,
visitor_seen_at TEXT,
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 TABLE IF NOT EXISTS visitor_sessions (
id INTEGER PRIMARY KEY,
company_id INTEGER NOT NULL REFERENCES companies(id),
site_id INTEGER NOT NULL REFERENCES sites(id),
visitor_token TEXT NOT NULL,
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,
first_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_activity_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(site_id, visitor_token)
);
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);
CREATE INDEX IF NOT EXISTS idx_visitor_sessions_seen ON visitor_sessions(site_id, last_seen_at);
`);
if (ensureColumn("conversations", "admin_seen_at", "TEXT")) {
db.prepare("UPDATE conversations SET admin_seen_at = last_message_at WHERE admin_seen_at IS NULL").run();
}
if (ensureColumn("visitor_sessions", "last_activity_at", "TEXT")) {
db.prepare("UPDATE visitor_sessions SET last_activity_at = last_seen_at WHERE last_activity_at IS NULL").run();
}
ensureColumn("conversations", "visitor_seen_at", "TEXT");
}
function ensureColumn(table, column, definition) {
const exists = db.prepare(`PRAGMA table_info(${table})`).all().some((item) => item.name === column);
if (exists) return false;
db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
return true;
}
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 },
notifications: { pulseOnAdminReply: true }
};
}
function configuredUsers() {
const users = [];
for (let i = 1; i < 50; i += 1) {
const username = process.env[`USER_${i}`];
const password = process.env[`PASS_${i}`];
const displayName = process.env[`NAME_${i}`] || username;
if (!username && !password) break;
if (username && password) users.push({ username, password, displayName });
}
return users;
}
function adminProfile(username) {
return configuredUsers().find((user) => user.username === username) || { username, displayName: username };
}
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, displayName: match.displayName } });
}
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 = adminProfile(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,
(SELECT COUNT(*) FROM attachments WHERE conversation_id = c.id AND mime_type LIKE 'image/%') AS image_count,
EXISTS(
SELECT 1 FROM messages unread
WHERE unread.conversation_id = c.id
AND unread.sender_type = 'visitor'
AND datetime(unread.created_at) > datetime(COALESCE(c.admin_seen_at, '1970-01-01 00:00:00'))
) AS has_unread,
EXISTS(
SELECT 1 FROM visitor_sessions vs
WHERE vs.site_id = c.site_id
AND vs.visitor_token = c.visitor_token
AND datetime(vs.last_seen_at) >= datetime('now', '-${PRESENT_VISITOR_SECONDS} seconds')
) AS visitor_online,
COALESCE((
SELECT CASE
WHEN datetime(vs.last_seen_at) < datetime('now', '-${PRESENT_VISITOR_SECONDS} seconds') THEN 'offline'
WHEN datetime(vs.last_activity_at) >= datetime('now', '-${ACTIVE_VISITOR_SECONDS} seconds') THEN 'active'
ELSE 'idle'
END
FROM visitor_sessions vs
WHERE vs.site_id = c.site_id AND vs.visitor_token = c.visitor_token
ORDER BY datetime(vs.last_seen_at) DESC
LIMIT 1
), 'offline') AS visitor_presence,
(SELECT COUNT(*) FROM conversations vc WHERE vc.site_id = c.site_id AND vc.visitor_token = c.visitor_token) AS visitor_conversation_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 adminVisitors(res) {
return json(res, 200, { visitors: currentVisitors() });
}
function adminConversation(res, url) {
const conversation = findConversation(publicIdFrom(url));
if (!conversation) return json(res, 404, { error: "conversation_not_found" });
markConversationSeen(conversation.id);
return json(res, 200, conversationDetails(findConversation(conversation.public_id)));
}
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 deleteConversation(res, url) {
const conversation = findConversation(publicIdFrom(url));
if (!conversation) return json(res, 404, { error: "conversation_not_found" });
const attachments = db.prepare("SELECT storage_path FROM attachments WHERE conversation_id = ?").all(conversation.id);
db.transaction(() => {
db.prepare("DELETE FROM attachments WHERE conversation_id = ?").run(conversation.id);
db.prepare("DELETE FROM messages WHERE conversation_id = ?").run(conversation.id);
db.prepare("DELETE FROM conversations WHERE id = ?").run(conversation.id);
})();
for (const attachment of attachments) {
if (attachment.storage_path && path.resolve(attachment.storage_path).startsWith(path.resolve(UPLOAD_DIR))) {
fs.rmSync(attachment.storage_path, { force: true });
}
}
publish("admin", { type: "conversation:delete", conversationId: conversation.public_id });
return json(res, 200, { ok: true });
}
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 visitorPing(req, res) {
const body = await readJson(req);
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 lastActivityAt = timestampOrNow(body.lastActivityAt);
db.prepare(`
INSERT INTO visitor_sessions (
company_id, site_id, visitor_token, current_url, referrer, browsing_history_json,
device_json, language, timezone, ip, country_code, first_seen_at, last_activity_at, last_seen_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?, CURRENT_TIMESTAMP)
ON CONFLICT(site_id, visitor_token) DO UPDATE SET
current_url = excluded.current_url,
referrer = COALESCE(excluded.referrer, visitor_sessions.referrer),
browsing_history_json = excluded.browsing_history_json,
device_json = excluded.device_json,
language = excluded.language,
timezone = excluded.timezone,
ip = excluded.ip,
country_code = COALESCE(excluded.country_code, visitor_sessions.country_code),
last_activity_at = CASE
WHEN datetime(excluded.last_activity_at) > datetime(visitor_sessions.last_activity_at)
THEN excluded.last_activity_at
ELSE visitor_sessions.last_activity_at
END,
last_seen_at = CURRENT_TIMESTAMP
`).run(
site.company_id,
site.id,
visitorToken,
nullish(body.currentUrl),
nullish(body.referrer),
JSON.stringify(body.browsingHistory || []),
JSON.stringify(body.device || {}),
nullish(body.language),
nullish(body.timezone),
clientIp(req),
nullish(countryFromHeaders(req)),
lastActivityAt
);
publish("admin", { type: "visitors:update", visitors: currentVisitors() });
return json(res, 200, { ok: true });
}
function getVisitorConversation(res, url) {
const conversation = findConversation(publicIdFrom(url));
if (!conversation) return json(res, 404, { error: "conversation_not_found" });
if (conversation.visitor_token !== url.searchParams.get("visitorToken")) return json(res, 403, { error: "forbidden" });
return json(res, 200, conversationDetails(conversation));
}
async function createConversation(req, res) {
const body = await readJson(req, 8_000_000);
if ((!body.message || !String(body.message).trim()) && !(body.attachments || []).length) return json(res, 400, { error: "message_or_image_required" });
if (!attachmentsAreAllowed(body.attachments || [])) return json(res, 400, { error: "only_images_allowed" });
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 = findConversation(publicId);
publish("admin", { type: "conversation:new", conversation: formatConversationSummary(conversation) });
publishConversation(conversation, { type: "message:new", payload: conversationDetails(conversation) });
return json(res, 201, conversationDetails(conversation));
}
async function createVisitorMessage(req, res, url) {
const body = await readJson(req, 8_000_000);
if ((!body.message || !String(body.message).trim()) && !(body.attachments || []).length) return json(res, 400, { error: "message_or_image_required" });
if (!attachmentsAreAllowed(body.attachments || [])) return json(res, 400, { error: "only_images_allowed" });
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 || []);
updateVisitorContext(conversation.id, body);
markConversationUnread(conversation.id);
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 markVisitorSeen(req, res, url) {
const body = await readJson(req).catch(() => ({}));
const conversation = findConversation(publicIdFrom(url));
if (!conversation) return json(res, 404, { error: "conversation_not_found" });
if (conversation.visitor_token !== body.visitorToken) return json(res, 403, { error: "forbidden" });
db.prepare("UPDATE conversations SET visitor_seen_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(conversation.id);
const updated = findConversation(conversation.public_id);
publishConversation(updated, { type: "conversation:seen", conversation: formatConversationSummary(updated) });
return json(res, 200, { ok: true, conversation: formatConversationSummary(updated) });
}
async function createAdminMessage(req, res, url) {
const body = await readJson(req, 8_000_000);
if ((!body.message || !String(body.message).trim()) && !(body.attachments || []).length) return json(res, 400, { error: "message_or_image_required" });
if (!attachmentsAreAllowed(body.attachments || [])) return json(res, 400, { error: "only_images_allowed" });
const conversation = findConversation(publicIdFrom(url));
if (!conversation) return json(res, 404, { error: "conversation_not_found" });
const messageId = insertMessage(conversation.id, "admin", req.adminUser.displayName || req.adminUser.username, String(body.message || "").trim());
saveAttachments(conversation.id, messageId, body.attachments || []);
bumpConversation(conversation.id, conversation.status === "new" ? "open" : conversation.status);
markConversationSeen(conversation.id);
const updated = findConversation(conversation.public_id);
publishConversation(updated, { type: "message:new", payload: conversationDetails(updated) });
return json(res, 201, conversationDetails(updated));
}
function deleteAttachment(res, url) {
const attachmentId = Number(url.pathname.split("/").pop());
const attachment = db.prepare(`
SELECT a.*, c.public_id
FROM attachments a
JOIN conversations c ON c.id = a.conversation_id
WHERE a.id = ?
`).get(attachmentId);
if (!attachment) return json(res, 404, { error: "attachment_not_found" });
db.prepare("DELETE FROM attachments WHERE id = ?").run(attachmentId);
if (attachment.storage_path && path.resolve(attachment.storage_path).startsWith(path.resolve(UPLOAD_DIR))) {
fs.rmSync(attachment.storage_path, { force: true });
}
const conversation = findConversation(attachment.public_id);
if (conversation) publishConversation(conversation, { type: "message:new", payload: conversationDetails(conversation) });
return json(res, 200, { ok: true });
}
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 (!isAllowedImageAttachment(attachment, buffer)) 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 updateVisitorContext(conversationId, body) {
db.prepare(`
UPDATE conversations
SET current_url = COALESCE(?, current_url),
referrer = COALESCE(?, referrer),
browsing_history_json = COALESCE(?, browsing_history_json),
device_json = COALESCE(?, device_json),
language = COALESCE(?, language),
timezone = COALESCE(?, timezone),
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`).run(
nullish(body.currentUrl),
nullish(body.referrer),
body.browsingHistory ? JSON.stringify(body.browsingHistory) : null,
body.device ? JSON.stringify(body.device) : null,
nullish(body.language),
nullish(body.timezone),
conversationId
);
}
function currentVisitors() {
const rows = db.prepare(`
SELECT vs.*, s.site_key,
(SELECT c.public_id FROM conversations c WHERE c.site_id = vs.site_id AND c.visitor_token = vs.visitor_token ORDER BY datetime(c.last_message_at) DESC, c.id DESC LIMIT 1) AS last_conversation_id,
(SELECT COUNT(*) FROM conversations c WHERE c.site_id = vs.site_id AND c.visitor_token = vs.visitor_token) AS visitor_conversation_count
FROM visitor_sessions vs
JOIN sites s ON s.id = vs.site_id
WHERE datetime(vs.last_seen_at) >= datetime('now', '-${PRESENT_VISITOR_SECONDS} seconds')
ORDER BY datetime(vs.last_seen_at) DESC
LIMIT 100
`).all();
return rows.map(formatVisitorSession);
}
function formatVisitorSession(row) {
const history = parseJson(row.browsing_history_json, []);
const device = parseJson(row.device_json, {});
const bot = botInfo(device.userAgent);
const countryCode = row.country_code || countryFromTimezone(row.timezone) || countryFromLanguage(row.language);
const presence = visitorPresence(row);
return {
id: row.visitor_token,
initials: visitorInitials(row.visitor_token),
label: visitorLabel(row.visitor_token),
online: presence !== "offline",
presence,
currentUrl: row.current_url,
referrer: row.referrer,
browsingHistory: history,
pageCount: new Set(history.map((item) => item.url).filter(Boolean)).size,
visitCount: Math.max(1, history.length),
conversationCount: row.visitor_conversation_count || 0,
lastConversationId: row.last_conversation_id,
isBot: bot.isBot,
botReason: bot.reason,
device,
language: row.language,
timezone: row.timezone,
countryCode,
countryFlag: countryCode ? flagEmoji(countryCode) : "",
sessionSeconds: sessionSeconds(row.browsing_history_json, row.first_seen_at),
lastActivityAt: row.last_activity_at,
lastSeenAt: row.last_seen_at
};
}
function visitorPresence(row) {
const lastSeen = parseTimestampMs(row.last_seen_at);
if (!Number.isFinite(lastSeen) || Date.now() - lastSeen > PRESENT_VISITOR_SECONDS * 1000) return "offline";
const lastActivity = parseTimestampMs(row.last_activity_at || row.last_seen_at);
return Number.isFinite(lastActivity) && Date.now() - lastActivity <= ACTIVE_VISITOR_SECONDS * 1000 ? "active" : "idle";
}
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,
visitorToken: row.visitor_token,
visitorInitials: visitorInitials(row.visitor_token),
visitorLabel: visitorLabel(row.visitor_token),
visitorOnline: Boolean(row.visitor_online),
visitorPresence: row.visitor_presence || (row.visitor_online ? "active" : "offline"),
visitorSeenAt: row.visitor_seen_at,
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 || countryFromTimezone(row.timezone) || countryFromLanguage(row.language),
vpnRisk: Boolean(row.vpn_risk),
lastBody: row.last_body,
messageCount: row.message_count,
imageCount: row.image_count || 0,
hasUnread: Boolean(row.has_unread),
pageCount: pageCount(row.browsing_history_json),
sessionSeconds: sessionSeconds(row.browsing_history_json, row.created_at),
visitorConversationCount: row.visitor_conversation_count || 1,
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) {
const settings = parseJson(site.settings_json, defaultSettings());
settings.notifications = { ...defaultSettings().notifications, ...(settings.notifications || {}) };
return {
id: site.id,
siteKey: site.site_key,
name: site.name,
domain: site.domain,
isOnline: Boolean(site.is_online),
settings
};
}
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,
(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,
(SELECT COUNT(*) FROM attachments WHERE conversation_id = c.id AND mime_type LIKE 'image/%') AS image_count,
EXISTS(
SELECT 1 FROM messages unread
WHERE unread.conversation_id = c.id
AND unread.sender_type = 'visitor'
AND datetime(unread.created_at) > datetime(COALESCE(c.admin_seen_at, '1970-01-01 00:00:00'))
) AS has_unread,
EXISTS(
SELECT 1 FROM visitor_sessions vs
WHERE vs.site_id = c.site_id
AND vs.visitor_token = c.visitor_token
AND datetime(vs.last_seen_at) >= datetime('now', '-${PRESENT_VISITOR_SECONDS} seconds')
) AS visitor_online,
COALESCE((
SELECT CASE
WHEN datetime(vs.last_seen_at) < datetime('now', '-${PRESENT_VISITOR_SECONDS} seconds') THEN 'offline'
WHEN datetime(vs.last_activity_at) >= datetime('now', '-${ACTIVE_VISITOR_SECONDS} seconds') THEN 'active'
ELSE 'idle'
END
FROM visitor_sessions vs
WHERE vs.site_id = c.site_id AND vs.visitor_token = c.visitor_token
ORDER BY datetime(vs.last_seen_at) DESC
LIMIT 1
), 'offline') AS visitor_presence,
(SELECT COUNT(*) FROM conversations vc WHERE vc.site_id = c.site_id AND vc.visitor_token = c.visitor_token) AS visitor_conversation_count
FROM conversations c
JOIN sites s ON s.id = c.site_id
WHERE c.public_id = ?
`).get(publicId);
}
function markConversationSeen(conversationId) {
db.prepare("UPDATE conversations SET admin_seen_at = CURRENT_TIMESTAMP WHERE id = ?").run(conversationId);
}
function markConversationUnread(conversationId) {
db.prepare("UPDATE conversations SET admin_seen_at = NULL WHERE id = ?").run(conversationId);
}
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));
const attachment = db.prepare("SELECT mime_type FROM attachments WHERE public_url = ?").get(`/uploads/${path.basename(pathname)}`);
return file(res, target, attachment?.mime_type || "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,DELETE,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 pageCount(historyJson) {
return new Set(parseJson(historyJson, []).map((item) => item.url).filter(Boolean)).size;
}
function sessionSeconds(historyJson, createdAt) {
const history = parseJson(historyJson, []);
const times = history
.map((item) => Date.parse(item.at))
.filter((value) => Number.isFinite(value));
if (times.length > 1) return Math.max(0, Math.round((Math.max(...times) - Math.min(...times)) / 1000));
const created = Date.parse(`${createdAt}Z`);
return Number.isFinite(created) ? Math.max(0, Math.round((Date.now() - created) / 1000)) : 0;
}
function id(prefix) {
return `${prefix}_${crypto.randomBytes(12).toString("hex")}`;
}
function nullish(value) {
return value === undefined || value === "" ? null : value;
}
function timestampOrNow(value) {
const parsed = Date.parse(value || "");
if (!Number.isFinite(parsed)) return new Date().toISOString();
const now = Date.now();
const bounded = Math.min(Math.max(parsed, now - 24 * 60 * 60 * 1000), now);
return new Date(bounded).toISOString();
}
function parseTimestampMs(value) {
if (!value) return NaN;
const text = String(value);
const normalized = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(text) ? `${text.replace(" ", "T")}Z` : text;
return Date.parse(normalized);
}
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 countryFromTimezone(timezone) {
const zones = {
"Europe/Prague": "CZ",
"Europe/Bratislava": "SK",
"Europe/Warsaw": "PL",
"Europe/Berlin": "DE",
"Europe/Vienna": "AT",
"Europe/Budapest": "HU"
};
return zones[String(timezone || "")] || null;
}
function countryFromLanguage(language) {
const match = String(language || "").match(/-([A-Za-z]{2})\b/);
return match ? match[1].toUpperCase() : null;
}
function botInfo(userAgent) {
const ua = String(userAgent || "");
const patterns = [
"googlebot", "bingbot", "slurp", "duckduckbot", "baiduspider", "yandexbot",
"ahrefsbot", "semrushbot", "mj12bot", "dotbot", "petalbot", "bytespider",
"facebookexternalhit", "twitterbot", "linkedinbot", "slackbot", "discordbot",
"whatsapp", "telegrambot", "preview", "crawler", "spider", "headlesschrome",
"pingdom", "uptimerobot", "curl", "wget", "python-requests", "axios", "httpclient"
];
const lower = ua.toLowerCase();
const match = patterns.find((pattern) => lower.includes(pattern));
if (match) return { isBot: true, reason: match };
if (/\bbot\b/i.test(ua)) return { isBot: true, reason: "bot" };
return { isBot: false, reason: "" };
}
function visitorInitials(visitorToken) {
const hash = crypto.createHash("sha1").update(String(visitorToken || "")).digest("hex").toUpperCase();
return hash.slice(0, 2);
}
function visitorLabel(visitorToken) {
return `Navstevnik ${visitorInitials(visitorToken)}`;
}
function flagEmoji(countryCode) {
if (!countryCode || countryCode.length !== 2) return "";
return countryCode.toUpperCase().replace(/./g, (char) => String.fromCodePoint(127397 + char.charCodeAt()));
}
function isAllowedImageAttachment(attachment, buffer) {
const extension = path.extname(attachment.name || "").toLowerCase();
const type = String(attachment.type || "").toLowerCase();
if (!buffer.length || buffer.length > MAX_ATTACHMENT_BYTES) return false;
if (!ALLOWED_IMAGE_TYPES.has(type) || !ALLOWED_IMAGE_EXTENSIONS.has(extension)) return false;
return hasExpectedImageSignature(type, buffer);
}
function attachmentsAreAllowed(attachments) {
return attachments.every((attachment) => {
if (!attachment.dataBase64 || !attachment.name) return true;
const buffer = Buffer.from(String(attachment.dataBase64).split(",").pop(), "base64");
return isAllowedImageAttachment(attachment, buffer);
});
}
function hasExpectedImageSignature(type, buffer) {
if (type === "image/jpeg") return buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff;
if (type === "image/png") return buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
if (type === "image/gif") return buffer.subarray(0, 6).toString("ascii") === "GIF87a" || buffer.subarray(0, 6).toString("ascii") === "GIF89a";
if (type === "image/webp") return buffer.subarray(0, 4).toString("ascii") === "RIFF" && buffer.subarray(8, 12).toString("ascii") === "WEBP";
if (type === "image/avif") return buffer.subarray(4, 12).toString("ascii").includes("ftyp");
return false;
}
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;
}
}