1151 lines
48 KiB
JavaScript
1151 lines
48 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\/visitors\/[^/]+$/) && req.method === "PATCH") return updateVisitorProfile(req, res, url);
|
|
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 TABLE IF NOT EXISTS visitor_profiles (
|
|
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,
|
|
display_name TEXT,
|
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
updated_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);
|
|
CREATE INDEX IF NOT EXISTS idx_visitor_profiles_token ON visitor_profiles(site_id, visitor_token);
|
|
`);
|
|
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 },
|
|
launcherEffects: {
|
|
breathe: true,
|
|
ring: true,
|
|
hoverLabel: true,
|
|
wave: true,
|
|
avatar: true
|
|
},
|
|
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, vp.display_name AS visitor_display_name,
|
|
(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
|
|
LEFT JOIN visitor_profiles vp ON vp.site_id = c.site_id AND vp.visitor_token = c.visitor_token
|
|
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() });
|
|
}
|
|
|
|
async function updateVisitorProfile(req, res, url) {
|
|
const site = getDefaultSite();
|
|
const visitorToken = decodeURIComponent(url.pathname.split("/").pop() || "");
|
|
if (!visitorToken) return json(res, 400, { error: "visitor_required" });
|
|
const body = await readJson(req);
|
|
const displayName = normalizeDisplayName(body.displayName);
|
|
|
|
db.prepare(`
|
|
INSERT INTO visitor_profiles (company_id, site_id, visitor_token, display_name)
|
|
VALUES (?, ?, ?, ?)
|
|
ON CONFLICT(site_id, visitor_token) DO UPDATE SET
|
|
display_name = excluded.display_name,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
`).run(site.company_id, site.id, visitorToken, displayName);
|
|
|
|
const conversations = db.prepare(`
|
|
SELECT public_id FROM conversations
|
|
WHERE site_id = ? AND visitor_token = ?
|
|
ORDER BY datetime(last_message_at) DESC
|
|
`).all(site.id, visitorToken);
|
|
const current = currentVisitors();
|
|
publish("admin", { type: "visitors:update", visitors: current });
|
|
for (const item of conversations) {
|
|
const conversation = findConversation(item.public_id);
|
|
publishConversation(conversation, { type: "conversation:update", conversation: formatConversationSummary(conversation) });
|
|
}
|
|
return json(res, 200, {
|
|
ok: true,
|
|
visitorToken,
|
|
displayName,
|
|
label: displayName || visitorLabel(visitorToken)
|
|
});
|
|
}
|
|
|
|
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, vp.display_name AS visitor_display_name,
|
|
(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
|
|
LEFT JOIN visitor_profiles vp ON vp.site_id = vs.site_id AND vp.visitor_token = vs.visitor_token
|
|
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: row.visitor_display_name || visitorLabel(row.visitor_token),
|
|
defaultLabel: visitorLabel(row.visitor_token),
|
|
visitorAlias: row.visitor_display_name || "",
|
|
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: row.visitor_display_name || visitorLabel(row.visitor_token),
|
|
visitorDefaultLabel: visitorLabel(row.visitor_token),
|
|
visitorAlias: row.visitor_display_name || "",
|
|
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),
|
|
countryFlag: (row.country_code || countryFromTimezone(row.timezone) || countryFromLanguage(row.language))
|
|
? flagEmoji(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 || {}) };
|
|
settings.launcherEffects = { ...defaultSettings().launcherEffects, ...(settings.launcherEffects || {}) };
|
|
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, vp.display_name AS visitor_display_name,
|
|
(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
|
|
LEFT JOIN visitor_profiles vp ON vp.site_id = c.site_id AND vp.visitor_token = c.visitor_token
|
|
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 normalizeDisplayName(value) {
|
|
const text = String(value || "").trim().replace(/\s+/g, " ");
|
|
return text ? text.slice(0, 80) : null;
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|