require('dotenv').config(); const crypto = require('crypto'); const express = require('express'); const { createServer } = require('http'); const { Server } = require('socket.io'); const EMIT_SECRET = process.env.EMIT_SECRET || ''; if (!EMIT_SECRET) { // Without a secret anyone could call /emit and sign socket tokens. console.error('EMIT_SECRET is not set — refusing to start.'); process.exit(1); } // Socket tokens are signed with a key derived from EMIT_SECRET, so a token can // never double as the /emit secret. Must match socket_token() in include_ending.php. const SOCKET_KEY = crypto.createHmac('sha256', EMIT_SECRET).update('socket-token').digest(); function safeEqual(a, b) { const x = Buffer.from(String(a)); const y = Buffer.from(String(b)); return x.length === y.length && crypto.timingSafeEqual(x, y); } // Token = base64url(JSON {c, u, r, exp}) + "." + base64url(HMAC-SHA256(payload)). // Returns the claims, or null when the token is missing, forged or expired. function verifySocketToken(token) { if (typeof token !== 'string' || token.indexOf('.') < 1) return null; const [payload, sig] = token.split('.', 2); const expected = crypto.createHmac('sha256', SOCKET_KEY).update(payload).digest('base64url'); if (!safeEqual(sig, expected)) return null; let claims; try { claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')); } catch (e) { return null; } if (!claims || !Number.isInteger(claims.c) || claims.c <= 0) return null; if (!Number.isInteger(claims.exp) || claims.exp < Math.floor(Date.now() / 1000)) return null; return claims; } const app = express(); const httpServer = createServer(app); const io = new Server(httpServer, { cors: { origin: process.env.ALLOWED_ORIGIN || 'http://localhost' } }); app.use(express.json()); // ── /emit ───────────────────────────────────────────────────────────────────── // PHP calls this after any significant action. // Body: { event, data, company_id } // app.post('/emit', (req, res) => { const secret = req.headers['x-emit-secret'] || ''; if (!safeEqual(secret, EMIT_SECRET)) { return res.status(403).json({ ok: false, message: 'Forbidden' }); } const { event, data, company_id, target, user_id } = req.body; if (!event || !company_id) { return res.status(400).json({ ok: false, message: 'event and company_id required' }); } if (target === 'user' && user_id) { // Notify the acting user on all their tabs + all admins (excluding the acting user to avoid duplicates) io.to(`user_${user_id}`).emit(event, data); io.to(`admin_${company_id}`).except(`user_${user_id}`).emit(event, data); console.log(`[emit] company=${company_id} user=${user_id} event=${event}`, data); } else if (target === 'admin') { // Admins and owners only io.to(`admin_${company_id}`).emit(event, data); console.log(`[emit] company=${company_id} admin-only event=${event}`, data); } else { // Company-wide — stock events, GL events, scheduler alerts io.to(`company_${company_id}`).emit(event, data); console.log(`[emit] company=${company_id} event=${event}`, data); } res.json({ ok: true }); }); // ── /health ─────────────────────────────────────────────────────────────────── // Deliberately public (used by uptime checks); reports status only. app.get('/health', (req, res) => { res.json({ status: 'ok' }); }); // ── WebSocket connections ───────────────────────────────────────────────────── // Each browser tab connects here on page load with a token PHP signed for the // signed-in user (include_ending.php). Rooms come only from the verified token — // never from values the browser chooses — so a visitor cannot listen to another // company's events. // io.use((socket, next) => { const claims = verifySocketToken(socket.handshake.auth && socket.handshake.auth.token); if (!claims) return next(new Error('unauthorized')); socket.data.claims = claims; next(); }); io.on('connection', (socket) => { const { c: company_id, u: user_id, r: role } = socket.data.claims; socket.join(`company_${company_id}`); if (user_id) { socket.join(`user_${user_id}`); } if (role === 'admin' || role === 'owner') { socket.join(`admin_${company_id}`); } console.log(`[connect] socket=${socket.id} company=${company_id} user=${user_id} role=${role}`); socket.on('disconnect', () => { console.log(`[disconnect] socket=${socket.id}`); }); }); // ── Start ───────────────────────────────────────────────────────────────────── const PORT = process.env.PORT || 3000; httpServer.listen(PORT, () => { console.log(`Node.js real-time server running on port ${PORT}`); });