instbyte 1.9.2 → 1.9.4

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/README.md CHANGED
@@ -308,6 +308,11 @@ The difference between *a tool you use* and *a tool you own.*
308
308
  **Read receipts** — see how many devices have viewed each shared item. Updates live as teammates open the page.
309
309
 
310
310
  **Item management** — add optional titles to label any item for future reference. Edit text items inline without deleting and re-pasting. Pinned items are protected from both manual deletion and auto-cleanup.
311
+
312
+ **Mobile ready** — install as a PWA directly from your browser. Add to Home Screen on iOS or Android for a native app feel without the App Store.
313
+
314
+ **Security hardened** — rate limiting on all write endpoints, magic number file validation, filename sanitisation, and forced download for executable file types.
315
+
311
316
  ---
312
317
 
313
318
  ## Keyboard Shortcuts
@@ -335,10 +340,46 @@ node server/server.js
335
340
 
336
341
  ## Use Cases
337
342
 
338
- - Moving content between your phone and laptop over WiFi
343
+ - Moving content between your phone and laptop, or just any device over WiFi
339
344
  - Sharing API payloads, logs, or screenshots during a sprint
340
345
  - A lightweight team clipboard during standups or pair sessions
341
346
  - Home lab file sharing without setting up NAS or cloud sync
347
+ - Piping build logs or stack traces from CI or terminal directly into a shared channel
348
+ - Sharing sensitive credentials or config files over LAN without leaving a cloud trail
349
+
350
+ ---
351
+
352
+ ## Terminal Usage
353
+
354
+ Since Instbyte exposes a simple HTTP API, you can push content directly from your terminal using `curl` — no browser needed.
355
+
356
+ **Send a log file:**
357
+ ```bash
358
+ curl -X POST http://192.168.x.x:3000/text \
359
+ -H "Content-Type: application/json" \
360
+ -d "{\"content\": \"$(cat error.log)\", \"channel\": \"general\", \"uploader\": \"terminal\"}"
361
+ ```
362
+
363
+ **Pipe command output directly:**
364
+ ```bash
365
+ npm run build 2>&1 | curl -X POST http://192.168.x.x:3000/text \
366
+ -H "Content-Type: application/json" \
367
+ --data-binary @- \
368
+ -H "X-Channel: general" \
369
+ -H "X-Uploader: CI"
370
+ ```
371
+
372
+ **Upload a file from the terminal:**
373
+ ```bash
374
+ curl -X POST http://192.168.x.x:3000/upload \
375
+ -F "file=@./build.log" \
376
+ -F "channel=general" \
377
+ -F "uploader=terminal"
378
+ ```
379
+
380
+ Replace `192.168.x.x:3000` with the URL shown when Instbyte starts. If auth is enabled, add `-b "instbyte_auth=your-token"` to each request.
381
+
382
+ Useful for piping stack traces, build logs, or environment dumps straight into a channel your whole team can see instantly.
342
383
 
343
384
  ---
344
385
 
