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/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,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, () => {