From f5c32d3c2da44d02a4129a081be63a6abed6d554 Mon Sep 17 00:00:00 2001 From: rajchales-monito Date: Thu, 23 Jul 2026 20:29:57 +0200 Subject: [PATCH] Initial MaalFlows live chat scaffold --- .env.example | 19 ++ .gitignore | 6 + README.md | 45 +++ package-lock.json | 464 ++++++++++++++++++++++++++++++ package.json | 15 + public/admin.css | 171 ++++++++++++ public/admin.html | 84 ++++++ public/admin.js | 257 +++++++++++++++++ public/preview.html | 56 ++++ public/widget.js | 277 ++++++++++++++++++ server.js | 666 ++++++++++++++++++++++++++++++++++++++++++++ 11 files changed, 2060 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 public/admin.css create mode 100644 public/admin.html create mode 100644 public/admin.js create mode 100644 public/preview.html create mode 100644 public/widget.js create mode 100644 server.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..eb97c72 --- /dev/null +++ b/.env.example @@ -0,0 +1,19 @@ +# MaalFlows - local/prod env template +NODE_ENV=development +PORT=3400 +PUBLIC_BASE_URL=http://localhost:3400 +APP_NAME=MaalFlows + +# Generate for production: +# node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +AUTH_SECRET=change-me-generate-long-random-secret + +# Admin users, extend with USER_2/PASS_2, USER_3/PASS_3, ... +USER_1=admin +PASS_1=change-me + +# Seed first company/site +DEFAULT_COMPANY_NAME=MAAL +DEFAULT_SITE_KEY=9b-plus +DEFAULT_SITE_NAME=9b-plus support +DEFAULT_SITE_DOMAIN=https://www.9b-plus.com diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d0586c7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +.env +data/*.sqlite +data/*.sqlite-* +data/uploads/ +npm-debug.log* diff --git a/README.md b/README.md new file mode 100644 index 0000000..4fbd825 --- /dev/null +++ b/README.md @@ -0,0 +1,45 @@ +# MaalFlows + +Samostatny live chat widget pro vlozeni pres GTM. Prvni verze je realny live chat bez AI. + +## Co uz kostra obsahuje + +- Node.js server bez frameworku, SQLite pres `better-sqlite3`. +- Multi-company / multi-site / multi-user schema. +- Admin login pres `.env` hodnoty `USER_1/PASS_1`, rozsiritelne o `USER_2/PASS_2`. +- Widget vlozitelny pres GTM, konfigurovatelny pres `PUBLIC_BASE_URL`. +- Zprava zakaznika je povinna, jmeno/email/telefon jsou schovane v rozbalovacim detailu. +- Realtime pres SSE: nove zpravy, nove konverzace, typing indikace. +- Rucni online/offline prepinac v adminu. +- Stavy konverzace: `new`, `open`, `resolved`, `spam`. +- Zaklad pro prilohy/fotky: schema, file input ve widgetu, ulozeni souboru do `data/uploads`. +- Sber URL, referreru, historie prochazeni v session, device/browser info, jazyka, timezone a IP/country placeholderu. + +## Lokalni spusteni + +```powershell +cd D:\Codex\01-projekty\maalflows +Copy-Item .env.example .env +npm.cmd install +npm.cmd run dev +``` + +Otevri: + +- admin: http://localhost:3400/admin +- test widgetu: http://localhost:3400/preview + +## GTM snippet + +```html + + +``` + +V produkci nastav `PUBLIC_BASE_URL` na cilovou domenu nebo subdomenu, odkud se bude nacitat `widget.js`. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..ec78203 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,464 @@ +{ + "name": "maalflows", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "maalflows", + "version": "0.1.0", + "dependencies": { + "better-sqlite3": "^11.10.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/better-sqlite3": { + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", + "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/node-abi": { + "version": "3.94.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz", + "integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tar-fs": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", + "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..e7bf327 --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "maalflows", + "version": "0.1.0", + "private": true, + "description": "MaalFlows live chat widget and admin.", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "node --watch server.js", + "check": "node --check server.js && node --check public/widget.js && node --check public/admin.js" + }, + "dependencies": { + "better-sqlite3": "^11.10.0" + } +} diff --git a/public/admin.css b/public/admin.css new file mode 100644 index 0000000..4f0b7d3 --- /dev/null +++ b/public/admin.css @@ -0,0 +1,171 @@ +:root { + color-scheme: light; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --line: #dfe6e2; + --ink: #17211d; + --muted: #627067; + --brand: #0f8f6f; + --bg: #f5f7f4; + --panel: #ffffff; +} + +* { box-sizing: border-box; } +body { margin: 0; background: var(--bg); color: var(--ink); } +button, input, textarea, select { font: inherit; } +button { + border: 0; + background: var(--brand); + color: white; + border-radius: 6px; + padding: 10px 14px; + cursor: pointer; +} +button:hover { filter: brightness(0.96); } +input, textarea, select { + width: 100%; + border: 1px solid var(--line); + border-radius: 6px; + padding: 9px 10px; + background: white; + color: var(--ink); +} +label { display: grid; gap: 6px; color: var(--muted); font-size: 13px; } + +.hidden { display: none !important; } +.shell { min-height: 100vh; } +.login { min-height: 100vh; display: grid; place-items: center; padding: 24px; } +.panel { + width: min(360px, 100%); + display: grid; + gap: 14px; + background: white; + border: 1px solid var(--line); + border-radius: 8px; + padding: 24px; +} +.panel h1 { margin: 0 0 6px; } +.error { min-height: 20px; color: #b42318; margin: 0; } + +.app { + min-height: 100vh; + display: grid; + grid-template-columns: 330px minmax(0, 1fr); +} +.sidebar { + border-right: 1px solid var(--line); + background: var(--panel); + display: grid; + grid-template-rows: auto auto minmax(0, 1fr); +} +.brand, .sitebar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 14px; + border-bottom: 1px solid var(--line); +} +.brand h1 { margin: 0; font-size: 20px; } +.brand button, .sitebar button { background: #edf3ef; color: var(--ink); } +.switch { display: flex; align-items: center; gap: 8px; } +.switch input { width: auto; } + +.conversation-list { overflow: auto; } +.conversation-item { + width: 100%; + text-align: left; + border: 0; + border-bottom: 1px solid var(--line); + background: white; + color: var(--ink); + border-radius: 0; + padding: 12px 14px; + display: grid; + gap: 5px; +} +.conversation-item.active { background: #eaf6f1; } +.conversation-item.fresh { animation: pulse 1.2s ease-in-out 4; } +.row { display: flex; justify-content: space-between; gap: 10px; align-items: center; } +.status { + font-size: 12px; + color: white; + background: #456257; + border-radius: 999px; + padding: 2px 8px; +} +.snippet, .preview, .meta { color: var(--muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.conversation { display: grid; grid-template-rows: auto minmax(0, 1fr) auto auto; min-width: 0; } +.conversation-header { + min-height: 74px; + padding: 16px 20px; + background: white; + border-bottom: 1px solid var(--line); + display: flex; + justify-content: space-between; + gap: 20px; +} +.conversation-header h2 { margin: 0 0 5px; font-size: 20px; } +.conversation-header p { margin: 0; color: var(--muted); font-size: 13px; } +.conversation-header select { width: 150px; align-self: start; } +.messages { + overflow: auto; + padding: 20px; + display: flex; + flex-direction: column; + gap: 12px; +} +.message { + max-width: min(680px, 86%); + padding: 10px 12px; + border-radius: 8px; + border: 1px solid var(--line); + background: white; +} +.message.admin { align-self: flex-end; background: #ecf8f3; border-color: #c7e8dc; } +.message .by { font-size: 12px; color: var(--muted); margin-bottom: 4px; } +.message p { margin: 0; white-space: pre-wrap; } +.message a { color: var(--brand); display: inline-block; margin-top: 6px; } +.typing { padding: 0 20px 10px; color: var(--brand); font-size: 13px; } +.reply { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + padding: 14px; + background: white; + border-top: 1px solid var(--line); +} + +.settings { + position: fixed; + inset: 0 0 0 auto; + width: min(430px, 100%); + overflow: auto; + background: white; + border-left: 1px solid var(--line); + padding: 16px; + box-shadow: -12px 0 30px rgb(0 0 0 / 12%); +} +.settings-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; } +.settings h2 { margin: 0 0 12px; } +.settings h3 { margin: 20px 0 8px; } +.settings form { display: grid; gap: 12px; } +pre { + white-space: pre-wrap; + overflow: auto; + padding: 12px; + border: 1px solid var(--line); + border-radius: 8px; + background: #f7faf8; +} + +@keyframes pulse { + 0%, 100% { background: white; } + 50% { background: #dff7eb; } +} + +@media (max-width: 820px) { + .app { grid-template-columns: 1fr; } + .sidebar { min-height: 42vh; border-right: 0; border-bottom: 1px solid var(--line); } + .conversation { min-height: 58vh; } +} diff --git a/public/admin.html b/public/admin.html new file mode 100644 index 0000000..0bc3381 --- /dev/null +++ b/public/admin.html @@ -0,0 +1,84 @@ + + + + + + MaalFlows Admin + + + +
+ + + +
+ + + diff --git a/public/admin.js b/public/admin.js new file mode 100644 index 0000000..7c10a04 --- /dev/null +++ b/public/admin.js @@ -0,0 +1,257 @@ +const state = { + site: null, + snippet: "", + conversations: [], + activeId: null, + active: null, + events: null +}; + +const $ = (selector) => document.querySelector(selector); + +boot(); + +async function boot() { + const me = await api("/api/admin/me").catch(() => null); + if (me?.user) showApp(); + else showLogin(); +} + +$("#loginForm").addEventListener("submit", async (event) => { + event.preventDefault(); + const form = new FormData(event.currentTarget); + const result = await api("/api/admin/login", { + method: "POST", + body: { username: form.get("username"), password: form.get("password") } + }).catch(() => null); + if (!result?.ok) { + $("#loginError").textContent = "Prihlaseni se nepodarilo."; + return; + } + showApp(); +}); + +$("#logoutButton").addEventListener("click", async () => { + await api("/api/admin/logout", { method: "POST" }); + location.reload(); +}); + +$("#settingsButton").addEventListener("click", () => $("#settingsPanel").classList.remove("hidden")); +$("#closeSettingsButton").addEventListener("click", () => $("#settingsPanel").classList.add("hidden")); + +$("#onlineToggle").addEventListener("change", async () => { + await saveSite({ isOnline: $("#onlineToggle").checked }); +}); + +$("#settingsForm").addEventListener("submit", async (event) => { + event.preventDefault(); + const form = new FormData(event.currentTarget); + const settings = structuredClone(state.site.settings); + settings.title.cs = form.get("titleCs"); + settings.intro.cs = form.get("introCs"); + settings.title.en = form.get("titleEn"); + settings.intro.en = form.get("introEn"); + settings.colors.brand = form.get("brand"); + settings.desktop.side = form.get("desktopSide"); + settings.desktop.bottomPx = Number(form.get("desktopBottom")); + settings.desktop.sideOffsetPx = Number(form.get("desktopOffset")); + settings.mobile.side = form.get("mobileSide"); + settings.mobile.bottomPx = Number(form.get("mobileBottom")); + settings.mobile.sideOffsetPx = Number(form.get("mobileOffset")); + await saveSite({ settings, isOnline: state.site.isOnline }); +}); + +$("#replyForm").addEventListener("submit", async (event) => { + event.preventDefault(); + if (!state.activeId) return; + const form = new FormData(event.currentTarget); + await api(`/api/admin/conversations/${state.activeId}/messages`, { + method: "POST", + body: { message: form.get("message") } + }); + event.currentTarget.reset(); + await openConversation(state.activeId); +}); + +$("#replyForm textarea").addEventListener("input", debounce(() => { + if (!state.activeId) return; + api(`/api/admin/conversations/${state.activeId}/typing`, { method: "POST" }).catch(() => {}); +}, 600)); + +$("#statusSelect").addEventListener("change", async () => { + if (!state.activeId) return; + await api(`/api/admin/conversations/${state.activeId}/status`, { + method: "PATCH", + body: { status: $("#statusSelect").value } + }); + await loadConversations(); +}); + +async function showApp() { + $("#loginView").classList.add("hidden"); + $("#appView").classList.remove("hidden"); + await loadBootstrap(); + await loadConversations(); + connectEvents(); +} + +function showLogin() { + $("#loginView").classList.remove("hidden"); + $("#appView").classList.add("hidden"); +} + +async function loadBootstrap() { + const data = await api("/api/admin/bootstrap"); + state.site = data.site; + state.snippet = data.snippet; + renderSite(); +} + +async function loadConversations() { + const data = await api("/api/admin/conversations"); + state.conversations = data.conversations; + renderConversations(); +} + +async function openConversation(id) { + state.activeId = id; + const data = await api(`/api/admin/conversations/${id}`); + state.active = data; + renderConversations(); + renderActive(); +} + +function connectEvents() { + if (state.events) state.events.close(); + state.events = new EventSource("/api/admin/events"); + state.events.addEventListener("message", async (event) => { + const data = JSON.parse(event.data); + if (data.type === "typing" && data.actor === "visitor" && data.conversationId !== state.activeId) return; + if (data.type === "typing" && data.actor === "visitor") return flashTyping(); + if (data.type === "site:update") { + state.site = data.site; + renderSite(); + return; + } + await loadConversations(); + if (state.activeId) await openConversation(state.activeId); + if (data.type === "conversation:new" || data.type === "message:new") notify(); + }); +} + +function renderSite() { + $("#onlineToggle").checked = state.site.isOnline; + $("#snippet").textContent = state.snippet; + const s = state.site.settings; + const form = $("#settingsForm"); + form.titleCs.value = s.title.cs || ""; + form.introCs.value = s.intro.cs || ""; + form.titleEn.value = s.title.en || ""; + form.introEn.value = s.intro.en || ""; + form.brand.value = s.colors.brand || "#0f8f6f"; + form.desktopSide.value = s.desktop.side || "right"; + form.desktopBottom.value = s.desktop.bottomPx || 22; + form.desktopOffset.value = s.desktop.sideOffsetPx || 22; + form.mobileSide.value = s.mobile.side || "right"; + form.mobileBottom.value = s.mobile.bottomPx || 14; + form.mobileOffset.value = s.mobile.sideOffsetPx || 12; +} + +function renderConversations() { + $("#conversationList").innerHTML = state.conversations.map((item) => ` + + `).join(""); + for (const button of document.querySelectorAll(".conversation-item")) { + button.addEventListener("click", () => openConversation(button.dataset.id)); + } +} + +function renderActive() { + const { conversation, messages } = state.active; + const statusSelect = $("#statusSelect"); + $("#conversationHeader").innerHTML = ` +
+

${escapeHtml(conversation.visitor.name || conversation.visitor.email || "Navstevnik")}

+

${flag(conversation.countryCode)} ${escapeHtml(conversation.currentUrl || "")}

+

${escapeHtml(conversation.device.userAgent || "")}

+
+ `; + $("#conversationHeader").append(statusSelect); + statusSelect.classList.remove("hidden"); + statusSelect.value = conversation.status; + $("#replyForm").classList.remove("hidden"); + $("#messages").innerHTML = messages.map((message) => ` +
+
${escapeHtml(message.senderName || message.senderType)} · ${escapeHtml(message.createdAt)}
+

${escapeHtml(message.body)}

+ ${message.attachments.map((a) => `${escapeHtml(a.name)}`).join("")} +
+ `).join(""); + $("#messages").scrollTop = $("#messages").scrollHeight; +} + +async function saveSite(patch) { + const data = await api("/api/admin/site", { method: "PATCH", body: patch }); + state.site = data.site; + state.snippet = data.snippet; + renderSite(); +} + +function notify() { + document.title = "* Nova zprava - MaalFlows"; + const audio = new AudioContext(); + const osc = audio.createOscillator(); + const gain = audio.createGain(); + osc.frequency.value = 880; + gain.gain.value = 0.04; + osc.connect(gain); + gain.connect(audio.destination); + osc.start(); + setTimeout(() => { + osc.stop(); + audio.close(); + }, 160); +} + +function flashTyping() { + $("#typingNotice").classList.remove("hidden"); + clearTimeout(flashTyping.timer); + flashTyping.timer = setTimeout(() => $("#typingNotice").classList.add("hidden"), 1800); +} + +async function api(url, options = {}) { + const response = await fetch(url, { + method: options.method || "GET", + headers: { "Content-Type": "application/json" }, + body: options.body ? JSON.stringify(options.body) : undefined + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json(); +} + +function debounce(fn, delay) { + let timer; + return (...args) => { + clearTimeout(timer); + timer = setTimeout(() => fn(...args), delay); + }; +} + +function escapeHtml(value) { + return String(value ?? "").replace(/[&<>"']/g, (char) => ({ + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'" + })[char]); +} + +function flag(countryCode) { + if (!countryCode || countryCode.length !== 2) return ""; + return countryCode.toUpperCase().replace(/./g, (char) => String.fromCodePoint(127397 + char.charCodeAt())); +} diff --git a/public/preview.html b/public/preview.html new file mode 100644 index 0000000..fc657a2 --- /dev/null +++ b/public/preview.html @@ -0,0 +1,56 @@ + + + + + + MaalFlows Preview + + + +
+

9b-plus test stranka

+

Tahle stranka simuluje eshop, kam se widget vlozi pres GTM. Otevri bublinu vpravo dole, posli zpravu a sleduj ji v administraci.

+
+
Produkt

Ukazkova stranka produktu pro sbirani current URL.

+
Kosik

Referrer, jazyk, timezone a device info se poslou do konverzace.

+
Podpora

Zakaznik muze napsat hned, kontaktni udaje jsou volitelne.

+
+
+ + + + diff --git a/public/widget.js b/public/widget.js new file mode 100644 index 0000000..7702066 --- /dev/null +++ b/public/widget.js @@ -0,0 +1,277 @@ +(function () { + const boot = window.MaalFlows || {}; + const apiBase = (boot.apiBase || "").replace(/\/$/, "") || new URL(document.currentScript.src).origin; + const siteKey = boot.siteKey || "9b-plus"; + const lang = (boot.lang || document.documentElement.lang || navigator.language || "cs").slice(0, 2).toLowerCase(); + const storageKey = `maalflows:${siteKey}`; + const session = loadSession(); + + fetch(`${apiBase}/api/widget/config?siteKey=${encodeURIComponent(siteKey)}`) + .then((res) => res.json()) + .then((config) => mount(config)) + .catch(() => {}); + + function mount(config) { + const site = config.site; + const settings = site.settings; + const copy = localized(settings, lang); + const host = document.createElement("div"); + host.id = "maalflows-widget"; + document.body.append(host); + const root = host.attachShadow({ mode: "open" }); + + root.innerHTML = ` + + +
+
+ ${escapeHtml(copy.title)} + ${escapeHtml(site.isOnline ? copy.intro : copy.offlineIntro)} + +
+
+ +
+
+ ${escapeHtml(copy.detailsLabel)} + + + +
+ +
+ + +
+

Odeslanim zpravy nam predavate udaje potrebne pro odpoved.

+
+
+ `; + + const panel = root.querySelector(".mf-panel"); + const launcher = root.querySelector(".mf-launcher"); + const close = root.querySelector(".mf-close"); + const form = root.querySelector(".mf-form"); + const messages = root.querySelector(".mf-messages"); + const typing = root.querySelector(".mf-typing"); + + launcher.addEventListener("click", () => panel.classList.toggle("open")); + close.addEventListener("click", () => panel.classList.remove("open")); + + form.message.addEventListener("input", debounce(() => { + if (!session.conversationId) return; + fetch(`${apiBase}/api/widget/conversations/${session.conversationId}/typing`, { method: "POST" }).catch(() => {}); + }, 700)); + + form.addEventListener("submit", async (event) => { + event.preventDefault(); + const body = await payloadFromForm(form); + if (!body.message.trim()) return; + addMessage(messages, "visitor", body.message, []); + form.message.value = ""; + form.attachments.value = ""; + + const url = session.conversationId + ? `${apiBase}/api/widget/conversations/${session.conversationId}/messages` + : `${apiBase}/api/widget/conversations`; + const data = await postJson(url, { + ...body, + siteKey, + visitorToken: session.visitorToken, + currentUrl: location.href, + referrer: document.referrer, + browsingHistory: rememberPage(), + language: navigator.language, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + device: { + userAgent: navigator.userAgent, + platform: navigator.platform, + viewport: `${innerWidth}x${innerHeight}` + } + }); + + session.conversationId = data.conversation.conversation.id; + session.visitorToken = data.visitorToken || session.visitorToken; + saveSession(); + renderConversation(messages, data.conversation); + connectEvents(messages, typing); + }); + + rememberPage(); + if (session.conversationId) connectEvents(messages, typing); + } + + async function payloadFromForm(form) { + const fd = new FormData(form); + const files = [...form.attachments.files].slice(0, 3); + return { + message: fd.get("message") || "", + visitorInfo: { + name: fd.get("name") || "", + email: fd.get("email") || "", + phone: fd.get("phone") || "" + }, + attachments: await Promise.all(files.map(fileToPayload)) + }; + } + + function connectEvents(messages, typing) { + if (session.events || !session.conversationId) return; + session.events = new EventSource(`${apiBase}/api/widget/conversations/${session.conversationId}/events`); + session.events.addEventListener("message", (event) => { + const data = JSON.parse(event.data); + if (data.type === "typing" && data.actor === "admin") { + typing.hidden = false; + clearTimeout(connectEvents.typingTimer); + connectEvents.typingTimer = setTimeout(() => { typing.hidden = true; }, 1800); + } + if (data.type === "message:new") renderConversation(messages, data.payload); + }); + } + + function renderConversation(messages, data) { + messages.innerHTML = ""; + for (const message of data.messages) { + addMessage(messages, message.senderType, message.body, message.attachments || []); + } + messages.scrollTop = messages.scrollHeight; + } + + function addMessage(messages, sender, body, attachments) { + const item = document.createElement("article"); + item.className = `mf-message ${sender}`; + item.innerHTML = `

${escapeHtml(body)}

${attachments.map((a) => `${escapeHtml(a.name)}`).join("")}`; + messages.append(item); + messages.scrollTop = messages.scrollHeight; + } + + function localized(settings, language) { + const pick = (obj) => obj?.[language] || obj?.en || obj?.cs || ""; + return { + title: pick(settings.title), + intro: pick(settings.intro), + offlineIntro: pick(settings.offlineIntro), + placeholder: pick(settings.placeholder), + sendLabel: pick(settings.sendLabel), + detailsLabel: pick(settings.detailsLabel) + }; + } + + function styles(settings) { + const c = settings.colors; + const desktop = settings.desktop; + const mobile = settings.mobile; + return ` + :host { all: initial; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: ${c.text}; } + * { box-sizing: border-box; } + button, input, textarea { font: inherit; } + .mf-launcher { + position: fixed; z-index: 2147483000; ${desktop.side}: ${desktop.sideOffsetPx}px; bottom: ${desktop.bottomPx}px; + width: 58px; height: 58px; border: 0; border-radius: 50%; background: ${c.brand}; color: ${c.brandText}; + box-shadow: 0 12px 30px rgb(0 0 0 / 22%); cursor: pointer; + } + .mf-launcher span, .mf-launcher span:before, .mf-launcher span:after { display: block; background: ${c.brandText}; height: 3px; border-radius: 4px; content: ""; } + .mf-launcher span { width: 26px; margin: 0 auto; } + .mf-launcher span:before { transform: translateY(-8px); } + .mf-launcher span:after { transform: translateY(5px); width: 18px; } + .mf-panel { + position: fixed; z-index: 2147483001; ${desktop.side}: ${desktop.sideOffsetPx}px; bottom: ${desktop.bottomPx + 72}px; + width: min(${desktop.widthPx}px, calc(100vw - 24px)); max-height: min(680px, calc(100vh - 110px)); display: none; + background: ${c.surface}; border: 1px solid #dfe6e2; border-radius: 8px; overflow: hidden; box-shadow: 0 18px 50px rgb(0 0 0 / 18%); + } + .mf-panel.open { display: grid; grid-template-rows: auto minmax(130px, 1fr) auto auto; } + header { position: relative; display: grid; gap: 3px; background: ${c.brand}; color: ${c.brandText}; padding: 15px 46px 15px 16px; } + header strong { font-size: 16px; } + header small { opacity: .92; font-size: 13px; line-height: 1.35; } + .mf-close { position: absolute; top: 9px; right: 9px; width: 30px; height: 30px; border: 0; border-radius: 6px; background: rgb(255 255 255 / 16%); color: ${c.brandText}; cursor: pointer; } + .mf-messages { padding: 14px; overflow: auto; display: flex; flex-direction: column; gap: 10px; } + .mf-message { max-width: 86%; padding: 9px 11px; border-radius: 8px; background: #f1f5f2; color: ${c.text}; } + .mf-message.admin { align-self: flex-start; background: #eef7f4; } + .mf-message.visitor { align-self: flex-end; background: ${c.brand}; color: ${c.brandText}; } + .mf-message p { margin: 0; white-space: pre-wrap; } + .mf-message a { color: inherit; display: inline-block; margin-top: 6px; } + .mf-typing { padding: 0 14px 8px; color: ${c.muted}; font-size: 13px; } + .mf-form { border-top: 1px solid #dfe6e2; padding: 12px; display: grid; gap: 9px; } + details { color: ${c.muted}; font-size: 13px; } + details[open] { display: grid; gap: 8px; } + summary { cursor: pointer; margin-bottom: 8px; } + input, textarea { width: 100%; border: 1px solid #dfe6e2; border-radius: 6px; padding: 9px 10px; color: ${c.text}; background: white; } + textarea { resize: vertical; min-height: 72px; } + .mf-actions { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 9px; align-items: center; } + .mf-actions button { border: 0; border-radius: 6px; background: ${c.brand}; color: ${c.brandText}; padding: 10px 13px; cursor: pointer; } + input[type=file] { font-size: 12px; padding: 7px; } + .mf-note { margin: 0; color: ${c.muted}; font-size: 11px; line-height: 1.3; } + @media (max-width: 640px) { + .mf-launcher { ${mobile.side}: ${mobile.sideOffsetPx}px; bottom: ${mobile.bottomPx}px; } + .mf-panel { ${mobile.side}: ${mobile.sideOffsetPx}px; bottom: ${mobile.bottomPx + 68}px; width: min(${mobile.widthPx}px, calc(100vw - 24px)); max-height: calc(100vh - 96px); } + } + `; + } + + function rememberPage() { + const entry = { url: location.href, title: document.title, at: new Date().toISOString() }; + session.history = (session.history || []).filter((item) => item.url !== entry.url).concat(entry).slice(-20); + saveSession(); + return session.history; + } + + function loadSession() { + try { + const existing = JSON.parse(localStorage.getItem(storageKey) || "{}"); + return { visitorToken: existing.visitorToken || randomId(), conversationId: existing.conversationId || null, history: existing.history || [] }; + } catch { + return { visitorToken: randomId(), conversationId: null, history: [] }; + } + } + + function saveSession() { + localStorage.setItem(storageKey, JSON.stringify({ + visitorToken: session.visitorToken, + conversationId: session.conversationId, + history: session.history || [] + })); + } + + function postJson(url, payload) { + return fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }).then((res) => { + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json(); + }); + } + + function fileToPayload(file) { + return new Promise((resolve) => { + const reader = new FileReader(); + reader.onload = () => resolve({ name: file.name, type: file.type, size: file.size, dataBase64: reader.result }); + reader.readAsDataURL(file); + }); + } + + function debounce(fn, delay) { + let timer; + return (...args) => { + clearTimeout(timer); + timer = setTimeout(() => fn(...args), delay); + }; + } + + function escapeHtml(value) { + return String(value ?? "").replace(/[&<>"']/g, (char) => ({ + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'" + })[char]); + } + + function randomId() { + return `vis_${Math.random().toString(16).slice(2)}${Date.now().toString(16)}`; + } +})(); diff --git a/server.js b/server.js new file mode 100644 index 0000000..c05b03b --- /dev/null +++ b/server.js @@ -0,0 +1,666 @@ +const http = require("http"); +const fs = require("fs"); +const path = require("path"); +const crypto = require("crypto"); +const Database = require("better-sqlite3"); + +const ROOT = __dirname; +const DATA_DIR = path.join(ROOT, "data"); +const PUBLIC_DIR = path.join(ROOT, "public"); +const UPLOAD_DIR = path.join(DATA_DIR, "uploads"); +const DB_PATH = path.join(DATA_DIR, "maalflows.sqlite"); + +loadDotEnv(path.join(ROOT, ".env")); + +const PORT = Number(process.env.PORT || 3400); +const PUBLIC_BASE_URL = (process.env.PUBLIC_BASE_URL || `http://localhost:${PORT}`).replace(/\/$/, ""); +const AUTH_SECRET = process.env.AUTH_SECRET || "dev-secret-change-me"; +const COOKIE_NAME = "mf_session"; + +fs.mkdirSync(DATA_DIR, { recursive: true }); +fs.mkdirSync(UPLOAD_DIR, { recursive: true }); + +const db = new Database(DB_PATH); +db.pragma("journal_mode = WAL"); +db.pragma("foreign_keys = ON"); +initDb(); +seedDefaults(); + +const streams = new Map(); + +const server = http.createServer(async (req, res) => { + try { + await route(req, res); + } catch (error) { + console.error(error); + json(res, 500, { error: "internal_error" }); + } +}); + +server.listen(PORT, () => { + console.log(`MaalFlows running on http://localhost:${PORT}`); +}); + +async function route(req, res) { + const url = new URL(req.url, PUBLIC_BASE_URL); + setCommonHeaders(res); + + if (req.method === "OPTIONS") return endCors(res); + if (req.method === "GET" && url.pathname === "/health") return json(res, 200, { ok: true }); + if (req.method === "GET" && url.pathname === "/admin") return file(res, path.join(PUBLIC_DIR, "admin.html"), "text/html; charset=utf-8"); + if (req.method === "GET" && url.pathname === "/preview") return file(res, path.join(PUBLIC_DIR, "preview.html"), "text/html; charset=utf-8"); + if (req.method === "GET" && url.pathname.startsWith("/uploads/")) return serveUpload(res, url.pathname); + if (req.method === "GET" && url.pathname === "/widget.js") return file(res, path.join(PUBLIC_DIR, "widget.js"), "application/javascript; charset=utf-8"); + if (req.method === "GET" && url.pathname.startsWith("/admin.")) return servePublic(res, url.pathname); + + if (url.pathname === "/api/admin/login" && req.method === "POST") return adminLogin(req, res); + if (url.pathname === "/api/admin/logout" && req.method === "POST") return adminLogout(res); + if (url.pathname.startsWith("/api/admin/")) return requireAdmin(req, res, () => adminApi(req, res, url)); + + if (url.pathname === "/api/widget/config" && req.method === "GET") return widgetConfig(res, url); + if (url.pathname === "/api/widget/conversations" && req.method === "POST") return createConversation(req, res); + if (url.pathname.match(/^\/api\/widget\/conversations\/[^/]+\/messages$/) && req.method === "POST") return createVisitorMessage(req, res, url); + if (url.pathname.match(/^\/api\/widget\/conversations\/[^/]+\/events$/) && req.method === "GET") return eventStream(req, res, url, "visitor"); + if (url.pathname.match(/^\/api\/widget\/conversations\/[^/]+\/typing$/) && req.method === "POST") return visitorTyping(req, res, url); + + return json(res, 404, { error: "not_found" }); +} + +async function adminApi(req, res, url) { + if (url.pathname === "/api/admin/me" && req.method === "GET") return json(res, 200, { user: req.adminUser }); + if (url.pathname === "/api/admin/bootstrap" && req.method === "GET") return adminBootstrap(res); + if (url.pathname === "/api/admin/events" && req.method === "GET") return eventStream(req, res, url, "admin"); + if (url.pathname === "/api/admin/site" && req.method === "PATCH") return updateSite(req, res); + if (url.pathname === "/api/admin/conversations" && req.method === "GET") return adminConversations(res); + if (url.pathname.match(/^\/api\/admin\/conversations\/[^/]+$/) && req.method === "GET") return adminConversation(res, url); + if (url.pathname.match(/^\/api\/admin\/conversations\/[^/]+\/messages$/) && req.method === "POST") return createAdminMessage(req, res, url); + if (url.pathname.match(/^\/api\/admin\/conversations\/[^/]+\/status$/) && req.method === "PATCH") return updateConversationStatus(req, res, url); + if (url.pathname.match(/^\/api\/admin\/conversations\/[^/]+\/typing$/) && req.method === "POST") return adminTyping(req, res, url); + return json(res, 404, { error: "not_found" }); +} + +function initDb() { + db.exec(` + CREATE TABLE IF NOT EXISTS companies ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS sites ( + id INTEGER PRIMARY KEY, + company_id INTEGER NOT NULL REFERENCES companies(id), + site_key TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + domain TEXT, + is_online INTEGER NOT NULL DEFAULT 1, + settings_json TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS admin_users ( + id INTEGER PRIMARY KEY, + company_id INTEGER NOT NULL REFERENCES companies(id), + username TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS conversations ( + id INTEGER PRIMARY KEY, + public_id TEXT NOT NULL UNIQUE, + company_id INTEGER NOT NULL REFERENCES companies(id), + site_id INTEGER NOT NULL REFERENCES sites(id), + status TEXT NOT NULL DEFAULT 'new', + visitor_token TEXT NOT NULL, + visitor_name TEXT, + visitor_email TEXT, + visitor_phone TEXT, + current_url TEXT, + referrer TEXT, + browsing_history_json TEXT NOT NULL DEFAULT '[]', + device_json TEXT NOT NULL DEFAULT '{}', + language TEXT, + timezone TEXT, + ip TEXT, + country_code TEXT, + vpn_risk INTEGER NOT NULL DEFAULT 0, + last_message_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY, + conversation_id INTEGER NOT NULL REFERENCES conversations(id), + sender_type TEXT NOT NULL, + sender_name TEXT, + body TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS attachments ( + id INTEGER PRIMARY KEY, + conversation_id INTEGER NOT NULL REFERENCES conversations(id), + message_id INTEGER REFERENCES messages(id), + original_name TEXT NOT NULL, + mime_type TEXT NOT NULL, + byte_size INTEGER NOT NULL, + storage_path TEXT NOT NULL, + public_url TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE INDEX IF NOT EXISTS idx_conversations_site_status ON conversations(site_id, status, last_message_at); + CREATE INDEX IF NOT EXISTS idx_messages_conversation ON messages(conversation_id, created_at); + `); +} + +function seedDefaults() { + const companyName = process.env.DEFAULT_COMPANY_NAME || "MAAL"; + const siteKey = process.env.DEFAULT_SITE_KEY || "9b-plus"; + const existingCompany = db.prepare("SELECT id FROM companies LIMIT 1").get(); + const companyId = existingCompany?.id || db.prepare("INSERT INTO companies (name) VALUES (?)").run(companyName).lastInsertRowid; + + const settings = defaultSettings(); + const existingSite = db.prepare("SELECT id FROM sites WHERE site_key = ?").get(siteKey); + if (!existingSite) { + db.prepare(` + INSERT INTO sites (company_id, site_key, name, domain, settings_json) + VALUES (?, ?, ?, ?, ?) + `).run( + companyId, + siteKey, + process.env.DEFAULT_SITE_NAME || "9b-plus support", + process.env.DEFAULT_SITE_DOMAIN || "https://www.9b-plus.com", + JSON.stringify(settings) + ); + } + + for (const user of configuredUsers()) { + db.prepare(` + INSERT OR IGNORE INTO admin_users (company_id, username, display_name) + VALUES (?, ?, ?) + `).run(companyId, user.username, user.username); + } +} + +function defaultSettings() { + return { + title: { cs: "Potrebujete poradit?", en: "Need help?" }, + intro: { cs: "Napiste nam. Odpovime co nejdrive.", en: "Message us and we will reply as soon as possible." }, + offlineIntro: { cs: "Ted nejsme online, ale zpravu nam muzete nechat.", en: "We are offline, but you can leave us a message." }, + placeholder: { cs: "Napiste zpravu...", en: "Write a message..." }, + sendLabel: { cs: "Odeslat", en: "Send" }, + detailsLabel: { cs: "Kontaktni udaje", en: "Contact details" }, + colors: { + brand: "#0f8f6f", + brandText: "#ffffff", + surface: "#ffffff", + text: "#17211d", + muted: "#647067" + }, + desktop: { side: "right", bottomPx: 22, sideOffsetPx: 22, widthPx: 370 }, + mobile: { side: "right", bottomPx: 14, sideOffsetPx: 12, widthPx: 340 } + }; +} + +function configuredUsers() { + const users = []; + for (let i = 1; i < 50; i += 1) { + const username = process.env[`USER_${i}`]; + const password = process.env[`PASS_${i}`]; + if (!username && !password) break; + if (username && password) users.push({ username, password }); + } + return users; +} + +async function adminLogin(req, res) { + const body = await readJson(req); + const match = configuredUsers().find((u) => u.username === body.username && u.password === body.password); + if (!match) return json(res, 401, { error: "bad_credentials" }); + const token = signSession(match.username); + res.setHeader("Set-Cookie", `${COOKIE_NAME}=${token}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${60 * 60 * 24 * 14}`); + return json(res, 200, { ok: true, user: { username: match.username } }); +} + +function adminLogout(res) { + res.setHeader("Set-Cookie", `${COOKIE_NAME}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0`); + return json(res, 200, { ok: true }); +} + +function requireAdmin(req, res, next) { + const token = parseCookies(req.headers.cookie || "")[COOKIE_NAME]; + const username = token && verifySession(token); + if (!username) return json(res, 401, { error: "unauthorized" }); + req.adminUser = { username }; + return next(); +} + +function adminBootstrap(res) { + const site = getDefaultSite(); + return json(res, 200, { + publicBaseUrl: PUBLIC_BASE_URL, + site: formatSite(site), + snippet: snippetFor(site) + }); +} + +function adminConversations(res) { + const rows = db.prepare(` + SELECT c.*, s.site_key, + (SELECT body FROM messages WHERE conversation_id = c.id ORDER BY id DESC LIMIT 1) AS last_body, + (SELECT COUNT(*) FROM messages WHERE conversation_id = c.id) AS message_count + FROM conversations c + JOIN sites s ON s.id = c.site_id + ORDER BY datetime(c.last_message_at) DESC, c.id DESC + LIMIT 200 + `).all(); + return json(res, 200, { conversations: rows.map(formatConversationSummary) }); +} + +function adminConversation(res, url) { + const conversation = findConversation(publicIdFrom(url)); + if (!conversation) return json(res, 404, { error: "conversation_not_found" }); + return json(res, 200, conversationDetails(conversation)); +} + +async function updateSite(req, res) { + const body = await readJson(req); + const site = getDefaultSite(); + const settings = { ...JSON.parse(site.settings_json), ...(body.settings || {}) }; + db.prepare(` + UPDATE sites + SET is_online = ?, settings_json = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + `).run(body.isOnline ? 1 : 0, JSON.stringify(settings), site.id); + const updated = getDefaultSite(); + publish("admin", { type: "site:update", site: formatSite(updated) }); + return json(res, 200, { site: formatSite(updated), snippet: snippetFor(updated) }); +} + +async function updateConversationStatus(req, res, url) { + const body = await readJson(req); + const status = ["new", "open", "resolved", "spam"].includes(body.status) ? body.status : null; + if (!status) return json(res, 400, { error: "bad_status" }); + const conversation = findConversation(publicIdFrom(url)); + if (!conversation) return json(res, 404, { error: "conversation_not_found" }); + db.prepare("UPDATE conversations SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(status, conversation.id); + const updated = findConversation(conversation.public_id); + publishConversation(updated, { type: "conversation:status", conversation: formatConversationSummary(updated) }); + return json(res, 200, { conversation: formatConversationSummary(updated) }); +} + +function widgetConfig(res, url) { + const siteKey = url.searchParams.get("siteKey") || process.env.DEFAULT_SITE_KEY || "9b-plus"; + const site = db.prepare("SELECT * FROM sites WHERE site_key = ?").get(siteKey); + if (!site) return json(res, 404, { error: "site_not_found" }); + return json(res, 200, { site: formatSite(site), publicBaseUrl: PUBLIC_BASE_URL }); +} + +async function createConversation(req, res) { + const body = await readJson(req, 2_000_000); + if (!body.message || !String(body.message).trim()) return json(res, 400, { error: "message_required" }); + const site = db.prepare("SELECT * FROM sites WHERE site_key = ?").get(body.siteKey || "9b-plus"); + if (!site) return json(res, 404, { error: "site_not_found" }); + + const visitorToken = body.visitorToken || id("vis"); + const publicId = id("cnv"); + const info = body.visitorInfo || {}; + const ip = clientIp(req); + + const insertConversation = db.transaction(() => { + const conversationId = db.prepare(` + INSERT INTO conversations ( + public_id, company_id, site_id, visitor_token, visitor_name, visitor_email, visitor_phone, + current_url, referrer, browsing_history_json, device_json, language, timezone, ip, country_code + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + publicId, + site.company_id, + site.id, + visitorToken, + nullish(info.name), + nullish(info.email), + nullish(info.phone), + nullish(body.currentUrl), + nullish(body.referrer), + JSON.stringify(body.browsingHistory || []), + JSON.stringify(body.device || {}), + nullish(body.language), + nullish(body.timezone), + ip, + nullish(countryFromHeaders(req)) + ).lastInsertRowid; + + const messageId = insertMessage(conversationId, "visitor", info.name || "Visitor", String(body.message).trim()); + saveAttachments(conversationId, messageId, body.attachments || []); + return conversationId; + }); + const insert = insertConversation(); + + const conversation = db.prepare("SELECT * FROM conversations WHERE id = ?").get(insert); + publish("admin", { type: "conversation:new", conversation: formatConversationSummary(conversation) }); + publishConversation(conversation, { type: "message:new", payload: conversationDetails(conversation) }); + return json(res, 201, { conversation: conversationDetails(conversation), visitorToken }); +} + +async function createVisitorMessage(req, res, url) { + const body = await readJson(req, 2_000_000); + if (!body.message || !String(body.message).trim()) return json(res, 400, { error: "message_required" }); + const conversation = findConversation(publicIdFrom(url)); + if (!conversation) return json(res, 404, { error: "conversation_not_found" }); + const messageId = insertMessage(conversation.id, "visitor", conversation.visitor_name || "Visitor", String(body.message).trim()); + saveAttachments(conversation.id, messageId, body.attachments || []); + bumpConversation(conversation.id, conversation.status === "resolved" ? "open" : conversation.status); + const updated = findConversation(conversation.public_id); + publishConversation(updated, { type: "message:new", payload: conversationDetails(updated) }); + return json(res, 201, conversationDetails(updated)); +} + +async function createAdminMessage(req, res, url) { + const body = await readJson(req, 2_000_000); + if (!body.message || !String(body.message).trim()) return json(res, 400, { error: "message_required" }); + const conversation = findConversation(publicIdFrom(url)); + if (!conversation) return json(res, 404, { error: "conversation_not_found" }); + insertMessage(conversation.id, "admin", req.adminUser.username, String(body.message).trim()); + bumpConversation(conversation.id, conversation.status === "new" ? "open" : conversation.status); + const updated = findConversation(conversation.public_id); + publishConversation(updated, { type: "message:new", payload: conversationDetails(updated) }); + return json(res, 201, conversationDetails(updated)); +} + +async function visitorTyping(req, res, url) { + const conversation = findConversation(publicIdFrom(url)); + if (!conversation) return json(res, 404, { error: "conversation_not_found" }); + publishConversation(conversation, { type: "typing", actor: "visitor", conversationId: conversation.public_id }); + return json(res, 200, { ok: true }); +} + +async function adminTyping(req, res, url) { + const conversation = findConversation(publicIdFrom(url)); + if (!conversation) return json(res, 404, { error: "conversation_not_found" }); + publishConversation(conversation, { type: "typing", actor: "admin", conversationId: conversation.public_id }); + return json(res, 200, { ok: true }); +} + +function eventStream(req, res, url, channel) { + const key = channel === "admin" ? "admin" : `conversation:${publicIdFrom(url)}`; + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + "Access-Control-Allow-Origin": req.headers.origin || "*", + "Access-Control-Allow-Credentials": "true" + }); + res.write(`event: hello\ndata: ${JSON.stringify({ ok: true })}\n\n`); + const client = { res }; + if (!streams.has(key)) streams.set(key, new Set()); + streams.get(key).add(client); + req.on("close", () => streams.get(key)?.delete(client)); +} + +function publishConversation(conversation, event) { + publish("admin", event); + publish(`conversation:${conversation.public_id}`, event); +} + +function publish(key, event) { + const listeners = streams.get(key); + if (!listeners) return; + for (const client of listeners) { + client.res.write(`event: message\ndata: ${JSON.stringify(event)}\n\n`); + } +} + +function insertMessage(conversationId, senderType, senderName, body) { + return db.prepare(` + INSERT INTO messages (conversation_id, sender_type, sender_name, body) + VALUES (?, ?, ?, ?) + `).run(conversationId, senderType, senderName, body).lastInsertRowid; +} + +function saveAttachments(conversationId, messageId, attachments) { + for (const attachment of attachments.slice(0, 3)) { + if (!attachment.dataBase64 || !attachment.name) continue; + const buffer = Buffer.from(String(attachment.dataBase64).split(",").pop(), "base64"); + if (!buffer.length || buffer.length > 3_000_000) continue; + const safeName = path.basename(attachment.name).replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 80); + const filename = `${Date.now()}-${crypto.randomBytes(6).toString("hex")}-${safeName}`; + const storagePath = path.join(UPLOAD_DIR, filename); + fs.writeFileSync(storagePath, buffer); + db.prepare(` + INSERT INTO attachments (conversation_id, message_id, original_name, mime_type, byte_size, storage_path, public_url) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).run( + conversationId, + messageId, + attachment.name, + attachment.type || "application/octet-stream", + buffer.length, + storagePath, + `/uploads/${filename}` + ); + } +} + +function bumpConversation(conversationId, status) { + db.prepare(` + UPDATE conversations + SET status = ?, last_message_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + `).run(status, conversationId); +} + +function conversationDetails(conversation) { + const messages = db.prepare("SELECT * FROM messages WHERE conversation_id = ? ORDER BY id ASC").all(conversation.id); + const attachments = db.prepare("SELECT * FROM attachments WHERE conversation_id = ? ORDER BY id ASC").all(conversation.id); + return { + conversation: formatConversationSummary(conversation), + messages: messages.map((message) => ({ + id: message.id, + senderType: message.sender_type, + senderName: message.sender_name, + body: message.body, + createdAt: message.created_at, + attachments: attachments + .filter((item) => item.message_id === message.id) + .map(formatAttachment) + })) + }; +} + +function formatConversationSummary(row) { + return { + id: row.public_id, + siteId: row.site_id, + siteKey: row.site_key, + status: row.status, + visitor: { + name: row.visitor_name, + email: row.visitor_email, + phone: row.visitor_phone + }, + currentUrl: row.current_url, + referrer: row.referrer, + browsingHistory: parseJson(row.browsing_history_json, []), + device: parseJson(row.device_json, {}), + language: row.language, + timezone: row.timezone, + ip: row.ip, + countryCode: row.country_code, + vpnRisk: Boolean(row.vpn_risk), + lastBody: row.last_body, + messageCount: row.message_count, + lastMessageAt: row.last_message_at, + createdAt: row.created_at + }; +} + +function formatAttachment(row) { + return { + id: row.id, + name: row.original_name, + type: row.mime_type, + size: row.byte_size, + url: row.public_url + }; +} + +function formatSite(site) { + return { + id: site.id, + siteKey: site.site_key, + name: site.name, + domain: site.domain, + isOnline: Boolean(site.is_online), + settings: parseJson(site.settings_json, defaultSettings()) + }; +} + +function snippetFor(site) { + return ` +`; +} + +function getDefaultSite() { + return db.prepare("SELECT * FROM sites ORDER BY id LIMIT 1").get(); +} + +function findConversation(publicId) { + return db.prepare(` + SELECT c.*, s.site_key + FROM conversations c + JOIN sites s ON s.id = c.site_id + WHERE c.public_id = ? + `).get(publicId); +} + +function publicIdFrom(url) { + const parts = url.pathname.split("/").filter(Boolean); + return parts[parts.indexOf("conversations") + 1]; +} + +function servePublic(res, pathname) { + const target = path.join(PUBLIC_DIR, path.basename(pathname)); + const type = pathname.endsWith(".css") ? "text/css; charset=utf-8" : "application/javascript; charset=utf-8"; + return file(res, target, type); +} + +function serveUpload(res, pathname) { + const target = path.join(UPLOAD_DIR, path.basename(pathname)); + return file(res, target, "application/octet-stream"); +} + +function file(res, target, contentType) { + if (!fs.existsSync(target)) return json(res, 404, { error: "not_found" }); + res.writeHead(200, { "Content-Type": contentType }); + fs.createReadStream(target).pipe(res); +} + +function json(res, status, payload) { + res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" }); + res.end(JSON.stringify(payload)); +} + +function setCommonHeaders(res) { + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type"); + res.setHeader("Access-Control-Allow-Methods", "GET,POST,PATCH,OPTIONS"); +} + +function endCors(res) { + res.writeHead(204); + res.end(); +} + +function readJson(req, limit = 500_000) { + return new Promise((resolve, reject) => { + let raw = ""; + req.on("data", (chunk) => { + raw += chunk; + if (raw.length > limit) { + reject(new Error("payload_too_large")); + req.destroy(); + } + }); + req.on("end", () => { + if (!raw) return resolve({}); + try { + resolve(JSON.parse(raw)); + } catch (error) { + reject(error); + } + }); + req.on("error", reject); + }); +} + +function signSession(username) { + const payload = Buffer.from(JSON.stringify({ username, exp: Date.now() + 14 * 86400_000 })).toString("base64url"); + const sig = crypto.createHmac("sha256", AUTH_SECRET).update(payload).digest("base64url"); + return `${payload}.${sig}`; +} + +function verifySession(token) { + const [payload, sig] = String(token).split("."); + if (!payload || !sig) return null; + const expected = crypto.createHmac("sha256", AUTH_SECRET).update(payload).digest("base64url"); + if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return null; + const data = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")); + return data.exp > Date.now() ? data.username : null; +} + +function parseCookies(header) { + return Object.fromEntries(header.split(";").filter(Boolean).map((part) => { + const [key, ...value] = part.trim().split("="); + return [key, value.join("=")]; + })); +} + +function parseJson(value, fallback) { + try { + return JSON.parse(value); + } catch { + return fallback; + } +} + +function id(prefix) { + return `${prefix}_${crypto.randomBytes(12).toString("hex")}`; +} + +function nullish(value) { + return value === undefined || value === "" ? null : value; +} + +function clientIp(req) { + return (req.headers["x-forwarded-for"] || req.socket.remoteAddress || "").split(",")[0].trim(); +} + +function countryFromHeaders(req) { + return req.headers["cf-ipcountry"] || req.headers["x-vercel-ip-country"] || null; +} + +function loadDotEnv(filePath) { + if (!fs.existsSync(filePath)) return; + const lines = fs.readFileSync(filePath, "utf8").split(/\r?\n/); + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const index = trimmed.indexOf("="); + if (index === -1) continue; + const key = trimmed.slice(0, index).trim(); + const value = trimmed.slice(index + 1).trim(); + if (!(key in process.env)) process.env[key] = value; + } +}