incognitolm commited on
Commit
793e55d
Β·
verified Β·
1 Parent(s): 483ebbc

Update server.js

Browse files
Files changed (1) hide show
  1. server.js +183 -146
server.js CHANGED
@@ -1,48 +1,76 @@
1
- const http = require("http");
2
- const fs = require("fs");
3
- const path = require("path");
 
 
4
  const crypto = require("crypto");
5
  const { WebSocketServer } = require("ws");
6
- const url = require("url");
7
 
8
- // ── Paths ──────────────────────────────────────────────────────────────────
9
- const DATA_DIR = path.join(__dirname, "data");
 
 
10
  const ACCOUNTS_DIR = path.join(DATA_DIR, "accounts");
11
- const PIXELS_DIR = path.join(DATA_DIR, "pixels");
12
- [DATA_DIR, ACCOUNTS_DIR, PIXELS_DIR].forEach((d) => fs.mkdirSync(d, { recursive: true }));
13
 
14
- // ── Tiny transparent 1Γ—1 PNG (base64) ────────────────────────────────────
15
- const TRANSPARENT_PNG = Buffer.from(
 
 
 
 
16
  "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==",
17
  "base64"
18
  );
19
 
20
- // ── Helpers ────────────────────────────────────────────────────────────────
21
- function uid() { return crypto.randomBytes(16).toString("hex"); }
22
- function hash(pw) { return crypto.createHash("sha256").update(pw).digest("hex"); }
 
 
 
 
 
 
 
 
 
 
 
 
23
  function readJSON(file) {
24
- try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch { return null; }
 
 
 
 
 
25
  }
26
- function writeJSON(file, data) { fs.writeFileSync(file, JSON.stringify(data, null, 2)); }
27
 
28
  // ── Account helpers ────────────────────────────────────────────────────────
29
- function accountFile(username) { return path.join(ACCOUNTS_DIR, `${username}.json`); }
30
- function getAccount(username) { return readJSON(accountFile(username)); }
31
- function saveAccount(username, data) { writeJSON(accountFile(username), data); }
32
 
33
  function registerUser(username, password) {
34
- if (!/^[a-zA-Z0-9_]{3,32}$/.test(username)) return { error: "Invalid username (3-32 alphanumeric/underscore)" };
35
- if (fs.existsSync(accountFile(username))) return { error: "Username taken" };
36
- const account = { username, passwordHash: hash(password), tokens: [], pixels: [] };
37
- saveAccount(username, account);
 
 
 
38
  return { ok: true };
39
  }
40
 
41
  function loginUser(username, password) {
42
  const acc = getAccount(username);
43
- if (!acc || acc.passwordHash !== hash(password)) return { error: "Invalid credentials" };
 
44
  const token = uid();
45
- acc.tokens.push(token);
 
46
  saveAccount(username, acc);
47
  return { token, username };
48
  }
@@ -55,144 +83,135 @@ function logoutUser(username, token) {
55
  }
56
 
57
  function verifyToken(username, token) {
 
58
  const acc = getAccount(username);
59
- return acc && acc.tokens.includes(token);
60
- }
61
-
62
- function getUserByToken(token) {
63
- const files = fs.readdirSync(ACCOUNTS_DIR);
64
- for (const f of files) {
65
- const acc = readJSON(path.join(ACCOUNTS_DIR, f));
66
- if (acc && acc.tokens.includes(token)) return acc;
67
- }
68
- return null;
69
  }
70
 
71
  // ── Pixel helpers ──────────────────────────────────────────────────────────
72
- function pixelFile(pixelId) { return path.join(PIXELS_DIR, `${pixelId}.json`); }
73
- function getPixel(pixelId) { return readJSON(pixelFile(pixelId)); }
74
 
75
  function createPixel(username, label) {
76
  const acc = getAccount(username);
77
  if (!acc) return { error: "Account not found" };
78
- const pixelId = uid();
79
- const pixel = {
80
- id: pixelId,
81
  owner: username,
82
- label: label || "Untitled",
83
  createdAt: Date.now(),
84
  opens: [],
85
- };
86
- writeJSON(pixelFile(pixelId), pixel);
87
- acc.pixels.push(pixelId);
88
  saveAccount(username, acc);
89
- return { pixelId };
90
  }
91
 
92
  function deletePixel(username, pixelId) {
93
  const pixel = getPixel(pixelId);
94
  if (!pixel || pixel.owner !== username) return { error: "Not found" };
95
- fs.unlinkSync(pixelFile(pixelId));
96
  const acc = getAccount(username);
97
- acc.pixels = acc.pixels.filter((p) => p !== pixelId);
98
- saveAccount(username, acc);
 
 
99
  return { ok: true };
100
  }
