283 lines
9.1 KiB
JavaScript
Vendored
283 lines
9.1 KiB
JavaScript
Vendored
/**
|
|
* Minimal Pusher-protocol WebSocket server for Laravel broadcasting.
|
|
* Compatible with laravel-echo + pusher-js (no cloud Pusher account needed).
|
|
*/
|
|
const http = require('http');
|
|
const crypto = require('crypto');
|
|
const WebSocket = require('ws');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { URL } = require('url');
|
|
|
|
const configPath = path.join(__dirname, 'soketi.json');
|
|
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
const app = (config['appManager.array.apps'] || [])[0] || {};
|
|
|
|
const APP_ID = app.id || 'ultimatepos';
|
|
const APP_KEY = app.key || 'ultimatepos-key';
|
|
const APP_SECRET = app.secret || 'ultimatepos-secret';
|
|
const HOST = config.host || '0.0.0.0';
|
|
const PORT = config.port || 6001;
|
|
|
|
/** @type {Map<string, Set<import('ws').WebSocket>>} */
|
|
const channelSubscribers = new Map();
|
|
|
|
/** @type {Map<import('ws').WebSocket, { socketId: string, channels: Set<string> }>} */
|
|
const socketMeta = new Map();
|
|
|
|
function socketId() {
|
|
// Pusher PHP SDK requires format: digits.digits
|
|
return `${Date.now()}.${Math.floor(Math.random() * 1_000_000)}`;
|
|
}
|
|
|
|
function parseBody(req) {
|
|
return new Promise((resolve, reject) => {
|
|
let body = '';
|
|
req.on('data', (chunk) => {
|
|
body += chunk;
|
|
if (body.length > 1_000_000) {
|
|
reject(new Error('Body too large'));
|
|
req.destroy();
|
|
}
|
|
});
|
|
req.on('end', () => resolve(body));
|
|
req.on('error', reject);
|
|
});
|
|
}
|
|
|
|
function verifyTriggerSignature(req, body, requestPath, query) {
|
|
const remote = req.socket.remoteAddress || '';
|
|
const isLoopback = remote === '127.0.0.1' || remote === '::1' || remote === '::ffff:127.0.0.1';
|
|
|
|
const authKey = query.get('auth_key');
|
|
const authSignature = query.get('auth_signature');
|
|
|
|
if (!authSignature) {
|
|
return isLoopback;
|
|
}
|
|
|
|
if (authKey && authKey !== APP_KEY) {
|
|
return false;
|
|
}
|
|
|
|
const params = {};
|
|
[...query.entries()].forEach(([key, value]) => {
|
|
params[key] = value;
|
|
});
|
|
delete params.auth_signature;
|
|
|
|
const sorted = Object.keys(params).sort().map((key) => `${key}=${params[key]}`).join('&');
|
|
const stringToSign = `${req.method}\n${requestPath}\n${sorted}`;
|
|
const expected = crypto.createHmac('sha256', APP_SECRET).update(stringToSign).digest('hex');
|
|
|
|
try {
|
|
return crypto.timingSafeEqual(Buffer.from(authSignature), Buffer.from(expected));
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function subscribe(ws, channel) {
|
|
if (!channelSubscribers.has(channel)) {
|
|
channelSubscribers.set(channel, new Set());
|
|
}
|
|
channelSubscribers.get(channel).add(ws);
|
|
socketMeta.get(ws).channels.add(channel);
|
|
}
|
|
|
|
function unsubscribe(ws, channel) {
|
|
channelSubscribers.get(channel)?.delete(ws);
|
|
socketMeta.get(ws)?.channels.delete(channel);
|
|
}
|
|
|
|
function cleanupSocket(ws) {
|
|
const meta = socketMeta.get(ws);
|
|
if (!meta) {
|
|
return;
|
|
}
|
|
meta.channels.forEach((channel) => unsubscribe(ws, channel));
|
|
socketMeta.delete(ws);
|
|
}
|
|
|
|
function broadcastToChannel(channel, event, data, excludeWs = null) {
|
|
const subs = channelSubscribers.get(channel);
|
|
if (!subs || subs.size === 0) {
|
|
return 0;
|
|
}
|
|
|
|
const payload = JSON.stringify({
|
|
event,
|
|
channel,
|
|
data: typeof data === 'string' ? data : JSON.stringify(data),
|
|
});
|
|
|
|
let sent = 0;
|
|
subs.forEach((ws) => {
|
|
if (ws === excludeWs) {
|
|
return;
|
|
}
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.send(payload);
|
|
sent += 1;
|
|
}
|
|
});
|
|
|
|
return sent;
|
|
}
|
|
|
|
const server = http.createServer(async (req, res) => {
|
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Pusher-Key, X-Pusher-Signature');
|
|
|
|
if (req.method === 'OPTIONS') {
|
|
res.writeHead(204);
|
|
res.end();
|
|
return;
|
|
}
|
|
|
|
const parsedUrl = new URL(req.url, `http://${req.headers.host || '127.0.0.1'}`);
|
|
const triggerMatch = parsedUrl.pathname.match(/^\/apps\/([^/]+)\/events\/?$/);
|
|
if (req.method === 'POST' && triggerMatch) {
|
|
if (triggerMatch[1] !== APP_ID) {
|
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: 'Unknown app id' }));
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const body = await parseBody(req);
|
|
if (!verifyTriggerSignature(req, body, parsedUrl.pathname, parsedUrl.searchParams)) {
|
|
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: 'Invalid signature' }));
|
|
return;
|
|
}
|
|
|
|
const payload = JSON.parse(body);
|
|
const eventName = payload.name;
|
|
const channels = payload.channels || (payload.channel ? [payload.channel] : []);
|
|
const data = payload.data;
|
|
|
|
let total = 0;
|
|
channels.forEach((channel) => {
|
|
total += broadcastToChannel(channel, eventName, data);
|
|
});
|
|
|
|
if (config.debug) {
|
|
console.log(`[broadcast] ${eventName} -> ${channels.join(', ')} (${total} clients)`);
|
|
}
|
|
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end('{}');
|
|
} catch (error) {
|
|
console.error('[http] trigger error', error);
|
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: error.message }));
|
|
}
|
|
return;
|
|
}
|
|
|
|
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
res.end('Not found');
|
|
});
|
|
|
|
const wss = new WebSocket.Server({ noServer: true });
|
|
|
|
server.on('upgrade', (req, socket, head) => {
|
|
const match = req.url.match(/^\/app\/([^/?]+)/);
|
|
if (!match || match[1] !== APP_KEY) {
|
|
socket.destroy();
|
|
return;
|
|
}
|
|
|
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
wss.emit('connection', ws, req);
|
|
});
|
|
});
|
|
|
|
wss.on('connection', (ws) => {
|
|
const id = socketId();
|
|
socketMeta.set(ws, { socketId: id, channels: new Set() });
|
|
|
|
ws.send(JSON.stringify({
|
|
event: 'pusher:connection_established',
|
|
data: JSON.stringify({
|
|
socket_id: id,
|
|
activity_timeout: 30,
|
|
}),
|
|
}));
|
|
|
|
ws.on('message', (raw) => {
|
|
try {
|
|
const message = JSON.parse(raw.toString());
|
|
if (message.event === 'pusher:subscribe') {
|
|
const data = typeof message.data === 'string' ? JSON.parse(message.data) : message.data;
|
|
const channel = data.channel;
|
|
if (!channel) {
|
|
return;
|
|
}
|
|
|
|
if (channel.startsWith('private-') && !data.auth) {
|
|
ws.send(JSON.stringify({
|
|
event: 'pusher:subscription_error',
|
|
data: JSON.stringify({ type: 'AuthError', error: 'Auth required', status: 401 }),
|
|
}));
|
|
return;
|
|
}
|
|
|
|
subscribe(ws, channel);
|
|
ws.send(JSON.stringify({
|
|
event: 'pusher_internal:subscription_succeeded',
|
|
channel,
|
|
data: '{}',
|
|
}));
|
|
ws.send(JSON.stringify({
|
|
event: 'pusher:subscription_succeeded',
|
|
channel,
|
|
data: '{}',
|
|
}));
|
|
|
|
if (config.debug) {
|
|
console.log(`[ws] ${id} subscribed to ${channel}`);
|
|
}
|
|
} else if (message.event === 'pusher:unsubscribe') {
|
|
const data = typeof message.data === 'string' ? JSON.parse(message.data) : message.data;
|
|
if (data.channel) {
|
|
unsubscribe(ws, data.channel);
|
|
}
|
|
} else if (message.event === 'pusher:ping') {
|
|
ws.send(JSON.stringify({ event: 'pusher:pong', data: {} }));
|
|
} else if (message.event && message.event.startsWith('client-')) {
|
|
const channel = message.channel;
|
|
if (!channel) {
|
|
return;
|
|
}
|
|
|
|
const meta = socketMeta.get(ws);
|
|
if (!meta || !meta.channels.has(channel)) {
|
|
return;
|
|
}
|
|
|
|
if (!channel.startsWith('private-') && !channel.startsWith('presence-')) {
|
|
return;
|
|
}
|
|
|
|
const sent = broadcastToChannel(channel, message.event, message.data, ws);
|
|
if (config.debug) {
|
|
console.log(`[client-event] ${message.event} -> ${channel} (${sent} clients)`);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('[ws] message error', error);
|
|
}
|
|
});
|
|
|
|
ws.on('close', () => cleanupSocket(ws));
|
|
ws.on('error', () => cleanupSocket(ws));
|
|
});
|
|
|
|
server.listen(PORT, HOST, () => {
|
|
console.log(`UltimatePOS WebSocket server running at ws://${HOST === '0.0.0.0' ? '127.0.0.1' : HOST}:${PORT}/app/${APP_KEY}`);
|
|
console.log(`App id: ${APP_ID}`);
|
|
});
|