pixelbeacon / server.js
incognitolm's picture
Update server.js
a5cbeab verified
"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<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}`);
});