//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: {}, NPCs: {} } //save the server startup time and send it in case the clients need to syncronize something var START_TIME = Date.now(); //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, START_TIME); //wait for the player to send their name and info, then broadcast them socket.on("join", function (playerInfo) { // console.log("join", 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 + " colors# " + playerInfo.colors + " " + 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(); } } //prevent secret rooms to be joined through URL if (DATA.ROOMS[playerInfo.room] != null) if (DATA.ROOMS[playerInfo.room].secret == true) { playerInfo.room = DATA.SETTINGS.defaultRoom; } 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."); 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), colors: playerInfo.colors, 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; gameState.players[socket.id].room = playerInfo.room; //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); //check if there are NPCs in this room and make them send info to the player for (var NPCId in gameState.NPCs) { var npc = gameState.NPCs[NPCId]; if (npc.room == playerInfo.room) { npc.sendIntroTo(socket.id); } } //check if there is a custom function in the MOD to call at this point if (MOD[playerInfo.room + "Join"] != null) { //call it! MOD[playerInfo.room + "Join"](newPlayer, playerInfo.room); } 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); var playerObject = gameState.players[socket.id]; io.sockets.emit("playerLeft", { id: socket.id, disconnect: true }); //check if there is a custom function in the MOD to call at this point if (playerObject != null) if (playerObject.room != null) { if (MOD[playerObject.room + "Leave"] != null) { //call it! MOD[playerObject.room + "Leave"](playerObject, playerObject.room); } } //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 // console.log("intro", newComer, obj); if (obj != null) { if (obj.id == socket.id) { io.to(newComer).emit("onIntro", obj); if (MOD[obj.room + "Intro"] != null) { MOD[obj.room + "Intro"](newComer, 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 { //check if there is a custom function in the MOD to call at this point if (MOD[obj.room + "TalkFilter"] != null) { //call it! obj.message = MOD[obj.room + "TalkFilter"](gameState.players[socket.id], obj.message); if (obj.message == null) { console.log("MOD: Warning - TalkFilter should return a message "); obj.message = ""; } } if (obj.message != "") 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 if (gameState.players[socket.id] != null) { 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, room: obj.from, 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; //check if there is a custom function in the MOD to call at this point if (MOD[obj.from + "Leave"] != null) { //call it! MOD[obj.from + "Leave"](playerObject, obj.from); } io.to(obj.to).emit("playerJoined", playerObject); //check if there is a custom function in the MOD to call at this point if (MOD[obj.to + "Join"] != null) { //call it! MOD[obj.to + "Join"](playerObject, obj.to); } //check if there are NPCs in this room and make them send info to the player for (var NPCId in gameState.NPCs) { var npc = gameState.NPCs[NPCId]; if (npc.room == obj.to) { npc.sendIntroTo(socket.id); } } } } 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); } }); //generic action listener, looks for a function with that id in the mod socket.on("action", function (aId) { if (MOD["on" + aId] != null) { //call it! //console.log("on" + aId + " exists in the mod, call it"); MOD["on" + aId](socket.id); } }); }); //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 var envCombo = admins[i].split("|"); if (p == envCombo[1]) 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) { console.log(nn + " logging as 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; //disconnect all sockets case "nuke": for (var id in io.sockets.sockets) { io.sockets.sockets[id].emit("errorMessage", "Server Restarted\nPlease Refresh"); io.sockets.sockets[id].disconnect(); } 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); //p5 style alias function print(s) { console.log(s); } /* NPC exists in a room broadcasts the the same join, leave, move, talk, intro events is rendered like and avatar by the client is controlled by the server */ global.NPC = function (o) { console.log("Create NPC " + o.id + " in room " + o.room + " nickNamed " + o.nickName); this.id = o.id; this.nickName = o.nickName; this.room = o.room; this.avatar = o.avatar; this.colors = o.colors; this.x = o.x * 2; this.y = o.y * 2; this.destinationX = o.x; this.destinationY = o.y; if (o.labelColor != null) this.labelColor = o.labelColor; else this.labelColor = "#FFFFFF"; //oops server doesn't know about colors //mimicks the emission from players this.sendIntroTo = function (pId) { //print("HELLO I"m " + this.nickName + " in " + this.room); //If I"m not the new player send an introduction to the new player //slight issue, server doesn't compute movements so if moving it appears at the destination //a way to solve this would be to save the time of the movement and lerp it io.sockets.sockets[pId].emit("onIntro", { id: this.id, nickName: this.nickName, colors: this.colors, avatar: this.avatar, room: this.room, x: this.destinationX, y: this.destinationY, destinationX: this.destinationX, destinationY: this.destinationY }); } this.move = function (dx, dy) { //print("HELLO I'm " + this.nickName + " and I move to " + dx + " " + dy); //broadcast the movement to everybody in the room //it doesn't check if the position is valid io.to(this.room).emit("playerMoved", { id: this.id, x: this.x, y: this.y, destinationX: dx, destinationY: dy }); //update for future intros this.destinationX = this.x = dx; this.destinationY = this.y = dy; } this.talk = function (message) { io.to(this.room).emit("playerTalked", { id: this.id, color: this.labelColor, message: message, x: this.x, y: this.y }); } this.delete = function () { io.to(this.room).emit("playerLeft", { id: this.id, room: this.room, disconnect: true }); delete gameState.NPCs[this.id]; } //add to NPC list gameState.NPCs[this.id] = this; } //modding var MOD = {}; //load server side mod file try { MOD = require("./serverMod"); if (MOD.initMod != null) { MOD.initMod(io, gameState, DATA); } } catch (e) { console.log(e); }