levix-bot 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +105 -0
  3. package/SETUP.md +242 -0
  4. package/app.cjs +337 -0
  5. package/bin/levix.js +7 -0
  6. package/deploy/install.sh +182 -0
  7. package/deploy/levix.service +56 -0
  8. package/deploy/nginx/levix.leviro.net.conf +79 -0
  9. package/package.json +78 -0
  10. package/public/brand/apple-touch-icon.png +0 -0
  11. package/public/brand/banner.jpg +0 -0
  12. package/public/brand/banner.webp +0 -0
  13. package/public/brand/icon-16.png +0 -0
  14. package/public/brand/icon-192.png +0 -0
  15. package/public/brand/icon-32.png +0 -0
  16. package/public/brand/icon-512.png +0 -0
  17. package/public/brand/icon-512.webp +0 -0
  18. package/public/brand/mark.png +0 -0
  19. package/public/brand/mark.svg +16 -0
  20. package/public/brand/mark.webp +0 -0
  21. package/public/brand/wordmark.png +0 -0
  22. package/public/brand/wordmark.webp +0 -0
  23. package/public/dashboard.css +881 -0
  24. package/public/dashboard.js +1057 -0
  25. package/public/logo.svg +16 -0
  26. package/public/qrcode.min.js +1 -0
  27. package/public/socket.io.min.js +7 -0
  28. package/scheduler.cjs +197 -0
  29. package/src/auth/auth-storage.cjs +74 -0
  30. package/src/auth/use-database-auth-state.js +130 -0
  31. package/src/bootstrap/core.js +74 -0
  32. package/src/bootstrap/events.cjs +43 -0
  33. package/src/bootstrap/panel.js +97 -0
  34. package/src/cli.js +153 -0
  35. package/src/commands/autoschedule.cjs +84 -0
  36. package/src/commands/blacklist.cjs +116 -0
  37. package/src/commands/block.cjs +70 -0
  38. package/src/commands/calc.cjs +387 -0
  39. package/src/commands/debt.cjs +175 -0
  40. package/src/commands/deleteschedule.cjs +36 -0
  41. package/src/commands/gemini.cjs +727 -0
  42. package/src/commands/group/add.cjs +136 -0
  43. package/src/commands/group/all.cjs +43 -0
  44. package/src/commands/group/antiSpam.cjs +96 -0
  45. package/src/commands/group/antilink.cjs +167 -0
  46. package/src/commands/group/approveall.cjs +54 -0
  47. package/src/commands/group/clearwarns.cjs +41 -0
  48. package/src/commands/group/deletenote.cjs +33 -0
  49. package/src/commands/group/demote.cjs +62 -0
  50. package/src/commands/group/kick.cjs +85 -0
  51. package/src/commands/group/media.cjs +120 -0
  52. package/src/commands/group/members.cjs +59 -0
  53. package/src/commands/group/note.cjs +32 -0
  54. package/src/commands/group/promote.cjs +60 -0
  55. package/src/commands/group/removeall.cjs +42 -0
  56. package/src/commands/group/rules.cjs +31 -0
  57. package/src/commands/group/save.cjs +36 -0
  58. package/src/commands/group/setname.cjs +41 -0
  59. package/src/commands/group/setpp.cjs +51 -0
  60. package/src/commands/group/setrules.cjs +46 -0
  61. package/src/commands/group/setwarn.cjs +58 -0
  62. package/src/commands/group/tagadmins.cjs +50 -0
  63. package/src/commands/group/warn.cjs +105 -0
  64. package/src/commands/group/warns.cjs +66 -0
  65. package/src/commands/group/welcome.cjs +109 -0
  66. package/src/commands/group.cjs +160 -0
  67. package/src/commands/help.cjs +230 -0
  68. package/src/commands/listschedules.cjs +47 -0
  69. package/src/commands/loop.cjs +163 -0
  70. package/src/commands/memory.cjs +201 -0
  71. package/src/commands/mod.cjs +155 -0
  72. package/src/commands/notes.cjs +28 -0
  73. package/src/commands/perm.cjs +236 -0
  74. package/src/commands/ping.cjs +17 -0
  75. package/src/commands/poll.cjs +80 -0
  76. package/src/commands/prayer.cjs +64 -0
  77. package/src/commands/qr.cjs +39 -0
  78. package/src/commands/rand.cjs +130 -0
  79. package/src/commands/restart.cjs +25 -0
  80. package/src/commands/schedule.cjs +86 -0
  81. package/src/commands/score.cjs +130 -0
  82. package/src/commands/setprefix.cjs +40 -0
  83. package/src/commands/shortlink.cjs +74 -0
  84. package/src/commands/shutdown.cjs +45 -0
  85. package/src/commands/status.cjs +75 -0
  86. package/src/commands/stt.cjs +150 -0
  87. package/src/commands/todo.cjs +88 -0
  88. package/src/commands/tts.cjs +170 -0
  89. package/src/commands/unblock.cjs +57 -0
  90. package/src/commands/weather.cjs +79 -0
  91. package/src/config/ai-persona.md +17 -0
  92. package/src/config/baileys.config.js +51 -0
  93. package/src/config/brand.cjs +36 -0
  94. package/src/config/brand.esm.js +17 -0
  95. package/src/config/constants.js +16 -0
  96. package/src/config/defaults.cjs +91 -0
  97. package/src/config/lock.cjs +110 -0
  98. package/src/config/paths.cjs +90 -0
  99. package/src/config/runtime-config.cjs +219 -0
  100. package/src/config/secrets.cjs +130 -0
  101. package/src/config/settings.cjs +386 -0
  102. package/src/core/connection.js +199 -0
  103. package/src/core/events.js +61 -0
  104. package/src/core/socket.js +64 -0
  105. package/src/db/db.cjs +282 -0
  106. package/src/db/store.cjs +837 -0
  107. package/src/db/store.esm.js +17 -0
  108. package/src/domain/caddy.js +120 -0
  109. package/src/domain/command.js +380 -0
  110. package/src/domain/detect.js +173 -0
  111. package/src/domain/nginx.js +138 -0
  112. package/src/domain/system.js +148 -0
  113. package/src/handlers/command.handler.js +414 -0
  114. package/src/handlers/group.handler.js +73 -0
  115. package/src/handlers/message.handler.js +149 -0
  116. package/src/index.js +217 -0
  117. package/src/middleware/antispam.middleware.js +68 -0
  118. package/src/middleware/blacklist.middleware.js +29 -0
  119. package/src/middleware/forward-tracking.middleware.js +49 -0
  120. package/src/middleware/permissions.middleware.js +114 -0
  121. package/src/routes/dashboard.api.esm.js +779 -0
  122. package/src/services/aiAgent.cjs +317 -0
  123. package/src/services/aiTools.cjs +635 -0
  124. package/src/utils/datetime.cjs +61 -0
  125. package/src/utils/lid-helper.esm.js +143 -0
  126. package/src/utils/logger.cjs +72 -0
  127. package/src/utils/memory.cjs +385 -0
  128. package/src/utils/normalizeJid.cjs +65 -0
  129. package/src/utils/normalizeJid.esm.js +37 -0
  130. package/src/utils/openBrowser.cjs +129 -0
  131. package/src/utils/permissions.cjs +154 -0
  132. package/src/utils/permissions.esm.js +375 -0
  133. package/src/utils/recentMessageCache.esm.js +70 -0
  134. package/src/utils/requestOrigin.cjs +102 -0
  135. package/src/utils/sendBotMessage.cjs +43 -0
  136. package/src/utils/sendBotMessage.esm.js +139 -0
  137. package/src/utils/statusMessage.cjs +179 -0
  138. package/src/utils/storage-hub.cjs +49 -0
  139. package/src/utils/storage-hub.esm.js +24 -0
  140. package/src/utils/storage.cjs +80 -0
  141. package/src/utils/storage.esm.js +88 -0
  142. package/src/utils/textDecode.cjs +197 -0
  143. package/src/utils/thumbnail.cjs +591 -0
  144. package/src/utils/typing.esm.js +102 -0
  145. package/views/dashboard.ejs +329 -0
  146. package/views/login.ejs +105 -0
  147. package/views/qr.ejs +108 -0
  148. package/views/setup.ejs +122 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Abdelrhman Diab (Leviro)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,105 @@
