Files
server-configs/httpserver/data/chat-server.js
2026-03-22 00:54:28 -07:00

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, '&lt;').replace(/>/g, '&gt;');
}
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, '&lt;').replace(/>/g, '&gt;');
}
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));
});