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 +44 -1
- package/client/css/app.css +156 -0
- package/client/index.html +7 -1
- package/client/js/app.js +59 -15
- package/client/sw.js +10 -0
- package/package.json +8 -4
- package/server/server.js +127 -7
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
|
package/client/css/app.css
CHANGED
|
@@ -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 —
|
|
220
|
-
|
|
221
|
+
// plain text — escape and preserve newlines
|
|
222
|
+
const escaped = text
|
|
221
223
|
.replace(/&/g, "&")
|
|
222
224
|
.replace(/</g, "<")
|
|
223
225
|
.replace(/>/g, ">")
|
|
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
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|
|
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
|
|
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();
|
|
954
|
+
scanOrphans();
|
|
837
955
|
});
|
|
838
956
|
});
|
|
839
957
|
|
|
958
|
+
module.exports = { app, server, sessions };
|
|
959
|
+
|
|
840
960
|
|
|
841
961
|
// ========================
|
|
842
962
|
// GRACEFUL SHUTDOWN
|