@@ -352,6 +393,8 @@ Instbyte follows [Semantic Versioning](https://semver.org). See [Releases](https
352
393
 
353
394
  Instbyte is intentionally lightweight and LAN-first. If you want to extend it — CLI tools, themes, integrations — open an issue or submit a pull request.
354
395
 
396
+ The codebase has a full test suite (184 tests across unit and integration). Run `npm test` before submitting anything. Issues tagged **good first issue** are a good starting point.
397
+
355
398
  ---
356
399
 
357
400
  ## License
@@ -651,6 +651,38 @@ input[type=text]:focus {
651
651
  padding-left: 20px;
652
652
  }
653
653
 
654
+ .item-text-collapsed {
655
+ position: relative;
656
+ max-height: 9em;
657
+ overflow: hidden;
658
+ }
659
+
660
+ .item-text-collapsed::after {
661
+ content: "";
662
+ position: absolute;
663
+ bottom: 0;
664
+ left: 0;
665
+ right: 0;
666
+ height: 3em;
667
+ background: linear-gradient(to bottom, transparent, #ffffff);
668
+ pointer-events: none;
669
+ }
670
+
671
+ .item-text-expand-btn {
672
+ display: block;
673
+ margin-top: 4px;
674
+ font-size: 12px;
675
+ color: var(--color-secondary);
676
+ background: none;
677
+ border: none;
678
+ padding: 2px 0;
679
+ cursor: pointer;
680
+ text-align: left;
681
+ }
682
+
683
+ .item-text-expand-btn:hover {
684
+ color: var(--color-primary);
685
+ }
654
686
 
655
687
  /* ============================================================
656
688
  PREVIEW PANEL
@@ -1174,6 +1206,122 @@ input[type=text]:focus {
1174
1206
  padding: 6px 12px;
1175
1207
  font-size: 12px;
1176
1208
  }
1209
+
1210
+ /* Backdrop */
1211
+ .bottom-sheet-backdrop {
1212
+ position: fixed;
1213
+ inset: 0;
1214
+ background: rgba(0, 0, 0, 0.4);
1215
+ z-index: 9998;
1216
+ display: none;
1217
+ animation: fadeInBackdrop 0.2s ease;
1218
+ }
1219
+
1220
+ .bottom-sheet-backdrop.open {
1221
+ display: block;
1222
+ }
1223
+
1224
+ @keyframes fadeInBackdrop {
1225
+ from {
1226
+ opacity: 0;
1227
+ }
1228
+
1229
+ to {
1230
+ opacity: 1;
1231
+ }
1232
+ }
1233
+
1234
+ /* Context menu becomes a bottom sheet */
1235
+ .context-menu {
1236
+ position: fixed;
1237
+ top: auto !important;
1238
+ left: 0 !important;
1239
+ right: 0;
1240
+ bottom: 0;
1241
+ width: 100%;
1242
+ max-width: 100%;
1243
+ border-radius: 16px 16px 0 0;
1244
+ padding: 8px 0 calc(8px + env(safe-area-inset-bottom));
1245
+ z-index: 9999;
1246
+ animation: slideUpSheet 0.25s ease;
1247
+ box-sizing: border-box;
1248
+ }
1249
+
1250
+ /* Drag handle */
1251
+ .context-menu::before {
1252
+ content: "";
1253
+ display: block;
1254
+ width: 36px;
1255
+ height: 4px;
1256
+ background: #d1d5db;
1257
+ border-radius: 2px;
1258
+ margin: 0 auto 10px;
1259
+ }
1260
+
1261
+ .context-menu button {
1262
+ padding: 14px 20px;
1263
+ font-size: 15px;
1264
+ border-radius: 0;
1265
+ }
1266
+
1267
+ /* Move dropdown becomes a bottom sheet */
1268
+ .move-dropdown {
1269
+ position: fixed !important;
1270
+ top: auto !important;
1271
+ bottom: 0 !important;
1272
+ left: 0 !important;
1273
+ right: 0 !important;
1274
+ width: 100% !important;
1275
+ min-width: 100% !important;
1276
+ border-radius: 16px 16px 0 0;
1277
+ padding: 8px 0 calc(8px + env(safe-area-inset-bottom));
1278
+ z-index: 9999;
1279
+ animation: slideUpSheet 0.25s ease;
1280
+ box-sizing: border-box;
1281
+ }
1282
+
1283
+ .move-dropdown::before {
1284
+ content: "";
1285
+ display: block;
1286
+ width: 36px;
1287
+ height: 4px;
1288
+ background: #d1d5db;
1289
+ border-radius: 2px;
1290
+ margin: 0 auto 10px;
1291
+ }
1292
+
1293
+ .move-dropdown button {
1294
+ padding: 14px 20px;
1295
+ font-size: 15px;
1296
+ border-radius: 0;
1297
+ }
1298
+
1299
+ .move-dropdown .dropdown-label {
1300
+ padding: 8px 20px 6px;
1301
+ font-size: 12px;
1302
+ }
1303
+
1304
+ .preview-panel pre {
1305
+ overflow-x: scroll;
1306
+ -webkit-overflow-scrolling: touch;
1307
+ max-height: 300px;
1308
+ font-size: 11px;
1309
+ }
1310
+
1311
+ .markdown-body pre {
1312
+ overflow-x: scroll;
1313
+ -webkit-overflow-scrolling: touch;
1314
+ }
1315
+
1316
+ @keyframes slideUpSheet {
1317
+ from {
1318
+ transform: translateY(100%);
1319
+ }
1320
+
1321
+ to {
1322
+ transform: translateY(0);
1323
+ }
1324
+ }
1177
1325
  }
1178
1326
 
1179
1327
 
@@ -1572,6 +1720,10 @@ input[type=text]:focus {
1572
1720
  background: #052e16;
1573
1721
  }
1574
1722
 
1723
+ .item-text-collapsed::after {
1724
+ background: linear-gradient(to bottom, transparent, #1e2433);
1725
+ }
1726
+
1575
1727
  #uploadStatus {
1576
1728
  background: #1a1d27 !important;
1577
1729
  color: #e5e7eb !important;
@@ -1966,6 +2118,10 @@ input[type=text]:focus {
1966
2118
  background: #052e16;
1967
2119
  }
1968
2120
 
2121
+ .item-text-collapsed::after {
2122
+ background: linear-gradient(to bottom, transparent, #1e2433);
2123
+ }
2124
+
1969
2125
  #uploadStatus {
1970
2126
  background: #1a1d27 !important;
1971
2127
  color: #e5e7eb !important;
package/client/index.html CHANGED
@@ -5,6 +5,8 @@
5
5
  <title>Instbyte</title>
6
6
  <link rel="icon" type="image/png" sizes="32x32" href="/favicon-dynamic.png">
7
7
  <link rel="icon" type="image/png" sizes="16x16" href="/favicon-dynamic.png">
8
+ <link rel="manifest" href="/manifest.json">
9
+ <meta name="theme-color" id="themeColorMeta" content="#111827">
8
10
  <meta name="viewport" content="width=device-width,initial-scale=1">
9
11
 
10
12
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css">
@@ -89,7 +91,11 @@
89
91
  <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
90
92
  <script src="/js/app.js?v=1.9.1.2"></script>
91
93
 
92
-
94
+ <script>
95
+ if ('serviceWorker' in navigator) {
96
+ navigator.serviceWorker.register('/sw.js').catch(() => { });
97
+ }
98
+ </script>
93
99
  </body>
94
100
 
95
101
  </html>
package/client/js/app.js CHANGED
@@ -116,6 +116,9 @@ async function applyBranding() {
116
116
  root.style.setProperty("--color-secondary-light", p.secondaryLight);
117
117
  root.style.setProperty("--color-on-secondary", p.onSecondary);
118
118
 
119
+ const themeMeta = document.getElementById('themeColorMeta');
120
+ if (themeMeta) themeMeta.setAttribute('content', p.primary);
121
+
119
122
  } catch (e) {
120
123
  // Branding failed — default styles remain, no crash
121
124
  }
@@ -210,18 +213,30 @@ function renderText(text) {
210
213
  if (!text) return "";
211
214
  if (looksLikeMarkdown(text)) {
212
215
  const html = marked.parse(text);
213
- // highlight code blocks after parse
214
216
  const wrap = document.createElement("div");
215
217
  wrap.innerHTML = html;
216
218
  wrap.querySelectorAll("pre code").forEach(el => hljs.highlightElement(el));
217
219
  return `<div class="markdown-body">${wrap.innerHTML}</div>`;
218
220
  }
219
- // plain text — just escape and preserve newlines
220
- return text
221
+ // plain text — escape and preserve newlines
222
+ const escaped = text
221
223
  .replace(/&/g, "&amp;")
222
224
  .replace(/</g, "&lt;")
223
225
  .replace(/>/g, "&gt;")
224
226
  .replace(/\n/g, "<br>");
227
+
228
+ // collapse long plain text (more than 8 newlines)
229
+ const lineCount = (text.match(/\n/g) || []).length;
230
+ if (lineCount > 8) {
231
+ const id = 'expand-' + Math.random().toString(36).slice(2, 8);
232
+ return `<div class="item-text-collapsed" id="${id}">${escaped}</div>
233
+ <button class="item-text-expand-btn" onclick="
234
+ document.getElementById('${id}').classList.remove('item-text-collapsed');
235
+ this.remove();
236
+ ">Show more</button>`;
237
+ }
238
+
239
+ return escaped;
225
240
  }
226
241
 
227
242
  const TEXT_EXTENSIONS = [
@@ -452,6 +467,7 @@ function toggleMoveDropdown(e, id, currentChannel) {
452
467
 
453
468
  dropdown.classList.add("open");
454
469
  openDropdown = dropdown;
470
+ if (window.innerWidth <= 640) openBottomSheet(dropdown);
455
471
  }
456
472
 
457
473
  function toggleMoreMenu(e, id, currentChannel) {
@@ -1315,6 +1331,7 @@ async function uploadFiles(files) {
1315
1331
  setTimeout(resolve, 1200);
1316
1332
  return;
1317
1333
  }
1334
+ if (navigator.vibrate) navigator.vibrate([50]);
1318
1335
  resolve();
1319
1336
  };
1320
1337
 
@@ -1476,21 +1493,27 @@ function showChannelMenu(e, ch) {
1476
1493
  </button>
1477
1494
  `;
1478
1495
 
1479
- menu.style.top = e.clientY + "px";
1480
- menu.style.left = e.clientX + "px";
1481
- menu.classList.add("open");
1482
-
1483
- requestAnimationFrame(() => {
1484
- const rect = menu.getBoundingClientRect();
1485
- if (rect.right > window.innerWidth - 8)
1486
- menu.style.left = (e.clientX - rect.width) + "px";
1487
- if (rect.bottom > window.innerHeight - 8)
1488
- menu.style.top = (e.clientY - rect.height) + "px";
1489
- });
1496
+ if (window.innerWidth <= 640) {
1497
+ // mobile bottom sheet, no positioning needed
1498
+ menu.classList.add("open");
1499
+ openBottomSheet(menu);
1500
+ } else {
1501
+ // desktop position by click coordinates
1502
+ menu.style.top = e.clientY + "px";
1503
+ menu.style.left = e.clientX + "px";
1504
+ menu.classList.add("open");
1505
+ requestAnimationFrame(() => {
1506
+ const rect = menu.getBoundingClientRect();
1507
+ if (rect.right > window.innerWidth - 8)
1508
+ menu.style.left = (e.clientX - rect.width) + "px";
1509
+ if (rect.bottom > window.innerHeight - 8)
1510
+ menu.style.top = (e.clientY - rect.height) + "px";
1511
+ });
1512
+ }
1490
1513
  }
1491
1514
 
1492
1515
  document.addEventListener("click", () => {
1493
- document.getElementById("channelMenu").classList.remove("open");
1516
+ closeAllBottomSheets();
1494
1517
  });
1495
1518
 
1496
1519
  document.addEventListener("contextmenu", (e) => {
@@ -1635,6 +1658,27 @@ document.addEventListener("keydown", e => {
1635
1658
 
1636
1659
  });
1637
1660
 
1661
+ // Create shared backdrop for bottom sheets
1662
+ const bottomSheetBackdrop = document.createElement('div');
1663
+ bottomSheetBackdrop.className = 'bottom-sheet-backdrop';
1664
+ document.body.appendChild(bottomSheetBackdrop);
1665
+
1666
+ function openBottomSheet(el) {
1667
+ bottomSheetBackdrop.classList.add('open');
1668
+ bottomSheetBackdrop.onclick = () => closeAllBottomSheets();
1669
+ }
1670
+
1671
+ function closeAllBottomSheets() {
1672
+ bottomSheetBackdrop.classList.remove('open');
1673
+ // close context menu
1674
+ document.getElementById('channelMenu').classList.remove('open');
1675
+ // close any open move dropdown
1676
+ if (openDropdown) {
1677
+ openDropdown.classList.remove('open');
1678
+ openDropdown = null;
1679
+ }
1680
+ }
1681
+
1638
1682
  (async function init() {
1639
1683
  await applyBranding();
1640
1684
  await initName();
package/client/sw.js ADDED
@@ -0,0 +1,10 @@
1
+ // sw.js — Instbyte service worker
2
+ // Minimal install-only service worker.
3
+ // We do not cache anything — Instbyte is a real-time LAN tool and must
4
+ // always fetch live data. This file exists solely to satisfy the PWA
5
+ // installability requirement.
6
+
7
+ self.addEventListener('install', () => self.skipWaiting());
8
+ self.addEventListener('activate', e => e.waitUntil(self.clients.claim()));
9
+
10
+ // No fetch handler — all requests go straight to the network as normal.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "instbyte",
3
- "version": "1.9.2",
3
+ "version": "1.9.4",
4
4
  "description": "A self-hosted LAN sharing utility for fast, frictionless file, link, and snippet exchange across devices — no cloud required.",
5
5
  "main": "bin/instbyte.js",
6
6
  "bin": {
@@ -13,7 +13,9 @@
13
13
  ],
14
14
  "scripts": {
15
15
  "start": "node bin/instbyte.js",
16
- "dev": "nodemon bin/instbyte.js"
16
+ "dev": "nodemon bin/instbyte.js",
17
+ "test": "vitest run",
18
+ "test:watch": "vitest"
17
19
  },
18
20
  "keywords": [
19
21
  "lan",
@@ -46,6 +48,8 @@
46
48
  "sqlite3": "^5.1.6"
47
49
  },
48
50
  "devDependencies": {
49
- "nodemon": "^3.1.0"
51
+ "nodemon": "^3.1.0",
52
+ "supertest": "^7.2.2",
53
+ "vitest": "^2.1.8"
50
54
  }
51
- }
55
+ }
package/server/server.js CHANGED
@@ -67,7 +67,14 @@ app.use((req, res, next) => {
67
67
  });
68
68
  app.use(cookieParser());
69
69
  app.use(requireAuth);
70
- app.use("/uploads", express.static(UPLOADS_DIR));
70
+ app.use("/uploads", (req, res, next) => {
71
+ const ext = req.path.split('.').pop().toLowerCase();
72
+ if (FORCE_DOWNLOAD_EXTENSIONS.has(ext)) {
73
+ const filename = path.basename(req.path);
74
+ res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
75
+ }
76
+ next();
77
+ }, express.static(UPLOADS_DIR));
71
78
  app.use(express.static(CLIENT_DIR));
72
79
 
73
80
  const storage = multer.diskStorage({
@@ -75,7 +82,8 @@ const storage = multer.diskStorage({
75
82
  cb(null, UPLOADS_DIR);
76
83
  },
77
84
  filename: (req, file, cb) => {
78
- const unique = Date.now() + "-" + file.originalname;
85
+ const safe = sanitiseFilename(file.originalname);
86
+ const unique = Date.now() + "-" + safe;
79
87
  cb(null, unique);
80
88
  },
81
89
  });
@@ -85,6 +93,64 @@ const upload = multer({
85
93
  limits: { fileSize: config.storage.maxFileSize },
86
94
  });
87
95
 
96
+ // FILE SECURITY
97
+
98
+ // Extensions that must never be rendered inline by a browser.
99
+ // These are served with Content-Disposition: attachment always.
100
+ const FORCE_DOWNLOAD_EXTENSIONS = new Set([
101
+ 'svg', 'html', 'htm', 'xml', 'xhtml', 'js', 'mjs', 'php',
102
+ 'sh', 'bash', 'py', 'rb', 'pl', 'ps1', 'bat', 'cmd', 'exe',
103
+ 'dll', 'jar', 'vbs', 'ws', 'hta'
104
+ ]);
105
+
106
+ // Magic number signatures — first bytes that identify real file types.
107
+ // Key = expected extension group, value = array of valid byte signatures.
108
+ const MAGIC_NUMBERS = {
109
+ jpg: [Buffer.from([0xFF, 0xD8, 0xFF])],
110
+ png: [Buffer.from([0x89, 0x50, 0x4E, 0x47])],
111
+ gif: [Buffer.from([0x47, 0x49, 0x46, 0x38])],
112
+ webp: [Buffer.from([0x52, 0x49, 0x46, 0x46])],
113
+ pdf: [Buffer.from([0x25, 0x50, 0x44, 0x46])],
114
+ zip: [Buffer.from([0x50, 0x4B, 0x03, 0x04]), Buffer.from([0x50, 0x4B, 0x05, 0x06])],
115
+ mp4: [Buffer.from([0x00, 0x00, 0x00, 0x18]), Buffer.from([0x00, 0x00, 0x00, 0x20])],
116
+ };
117
+
118
+ // Extension → magic group mapping
119
+ const EXT_TO_MAGIC = {
120
+ jpg: 'jpg', jpeg: 'jpg',
121
+ png: 'png',
122
+ gif: 'gif',
123
+ webp: 'webp',
124
+ pdf: 'pdf',
125
+ zip: 'zip',
126
+ mp4: 'mp4',
127
+ };
128
+
129
+ function sanitiseFilename(name) {
130
+ return name
131
+ .replace(/[/\\?%*:|"<>\x00]/g, '_') // strip path separators and dangerous chars
132
+ .replace(/\.{2,}/g, '.') // collapse .. sequences
133
+ .trim()
134
+ .slice(0, 255); // max filename length
135
+ }
136
+
137
+ function checkMagicNumber(filePath, ext) {
138
+ const group = EXT_TO_MAGIC[ext.toLowerCase()];
139
+ if (!group) return true; // no check defined for this type — allow through
140
+
141
+ const signatures = MAGIC_NUMBERS[group];
142
+ if (!signatures) return true;
143
+
144
+ try {
145
+ const fd = fs.openSync(filePath, 'r');
146
+ const buf = Buffer.alloc(8);
147
+ fs.readSync(fd, buf, 0, 8, 0);
148
+ fs.closeSync(fd);
149
+ return signatures.some(sig => buf.slice(0, sig.length).equals(sig));
150
+ } catch (e) {
151
+ return false; // can't read → reject
152
+ }
153
+ }
88
154
 
89
155
  function hexToHsl(hex) {
90
156
  let r = parseInt(hex.slice(1, 3), 16) / 255;
@@ -284,6 +350,20 @@ const loginLimiter = rateLimit({
284
350
  message: { error: "Too many attempts, try again later" }
285
351
  });
286
352
 
353
+ const dropLimiter = rateLimit({
354
+ windowMs: 5 * 60 * 1000,
355
+ max: 60,
356
+ skip: () => process.env.NODE_ENV === 'test',
357
+ message: { error: "Too many requests, try again later" }
358
+ });
359
+
360
+ const channelLimiter = rateLimit({
361
+ windowMs: 5 * 60 * 1000,
362
+ max: 20,
363
+ skip: () => process.env.NODE_ENV === 'test',
364
+ message: { error: "Too many requests, try again later" }
365
+ });
366
+
287
367
  /* LOGIN POST */
288
368
  app.post("/login", loginLimiter, (req, res) => {
289
369
  if (!config.auth.passphrase) return res.redirect("/");
@@ -313,11 +393,19 @@ app.post("/logout", (req, res) => {
313
393
 
314
394
 
315
395
  /* FILE UPLOAD */
316
- app.post("/upload", upload.single("file"), (req, res) => {
396
+ app.post("/upload", dropLimiter, upload.single("file"), (req, res) => {
317
397
  if (!req.file) {
318
398
  return res.status(400).json({ error: "No file received" });
319
399
  }
320
400
 
401
+ // Magic number check — verify file content matches its extension
402
+ const ext = req.file.originalname.split('.').pop().toLowerCase();
403
+ const filePath = path.join(UPLOADS_DIR, req.file.filename);
404
+ if (!checkMagicNumber(filePath, ext)) {
405
+ fs.unlinkSync(filePath); // delete the suspicious file immediately
406
+ return res.status(400).json({ error: "File content does not match its extension" });
407
+ }
408
+
321
409
  const { channel, uploader } = req.body;
322
410
 
323
411
  const item = {
@@ -359,7 +447,7 @@ app.use((err, req, res, next) => {
359
447
  });
360
448
 
361
449
  /* TEXT/LINK */
362
- app.post("/text", (req, res) => {
450
+ app.post("/text", dropLimiter, (req, res) => {
363
451
  const { content, channel, uploader } = req.body;
364
452
 
365
453
  const item = {
@@ -496,7 +584,7 @@ app.get("/channels", (req, res) => {
496
584
  });
497
585
 
498
586
  /* ADD CHANNEL */
499
- app.post("/channels", (req, res) => {
587
+ app.post("/channels", channelLimiter, (req, res) => {
500
588
 
501
589
  const { name } = req.body;
502
590
  if (!name) return res.status(400).json({ error: "Name required" });
@@ -684,6 +772,37 @@ app.get("/branding", (req, res) => {
684
772
  });
685
773
  });
686
774
 
775
+ /* PWA MANIFEST */
776
+ app.get("/manifest.json", (req, res) => {
777
+ const b = config.branding;
778
+ const name = b.appName || "Instbyte";
779
+ const color = b.primaryColor || "#111827";
780
+
781
+ res.json({
782
+ name,
783
+ short_name: name.length > 12 ? name.slice(0, 12) : name,
784
+ description: "Real-time LAN sharing — no cloud, no accounts",
785
+ start_url: "/",
786
+ display: "standalone",
787
+ background_color: "#f3f4f6",
788
+ theme_color: color,
789
+ icons: [
790
+ {
791
+ src: "/logo-dynamic.png",
792
+ sizes: "192x192",
793
+ type: "image/png",
794
+ purpose: "any maskable"
795
+ },
796
+ {
797
+ src: "/logo-dynamic.png",
798
+ sizes: "512x512",
799
+ type: "image/png",
800
+ purpose: "any maskable"
801
+ }
802
+ ]
803
+ });
804
+ });
805
+
687
806
  /* HEALTH MONITOR */
688
807
  app.get("/health", (req, res) => {
689
808
  res.json({
@@ -822,7 +941,6 @@ const PREFERRED = parseInt(process.env.PORT) || config.server.port;
822
941
  const localIP = getLocalIP();
823
942
 
824
943
  let PORT;
825
-
826
944
  findFreePort(PREFERRED).then(p => {
827
945
  PORT = p;
828
946
  server.listen(PORT, () => {
@@ -833,10 +951,12 @@ findFreePort(PREFERRED).then(p => {
833
951
  console.log(`(port ${PREFERRED} was busy, switched to ${PORT})`);
834
952
  }
835
953
  console.log("");
836
- scanOrphans(); // clean up any pre-v1.9.1 ghost files
954
+ scanOrphans();
837
955
  });
838
956
  });
839
957
 
958
+ module.exports = { app, server, sessions };
959
+
840
960
 
841
961
  // ========================
842
962
  // GRACEFUL SHUTDOWN