1
+ # Levix
2
+
3
+ A self-hosted personal WhatsApp bot: 55 commands, group moderation, an AI agent
4
+ on Google Gemini, scheduled messages, and a web control panel that can change
5
+ almost all of it while the bot runs.
6
+
7
+ Built by Abdelrhman Diab (Leviro).
8
+
9
+ ```bash
10
+ npm install -g levix-bot
11
+ levix
12
+ ```
13
+
14
+ The npm package is named `levix-bot`; the installed CLI command stays `levix`.
15
+
16
+ On a desktop that opens the panel in your browser by itself. Pick a password,
17
+ scan the QR, done — see [SETUP.md](SETUP.md) for the longer version, Docker,
18
+ and running it as a service.
19
+
20
+ ```
21
+ levix start Levix with the web panel
22
+ levix headless start Levix with no web UI at all
23
+ levix where print the data directory
24
+ levix reset-password reset the panel password
25
+ levix domain [name] point a domain at Levix (may need sudo)
26
+ ```
27
+
28
+ ---
29
+
30
+ ## What it does
31
+
32
+ **Commands** — `!ping`, `!help`, `!calc`, `!poll`, `!rand`, `!loop`, `!todo`,
33
+ `!notes`, `!debt`, `!weather`, `!prayer`, `!tts`, `!stt`, `!shortlink`,
34
+ `!qr`, `!score`… every one of them can be renamed, re-permissioned or turned
35
+ off from the panel.
36
+
37
+ **Group moderation** — welcome messages, anti-link, anti-spam, media rules,
38
+ warnings with auto-kick, rules and notes, plus the usual `!group kick`,
39
+ `promote`, `demote`, `add`, `tagadmins`.
40
+
41
+ **An AI agent, not a chat box** — `!gemini` can search the web, open a page and
42
+ read it, save something to its long-term memory and hand out bot roles, across
43
+ several tool rounds, narrating the whole run inside a single message it keeps
44
+ editing. Its personality is a Markdown file you can edit from the panel.
45
+
46
+ **Long-term memory** — "remember that…" writes to `memory/global.md` or a
47
+ per-chat file. Plain Markdown, hand-editable, injected into every prompt.
48
+
49
+ **Scheduled messages** — one-off (`!schedule`) or recurring (`!autoschedule`),
50
+ stored in the database and listed in the panel.
51
+
52
+ **A panel you can also do without** — `levix headless` runs the bot with no
53
+ Express, no socket.io and no port open at all; an unpaired install prints its
54
+ QR straight to the terminal.
55
+
56
+ **A domain, without a takeover** — `levix domain bot.example.com` looks at what
57
+ the server already runs and works with it: it adds one nginx or Caddy site and
58
+ validates the whole configuration before reloading, and on a panel-managed or
59
+ containerised host it changes nothing and prints the exact reverse-proxy
60
+ settings instead.
61
+
62
+ ---
63
+
64
+ ## How it is put together
65
+
66
+ - **Node 24+**, ES modules and CommonJS side by side (see `CLAUDE.md`).
67
+ - **[Baileys v7](https://github.com/WhiskeySockets/Baileys)** for WhatsApp,
68
+ including the LID system.
69
+ - **SQLite through `node:sqlite`** — Node's own module, so the whole datastore
70
+ is one file and zero dependencies. No database server, nothing to compile.
71
+ - **Express + EJS** for the panel. No framework, no CDN, no build step.
72
+ - **Google Gemini** for the AI, with an optional Groq fallback.
73
+
74
+ Three things follow from that, and they are the point of the design:
75
+
76
+ **The database is the source of truth, the panel is how you edit it.** There is
77
+ no `.env` file and no config file — the prefix, every permission, every API key
78
+ and every tuning value is a row in `bot_settings`, and every read goes through
79
+ an accessor at call time so a change applies to the next message.
80
+
81
+ **Secrets are generated, not configured.** The session signing key is made on
82
+ first start. The panel password is chosen once, in the browser, and stored as
83
+ an scrypt hash.
84
+
85
+ **Everything mutable lives in one directory** — database, WhatsApp session,
86
+ memory files, logs. `levix where` prints it; copying it is a full backup.
87
+
88
+ ---
89
+
90
+ ## For developers
91
+
92
+ ```bash
93
+ git clone https://github.com/Abdodiab2005/levix
94
+ cd levix
95
+ npm install
96
+ npm start # data lands in ./data
97
+ ```
98
+
99
+ `CLAUDE.md` is the architecture document: module layout, the storage API, the
100
+ message flow, and the rules a change has to respect. `PACKAGING.md` covers
101
+ shipping it — npm, Docker, systemd, and the single-executable build.
102
+
103
+ ## License
104
+
105
+ MIT.
package/SETUP.md ADDED
@@ -0,0 +1,242 @@
1
+ # Setting up Levix
2
+
3
+ Levix is a WhatsApp bot you run yourself. It links to your own WhatsApp account
4
+ by scanning a QR code, exactly like WhatsApp Web, and everything after that is
5
+ configured from a control panel in your browser.
6
+
7
+ There is no configuration file to edit. There are no keys to generate. If a
8
+ guide anywhere tells you to create a `.env` file, it is out of date.
9
+
10
+ ---
11
+
12
+ ## What you need
13
+
14
+ - A computer or a small server that stays on. A Raspberry Pi is enough.
15
+ - **Node.js 24 or newer** — [nodejs.org](https://nodejs.org), pick the LTS
16
+ button. (Or skip Node entirely and use Docker, below.)
17
+ - A phone with WhatsApp, to scan the QR once.
18
+
19
+ Levix stores everything in one file using SQLite, which Node 24 includes.
20
+ Nothing is compiled, nothing else is installed, no database server is needed.
21
+
22
+ ---
23
+
24
+ ## The quickest way
25
+
26
+ ```bash
27
+ npm install -g levix-bot
28
+ levix
29
+ ```
30
+
31
+ `levix-bot` is the npm package name. The command you run after installing it is
32
+ still just `levix`.
33
+
34
+ The terminal prints something like:
35
+
36
+ ```
37
+ Levix is running
38
+
39
+ Panel: http://localhost:3001/setup
40
+ Data: ~/.levix
41
+
42
+ First run — that link asks you to pick a password.
43
+ Opening it from another machine also needs this code: 43CB5162
44
+
45
+ Opening Levix in your browser...
46
+
47
+ Press Ctrl+C to stop.
48
+ ```
49
+
50
+ 1. On a desktop the browser opens by itself. Otherwise open that address.
51
+ 2. Choose a password. (Opening the page from a *different* machine also asks
52
+ for the setup code above — that's what stops a stranger from claiming your
53
+ bot before you do.)
54
+ 3. You land on the control panel. Go to **Connection** and scan the QR with
55
+ WhatsApp → Settings → Linked devices → Link a device.
56
+ 4. Send `!ping` in any chat. The bot answers.
57
+
58
+ Levix only opens a browser when there is one to open: over SSH, under systemd,
59
+ in Docker or in CI it just prints the address and says why. `--no-open` turns
60
+ it off anywhere.
61
+
62
+ That's the whole setup. Keep the terminal open, or see *Keeping it running*.
63
+
64
+ ---
65
+
66
+ ## With Docker instead
67
+
68
+ No Node install, nothing on your system but the container:
69
+
70
+ ```bash
71
+ git clone https://github.com/Abdodiab2005/levix
72
+ cd levix
73
+ docker compose up -d
74
+ ```
75
+
76
+ Then open <http://localhost:3001>. The setup code is in the logs:
77
+
78
+ ```bash
79
+ docker compose logs levix | grep -A3 "Setup code"
80
+ ```
81
+
82
+ Everything the bot owns lives in the `levix-data` volume.
83
+
84
+ ---
85
+
86
+ ## Running it without the web panel
87
+
88
+ On a server you may not want a panel at all:
89
+
90
+ ```bash
91
+ levix headless
92
+ ```
93
+
94
+ That starts the bot and nothing else — no web interface, no port opened. If the
95
+ bot has never been paired it prints the QR straight into the terminal:
96
+
97
+ ```
98
+ Levix — headless
99
+
100
+ No WhatsApp session found.
101
+
102
+ Scan this QR from:
103
+ WhatsApp -> Linked devices -> Link a device
104
+
105
+ [ the QR ]
106
+
107
+ ✓ WhatsApp connected
108
+ ✓ Levix is ready
109
+
110
+ 55 commands loaded
111
+ ```
112
+
113
+ You can switch modes freely: the data directory is the same either way.
114
+
115
+ ---
116
+
117
+ ## Putting it on a domain
118
+
119
+ ```bash
120
+ sudo levix domain bot.example.com
121
+ ```
122
+
123
+ Levix looks at what the server already runs and fits in with it:
124
+
125
+ | What it finds | What it does |
126
+ | --- | --- |
127
+ | nginx (+ certbot) | adds one nginx site, runs `nginx -t`, reloads, offers HTTPS |
128
+ | Caddy | adds one site file to the existing Caddy |
129
+ | Apache | prints the reverse-proxy settings; changes nothing |
130
+ | a hosting panel (Plesk, cPanel, CloudPanel, …) | prints the settings and where to enter them; changes nothing |
131
+ | nothing at all | offers to install Caddy, which handles HTTPS by itself |
132
+ | something unrecognised on :80/:443 | stops and tells you what is there |
133
+
134
+ It never edits another site, never overwrites `nginx.conf`, and never reloads a
135
+ configuration that failed validation. Running it twice does not create a second
136
+ copy of anything.
137
+
138
+ The bot itself never needs root — only this command does, and only when it is
139
+ actually going to write to `/etc`.
140
+
141
+ ---
142
+
143
+ ## Turning on the parts that need a key
144
+
145
+ The bot works out of the box. Two features need a free key, and both are set in
146
+ **Settings** in the panel — never in a file:
147
+
148
+ | Feature | Where to get the key |
149
+ | --- | --- |
150
+ | AI replies (`!gemini`, `!ai`) | [aistudio.google.com/apikey](https://aistudio.google.com/apikey) |
151
+ | Weather (`!weather`) | [openweathermap.org/api](https://openweathermap.org/api) |
152
+
153
+ Paste the key, press Save. It applies to the next message — no restart.
154
+
155
+ ---
156
+
157
+ ## Keeping it running
158
+
159
+ Closing the terminal stops the bot. Pick one:
160
+
161
+ **Docker** — already handled; the container restarts by itself.
162
+
163
+ **A Linux server** — the installer sets up a service that starts on boot:
164
+
165
+ ```bash
166
+ curl -fsSL https://levix.leviro.net/install.sh | bash
167
+ ```
168
+
169
+ Read the script first if you like — it is short, and it explains each step.
170
+
171
+ That always installs the newest stable Levix. To stay on one version — the same
172
+ script, pinned — use its own URL:
173
+
174
+ ```bash
175
+ curl -fsSL https://levix.leviro.net/install/v2.0.0.sh | bash
176
+ ```
177
+
178
+ **Your own machine** — `pm2` works well:
179
+
180
+ ```bash
181
+ npm install -g pm2
182
+ pm2 start levix --name levix
183
+ pm2 save && pm2 startup
184
+ ```
185
+
186
+ ---
187
+
188
+ ## Where your data is
189
+
190
+ One directory holds all of it: the database, the WhatsApp session, the bot's
191
+ long-term memory, the logs.
192
+
193
+ ```bash
194
+ levix where
195
+ ```
196
+
197
+ - installed with npm: `~/.levix`
198
+ - running from a git clone: `./data` inside the clone
199
+ - Docker: the `levix-data` volume
200
+
201
+ **Backing up** means copying that directory. **Moving to another machine**
202
+ means copying it across and starting Levix there — including the WhatsApp
203
+ session, so you don't even rescan the QR.
204
+
205
+ ---
206
+
207
+ ## When something goes wrong
208
+
209
+ **"Port 3001 is already taken"** — Levix is already running, or something else
210
+ holds that port. Open <http://localhost:3001> and see. To move it: Settings →
211
+ Server → Control panel port, then restart.
212
+
213
+ **Forgot the panel password**
214
+
215
+ ```bash
216
+ levix reset-password
217
+ ```
218
+
219
+ Then start it again and pick a new one. The WhatsApp link is untouched.
220
+
221
+ **The bot stopped answering** — WhatsApp dropped the link, usually because the
222
+ phone was offline for a long time. Open the panel; if it asks for a QR, scan it
223
+ again.
224
+
225
+ **"Levix needs Node 24 or newer"** — your Node is older. Install the LTS build
226
+ from [nodejs.org](https://nodejs.org) and try again.
227
+
228
+ **Start over completely** — stop the bot, delete the data directory
229
+ (`levix where` prints it), start it again. That wipes everything: settings,
230
+ memory, the WhatsApp link.
231
+
232
+ ---
233
+
234
+ ## Two things worth knowing
235
+
236
+ **Whoever scans the QR owns the bot.** That account is the owner and can run
237
+ every command. You can add more owners and admins from **Roles** in the panel.
238
+
239
+ **The panel has no HTTPS of its own.** On your own machine that's fine. On a
240
+ server reachable from the internet, run `sudo levix domain bot.example.com` —
241
+ it puts a reverse proxy with a certificate in front, sets the proxy-hop count
242
+ for you, and stops the panel listening publicly on its raw port.
package/app.cjs ADDED
@@ -0,0 +1,337 @@
1
+ // file: app.cjs
2
+ //
3
+ // The control panel's HTTP surface. It is not optional: the panel is the only
4
+ // place the bot is configured, so there is always a server and always a login.
5
+ //
6
+ // Nothing here reads a configuration file or an environment variable. The
7
+ // session key is generated on first start (src/config/secrets.cjs), the
8
+ // password is chosen once from the browser, and the handful of HTTP knobs
9
+ // (port, proxy hops, extra origin) are settings in the database that the panel
10
+ // itself can change.
11
+
12
+ const http = require("node:http");
13
+ const express = require("express");
14
+ const { Server } = require("socket.io");
15
+ const session = require("express-session");
16
+
17
+ const logger = require("./src/utils/logger.cjs");
18
+ const { assetPath } = require("./src/config/paths.cjs");
19
+ const brand = require("./src/config/brand.cjs");
20
+ const settings = require("./src/config/settings.cjs");
21
+ const secrets = require("./src/config/secrets.cjs");
22
+ const { getQrCode } = require("./src/utils/storage.cjs");
23
+ const {
24
+ clientAddress,
25
+ isDirectLocalRequest,
26
+ } = require("./src/utils/requestOrigin.cjs");
27
+
28
+ const SESSION_COOKIE_NAME = "wa.sid";
29
+ const SESSION_MAX_AGE_MS = 12 * 60 * 60 * 1000;
30
+
31
+ const app = express();
32
+ const server = http.createServer(app);
33
+
34
+ // Origin check for the socket: socket.io's `cors` option only covers polling,
35
+ // and the browser doesn't apply CORS to a websocket at all.
36
+ function isAllowedOrigin(origin, host) {
37
+ if (!origin) return true; // curl / a mobile app: no browser to protect
38
+ const extra = settings.get("dashboard_origin");
39
+ if (extra && origin === extra) return true;
40
+ return origin === `http://${host}` || origin === `https://${host}`;
41
+ }
42
+
43
+ // serveClient reads the browser bundle out of node_modules on every request,
44
+ // which a packaged build has no way to do. We vendor it in public/ instead —
45
+ // same reason qrcode.min.js is there rather than on a CDN.
46
+ const io = new Server(server, {
47
+ serveClient: false,
48
+ maxHttpBufferSize: 1e6,
49
+ allowRequest: (req, callback) => {
50
+ callback(null, isAllowedOrigin(req.headers.origin, req.headers.host));
51
+ },
52
+ });
53
+
54
+ app.disable("x-powered-by");
55
+
56
+ // Behind a proxy (nginx / Cloudflare) this has to match the real number of
57
+ // hops, or req.ip is the proxy's address — or worse, one the client forged.
58
+ // Default: off, which is right when the panel is exposed directly.
59
+ const trustProxy = settings.get("trust_proxy");
60
+ // Remembered rather than re-read: whether a proxy is in front decides whether
61
+ // the first-run page can ever skip the setup code, and that must not change
62
+ // under us mid-process. See src/utils/requestOrigin.cjs.
63
+ const PROXY_CONFIGURED = Boolean(trustProxy);
64
+ if (PROXY_CONFIGURED) {
65
+ app.set("trust proxy", /^\d+$/.test(trustProxy) ? Number(trustProxy) : trustProxy);
66
+ }
67
+
68
+ app.use((req, res, next) => {
69
+ res.set("X-Content-Type-Options", "nosniff");
70
+ res.set("Referrer-Policy", "no-referrer");
71
+ res.set("X-Frame-Options", "DENY");
72
+ res.set("Content-Security-Policy", "frame-ancestors 'none'");
73
+ res.set("Cross-Origin-Opener-Policy", "same-origin");
74
+ next();
75
+ });
76
+
77
+ // 100 KB is plenty for the small dashboard mutations. The dashboard API is the
78
+ // exception: it carries whole Markdown files (the AI persona, a memory file),
79
+ // so src/index.js mounts `dashboardJson` in front of it and this parser skips
80
+ // those paths.
81
+ const jsonBody = express.json({ limit: "100kb" });
82
+ const dashboardJson = express.json({ limit: "2mb" });
83
+
84
+ app.use((req, res, next) => {
85
+ if (req.path.startsWith("/dashboard/api/")) return next();
86
+ return jsonBody(req, res, next);
87
+ });
88
+
89
+ // SameSite=lax closes the classic CSRF; this is a cheap check on top of it for
90
+ // anything that changes state.
91
+ const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
92
+ app.use((req, res, next) => {
93
+ if (SAFE_METHODS.has(req.method)) return next();
94
+ if (isAllowedOrigin(req.get("origin"), req.get("host"))) return next();
95
+ return res.status(403).json({ error: "Cross-origin request rejected" });
96
+ });
97
+
98
+ function requireLoginApi(req, res, next) {
99
+ if (req.session?.loggedIn) return next();
100
+ return res.status(401).json({ error: "Unauthorized" });
101
+ }
102
+
103
+ // The QR and the dashboard are sensitive: no-store so a proxy or the browser's
104
+ // back button can't hand a signed-in page to someone else.
105
+ function noStore(req, res, next) {
106
+ res.set("Cache-Control", "no-store");
107
+ next();
108
+ }
109
+
110
+ function requireLoginPage(req, res, next) {
111
+ if (req.session?.loggedIn) return next();
112
+ return res.redirect("/");
113
+ }
114
+
115
+ // Bind the engine by value rather than letting express require("ejs") lazily
116
+ // by name: a packaged build has no module resolver to answer that call.
117
+ app.engine("ejs", require("ejs").__express);
118
+ app.set("view engine", "ejs");
119
+ app.set("views", assetPath("views"));
120
+ // Name, tagline and credit for every template. Frozen — see brand.cjs.
121
+ app.locals.brand = brand;
122
+ app.use(express.static(assetPath("public")));
123
+ app.use(express.urlencoded({ extended: true, limit: "100kb" }));
124
+
125
+ const sessionMiddleware = session({
126
+ name: SESSION_COOKIE_NAME,
127
+ secret: secrets.getSessionSecret(),
128
+ resave: false,
129
+ saveUninitialized: false,
130
+ cookie: {
131
+ httpOnly: true,
132
+ sameSite: "lax",
133
+ // "auto" = Secure only on an HTTPS request, so the same install works on
134
+ // http://localhost and behind a TLS proxy without a setting to get wrong.
135
+ secure: "auto",
136
+ path: "/",
137
+ maxAge: SESSION_MAX_AGE_MS,
138
+ },
139
+ });
140
+
141
+ app.use(sessionMiddleware);
142
+
143
+ // Every socket receives the pairing QR, so it goes through the same session.
144
+ io.engine.use(sessionMiddleware);
145
+ io.use((socket, next) => {
146
+ if (socket.request.session?.loggedIn) return next();
147
+ next(new Error("unauthorized"));
148
+ });
149
+
150
+ // --- Attempt throttling ---------------------------------------------------
151
+ // Shared by /login and /setup: both are a guess at a secret.
152
+ //
153
+ // Keyed on the TCP peer, never on `req.ip`: with `trust proxy` set, a client
154
+ // picks its own `req.ip` by writing a header, so it could rotate the value and
155
+ // never hit the limit.
156
+
157
+ const ATTEMPT_WINDOW_MS = 15 * 60 * 1000;
158
+ const MAX_ATTEMPTS = 5;
159
+ const attempts = new Map();
160
+
161
+ function blockedFor(ip) {
162
+ const now = Date.now();
163
+ for (const [key, entry] of attempts) {
164
+ if (now - entry.firstAt > ATTEMPT_WINDOW_MS) attempts.delete(key);
165
+ }
166
+ const entry = attempts.get(ip);
167
+ if (!entry || entry.count < MAX_ATTEMPTS) return 0;
168
+ const remaining = ATTEMPT_WINDOW_MS - (now - entry.firstAt);
169
+ return remaining > 0 ? Math.ceil(remaining / 1000) : 0;
170
+ }
171
+
172
+ function recordFailure(ip) {
173
+ const now = Date.now();
174
+ const entry = attempts.get(ip);
175
+ if (!entry || now - entry.firstAt > ATTEMPT_WINDOW_MS) {
176
+ attempts.set(ip, { count: 1, firstAt: now });
177
+ return;
178
+ }
179
+ entry.count += 1;
180
+ }
181
+
182
+ // --- First run ------------------------------------------------------------
183
+ //
184
+ // Until a password exists there is nothing to log into. The panel shows a
185
+ // "choose your password" page instead.
186
+ //
187
+ // From a direct loopback connection that is all it asks: whoever is sitting at
188
+ // the machine is the operator. Every other request — remote, proxied, or merely
189
+ // carrying a forwarding header — also needs the setup code printed on the
190
+ // server's console, so claiming the bot requires access to the machine rather
191
+ // than to the port. See src/utils/requestOrigin.cjs for why this can't be
192
+ // `req.ip`.
193
+
194
+ function isLocalRequest(req) {
195
+ return isDirectLocalRequest(req, { proxyConfigured: PROXY_CONFIGURED });
196
+ }
197
+
198
+ function renderSetup(req, res, error, status = 200) {
199
+ res.status(status).render("setup", {
200
+ error,
201
+ needsCode: !isLocalRequest(req),
202
+ minLength: secrets.MIN_PASSWORD_LENGTH,
203
+ });
204
+ }
205
+
206
+ app.get("/setup", noStore, (req, res) => {
207
+ if (secrets.hasDashboardPassword()) return res.redirect("/");
208
+ renderSetup(req, res, null);
209
+ });
210
+
211
+ app.post("/setup", (req, res, next) => {
212
+ if (secrets.hasDashboardPassword()) return res.redirect("/");
213
+
214
+ const peer = clientAddress(req);
215
+ const retryAfter = blockedFor(peer);
216
+ if (retryAfter) {
217
+ res.set("Retry-After", String(retryAfter));
218
+ return renderSetup(req, res, "Too many attempts. Try again later.", 429);
219
+ }
220
+
221
+ const { password, confirm, code } = req.body || {};
222
+
223
+ if (!isLocalRequest(req) && !secrets.setupCodeMatches(code)) {
224
+ recordFailure(peer);
225
+ logger.warn({ ip: peer }, "[dashboard] Setup attempted with a wrong code");
226
+ return renderSetup(req, res, "Wrong setup code.", 401);
227
+ }
228
+
229
+ if (password !== confirm) {
230
+ return renderSetup(req, res, "The two passwords don't match.", 400);
231
+ }
232
+
233
+ try {
234
+ secrets.setDashboardPassword(password);
235
+ } catch (error) {
236
+ return renderSetup(req, res, error.message, 400);
237
+ }
238
+
239
+ attempts.delete(peer);
240
+ logger.info({ ip: peer }, "[dashboard] Password set — first run complete");
241
+
242
+ req.session.regenerate((err) => {
243
+ if (err) return next(err);
244
+ req.session.loggedIn = true;
245
+ req.session.save((saveErr) => {
246
+ if (saveErr) return next(saveErr);
247
+ res.redirect(303, "/");
248
+ });
249
+ });
250
+ });
251
+
252
+ // --- Routes ---------------------------------------------------------------
253
+
254
+ app.get("/", noStore, (req, res) => {
255
+ if (!secrets.hasDashboardPassword()) return res.redirect("/setup");
256
+ if (req.session.loggedIn) return res.render("dashboard");
257
+ return res.render("login", { error: null });
258
+ });
259
+
260
+ app.post("/login", (req, res, next) => {
261
+ if (!secrets.hasDashboardPassword()) return res.redirect("/setup");
262
+
263
+ const peer = clientAddress(req);
264
+ const retryAfter = blockedFor(peer);
265
+ if (retryAfter) {
266
+ res.set("Retry-After", String(retryAfter));
267
+ return res.status(429).render("login", {
268
+ error: "Too many attempts. Try again later.",
269
+ });
270
+ }
271
+
272
+ if (!secrets.verifyDashboardPassword(req.body?.password)) {
273
+ recordFailure(peer);
274
+ logger.warn({ ip: peer }, "[dashboard] Failed login attempt");
275
+ return res.status(401).render("login", { error: "Incorrect Password" });
276
+ }
277
+
278
+ attempts.delete(peer);
279
+
280
+ // A fresh id after login, or the id an attacker planted beforehand still works.
281
+ req.session.regenerate((err) => {
282
+ if (err) return next(err);
283
+ req.session.loggedIn = true;
284
+ req.session.save((saveErr) => {
285
+ if (saveErr) return next(saveErr);
286
+ res.redirect(303, "/");
287
+ });
288
+ });
289
+ });
290
+
291
+ // POST, not GET: a link that changes state gets fired by an <img> or by the
292
+ // browser's prefetch.
293
+ app.post("/logout", (req, res, next) => {
294
+ req.session.destroy((err) => {
295
+ if (err) return next(err);
296
+ res.clearCookie(SESSION_COOKIE_NAME, {
297
+ httpOnly: true,
298
+ sameSite: "lax",
299
+ path: "/",
300
+ });
301
+ res.redirect(303, "/");
302
+ });
303
+ });
304
+
305
+ app.get("/qr", requireLoginPage, noStore, (req, res) => {
306
+ const qr = getQrCode();
307
+ res.render("qr", { qr: qr || "" });
308
+ });
309
+
310
+ // Unlink / restart / everything else live on the dashboard API
311
+ // (/dashboard/api/*), registered in src/index.js once the socket exists.
312
+
313
+ // The 404 and the error handler have to be registered after every route — put
314
+ // them here and they would swallow the routes index.js adds later.
315
+ function installFinalHandlers() {
316
+ app.use((req, res) => {
317
+ res.status(404).json({ error: "Not found" });
318
+ });
319
+
320
+ // eslint-disable-next-line no-unused-vars
321
+ app.use((err, req, res, next) => {
322
+ logger.error({ err, method: req.method, path: req.path }, "[http] Unhandled error");
323
+ if (res.headersSent) return;
324
+ res.status(err.statusCode || 500).json({ error: "Internal server error" });
325
+ });
326
+ }
327
+
328
+ module.exports = {
329
+ app,
330
+ server,
331
+ io,
332
+ dashboardJson,
333
+ requireLoginApi,
334
+ requireLoginPage,
335
+ noStore,
336
+ installFinalHandlers,
337
+ };