#!/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, '>'); } 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, '>'); } 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)); });