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/README.md +24 -164
- package/bin/instbyte.js +15 -6
- package/client/css/app.css +366 -0
- package/client/index.html +31 -0
- package/client/js/app.js +498 -3
- package/package.json +2 -1
- package/server/config.js +15 -6
- package/server/server.js +271 -19
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();
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
451
|
-
|
|
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
|
-
|
|
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
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
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
|
-
|
|
945
|
-
|
|
946
|
-
|
|
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");
|