instbyte 1.9.1 → 1.9.2

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.
package/server/server.js CHANGED
@@ -1,866 +1,868 @@
1
- require("./cleanup");
2
- const fs = require("fs");
3
- const os = require("os");
4
- const net = require("net");
5
- const cookieParser = require("cookie-parser");
6
- const rateLimit = require("express-rate-limit");
7
- const helmet = require("helmet");
8
-
9
- let sharp = null;
10
- try { sharp = require("sharp"); } catch (e) { }
11
-
12
- const express = require("express");
13
- const http = require("http");
14
- const { Server } = require("socket.io");
15
- const multer = require("multer");
16
- const path = require("path");
17
- const db = require("./db");
18
-
19
- const config = require("./config");
20
-
21
- const UPLOADS_DIR = process.env.INSTBYTE_UPLOADS
22
- || path.join(__dirname, "../uploads");
23
-
24
- /* STARTUP ORPHAN SCAN
25
- Deletes any files in uploads dir that have no matching DB record.
26
- Catches ghost files left by aborted uploads before fix in v1.9.1 */
27
- function scanOrphans() {
28
- fs.readdir(UPLOADS_DIR, (err, files) => {
29
- if (err || !files || !files.length) return;
30
-
31
- db.all("SELECT filename FROM items WHERE filename IS NOT NULL", (err, rows) => {
32
- if (err) return;
33
-
34
- const known = new Set(rows.map(r => r.filename));
35
- files.forEach(file => {
36
- if (!known.has(file)) {
37
- const orphan = path.join(UPLOADS_DIR, file);
38
- fs.unlink(orphan, err => {
39
- if (!err) console.log("Orphan removed:", file);
40
- });
41
- }
42
- });
43
- });
44
- });
45
- }
46
-
47
- const CLIENT_DIR = path.join(__dirname, "../client");
48
-
49
- const app = express();
50
- const server = http.createServer(app);
51
- const io = new Server(server, { cors: { origin: "*" } });
52
-
53
- app.use(helmet({
54
- contentSecurityPolicy: false // disable CSP for now — it would block CDN scripts
55
- }));
56
-
57
- app.use(express.json());
58
- app.use(cookieParser());
59
- app.use(requireAuth);
60
- app.use("/uploads", express.static(UPLOADS_DIR));
61
- app.use(express.static(CLIENT_DIR));
62
-
63
- const storage = multer.diskStorage({
64
- destination: (req, file, cb) => {
65
- cb(null, UPLOADS_DIR);
66
- },
67
- filename: (req, file, cb) => {
68
- const unique = Date.now() + "-" + file.originalname;
69
- cb(null, unique);
70
- },
71
- });
72
-
73
- const upload = multer({
74
- storage,
75
- limits: { fileSize: config.storage.maxFileSize },
76
- });
77
-
78
-
79
- function hexToHsl(hex) {
80
- let r = parseInt(hex.slice(1, 3), 16) / 255;
81
- let g = parseInt(hex.slice(3, 5), 16) / 255;
82
- let b = parseInt(hex.slice(5, 7), 16) / 255;
83
-
84
- const max = Math.max(r, g, b), min = Math.min(r, g, b);
85
- let h, s, l = (max + min) / 2;
86
-
87
- if (max === min) {
88
- h = s = 0;
89
- } else {
90
- const d = max - min;
91
- s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
92
- switch (max) {
93
- case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
94
- case g: h = ((b - r) / d + 2) / 6; break;
95
- case b: h = ((r - g) / d + 4) / 6; break;
96
- }
97
- }
98
- return [Math.round(h * 360), Math.round(s * 100), Math.round(l * 100)];
99
- }
100
-
101
- function hslToHex(h, s, l) {
102
- s /= 100; l /= 100;
103
- const k = n => (n + h / 30) % 12;
104
- const a = s * Math.min(l, 1 - l);
105
- const f = n => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
106
- return "#" + [f(0), f(8), f(4)]
107
- .map(x => Math.round(x * 255).toString(16).padStart(2, "0"))
108
- .join("");
109
- }
110
-
111
- function getLuminance(hex) {
112
- const r = parseInt(hex.slice(1, 3), 16) / 255;
113
- const g = parseInt(hex.slice(3, 5), 16) / 255;
114
- const b = parseInt(hex.slice(5, 7), 16) / 255;
115
- const toLinear = c => c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
116
- return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
117
- }
118
-
119
- function buildPalette(hex) {
120
- // Fallback if hex is invalid
121
- if (!hex || !/^#[0-9a-f]{6}$/i.test(hex)) hex = "#111827";
122
-
123
- const [h, s, l] = hexToHsl(hex);
124
-
125
- // Derive secondary as complementary (180° opposite on color wheel)
126
- const secondaryHex = hslToHex((h + 180) % 360, Math.min(s, 60), Math.max(l, 35));
127
-
128
- // Text on primary — white or dark based on contrast
129
- const onPrimary = getLuminance(hex) > 0.179 ? "#111827" : "#ffffff";
130
- const onSecondary = getLuminance(secondaryHex) > 0.179 ? "#111827" : "#ffffff";
131
-
132
- return {
133
- primary: hex,
134
- primaryHover: hslToHex(h, s, Math.max(l - 10, 10)),
135
- primaryLight: hslToHex(h, Math.min(s, 80), Math.min(l + 40, 96)),
136
- primaryDark: hslToHex(h, s, Math.max(l - 20, 5)),
137
- onPrimary,
138
- secondary: secondaryHex,
139
- secondaryHover: hslToHex((h + 180) % 360, Math.min(s, 60), Math.max(l - 10, 10)),
140
- secondaryLight: hslToHex((h + 180) % 360, Math.min(s, 60), Math.min(l + 40, 96)),
141
- onSecondary,
142
- };
143
- }
144
-
145
- const COOKIE_NAME = "instbyte_auth";
146
- const COOKIE_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days
147
-
148
- function requireAuth(req, res, next) {
149
- if (!config.auth.passphrase) return next(); // no passphrase set, skip
150
-
151
- // Allow the login route itself through
152
- if (req.path === "/login" || req.path === "/info" || req.path === "/health") return next();
153
-
154
-
155
- // Check cookie
156
- const cookie = req.cookies[COOKIE_NAME];
157
- if (cookie && cookie === config.auth.passphrase) return next();
158
-
159
- // Not authenticated
160
- if (req.path.startsWith("/socket.io")) return next();
161
- if (req.headers["content-type"] === "application/json" || req.xhr) {
162
- return res.status(401).json({ error: "Unauthorized" });
163
- }
164
-
165
- res.redirect("/login");
166
- }
167
-
168
-
169
- /* LOGIN PAGE */
170
- app.get("/login", (req, res) => {
171
- if (!config.auth.passphrase) return res.redirect("/");
172
- if (req.cookies[COOKIE_NAME] === config.auth.passphrase) return res.redirect("/");
173
-
174
- const loginPalette = buildPalette(config.branding.primaryColor);
175
- const loginBrandingStyle = `
176
- button { background: ${loginPalette.primary}; color: ${loginPalette.onPrimary}; }
177
- button:hover { background: ${loginPalette.primaryHover}; }
178
- `;
179
-
180
- res.send(`<!DOCTYPE html>
181
- <html>
182
- <head>
183
- <title>${config.branding.appName || "Instbyte"} Login</title>
184
- <meta name="viewport" content="width=device-width,initial-scale=1">
185
- <style>
186
- * { box-sizing: border-box; margin: 0; padding: 0; }
187
- body {
188
- font-family: system-ui;
189
- background: #f3f4f6;
190
- display: flex;
191
- align-items: center;
192
- justify-content: center;
193
- min-height: 100vh;
194
- }
195
- .box {
196
- background: #fff;
197
- border-radius: 12px;
198
- padding: 36px;
199
- width: 100%;
200
- max-width: 360px;
201
- box-shadow: 0 4px 24px rgba(0,0,0,0.08);
202
- text-align: center;
203
- }
204
- .logo { font-size: 22px; font-weight: 700; color: #111827; margin-bottom: 6px; }
205
- .sub { font-size: 13px; color: #9ca3af; margin-bottom: 28px; }
206
- input {
207
- width: 100%;
208
- padding: 11px 14px;
209
- border: 1px solid #e5e7eb;
210
- border-radius: 8px;
211
- font-size: 14px;
212
- margin-bottom: 12px;
213
- outline: none;
214
- }
215
- input:focus { border-color: #9ca3af; }
216
- button {
217
- width: 100%;
218
- padding: 11px;
219
- background: #111827;
220
- color: #fff;
221
- border: none;
222
- border-radius: 8px;
223
- font-size: 14px;
224
- cursor: pointer;
225
- }
226
- button:hover { background: #1f2937; }
227
- .error {
228
- color: #b91c1c;
229
- font-size: 13px;
230
- margin-top: 10px;
231
- display: none;
232
- }
233
-
234
- ${loginBrandingStyle}
235
-
236
- </style>
237
- </head>
238
- <body>
239
- <div class="box">
240
- <div class="logo">${config.branding.appName || "Instbyte"}</div>
241
- <div class="sub">Enter passphrase to continue</div>
242
- <input type="password" id="pass" placeholder="Passphrase" autofocus
243
- onkeydown="if(event.key==='Enter') submit()">
244
- <button onclick="submit()">Continue</button>
245
- <div class="error" id="err">Incorrect passphrase</div>
246
- </div>
247
- <script>
248
- async function submit() {
249
- const pass = document.getElementById("pass").value;
250
- const res = await fetch("/login", {
251
- method: "POST",
252
- headers: { "Content-Type": "application/json" },
253
- body: JSON.stringify({ passphrase: pass })
254
- });
255
- if (res.ok) {
256
- window.location.href = "/";
257
- } else {
258
- document.getElementById("err").style.display = "block";
259
- document.getElementById("pass").value = "";
260
- document.getElementById("pass").focus();
261
- }
262
- }
263
- </script>
264
- </body>
265
- </html>`);
266
- });
267
-
268
- const loginLimiter = rateLimit({
269
- windowMs: 15 * 60 * 1000,
270
- max: 10,
271
- message: { error: "Too many attempts, try again later" }
272
- });
273
-
274
- /* LOGIN POST */
275
- app.post("/login", loginLimiter, (req, res) => {
276
- if (!config.auth.passphrase) return res.redirect("/");
277
-
278
- const { passphrase } = req.body;
279
- if (passphrase === config.auth.passphrase) {
280
- res.cookie(COOKIE_NAME, passphrase, {
281
- maxAge: COOKIE_MAX_AGE,
282
- httpOnly: true,
283
- sameSite: "strict"
284
- });
285
- return res.json({ ok: true });
286
- }
287
-
288
- res.status(401).json({ error: "Incorrect passphrase" });
289
- });
290
-
291
- /* LOGOUT */
292
- app.post("/logout", (req, res) => {
293
- res.clearCookie(COOKIE_NAME);
294
- res.redirect("/login");
295
- });
296
-
297
-
298
- /* FILE UPLOAD */
299
- app.post("/upload", (req, res) => {
300
- upload.single("file")(req, res, (err) => {
301
- if (err && err.code === "LIMIT_FILE_SIZE") {
302
- // Clean up in case Multer may have written a partial file before hitting limit
303
- if (req.file) {
304
- const partial = path.join(UPLOADS_DIR, req.file.filename);
305
- if (fs.existsSync(partial)) fs.unlinkSync(partial);
306
- }
307
- return res.status(413).json({ error: "File exceeds limit" });
308
- }
309
- if (err) {
310
- if (req.file) {
311
- const partial = path.join(UPLOADS_DIR, req.file.filename);
312
- if (fs.existsSync(partial)) fs.unlinkSync(partial);
313
- }
314
- return res.status(500).json({ error: "Upload failed" });
315
- }
316
-
317
- // req.file missing means the request was aborted before Multer
318
- // finished. No register or clean up required
319
- if (!req.file) {
320
- return res.status(400).json({ error: "No file received" });
321
- }
322
-
323
- // Detect client disconnect that happened after Multer finished writing
324
- // but before we could respond. Clean up the orphaned file.
325
- if (req.destroyed || res.destroyed) {
326
- const partial = path.join(UPLOADS_DIR, req.file.filename);
327
- if (fs.existsSync(partial)) fs.unlinkSync(partial);
328
- return;
329
- }
330
-
331
- const { channel, uploader } = req.body;
332
-
333
- const item = {
334
- type: "file",
335
- filename: req.file.filename,
336
- size: req.file.size,
337
- channel,
338
- uploader,
339
- created_at: Date.now(),
340
- };
341
-
342
- db.run(
343
- `INSERT INTO items (type, filename, size, channel, uploader, created_at)
344
- VALUES (?, ?, ?, ?, ?, ?)`,
345
- ["file", item.filename, item.size, channel, uploader, item.created_at],
346
- function (dbErr) {
347
- if (dbErr) {
348
- // DB insert failed — don't leave the file on disk orphaned
349
- const orphan = path.join(UPLOADS_DIR, item.filename);
350
- if (fs.existsSync(orphan)) fs.unlinkSync(orphan);
351
- return res.status(500).json({ error: "Failed to save item" });
352
- }
353
- item.id = this.lastID;
354
- io.emit("new-item", item);
355
- res.json(item);
356
- }
357
- );
358
- });
359
- });
360
-
361
- /* TEXT/LINK */
362
- app.post("/text", (req, res) => {
363
- const { content, channel, uploader } = req.body;
364
-
365
- const item = {
366
- type: "text",
367
- content,
368
- channel,
369
- uploader,
370
- created_at: Date.now(),
371
- };
372
-
373
- db.run(
374
- `INSERT INTO items (type, content, channel, uploader, created_at)
375
- VALUES (?, ?, ?, ?, ?)`,
376
- ["text", content, channel, uploader, item.created_at],
377
- function () {
378
- item.id = this.lastID;
379
- io.emit("new-item", item);
380
- res.json(item);
381
- }
382
- );
383
- });
384
-
385
- /* DELETE ITEM */
386
- app.delete("/item/:id", (req, res) => {
387
- const id = req.params.id;
388
-
389
- db.get(`SELECT * FROM items WHERE id=?`, [id], (err, item) => {
390
- if (!item) return res.sendStatus(404);
391
-
392
- if (item.filename) {
393
- const filePath = path.join(UPLOADS_DIR, item.filename);
394
- if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
395
- }
396
-
397
- db.run(`DELETE FROM items WHERE id=?`, [id], () => {
398
- io.emit("delete-item", id);
399
- res.sendStatus(200);
400
- });
401
- });
402
- });
403
-
404
- /* PIN */
405
- app.post("/pin/:id", (req, res) => {
406
- const id = req.params.id;
407
-
408
- db.run(
409
- `UPDATE items SET pinned = CASE WHEN pinned=1 THEN 0 ELSE 1 END WHERE id=?`,
410
- [id],
411
- () => {
412
- io.emit("pin-update");
413
- res.sendStatus(200);
414
- }
415
- );
416
- });
417
-
418
- /* GET ITEMS */
419
- app.get("/items/:channel", (req, res) => {
420
- const channel = req.params.channel;
421
- const page = parseInt(req.query.page) || 1;
422
- const limit = 10;
423
- const offset = (page - 1) * limit;
424
-
425
- db.get(
426
- `SELECT COUNT(*) as count FROM items WHERE channel=? AND pinned=0`,
427
- [channel],
428
- (err, row) => {
429
- if (err) return res.status(500).json({ error: "DB error" });
430
-
431
- const totalUnpinned = row.count;
432
- const hasMore = offset + limit < totalUnpinned;
433
-
434
- db.all(
435
- `SELECT * FROM items WHERE channel=? AND pinned=0
436
- ORDER BY created_at DESC LIMIT ? OFFSET ?`,
437
- [channel, limit, offset],
438
- (err, unpinned) => {
439
- if (err) return res.status(500).json({ error: "DB error" });
440
-
441
- if (page === 1) {
442
- // only fetch pinned on first page
443
- db.all(
444
- `SELECT * FROM items WHERE channel=? AND pinned=1
445
- ORDER BY created_at DESC`,
446
- [channel],
447
- (err, pinned) => {
448
- if (err) return res.status(500).json({ error: "DB error" });
449
- res.json({ items: [...pinned, ...unpinned], hasMore, page });
450
- }
451
- );
452
- } else {
453
- res.json({ items: unpinned, hasMore, page });
454
- }
455
- }
456
- );
457
- }
458
- );
459
- });
460
-
461
-
462
- /* SEARCH */
463
- app.get("/search/:channel/:q", (req, res) => {
464
- const { channel, q } = req.params;
465
-
466
- db.all(
467
- `SELECT * FROM items
468
- WHERE channel=? AND (content LIKE ? OR filename LIKE ?)
469
- ORDER BY pinned DESC, created_at DESC`,
470
- [channel, `%${q}%`, `%${q}%`],
471
- (err, rows) => res.json(rows)
472
- );
473
- });
474
-
475
- /* GLOBAL SEARCH */
476
- app.get("/search/:q", (req, res) => {
477
- const q = req.params.q;
478
-
479
- db.all(
480
- `SELECT * FROM items
481
- WHERE content LIKE ? OR filename LIKE ?
482
- ORDER BY channel ASC, pinned DESC, created_at DESC`,
483
- [`%${q}%`, `%${q}%`],
484
- (err, rows) => {
485
- res.json(rows);
486
- }
487
- );
488
- });
489
-
490
- app.get("/channels", (req, res) => {
491
- db.all("SELECT * FROM channels ORDER BY pinned DESC, id ASC", (err, rows) => {
492
- res.json(rows);
493
- });
494
- });
495
-
496
- /* ADD CHANNEL */
497
- app.post("/channels", (req, res) => {
498
-
499
- const { name } = req.body;
500
- if (!name) return res.status(400).json({ error: "Name required" });
501
-
502
- const trimmed = name.trim();
503
- if (trimmed.length < 1 || trimmed.length > 32) {
504
- return res.status(400).json({ error: "Channel name must be 1–32 characters" });
505
- }
506
- if (!/^[a-zA-Z0-9 _\-]+$/.test(trimmed)) {
507
- return res.status(400).json({ error: "Only letters, numbers, spaces, hyphens, and underscores allowed" });
508
- }
509
-
510
- db.get("SELECT COUNT(*) as count FROM channels", (err, row) => {
511
-
512
- if (row.count >= 10) {
513
- return res.status(400).json({ error: "Max 10 channels allowed" });
514
- }
515
-
516
- db.run("INSERT INTO channels (name) VALUES (?)", [trimmed], function (err) {
517
-
518
- if (err) {
519
- return res.status(400).json({ error: "Channel exists" });
520
- }
521
- io.emit("channel-added", { id: this.lastID, trimmed });
522
- res.json({ id: this.lastID, name: trimmed });
523
-
524
- });
525
-
526
- });
527
-
528
- });
529
-
530
-
531
- /* DELETE CHANNEL */
532
- app.delete("/channels/:name", (req, res) => {
533
- const name = req.params.name;
534
-
535
- db.get("SELECT * FROM channels WHERE name=?", [name], (err, ch) => {
536
- if (!ch) return res.status(404).json({ error: "Channel not found" });
537
-
538
- if (ch.pinned) {
539
- return res.status(403).json({ error: "Unpin this channel before deleting" });
540
- }
541
-
542
- db.get("SELECT COUNT(*) as count FROM channels", (err, row) => {
543
- if (row.count <= 1) {
544
- return res.status(400).json({ error: "At least one channel required" });
545
- }
546
-
547
- db.all("SELECT * FROM items WHERE channel=?", [name], (err, rows) => {
548
- rows.forEach(item => {
549
- if (item.filename) {
550
- const filePath = path.join(__dirname, "../uploads", item.filename);
551
- if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
552
- }
553
- });
554
-
555
- db.run("DELETE FROM items WHERE channel=?", [name], () => {
556
- db.run("DELETE FROM channels WHERE name=?", [name], () => {
557
- io.emit("channel-deleted", { name });
558
- res.sendStatus(200);
559
- });
560
- });
561
- });
562
- });
563
- });
564
- });
565
-
566
- /* MOVE ROW */
567
- app.patch("/item/:id/move", (req, res) => {
568
- const { id } = req.params;
569
- const { channel } = req.body;
570
-
571
- if (!channel) return res.status(400).json({ error: "Channel required" });
572
-
573
- db.run("UPDATE items SET channel=? WHERE id=?", [channel, id], function (err) {
574
- if (err) return res.status(500).json({ error: "Move failed" });
575
- io.emit("item-moved", { id: parseInt(id), channel });
576
- res.json({ id, channel });
577
- });
578
- });
579
-
580
- /* UPDATE ITEM TITLE */
581
- app.patch("/item/:id/title", (req, res) => {
582
- const { id } = req.params;
583
- const { title } = req.body;
584
-
585
- if (title === undefined) return res.status(400).json({ error: "Title required" });
586
-
587
- db.run(
588
- "UPDATE items SET title=? WHERE id=?",
589
- [title.trim(), id],
590
- function (err) {
591
- if (err) return res.status(500).json({ error: "Update failed" });
592
- if (this.changes === 0) return res.status(404).json({ error: "Item not found" });
593
- io.emit("item-updated", { id: parseInt(id), title: title.trim() });
594
- res.json({ id, title: title.trim() });
595
- }
596
- );
597
- });
598
-
599
- /* UPDATE ITEM CONTENT */
600
- app.patch("/item/:id/content", (req, res) => {
601
- const { id } = req.params;
602
- const { content } = req.body;
603
-
604
- if (content === undefined) return res.status(400).json({ error: "Content required" });
605
- if (content.trim() === "") return res.status(400).json({ error: "Content cannot be empty" });
606
-
607
- db.run(
608
- "UPDATE items SET content=?, edited_at=? WHERE id=? AND type='text'",
609
- [content.trim(), Date.now(), id],
610
- function (err) {
611
- if (err) return res.status(500).json({ error: "Update failed" });
612
- if (this.changes === 0) return res.status(404).json({ error: "Item not found or not editable" });
613
- io.emit("item-updated", { id: parseInt(id), content: content.trim(), edited_at: Date.now() });
614
- res.json({ id, content: content.trim() });
615
- }
616
- );
617
- });
618
-
619
- /* RENAME CHANNEL */
620
- app.patch("/channels/:name", (req, res) => {
621
- const oldName = req.params.name;
622
- const { name: newName } = req.body;
623
- if (!newName) return res.status(400).json({ error: "Name required" });
624
-
625
- const trimmed = newName.trim();
626
- if (trimmed.length < 1 || trimmed.length > 32) {
627
- return res.status(400).json({ error: "Channel name must be 1–32 characters" });
628
- }
629
- if (!/^[a-zA-Z0-9 _\-]+$/.test(trimmed)) {
630
- return res.status(400).json({ error: "Only letters, numbers, spaces, hyphens, and underscores allowed" });
631
- }
632
-
633
- db.get("SELECT * FROM channels WHERE name=?", [oldName], (err, row) => {
634
- if (!row) return res.status(404).json({ error: "Channel not found" });
635
-
636
- db.run("UPDATE channels SET name=? WHERE name=?", [trimmed, oldName], (err) => {
637
- if (err) return res.status(400).json({ error: "Channel name already exists" });
638
-
639
- db.run("UPDATE items SET channel=? WHERE channel=?", [trimmed, oldName], () => {
640
- io.emit("channel-renamed", { oldName, newName: trimmed });
641
- res.json({ oldName, newName: trimmed });
642
- });
643
- });
644
- });
645
- });
646
-
647
- /* PIN CHANNEL */
648
- app.post("/channels/:name/pin", (req, res) => {
649
- db.run(
650
- `UPDATE channels SET pinned = CASE WHEN pinned=1 THEN 0 ELSE 1 END WHERE name=?`,
651
- [req.params.name],
652
- function (err) {
653
- if (err) return res.status(500).json({ error: "Pin failed" });
654
- db.get("SELECT pinned FROM channels WHERE name=?", [req.params.name], (err, row) => {
655
- io.emit("channel-pin-update", { name: req.params.name, pinned: row.pinned });
656
- res.json({ pinned: row.pinned });
657
- });
658
- }
659
- );
660
- });
661
-
662
-
663
- /* */
664
- app.get("/info", (req, res) => {
665
- res.json({
666
- url: `http://${localIP}:${PORT}`,
667
- hasAuth: !!config.auth.passphrase,
668
- retention: config.storage.retention // null means "never"
669
- });
670
- });
671
-
672
-
673
- /* BRAND */
674
- app.get("/branding", (req, res) => {
675
- const b = config.branding;
676
- const palette = buildPalette(b.primaryColor);
677
-
678
- res.json({
679
- appName: b.appName || "Instbyte",
680
- hasLogo: !!b.logoPath,
681
- palette
682
- });
683
- });
684
-
685
- /* HEALTH MONITOR */
686
- app.get("/health", (req, res) => {
687
- res.json({
688
- status: "ok",
689
- uptime: Math.floor(process.uptime()),
690
- version: require("../package.json").version
691
- });
692
- });
693
-
694
-
695
- /* FAVICON */
696
- app.get("/favicon-dynamic.png", async (req, res) => {
697
- const b = config.branding;
698
-
699
- // User provided their own favicon — serve it directly
700
- if (b.faviconPath) {
701
- const fp = path.resolve(process.cwd(), b.faviconPath);
702
- if (fs.existsSync(fp)) return res.sendFile(fp);
703
- }
704
-
705
- // Try to generate favicon from logo using sharp
706
- if (b.logoPath && sharp) {
707
- const lp = path.resolve(process.cwd(), b.logoPath);
708
- if (fs.existsSync(lp)) {
709
- try {
710
- const buf = await sharp(lp).resize(32, 32).png().toBuffer();
711
- res.set("Content-Type", "image/png");
712
- return res.send(buf);
713
- } catch (e) { }
714
- }
715
- }
716
-
717
- // Fall back to default favicon
718
- const defaultFavicon = path.join(__dirname, "../client/assets/favicon.png");
719
- if (fs.existsSync(defaultFavicon)) return res.sendFile(defaultFavicon);
720
-
721
- res.sendStatus(404);
722
- });
723
-
724
-
725
-
726
- /* LOGO */
727
- app.get("/logo-dynamic.png", (req, res) => {
728
- const b = config.branding;
729
-
730
- if (b.logoPath) {
731
- const lp = path.resolve(process.cwd(), b.logoPath);
732
- if (fs.existsSync(lp)) return res.sendFile(lp);
733
- }
734
-
735
- // Fall back to default logo
736
- const defaultLogo = path.join(__dirname, "../client/assets/logo.png");
737
- if (fs.existsSync(defaultLogo)) return res.sendFile(defaultLogo);
738
-
739
- res.sendStatus(404);
740
- });
741
-
742
- /* ============================
743
- SOCKET CONNECTION LOGGING
744
- ============================ */
745
- // in-memory seen tracking — item id → Set of socket ids
746
- // resets on server restart, no DB needed
747
- const seenBy = new Map();
748
-
749
- let connectedUsers = 0;
750
-
751
- io.on("connection", (socket) => {
752
- connectedUsers++;
753
- io.emit("user-count", connectedUsers);
754
-
755
- let username = "Unknown";
756
-
757
- socket.on("join", (name) => {
758
- username = name || "Unknown";
759
- console.log(username + " connected | total:", connectedUsers);
760
- });
761
-
762
- socket.on("seen", ({ id, name }) => {
763
- if (!id || !name) return;
764
- if (!seenBy.has(id)) seenBy.set(id, new Set());
765
- seenBy.get(id).add(name); // name instead of socket.id
766
- const count = seenBy.get(id).size;
767
- console.log(`seen: item ${id} | count: ${count}`);
768
- io.emit("seen-update", { id, count });
769
- });
770
-
771
- socket.on("disconnect", () => {
772
- connectedUsers--;
773
- console.log(username + " disconnected | total:", connectedUsers);
774
- io.emit("user-count", connectedUsers);
775
- });
776
- });
777
-
778
-
779
- /* ============================
780
- SHOW LOCAL + LAN URL
781
- ============================ */
782
-
783
- function getLocalIP() {
784
- const nets = os.networkInterfaces();
785
- const candidates = [];
786
-
787
- for (const name of Object.keys(nets)) {
788
- for (const net of nets[name]) {
789
- if (net.family !== "IPv4" || net.internal) continue;
790
-
791
- const n = name.toLowerCase();
792
- if (/loopback|vmware|virtualbox|vethernet|wsl|hyper|utun|tun|tap|docker|br-|vbox/.test(n)) continue;
793
-
794
- candidates.push({ name, address: net.address });
795
- }
796
- }
797
-
798
- const preferred =
799
- candidates.find(c => c.address.startsWith("192.168.")) ||
800
- candidates.find(c => c.address.startsWith("10.")) ||
801
- candidates.find(c => c.address.startsWith("172.16.")) ||
802
- candidates[0];
803
-
804
- return preferred ? preferred.address : "localhost";
805
- }
806
-
807
- function findFreePort(start) {
808
- return new Promise((resolve) => {
809
- const srv = net.createServer();
810
- srv.listen(start, () => {
811
- const port = srv.address().port;
812
- srv.close(() => resolve(port));
813
- });
814
- srv.on("error", () => resolve(findFreePort(start + 1)));
815
- });
816
- }
817
-
818
- const PREFERRED = parseInt(process.env.PORT) || config.server.port;
819
-
820
- const localIP = getLocalIP();
821
-
822
- let PORT;
823
-
824
- findFreePort(PREFERRED).then(p => {
825
- PORT = p;
826
- server.listen(PORT, () => {
827
- console.log("\nInstbyte running");
828
- console.log("Local: http://localhost:" + PORT);
829
- console.log("Network: http://" + localIP + ":" + PORT);
830
- if (PORT !== PREFERRED) {
831
- console.log(`(port ${PREFERRED} was busy, switched to ${PORT})`);
832
- }
833
- console.log("");
834
- scanOrphans(); // clean up any pre-v1.9.1 ghost files
835
- });
836
- });
837
-
838
-
839
- // ========================
840
- // GRACEFUL SHUTDOWN
841
- // ========================
842
- function shutdown(signal) {
843
- console.log(`\n${signal} received — shutting down gracefully...`);
844
-
845
- // stop accepting new connections
846
- server.close(() => {
847
- console.log("HTTP server closed");
848
-
849
- // close database connection
850
- db.close((err) => {
851
- if (err) console.error("Error closing database:", err);
852
- else console.log("Database connection closed");
853
- console.log("Shutdown complete");
854
- process.exit(0);
855
- });
856
- });
857
-
858
- // force exit after 10 seconds if something hangs
859
- setTimeout(() => {
860
- console.error("Forced shutdown after timeout");
861
- process.exit(1);
862
- }, 10000);
863
- }
864
-
865
- process.on("SIGTERM", () => shutdown("SIGTERM"));
1
+ require("./cleanup");
2
+ const fs = require("fs");
3
+ const os = require("os");
4
+ const net = require("net");
5
+ const crypto = require("crypto");
6
+ const cookieParser = require("cookie-parser");
7
+ const rateLimit = require("express-rate-limit");
8
+ const helmet = require("helmet");
9
+
10
+ let sharp = null;
11
+ try { sharp = require("sharp"); } catch (e) { }
12
+
13
+ const express = require("express");
14
+ const http = require("http");
15
+ const { Server } = require("socket.io");
16
+ const multer = require("multer");
17
+ const path = require("path");
18
+ const db = require("./db");
19
+
20
+ const config = require("./config");
21
+
22
+ const UPLOADS_DIR = process.env.INSTBYTE_UPLOADS
23
+ || path.join(__dirname, "../uploads");
24
+
25
+ // Create the uploads folder if it doesn't exist yet.
26
+ // Needed for Docker the volume mount replaces anything created during build.
27
+ if (!fs.existsSync(UPLOADS_DIR)) {
28
+ fs.mkdirSync(UPLOADS_DIR, { recursive: true });
29
+ }
30
+
31
+ /* STARTUP ORPHAN SCAN
32
+ Deletes any files in uploads dir that have no matching DB record.
33
+ Catches ghost files left by aborted uploads before fix in v1.9.1 */
34
+ function scanOrphans() {
35
+ fs.readdir(UPLOADS_DIR, (err, files) => {
36
+ if (err || !files || !files.length) return;
37
+
38
+ db.all("SELECT filename FROM items WHERE filename IS NOT NULL", (err, rows) => {
39
+ if (err) return;
40
+
41
+ const known = new Set(rows.map(r => r.filename));
42
+ files.forEach(file => {
43
+ if (!known.has(file)) {
44
+ const orphan = path.join(UPLOADS_DIR, file);
45
+ fs.unlink(orphan, err => {
46
+ if (!err) console.log("Orphan removed:", file);
47
+ });
48
+ }
49
+ });
50
+ });
51
+ });
52
+ }
53
+
54
+ const CLIENT_DIR = path.join(__dirname, "../client");
55
+
56
+ const app = express();
57
+ const server = http.createServer(app);
58
+ const io = new Server(server, { cors: { origin: "*" } });
59
+
60
+ app.use(helmet({
61
+ contentSecurityPolicy: false // disable CSP for now — it would block CDN scripts
62
+ }));
63
+
64
+ app.use((req, res, next) => {
65
+ if (req.path === '/upload') return next();
66
+ express.json()(req, res, next);
67
+ });
68
+ app.use(cookieParser());
69
+ app.use(requireAuth);
70
+ app.use("/uploads", express.static(UPLOADS_DIR));
71
+ app.use(express.static(CLIENT_DIR));
72
+
73
+ const storage = multer.diskStorage({
74
+ destination: (req, file, cb) => {
75
+ cb(null, UPLOADS_DIR);
76
+ },
77
+ filename: (req, file, cb) => {
78
+ const unique = Date.now() + "-" + file.originalname;
79
+ cb(null, unique);
80
+ },
81
+ });
82
+
83
+ const upload = multer({
84
+ storage,
85
+ limits: { fileSize: config.storage.maxFileSize },
86
+ });
87
+
88
+
89
+ function hexToHsl(hex) {
90
+ let r = parseInt(hex.slice(1, 3), 16) / 255;
91
+ let g = parseInt(hex.slice(3, 5), 16) / 255;
92
+ let b = parseInt(hex.slice(5, 7), 16) / 255;
93
+
94
+ const max = Math.max(r, g, b), min = Math.min(r, g, b);
95
+ let h, s, l = (max + min) / 2;
96
+
97
+ if (max === min) {
98
+ h = s = 0;
99
+ } else {
100
+ const d = max - min;
101
+ s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
102
+ switch (max) {
103
+ case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
104
+ case g: h = ((b - r) / d + 2) / 6; break;
105
+ case b: h = ((r - g) / d + 4) / 6; break;
106
+ }
107
+ }
108
+ return [Math.round(h * 360), Math.round(s * 100), Math.round(l * 100)];
109
+ }
110
+
111
+ function hslToHex(h, s, l) {
112
+ s /= 100; l /= 100;
113
+ const k = n => (n + h / 30) % 12;
114
+ const a = s * Math.min(l, 1 - l);
115
+ const f = n => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
116
+ return "#" + [f(0), f(8), f(4)]
117
+ .map(x => Math.round(x * 255).toString(16).padStart(2, "0"))
118
+ .join("");
119
+ }
120
+
121
+ function getLuminance(hex) {
122
+ const r = parseInt(hex.slice(1, 3), 16) / 255;
123
+ const g = parseInt(hex.slice(3, 5), 16) / 255;
124
+ const b = parseInt(hex.slice(5, 7), 16) / 255;
125
+ const toLinear = c => c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
126
+ return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
127
+ }
128
+
129
+ function buildPalette(hex) {
130
+ // Fallback if hex is invalid
131
+ if (!hex || !/^#[0-9a-f]{6}$/i.test(hex)) hex = "#111827";
132
+
133
+ const [h, s, l] = hexToHsl(hex);
134
+
135
+ // Derive secondary as complementary (180° opposite on color wheel)
136
+ const secondaryHex = hslToHex((h + 180) % 360, Math.min(s, 60), Math.max(l, 35));
137
+
138
+ // Text on primary — white or dark based on contrast
139
+ const onPrimary = getLuminance(hex) > 0.179 ? "#111827" : "#ffffff";
140
+ const onSecondary = getLuminance(secondaryHex) > 0.179 ? "#111827" : "#ffffff";
141
+
142
+ return {
143
+ primary: hex,
144
+ primaryHover: hslToHex(h, s, Math.max(l - 10, 10)),
145
+ primaryLight: hslToHex(h, Math.min(s, 80), Math.min(l + 40, 96)),
146
+ primaryDark: hslToHex(h, s, Math.max(l - 20, 5)),
147
+ onPrimary,
148
+ secondary: secondaryHex,
149
+ secondaryHover: hslToHex((h + 180) % 360, Math.min(s, 60), Math.max(l - 10, 10)),
150
+ secondaryLight: hslToHex((h + 180) % 360, Math.min(s, 60), Math.min(l + 40, 96)),
151
+ onSecondary,
152
+ };
153
+ }
154
+
155
+ const COOKIE_NAME = "instbyte_auth";
156
+ const COOKIE_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days
157
+
158
+ // Active sessions — token → true. Cleared on restart, which is intentional.
159
+ const sessions = new Map();
160
+
161
+ function requireAuth(req, res, next) {
162
+ if (!config.auth.passphrase) return next(); // no passphrase set, skip
163
+
164
+ // Allow the login route itself through
165
+ if (req.path === "/login" || req.path === "/info" || req.path === "/health") return next();
166
+
167
+
168
+ // Check cookie holds a valid session token
169
+ const cookie = req.cookies[COOKIE_NAME];
170
+ if (cookie && sessions.has(cookie)) return next();
171
+
172
+ // Not authenticated
173
+ if (req.path.startsWith("/socket.io")) return next();
174
+ if (req.headers["content-type"] === "application/json" || req.xhr) {
175
+ return res.status(401).json({ error: "Unauthorized" });
176
+ }
177
+
178
+ res.redirect("/login");
179
+ }
180
+
181
+
182
+ /* LOGIN PAGE */
183
+ app.get("/login", (req, res) => {
184
+ if (!config.auth.passphrase) return res.redirect("/");
185
+ if (sessions.has(req.cookies[COOKIE_NAME])) return res.redirect("/");
186
+
187
+ const loginPalette = buildPalette(config.branding.primaryColor);
188
+ const loginBrandingStyle = `
189
+ button { background: ${loginPalette.primary}; color: ${loginPalette.onPrimary}; }
190
+ button:hover { background: ${loginPalette.primaryHover}; }
191
+ `;
192
+
193
+ res.send(`<!DOCTYPE html>
194
+ <html>
195
+ <head>
196
+ <title>${config.branding.appName || "Instbyte"} — Login</title>
197
+ <meta name="viewport" content="width=device-width,initial-scale=1">
198
+ <style>
199
+ * { box-sizing: border-box; margin: 0; padding: 0; }
200
+ body {
201
+ font-family: system-ui;
202
+ background: #f3f4f6;
203
+ display: flex;
204
+ align-items: center;
205
+ justify-content: center;
206
+ min-height: 100vh;
207
+ }
208
+ .box {
209
+ background: #fff;
210
+ border-radius: 12px;
211
+ padding: 36px;
212
+ width: 100%;
213
+ max-width: 360px;
214
+ box-shadow: 0 4px 24px rgba(0,0,0,0.08);
215
+ text-align: center;
216
+ }
217
+ .logo { font-size: 22px; font-weight: 700; color: #111827; margin-bottom: 6px; }
218
+ .sub { font-size: 13px; color: #9ca3af; margin-bottom: 28px; }
219
+ input {
220
+ width: 100%;
221
+ padding: 11px 14px;
222
+ border: 1px solid #e5e7eb;
223
+ border-radius: 8px;
224
+ font-size: 14px;
225
+ margin-bottom: 12px;
226
+ outline: none;
227
+ }
228
+ input:focus { border-color: #9ca3af; }
229
+ button {
230
+ width: 100%;
231
+ padding: 11px;
232
+ background: #111827;
233
+ color: #fff;
234
+ border: none;
235
+ border-radius: 8px;
236
+ font-size: 14px;
237
+ cursor: pointer;
238
+ }
239
+ button:hover { background: #1f2937; }
240
+ .error {
241
+ color: #b91c1c;
242
+ font-size: 13px;
243
+ margin-top: 10px;
244
+ display: none;
245
+ }
246
+
247
+ ${loginBrandingStyle}
248
+
249
+ </style>
250
+ </head>
251
+ <body>
252
+ <div class="box">
253
+ <div class="logo">${config.branding.appName || "Instbyte"}</div>
254
+ <div class="sub">Enter passphrase to continue</div>
255
+ <input type="password" id="pass" placeholder="Passphrase" autofocus
256
+ onkeydown="if(event.key==='Enter') submit()">
257
+ <button onclick="submit()">Continue</button>
258
+ <div class="error" id="err">Incorrect passphrase</div>
259
+ </div>
260
+ <script>
261
+ async function submit() {
262
+ const pass = document.getElementById("pass").value;
263
+ const res = await fetch("/login", {
264
+ method: "POST",
265
+ headers: { "Content-Type": "application/json" },
266
+ body: JSON.stringify({ passphrase: pass })
267
+ });
268
+ if (res.ok) {
269
+ window.location.href = "/";
270
+ } else {
271
+ document.getElementById("err").style.display = "block";
272
+ document.getElementById("pass").value = "";
273
+ document.getElementById("pass").focus();
274
+ }
275
+ }
276
+ </script>
277
+ </body>
278
+ </html>`);
279
+ });
280
+
281
+ const loginLimiter = rateLimit({
282
+ windowMs: 15 * 60 * 1000,
283
+ max: 10,
284
+ message: { error: "Too many attempts, try again later" }
285
+ });
286
+
287
+ /* LOGIN POST */
288
+ app.post("/login", loginLimiter, (req, res) => {
289
+ if (!config.auth.passphrase) return res.redirect("/");
290
+
291
+ const { passphrase } = req.body;
292
+ if (passphrase === config.auth.passphrase) {
293
+ const token = crypto.randomBytes(32).toString("hex");
294
+ sessions.set(token, true);
295
+ res.cookie(COOKIE_NAME, token, {
296
+ maxAge: COOKIE_MAX_AGE,
297
+ httpOnly: true,
298
+ sameSite: "strict"
299
+ });
300
+ return res.json({ ok: true });
301
+ }
302
+
303
+ res.status(401).json({ error: "Incorrect passphrase" });
304
+ });
305
+
306
+ /* LOGOUT */
307
+ app.post("/logout", (req, res) => {
308
+ const token = req.cookies[COOKIE_NAME];
309
+ if (token) sessions.delete(token);
310
+ res.clearCookie(COOKIE_NAME);
311
+ res.redirect("/login");
312
+ });
313
+
314
+
315
+ /* FILE UPLOAD */
316
+ app.post("/upload", upload.single("file"), (req, res) => {
317
+ if (!req.file) {
318
+ return res.status(400).json({ error: "No file received" });
319
+ }
320
+
321
+ const { channel, uploader } = req.body;
322
+
323
+ const item = {
324
+ type: "file",
325
+ filename: req.file.filename,
326
+ size: req.file.size,
327
+ channel,
328
+ uploader,
329
+ created_at: Date.now(),
330
+ };
331
+
332
+ db.run(
333
+ `INSERT INTO items (type, filename, size, channel, uploader, created_at)
334
+ VALUES (?, ?, ?, ?, ?, ?)`,
335
+ ["file", item.filename, item.size, channel, uploader, item.created_at],
336
+ function (dbErr) {
337
+ if (dbErr) {
338
+ const orphan = path.join(UPLOADS_DIR, item.filename);
339
+ if (fs.existsSync(orphan)) fs.unlinkSync(orphan);
340
+ return res.status(500).json({ error: "Failed to save item" });
341
+ }
342
+ item.id = this.lastID;
343
+ io.emit("new-item", item);
344
+ res.json(item);
345
+ }
346
+ );
347
+ });
348
+
349
+ // Multer error handler — catches file size limit and other upload errors
350
+ app.use((err, req, res, next) => {
351
+ if (err && err.code === "LIMIT_FILE_SIZE") {
352
+ if (req.file) {
353
+ const partial = path.join(UPLOADS_DIR, req.file.filename);
354
+ if (fs.existsSync(partial)) fs.unlinkSync(partial);
355
+ }
356
+ return res.status(413).json({ error: "File exceeds limit" });
357
+ }
358
+ next(err);
359
+ });
360
+
361
+ /* TEXT/LINK */
362
+ app.post("/text", (req, res) => {
363
+ const { content, channel, uploader } = req.body;
364
+
365
+ const item = {
366
+ type: "text",
367
+ content,
368
+ channel,
369
+ uploader,
370
+ created_at: Date.now(),
371
+ };
372
+
373
+ db.run(
374
+ `INSERT INTO items (type, content, channel, uploader, created_at)
375
+ VALUES (?, ?, ?, ?, ?)`,
376
+ ["text", content, channel, uploader, item.created_at],
377
+ function (err) {
378
+ if (err) return res.status(500).json({ error: "Failed to save item" });
379
+ item.id = this.lastID;
380
+ io.emit("new-item", item);
381
+ res.json(item);
382
+ }
383
+ );
384
+ });
385
+
386
+ /* DELETE ITEM */
387
+ app.delete("/item/:id", (req, res) => {
388
+ const id = req.params.id;
389
+
390
+ db.get(`SELECT * FROM items WHERE id=?`, [id], (err, item) => {
391
+ if (!item) return res.sendStatus(404);
392
+
393
+ if (item.filename) {
394
+ const filePath = path.join(UPLOADS_DIR, item.filename);
395
+ if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
396
+ }
397
+
398
+ db.run(`DELETE FROM items WHERE id=?`, [id], () => {
399
+ seenBy.delete(parseInt(id));
400
+ io.emit("delete-item", id);
401
+ res.sendStatus(200);
402
+ });
403
+ });
404
+ });
405
+
406
+ /* PIN */
407
+ app.post("/pin/:id", (req, res) => {
408
+ const id = req.params.id;
409
+
410
+ db.run(
411
+ `UPDATE items SET pinned = CASE WHEN pinned=1 THEN 0 ELSE 1 END WHERE id=?`,
412
+ [id],
413
+ () => {
414
+ io.emit("pin-update");
415
+ res.sendStatus(200);
416
+ }
417
+ );
418
+ });
419
+
420
+ /* GET ITEMS */
421
+ app.get("/items/:channel", (req, res) => {
422
+ const channel = req.params.channel;
423
+ const page = parseInt(req.query.page) || 1;
424
+ const limit = 10;
425
+ const offset = (page - 1) * limit;
426
+
427
+ db.get(
428
+ `SELECT COUNT(*) as count FROM items WHERE channel=? AND pinned=0`,
429
+ [channel],
430
+ (err, row) => {
431
+ if (err) return res.status(500).json({ error: "DB error" });
432
+
433
+ const totalUnpinned = row.count;
434
+ const hasMore = offset + limit < totalUnpinned;
435
+
436
+ db.all(
437
+ `SELECT * FROM items WHERE channel=? AND pinned=0
438
+ ORDER BY created_at DESC LIMIT ? OFFSET ?`,
439
+ [channel, limit, offset],
440
+ (err, unpinned) => {
441
+ if (err) return res.status(500).json({ error: "DB error" });
442
+
443
+ if (page === 1) {
444
+ // only fetch pinned on first page
445
+ db.all(
446
+ `SELECT * FROM items WHERE channel=? AND pinned=1
447
+ ORDER BY created_at DESC`,
448
+ [channel],
449
+ (err, pinned) => {
450
+ if (err) return res.status(500).json({ error: "DB error" });
451
+ res.json({ items: [...pinned, ...unpinned], hasMore, page });
452
+ }
453
+ );
454
+ } else {
455
+ res.json({ items: unpinned, hasMore, page });
456
+ }
457
+ }
458
+ );
459
+ }
460
+ );
461
+ });
462
+
463
+
464
+ /* SEARCH */
465
+ app.get("/search/:channel/:q", (req, res) => {
466
+ const { channel, q } = req.params;
467
+
468
+ db.all(
469
+ `SELECT * FROM items
470
+ WHERE channel=? AND (content LIKE ? OR filename LIKE ?)
471
+ ORDER BY pinned DESC, created_at DESC`,
472
+ [channel, `%${q}%`, `%${q}%`],
473
+ (err, rows) => res.json(rows)
474
+ );
475
+ });
476
+
477
+ /* GLOBAL SEARCH */
478
+ app.get("/search/:q", (req, res) => {
479
+ const q = req.params.q;
480
+
481
+ db.all(
482
+ `SELECT * FROM items
483
+ WHERE content LIKE ? OR filename LIKE ?
484
+ ORDER BY channel ASC, pinned DESC, created_at DESC`,
485
+ [`%${q}%`, `%${q}%`],
486
+ (err, rows) => {
487
+ res.json(rows);
488
+ }
489
+ );
490
+ });
491
+
492
+ app.get("/channels", (req, res) => {
493
+ db.all("SELECT * FROM channels ORDER BY pinned DESC, id ASC", (err, rows) => {
494
+ res.json(rows);
495
+ });
496
+ });
497
+
498
+ /* ADD CHANNEL */
499
+ app.post("/channels", (req, res) => {
500
+
501
+ const { name } = req.body;
502
+ if (!name) return res.status(400).json({ error: "Name required" });
503
+
504
+ const trimmed = name.trim();
505
+ if (trimmed.length < 1 || trimmed.length > 32) {
506
+ return res.status(400).json({ error: "Channel name must be 1–32 characters" });
507
+ }
508
+ if (!/^[a-zA-Z0-9 _\-]+$/.test(trimmed)) {
509
+ return res.status(400).json({ error: "Only letters, numbers, spaces, hyphens, and underscores allowed" });
510
+ }
511
+
512
+ db.get("SELECT COUNT(*) as count FROM channels", (err, row) => {
513
+
514
+ if (row.count >= 10) {
515
+ return res.status(400).json({ error: "Max 10 channels allowed" });
516
+ }
517
+
518
+ db.run("INSERT INTO channels (name) VALUES (?)", [trimmed], function (err) {
519
+
520
+ if (err) {
521
+ return res.status(400).json({ error: "Channel exists" });
522
+ }
523
+ io.emit("channel-added", { id: this.lastID, trimmed });
524
+ res.json({ id: this.lastID, name: trimmed });
525
+
526
+ });
527
+
528
+ });
529
+
530
+ });
531
+
532
+
533
+ /* DELETE CHANNEL */
534
+ app.delete("/channels/:name", (req, res) => {
535
+ const name = req.params.name;
536
+
537
+ db.get("SELECT * FROM channels WHERE name=?", [name], (err, ch) => {
538
+ if (!ch) return res.status(404).json({ error: "Channel not found" });
539
+
540
+ if (ch.pinned) {
541
+ return res.status(403).json({ error: "Unpin this channel before deleting" });
542
+ }
543
+
544
+ db.get("SELECT COUNT(*) as count FROM channels", (err, row) => {
545
+ if (row.count <= 1) {
546
+ return res.status(400).json({ error: "At least one channel required" });
547
+ }
548
+
549
+ db.all("SELECT * FROM items WHERE channel=?", [name], (err, rows) => {
550
+ rows.forEach(item => {
551
+ if (item.filename) {
552
+ const filePath = path.join(__dirname, "../uploads", item.filename);
553
+ if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
554
+ }
555
+ });
556
+
557
+ db.run("DELETE FROM items WHERE channel=?", [name], () => {
558
+ db.run("DELETE FROM channels WHERE name=?", [name], () => {
559
+ io.emit("channel-deleted", { name });
560
+ res.sendStatus(200);
561
+ });
562
+ });
563
+ });
564
+ });
565
+ });
566
+ });
567
+
568
+ /* MOVE ROW */
569
+ app.patch("/item/:id/move", (req, res) => {
570
+ const { id } = req.params;
571
+ const { channel } = req.body;
572
+
573
+ if (!channel) return res.status(400).json({ error: "Channel required" });
574
+
575
+ db.run("UPDATE items SET channel=? WHERE id=?", [channel, id], function (err) {
576
+ if (err) return res.status(500).json({ error: "Move failed" });
577
+ io.emit("item-moved", { id: parseInt(id), channel });
578
+ res.json({ id, channel });
579
+ });
580
+ });
581
+
582
+ /* UPDATE ITEM TITLE */
583
+ app.patch("/item/:id/title", (req, res) => {
584
+ const { id } = req.params;
585
+ const { title } = req.body;
586
+
587
+ if (title === undefined) return res.status(400).json({ error: "Title required" });
588
+
589
+ db.run(
590
+ "UPDATE items SET title=? WHERE id=?",
591
+ [title.trim(), id],
592
+ function (err) {
593
+ if (err) return res.status(500).json({ error: "Update failed" });
594
+ if (this.changes === 0) return res.status(404).json({ error: "Item not found" });
595
+ io.emit("item-updated", { id: parseInt(id), title: title.trim() });
596
+ res.json({ id, title: title.trim() });
597
+ }
598
+ );
599
+ });
600
+
601
+ /* UPDATE ITEM CONTENT */
602
+ app.patch("/item/:id/content", (req, res) => {
603
+ const { id } = req.params;
604
+ const { content } = req.body;
605
+
606
+ if (content === undefined) return res.status(400).json({ error: "Content required" });
607
+ if (content.trim() === "") return res.status(400).json({ error: "Content cannot be empty" });
608
+
609
+ db.run(
610
+ "UPDATE items SET content=?, edited_at=? WHERE id=? AND type='text'",
611
+ [content.trim(), Date.now(), id],
612
+ function (err) {
613
+ if (err) return res.status(500).json({ error: "Update failed" });
614
+ if (this.changes === 0) return res.status(404).json({ error: "Item not found or not editable" });
615
+ io.emit("item-updated", { id: parseInt(id), content: content.trim(), edited_at: Date.now() });
616
+ res.json({ id, content: content.trim() });
617
+ }
618
+ );
619
+ });
620
+
621
+ /* RENAME CHANNEL */
622
+ app.patch("/channels/:name", (req, res) => {
623
+ const oldName = req.params.name;
624
+ const { name: newName } = req.body;
625
+ if (!newName) return res.status(400).json({ error: "Name required" });
626
+
627
+ const trimmed = newName.trim();
628
+ if (trimmed.length < 1 || trimmed.length > 32) {
629
+ return res.status(400).json({ error: "Channel name must be 1–32 characters" });
630
+ }
631
+ if (!/^[a-zA-Z0-9 _\-]+$/.test(trimmed)) {
632
+ return res.status(400).json({ error: "Only letters, numbers, spaces, hyphens, and underscores allowed" });
633
+ }
634
+
635
+ db.get("SELECT * FROM channels WHERE name=?", [oldName], (err, row) => {
636
+ if (!row) return res.status(404).json({ error: "Channel not found" });
637
+
638
+ db.run("UPDATE channels SET name=? WHERE name=?", [trimmed, oldName], (err) => {
639
+ if (err) return res.status(400).json({ error: "Channel name already exists" });
640
+
641
+ db.run("UPDATE items SET channel=? WHERE channel=?", [trimmed, oldName], () => {
642
+ io.emit("channel-renamed", { oldName, newName: trimmed });
643
+ res.json({ oldName, newName: trimmed });
644
+ });
645
+ });
646
+ });
647
+ });
648
+
649
+ /* PIN CHANNEL */
650
+ app.post("/channels/:name/pin", (req, res) => {
651
+ db.run(
652
+ `UPDATE channels SET pinned = CASE WHEN pinned=1 THEN 0 ELSE 1 END WHERE name=?`,
653
+ [req.params.name],
654
+ function (err) {
655
+ if (err) return res.status(500).json({ error: "Pin failed" });
656
+ db.get("SELECT pinned FROM channels WHERE name=?", [req.params.name], (err, row) => {
657
+ io.emit("channel-pin-update", { name: req.params.name, pinned: row.pinned });
658
+ res.json({ pinned: row.pinned });
659
+ });
660
+ }
661
+ );
662
+ });
663
+
664
+
665
+ /* */
666
+ app.get("/info", (req, res) => {
667
+ res.json({
668
+ url: `http://${localIP}:${PORT}`,
669
+ hasAuth: !!config.auth.passphrase,
670
+ retention: config.storage.retention // null means "never"
671
+ });
672
+ });
673
+
674
+
675
+ /* BRAND */
676
+ app.get("/branding", (req, res) => {
677
+ const b = config.branding;
678
+ const palette = buildPalette(b.primaryColor);
679
+
680
+ res.json({
681
+ appName: b.appName || "Instbyte",
682
+ hasLogo: !!b.logoPath,
683
+ palette
684
+ });
685
+ });
686
+
687
+ /* HEALTH MONITOR */
688
+ app.get("/health", (req, res) => {
689
+ res.json({
690
+ status: "ok",
691
+ uptime: Math.floor(process.uptime()),
692
+ version: require("../package.json").version
693
+ });
694
+ });
695
+
696
+
697
+ /* FAVICON */
698
+ app.get("/favicon-dynamic.png", async (req, res) => {
699
+ const b = config.branding;
700
+
701
+ // User provided their own favicon — serve it directly
702
+ if (b.faviconPath) {
703
+ const fp = path.resolve(process.cwd(), b.faviconPath);
704
+ if (fs.existsSync(fp)) return res.sendFile(fp);
705
+ }
706
+
707
+ // Try to generate favicon from logo using sharp
708
+ if (b.logoPath && sharp) {
709
+ const lp = path.resolve(process.cwd(), b.logoPath);
710
+ if (fs.existsSync(lp)) {
711
+ try {
712
+ const buf = await sharp(lp).resize(32, 32).png().toBuffer();
713
+ res.set("Content-Type", "image/png");
714
+ return res.send(buf);
715
+ } catch (e) { }
716
+ }
717
+ }
718
+
719
+ // Fall back to default favicon
720
+ const defaultFavicon = path.join(__dirname, "../client/assets/favicon.png");
721
+ if (fs.existsSync(defaultFavicon)) return res.sendFile(defaultFavicon);
722
+
723
+ res.sendStatus(404);
724
+ });
725
+
726
+
727
+
728
+ /* LOGO */
729
+ app.get("/logo-dynamic.png", (req, res) => {
730
+ const b = config.branding;
731
+
732
+ if (b.logoPath) {
733
+ const lp = path.resolve(process.cwd(), b.logoPath);
734
+ if (fs.existsSync(lp)) return res.sendFile(lp);
735
+ }
736
+
737
+ // Fall back to default logo
738
+ const defaultLogo = path.join(__dirname, "../client/assets/logo.png");
739
+ if (fs.existsSync(defaultLogo)) return res.sendFile(defaultLogo);
740
+
741
+ res.sendStatus(404);
742
+ });
743
+
744
+ /* ============================
745
+ SOCKET CONNECTION LOGGING
746
+ ============================ */
747
+ // in-memory seen tracking — item id → Set of socket ids
748
+ // resets on server restart, no DB needed
749
+ const seenBy = new Map();
750
+
751
+ let connectedUsers = 0;
752
+
753
+ io.on("connection", (socket) => {
754
+ connectedUsers++;
755
+ io.emit("user-count", connectedUsers);
756
+
757
+ let username = "Unknown";
758
+
759
+ socket.on("join", (name) => {
760
+ username = name || "Unknown";
761
+ console.log(username + " connected | total:", connectedUsers);
762
+ });
763
+
764
+ socket.on("seen", ({ id, name }) => {
765
+ if (!id || !name) return;
766
+ if (!seenBy.has(id)) seenBy.set(id, new Set());
767
+ seenBy.get(id).add(name); // name instead of socket.id
768
+ const count = seenBy.get(id).size;
769
+ console.log(`seen: item ${id} | count: ${count}`);
770
+ io.emit("seen-update", { id, count });
771
+ });
772
+
773
+ socket.on("disconnect", () => {
774
+ connectedUsers--;
775
+ console.log(username + " disconnected | total:", connectedUsers);
776
+ io.emit("user-count", connectedUsers);
777
+ });
778
+ });
779
+
780
+
781
+ /* ============================
782
+ SHOW LOCAL + LAN URL
783
+ ============================ */
784
+
785
+ function getLocalIP() {
786
+ const nets = os.networkInterfaces();
787
+ const candidates = [];
788
+
789
+ for (const name of Object.keys(nets)) {
790
+ for (const net of nets[name]) {
791
+ if (net.family !== "IPv4" || net.internal) continue;
792
+
793
+ const n = name.toLowerCase();
794
+ if (/loopback|vmware|virtualbox|vethernet|wsl|hyper|utun|tun|tap|docker|br-|vbox/.test(n)) continue;
795
+
796
+ candidates.push({ name, address: net.address });
797
+ }
798
+ }
799
+
800
+ const preferred =
801
+ candidates.find(c => c.address.startsWith("192.168.")) ||
802
+ candidates.find(c => c.address.startsWith("10.")) ||
803
+ candidates.find(c => c.address.startsWith("172.16.")) ||
804
+ candidates[0];
805
+
806
+ return preferred ? preferred.address : "localhost";
807
+ }
808
+
809
+ function findFreePort(start) {
810
+ return new Promise((resolve) => {
811
+ const srv = net.createServer();
812
+ srv.listen(start, () => {
813
+ const port = srv.address().port;
814
+ srv.close(() => resolve(port));
815
+ });
816
+ srv.on("error", () => resolve(findFreePort(start + 1)));
817
+ });
818
+ }
819
+
820
+ const PREFERRED = parseInt(process.env.PORT) || config.server.port;
821
+
822
+ const localIP = getLocalIP();
823
+
824
+ let PORT;
825
+
826
+ findFreePort(PREFERRED).then(p => {
827
+ PORT = p;
828
+ server.listen(PORT, () => {
829
+ console.log("\nInstbyte running");
830
+ console.log("Local: http://localhost:" + PORT);
831
+ console.log("Network: http://" + localIP + ":" + PORT);
832
+ if (PORT !== PREFERRED) {
833
+ console.log(`(port ${PREFERRED} was busy, switched to ${PORT})`);
834
+ }
835
+ console.log("");
836
+ scanOrphans(); // clean up any pre-v1.9.1 ghost files
837
+ });
838
+ });
839
+
840
+
841
+ // ========================
842
+ // GRACEFUL SHUTDOWN
843
+ // ========================
844
+ function shutdown(signal) {
845
+ console.log(`\n${signal} received shutting down gracefully...`);
846
+
847
+ // stop accepting new connections
848
+ server.close(() => {
849
+ console.log("HTTP server closed");
850
+
851
+ // close database connection
852
+ db.close((err) => {
853
+ if (err) console.error("Error closing database:", err);
854
+ else console.log("Database connection closed");
855
+ console.log("Shutdown complete");
856
+ process.exit(0);
857
+ });
858
+ });
859
+
860
+ // force exit after 10 seconds if something hangs
861
+ setTimeout(() => {
862
+ console.error("Forced shutdown after timeout");
863
+ process.exit(1);
864
+ }, 10000);
865
+ }
866
+
867
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
866
868
  process.on("SIGINT", () => shutdown("SIGINT"));