Spaces:
Sleeping
Sleeping
| ; | |
| const http = require("http"); | |
| const fs = require("fs"); | |
| const path = require("path"); | |
| const crypto = require("crypto"); | |
| const webpush = require("web-push"); | |
| const { WebSocketServer } = require("ws"); | |
| const { URL } = require("url"); | |
| // ββ Directories ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const ROOT = __dirname; | |
| const PUBLIC_DIR = path.join(ROOT, "public"); | |
| const DATA_DIR = process.env.DATA_DIR || "/data"; | |
| const ACCOUNTS_FILE = path.join(DATA_DIR, "accounts.json"); | |
| const VAPID_FILE = path.join(DATA_DIR, "vapid.json"); | |
| // Legacy paths used only for migration, never for runtime storage. | |
| const LEGACY_ACCOUNTS_DIR = path.join(DATA_DIR, "accounts"); | |
| const LEGACY_PIXELS_DIR = path.join(DATA_DIR, "pixels"); | |
| fs.mkdirSync(PUBLIC_DIR, { recursive: true }); | |
| if (!fs.existsSync(DATA_DIR)) { | |
| console.error(`ERROR: storage directory not mounted: ${DATA_DIR}`); | |
| process.exit(1); | |
| } | |
| // ββ Storage encryption (AES-256-GCM) βββββββββββββββββββββββββββββββββββββββ | |
| const ENC_KEY_B64 = process.env.DATA_ENCRYPTION_KEY; | |
| if (!ENC_KEY_B64) { | |
| console.error("Missing DATA_ENCRYPTION_KEY environment secret"); | |
| process.exit(1); | |
| } | |
| const ENC_KEY = Buffer.from(ENC_KEY_B64, "base64"); | |
| if (ENC_KEY.length !== 32) { | |
| console.error("DATA_ENCRYPTION_KEY must be 32 bytes (base64 encoded)"); | |
| process.exit(1); | |
| } | |
| function encrypt(data) { | |
| const iv = crypto.randomBytes(12); | |
| const cipher = crypto.createCipheriv("aes-256-gcm", ENC_KEY, iv); | |
| const enc = Buffer.concat([ | |
| cipher.update(JSON.stringify(data), "utf8"), | |
| cipher.final(), | |
| ]); | |
| const tag = cipher.getAuthTag(); | |
| return Buffer.concat([iv, tag, enc]).toString("base64"); | |
| } | |
| function decrypt(str) { | |
| const buf = Buffer.from(str, "base64"); | |
| const iv = buf.slice(0, 12); | |
| const tag = buf.slice(12, 28); | |
| const enc = buf.slice(28); | |
| const decipher = crypto.createDecipheriv("aes-256-gcm", ENC_KEY, iv); | |
| decipher.setAuthTag(tag); | |
| const dec = Buffer.concat([decipher.update(enc), decipher.final()]); | |
| return JSON.parse(dec.toString("utf8")); | |
| } | |
| function readEncryptedJSON(file, fallback = null) { | |
| try { | |
| return decrypt(fs.readFileSync(file, "utf8")); | |
| } catch { | |
| return fallback; | |
| } | |
| } | |
| function writeEncryptedJSON(file, data) { | |
| const tmp = `${file}.tmp`; | |
| fs.writeFileSync(tmp, encrypt(data)); | |
| fs.renameSync(tmp, file); | |
| } | |
| // ββ Helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const uid = () => crypto.randomBytes(16).toString("hex"); | |
| function browserFromUA(ua) { | |
| if (/Edg\//i.test(ua)) return "Edge"; | |
| if (/Firefox\//i.test(ua)) return "Firefox"; | |
| if (/Chrome\//i.test(ua) && !/Edg\//i.test(ua)) return "Chrome"; | |
| if (/Safari\//i.test(ua) && !/Chrome\//i.test(ua)) return "Safari"; | |
| return "Other"; | |
| } | |
| function hashPassword(password) { | |
| const salt = crypto.randomBytes(16).toString("base64"); | |
| const derived = crypto.scryptSync(password, salt, 32).toString("base64"); | |
| return `scrypt$${salt}$${derived}`; | |
| } | |
| function verifyPassword(password, stored) { | |
| if (typeof stored !== "string") return false; | |
| if (stored.startsWith("scrypt$")) { | |
| const parts = stored.split("$"); | |
| if (parts.length !== 3) return false; | |
| const salt = parts[1]; | |
| const expected = parts[2]; | |
| const actual = crypto.scryptSync(password, salt, 32).toString("base64"); | |
| return crypto.timingSafeEqual(Buffer.from(actual), Buffer.from(expected)); | |
| } | |
| // Legacy fallback for old SHA-256 scheme. | |
| const legacy = crypto | |
| .createHash("sha256") | |
| .update(password + "pb-salt") | |
| .digest("hex"); | |
| return stored === legacy; | |
| } | |
| function verifyBucketMount() { | |
| try { | |
| const stat = fs.statSync(DATA_DIR); | |
| if (!stat.isDirectory()) throw new Error("storage path is not a directory"); | |
| const testFile = path.join(DATA_DIR, ".mount-test"); | |
| fs.writeFileSync(testFile, "ok"); | |
| fs.unlinkSync(testFile); | |
| console.log("[Storage] Bucket mount OK:", DATA_DIR); | |
| } catch (err) { | |
| console.error("[Storage] Bucket mount FAILED:", err.message); | |
| process.exit(1); | |
| } | |
| } | |
| // ββ DB access ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function loadDB() { | |
| return readEncryptedJSON(ACCOUNTS_FILE, { accounts: {} }); | |
| } | |
| function saveDB(db) { | |
| writeEncryptedJSON(ACCOUNTS_FILE, db); | |
| } | |
| function getAccount(username) { | |
| const db = loadDB(); | |
| return db.accounts?.[username] || null; | |
| } | |
| function saveAccount(username, account) { | |
| const db = loadDB(); | |
| if (!db.accounts) db.accounts = {}; | |
| db.accounts[username] = account; | |
| saveDB(db); | |
| } | |
| function findPixelById(db, pixelId) { | |
| for (const [username, acc] of Object.entries(db.accounts || {})) { | |
| const pixels = Array.isArray(acc.pixels) ? acc.pixels : []; | |
| const idx = pixels.findIndex((p) => p.id === pixelId); | |
| if (idx >= 0) { | |
| return { username, account: acc, pixel: pixels[idx], index: idx }; | |
| } | |
| } | |
| return null; | |
| } | |
| // ββ Migration from legacy layouts ββββββββββββββββββββββββββββββββββββββββββ | |
| function migrateLegacyStorage() { | |
| const db = loadDB(); | |
| let changed = false; | |
| // Migrate old per-user account files if they exist. | |
| if (fs.existsSync(LEGACY_ACCOUNTS_DIR)) { | |
| try { | |
| for (const file of fs.readdirSync(LEGACY_ACCOUNTS_DIR)) { | |
| if (!file.endsWith(".json")) continue; | |
| const username = path.basename(file, ".json"); | |
| if (db.accounts?.[username]) continue; | |
| const acc = readEncryptedJSON(path.join(LEGACY_ACCOUNTS_DIR, file), null); | |
| if (!acc) continue; | |
| if (typeof acc.username !== "string") acc.username = username; | |
| if (!Array.isArray(acc.tokens)) acc.tokens = []; | |
| if (!Array.isArray(acc.pushSubscriptions)) acc.pushSubscriptions = []; | |
| if (!Array.isArray(acc.pixels)) acc.pixels = []; | |
| db.accounts[username] = acc; | |
| changed = true; | |
| } | |
| } catch (err) { | |
| console.error("[MIGRATION] Failed to read legacy account files:", err.message); | |
| } | |
| } | |
| // Migrate old per-pixel files into the owning account's pixels array. | |
| if (fs.existsSync(LEGACY_PIXELS_DIR)) { | |
| try { | |
| for (const file of fs.readdirSync(LEGACY_PIXELS_DIR)) { | |
| if (!file.endsWith(".json")) continue; | |
| const pixel = readEncryptedJSON(path.join(LEGACY_PIXELS_DIR, file), null); | |
| if (!pixel || !pixel.id || !pixel.owner) continue; | |
| const owner = pixel.owner; | |
| const acc = db.accounts?.[owner]; | |
| if (!acc) continue; | |
| if (!Array.isArray(acc.pixels)) acc.pixels = []; | |
| const exists = acc.pixels.some((p) => p.id === pixel.id); | |
| if (!exists) { | |
| acc.pixels.push({ | |
| id: pixel.id, | |
| label: pixel.label || "Untitled", | |
| createdAt: pixel.createdAt || Date.now(), | |
| opens: Array.isArray(pixel.opens) ? pixel.opens : [], | |
| }); | |
| db.accounts[owner] = acc; | |
| changed = true; | |
| } | |
| } | |
| } catch (err) { | |
| console.error("[MIGRATION] Failed to read legacy pixel files:", err.message); | |
| } | |
| } | |
| if (changed) { | |
| saveDB(db); | |
| console.log("[MIGRATION] Legacy storage migrated into encrypted accounts.json"); | |
| } | |
| // Best-effort cleanup of old runtime files. | |
| try { | |
| if (fs.existsSync(LEGACY_ACCOUNTS_DIR)) { | |
| for (const file of fs.readdirSync(LEGACY_ACCOUNTS_DIR)) { | |
| if (file.endsWith(".json")) fs.unlinkSync(path.join(LEGACY_ACCOUNTS_DIR, file)); | |
| } | |
| } | |
| } catch (err) { | |
| console.error("[MIGRATION] Could not clean legacy account files:", err.message); | |
| } | |
| try { | |
| if (fs.existsSync(LEGACY_PIXELS_DIR)) { | |
| for (const file of fs.readdirSync(LEGACY_PIXELS_DIR)) { | |
| if (file.endsWith(".json")) fs.unlinkSync(path.join(LEGACY_PIXELS_DIR, file)); | |
| } | |
| } | |
| } catch (err) { | |
| console.error("[MIGRATION] Could not clean legacy pixel files:", err.message); | |
| } | |
| } | |
| // ββ VAPID setup (encrypted at rest) ββββββββββββββββββββββββββββββββββββββββ | |
| let VAPID_KEYS = readEncryptedJSON(VAPID_FILE, null); | |
| if (!VAPID_KEYS && fs.existsSync(VAPID_FILE)) { | |
| try { | |
| const legacy = JSON.parse(fs.readFileSync(VAPID_FILE, "utf8")); | |
| if (legacy && legacy.publicKey && legacy.privateKey) { | |
| VAPID_KEYS = legacy; | |
| writeEncryptedJSON(VAPID_FILE, VAPID_KEYS); | |
| console.log("[VAPID] Migrated plaintext vapid.json to encrypted storage"); | |
| } | |
| } catch {} | |
| } | |
| if (!VAPID_KEYS) { | |
| VAPID_KEYS = webpush.generateVAPIDKeys(); | |
| writeEncryptedJSON(VAPID_FILE, VAPID_KEYS); | |
| console.log("[VAPID] Generated new key pair"); | |
| } | |
| webpush.setVapidDetails( | |
| "mailto:admin@pixelbeacon.app", | |
| VAPID_KEYS.publicKey, | |
| VAPID_KEYS.privateKey | |
| ); | |
| // Run storage checks after helpers exist. | |
| verifyBucketMount(); | |
| migrateLegacyStorage(); | |
| // ββ Transparent 1Γ1 PNG βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const PIXEL_PNG = Buffer.from( | |
| "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4AWJiYGBgAAAAAP//XRcpzQAAAAZJREFUAwAADwADJDd96QAAAABJRU5ErkJggg==", | |
| "base64" | |
| ); | |
| // ββ MIME types βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const MIME = { | |
| ".html": "text/html; charset=utf-8", | |
| ".css": "text/css; charset=utf-8", | |
| ".js": "application/javascript; charset=utf-8", | |
| ".svg": "image/svg+xml", | |
| ".ico": "image/x-icon", | |
| ".png": "image/png", | |
| ".json": "application/json", | |
| }; | |
| // ββ Account helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function registerUser(username, password) { | |
| if (!/^[a-zA-Z0-9_]{3,32}$/.test(username)) { | |
| return { error: "Username must be 3β32 characters (letters, numbers, underscore)" }; | |
| } | |
| if (!password || password.length < 6) { | |
| return { error: "Password must be at least 6 characters" }; | |
| } | |
| const db = loadDB(); | |
| if (db.accounts?.[username]) return { error: "Username already taken" }; | |
| if (!db.accounts) db.accounts = {}; | |
| db.accounts[username] = { | |
| username, | |
| passwordHash: hashPassword(password), | |
| tokens: [], | |
| pixels: [], | |
| pushSubscriptions: [], | |
| }; | |
| saveDB(db); | |
| return { ok: true }; | |
| } | |
| function loginUser(username, password) { | |
| const db = loadDB(); | |
| const acc = db.accounts?.[username]; | |
| if (!acc || !verifyPassword(password, acc.passwordHash)) { | |
| return { error: "Invalid username or password" }; | |
| } | |
| const token = uid(); | |
| acc.tokens = Array.isArray(acc.tokens) ? acc.tokens : []; | |
| acc.tokens = [...acc.tokens.slice(-19), token]; | |
| db.accounts[username] = acc; | |
| saveDB(db); | |
| return { token, username }; | |
| } | |
| function logoutUser(username, token) { | |
| const db = loadDB(); | |
| const acc = db.accounts?.[username]; | |
| if (!acc) return; | |
| acc.tokens = (Array.isArray(acc.tokens) ? acc.tokens : []).filter((t) => t !== token); | |
| db.accounts[username] = acc; | |
| saveDB(db); | |
| } | |
| function verifyToken(username, token) { | |
| if (!username || !token) return false; | |
| const db = loadDB(); | |
| const acc = db.accounts?.[username]; | |
| return !!(acc && Array.isArray(acc.tokens) && acc.tokens.includes(token)); | |
| } | |
| // ββ Pixel helpers (stored inside accounts.json) ββββββββββββββββββββββββββββ | |
| function createPixel(username, label) { | |
| const db = loadDB(); | |
| const acc = db.accounts?.[username]; | |
| if (!acc) return { error: "Account not found" }; | |
| if (!Array.isArray(acc.pixels)) acc.pixels = []; | |
| const id = uid(); | |
| acc.pixels.push({ | |
| id, | |
| label: (label || "Untitled").slice(0, 80), | |
| createdAt: Date.now(), | |
| opens: [], | |
| }); | |
| db.accounts[username] = acc; | |
| saveDB(db); | |
| return { pixelId: id }; | |
| } | |
| function getUserPixels(username) { | |
| const acc = getAccount(username); | |
| if (!acc || !Array.isArray(acc.pixels)) return []; | |
| return [...acc.pixels].sort((a, b) => b.createdAt - a.createdAt); | |
| } | |
| function recordPixelOpen(pixelId, entry) { | |
| const db = loadDB(); | |
| const hit = findPixelById(db, pixelId); | |
| if (!hit) return null; | |
| if (!Array.isArray(hit.pixel.opens)) hit.pixel.opens = []; | |
| hit.pixel.opens.push(entry); | |
| hit.account.pixels[hit.index] = hit.pixel; | |
| db.accounts[hit.username] = hit.account; | |
| saveDB(db); | |
| return hit; | |
| } | |
| function deletePixel(username, pixelId) { | |
| const db = loadDB(); | |
| const acc = db.accounts?.[username]; | |
| if (!acc || !Array.isArray(acc.pixels)) return { error: "Not found" }; | |
| const before = acc.pixels.length; | |
| acc.pixels = acc.pixels.filter((p) => p.id !== pixelId); | |
| if (acc.pixels.length === before) return { error: "Pixel not found" }; | |
| db.accounts[username] = acc; | |
| saveDB(db); | |
| return { ok: true }; | |
| } | |
| function deleteOpen(username, pixelId, ts) { | |
| const db = loadDB(); | |
| const hit = findPixelById(db, pixelId); | |
| if (!hit || hit.username !== username) return { error: "Not found" }; | |
| const before = hit.pixel.opens.length; | |
| hit.pixel.opens = hit.pixel.opens.filter((o) => o.ts !== ts); | |
| if (hit.pixel.opens.length === before) return { error: "Open not found" }; | |
| hit.account.pixels[hit.index] = hit.pixel; | |
| db.accounts[username] = hit.account; | |
| saveDB(db); | |
| return { ok: true }; | |
| } | |
| function clearOpens(username, pixelId) { | |
| const db = loadDB(); | |
| const hit = findPixelById(db, pixelId); | |
| if (!hit || hit.username !== username) return { error: "Not found" }; | |
| hit.pixel.opens = []; | |
| hit.account.pixels[hit.index] = hit.pixel; | |
| db.accounts[username] = hit.account; | |
| saveDB(db); | |
| return { ok: true }; | |
| } | |
| // ββ Push subscription helpers ββββββββββββββββββββββββββββββββββββββββββββββ | |
| function savePushSubscription(username, subscription) { | |
| const db = loadDB(); | |
| const acc = db.accounts?.[username]; | |
| if (!acc) return { error: "Account not found" }; | |
| if (!Array.isArray(acc.pushSubscriptions)) acc.pushSubscriptions = []; | |
| const idx = acc.pushSubscriptions.findIndex((s) => s.endpoint === subscription.endpoint); | |
| if (idx >= 0) acc.pushSubscriptions[idx] = subscription; | |
| else acc.pushSubscriptions.push(subscription); | |
| db.accounts[username] = acc; | |
| saveDB(db); | |
| return { ok: true }; | |
| } | |
| function removePushSubscription(username, endpoint) { | |
| const db = loadDB(); | |
| const acc = db.accounts?.[username]; | |
| if (!acc) return; | |
| acc.pushSubscriptions = (Array.isArray(acc.pushSubscriptions) ? acc.pushSubscriptions : []) | |
| .filter((s) => s.endpoint !== endpoint); | |
| db.accounts[username] = acc; | |
| saveDB(db); | |
| } | |
| async function sendPushToUser(username, payload) { | |
| const acc = getAccount(username); | |
| if (!acc || !Array.isArray(acc.pushSubscriptions) || !acc.pushSubscriptions.length) return; | |
| const msg = JSON.stringify(payload); | |
| const dead = []; | |
| await Promise.all( | |
| acc.pushSubscriptions.map(async (sub) => { | |
| try { | |
| await webpush.sendNotification(sub, msg); | |
| } catch (err) { | |
| if (err.statusCode === 410 || err.statusCode === 404) dead.push(sub.endpoint); | |
| else { | |
| console.error( | |
| "[Push] sendNotification error β status:", | |
| err.statusCode, | |
| "body:", | |
| err.body, | |
| "message:", | |
| err.message | |
| ); | |
| } | |
| } | |
| }) | |
| ); | |
| if (dead.length) { | |
| acc.pushSubscriptions = acc.pushSubscriptions.filter((s) => !dead.includes(s.endpoint)); | |
| saveAccount(username, acc); | |
| } | |
| } | |
| // ββ WebSocket registry βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // username β Set<ws> (each ws has a .deviceId property attached) | |
| const connections = new Map(); | |
| function addConn(username, ws) { | |
| if (!connections.has(username)) connections.set(username, new Set()); | |
| connections.get(username).add(ws); | |
| } | |
| function removeConn(username, ws) { | |
| const s = connections.get(username); | |
| if (!s) return; | |
| s.delete(ws); | |
| if (s.size === 0) connections.delete(username); | |
| } | |
| function liveDeviceIds(username) { | |
| const s = connections.get(username); | |
| if (!s) return new Set(); | |
| const ids = new Set(); | |
| for (const ws of s) { | |
| if (ws.readyState === 1 && ws.deviceId) ids.add(ws.deviceId); | |
| } | |
| return ids; | |
| } | |
| function broadcast(username, payload) { | |
| const s = connections.get(username); | |
| if (!s) return; | |
| const msg = JSON.stringify(payload); | |
| for (const ws of s) { | |
| if (ws.readyState === 1) ws.send(msg); | |
| } | |
| } | |
| // ββ Auth header parser βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function parseAuth(header) { | |
| const raw = (header || "").trim(); | |
| const idx = raw.indexOf(":"); | |
| if (idx < 1) return [null, null]; | |
| return [raw.slice(0, idx), raw.slice(idx + 1)]; | |
| } | |
| // ββ HTTP request handler βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const server = http.createServer((req, res) => { | |
| let parsed; | |
| try { | |
| parsed = new URL(req.url, "http://x"); | |
| } catch { | |
| res.writeHead(400); | |
| return res.end("Bad request"); | |
| } | |
| const pathname = parsed.pathname; | |
| res.setHeader("Access-Control-Allow-Origin", "*"); | |
| res.setHeader("Access-Control-Allow-Headers", "Content-Type, X-Token"); | |
| if (req.method === "OPTIONS") { | |
| res.writeHead(204); | |
| return res.end(); | |
| } | |
| // ββ 1. Pixel tracker ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| if (pathname.startsWith("/px/")) { | |
| const pixelId = pathname.slice(4).replace(/\//g, ""); | |
| const ip = (req.headers["x-forwarded-for"] || req.socket.remoteAddress || "") | |
| .split(",")[0] | |
| .trim(); | |
| const ua = req.headers["user-agent"] || ""; | |
| const entry = { | |
| ts: Date.now(), | |
| ip, | |
| browser: browserFromUA(ua), | |
| }; | |
| const hit = recordPixelOpen(pixelId, entry); | |
| if (!hit) { | |
| res.writeHead(404); | |
| return res.end(); | |
| } | |
| broadcast(hit.username, { | |
| type: "open", | |
| pixelId, | |
| label: hit.pixel.label, | |
| ...entry, | |
| }); | |
| sendPushToUser(hit.username, { | |
| type: "open", | |
| pixelId, | |
| label: hit.pixel.label, | |
| ...entry, | |
| }).catch((e) => console.error("[Push] background send failed:", e.message)); | |
| res.writeHead(200, { | |
| "Content-Type": "image/png", | |
| "Content-Length": PIXEL_PNG.length, | |
| "Cache-Control": "no-store, no-cache, must-revalidate, proxy-revalidate", | |
| "Pragma": "no-cache", | |
| "Expires": "0", | |
| }); | |
| return res.end(PIXEL_PNG); | |
| } | |
| // ββ 2. REST API βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| if (pathname.startsWith("/api/")) { | |
| let body = ""; | |
| req.on("data", (chunk) => { | |
| if (body.length < 65536) body += chunk; | |
| }); | |
| req.on("end", () => { | |
| let data = {}; | |
| try { | |
| if (body) data = JSON.parse(body); | |
| } catch { | |
| res.writeHead(400, { "Content-Type": "application/json" }); | |
| return res.end(JSON.stringify({ error: "Invalid JSON" })); | |
| } | |
| const [authUser, authToken] = parseAuth(req.headers["x-token"]); | |
| function authed() { | |
| if (!verifyToken(authUser, authToken)) { | |
| res.writeHead(401, { "Content-Type": "application/json" }); | |
| res.end(JSON.stringify({ error: "Unauthorized" })); | |
| return false; | |
| } | |
| return true; | |
| } | |
| function json(status, obj) { | |
| const body = JSON.stringify(obj); | |
| res.writeHead(status, { | |
| "Content-Type": "application/json", | |
| "Content-Length": Buffer.byteLength(body), | |
| }); | |
| res.end(body); | |
| } | |
| const route = `${req.method} ${pathname}`; | |
| if (route === "POST /api/register") return json(200, registerUser(data.username, data.password)); | |
| if (route === "POST /api/login") return json(200, loginUser(data.username, data.password)); | |
| if (route === "POST /api/logout") { | |
| if (!authed()) return; | |
| logoutUser(authUser, authToken); | |
| return json(200, { ok: true }); | |
| } | |
| if (route === "GET /api/pixels") { | |
| if (!authed()) return; | |
| return json(200, { pixels: getUserPixels(authUser) }); | |
| } | |
| if (route === "POST /api/pixels") { | |
| if (!authed()) return; | |
| return json(200, createPixel(authUser, data.label)); | |
| } | |
| if (route === "DELETE /api/pixels") { | |
| if (!authed()) return; | |
| return json(200, deletePixel(authUser, data.pixelId)); | |
| } | |
| if (route === "DELETE /api/opens") { | |
| if (!authed()) return; | |
| if (!data.pixelId || data.ts == null) return json(400, { error: "Missing pixelId or ts" }); | |
| return json(200, deleteOpen(authUser, data.pixelId, data.ts)); | |
| } | |
| if (route === "DELETE /api/opens/all") { | |
| if (!authed()) return; | |
| if (!data.pixelId) return json(400, { error: "Missing pixelId" }); | |
| return json(200, clearOpens(authUser, data.pixelId)); | |
| } | |
| if (route === "GET /api/vapid-public-key") { | |
| return json(200, { publicKey: VAPID_KEYS.publicKey }); | |
| } | |
| if (route === "POST /api/push-subscribe") { | |
| if (!authed()) return; | |
| if (!data.endpoint || !data.keys) return json(400, { error: "Invalid subscription" }); | |
| return json(200, savePushSubscription(authUser, data)); | |
| } | |
| if (route === "POST /api/push-unsubscribe") { | |
| if (!authed()) return; | |
| if (!data.endpoint) return json(400, { error: "Missing endpoint" }); | |
| removePushSubscription(authUser, data.endpoint); | |
| return json(200, { ok: true }); | |
| } | |
| return json(404, { error: "Not found" }); | |
| }); | |
| return; | |
| } | |
| // ββ 3. Service worker ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| if (pathname === "/sw.js") { | |
| const swPath = path.join(ROOT, "sw.js"); | |
| if (!fs.existsSync(swPath)) { | |
| res.writeHead(404); | |
| return res.end(); | |
| } | |
| const content = fs.readFileSync(swPath); | |
| res.writeHead(200, { | |
| "Content-Type": "application/javascript; charset=utf-8", | |
| "Content-Length": content.length, | |
| "Service-Worker-Allowed": "/", | |
| "Cache-Control": "no-store", | |
| }); | |
| return res.end(content); | |
| } | |
| // ββ 4. Static files from /public ββββββββββββββββββββββββββββββββββββββββ | |
| { | |
| const rel = pathname === "/" ? "/index.html" : pathname; | |
| const resolved = path.resolve(PUBLIC_DIR, "." + rel); | |
| if (!resolved.startsWith(PUBLIC_DIR + path.sep) && resolved !== PUBLIC_DIR) { | |
| res.writeHead(403); | |
| return res.end("Forbidden"); | |
| } | |
| const ext = path.extname(resolved).toLowerCase(); | |
| if (MIME[ext] && fs.existsSync(resolved)) { | |
| const content = fs.readFileSync(resolved); | |
| res.writeHead(200, { | |
| "Content-Type": MIME[ext], | |
| "Content-Length": content.length, | |
| "Cache-Control": "public, max-age=300", | |
| }); | |
| return res.end(content); | |
| } | |
| } | |
| res.writeHead(404); | |
| res.end("Not found"); | |
| }); | |
| // ββ WebSocket server βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const wss = new WebSocketServer({ server }); | |
| wss.on("connection", (ws, req) => { | |
| let parsed; | |
| try { | |
| parsed = new URL(req.url, "http://x"); | |
| } catch { | |
| return ws.close(4000, "Bad URL"); | |
| } | |
| const rawToken = parsed.searchParams.get("token") || ""; | |
| const colonIdx = rawToken.indexOf(":"); | |
| const wsUsername = colonIdx > 0 ? rawToken.slice(0, colonIdx) : ""; | |
| const wsToken = colonIdx > 0 ? rawToken.slice(colonIdx + 1) : ""; | |
| if (!verifyToken(wsUsername, wsToken)) { | |
| ws.close(4001, "Unauthorized"); | |
| return; | |
| } | |
| ws.deviceId = parsed.searchParams.get("deviceId") || ""; | |
| addConn(wsUsername, ws); | |
| ws.send(JSON.stringify({ type: "connected", username: wsUsername })); | |
| const keepalive = setInterval(() => { | |
| if (ws.readyState === 1) ws.ping(); | |
| else clearInterval(keepalive); | |
| }, 30000); | |
| ws.on("message", (raw) => { | |
| try { | |
| const msg = JSON.parse(raw); | |
| if (msg.type === "ping") ws.send(JSON.stringify({ type: "pong" })); | |
| } catch {} | |
| }); | |
| ws.on("pong", () => {}); | |
| ws.on("close", () => { | |
| clearInterval(keepalive); | |
| removeConn(wsUsername, ws); | |
| }); | |
| ws.on("error", (e) => { | |
| console.error("[WS] error", e.message); | |
| clearInterval(keepalive); | |
| removeConn(wsUsername, ws); | |
| }); | |
| }); | |
| // ββ Start ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const PORT = parseInt(process.env.PORT || "7860", 10); | |
| server.listen(PORT, "0.0.0.0", () => { | |
| console.log(`PixelBeacon listening on :${PORT}`); | |
| console.log(`Data directory: ${DATA_DIR}`); | |
| }); |