import time, json, re import flask from flask import Flask from flask_socketio import SocketIO, send, emit, join_room, leave_room, has_request_context, disconnect """ ## Notes from the python/flask port: Still to do: * NPC * Language Filter * Connection monitoring: PACKETS_PER_SECONDS * serverMod.js! ## Notes from original/node: 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 """ PACKETS_PER_SECONDS = 30 VERSION = "1.0" with open("data.json") as datafile: DATA = json.load(datafile) # time before disconnecting (forgot the tab open?) ACTIVITY_TIMEOUT = 10 * 60 * 1000 # should be the same as index maxlength="16" MAX_NAME_LENGTH = 16 # cap the overall players MAX_PLAYERS = -1 # refuse people when a room is full MAX_PLAYERS_PER_ROOM = 200 # views since the server started counts relogs visits = 0 admins = [] # We want the server to keep track of the whole game state # in this case the game state are the attributes of each player gameState = { 'players': {}, 'NPCs': {} } # save the server startup time and send it in case the clients need to syncronize something START_TIME = int(time.time()*1000) # datetime.datetime.now() # a collection of banned IPs # not permanent, it lasts until the server restarts banned = [] app = Flask(__name__, static_url_path='', static_folder='public') app.add_url_rule("/", view_func=lambda **kw: app.send_static_file("index.html", **kw)) # app.config['SECRET_KEY'] = 'secret!' socketio = SocketIO(app) connections = 0 from interval_tasks import IntervalTaskMaster @socketio.on('connect') def connect(sid=None, environ=None, auth=None): global connections connections += 1 print (f"A user connected ({connections} connections)") emit('serverWelcome', (VERSION, DATA, START_TIME)) @socketio.on('disconnect') def disconnect(auth=None): global connections connections -= 1 # print ("disconnect") print(f'Client disconnected ({connections} connections)') # when a client disconnects I have to delete its player object # or I would end up with ghost players try: print(f"Player disconnected {flask.request.sid}") playerObject = gameState['players'].get(flask.request.sid); emit("playerLeft", { 'id': flask.request.sid, 'disconnect': True }, broadcast=True) # check if there is a custom function in the MOD to call at this point if playerObject is not None: if playerObject['room']: if hasattr(MOD, playerObject['room']+"Leave"): mod_fn = getattr(MOD, playerObject['room']+"Leave") mod_fn(playerObject, playerObject['room']) # send the disconnect # delete the player object if flask.request.sid in gameState['players']: del gameState['players'][flask.request.sid] print(f"There are now {len(gameState['players'])} players on this server") except Exception as e: print(f"Error on disconnect, object malformed from {flask.request.sid}?") print(f"Exception: {e}") @socketio.on('join') def join (playerInfo): global visits # print ("join", playerInfo) try: # if running locally it's not gonna work IP = "" if (has_request_context() and flask.request.headers): if (flask.request.headers.get("x-forwarded-for") is not None): IP = flask.request.headers.get("x-forwarded-for").split(",")[0] if (playerInfo['nickName'] == ""): print(f"New user joined the server in lurking mode {flask.request.sid} {IP}") else: print(f"New user joined the game: {playerInfo['nickName']} avatar# {playerInfo['avatar']} colors# {playerInfo['colors']} {flask.request.sid}") roomPlayers = 1 # from https://github.com/miguelgrinberg/python-socketio/blob/main/src/socketio/base_manager.py # manager.rooms = {} # self.rooms[namespace][room][sio_sid] = eio_sid # io.sockets.adapter (js) == socketio.server.manager (python) # print ("rooms", socketio.server.manager.rooms) myRoom = socketio.server.manager.rooms['/'].get(playerInfo['room']) if myRoom is not None: roomPlayers = len(myRoom.keys()) + 1 # roomPlayers = len(list(socketio.server.manager.get_participants('/', playerInfo['room']))) print (f"There are now {roomPlayers} users in {playerInfo['room']}") # serverPlayers = Object.keys(io.sockets.connected).length + 1 serverPlayers = connections + 1 isBanned = False # prevent banned IPs from joining if (IP != ""): try: index = banned.index(IP); print(f"ATTENTION: banned {IP} is trying to log in again") isBanned = True emit("errorMessage", "You have been banned") disconnect() except ValueError: pass # prevent secret rooms to be joined through URL if playerInfo['room'] in DATA['ROOMS']: if DATA['ROOMS'][playerInfo['room']].get('secret'): playerInfo['room'] = DATA['SETTINGS']['defaultRoom'] if isBanned: pass elif flask.request.sid in gameState['players']: # prevent a hacked client from duplicating players print(f"ATTENTION: there is already a player associated to the socket {flask.request.sid}") elif ((serverPlayers > MAX_PLAYERS and MAX_PLAYERS != -1) or (roomPlayers > MAX_PLAYERS_PER_ROOM and MAX_PLAYERS_PER_ROOM != -1)): # limit the number of players print(f"ATTENTION: {playerInfo['room']} reached maximum capacity") emit("errorMessage", "The server is full, please try again later.") disconnect() else: # if client hacked truncate playerInfo['nickName'] = playerInfo['nickName'][:MAX_NAME_LENGTH] # the first validation was to give the player feedback, this one is for real val = 1 # always validate lurkers, they can't do anything if playerInfo['nickName'] != "": val = validateName(playerInfo['nickName']) if (val == 0 or val == 3): print(f"ATTENTION: {flask.request.sid} tried to bypass username validation") else: #if there is an | strip the after so the password remains in the admin client combo = playerInfo['nickName'].split("|") playerInfo['nickName'] = combo[0] if (val == 2): print(f"{playerInfo['nickName']} joins as admin") #the player objects on the client will keep track of the room newPlayer = { 'id': flask.request.sid, '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 sid = flask.request.sid gameState['players'][sid] = newPlayer # set last message at the beginning of time, the SEVENTIES gameState['players'][sid]['lastMessage'] = 0; # is it admin? gameState['players'][sid]['admin'] = (val == 2) gameState['players'][sid]['spam'] = 0; gameState['players'][sid]['lastActivity'] = int(time.time()*1000) gameState['players'][sid]['muted'] = False gameState['players'][sid]['IP'] = IP gameState['players'][sid]['floodCount'] = 0 gameState['players'][sid]['room'] = playerInfo['room'] # send the user to the default room join_room(playerInfo['room']) newPlayer['new'] = True # let's not count lurkers if playerInfo['nickName'] != "": visits += 1 #send all players information about the new player #upon creation destination and position are the same # print (f"playerJoined {newPlayer} {playerInfo['room']}") emit("playerJoined", newPlayer, room=playerInfo['room']) # check if there are NPCs in this room and make them send info to the player for NPCId in gameState['NPCs']: npc = gameState['NPCs'][NPCId] if npc.room == playerInfo['room']: npc.sendIntroTo(flask.request.sid) #check if there is a custom function in the MOD to call at this point if hasattr(MOD, playerInfo['room']+"Join"): # call it! mod_fn = getattr(MOD, playerInfo['room']+"Join") mod_fn(newPlayer, playerInfo['room']) print(f"There are now {len(gameState['players'])} players on this server. Total visits {visits}") except Exception as e: print(f"Error on join, object malformed from {flask.request.sid}?") print(f"Exception: {e}") @socketio.on('intro') def intro (newComer, obj): """ when I receive an intro send it to the recipient intro messages are sent by the client in reponse to a playerJoin message of a "new" player obj is a "auto biography" from the sender to relay to newComer containing info (like avatar) that the server merely relays """ # print (f"intro {newComer} {obj}") # verify the id to make sure a hacked client can"t fake players if (obj is not None): if obj['id'] == flask.request.sid: emit("onIntro", obj, room=newComer) if hasattr(MOD, obj['room'] + "Intro"): mod_fn = getattr(MOD, obj['room'] + "Intro") mod_fn(newComer, obj) else: print(f"ATTENTION: Illegitimate intro from {flask.request.sid}") @socketio.on('talk') def talk (obj): """ when I receive a talk send it to everybody in the room """ try: mtime = int(time.time()*1000) # block if spamming if (mtime - gameState['players'][flask.request.sid]['lastMessage'] > DATA['SETTINGS']['ANTI_SPAM'] and not gameState['players'][flask.request.sid]['muted']): # Admin commands can be typed as messages # is this an admin if (gameState['players'][flask.request.sid].get("admin") and obj['message'][0] == "/"): print(f"Admin {gameState['players'][flask.request.sid]['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, ""); obj['message'] = re.sub(r'[^A-Za-z0-9_!$%*()@./#&+-|]*$', "", obj['message']) # remove leading and trailing whitespaces obj['message'] = obj['message'].strip() # filter bad words obj['message'] = filter.clean(obj['message']) # advanced cleaning # f u c k test = re.sub('\s', "", obj['message']) # fffffuuuuck test2 = re.sub('(.)(?=.*\1)', "", obj['message']) # f*u*c*k test3 = re.sub('\W', "", obj['message']) # spaces test4 = re.sub('\s', "", obj['message']); if (filter.isProfane(test) or filter.isProfane(test2) or filter.isProfane(test3) or test4 == ""): print(f"{flask.request.sid} is problematic") else: # check if there is a custom function in the MOD to call at this point if hasattr(MOD, obj['room']+"TalkFilter"): mod_fn = getattr(MOD, obj['room']+"TalkFilter") obj.message = mod_fn(gameState['players'][flask.request.sid], obj['message']) if obj['message'] is not None: print("MOD: Warning - TalkFilter should return a message ") obj['message'] = "" if obj['message']: emit("playerTalked", { 'id': flask.request.sid, 'color': obj['color'], 'message': obj['message'], 'x': obj['x'], 'y': obj['y'] }, room=obj['room']) # update the last message time # if (gameState['players'][flask.request.sid]: gameState['players'][flask.request.sid]['lastMessage'] = mtime gameState['players'][flask.request.sid]['lastActivity'] = mtime except Exception as e: print(f"Error on talk, object malformed from {flask.request.sid}?") print(f"Exception: {e}") @socketio.on('changeRoom') def changeRoom (obj): """ when I receive a move sent it to everybody""" try: roomPlayers = 1 # myRoom = io.sockets.adapter.rooms[obj.to]; # if myRoom is not None: # roomPlayers = myRoom.length + 1; myRoom = socketio.server.manager.rooms['/'].get(obj['to']) if myRoom is not None: roomPlayers = len(myRoom.keys()) + 1 if roomPlayers > MAX_PLAYERS_PER_ROOM and MAX_PLAYERS_PER_ROOM != -1: # limit the number of players print(f"ATTENTION: {obj['to']} reached maximum capacity") # keep the player in game, send a message emit("godMessage", "The room looks full") else: print(f"Player {flask.request.sid} moved from {obj['from']} to {obj['to']}") leave_room(obj['from']) join_room(obj['to']) # broadcast the change to everybody in the current room # from the client perspective leaving the room is the same as disconnecting emit("playerLeft", { 'id': flask.request.sid, 'room': obj['from'], 'disconnect': False }, room=obj['from']) # same for joining, sending everybody in the room the player state playerObject = gameState['players'][flask.request.sid] 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 hasattr(MOD, obj['from']+"Leave"): mod_fn = getattr(MOD, obj['from']+"Leave") mod_fn(playerObject, obj['from']) emit("playerJoined", playerObject, room=obj['to']) # check if there is a custom function in the MOD to call at this point if hasattr(MOD, obj['to']+"Join"): mod_fn = getattr(MOD, obj['to']+"Join") mod_fn(playerObject, obj['to']) # check if there are NPCs in this room and make them send info to the player for NPCId in gameState['NPCs']: npc = gameState['NPCs'][NPCId] if (npc.room == obj['to']): npc.sendIntroTo(flask.request.sid) except Exception as e: print(f"Error on changeRoom, object malformed from {flask.request.sid}?") print(f"Exception: {e}") # when I receive a move sent it to everybody @socketio.on('move') def move(obj): try: gameState['players'][flask.request.sid]['lastActivity'] = int(time.time()*1000) # broadcast the movement to everybody # print (f"emit playerMoved to everybody in {obj['room']}") emit("playerMoved", { 'id': flask.request.sid, 'x': obj['x'], 'y': obj['y'], 'destinationX': obj['destinationX'], 'destinationY': obj['destinationY'] }, room=obj['room']) except Exception as e: print(f"Error on move, object malformed from {flask.request.sid}?") print(f"Exception: {e}") # when I receive a user name validate it @socketio.on('sendName') def sendName(nn): try: res = validateName(nn) # send the code 0 no - 1 ok - 2 admin emit("nameValidation", res) except Exception as e: print(f"Error on sendName, object malformed from {flask.request.sid}?") print(f"Exception: {e}") @socketio.on('emote') def emote(obj): """when a character emote animation changes""" try: emit("playerEmoted", (flask.request.sid, obj['em']), room=obj['room']) except Exception as e: print(f"Error on emote, object malformed from {flask.request.sid}?") print(f"Exception: {e}") @socketio.on('focus') def focus(obj): """user afk """ try: # print(f"{flask.request.sid} back from AFK") emit("playerFocused", flask.request.sid, room=obj['room']); except Exception as e: print(f"Error on focus, object malformed from {flask.request.sid}?") print(f"Exception: {e}") @socketio.on('blur') def blur(obj): try: # print(f"{flask.request.sid} is AFK") emit("playerBlurred", flask.request.sid, room=obj['room']) except Exception as e: print(f"Error on blur, object malformed from {flask.request.sid}?") print(f"Exception: {e}") @socketio.on('action') def action(aId): """ generic action listener, looks for a function with that id in the mod """ if hasattr(MOD, "on"+aId): mod_fn = getattr(MOD, "on"+aId) # print(f"on {aId} exists in the mod, call it") mod_fn(flask.request.sid); def validateName(nn): admin = False duplicate = False reserved = False #check if the nickname is a name + password combo combo = nn.split("|") #it may be if len(combo) > 1: n = combo[0] p = combo[1] for admin in admins: if admin.upper() == nn.upper(): #it is an admin name! check if the password is correct, case insensitive envCombo = admin.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 not admin: for admin in admins: combo = admin.split("|"); if combo[0].upper() == nn.upper(): #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 id = idByName(nn) if (id is not None): duplicate = True print(f"There is already a player named {nn}") #i hate this double negative logic but I hate learning regex more res = re.match(r'^([a-zA-Z0-9 !@#$%&*(),._-]+)$', nn) if (res is None): return 3 elif duplicate or reserved: return 0 elif admin: print(f"{nn} logging as admin") return 2 else: return 1 def adminCommand(adminSocket, str): try: # remove / str = str[1:] cmd = str.split(" ") if cmd[0] == "kick": sid = socketIDByName(cmd[1]) if sid: # shadow disconnect disconnect(sid) else: # popup to admin emit("popup", f"I can't find a user named {cmd[1]}") elif cmd[0] == "mute": s = idByName(cmd[1]) if s: gameState['players'][s]['muted'] = True else: # popup to admin emit("popup", f"I can't find a user named {cmd[1]}") elif cmd[0] == "unmute": s = idByName(cmd[1]); if s: gameState['players'][s]['muted'] = False else: # popup to admin emit("popup", f"I can't find a user named {cmd[1]}") # trigger a direct popup elif cmd[0] == "popup": s = socketIDByName(cmd[1]) if s: msg = cmd[2:].join(" "); emit("popup", msg, room=s) else: # popup to admin emit("popup", f"I can't find a user named {cmd[1]}") # send fullscreen message to everybody elif cmd[0] == "god": msg = cmd[1:].join(" "); emit("godMessage", msg, broadcast=True) # disconnect all sockets elif cmd[0] == "nuke": emit("errorMessage", "Server Restarted\nPlease Refresh", broadcast=True) for sid in gameState['players']: disconnect(sid) # add to the list of banned IPs elif cmd[0] == "ban": IP = IPByName(cmd[1]) s = socketByName(cmd[1]) if IP: banned.append(IP) if s: s.emit("errorMessage", "You have been banned") s.disconnect() else: # popup to admin emit("popup", f"I can't find a user named {cmd[1]}") elif cmd[0] == "unban": # releases the ban banned = [] # forces a hard refresh - all players disconnect # used to load a new version of the client elif cmd[0] == "refresh": io.sockets.emit("refresh"); except Exception as e: print ("Error admin command") print (f"Exception: {e}") # admin functions, the admin exists in the client frontend so they don't have access to ip and id of other users def socketIDByName(nick): s = None for sid in gameState['players']: if gameState['players'][sid]['nickName'].upper() == nick.upper(): s = sid return s def idByName(nick): i = None for sid in gameState['players']: if gameState['players'][sid]['nickName'].upper() == nick.upper(): i = sid return i def IPByName(nick): IP = "" for sid in gameState['players']: if gameState['players'][sid]['nickName'].upper() == nick.upper(): IP = gameState['players'][sid]['IP'] return IP class BadWordsFilter (object): def __init__(self, initial_data=None): self.words=[] def clean (self, text): return text def isProfane(self, text): return False def addWords(self, *words): self.words.extend(words) filter = BadWordsFilter() taskmaster = IntervalTaskMaster(socketio) # modding MOD = {} try: import serverMod as MOD MOD.initMod(socketio, gameState, DATA, taskmaster) except ImportError as e: print (f"Error importing serverMod: {e}") if __name__ == '__main__': taskmaster.start() socketio.run(app)