Chat / server.js
Ramazanpro995's picture
Upload 160 files
db63cd7 verified
Raw
History Blame Contribute Delete
23.4 kB
const fs = require("fs");
const http = require("http");
const path = require("path");
const WebSocket = require("ws");
const {
detectLanguageFromRequest,
translateText,
translationStatus
} = require("./translation");
const {
ENGLISH_PROFANITY_ROOTS,
ENGLISH_PROFANITY_SUFFIXES,
GENERATED_VARIANT_COUNT
} = require("./profanity-words");
const {
cleanModerationLanguage,
filterMultilingualProfanity,
multilingualModerationStatus
} = require("./multilingual-profanity");
const PORT = process.env.PORT || 7860;
const MAX_MESSAGE_LENGTH = 600;
const MAX_HISTORY_PER_ROOM = 100;
const MESSAGE_TTL_MS = 24 * 60 * 60 * 1000;
const MESSAGE_COOLDOWN_MS = 1500;
const HEARTBEAT_TIMEOUT_MS = 10 * 1000;
const STALE_SOCKET_TIMEOUT_MS = 30 * 1000;
const ROOM_INFO_BROADCAST_MS = 2000;
const SERVER_STATUS_BROADCAST_MS = 10000;
const MAX_WS_BUFFERED_BYTES = 1024 * 1024;
const TERMINATE_WS_BUFFERED_BYTES = 4 * 1024 * 1024;
const ADMIN_NAME = "\uD83D\uDC51Admin";
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || "global-chat.1";
const rooms = new Map();
const clients = new Map();
const roomClients = new Map();
const lastRoomOnlineCounts = new Map();
const mutedUsers = new Set();
const translationRateLimits = new Map();
let chatPaused = false;
let lastCpuUsage = process.cpuUsage();
let lastCpuTime = process.hrtime.bigint();
let storageCache = null;
let storageCacheAt = 0;
let statusCache = null;
let statusCacheAt = 0;
let statusCacheJson = "";
const LEET_CHARACTER_PATTERNS = Object.freeze({
a: "[a@4\\u00e0-\\u00e5]",
b: "[b8]",
c: "[c\\u00e7(]",
d: "d",
e: "[e3\\u00e8-\\u00eb]",
f: "f",
g: "[g\\u011f69]",
h: "h",
i: "[i\\u0130\\u01311!|\\u00ec-\\u00ef]",
j: "j",
k: "k",
l: "[l1|]",
m: "m",
n: "n",
o: "[o0\\u00f2-\\u00f6]",
p: "p",
q: "q",
r: "r",
s: "[s\\u015f5$]",
t: "[t7+]",
u: "[u\\u00f9-\\u00fc]",
v: "v",
w: "w",
x: "x",
y: "y",
z: "[z2]"
});
const PROFANITY_SEPARATOR_PATTERN = "[\\s._*\\-]{0,8}";
const PROFANITY_PATTERN_CHUNK_SIZE = 24;
function buildLetterPattern(text) {
return Array.from(text)
.map((character) => LEET_CHARACTER_PATTERNS[character] || character)
.map((characterPattern) => `(?:${characterPattern}){1,6}`)
.join(PROFANITY_SEPARATOR_PATTERN);
}
function buildProfanitySource(term) {
const letters = String(term).toLowerCase().replace(/[^a-z]/g, "");
if (!letters) return null;
const rootPattern = buildLetterPattern(letters);
const suffixPatterns = ENGLISH_PROFANITY_SUFFIXES
.filter(Boolean)
.map((suffix) => buildLetterPattern(suffix));
const suffixPattern = suffixPatterns.length > 0
? `(?:${suffixPatterns.join("|")})?`
: "";
return `${rootPattern}${suffixPattern}`;
}
function buildProfanityPatterns(terms) {
const sources = terms
.slice()
.sort((left, right) => right.length - left.length)
.map(buildProfanitySource)
.filter(Boolean);
const patterns = [];
for (let index = 0; index < sources.length; index += PROFANITY_PATTERN_CHUNK_SIZE) {
const alternatives = sources.slice(index, index + PROFANITY_PATTERN_CHUNK_SIZE).join("|");
patterns.push(new RegExp(
`(^|[^\\p{L}\\p{N}])(${alternatives})(?=$|[^\\p{L}\\p{N}])`,
"giu"
));
}
return patterns;
}
const profanityPatterns = buildProfanityPatterns(ENGLISH_PROFANITY_ROOTS);
function now() {
return Date.now();
}
function safeText(value, fallback = "") {
if (typeof value !== "string") return fallback;
return value.trim().slice(0, MAX_MESSAGE_LENGTH);
}
function cleanNickname(value, language = "en") {
const nickname = safeText(value, "Player").slice(0, 24);
const reservedName = nickname
.toLowerCase()
.replace(/[^a-z]/g, "");
if (!nickname || reservedName.includes("admin") || reservedName.includes("moderator")) {
return "Player";
}
return filterMessage(nickname, language);
}
function cleanRoomName(value) {
const raw = safeText(value || "global", "global").toLowerCase();
const clean = raw.replace(/[^a-z0-9_-]/g, "");
return clean.length > 0 ? clean.slice(0, 40) : "global";
}
function filterMessage(text, language = "en") {
let filtered = filterMultilingualProfanity(text, language);
for (const pattern of profanityPatterns) {
pattern.lastIndex = 0;
filtered = filtered.replace(pattern, (match, prefix, offensiveText) => {
const replacementLength = Array.from(offensiveText).length;
return `${prefix}${"*".repeat(replacementLength)}`;
});
}
return filtered;
}
function getRoom(roomName) {
const room = cleanRoomName(roomName);
if (!rooms.has(room)) {
rooms.set(room, []);
invalidateStatusCache();
}
return room;
}
function isActiveClient(client) {
return client && !client.isAdmin && now() - client.lastSeen <= HEARTBEAT_TIMEOUT_MS;
}
function activeClientsCount() {
let count = 0;
for (const client of clients.values()) {
if (isActiveClient(client)) count++;
}
return count;
}
function countClientsInRoom(room) {
let count = 0;
const members = roomClients.get(room);
if (!members) return 0;
for (const ws of members) {
const client = clients.get(ws);
if (isActiveClient(client)) count++;
}
return count;
}
function addClientToRoom(ws, client, room) {
const previousRoom = client.room;
if (previousRoom === room) return previousRoom;
if (previousRoom) {
const previousMembers = roomClients.get(previousRoom);
if (previousMembers) {
previousMembers.delete(ws);
if (previousMembers.size === 0) roomClients.delete(previousRoom);
}
}
client.room = room;
if (!roomClients.has(room)) roomClients.set(room, new Set());
roomClients.get(room).add(ws);
return previousRoom;
}
function removeClientFromRoom(ws, client) {
const room = client?.room;
if (!room) return "";
const members = roomClients.get(room);
if (members) {
members.delete(ws);
if (members.size === 0) roomClients.delete(room);
}
client.room = "";
return room;
}
function findRoomForJoin(baseRoom, requestedLimit) {
const limit = Math.max(0, Math.floor(Number(requestedLimit) || 0));
if (limit <= 0) return getRoom(baseRoom);
let index = 1;
while (true) {
const room = index === 1 ? baseRoom : `${baseRoom}-${index}`;
getRoom(room);
if (countClientsInRoom(room) < limit) return room;
index++;
}
}
function cleanRoom(room) {
const history = rooms.get(room) || [];
const cutoff = now() - MESSAGE_TTL_MS;
const cleaned = history
.filter((msg) => msg.time >= cutoff)
.slice(-MAX_HISTORY_PER_ROOM);
rooms.set(room, cleaned);
return cleaned;
}
function serializePacket(type, payload = {}) {
return JSON.stringify({ type, ...payload });
}
function sendSerialized(ws, packet) {
if (ws.readyState !== WebSocket.OPEN) return false;
if (ws.bufferedAmount >= TERMINATE_WS_BUFFERED_BYTES) {
ws.terminate();
return false;
}
if (ws.bufferedAmount >= MAX_WS_BUFFERED_BYTES) return false;
try {
ws.send(packet);
return true;
} catch {
return false;
}
}
function send(ws, type, payload = {}) {
return sendSerialized(ws, serializePacket(type, payload));
}
function broadcast(room, type, payload = {}) {
const members = roomClients.get(room);
if (!members || members.size === 0) return 0;
const packet = serializePacket(type, payload);
let sent = 0;
for (const ws of members) {
if (sendSerialized(ws, packet)) sent++;
}
return sent;
}
function broadcastAll(type, payload = {}) {
const packet = serializePacket(type, payload);
let sent = 0;
for (const ws of clients.keys()) {
if (sendSerialized(ws, packet)) sent++;
}
return sent;
}
function broadcastRoomInfo(room, force = false) {
const online = countClientsInRoom(room);
if (!force && lastRoomOnlineCounts.get(room) === online) return;
lastRoomOnlineCounts.set(room, online);
broadcast(room, "roomInfo", {
room,
online
});
}
function broadcastEveryRoomInfo() {
const roomNames = new Set([...rooms.keys(), ...roomClients.keys()]);
for (const room of roomNames) {
broadcastRoomInfo(room);
}
}
function messageForClient(chatMessage, client) {
const filteringEnabled = client?.profanityFilterEnabled !== false;
const filtered = filteringEnabled && chatMessage.filtered;
return {
id: chatMessage.id,
room: chatMessage.room,
nickname: chatMessage.nickname,
message: filtered ? chatMessage.message : chatMessage.originalMessage,
filtered,
admin: chatMessage.admin,
time: chatMessage.time
};
}
function sendHistory(ws, client, room, history) {
send(ws, "history", {
room,
messages: history.map((message) => messageForClient(message, client))
});
}
function broadcastChatMessage(room, chatMessage) {
const members = roomClients.get(room);
if (!members || members.size === 0) return 0;
const filteredPacket = serializePacket("message", messageForClient(chatMessage, { profanityFilterEnabled: true }));
const originalPacket = serializePacket("message", messageForClient(chatMessage, { profanityFilterEnabled: false }));
let sent = 0;
for (const ws of members) {
const client = clients.get(ws);
const packet = client?.profanityFilterEnabled === false ? originalPacket : filteredPacket;
if (sendSerialized(ws, packet)) sent++;
}
return sent;
}
function appendMessage(room, nickname, message, admin = false, language = "en") {
const originalMessage = safeText(message);
const filteredMessage = filterMessage(originalMessage, language);
const chatMessage = {
id: `${now()}-${Math.random().toString(16).slice(2)}`,
room,
nickname,
originalMessage,
message: filteredMessage,
filtered: filteredMessage !== originalMessage,
admin,
time: now()
};
const history = cleanRoom(room);
history.push(chatMessage);
rooms.set(room, history.slice(-MAX_HISTORY_PER_ROOM));
broadcastChatMessage(room, chatMessage);
return chatMessage;
}
function bytes(value) {
if (!Number.isFinite(value) || value < 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
let size = value;
let index = 0;
while (size >= 1024 && index < units.length - 1) {
size /= 1024;
index++;
}
return `${size.toFixed(size >= 10 || index === 0 ? 0 : 1)} ${units[index]}`;
}
function getDirectorySize(directory) {
let total = 0;
try {
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const entryPath = path.join(directory, entry.name);
if (entry.isDirectory()) {
total += getDirectorySize(entryPath);
} else if (entry.isFile()) {
total += fs.statSync(entryPath).size;
}
}
} catch {
}
return total;
}
function getStorageStats() {
if (storageCache && now() - storageCacheAt < 10000) {
return storageCache;
}
const used = getDirectorySize(__dirname);
storageCache = {
used,
usedText: bytes(used),
label: "App files only"
};
storageCacheAt = now();
return storageCache;
}
function getProcessCpuPercent() {
const usageDelta = process.cpuUsage(lastCpuUsage);
const currentTime = process.hrtime.bigint();
const elapsedMicros = Number(currentTime - lastCpuTime) / 1000;
lastCpuUsage = process.cpuUsage();
lastCpuTime = currentTime;
if (elapsedMicros <= 0) return 0;
const usedMicros = usageDelta.user + usageDelta.system;
return Math.max(0, Math.min(100, Math.round((usedMicros / elapsedMicros) * 100)));
}
function getServerStats() {
const memory = process.memoryUsage();
const cpuPercent = getProcessCpuPercent();
const storage = getStorageStats();
return {
cpu: {
percent: cpuPercent,
label: "Node process CPU"
},
memory: {
rss: memory.rss,
heapUsed: memory.heapUsed,
heapTotal: memory.heapTotal,
external: memory.external,
rssText: bytes(memory.rss),
heapUsedText: bytes(memory.heapUsed),
heapTotalText: bytes(memory.heapTotal)
},
storage,
overloaded: false
};
}
function statusPayload(cacheMs = 0) {
if (cacheMs > 0 && statusCache && now() - statusCacheAt < cacheMs) {
return statusCache;
}
const stats = getServerStats();
const payload = {
ok: true,
paused: chatPaused,
overloaded: stats.overloaded,
clients: activeClientsCount(),
totalSockets: clients.size,
rooms: rooms.size,
moderation: {
language: "Multilingual",
manualEnglishRoots: ENGLISH_PROFANITY_ROOTS.length,
generatedEnglishVariants: GENERATED_VARIANT_COUNT,
multilingual: multilingualModerationStatus()
},
translation: translationStatus(),
stats,
time: new Date().toISOString()
};
if (cacheMs > 0) {
statusCache = payload;
statusCacheAt = now();
statusCacheJson = JSON.stringify(payload);
}
return payload;
}
function invalidateStatusCache() {
statusCache = null;
statusCacheAt = 0;
statusCacheJson = "";
}
function cachedStatusJson() {
statusPayload(1000);
return statusCacheJson;
}
function sendServerStatus(ws) {
const status = statusPayload(1000);
send(ws, "serverStatus", {
paused: status.paused,
overloaded: status.overloaded,
stats: status.stats
});
}
function readIndexHtml() {
try {
return fs.readFileSync(path.join(__dirname, "index.html"));
} catch {
return Buffer.from("Admin panel missing.");
}
}
function sendJsonResponse(res, statusCode, payload) {
res.writeHead(statusCode, {
"Content-Type": "application/json; charset=utf-8",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "Content-Type, Accept-Language, X-Client-Language",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS"
});
res.end(typeof payload === "string" ? payload : JSON.stringify(payload));
}
function readJsonBody(req, maxBytes = 8192) {
return new Promise((resolve, reject) => {
const chunks = [];
let total = 0;
req.on("data", (chunk) => {
total += chunk.length;
if (total > maxBytes) {
reject(new Error("Request body is too large."));
req.destroy();
return;
}
chunks.push(chunk);
});
req.on("end", () => {
try {
const raw = Buffer.concat(chunks).toString("utf8");
resolve(raw ? JSON.parse(raw) : {});
} catch {
reject(new Error("Invalid JSON."));
}
});
req.on("error", reject);
});
}
function translationClientId(req) {
return String(req.headers["x-forwarded-for"] || req.headers["x-real-ip"] || req.socket?.remoteAddress || "unknown")
.split(",")[0]
.trim();
}
function allowTranslationRequest(req) {
const key = translationClientId(req);
const currentTime = now();
const windowMs = 60 * 1000;
const limit = 180;
const entry = translationRateLimits.get(key);
if (!entry || currentTime - entry.startedAt >= windowMs) {
translationRateLimits.set(key, { startedAt: currentTime, count: 1 });
return true;
}
entry.count++;
return entry.count <= limit;
}
const server = http.createServer(async (req, res) => {
const requestUrl = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
if (req.method === "OPTIONS") {
sendJsonResponse(res, 204, {});
return;
}
if (requestUrl.pathname === "/" || requestUrl.pathname === "/index.html") {
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(readIndexHtml());
return;
}
if (requestUrl.pathname === "/health" || requestUrl.pathname === "/stats") {
sendJsonResponse(res, 200, cachedStatusJson());
return;
}
if (requestUrl.pathname === "/locale" && req.method === "GET") {
const locale = await detectLanguageFromRequest(req);
sendJsonResponse(res, 200, { ok: true, ...locale });
return;
}
if (requestUrl.pathname === "/translate" && req.method === "POST") {
if (!allowTranslationRequest(req)) {
sendJsonResponse(res, 429, {
ok: false,
code: "rate_limited",
message: "Server could not translate. Please wait."
});
return;
}
try {
const data = await readJsonBody(req);
const result = await translateText({
text: data.text,
targetLanguage: data.targetLanguage,
mode: data.mode,
provider: data.provider
});
sendJsonResponse(res, 200, { ok: true, ...result });
} catch (error) {
const code = error.code || "translation_failed";
console.error(`[translation] ${code}: ${error.message}`);
sendJsonResponse(res, code === "filtered_message" ? 422 : 503, {
ok: false,
code,
provider: error.provider || "",
message: code === "filtered_message"
? "Filtered messages are not translated."
: "Server could not translate."
});
}
return;
}
res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
res.end("Global Chat Server is running. Use /health for uptime checks.");
});
const wss = new WebSocket.Server({
server,
path: "/ws",
clientTracking: false,
perMessageDeflate: false,
maxPayload: 8 * 1024
});
wss.on("connection", (ws) => {
const client = {
nickname: "Player",
room: "",
language: "en",
profanityFilterEnabled: true,
lastMessageTime: 0,
lastSeen: now(),
isAdmin: false
};
clients.set(ws, client);
invalidateStatusCache();
send(ws, "connected", {
message: "Connected to Global Chat Server"
});
sendServerStatus(ws);
ws.on("message", (raw) => {
let data;
try {
data = JSON.parse(raw.toString());
} catch {
send(ws, "error", { message: "Invalid JSON." });
return;
}
client.lastSeen = now();
if (data.type === "ping") {
if (typeof data.profanityFilter === "boolean") {
client.profanityFilterEnabled = data.profanityFilter;
}
send(ws, "pong", { time: now() });
return;
}
if (data.type === "join") {
client.language = cleanModerationLanguage(data.language) || "en";
if (typeof data.profanityFilter === "boolean") {
client.profanityFilterEnabled = data.profanityFilter;
}
client.nickname = client.isAdmin ? ADMIN_NAME : cleanNickname(data.nickname, client.language);
const baseRoom = getRoom(data.room);
const selectedRoom = findRoomForJoin(baseRoom, data.roomLimit);
const previousRoom = addClientToRoom(ws, client, selectedRoom);
const history = cleanRoom(client.room);
sendHistory(ws, client, client.room, history);
if (previousRoom && previousRoom !== client.room) broadcastRoomInfo(previousRoom);
broadcastRoomInfo(client.room);
sendServerStatus(ws);
return;
}
if (data.type === "message") {
if (!client.room) {
send(ws, "error", { message: "Join a room first." });
return;
}
if (chatPaused) {
send(ws, "error", {
code: "paused",
message: "Admin stopped Global Chat. Please come back later."
});
return;
}
const userKey = `${client.room}:${client.nickname.toLowerCase()}`;
if (mutedUsers.has(userKey)) {
send(ws, "error", { message: "You are muted." });
return;
}
if (now() - client.lastMessageTime < MESSAGE_COOLDOWN_MS) {
send(ws, "error", { message: "Slow down." });
return;
}
const rawText = safeText(data.message);
if (!rawText) return;
client.lastMessageTime = now();
appendMessage(client.room, client.nickname, rawText, false, client.language);
return;
}
if (data.type === "admin") {
if (!client.isAdmin) {
if (data.password !== ADMIN_PASSWORD) {
send(ws, "error", { message: "Wrong admin password." });
return;
}
client.isAdmin = true;
client.nickname = ADMIN_NAME;
client.lastSeen = now();
invalidateStatusCache();
if (client.room) broadcastRoomInfo(client.room);
}
if (data.action === "auth") {
send(ws, "adminReady", statusPayload());
return;
}
if (data.action === "pause") {
chatPaused = true;
invalidateStatusCache();
broadcastAll("serverStatus", {
paused: chatPaused,
overloaded: getServerStats().overloaded
});
send(ws, "adminReady", statusPayload());
return;
}
if (data.action === "resume") {
chatPaused = false;
invalidateStatusCache();
broadcastAll("serverStatus", {
paused: chatPaused,
overloaded: getServerStats().overloaded
});
send(ws, "adminReady", statusPayload());
return;
}
if (data.action === "message") {
const room = getRoom(data.room || "global");
const adminMessage = safeText(data.message);
if (adminMessage) appendMessage(room, ADMIN_NAME, adminMessage, true, client.language);
send(ws, "adminReady", statusPayload());
return;
}
if (data.action === "clear") {
const room = getRoom(data.room || "global");
rooms.set(room, []);
broadcast(room, "history", { room, messages: [] });
send(ws, "adminReady", statusPayload());
return;
}
if (data.action === "mute") {
const room = getRoom(data.room || "global");
const target = safeText(data.nickname).toLowerCase();
if (target) {
mutedUsers.add(`${room}:${target}`);
broadcast(room, "system", { message: `${target} muted by admin.` });
}
send(ws, "adminReady", statusPayload());
return;
}
if (data.action === "unmute") {
const room = getRoom(data.room || "global");
const target = safeText(data.nickname).toLowerCase();
if (target) {
mutedUsers.delete(`${room}:${target}`);
broadcast(room, "system", { message: `${target} unmuted by admin.` });
}
send(ws, "adminReady", statusPayload());
}
}
});
ws.on("close", () => {
const client = clients.get(ws);
const room = removeClientFromRoom(ws, client);
clients.delete(ws);
invalidateStatusCache();
if (room) broadcastRoomInfo(room);
});
ws.on("error", () => {
// Close/terminate cleanup is handled by the close event.
});
});
setInterval(() => {
for (const room of rooms.keys()) {
cleanRoom(room);
if (countClientsInRoom(room) === 0 && (rooms.get(room) || []).length === 0) {
rooms.delete(room);
lastRoomOnlineCounts.delete(room);
invalidateStatusCache();
}
}
for (const [clientId, entry] of translationRateLimits.entries()) {
if (now() - entry.startedAt > 2 * 60 * 1000) {
translationRateLimits.delete(clientId);
}
}
}, 60 * 1000);
setInterval(() => {
broadcastEveryRoomInfo();
}, ROOM_INFO_BROADCAST_MS);
setInterval(() => {
const status = statusPayload(1000);
broadcastAll("serverStatus", {
paused: status.paused,
overloaded: status.overloaded
});
}, SERVER_STATUS_BROADCAST_MS);
setInterval(() => {
const cutoff = now() - STALE_SOCKET_TIMEOUT_MS;
for (const [ws, client] of clients.entries()) {
if (client.lastSeen < cutoff) ws.terminate();
}
}, 10 * 1000);
server.listen(PORT, "0.0.0.0", () => {
console.log(`Global Chat Server running on port ${PORT}`);
});