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 OPENAI_API_KEY = process.env.OPENAI_API_KEY || ""; const OPENAI_TRANSLATION_MODEL = process.env.OPENAI_TRANSLATION_MODEL || "gpt-5.6-luna"; 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") { res.setHeader("Cache-Control", "no-store, max-age=0"); 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, customer_language 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, client_message_id TEXT, original_language TEXT, translated_body TEXT, translated_language TEXT, translation_status 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"); ensureColumn("conversations", "customer_language", "TEXT"); ensureColumn("messages", "client_message_id", "TEXT"); ensureColumn("messages", "original_language", "TEXT"); ensureColumn("messages", "translated_body", "TEXT"); ensureColumn("messages", "translated_language", "TEXT"); ensureColumn("messages", "translation_status", "TEXT"); db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_client_id ON messages(conversation_id, client_message_id) WHERE client_message_id IS NOT NULL"); } 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, avatar: true }, notifications: { pulseOnAdminReply: true, badgeOnAdminReply: true, labelOnAdminReply: true, wiggleOnAdminReply: true }, translations: { enabled: false, operatorLanguage: "cs", model: OPENAI_TRANSLATION_MODEL } }; } 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 clientMessageId = normalizeClientMessageId(body.clientMessageId); const existingConversation = findConversationByClientMessage(site.id, visitorToken, clientMessageId); if (existingConversation) return json(res, 200, conversationDetails(existingConversation)); 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(), clientMessageId); saveAttachments(conversationId, messageId, body.attachments || []); return { conversationId, messageId }; }); const insert = insertConversation(); await translateStoredMessage(insert.conversationId, insert.messageId, "visitor", body); 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 clientMessageId = normalizeClientMessageId(body.clientMessageId); if (clientMessageId && messageExists(conversation.id, clientMessageId)) { updateVisitorContext(conversation.id, body); return json(res, 200, conversationDetails(findConversation(conversation.public_id))); } const messageId = insertMessage(conversation.id, "visitor", conversation.visitor_name || "Visitor", String(body.message || "").trim(), clientMessageId); saveAttachments(conversation.id, messageId, body.attachments || []); updateVisitorContext(conversation.id, body); await translateStoredMessage(conversation.id, messageId, "visitor", 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 || []); await translateStoredMessage(conversation.id, messageId, "admin", body); 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, clientMessageId = null) { return db.prepare(` INSERT INTO messages (conversation_id, sender_type, sender_name, client_message_id, body, translation_status) VALUES (?, ?, ?, ?, ?, ?) `).run(conversationId, senderType, senderName, clientMessageId, body, body ? "pending" : "skipped").lastInsertRowid; } function messageExists(conversationId, clientMessageId) { return Boolean(db.prepare("SELECT 1 FROM messages WHERE conversation_id = ? AND client_message_id = ?").get(conversationId, clientMessageId)); } function findConversationByClientMessage(siteId, visitorToken, clientMessageId) { if (!clientMessageId) return null; const row = db.prepare(` SELECT c.public_id FROM messages m JOIN conversations c ON c.id = m.conversation_id WHERE c.site_id = ? AND c.visitor_token = ? AND m.client_message_id = ? ORDER BY m.id DESC LIMIT 1 `).get(siteId, visitorToken, clientMessageId); return row ? findConversation(row.public_id) : null; } function normalizeClientMessageId(value) { const text = String(value || "").trim(); return /^[a-zA-Z0-9_-]{8,80}$/.test(text) ? text : null; } async function translateStoredMessage(conversationId, messageId, senderType, body) { const message = db.prepare("SELECT * FROM messages WHERE id = ? AND conversation_id = ?").get(messageId, conversationId); if (!message || !message.body.trim()) return markMessageTranslation(messageId, null, null, null, "skipped"); const conversation = findConversationById(conversationId); const settings = getTranslationSettings(); if (!settings.enabled) return markMessageTranslation(messageId, null, null, null, "disabled"); if (!OPENAI_API_KEY) return markMessageTranslation(messageId, null, null, null, "missing_key"); const targetLanguage = senderType === "visitor" ? settings.operatorLanguage : normalizeLanguage(conversation.customer_language || conversation.language || body.language || settings.operatorLanguage); if (!targetLanguage) return markMessageTranslation(messageId, null, null, null, "skipped"); try { const result = await translateText(message.body, targetLanguage, settings.model); const sourceLanguage = normalizeLanguage(result.sourceLanguage); const translatedText = String(result.translatedText || "").trim(); const translatedLanguage = normalizeLanguage(result.translatedLanguage || targetLanguage); const sameLanguage = sourceLanguage && translatedLanguage && sourceLanguage === translatedLanguage; if (senderType === "visitor" && sourceLanguage) { db.prepare("UPDATE conversations SET customer_language = COALESCE(?, customer_language), updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(sourceLanguage, conversationId); } if (!translatedText || sameLanguage) { return markMessageTranslation(messageId, sourceLanguage, null, translatedLanguage, sameLanguage ? "same_language" : "empty"); } markMessageTranslation(messageId, sourceLanguage, translatedText, translatedLanguage, "translated"); } catch (error) { console.error("translation_failed", error.message); markMessageTranslation(messageId, null, null, targetLanguage, "failed"); } } function markMessageTranslation(messageId, sourceLanguage, translatedBody, translatedLanguage, status) { db.prepare(` UPDATE messages SET original_language = ?, translated_body = ?, translated_language = ?, translation_status = ? WHERE id = ? `).run(sourceLanguage, translatedBody, translatedLanguage, status, messageId); } async function translateText(text, targetLanguage, model) { const response = await fetch("https://api.openai.com/v1/responses", { method: "POST", headers: { "Authorization": `Bearer ${OPENAI_API_KEY}`, "Content-Type": "application/json" }, body: JSON.stringify({ model, input: [ { role: "system", content: "You translate live chat messages for an e-commerce support team. Return only compact JSON with keys sourceLanguage, translatedLanguage, translatedText. Preserve meaning, product terms, numbers, names, URLs, and tone. Do not answer the customer." }, { role: "user", content: JSON.stringify({ targetLanguage, text }) } ] }) }); if (!response.ok) throw new Error(`OpenAI HTTP ${response.status}`); const data = await response.json(); return parseTranslationJson(outputTextFromResponse(data)); } function parseTranslationJson(value) { try { const text = String(value || "").trim().replace(/^```(?:json)?/i, "").replace(/```$/, "").trim(); const parsed = JSON.parse(text); return { sourceLanguage: parsed.sourceLanguage, translatedLanguage: parsed.translatedLanguage, translatedText: parsed.translatedText }; } catch { throw new Error("OpenAI translation response was not valid JSON"); } } function outputTextFromResponse(data) { if (data.output_text) return data.output_text; const chunks = []; for (const item of data.output || []) { for (const content of item.content || []) { if (content.type === "output_text" && content.text) chunks.push(content.text); if (content.type === "text" && content.text) chunks.push(content.text); } } return chunks.join(""); } function getTranslationSettings() { const site = getDefaultSite(); const settings = parseJson(site.settings_json, defaultSettings()); const translations = { ...defaultSettings().translations, ...(settings.translations || {}) }; return { enabled: Boolean(translations.enabled), operatorLanguage: normalizeLanguage(translations.operatorLanguage) || "cs", model: String(translations.model || OPENAI_TRANSLATION_MODEL).trim() || OPENAI_TRANSLATION_MODEL }; } function normalizeLanguage(value) { const text = String(value || "").trim().toLowerCase().replace("_", "-"); if (!text) return ""; return text.split("-")[0].slice(0, 8); } 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, originalLanguage: message.original_language, translatedBody: message.translated_body, translatedLanguage: message.translated_language, translationStatus: message.translation_status, 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, customerLanguage: row.customer_language || "", 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 || {}) }; settings.translations = { ...defaultSettings().translations, ...(settings.translations || {}) }; return { id: site.id, siteKey: site.site_key, name: site.name, domain: site.domain, isOnline: Boolean(site.is_online), settings }; } function snippetFor(site) { return ` `; } 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 findConversationById(id) { const row = db.prepare("SELECT public_id FROM conversations WHERE id = ?").get(id); return row ? findConversation(row.public_id) : null; } 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; } }