instbyte 1.6.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.
- package/LICENSE +21 -0
- package/README.md +200 -0
- package/bin/instbyte.js +28 -0
- package/client/assets/favicon-16.png +0 -0
- package/client/assets/favicon.png +0 -0
- package/client/assets/logo.png +0 -0
- package/client/css/app.css +813 -0
- package/client/index.html +81 -0
- package/client/js/app.js +946 -0
- package/package.json +47 -0
- package/server/cleanup.js +27 -0
- package/server/config.js +69 -0
- package/server/db.js +56 -0
- package/server/server.js +668 -0
package/server/server.js
ADDED
|
@@ -0,0 +1,668 @@
|
|
|
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
|
+
|
|
8
|
+
let sharp = null;
|
|
9
|
+
try { sharp = require("sharp"); } catch (e) { }
|
|
10
|
+
|
|
11
|
+
const express = require("express");
|
|
12
|
+
const http = require("http");
|
|
13
|
+
const { Server } = require("socket.io");
|
|
14
|
+
const multer = require("multer");
|
|
15
|
+
const path = require("path");
|
|
16
|
+
const db = require("./db");
|
|
17
|
+
|
|
18
|
+
const config = require("./config");
|
|
19
|
+
|
|
20
|
+
const UPLOADS_DIR = process.env.INSTBYTE_UPLOADS
|
|
21
|
+
|| path.join(__dirname, "../uploads");
|
|
22
|
+
|
|
23
|
+
const CLIENT_DIR = path.join(__dirname, "../client");
|
|
24
|
+
|
|
25
|
+
const app = express();
|
|
26
|
+
const server = http.createServer(app);
|
|
27
|
+
const io = new Server(server, { cors: { origin: "*" } });
|
|
28
|
+
|
|
29
|
+
app.use(express.json());
|
|
30
|
+
app.use(cookieParser());
|
|
31
|
+
app.use(requireAuth);
|
|
32
|
+
app.use("/uploads", express.static(UPLOADS_DIR));
|
|
33
|
+
app.use(express.static(CLIENT_DIR));
|
|
34
|
+
|
|
35
|
+
const storage = multer.diskStorage({
|
|
36
|
+
destination: (req, file, cb) => {
|
|
37
|
+
cb(null, UPLOADS_DIR);
|
|
38
|
+
},
|
|
39
|
+
filename: (req, file, cb) => {
|
|
40
|
+
const unique = Date.now() + "-" + file.originalname;
|
|
41
|
+
cb(null, unique);
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const upload = multer({
|
|
46
|
+
storage,
|
|
47
|
+
limits: { fileSize: config.storage.maxFileSize },
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
function hexToHsl(hex) {
|
|
52
|
+
let r = parseInt(hex.slice(1, 3), 16) / 255;
|
|
53
|
+
let g = parseInt(hex.slice(3, 5), 16) / 255;
|
|
54
|
+
let b = parseInt(hex.slice(5, 7), 16) / 255;
|
|
55
|
+
|
|
56
|
+
const max = Math.max(r, g, b), min = Math.min(r, g, b);
|
|
57
|
+
let h, s, l = (max + min) / 2;
|
|
58
|
+
|
|
59
|
+
if (max === min) {
|
|
60
|
+
h = s = 0;
|
|
61
|
+
} else {
|
|
62
|
+
const d = max - min;
|
|
63
|
+
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
|
64
|
+
switch (max) {
|
|
65
|
+
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
|
|
66
|
+
case g: h = ((b - r) / d + 2) / 6; break;
|
|
67
|
+
case b: h = ((r - g) / d + 4) / 6; break;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return [Math.round(h * 360), Math.round(s * 100), Math.round(l * 100)];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function hslToHex(h, s, l) {
|
|
74
|
+
s /= 100; l /= 100;
|
|
75
|
+
const k = n => (n + h / 30) % 12;
|
|
76
|
+
const a = s * Math.min(l, 1 - l);
|
|
77
|
+
const f = n => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
|
|
78
|
+
return "#" + [f(0), f(8), f(4)]
|
|
79
|
+
.map(x => Math.round(x * 255).toString(16).padStart(2, "0"))
|
|
80
|
+
.join("");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function getLuminance(hex) {
|
|
84
|
+
const r = parseInt(hex.slice(1, 3), 16) / 255;
|
|
85
|
+
const g = parseInt(hex.slice(3, 5), 16) / 255;
|
|
86
|
+
const b = parseInt(hex.slice(5, 7), 16) / 255;
|
|
87
|
+
const toLinear = c => c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
|
88
|
+
return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function buildPalette(hex) {
|
|
92
|
+
// Fallback if hex is invalid
|
|
93
|
+
if (!hex || !/^#[0-9a-f]{6}$/i.test(hex)) hex = "#111827";
|
|
94
|
+
|
|
95
|
+
const [h, s, l] = hexToHsl(hex);
|
|
96
|
+
|
|
97
|
+
// Derive secondary as complementary (180° opposite on color wheel)
|
|
98
|
+
const secondaryHex = hslToHex((h + 180) % 360, Math.min(s, 60), Math.max(l, 35));
|
|
99
|
+
|
|
100
|
+
// Text on primary — white or dark based on contrast
|
|
101
|
+
const onPrimary = getLuminance(hex) > 0.179 ? "#111827" : "#ffffff";
|
|
102
|
+
const onSecondary = getLuminance(secondaryHex) > 0.179 ? "#111827" : "#ffffff";
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
primary: hex,
|
|
106
|
+
primaryHover: hslToHex(h, s, Math.max(l - 10, 10)),
|
|
107
|
+
primaryLight: hslToHex(h, Math.min(s, 80), Math.min(l + 40, 96)),
|
|
108
|
+
primaryDark: hslToHex(h, s, Math.max(l - 20, 5)),
|
|
109
|
+
onPrimary,
|
|
110
|
+
secondary: secondaryHex,
|
|
111
|
+
secondaryHover: hslToHex((h + 180) % 360, Math.min(s, 60), Math.max(l - 10, 10)),
|
|
112
|
+
secondaryLight: hslToHex((h + 180) % 360, Math.min(s, 60), Math.min(l + 40, 96)),
|
|
113
|
+
onSecondary,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const COOKIE_NAME = "instbyte_auth";
|
|
118
|
+
const COOKIE_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
119
|
+
|
|
120
|
+
function requireAuth(req, res, next) {
|
|
121
|
+
if (!config.auth.passphrase) return next(); // no passphrase set, skip
|
|
122
|
+
|
|
123
|
+
// Allow the login route itself through
|
|
124
|
+
if (req.path === "/login" || req.path === "/info") return next();
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
// Check cookie
|
|
128
|
+
const cookie = req.cookies[COOKIE_NAME];
|
|
129
|
+
if (cookie && cookie === config.auth.passphrase) return next();
|
|
130
|
+
|
|
131
|
+
// Not authenticated
|
|
132
|
+
if (req.path.startsWith("/socket.io")) return next();
|
|
133
|
+
if (req.headers["content-type"] === "application/json" || req.xhr) {
|
|
134
|
+
return res.status(401).json({ error: "Unauthorized" });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
res.redirect("/login");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
/* LOGIN PAGE */
|
|
142
|
+
app.get("/login", (req, res) => {
|
|
143
|
+
if (!config.auth.passphrase) return res.redirect("/");
|
|
144
|
+
if (req.cookies[COOKIE_NAME] === config.auth.passphrase) return res.redirect("/");
|
|
145
|
+
|
|
146
|
+
const loginPalette = buildPalette(config.branding.primaryColor);
|
|
147
|
+
const loginBrandingStyle = `
|
|
148
|
+
button { background: ${loginPalette.primary}; color: ${loginPalette.onPrimary}; }
|
|
149
|
+
button:hover { background: ${loginPalette.primaryHover}; }
|
|
150
|
+
`;
|
|
151
|
+
|
|
152
|
+
res.send(`<!DOCTYPE html>
|
|
153
|
+
<html>
|
|
154
|
+
<head>
|
|
155
|
+
<title>${config.branding.appName || "Instbyte"} — Login</title>
|
|
156
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
157
|
+
<style>
|
|
158
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
159
|
+
body {
|
|
160
|
+
font-family: system-ui;
|
|
161
|
+
background: #f3f4f6;
|
|
162
|
+
display: flex;
|
|
163
|
+
align-items: center;
|
|
164
|
+
justify-content: center;
|
|
165
|
+
min-height: 100vh;
|
|
166
|
+
}
|
|
167
|
+
.box {
|
|
168
|
+
background: #fff;
|
|
169
|
+
border-radius: 12px;
|
|
170
|
+
padding: 36px;
|
|
171
|
+
width: 100%;
|
|
172
|
+
max-width: 360px;
|
|
173
|
+
box-shadow: 0 4px 24px rgba(0,0,0,0.08);
|
|
174
|
+
text-align: center;
|
|
175
|
+
}
|
|
176
|
+
.logo { font-size: 22px; font-weight: 700; color: #111827; margin-bottom: 6px; }
|
|
177
|
+
.sub { font-size: 13px; color: #9ca3af; margin-bottom: 28px; }
|
|
178
|
+
input {
|
|
179
|
+
width: 100%;
|
|
180
|
+
padding: 11px 14px;
|
|
181
|
+
border: 1px solid #e5e7eb;
|
|
182
|
+
border-radius: 8px;
|
|
183
|
+
font-size: 14px;
|
|
184
|
+
margin-bottom: 12px;
|
|
185
|
+
outline: none;
|
|
186
|
+
}
|
|
187
|
+
input:focus { border-color: #9ca3af; }
|
|
188
|
+
button {
|
|
189
|
+
width: 100%;
|
|
190
|
+
padding: 11px;
|
|
191
|
+
background: #111827;
|
|
192
|
+
color: #fff;
|
|
193
|
+
border: none;
|
|
194
|
+
border-radius: 8px;
|
|
195
|
+
font-size: 14px;
|
|
196
|
+
cursor: pointer;
|
|
197
|
+
}
|
|
198
|
+
button:hover { background: #1f2937; }
|
|
199
|
+
.error {
|
|
200
|
+
color: #b91c1c;
|
|
201
|
+
font-size: 13px;
|
|
202
|
+
margin-top: 10px;
|
|
203
|
+
display: none;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
${loginBrandingStyle}
|
|
207
|
+
|
|
208
|
+
</style>
|
|
209
|
+
</head>
|
|
210
|
+
<body>
|
|
211
|
+
<div class="box">
|
|
212
|
+
<div class="logo">${config.branding.appName || "Instbyte"}</div>
|
|
213
|
+
<div class="sub">Enter passphrase to continue</div>
|
|
214
|
+
<input type="password" id="pass" placeholder="Passphrase" autofocus
|
|
215
|
+
onkeydown="if(event.key==='Enter') submit()">
|
|
216
|
+
<button onclick="submit()">Continue</button>
|
|
217
|
+
<div class="error" id="err">Incorrect passphrase</div>
|
|
218
|
+
</div>
|
|
219
|
+
<script>
|
|
220
|
+
async function submit() {
|
|
221
|
+
const pass = document.getElementById("pass").value;
|
|
222
|
+
const res = await fetch("/login", {
|
|
223
|
+
method: "POST",
|
|
224
|
+
headers: { "Content-Type": "application/json" },
|
|
225
|
+
body: JSON.stringify({ passphrase: pass })
|
|
226
|
+
});
|
|
227
|
+
if (res.ok) {
|
|
228
|
+
window.location.href = "/";
|
|
229
|
+
} else {
|
|
230
|
+
document.getElementById("err").style.display = "block";
|
|
231
|
+
document.getElementById("pass").value = "";
|
|
232
|
+
document.getElementById("pass").focus();
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
</script>
|
|
236
|
+
</body>
|
|
237
|
+
</html>`);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
const loginLimiter = rateLimit({
|
|
241
|
+
windowMs: 15 * 60 * 1000,
|
|
242
|
+
max: 10,
|
|
243
|
+
message: { error: "Too many attempts, try again later" }
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
/* LOGIN POST */
|
|
247
|
+
app.post("/login", loginLimiter, (req, res) => {
|
|
248
|
+
if (!config.auth.passphrase) return res.redirect("/");
|
|
249
|
+
|
|
250
|
+
const { passphrase } = req.body;
|
|
251
|
+
if (passphrase === config.auth.passphrase) {
|
|
252
|
+
res.cookie(COOKIE_NAME, passphrase, {
|
|
253
|
+
maxAge: COOKIE_MAX_AGE,
|
|
254
|
+
httpOnly: true,
|
|
255
|
+
sameSite: "strict"
|
|
256
|
+
});
|
|
257
|
+
return res.json({ ok: true });
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
res.status(401).json({ error: "Incorrect passphrase" });
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
/* LOGOUT */
|
|
264
|
+
app.post("/logout", (req, res) => {
|
|
265
|
+
res.clearCookie(COOKIE_NAME);
|
|
266
|
+
res.redirect("/login");
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
/* FILE UPLOAD */
|
|
271
|
+
app.post("/upload", (req, res) => {
|
|
272
|
+
upload.single("file")(req, res, (err) => {
|
|
273
|
+
if (err && err.code === "LIMIT_FILE_SIZE") {
|
|
274
|
+
return res.status(413).json({ error: "File exceeds 2GB limit" });
|
|
275
|
+
}
|
|
276
|
+
if (err) {
|
|
277
|
+
return res.status(500).json({ error: "Upload failed" });
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const { channel, uploader } = req.body;
|
|
281
|
+
|
|
282
|
+
const item = {
|
|
283
|
+
type: "file",
|
|
284
|
+
filename: req.file.filename,
|
|
285
|
+
size: req.file.size,
|
|
286
|
+
channel,
|
|
287
|
+
uploader,
|
|
288
|
+
created_at: Date.now(),
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
db.run(
|
|
292
|
+
`INSERT INTO items (type, filename, size, channel, uploader, created_at)
|
|
293
|
+
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
294
|
+
["file", item.filename, item.size, channel, uploader, item.created_at],
|
|
295
|
+
function () {
|
|
296
|
+
item.id = this.lastID;
|
|
297
|
+
io.emit("new-item", item);
|
|
298
|
+
res.json(item);
|
|
299
|
+
}
|
|
300
|
+
);
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
/* TEXT/LINK */
|
|
305
|
+
app.post("/text", (req, res) => {
|
|
306
|
+
const { content, channel, uploader } = req.body;
|
|
307
|
+
|
|
308
|
+
const item = {
|
|
309
|
+
type: "text",
|
|
310
|
+
content,
|
|
311
|
+
channel,
|
|
312
|
+
uploader,
|
|
313
|
+
created_at: Date.now(),
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
db.run(
|
|
317
|
+
`INSERT INTO items (type, content, channel, uploader, created_at)
|
|
318
|
+
VALUES (?, ?, ?, ?, ?)`,
|
|
319
|
+
["text", content, channel, uploader, item.created_at],
|
|
320
|
+
function () {
|
|
321
|
+
item.id = this.lastID;
|
|
322
|
+
io.emit("new-item", item);
|
|
323
|
+
res.json(item);
|
|
324
|
+
}
|
|
325
|
+
);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
/* DELETE ITEM */
|
|
329
|
+
app.delete("/item/:id", (req, res) => {
|
|
330
|
+
const id = req.params.id;
|
|
331
|
+
|
|
332
|
+
db.get(`SELECT * FROM items WHERE id=?`, [id], (err, item) => {
|
|
333
|
+
if (!item) return res.sendStatus(404);
|
|
334
|
+
|
|
335
|
+
if (item.filename) {
|
|
336
|
+
const filePath = path.join(UPLOADS_DIR, item.filename);
|
|
337
|
+
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
db.run(`DELETE FROM items WHERE id=?`, [id], () => {
|
|
341
|
+
io.emit("delete-item", id);
|
|
342
|
+
res.sendStatus(200);
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
/* PIN */
|
|
348
|
+
app.post("/pin/:id", (req, res) => {
|
|
349
|
+
const id = req.params.id;
|
|
350
|
+
|
|
351
|
+
db.run(
|
|
352
|
+
`UPDATE items SET pinned = CASE WHEN pinned=1 THEN 0 ELSE 1 END WHERE id=?`,
|
|
353
|
+
[id],
|
|
354
|
+
() => {
|
|
355
|
+
io.emit("pin-update");
|
|
356
|
+
res.sendStatus(200);
|
|
357
|
+
}
|
|
358
|
+
);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
/* GET ITEMS */
|
|
362
|
+
app.get("/items/:channel", (req, res) => {
|
|
363
|
+
const channel = req.params.channel;
|
|
364
|
+
|
|
365
|
+
db.all(
|
|
366
|
+
`SELECT * FROM items WHERE channel=? ORDER BY pinned DESC, created_at DESC`,
|
|
367
|
+
[channel],
|
|
368
|
+
(err, rows) => {
|
|
369
|
+
res.json(rows);
|
|
370
|
+
}
|
|
371
|
+
);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
/* SEARCH */
|
|
375
|
+
app.get("/search/:channel/:q", (req, res) => {
|
|
376
|
+
const { channel, q } = req.params;
|
|
377
|
+
|
|
378
|
+
db.all(
|
|
379
|
+
`SELECT * FROM items
|
|
380
|
+
WHERE channel=? AND (content LIKE ? OR filename LIKE ?)
|
|
381
|
+
ORDER BY pinned DESC, created_at DESC`,
|
|
382
|
+
[channel, `%${q}%`, `%${q}%`],
|
|
383
|
+
(err, rows) => res.json(rows)
|
|
384
|
+
);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
/* GLOBAL SEARCH */
|
|
388
|
+
app.get("/search/:q", (req, res) => {
|
|
389
|
+
const q = req.params.q;
|
|
390
|
+
|
|
391
|
+
db.all(
|
|
392
|
+
`SELECT * FROM items
|
|
393
|
+
WHERE content LIKE ? OR filename LIKE ?
|
|
394
|
+
ORDER BY channel ASC, pinned DESC, created_at DESC`,
|
|
395
|
+
[`%${q}%`, `%${q}%`],
|
|
396
|
+
(err, rows) => {
|
|
397
|
+
res.json(rows);
|
|
398
|
+
}
|
|
399
|
+
);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
app.get("/channels", (req, res) => {
|
|
403
|
+
db.all("SELECT * FROM channels ORDER BY pinned DESC, id ASC", (err, rows) => {
|
|
404
|
+
res.json(rows);
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
/* ADD CHANNEL */
|
|
409
|
+
app.post("/channels", (req, res) => {
|
|
410
|
+
|
|
411
|
+
const { name } = req.body;
|
|
412
|
+
if (!name) return res.status(400).json({ error: "Name required" });
|
|
413
|
+
|
|
414
|
+
db.get("SELECT COUNT(*) as count FROM channels", (err, row) => {
|
|
415
|
+
|
|
416
|
+
if (row.count >= 10) {
|
|
417
|
+
return res.status(400).json({ error: "Max 10 channels allowed" });
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
db.run("INSERT INTO channels (name) VALUES (?)", [name], function (err) {
|
|
421
|
+
|
|
422
|
+
if (err) {
|
|
423
|
+
return res.status(400).json({ error: "Channel exists" });
|
|
424
|
+
}
|
|
425
|
+
io.emit("channel-added", { id: this.lastID, name });
|
|
426
|
+
res.json({ id: this.lastID, name });
|
|
427
|
+
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
/* DELETE CHANNEL */
|
|
436
|
+
app.delete("/channels/:name", (req, res) => {
|
|
437
|
+
const name = req.params.name;
|
|
438
|
+
|
|
439
|
+
db.get("SELECT * FROM channels WHERE name=?", [name], (err, ch) => {
|
|
440
|
+
if (!ch) return res.status(404).json({ error: "Channel not found" });
|
|
441
|
+
|
|
442
|
+
if (ch.pinned) {
|
|
443
|
+
return res.status(403).json({ error: "Unpin this channel before deleting" });
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
db.get("SELECT COUNT(*) as count FROM channels", (err, row) => {
|
|
447
|
+
if (row.count <= 1) {
|
|
448
|
+
return res.status(400).json({ error: "At least one channel required" });
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
db.all("SELECT * FROM items WHERE channel=?", [name], (err, rows) => {
|
|
452
|
+
rows.forEach(item => {
|
|
453
|
+
if (item.filename) {
|
|
454
|
+
const filePath = path.join(__dirname, "../uploads", item.filename);
|
|
455
|
+
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
|
456
|
+
}
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
db.run("DELETE FROM items WHERE channel=?", [name], () => {
|
|
460
|
+
db.run("DELETE FROM channels WHERE name=?", [name], () => {
|
|
461
|
+
io.emit("channel-deleted", { name });
|
|
462
|
+
res.sendStatus(200);
|
|
463
|
+
});
|
|
464
|
+
});
|
|
465
|
+
});
|
|
466
|
+
});
|
|
467
|
+
});
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
/* MOVE ROW */
|
|
471
|
+
app.patch("/item/:id/move", (req, res) => {
|
|
472
|
+
const { id } = req.params;
|
|
473
|
+
const { channel } = req.body;
|
|
474
|
+
|
|
475
|
+
if (!channel) return res.status(400).json({ error: "Channel required" });
|
|
476
|
+
|
|
477
|
+
db.run("UPDATE items SET channel=? WHERE id=?", [channel, id], function (err) {
|
|
478
|
+
if (err) return res.status(500).json({ error: "Move failed" });
|
|
479
|
+
io.emit("item-moved", { id: parseInt(id), channel });
|
|
480
|
+
res.json({ id, channel });
|
|
481
|
+
});
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
/* RENAME CHANNEL */
|
|
485
|
+
app.patch("/channels/:name", (req, res) => {
|
|
486
|
+
const oldName = req.params.name;
|
|
487
|
+
const { name: newName } = req.body;
|
|
488
|
+
|
|
489
|
+
if (!newName) return res.status(400).json({ error: "Name required" });
|
|
490
|
+
|
|
491
|
+
db.get("SELECT * FROM channels WHERE name=?", [oldName], (err, row) => {
|
|
492
|
+
if (!row) return res.status(404).json({ error: "Channel not found" });
|
|
493
|
+
|
|
494
|
+
db.run("UPDATE channels SET name=? WHERE name=?", [newName, oldName], (err) => {
|
|
495
|
+
if (err) return res.status(400).json({ error: "Channel name already exists" });
|
|
496
|
+
|
|
497
|
+
db.run("UPDATE items SET channel=? WHERE channel=?", [newName, oldName], () => {
|
|
498
|
+
io.emit("channel-renamed", { oldName, newName });
|
|
499
|
+
res.json({ oldName, newName });
|
|
500
|
+
});
|
|
501
|
+
});
|
|
502
|
+
});
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
/* PIN CHANNEL */
|
|
506
|
+
app.post("/channels/:name/pin", (req, res) => {
|
|
507
|
+
db.run(
|
|
508
|
+
`UPDATE channels SET pinned = CASE WHEN pinned=1 THEN 0 ELSE 1 END WHERE name=?`,
|
|
509
|
+
[req.params.name],
|
|
510
|
+
function (err) {
|
|
511
|
+
if (err) return res.status(500).json({ error: "Pin failed" });
|
|
512
|
+
db.get("SELECT pinned FROM channels WHERE name=?", [req.params.name], (err, row) => {
|
|
513
|
+
io.emit("channel-pin-update", { name: req.params.name, pinned: row.pinned });
|
|
514
|
+
res.json({ pinned: row.pinned });
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
);
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
/* */
|
|
522
|
+
app.get("/info", (req, res) => {
|
|
523
|
+
res.json({
|
|
524
|
+
url: `http://${localIP}:${PORT}`,
|
|
525
|
+
hasAuth: !!config.auth.passphrase
|
|
526
|
+
});
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
/* BRAND */
|
|
531
|
+
app.get("/branding", (req, res) => {
|
|
532
|
+
const b = config.branding;
|
|
533
|
+
const palette = buildPalette(b.primaryColor);
|
|
534
|
+
|
|
535
|
+
res.json({
|
|
536
|
+
appName: b.appName || "Instbyte",
|
|
537
|
+
hasLogo: !!b.logoPath,
|
|
538
|
+
palette
|
|
539
|
+
});
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
/* FAVICON */
|
|
544
|
+
app.get("/favicon-dynamic.png", async (req, res) => {
|
|
545
|
+
const b = config.branding;
|
|
546
|
+
|
|
547
|
+
// User provided their own favicon — serve it directly
|
|
548
|
+
if (b.faviconPath) {
|
|
549
|
+
const fp = path.resolve(process.cwd(), b.faviconPath);
|
|
550
|
+
if (fs.existsSync(fp)) return res.sendFile(fp);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// Try to generate favicon from logo using sharp
|
|
554
|
+
if (b.logoPath && sharp) {
|
|
555
|
+
const lp = path.resolve(process.cwd(), b.logoPath);
|
|
556
|
+
if (fs.existsSync(lp)) {
|
|
557
|
+
try {
|
|
558
|
+
const buf = await sharp(lp).resize(32, 32).png().toBuffer();
|
|
559
|
+
res.set("Content-Type", "image/png");
|
|
560
|
+
return res.send(buf);
|
|
561
|
+
} catch (e) { }
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// Fall back to default favicon
|
|
566
|
+
const defaultFavicon = path.join(__dirname, "../client/assets/favicon.png");
|
|
567
|
+
if (fs.existsSync(defaultFavicon)) return res.sendFile(defaultFavicon);
|
|
568
|
+
|
|
569
|
+
res.sendStatus(404);
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
/* LOGO */
|
|
574
|
+
app.get("/logo-dynamic.png", (req, res) => {
|
|
575
|
+
const b = config.branding;
|
|
576
|
+
|
|
577
|
+
if (b.logoPath) {
|
|
578
|
+
const lp = path.resolve(process.cwd(), b.logoPath);
|
|
579
|
+
if (fs.existsSync(lp)) return res.sendFile(lp);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// Fall back to default logo
|
|
583
|
+
const defaultLogo = path.join(__dirname, "../client/assets/logo.png");
|
|
584
|
+
if (fs.existsSync(defaultLogo)) return res.sendFile(defaultLogo);
|
|
585
|
+
|
|
586
|
+
res.sendStatus(404);
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
/* ============================
|
|
590
|
+
SOCKET CONNECTION LOGGING
|
|
591
|
+
============================ */
|
|
592
|
+
|
|
593
|
+
let connectedUsers = 0;
|
|
594
|
+
|
|
595
|
+
io.on("connection", (socket) => {
|
|
596
|
+
connectedUsers++;
|
|
597
|
+
|
|
598
|
+
let username = "Unknown";
|
|
599
|
+
|
|
600
|
+
socket.on("join", (name) => {
|
|
601
|
+
username = name || "Unknown";
|
|
602
|
+
console.log(username + " connected | total:", connectedUsers);
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
socket.on("disconnect", () => {
|
|
606
|
+
connectedUsers--;
|
|
607
|
+
console.log(username + " disconnected | total:", connectedUsers);
|
|
608
|
+
});
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
/* ============================
|
|
613
|
+
SHOW LOCAL + LAN URL
|
|
614
|
+
============================ */
|
|
615
|
+
|
|
616
|
+
function getLocalIP() {
|
|
617
|
+
const nets = os.networkInterfaces();
|
|
618
|
+
const candidates = [];
|
|
619
|
+
|
|
620
|
+
for (const name of Object.keys(nets)) {
|
|
621
|
+
for (const net of nets[name]) {
|
|
622
|
+
if (net.family !== "IPv4" || net.internal) continue;
|
|
623
|
+
|
|
624
|
+
const n = name.toLowerCase();
|
|
625
|
+
if (/loopback|vmware|virtualbox|vethernet|wsl|hyper|utun|tun|tap|docker|br-|vbox/.test(n)) continue;
|
|
626
|
+
|
|
627
|
+
candidates.push({ name, address: net.address });
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
const preferred =
|
|
632
|
+
candidates.find(c => c.address.startsWith("192.168.")) ||
|
|
633
|
+
candidates.find(c => c.address.startsWith("10.")) ||
|
|
634
|
+
candidates.find(c => c.address.startsWith("172.16.")) ||
|
|
635
|
+
candidates[0];
|
|
636
|
+
|
|
637
|
+
return preferred ? preferred.address : "localhost";
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function findFreePort(start) {
|
|
641
|
+
return new Promise((resolve) => {
|
|
642
|
+
const srv = net.createServer();
|
|
643
|
+
srv.listen(start, () => {
|
|
644
|
+
const port = srv.address().port;
|
|
645
|
+
srv.close(() => resolve(port));
|
|
646
|
+
});
|
|
647
|
+
srv.on("error", () => resolve(findFreePort(start + 1)));
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
const PREFERRED = parseInt(process.env.PORT) || config.server.port;
|
|
652
|
+
|
|
653
|
+
const localIP = getLocalIP();
|
|
654
|
+
|
|
655
|
+
let PORT;
|
|
656
|
+
|
|
657
|
+
findFreePort(PREFERRED).then(p => {
|
|
658
|
+
PORT = p;
|
|
659
|
+
server.listen(PORT, () => {
|
|
660
|
+
console.log("\nInstbyte running");
|
|
661
|
+
console.log("Local: http://localhost:" + PORT);
|
|
662
|
+
console.log("Network: http://" + localIP + ":" + PORT);
|
|
663
|
+
if (PORT !== PREFERRED) {
|
|
664
|
+
console.log(`(port ${PREFERRED} was busy, switched to ${PORT})`);
|
|
665
|
+
}
|
|
666
|
+
console.log("");
|
|
667
|
+
});
|
|
668
|
+
});
|