101
 
102
  function getUserPixels(username) {
103
  const acc = getAccount(username);
104
  if (!acc) return [];
105
- return acc.pixels
106
- .map((id) => getPixel(id))
107
- .filter(Boolean)
108
- .sort((a, b) => b.createdAt - a.createdAt);
109
  }
110
 
111
- // ── WebSocket connection registry ──────────────────────────────────────────
112
- // Map: username β†’ Set of { ws, type } where type is "browser" | "sw"
113
  const connections = new Map();
114
 
115
- function addConnection(username, ws, type) {
116
  if (!connections.has(username)) connections.set(username, new Set());
117
- connections.get(username).add({ ws, type });
118
  }
119
 
120
- function removeConnection(username, ws) {
121
- const conns = connections.get(username);
122
- if (!conns) return;
123
- for (const entry of conns) {
124
- if (entry.ws === ws) { conns.delete(entry); break; }
125
- }
126
- if (conns.size === 0) connections.delete(username);
127
  }
128
 
129
- function notifyUser(username, payload) {
130
- const conns = connections.get(username);
131
- if (!conns) return;
132
  const msg = JSON.stringify(payload);
133
- for (const { ws } of conns) {
134
- if (ws.readyState === 1) ws.send(msg);
135
  }
136
  }
137
 
