Initial MaalFlows live chat scaffold
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
.env
|
||||
data/*.sqlite
|
||||
data/*.sqlite-*
|
||||
data/uploads/
|
||||
npm-debug.log*
|
||||
@@ -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
|
||||
<script>
|
||||
window.MaalFlows = {
|
||||
siteKey: "9b-plus",
|
||||
lang: "cs",
|
||||
apiBase: "http://localhost:3400"
|
||||
};
|
||||
</script>
|
||||
<script async src="http://localhost:3400/widget.js"></script>
|
||||
```
|
||||
|
||||
V produkci nastav `PUBLIC_BASE_URL` na cilovou domenu nebo subdomenu, odkud se bude nacitat `widget.js`.
|
||||
Generated
+464
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<!doctype html>
|
||||
<html lang="cs">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>MaalFlows Admin</title>
|
||||
<link rel="stylesheet" href="/admin.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
<section id="loginView" class="login">
|
||||
<form id="loginForm" class="panel">
|
||||
<h1>MaalFlows</h1>
|
||||
<label>Uzivatel <input name="username" autocomplete="username" required></label>
|
||||
<label>Heslo <input name="password" type="password" autocomplete="current-password" required></label>
|
||||
<button type="submit">Prihlasit</button>
|
||||
<p id="loginError" class="error"></p>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section id="appView" class="app hidden">
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<h1>MaalFlows</h1>
|
||||
<button id="logoutButton" type="button">Odhlasit</button>
|
||||
</div>
|
||||
<div class="sitebar">
|
||||
<label class="switch">
|
||||
<input id="onlineToggle" type="checkbox">
|
||||
<span>Online</span>
|
||||
</label>
|
||||
<button id="settingsButton" type="button">Nastaveni</button>
|
||||
</div>
|
||||
<div id="conversationList" class="conversation-list"></div>
|
||||
</aside>
|
||||
|
||||
<section class="conversation">
|
||||
<header id="conversationHeader" class="conversation-header">
|
||||
<div>
|
||||
<h2>Vyber konverzaci</h2>
|
||||
<p>Nova konverzace se tady zvyrazni a pusti zvuk.</p>
|
||||
</div>
|
||||
<select id="statusSelect" class="hidden">
|
||||
<option value="new">nove</option>
|
||||
<option value="open">otevrene</option>
|
||||
<option value="resolved">vyresene</option>
|
||||
<option value="spam">spam</option>
|
||||
</select>
|
||||
</header>
|
||||
<div id="messages" class="messages"></div>
|
||||
<div id="typingNotice" class="typing hidden">Zakaznik pise...</div>
|
||||
<form id="replyForm" class="reply hidden">
|
||||
<textarea name="message" rows="3" required placeholder="Napsat odpoved..."></textarea>
|
||||
<button type="submit">Odeslat</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<aside id="settingsPanel" class="settings hidden">
|
||||
<div class="settings-head">
|
||||
<h2>Nastaveni widgetu</h2>
|
||||
<button id="closeSettingsButton" type="button">Zavrit</button>
|
||||
</div>
|
||||
<form id="settingsForm">
|
||||
<label>Titulek CS <input name="titleCs"></label>
|
||||
<label>Intro CS <textarea name="introCs" rows="2"></textarea></label>
|
||||
<label>Titulek EN <input name="titleEn"></label>
|
||||
<label>Intro EN <textarea name="introEn" rows="2"></textarea></label>
|
||||
<label>Barva <input name="brand" type="color"></label>
|
||||
<label>Pozice desktop <select name="desktopSide"><option>right</option><option>left</option></select></label>
|
||||
<label>Desktop bottom px <input name="desktopBottom" type="number"></label>
|
||||
<label>Desktop offset px <input name="desktopOffset" type="number"></label>
|
||||
<label>Pozice mobil <select name="mobileSide"><option>right</option><option>left</option></select></label>
|
||||
<label>Mobil bottom px <input name="mobileBottom" type="number"></label>
|
||||
<label>Mobil offset px <input name="mobileOffset" type="number"></label>
|
||||
<button type="submit">Ulozit</button>
|
||||
</form>
|
||||
<h3>GTM snippet</h3>
|
||||
<pre id="snippet"></pre>
|
||||
</aside>
|
||||
</section>
|
||||
</main>
|
||||
<script src="/admin.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+257
@@ -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) => `
|
||||
<button class="conversation-item ${item.id === state.activeId ? "active" : ""} ${item.status === "new" ? "fresh" : ""}" data-id="${item.id}">
|
||||
<span class="row"><strong>${escapeHtml(item.visitor.name || item.visitor.email || "Navstevnik")}</strong><span class="status">${item.status}</span></span>
|
||||
<span class="preview">${escapeHtml(item.lastBody || "")}</span>
|
||||
<span class="meta">${flag(item.countryCode)} ${escapeHtml(item.language || "")} ${escapeHtml(item.currentUrl || "")}</span>
|
||||
</button>
|
||||
`).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 = `
|
||||
<div>
|
||||
<h2>${escapeHtml(conversation.visitor.name || conversation.visitor.email || "Navstevnik")}</h2>
|
||||
<p>${flag(conversation.countryCode)} ${escapeHtml(conversation.currentUrl || "")}</p>
|
||||
<p>${escapeHtml(conversation.device.userAgent || "")}</p>
|
||||
</div>
|
||||
`;
|
||||
$("#conversationHeader").append(statusSelect);
|
||||
statusSelect.classList.remove("hidden");
|
||||
statusSelect.value = conversation.status;
|
||||
$("#replyForm").classList.remove("hidden");
|
||||
$("#messages").innerHTML = messages.map((message) => `
|
||||
<article class="message ${message.senderType}">
|
||||
<div class="by">${escapeHtml(message.senderName || message.senderType)} · ${escapeHtml(message.createdAt)}</div>
|
||||
<p>${escapeHtml(message.body)}</p>
|
||||
${message.attachments.map((a) => `<a href="${a.url}" target="_blank" rel="noreferrer">${escapeHtml(a.name)}</a>`).join("")}
|
||||
</article>
|
||||
`).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()));
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<!doctype html>
|
||||
<html lang="cs">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>MaalFlows Preview</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
color: #17211d;
|
||||
background: #f5f7f4;
|
||||
}
|
||||
main {
|
||||
max-width: 980px;
|
||||
margin: 0 auto;
|
||||
padding: 56px 20px;
|
||||
display: grid;
|
||||
gap: 22px;
|
||||
}
|
||||
h1 { margin: 0; font-size: clamp(32px, 5vw, 56px); }
|
||||
p { max-width: 680px; line-height: 1.6; color: #627067; }
|
||||
.demo {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
.demo div {
|
||||
background: white;
|
||||
border: 1px solid #dfe6e2;
|
||||
border-radius: 8px;
|
||||
padding: 18px;
|
||||
min-height: 120px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>9b-plus test stranka</h1>
|
||||
<p>Tahle stranka simuluje eshop, kam se widget vlozi pres GTM. Otevri bublinu vpravo dole, posli zpravu a sleduj ji v administraci.</p>
|
||||
<section class="demo">
|
||||
<div><strong>Produkt</strong><p>Ukazkova stranka produktu pro sbirani current URL.</p></div>
|
||||
<div><strong>Kosik</strong><p>Referrer, jazyk, timezone a device info se poslou do konverzace.</p></div>
|
||||
<div><strong>Podpora</strong><p>Zakaznik muze napsat hned, kontaktni udaje jsou volitelne.</p></div>
|
||||
</section>
|
||||
</main>
|
||||
<script>
|
||||
window.MaalFlows = {
|
||||
siteKey: "9b-plus",
|
||||
lang: "cs",
|
||||
apiBase: "http://localhost:3400"
|
||||
};
|
||||
</script>
|
||||
<script async src="/widget.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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 = `
|
||||
<style>${styles(settings)}</style>
|
||||
<button class="mf-launcher" type="button" aria-label="${copy.title}">
|
||||
<span></span>
|
||||
</button>
|
||||
<section class="mf-panel" aria-live="polite">
|
||||
<header>
|
||||
<strong>${escapeHtml(copy.title)}</strong>
|
||||
<small>${escapeHtml(site.isOnline ? copy.intro : copy.offlineIntro)}</small>
|
||||
<button class="mf-close" type="button" aria-label="Close">×</button>
|
||||
</header>
|
||||
<div class="mf-messages"></div>
|
||||
<div class="mf-typing" hidden>Operator pise...</div>
|
||||
<form class="mf-form">
|
||||
<details>
|
||||
<summary>${escapeHtml(copy.detailsLabel)}</summary>
|
||||
<input name="name" autocomplete="name" placeholder="Jmeno">
|
||||
<input name="email" type="email" autocomplete="email" placeholder="Email">
|
||||
<input name="phone" autocomplete="tel" placeholder="Telefon">
|
||||
</details>
|
||||
<textarea name="message" required rows="3" placeholder="${escapeHtml(copy.placeholder)}"></textarea>
|
||||
<div class="mf-actions">
|
||||
<input name="attachments" type="file" accept="image/*" multiple>
|
||||
<button type="submit">${escapeHtml(copy.sendLabel)}</button>
|
||||
</div>
|
||||
<p class="mf-note">Odeslanim zpravy nam predavate udaje potrebne pro odpoved.</p>
|
||||
</form>
|
||||
</section>
|
||||
`;
|
||||
|
||||
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 = `<p>${escapeHtml(body)}</p>${attachments.map((a) => `<a href="${apiBase}${a.url}" target="_blank" rel="noreferrer">${escapeHtml(a.name)}</a>`).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)}`;
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,666 @@
|
||||
const http = require("http");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const crypto = require("crypto");
|
||||
const Database = require("better-sqlite3");
|
||||
|
||||
const ROOT = __dirname;
|
||||
const DATA_DIR = path.join(ROOT, "data");
|
||||
const PUBLIC_DIR = path.join(ROOT, "public");
|
||||
const UPLOAD_DIR = path.join(DATA_DIR, "uploads");
|
||||
const DB_PATH = path.join(DATA_DIR, "maalflows.sqlite");
|
||||
|
||||
loadDotEnv(path.join(ROOT, ".env"));
|
||||
|
||||
const PORT = Number(process.env.PORT || 3400);
|
||||
const PUBLIC_BASE_URL = (process.env.PUBLIC_BASE_URL || `http://localhost:${PORT}`).replace(/\/$/, "");
|
||||
const AUTH_SECRET = process.env.AUTH_SECRET || "dev-secret-change-me";
|
||||
const COOKIE_NAME = "mf_session";
|
||||
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||
|
||||
const db = new Database(DB_PATH);
|
||||
db.pragma("journal_mode = WAL");
|
||||
db.pragma("foreign_keys = ON");
|
||||
initDb();
|
||||
seedDefaults();
|
||||
|
||||
const streams = new Map();
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
await route(req, res);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
json(res, 500, { error: "internal_error" });
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`MaalFlows running on http://localhost:${PORT}`);
|
||||
});
|
||||
|
||||
async function route(req, res) {
|
||||
const url = new URL(req.url, PUBLIC_BASE_URL);
|
||||
setCommonHeaders(res);
|
||||
|
||||
if (req.method === "OPTIONS") return endCors(res);
|
||||
if (req.method === "GET" && url.pathname === "/health") return json(res, 200, { ok: true });
|
||||
if (req.method === "GET" && url.pathname === "/admin") return file(res, path.join(PUBLIC_DIR, "admin.html"), "text/html; charset=utf-8");
|
||||
if (req.method === "GET" && url.pathname === "/preview") return file(res, path.join(PUBLIC_DIR, "preview.html"), "text/html; charset=utf-8");
|
||||
if (req.method === "GET" && url.pathname.startsWith("/uploads/")) return serveUpload(res, url.pathname);
|
||||
if (req.method === "GET" && url.pathname === "/widget.js") return file(res, path.join(PUBLIC_DIR, "widget.js"), "application/javascript; charset=utf-8");
|
||||
if (req.method === "GET" && url.pathname.startsWith("/admin.")) return servePublic(res, url.pathname);
|
||||
|
||||
if (url.pathname === "/api/admin/login" && req.method === "POST") return adminLogin(req, res);
|
||||
if (url.pathname === "/api/admin/logout" && req.method === "POST") return adminLogout(res);
|
||||
if (url.pathname.startsWith("/api/admin/")) return requireAdmin(req, res, () => adminApi(req, res, url));
|
||||
|
||||
if (url.pathname === "/api/widget/config" && req.method === "GET") return widgetConfig(res, url);
|
||||
if (url.pathname === "/api/widget/conversations" && req.method === "POST") return createConversation(req, res);
|
||||
if (url.pathname.match(/^\/api\/widget\/conversations\/[^/]+\/messages$/) && req.method === "POST") return createVisitorMessage(req, res, url);
|
||||
if (url.pathname.match(/^\/api\/widget\/conversations\/[^/]+\/events$/) && req.method === "GET") return eventStream(req, res, url, "visitor");
|
||||
if (url.pathname.match(/^\/api\/widget\/conversations\/[^/]+\/typing$/) && req.method === "POST") return visitorTyping(req, res, url);
|
||||
|
||||
return json(res, 404, { error: "not_found" });
|
||||
}
|
||||
|
||||
async function adminApi(req, res, url) {
|
||||
if (url.pathname === "/api/admin/me" && req.method === "GET") return json(res, 200, { user: req.adminUser });
|
||||
if (url.pathname === "/api/admin/bootstrap" && req.method === "GET") return adminBootstrap(res);
|
||||
if (url.pathname === "/api/admin/events" && req.method === "GET") return eventStream(req, res, url, "admin");
|
||||
if (url.pathname === "/api/admin/site" && req.method === "PATCH") return updateSite(req, res);
|
||||
if (url.pathname === "/api/admin/conversations" && req.method === "GET") return adminConversations(res);
|
||||
if (url.pathname.match(/^\/api\/admin\/conversations\/[^/]+$/) && req.method === "GET") return adminConversation(res, url);
|
||||
if (url.pathname.match(/^\/api\/admin\/conversations\/[^/]+\/messages$/) && req.method === "POST") return createAdminMessage(req, res, url);
|
||||
if (url.pathname.match(/^\/api\/admin\/conversations\/[^/]+\/status$/) && req.method === "PATCH") return updateConversationStatus(req, res, url);
|
||||
if (url.pathname.match(/^\/api\/admin\/conversations\/[^/]+\/typing$/) && req.method === "POST") return adminTyping(req, res, url);
|
||||
return json(res, 404, { error: "not_found" });
|
||||
}
|
||||
|
||||
function initDb() {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS companies (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sites (
|
||||
id INTEGER PRIMARY KEY,
|
||||
company_id INTEGER NOT NULL REFERENCES companies(id),
|
||||
site_key TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
domain TEXT,
|
||||
is_online INTEGER NOT NULL DEFAULT 1,
|
||||
settings_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS admin_users (
|
||||
id INTEGER PRIMARY KEY,
|
||||
company_id INTEGER NOT NULL REFERENCES companies(id),
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
display_name TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS conversations (
|
||||
id INTEGER PRIMARY KEY,
|
||||
public_id TEXT NOT NULL UNIQUE,
|
||||
company_id INTEGER NOT NULL REFERENCES companies(id),
|
||||
site_id INTEGER NOT NULL REFERENCES sites(id),
|
||||
status TEXT NOT NULL DEFAULT 'new',
|
||||
visitor_token TEXT NOT NULL,
|
||||
visitor_name TEXT,
|
||||
visitor_email TEXT,
|
||||
visitor_phone TEXT,
|
||||
current_url TEXT,
|
||||
referrer TEXT,
|
||||
browsing_history_json TEXT NOT NULL DEFAULT '[]',
|
||||
device_json TEXT NOT NULL DEFAULT '{}',
|
||||
language TEXT,
|
||||
timezone TEXT,
|
||||
ip TEXT,
|
||||
country_code TEXT,
|
||||
vpn_risk INTEGER NOT NULL DEFAULT 0,
|
||||
last_message_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id INTEGER PRIMARY KEY,
|
||||
conversation_id INTEGER NOT NULL REFERENCES conversations(id),
|
||||
sender_type TEXT NOT NULL,
|
||||
sender_name TEXT,
|
||||
body TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS attachments (
|
||||
id INTEGER PRIMARY KEY,
|
||||
conversation_id INTEGER NOT NULL REFERENCES conversations(id),
|
||||
message_id INTEGER REFERENCES messages(id),
|
||||
original_name TEXT NOT NULL,
|
||||
mime_type TEXT NOT NULL,
|
||||
byte_size INTEGER NOT NULL,
|
||||
storage_path TEXT NOT NULL,
|
||||
public_url TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_conversations_site_status ON conversations(site_id, status, last_message_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_conversation ON messages(conversation_id, created_at);
|
||||
`);
|
||||
}
|
||||
|
||||
function seedDefaults() {
|
||||
const companyName = process.env.DEFAULT_COMPANY_NAME || "MAAL";
|
||||
const siteKey = process.env.DEFAULT_SITE_KEY || "9b-plus";
|
||||
const existingCompany = db.prepare("SELECT id FROM companies LIMIT 1").get();
|
||||
const companyId = existingCompany?.id || db.prepare("INSERT INTO companies (name) VALUES (?)").run(companyName).lastInsertRowid;
|
||||
|
||||
const settings = defaultSettings();
|
||||
const existingSite = db.prepare("SELECT id FROM sites WHERE site_key = ?").get(siteKey);
|
||||
if (!existingSite) {
|
||||
db.prepare(`
|
||||
INSERT INTO sites (company_id, site_key, name, domain, settings_json)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
companyId,
|
||||
siteKey,
|
||||
process.env.DEFAULT_SITE_NAME || "9b-plus support",
|
||||
process.env.DEFAULT_SITE_DOMAIN || "https://www.9b-plus.com",
|
||||
JSON.stringify(settings)
|
||||
);
|
||||
}
|
||||
|
||||
for (const user of configuredUsers()) {
|
||||
db.prepare(`
|
||||
INSERT OR IGNORE INTO admin_users (company_id, username, display_name)
|
||||
VALUES (?, ?, ?)
|
||||
`).run(companyId, user.username, user.username);
|
||||
}
|
||||
}
|
||||
|
||||
function defaultSettings() {
|
||||
return {
|
||||
title: { cs: "Potrebujete poradit?", en: "Need help?" },
|
||||
intro: { cs: "Napiste nam. Odpovime co nejdrive.", en: "Message us and we will reply as soon as possible." },
|
||||
offlineIntro: { cs: "Ted nejsme online, ale zpravu nam muzete nechat.", en: "We are offline, but you can leave us a message." },
|
||||
placeholder: { cs: "Napiste zpravu...", en: "Write a message..." },
|
||||
sendLabel: { cs: "Odeslat", en: "Send" },
|
||||
detailsLabel: { cs: "Kontaktni udaje", en: "Contact details" },
|
||||
colors: {
|
||||
brand: "#0f8f6f",
|
||||
brandText: "#ffffff",
|
||||
surface: "#ffffff",
|
||||
text: "#17211d",
|
||||
muted: "#647067"
|
||||
},
|
||||
desktop: { side: "right", bottomPx: 22, sideOffsetPx: 22, widthPx: 370 },
|
||||
mobile: { side: "right", bottomPx: 14, sideOffsetPx: 12, widthPx: 340 }
|
||||
};
|
||||
}
|
||||
|
||||
function configuredUsers() {
|
||||
const users = [];
|
||||
for (let i = 1; i < 50; i += 1) {
|
||||
const username = process.env[`USER_${i}`];
|
||||
const password = process.env[`PASS_${i}`];
|
||||
if (!username && !password) break;
|
||||
if (username && password) users.push({ username, password });
|
||||
}
|
||||
return users;
|
||||
}
|
||||
|
||||
async function adminLogin(req, res) {
|
||||
const body = await readJson(req);
|
||||
const match = configuredUsers().find((u) => u.username === body.username && u.password === body.password);
|
||||
if (!match) return json(res, 401, { error: "bad_credentials" });
|
||||
const token = signSession(match.username);
|
||||
res.setHeader("Set-Cookie", `${COOKIE_NAME}=${token}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${60 * 60 * 24 * 14}`);
|
||||
return json(res, 200, { ok: true, user: { username: match.username } });
|
||||
}
|
||||
|
||||
function adminLogout(res) {
|
||||
res.setHeader("Set-Cookie", `${COOKIE_NAME}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0`);
|
||||
return json(res, 200, { ok: true });
|
||||
}
|
||||
|
||||
function requireAdmin(req, res, next) {
|
||||
const token = parseCookies(req.headers.cookie || "")[COOKIE_NAME];
|
||||
const username = token && verifySession(token);
|
||||
if (!username) return json(res, 401, { error: "unauthorized" });
|
||||
req.adminUser = { username };
|
||||
return next();
|
||||
}
|
||||
|
||||
function adminBootstrap(res) {
|
||||
const site = getDefaultSite();
|
||||
return json(res, 200, {
|
||||
publicBaseUrl: PUBLIC_BASE_URL,
|
||||
site: formatSite(site),
|
||||
snippet: snippetFor(site)
|
||||
});
|
||||
}
|
||||
|
||||
function adminConversations(res) {
|
||||
const rows = db.prepare(`
|
||||
SELECT c.*, s.site_key,
|
||||
(SELECT body FROM messages WHERE conversation_id = c.id ORDER BY id DESC LIMIT 1) AS last_body,
|
||||
(SELECT COUNT(*) FROM messages WHERE conversation_id = c.id) AS message_count
|
||||
FROM conversations c
|
||||
JOIN sites s ON s.id = c.site_id
|
||||
ORDER BY datetime(c.last_message_at) DESC, c.id DESC
|
||||
LIMIT 200
|
||||
`).all();
|
||||
return json(res, 200, { conversations: rows.map(formatConversationSummary) });
|
||||
}
|
||||
|
||||
function adminConversation(res, url) {
|
||||
const conversation = findConversation(publicIdFrom(url));
|
||||
if (!conversation) return json(res, 404, { error: "conversation_not_found" });
|
||||
return json(res, 200, conversationDetails(conversation));
|
||||
}
|
||||
|
||||
async function updateSite(req, res) {
|
||||
const body = await readJson(req);
|
||||
const site = getDefaultSite();
|
||||
const settings = { ...JSON.parse(site.settings_json), ...(body.settings || {}) };
|
||||
db.prepare(`
|
||||
UPDATE sites
|
||||
SET is_online = ?, settings_json = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`).run(body.isOnline ? 1 : 0, JSON.stringify(settings), site.id);
|
||||
const updated = getDefaultSite();
|
||||
publish("admin", { type: "site:update", site: formatSite(updated) });
|
||||
return json(res, 200, { site: formatSite(updated), snippet: snippetFor(updated) });
|
||||
}
|
||||
|
||||
async function updateConversationStatus(req, res, url) {
|
||||
const body = await readJson(req);
|
||||
const status = ["new", "open", "resolved", "spam"].includes(body.status) ? body.status : null;
|
||||
if (!status) return json(res, 400, { error: "bad_status" });
|
||||
const conversation = findConversation(publicIdFrom(url));
|
||||
if (!conversation) return json(res, 404, { error: "conversation_not_found" });
|
||||
db.prepare("UPDATE conversations SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(status, conversation.id);
|
||||
const updated = findConversation(conversation.public_id);
|
||||
publishConversation(updated, { type: "conversation:status", conversation: formatConversationSummary(updated) });
|
||||
return json(res, 200, { conversation: formatConversationSummary(updated) });
|
||||
}
|
||||
|
||||
function widgetConfig(res, url) {
|
||||
const siteKey = url.searchParams.get("siteKey") || process.env.DEFAULT_SITE_KEY || "9b-plus";
|
||||
const site = db.prepare("SELECT * FROM sites WHERE site_key = ?").get(siteKey);
|
||||
if (!site) return json(res, 404, { error: "site_not_found" });
|
||||
return json(res, 200, { site: formatSite(site), publicBaseUrl: PUBLIC_BASE_URL });
|
||||
}
|
||||
|
||||
async function createConversation(req, res) {
|
||||
const body = await readJson(req, 2_000_000);
|
||||
if (!body.message || !String(body.message).trim()) return json(res, 400, { error: "message_required" });
|
||||
const site = db.prepare("SELECT * FROM sites WHERE site_key = ?").get(body.siteKey || "9b-plus");
|
||||
if (!site) return json(res, 404, { error: "site_not_found" });
|
||||
|
||||
const visitorToken = body.visitorToken || id("vis");
|
||||
const publicId = id("cnv");
|
||||
const info = body.visitorInfo || {};
|
||||
const ip = clientIp(req);
|
||||
|
||||
const insertConversation = db.transaction(() => {
|
||||
const conversationId = db.prepare(`
|
||||
INSERT INTO conversations (
|
||||
public_id, company_id, site_id, visitor_token, visitor_name, visitor_email, visitor_phone,
|
||||
current_url, referrer, browsing_history_json, device_json, language, timezone, ip, country_code
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
publicId,
|
||||
site.company_id,
|
||||
site.id,
|
||||
visitorToken,
|
||||
nullish(info.name),
|
||||
nullish(info.email),
|
||||
nullish(info.phone),
|
||||
nullish(body.currentUrl),
|
||||
nullish(body.referrer),
|
||||
JSON.stringify(body.browsingHistory || []),
|
||||
JSON.stringify(body.device || {}),
|
||||
nullish(body.language),
|
||||
nullish(body.timezone),
|
||||
ip,
|
||||
nullish(countryFromHeaders(req))
|
||||
).lastInsertRowid;
|
||||
|
||||
const messageId = insertMessage(conversationId, "visitor", info.name || "Visitor", String(body.message).trim());
|
||||
saveAttachments(conversationId, messageId, body.attachments || []);
|
||||
return conversationId;
|
||||
});
|
||||
const insert = insertConversation();
|
||||
|
||||
const conversation = db.prepare("SELECT * FROM conversations WHERE id = ?").get(insert);
|
||||
publish("admin", { type: "conversation:new", conversation: formatConversationSummary(conversation) });
|
||||
publishConversation(conversation, { type: "message:new", payload: conversationDetails(conversation) });
|
||||
return json(res, 201, { conversation: conversationDetails(conversation), visitorToken });
|
||||
}
|
||||
|
||||
async function createVisitorMessage(req, res, url) {
|
||||
const body = await readJson(req, 2_000_000);
|
||||
if (!body.message || !String(body.message).trim()) return json(res, 400, { error: "message_required" });
|
||||
const conversation = findConversation(publicIdFrom(url));
|
||||
if (!conversation) return json(res, 404, { error: "conversation_not_found" });
|
||||
const messageId = insertMessage(conversation.id, "visitor", conversation.visitor_name || "Visitor", String(body.message).trim());
|
||||
saveAttachments(conversation.id, messageId, body.attachments || []);
|
||||
bumpConversation(conversation.id, conversation.status === "resolved" ? "open" : conversation.status);
|
||||
const updated = findConversation(conversation.public_id);
|
||||
publishConversation(updated, { type: "message:new", payload: conversationDetails(updated) });
|
||||
return json(res, 201, conversationDetails(updated));
|
||||
}
|
||||
|
||||
async function createAdminMessage(req, res, url) {
|
||||
const body = await readJson(req, 2_000_000);
|
||||
if (!body.message || !String(body.message).trim()) return json(res, 400, { error: "message_required" });
|
||||
const conversation = findConversation(publicIdFrom(url));
|
||||
if (!conversation) return json(res, 404, { error: "conversation_not_found" });
|
||||
insertMessage(conversation.id, "admin", req.adminUser.username, String(body.message).trim());
|
||||
bumpConversation(conversation.id, conversation.status === "new" ? "open" : conversation.status);
|
||||
const updated = findConversation(conversation.public_id);
|
||||
publishConversation(updated, { type: "message:new", payload: conversationDetails(updated) });
|
||||
return json(res, 201, conversationDetails(updated));
|
||||
}
|
||||
|
||||
async function visitorTyping(req, res, url) {
|
||||
const conversation = findConversation(publicIdFrom(url));
|
||||
if (!conversation) return json(res, 404, { error: "conversation_not_found" });
|
||||
publishConversation(conversation, { type: "typing", actor: "visitor", conversationId: conversation.public_id });
|
||||
return json(res, 200, { ok: true });
|
||||
}
|
||||
|
||||
async function adminTyping(req, res, url) {
|
||||
const conversation = findConversation(publicIdFrom(url));
|
||||
if (!conversation) return json(res, 404, { error: "conversation_not_found" });
|
||||
publishConversation(conversation, { type: "typing", actor: "admin", conversationId: conversation.public_id });
|
||||
return json(res, 200, { ok: true });
|
||||
}
|
||||
|
||||
function eventStream(req, res, url, channel) {
|
||||
const key = channel === "admin" ? "admin" : `conversation:${publicIdFrom(url)}`;
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
"Access-Control-Allow-Origin": req.headers.origin || "*",
|
||||
"Access-Control-Allow-Credentials": "true"
|
||||
});
|
||||
res.write(`event: hello\ndata: ${JSON.stringify({ ok: true })}\n\n`);
|
||||
const client = { res };
|
||||
if (!streams.has(key)) streams.set(key, new Set());
|
||||
streams.get(key).add(client);
|
||||
req.on("close", () => streams.get(key)?.delete(client));
|
||||
}
|
||||
|
||||
function publishConversation(conversation, event) {
|
||||
publish("admin", event);
|
||||
publish(`conversation:${conversation.public_id}`, event);
|
||||
}
|
||||
|
||||
function publish(key, event) {
|
||||
const listeners = streams.get(key);
|
||||
if (!listeners) return;
|
||||
for (const client of listeners) {
|
||||
client.res.write(`event: message\ndata: ${JSON.stringify(event)}\n\n`);
|
||||
}
|
||||
}
|
||||
|
||||
function insertMessage(conversationId, senderType, senderName, body) {
|
||||
return db.prepare(`
|
||||
INSERT INTO messages (conversation_id, sender_type, sender_name, body)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run(conversationId, senderType, senderName, body).lastInsertRowid;
|
||||
}
|
||||
|
||||
function saveAttachments(conversationId, messageId, attachments) {
|
||||
for (const attachment of attachments.slice(0, 3)) {
|
||||
if (!attachment.dataBase64 || !attachment.name) continue;
|
||||
const buffer = Buffer.from(String(attachment.dataBase64).split(",").pop(), "base64");
|
||||
if (!buffer.length || buffer.length > 3_000_000) continue;
|
||||
const safeName = path.basename(attachment.name).replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 80);
|
||||
const filename = `${Date.now()}-${crypto.randomBytes(6).toString("hex")}-${safeName}`;
|
||||
const storagePath = path.join(UPLOAD_DIR, filename);
|
||||
fs.writeFileSync(storagePath, buffer);
|
||||
db.prepare(`
|
||||
INSERT INTO attachments (conversation_id, message_id, original_name, mime_type, byte_size, storage_path, public_url)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
conversationId,
|
||||
messageId,
|
||||
attachment.name,
|
||||
attachment.type || "application/octet-stream",
|
||||
buffer.length,
|
||||
storagePath,
|
||||
`/uploads/${filename}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function bumpConversation(conversationId, status) {
|
||||
db.prepare(`
|
||||
UPDATE conversations
|
||||
SET status = ?, last_message_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`).run(status, conversationId);
|
||||
}
|
||||
|
||||
function conversationDetails(conversation) {
|
||||
const messages = db.prepare("SELECT * FROM messages WHERE conversation_id = ? ORDER BY id ASC").all(conversation.id);
|
||||
const attachments = db.prepare("SELECT * FROM attachments WHERE conversation_id = ? ORDER BY id ASC").all(conversation.id);
|
||||
return {
|
||||
conversation: formatConversationSummary(conversation),
|
||||
messages: messages.map((message) => ({
|
||||
id: message.id,
|
||||
senderType: message.sender_type,
|
||||
senderName: message.sender_name,
|
||||
body: message.body,
|
||||
createdAt: message.created_at,
|
||||
attachments: attachments
|
||||
.filter((item) => item.message_id === message.id)
|
||||
.map(formatAttachment)
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
function formatConversationSummary(row) {
|
||||
return {
|
||||
id: row.public_id,
|
||||
siteId: row.site_id,
|
||||
siteKey: row.site_key,
|
||||
status: row.status,
|
||||
visitor: {
|
||||
name: row.visitor_name,
|
||||
email: row.visitor_email,
|
||||
phone: row.visitor_phone
|
||||
},
|
||||
currentUrl: row.current_url,
|
||||
referrer: row.referrer,
|
||||
browsingHistory: parseJson(row.browsing_history_json, []),
|
||||
device: parseJson(row.device_json, {}),
|
||||
language: row.language,
|
||||
timezone: row.timezone,
|
||||
ip: row.ip,
|
||||
countryCode: row.country_code,
|
||||
vpnRisk: Boolean(row.vpn_risk),
|
||||
lastBody: row.last_body,
|
||||
messageCount: row.message_count,
|
||||
lastMessageAt: row.last_message_at,
|
||||
createdAt: row.created_at
|
||||
};
|
||||
}
|
||||
|
||||
function formatAttachment(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.original_name,
|
||||
type: row.mime_type,
|
||||
size: row.byte_size,
|
||||
url: row.public_url
|
||||
};
|
||||
}
|
||||
|
||||
function formatSite(site) {
|
||||
return {
|
||||
id: site.id,
|
||||
siteKey: site.site_key,
|
||||
name: site.name,
|
||||
domain: site.domain,
|
||||
isOnline: Boolean(site.is_online),
|
||||
settings: parseJson(site.settings_json, defaultSettings())
|
||||
};
|
||||
}
|
||||
|
||||
function snippetFor(site) {
|
||||
return `<script>
|
||||
window.MaalFlows = {
|
||||
siteKey: "${site.site_key}",
|
||||
lang: "cs",
|
||||
apiBase: "${PUBLIC_BASE_URL}"
|
||||
};
|
||||
</script>
|
||||
<script async src="${PUBLIC_BASE_URL}/widget.js"></script>`;
|
||||
}
|
||||
|
||||
function getDefaultSite() {
|
||||
return db.prepare("SELECT * FROM sites ORDER BY id LIMIT 1").get();
|
||||
}
|
||||
|
||||
function findConversation(publicId) {
|
||||
return db.prepare(`
|
||||
SELECT c.*, s.site_key
|
||||
FROM conversations c
|
||||
JOIN sites s ON s.id = c.site_id
|
||||
WHERE c.public_id = ?
|
||||
`).get(publicId);
|
||||
}
|
||||
|
||||
function publicIdFrom(url) {
|
||||
const parts = url.pathname.split("/").filter(Boolean);
|
||||
return parts[parts.indexOf("conversations") + 1];
|
||||
}
|
||||
|
||||
function servePublic(res, pathname) {
|
||||
const target = path.join(PUBLIC_DIR, path.basename(pathname));
|
||||
const type = pathname.endsWith(".css") ? "text/css; charset=utf-8" : "application/javascript; charset=utf-8";
|
||||
return file(res, target, type);
|
||||
}
|
||||
|
||||
function serveUpload(res, pathname) {
|
||||
const target = path.join(UPLOAD_DIR, path.basename(pathname));
|
||||
return file(res, target, "application/octet-stream");
|
||||
}
|
||||
|
||||
function file(res, target, contentType) {
|
||||
if (!fs.existsSync(target)) return json(res, 404, { error: "not_found" });
|
||||
res.writeHead(200, { "Content-Type": contentType });
|
||||
fs.createReadStream(target).pipe(res);
|
||||
}
|
||||
|
||||
function json(res, status, payload) {
|
||||
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
function setCommonHeaders(res) {
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET,POST,PATCH,OPTIONS");
|
||||
}
|
||||
|
||||
function endCors(res) {
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
}
|
||||
|
||||
function readJson(req, limit = 500_000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let raw = "";
|
||||
req.on("data", (chunk) => {
|
||||
raw += chunk;
|
||||
if (raw.length > limit) {
|
||||
reject(new Error("payload_too_large"));
|
||||
req.destroy();
|
||||
}
|
||||
});
|
||||
req.on("end", () => {
|
||||
if (!raw) return resolve({});
|
||||
try {
|
||||
resolve(JSON.parse(raw));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function signSession(username) {
|
||||
const payload = Buffer.from(JSON.stringify({ username, exp: Date.now() + 14 * 86400_000 })).toString("base64url");
|
||||
const sig = crypto.createHmac("sha256", AUTH_SECRET).update(payload).digest("base64url");
|
||||
return `${payload}.${sig}`;
|
||||
}
|
||||
|
||||
function verifySession(token) {
|
||||
const [payload, sig] = String(token).split(".");
|
||||
if (!payload || !sig) return null;
|
||||
const expected = crypto.createHmac("sha256", AUTH_SECRET).update(payload).digest("base64url");
|
||||
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return null;
|
||||
const data = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
||||
return data.exp > Date.now() ? data.username : null;
|
||||
}
|
||||
|
||||
function parseCookies(header) {
|
||||
return Object.fromEntries(header.split(";").filter(Boolean).map((part) => {
|
||||
const [key, ...value] = part.trim().split("=");
|
||||
return [key, value.join("=")];
|
||||
}));
|
||||
}
|
||||
|
||||
function parseJson(value, fallback) {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function id(prefix) {
|
||||
return `${prefix}_${crypto.randomBytes(12).toString("hex")}`;
|
||||
}
|
||||
|
||||
function nullish(value) {
|
||||
return value === undefined || value === "" ? null : value;
|
||||
}
|
||||
|
||||
function clientIp(req) {
|
||||
return (req.headers["x-forwarded-for"] || req.socket.remoteAddress || "").split(",")[0].trim();
|
||||
}
|
||||
|
||||
function countryFromHeaders(req) {
|
||||
return req.headers["cf-ipcountry"] || req.headers["x-vercel-ip-country"] || null;
|
||||
}
|
||||
|
||||
function loadDotEnv(filePath) {
|
||||
if (!fs.existsSync(filePath)) return;
|
||||
const lines = fs.readFileSync(filePath, "utf8").split(/\r?\n/);
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
const index = trimmed.indexOf("=");
|
||||
if (index === -1) continue;
|
||||
const key = trimmed.slice(0, index).trim();
|
||||
const value = trimmed.slice(index + 1).trim();
|
||||
if (!(key in process.env)) process.env[key] = value;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user