instbyte 1.9.4 → 1.11.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
@@ -19,6 +19,9 @@ const db = require("./db");
19
19
 
20
20
  const config = require("./config");
21
21
 
22
+ let mdns = null;
23
+ try { mdns = require('multicast-dns'); } catch (e) { }
24
+
22
25
  const UPLOADS_DIR = process.env.INSTBYTE_UPLOADS
23
26
  || path.join(__dirname, "../uploads");
24
27
 
@@ -63,8 +66,16 @@ app.use(helmet({
63
66
 
64
67
  app.use((req, res, next) => {
65
68
  if (req.path === '/upload') return next();
69
+
70
+ const ct = req.headers['content-type'] || '';
71
+
72
+ if (ct.startsWith('text/plain')) {
73
+ return express.text({ limit: '10mb' })(req, res, next);
74
+ }
75
+
66
76
  express.json()(req, res, next);
67
77
  });
78
+
68
79
  app.use(cookieParser());
69
80
  app.use(requireAuth);
70
81
  app.use("/uploads", (req, res, next) => {
@@ -225,19 +236,28 @@ const COOKIE_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days
225
236
  const sessions = new Map();
226
237
 
227
238
  function requireAuth(req, res, next) {
228
- if (!config.auth.passphrase) return next(); // no passphrase set, skip
239
+ if (!config.auth.passphrase) return next();
229
240
 
230
- // Allow the login route itself through
231
241
  if (req.path === "/login" || req.path === "/info" || req.path === "/health") return next();
232
242
 
243
+ // Terminal / API clients — accept passphrase via header
244
+ const headerPass = req.headers["x-passphrase"];
245
+ if (headerPass && headerPass === config.auth.passphrase) return next();
233
246
 
234
- // Check cookie holds a valid session token
247
+ // Browser clients check session cookie
235
248
  const cookie = req.cookies[COOKIE_NAME];
236
249
  if (cookie && sessions.has(cookie)) return next();
237
250
 
238
- // Not authenticated
239
251
  if (req.path.startsWith("/socket.io")) return next();
240
- if (req.headers["content-type"] === "application/json" || req.xhr) {
252
+
253
+ // Return JSON 401 for any non-browser request
254
+ const ct = req.headers["content-type"] || "";
255
+ const isApiRequest = ct.startsWith("application/json") ||
256
+ ct.startsWith("text/plain") ||
257
+ req.xhr ||
258
+ req.headers["x-passphrase"] !== undefined;
259
+
260
+ if (isApiRequest) {
241
261
  return res.status(401).json({ error: "Unauthorized" });
242
262
  }
243
263
 
@@ -364,6 +384,13 @@ const channelLimiter = rateLimit({
364
384
  message: { error: "Too many requests, try again later" }
365
385
  });
366
386
 
387
+ const broadcastLimiter = rateLimit({
388
+ windowMs: 60 * 1000,
389
+ max: 5,
390
+ skip: () => process.env.NODE_ENV === 'test',
391
+ message: { error: "Too many broadcast attempts, slow down" }
392
+ });
393
+
367
394
  /* LOGIN POST */
368
395
  app.post("/login", loginLimiter, (req, res) => {
369
396
  if (!config.auth.passphrase) return res.redirect("/");
@@ -446,9 +473,27 @@ app.use((err, req, res, next) => {
446
473
  next(err);
447
474
  });
448
475
 
449
- /* TEXT/LINK */
450
- app.post("/text", dropLimiter, (req, res) => {
451
- const { content, channel, uploader } = req.body;
476
+ /* TEXT/LINK — accepts application/json and text/plain */
477
+ function handleTextPost(req, res) {
478
+ let content, channel, uploader;
479
+
480
+ if (req.is("text/plain")) {
481
+ // Terminal path — body is raw string, metadata comes from headers
482
+ content = typeof req.body === "string" ? req.body : String(req.body);
483
+ channel = (req.headers["x-channel"] || "general").trim();
484
+ uploader = (req.headers["x-uploader"] || "terminal").trim();
485
+ } else {
486
+ // Browser / JSON path — existing behaviour unchanged
487
+ ({ content, channel, uploader } = req.body || {});
488
+ }
489
+
490
+ if (!content || !content.trim()) {
491
+ return res.status(400).json({ error: "Content is required" });
492
+ }
493
+
494
+ if (!channel) {
495
+ return res.status(400).json({ error: "Channel is required" });
496
+ }
452
497
 
453
498
  const item = {
454
499
  type: "text",
@@ -469,7 +514,11 @@ app.post("/text", dropLimiter, (req, res) => {
469
514
  res.json(item);
470
515
  }
471
516
  );
472
- });
517
+ }
518
+
519
+ app.post("/text", dropLimiter, handleTextPost);
520
+ app.post("/push", dropLimiter, handleTextPost); // terminal-friendly alias
521
+
473
522
 
474
523
  /* DELETE ITEM */
475
524
  app.delete("/item/:id", (req, res) => {
@@ -812,6 +861,62 @@ app.get("/health", (req, res) => {
812
861
  });
813
862
  });
814
863
 
864
+ /* BROADCAST*/
865
+ /* GET /broadcast/status — returns current broadcast or null */
866
+ app.get("/broadcast/status", (req, res) => {
867
+ if (!currentBroadcast) return res.json({ live: false });
868
+ res.json({
869
+ live: true,
870
+ uploader: currentBroadcast.uploader,
871
+ channel: currentBroadcast.channel,
872
+ startedAt: currentBroadcast.startedAt
873
+ });
874
+ });
875
+
876
+ /* POST /broadcast/start */
877
+ app.post("/broadcast/start", broadcastLimiter, (req, res) => {
878
+
879
+ if (currentBroadcast) {
880
+ return res.status(409).json({
881
+ error: "Broadcast already in progress",
882
+ uploader: currentBroadcast.uploader
883
+ });
884
+ }
885
+
886
+ const { uploader, channel, socketId } = req.body;
887
+ if (!uploader || !channel) {
888
+ return res.status(400).json({ error: "uploader and channel required" });
889
+ }
890
+
891
+ currentBroadcast = {
892
+ uploader,
893
+ channel,
894
+ startedAt: Date.now(),
895
+ lastFrame: null,
896
+ socketId
897
+ };
898
+
899
+ io.emit("broadcast-started", {
900
+ uploader: currentBroadcast.uploader,
901
+ channel: currentBroadcast.channel,
902
+ startedAt: currentBroadcast.startedAt
903
+ });
904
+
905
+ console.log(`Broadcast started by ${uploader}`);
906
+ res.json({ ok: true, ...currentBroadcast });
907
+ });
908
+
909
+ /* POST /broadcast/end */
910
+ app.post("/broadcast/end", (req, res) => {
911
+ if (!currentBroadcast) return res.status(400).json({ error: "No broadcast in progress" });
912
+
913
+ const uploader = currentBroadcast.uploader;
914
+ currentBroadcast = null;
915
+
916
+ io.emit("broadcast-ended", { uploader });
917
+ console.log(`Broadcast ended by ${uploader}`);
918
+ res.json({ ok: true });
919
+ });
815
920
 
816
921
  /* FAVICON */
817
922
  app.get("/favicon-dynamic.png", async (req, res) => {
@@ -867,6 +972,9 @@ app.get("/logo-dynamic.png", (req, res) => {
867
972
  // resets on server restart, no DB needed
868
973
  const seenBy = new Map();
869
974
 
975
+ // Broadcast state — null when IDLE, populated when LIVE
976
+ let currentBroadcast = null;
977
+
870
978
  let connectedUsers = 0;
871
979
 
872
980
  io.on("connection", (socket) => {
@@ -889,10 +997,56 @@ io.on("connection", (socket) => {
889
997
  io.emit("seen-update", { id, count });
890
998
  });
891
999
 
1000
+ // WebRTC — viewer joins, notify broadcaster to initiate peer connection
1001
+ socket.on("broadcast-join", () => {
1002
+ if (!currentBroadcast) return;
1003
+ // tell broadcaster a new viewer has joined, pass viewer's socket id
1004
+ const broadcasterSocket = io.sockets.sockets.get(currentBroadcast.socketId);
1005
+ if (broadcasterSocket) {
1006
+ broadcasterSocket.emit("webrtc-viewer-joined", { viewerId: socket.id });
1007
+ }
1008
+ });
1009
+
1010
+ // WebRTC — broadcaster sends offer to a specific viewer
1011
+ socket.on("webrtc-offer", ({ offer, viewerId }) => {
1012
+ const viewerSocket = io.sockets.sockets.get(viewerId);
1013
+ if (viewerSocket) {
1014
+ viewerSocket.emit("webrtc-offer", { offer, broadcasterId: socket.id });
1015
+ }
1016
+ });
1017
+
1018
+ // WebRTC — viewer sends answer back to broadcaster
1019
+ socket.on("webrtc-answer", ({ answer, broadcasterId }) => {
1020
+ const broadcasterSocket = io.sockets.sockets.get(broadcasterId);
1021
+ if (broadcasterSocket) {
1022
+ broadcasterSocket.emit("webrtc-answer", { answer, viewerId: socket.id });
1023
+ }
1024
+ });
1025
+
1026
+ // WebRTC — ICE candidate exchange, both directions
1027
+ socket.on("webrtc-ice", ({ candidate, targetId }) => {
1028
+ const targetSocket = io.sockets.sockets.get(targetId);
1029
+ if (targetSocket) {
1030
+ targetSocket.emit("webrtc-ice", { candidate, fromId: socket.id });
1031
+ }
1032
+ });
1033
+
1034
+ // Broadcast — raise hand, relay to broadcaster
1035
+ socket.on("broadcast-reaction", ({ from }) => {
1036
+ if (!currentBroadcast) return;
1037
+ io.emit("broadcast-reaction-received", { from });
1038
+ });
1039
+
892
1040
  socket.on("disconnect", () => {
893
1041
  connectedUsers--;
894
1042
  console.log(username + " disconnected | total:", connectedUsers);
895
1043
  io.emit("user-count", connectedUsers);
1044
+
1045
+ if (currentBroadcast && currentBroadcast.socketId === socket.id) {
1046
+ currentBroadcast = null;
1047
+ io.emit("broadcast-ended");
1048
+ console.log("Broadcast ended — broadcaster disconnected");
1049
+ }
896
1050
  });
897
1051
  });
898
1052
 
@@ -925,14 +1079,74 @@ function getLocalIP() {
925
1079
  return preferred ? preferred.address : "localhost";
926
1080
  }
927
1081
 
928
- function findFreePort(start) {
1082
+
1083
+ function getMdnsName() {
1084
+ const appName = config.branding && config.branding.appName;
1085
+ if (appName && appName.toLowerCase() !== 'instbyte') {
1086
+ return appName.toLowerCase().replace(/\s+/g, '-') + '.local';
1087
+ }
1088
+ return 'instbyte.local';
1089
+ }
1090
+
1091
+ function getHostnameFallback() {
1092
+ return os.hostname().toLowerCase() + '.local';
1093
+ }
1094
+
1095
+ function probeMdns(name) {
929
1096
  return new Promise((resolve) => {
930
- const srv = net.createServer();
931
- srv.listen(start, () => {
932
- const port = srv.address().port;
933
- srv.close(() => resolve(port));
1097
+ if (!mdns) return resolve(false);
1098
+ const m = mdns();
1099
+ const timer = setTimeout(() => {
1100
+ m.destroy();
1101
+ resolve(false); // no response = name is free
1102
+ }, 2000);
1103
+
1104
+ m.on('response', (response) => {
1105
+ const taken = response.answers.some(a =>
1106
+ a.name === name && a.type === 'A'
1107
+ );
1108
+ if (taken) {
1109
+ clearTimeout(timer);
1110
+ m.destroy();
1111
+ resolve(true); // name is taken
1112
+ }
1113
+ });
1114
+
1115
+ m.query({ questions: [{ name, type: 'A' }] });
1116
+ });
1117
+ }
1118
+
1119
+ function startMdnsAdvertise(name, ip) {
1120
+ if (!mdns) return null;
1121
+ try {
1122
+ const m = mdns();
1123
+ m.on('query', (query) => {
1124
+ query.questions.forEach(q => {
1125
+ if (q.name === name && q.type === 'A') {
1126
+ m.respond({
1127
+ answers: [{ name, type: 'A', ttl: 300, data: ip }]
1128
+ });
1129
+ }
1130
+ });
1131
+ });
1132
+ return m;
1133
+ } catch (e) {
1134
+ return null;
1135
+ }
1136
+ }
1137
+
1138
+
1139
+ function listenOnFreePort(server, preferredPort) {
1140
+ return new Promise((resolve, reject) => {
1141
+ server.listen(preferredPort, () => resolve(server.address().port));
1142
+ server.on('error', (err) => {
1143
+ if (err.code === 'EADDRINUSE') {
1144
+ server.removeAllListeners('error');
1145
+ listenOnFreePort(server, preferredPort + 1).then(resolve).catch(reject);
1146
+ } else {
1147
+ reject(err);
1148
+ }
934
1149
  });
935
- srv.on("error", () => resolve(findFreePort(start + 1)));
936
1150
  });
937
1151
  }
938
1152
 
@@ -941,19 +1155,52 @@ const PREFERRED = parseInt(process.env.PORT) || config.server.port;
941
1155
  const localIP = getLocalIP();
942
1156
 
943
1157
  let PORT;
944
- findFreePort(PREFERRED).then(p => {
945
- PORT = p;
946
- server.listen(PORT, () => {
1158
+ let mdnsAdvertiser = null;
1159
+
1160
+ if (process.env.INSTBYTE_BOOT === '1') {
1161
+ listenOnFreePort(server, PREFERRED).then(async p => {
1162
+ PORT = p;
947
1163
  console.log("\nInstbyte running");
948
1164
  console.log("Local: http://localhost:" + PORT);
949
1165
  console.log("Network: http://" + localIP + ":" + PORT);
950
1166
  if (PORT !== PREFERRED) {
951
1167
  console.log(`(port ${PREFERRED} was busy, switched to ${PORT})`);
952
1168
  }
1169
+
1170
+ // mDNS
1171
+ if (mdns) {
1172
+ try {
1173
+ const preferredName = getMdnsName();
1174
+ const isTaken = await probeMdns(preferredName);
1175
+
1176
+ let mdnsName;
1177
+ if (isTaken) {
1178
+ mdnsName = getHostnameFallback();
1179
+ if (os.platform() !== 'win32') {
1180
+ console.log(`mDNS: http://${preferredName} already in use on this network`);
1181
+ console.log(`mDNS: http://${mdnsName}`);
1182
+ }
1183
+ } else {
1184
+ mdnsName = preferredName;
1185
+ if (os.platform() !== 'win32') {
1186
+ console.log(`mDNS: http://${mdnsName}`);
1187
+ }
1188
+ }
1189
+
1190
+ mdnsAdvertiser = startMdnsAdvertise(mdnsName, localIP);
1191
+
1192
+ } catch (e) {
1193
+ console.log("mDNS: unavailable on this network — use IP or QR code to join");
1194
+ }
1195
+ }
1196
+
953
1197
  console.log("");
954
1198
  scanOrphans();
1199
+ }).catch(err => {
1200
+ console.error("Failed to start server:", err.message);
1201
+ process.exit(1);
955
1202
  });
956
- });
1203
+ }
957
1204
 
958
1205
  module.exports = { app, server, sessions };
959
1206
 
@@ -964,6 +1211,11 @@ module.exports = { app, server, sessions };
964
1211
  function shutdown(signal) {
965
1212
  console.log(`\n${signal} received — shutting down gracefully...`);
966
1213
 
1214
+ // destroy mDNS advertiser if running
1215
+ if (typeof mdnsAdvertiser !== 'undefined' && mdnsAdvertiser) {
1216
+ try { mdnsAdvertiser.destroy(); } catch (e) { }
1217
+ }
1218
+
967
1219
  // stop accepting new connections
968
1220
  server.close(() => {
969
1221
  console.log("HTTP server closed");