- .htaccess: refuse .git, .env, docker, nodejs, sdlc*, scripts, archives and app internals; security headers, HSTS over TLS, optional HTTPS redirect - uploads served through app/file.php to signed-in users only - Apache/PHP hardening config for the container (ServerTokens, expose_php) - least-privilege DB account and APP_SECRET_KEY via docker/php/provision.php; SMTP passwords re-encrypted with a random IV (secret_box.php) - Socket.IO rooms from a PHP-signed token; /emit and cron refuse empty secrets - escape notification text; CLI guards on build scripts; no fixed demo password
125 lines
5.2 KiB
JavaScript
125 lines
5.2 KiB
JavaScript
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}`);
|
|
});
|