web-chatter 0.1.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 (2) hide show
  1. package/package.json +45 -0
  2. package/web-chatter.js +549 -0
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "web-chatter",
3
+ "version": "0.1.0",
4
+ "description": "Simple Express-based web server for chat bots with user authentication, SQLite persistence per user, admin controls, and customizable authenticated routes",
5
+ "main": "./web-chatter.js",
6
+ "type": "module",
7
+ "exports": {
8
+ "default": "./web-chatter.js"
9
+ },
10
+ "files": [
11
+ "web-chatter.js",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "keywords": [
16
+ "express",
17
+ "chatbot",
18
+ "web-server",
19
+ "authentication",
20
+ "sqlite",
21
+ "user-management",
22
+ "admin-panel",
23
+ "session",
24
+ "bcrypt"
25
+ ],
26
+ "author": "Dan <littlejustnode@gmail.com>[](https://github.com/littlejustnode)",
27
+ "license": "MIT",
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "git+https://github.com/littlejustnode/web-chatter.git"
31
+ },
32
+ "bugs": {
33
+ "url": "https://github.com/littlejustnode/web-chatter/issues"
34
+ },
35
+ "homepage": "https://github.com/littlejustnode/web-chatter#readme",
36
+ "dependencies": {
37
+ "express": "^4.21.1",
38
+ "express-session": "^1.18.1",
39
+ "better-sqlite3": "^11.5.0",
40
+ "bcryptjs": "^2.4.3"
41
+ },
42
+ "engines": {
43
+ "node": ">=18.0.0"
44
+ }
45
+ }
package/web-chatter.js ADDED
@@ -0,0 +1,549 @@
1
+ // web-bot.js
2
+ import express from 'express';
3
+ import session from 'express-session';
4
+ import path from 'path';
5
+ import { fileURLToPath } from 'url';
6
+ import Database from 'better-sqlite3';
7
+ import bcrypt from 'bcryptjs';
8
+ import fs from 'fs';
9
+
10
+ const __filename = fileURLToPath(import.meta.url);
11
+ const __dirname = path.dirname(__filename);
12
+
13
+ export class WebChat {
14
+ static app(options = {}) {
15
+ return new WebChat(options);
16
+ }
17
+
18
+ #app;
19
+ #usersDb;
20
+ #options;
21
+ #dbDir;
22
+
23
+ constructor(options = {}) {
24
+ this.#options = {
25
+ dbDir: path.join(process.cwd(), 'data'),
26
+ sessionSecret: 'super-secret-key-CHANGE-ME-12345',
27
+ port: 3000,
28
+ host: '0.0.0.0',
29
+ staticDir: path.join(__dirname, './public'),
30
+ onChat: null,
31
+ trustAuth: ['127.0.0.1', '::1', 'localhost'],
32
+ ...options,
33
+ };
34
+
35
+ this.#options.trustAuth = this.#options.trustAuth.map(ip => {
36
+ if (ip === 'localhost') return ['127.0.0.1', '::1'];
37
+ return ip;
38
+ }).flat();
39
+
40
+ this.#dbDir = this.#options.dbDir;
41
+
42
+ if (!fs.existsSync(this.#dbDir)) {
43
+ fs.mkdirSync(this.#dbDir, { recursive: true });
44
+ console.log(`Created database directory: ${this.#dbDir}`);
45
+ }
46
+
47
+ this.#app = express();
48
+
49
+ this.#initUsersDatabase();
50
+ this.#setupMiddleware();
51
+ this.#setupCoreRoutes();
52
+ }
53
+
54
+ #initUsersDatabase() {
55
+ const usersDbPath = path.join(this.#dbDir, 'users.db');
56
+ this.#usersDb = new Database(usersDbPath, { verbose: console.log });
57
+
58
+ this.#usersDb.exec(`
59
+ CREATE TABLE IF NOT EXISTS users (
60
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
61
+ username TEXT UNIQUE NOT NULL,
62
+ password TEXT NOT NULL,
63
+ name TEXT,
64
+ role TEXT NOT NULL DEFAULT 'user',
65
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
66
+ );
67
+ `);
68
+ }
69
+
70
+ #getUserDb(username) {
71
+ const safeUsername = username.replace(/[^a-zA-Z0-9_-]/g, '_');
72
+ const userDbPath = path.join(this.#dbDir, `chat-${safeUsername}.db`);
73
+ return new Database(userDbPath, { verbose: console.log });
74
+ }
75
+
76
+ #setupMiddleware() {
77
+ this.#app.use(express.static(this.#options.staticDir));
78
+ this.#app.use(express.json());
79
+
80
+ this.#app.use(
81
+ session({
82
+ secret: this.#options.sessionSecret,
83
+ resave: false,
84
+ saveUninitialized: false,
85
+ cookie: { maxAge: 1000 * 60 * 60 * 24 * 7 }, // 1 week
86
+ })
87
+ );
88
+ }
89
+
90
+ #requireLogin(req, res, next) {
91
+ if (!req.session.user) {
92
+ return res.status(401).json({ error: 'Authentication required' });
93
+ }
94
+ next();
95
+ }
96
+
97
+ #requireAdmin(req, res, next) {
98
+ if (!req.session.user) {
99
+ return res.status(401).json({ error: 'Authentication required' });
100
+ }
101
+ const user = this.#usersDb.prepare('SELECT role FROM users WHERE username = ?').get(req.session.user.username);
102
+ if (!user || user.role !== 'admin') {
103
+ return res.status(403).json({ error: 'Admin access required' });
104
+ }
105
+ next();
106
+ }
107
+
108
+ #isTrustedIP(req) {
109
+ let ip = req.ip || req.connection.remoteAddress || req.socket.remoteAddress || '';
110
+ ip = ip.replace(/^::ffff:/, '');
111
+ return this.#options.trustAuth.includes(ip);
112
+ }
113
+
114
+ #maybeSkipAuth(req, res, next) {
115
+ if (this.#isTrustedIP(req)) {
116
+ // Trusted clients do NOT get auto-login — they must send username in body
117
+ return next();
118
+ }
119
+
120
+ if (!req.session.user) {
121
+ return res.status(401).json({ error: 'Authentication required' });
122
+ }
123
+ next();
124
+ }
125
+
126
+
127
+ #getAuthMiddleware(level) {
128
+ level = (level || '').toLowerCase();
129
+
130
+ if (level === 'admin') {
131
+ return this.#requireAdmin.bind(this);
132
+ }
133
+ if (level === 'user') {
134
+ return this.#requireLogin.bind(this);
135
+ }
136
+ if (level === 'none' || level === '') {
137
+ return (req, res, next) => next();
138
+ }
139
+
140
+ throw new Error(`Invalid access level: "${level}". Use "admin", "user" or "none"`);
141
+ }
142
+
143
+ #mountWithAuth(path, access, handler) {
144
+ const authMiddleware = this.#getAuthMiddleware(access);
145
+ this.#app.use(path, authMiddleware, handler);
146
+ }
147
+
148
+
149
+ /**
150
+ * Register a custom route with flexible auth level
151
+ *
152
+ * @param {string} path - route path (e.g. '/report', '/api/v1/stats')
153
+ * @param {string} method - 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'ALL'
154
+ * @param {string} access - 'admin' | 'user' | 'none'
155
+ * @param {Function} handler - express handler (req, res) => { … }
156
+ */
157
+ use(path, method, access, handler) {
158
+ if (typeof handler !== 'function') {
159
+ throw new Error('handler must be a function (req, res) => { … }');
160
+ }
161
+
162
+ const upperMethod = method.toUpperCase();
163
+
164
+ if (upperMethod === 'ALL') {
165
+ // special case: matches any method
166
+ this.#mountWithAuth(path, access, (req, res, next) => {
167
+ handler(req, res);
168
+ });
169
+ return;
170
+ }
171
+
172
+ if (!['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].includes(upperMethod)) {
173
+ throw new Error(`Unsupported method: ${method}. Use GET, POST, PUT, PATCH, DELETE or ALL`);
174
+ }
175
+
176
+ const authMiddleware = this.#getAuthMiddleware(access);
177
+
178
+ // Mount under the real path (no forced /api/custom prefix)
179
+ this.#app[upperMethod.toLowerCase()](path, authMiddleware, (req, res) => {
180
+ try {
181
+ handler(req, res);
182
+ } catch (err) {
183
+ console.error(`Custom route error [${upperMethod} ${path}]:`, err);
184
+ if (!res.headersSent) {
185
+ res.status(500).json({ error: 'Custom endpoint failed' });
186
+ }
187
+ }
188
+ });
189
+
190
+ console.log(`Custom route added: ${upperMethod} ${path} (access: ${access})`);
191
+ }
192
+
193
+ createUser(username, password, name = null, role = 'user') {
194
+ if (!username || typeof username !== 'string' || username.length < 3) {
195
+ throw new Error('Valid username required (≥ 3 characters)');
196
+ }
197
+ if (!password || typeof password !== 'string' || password.length < 6) {
198
+ throw new Error('Valid password required (≥ 6 characters)');
199
+ }
200
+ if (!['user', 'admin'].includes(role)) {
201
+ throw new Error('Role must be "user" or "admin"');
202
+ }
203
+
204
+ const existing = this.#usersDb.prepare('SELECT 1 FROM users WHERE username = ?').get(username);
205
+ if (existing) {
206
+ throw new Error(`Username "${username}" already exists`);
207
+ }
208
+
209
+ const hashed = bcrypt.hashSync(password, 10);
210
+
211
+ this.#usersDb.prepare(
212
+ 'INSERT INTO users (username, password, name, role) VALUES (?, ?, ?, ?)'
213
+ ).run(username, hashed, name || null, role);
214
+
215
+ const userDb = this.#getUserDb(username);
216
+ userDb.exec(`
217
+ CREATE TABLE IF NOT EXISTS chat (
218
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
219
+ text TEXT NOT NULL,
220
+ response TEXT,
221
+ time TEXT NOT NULL,
222
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
223
+ );
224
+ `);
225
+ userDb.close();
226
+
227
+ console.log(`Created user: ${username} (${role})${name ? ` - "${name}"` : ''}`);
228
+ }
229
+
230
+ setOnChat(handler) {
231
+ if (handler !== null && typeof handler !== 'function') {
232
+ throw new Error('onChat handler must be a function or null');
233
+ }
234
+ this.#options.onChat = handler;
235
+ console.log(
236
+ handler
237
+ ? `onChat handler set (${handler.name || 'anonymous function'})`
238
+ : 'onChat handler cleared (now null)'
239
+ );
240
+ }
241
+
242
+ getOnChat() {
243
+ return this.#options.onChat;
244
+ }
245
+
246
+ #setupCoreRoutes() {
247
+ const { staticDir } = this.#options;
248
+
249
+ // ── Public ──────────────────────────────────────────────────
250
+ this.#app.get('/', (req, res) => {
251
+ if (req.session.user) return res.redirect('/chat');
252
+ res.sendFile(path.join(staticDir, 'index.html'));
253
+ });
254
+
255
+ this.#app.post('/login', (req, res) => {
256
+ let { username, password } = req.body;
257
+
258
+
259
+
260
+ if (!username)
261
+ username = req.query.username;
262
+ if (!password)
263
+ password = req.query.password;
264
+
265
+ if (!username || !password) {
266
+ return res.status(400).json({ error: 'username and password are required in JSON body' });
267
+ }
268
+
269
+ const user = this.#usersDb.prepare('SELECT * FROM users WHERE username = ?').get(username);
270
+
271
+ if (!user || !bcrypt.compareSync(password, user.password)) {
272
+ return res.status(401).json({ error: 'Invalid username or password' });
273
+ }
274
+
275
+ req.session.user = {
276
+ username,
277
+ role: user.role,
278
+ name: user.name || username,
279
+ };
280
+
281
+ res.json({ success: true });
282
+ });
283
+
284
+ this.#app.get('/logout', (req, res) => {
285
+ req.session.destroy(() => {
286
+ res.json({ success: true });
287
+ });
288
+ });
289
+
290
+ this.#app.get('/chat', this.#requireLogin.bind(this), (req, res) => {
291
+ res.sendFile(path.join(staticDir, 'chat.html'));
292
+ });
293
+
294
+ // ── /api/chat ───────────────────────────────────────────────
295
+ this.#app.post('/api/chat', this.#maybeSkipAuth.bind(this), async (req, res) => {
296
+ let { username, message } = req.body;
297
+
298
+ if (!username)
299
+ username = req.session.user.username;
300
+
301
+ console.log(req.session);
302
+
303
+ if (typeof message !== 'string' || !message.trim()) {
304
+ return res.status(400).json({
305
+ error: 'Field "message" is required and must be a non-empty string'
306
+ });
307
+ }
308
+
309
+ let targetUsername;
310
+
311
+ if (this.#isTrustedIP(req)) {
312
+ if (!username || typeof username !== 'string' || username.trim() === '') {
313
+ return res.status(400).json({
314
+ error: 'Trusted clients must provide "username" in JSON body'
315
+ });
316
+ }
317
+ targetUsername = username.trim();
318
+ } else {
319
+ if (!req.session.user || !req.session.user.username) {
320
+ return res.status(401).json({ error: 'Authentication required' });
321
+ }
322
+ if (username && username !== req.session.user.username) {
323
+ return res.status(403).json({ error: 'Cannot impersonate another user' });
324
+ }
325
+ targetUsername = req.session.user.username;
326
+ }
327
+
328
+ // ──────────────── Add this check ─────────────────────────────
329
+ const userExists = this.#usersDb
330
+ .prepare('SELECT 1 FROM users WHERE username = ?')
331
+ .get(targetUsername);
332
+
333
+ if (!userExists) {
334
+ return res.status(404).json({
335
+ error: `User '${targetUsername}' does not exist`
336
+ });
337
+ }
338
+ // ─────────────────────────────────────────────────────────────
339
+
340
+ const cleanMessage = message.trim();
341
+ const time = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
342
+
343
+ const userDb = this.#getUserDb(targetUsername);
344
+
345
+ try {
346
+ const insert = userDb.prepare(`
347
+ INSERT INTO chat (text, response, time)
348
+ VALUES (?, NULL, ?)
349
+ `);
350
+ const info = insert.run(cleanMessage, time);
351
+
352
+ const entry = {
353
+ id: info.lastInsertRowid,
354
+ username: targetUsername,
355
+ text: cleanMessage,
356
+ response: null,
357
+ time,
358
+ };
359
+
360
+ if (typeof this.#options.onChat === 'function') {
361
+ try {
362
+ const response = await this.#options.onChat(targetUsername, cleanMessage);
363
+
364
+ if (typeof response === 'string' && response.trim()) {
365
+ userDb.prepare('UPDATE chat SET response = ? WHERE id = ?').run(
366
+ response.trim(),
367
+ entry.id
368
+ );
369
+ entry.response = response.trim();
370
+ }
371
+ } catch (err) {
372
+ console.error('Error in onChat handler:', err);
373
+ }
374
+ }
375
+
376
+ res.json({ success: true, result: entry.response });
377
+ } finally {
378
+ userDb.close();
379
+ }
380
+ });
381
+
382
+ // ── Admin routes ────────────────────────────────────────────
383
+ this.#app.get('/api/admin/users', this.#requireAdmin.bind(this), (req, res) => {
384
+ const users = this.#usersDb
385
+ .prepare('SELECT id, username, name, role, created_at FROM users ORDER BY created_at ASC')
386
+ .all();
387
+ res.json(users);
388
+ });
389
+
390
+ this.#app.get('/api/admin/user', this.#requireAdmin.bind(this), (req, res) => {
391
+ const { username: paramUsername } = req.params;
392
+ const { username: bodyUsername } = req.body;
393
+ const target = bodyUsername || paramUsername;
394
+
395
+ if (!target) return res.status(400).json({ error: 'username required' });
396
+
397
+ const user = this.#usersDb
398
+ .prepare('SELECT id, username, name, role, created_at FROM users WHERE username = ?')
399
+ .get(target);
400
+
401
+ if (!user) return res.status(404).json({ error: 'User not found' });
402
+ res.json(user);
403
+ });
404
+
405
+ this.#app.post('/api/admin/addUser', this.#requireAdmin.bind(this), (req, res) => {
406
+ const { username, password, name, role = 'user' } = req.body;
407
+
408
+ if (!username || !password) {
409
+ return res.status(400).json({ error: 'username and password are required' });
410
+ }
411
+
412
+ try {
413
+ this.createUser(username, password, name, role);
414
+ res.status(201).json({ success: true, username });
415
+ } catch (err) {
416
+ res.status(400).json({ error: err.message });
417
+ }
418
+ });
419
+
420
+ this.#app.patch('/api/admin/user', this.#requireAdmin.bind(this), (req, res) => {
421
+ const { username: paramUsername } = req.params;
422
+ const { username: bodyUsername, name, role } = req.body;
423
+
424
+ const target = bodyUsername || paramUsername;
425
+
426
+ if (!target) {
427
+ return res.status(400).json({ error: 'username required in JSON body or path' });
428
+ }
429
+
430
+ if (name === undefined && role === undefined) {
431
+ return res.status(400).json({ error: 'Provide at least "name" or "role"' });
432
+ }
433
+
434
+ const user = this.#usersDb
435
+ .prepare('SELECT role FROM users WHERE username = ?')
436
+ .get(target);
437
+
438
+ if (!user) return res.status(404).json({ error: 'User not found' });
439
+
440
+ const updates = [];
441
+ const params = [];
442
+
443
+ if (name !== undefined) {
444
+ updates.push('name = ?');
445
+ params.push(name || null);
446
+ }
447
+
448
+ if (role !== undefined) {
449
+ if (!['user', 'admin'].includes(role)) {
450
+ return res.status(400).json({ error: 'Role must be "user" or "admin"' });
451
+ }
452
+
453
+ if (role !== 'admin' && user.role === 'admin') {
454
+ const adminCount = this.#usersDb
455
+ .prepare('SELECT COUNT(*) as cnt FROM users WHERE role = "admin"')
456
+ .get().cnt;
457
+ if (adminCount <= 1) {
458
+ return res.status(403).json({ error: 'Cannot demote the last admin account' });
459
+ }
460
+ }
461
+
462
+ updates.push('role = ?');
463
+ params.push(role);
464
+ }
465
+
466
+ if (updates.length === 0) {
467
+ return res.status(400).json({ error: 'No valid fields to update' });
468
+ }
469
+
470
+ params.push(target);
471
+
472
+ this.#usersDb.prepare(`
473
+ UPDATE users
474
+ SET ${updates.join(', ')}
475
+ WHERE username = ?
476
+ `).run(...params);
477
+
478
+ res.json({ success: true });
479
+ });
480
+
481
+
482
+
483
+ this.#app.delete('/api/admin/user', this.#requireAdmin.bind(this), (req, res) => {
484
+ const { username: paramUsername } = req.params;
485
+ const { username: bodyUsername } = req.body;
486
+
487
+ const target = bodyUsername || paramUsername;
488
+
489
+ if (!target) {
490
+ return res.status(400).json({ error: 'username required in JSON body or path' });
491
+ }
492
+
493
+ if (req.session.user.username === target) {
494
+ return res.status(403).json({ error: 'Cannot delete your own account' });
495
+ }
496
+
497
+ const user = this.#usersDb
498
+ .prepare('SELECT role FROM users WHERE username = ?')
499
+ .get(target);
500
+
501
+ if (!user) {
502
+ return res.status(404).json({ error: 'User not found' });
503
+ }
504
+
505
+ // ── Changed logic: block deletion of ANY admin ───────────────
506
+ if (user.role === 'admin') {
507
+ return res.status(403).json({
508
+ error: 'Admin accounts cannot be deleted through this endpoint'
509
+ });
510
+ }
511
+ // ──────────────────────────────────────────────────────────────
512
+
513
+ const safeUsername = target.replace(/[^a-zA-Z0-9_-]/g, '_');
514
+ const userDbPath = path.join(this.#dbDir, `chat-${safeUsername}.db`);
515
+ if (fs.existsSync(userDbPath)) {
516
+ try {
517
+ fs.unlinkSync(userDbPath);
518
+ console.log(`Deleted user chat DB: ${userDbPath}`);
519
+ } catch (err) {
520
+ console.warn(`Failed to delete user chat DB ${userDbPath}:`, err.message);
521
+ }
522
+ }
523
+
524
+ this.#usersDb.prepare('DELETE FROM users WHERE username = ?').run(target);
525
+
526
+ res.json({ success: true, deleted: target });
527
+ });
528
+
529
+
530
+ }
531
+
532
+ start(port = this.#options.port, host = this.#options.host) {
533
+ this.#app.listen(port, host, () => {
534
+ console.log(
535
+ `WebChat server listening on http://${host === '0.0.0.0' ? 'localhost' : host}:${port}`
536
+ );
537
+ console.log(`Users DB: ${path.join(this.#dbDir, 'users.db')}`);
538
+ console.log(`User chat DBs in: ${this.#dbDir}`);
539
+ if (this.#options.trustAuth?.length) {
540
+ console.log(`Trusted IPs: ${this.#options.trustAuth.join(', ')}`);
541
+ }
542
+ if (this.#options.onChat) {
543
+ console.log(`onChat handler: ${this.#options.onChat.name || 'anonymous'}`);
544
+ }
545
+ });
546
+
547
+ return this.#app;
548
+ }
549
+ }