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.

604 lines
23 KiB
Python

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.

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)