instbyte 1.9.3 → 1.10.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/README.md +49 -132
- package/bin/instbyte.js +1 -0
- package/client/css/app.css +522 -0
- package/client/index.html +38 -1
- package/client/js/app.js +557 -18
- package/client/sw.js +10 -0
- package/package.json +1 -1
- package/server/server.js +237 -7
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,27 @@ 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
|
+
|
|
367
|
+
const broadcastLimiter = rateLimit({
|
|
368
|
+
windowMs: 60 * 1000,
|
|
369
|
+
max: 5,
|
|
370
|
+
skip: () => process.env.NODE_ENV === 'test',
|
|
371
|
+
message: { error: "Too many broadcast attempts, slow down" }
|
|
372
|
+
});
|
|
373
|
+
|
|
287
374
|
/* LOGIN POST */
|
|
288
375
|
app.post("/login", loginLimiter, (req, res) => {
|
|
289
376
|
if (!config.auth.passphrase) return res.redirect("/");
|
|
@@ -313,11 +400,19 @@ app.post("/logout", (req, res) => {
|
|
|
313
400
|
|
|
314
401
|
|
|
315
402
|
/* FILE UPLOAD */
|
|
316
|
-
app.post("/upload", upload.single("file"), (req, res) => {
|
|
403
|
+
app.post("/upload", dropLimiter, upload.single("file"), (req, res) => {
|
|
317
404
|
if (!req.file) {
|
|
318
405
|
return res.status(400).json({ error: "No file received" });
|
|
319
406
|
}
|
|
320
407
|
|
|
408
|
+
// Magic number check — verify file content matches its extension
|
|
409
|
+
const ext = req.file.originalname.split('.').pop().toLowerCase();
|
|
410
|
+
const filePath = path.join(UPLOADS_DIR, req.file.filename);
|
|
411
|
+
if (!checkMagicNumber(filePath, ext)) {
|
|
412
|
+
fs.unlinkSync(filePath); // delete the suspicious file immediately
|
|
413
|
+
return res.status(400).json({ error: "File content does not match its extension" });
|
|
414
|
+
}
|
|
415
|
+
|
|
321
416
|
const { channel, uploader } = req.body;
|
|
322
417
|
|
|
323
418
|
const item = {
|
|
@@ -359,7 +454,7 @@ app.use((err, req, res, next) => {
|
|
|
359
454
|
});
|
|
360
455
|
|
|
361
456
|
/* TEXT/LINK */
|
|
362
|
-
app.post("/text", (req, res) => {
|
|
457
|
+
app.post("/text", dropLimiter, (req, res) => {
|
|
363
458
|
const { content, channel, uploader } = req.body;
|
|
364
459
|
|
|
365
460
|
const item = {
|
|
@@ -496,7 +591,7 @@ app.get("/channels", (req, res) => {
|
|
|
496
591
|
});
|
|
497
592
|
|
|
498
593
|
/* ADD CHANNEL */
|
|
499
|
-
app.post("/channels", (req, res) => {
|
|
594
|
+
app.post("/channels", channelLimiter, (req, res) => {
|
|
500
595
|
|
|
501
596
|
const { name } = req.body;
|
|
502
597
|
if (!name) return res.status(400).json({ error: "Name required" });
|
|
@@ -684,6 +779,37 @@ app.get("/branding", (req, res) => {
|
|
|
684
779
|
});
|
|
685
780
|
});
|
|
686
781
|
|
|
782
|
+
/* PWA MANIFEST */
|
|
783
|
+
app.get("/manifest.json", (req, res) => {
|
|
784
|
+
const b = config.branding;
|
|
785
|
+
const name = b.appName || "Instbyte";
|
|
786
|
+
const color = b.primaryColor || "#111827";
|
|
787
|
+
|
|
788
|
+
res.json({
|
|
789
|
+
name,
|
|
790
|
+
short_name: name.length > 12 ? name.slice(0, 12) : name,
|
|
791
|
+
description: "Real-time LAN sharing — no cloud, no accounts",
|
|
792
|
+
start_url: "/",
|
|
793
|
+
display: "standalone",
|
|
794
|
+
background_color: "#f3f4f6",
|
|
795
|
+
theme_color: color,
|
|
796
|
+
icons: [
|
|
797
|
+
{
|
|
798
|
+
src: "/logo-dynamic.png",
|
|
799
|
+
sizes: "192x192",
|
|
800
|
+
type: "image/png",
|
|
801
|
+
purpose: "any maskable"
|
|
802
|
+
},
|
|
803
|
+
{
|
|
804
|
+
src: "/logo-dynamic.png",
|
|
805
|
+
sizes: "512x512",
|
|
806
|
+
type: "image/png",
|
|
807
|
+
purpose: "any maskable"
|
|
808
|
+
}
|
|
809
|
+
]
|
|
810
|
+
});
|
|
811
|
+
});
|
|
812
|
+
|
|
687
813
|
/* HEALTH MONITOR */
|
|
688
814
|
app.get("/health", (req, res) => {
|
|
689
815
|
res.json({
|
|
@@ -693,6 +819,62 @@ app.get("/health", (req, res) => {
|
|
|
693
819
|
});
|
|
694
820
|
});
|
|
695
821
|
|
|
822
|
+
/* BROADCAST*/
|
|
823
|
+
/* GET /broadcast/status — returns current broadcast or null */
|
|
824
|
+
app.get("/broadcast/status", (req, res) => {
|
|
825
|
+
if (!currentBroadcast) return res.json({ live: false });
|
|
826
|
+
res.json({
|
|
827
|
+
live: true,
|
|
828
|
+
uploader: currentBroadcast.uploader,
|
|
829
|
+
channel: currentBroadcast.channel,
|
|
830
|
+
startedAt: currentBroadcast.startedAt
|
|
831
|
+
});
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
/* POST /broadcast/start */
|
|
835
|
+
app.post("/broadcast/start", broadcastLimiter, (req, res) => {
|
|
836
|
+
|
|
837
|
+
if (currentBroadcast) {
|
|
838
|
+
return res.status(409).json({
|
|
839
|
+
error: "Broadcast already in progress",
|
|
840
|
+
uploader: currentBroadcast.uploader
|
|
841
|
+
});
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
const { uploader, channel, socketId } = req.body;
|
|
845
|
+
if (!uploader || !channel) {
|
|
846
|
+
return res.status(400).json({ error: "uploader and channel required" });
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
currentBroadcast = {
|
|
850
|
+
uploader,
|
|
851
|
+
channel,
|
|
852
|
+
startedAt: Date.now(),
|
|
853
|
+
lastFrame: null,
|
|
854
|
+
socketId
|
|
855
|
+
};
|
|
856
|
+
|
|
857
|
+
io.emit("broadcast-started", {
|
|
858
|
+
uploader: currentBroadcast.uploader,
|
|
859
|
+
channel: currentBroadcast.channel,
|
|
860
|
+
startedAt: currentBroadcast.startedAt
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
console.log(`Broadcast started by ${uploader}`);
|
|
864
|
+
res.json({ ok: true, ...currentBroadcast });
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
/* POST /broadcast/end */
|
|
868
|
+
app.post("/broadcast/end", (req, res) => {
|
|
869
|
+
if (!currentBroadcast) return res.status(400).json({ error: "No broadcast in progress" });
|
|
870
|
+
|
|
871
|
+
const uploader = currentBroadcast.uploader;
|
|
872
|
+
currentBroadcast = null;
|
|
873
|
+
|
|
874
|
+
io.emit("broadcast-ended", { uploader });
|
|
875
|
+
console.log(`Broadcast ended by ${uploader}`);
|
|
876
|
+
res.json({ ok: true });
|
|
877
|
+
});
|
|
696
878
|
|
|
697
879
|
/* FAVICON */
|
|
698
880
|
app.get("/favicon-dynamic.png", async (req, res) => {
|
|
@@ -748,6 +930,9 @@ app.get("/logo-dynamic.png", (req, res) => {
|
|
|
748
930
|
// resets on server restart, no DB needed
|
|
749
931
|
const seenBy = new Map();
|
|
750
932
|
|
|
933
|
+
// Broadcast state — null when IDLE, populated when LIVE
|
|
934
|
+
let currentBroadcast = null;
|
|
935
|
+
|
|
751
936
|
let connectedUsers = 0;
|
|
752
937
|
|
|
753
938
|
io.on("connection", (socket) => {
|
|
@@ -770,10 +955,56 @@ io.on("connection", (socket) => {
|
|
|
770
955
|
io.emit("seen-update", { id, count });
|
|
771
956
|
});
|
|
772
957
|
|
|
958
|
+
// WebRTC — viewer joins, notify broadcaster to initiate peer connection
|
|
959
|
+
socket.on("broadcast-join", () => {
|
|
960
|
+
if (!currentBroadcast) return;
|
|
961
|
+
// tell broadcaster a new viewer has joined, pass viewer's socket id
|
|
962
|
+
const broadcasterSocket = io.sockets.sockets.get(currentBroadcast.socketId);
|
|
963
|
+
if (broadcasterSocket) {
|
|
964
|
+
broadcasterSocket.emit("webrtc-viewer-joined", { viewerId: socket.id });
|
|
965
|
+
}
|
|
966
|
+
});
|
|
967
|
+
|
|
968
|
+
// WebRTC — broadcaster sends offer to a specific viewer
|
|
969
|
+
socket.on("webrtc-offer", ({ offer, viewerId }) => {
|
|
970
|
+
const viewerSocket = io.sockets.sockets.get(viewerId);
|
|
971
|
+
if (viewerSocket) {
|
|
972
|
+
viewerSocket.emit("webrtc-offer", { offer, broadcasterId: socket.id });
|
|
973
|
+
}
|
|
974
|
+
});
|
|
975
|
+
|
|
976
|
+
// WebRTC — viewer sends answer back to broadcaster
|
|
977
|
+
socket.on("webrtc-answer", ({ answer, broadcasterId }) => {
|
|
978
|
+
const broadcasterSocket = io.sockets.sockets.get(broadcasterId);
|
|
979
|
+
if (broadcasterSocket) {
|
|
980
|
+
broadcasterSocket.emit("webrtc-answer", { answer, viewerId: socket.id });
|
|
981
|
+
}
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
// WebRTC — ICE candidate exchange, both directions
|
|
985
|
+
socket.on("webrtc-ice", ({ candidate, targetId }) => {
|
|
986
|
+
const targetSocket = io.sockets.sockets.get(targetId);
|
|
987
|
+
if (targetSocket) {
|
|
988
|
+
targetSocket.emit("webrtc-ice", { candidate, fromId: socket.id });
|
|
989
|
+
}
|
|
990
|
+
});
|
|
991
|
+
|
|
992
|
+
// Broadcast — raise hand, relay to broadcaster
|
|
993
|
+
socket.on("broadcast-reaction", ({ from }) => {
|
|
994
|
+
if (!currentBroadcast) return;
|
|
995
|
+
io.emit("broadcast-reaction-received", { from });
|
|
996
|
+
});
|
|
997
|
+
|
|
773
998
|
socket.on("disconnect", () => {
|
|
774
999
|
connectedUsers--;
|
|
775
1000
|
console.log(username + " disconnected | total:", connectedUsers);
|
|
776
1001
|
io.emit("user-count", connectedUsers);
|
|
1002
|
+
|
|
1003
|
+
if (currentBroadcast && currentBroadcast.socketId === socket.id) {
|
|
1004
|
+
currentBroadcast = null;
|
|
1005
|
+
io.emit("broadcast-ended");
|
|
1006
|
+
console.log("Broadcast ended — broadcaster disconnected");
|
|
1007
|
+
}
|
|
777
1008
|
});
|
|
778
1009
|
});
|
|
779
1010
|
|
|
@@ -822,8 +1053,7 @@ const PREFERRED = parseInt(process.env.PORT) || config.server.port;
|
|
|
822
1053
|
const localIP = getLocalIP();
|
|
823
1054
|
|
|
824
1055
|
let PORT;
|
|
825
|
-
|
|
826
|
-
if (require.main === module) {
|
|
1056
|
+
if (process.env.INSTBYTE_BOOT === '1') {
|
|
827
1057
|
findFreePort(PREFERRED).then(p => {
|
|
828
1058
|
PORT = p;
|
|
829
1059
|
server.listen(PORT, () => {
|