"use strict"; 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 (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}`); });