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.

894 lines
30 KiB
JavaScript

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

//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);
}