202 lines
6.2 KiB
JavaScript
202 lines
6.2 KiB
JavaScript
#!/usr/bin/env node
|
|
'use strict';
|
|
|
|
const { WebSocketServer } = require('ws');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const PORT = Number(process.env.CHAT_PORT) || 8081;
|
|
const HISTORY_MAX = Math.max(1, Math.min(1000, Number(process.env.CHAT_HISTORY_MAX) || 100));
|
|
const RATE_LIMIT_WINDOW_MS = 60 * 1000;
|
|
const RATE_LIMIT_MAX_MESSAGES = Number(process.env.CHAT_RATE_LIMIT) || 30;
|
|
const DEFAULT_LOG_DIR = __dirname;
|
|
const LOG_PATH = process.env.CHAT_LOG_PATH
|
|
? path.resolve(process.env.CHAT_LOG_PATH)
|
|
: path.join(DEFAULT_LOG_DIR, 'messages.log');
|
|
|
|
const clients = new Set();
|
|
const history = [];
|
|
const connectionMessageCount = new WeakMap();
|
|
const connectionWindowStart = new WeakMap();
|
|
const connectionNickSuffix = new WeakMap();
|
|
|
|
function getClientIp(req) {
|
|
const xff = req && req.headers ? req.headers['x-forwarded-for'] : null;
|
|
if (typeof xff === 'string' && xff.trim()) {
|
|
// May be a comma-separated list: client, proxy1, proxy2
|
|
const first = xff.split(',')[0].trim();
|
|
if (first) return first;
|
|
}
|
|
const realIp = req && req.headers ? req.headers['x-real-ip'] : null;
|
|
if (typeof realIp === 'string' && realIp.trim()) return realIp.trim();
|
|
const ra = req && req.socket ? req.socket.remoteAddress : null;
|
|
return typeof ra === 'string' && ra ? ra : '';
|
|
}
|
|
|
|
function last3DigitsOfIp(ip) {
|
|
const digits = String(ip || '').replace(/\D/g, '');
|
|
return digits.slice(-3).padStart(3, '0');
|
|
}
|
|
|
|
function sanitizeNickBase(str) {
|
|
if (typeof str !== 'string') return '';
|
|
// Keep it short; we append 3 digits after.
|
|
return str.trim().slice(0, 20).replace(/</g, '<').replace(/>/g, '>');
|
|
}
|
|
|
|
function loadHistoryFromLog() {
|
|
// Read the log as newline-delimited JSON and keep the last HISTORY_MAX entries.
|
|
// Uses a ring buffer so it can handle large logs without loading everything.
|
|
try {
|
|
if (!fs.existsSync(LOG_PATH)) return;
|
|
} catch (_) {
|
|
return;
|
|
}
|
|
|
|
const ring = new Array(HISTORY_MAX);
|
|
let count = 0;
|
|
let buf = '';
|
|
|
|
try {
|
|
const fd = fs.openSync(LOG_PATH, 'r');
|
|
try {
|
|
const chunk = Buffer.allocUnsafe(64 * 1024);
|
|
let bytesRead = 0;
|
|
let pos = 0;
|
|
// Stream through the file in chunks (simple forward scan).
|
|
while ((bytesRead = fs.readSync(fd, chunk, 0, chunk.length, pos)) > 0) {
|
|
pos += bytesRead;
|
|
buf += chunk.subarray(0, bytesRead).toString('utf8');
|
|
let idx;
|
|
while ((idx = buf.indexOf('\n')) !== -1) {
|
|
const line = buf.slice(0, idx);
|
|
buf = buf.slice(idx + 1);
|
|
const trimmed = line.trim();
|
|
if (!trimmed) continue;
|
|
let obj;
|
|
try {
|
|
obj = JSON.parse(trimmed);
|
|
} catch (_) {
|
|
continue;
|
|
}
|
|
if (!obj || typeof obj.nick !== 'string' || typeof obj.text !== 'string') continue;
|
|
const entry = {
|
|
nick: sanitize(obj.nick || 'Visitor'),
|
|
text: sanitize(obj.text || '').trim(),
|
|
ts: Number.isFinite(obj.ts) ? obj.ts : Date.now(),
|
|
};
|
|
if (!entry.text) continue;
|
|
ring[count % HISTORY_MAX] = entry;
|
|
count++;
|
|
}
|
|
}
|
|
} finally {
|
|
fs.closeSync(fd);
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to read message log for history:', err && err.message ? err.message : err);
|
|
return;
|
|
}
|
|
|
|
// Flush any partial final line (no trailing newline).
|
|
const last = buf.trim();
|
|
if (last) {
|
|
try {
|
|
const obj = JSON.parse(last);
|
|
if (obj && typeof obj.nick === 'string' && typeof obj.text === 'string') {
|
|
const entry = {
|
|
nick: sanitize(obj.nick || 'Visitor'),
|
|
text: sanitize(obj.text || '').trim(),
|
|
ts: Number.isFinite(obj.ts) ? obj.ts : Date.now(),
|
|
};
|
|
if (entry.text) {
|
|
ring[count % HISTORY_MAX] = entry;
|
|
count++;
|
|
}
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
|
|
const total = Math.min(count, HISTORY_MAX);
|
|
const start = count > HISTORY_MAX ? (count % HISTORY_MAX) : 0;
|
|
for (let i = 0; i < total; i++) {
|
|
history.push(ring[(start + i) % HISTORY_MAX]);
|
|
}
|
|
}
|
|
|
|
function sanitize(str) {
|
|
if (typeof str !== 'string') return '';
|
|
return str.slice(0, 200).replace(/</g, '<').replace(/>/g, '>');
|
|
}
|
|
|
|
function checkRateLimit(ws) {
|
|
const now = Date.now();
|
|
let start = connectionWindowStart.get(ws);
|
|
let count = connectionMessageCount.get(ws) || 0;
|
|
if (start == null || now - start >= RATE_LIMIT_WINDOW_MS) {
|
|
start = now;
|
|
count = 0;
|
|
}
|
|
count++;
|
|
connectionWindowStart.set(ws, start);
|
|
connectionMessageCount.set(ws, count);
|
|
return count <= RATE_LIMIT_MAX_MESSAGES;
|
|
}
|
|
|
|
function safeAppendLogLine(line) {
|
|
fs.appendFile(LOG_PATH, line + '\n', { encoding: 'utf8' }, (err) => {
|
|
if (err) console.error('Failed to append message log:', err.message);
|
|
});
|
|
}
|
|
|
|
const wss = new WebSocketServer({ port: PORT }, () => {
|
|
try {
|
|
fs.mkdirSync(path.dirname(LOG_PATH), { recursive: true });
|
|
} catch (err) {
|
|
console.error('Failed to create log directory:', err && err.message ? err.message : err);
|
|
}
|
|
loadHistoryFromLog();
|
|
console.log('Chat server listening on port', PORT);
|
|
console.log('Message log path:', LOG_PATH);
|
|
});
|
|
|
|
wss.on('connection', (ws, req) => {
|
|
clients.add(ws);
|
|
const ip = getClientIp(req);
|
|
const suffix = last3DigitsOfIp(ip);
|
|
connectionNickSuffix.set(ws, suffix);
|
|
if (history.length > 0) {
|
|
const payload = JSON.stringify({ type: 'history', messages: history });
|
|
if (ws.readyState === 1) ws.send(payload);
|
|
}
|
|
|
|
ws.on('message', (raw) => {
|
|
if (!checkRateLimit(ws)) return;
|
|
let msg;
|
|
try {
|
|
msg = JSON.parse(String(raw));
|
|
} catch (_) {
|
|
return;
|
|
}
|
|
const suffix = connectionNickSuffix.get(ws) || '000';
|
|
const base = sanitizeNickBase(msg.nick) || 'visitor';
|
|
const nick = `${base}${suffix}`;
|
|
const text = sanitize(msg.text || '');
|
|
if (!text.trim()) return;
|
|
const ts = Date.now();
|
|
const entry = { nick, text: text.trim(), ts };
|
|
history.push(entry);
|
|
while (history.length > HISTORY_MAX) history.shift();
|
|
|
|
safeAppendLogLine(JSON.stringify({ ...entry, iso: new Date(ts).toISOString() }));
|
|
|
|
const payload = JSON.stringify(entry);
|
|
clients.forEach((client) => {
|
|
if (client.readyState === 1) client.send(payload);
|
|
});
|
|
});
|
|
|
|
ws.on('close', () => clients.delete(ws));
|
|
ws.on('error', () => clients.delete(ws));
|
|
});
|