From ce61b59284af1874080d8f5fba522a93384d687c Mon Sep 17 00:00:00 2001 From: MikeDEV Date: Fri, 9 Sep 2022 22:09:52 -0400 Subject: [PATCH] 0.1.8.3 * New: Added CL4 client mode. * New: Added client-example.py * Change: Renamed example.py to server-example.py * Enhancement: Code restructure. Server and client modes are now in separate modules. * Enhancement: Optimized callback binding system. * Enhancement: Optimized built-in command loading system. * Enhancement: Guard cases are used (where applicable) as an effort to improve code readability. * Bugfix: Fixed a bug that would sometimes have duplicate user IDs in a room. * Reversion: Modified websocket server will return to using a counter-based ID system. I've been able to get the existing size-based system to bug out from time to time. --- client-example.py | 109 +++++ cloudlink/__init__.py | 2 +- cloudlink/client/__init__.py | 1 + cloudlink/client/client.py | 168 +++++++ cloudlink/client/clientInternalHandlers.py | 130 ++++++ cloudlink/client/clientRootHandlers.py | 112 +++++ cloudlink/cloudlink.py | 21 +- cloudlink/docs/docs.txt | 1 + cloudlink/server/__init__.py | 1 + cloudlink/{ => server}/server.py | 39 +- cloudlink/server/serverInternalHandlers.py | 411 ++++++++++++++++++ cloudlink/{ => server}/serverRootHandlers.py | 16 +- .../{ => server}/websocket_server/__init__.py | 0 .../{ => server}/websocket_server/thread.py | 0 .../websocket_server/websocket_server.py | 20 +- cloudlink/serverInternalHandlers.py | 227 ---------- cloudlink/supporter.py | 67 ++- example.py => server-example.py | 53 ++- 18 files changed, 1065 insertions(+), 313 deletions(-) create mode 100644 client-example.py create mode 100644 cloudlink/client/__init__.py create mode 100644 cloudlink/client/client.py create mode 100644 cloudlink/client/clientInternalHandlers.py create mode 100644 cloudlink/client/clientRootHandlers.py create mode 100644 cloudlink/docs/docs.txt create mode 100644 cloudlink/server/__init__.py rename cloudlink/{ => server}/server.py (73%) create mode 100644 cloudlink/server/serverInternalHandlers.py rename cloudlink/{ => server}/serverRootHandlers.py (92%) rename cloudlink/{ => server}/websocket_server/__init__.py (100%) rename cloudlink/{ => server}/websocket_server/thread.py (100%) rename cloudlink/{ => server}/websocket_server/websocket_server.py (97%) delete mode 100644 cloudlink/serverInternalHandlers.py rename example.py => server-example.py (56%) diff --git a/client-example.py b/client-example.py new file mode 100644 index 0000000..890b37c --- /dev/null +++ b/client-example.py @@ -0,0 +1,109 @@ +from cloudlink import Cloudlink + +class demoCallbacksClient: + """ + demoCallbacksClient + + This is an example of Cloudlink's callback system. + """ + + def __init__(self, cloudlink): + # To use callbacks, you will need to initialize your callbacks class with Cloudlink. This is required. + self.cloudlink = cloudlink + + # Below are templates for binding generic callbacks. + + def on_packet(self, message): # Called when any packet is received, regardless of packet command. + print("on_packet fired!") + + def on_connect(self): # Called when the client is connected to the server. + print("on_connect fired!") + self.cloudlink.sendGlobalMessage("this is a test") + self.cloudlink.setUsername("test") + + def on_close(self, close_status_code, close_msg): # Called when the client is disconnected from the server. + print("on_close fired!") + + def on_error(self, error): # Called when the client encounters an exception. + print("on_error fired!") + + # Below are templates for binding command-specific callbacks. + + def on_direct(self, message:any): # Called when a packet is received with the direct command. + print("on_direct fired!") + #pass + + def on_version(self, version:str): # Called when a packet is received with the server_version command. + print("on_version fired!") + # pass + + def on_motd(self, motd:str): # Called when a packet is received with the motd command. + print("on_motd fired!") + # pass + + def on_ip(self, ip:str): # Called when a packet is received with the client_ip command. + print("on_ip fired!") + # pass + + def on_ulist(self, ulist:list): # Called when a packet is received with the ulist command. + print("on_ulist fired!") + # pass + + def on_statuscode(self, code:str): # Called when a packet is received with the statuscode command. + print("on_statuscode fired!") + # pass + + def on_gmsg(self, message:str): # Called when a packet is received with the gmsg command. + print("on_gmsg fired!") + # pass + + def on_gvar(self, var_name:str, var_value:any): # Called when a packet is received with the gvar command. + print("on_gvar fired!") + # pass + + def on_pvar(self, var_name:str, var_value:any, origin:any): # Called when a packet is received with the pvar command. + print("on_pvar fired!") + # pass + + def on_pmsg(self, value:str, origin:any): # Called when a packet is received with the pmsg command. + print("on_pmsg fired!") + # pass + + def on_ping(self, value:str, origin:any): # Called when the client is being pinged by another client (It will automatically respond to the ping, this is just used for diagnostics). + print("on_ping fired!") + # pass + +if __name__ == "__main__": + # Initialize Cloudlink. You will only need to initialize one instance of the main cloudlink module. + cl = Cloudlink() + + # Create a new client object. This supports initializing many clients at once. + client = cl.client(logs=True) + + # Create demo callbacks. You can only initialize callbacks after you have initialized a cloudlink client object. + dummy = demoCallbacksClient(client) + + # Bind demo callbacks + client.callback(client.on_packet, dummy.on_packet) + client.callback(client.on_connect, dummy.on_connect) + client.callback(client.on_close, dummy.on_close) + client.callback(client.on_error, dummy.on_error) + + # Bind template callbacks + client.callback(client.on_direct, dummy.on_direct) + client.callback(client.on_version, dummy.on_version) + client.callback(client.on_motd, dummy.on_motd) + client.callback(client.on_ip, dummy.on_ip) + client.callback(client.on_ulist, dummy.on_ulist) + client.callback(client.on_statuscode, dummy.on_statuscode) + client.callback(client.on_gmsg, dummy.on_gmsg) + client.callback(client.on_gvar, dummy.on_gvar) + client.callback(client.on_pvar, dummy.on_pvar) + client.callback(client.on_pmsg, dummy.on_pmsg) + client.callback(client.on_ping, dummy.on_ping) + + # Command disabler. Simply pass a list of strings containing CLPv4 commands to ignore. + #client.disableCommands(["gmsg"]) + + # Connect to the server and run the client. + client.run(ip="ws://127.0.0.1:3000/") \ No newline at end of file diff --git a/cloudlink/__init__.py b/cloudlink/__init__.py index 5c2e4e0..83c28c3 100644 --- a/cloudlink/__init__.py +++ b/cloudlink/__init__.py @@ -1 +1 @@ -from .cloudlink import Cloudlink \ No newline at end of file +from .cloudlink import * \ No newline at end of file diff --git a/cloudlink/client/__init__.py b/cloudlink/client/__init__.py new file mode 100644 index 0000000..4c51a32 --- /dev/null +++ b/cloudlink/client/__init__.py @@ -0,0 +1 @@ +from .client import * \ No newline at end of file diff --git a/cloudlink/client/client.py b/cloudlink/client/client.py new file mode 100644 index 0000000..a7261ac --- /dev/null +++ b/cloudlink/client/client.py @@ -0,0 +1,168 @@ +import websocket as WebsocketClient +from .clientRootHandlers import clientRootHandlers +from .clientInternalHandlers import clientInternalHandlers + +class client: + def __init__(self, parentCl, enable_logs=True): + # Read the CloudLink version from the parent class + self.version = parentCl.version + + # Init the client + self.motd_msg = "" + self.sever_version = "" + self.ip_address = "" + self.motd_msg = "" + self.userlist = {} + self.myClientObject = {} + + self.linkStatus = 0 + self.failedToConnect = False + self.connectionLost = False + self.connected = False + + # Init modules + self.supporter = parentCl.supporter(self, enable_logs, 2) + self.clientRootHandlers = clientRootHandlers(self) + self.clientInternalHandlers = clientInternalHandlers(self) + + # Load built-in commands (automatically generates attributes for callbacks) + self.builtInCommands = [] + self.customCommands = [] + self.disabledCommands = [] + self.usercallbacks = {} + self.supporter.loadBuiltinCommands(self.clientInternalHandlers) + + # Create API + self.loadCustomCommands = self.supporter.loadCustomCommands + self.disableCommands = self.supporter.disableCommands + self.sendPacket = self.supporter.sendPacket + self.sendCode = self.supporter.sendCode + self.log = self.supporter.log + self.callback = self.supporter.callback + + # Create default callbacks + self.usercallbacks = {} + self.on_packet = self.clientRootHandlers.on_packet + self.on_connect = self.clientRootHandlers.on_connect + self.on_close = self.clientRootHandlers.on_close + self.on_error = self.clientRootHandlers.on_error + + # Callbacks for command-specific events + self.on_direct = self.clientInternalHandlers.direct + self.on_version = self.clientInternalHandlers.server_version + self.on_motd = self.clientInternalHandlers.motd + self.on_ip = self.clientInternalHandlers.client_ip + self.on_ulist = self.clientInternalHandlers.ulist + self.on_statuscode = self.clientInternalHandlers.statuscode + self.on_gmsg = self.clientInternalHandlers.gmsg + self.on_gvar = self.clientInternalHandlers.gvar + self.on_pvar = self.clientInternalHandlers.pvar + self.on_pmsg = self.clientInternalHandlers.pmsg + self.on_ping = self.clientInternalHandlers.ping + + self.log("Cloudlink client initialized!") + + def run(self, ip="ws://127.0.0.1:3000/"): + # Initialize the Websocket client + self.log("Cloudlink client starting up now...") + self.wss = WebsocketClient.WebSocketApp( + ip, + on_message = self.clientRootHandlers.on_packet, + on_error = self.clientRootHandlers.on_error, + on_open = self.clientRootHandlers.on_connect, + on_close = self.clientRootHandlers.on_close + ) + + # Run the CloudLink client + self.linkStatus = 1 + self.wss.run_forever() + self.log("Cloudlink client exiting...") + + def stop(self): + if self.connected: + self.linkStatus = 3 + self.log("Cloudlink client disconnecting...") + self.wss.close() + + # Client API + + def setUsername(self, username:str): + if self.connected: + msg = {"cmd": "setid", "val": username, "listener": "username_set"} + self.cloudlink.sendPacket(msg) + + def getUserlist(self, listener:str = None): + if self.connected: + msg = {"cmd": "ulist", "val": ""} + if listener: + msg["listener"] = listener + self.cloudlink.sendPacket(msg) + + def linkToRooms(self, rooms:list = ["default"], listener:str = None): + if self.connected: + msg = {"cmd": "link", "val": rooms} + if listener: + msg["listener"] = listener + self.cloudlink.sendPacket(msg) + + def unlinkFromRooms(self, listener:str = None): + if self.connected: + msg = {"cmd": "unlink", "val": ""} + if listener: + msg["listener"] = listener + self.cloudlink.sendPacket(msg) + + def sendDirect(self, message:str, username:str = None, listener:str = None): + if self.connected: + msg = {"cmd": "direct", "val": message} + if listener: + msg["listener"] = listener + if username: + msg["id"] = username + self.cloudlink.sendPacket(msg) + + def sendCustom(self, cmd:str, message:str, username:str = None, listener:str = None): + if self.connected: + msg = {"cmd": cmd, "val": message} + if listener: + msg["listener"] = listener + if username: + msg["id"] = username + self.cloudlink.sendPacket(msg) + + def sendPing(self, dummy_payload:str = "", username:str = None, listener:str = None): + if self.connected: + msg = {"cmd": "ping", "val": dummy_payload} + if listener: + msg["listener"] = listener + if username: + msg["id"] = username + self.cloudlink.sendPacket(msg) + + def sendGlobalMessage(self, message:str, listener:str = None): + if self.connected: + msg = {"cmd": "gmsg", "val": message} + if listener: + msg["listener"] = listener + self.cloudlink.sendPacket(msg) + + def sendPrivateMessage(self, message:str, username:str = "", listener:str = None): + if self.connected: + msg = {"cmd": "pmsg", "val": message, "id": username} + if listener: + msg["listener"] = listener + self.cloudlink.sendPacket(msg) + + def sendGlobalVariable(self, var_name:str, var_value:str, listener:str = None): + if self.connected: + msg = {"cmd": "gvar", "val": var_value, "name": var_name} + if listener: + msg["listener"] = listener + self.cloudlink.sendPacket(msg) + + def sendPrivateVariable(self, var_name:str, var_value:str, username:str = "", listener:str = None): + if self.connected: + msg = {"cmd": "pvar", "val": var_value, "name": var_name, "id": username} + if listener: + msg["listener"] = listener + self.cloudlink.sendPacket(msg) \ No newline at end of file diff --git a/cloudlink/client/clientInternalHandlers.py b/cloudlink/client/clientInternalHandlers.py new file mode 100644 index 0000000..e221b6c --- /dev/null +++ b/cloudlink/client/clientInternalHandlers.py @@ -0,0 +1,130 @@ +class clientInternalHandlers(): + """ + The clientInternalHandlers inter class serves as the clients's built-in command handler. + These commands are hard-coded per-spec as outlined in the CLPv4 (Cloudlink Protocol) guideline. + """ + + def __init__(self, cloudlink): + self.cloudlink = cloudlink + self.supporter = self.cloudlink.supporter + self.importer_ignore_functions = ["relay"] + + def direct(self, message): + self.supporter.log(f"Client received direct data: \"{message['val']}\"") + + # Fire callbacks + if self.direct in self.cloudlink.usercallbacks: + if self.cloudlink.usercallbacks[self.direct] != None: + self.cloudlink.usercallbacks[self.direct](message) + + def server_version(self, message): + self.supporter.log(f"Server reports version: {message['val']}") + self.cloudlink.sever_version = message['val'] + + # Fire callbacks + if self.server_version in self.cloudlink.usercallbacks: + if self.cloudlink.usercallbacks[self.server_version] != None: + self.cloudlink.usercallbacks[self.server_version](message['val']) + + def motd(self, message): + self.supporter.log(f"Message of the day: \"{message['val']}\"") + self.cloudlink.motd_msg = message['val'] + + # Fire callbacks + if self.motd in self.cloudlink.usercallbacks: + if self.cloudlink.usercallbacks[self.motd] != None: + self.cloudlink.usercallbacks[self.motd](message['val']) + + def client_ip(self, message): + self.supporter.log(f"Server reports client IP: {message['val']}") + self.cloudlink.ip_address = message['val'] + + # Fire callbacks + if self.client_ip in self.cloudlink.usercallbacks: + if self.cloudlink.usercallbacks[self.client_ip] != None: + self.cloudlink.usercallbacks[self.client_ip](message["val"]) + + def ulist(self, message): + self.supporter.log(f"Userlist updated: {message['val']}") + + if "room" in message: + self.cloudlink.userlist[message['room']] = message['val'] + else: + self.cloudlink.userlist["default"] = message['val'] + + # Fire callbacks + if self.ulist in self.cloudlink.usercallbacks: + if self.cloudlink.usercallbacks[self.ulist] != None: + self.cloudlink.usercallbacks[self.ulist](self.cloudlink.userlist) + + # Status codes + def statuscode(self, message): + if "listener" in message: + self.supporter.log(f"Client received status code for handler {message['listener']}: {message['code']}") + + # Check if the username was set + if (message["listener"] == "username_set") and (message["code"] == self.supporter.codes["OK"]): + self.cloudlink.myClientObject = message["val"] + self.supporter.log(f"Client received it's user object: {self.cloudlink.myClientObject}") + + # Check if a ping was successful + if (message["listener"] == "ping_handler") and (message["code"] == self.supporter.codes["OK"]): + self.supporter.log("Last automatic ping return was successful!") + + else: + self.supporter.log(f"Client received status code: {message['code']}") + + # Fire callbacks + if self.statuscode in self.cloudlink.usercallbacks: + if self.cloudlink.usercallbacks[self.statuscode] != None: + self.cloudlink.usercallbacks[self.statuscode](message["code"]) + + # Global messages + def gmsg(self, message): + self.supporter.log(f"Client received global message with data \"{message['val']}\"") + + # Fire callbacks + if self.gmsg in self.cloudlink.usercallbacks: + if self.cloudlink.usercallbacks[self.gmsg] != None: + self.cloudlink.usercallbacks[self.gmsg](message["val"]) + + # Global cloud variables + def gvar(self, message): + self.supporter.log(f"Client received global variable with data \"{message['val']}\"") + + # Fire callbacks + if self.gvar in self.cloudlink.usercallbacks: + if self.cloudlink.usercallbacks[self.gvar] != None: + self.cloudlink.usercallbacks[self.gvar](var_name=message["name"], var_value=message["val"]) + + # Private cloud variables + def pvar(self, message): + self.supporter.log(f"Client received private message with data \"{message['val']}\"") + + # Fire callbacks + if self.pvar in self.cloudlink.usercallbacks: + if self.cloudlink.usercallbacks[self.pvar] != None: + self.cloudlink.usercallbacks[self.pvar](var_name=message["name"], var_value=message["val"], origin=message["origin"]) + + # Private messages + def pmsg(self, message): + self.supporter.log(f"Client received private message with data \"{message['val']}\"") + + # Fire callbacks + if self.pmsg in self.cloudlink.usercallbacks: + if self.cloudlink.usercallbacks[self.pmsg] != None: + self.cloudlink.usercallbacks[self.pmsg](value=message["val"], origin=message["origin"]) + + # Pings + def ping(self, message): + self.supporter.log(f"Client received a ping from {message['origin']}!") + self.cloudlink.sendPacket({"cmd": "statuscode", "val": self.supporter.codes["OK"], "id": message["origin"], "listener": "ping_handler"}) + + # Fire callbacks + if self.ping in self.cloudlink.usercallbacks: + if self.cloudlink.usercallbacks[self.ping] != None: + self.cloudlink.usercallbacks[self.ping](value=message["val"], origin=message["origin"]) + + # WIP + def relay(self, message): + pass \ No newline at end of file diff --git a/cloudlink/client/clientRootHandlers.py b/cloudlink/client/clientRootHandlers.py new file mode 100644 index 0000000..223a1c4 --- /dev/null +++ b/cloudlink/client/clientRootHandlers.py @@ -0,0 +1,112 @@ +class clientRootHandlers: + """ + The clientRootHandlers inter class is an interface for the WebsocketClient to communicate with Cloudlink. + Cloudlink.clientRootHandlers.onPacket talks to the Cloudlink.packetHandler function to handle packets, + which will call upon Cloudlink.internalHandlers or some other external, custom code (if used). + """ + + def __init__(self, cloudlink): + self.cloudlink = cloudlink + self.supporter = self.cloudlink.supporter + + def on_error(self, ws, error): + if not error == None: + return + self.supporter.log(f"Client error: {error}") + + # Fire callbacks + if self.on_error in self.cloudlink.usercallbacks: + if self.cloudlink.usercallbacks[self.on_error] != None: + self.cloudlink.usercallbacks[self.on_error](error=error) + + def on_connect(self, ws): + self.supporter.log(f"Client connected.") + self.cloudlink.linkStatus = 2 + self.cloudlink.connected = True + + # Fire callbacks + if self.on_connect in self.cloudlink.usercallbacks: + if self.cloudlink.usercallbacks[self.on_connect] != None: + self.cloudlink.usercallbacks[self.on_connect]() + + def on_close(self, ws, close_status_code, close_msg): + if self.cloudlink.linkStatus == 1: + self.cloudlink.linkStatus = 4 + self.cloudlink.failedToConnect = True + self.supporter.log(f"Client failed to connect! Disconnected with status code {close_status_code} and message \"{close_msg}\"") + elif self.cloudlink.linkStatus == 2: + self.cloudlink.linkStatus = 4 + self.cloudlink.connectionLost = True + self.supporter.log(f"Client lost connection! Disconnected with status code {close_status_code} and message \"{close_msg}\"") + else: + self.cloudlink.linkStatus = 3 + self.supporter.log(f"Client gracefully disconnected! Disconnected with status code {close_status_code} and message \"{close_msg}\"") + self.cloudlink.connected = False + + # Fire callbacks + if self.on_close in self.cloudlink.usercallbacks: + if self.cloudlink.usercallbacks[self.on_close] != None: + self.cloudlink.usercallbacks[self.on_close](close_status_code=close_status_code, close_msg=close_msg) + + def on_packet(self, ws, message): + if len(message) != 0: + try: + isPacketSane = self.supporter.isPacketSane(message) + if isPacketSane: + message = self.supporter.json.loads(message) + + # Convert keys in the packet to proper JSON (Primarily for Scratch-based clients) + for key in message.keys(): + if type(message[key]) == str: + if self.supporter.isJSON(message[key]): + message[key] = self.supporter.json.loads(message[key]) + + # Check if the command is a built-in Cloudlink command + if ((message["cmd"] in self.cloudlink.builtInCommands) and not(message["cmd"] == "direct")): + getattr(self.cloudlink, str(message["cmd"]))(message) + # Fire callbacks + if self.on_packet in self.cloudlink.usercallbacks: + if self.cloudlink.usercallbacks[self.on_packet] != None: + self.cloudlink.usercallbacks[self.on_packet](message=message) + else: + # Attempt to read the command as a direct or custom command + isCustom = False + isLegacy = False + isValid = True + if message["cmd"] in self.cloudlink.customCommands: + # New custom command system. + isCustom = True + elif message["cmd"] == "direct": + if self.supporter.isPacketSane(message["val"]): + if type(message["val"]) == dict: + if message["val"]["cmd"] in self.cloudlink.customCommands: + # Legacy custom command system (using direct) + isLegacy = True + else: + isCustom = True + else: + isValid = False + if isValid: + if isLegacy: + self.supporter.log(f"Client recieved legacy custom command \"{message['val']['cmd']}\"") + getattr(self.cloudlink, str(message["val"]["cmd"]))(message=message) + else: + if isCustom: + self.supporter.log(f"Client recieved custom command \"{message['cmd']}\"") + getattr(self.cloudlink, str(message["cmd"]))(message=message) + else: + getattr(self.cloudlink, "direct")(message=message) + + # Fire callbacks + if self.on_packet in self.cloudlink.usercallbacks: + if self.cloudlink.usercallbacks[self.on_packet] != None: + self.cloudlink.usercallbacks[self.on_packet](message=message) + else: + if message["cmd"] in self.cloudlink.disabledCommands: + self.supporter.log(f"Client recieved command \"{message['cmd']}\", but the command is disabled.") + else: + self.supporter.log(f"Client recieved command \"{message['cmd']}\", but the command is invalid or it was not loaded.") + else: + self.supporter.log(f"Packet \"{message}\" is invalid, incomplete, or malformed!") + except: + self.supporter.log(f"An exception has occurred. {self.supporter.full_stack()}") \ No newline at end of file diff --git a/cloudlink/cloudlink.py b/cloudlink/cloudlink.py index b9d1010..2d407d8 100644 --- a/cloudlink/cloudlink.py +++ b/cloudlink/cloudlink.py @@ -1,7 +1,9 @@ #!/usr/bin/env python3 +from .supporter import supporter + """ -CloudLink Server +CloudLink 4.0 Server and Client CloudLink is a free and open-source, websocket-powered API optimized for Scratch 3.0. For documentation, please visit https://hackmd.io/g6BogABhT6ux1GA2oqaOXA @@ -9,22 +11,27 @@ Server based on https://github.com/Pithikos/python-websocket-server The WebsocketServer that is bundled with CloudLink is modified to support Cloudflared and fixes an issue with asserting the HTTP websocket upgrade request. +Client based upon https://github.com/websocket-client/websocket-client + Please see https://github.com/MikeDev101/cloudlink for more details. """ class Cloudlink: def __init__(self): - self.version = "0.1.8.2" + self.version = "0.1.8.3" + self.supporter = supporter + print(f"Cloudlink v{self.version}") def server(self, logs=False): # Initialize Cloudlink server from .server import server return server(self, logs) - def client(self, server_ip = "ws://127.0.0.1:3000/", logs=False): - # TODO - pass + def client(self, logs=False): + # Initialize Cloudlink client + from .client import client + return client(self, logs) - def relay(self, server_ip = "ws://127.0.0.1:3000/", relay_ip = "127.0.0.1", relay_port = 3000, logs=False): - # TODO + def relay(self, logs=False): + # TODO: Client and server modes now exist together, still need to finish spec and functionality for Relay mode pass diff --git a/cloudlink/docs/docs.txt b/cloudlink/docs/docs.txt new file mode 100644 index 0000000..c7f9328 --- /dev/null +++ b/cloudlink/docs/docs.txt @@ -0,0 +1 @@ +https://hackmd.io/g6BogABhT6ux1GA2oqaOXA \ No newline at end of file diff --git a/cloudlink/server/__init__.py b/cloudlink/server/__init__.py new file mode 100644 index 0000000..2787d2b --- /dev/null +++ b/cloudlink/server/__init__.py @@ -0,0 +1 @@ +from .server import * \ No newline at end of file diff --git a/cloudlink/server.py b/cloudlink/server/server.py similarity index 73% rename from cloudlink/server.py rename to cloudlink/server/server.py index 057a214..3e7969e 100644 --- a/cloudlink/server.py +++ b/cloudlink/server/server.py @@ -1,27 +1,23 @@ from .websocket_server import WebsocketServer -from .supporter import supporter from .serverRootHandlers import serverRootHandlers from .serverInternalHandlers import serverInternalHandlers class server: - def __init__(self, parentCl, enable_logs): + def __init__(self, parentCl, enable_logs=True): # Read the CloudLink version from the parent class self.version = parentCl.version - # Init stuff for the server + # Init the server self.userlist = [] - - # Rooms system - self.roomData = { - "default": set() - } - self.motd_enable = False self.motd_msg = "" self.global_msg = "" + self.roomData = { + "default": set() + } # Init modules - self.supporter = supporter(self, enable_logs) + self.supporter = parentCl.supporter(self, enable_logs, 1) self.serverRootHandlers = serverRootHandlers(self) self.serverInternalHandlers = serverInternalHandlers(self) @@ -30,7 +26,7 @@ def __init__(self, parentCl, enable_logs): self.customCommands = [] self.disabledCommands = [] self.usercallbacks = {} - self.supporter.loadBuiltinCommands() + self.supporter.loadBuiltinCommands(self.serverInternalHandlers) # Extra configuration self.ipblocklist = [] # Use to block IP addresses @@ -50,12 +46,15 @@ def __init__(self, parentCl, enable_logs): self.getUsernames = self.supporter.getUsernames self.setClientUsername = self.supporter.setClientUsername self.log = self.supporter.log + self.callback = self.supporter.callback - # Create stuff for the callback system + # Create callbacks, command-specific callbacks are not needed in server mode self.on_packet = self.serverRootHandlers.on_packet self.on_connect = self.serverRootHandlers.on_connect self.on_close = self.serverRootHandlers.on_close + self.log("Cloudlink server initialized!") + def run(self, port=3000, host="127.0.0.1"): # Initialize the Websocket Server self.log("Cloudlink server starting up now...") @@ -77,18 +76,4 @@ def setMOTD(self, enable:bool, msg:str): self.motd_enable = enable self.motd_msg = msg - def callback(self, callback_id, function): - # Support older servers which use the old callback system. - if type(callback_id) == str: - callback_id = getattr(self, callback_id) - - # New callback system. - if callable(callback_id): - if callback_id == self.on_packet: - self.usercallbacks[self.on_packet] = function - elif callback_id == self.on_connect: - self.usercallbacks[self.on_connect] = function - elif callback_id == self.on_close: - self.usercallbacks[self.on_close] = function - elif callback_id == self.on_error: - self.usercallbacks[self.on_error] = function + diff --git a/cloudlink/server/serverInternalHandlers.py b/cloudlink/server/serverInternalHandlers.py new file mode 100644 index 0000000..10bc680 --- /dev/null +++ b/cloudlink/server/serverInternalHandlers.py @@ -0,0 +1,411 @@ +class serverInternalHandlers(): + """ + The serverInternalHandlers inter class serves as the server's built-in command handler. + These commands are hard-coded per-spec as outlined in the CLPv4 (Cloudlink Protocol) guideline. + """ + + def __init__(self, cloudlink): + self.cloudlink = cloudlink + self.supporter = self.cloudlink.supporter + self.importer_ignore_functions = ["relay"] + + # Manually fetch the userlist + def ulist(self, client, server, message, listener_detected, listener_id, room_id): + for room in client.rooms: + ulist = [] + for user in self.supporter.getUsernames(room): + ulist.append(user) + self.cloudlink.sendPacket(client, {"cmd": "ulist", "val": ulist}, rooms = room) + + # Status codes (For client-to-client) + def statuscode(self, client, server, message, listener_detected, listener_id, room_id): + # Sanity check the message + for key in ["val", "id"]: + if not key in message: + self.cloudlink.sendCode(client, "Syntax", listener_detected, listener_id) + return + + if type(message["id"]) == list: + rx_client = self.supporter.selectMultiUserObjects(message["id"]) + if not(len(rx_client) == 0): + # Send the message to all recipient IDs + self.supporter.log(f"Client {client.id} ({client.full_ip}) is sending a status code to other clients!") + self.cloudlink.sendPacket(rx_client, {"cmd": "statuscode", "code": message["val"], "origin": self.supporter.getUserObjectFromClientObj(client)}) + + # Tell the client that all status codes were successfully sent + self.cloudlink.sendCode(client, "OK", listener_detected, listener_id) + else: + # Tell the client that the server failed to find a client with those IDs + self.cloudlink.sendCode(client, "IDNotFound", listener_detected, listener_id) + else: + rx_client = self.supporter.getUserObject(message["id"]) + if rx_client == None: + # Tell the client that the server failed to find a client with that ID + self.cloudlink.sendCode(client, "IDNotFound", listener_detected, listener_id) + return + + if rx_client == LookupError: + # Tell the client that the server needs the ID to be more specific + self.cloudlink.sendCode(client, "IDNotSpecific", listener_detected, listener_id) + return + + if rx_client == TypeError: + # Tell the client it sent an unsupported datatype + self.cloudlink.sendCode(client, "DataType", listener_detected, listener_id) + return + + # Send the status code to the recipient ID + self.supporter.log(f"Client {client.id} ({client.full_ip}) is sending a status code to {rx_client.id} ({rx_client.full_ip})!") + self.cloudlink.sendPacket(rx_client, {"cmd": "statuscode", "code": message["val"], "origin": self.supporter.getUserObjectFromClientObj(client)}, rooms = room_id) + + # Tell the client that the status code was successfully sent + self.cloudlink.sendCode(client, "OK", listener_detected, listener_id) + + # Ping + def ping(self, client, server, message, listener_detected, listener_id, room_id): + # Check if the client is pinging the server only + if not "id" in message: + self.supporter.log(f"Client {client.id} ({client.full_ip}) pinged the server!") + self.cloudlink.sendCode(client, "OK", listener_detected, listener_id) + return + + # Prevent clients without usernames from linking + if not client.username_set: + self.cloudlink.sendCode(client, "IDRequired", listener_detected, listener_id) + return + + # Sanity check the message + for key in ["val", "id"]: + if not key in message: + self.cloudlink.sendCode(client, "Syntax", listener_detected, listener_id) + return + + if type(message["id"]) == list: + rx_client = self.supporter.selectMultiUserObjects(message["id"]) + if not(len(rx_client) == 0): + # Send the message to all recipient IDs + self.supporter.log(f"Client {client.id} ({client.full_ip}) is pinging other clients!") + self.cloudlink.sendPacket(rx_client, {"cmd": "ping", "val": message["val"], "origin": self.supporter.getUserObjectFromClientObj(client)}) + # Assume that each of the client's recipients will respond with a statuscode + else: + # Tell the client that the server failed to find a client with those IDs + self.cloudlink.sendCode(client, "IDNotFound", listener_detected, listener_id) + else: + rx_client = self.supporter.getUserObject(message["id"]) + if rx_client == None: + # Tell the client that the server failed to find a client with that ID + self.cloudlink.sendCode(client, "IDNotFound", listener_detected, listener_id) + return + + if rx_client == LookupError: + # Tell the client that the server needs the ID to be more specific + self.cloudlink.sendCode(client, "IDNotSpecific", listener_detected, listener_id) + return + + if rx_client == TypeError: + # Tell the client it sent an unsupported datatype + self.cloudlink.sendCode(client, "DataType", listener_detected, listener_id) + return + + self.supporter.log(f"Client {client.id} ({client.full_ip}) is sending a ping to {rx_client.id} ({rx_client.full_ip})!") + # Send the ping to the recipient ID + self.cloudlink.sendPacket(rx_client, {"cmd": "ping", "val": message["val"], "origin": self.supporter.getUserObjectFromClientObj(client)}, rooms = room_id) + # Assume that the client's recipient will respond with a statuscode + + # Link client to a room/rooms + def link(self, client, server, message, listener_detected, listener_id, room_id): + self.supporter.log(f"Client {client.id} ({client.full_ip}) is linking to room(s): {message['val']}") + + # Temporarily save the client's old rooms data + old_rooms = client.rooms + + # Prevent clients without usernames from linking + if not client.username_set: + self.cloudlink.sendCode(client, "IDRequired", listener_detected, listener_id) + return + + # Sanity check the message + if not "val" in message: + self.cloudlink.sendCode(client, "Syntax", listener_detected, listener_id) + return + + if type(message["val"]) in [list, str]: + # Convert to set + if type(message["val"]) == str: + message["val"] = set([message["val"]]) + elif type(message["val"]) == list: + message["val"] = set(message["val"]) + + # Remove the client from all rooms and set their room to the default room + self.supporter.unlinkClientFromRooms(client) + + # Add client to rooms + self.supporter.linkClientToRooms(client, message["val"]) + + # Tell the client that they were linked + self.cloudlink.sendCode(client, "OK", listener_detected, listener_id) + + # Update all client ulists for the default room + if "default" in old_rooms: + self.cloudlink.sendPacket(self.supporter.getAllUsersInRoom("default"), {"cmd": "ulist", "val": self.supporter.getUsernames()}) + + # Update all client ulists in the rooms that the client joined + self.cloudlink.sendPacket(self.supporter.getAllUsersInManyRooms(message["val"]), {"cmd": "ulist", "val": self.supporter.getUsernames(message["val"])}, rooms = message["val"]) + + else: + self.cloudlink.sendCode(client, "Datatype", listener_detected, listener_id) + + # Unlink client from all rooms, and then link the client to the default room + def unlink(self, client, server, message, listener_detected, listener_id, room_id): + self.supporter.log(f"Client {client.id} ({client.full_ip}) unlinking from all rooms") + + # Prevent clients without usernames from using this command + if not client.username_set: + self.cloudlink.sendCode(client, "IDRequired", listener_detected, listener_id) + return + + if client.is_linked: + # Temporarily save the client's old rooms data + old_rooms = client.rooms + + # Remove the client from all rooms and set their room to the default room + self.supporter.unlinkClientFromRooms(client) + + # Tell the client that they were unlinked + self.cloudlink.sendCode(client, "OK", listener_detected, listener_id) + + # Update all client ulists for the default room + self.cloudlink.sendPacket(self.supporter.getAllUsersInRoom("default"), {"cmd": "ulist", "val": self.supporter.getUsernames()}) + + # Update all client ulists in the room that the client left + self.cloudlink.sendPacket(self.supporter.getAllUsersInManyRooms(old_rooms), {"cmd": "ulist", "val": self.supporter.getUsernames(old_rooms)}, rooms = old_rooms) + else: + self.supporter.log(f"Client {client.id} ({client.full_ip}) was already unlinked!") + # Tell the client that it was already unlinked + self.cloudlink.sendCode(client, "OK", listener_detected, listener_id) + + # Give the client the default room ulist + self.cloudlink.sendPacket(client, {"cmd": "ulist", "val": self.supporter.getUsernames()}) + + # Direct messages, this command is pretty obsolete, since custom commands are loaded directly into Cloudlink instead of using direct in CL3. Idfk what this should do. + def direct(self, client, server, message, listener_detected, listener_id, room_id): + # Sanity check the message + if not "id" in message: + self.cloudlink.sendCode(client, "Syntax", listener_detected, listener_id) + return + + self.supporter.log(f"Client {client.id} ({client.full_ip}) sent direct data: \"{message}\"") + + if type(message["id"]) == list: + rx_client = self.supporter.selectMultiUserObjects(message["id"]) + if not(len(rx_client) == 0): + # Send the message to all recipient IDs + self.supporter.log(f"Client {client.id} ({client.full_ip}) is sending direct data to various clients!") + self.cloudlink.sendPacket(rx_client, {"cmd": "direct", "val": message["val"], "origin": self.supporter.getUserObjectFromClientObj(client)}) + + # Tell the client that all direct data were successfully sent + self.cloudlink.sendCode(client, "OK", listener_detected, listener_id) + else: + # Tell the client that the server failed to find a client with those IDs + self.cloudlink.sendCode(client, "IDNotFound", listener_detected, listener_id) + else: + rx_client = self.supporter.getUserObject(message["id"]) + if rx_client == None: + # Tell the client that the server failed to find a client with that ID + self.cloudlink.sendCode(client, "IDNotFound", listener_detected, listener_id) + return + + if rx_client == LookupError: + # Tell the client that the server needs the ID to be more specific + self.cloudlink.sendCode(client, "IDNotSpecific", listener_detected, listener_id) + return + + if rx_client == TypeError: + # Tell the client it sent an unsupported datatype + self.cloudlink.sendCode(client, "DataType", listener_detected, listener_id) + return + + self.supporter.log(f"Client {client.id} ({client.full_ip}) is sending direct data to {rx_client.id} ({rx_client.full_ip})!") + # Send the ping to the recipient ID + self.cloudlink.sendPacket(rx_client, {"cmd": "direct", "val": message["val"], "name": message["name"], "origin": self.supporter.getUserObjectFromClientObj(client)}, rooms = room_id) + + # Tell the client that the direct data was successfully sent + self.cloudlink.sendCode(client, "OK", listener_detected, listener_id) + + # Global messages + def gmsg(self, client, server, message, listener_detected, listener_id, room_id): + self.supporter.log(f"Client {client.id} ({client.full_ip}) sent global message with data \"{message['val']}\"") + + # Send the message to all clients except the origin + ulist = self.supporter.getAllUsersInManyRooms(room_id) + ulist.remove(client) + msg = { + "cmd": "gmsg", + "val": message["val"] + } + self.cloudlink.sendPacket(ulist, msg, rooms=room_id) + + # Send the message back to origin + self.cloudlink.sendPacket(client, msg, listener_detected, listener_id, room_id) + + # Cache the last message for new clients + self.cloudlink.global_msg = message["val"] + + # Global cloud variables + def gvar(self, client, server, message, listener_detected, listener_id, room_id): + + # Sanity check the message + for key in ["val", "name"]: + if not key in message: + self.cloudlink.sendCode(client, "Syntax", listener_detected, listener_id) + return + + self.supporter.log(f"Client {client.id} ({client.full_ip}) sent global variable with data \"{message['val']}\"") + + # Send the message to all clients except the origin + ulist = self.supporter.getAllUsersInManyRooms(room_id) + ulist.remove(client) + msg = { + "cmd": "gvar", + "val": message["val"], + "name": message["name"] + } + self.cloudlink.sendPacket(ulist, msg, rooms=room_id) + + # Send the message back to origin + self.cloudlink.sendPacket(client, msg, listener_detected, listener_id, room_id) + + # Private cloud variables + def pvar(self, client, server, message, listener_detected, listener_id, room_id): + # Prevent clients without usernames from using this command + if not client.username_set: + self.cloudlink.sendCode(client, "IDRequired", listener_detected, listener_id) + return + + # Sanity check the message + for key in ["val", "name", "id"]: + if not key in message: + self.cloudlink.sendCode(client, "Syntax", listener_detected, listener_id) + return + + if type(message["id"]) == list: + rx_client = self.supporter.selectMultiUserObjects(message["id"]) + if not(len(rx_client) == 0): + # Send the message to all recipient IDs + self.supporter.log(f"Client {client.id} ({client.full_ip}) sent private variable data \"{message['val']}\" going to various clients!") + self.cloudlink.sendPacket(rx_client, {"cmd": "pvar", "val": message["val"], "name": message["name"], "origin": self.supporter.getUserObjectFromClientObj(client)}, rooms = room_id) + + # Tell the client that the messages were successfully sent + self.cloudlink.sendCode(client, "OK", listener_detected, listener_id) + else: + # Tell the client that the server failed to find a client with those IDs + self.cloudlink.sendCode(client, "IDNotFound", listener_detected, listener_id) + else: + rx_client = self.supporter.getUserObject(message["id"]) + if rx_client == None: + # Tell the client that the server failed to find a client with that ID + self.cloudlink.sendCode(client, "IDNotFound", listener_detected, listener_id) + return + + if rx_client == LookupError: + # Tell the client that the server needs the ID to be more specific + self.cloudlink.sendCode(client, "IDNotSpecific", listener_detected, listener_id) + return + + if rx_client == TypeError: + # Tell the client it sent an unsupported datatype + self.cloudlink.sendCode(client, "DataType", listener_detected, listener_id) + return + + # Send the message to the recipient ID + self.supporter.log(f"Client {client.id} ({client.full_ip}) sent private variable data \"{message['val']}\" going to {rx_client.id} ({rx_client.full_ip})!") + self.cloudlink.sendPacket(rx_client, {"cmd": "pvar", "val": message["val"], "name": message["name"], "origin": self.supporter.getUserObjectFromClientObj(client)}, rooms = room_id) + + # Tell the client that the message was successfully sent + self.cloudlink.sendCode(client, "OK", listener_detected, listener_id) + + # Private messages + def pmsg(self, client, server, message, listener_detected, listener_id, room_id): + # Prevent clients without usernames from using this command + if not client.username_set: + self.cloudlink.sendCode(client, "IDRequired", listener_detected, listener_id) + return + + # Sanity check the message + for key in ["val", "id"]: + if not key in message: + self.cloudlink.sendCode(client, "Syntax", listener_detected, listener_id) + return + + if type(message["id"]) == list: + rx_client = self.supporter.selectMultiUserObjects(message["id"]) + if not(len(rx_client) == 0): + # Send the message to all recipient IDs + self.supporter.log(f"Client {client.id} ({client.full_ip}) sent private messsage data \"{message['val']}\" going to various clients!") + self.cloudlink.sendPacket(rx_client, {"cmd": "pmsg", "val": message["val"], "origin": self.supporter.getUserObjectFromClientObj(client)}) + + # Tell the client that the messages were successfully sent + self.cloudlink.sendCode(client, "OK", listener_detected, listener_id) + else: + # Tell the client that the server failed to find a client with those IDs + self.cloudlink.sendCode(client, "IDNotFound", listener_detected, listener_id) + else: + rx_client = self.supporter.getUserObject(message["id"]) + if rx_client == None: + # Tell the client that the server failed to find a client with that ID + self.cloudlink.sendCode(client, "IDNotFound", listener_detected, listener_id) + return + + if rx_client == LookupError: + # Tell the client that the server needs the ID to be more specific + self.cloudlink.sendCode(client, "IDNotSpecific", listener_detected, listener_id) + return + + if rx_client == TypeError: + # Tell the client it sent an unsupported datatype + self.cloudlink.sendCode(client, "DataType", listener_detected, listener_id) + return + + # Send the message to the recipient ID + self.supporter.log(f"Client {client.id} ({client.full_ip}) sent private message data \"{message['val']}\" going to {rx_client.id} ({rx_client.full_ip})!") + self.cloudlink.sendPacket(rx_client, {"cmd": "pmsg", "val": message["val"], "origin": self.supporter.getUserObjectFromClientObj(client)}, rooms = room_id) + + # Tell the client that the message was successfully sent + self.cloudlink.sendCode(client, "OK", listener_detected, listener_id) + + # Set username + def setid(self, client, server, message, listener_detected, listener_id, room_id): + # Prevent clients from being able to rewrite their username + if client.username_set: + self.supporter.log(f"Client {client.id} ({client.full_ip}) specified username \"{message['val']}\", but username was already set to \"{client.friendly_username}\"") + self.cloudlink.sendCode(client, "IDSet", listener_detected, listener_id) + return + + # Only support strings for usernames + if not type(message["val"]) == str: + self.supporter.log(f"Client {client.id} ({client.full_ip}) specified username \"{message['val']}\", but username is not the correct datatype!") + self.cloudlink.sendCode(client, "DataType", listener_detected, listener_id) + return + + # Keep username sizes within a reasonable length + if not len(message["val"]) in range(1, 21): + self.supporter.log(f"Client {client.id} ({client.full_ip}) specified username \"{message['val']}\", but username is not within 1-20 characters!") + self.cloudlink.sendCode(client, "Refused", listener_detected, listener_id) + return + + self.supporter.log(f"Client {client.id} ({client.full_ip}) specified username \"{message['val']}\"") + client.friendly_username = str(message["val"]) + client.username_set = True + # Report to the client that the username was accepted + msg = { + "username": client.friendly_username, + "id": client.id + } + self.cloudlink.sendCode(client, "OK", listener_detected, listener_id, msg) + + # Update all clients with the updated userlist + self.cloudlink.sendPacket(self.supporter.getAllUsersInRoom("default"), {"cmd": "ulist", "val": self.supporter.getUsernames()}) + + # WIP + def relay(self, client, server, message, listener_detected, listener_id, room_id): + pass \ No newline at end of file diff --git a/cloudlink/serverRootHandlers.py b/cloudlink/server/serverRootHandlers.py similarity index 92% rename from cloudlink/serverRootHandlers.py rename to cloudlink/server/serverRootHandlers.py index 3d2f477..efd28ab 100644 --- a/cloudlink/serverRootHandlers.py +++ b/cloudlink/server/serverRootHandlers.py @@ -47,6 +47,7 @@ def on_connect(self, client, server): if self.cloudlink.motd_enable: self.cloudlink.sendPacket(client, {"cmd": "motd", "val": self.cloudlink.motd_msg}, ignore_rooms = True) + # Fire callbacks if self.on_connect in self.cloudlink.usercallbacks: if self.cloudlink.usercallbacks[self.on_connect] != None: self.cloudlink.usercallbacks[self.on_connect](client=client, server=server) @@ -64,6 +65,7 @@ def on_close(self, client, server): ulist = self.supporter.getUsernames(room) self.supporter.sendPacket(clist, {"cmd": "ulist", "val": ulist}, rooms = room) + # Fire callbacks if self.on_close in self.cloudlink.usercallbacks: if self.cloudlink.usercallbacks[self.on_close] != None: self.cloudlink.usercallbacks[self.on_close](client=client, server=server) @@ -120,6 +122,10 @@ def on_packet(self, client, server, message): # Check if the command is a built-in Cloudlink command if ((message["cmd"] in self.cloudlink.builtInCommands) and not(message["cmd"] == "direct")): getattr(self.cloudlink, str(message["cmd"]))(client, server, message, listener_detected, listener_id, room_id) + # Fire callbacks + if self.on_packet in self.cloudlink.usercallbacks: + if self.cloudlink.usercallbacks[self.on_packet] != None: + self.cloudlink.usercallbacks[self.on_packet](client=client, server=server, message=message) else: # Attempt to read the command as a direct or custom command isCustom = False @@ -148,6 +154,11 @@ def on_packet(self, client, server, message): getattr(self.cloudlink, str(message["cmd"]))(client, server, message, listener_detected, listener_id, room_id) else: getattr(self.cloudlink, "direct")(client, server, message, listener_detected, listener_id, room_id) + + # Fire callbacks + if self.on_packet in self.cloudlink.usercallbacks: + if self.cloudlink.usercallbacks[self.on_packet] != None: + self.cloudlink.usercallbacks[self.on_packet](client=client, server=server, message=message) else: if message["cmd"] in self.cloudlink.disabledCommands: self.supporter.log(f"Client {client.id} ({client.full_ip}) sent custom command \"{message['cmd']}\", but the command is disabled.") @@ -155,10 +166,7 @@ def on_packet(self, client, server, message): else: self.supporter.log(f"Client {client.id} ({client.full_ip}) sent custom command \"{message['cmd']}\", but the command is invalid or it was not loaded.") self.cloudlink.sendCode(client, "Invalid") - - if self.on_packet in self.cloudlink.usercallbacks: - if self.cloudlink.usercallbacks[self.on_packet] != None: - self.cloudlink.usercallbacks[self.on_packet](client=client, server=server, message=message) + else: self.supporter.log(f"Packet \"{message}\" is invalid, incomplete, or malformed!") self.cloudlink.sendCode(client, "Syntax") diff --git a/cloudlink/websocket_server/__init__.py b/cloudlink/server/websocket_server/__init__.py similarity index 100% rename from cloudlink/websocket_server/__init__.py rename to cloudlink/server/websocket_server/__init__.py diff --git a/cloudlink/websocket_server/thread.py b/cloudlink/server/websocket_server/thread.py similarity index 100% rename from cloudlink/websocket_server/thread.py rename to cloudlink/server/websocket_server/thread.py diff --git a/cloudlink/websocket_server/websocket_server.py b/cloudlink/server/websocket_server/websocket_server.py similarity index 97% rename from cloudlink/websocket_server/websocket_server.py rename to cloudlink/server/websocket_server/websocket_server.py index 47cd904..1cda5aa 100644 --- a/cloudlink/websocket_server/websocket_server.py +++ b/cloudlink/server/websocket_server/websocket_server.py @@ -133,6 +133,7 @@ def __init__(self, host='127.0.0.1', port=0, loglevel=logging.WARNING, key=None, self.cert = cert self.clients = set() + self.idcounter = 0 self.thread = None self._deny_clients = False @@ -174,7 +175,9 @@ def _new_client_(self, handler): self._terminate_client_handler(handler) return - handler.id = len(self.clients) + 1 + handler.id = self.idcounter + self.idcounter += 1 + if type(handler.client_address) == tuple: handler.full_ip = f"{handler.client_address[0]}:{handler.client_address[1]}" handler.friendly_ip = handler.client_address[0] @@ -442,20 +445,9 @@ def handshake(self): self.keep_alive = False return - # Guard cases - ipcheck = False - - # Cloudflare support + # CloudFlared support if "cf-connecting-ip" in self.headers: - if not ipcheck: - ipcheck = True - self.client_address = self.headers["cf-connecting-ip"] - - # X-Forwarded-For support - if "x-forwarded-for" in self.headers: - if not ipcheck: - ipcheck = True - self.client_address = self.headers["x-forwarded-for"].split(",")[0] + self.client_address = self.headers["cf-connecting-ip"] response = self.make_handshake_response(key) with self._send_lock: diff --git a/cloudlink/serverInternalHandlers.py b/cloudlink/serverInternalHandlers.py deleted file mode 100644 index 00b1ce6..0000000 --- a/cloudlink/serverInternalHandlers.py +++ /dev/null @@ -1,227 +0,0 @@ -class serverInternalHandlers(): - """ - The serverInternalHandlers inter class serves as the server's built-in command handler. - These commands are hard-coded per-spec as outlined in the CLPv4 (Cloudlink Protocol) guideline. - """ - - def __init__(self, cloudlink): - self.cloudlink = cloudlink - self.supporter = self.cloudlink.supporter - self.importer_ignore_functions = ["relay"] - - # Ping - def ping(self, client, server, message, listener_detected, listener_id, room_id): - self.supporter.log(f"Client {client.id} ({client.full_ip}) pinged the server!") - self.cloudlink.sendCode(client, "OK", listener_detected, listener_id) - - # Link client to a room/rooms - def link(self, client, server, message, listener_detected, listener_id, room_id): - self.supporter.log(f"Client {client.id} ({client.full_ip}) is linking to room(s): {message['val']}") - if client.username_set: - if type(message["val"]) in [list, str]: - # Convert to set - if type(message["val"]) == str: - message["val"] = set([message["val"]]) - elif type(message["val"]) == list: - message["val"] = set(message["val"]) - - # Add client to rooms - self.supporter.linkClientToRooms(client, message["val"]) - - # Tell the client that they were linked - self.cloudlink.sendCode(client, "OK", listener_detected, listener_id) - - # Update all client ulists for the default room - self.cloudlink.sendPacket(self.supporter.getAllUsersInRoom("default"), {"cmd": "ulist", "val": self.supporter.getUsernames()}) - - # Update all client ulists in the rooms that the client joined - self.cloudlink.sendPacket(self.supporter.getAllUsersInManyRooms(message["val"]), {"cmd": "ulist", "val": self.supporter.getUsernames(message["val"])}, rooms = message["val"]) - - else: - self.cloudlink.sendCode(client, "Datatype", listener_detected, listener_id) - else: - self.cloudlink.sendCode(client, "IDRequired", listener_detected, listener_id) - - # Unlink client from all rooms, and then link the client to the default room - def unlink(self, client, server, message, listener_detected, listener_id, room_id): - self.supporter.log(f"Client {client.id} ({client.full_ip}) unlinking from all rooms") - if client.username_set: - if client.is_linked: - # Temporarily save the client's old rooms data - old_rooms = client.rooms - - # Remove the client from all rooms and set their room to the default room - self.supporter.unlinkClientFromRooms(client) - - # Tell the client that they were unlinked - self.cloudlink.sendCode(client, "OK", listener_detected, listener_id) - - # Update all client ulists for the default room - self.cloudlink.sendPacket(self.supporter.getAllUsersInRoom("default"), {"cmd": "ulist", "val": self.supporter.getUsernames()}) - - # Update all client ulists in the room that the client left - self.cloudlink.sendPacket(self.supporter.getAllUsersInManyRooms(old_rooms), {"cmd": "ulist", "val": self.supporter.getUsernames(old_rooms)}, rooms = old_rooms) - else: - self.supporter.log(f"Client {client.id} ({client.full_ip}) was already unlinked!") - # Tell the client that it was already unlinked - self.cloudlink.sendCode(client, "OK", listener_detected, listener_id) - - # Give the client the default room ulist - self.cloudlink.sendPacket(client, {"cmd": "ulist", "val": self.supporter.getUsernames()}) - else: - self.cloudlink.sendCode(client, "IDRequired", listener_detected, listener_id) - - # Direct messages, this command is pretty obsolete, since custom commands are loaded directly into Cloudlink instead of using direct in CL3. Idfk what this should do. - def direct(self, client, server, message, listener_detected, listener_id, room_id): - self.supporter.log(f"Client {client.id} ({client.full_ip}) sent direct data: \"{message}\"") - - # Global messages - def gmsg(self, client, server, message, listener_detected, listener_id, room_id): - - self.supporter.log(f"Client {client.id} ({client.full_ip}) sent global message with data \"{message['val']}\"") - - # Send the message to all clients except the origin - ulist = self.supporter.getAllUsersInManyRooms(room_id) - ulist.remove(client) - msg = { - "cmd": "gmsg", - "val": message["val"] - } - self.cloudlink.sendPacket(ulist, msg, rooms=room_id) - - # Send the message back to origin - self.cloudlink.sendPacket(client, msg, listener_detected, listener_id, room_id) - - # Cache the last message for new clients - self.cloudlink.global_msg = message["val"] - - # Global cloud variables - def gvar(self, client, server, message, listener_detected, listener_id, room_id): - self.supporter.log(f"Client {client.id} ({client.full_ip}) sent global variable with data \"{message['val']}\"") - - # Send the message to all clients except the origin - ulist = self.supporter.getAllUsersInManyRooms(room_id) - ulist.remove(client) - msg = { - "cmd": "gvar", - "val": message["val"], - "name": message["name"] - } - self.cloudlink.sendPacket(ulist, msg, rooms=room_id) - - # Send the message back to origin - self.cloudlink.sendPacket(client, msg, listener_detected, listener_id, room_id) - - # Private cloud variables - def pvar(self, client, server, message, listener_detected, listener_id, room_id): - self.supporter.log(f"Client {client.id} ({client.full_ip}) sent private message with data \"{message['val']}\" going to {message['id']}") - if client.username_set: - if type(message["id"]) == list: - rx_client = self.supporter.selectMultiUserObjects(message["id"]) - if not(len(rx_client) == 0): - # Send the message to all recipient IDs - self.cloudlink.sendPacket(rx_client, {"cmd": "pvar", "val": message["val"], "name": message["name"], "origin": self.supporter.getUserObjectFromClientObj(client)}, rooms = room_id) - - # Tell the client that the messages were successfully sent - self.cloudlink.sendCode(client, "OK", listener_detected, listener_id) - else: - # Tell the client that the server failed to find a client with those IDs - self.cloudlink.sendCode(client, "IDNotFound", listener_detected, listener_id) - else: - rx_client = self.supporter.getUserObject(message["id"]) - if rx_client == None: - # Tell the client that the server failed to find a client with that ID - self.cloudlink.sendCode(client, "IDNotFound", listener_detected, listener_id) - - elif rx_client == LookupError: - # Tell the client that the server needs the ID to be more specific - self.cloudlink.sendCode(client, "IDNotSpecific", listener_detected, listener_id) - - elif rx_client == TypeError: - # Tell the client it sent an unsupported datatype - self.cloudlink.sendCode(client, "DataType", listener_detected, listener_id) - - else: - # Send the message to the recipient ID - self.cloudlink.sendPacket(rx_client, {"cmd": "pvar", "val": message["val"], "name": message["name"], "origin": self.supporter.getUserObjectFromClientObj(client)}, rooms = room_id) - - # Tell the client that the message was successfully sent - self.cloudlink.sendCode(client, "OK", listener_detected, listener_id) - else: - # Tell the client that it needs to set a username first - self.cloudlink.sendPacket(client, {"cmd": "statuscode", "code": self.supporter.codes["IDRequired"]}, listener_detected, listener_id, room_id) - - # Private messages - def pmsg(self, client, server, message, listener_detected, listener_id, room_id): - self.supporter.log(f"Client {client.id} ({client.full_ip}) sent private message with data \"{message['val']}\" going to {message['id']}") - if client.username_set: - if type(message["id"]) == list: - rx_client = self.supporter.selectMultiUserObjects(message["id"]) - if not(len(rx_client) == 0): - # Send the message to all recipient IDs - self.cloudlink.sendPacket(rx_client, {"cmd": "pmsg", "val": message["val"], "origin": self.supporter.getUserObjectFromClientObj(client)}) - - # Tell the client that the messages were successfully sent - self.cloudlink.sendCode(client, "OK", listener_detected, listener_id) - else: - # Tell the client that the server failed to find a client with those IDs - self.cloudlink.sendCode(client, "IDNotFound", listener_detected, listener_id) - else: - rx_client = self.supporter.getUserObject(message["id"]) - if rx_client == None: - # Tell the client that the server failed to find a client with that ID - self.cloudlink.sendCode(client, "IDNotFound", listener_detected, listener_id) - - elif rx_client == LookupError: - # Tell the client that the server needs the ID to be more specific - self.cloudlink.sendCode(client, "IDNotSpecific", listener_detected, listener_id) - - elif rx_client == TypeError: - # Tell the client it sent an unsupported datatype - self.cloudlink.sendCode(client, "DataType", listener_detected, listener_id) - - else: - # Send the message to the recipient ID - self.cloudlink.sendPacket(rx_client, {"cmd": "pmsg", "val": message["val"], "origin": self.supporter.getUserObjectFromClientObj(client)}, rooms = room_id) - - # Tell the client that the message was successfully sent - self.cloudlink.sendCode(client, "OK", listener_detected, listener_id) - else: - # Tell the client that it needs to set a username first - self.cloudlink.sendPacket(client, {"cmd": "statuscode", "code": self.supporter.codes["IDRequired"]}, listener_detected, listener_id, room_id) - - # Set username - def setid(self, client, server, message, listener_detected, listener_id, room_id): - # Prevent clients from being able to rewrite their username - if not client.username_set: - # Only support strings for usernames - if type(message["val"]) == str: - # Keep username sizes within a reasonable length - if len(message["val"]) in range(1, 21): - self.supporter.log(f"Client {client.id} ({client.full_ip}) specified username \"{message['val']}\"") - client.friendly_username = str(message["val"]) - client.username_set = True - - # Report to the client that the username was accepted - msg = { - "username": client.friendly_username, - "id": client.id - } - self.cloudlink.sendCode(client, "OK", listener_detected, listener_id, msg) - - # Update all clients with the updated userlist - self.cloudlink.sendPacket(self.supporter.getAllUsersInRoom("default"), {"cmd": "ulist", "val": self.supporter.getUsernames()}) - - else: - self.supporter.log(f"Client {client.id} ({client.full_ip}) specified username \"{message['val']}\", but username is not within 1-20 characters!") - self.cloudlink.sendCode(client, "Refused", listener_detected, listener_id) - else: - self.supporter.log(f"Client {client.id} ({client.full_ip}) specified username \"{message['val']}\", but username is not the correct datatype!") - self.cloudlink.sendCode(client, "DataType", listener_detected, listener_id) - else: - self.supporter.log(f"Client {client.id} ({client.full_ip}) specified username \"{message['val']}\", but username was already set to \"{client.friendly_username}\"") - self.cloudlink.sendCode(client, "IDSet", listener_detected, listener_id) - - # WIP - def relay(self, client, server, message, listener_detected, listener_id, room_id): - pass \ No newline at end of file diff --git a/cloudlink/supporter.py b/cloudlink/supporter.py index c997a32..38f938a 100644 --- a/cloudlink/supporter.py +++ b/cloudlink/supporter.py @@ -5,10 +5,10 @@ class supporter: """ - This module provides extended functionality for Cloudlink. + This module provides extended functionality for Cloudlink. This class is shared between the server and client modes. """ - def __init__(self, cloudlink, enable_logs): + def __init__(self, cloudlink, enable_logs, mode:int): self.cloudlink = cloudlink self.json = json self.datetime = datetime @@ -28,6 +28,16 @@ def __init__(self, cloudlink, enable_logs): "Disabled": "E:110 | Command disabled", "IDRequired": "E:111 | ID required", } + + if mode == None: + raise TypeError("Mode cannot be None, or any value other than 1 or 0!") + else: + if mode == 1: + setattr(self, "sendPacket", self._sendPacket_server) + elif mode == 2: + setattr(self, "sendPacket", self._sendPacket_client) + else: + raise NotImplementedError("Invalid supporter mode") def sendCode(self, client:dict, code:str, listener_detected:bool = False, listener_id:str = "", extra_data = None): message = { @@ -42,8 +52,17 @@ def sendCode(self, client:dict, code:str, listener_detected:bool = False, listen message["listener"] = listener_id self.cloudlink.wss.send_message(client, self.json.dumps(message)) - - def sendPacket(self, clients, message:dict, listener_detected:bool = False, listener_id:str = "", rooms:list = None, ignore_rooms:bool = False): + + def _sendPacket_client(self, message): + try: + self.log(f"Sending packet: {message}") + self.cloudlink.wss.send(self.json.dumps(message)) + except BrokenPipeError: + self.log(f"Broken Pipe Error: Attempted to send packet {message}!") + except Exception as e: + self.log(f"Exception: {e}, Attempted to send packet {message}!") + + def _sendPacket_server(self, clients, message:dict, listener_detected:bool = False, listener_id:str = "", rooms:list = None, ignore_rooms:bool = False): if type(message) == dict: if self.isPacketSane(message, ["cmd"]): # Attach listener (if applied to origin message) @@ -220,6 +239,17 @@ def rejectClient(self, client, reason): client.send_close(1000, bytes(reason, encoding='utf-8')) self.cloudlink.wss._terminate_client_handler(client) + def callback(self, callback_id, function): + # Support older servers which use the old callback system. + if type(callback_id) == str: + callback_id = getattr(self.cloudlink, callback_id) + + # New callback system. + if callable(callback_id): + if hasattr(self.cloudlink, callback_id.__name__): + self.log(f"Creating callback for {callback_id.__name__} to function {function.__name__}...") + self.cloudlink.usercallbacks[callback_id] = function + def full_stack(self): exc = sys.exc_info()[0] if exc is not None: @@ -286,23 +316,23 @@ def loadCustomCommands(self, classes, custom:dict = None): else: raise TypeError(f"Attempted to load \"{str(classEntry)}\", which is not a class!") - def loadBuiltinCommands(self): + def loadBuiltinCommands(self, handlerObject): # Get an array of attributes from self.serverInternalHandlers - classFunctions = dir(self.cloudlink.serverInternalHandlers) + classFunctions = dir(handlerObject) for functionEntry in classFunctions: # Check if a function within self.serverInternalHandlers is a private function if (not("__" in functionEntry) and (not(hasattr(self.cloudlink, functionEntry)))): try: # Check if a function is marked as ignore shouldAdd = True - if hasattr(self.cloudlink.serverInternalHandlers, "importer_ignore_functions"): - if type(self.cloudlink.serverInternalHandlers.importer_ignore_functions) == list: - if str(functionEntry) in self.cloudlink.serverInternalHandlers.importer_ignore_functions: + if hasattr(handlerObject, "importer_ignore_functions"): + if type(handlerObject.importer_ignore_functions) == list: + if str(functionEntry) in handlerObject.importer_ignore_functions: shouldAdd = False if shouldAdd: self.cloudlink.builtInCommands.append(str(functionEntry)) - setattr(self.cloudlink, str(functionEntry), getattr(self.cloudlink.serverInternalHandlers, str(functionEntry))) + setattr(self.cloudlink, str(functionEntry), getattr(handlerObject, str(functionEntry))) except: self.log(f"An exception has occurred whilst loading a built-in command: {self.full_stack()}") @@ -319,7 +349,8 @@ def getUsernames(self, rooms:list = ["default"]): for client in self.getAllUsersInRoom(room): result = self.getUserObjectFromClientObj(client) if result != None: - userlist.append(result) + if not result in userlist: + userlist.append(result) return userlist def getUserObjectFromClientObj(self, client): @@ -330,14 +361,18 @@ def getUserObjectFromClientObj(self, client): "id": client.id } - def selectMultiUserObjects(self, identifiers:list): + def selectMultiUserObjects(self, identifiers:list, rooms:list = ["default"]): # Implement multicast support objList = [] if type(identifiers) == list: for client in identifiers: obj = self.getUserObject(client) - if not(obj in [TypeError, LookupError, None]): - objList.append(obj) + # TODO: optimize this fugly mess + if not obj in [TypeError, LookupError, None]: + for room in rooms: + if room in obj.rooms: + if not obj in objList: + objList.append(obj) return objList else: return TypeError @@ -421,6 +456,7 @@ def isJSON(self, jsonStr): return is_valid_json def isPacketSane(self, message, keycheck=["cmd", "val"], datalimit=1000): + # TODO: Optimize this fugly mess tmp_msg = message is_valid_json = False is_sane = True @@ -437,7 +473,8 @@ def isPacketSane(self, message, keycheck=["cmd", "val"], datalimit=1000): if is_valid_json: for key in keycheck: if not key in tmp_msg: - is_sane = False + if (not "val" in tmp_msg) and (not "code" in tmp_msg): + is_sane = False if is_sane: if type(tmp_msg["cmd"]) != str: is_sane = False diff --git a/example.py b/server-example.py similarity index 56% rename from example.py rename to server-example.py index 861be6d..42f4c73 100644 --- a/example.py +++ b/server-example.py @@ -4,15 +4,19 @@ class customCommands: """ customCommands - This is an example of Cloudlink 4.0's new custom commands system. + This is an example of Cloudlink 4.0's custom commands system. """ - def __init__(self, cloudlink): + def __init__(self, cloudlink, extra_data:any = None): # To use custom commands, you will need to initialize your custom commands class with Cloudlink. This is required. self.cloudlink = cloudlink + # You can specify which functions to ignore when using Cloudlink.server.loadCustomCommands. This is optional. self.importer_ignore_functions = [] # ["test"] if you don't want to load the custom command "test". + # You can specify extra data to this class, see __main__ below. + self.extra_data = extra_data + # Optionally, you can reference Cloudlink components for extended functionality. self.supporter = self.cloudlink.supporter @@ -24,6 +28,7 @@ def test(self, client, server, message, listener_detected, listener_id, room_id) self - Should be a class reference to itself. client - Dictionary object that identifies clients. Contains headers, address, handler, and id. See /cloudlink/websocket_server/websocket_server.py for info. server - Required for the websocket server and for backward compatibility. + message - The command's payload. listener_detected - Boolean that is set when Cloudlink.server.serverRootHandlers checks a packet and identifies the JSON key "listener" in a packet. listener_id - Any value that is set when Cloudlink.server.serverRootHandlers checks a packet and reads the JSON key "listener" in a packet. room_id - Array of strings that is set when a client has been linked to rooms. Defaults to either None or ["default"]. @@ -32,38 +37,50 @@ def test(self, client, server, message, listener_detected, listener_id, room_id) """ self.cloudlink.sendPacket(client, {"cmd": "direct", "val": "test"}, listener_detected, listener_id, room_id) -class demoCallbacks: +class demoCallbacksServer: """ - demoCallbacks + demoCallbacksServer - This is an example of Cloudlink's callback compatibility system, which re-implements callbacks used in older versions of Cloudlink. - It is not recommended to use this feature in newer implementations as it has been replaced with Cloudlink.server.loadCustomCommands. + This is an example of Cloudlink's callback system. """ - def __init__(self): - pass + def __init__(self, cloudlink): + # To use callbacks, you will need to initialize your callbacks class with Cloudlink. This is required. + self.cloudlink = cloudlink def on_packet(self, client, server, message): - print("on_packet") + print("on_packet fired!") def on_connect(self, client, server): - print("on_connect") + print("on_connect fired!") def on_close(self, client, server): - print("on_close") + print("on_close fired!") if __name__ == "__main__": + # Initialize Cloudlink. You will only need to initialize one instance of the main cloudlink module. cl = Cloudlink() - dummy = demoCallbacks() - + + # Create a new server object. This supports initializing many server at once. server = cl.server(logs=True) + + # Set the server's Message-Of-The-Day. server.setMOTD(True, "CloudLink 4 Test") - #server.callback(server.on_packet, dummy.on_packet) - #server.callback(server.on_connect, dummy.on_connect) - #server.callback(server.on_close, dummy.on_close) + # Create demo callbacks. You can only initialize callbacks after you have initialized a cloudlink server object. + dummy = demoCallbacksServer(server) + + # Bind demo callbacks + server.callback(server.on_packet, dummy.on_packet) + server.callback(server.on_connect, dummy.on_connect) + server.callback(server.on_close, dummy.on_close) + + # To pass custom commands, simply pass a list containing uninitialized classes. + # To specify custom parameters, pass a dictionary object with an uninitialized class as a key and your custom parameters as it's value. + #client.loadCustomCommands(customCommands, {customCommands: dummy}) + server.loadCustomCommands(customCommands) - #server.loadCustomCommands(customCommands) + # Command disabler. Simply pass a list of strings containing either CLPv4 commands to ignore, or custom commands to unload. #server.disableCommands(["gmsg"]) - server.run(host="0.0.0.0", port=3002) \ No newline at end of file + server.run(host="0.0.0.0", port=3000) \ No newline at end of file