You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

672 lines
23 KiB
JavaScript

//check README.md
//load secret config vars
require('dotenv').config();
const DATA = require('./data');
//.env content
/*
ADMINS=username1|pass1,username2|pass2
PORT = 3000
*/
var port = process.env.PORT || 3000;
//number of emits per second allowed for each player, after that ban the IP.
//over 30 emits in this game means that the client is hacked and the flooding is malicious
//if you change the game logic make sure this limit is still reasonable
var PACKETS_PER_SECONDS = 30;
/*
The client and server version strings MUST be the same!
They can be used to force clients to hard refresh to load the latest client.
If the server gets updated it can be restarted, but if there are active clients (users' open browsers) they could be outdated and create issues.
If the VERSION vars are mismatched they will send all clients in an infinite refresh loop. Make sure you update sketch.js before restarting server.js
*/
var VERSION = "1.0";
//create a web application that uses the express frameworks and socket.io to communicate via http (the web protocol)
var express = require('express');
var app = express();
var http = require('http').createServer(app);
var io = require('socket.io')(http);
var Filter = require('bad-words');
//time before disconnecting (forgot the tab open?)
var ACTIVITY_TIMEOUT = 10 * 60 * 1000;
//should be the same as index maxlength="16"
var MAX_NAME_LENGTH = 16;
//cap the overall players
var MAX_PLAYERS = -1;
//refuse people when a room is full
var MAX_PLAYERS_PER_ROOM = 200;
//views since the server started counts relogs
var visits = 0;
/*
A very rudimentary admin system.
Reserved usernames and admin pass are stored in .env file as
ADMINS=username1|pass1,username2|pass2
Admin logs in as username|password in the normal field
If combo user|password is correct (case insensitive) mark the player as admin on the server side
The "username|password" remains stored on the client as var nickName
and it's never shared to other clients, unlike player.nickName
admins can call admin commands from the chat like /kick nickName
*/
var admins = [];
if (process.env.ADMINS != null)
admins = process.env.ADMINS.split(",");
//We want the server to keep track of the whole game state
//in this case the game state are the attributes of each player
var gameState = {
players: {}
}
//a collection of banned IPs
//not permanent, it lasts until the server restarts
var banned = [];
//when a client connects serve the static files in the public directory ie public/index.html
app.use(express.static('public'));
//when a client connects the socket is established and I set up all the functions listening for events
io.on('connection', function (socket) {
//this bit (middleware?) catches all incoming packets
//I use to make my own lil rate limiter without unleashing 344525 dependencies
//a rate limiter prevents malicious flooding from a hacked client
socket.use((packet, next) => {
if (gameState.players[socket.id] != null) {
var p = gameState.players[socket.id];
p.floodCount++;
if (p.floodCount > PACKETS_PER_SECONDS) {
console.log(socket.id + " is flooding! BAN BAN BAN");
if (p.IP != "") {
//comment this if you don't want to ban the IP
banned.push(p.IP);
socket.emit("errorMessage", "Flooding attempt! You are banned");
socket.disconnect();
}
}
}
next();
});
//this appears in the terminal
console.log('A user connected');
//this is sent to the client upon connection
socket.emit('serverWelcome', VERSION, DATA);
//wait for the player to send their name and info, then broadcast them
socket.on('join', function (playerInfo) {
//console.log("Number of sockets " + Object.keys(io.sockets.connected).length);
try {
//if running locally it's not gonna work
var IP = "";
//oh look at this beautiful socket.io to get an goddamn ip address
if (socket.handshake.headers != null)
if (socket.handshake.headers["x-forwarded-for"] != null) {
IP = socket.handshake.headers["x-forwarded-for"].split(",")[0];
}
if (playerInfo.nickName == "")
console.log("New user joined the server in lurking mode " + socket.id + " " + IP);
else
console.log("New user joined the game: " + playerInfo.nickName + " avatar# " + playerInfo.avatar + " color# " + playerInfo.color + " " + socket.id);
var roomPlayers = 1;
var myRoom = io.sockets.adapter.rooms[playerInfo.room];
if (myRoom != undefined) {
roomPlayers = myRoom.length + 1;
console.log("There are now " + roomPlayers + " users in " + playerInfo.room);
}
var serverPlayers = Object.keys(io.sockets.connected).length + 1;
var isBanned = false;
//prevent banned IPs from joining
if (IP != "") {
var index = banned.indexOf(IP);
//found
if (index > -1) {
console.log("ATTENTION: banned " + IP + " is trying to log in again");
isBanned = true;
socket.emit("errorMessage", "You have been banned");
socket.disconnect();
}
}
if (isBanned) {
}
//prevent a hacked client from duplicating players
else if (gameState.players[socket.id] != null) {
console.log("ATTENTION: there is already a player associated to the socket " + socket.id);
}
else if ((serverPlayers > MAX_PLAYERS && MAX_PLAYERS != -1) || (roomPlayers > MAX_PLAYERS_PER_ROOM && MAX_PLAYERS_PER_ROOM != -1)) {
//limit the number of players
console.log("ATTENTION: " + playerInfo.room + " reached maximum capacity");
socket.emit("errorMessage", "The server is full, please try again later. Click to refresh.");
socket.disconnect();
}
else {
//if client hacked truncate
if (playerInfo.nickName.length > MAX_NAME_LENGTH)
playerInfo.nickName = playerInfo.nickName.substring(0, MAX_NAME_LENGTH);
//the first validation was to give the player feedback, this one is for real
var val = 1;
//always validate lurkers, they can't do anything
if (playerInfo.nickName != "")
val = validateName(playerInfo.nickName);
if (val == 0 || val == 3) {
console.log("ATTENTION: " + socket.id + " tried to bypass username validation");
}
else {
//if there is an | strip the after so the password remains in the admin client
var combo = playerInfo.nickName.split("|");
playerInfo.nickName = combo[0];
if (val == 2)
console.log(playerInfo.nickName + " joins as admin");
//the player objects on the client will keep track of the room
var newPlayer = { id: socket.id, nickName: filter.clean(playerInfo.nickName), color: playerInfo.color, room: playerInfo.room, avatar: playerInfo.avatar, x: playerInfo.x, y: playerInfo.y };
//save the same information in my game state
gameState.players[socket.id] = newPlayer;
//set last message at the beginning of time, the SEVENTIES
gameState.players[socket.id].lastMessage = 0;
//is it admin?
gameState.players[socket.id].admin = (val == 2) ? true : false;
gameState.players[socket.id].spam = 0;
gameState.players[socket.id].lastActivity = new Date().getTime();
gameState.players[socket.id].muted = false;
gameState.players[socket.id].IP = IP;
gameState.players[socket.id].floodCount = 0;
//send the user to the default room
socket.join(playerInfo.room, function () {
//console.log(socket.rooms);
});
newPlayer.new = true;
//let's not count lurkers
if (playerInfo.nickName != "")
visits++;
//send all players information about the new player
//upon creation destination and position are the same
io.to(playerInfo.room).emit('playerJoined', newPlayer);
console.log("There are now " + Object.keys(gameState.players).length + " players on this server. Total visits " + visits);
}
}
} catch (e) {
console.log("Error on join, object malformed from" + socket.id + "?");
console.error(e);
}
});
//when a client disconnects I have to delete its player object
//or I would end up with ghost players
socket.on('disconnect', function () {
try {
console.log("Player disconnected " + socket.id);
io.sockets.emit('playerLeft', { id: socket.id, disconnect: true });
//send the disconnect
//delete the player object
delete gameState.players[socket.id];
console.log("There are now " + Object.keys(gameState.players).length + " players on this server");
}
catch (e) {
console.log("Error on disconnect, object malformed from" + socket.id + "?");
console.error(e);
}
});
//when I receive an intro send it to the recipient
socket.on('intro', function (newComer, obj) {
//verify the id to make sure a hacked client can't fake players
if (obj != null) {
if (obj.id == socket.id) {
io.to(newComer).emit('onIntro', obj);
}
else {
console.log("ATTENTION: Illegitimate intro from " + socket.id);
}
}
});
//when I receive a talk send it to everybody in the room
socket.on('talk', function (obj) {
try {
var time = new Date().getTime();
//block if spamming
if (time - gameState.players[socket.id].lastMessage > DATA.SETTINGS.ANTI_SPAM && !gameState.players[socket.id].muted) {
//Admin commands can be typed as messages
//is this an admin
if (gameState.players[socket.id].admin && obj.message.charAt(0) == "/") {
console.log("Admin " + gameState.players[socket.id].nickName + " attempts command " + obj.message);
adminCommand(socket, obj.message);
}
else {
//normal talk stuff
//aphostrophe
obj.message = obj.message.replace("", "'");
//replace unmapped characters
obj.message = obj.message.replace(/[^A-Za-z0-9_!$%*()@./#&+-|]*$/g, "");
//remove leading and trailing whitespaces
obj.message = obj.message.replace(/^\s+|\s+$/g, "");
//filter bad words
obj.message = filter.clean(obj.message);
//advanced cleaning
//f u c k
var test = obj.message.replace(/\s/g, '');
//fffffuuuuck
var test2 = obj.message.replace(/(.)(?=.*\1)/g, "");
//f*u*c*k
var test3 = obj.message.replace(/\W/g, "");
//spaces
var test4 = obj.message.replace(/\s/g, '');
if (filter.isProfane(test) || filter.isProfane(test2) || filter.isProfane(test3) || test4 == "") {
console.log(socket.id + " is problematic");
}
else {
io.to(obj.room).emit('playerTalked', { id: socket.id, color: obj.color, message: obj.message, x: obj.x, y: obj.y });
}
}
//update the last message time
gameState.players[socket.id].lastMessage = time;
gameState.players[socket.id].lastActivity = time;
}
} catch (e) {
console.log("Error on talk, object malformed from" + socket.id + "?");
console.error(e);
}
});
//when I receive a move sent it to everybody
socket.on('changeRoom', function (obj) {
try {
var roomPlayers = 1;
var myRoom = io.sockets.adapter.rooms[obj.to];
if (myRoom != undefined) {
roomPlayers = myRoom.length + 1;
}
if (roomPlayers > MAX_PLAYERS_PER_ROOM && MAX_PLAYERS_PER_ROOM != -1) {
//limit the number of players
console.log("ATTENTION: " + obj.to + " reached maximum capacity");
//keep the player in game, send a message
socket.emit('godMessage', "The room looks full");
}
else {
//console.log("Player " + socket.id + " moved from " + obj.from + " to " + obj.to);
socket.leave(obj.from);
socket.join(obj.to);
//broadcast the change to everybody in the current room
//from the client perspective leaving the room is the same as disconnecting
io.to(obj.from).emit('playerLeft', { id: socket.id, disconnect: false });
//same for joining, sending everybody in the room the player state
var playerObject = gameState.players[socket.id];
playerObject.room = obj.to;
playerObject.x = playerObject.destinationX = obj.x;
playerObject.y = playerObject.destinationY = obj.y;
playerObject.new = false;
io.to(obj.to).emit('playerJoined', playerObject);
}
} catch (e) {
console.log("Error on join, object malformed from" + socket.id + "?");
console.error(e);
}
});
//when I receive a move sent it to everybody
socket.on('move', function (obj) {
try {
gameState.players[socket.id].lastActivity = new Date().getTime();
//broadcast the movement to everybody
io.to(obj.room).emit('playerMoved', { id: socket.id, x: obj.x, y: obj.y, destinationX: obj.destinationX, destinationY: obj.destinationY });
} catch (e) {
console.log("Error on join, object malformed from" + socket.id + "?");
console.error(e);
}
});
//when I receive a user name validate it
socket.on('sendName', function (nn) {
try {
var res = validateName(nn);
//send the code 0 no - 1 ok - 2 admin
socket.emit('nameValidation', res);
} catch (e) {
console.log("Error on sendName " + socket.id + "?");
console.error(e);
}
});
//when a character emote animation changes
socket.on('emote', function (obj) {
try {
io.to(obj.room).emit('playerEmoted', socket.id, obj.em);
} catch (e) {
console.log("Error on emote " + socket.id + "?");
console.error(e);
}
});
//user afk
socket.on('focus', function (obj) {
try {
//console.log(socket.id + " back from AFK");
io.to(obj.room).emit('playerFocused', socket.id);
} catch (e) {
console.log("Error on focus " + socket.id + "?");
console.error(e);
}
});
socket.on('blur', function (obj) {
try {
//console.log(socket.id + " is AFK");
io.to(obj.room).emit('playerBlurred', socket.id)
} catch (e) {
console.log("Error on blur " + socket.id + "?");
console.error(e);
}
});
});
//rate limiting - clears the flood count
setInterval(function () {
for (var id in gameState.players) {
if (gameState.players.hasOwnProperty(id)) {
gameState.players[id].floodCount = 0;
}
}
}, 1000);
function validateName(nn) {
var admin = false;
var duplicate = false;
var reserved = false;
//check if the nickname is a name + password combo
var combo = nn.split("|");
//it may be
if (combo.length > 1) {
var n = combo[0];
var p = combo[1];
for (var i = 0; i < admins.length; i++) {
if (admins[i].toUpperCase() == nn.toUpperCase()) {
//it is an admin name! check if the password is correct, case insensitive
admin = true;
}
}
//if there is an | just strip the after
nn = n;
}
//if not admin check if the nickname is reserved (case insensitive)
if (!admin) {
for (var i = 0; i < admins.length; i++) {
var combo = admins[i].split("|");
if (combo[0].toUpperCase() == nn.toUpperCase()) {
//it is! kill it. Yes, it should be done at login and communicated
//but hey I don't have to be nice to users who steal my name
reserved = true;
}
}
}
var id = idByName(nn);
if (id != null) {
duplicate = true;
console.log("There is already a player named " + nn);
}
//i hate this double negative logic but I hate learning regex more
var res = nn.match(/^([a-zA-Z0-9 !@#$%&*(),._-]+)$/);
if (res == null)
return 3
else if (duplicate || reserved)
return 0
else if (admin)
return 2
else
return 1
}
//parse a potential admin command
function adminCommand(adminSocket, str) {
try {
//remove /
str = str.substr(1);
var cmd = str.split(" ");
switch (cmd[0]) {
case "kick":
var s = socketByName(cmd[1]);
if (s != null) {
//shadow disconnect
s.disconnect();
}
else {
//popup to admin
adminSocket.emit("popup", "I can't find a user named " + cmd[1]);
}
break;
case "mute":
var s = idByName(cmd[1]);
if (s != null) {
gameState.players[s].muted = true;
}
else {
//popup to admin
adminSocket.emit("popup", "I can't find a user named " + cmd[1]);
}
break;
case "unmute":
var s = idByName(cmd[1]);
if (s != null) {
gameState.players[s].muted = false;
}
else {
//popup to admin
adminSocket.emit("popup", "I can't find a user named " + cmd[1]);
}
break;
//trigger a direct popup
case "popup":
var s = socketByName(cmd[1]);
if (s != null) {
//take the rest as string
cmd.shift();
cmd.shift();
var msg = cmd.join(" ");
s.emit("popup", msg);
}
else {
//popup to admin
adminSocket.emit("popup", "I can't find a user named " + cmd[1]);
}
break;
//send fullscreen message to everybody
case "god":
cmd.shift();
var msg = cmd.join(" ");
io.sockets.emit('godMessage', msg);
break;
//add to the list of banned IPs
case "ban":
var IP = IPByName(cmd[1]);
var s = socketByName(cmd[1]);
if (IP != "") {
banned.push(IP);
}
if (s != null) {
s.emit("errorMessage", "You have been banned");
s.disconnect();
}
else {
//popup to admin
adminSocket.emit("popup", "I can't find a user named " + cmd[1]);
}
break;
case "unban":
//releases the ban
banned = [];
break;
//forces a hard refresh - all players disconnect
//used to load a new version of the client
case "refresh":
io.sockets.emit("refresh");
break;
}
}
catch (e) {
console.log("Error admin command");
console.error(e);
}
}
//admin functions, the admin exists in the client frontend so they don't have access to ip and id of other users
function socketByName(nick) {
var s = null;
for (var id in gameState.players) {
if (gameState.players.hasOwnProperty(id)) {
if (gameState.players[id].nickName.toUpperCase() == nick.toUpperCase()) {
s = io.sockets.sockets[id];
}
}
}
return s;
}
function idByName(nick) {
var i = null;
for (var id in gameState.players) {
if (gameState.players.hasOwnProperty(id)) {
if (gameState.players[id].nickName.toUpperCase() == nick.toUpperCase()) {
i = id;
}
}
}
return i;
}
function IPByName(nick) {
var IP = "";
for (var id in gameState.players) {
if (gameState.players.hasOwnProperty(id)) {
if (gameState.players[id].nickName.toUpperCase() == nick.toUpperCase()) {
IP = gameState.players[id].IP;
}
}
}
return IP;
}
//listen to the port 3000 this powers the whole socket.io
http.listen(port, function () {
console.log('listening on *:3000');
});
//check the last activity and disconnect players that have been idle for too long
setInterval(function () {
var time = new Date().getTime();
for (var id in gameState.players) {
if (gameState.players.hasOwnProperty(id)) {
if (gameState.players[id].nickName != "" && (time - gameState.players[id].lastActivity) > ACTIVITY_TIMEOUT) {
console.log(id + " has been idle for more than " + ACTIVITY_TIMEOUT + " disconnecting");
io.sockets.sockets[id].emit("refresh");
io.sockets.sockets[id].disconnect();
}
}
}
}, 1000);
//in my gallery people can swear but not use slurs, override bad-words list, and add my own, pardon for my french
let myBadWords = ['chink', 'cunt', 'cunts', "fag", "fagging", "faggitt", "faggot", "faggs", "fagot", "fagots", "fags", "jap", "homo", "nigger", "niggers", "n1gger", "nigg3r"];
var filter = new Filter({ emptyList: true });
filter.addWords(...myBadWords);