138
- // ── HTTP Server ────────────────────────────────────────────────────────────
139
- const STATIC = { ".html": "text/html", ".css": "text/css", ".js": "application/javascript" };
 
 
 
 
 
 
140
 
 
141
  const server = http.createServer((req, res) => {
142
- const parsed = url.parse(req.url, true);
 
 
 
143
  const pathname = parsed.pathname;
144
 
145
- // CORS
146
  res.setHeader("Access-Control-Allow-Origin", "*");
147
  res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
148
  if (req.method === "OPTIONS") { res.writeHead(204); return res.end(); }
149
 
150
- // ── Pixel image endpoint ────────────────────────────────────────────────
151
  if (pathname.startsWith("/px/")) {
152
- const pixelId = pathname.slice(4).replace(/\/$/, "");
153
  const pixel = getPixel(pixelId);
154
  if (!pixel) { res.writeHead(404); return res.end(); }
155
 
156
- const ip = req.headers["x-forwarded-for"] || req.socket.remoteAddress;
157
- const ua = req.headers["user-agent"] || "";
158
- const openEntry = { ts: Date.now(), ip, ua };
159
- pixel.opens.push(openEntry);
160
  writeJSON(pixelFile(pixelId), pixel);
161
 
162
- // Notify all connected sessions of the owner
163
- notifyUser(pixel.owner, {
164
- type: "open",
165
- pixelId: pixel.id,
166
- label: pixel.label,
167
- ts: openEntry.ts,
168
- ip,
169
- ua,
170
- });
171
 
172
  res.writeHead(200, {
173
- "Content-Type": "image/png",
174
- "Content-Length": TRANSPARENT_PNG.length,
175
- "Cache-Control": "no-store, no-cache, must-revalidate",
176
- Pragma: "no-cache",
177
- Expires: "0",
178
  });
179
- return res.end(TRANSPARENT_PNG);
180
  }
181
 
182
- // ── REST API ────────────────────────────────────────────────────────────
183
  if (pathname.startsWith("/api/")) {
184
  let body = "";
185
- req.on("data", (d) => (body += d));
186
  req.on("end", () => {
187
  let data = {};
188
- try { data = body ? JSON.parse(body) : {}; } catch {}
 
 
 
189
 
190
- // Auth helper
191
- const authHeader = req.headers["authorization"] || "";
192
- const [authUser, authToken] = (authHeader.replace("Bearer ", "")).split(":");
193
 
194
  function authed() {
195
- if (!authUser || !authToken || !verifyToken(authUser, authToken)) {
196
  res.writeHead(401, { "Content-Type": "application/json" });
197
  res.end(JSON.stringify({ error: "Unauthorized" }));
198
  return false;
@@ -201,18 +220,18 @@ const server = http.createServer((req, res) => {
201
  }
202
 
203
  function json(status, obj) {
204
- res.writeHead(status, { "Content-Type": "application/json" });
205
- res.end(JSON.stringify(obj));
 
 
 
 
206
  }
207
 
208
  const route = `${req.method} ${pathname}`;
209
 
210
- if (route === "POST /api/register") {
211
- return json(200, registerUser(data.username, data.password));
212
- }
213
- if (route === "POST /api/login") {
214
- return json(200, loginUser(data.username, data.password));
215
- }
216
  if (route === "POST /api/logout") {
217
  if (!authed()) return;
218
  logoutUser(authUser, authToken);
@@ -230,74 +249,92 @@ const server = http.createServer((req, res) => {
230
  if (!authed()) return;
231
  return json(200, deletePixel(authUser, data.pixelId));
232
  }
233
-
234
  return json(404, { error: "Not found" });
235
  });
236
  return;
237
  }
238
 
239
- // ── Service Worker (must be at root scope, served before generic static) ──
240
  if (pathname === "/sw.js") {
241
- const swPath = path.join(__dirname, "sw.js");
242
- if (fs.existsSync(swPath)) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
243
  res.writeHead(200, {
244
- "Content-Type": "application/javascript",
245
- "Service-Worker-Allowed": "/",
246
- "Cache-Control": "no-store",
247
  });
248
- return res.end(fs.readFileSync(swPath));
249
  }
250
  }
251
 
252
- // ── Static files (served from /public) ──────────────────────────────────
253
- let filePath = pathname === "/" ? "/index.html" : pathname;
254
- filePath = path.join(__dirname, "public", filePath);
255
- const ext = path.extname(filePath);
256
- if (STATIC[ext] && fs.existsSync(filePath)) {
257
- res.writeHead(200, { "Content-Type": STATIC[ext] });
258
- return res.end(fs.readFileSync(filePath));
259
- }
260
-
261
- res.writeHead(404);
262
- res.end("Not found");
263
  });
264
 
265
- // ── WebSocket Server ───────────────────────────────────────────────────────
266
  const wss = new WebSocketServer({ server });
267
 
268
  wss.on("connection", (ws, req) => {
269
- const parsed = url.parse(req.url, true);
270
- // token param is "username:rawtoken"
271
- const rawParam = parsed.query.token || "";
272
- const colonIdx = rawParam.indexOf(":");
273
- const wsUsername = colonIdx > -1 ? rawParam.slice(0, colonIdx) : "";
274
- const wsToken = colonIdx > -1 ? rawParam.slice(colonIdx + 1) : rawParam;
275
- const type = parsed.query.type || "browser"; // "browser" | "sw"
276
-
277
- if (!wsUsername || !verifyToken(wsUsername, wsToken)) {
 
 
278
  ws.close(4001, "Unauthorized");
279
  return;
280
  }
281
- const user = getAccount(wsUsername);
282
 
283
- const username = user.username; // same as wsUsername
284
- addConnection(username, ws, type);
285
 
286
- // Confirm connection
287
- ws.send(JSON.stringify({ type: "connected", username }));
 
 
 
288
 
289
  ws.on("message", (raw) => {
290
  try {
291
  const msg = JSON.parse(raw);
292
- // Client heartbeat ping β€” reply with pong so client knows connection is live
293
  if (msg.type === "ping") ws.send(JSON.stringify({ type: "pong" }));
294
  } catch {}
295
  });
296
 
297
- ws.on("close", () => removeConnection(username, ws));
298
- ws.on("error", () => removeConnection(username, ws));
 
 
299
  });
300
 
301
  // ── Start ──────────────────────────────────────────────────────────────────
302
- const PORT = process.env.PORT || 7860;
303
- server.listen(PORT, () => console.log(`PixelBeacon running on :${PORT}`));
 
1
+ "use strict";
2
+ const http = require("http");
3
+ const fs = require("fs");
4
+ const fsp = require("fs/promises");
5
+ const path = require("path");
6
  const crypto = require("crypto");
7
  const { WebSocketServer } = require("ws");
8
+ const { URL } = require("url");
9
 
10
+ // ── Directories ────────────────────────────────────────────────────────────
11
+ const ROOT = __dirname;
12
+ const PUBLIC_DIR = path.join(ROOT, "public");
13
+ const DATA_DIR = path.join(ROOT, "data");
14
  const ACCOUNTS_DIR = path.join(DATA_DIR, "accounts");
15
+ const PIXELS_DIR = path.join(DATA_DIR, "pixels");
 
16
 
17
+ for (const d of [DATA_DIR, ACCOUNTS_DIR, PIXELS_DIR, PUBLIC_DIR]) {
18
+ fs.mkdirSync(d, { recursive: true });
19
+ }
20
+
21
+ // ── Transparent 1Γ—1 PNG ───────────────────────────────────────────────────
22
+ const PIXEL_PNG = Buffer.from(
23
  "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==",
24
  "base64"
25
  );
26
 
27
+ // ── MIME types ─────────────────────────────────────────────────────────────
28
+ const MIME = {
29
+ ".html": "text/html; charset=utf-8",
30
+ ".css": "text/css; charset=utf-8",
31
+ ".js": "application/javascript; charset=utf-8",
32
+ ".svg": "image/svg+xml",
33
+ ".ico": "image/x-icon",
34
+ ".png": "image/png",
35
+ ".json": "application/json",
36
+ };
37
+
38
+ // ── Pure helpers ───────────────────────────────────────────────────────────
39
+ const uid = () => crypto.randomBytes(16).toString("hex");
40
+ const hash = (pw) => crypto.createHash("sha256").update(pw + "pb-salt").digest("hex");
41
+
42
  function readJSON(file) {
43
+ try { return JSON.parse(fs.readFileSync(file, "utf8")); }
44
+ catch { return null; }
45
+ }
46
+ function writeJSON(file, data) {
47
+ fs.writeFileSync(file + ".tmp", JSON.stringify(data, null, 2));
48
+ fs.renameSync(file + ".tmp", file); // atomic write
49
  }
 
50
 
51
  // ── Account helpers ────────────────────────────────────────────────────────
52
+ const accountFile = (u) => path.join(ACCOUNTS_DIR, `${u}.json`);
53
+ const getAccount = (u) => readJSON(accountFile(u));
54
+ const saveAccount = (u, d) => writeJSON(accountFile(u), d);
55
 
56
  function registerUser(username, password) {
57
+ if (!/^[a-zA-Z0-9_]{3,32}$/.test(username))
58
+ return { error: "Username must be 3–32 characters (letters, numbers, underscore)" };
59
+ if (!password || password.length < 6)
60
+ return { error: "Password must be at least 6 characters" };
61
+ if (fs.existsSync(accountFile(username)))
62
+ return { error: "Username already taken" };
63
+ saveAccount(username, { username, passwordHash: hash(password), tokens: [], pixels: [] });
64
  return { ok: true };
65
  }
66
 
67
  function loginUser(username, password) {
68
  const acc = getAccount(username);
69
+ if (!acc || acc.passwordHash !== hash(password))
70
+ return { error: "Invalid username or password" };
71
  const token = uid();
72
+ // Cap active sessions at 20 to prevent unbounded growth
73
+ acc.tokens = [...acc.tokens.slice(-19), token];
74
  saveAccount(username, acc);
75
  return { token, username };
76
  }
 
83
  }
84
 
85
  function verifyToken(username, token) {
86
+ if (!username || !token) return false;
87
  const acc = getAccount(username);
88
+ return !!(acc && acc.tokens.includes(token));
 
 
 
 
 
 
 
 
 
89
  }
90
 
91
  // ── Pixel helpers ──────────────────────────────────────────────────────────
92
+ const pixelFile = (id) => path.join(PIXELS_DIR, `${id}.json`);
93
+ const getPixel = (id) => readJSON(pixelFile(id));
94
 
95
  function createPixel(username, label) {
96
  const acc = getAccount(username);
97
  if (!acc) return { error: "Account not found" };
98
+ const id = uid();
99
+ writeJSON(pixelFile(id), {
100
+ id,
101
  owner: username,
102
+ label: (label || "Untitled").slice(0, 80),
103
  createdAt: Date.now(),
104
  opens: [],
105
+ });
106
+ acc.pixels.push(id);
 
107
  saveAccount(username, acc);
108
+ return { pixelId: id };
109
  }
110
 
111
  function deletePixel(username, pixelId) {
112
  const pixel = getPixel(pixelId);
113
  if (!pixel || pixel.owner !== username) return { error: "Not found" };
114
+ try { fs.unlinkSync(pixelFile(pixelId)); } catch {}
115
  const acc = getAccount(username);
116
+ if (acc) {
117
+ acc.pixels = acc.pixels.filter((p) => p !== pixelId);
118
+ saveAccount(username, acc);
119
+ }
120
  return { ok: true };
121
  }
122
 
123
  function getUserPixels(username) {
124
  const acc = getAccount(username);
125
  if (!acc) return [];
126
+ return acc.pixels.map(getPixel).filter(Boolean).sort((a, b) => b.createdAt - a.createdAt);
 
 
 
127
  }
128
 
129
+ // ── WebSocket registry ─────────────────────────────────────────────────────
130
+ // username β†’ Set<ws>
131
  const connections = new Map();
132
 
133
+ function addConn(username, ws) {
134
  if (!connections.has(username)) connections.set(username, new Set());
135
+ connections.get(username).add(ws);
136
  }
137
 
138
+ function removeConn(username, ws) {
139
+ const s = connections.get(username);
140
+ if (!s) return;
141
+ s.delete(ws);
142
+ if (s.size === 0) connections.delete(username);
 
 
143
  }
144
 
145
+ function broadcast(username, payload) {
146
+ const s = connections.get(username);
147
+ if (!s) return;
148
  const msg = JSON.stringify(payload);
149
+ for (const ws of s) {
150
+ if (ws.readyState === 1 /* OPEN */) ws.send(msg);
151
  }
152
  }
153
 
154
+ // ── Auth header parser ─────────────────────────────────────────────────────
155
+ // Format: "Bearer username:token" β€” username is alphanumeric so first : is the split
156
+ function parseAuth(header) {
157
+ const raw = (header || "").replace(/^Bearer\s+/, "");
158
+ const idx = raw.indexOf(":");
159
+ if (idx < 1) return [null, null];
160
+ return [raw.slice(0, idx), raw.slice(idx + 1)];
161
+ }
162
 
163
+ // ── HTTP request handler ───────────────────────────────────────────────────
164
  const server = http.createServer((req, res) => {
165
+ // Parse URL safely
166
+ let parsed;
167
+ try { parsed = new URL(req.url, "http://x"); }
168
+ catch { res.writeHead(400); return res.end("Bad request"); }
169
  const pathname = parsed.pathname;
170
 
171
+ // CORS pre-flight
172
  res.setHeader("Access-Control-Allow-Origin", "*");
173
  res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
174
  if (req.method === "OPTIONS") { res.writeHead(204); return res.end(); }
175
 
176
+ // ── 1. Pixel tracker ────────────────────────────────────────────────────
177
  if (pathname.startsWith("/px/")) {
178
+ const pixelId = pathname.slice(4).replace(/\//g, "");
179
  const pixel = getPixel(pixelId);
180
  if (!pixel) { res.writeHead(404); return res.end(); }
181
 
182
+ const ip = (req.headers["x-forwarded-for"] || req.socket.remoteAddress || "").split(",")[0].trim();
183
+ const ua = req.headers["user-agent"] || "";
184
+ const entry = { ts: Date.now(), ip, ua };
185
+ pixel.opens.push(entry);
186
  writeJSON(pixelFile(pixelId), pixel);
187
 
188
+ broadcast(pixel.owner, { type: "open", pixelId: pixel.id, label: pixel.label, ...entry });
 
 
 
 
 
 
 
 
189
 
190
  res.writeHead(200, {
191
+ "Content-Type": "image/png",
192
+ "Content-Length": PIXEL_PNG.length,
193
+ "Cache-Control": "no-store, no-cache, must-revalidate, proxy-revalidate",
194
+ "Pragma": "no-cache",
195
+ "Expires": "0",
196
  });
197
+ return res.end(PIXEL_PNG);
198
  }
199
 
200
+ // ── 2. REST API ─────────────────────────────────────────────────────────
201
  if (pathname.startsWith("/api/")) {
202
  let body = "";
203
+ req.on("data", (chunk) => { if (body.length < 65536) body += chunk; });
204
  req.on("end", () => {
205
  let data = {};
206
+ try { if (body) data = JSON.parse(body); } catch {
207
+ res.writeHead(400, { "Content-Type": "application/json" });
208
+ return res.end(JSON.stringify({ error: "Invalid JSON" }));
209
+ }
210
 
211
+ const [authUser, authToken] = parseAuth(req.headers["authorization"]);
 
 
212
 
213
  function authed() {
214
+ if (!verifyToken(authUser, authToken)) {
215
  res.writeHead(401, { "Content-Type": "application/json" });
216
  res.end(JSON.stringify({ error: "Unauthorized" }));
217
  return false;
 
220
  }
221
 
222
  function json(status, obj) {
223
+ const body = JSON.stringify(obj);
224
+ res.writeHead(status, {
225
+ "Content-Type": "application/json",
226
+ "Content-Length": Buffer.byteLength(body),
227
+ });
228
+ res.end(body);
229
  }
230
 
231
  const route = `${req.method} ${pathname}`;
232
 
233
+ if (route === "POST /api/register") return json(200, registerUser(data.username, data.password));
234
+ if (route === "POST /api/login") return json(200, loginUser(data.username, data.password));
 
 
 
 
235
  if (route === "POST /api/logout") {
236
  if (!authed()) return;
237
  logoutUser(authUser, authToken);
 
249
  if (!authed()) return;
250
  return json(200, deletePixel(authUser, data.pixelId));
251
  }
 
252
  return json(404, { error: "Not found" });
253
  });
254
  return;
255
  }
256
 
257
+ // ── 3. Service worker (root scope, no-cache) ────────────────────────────
258
  if (pathname === "/sw.js") {
259
+ const swPath = path.join(ROOT, "sw.js");
260
+ if (!fs.existsSync(swPath)) { res.writeHead(404); return res.end(); }
261
+ const content = fs.readFileSync(swPath);
262
+ res.writeHead(200, {
263
+ "Content-Type": "application/javascript; charset=utf-8",
264
+ "Content-Length": content.length,
265
+ "Service-Worker-Allowed": "/",
266
+ "Cache-Control": "no-store",
267
+ });
268
+ return res.end(content);
269
+ }
270
+
271
+ // ── 4. Static files from /public (path-traversal safe) ──────────────────
272
+ {
273
+ const rel = pathname === "/" ? "/index.html" : pathname;
274
+ const resolved = path.resolve(PUBLIC_DIR, "." + rel);
275
+
276
+ // Reject anything that escapes /public
277
+ if (!resolved.startsWith(PUBLIC_DIR + path.sep) && resolved !== PUBLIC_DIR) {
278
+ res.writeHead(403); return res.end("Forbidden");
279
+ }
280
+
281
+ const ext = path.extname(resolved).toLowerCase();
282
+ if (MIME[ext] && fs.existsSync(resolved)) {
283
+ const content = fs.readFileSync(resolved);
284
  res.writeHead(200, {
285
+ "Content-Type": MIME[ext],
286
+ "Content-Length": content.length,
287
+ "Cache-Control": "public, max-age=300",
288
  });
289
+ return res.end(content);
290
  }
291
  }
292
 
293
+ res.writeHead(404); res.end("Not found");
 
 
 
 
 
 
 
 
 
 
294
  });
295
 
296
+ // ── WebSocket server ───────────────────────────────────────────────────────
297
  const wss = new WebSocketServer({ server });
298
 
299
  wss.on("connection", (ws, req) => {
300
+ let parsed;
301
+ try { parsed = new URL(req.url, "http://x"); }
302
+ catch { return ws.close(4000, "Bad URL"); }
303
+
304
+ // token param: "username:rawtoken"
305
+ const rawToken = parsed.searchParams.get("token") || "";
306
+ const colonIdx = rawToken.indexOf(":");
307
+ const wsUsername = colonIdx > 0 ? rawToken.slice(0, colonIdx) : "";
308
+ const wsToken = colonIdx > 0 ? rawToken.slice(colonIdx + 1) : "";
309
+
310
+ if (!verifyToken(wsUsername, wsToken)) {
311
  ws.close(4001, "Unauthorized");
312
  return;
313
  }
 
314
 
315
+ addConn(wsUsername, ws);
316
+ ws.send(JSON.stringify({ type: "connected", username: wsUsername }));
317
 
318
+ // Server-side ping to detect dead connections (every 30s)
319
+ const keepalive = setInterval(() => {
320
+ if (ws.readyState === 1) ws.ping();
321
+ else { clearInterval(keepalive); }
322
+ }, 30000);
323
 
324
  ws.on("message", (raw) => {
325
  try {
326
  const msg = JSON.parse(raw);
327
+ // Client heartbeat β€” reply so client can verify the connection is live
328
  if (msg.type === "ping") ws.send(JSON.stringify({ type: "pong" }));
329
  } catch {}
330
  });
331
 
332
+ ws.on("pong", () => {}); // keep-alive acknowledged
333
+
334
+ ws.on("close", () => { clearInterval(keepalive); removeConn(wsUsername, ws); });
335
+ ws.on("error", (e) => { console.error("[WS] error", e.message); clearInterval(keepalive); removeConn(wsUsername, ws); });
336
  });
337
 
338
  // ── Start ──────────────────────────────────────────────────────────────────
339
+ const PORT = parseInt(process.env.PORT || "7860", 10);
340
+ server.listen(PORT, "0.0.0.0", () => console.log(`PixelBeacon listening on :${PORT}`));