From b263ec1b1174512f75d8a7e0f94c4ab86f207a56 Mon Sep 17 00:00:00 2001 From: ShowierData9978 Date: Thu, 12 Jan 2023 10:48:23 -0600 Subject: [PATCH 01/59] Use logging as a lib properly --- cloudlink/supporter.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cloudlink/supporter.py b/cloudlink/supporter.py index 3718f0b..ef9ff24 100644 --- a/cloudlink/supporter.py +++ b/cloudlink/supporter.py @@ -8,7 +8,7 @@ def __init__(self, parent): self.parent = parent # Use logging library - self.logger = logging + self.logger = logging.getLogger("Cloudlink") # Reconfigure when needed self.logger.basicConfig(format="[%(asctime)s | %(created)f] (%(thread)d - %(threadName)s) %(levelname)s: %(message)s", level=self.logger.INFO) @@ -271,4 +271,4 @@ def log_critical(self, msg): self.logger.critical(msg) def log_error(self, msg): - self.logger.error(msg) \ No newline at end of file + self.logger.error(msg) From 2f0541ac1fbd717e4b63d0add1190d7ad240da9c Mon Sep 17 00:00:00 2001 From: ShowierData9978 Date: Thu, 12 Jan 2023 10:50:56 -0600 Subject: [PATCH 02/59] Update supporter.py --- cloudlink/supporter.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cloudlink/supporter.py b/cloudlink/supporter.py index ef9ff24..140a932 100644 --- a/cloudlink/supporter.py +++ b/cloudlink/supporter.py @@ -11,7 +11,8 @@ def __init__(self, parent): self.logger = logging.getLogger("Cloudlink") # Reconfigure when needed - self.logger.basicConfig(format="[%(asctime)s | %(created)f] (%(thread)d - %(threadName)s) %(levelname)s: %(message)s", level=self.logger.INFO) + # should be in exapmles, not the lib itself. + logging.basicConfig(format="[%(asctime)s | %(created)f] (%(thread)d - %(threadName)s) %(levelname)s: %(message)s", level=self.logger.INFO) # Define protocol types self.proto_unset = "proto_unset" From f38168ddb760ea336523866c07febb7593262603 Mon Sep 17 00:00:00 2001 From: "Mike J. Renaker / \"MikeDEV" Date: Wed, 15 Mar 2023 15:31:56 -0400 Subject: [PATCH 03/59] Revise readme.md --- README.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/README.md b/README.md index af80f0d..6ee0e16 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,38 @@ ![CLOUDLINK 4 BANNER](https://user-images.githubusercontent.com/12957745/188282246-a221e66a-5d8a-4516-9ae2-79212b745d91.png) + +# Cloudlink +Cloudlink is a free and open-source websocket solution optimized for Scratch. Originally created as a cloud variables alternative, it can function as a multi-purpose websocket framework for other projects. + +# πŸ’‘ Features πŸ’‘ +### πŸ”¨ Low threshold, high ceiling +To set up a Cloudlink server, all you need is a copy of Cloudlink and the latest version of Python. Install dependencies using `pip install -r requirements.txt` and you'll be ready to go! + +You can learn about the protocol using the original Scratch 3.0 client extension. Feel free to test-drive the extension in any of these Scratch mods: +- [TurboWarp](https://turbowarp.org/editor?extension=https://mikedev101.github.io/cloudlink/S4-0-nosuite.js) +- [SheepTester's E 羊 icques](https://sheeptester.github.io/scratch-gui/?url=https://mikedev101.github.io/cloudlink/S4-0-nosuite.js) +- [Ogadaki's Adacraft](https://adacraft.org/studio/) +- [Ogadaki's Adacraft (Beta)](https://beta.adacraft.org/studio/) + +For more advanced usage, you can implement custom apps or entire projects using the Python client/server pair. + +As for the Python server: +* Unicast and multicast packets across clients +* Expandable functionality with a built-in method loader +* Support for bridging servers +* Admin functionality for management + +### πŸͺΆ Fast and lightweight +Cloudlink can run on minimal resources. At least 32MB of RAM and any reasonably capable CPU can run a Cloudlink server. + +# πŸ“¦ Dependencies πŸ“¦ +* 🐍 Python >=3.11 +* 🌐 [aaugustin/websockets](https://github.com/aaugustin/websockets) +* 🌐 [websocket-client](https://github.com/websocket-client/websocket-client) + +# πŸ“ƒ The Cloudlink Protocol πŸ“ƒ +You can learn more about the Cloudlink protocol on [CL's official HackMD documentation page.](https://hackmd.io/g6BogABhT6ux1GA2oqaOXA) + + \ No newline at end of file From 095d020ce93c942cebe104623309d96135210a16 Mon Sep 17 00:00:00 2001 From: MikeDEV Date: Sun, 19 Mar 2023 21:13:10 -0400 Subject: [PATCH 04/59] 0.2.0 WIP CloudLink 0.2.0 is a complete rewrite that brings new features, modularity, stability, security, and performance. Done * CORE: Websocket framework * CORE: Event system * CORE: Event manager * CORE: Iterable to async iterable converter * CORE: Schema validator * DOCS: README.md update Work-in-progress * PORT: CL4 protocol handler * PORT: Scratch protocol handler * SUPPORT: 0.1.9.x backward compatibility --- README.md | 127 ++--- cloudlink/cloudlink.py | 520 ++++++++++++++++-- cloudlink/modules/__init__.py | 5 + cloudlink/modules/async_event_manager.py | 57 ++ cloudlink/modules/async_iterables.py | 35 ++ cloudlink/modules/clients_manager.py | 175 ++++++ cloudlink/modules/rooms_manager.py | 76 +++ cloudlink/modules/schemas.py | 371 +++++++++++++ cloudlink/protocols/__init__.py | 2 + cloudlink/protocols/clpv4.py | 199 +++++++ cloudlink/protocols/scratch.py | 125 +++++ main.py | 20 + .../client-test-async.py | 0 client-test-old.py => old/client-test-old.py | 0 old/cloudlink/__init__.py | 1 + {cloudlink => old/cloudlink}/__main__.py | 0 .../cloudlink}/async_client/__init__.py | 0 .../cloudlink}/async_client/async_client.py | 0 old/cloudlink/cloudlink.py | 52 ++ {cloudlink => old/cloudlink}/docs/docs.txt | 0 .../cloudlink}/old_client/__init__.py | 0 .../cloudlink}/old_client/old_client.py | 0 .../cloudlink}/server/__init__.py | 0 .../cloudlink}/server/protocols/__init__.py | 0 .../cloudlink}/server/protocols/cl_methods.py | 0 .../server/protocols/scratch_methods.py | 0 {cloudlink => old/cloudlink}/server/server.py | 0 {cloudlink => old/cloudlink}/supporter.py | 0 old/requirements.txt | 2 + server_example.py => old/server_example.py | 0 requirements.txt | 6 +- 31 files changed, 1654 insertions(+), 119 deletions(-) create mode 100644 cloudlink/modules/__init__.py create mode 100644 cloudlink/modules/async_event_manager.py create mode 100644 cloudlink/modules/async_iterables.py create mode 100644 cloudlink/modules/clients_manager.py create mode 100644 cloudlink/modules/rooms_manager.py create mode 100644 cloudlink/modules/schemas.py create mode 100644 cloudlink/protocols/__init__.py create mode 100644 cloudlink/protocols/clpv4.py create mode 100644 cloudlink/protocols/scratch.py create mode 100644 main.py rename client-test-async.py => old/client-test-async.py (100%) rename client-test-old.py => old/client-test-old.py (100%) create mode 100644 old/cloudlink/__init__.py rename {cloudlink => old/cloudlink}/__main__.py (100%) rename {cloudlink => old/cloudlink}/async_client/__init__.py (100%) rename {cloudlink => old/cloudlink}/async_client/async_client.py (100%) create mode 100644 old/cloudlink/cloudlink.py rename {cloudlink => old/cloudlink}/docs/docs.txt (100%) rename {cloudlink => old/cloudlink}/old_client/__init__.py (100%) rename {cloudlink => old/cloudlink}/old_client/old_client.py (100%) rename {cloudlink => old/cloudlink}/server/__init__.py (100%) rename {cloudlink => old/cloudlink}/server/protocols/__init__.py (100%) rename {cloudlink => old/cloudlink}/server/protocols/cl_methods.py (100%) rename {cloudlink => old/cloudlink}/server/protocols/scratch_methods.py (100%) rename {cloudlink => old/cloudlink}/server/server.py (100%) rename {cloudlink => old/cloudlink}/supporter.py (100%) create mode 100644 old/requirements.txt rename server_example.py => old/server_example.py (100%) diff --git a/README.md b/README.md index 6ee0e16..a75fe3c 100644 --- a/README.md +++ b/README.md @@ -1,100 +1,75 @@ ![CLOUDLINK 4 BANNER](https://user-images.githubusercontent.com/12957745/188282246-a221e66a-5d8a-4516-9ae2-79212b745d91.png) -# Cloudlink -Cloudlink is a free and open-source websocket solution optimized for Scratch. Originally created as a cloud variables alternative, it can function as a multi-purpose websocket framework for other projects. +# Cloudlink v0.2.0 +CloudLink is a free and open-source websocket solution optimized for Scratch. +Originally created as a cloud variables alternative, it can function as a multi-purpose websocket framework for other projects. +**THIS VERSION OF CLOUDLINK IS STILL UNDER DEVELOPMENT. DO NOT USE IN A PRODUCTION ENVIRONMENT.** # πŸ’‘ Features πŸ’‘ -### πŸ”¨ Low threshold, high ceiling -To set up a Cloudlink server, all you need is a copy of Cloudlink and the latest version of Python. Install dependencies using `pip install -r requirements.txt` and you'll be ready to go! -You can learn about the protocol using the original Scratch 3.0 client extension. Feel free to test-drive the extension in any of these Scratch mods: -- [TurboWarp](https://turbowarp.org/editor?extension=https://mikedev101.github.io/cloudlink/S4-0-nosuite.js) -- [SheepTester's E 羊 icques](https://sheeptester.github.io/scratch-gui/?url=https://mikedev101.github.io/cloudlink/S4-0-nosuite.js) -- [Ogadaki's Adacraft](https://adacraft.org/studio/) -- [Ogadaki's Adacraft (Beta)](https://beta.adacraft.org/studio/) - -For more advanced usage, you can implement custom apps or entire projects using the Python client/server pair. +### πŸͺΆ Fast and lightweight +CloudLink can run on minimal resources. At least 25MB of RAM and any reasonably capable CPU can run a CloudLink server. -As for the Python server: +### 🌐 Essential networking tools * Unicast and multicast packets across clients * Expandable functionality with a built-in method loader * Support for bridging servers * Admin functionality for management -### πŸͺΆ Fast and lightweight -Cloudlink can run on minimal resources. At least 32MB of RAM and any reasonably capable CPU can run a Cloudlink server. - -# πŸ“¦ Dependencies πŸ“¦ +# πŸ“¦ Minimal dependencies +All dependencies below can be installed using `pip install -r requirements.txt`. * 🐍 Python >=3.11 +* 🧡 asyncio (Built-in) +* πŸ“ƒ ["ujson" ultrajson](https://github.com/ultrajson/ultrajson) +* πŸ” [pyeve/cerberus](https://github.com/pyeve/cerberus) +* ❄️ ["snowflake-id" vd2org/snowflake](https://github.com/vd2org/snowflake) * 🌐 [aaugustin/websockets](https://github.com/aaugustin/websockets) -* 🌐 [websocket-client](https://github.com/websocket-client/websocket-client) - -# πŸ“ƒ The Cloudlink Protocol πŸ“ƒ -You can learn more about the Cloudlink protocol on [CL's official HackMD documentation page.](https://hackmd.io/g6BogABhT6ux1GA2oqaOXA) - - \ No newline at end of file +# πŸ“ƒ The CloudLink Protocol πŸ“ƒ +You can learn more about the CloudLink protocol on [CL's official HackMD documentation page.](https://hackmd.io/g6BogABhT6ux1GA2oqaOXA) diff --git a/cloudlink/cloudlink.py b/cloudlink/cloudlink.py index bc9968b..1ac28b0 100644 --- a/cloudlink/cloudlink.py +++ b/cloudlink/cloudlink.py @@ -1,52 +1,490 @@ -from .supporter import supporter +import websockets +import asyncio +import ujson +import cerberus +import logging +from copy import copy +from snowflake import SnowflakeGenerator -""" -CloudLink 4.0 Server and Client +# Import Cloudlink modules +from cloudlink.modules.async_event_manager import async_event_manager +from cloudlink.modules.async_iterables import async_iterable +from cloudlink.modules.clients_manager import clients_manager -CloudLink is a free and open-source, websocket-powered API optimized for Scratch 3.0. -For documentation, please visit https://hackmd.io/g6BogABhT6ux1GA2oqaOXA +# Import builtin schemas to validate the CLPv4 / Scratch Cloud Variable protocol(s) +from cloudlink.modules.schemas import schemas -Cloudlink is built upon https://github.com/aaugustin/websockets. +# Import protocols +from cloudlink.protocols import clpv4 +from cloudlink.protocols import scratch -Please see https://github.com/MikeDev101/cloudlink for more details. -Cloudlink's dependencies are: -* websockets (for server and asyncio client) -* websocket-client (for non-asyncio client) +# Define server exceptions +class exceptions: + class EmptyMessage(Exception): + """This exception is raised when a client sends an empty packet.""" + pass -These dependencies are built-in to Python. -* copy -* asyncio -* traceback -* datetime -* json -""" + class InvalidCommand(Exception): + """This exception is raised when a client sends an invalid command for it's determined protocol.""" + pass + class JSONError(Exception): + """This exception is raised when the server fails to parse a message's JSON.""" + pass -class cloudlink: + class InternalError(Exception): + """This exception is raised when an unexpected and/or unhandled exception is raised.""" + pass + + class Overloaded(Exception): + """This exception is raised when the server believes it is overloaded.""" + pass + + +# Main server +class server: def __init__(self): - self.version = "0.1.9.2" - self.supporter = supporter - - def server(self, logs: bool = False): - # Initialize Cloudlink server - from .server import server - return server(self, logs) - - def client(self, logs: bool = False, async_client: bool = True): - # Initialize Cloudlink client - if async_client: - from .async_client import async_client - return async_client.client(self, logs) + self.version = "0.2.0" + + # Logging + self.logging = logging + self.logger = self.logging.getLogger(__name__) + + # Asyncio + self.asyncio = asyncio + + # Websocket server + self.ws = websockets + + # Components + self.gen = SnowflakeGenerator(42) + self.validator = cerberus.Validator() + self.schemas = schemas + self.async_iterable = async_iterable + self.copy = copy + self.clients_manager = clients_manager(self) + self.exceptions = exceptions() + + # Create event managers + self.on_connect_events = async_event_manager(self) + self.on_message_events = async_event_manager(self) + self.on_disconnect_events = async_event_manager(self) + self.on_error_events = async_event_manager(self) + self.exception_handlers = dict() + + # Create method handlers + self.command_handlers = dict() + + # Enable scratch protocol support on startup + self.enable_scratch_support = True + + # Message of the day + self.enable_motd = False + self.motd_message = str() + + # Set to -1 to allow as many client as possible + self.max_clients = -1 + + # Runs the server. + def run(self, ip="127.0.0.1", port=3000): + try: + self.logger.info(f"Cloudlink Server v{self.version}") + + # Validate config before startup + if type(self.max_clients) != int: + raise TypeError("The max_clients value must be a integer value set to -1 (unlimited clients) or greater than zero!") + + if self.max_clients < -1 or self.max_clients == 0: + raise ValueError("The max_clients value must be a integer value set to -1 (unlimited clients) or greater than zero!") + + if type(self.enable_scratch_support) != bool: + raise TypeError("The enable_scratch_support value must be a boolean!") + + if type(self.enable_motd) != bool: + raise TypeError("The enable_motd value must be a boolean!") + + if type(self.motd_message) != str: + raise TypeError("The motd_message value must be a string!") + + # Load CLPv4 protocol + clpv4(self) + + # Load Scratch protocol + if self.enable_scratch_support: + scratch(self) + + # Start server + self.asyncio.run(self.__run__(ip, port)) + + except KeyboardInterrupt: + pass + + # Event binder for on_command events + def on_command(self, cmd, schema): + def bind_event(func): + + # Create schema category for command event manager + if schema not in self.command_handlers: + self.logger.debug(f"Adding schema {schema.__qualname__} to command handlers") + self.command_handlers[schema] = dict() + + # Create command event handler + if cmd not in self.command_handlers[schema]: + self.logger.debug(f"Creating command handler \"{cmd}\" with schema {schema.__qualname__}") + self.command_handlers[schema][cmd] = async_event_manager(self) + + # Add function to the command handler + self.logger.debug(f"Binding function {func.__qualname__} to command handler \"{cmd}\" with schema {schema.__qualname__}") + self.command_handlers[schema][cmd].bind(func) + + # End on_command binder + return bind_event + + # Event binder for on_error events with specific shemas/exception types + def on_exception(self, exception_type, schema): + def bind_event(func): + + # Create schema category for error event manager + if schema not in self.exception_handlers: + self.logger.debug(f"Adding schema {schema.__qualname__} to exception handlers") + self.exception_handlers[schema] = dict() + + # Create error event handler + if exception_type not in self.exception_handlers[schema]: + self.logger.debug(f"Creating exception handler {exception_type} with schema {schema.__qualname__}") + self.exception_handlers[schema][exception_type] = async_event_manager(self) + + # Add function to the error command handler + self.logger.debug( + f"Binding function {func.__qualname__} to exception handler {exception_type} with schema {schema.__qualname__}") + self.exception_handlers[schema][exception_type].bind(func) + + # End on_error_specific binder + return bind_event + + # Event binder for on_message events + def on_message(self, func): + self.on_message_events.bind(func) + + # Event binder for on_connect events. + def on_connect(self, func): + self.on_connect_events.bind(func) + + # Event binder for on_disconnect events. + def on_disconnect(self, func): + self.on_disconnect_events.bind(func) + + # Event binder for on_error events. + def on_error(self, func): + self.on_error_events.bind(func) + + # Friendly version of send_packet_unicast / send_packet_multicast + def send_packet(self, obj, message): + if hasattr(obj, "__iter__"): + self.asyncio.create_task(self.__execute_multicast__(obj, message)) else: - from .old_client import old_client - return old_client.client(self, logs) - - def multi_client(self, logs: bool = False, async_client: bool = True): - # Initialize Cloudlink client - if async_client: - from .async_client import async_client - return async_client.multi_client(self, logs) + self.asyncio.create_task(self.__execute_unicast__(obj, message)) + + # Send message to a single client + def send_packet_unicast(self, client, message): + # Create unicast task + self.asyncio.create_task(self.__execute_unicast__(client, message)) + + # Send message to multiple clients + def send_packet_multicast(self, clients, message): + # Create multicast task + self.asyncio.create_task(self.__execute_multicast__(clients, message)) + + # Close the connection to client(s) + def close_connection(self, obj, code=1000, reason=""): + if hasattr(obj, "__iter__"): + self.asyncio.create_task(self.__execute_close_multi__(obj, code, reason)) + else: + self.asyncio.create_task(self.__execute_close_single__(obj, code, reason)) + + # Message processor + async def __process__(self, client, message): + + # Empty packet + if not len(message): + + # Fire on_error events + asyncio.create_task(self.__execute_on_error_events__(client, self.exceptions.EmptyMessage)) + + # Fire exception handling events + if client.protocol_set: + self.asyncio.create_task( + self.__execute_exception_handlers__( + client=client, + exception_type=self.exceptions.EmptyMessage, + schema=client.protocol, + details="Empty message" + ) + ) + + # End __process__ coroutine + return + + # Parse JSON in message and convert to dict + try: + message = ujson.loads(message) + except Exception as error: + + # Log JSON parsing error + self.logger.warning(f"Handling JSON exception: {error}") + + # Fire on_error events + self.asyncio.create_task(self.__execute_on_error_events__(client, error)) + + # Fire exception handling events + if client.protocol_set: + self.asyncio.create_task( + self.__execute_exception_handlers__( + client=client, + exception_type=self.exceptions.JSONError, + schema=client.protocol, + details=f"JSON parsing error: {error}" + ) + ) + + # End __process__ coroutine + return + + self.logger.debug(f"Got message from client {client.snowflake}: {message}") + + # Begin validation + valid = False + selected_protocol = None + + # Client protocol is unknown + if not client.protocol: + + # Identify protocol + errorlist = list() + for schema in self.command_handlers: + if self.validator(message, schema.default): + valid = True + selected_protocol = schema + self.logger.debug(f"Validation passed; Detected schema: {selected_protocol}") + break + else: + errorlist.append(self.validator.errors) + self.logger.debug(f"Validation error: {self.validator.errors}") + + if not valid: + # Log failed identification + self.logger.debug(f"Could not identify protocol used by client {client.snowflake}: {errorlist}") + + # Fire on_error events + self.asyncio.create_task(self.__execute_on_error_events__(client, "Unable to identify protocol")) + + # Close the connection + await client.send("Unable to identify protocol") + self.close_connection(client, reason="Unable to identify protocol") + + # End __process__ coroutine + return + + # Log known protocol + self.logger.info(f"Client {client.snowflake} is using protocol {selected_protocol.__qualname__}") + + # Make the client's protocol known + self.clients_manager.set_protocol(client, selected_protocol) + else: - from .old_client import old_client - return old_client.multi_client(self, logs) + # Validate message using known protocol + selected_protocol = client.protocol + + if not self.validator(message, selected_protocol.default): + errors = self.validator.errors + + # Log failed validation + self.logger.debug(f"Client {client.snowflake} sent message that failed validation: {errors}") + + # Fire on_error events + self.asyncio.create_task(self.__execute_on_error_events__(client, errors)) + + # End __process__ coroutine + return + + # Check if command exists + if message[selected_protocol.command_key] not in self.command_handlers[selected_protocol]: + + # Log invalid command + self.logger.warning(f"Invalid command \"{message[selected_protocol.command_key]}\" in protocol {selected_protocol.__qualname__} from client {client.snowflake}") + + # Fire on_error events + self.asyncio.create_task(self.__execute_on_error_events__(client, "Invalid command")) + + # Fire exception handling events + if client.protocol_set: + self.asyncio.create_task( + self.__execute_exception_handlers__( + client=client, + exception_type=self.exceptions.InvalidCommand, + schema=client.protocol, + details=f"Invalid command: {message[selected_protocol.command_key]}" + ) + ) + + # End __process__ coroutine + return + + # Fire on_command events + self.asyncio.create_task( + self.__execute_on_command_events__( + client, + message, + selected_protocol + ) + ) + + # Fire on_message events + self.asyncio.create_task( + self.__execute_on_message_events__( + client, + message + ) + ) + + # Connection handler + async def __handler__(self, client): + # Limit the amount of clients connected + if self.max_clients != -1: + if len(self.clients_manager) >= self.max_clients: + self.logger.warning(f"Refusing new connection due to the server being full") + await client.send("Server is full") + await client.close(reason="Server is full") + return + + # Startup client attributes + client.snowflake = str(next(self.gen)) + client.protocol = None + client.protocol_set = False + client.rooms = set + client.username_set = False + client.friendly_username = str() + client.linked = False + + # Add to clients manager + self.clients_manager.add(client) + + # Log new connection + self.logger.info(f"Client {client.snowflake} connected") + + # Log current number of clients + self.logger.info(f"There are now {len(self.clients_manager)} clients connected.") + + # Fire on_connect events + self.asyncio.create_task(self.__execute_on_connect_events__(client)) + + # Primary asyncio loop for the lifespan of the websocket connection + try: + async for message in client: + await self.__process__(client, message) + + # Handle unexpected disconnects + except self.ws.exceptions.ConnectionClosedError: + self.logger.debug(f"Handling ConnectionClosedError exception raised by websocket") + + # Handle OK disconnects + except self.ws.exceptions.ConnectionClosedOK: + self.logger.debug(f"Handling ConnectionClosedOK exception raised by websocket") + + # Catch any unexpected exceptions + except Exception as e: + self.logger.critical(f"Unexpected exception was raised: {e}") + + # Fire on_error events + self.asyncio.create_task(self.__execute_on_error_events__(client, f"Unexpected exception was raised: {e}")) + + # Fire exception handling events + if client.protocol_set: + self.asyncio.create_task( + self.__execute_exception_handlers__( + client=client, + exception_type=self.exceptions.InternalError, + schema=client.protocol, + details=f"Unexpected exception was raised: {e}" + ) + ) + + # Graceful shutdown + finally: + + # Remove from clients manager + self.clients_manager.remove(client) + + # Log disconnect + self.logger.info(f"Client {client.snowflake} disconnected") + + # Log current number of clients + self.logger.info(f"There are now {len(self.clients_manager)} clients connected.") + + # Fire on_disconnect events + self.asyncio.create_task(self.__execute_on_disconnect_events__(client)) + + # Server Asyncio loop + async def __run__(self, ip, port): + + # Main event loop + async with self.ws.serve(self.__handler__, ip, port): + await self.asyncio.Future() + + # Asyncio event-handling coroutines + async def __execute_on_disconnect_events__(self, client): + self.logger.debug(f"Firing all events bound to on_disconnect") + async for event in self.on_disconnect_events: + await event(client) + + async def __execute_on_connect_events__(self, client): + self.logger.debug(f"Firing all events bound to on_connect") + async for event in self.on_connect_events: + await event(client) + + async def __execute_on_message_events__(self, client, message): + self.logger.debug(f"Firing all events bound to on_message") + async for event in self.on_message_events: + await event(client, message) + + async def __execute_on_command_events__(self, client, message, schema): + self.logger.debug(f"Firing all bound events to command handler \"{message[schema.command_key]}\" with schema {schema.__qualname__}") + async for event in self.command_handlers[schema][message[schema.command_key]]: + await event(client, message) + + async def __execute_on_error_events__(self, client, errors): + self.logger.debug(f"Firing all events bound to on_error") + async for event in self.on_error_events: + await event(client, errors) + + async def __execute_exception_handlers__(self, client, exception_type, schema, details): + # Guard clauses + if schema not in self.exception_handlers: + return + if exception_type not in self.exception_handlers[schema]: + return + + self.logger.debug(f"Firing all exception handlers bound to schema {schema.__qualname__} and exception type {exception_type}") + + # Fire events + async for event in self.exception_handlers[schema][exception_type]: + await event(client, details) + + async def __execute_unicast__(self, client, message): + self.logger.debug(f"Sending unicast message {message} to {client.snowflake}") + await client.send(ujson.dumps(message)) + + async def __execute_multicast__(self, clients, message): + self.logger.debug(f"Sending multicast message {message} to {len(clients)} clients") + async for client in self.async_iterable(self, clients): + await client.send(ujson.dumps(message)) + + async def __execute_close_single__(self, client, code=1000, reason=""): + self.logger.debug(f"Closing the connection to client {client.snowflake} with code {code} and reason {reason}") + await client.close(code, reason) + + async def __execute_close_multi__(self, clients, code=1000, reason=""): + self.logger.debug(f"Closing the connection to {len(clients)} clients with code {code} and reason {reason}") + async for client in self.async_iterable(self, clients): + await client.close(code, reason) diff --git a/cloudlink/modules/__init__.py b/cloudlink/modules/__init__.py new file mode 100644 index 0000000..1affe65 --- /dev/null +++ b/cloudlink/modules/__init__.py @@ -0,0 +1,5 @@ +from .async_event_manager import async_event_manager +from .async_iterables import async_iterable +from .clients_manager import * +from .rooms_manager import * +from .schemas import * diff --git a/cloudlink/modules/async_event_manager.py b/cloudlink/modules/async_event_manager.py new file mode 100644 index 0000000..4f8975e --- /dev/null +++ b/cloudlink/modules/async_event_manager.py @@ -0,0 +1,57 @@ +""" +async_event_manager - A more powerful way to fire callbacks and create decorator events with Cloudlink. + +bind(func: ) +* Adds an asyncio method to the event manager object. +* bind() should only be called during setup, and should not be called after the server is started. + +unbind(func: ) +* Removes an asyncio method from the event manager object. +* unbind() should only be called during setup, and should not be called after the server is started. + +reset() +* Clears all asyncio methods to be executed from the event manager object. +""" + + +class async_event_manager: + def __init__(self, parent): + # Declare constructor with initial state + self.iterator = 0 + self.events = set() + + # Init logger + self.logging = parent.logging + self.logger = self.logging.getLogger(__name__) + + # Add functions to event list + def bind(self, func): + self.events.add(func) + + # Remove functions from events list + def unbind(self, func): + self.events.remove(func) + + # Cleanup events list + def reset(self): + self.iterator = 0 + self.events = set() + + # Create instance of async iterator + def __aiter__(self): + return self + + # Return next awaitable + async def __anext__(self): + # Check for further events in the list of events + if self.iterator >= len(self.events): + self.logger.debug(f"Finished executing awaitable events") + self.iterator = 0 + raise StopAsyncIteration + + # Increment iterator + self.iterator += 1 + + # Execute async event + self.logger.debug(f"Executing event {self.iterator} of {len(self.events)}") + return list(self.events)[self.iterator - 1] diff --git a/cloudlink/modules/async_iterables.py b/cloudlink/modules/async_iterables.py new file mode 100644 index 0000000..583604d --- /dev/null +++ b/cloudlink/modules/async_iterables.py @@ -0,0 +1,35 @@ +""" +async_iterable - converts a list or set of methods into an asyncio iterable +which can be used in the async for function. + +to use, init the class with the server parent and the list/set of functions. + +import async_iterable +... +async for event in async_iterable(parent, [foo, bar]): + await event() +""" + + +class async_iterable: + def __init__(self, parent, iterables): + self.iterator = 0 + self.iterable = list(iterables) + + # Init logger + self.logging = parent.logging + self.logger = self.logging.getLogger(__name__) + + def __aiter__(self): + return self + + async def __anext__(self): + if self.iterator >= len(self.iterable): + self.logger.debug(f"Finished iterating") + self.iterator = 0 + raise StopAsyncIteration + + self.iterator += 1 + + self.logger.debug(f"Iterating {self.iterator} of {len(self.iterable)}") + return self.iterable[self.iterator - 1] diff --git a/cloudlink/modules/clients_manager.py b/cloudlink/modules/clients_manager.py new file mode 100644 index 0000000..23334c8 --- /dev/null +++ b/cloudlink/modules/clients_manager.py @@ -0,0 +1,175 @@ +""" +clients_manager - Provides tools to search, add, and remove clients from the server. +""" + + +class exceptions: + class ClientDoesNotExist(Exception): + """This exception is raised when a client object does not exist""" + pass + + class ClientAlreadyExists(Exception): + """This exception is raised when attempting to add a client object that is already present""" + pass + + class ClientUsernameAlreadySet(Exception): + """This exception is raised when a client attempts to set their friendly username, but it was already set.""" + pass + + class ClientUsernameNotSet(Exception): + """This exception is raised when a client object has not yet set it's friendly username.""" + pass + + class NoResultsFound(Exception): + """This exception is raised when there are no results for a client search request.""" + pass + + class ProtocolAlreadySet(Exception): + """This exception is raised when attempting to change a client's protocol.""" + pass + + +class clients_manager: + def __init__(self, parent): + # Inherit parent + self.parent = parent + + # Create attributes for storage/searching + self.clients = set() + self.snowflakes = dict() + self.protocols = dict() + self.usernames = dict() + self.uuids = dict() + + # Init exceptions + self.exceptions = exceptions() + + # Init logger + self.logging = parent.logging + self.logger = self.logging.getLogger(__name__) + + def __len__(self): + return len(self.clients) + + def get_snowflakes(self): + return set(obj.snowflake for obj in self.clients) + + def get_uuids(self): + return set(str(obj.id) for obj in self.clients) + + def exists(self, obj): + return obj in self.clients + + def add(self, obj): + if self.exists(obj): + raise self.exceptions.ClientAlreadyExists + + self.logger.debug(f"Adding client object: {obj}") + + # Add object to set + self.clients.add(obj) + self.logger.debug(f"Current clients set state: {self.clients}") + + # Add to snowflakes + self.snowflakes[obj.snowflake] = obj + self.logger.debug(f"Current snowflakes state: {self.snowflakes}") + + # Add to UUIDs + self.uuids[str(obj.id)] = obj + self.logger.debug(f"Current UUIDs state: {self.uuids}") + + def remove(self, obj): + if not self.exists(obj): + raise self.exceptions.ClientDoesNotExist + + self.logger.debug(f"Removing client object: {obj}") + + # Remove from all clients set + self.clients.remove(obj) + self.logger.debug(f"Current clients set state: {self.clients}") + + # Remove from snowflakes + self.snowflakes.pop(obj.snowflake) + self.logger.debug(f"Current snowflakes state: {self.snowflakes}") + + # Remove from UUIDs + self.uuids.pop(str(obj.id)) + self.logger.debug(f"Current UUIDs state: {self.uuids}") + + # Remove from protocol references + if obj.protocol_set: + + # Remove reference to protocol object + if obj in self.protocols[obj.protocol]: + self.logger.debug(f"Removing client {obj.snowflake} from protocol reference: {obj.protocol}") + self.protocols[obj.protocol].remove(obj) + + # Clean up unused protocol references + if not len(self.protocols[obj.protocol]): + self.logger.debug(f"Removing protocol {obj.protocol.__qualname__} from protocols") + del self.protocols[obj.protocol] + + # Remove client from username references + if obj.username_set: + + # Remove reference to username object + if obj.friendly_username in self.usernames: + self.logger.debug(f"Removing client {obj.snowflake} from username reference: {obj.friendly_username}") + self.usernames[obj.friendly_username].remove(obj) + + # Clean up unused usernames + if not len(self.usernames[obj.friendly_username]): + self.logger.debug(f"Removing empty username reference: {obj.friendly_username}") + del self.usernames[obj.friendly_username] + + def set_username(self, obj, username): + if not self.exists(obj): + raise self.exceptions.ClientDoesNotExist + + if obj.username_set: + raise self.exceptions.ClientUsernameAlreadySet + + self.logger.debug(f"Setting client {obj.snowflake}'s friendly username to {username}") + + # Create username reference + if username not in self.usernames: + self.logger.debug(f"Creating new username reference: {username}") + self.usernames[username] = set() + + # Create reference to object from it's username + self.usernames[username].add(obj) + + # Finally set attributes + obj.username_set = True + obj.friendly_username = username + + def set_protocol(self, obj, schema): + if not self.exists(obj): + raise self.exceptions.ClientDoesNotExist + + if obj.protocol_set: + raise self.exceptions.ProtocolAlreadySet + + # If the protocol was not specified beforehand, create it + if schema not in self.protocols: + self.logger.debug(f"Adding protocol {schema.__qualname__} to protocols") + self.protocols[schema] = set() + + self.logger.debug(f"Setting client {obj.snowflake}'s protocol to {schema.__qualname__}") + + # Add client to the protocols identifier + self.protocols[schema].add(obj) + + # Set client protocol + obj.protocol = schema + obj.protocol_set = True + + def find_obj(self, query): + if query in self.usernames: + return self.usernames[query] + elif query in self.get_uuids(): + return self.uuids[query] + elif query in self.get_snowflakes(): + return self.snowflakes[query] + else: + raise self.exceptions.NoResultsFound diff --git a/cloudlink/modules/rooms_manager.py b/cloudlink/modules/rooms_manager.py new file mode 100644 index 0000000..260d8c1 --- /dev/null +++ b/cloudlink/modules/rooms_manager.py @@ -0,0 +1,76 @@ +""" +rooms_manager - Provides tools to search, add, and remove rooms from the server. +""" + + +class exceptions: + class RoomDoesNotExist(Exception): + """This exception is raised when a client accesses a room that does not exist""" + pass + + class RoomAlreadyExists(Exception): + """This exception is raised when attempting to create a room that already exists""" + pass + + class RoomNotEmpty(Exception): + """This exception is raised when attempting to delete a room that is not empty""" + pass + + class RoomAlreadyJoined(Exception): + """This exception is raised when a client attempts to join a room, but it was already joined.""" + pass + + class RoomsNotJoined(Exception): + """This exception is raised when a client attempts to access a room not joined.""" + pass + + class NoResultsFound(Exception): + """This exception is raised when there are no results for a room search request.""" + pass + + class RoomUnsupportedProtocol(Exception): + """This exception is raised when a room does not support a client's protocol.""" + pass + + +class rooms_manager: + def __init__(self, parent): + # Inherit parent + self.parent = parent + + # Storage of rooms + self.rooms = dict() + + # Init exceptions + self.exceptions = exceptions() + + # Init logger + self.logging = parent.logging + self.logger = self.logging.getLogger(__name__) + + def create(self, room_id, supported_protocols=set): + if type(room_id) not in [str, int, float, bool]: + raise TypeError("Room IDs only support strings, booleans, or numbers!") + + if self.exists(room_id): + raise self.exceptions.RoomAlreadyExists + + # Create the room + self.rooms[room_id] = { + "clients": set(), + "supported_protocols": supported_protocols, + "global_variables": set() + } + + def delete(self, room_id): + if not self.exists(room_id): + raise self.exceptions.RoomDoesNotExist + + if len(self.rooms[room_id]["clients"]): + raise self.exceptions.RoomNotEmpty + + # Delete the room + self.rooms.pop(room_id) + + def exists(self, room_id): + return room_id in self.rooms diff --git a/cloudlink/modules/schemas.py b/cloudlink/modules/schemas.py new file mode 100644 index 0000000..43903d1 --- /dev/null +++ b/cloudlink/modules/schemas.py @@ -0,0 +1,371 @@ +""" +schemas - A replacement for the validate() function in older versions. +""" + + +class schemas: + # Schema for interpreting the Cloudlink protocol v4.0 (CLPv4) command set + class clpv4: + # Required - Defines the keyword to use to define the command + command_key = "cmd" + + # Required - Defines the default schema to test against + default = { + "cmd": { + "type": "string", + "required": True + }, + "val": { + "type": [ + "string", + "integer", + "float", + "number", + "boolean", + "dict", + "list", + "set", + ], + "required": False, + }, + "name": { + "type": "string", + "required": False + }, + "id": { + "type": [ + "string", + "integer", + "float", + "boolean", + "number" + ], + "required": False + }, + "listener": { + "type": [ + "string", + "integer", + "float", + "boolean", + "number" + ], + "required": False + }, + "rooms": { + "type": [ + "string", + "integer", + "float", + "boolean", + "number", + "list", + "set" + ], + "required": False + } + } + + gmsg = { + "cmd": { + "type": "string", + "required": True + }, + "val": { + "type": [ + "string", + "integer", + "float", + "number", + "boolean", + "dict", + "list", + "set", + ], + "required": True + }, + "listener": { + "type": [ + "string", + "integer", + "float", + "boolean", + "number" + ], + "required": False + }, + "rooms": { + "type": [ + "string", + "integer", + "float", + "boolean", + "number", + "list", + "set" + ], + "required": False + } + } + + gvar = { + "cmd": { + "type": "string", + "required": True + }, + "name": { + "type": "string", + "required": True + }, + "val": { + "type": [ + "string", + "integer", + "float", + "number", + "boolean", + "dict", + "list", + "set", + ], + "required": True + }, + "listener": { + "type": [ + "string", + "integer", + "float", + "boolean", + "number" + ], + "required": False + }, + "rooms": { + "type": [ + "string", + "integer", + "float", + "boolean", + "number", + "list", + "set" + ], + "required": False + } + } + + pmsg = { + "cmd": { + "type": "string", + "required": True + }, + "id": { + "type": [ + "string", + "integer", + "float", + "number", + "boolean", + "dict", + "list", + "set", + ], + "required": True + }, + "val": { + "type": [ + "string", + "integer", + "float", + "number", + "boolean", + "dict", + "list", + "set", + ], + "required": True + }, + "listener": { + "type": [ + "string", + "integer", + "float", + "boolean", + "number" + ], + "required": False + }, + "rooms": { + "type": [ + "string", + "integer", + "float", + "boolean", + "number", + "list", + "set" + ], + "required": False + } + } + + pvar = { + "cmd": { + "type": "string", + "required": True + }, + "name": { + "type": "string", + "required": True + }, + "id": { + "type": [ + "string", + "integer", + "float", + "number", + "boolean", + "dict", + "list", + "set", + ], + "required": True + }, + "val": { + "type": [ + "string", + "integer", + "float", + "number", + "boolean", + "dict", + "list", + "set", + ], + "required": True + }, + "listener": { + "type": [ + "string", + "integer", + "float", + "boolean", + "number" + ], + "required": False + }, + "rooms": { + "type": [ + "string", + "integer", + "float", + "boolean", + "number", + "list", + "set" + ], + "required": False + } + } + + # Schema for interpreting the Cloud Variables protocol used in Scratch 3.0 + class scratch: + # Required - Defines the keyword to use to define the command + command_key = "method" + + # Required - Defines the default schema to test against + default = { + "method": { + "type": "string", + "required": True + }, + "name": { + "type": "string", + "required": False + }, + "value": { + "type": [ + "string", + "integer", + "float", + "boolean", + "dict", + "list", + "set" + ], + "required": False + }, + "project_id": { + "type": [ + "string", + "integer", + "float", + "boolean" + ], + "required": False, + }, + "user": { + "type": "string", + "required": False, + "minlength": 1, + "maxlength": 20 + } + } + + handshake = { + "method": { + "type": "string", + "required": True + }, + "project_id": { + "type": [ + "string", + "integer", + "float", + "boolean" + ], + "required": True, + }, + "user": { + "type": "string", + "required": True, + } + } + + method = { + "method": { + "type": "string", + "required": True + }, + "name": { + "type": "string", + "required": True + }, + "value": { + "type": [ + "string", + "integer", + "float", + "boolean", + "dict", + "list", + "set" + ], + "required": False + }, + "project_id": { + "type": [ + "string", + "integer", + "float", + "boolean" + ], + "required": True, + }, + "user": { + "type": "string", + "required": False, + "minlength": 1, + "maxlength": 20 + } + } diff --git a/cloudlink/protocols/__init__.py b/cloudlink/protocols/__init__.py new file mode 100644 index 0000000..ffac35d --- /dev/null +++ b/cloudlink/protocols/__init__.py @@ -0,0 +1,2 @@ +from .clpv4 import * +from .scratch import * diff --git a/cloudlink/protocols/clpv4.py b/cloudlink/protocols/clpv4.py new file mode 100644 index 0000000..9b30be5 --- /dev/null +++ b/cloudlink/protocols/clpv4.py @@ -0,0 +1,199 @@ +class clpv4: + def __init__(self, server): + + # protocol_schema: The default schema to identify the protocol. + self.protocol_schema = server.schemas.clpv4 + + # valid(message, schema): Used to verify messages. + def valid(client, message, schema): + if server.validator(message, schema): + return True + else: + errors = server.validator.errors + server.logger.debug(f"Error: {errors}") + server.send_packet(client, { + "cmd": "statuscode", + "val": "error", + "details": dict(errors) + }) + return False + + @server.on_exception(exception_type=server.exceptions.InvalidCommand, schema=self.protocol_schema) + async def exception_handler(client, details): + server.send_packet(client, { + "cmd": "statuscode", + "val": "error", + "details": details + }) + + @server.on_exception(exception_type=server.exceptions.JSONError, schema=self.protocol_schema) + async def exception_handler(client, details): + server.send_packet(client, { + "cmd": "statuscode", + "val": "error", + "details": details + }) + + @server.on_exception(exception_type=server.exceptions.EmptyMessage, schema=self.protocol_schema) + async def exception_handler(client, details): + server.send_packet(client, { + "cmd": "statuscode", + "val": "error", + "details": details + }) + + @server.on_command(cmd="handshake", schema=self.protocol_schema) + async def on_handshake(client, message): + server.send_packet(client, { + "cmd": "client_ip", + "val": client.remote_address[0] + }) + + server.send_packet(client, { + "cmd": "server_version", + "val": server.version + }) + + if server.enable_motd: + server.send_packet(client, { + "cmd": "motd", + "val": server.motd_message + }) + + server.send_packet(client, { + "cmd": "client_id", + "val": client.snowflake + }) + + server.send_packet(client, { + "cmd": "statuscode", + "val": "ok" + }) + + @server.on_command(cmd="ping", schema=self.protocol_schema) + async def on_ping(client, message): + server.send_packet(client, { + "cmd": "statuscode", + "val": "ok" + }) + + @server.on_command(cmd="gmsg", schema=self.protocol_schema) + async def on_gmsg(client, message): + + # Validate schema + if not valid(client, message, self.protocol_schema.gmsg): + return + + # Copy the current set of connected client objects + clients = server.copy(server.clients_manager.protocols[self.protocol_schema]) + + # Attach listener (if present) and broadcast + if "listener" in message: + + # Remove originating client from broadcast + clients.remove(client) + + # Define the message to broadcast + tmp_message = { + "cmd": "gmsg", + "val": message["val"] + } + server.send_packet(clients, tmp_message) + + # Send message to originating client with listener + # Define the message to send + tmp_message = { + "cmd": "gmsg", + "val": message["val"], + "listener": message["listener"] + } + server.send_packet(client, tmp_message) + else: + # Broadcast message to all clients + tmp_message = { + "cmd": "gmsg", + "val": message["val"] + } + server.send_packet(clients, tmp_message) + + @server.on_command(cmd="pmsg", schema=self.protocol_schema) + async def on_pmsg(client, message): + + tmp_client = None + + # Validate schema + if not valid(client, message, self.protocol_schema.pmsg): + return + + # Find client + try: + tmp_client = server.clients_manager.find_obj(message['id']) + except server.clients_manager.exceptions.NoResultsFound: + server.send_packet(client, { + "cmd": "statuscode", + "val": "notfound", + "details": f'No matches found: {message["id"]}' + }) + + # Broadcast message to client + tmp_message = { + "cmd": "pmsg", + "val": message["val"], + "origin": client.snowflake + } + + # Send private message + server.send_packet(tmp_client, tmp_message) + + @server.on_command(cmd="gvar", schema=self.protocol_schema) + async def on_gvar(client, message): + + # Validate schema + if not valid(client, message, self.protocol_schema.gvar): + return + + # Define the message to send + tmp_message = { + "cmd": "gvar", + "name": message["name"], + "val": message["val"] + } + + # Copy the current set of connected client objects + clients = server.copy(server.clients_manager.protocols[self.protocol_schema]) + + # Attach listener (if present) and broadcast + if "listener" in message: + clients.remove(client) + server.send_packet(clients, tmp_message) + tmp_message["listener"] = message["listener"] + server.send_packet(client, tmp_message) + else: + server.send_packet(clients, tmp_message) + + @server.on_command(cmd="pvar", schema=self.protocol_schema) + async def on_pvar(client, message): + print("Private variables!") + + @server.on_command(cmd="setid", schema=self.protocol_schema) + async def on_setid(client, message): + try: + server.clients_manager.set_username(client, message['val']) + except server.clients_manager.exceptions.ClientUsernameAlreadySet: + server.logger.error(f"Client {client.snowflake} attempted to set username again!") + + @server.on_command(cmd="link", schema=self.protocol_schema) + async def on_link(client, message): + pass + + @server.on_command(cmd="unlink", schema=self.protocol_schema) + async def on_unlink(client, message): + pass + + @server.on_command(cmd="direct", schema=self.protocol_schema) + async def on_direct(client, message): + pass + + @server.on_command(cmd="bridge", schema=self.protocol_schema) + async def on_bridge(client, message): + pass diff --git a/cloudlink/protocols/scratch.py b/cloudlink/protocols/scratch.py new file mode 100644 index 0000000..c286ac4 --- /dev/null +++ b/cloudlink/protocols/scratch.py @@ -0,0 +1,125 @@ +class scratch: + def __init__(self, server): + + # protocol_schema: The default schema to identify the protocol. + self.protocol_schema = server.schemas.scratch + + self.storage = dict() + + # valid(message, schema): Used to verify messages. + def valid(client, message, schema): + if server.validator(message, schema): + return True + else: + errors = server.validator.errors + server.logger.warning(f"Error: {errors}") + server.close_connection(client, reason=f"Message schema validation failed") + return False + + @server.on_command(cmd="handshake", schema=self.protocol_schema) + async def handshake(client, message): + + # Safety first + if ("scratchsessionsid" in client.request_headers) or ("scratchsessionsid" in client.response_headers): + + # Log the hiccup + server.logger.critical(f"Client {client.id} sent scratchsessionsid header(s) - Aborting connection!") + + # Tell the client they are doing a no-no + await client.send("The cloud data library you are using is putting your Scratch account at risk by sending your login token for no reason. Change your Scratch password immediately, then contact the maintainers of that library for further information. This connection is being closed to protect your security.") + + # Abort the connection + server.close_connection(client, code=4005, reason=f"Connection closed for security reasons") + + # End this guard clause + return + + # Create project ID (temporary since rooms_manager isn't done yet) + if not message["project_id"] in self.storage: + self.storage[message["project_id"]] = dict() + + # Sync project ID variable state + for variable in self.storage[message["project_id"]]: + server.logger.debug(f"Sending variable {variable} to client {client.id}") + server.send_packet_unicast(client, { + "method": "set", + "name": variable, + "value": self.storage[message["project_id"]][variable] + }) + + @server.on_command(cmd="create", schema=self.protocol_schema) + async def create_variable(client, message): + if not valid(client, message, self.protocol_schema.method): + return + + # Guard clause - Room must exist before adding to it + if not message["project_id"] in self.storage: + server.logger.warning(f"Error: room {message['project_id']} does not exist yet") + + # Abort the connection + server.close_connection(client, code=4004, reason=f"Invalid room ID: {message['project_id']}") + + server.logger.debug(f"Creating global variable {message['name']} in {message['project_id']}") + + # Create the variable + self.storage[message["project_id"]][message['name']] = message["value"] + + # Broadcast the variable + for variable in self.storage[message["project_id"]]: + server.logger.debug(f"Creating variable {variable} in {len(server.clients_manager)} clients") + server.send_packet_multicast(server.clients_manager.clients, { + "method": "create", + "name": variable, + "value": self.storage[message["project_id"]][variable] + }) + + @server.on_command(cmd="delete", schema=self.protocol_schema) + async def create_variable(client, message): + if not valid(client, message, self.protocol_schema.method): + return + + # Guard clause - Room must exist before deleting values from it + if not message["project_id"] in self.storage: + server.logger.warning(f"Error: room {message['project_id']} does not exist yet") + + # Abort the connection + server.close_connection(client, code=4004, reason=f"Invalid room ID: {message['project_id']}") + + server.logger.debug(f"Deleting global variable {message['name']} in {message['project_id']}") + + # Delete the variable + del self.storage[message["project_id"]][message['name']] + + # Broadcast the variable + for variable in self.storage[message["project_id"]]: + server.logger.debug(f"Deleting variable {variable} in {len(server.clients_manager)} clients") + server.send_packet_multicast(server.clients_manager.clients, { + "method": "delete", + "name": variable + }) + + @server.on_command(cmd="set", schema=self.protocol_schema) + async def set_value(client, message): + if not valid(client, message, self.protocol_schema.method): + return + + server.logger.debug(f"Updating global variable {message['name']} to value {message['value']}") + + # Guard clause - Room must exist before adding to it + if not message["project_id"] in self.storage: + server.logger.warning(f"Error: {errors}") + + # Abort the connection + server.close_connection(client, code=4004, reason=f"Invalid room ID: {message['project_id']}") + + # Update variable state + self.storage[message["project_id"]][message['name']] = message['value'] + + # Broadcast the variable + for variable in self.storage[message["project_id"]]: + server.logger.debug(f"Sending variable {variable} to {len(server.clients_manager)} clients") + server.send_packet_multicast(server.clients_manager.clients, { + "method": "set", + "name": variable, + "value": self.storage[message["project_id"]][variable] + }) \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..0564f9e --- /dev/null +++ b/main.py @@ -0,0 +1,20 @@ +from cloudlink import cloudlink + + +if __name__ == "__main__": + cl = cloudlink.server() + + # Set logging level + cl.logging.basicConfig( + format="[%(asctime)s] %(levelname)s: %(message)s", + level=cl.logging.DEBUG + ) + + # Configure + cl.enable_motd = True + cl.motd_message = "Hello world!" + cl.enable_scratch_support = True + cl.max_clients = 5 # Set to -1 for unlimited + + # Start the server + cl.run() diff --git a/client-test-async.py b/old/client-test-async.py similarity index 100% rename from client-test-async.py rename to old/client-test-async.py diff --git a/client-test-old.py b/old/client-test-old.py similarity index 100% rename from client-test-old.py rename to old/client-test-old.py diff --git a/old/cloudlink/__init__.py b/old/cloudlink/__init__.py new file mode 100644 index 0000000..83c28c3 --- /dev/null +++ b/old/cloudlink/__init__.py @@ -0,0 +1 @@ +from .cloudlink import * \ No newline at end of file diff --git a/cloudlink/__main__.py b/old/cloudlink/__main__.py similarity index 100% rename from cloudlink/__main__.py rename to old/cloudlink/__main__.py diff --git a/cloudlink/async_client/__init__.py b/old/cloudlink/async_client/__init__.py similarity index 100% rename from cloudlink/async_client/__init__.py rename to old/cloudlink/async_client/__init__.py diff --git a/cloudlink/async_client/async_client.py b/old/cloudlink/async_client/async_client.py similarity index 100% rename from cloudlink/async_client/async_client.py rename to old/cloudlink/async_client/async_client.py diff --git a/old/cloudlink/cloudlink.py b/old/cloudlink/cloudlink.py new file mode 100644 index 0000000..bc9968b --- /dev/null +++ b/old/cloudlink/cloudlink.py @@ -0,0 +1,52 @@ +from .supporter import supporter + +""" +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 + +Cloudlink is built upon https://github.com/aaugustin/websockets. + +Please see https://github.com/MikeDev101/cloudlink for more details. + +Cloudlink's dependencies are: +* websockets (for server and asyncio client) +* websocket-client (for non-asyncio client) + +These dependencies are built-in to Python. +* copy +* asyncio +* traceback +* datetime +* json +""" + + +class cloudlink: + def __init__(self): + self.version = "0.1.9.2" + self.supporter = supporter + + def server(self, logs: bool = False): + # Initialize Cloudlink server + from .server import server + return server(self, logs) + + def client(self, logs: bool = False, async_client: bool = True): + # Initialize Cloudlink client + if async_client: + from .async_client import async_client + return async_client.client(self, logs) + else: + from .old_client import old_client + return old_client.client(self, logs) + + def multi_client(self, logs: bool = False, async_client: bool = True): + # Initialize Cloudlink client + if async_client: + from .async_client import async_client + return async_client.multi_client(self, logs) + else: + from .old_client import old_client + return old_client.multi_client(self, logs) diff --git a/cloudlink/docs/docs.txt b/old/cloudlink/docs/docs.txt similarity index 100% rename from cloudlink/docs/docs.txt rename to old/cloudlink/docs/docs.txt diff --git a/cloudlink/old_client/__init__.py b/old/cloudlink/old_client/__init__.py similarity index 100% rename from cloudlink/old_client/__init__.py rename to old/cloudlink/old_client/__init__.py diff --git a/cloudlink/old_client/old_client.py b/old/cloudlink/old_client/old_client.py similarity index 100% rename from cloudlink/old_client/old_client.py rename to old/cloudlink/old_client/old_client.py diff --git a/cloudlink/server/__init__.py b/old/cloudlink/server/__init__.py similarity index 100% rename from cloudlink/server/__init__.py rename to old/cloudlink/server/__init__.py diff --git a/cloudlink/server/protocols/__init__.py b/old/cloudlink/server/protocols/__init__.py similarity index 100% rename from cloudlink/server/protocols/__init__.py rename to old/cloudlink/server/protocols/__init__.py diff --git a/cloudlink/server/protocols/cl_methods.py b/old/cloudlink/server/protocols/cl_methods.py similarity index 100% rename from cloudlink/server/protocols/cl_methods.py rename to old/cloudlink/server/protocols/cl_methods.py diff --git a/cloudlink/server/protocols/scratch_methods.py b/old/cloudlink/server/protocols/scratch_methods.py similarity index 100% rename from cloudlink/server/protocols/scratch_methods.py rename to old/cloudlink/server/protocols/scratch_methods.py diff --git a/cloudlink/server/server.py b/old/cloudlink/server/server.py similarity index 100% rename from cloudlink/server/server.py rename to old/cloudlink/server/server.py diff --git a/cloudlink/supporter.py b/old/cloudlink/supporter.py similarity index 100% rename from cloudlink/supporter.py rename to old/cloudlink/supporter.py diff --git a/old/requirements.txt b/old/requirements.txt new file mode 100644 index 0000000..9f0a10d --- /dev/null +++ b/old/requirements.txt @@ -0,0 +1,2 @@ +websockets +websocket-client \ No newline at end of file diff --git a/server_example.py b/old/server_example.py similarity index 100% rename from server_example.py rename to old/server_example.py diff --git a/requirements.txt b/requirements.txt index 9f0a10d..52ed0a6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,4 @@ -websockets -websocket-client \ No newline at end of file +snowflake-id>=0.0.2 +cerberus>=1.3.4 +websockets>=10.4 +ujson>=5.7.0 From a3a941f9168a15d9e662da95f6ee0a8d0332f469 Mon Sep 17 00:00:00 2001 From: "Mike J. Renaker / \"MikeDEV" Date: Sun, 19 Mar 2023 21:13:55 -0400 Subject: [PATCH 05/59] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a75fe3c..91a1036 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ CloudLink can run on minimal resources. At least 25MB of RAM and any reasonably * Support for bridging servers * Admin functionality for management -# πŸ“¦ Minimal dependencies +### πŸ“¦ Minimal dependencies All dependencies below can be installed using `pip install -r requirements.txt`. * 🐍 Python >=3.11 * 🧡 asyncio (Built-in) From ba4db221f84550c12a0a05a2c252bed607899aa0 Mon Sep 17 00:00:00 2001 From: "Mike J. Renaker / \"MikeDEV" Date: Mon, 20 Mar 2023 17:02:15 -0400 Subject: [PATCH 06/59] New fun testing * Engine is now portable (to a degree) - I just tested monkey-patching FastAPI and it works??? * Removed some unnecessary debug messages --- cloudlink/cloudlink.py | 269 +++++++++++++---------- cloudlink/modules/async_event_manager.py | 2 - cloudlink/modules/async_iterables.py | 2 - cloudlink/modules/clients_manager.py | 20 -- cloudlink/protocols/clpv4.py | 4 + cloudlink/protocols/scratch.py | 2 +- main.py | 64 +++++- test.py | 23 ++ 8 files changed, 229 insertions(+), 157 deletions(-) create mode 100644 test.py diff --git a/cloudlink/cloudlink.py b/cloudlink/cloudlink.py index 1ac28b0..1143e73 100644 --- a/cloudlink/cloudlink.py +++ b/cloudlink/cloudlink.py @@ -1,4 +1,4 @@ -import websockets +# Core components of the CloudLink engine import asyncio import ujson import cerberus @@ -6,7 +6,10 @@ from copy import copy from snowflake import SnowflakeGenerator -# Import Cloudlink modules +# Import websocket engine +import websockets + +# Import CloudLink modules from cloudlink.modules.async_event_manager import async_event_manager from cloudlink.modules.async_iterables import async_iterable from cloudlink.modules.clients_manager import clients_manager @@ -54,8 +57,9 @@ def __init__(self): # Asyncio self.asyncio = asyncio - # Websocket server + # Configure websocket framework self.ws = websockets + self.ujson = ujson # Components self.gen = SnowflakeGenerator(42) @@ -65,6 +69,7 @@ def __init__(self): self.copy = copy self.clients_manager = clients_manager(self) self.exceptions = exceptions() + # Create event managers self.on_connect_events = async_event_manager(self) @@ -82,15 +87,16 @@ def __init__(self): # Message of the day self.enable_motd = False self.motd_message = str() - + + # Configure framework logging + self.supress_websocket_logs = True + # Set to -1 to allow as many client as possible self.max_clients = -1 - + # Runs the server. def run(self, ip="127.0.0.1", port=3000): try: - self.logger.info(f"Cloudlink Server v{self.version}") - # Validate config before startup if type(self.max_clients) != int: raise TypeError("The max_clients value must be a integer value set to -1 (unlimited clients) or greater than zero!") @@ -106,16 +112,27 @@ def run(self, ip="127.0.0.1", port=3000): if type(self.motd_message) != str: raise TypeError("The motd_message value must be a string!") - + # Load CLPv4 protocol clpv4(self) - + # Load Scratch protocol if self.enable_scratch_support: scratch(self) - + + # Startup message + self.logger.info(f"CloudLink {self.version} - Now listening to {ip}:{port}") + + # Supress websocket library logging + if self.supress_websocket_logs: + self.logging.getLogger('asyncio').setLevel(self.logging.ERROR) + self.logging.getLogger('asyncio.coroutines').setLevel(self.logging.ERROR) + self.logging.getLogger('websockets.server').setLevel(self.logging.ERROR) + self.logging.getLogger('websockets.protocol').setLevel(self.logging.ERROR) + # Start server self.asyncio.run(self.__run__(ip, port)) + except KeyboardInterrupt: pass @@ -126,16 +143,14 @@ def bind_event(func): # Create schema category for command event manager if schema not in self.command_handlers: - self.logger.debug(f"Adding schema {schema.__qualname__} to command handlers") + self.logger.info(f"Creating command event manager {schema.__qualname__}") self.command_handlers[schema] = dict() # Create command event handler if cmd not in self.command_handlers[schema]: - self.logger.debug(f"Creating command handler \"{cmd}\" with schema {schema.__qualname__}") self.command_handlers[schema][cmd] = async_event_manager(self) # Add function to the command handler - self.logger.debug(f"Binding function {func.__qualname__} to command handler \"{cmd}\" with schema {schema.__qualname__}") self.command_handlers[schema][cmd].bind(func) # End on_command binder @@ -147,17 +162,14 @@ def bind_event(func): # Create schema category for error event manager if schema not in self.exception_handlers: - self.logger.debug(f"Adding schema {schema.__qualname__} to exception handlers") + self.logger.info(f"Creating exception event manager {schema.__qualname__}") self.exception_handlers[schema] = dict() # Create error event handler if exception_type not in self.exception_handlers[schema]: - self.logger.debug(f"Creating exception handler {exception_type} with schema {schema.__qualname__}") self.exception_handlers[schema][exception_type] = async_event_manager(self) # Add function to the error command handler - self.logger.debug( - f"Binding function {func.__qualname__} to exception handler {exception_type} with schema {schema.__qualname__}") self.exception_handlers[schema][exception_type].bind(func) # End on_error_specific binder @@ -181,118 +193,125 @@ def on_error(self, func): # Friendly version of send_packet_unicast / send_packet_multicast def send_packet(self, obj, message): - if hasattr(obj, "__iter__"): - self.asyncio.create_task(self.__execute_multicast__(obj, message)) + if type(obj) in [list, set]: + print("Multicasting", message) + self.asyncio.create_task(self.execute_multicast(obj, message)) else: - self.asyncio.create_task(self.__execute_unicast__(obj, message)) + print("Unicasting", message) + self.asyncio.create_task(self.execute_unicast(obj, message)) # Send message to a single client def send_packet_unicast(self, client, message): # Create unicast task - self.asyncio.create_task(self.__execute_unicast__(client, message)) + self.asyncio.create_task(self.execute_unicast(client, message)) # Send message to multiple clients def send_packet_multicast(self, clients, message): # Create multicast task - self.asyncio.create_task(self.__execute_multicast__(clients, message)) + self.asyncio.create_task(self.execute_multicast(clients, message)) # Close the connection to client(s) def close_connection(self, obj, code=1000, reason=""): - if hasattr(obj, "__iter__"): - self.asyncio.create_task(self.__execute_close_multi__(obj, code, reason)) + if type(obj) in [list, set]: + self.asyncio.create_task(self.execute_close_multi(obj, code, reason)) else: - self.asyncio.create_task(self.__execute_close_single__(obj, code, reason)) + self.asyncio.create_task(self.execute_close_single(obj, code, reason)) # Message processor - async def __process__(self, client, message): - + async def message_processor(self, client, message): + print(client, message) # Empty packet if not len(message): - + print("Empty!") # Fire on_error events - asyncio.create_task(self.__execute_on_error_events__(client, self.exceptions.EmptyMessage)) + asyncio.create_task(self.execute_on_error_events(client, self.exceptions.EmptyMessage)) # Fire exception handling events if client.protocol_set: self.asyncio.create_task( - self.__execute_exception_handlers__( + self.execute_exception_handlers( client=client, exception_type=self.exceptions.EmptyMessage, schema=client.protocol, details="Empty message" ) ) + else: + # Close the connection + self.send_packet(client, "Empty message") + self.close_connection(client, reason="Empty message") - # End __process__ coroutine + # End message_processor coroutine return - + # Parse JSON in message and convert to dict try: - message = ujson.loads(message) + message = self.ujson.loads(message) + print("JSON OK") + except Exception as error: - - # Log JSON parsing error - self.logger.warning(f"Handling JSON exception: {error}") - # Fire on_error events - self.asyncio.create_task(self.__execute_on_error_events__(client, error)) + self.asyncio.create_task(self.execute_on_error_events(client, error)) # Fire exception handling events if client.protocol_set: self.asyncio.create_task( - self.__execute_exception_handlers__( + self.execute_exception_handlers( client=client, exception_type=self.exceptions.JSONError, schema=client.protocol, details=f"JSON parsing error: {error}" ) ) + else: + # Close the connection + self.send_packet(client, "Invalid JSON") + self.close_connection(client, reason="Invalid JSON") - # End __process__ coroutine + # End message_processor coroutine return - self.logger.debug(f"Got message from client {client.snowflake}: {message}") - # Begin validation valid = False selected_protocol = None # Client protocol is unknown if not client.protocol: - + self.logger.debug(f"Trying to identify client {client.snowflake}'s protocol") + # Identify protocol errorlist = list() for schema in self.command_handlers: if self.validator(message, schema.default): valid = True selected_protocol = schema - self.logger.debug(f"Validation passed; Detected schema: {selected_protocol}") break else: errorlist.append(self.validator.errors) - self.logger.debug(f"Validation error: {self.validator.errors}") if not valid: # Log failed identification self.logger.debug(f"Could not identify protocol used by client {client.snowflake}: {errorlist}") # Fire on_error events - self.asyncio.create_task(self.__execute_on_error_events__(client, "Unable to identify protocol")) + self.asyncio.create_task(self.execute_on_error_events(client, "Unable to identify protocol")) # Close the connection - await client.send("Unable to identify protocol") + self.send_packet(client, "Unable to identify protocol") self.close_connection(client, reason="Unable to identify protocol") - # End __process__ coroutine + # End message_processor coroutine return # Log known protocol - self.logger.info(f"Client {client.snowflake} is using protocol {selected_protocol.__qualname__}") + self.logger.debug(f"Client {client.snowflake} is using protocol {selected_protocol.__qualname__}") # Make the client's protocol known self.clients_manager.set_protocol(client, selected_protocol) else: + self.logger.debug(f"Validating client {client.snowflake}'s message using known protocol: {client.protocol}") + # Validate message using known protocol selected_protocol = client.protocol @@ -303,24 +322,24 @@ async def __process__(self, client, message): self.logger.debug(f"Client {client.snowflake} sent message that failed validation: {errors}") # Fire on_error events - self.asyncio.create_task(self.__execute_on_error_events__(client, errors)) + self.asyncio.create_task(self.execute_on_error_events(client, errors)) - # End __process__ coroutine + # End message_processor coroutine return # Check if command exists if message[selected_protocol.command_key] not in self.command_handlers[selected_protocol]: # Log invalid command - self.logger.warning(f"Invalid command \"{message[selected_protocol.command_key]}\" in protocol {selected_protocol.__qualname__} from client {client.snowflake}") + self.logger.debug(f"Invalid command \"{message[selected_protocol.command_key]}\" in protocol {selected_protocol.__qualname__} from client {client.snowflake}") # Fire on_error events - self.asyncio.create_task(self.__execute_on_error_events__(client, "Invalid command")) + self.asyncio.create_task(self.execute_on_error_events(client, "Invalid command")) # Fire exception handling events if client.protocol_set: self.asyncio.create_task( - self.__execute_exception_handlers__( + self.execute_exception_handlers( client=client, exception_type=self.exceptions.InvalidCommand, schema=client.protocol, @@ -328,12 +347,12 @@ async def __process__(self, client, message): ) ) - # End __process__ coroutine + # End message_processor coroutine return # Fire on_command events self.asyncio.create_task( - self.__execute_on_command_events__( + self.execute_on_command_events( client, message, selected_protocol @@ -342,20 +361,21 @@ async def __process__(self, client, message): # Fire on_message events self.asyncio.create_task( - self.__execute_on_message_events__( + self.execute_on_message_events( client, message ) ) # Connection handler - async def __handler__(self, client): + async def connection_handler(self, client): + # Limit the amount of clients connected if self.max_clients != -1: if len(self.clients_manager) >= self.max_clients: - self.logger.warning(f"Refusing new connection due to the server being full") - await client.send("Server is full") - await client.close(reason="Server is full") + self.logger.warning("Server full: Refused a new connection") + self.send_packet(client, "Server is full!") + self.close_connection(client, reason="Server is full!") return # Startup client attributes @@ -370,121 +390,128 @@ async def __handler__(self, client): # Add to clients manager self.clients_manager.add(client) - # Log new connection - self.logger.info(f"Client {client.snowflake} connected") - - # Log current number of clients - self.logger.info(f"There are now {len(self.clients_manager)} clients connected.") - # Fire on_connect events - self.asyncio.create_task(self.__execute_on_connect_events__(client)) - + self.asyncio.create_task(self.execute_on_connect_events(client)) + + # Run connection loop + await self.connection_loop(client) + + # Remove from clients manager + self.clients_manager.remove(client) + + # Fire on_disconnect events + self.asyncio.create_task(self.execute_on_disconnect_events(client)) + + # Connection loop - Redefine for use with another outside library + async def connection_loop(self, client): # Primary asyncio loop for the lifespan of the websocket connection try: async for message in client: - await self.__process__(client, message) + await self.message_processor(client, message) # Handle unexpected disconnects except self.ws.exceptions.ConnectionClosedError: - self.logger.debug(f"Handling ConnectionClosedError exception raised by websocket") + pass # Handle OK disconnects except self.ws.exceptions.ConnectionClosedOK: - self.logger.debug(f"Handling ConnectionClosedOK exception raised by websocket") + pass # Catch any unexpected exceptions except Exception as e: self.logger.critical(f"Unexpected exception was raised: {e}") # Fire on_error events - self.asyncio.create_task(self.__execute_on_error_events__(client, f"Unexpected exception was raised: {e}")) + self.asyncio.create_task(self.execute_on_error_events(client, f"Unexpected exception was raised: {e}")) # Fire exception handling events if client.protocol_set: self.asyncio.create_task( - self.__execute_exception_handlers__( + self.execute_exception_handlers( client=client, exception_type=self.exceptions.InternalError, schema=client.protocol, details=f"Unexpected exception was raised: {e}" ) ) - - # Graceful shutdown - finally: - - # Remove from clients manager - self.clients_manager.remove(client) - - # Log disconnect - self.logger.info(f"Client {client.snowflake} disconnected") - - # Log current number of clients - self.logger.info(f"There are now {len(self.clients_manager)} clients connected.") - - # Fire on_disconnect events - self.asyncio.create_task(self.__execute_on_disconnect_events__(client)) - - # Server Asyncio loop + + # Server - You can modify this code to use a different websocket engine. async def __run__(self, ip, port): - # Main event loop - async with self.ws.serve(self.__handler__, ip, port): + async with self.ws.serve(self.connection_handler, ip, port): await self.asyncio.Future() # Asyncio event-handling coroutines - async def __execute_on_disconnect_events__(self, client): - self.logger.debug(f"Firing all events bound to on_disconnect") + + async def execute_on_disconnect_events(self, client): async for event in self.on_disconnect_events: await event(client) - async def __execute_on_connect_events__(self, client): - self.logger.debug(f"Firing all events bound to on_connect") + async def execute_on_connect_events(self, client): async for event in self.on_connect_events: await event(client) - async def __execute_on_message_events__(self, client, message): - self.logger.debug(f"Firing all events bound to on_message") + async def execute_on_message_events(self, client, message): async for event in self.on_message_events: await event(client, message) - async def __execute_on_command_events__(self, client, message, schema): - self.logger.debug(f"Firing all bound events to command handler \"{message[schema.command_key]}\" with schema {schema.__qualname__}") + async def execute_on_command_events(self, client, message, schema): async for event in self.command_handlers[schema][message[schema.command_key]]: await event(client, message) - async def __execute_on_error_events__(self, client, errors): - self.logger.debug(f"Firing all events bound to on_error") + async def execute_on_error_events(self, client, errors): async for event in self.on_error_events: await event(client, errors) - - async def __execute_exception_handlers__(self, client, exception_type, schema, details): + + async def execute_exception_handlers(self, client, exception_type, schema, details): # Guard clauses if schema not in self.exception_handlers: return if exception_type not in self.exception_handlers[schema]: return - self.logger.debug(f"Firing all exception handlers bound to schema {schema.__qualname__} and exception type {exception_type}") - # Fire events async for event in self.exception_handlers[schema][exception_type]: await event(client, details) - - async def __execute_unicast__(self, client, message): - self.logger.debug(f"Sending unicast message {message} to {client.snowflake}") - await client.send(ujson.dumps(message)) - - async def __execute_multicast__(self, clients, message): - self.logger.debug(f"Sending multicast message {message} to {len(clients)} clients") + + # You can modify the code below to use different websocket engines. + + async def execute_unicast(self, client, message): + + # Guard clause + if type(message) not in [dict, str]: + raise TypeError("Supported datatypes for messages are dicts and strings, got type {type(message)}.") + + # Convert dict to JSON + if type(message) == dict: + message = self.ujson.dumps(message) + + # Attempt to send the packet + try: + await client.send(message) + except: + pass + + async def execute_multicast(self, clients, message): + + # Guard clause + if type(message) not in [dict, str]: + raise TypeError("Supported datatypes for messages are dicts and strings, got type {type(message)}.") + + # Convert dict to JSON + if type(message) == dict: + message = self.ujson.dumps(message) + + # Attempt to broadcast the packet async for client in self.async_iterable(self, clients): - await client.send(ujson.dumps(message)) - - async def __execute_close_single__(self, client, code=1000, reason=""): - self.logger.debug(f"Closing the connection to client {client.snowflake} with code {code} and reason {reason}") + try: + await self.execute_unicast(client, message) + except: + pass + + async def execute_close_single(self, client, code=1000, reason=""): await client.close(code, reason) - async def __execute_close_multi__(self, clients, code=1000, reason=""): - self.logger.debug(f"Closing the connection to {len(clients)} clients with code {code} and reason {reason}") + async def execute_close_multi(self, clients, code=1000, reason=""): async for client in self.async_iterable(self, clients): - await client.close(code, reason) + await self.execute_close_single(client, code, reason) diff --git a/cloudlink/modules/async_event_manager.py b/cloudlink/modules/async_event_manager.py index 4f8975e..0a26f95 100644 --- a/cloudlink/modules/async_event_manager.py +++ b/cloudlink/modules/async_event_manager.py @@ -45,7 +45,6 @@ def __aiter__(self): async def __anext__(self): # Check for further events in the list of events if self.iterator >= len(self.events): - self.logger.debug(f"Finished executing awaitable events") self.iterator = 0 raise StopAsyncIteration @@ -53,5 +52,4 @@ async def __anext__(self): self.iterator += 1 # Execute async event - self.logger.debug(f"Executing event {self.iterator} of {len(self.events)}") return list(self.events)[self.iterator - 1] diff --git a/cloudlink/modules/async_iterables.py b/cloudlink/modules/async_iterables.py index 583604d..af25068 100644 --- a/cloudlink/modules/async_iterables.py +++ b/cloudlink/modules/async_iterables.py @@ -25,11 +25,9 @@ def __aiter__(self): async def __anext__(self): if self.iterator >= len(self.iterable): - self.logger.debug(f"Finished iterating") self.iterator = 0 raise StopAsyncIteration self.iterator += 1 - self.logger.debug(f"Iterating {self.iterator} of {len(self.iterable)}") return self.iterable[self.iterator - 1] diff --git a/cloudlink/modules/clients_manager.py b/cloudlink/modules/clients_manager.py index 23334c8..d8150f2 100644 --- a/cloudlink/modules/clients_manager.py +++ b/cloudlink/modules/clients_manager.py @@ -64,49 +64,37 @@ def add(self, obj): if self.exists(obj): raise self.exceptions.ClientAlreadyExists - self.logger.debug(f"Adding client object: {obj}") - # Add object to set self.clients.add(obj) - self.logger.debug(f"Current clients set state: {self.clients}") # Add to snowflakes self.snowflakes[obj.snowflake] = obj - self.logger.debug(f"Current snowflakes state: {self.snowflakes}") # Add to UUIDs self.uuids[str(obj.id)] = obj - self.logger.debug(f"Current UUIDs state: {self.uuids}") def remove(self, obj): if not self.exists(obj): raise self.exceptions.ClientDoesNotExist - self.logger.debug(f"Removing client object: {obj}") - # Remove from all clients set self.clients.remove(obj) - self.logger.debug(f"Current clients set state: {self.clients}") # Remove from snowflakes self.snowflakes.pop(obj.snowflake) - self.logger.debug(f"Current snowflakes state: {self.snowflakes}") # Remove from UUIDs self.uuids.pop(str(obj.id)) - self.logger.debug(f"Current UUIDs state: {self.uuids}") # Remove from protocol references if obj.protocol_set: # Remove reference to protocol object if obj in self.protocols[obj.protocol]: - self.logger.debug(f"Removing client {obj.snowflake} from protocol reference: {obj.protocol}") self.protocols[obj.protocol].remove(obj) # Clean up unused protocol references if not len(self.protocols[obj.protocol]): - self.logger.debug(f"Removing protocol {obj.protocol.__qualname__} from protocols") del self.protocols[obj.protocol] # Remove client from username references @@ -114,12 +102,10 @@ def remove(self, obj): # Remove reference to username object if obj.friendly_username in self.usernames: - self.logger.debug(f"Removing client {obj.snowflake} from username reference: {obj.friendly_username}") self.usernames[obj.friendly_username].remove(obj) # Clean up unused usernames if not len(self.usernames[obj.friendly_username]): - self.logger.debug(f"Removing empty username reference: {obj.friendly_username}") del self.usernames[obj.friendly_username] def set_username(self, obj, username): @@ -129,11 +115,8 @@ def set_username(self, obj, username): if obj.username_set: raise self.exceptions.ClientUsernameAlreadySet - self.logger.debug(f"Setting client {obj.snowflake}'s friendly username to {username}") - # Create username reference if username not in self.usernames: - self.logger.debug(f"Creating new username reference: {username}") self.usernames[username] = set() # Create reference to object from it's username @@ -152,11 +135,8 @@ def set_protocol(self, obj, schema): # If the protocol was not specified beforehand, create it if schema not in self.protocols: - self.logger.debug(f"Adding protocol {schema.__qualname__} to protocols") self.protocols[schema] = set() - self.logger.debug(f"Setting client {obj.snowflake}'s protocol to {schema.__qualname__}") - # Add client to the protocols identifier self.protocols[schema].add(obj) diff --git a/cloudlink/protocols/clpv4.py b/cloudlink/protocols/clpv4.py index 9b30be5..71a62b6 100644 --- a/cloudlink/protocols/clpv4.py +++ b/cloudlink/protocols/clpv4.py @@ -197,3 +197,7 @@ async def on_direct(client, message): @server.on_command(cmd="bridge", schema=self.protocol_schema) async def on_bridge(client, message): pass + + @server.on_command(cmd="echo", schema=self.protocol_schema) + async def on_echo(client, message): + server.send_packet(client, {"cmd": "echo", "val": message["val"]}) diff --git a/cloudlink/protocols/scratch.py b/cloudlink/protocols/scratch.py index c286ac4..8269d8c 100644 --- a/cloudlink/protocols/scratch.py +++ b/cloudlink/protocols/scratch.py @@ -26,7 +26,7 @@ async def handshake(client, message): server.logger.critical(f"Client {client.id} sent scratchsessionsid header(s) - Aborting connection!") # Tell the client they are doing a no-no - await client.send("The cloud data library you are using is putting your Scratch account at risk by sending your login token for no reason. Change your Scratch password immediately, then contact the maintainers of that library for further information. This connection is being closed to protect your security.") + server.send_packet_unicast(client, "The cloud data library you are using is putting your Scratch account at risk by sending your login token for no reason. Change your Scratch password immediately, then contact the maintainers of that library for further information. This connection is being closed to protect your security.") # Abort the connection server.close_connection(client, code=4005, reason=f"Connection closed for security reasons") diff --git a/main.py b/main.py index 0564f9e..e03d081 100644 --- a/main.py +++ b/main.py @@ -1,20 +1,62 @@ from cloudlink import cloudlink +from cloudlink.protocols import clpv4 +from fastapi import FastAPI, WebSocket +import random +import uvicorn +global idnum +idnum = 0 -if __name__ == "__main__": - cl = cloudlink.server() +# Create application +cl = cloudlink.server() +app = FastAPI(title='WebSocket Example') + +# Monkey-patch code to support FastAPI +async def connection_loop(client): + while True: + message = await client.receive_text() + print(message) + await cl.message_processor(client, message) + +async def execute_unicast(client, message): + # Guard clause + if type(message) not in [dict, str]: + raise TypeError("Supported datatypes for messages are dicts and strings, got type {type(message)}.") + + # Convert dict to JSON + if type(message) == dict: + message = cl.ujson.dumps(message) + + await client.send_text(message) + +# Finish monkey-patch +cl.connection_loop = connection_loop +cl.execute_unicast = execute_unicast + +# Declare websocket endpoint +@app.websocket("/") +async def websocket_endpoint(client: WebSocket): + global idnum + await client.accept() + + # Fix unresolved attributes + client.id = idnum + idnum += 1 + client.remote_address = (client.client.host, client.client.port) + # Execute the command handler + await cl.connection_handler(client) + +if __name__ == "__main__": + # Set logging level cl.logging.basicConfig( format="[%(asctime)s] %(levelname)s: %(message)s", - level=cl.logging.DEBUG + level=cl.logging.INFO ) - - # Configure - cl.enable_motd = True - cl.motd_message = "Hello world!" - cl.enable_scratch_support = True - cl.max_clients = 5 # Set to -1 for unlimited - + + # Load CL4 protocol + clpv4(cl) + # Start the server - cl.run() + uvicorn.run(app, port=3000, log_level="debug") diff --git a/test.py b/test.py new file mode 100644 index 0000000..55e700d --- /dev/null +++ b/test.py @@ -0,0 +1,23 @@ +from fastapi import FastAPI, WebSocket +import random +import uvicorn + +# Create application +app = FastAPI(title='WebSocket Example') + +@app.websocket("/") +async def websocket_endpoint(websocket: WebSocket): + await websocket.accept() + while True: + try: + # Wait for any message from the client + message = await websocket.receive_text() + print(message) + # echo + await websocket.send_text(message) + except Exception as e: + print('error:', e) + break + +if __name__ == "__main__": + uvicorn.run(app, port=3001, log_level="debug") \ No newline at end of file From ac9403396d6b9b9d80b378e99cac69f97e3fbd51 Mon Sep 17 00:00:00 2001 From: "Mike J. Renaker / \"MikeDEV" Date: Mon, 20 Mar 2023 19:15:35 -0400 Subject: [PATCH 07/59] It's a fugly mess! --- cloudlink/cloudlink.py | 19 ++++-- main.py | 136 ++++++++++++++++++++++++++++------------- 2 files changed, 107 insertions(+), 48 deletions(-) diff --git a/cloudlink/cloudlink.py b/cloudlink/cloudlink.py index 1143e73..071e02f 100644 --- a/cloudlink/cloudlink.py +++ b/cloudlink/cloudlink.py @@ -194,10 +194,8 @@ def on_error(self, func): # Friendly version of send_packet_unicast / send_packet_multicast def send_packet(self, obj, message): if type(obj) in [list, set]: - print("Multicasting", message) self.asyncio.create_task(self.execute_multicast(obj, message)) else: - print("Unicasting", message) self.asyncio.create_task(self.execute_unicast(obj, message)) # Send message to a single client @@ -219,10 +217,13 @@ def close_connection(self, obj, code=1000, reason=""): # Message processor async def message_processor(self, client, message): + print(client, message) + # Empty packet if not len(message): - print("Empty!") + self.logger.debug(f"Client {client.snowflake} sent empty message ") + # Fire on_error events asyncio.create_task(self.execute_on_error_events(client, self.exceptions.EmptyMessage)) @@ -247,9 +248,10 @@ async def message_processor(self, client, message): # Parse JSON in message and convert to dict try: message = self.ujson.loads(message) - print("JSON OK") except Exception as error: + self.logger.debug(f"Client {client.snowflake} sent invalid JSON: {error}") + # Fire on_error events self.asyncio.create_task(self.execute_on_error_events(client, error)) @@ -281,6 +283,9 @@ async def message_processor(self, client, message): # Identify protocol errorlist = list() + + self.logger.debug(f"Checking for protocol using loaded handlers: {self.command_handlers}") + for schema in self.command_handlers: if self.validator(message, schema.default): valid = True @@ -310,7 +315,7 @@ async def message_processor(self, client, message): self.clients_manager.set_protocol(client, selected_protocol) else: - self.logger.debug(f"Validating client {client.snowflake}'s message using known protocol: {client.protocol}") + self.logger.debug(f"Validating client {client.snowflake}'s message using known protocol: {client.protocol.__qualname__}") # Validate message using known protocol selected_protocol = client.protocol @@ -393,6 +398,8 @@ async def connection_handler(self, client): # Fire on_connect events self.asyncio.create_task(self.execute_on_connect_events(client)) + self.logger.debug(f"Client {client.snowflake} connected") + # Run connection loop await self.connection_loop(client) @@ -401,6 +408,8 @@ async def connection_handler(self, client): # Fire on_disconnect events self.asyncio.create_task(self.execute_on_disconnect_events(client)) + + self.logger.debug(f"Client {client.snowflake} disconnected") # Connection loop - Redefine for use with another outside library async def connection_loop(self, client): diff --git a/main.py b/main.py index e03d081..9458487 100644 --- a/main.py +++ b/main.py @@ -1,62 +1,112 @@ from cloudlink import cloudlink from cloudlink.protocols import clpv4 -from fastapi import FastAPI, WebSocket +#from fastapi import FastAPI, WebSocket +from sanic import Sanic, Request, Websocket, json +import sys +from functools import partial +from sanic.worker.loader import AppLoader import random import uvicorn global idnum idnum = 0 -# Create application cl = cloudlink.server() -app = FastAPI(title='WebSocket Example') -# Monkey-patch code to support FastAPI -async def connection_loop(client): - while True: - message = await client.receive_text() - print(message) - await cl.message_processor(client, message) - -async def execute_unicast(client, message): - # Guard clause - if type(message) not in [dict, str]: - raise TypeError("Supported datatypes for messages are dicts and strings, got type {type(message)}.") - - # Convert dict to JSON - if type(message) == dict: - message = cl.ujson.dumps(message) - - await client.send_text(message) +cl.logging.basicConfig( + format="[%(asctime)s] %(levelname)s: %(message)s", + level=cl.logging.DEBUG +) -# Finish monkey-patch -cl.connection_loop = connection_loop -cl.execute_unicast = execute_unicast +clpv4(cl) -# Declare websocket endpoint -@app.websocket("/") -async def websocket_endpoint(client: WebSocket): - global idnum - await client.accept() - - # Fix unresolved attributes - client.id = idnum - idnum += 1 - client.remote_address = (client.client.host, client.client.port) - - # Execute the command handler - await cl.connection_handler(client) +def fastapi_monkeypatch(): + app = FastAPI(title='WebSocket Example') -if __name__ == "__main__": + # Monkey-patch code to support FastAPI + async def connection_loop(client): + while True: + try: + message = await client.receive_text() + await cl.message_processor(client, message) + except Exception as e: + print(e) + break + + async def execute_unicast(client, message): + # Guard clause + if type(message) not in [dict, str]: + raise TypeError("Supported datatypes for messages are dicts and strings, got type {type(message)}.") + + # Convert dict to JSON + if type(message) == dict: + message = cl.ujson.dumps(message) + + await client.send_text(message) + + # Finish monkey-patch + cl.connection_loop = connection_loop + cl.execute_unicast = execute_unicast - # Set logging level - cl.logging.basicConfig( - format="[%(asctime)s] %(levelname)s: %(message)s", - level=cl.logging.INFO - ) + # Declare websocket endpoint + @app.websocket("/") + async def websocket_endpoint(client: WebSocket): + global idnum + await client.accept() + + # Fix unresolved attributes + client.id = idnum + idnum += 1 + client.remote_address = (client.client.host, client.client.port) + + # Execute the command handler + await cl.connection_handler(client) + + # Load CL4 protocol clpv4(cl) # Start the server - uvicorn.run(app, port=3000, log_level="debug") + uvicorn.run(app, port=3000) + +def vanilla(): + cl.run(port=3000) + +def attach_endpoints(app: Sanic): + @app.websocket("/") + async def feed(request: Request, ws: Websocket): + global idnum + + # Fix unresolved attributes + ws.id = idnum + idnum += 1 + ws.remote_address = ("127.0.0.1", 3000) + + # Execute the command handler + await cl.connection_handler(ws) + +def create_app(app_name: str) -> Sanic: + app = Sanic(app_name) + attach_endpoints(app) + return app + +async def connection_loop(client): + try: + async for message in client: + await cl.message_processor(client, message) + except Exception as e: + print(e) + +cl.connection_loop = connection_loop + +if __name__ == "__main__": + #vanilla() + #monkeypatch() + + # Start sanic server + app_name = "test" + loader = AppLoader(factory=partial(create_app, app_name)) + app = loader.load() + app.prepare(port=3000, dev=True) + Sanic.serve(primary=app, app_loader=loader) \ No newline at end of file From 1b8f741608fa8701178f99110ffe07c7f840ae9e Mon Sep 17 00:00:00 2001 From: "Mike J. Renaker / \"MikeDEV" Date: Tue, 21 Mar 2023 13:14:43 -0400 Subject: [PATCH 08/59] Cleanup time --- cloudlink/cloudlink.py | 2 - main.py | 113 +++-------------------------------------- 2 files changed, 7 insertions(+), 108 deletions(-) diff --git a/cloudlink/cloudlink.py b/cloudlink/cloudlink.py index 071e02f..2e44ed4 100644 --- a/cloudlink/cloudlink.py +++ b/cloudlink/cloudlink.py @@ -218,8 +218,6 @@ def close_connection(self, obj, code=1000, reason=""): # Message processor async def message_processor(self, client, message): - print(client, message) - # Empty packet if not len(message): self.logger.debug(f"Client {client.snowflake} sent empty message ") diff --git a/main.py b/main.py index 9458487..206211b 100644 --- a/main.py +++ b/main.py @@ -1,112 +1,13 @@ from cloudlink import cloudlink -from cloudlink.protocols import clpv4 -#from fastapi import FastAPI, WebSocket -from sanic import Sanic, Request, Websocket, json -import sys -from functools import partial -from sanic.worker.loader import AppLoader -import random -import uvicorn -global idnum -idnum = 0 -cl = cloudlink.server() - -cl.logging.basicConfig( - format="[%(asctime)s] %(levelname)s: %(message)s", - level=cl.logging.DEBUG -) - -clpv4(cl) - -def fastapi_monkeypatch(): - app = FastAPI(title='WebSocket Example') - - # Monkey-patch code to support FastAPI - async def connection_loop(client): - while True: - try: - message = await client.receive_text() - await cl.message_processor(client, message) - except Exception as e: - print(e) - break - - async def execute_unicast(client, message): - # Guard clause - if type(message) not in [dict, str]: - raise TypeError("Supported datatypes for messages are dicts and strings, got type {type(message)}.") - - # Convert dict to JSON - if type(message) == dict: - message = cl.ujson.dumps(message) - - await client.send_text(message) - - # Finish monkey-patch - cl.connection_loop = connection_loop - cl.execute_unicast = execute_unicast +if __name__ == "__main__": + cl = cloudlink.server() - # Declare websocket endpoint - @app.websocket("/") - async def websocket_endpoint(client: WebSocket): - global idnum - await client.accept() - - # Fix unresolved attributes - client.id = idnum - idnum += 1 - client.remote_address = (client.client.host, client.client.port) - - # Execute the command handler - await cl.connection_handler(client) - - - - # Load CL4 protocol - clpv4(cl) - - # Start the server - uvicorn.run(app, port=3000) + cl.logging.basicConfig( + format="[%(asctime)s] %(levelname)s: %(message)s", + level=cl.logging.INFO + ) -def vanilla(): cl.run(port=3000) - -def attach_endpoints(app: Sanic): - @app.websocket("/") - async def feed(request: Request, ws: Websocket): - global idnum - - # Fix unresolved attributes - ws.id = idnum - idnum += 1 - ws.remote_address = ("127.0.0.1", 3000) - - # Execute the command handler - await cl.connection_handler(ws) - -def create_app(app_name: str) -> Sanic: - app = Sanic(app_name) - attach_endpoints(app) - return app - -async def connection_loop(client): - try: - async for message in client: - await cl.message_processor(client, message) - except Exception as e: - print(e) - -cl.connection_loop = connection_loop - -if __name__ == "__main__": - #vanilla() - #monkeypatch() - - # Start sanic server - app_name = "test" - loader = AppLoader(factory=partial(create_app, app_name)) - app = loader.load() - app.prepare(port=3000, dev=True) - Sanic.serve(primary=app, app_loader=loader) \ No newline at end of file + \ No newline at end of file From 4bdf7ff808ef7e5f64f8845253f11e248c3a71c7 Mon Sep 17 00:00:00 2001 From: "Mike J. Renaker / \"MikeDEV" Date: Tue, 21 Mar 2023 14:34:09 -0400 Subject: [PATCH 09/59] insanity --- .idea/cloudlink.iml | 10 ++ .idea/inspectionProfiles/Project_Default.xml | 12 +++ .../inspectionProfiles/profiles_settings.xml | 6 ++ .idea/misc.xml | 4 + .idea/modules.xml | 8 ++ .idea/vcs.xml | 6 ++ .idea/workspace.xml | 66 +++++++++++++ cloudlink/__init__.py | 2 +- cloudlink/cloudlink.py | 53 +++++++---- cloudlink/modules/clients_manager.py | 2 +- cloudlink/modules/rooms_manager.py | 68 ++++++++++++-- cloudlink/modules/schemas.py | 5 +- cloudlink/protocols/__init__.py | 1 + cloudlink/protocols/clpv4.py | 92 +++++++++++++------ cloudlink/protocols/scratch.py | 26 +++++- 15 files changed, 303 insertions(+), 58 deletions(-) create mode 100644 .idea/cloudlink.iml create mode 100644 .idea/inspectionProfiles/Project_Default.xml create mode 100644 .idea/inspectionProfiles/profiles_settings.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/vcs.xml create mode 100644 .idea/workspace.xml diff --git a/.idea/cloudlink.iml b/.idea/cloudlink.iml new file mode 100644 index 0000000..544fcff --- /dev/null +++ b/.idea/cloudlink.iml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..f5cd9d9 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,12 @@ + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..1fbdce6 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..d8fefc9 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/workspace.xml b/.idea/workspace.xml new file mode 100644 index 0000000..09f79e4 --- /dev/null +++ b/.idea/workspace.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + 1679419080511 + + + + \ No newline at end of file diff --git a/cloudlink/__init__.py b/cloudlink/__init__.py index 83c28c3..13cb868 100644 --- a/cloudlink/__init__.py +++ b/cloudlink/__init__.py @@ -1 +1 @@ -from .cloudlink import * \ No newline at end of file +from .cloudlink import * diff --git a/cloudlink/cloudlink.py b/cloudlink/cloudlink.py index 2e44ed4..1bba682 100644 --- a/cloudlink/cloudlink.py +++ b/cloudlink/cloudlink.py @@ -1,6 +1,5 @@ # Core components of the CloudLink engine import asyncio -import ujson import cerberus import logging from copy import copy @@ -21,6 +20,13 @@ from cloudlink.protocols import clpv4 from cloudlink.protocols import scratch +# Import JSON library - Prefer UltraJSON but use native JSON if failed +try: + import ujson +except ImportError: + print("Failed to import UltraJSON, failing back to native JSON library.") + import json as ujson + # Define server exceptions class exceptions: @@ -59,9 +65,9 @@ def __init__(self): # Configure websocket framework self.ws = websockets - self.ujson = ujson # Components + self.ujson = ujson self.gen = SnowflakeGenerator(42) self.validator = cerberus.Validator() self.schemas = schemas @@ -69,7 +75,6 @@ def __init__(self): self.copy = copy self.clients_manager = clients_manager(self) self.exceptions = exceptions() - # Create event managers self.on_connect_events = async_event_manager(self) @@ -99,10 +104,14 @@ def run(self, ip="127.0.0.1", port=3000): try: # Validate config before startup if type(self.max_clients) != int: - raise TypeError("The max_clients value must be a integer value set to -1 (unlimited clients) or greater than zero!") + raise TypeError( + "The max_clients value must be a integer value set to -1 (unlimited clients) or greater than zero!" + ) if self.max_clients < -1 or self.max_clients == 0: - raise ValueError("The max_clients value must be a integer value set to -1 (unlimited clients) or greater than zero!") + raise ValueError( + "The max_clients value must be a integer value set to -1 (unlimited clients) or greater than zero!" + ) if type(self.enable_scratch_support) != bool: raise TypeError("The enable_scratch_support value must be a boolean!") @@ -132,7 +141,6 @@ def run(self, ip="127.0.0.1", port=3000): # Start server self.asyncio.run(self.__run__(ip, port)) - except KeyboardInterrupt: pass @@ -282,8 +290,6 @@ async def message_processor(self, client, message): # Identify protocol errorlist = list() - self.logger.debug(f"Checking for protocol using loaded handlers: {self.command_handlers}") - for schema in self.command_handlers: if self.validator(message, schema.default): valid = True @@ -313,8 +319,8 @@ async def message_processor(self, client, message): self.clients_manager.set_protocol(client, selected_protocol) else: - self.logger.debug(f"Validating client {client.snowflake}'s message using known protocol: {client.protocol.__qualname__}") - + self.logger.debug(f"Validating message from {client.snowflake} using protocol {client.protocol}") + # Validate message using known protocol selected_protocol = client.protocol @@ -334,7 +340,11 @@ async def message_processor(self, client, message): if message[selected_protocol.command_key] not in self.command_handlers[selected_protocol]: # Log invalid command - self.logger.debug(f"Invalid command \"{message[selected_protocol.command_key]}\" in protocol {selected_protocol.__qualname__} from client {client.snowflake}") + cmd = message[selected_protocol.command_key] + proto = selected_protocol.__qualname__ + self.logger.debug( + f"Invalid command \"{cmd}\" in protocol {proto} from client {client.snowflake}" + ) # Fire on_error events self.asyncio.create_task(self.execute_on_error_events(client, "Invalid command")) @@ -385,7 +395,7 @@ async def connection_handler(self, client): client.snowflake = str(next(self.gen)) client.protocol = None client.protocol_set = False - client.rooms = set + client.rooms = set() client.username_set = False client.friendly_username = str() client.linked = False @@ -496,8 +506,10 @@ async def execute_unicast(self, client, message): # Attempt to send the packet try: await client.send(message) - except: - pass + except Exception as e: + self.logger.critical( + f"Unexpected exception was raised while unicasting message to client {client.snowflake}: {e}" + ) async def execute_multicast(self, clients, message): @@ -513,11 +525,18 @@ async def execute_multicast(self, clients, message): async for client in self.async_iterable(self, clients): try: await self.execute_unicast(client, message) - except: - pass + except Exception as e: + self.logger.critical( + f"Unexpected exception was raised while multicasting message to client {client.snowflake}: {e}" + ) async def execute_close_single(self, client, code=1000, reason=""): - await client.close(code, reason) + try: + await client.close(code, reason) + except Exception as e: + self.logger.critical( + f"Unexpected exception was raised while closing connection to client {client.snowflake}: {e}" + ) async def execute_close_multi(self, clients, code=1000, reason=""): async for client in self.async_iterable(self, clients): diff --git a/cloudlink/modules/clients_manager.py b/cloudlink/modules/clients_manager.py index d8150f2..5b83153 100644 --- a/cloudlink/modules/clients_manager.py +++ b/cloudlink/modules/clients_manager.py @@ -119,7 +119,7 @@ def set_username(self, obj, username): if username not in self.usernames: self.usernames[username] = set() - # Create reference to object from it's username + # Create reference to object from its username self.usernames[username].add(obj) # Finally set attributes diff --git a/cloudlink/modules/rooms_manager.py b/cloudlink/modules/rooms_manager.py index 260d8c1..b243b46 100644 --- a/cloudlink/modules/rooms_manager.py +++ b/cloudlink/modules/rooms_manager.py @@ -20,7 +20,7 @@ class RoomAlreadyJoined(Exception): """This exception is raised when a client attempts to join a room, but it was already joined.""" pass - class RoomsNotJoined(Exception): + class RoomNotJoined(Exception): """This exception is raised when a client attempts to access a room not joined.""" pass @@ -48,29 +48,85 @@ def __init__(self, parent): self.logging = parent.logging self.logger = self.logging.getLogger(__name__) - def create(self, room_id, supported_protocols=set): - if type(room_id) not in [str, int, float, bool]: - raise TypeError("Room IDs only support strings, booleans, or numbers!") + def create(self, room_id): + # Rooms may only have string names + if type(room_id) != str: + raise TypeError("Room IDs only support strings!") + # Prevent re-declaring a room if self.exists(room_id): raise self.exceptions.RoomAlreadyExists # Create the room self.rooms[room_id] = { "clients": set(), - "supported_protocols": supported_protocols, - "global_variables": set() + "global_vars": dict(), + "private_vars": dict() } def delete(self, room_id): + # Rooms may only have string names + if type(room_id) != str: + raise TypeError("Room IDs only support strings!") + + # Check if the room exists if not self.exists(room_id): raise self.exceptions.RoomDoesNotExist + # Prevent deleting a room if it's not empty if len(self.rooms[room_id]["clients"]): raise self.exceptions.RoomNotEmpty # Delete the room self.rooms.pop(room_id) + # Delete reference to room + self.room_names.pop(room_id) + def exists(self, room_id): + # Rooms may only have string names + if type(room_id) != str: + raise TypeError("Room IDs only support strings!") + return room_id in self.rooms + + def subscribe(self, obj, room_id): + # Rooms may only have string names + if type(room_id) != str: + raise TypeError("Room IDs only support strings!") + + # Check if room exists + if not self.exists(room_id): + self.create(room_id) + + # Check if client has not subscribed to a room + if obj in self.rooms[room_id]["clients"]: + raise self.exceptions.RoomAlreadyJoined + + # Add to room + self.rooms[room_id]["clients"].add(obj) + + def unsubscribe(self, obj, room_id): + # Rooms may only have string names + if type(room_id) != str: + raise TypeError("Room IDs only support strings!") + + # Check if room exists + if not self.exists(room_id): + raise self.exceptions.RoomDoesNotExist + + # Check if a client has subscribed to a room + if obj not in self.rooms[room_id]["clients"]: + raise self.exceptions.RoomNotJoined + + # Remove from room + self.rooms[room_id]["clients"].remove(obj) + + def find_obj(self, query): + # Rooms may only have string names + if type(query) != str: + raise TypeError("Searching for room objects requires a string for the query.") + if query in (room for room in self.rooms): + return self.rooms[query] + else: + raise self.exceptions.NoResultsFound diff --git a/cloudlink/modules/schemas.py b/cloudlink/modules/schemas.py index 43903d1..e727b18 100644 --- a/cloudlink/modules/schemas.py +++ b/cloudlink/modules/schemas.py @@ -1,11 +1,13 @@ """ -schemas - A replacement for the validate() function in older versions. +schemas - A replacement for validate() function in older versions of CloudLink. """ class schemas: + # Schema for interpreting the Cloudlink protocol v4.0 (CLPv4) command set class clpv4: + # Required - Defines the keyword to use to define the command command_key = "cmd" @@ -270,6 +272,7 @@ class clpv4: # Schema for interpreting the Cloud Variables protocol used in Scratch 3.0 class scratch: + # Required - Defines the keyword to use to define the command command_key = "method" diff --git a/cloudlink/protocols/__init__.py b/cloudlink/protocols/__init__.py index ffac35d..2fb1efb 100644 --- a/cloudlink/protocols/__init__.py +++ b/cloudlink/protocols/__init__.py @@ -1,2 +1,3 @@ from .clpv4 import * from .scratch import * + diff --git a/cloudlink/protocols/clpv4.py b/cloudlink/protocols/clpv4.py index 71a62b6..75346e9 100644 --- a/cloudlink/protocols/clpv4.py +++ b/cloudlink/protocols/clpv4.py @@ -1,8 +1,18 @@ +""" +This is the default protocol used for the CloudLink server. +The CloudLink 4.1 Protocol retains full support for CLPv4. + +Each packet format is compliant with UPLv2 formatting rules. + +Documentation for the CLPv4.1 protocol can be found here: +https://hackmd.io/@MikeDEV/HJiNYwOfo +""" + class clpv4: def __init__(self, server): # protocol_schema: The default schema to identify the protocol. - self.protocol_schema = server.schemas.clpv4 + self.protocol = server.schemas.clpv4 # valid(message, schema): Used to verify messages. def valid(client, message, schema): @@ -18,7 +28,7 @@ def valid(client, message, schema): }) return False - @server.on_exception(exception_type=server.exceptions.InvalidCommand, schema=self.protocol_schema) + @server.on_exception(exception_type=server.exceptions.InvalidCommand, schema=self.protocol) async def exception_handler(client, details): server.send_packet(client, { "cmd": "statuscode", @@ -26,7 +36,7 @@ async def exception_handler(client, details): "details": details }) - @server.on_exception(exception_type=server.exceptions.JSONError, schema=self.protocol_schema) + @server.on_exception(exception_type=server.exceptions.JSONError, schema=self.protocol) async def exception_handler(client, details): server.send_packet(client, { "cmd": "statuscode", @@ -34,7 +44,7 @@ async def exception_handler(client, details): "details": details }) - @server.on_exception(exception_type=server.exceptions.EmptyMessage, schema=self.protocol_schema) + @server.on_exception(exception_type=server.exceptions.EmptyMessage, schema=self.protocol) async def exception_handler(client, details): server.send_packet(client, { "cmd": "statuscode", @@ -42,50 +52,71 @@ async def exception_handler(client, details): "details": details }) - @server.on_command(cmd="handshake", schema=self.protocol_schema) + @server.on_command(cmd="handshake", schema=self.protocol) async def on_handshake(client, message): + # Send client IP address server.send_packet(client, { "cmd": "client_ip", "val": client.remote_address[0] }) + # Send server version server.send_packet(client, { "cmd": "server_version", "val": server.version }) + # Send Message-Of-The-Day if server.enable_motd: server.send_packet(client, { "cmd": "motd", "val": server.motd_message }) + # Send client's Snowflake ID server.send_packet(client, { "cmd": "client_id", "val": client.snowflake }) - server.send_packet(client, { + # Define status message + tmp_message = { "cmd": "statuscode", "val": "ok" - }) + } - @server.on_command(cmd="ping", schema=self.protocol_schema) + # Attach listener + if "listener" in message: + tmp_message["listener"] = message["listener"] + + # Return ping + server.send_packet(client, tmp_message) + + @server.on_command(cmd="ping", schema=self.protocol) async def on_ping(client, message): - server.send_packet(client, { + + # Define message + tmp_message = { "cmd": "statuscode", "val": "ok" - }) + } + + # Attach listener + if "listener" in message: + tmp_message["listener"] = message["listener"] - @server.on_command(cmd="gmsg", schema=self.protocol_schema) + # Return ping + server.send_packet(client, tmp_message) + + @server.on_command(cmd="gmsg", schema=self.protocol) async def on_gmsg(client, message): # Validate schema - if not valid(client, message, self.protocol_schema.gmsg): + if not valid(client, message, self.protocol.gmsg): return # Copy the current set of connected client objects - clients = server.copy(server.clients_manager.protocols[self.protocol_schema]) + clients = server.copy(server.clients_manager.protocols[self.protocol]) # Attach listener (if present) and broadcast if "listener" in message: @@ -98,31 +129,36 @@ async def on_gmsg(client, message): "cmd": "gmsg", "val": message["val"] } + + # Broadcast message server.send_packet(clients, tmp_message) - # Send message to originating client with listener # Define the message to send tmp_message = { "cmd": "gmsg", "val": message["val"], "listener": message["listener"] } + + # Unicast message server.send_packet(client, tmp_message) else: - # Broadcast message to all clients + # Define the message to broadcast tmp_message = { "cmd": "gmsg", "val": message["val"] } + + # Broadcast message server.send_packet(clients, tmp_message) - @server.on_command(cmd="pmsg", schema=self.protocol_schema) + @server.on_command(cmd="pmsg", schema=self.protocol) async def on_pmsg(client, message): tmp_client = None # Validate schema - if not valid(client, message, self.protocol_schema.pmsg): + if not valid(client, message, self.protocol.pmsg): return # Find client @@ -145,11 +181,11 @@ async def on_pmsg(client, message): # Send private message server.send_packet(tmp_client, tmp_message) - @server.on_command(cmd="gvar", schema=self.protocol_schema) + @server.on_command(cmd="gvar", schema=self.protocol) async def on_gvar(client, message): # Validate schema - if not valid(client, message, self.protocol_schema.gvar): + if not valid(client, message, self.protocol.gvar): return # Define the message to send @@ -160,7 +196,7 @@ async def on_gvar(client, message): } # Copy the current set of connected client objects - clients = server.copy(server.clients_manager.protocols[self.protocol_schema]) + clients = server.copy(server.clients_manager.protocols[self.protocol]) # Attach listener (if present) and broadcast if "listener" in message: @@ -171,33 +207,33 @@ async def on_gvar(client, message): else: server.send_packet(clients, tmp_message) - @server.on_command(cmd="pvar", schema=self.protocol_schema) + @server.on_command(cmd="pvar", schema=self.protocol) async def on_pvar(client, message): print("Private variables!") - @server.on_command(cmd="setid", schema=self.protocol_schema) + @server.on_command(cmd="setid", schema=self.protocol) async def on_setid(client, message): try: server.clients_manager.set_username(client, message['val']) except server.clients_manager.exceptions.ClientUsernameAlreadySet: server.logger.error(f"Client {client.snowflake} attempted to set username again!") - @server.on_command(cmd="link", schema=self.protocol_schema) + @server.on_command(cmd="link", schema=self.protocol) async def on_link(client, message): - pass + server.rooms_manager.subscribe(client, message["rooms"]) - @server.on_command(cmd="unlink", schema=self.protocol_schema) + @server.on_command(cmd="unlink", schema=self.protocol) async def on_unlink(client, message): pass - @server.on_command(cmd="direct", schema=self.protocol_schema) + @server.on_command(cmd="direct", schema=self.protocol) async def on_direct(client, message): pass - @server.on_command(cmd="bridge", schema=self.protocol_schema) + @server.on_command(cmd="bridge", schema=self.protocol) async def on_bridge(client, message): pass - @server.on_command(cmd="echo", schema=self.protocol_schema) + @server.on_command(cmd="echo", schema=self.protocol) async def on_echo(client, message): server.send_packet(client, {"cmd": "echo", "val": message["val"]}) diff --git a/cloudlink/protocols/scratch.py b/cloudlink/protocols/scratch.py index 8269d8c..3b34b52 100644 --- a/cloudlink/protocols/scratch.py +++ b/cloudlink/protocols/scratch.py @@ -1,3 +1,9 @@ +""" +This is a FOSS reimplementation of Scratch's Cloud Variable protocol. +See https://github.com/TurboWarp/cloud-server/blob/master/doc/protocol.md for details. +""" + + class scratch: def __init__(self, server): @@ -49,6 +55,8 @@ async def handshake(client, message): @server.on_command(cmd="create", schema=self.protocol_schema) async def create_variable(client, message): + + # Validate schema if not valid(client, message, self.protocol_schema.method): return @@ -73,8 +81,17 @@ async def create_variable(client, message): "value": self.storage[message["project_id"]][variable] }) + @server.on_command(cmd="rename", schema=self.protocol_schema) + async def rename_variable(client, message): + + # Validate schema + if not valid(client, message, self.protocol_schema.method): + return + @server.on_command(cmd="delete", schema=self.protocol_schema) async def create_variable(client, message): + + # Validate schema if not valid(client, message, self.protocol_schema.method): return @@ -100,18 +117,19 @@ async def create_variable(client, message): @server.on_command(cmd="set", schema=self.protocol_schema) async def set_value(client, message): + + # Validate schema if not valid(client, message, self.protocol_schema.method): return - server.logger.debug(f"Updating global variable {message['name']} to value {message['value']}") - # Guard clause - Room must exist before adding to it if not message["project_id"] in self.storage: - server.logger.warning(f"Error: {errors}") # Abort the connection server.close_connection(client, code=4004, reason=f"Invalid room ID: {message['project_id']}") + server.logger.debug(f"Updating global variable {message['name']} to value {message['value']}") + # Update variable state self.storage[message["project_id"]][message['name']] = message['value'] @@ -122,4 +140,4 @@ async def set_value(client, message): "method": "set", "name": variable, "value": self.storage[message["project_id"]][variable] - }) \ No newline at end of file + }) From 13263fe1f391d150a97e74cb58743d0444be7290 Mon Sep 17 00:00:00 2001 From: "Mike J. Renaker / \"MikeDEV" Date: Wed, 22 Mar 2023 16:01:26 -0400 Subject: [PATCH 10/59] remove junk --- .gitignore | 3 +- .idea/cloudlink.iml | 10 --- .idea/inspectionProfiles/Project_Default.xml | 12 ---- .../inspectionProfiles/profiles_settings.xml | 6 -- .idea/misc.xml | 4 -- .idea/modules.xml | 8 --- .idea/vcs.xml | 6 -- .idea/workspace.xml | 66 ------------------- 8 files changed, 2 insertions(+), 113 deletions(-) delete mode 100644 .idea/cloudlink.iml delete mode 100644 .idea/inspectionProfiles/Project_Default.xml delete mode 100644 .idea/inspectionProfiles/profiles_settings.xml delete mode 100644 .idea/misc.xml delete mode 100644 .idea/modules.xml delete mode 100644 .idea/vcs.xml delete mode 100644 .idea/workspace.xml diff --git a/.gitignore b/.gitignore index f984034..043d5d2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ __pycache__ .vscode -*.bak \ No newline at end of file +*.bak +.idea \ No newline at end of file diff --git a/.idea/cloudlink.iml b/.idea/cloudlink.iml deleted file mode 100644 index 544fcff..0000000 --- a/.idea/cloudlink.iml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml deleted file mode 100644 index f5cd9d9..0000000 --- a/.idea/inspectionProfiles/Project_Default.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml deleted file mode 100644 index 105ce2d..0000000 --- a/.idea/inspectionProfiles/profiles_settings.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index 1fbdce6..0000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index d8fefc9..0000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 35eb1dd..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/workspace.xml b/.idea/workspace.xml deleted file mode 100644 index 09f79e4..0000000 --- a/.idea/workspace.xml +++ /dev/null @@ -1,66 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - 1679419080511 - - - - \ No newline at end of file From 2a8d2e686a1c38ea43541febf9cb42f848de740e Mon Sep 17 00:00:00 2001 From: "Mike J. Renaker / \"MikeDEV" Date: Fri, 24 Mar 2023 14:19:31 -0400 Subject: [PATCH 11/59] some work I forgot to commit --- cloudlink/cloudlink.py | 15 ++++-- cloudlink/modules/async_iterables.py | 6 +-- cloudlink/modules/rooms_manager.py | 2 +- cloudlink/modules/schemas.py | 21 ++++++++ cloudlink/protocols/clpv4.py | 78 ++++++++++++++++++++++++---- 5 files changed, 103 insertions(+), 19 deletions(-) diff --git a/cloudlink/cloudlink.py b/cloudlink/cloudlink.py index 1bba682..f5032b3 100644 --- a/cloudlink/cloudlink.py +++ b/cloudlink/cloudlink.py @@ -12,6 +12,7 @@ from cloudlink.modules.async_event_manager import async_event_manager from cloudlink.modules.async_iterables import async_iterable from cloudlink.modules.clients_manager import clients_manager +from cloudlink.modules.rooms_manager import rooms_manager # Import builtin schemas to validate the CLPv4 / Scratch Cloud Variable protocol(s) from cloudlink.modules.schemas import schemas @@ -74,6 +75,7 @@ def __init__(self): self.async_iterable = async_iterable self.copy = copy self.clients_manager = clients_manager(self) + self.rooms_manager = rooms_manager(self) self.exceptions = exceptions() # Create event managers @@ -271,6 +273,7 @@ async def message_processor(self, client, message): details=f"JSON parsing error: {error}" ) ) + else: # Close the connection self.send_packet(client, "Invalid JSON") @@ -403,6 +406,9 @@ async def connection_handler(self, client): # Add to clients manager self.clients_manager.add(client) + # Add to default room + self.rooms_manager.subscribe(client, "default") + # Fire on_connect events self.asyncio.create_task(self.execute_on_connect_events(client)) @@ -416,7 +422,10 @@ async def connection_handler(self, client): # Fire on_disconnect events self.asyncio.create_task(self.execute_on_disconnect_events(client)) - + + async for room in self.async_iterable(client.rooms): + self.rooms_manager.unsubscribe(client, room) + self.logger.debug(f"Client {client.snowflake} disconnected") # Connection loop - Redefine for use with another outside library @@ -522,7 +531,7 @@ async def execute_multicast(self, clients, message): message = self.ujson.dumps(message) # Attempt to broadcast the packet - async for client in self.async_iterable(self, clients): + async for client in self.async_iterable(clients): try: await self.execute_unicast(client, message) except Exception as e: @@ -539,5 +548,5 @@ async def execute_close_single(self, client, code=1000, reason=""): ) async def execute_close_multi(self, clients, code=1000, reason=""): - async for client in self.async_iterable(self, clients): + async for client in self.async_iterable(clients): await self.execute_close_single(client, code, reason) diff --git a/cloudlink/modules/async_iterables.py b/cloudlink/modules/async_iterables.py index af25068..a882a59 100644 --- a/cloudlink/modules/async_iterables.py +++ b/cloudlink/modules/async_iterables.py @@ -12,14 +12,10 @@ class async_iterable: - def __init__(self, parent, iterables): + def __init__(self, iterables): self.iterator = 0 self.iterable = list(iterables) - # Init logger - self.logging = parent.logging - self.logger = self.logging.getLogger(__name__) - def __aiter__(self): return self diff --git a/cloudlink/modules/rooms_manager.py b/cloudlink/modules/rooms_manager.py index b243b46..41a983d 100644 --- a/cloudlink/modules/rooms_manager.py +++ b/cloudlink/modules/rooms_manager.py @@ -59,7 +59,7 @@ def create(self, room_id): # Create the room self.rooms[room_id] = { - "clients": set(), + "clients": dict(), "global_vars": dict(), "private_vars": dict() } diff --git a/cloudlink/modules/schemas.py b/cloudlink/modules/schemas.py index e727b18..f8a2c41 100644 --- a/cloudlink/modules/schemas.py +++ b/cloudlink/modules/schemas.py @@ -68,6 +68,27 @@ class clpv4: } } + setid = { + "cmd": { + "type": "string", + "required": True + }, + "val": { + "type": "string", + "required": True + }, + "listener": { + "type": [ + "string", + "integer", + "float", + "boolean", + "number" + ], + "required": False + } + } + gmsg = { "cmd": { "type": "string", diff --git a/cloudlink/protocols/clpv4.py b/cloudlink/protocols/clpv4.py index 75346e9..ffd3e7b 100644 --- a/cloudlink/protocols/clpv4.py +++ b/cloudlink/protocols/clpv4.py @@ -23,32 +23,36 @@ def valid(client, message, schema): server.logger.debug(f"Error: {errors}") server.send_packet(client, { "cmd": "statuscode", - "val": "error", + "code": "error", + "code_id": 1234, "details": dict(errors) }) return False @server.on_exception(exception_type=server.exceptions.InvalidCommand, schema=self.protocol) - async def exception_handler(client, details): + async def invalid_command(client, details): server.send_packet(client, { "cmd": "statuscode", - "val": "error", + "code": "error", + "code_id": 1234, "details": details }) @server.on_exception(exception_type=server.exceptions.JSONError, schema=self.protocol) - async def exception_handler(client, details): + async def json_exception(client, details): server.send_packet(client, { "cmd": "statuscode", - "val": "error", + "code": "error", + "code_id": 1234, "details": details }) @server.on_exception(exception_type=server.exceptions.EmptyMessage, schema=self.protocol) - async def exception_handler(client, details): + async def empty_message(client, details): server.send_packet(client, { "cmd": "statuscode", - "val": "error", + "code": "error", + "code_id": 1234, "details": details }) @@ -167,7 +171,8 @@ async def on_pmsg(client, message): except server.clients_manager.exceptions.NoResultsFound: server.send_packet(client, { "cmd": "statuscode", - "val": "notfound", + "code": "error", + "code_id": 1234, "details": f'No matches found: {message["id"]}' }) @@ -175,7 +180,10 @@ async def on_pmsg(client, message): tmp_message = { "cmd": "pmsg", "val": message["val"], - "origin": client.snowflake + "origin": { + "id": client.snowflake, + "username": client.friendy_username + } } # Send private message @@ -209,14 +217,64 @@ async def on_gvar(client, message): @server.on_command(cmd="pvar", schema=self.protocol) async def on_pvar(client, message): - print("Private variables!") + tmp_client = None + + # Validate schema + if not valid(client, message, self.protocol.pvar): + return + + # Find client + try: + tmp_client = server.clients_manager.find_obj(message['id']) + except server.clients_manager.exceptions.NoResultsFound: + server.send_packet(client, { + "cmd": "statuscode", + "code": "error", + "code_id": 1234, + "details": f'No matches found: {message["id"]}' + }) + + # Broadcast message to client + tmp_message = { + "cmd": "pvar", + "name": message["name"], + "val": message["val"], + "origin": { + "id": client.snowflake, + "username": client.friendy_username + } + } + + server.send_packet(client, tmp_message) @server.on_command(cmd="setid", schema=self.protocol) async def on_setid(client, message): + # Validate schema + if not valid(client, message, self.protocol.setid): + return + try: server.clients_manager.set_username(client, message['val']) except server.clients_manager.exceptions.ClientUsernameAlreadySet: server.logger.error(f"Client {client.snowflake} attempted to set username again!") + return + + tmp_message = { + "cmd": "statuscode", + "val": { + "id": client.snowflake, + "username": client.friendy_username + }, + "code": "error", + "code_id": 1234 + } + + # Attach listener (if present) and broadcast + if "listener" in message: + tmp_message["listener"] = message["listener"] + server.send_packet(client, tmp_message) + else: + server.send_packet(client, tmp_message) @server.on_command(cmd="link", schema=self.protocol) async def on_link(client, message): From 67b0c1083e83dfef4cfcddc51d1a8734b320a760 Mon Sep 17 00:00:00 2001 From: "Mike J. Renaker / \"MikeDEV" Date: Fri, 24 Mar 2023 17:05:43 -0400 Subject: [PATCH 12/59] Almost there --- cloudlink/cloudlink.py | 157 ++++++++--- cloudlink/modules/clients_manager.py | 9 +- cloudlink/modules/rooms_manager.py | 4 +- cloudlink/modules/schemas.py | 21 +- cloudlink/protocols/clpv4.py | 391 +++++++++++++++++++++------ cloudlink/protocols/scratch.py | 20 +- main.py | 33 ++- 7 files changed, 471 insertions(+), 164 deletions(-) diff --git a/cloudlink/cloudlink.py b/cloudlink/cloudlink.py index f5032b3..9895bb9 100644 --- a/cloudlink/cloudlink.py +++ b/cloudlink/cloudlink.py @@ -3,6 +3,7 @@ import cerberus import logging from copy import copy +import time from snowflake import SnowflakeGenerator # Import websocket engine @@ -17,10 +18,6 @@ # Import builtin schemas to validate the CLPv4 / Scratch Cloud Variable protocol(s) from cloudlink.modules.schemas import schemas -# Import protocols -from cloudlink.protocols import clpv4 -from cloudlink.protocols import scratch - # Import JSON library - Prefer UltraJSON but use native JSON if failed try: import ujson @@ -43,6 +40,10 @@ class JSONError(Exception): """This exception is raised when the server fails to parse a message's JSON.""" pass + class ValidationError(Exception): + """This exception is raised when a client with a known protocol sends a message that fails validation before commands can execute.""" + pass + class InternalError(Exception): """This exception is raised when an unexpected and/or unhandled exception is raised.""" pass @@ -78,23 +79,20 @@ def __init__(self): self.rooms_manager = rooms_manager(self) self.exceptions = exceptions() + # Dictionary containing protocols as keys and sets of commands as values + self.disabled_commands = dict() + # Create event managers self.on_connect_events = async_event_manager(self) self.on_message_events = async_event_manager(self) self.on_disconnect_events = async_event_manager(self) self.on_error_events = async_event_manager(self) self.exception_handlers = dict() + self.disabled_commands_handlers = dict() # Create method handlers self.command_handlers = dict() - # Enable scratch protocol support on startup - self.enable_scratch_support = True - - # Message of the day - self.enable_motd = False - self.motd_message = str() - # Configure framework logging self.supress_websocket_logs = True @@ -114,22 +112,6 @@ def run(self, ip="127.0.0.1", port=3000): raise ValueError( "The max_clients value must be a integer value set to -1 (unlimited clients) or greater than zero!" ) - - if type(self.enable_scratch_support) != bool: - raise TypeError("The enable_scratch_support value must be a boolean!") - - if type(self.enable_motd) != bool: - raise TypeError("The enable_motd value must be a boolean!") - - if type(self.motd_message) != str: - raise TypeError("The motd_message value must be a string!") - - # Load CLPv4 protocol - clpv4(self) - - # Load Scratch protocol - if self.enable_scratch_support: - scratch(self) # Startup message self.logger.info(f"CloudLink {self.version} - Now listening to {ip}:{port}") @@ -153,7 +135,7 @@ def bind_event(func): # Create schema category for command event manager if schema not in self.command_handlers: - self.logger.info(f"Creating command event manager {schema.__qualname__}") + self.logger.info(f"Creating protocol {schema.__qualname__} command event manager") self.command_handlers[schema] = dict() # Create command event handler @@ -172,7 +154,7 @@ def bind_event(func): # Create schema category for error event manager if schema not in self.exception_handlers: - self.logger.info(f"Creating exception event manager {schema.__qualname__}") + self.logger.info(f"Creating protocol {schema.__qualname__} exception event manager") self.exception_handlers[schema] = dict() # Create error event handler @@ -185,6 +167,21 @@ def bind_event(func): # End on_error_specific binder return bind_event + # Event binder for invalid command events with specific shemas/exception types + def on_disabled_command(self, schema): + def bind_event(func): + + # Create disabled command event manager + if schema not in self.disabled_commands_handlers: + self.logger.info(f"Creating disabled command event manager {schema.__qualname__}") + self.disabled_commands_handlers[schema] = async_event_manager(self) + + # Add function to the error command handler + self.disabled_commands_handlers[schema].bind(func) + + # End on_error_specific binder + return bind_event + # Event binder for on_message events def on_message(self, func): self.on_message_events.bind(func) @@ -225,6 +222,38 @@ def close_connection(self, obj, code=1000, reason=""): else: self.asyncio.create_task(self.execute_close_single(obj, code, reason)) + # Command disabler + def disable_command(self, cmd, schema): + # Check if the schema has no disabled commands + if schema not in self.disabled_commands: + self.disabled_commands[schema] = set() + + # Check if the command isn't already disabled + if cmd in self.disabled_commands[schema]: + raise ValueError(f"The command {cmd} is already disabled in protocol {schema.__qualname__}, or was enabled beforehand.") + + # Disable the command + self.disabled_commands[schema].add(cmd) + self.logger.debug(f"Disabled command {cmd} in protocol {schema.__qualname__}") + + # Command enabler + def enable_command(self, cmd, schema): + # Check if the schema has disabled commands + if schema not in self.disabled_commands: + raise ValueError(f"There are no commands to disable in protocol {schema.__qualname__}.") + + # Check if the command is disabled + if cmd not in self.disabled_commands[schema]: + raise ValueError(f"The command {cmd} is already enabled in protocol {schema.__qualname__}, or wasn't disabled beforehand.") + + # Enable the command + self.disabled_commands[schema].remove(cmd) + self.logger.debug(f"Enabled command {cmd} in protocol {schema.__qualname__}") + + # Free up unused disablers + if not len(self.disabled_commands[schema]): + self.disabled_commands.pop(schema) + # Message processor async def message_processor(self, client, message): @@ -270,7 +299,7 @@ async def message_processor(self, client, message): client=client, exception_type=self.exceptions.JSONError, schema=client.protocol, - details=f"JSON parsing error: {error}" + details=error ) ) @@ -322,7 +351,7 @@ async def message_processor(self, client, message): self.clients_manager.set_protocol(client, selected_protocol) else: - self.logger.debug(f"Validating message from {client.snowflake} using protocol {client.protocol}") + self.logger.debug(f"Validating message from {client.snowflake} using protocol {client.protocol.__qualname__}") # Validate message using known protocol selected_protocol = client.protocol @@ -336,6 +365,17 @@ async def message_processor(self, client, message): # Fire on_error events self.asyncio.create_task(self.execute_on_error_events(client, errors)) + # Fire exception handling events + if client.protocol_set: + self.asyncio.create_task( + self.execute_exception_handlers( + client=client, + exception_type=self.exceptions.ValidationError, + schema=client.protocol, + details=errors + ) + ) + # End message_processor coroutine return @@ -343,11 +383,7 @@ async def message_processor(self, client, message): if message[selected_protocol.command_key] not in self.command_handlers[selected_protocol]: # Log invalid command - cmd = message[selected_protocol.command_key] - proto = selected_protocol.__qualname__ - self.logger.debug( - f"Invalid command \"{cmd}\" in protocol {proto} from client {client.snowflake}" - ) + self.logger.debug(f"Client {client.snowflake} sent an invalid command \"{message[selected_protocol.command_key]}\" in protocol {selected_protocol.__qualname__}") # Fire on_error events self.asyncio.create_task(self.execute_on_error_events(client, "Invalid command")) @@ -359,13 +395,30 @@ async def message_processor(self, client, message): client=client, exception_type=self.exceptions.InvalidCommand, schema=client.protocol, - details=f"Invalid command: {message[selected_protocol.command_key]}" + details=message[selected_protocol.command_key] ) ) # End message_processor coroutine return + # Check if the command is disabled + if selected_protocol in self.disabled_commands: + if message[selected_protocol.command_key] in self.disabled_commands[selected_protocol]: + self.logger.debug(f"Client {client.snowflake} sent a disabled command \"{message[selected_protocol.command_key]}\" in protocol {selected_protocol.__qualname__}") + + # Fire disabled command event + self.asyncio.create_task( + self.execute_disabled_command_events( + client, + selected_protocol, + message[selected_protocol.command_key] + ) + ) + + # End message_processor coroutine + return + # Fire on_command events self.asyncio.create_task( self.execute_on_command_events( @@ -400,9 +453,12 @@ async def connection_handler(self, client): client.protocol_set = False client.rooms = set() client.username_set = False - client.friendly_username = str() + client.username = str() client.linked = False - + + # Begin tracking the lifetime of the client + client.birth_time = time.monotonic() + # Add to clients manager self.clients_manager.add(client) @@ -426,15 +482,23 @@ async def connection_handler(self, client): async for room in self.async_iterable(client.rooms): self.rooms_manager.unsubscribe(client, room) - self.logger.debug(f"Client {client.snowflake} disconnected") + self.logger.debug(f"Client {client.snowflake} disconnected: Total lifespan of {time.monotonic() - client.birth_time} seconds.") # Connection loop - Redefine for use with another outside library async def connection_loop(self, client): # Primary asyncio loop for the lifespan of the websocket connection try: async for message in client: + # Start keeping track of processing time + start = time.perf_counter() + self.logger.debug(f"Now processing message from client {client.snowflake}...") + + # Process the message await self.message_processor(client, message) + # Log processing time + self.logger.debug(f"Done processing message from client {client.snowflake}. Processing took {time.perf_counter() - start} seconds.") + # Handle unexpected disconnects except self.ws.exceptions.ConnectionClosedError: pass @@ -461,7 +525,7 @@ async def connection_loop(self, client): ) ) - # Server - You can modify this code to use a different websocket engine. + # WebSocket-specific server loop async def __run__(self, ip, port): # Main event loop async with self.ws.serve(self.connection_handler, ip, port): @@ -499,8 +563,17 @@ async def execute_exception_handlers(self, client, exception_type, schema, detai # Fire events async for event in self.exception_handlers[schema][exception_type]: await event(client, details) + + async def execute_disabled_command_events(self, client, schema, cmd): + # Guard clauses + if schema not in self.disabled_commands_handlers: + return + + # Fire events + async for event in self.disabled_commands_handlers[schema]: + await event(client, cmd) - # You can modify the code below to use different websocket engines. + # WebSocket-specific coroutines async def execute_unicast(self, client, message): diff --git a/cloudlink/modules/clients_manager.py b/cloudlink/modules/clients_manager.py index 5b83153..034e9c7 100644 --- a/cloudlink/modules/clients_manager.py +++ b/cloudlink/modules/clients_manager.py @@ -101,8 +101,8 @@ def remove(self, obj): if obj.username_set: # Remove reference to username object - if obj.friendly_username in self.usernames: - self.usernames[obj.friendly_username].remove(obj) + if obj.username in self.usernames: + self.usernames[obj.username].remove(obj) # Clean up unused usernames if not len(self.usernames[obj.friendly_username]): @@ -124,7 +124,7 @@ def set_username(self, obj, username): # Finally set attributes obj.username_set = True - obj.friendly_username = username + obj.username = username def set_protocol(self, obj, schema): if not self.exists(obj): @@ -145,6 +145,9 @@ def set_protocol(self, obj, schema): obj.protocol_set = True def find_obj(self, query): + if type(query) not in [str, dict]: + raise TypeError("Clients can only be usernames (str), snowflakes (str), UUIDs (str), or Objects (dict).") + if query in self.usernames: return self.usernames[query] elif query in self.get_uuids(): diff --git a/cloudlink/modules/rooms_manager.py b/cloudlink/modules/rooms_manager.py index 41a983d..b5c0ba5 100644 --- a/cloudlink/modules/rooms_manager.py +++ b/cloudlink/modules/rooms_manager.py @@ -104,7 +104,7 @@ def subscribe(self, obj, room_id): raise self.exceptions.RoomAlreadyJoined # Add to room - self.rooms[room_id]["clients"].add(obj) + self.rooms[room_id]["clients"][obj.snowflake] = obj def unsubscribe(self, obj, room_id): # Rooms may only have string names @@ -120,7 +120,7 @@ def unsubscribe(self, obj, room_id): raise self.exceptions.RoomNotJoined # Remove from room - self.rooms[room_id]["clients"].remove(obj) + del self.rooms[room_id]["clients"][obj.snowflake] def find_obj(self, query): # Rooms may only have string names diff --git a/cloudlink/modules/schemas.py b/cloudlink/modules/schemas.py index f8a2c41..456c77a 100644 --- a/cloudlink/modules/schemas.py +++ b/cloudlink/modules/schemas.py @@ -37,10 +37,7 @@ class clpv4: "id": { "type": [ "string", - "integer", - "float", - "boolean", - "number" + "dict" ], "required": False }, @@ -185,13 +182,7 @@ class clpv4: "id": { "type": [ "string", - "integer", - "float", - "number", - "boolean", - "dict", - "list", - "set", + "dict" ], "required": True }, @@ -244,13 +235,7 @@ class clpv4: "id": { "type": [ "string", - "integer", - "float", - "number", - "boolean", - "dict", - "list", - "set", + "dict" ], "required": True }, diff --git a/cloudlink/protocols/clpv4.py b/cloudlink/protocols/clpv4.py index ffd3e7b..252eb9d 100644 --- a/cloudlink/protocols/clpv4.py +++ b/cloudlink/protocols/clpv4.py @@ -8,60 +8,146 @@ https://hackmd.io/@MikeDEV/HJiNYwOfo """ + class clpv4: def __init__(self, server): + # Configuration settings + + # Warn origin client if it's attempting to send messages using a username that resolves more than one client. + self.warn_if_multiple_username_matches = True + + # Message of the day + self.enable_motd = False + self.motd_message = str() # protocol_schema: The default schema to identify the protocol. self.protocol = server.schemas.clpv4 + # Define various status codes for the protocol. + class statuscodes: + # Code type character + info = "I" + error = "E" + + # Error / info codes as tuples + test = (info, 0, "Test") + echo = (info, 1, "Echo") + ok = (info, 100, "OK") + syntax = (error, 101, "Syntax") + datatype = (error, 102, "Datatype") + id_not_found = (error, 103, "ID not found") + id_not_specific = (error, 104, "ID not specific enough") + internal_error = (error, 105, "Internal server error") + empty_packet = (error, 106, "Empty packet") + id_already_set = (error, 107, "ID already set") + refused = (error, 108, "Refused") + invalid_command = (error, 109, "Invalid command") + disabled_command = (error, 110, "Command disabled") + id_required = (error, 111, "ID required") + id_conflict = (error, 112, "ID conflict") + too_large = (error, 113, "Too large") + json_error = (error, 114, "JSON error") + + @staticmethod + def generate(code: tuple): + return f"{code[0]}:{code[1]} | {code[2]}", code[1] + + # Identification of a client's IP address + def get_client_ip(client): + # Grab forwarded IP address + if "x-forwarded-for" in client.request_headers: + return client.request_headers.get("x-forwarded-for") + + # Grab Cloudflare IP address + if "cf-connecting-ip" in client.request_headers: + return client.request_headers.get("cf-connecting-ip") + + # Grab host address, ignoring port info + if type(client.remote_address) == tuple: + return str(client.remote_address[0]) + + # Default if none of the above work + return client.remote_address + # valid(message, schema): Used to verify messages. def valid(client, message, schema): if server.validator(message, schema): return True - else: - errors = server.validator.errors - server.logger.debug(f"Error: {errors}") - server.send_packet(client, { - "cmd": "statuscode", - "code": "error", - "code_id": 1234, - "details": dict(errors) - }) - return False + + # Alert the client that the schema was invalid + send_statuscode(client, statuscodes.syntax, details=dict(server.validator.errors)) + return False + + # Simplify sending error/info messages + def send_statuscode(client, code, details=None, listener=None, val=None): + # Generate a statuscode + code_human, code_id = statuscodes.generate(code) + + # Template the message + tmp_message = { + "cmd": "statuscode", + "code": code_human, + "code_id": code_id + } + + if details: + tmp_message["details"] = details + + if listener: + tmp_message["listener"] = listener + + if val: + tmp_message["val"] = val + + # Send the code + server.send_packet(client, tmp_message) + + # Exception handlers + + @server.on_exception(exception_type=server.exceptions.ValidationError, schema=self.protocol) + async def validation_failure(client, details): + send_statuscode(client, statuscodes.syntax, details=dict(details)) @server.on_exception(exception_type=server.exceptions.InvalidCommand, schema=self.protocol) async def invalid_command(client, details): - server.send_packet(client, { - "cmd": "statuscode", - "code": "error", - "code_id": 1234, - "details": details - }) + send_statuscode( + client, + statuscodes.invalid_command, + details=f"{details} is an invalid command." + ) + + @server.on_disabled_command(schema=self.protocol) + async def disabled_command(client, details): + send_statuscode( + client, + statuscodes.disabled_command, + details=f"{details} is a disabled command." + ) @server.on_exception(exception_type=server.exceptions.JSONError, schema=self.protocol) async def json_exception(client, details): - server.send_packet(client, { - "cmd": "statuscode", - "code": "error", - "code_id": 1234, - "details": details - }) + send_statuscode( + client, + statuscodes.json_error, + details=f"A JSON error was raised: {details}" + ) @server.on_exception(exception_type=server.exceptions.EmptyMessage, schema=self.protocol) async def empty_message(client, details): - server.send_packet(client, { - "cmd": "statuscode", - "code": "error", - "code_id": 1234, - "details": details - }) + send_statuscode( + client, + statuscodes.empty_packet, + details="Your client has sent an empty message." + ) + + # The CLPv4 command set @server.on_command(cmd="handshake", schema=self.protocol) async def on_handshake(client, message): # Send client IP address server.send_packet(client, { "cmd": "client_ip", - "val": client.remote_address[0] + "val": get_client_ip(client) }) # Send server version @@ -71,10 +157,10 @@ async def on_handshake(client, message): }) # Send Message-Of-The-Day - if server.enable_motd: + if self.enable_motd: server.send_packet(client, { "cmd": "motd", - "val": server.motd_message + "val": self.motd_message }) # Send client's Snowflake ID @@ -83,38 +169,23 @@ async def on_handshake(client, message): "val": client.snowflake }) - # Define status message - tmp_message = { - "cmd": "statuscode", - "val": "ok" - } - # Attach listener if "listener" in message: - tmp_message["listener"] = message["listener"] - - # Return ping - server.send_packet(client, tmp_message) + send_statuscode(client, statuscodes.ok, listener=message["listener"]) + else: + send_statuscode(client, statuscodes.ok) @server.on_command(cmd="ping", schema=self.protocol) async def on_ping(client, message): + listener = None - # Define message - tmp_message = { - "cmd": "statuscode", - "val": "ok" - } - - # Attach listener if "listener" in message: - tmp_message["listener"] = message["listener"] + listener = message["listener"] - # Return ping - server.send_packet(client, tmp_message) + send_statuscode(client, statuscodes.ok, listener=listener) @server.on_command(cmd="gmsg", schema=self.protocol) async def on_gmsg(client, message): - # Validate schema if not valid(client, message, self.protocol.gmsg): return @@ -158,6 +229,25 @@ async def on_gmsg(client, message): @server.on_command(cmd="pmsg", schema=self.protocol) async def on_pmsg(client, message): + # Require sending client to have set their username + if not client.username_set: + # Attach listener + if "listener" in message: + send_statuscode( + client, + statuscodes.id_required, + details="This command requires setting a username.", + listener=message["listener"] + ) + else: + send_statuscode( + client, + statuscodes.id_required, + details="This command requires setting a username." + ) + + # End pmsg command handler + return tmp_client = None @@ -168,13 +258,46 @@ async def on_pmsg(client, message): # Find client try: tmp_client = server.clients_manager.find_obj(message['id']) + + # No objects found except server.clients_manager.exceptions.NoResultsFound: - server.send_packet(client, { - "cmd": "statuscode", - "code": "error", - "code_id": 1234, - "details": f'No matches found: {message["id"]}' - }) + + # Attach listener + if "listener" in message: + send_statuscode( + client, + statuscodes.id_not_found, + details=f'No matches found: {message["id"]}', + listener=message["listener"] + ) + else: + send_statuscode( + client, + statuscodes.id_not_found, + details=f'No matches found: {message["id"]}' + ) + + # End pmsg command handler + return + + # Warn client if they are attempting to send to a username with multiple matches + if self.warn_if_multiple_username_matches and len(tmp_client) >> 1: + # Attach listener + if "listener" in message: + send_statuscode( + client, + statuscodes.id_not_specific, + details=f'Multiple matches found for {message["id"]}, found {len(tmp_client)} matches. Please use Snowflakes or dict objects instead.', + listener=message["listener"] + ) + else: + send_statuscode( + client, + statuscodes.id_not_specific, + details=f'Multiple matches found for {message["id"]}, found {len(tmp_client)} matches. Please use Snowflakes or dict objects instead.' + ) + # End pmsg command handler + return # Broadcast message to client tmp_message = { @@ -182,13 +305,25 @@ async def on_pmsg(client, message): "val": message["val"], "origin": { "id": client.snowflake, - "username": client.friendy_username + "username": client.username, + "uuid": str(client.id) } } - - # Send private message server.send_packet(tmp_client, tmp_message) + # Tell the origin client that the message sent successfully + if "listener" in message: + send_statuscode( + client, + statuscodes.ok, + listener=message["listener"] + ) + else: + send_statuscode( + client, + statuscodes.ok + ) + @server.on_command(cmd="gvar", schema=self.protocol) async def on_gvar(client, message): @@ -217,6 +352,26 @@ async def on_gvar(client, message): @server.on_command(cmd="pvar", schema=self.protocol) async def on_pvar(client, message): + # Require sending client to have set their username + if not client.username_set: + # Attach listener + if "listener" in message: + send_statuscode( + client, + statuscodes.id_required, + details="This command requires setting a username.", + listener=message["listener"] + ) + else: + send_statuscode( + client, + statuscodes.id_required, + details="This command requires setting a username." + ) + + # End pmsg command handler + return + tmp_client = None # Validate schema @@ -226,13 +381,27 @@ async def on_pvar(client, message): # Find client try: tmp_client = server.clients_manager.find_obj(message['id']) + + # No objects found except server.clients_manager.exceptions.NoResultsFound: - server.send_packet(client, { - "cmd": "statuscode", - "code": "error", - "code_id": 1234, - "details": f'No matches found: {message["id"]}' - }) + + # Attach listener + if "listener" in message: + send_statuscode( + client, + statuscodes.id_not_found, + details=f'No matches found: {message["id"]}', + listener=message["listener"] + ) + else: + send_statuscode( + client, + statuscodes.id_not_found, + details=f'No matches found: {message["id"]}' + ) + + # End pvar command handler + return # Broadcast message to client tmp_message = { @@ -241,40 +410,81 @@ async def on_pvar(client, message): "val": message["val"], "origin": { "id": client.snowflake, - "username": client.friendy_username + "username": client.username, + "uuid": str(client.id) } } - server.send_packet(client, tmp_message) + # Tell the origin client that the message sent successfully + if "listener" in message: + send_statuscode( + client, + statuscodes.ok, + listener=message["listener"] + ) + else: + send_statuscode( + client, + statuscodes.ok + ) + @server.on_command(cmd="setid", schema=self.protocol) async def on_setid(client, message): # Validate schema if not valid(client, message, self.protocol.setid): return - try: - server.clients_manager.set_username(client, message['val']) - except server.clients_manager.exceptions.ClientUsernameAlreadySet: + # Prevent setting the username more than once + if client.username_set: server.logger.error(f"Client {client.snowflake} attempted to set username again!") + if "listener" in message: + send_statuscode( + client, + statuscodes.id_already_set, + val={ + "id": client.snowflake, + "username": client.username + }, + listener=message["listener"] + ) + else: + send_statuscode( + client, + statuscodes.id_already_set, + val={ + "id": client.snowflake, + "username": client.username + } + ) + + # Exit setid command return - tmp_message = { - "cmd": "statuscode", - "val": { - "id": client.snowflake, - "username": client.friendy_username - }, - "code": "error", - "code_id": 1234 - } + # Set the username + server.clients_manager.set_username(client, message['val']) # Attach listener (if present) and broadcast if "listener" in message: - tmp_message["listener"] = message["listener"] - server.send_packet(client, tmp_message) + send_statuscode( + client, + statuscodes.ok, + val={ + "id": client.snowflake, + "username": client.username, + "uuid": str(client.id) + }, + listener=message["listener"]) else: - server.send_packet(client, tmp_message) + send_statuscode( + client, + statuscodes.ok, + val={ + "id": client.snowflake, + "username": client.username, + "uuid": str(client.id) + }, + ) @server.on_command(cmd="link", schema=self.protocol) async def on_link(client, message): @@ -291,7 +501,16 @@ async def on_direct(client, message): @server.on_command(cmd="bridge", schema=self.protocol) async def on_bridge(client, message): pass - + @server.on_command(cmd="echo", schema=self.protocol) async def on_echo(client, message): - server.send_packet(client, {"cmd": "echo", "val": message["val"]}) + val = None + listener = None + + if "val" in message: + val = message["val"] + + if "listener" in message: + listener = message["listener"] + + send_statuscode(client, statuscodes.echo, val=val, listener=listener) diff --git a/cloudlink/protocols/scratch.py b/cloudlink/protocols/scratch.py index 3b34b52..de5af8d 100644 --- a/cloudlink/protocols/scratch.py +++ b/cloudlink/protocols/scratch.py @@ -7,11 +7,17 @@ class scratch: def __init__(self, server): + # Define various status codes for the protocol. + class statuscodes: + connection_error = 4000 + username_error = 4002 + overloaded = 4003 + unavailable = 4004 + refused_security = 4005 + # protocol_schema: The default schema to identify the protocol. self.protocol_schema = server.schemas.scratch - self.storage = dict() - # valid(message, schema): Used to verify messages. def valid(client, message, schema): if server.validator(message, schema): @@ -19,7 +25,7 @@ def valid(client, message, schema): else: errors = server.validator.errors server.logger.warning(f"Error: {errors}") - server.close_connection(client, reason=f"Message schema validation failed") + server.close_connection(client, code=statuscodes.connection_error, reason=f"Message schema validation failed") return False @server.on_command(cmd="handshake", schema=self.protocol_schema) @@ -35,7 +41,7 @@ async def handshake(client, message): server.send_packet_unicast(client, "The cloud data library you are using is putting your Scratch account at risk by sending your login token for no reason. Change your Scratch password immediately, then contact the maintainers of that library for further information. This connection is being closed to protect your security.") # Abort the connection - server.close_connection(client, code=4005, reason=f"Connection closed for security reasons") + server.close_connection(client, code=statuscodes.refused_security, reason=f"Connection closed for security reasons") # End this guard clause return @@ -65,7 +71,7 @@ async def create_variable(client, message): server.logger.warning(f"Error: room {message['project_id']} does not exist yet") # Abort the connection - server.close_connection(client, code=4004, reason=f"Invalid room ID: {message['project_id']}") + server.close_connection(client, code=statuscodes.unavailable, reason=f"Invalid room ID: {message['project_id']}") server.logger.debug(f"Creating global variable {message['name']} in {message['project_id']}") @@ -100,7 +106,7 @@ async def create_variable(client, message): server.logger.warning(f"Error: room {message['project_id']} does not exist yet") # Abort the connection - server.close_connection(client, code=4004, reason=f"Invalid room ID: {message['project_id']}") + server.close_connection(client, code=statuscodes.unavailable, reason=f"Invalid room ID: {message['project_id']}") server.logger.debug(f"Deleting global variable {message['name']} in {message['project_id']}") @@ -126,7 +132,7 @@ async def set_value(client, message): if not message["project_id"] in self.storage: # Abort the connection - server.close_connection(client, code=4004, reason=f"Invalid room ID: {message['project_id']}") + server.close_connection(client, code=statuscodes.unavailable, reason=f"Invalid room ID: {message['project_id']}") server.logger.debug(f"Updating global variable {message['name']} to value {message['value']}") diff --git a/main.py b/main.py index 206211b..ffabb57 100644 --- a/main.py +++ b/main.py @@ -1,13 +1,34 @@ from cloudlink import cloudlink - +from cloudlink.protocols import clpv4, scratch if __name__ == "__main__": - cl = cloudlink.server() + # Initialize the server + server = cloudlink.server() - cl.logging.basicConfig( + # Configure logging settings + server.logging.basicConfig( format="[%(asctime)s] %(levelname)s: %(message)s", - level=cl.logging.INFO + level=server.logging.DEBUG ) - cl.run(port=3000) - \ No newline at end of file + # Load protocols + cl_protocol = clpv4(server) + scratch_protocol = scratch(server) + + # Disable CL commands + for command in ["gmsg", "gvar"]: + server.disable_command(command, cl_protocol.protocol) + + # Create a demo command + @server.on_command(cmd="test", schema=cl_protocol.protocol) + async def on_test(client, message): + print(message) + + # Configure protocol settings + # cl_protocol.warn_if_multiple_username_matches = False + + # Configure max clients + server.max_clients = 2 + + # Start the server + server.run(port=3000) From ca18d98abefa35add5e9380147c4e8deee174ca3 Mon Sep 17 00:00:00 2001 From: "Mike J. Renaker / \"MikeDEV" Date: Fri, 24 Mar 2023 17:52:43 -0400 Subject: [PATCH 13/59] Various enhancements * Websocket engine is now independent of protocols * Separated schemas used in schemas.py and clpv4.py / scratch.py are now restructured * Miscellaneous comment updates --- cloudlink/cloudlink.py | 8 +- cloudlink/modules/__init__.py | 1 - cloudlink/modules/schemas.py | 380 ------------------- cloudlink/protocols/__init__.py | 5 +- cloudlink/protocols/clpv4/__init__.py | 1 + cloudlink/protocols/{ => clpv4}/clpv4.py | 54 +-- cloudlink/protocols/clpv4/schema.py | 270 +++++++++++++ cloudlink/protocols/scratch/__init__.py | 1 + cloudlink/protocols/scratch/schema.py | 102 +++++ cloudlink/protocols/{ => scratch}/scratch.py | 24 +- main.py | 14 +- 11 files changed, 423 insertions(+), 437 deletions(-) delete mode 100644 cloudlink/modules/schemas.py create mode 100644 cloudlink/protocols/clpv4/__init__.py rename cloudlink/protocols/{ => clpv4}/clpv4.py (92%) create mode 100644 cloudlink/protocols/clpv4/schema.py create mode 100644 cloudlink/protocols/scratch/__init__.py create mode 100644 cloudlink/protocols/scratch/schema.py rename cloudlink/protocols/{ => scratch}/scratch.py (88%) diff --git a/cloudlink/cloudlink.py b/cloudlink/cloudlink.py index 9895bb9..60ff082 100644 --- a/cloudlink/cloudlink.py +++ b/cloudlink/cloudlink.py @@ -2,8 +2,8 @@ import asyncio import cerberus import logging -from copy import copy import time +from copy import copy from snowflake import SnowflakeGenerator # Import websocket engine @@ -15,9 +15,6 @@ from cloudlink.modules.clients_manager import clients_manager from cloudlink.modules.rooms_manager import rooms_manager -# Import builtin schemas to validate the CLPv4 / Scratch Cloud Variable protocol(s) -from cloudlink.modules.schemas import schemas - # Import JSON library - Prefer UltraJSON but use native JSON if failed try: import ujson @@ -72,7 +69,6 @@ def __init__(self): self.ujson = ujson self.gen = SnowflakeGenerator(42) self.validator = cerberus.Validator() - self.schemas = schemas self.async_iterable = async_iterable self.copy = copy self.clients_manager = clients_manager(self) @@ -597,7 +593,7 @@ async def execute_multicast(self, clients, message): # Guard clause if type(message) not in [dict, str]: - raise TypeError("Supported datatypes for messages are dicts and strings, got type {type(message)}.") + raise TypeError(f"Supported datatypes for messages are dicts and strings, got type {type(message)}.") # Convert dict to JSON if type(message) == dict: diff --git a/cloudlink/modules/__init__.py b/cloudlink/modules/__init__.py index 1affe65..534b898 100644 --- a/cloudlink/modules/__init__.py +++ b/cloudlink/modules/__init__.py @@ -2,4 +2,3 @@ from .async_iterables import async_iterable from .clients_manager import * from .rooms_manager import * -from .schemas import * diff --git a/cloudlink/modules/schemas.py b/cloudlink/modules/schemas.py deleted file mode 100644 index 456c77a..0000000 --- a/cloudlink/modules/schemas.py +++ /dev/null @@ -1,380 +0,0 @@ -""" -schemas - A replacement for validate() function in older versions of CloudLink. -""" - - -class schemas: - - # Schema for interpreting the Cloudlink protocol v4.0 (CLPv4) command set - class clpv4: - - # Required - Defines the keyword to use to define the command - command_key = "cmd" - - # Required - Defines the default schema to test against - default = { - "cmd": { - "type": "string", - "required": True - }, - "val": { - "type": [ - "string", - "integer", - "float", - "number", - "boolean", - "dict", - "list", - "set", - ], - "required": False, - }, - "name": { - "type": "string", - "required": False - }, - "id": { - "type": [ - "string", - "dict" - ], - "required": False - }, - "listener": { - "type": [ - "string", - "integer", - "float", - "boolean", - "number" - ], - "required": False - }, - "rooms": { - "type": [ - "string", - "integer", - "float", - "boolean", - "number", - "list", - "set" - ], - "required": False - } - } - - setid = { - "cmd": { - "type": "string", - "required": True - }, - "val": { - "type": "string", - "required": True - }, - "listener": { - "type": [ - "string", - "integer", - "float", - "boolean", - "number" - ], - "required": False - } - } - - gmsg = { - "cmd": { - "type": "string", - "required": True - }, - "val": { - "type": [ - "string", - "integer", - "float", - "number", - "boolean", - "dict", - "list", - "set", - ], - "required": True - }, - "listener": { - "type": [ - "string", - "integer", - "float", - "boolean", - "number" - ], - "required": False - }, - "rooms": { - "type": [ - "string", - "integer", - "float", - "boolean", - "number", - "list", - "set" - ], - "required": False - } - } - - gvar = { - "cmd": { - "type": "string", - "required": True - }, - "name": { - "type": "string", - "required": True - }, - "val": { - "type": [ - "string", - "integer", - "float", - "number", - "boolean", - "dict", - "list", - "set", - ], - "required": True - }, - "listener": { - "type": [ - "string", - "integer", - "float", - "boolean", - "number" - ], - "required": False - }, - "rooms": { - "type": [ - "string", - "integer", - "float", - "boolean", - "number", - "list", - "set" - ], - "required": False - } - } - - pmsg = { - "cmd": { - "type": "string", - "required": True - }, - "id": { - "type": [ - "string", - "dict" - ], - "required": True - }, - "val": { - "type": [ - "string", - "integer", - "float", - "number", - "boolean", - "dict", - "list", - "set", - ], - "required": True - }, - "listener": { - "type": [ - "string", - "integer", - "float", - "boolean", - "number" - ], - "required": False - }, - "rooms": { - "type": [ - "string", - "integer", - "float", - "boolean", - "number", - "list", - "set" - ], - "required": False - } - } - - pvar = { - "cmd": { - "type": "string", - "required": True - }, - "name": { - "type": "string", - "required": True - }, - "id": { - "type": [ - "string", - "dict" - ], - "required": True - }, - "val": { - "type": [ - "string", - "integer", - "float", - "number", - "boolean", - "dict", - "list", - "set", - ], - "required": True - }, - "listener": { - "type": [ - "string", - "integer", - "float", - "boolean", - "number" - ], - "required": False - }, - "rooms": { - "type": [ - "string", - "integer", - "float", - "boolean", - "number", - "list", - "set" - ], - "required": False - } - } - - # Schema for interpreting the Cloud Variables protocol used in Scratch 3.0 - class scratch: - - # Required - Defines the keyword to use to define the command - command_key = "method" - - # Required - Defines the default schema to test against - default = { - "method": { - "type": "string", - "required": True - }, - "name": { - "type": "string", - "required": False - }, - "value": { - "type": [ - "string", - "integer", - "float", - "boolean", - "dict", - "list", - "set" - ], - "required": False - }, - "project_id": { - "type": [ - "string", - "integer", - "float", - "boolean" - ], - "required": False, - }, - "user": { - "type": "string", - "required": False, - "minlength": 1, - "maxlength": 20 - } - } - - handshake = { - "method": { - "type": "string", - "required": True - }, - "project_id": { - "type": [ - "string", - "integer", - "float", - "boolean" - ], - "required": True, - }, - "user": { - "type": "string", - "required": True, - } - } - - method = { - "method": { - "type": "string", - "required": True - }, - "name": { - "type": "string", - "required": True - }, - "value": { - "type": [ - "string", - "integer", - "float", - "boolean", - "dict", - "list", - "set" - ], - "required": False - }, - "project_id": { - "type": [ - "string", - "integer", - "float", - "boolean" - ], - "required": True, - }, - "user": { - "type": "string", - "required": False, - "minlength": 1, - "maxlength": 20 - } - } diff --git a/cloudlink/protocols/__init__.py b/cloudlink/protocols/__init__.py index 2fb1efb..72fbef9 100644 --- a/cloudlink/protocols/__init__.py +++ b/cloudlink/protocols/__init__.py @@ -1,3 +1,2 @@ -from .clpv4 import * -from .scratch import * - +from .clpv4.clpv4 import * +from .scratch.scratch import * diff --git a/cloudlink/protocols/clpv4/__init__.py b/cloudlink/protocols/clpv4/__init__.py new file mode 100644 index 0000000..a9e2996 --- /dev/null +++ b/cloudlink/protocols/clpv4/__init__.py @@ -0,0 +1 @@ +from .clpv4 import * diff --git a/cloudlink/protocols/clpv4.py b/cloudlink/protocols/clpv4/clpv4.py similarity index 92% rename from cloudlink/protocols/clpv4.py rename to cloudlink/protocols/clpv4/clpv4.py index 252eb9d..cfc96ef 100644 --- a/cloudlink/protocols/clpv4.py +++ b/cloudlink/protocols/clpv4/clpv4.py @@ -1,3 +1,5 @@ +from .schema import cl4_protocol + """ This is the default protocol used for the CloudLink server. The CloudLink 4.1 Protocol retains full support for CLPv4. @@ -20,8 +22,8 @@ def __init__(self, server): self.enable_motd = False self.motd_message = str() - # protocol_schema: The default schema to identify the protocol. - self.protocol = server.schemas.clpv4 + # Exposes the schema of the protocol. + self.schema = cl4_protocol # Define various status codes for the protocol. class statuscodes: @@ -104,11 +106,11 @@ def send_statuscode(client, code, details=None, listener=None, val=None): # Exception handlers - @server.on_exception(exception_type=server.exceptions.ValidationError, schema=self.protocol) + @server.on_exception(exception_type=server.exceptions.ValidationError, schema=cl4_protocol) async def validation_failure(client, details): send_statuscode(client, statuscodes.syntax, details=dict(details)) - @server.on_exception(exception_type=server.exceptions.InvalidCommand, schema=self.protocol) + @server.on_exception(exception_type=server.exceptions.InvalidCommand, schema=cl4_protocol) async def invalid_command(client, details): send_statuscode( client, @@ -116,7 +118,7 @@ async def invalid_command(client, details): details=f"{details} is an invalid command." ) - @server.on_disabled_command(schema=self.protocol) + @server.on_disabled_command(schema=cl4_protocol) async def disabled_command(client, details): send_statuscode( client, @@ -124,7 +126,7 @@ async def disabled_command(client, details): details=f"{details} is a disabled command." ) - @server.on_exception(exception_type=server.exceptions.JSONError, schema=self.protocol) + @server.on_exception(exception_type=server.exceptions.JSONError, schema=cl4_protocol) async def json_exception(client, details): send_statuscode( client, @@ -132,7 +134,7 @@ async def json_exception(client, details): details=f"A JSON error was raised: {details}" ) - @server.on_exception(exception_type=server.exceptions.EmptyMessage, schema=self.protocol) + @server.on_exception(exception_type=server.exceptions.EmptyMessage, schema=cl4_protocol) async def empty_message(client, details): send_statuscode( client, @@ -142,7 +144,7 @@ async def empty_message(client, details): # The CLPv4 command set - @server.on_command(cmd="handshake", schema=self.protocol) + @server.on_command(cmd="handshake", schema=cl4_protocol) async def on_handshake(client, message): # Send client IP address server.send_packet(client, { @@ -175,7 +177,7 @@ async def on_handshake(client, message): else: send_statuscode(client, statuscodes.ok) - @server.on_command(cmd="ping", schema=self.protocol) + @server.on_command(cmd="ping", schema=cl4_protocol) async def on_ping(client, message): listener = None @@ -184,14 +186,14 @@ async def on_ping(client, message): send_statuscode(client, statuscodes.ok, listener=listener) - @server.on_command(cmd="gmsg", schema=self.protocol) + @server.on_command(cmd="gmsg", schema=cl4_protocol) async def on_gmsg(client, message): # Validate schema - if not valid(client, message, self.protocol.gmsg): + if not valid(client, message, cl4_protocol.gmsg): return # Copy the current set of connected client objects - clients = server.copy(server.clients_manager.protocols[self.protocol]) + clients = server.copy(server.clients_manager.protocols[cl4_protocol]) # Attach listener (if present) and broadcast if "listener" in message: @@ -227,7 +229,7 @@ async def on_gmsg(client, message): # Broadcast message server.send_packet(clients, tmp_message) - @server.on_command(cmd="pmsg", schema=self.protocol) + @server.on_command(cmd="pmsg", schema=cl4_protocol) async def on_pmsg(client, message): # Require sending client to have set their username if not client.username_set: @@ -252,7 +254,7 @@ async def on_pmsg(client, message): tmp_client = None # Validate schema - if not valid(client, message, self.protocol.pmsg): + if not valid(client, message, cl4_protocol.pmsg): return # Find client @@ -324,11 +326,11 @@ async def on_pmsg(client, message): statuscodes.ok ) - @server.on_command(cmd="gvar", schema=self.protocol) + @server.on_command(cmd="gvar", schema=cl4_protocol) async def on_gvar(client, message): # Validate schema - if not valid(client, message, self.protocol.gvar): + if not valid(client, message, cl4_protocol.gvar): return # Define the message to send @@ -339,7 +341,7 @@ async def on_gvar(client, message): } # Copy the current set of connected client objects - clients = server.copy(server.clients_manager.protocols[self.protocol]) + clients = server.copy(server.clients_manager.protocols[cl4_protocol]) # Attach listener (if present) and broadcast if "listener" in message: @@ -350,7 +352,7 @@ async def on_gvar(client, message): else: server.send_packet(clients, tmp_message) - @server.on_command(cmd="pvar", schema=self.protocol) + @server.on_command(cmd="pvar", schema=cl4_protocol) async def on_pvar(client, message): # Require sending client to have set their username if not client.username_set: @@ -375,7 +377,7 @@ async def on_pvar(client, message): tmp_client = None # Validate schema - if not valid(client, message, self.protocol.pvar): + if not valid(client, message, cl4_protocol.pvar): return # Find client @@ -429,10 +431,10 @@ async def on_pvar(client, message): statuscodes.ok ) - @server.on_command(cmd="setid", schema=self.protocol) + @server.on_command(cmd="setid", schema=cl4_protocol) async def on_setid(client, message): # Validate schema - if not valid(client, message, self.protocol.setid): + if not valid(client, message, cl4_protocol.setid): return # Prevent setting the username more than once @@ -486,23 +488,23 @@ async def on_setid(client, message): }, ) - @server.on_command(cmd="link", schema=self.protocol) + @server.on_command(cmd="link", schema=cl4_protocol) async def on_link(client, message): server.rooms_manager.subscribe(client, message["rooms"]) - @server.on_command(cmd="unlink", schema=self.protocol) + @server.on_command(cmd="unlink", schema=cl4_protocol) async def on_unlink(client, message): pass - @server.on_command(cmd="direct", schema=self.protocol) + @server.on_command(cmd="direct", schema=cl4_protocol) async def on_direct(client, message): pass - @server.on_command(cmd="bridge", schema=self.protocol) + @server.on_command(cmd="bridge", schema=cl4_protocol) async def on_bridge(client, message): pass - @server.on_command(cmd="echo", schema=self.protocol) + @server.on_command(cmd="echo", schema=cl4_protocol) async def on_echo(client, message): val = None listener = None diff --git a/cloudlink/protocols/clpv4/schema.py b/cloudlink/protocols/clpv4/schema.py new file mode 100644 index 0000000..79dc45a --- /dev/null +++ b/cloudlink/protocols/clpv4/schema.py @@ -0,0 +1,270 @@ +# Schema for interpreting the Cloudlink protocol v4.0 (CLPv4) command set +class cl4_protocol: + + # Required - Defines the keyword to use to define the command + command_key = "cmd" + + # Required - Defines the default schema to test against + default = { + "cmd": { + "type": "string", + "required": True + }, + "val": { + "type": [ + "string", + "integer", + "float", + "number", + "boolean", + "dict", + "list", + "set", + ], + "required": False, + }, + "name": { + "type": "string", + "required": False + }, + "id": { + "type": [ + "string", + "dict" + ], + "required": False + }, + "listener": { + "type": [ + "string", + "integer", + "float", + "boolean", + "number" + ], + "required": False + }, + "rooms": { + "type": [ + "string", + "integer", + "float", + "boolean", + "number", + "list", + "set" + ], + "required": False + } + } + + setid = { + "cmd": { + "type": "string", + "required": True + }, + "val": { + "type": "string", + "required": True + }, + "listener": { + "type": [ + "string", + "integer", + "float", + "boolean", + "number" + ], + "required": False + } + } + + gmsg = { + "cmd": { + "type": "string", + "required": True + }, + "val": { + "type": [ + "string", + "integer", + "float", + "number", + "boolean", + "dict", + "list", + "set", + ], + "required": True + }, + "listener": { + "type": [ + "string", + "integer", + "float", + "boolean", + "number" + ], + "required": False + }, + "rooms": { + "type": [ + "string", + "integer", + "float", + "boolean", + "number", + "list", + "set" + ], + "required": False + } + } + + gvar = { + "cmd": { + "type": "string", + "required": True + }, + "name": { + "type": "string", + "required": True + }, + "val": { + "type": [ + "string", + "integer", + "float", + "number", + "boolean", + "dict", + "list", + "set", + ], + "required": True + }, + "listener": { + "type": [ + "string", + "integer", + "float", + "boolean", + "number" + ], + "required": False + }, + "rooms": { + "type": [ + "string", + "integer", + "float", + "boolean", + "number", + "list", + "set" + ], + "required": False + } + } + + pmsg = { + "cmd": { + "type": "string", + "required": True + }, + "id": { + "type": [ + "string", + "dict" + ], + "required": True + }, + "val": { + "type": [ + "string", + "integer", + "float", + "number", + "boolean", + "dict", + "list", + "set", + ], + "required": True + }, + "listener": { + "type": [ + "string", + "integer", + "float", + "boolean", + "number" + ], + "required": False + }, + "rooms": { + "type": [ + "string", + "integer", + "float", + "boolean", + "number", + "list", + "set" + ], + "required": False + } + } + + pvar = { + "cmd": { + "type": "string", + "required": True + }, + "name": { + "type": "string", + "required": True + }, + "id": { + "type": [ + "string", + "dict" + ], + "required": True + }, + "val": { + "type": [ + "string", + "integer", + "float", + "number", + "boolean", + "dict", + "list", + "set", + ], + "required": True + }, + "listener": { + "type": [ + "string", + "integer", + "float", + "boolean", + "number" + ], + "required": False + }, + "rooms": { + "type": [ + "string", + "integer", + "float", + "boolean", + "number", + "list", + "set" + ], + "required": False + } + } \ No newline at end of file diff --git a/cloudlink/protocols/scratch/__init__.py b/cloudlink/protocols/scratch/__init__.py new file mode 100644 index 0000000..c2ce79b --- /dev/null +++ b/cloudlink/protocols/scratch/__init__.py @@ -0,0 +1 @@ +from .scratch import * \ No newline at end of file diff --git a/cloudlink/protocols/scratch/schema.py b/cloudlink/protocols/scratch/schema.py new file mode 100644 index 0000000..3e15bc7 --- /dev/null +++ b/cloudlink/protocols/scratch/schema.py @@ -0,0 +1,102 @@ +# Schema for interpreting the Cloud Variables protocol used in Scratch 3.0 +class scratch_protocol: + + # Required - Defines the keyword to use to define the command + command_key = "method" + + # Required - Defines the default schema to test against + default = { + "method": { + "type": "string", + "required": True + }, + "name": { + "type": "string", + "required": False + }, + "value": { + "type": [ + "string", + "integer", + "float", + "boolean", + "dict", + "list", + "set" + ], + "required": False + }, + "project_id": { + "type": [ + "string", + "integer", + "float", + "boolean" + ], + "required": False, + }, + "user": { + "type": "string", + "required": False, + "minlength": 1, + "maxlength": 20 + } + } + + handshake = { + "method": { + "type": "string", + "required": True + }, + "project_id": { + "type": [ + "string", + "integer", + "float", + "boolean" + ], + "required": True, + }, + "user": { + "type": "string", + "required": True, + } + } + + method = { + "method": { + "type": "string", + "required": True + }, + "name": { + "type": "string", + "required": True + }, + "value": { + "type": [ + "string", + "integer", + "float", + "boolean", + "dict", + "list", + "set" + ], + "required": False + }, + "project_id": { + "type": [ + "string", + "integer", + "float", + "boolean" + ], + "required": True, + }, + "user": { + "type": "string", + "required": False, + "minlength": 1, + "maxlength": 20 + } + } diff --git a/cloudlink/protocols/scratch.py b/cloudlink/protocols/scratch/scratch.py similarity index 88% rename from cloudlink/protocols/scratch.py rename to cloudlink/protocols/scratch/scratch.py index de5af8d..c41909c 100644 --- a/cloudlink/protocols/scratch.py +++ b/cloudlink/protocols/scratch/scratch.py @@ -1,3 +1,5 @@ +from .schema import scratch_protocol + """ This is a FOSS reimplementation of Scratch's Cloud Variable protocol. See https://github.com/TurboWarp/cloud-server/blob/master/doc/protocol.md for details. @@ -15,8 +17,8 @@ class statuscodes: unavailable = 4004 refused_security = 4005 - # protocol_schema: The default schema to identify the protocol. - self.protocol_schema = server.schemas.scratch + # Exposes the schema of the protocol. + self.schema = scratch # valid(message, schema): Used to verify messages. def valid(client, message, schema): @@ -28,7 +30,7 @@ def valid(client, message, schema): server.close_connection(client, code=statuscodes.connection_error, reason=f"Message schema validation failed") return False - @server.on_command(cmd="handshake", schema=self.protocol_schema) + @server.on_command(cmd="handshake", schema=scratch_protocol) async def handshake(client, message): # Safety first @@ -59,11 +61,11 @@ async def handshake(client, message): "value": self.storage[message["project_id"]][variable] }) - @server.on_command(cmd="create", schema=self.protocol_schema) + @server.on_command(cmd="create", schema=scratch_protocol) async def create_variable(client, message): # Validate schema - if not valid(client, message, self.protocol_schema.method): + if not valid(client, message, scratch_protocol.method): return # Guard clause - Room must exist before adding to it @@ -87,18 +89,18 @@ async def create_variable(client, message): "value": self.storage[message["project_id"]][variable] }) - @server.on_command(cmd="rename", schema=self.protocol_schema) + @server.on_command(cmd="rename", schema=scratch_protocol) async def rename_variable(client, message): # Validate schema - if not valid(client, message, self.protocol_schema.method): + if not valid(client, message, scratch_protocol.method): return - @server.on_command(cmd="delete", schema=self.protocol_schema) + @server.on_command(cmd="delete", schema=scratch_protocol) async def create_variable(client, message): # Validate schema - if not valid(client, message, self.protocol_schema.method): + if not valid(client, message, scratch_protocol.method): return # Guard clause - Room must exist before deleting values from it @@ -121,11 +123,11 @@ async def create_variable(client, message): "name": variable }) - @server.on_command(cmd="set", schema=self.protocol_schema) + @server.on_command(cmd="set", schema=scratch_protocol) async def set_value(client, message): # Validate schema - if not valid(client, message, self.protocol_schema.method): + if not valid(client, message, scratch_protocol.method): return # Guard clause - Room must exist before adding to it diff --git a/main.py b/main.py index ffabb57..58aa9fd 100644 --- a/main.py +++ b/main.py @@ -12,23 +12,17 @@ ) # Load protocols - cl_protocol = clpv4(server) - scratch_protocol = scratch(server) + clpv4 = clpv4(server) + # scratch = scratch(server) # Disable CL commands for command in ["gmsg", "gvar"]: - server.disable_command(command, cl_protocol.protocol) + server.disable_command(cmd=command, schema=clpv4.schema) # Create a demo command - @server.on_command(cmd="test", schema=cl_protocol.protocol) + @server.on_command(cmd="test", schema=clpv4.schema) async def on_test(client, message): print(message) - # Configure protocol settings - # cl_protocol.warn_if_multiple_username_matches = False - - # Configure max clients - server.max_clients = 2 - # Start the server server.run(port=3000) From 97b387c9bf3f81f57e70f7bd732589d6bbeab471 Mon Sep 17 00:00:00 2001 From: "Mike J. Renaker / \"MikeDEV" Date: Fri, 24 Mar 2023 17:54:51 -0400 Subject: [PATCH 14/59] Rename things + Improve imports ^ --- cloudlink/__init__.py | 2 +- cloudlink/{cloudlink.py => server.py} | 0 main.py | 7 ++++--- 3 files changed, 5 insertions(+), 4 deletions(-) rename cloudlink/{cloudlink.py => server.py} (100%) diff --git a/cloudlink/__init__.py b/cloudlink/__init__.py index 13cb868..8d730e9 100644 --- a/cloudlink/__init__.py +++ b/cloudlink/__init__.py @@ -1 +1 @@ -from .cloudlink import * +from .server import server diff --git a/cloudlink/cloudlink.py b/cloudlink/server.py similarity index 100% rename from cloudlink/cloudlink.py rename to cloudlink/server.py diff --git a/main.py b/main.py index 58aa9fd..ce8cd0b 100644 --- a/main.py +++ b/main.py @@ -1,9 +1,10 @@ -from cloudlink import cloudlink +from cloudlink import server from cloudlink.protocols import clpv4, scratch + if __name__ == "__main__": # Initialize the server - server = cloudlink.server() + server = server() # Configure logging settings server.logging.basicConfig( @@ -13,7 +14,7 @@ # Load protocols clpv4 = clpv4(server) - # scratch = scratch(server) + scratch = scratch(server) # Disable CL commands for command in ["gmsg", "gvar"]: From f4f92234ec586f947a2d04a28a46fac5e5a56183 Mon Sep 17 00:00:00 2001 From: "Mike J. Renaker / \"MikeDEV" Date: Fri, 24 Mar 2023 18:03:03 -0400 Subject: [PATCH 15/59] End of day commit * Fix for KeyErrors in Scratch protocol - Forgot to validate --- cloudlink/protocols/scratch/scratch.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/cloudlink/protocols/scratch/scratch.py b/cloudlink/protocols/scratch/scratch.py index c41909c..516970b 100644 --- a/cloudlink/protocols/scratch/scratch.py +++ b/cloudlink/protocols/scratch/scratch.py @@ -20,6 +20,9 @@ class statuscodes: # Exposes the schema of the protocol. self.schema = scratch + #TODO: Use rooms manager from server + self.storage = dict() + # valid(message, schema): Used to verify messages. def valid(client, message, schema): if server.validator(message, schema): @@ -27,7 +30,8 @@ def valid(client, message, schema): else: errors = server.validator.errors server.logger.warning(f"Error: {errors}") - server.close_connection(client, code=statuscodes.connection_error, reason=f"Message schema validation failed") + server.send_packet_unicast(client, f"Validation failed: {dict(errors)}") + server.close_connection(client, code=statuscodes.connection_error, reason=f"Validation failed") return False @server.on_command(cmd="handshake", schema=scratch_protocol) @@ -48,6 +52,10 @@ async def handshake(client, message): # End this guard clause return + # Validate schema + if not valid(client, message, scratch_protocol.handshake): + return + # Create project ID (temporary since rooms_manager isn't done yet) if not message["project_id"] in self.storage: self.storage[message["project_id"]] = dict() From 6a5ae01c9a55e4851750899cd895db287f18f7b3 Mon Sep 17 00:00:00 2001 From: MikeDEV Date: Sat, 25 Mar 2023 12:23:16 -0400 Subject: [PATCH 16/59] SSL SUPPORT GO BRRRRRRRRRR --- cloudlink/server.py | 21 ++++++++++++++++++--- main.py | 19 ++++++++++++++----- 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/cloudlink/server.py b/cloudlink/server.py index 60ff082..2c590a3 100644 --- a/cloudlink/server.py +++ b/cloudlink/server.py @@ -94,6 +94,16 @@ def __init__(self): # Set to -1 to allow as many client as possible self.max_clients = -1 + + # Configure SSL support + self.ssl_enabled = False + self.ssl_context = None + + # Enables SSL support + def enable_ssl(self, ctx): + self.ssl_context = ctx + self.ssl_enabled = True + self.logger.info(f"SSL Support enabled!") # Runs the server. def run(self, ip="127.0.0.1", port=3000): @@ -523,9 +533,14 @@ async def connection_loop(self, client): # WebSocket-specific server loop async def __run__(self, ip, port): - # Main event loop - async with self.ws.serve(self.connection_handler, ip, port): - await self.asyncio.Future() + if self.ssl_enabled: + # Run with SSL support + async with self.ws.serve(self.connection_handler, ip, port, ssl=self.ssl_context): + await self.asyncio.Future() + else: + # Run without security + async with self.ws.serve(self.connection_handler, ip, port): + await self.asyncio.Future() # Asyncio event-handling coroutines diff --git a/main.py b/main.py index ce8cd0b..c551fd1 100644 --- a/main.py +++ b/main.py @@ -1,15 +1,21 @@ from cloudlink import server from cloudlink.protocols import clpv4, scratch - +import ssl if __name__ == "__main__": # Initialize the server server = server() - + + # Initialize SSL + ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + certfile = "cert.pem" + keyfile = "privkey.pem" + ssl_context.load_cert_chain(certfile=certfile, keyfile=keyfile) + # Configure logging settings server.logging.basicConfig( format="[%(asctime)s] %(levelname)s: %(message)s", - level=server.logging.DEBUG + level=server.logging.INFO ) # Load protocols @@ -24,6 +30,9 @@ @server.on_command(cmd="test", schema=clpv4.schema) async def on_test(client, message): print(message) - + + # Pass the SSL context to the server + server.enable_ssl(ssl_context) + # Start the server - server.run(port=3000) + server.run(ip="0.0.0.0", port=3000) From 658485540fb2fd74d39f372169280033a1632484 Mon Sep 17 00:00:00 2001 From: MikeDEV Date: Sat, 25 Mar 2023 14:01:37 -0400 Subject: [PATCH 17/59] Finish implementing rooms manager --- cloudlink/modules/rooms_manager.py | 59 ++++++++++++++++-- cloudlink/protocols/clpv4/clpv4.py | 42 ++++++++----- cloudlink/protocols/scratch/scratch.py | 86 ++++++++++++++------------ cloudlink/server.py | 35 +++++++++-- 4 files changed, 155 insertions(+), 67 deletions(-) diff --git a/cloudlink/modules/rooms_manager.py b/cloudlink/modules/rooms_manager.py index b5c0ba5..1338e71 100644 --- a/cloudlink/modules/rooms_manager.py +++ b/cloudlink/modules/rooms_manager.py @@ -48,6 +48,12 @@ def __init__(self, parent): self.logging = parent.logging self.logger = self.logging.getLogger(__name__) + def get(self, room_id): + try: + return self.find_obj(room_id) + except self.exceptions.NoResultsFound: + return dict() + def create(self, room_id): # Rooms may only have string names if type(room_id) != str: @@ -60,6 +66,7 @@ def create(self, room_id): # Create the room self.rooms[room_id] = { "clients": dict(), + "usernames": dict(), "global_vars": dict(), "private_vars": dict() } @@ -80,8 +87,8 @@ def delete(self, room_id): # Delete the room self.rooms.pop(room_id) - # Delete reference to room - self.room_names.pop(room_id) + # Log deletion + self.parent.logger.debug(f"Deleted room {room_id}") def exists(self, room_id): # Rooms may only have string names @@ -103,8 +110,25 @@ def subscribe(self, obj, room_id): if obj in self.rooms[room_id]["clients"]: raise self.exceptions.RoomAlreadyJoined + # Create room protocol categories + if obj.protocol not in self.rooms[room_id]["clients"]: + self.rooms[room_id]["clients"][obj.protocol] = set() + # Add to room - self.rooms[room_id]["clients"][obj.snowflake] = obj + self.rooms[room_id]["clients"][obj.protocol].add(obj) + + # Create room username reference + if obj.username not in self.rooms[room_id]["usernames"]: + self.rooms[room_id]["usernames"][obj.username] = set() + + # Add to usernames reference + self.rooms[room_id]["usernames"][obj.username].add(obj) + + # Add room to client object + obj.rooms.add(room_id) + + # Log room subscribe + self.parent.logger.debug(f"Subscribed client {obj.snowflake} to room {room_id}") def unsubscribe(self, obj, room_id): # Rooms may only have string names @@ -116,11 +140,34 @@ def unsubscribe(self, obj, room_id): raise self.exceptions.RoomDoesNotExist # Check if a client has subscribed to a room - if obj not in self.rooms[room_id]["clients"]: - raise self.exceptions.RoomNotJoined + if obj not in self.rooms[room_id]["clients"][obj.protocol]: + raise self.exceptions.RoomNotJoined(f"Client was not found in room {room_id}!") # Remove from room - del self.rooms[room_id]["clients"][obj.snowflake] + self.rooms[room_id]["clients"][obj.protocol].remove(obj) + + # Clean up room protocol categories + if not len(self.rooms[room_id]["clients"][obj.protocol]): + self.rooms[room_id]["clients"].pop(obj.protocol) + + # Remove from username reference + if obj.username in self.rooms[room_id]["usernames"]: + self.rooms[room_id]["usernames"][obj.username].remove(obj) + + # Remove empty username reference set + if not len(self.rooms[room_id]["usernames"][obj.username]): + self.rooms[room_id]["usernames"].pop(obj.username) + + # Remove room from client object + obj.rooms.remove(room_id) + + # Log room unsubscribe + self.parent.logger.debug(f"Unsubscribed client {obj.snowflake} from room {room_id}") + + # Delete empty room + if not len(self.rooms[room_id]["clients"]): + self.parent.logger.debug(f"Deleting emptied room {room_id}...") + self.delete(room_id) def find_obj(self, query): # Rooms may only have string names diff --git a/cloudlink/protocols/clpv4/clpv4.py b/cloudlink/protocols/clpv4/clpv4.py index cfc96ef..4342261 100644 --- a/cloudlink/protocols/clpv4/clpv4.py +++ b/cloudlink/protocols/clpv4/clpv4.py @@ -142,6 +142,12 @@ async def empty_message(client, details): details="Your client has sent an empty message." ) + # Protocol identified event + @server.on_protocol_identified(schema=cl4_protocol) + async def protocol_identified(client): + server.logger.debug(f"Adding client {client.snowflake} to default room.") + server.rooms_manager.subscribe(client, "default") + # The CLPv4 command set @server.on_command(cmd="handshake", schema=cl4_protocol) @@ -283,23 +289,25 @@ async def on_pmsg(client, message): return # Warn client if they are attempting to send to a username with multiple matches - if self.warn_if_multiple_username_matches and len(tmp_client) >> 1: - # Attach listener - if "listener" in message: - send_statuscode( - client, - statuscodes.id_not_specific, - details=f'Multiple matches found for {message["id"]}, found {len(tmp_client)} matches. Please use Snowflakes or dict objects instead.', - listener=message["listener"] - ) - else: - send_statuscode( - client, - statuscodes.id_not_specific, - details=f'Multiple matches found for {message["id"]}, found {len(tmp_client)} matches. Please use Snowflakes or dict objects instead.' - ) - # End pmsg command handler - return + if self.warn_if_multiple_username_matches: + if type(tmp_client) == set: + if len(tmp_client) >> 1: + # Attach listener + if "listener" in message: + send_statuscode( + client, + statuscodes.id_not_specific, + details=f'Multiple matches found for {message["id"]}, found {len(tmp_client)} matches. Please use Snowflakes, UUIDs, or client objects instead.', + listener=message["listener"] + ) + else: + send_statuscode( + client, + statuscodes.id_not_specific, + details=f'Multiple matches found for {message["id"]}, found {len(tmp_client)} matches. Please use Snowflakes, UUIDs, or client objects instead.' + ) + # End pmsg command handler + return # Broadcast message to client tmp_message = { diff --git a/cloudlink/protocols/scratch/scratch.py b/cloudlink/protocols/scratch/scratch.py index 516970b..a9000b0 100644 --- a/cloudlink/protocols/scratch/scratch.py +++ b/cloudlink/protocols/scratch/scratch.py @@ -20,9 +20,6 @@ class statuscodes: # Exposes the schema of the protocol. self.schema = scratch - #TODO: Use rooms manager from server - self.storage = dict() - # valid(message, schema): Used to verify messages. def valid(client, message, schema): if server.validator(message, schema): @@ -56,17 +53,25 @@ async def handshake(client, message): if not valid(client, message, scratch_protocol.handshake): return - # Create project ID (temporary since rooms_manager isn't done yet) - if not message["project_id"] in self.storage: - self.storage[message["project_id"]] = dict() + # Set username + server.logger.debug(f"Scratch client {client.snowflake} declares username {message['user']}.") + + # Set client username + server.clients_manager.set_username(client, message['user']) + + # Subscribe to room + server.rooms_manager.subscribe(client, message["project_id"]) + + # Get values + room_data = server.rooms_manager.get(message["project_id"]) # Sync project ID variable state - for variable in self.storage[message["project_id"]]: + async for variable in server.async_iterable(room_data["global_vars"]): server.logger.debug(f"Sending variable {variable} to client {client.id}") server.send_packet_unicast(client, { "method": "set", "name": variable, - "value": self.storage[message["project_id"]][variable] + "value": room_data["global_vars"][variable] }) @server.on_command(cmd="create", schema=scratch_protocol) @@ -85,17 +90,18 @@ async def create_variable(client, message): server.logger.debug(f"Creating global variable {message['name']} in {message['project_id']}") - # Create the variable - self.storage[message["project_id"]][message['name']] = message["value"] + # Get values + room_data = server.rooms_manager.get(message["project_id"]) - # Broadcast the variable - for variable in self.storage[message["project_id"]]: - server.logger.debug(f"Creating variable {variable} in {len(server.clients_manager)} clients") - server.send_packet_multicast(server.clients_manager.clients, { - "method": "create", - "name": variable, - "value": self.storage[message["project_id"]][variable] - }) + # Create variable + room_data["global_vars"][message['name']] = message["value"] + + # Broadcast the variable state + server.send_packet_multicast(room_data["clients"][scratch_protocol], { + "method": "create", + "name": message['name'], + "value": room_data["global_vars"][message['name']] + }) @server.on_command(cmd="rename", schema=scratch_protocol) async def rename_variable(client, message): @@ -120,16 +126,17 @@ async def create_variable(client, message): server.logger.debug(f"Deleting global variable {message['name']} in {message['project_id']}") - # Delete the variable - del self.storage[message["project_id"]][message['name']] + # Get values + room_data = server.rooms_manager.get(message["project_id"]) - # Broadcast the variable - for variable in self.storage[message["project_id"]]: - server.logger.debug(f"Deleting variable {variable} in {len(server.clients_manager)} clients") - server.send_packet_multicast(server.clients_manager.clients, { - "method": "delete", - "name": variable - }) + # Delete variable + room_data["global_vars"].pop(message['name']) + + # Broadcast the variable state + server.send_packet_multicast(room_data["clients"][scratch_protocol], { + "method": "delete", + "name": message['name'] + }) @server.on_command(cmd="set", schema=scratch_protocol) async def set_value(client, message): @@ -139,21 +146,22 @@ async def set_value(client, message): return # Guard clause - Room must exist before adding to it - if not message["project_id"] in self.storage: + if not server.rooms_manager.exists(message["project_id"]): # Abort the connection server.close_connection(client, code=statuscodes.unavailable, reason=f"Invalid room ID: {message['project_id']}") - server.logger.debug(f"Updating global variable {message['name']} to value {message['value']}") + server.logger.debug(f"Updating global variable {message['name']} in {message['project_id']} to value {message['value']}") - # Update variable state - self.storage[message["project_id"]][message['name']] = message['value'] + # Get values + room_data = server.rooms_manager.get(message["project_id"]) - # Broadcast the variable - for variable in self.storage[message["project_id"]]: - server.logger.debug(f"Sending variable {variable} to {len(server.clients_manager)} clients") - server.send_packet_multicast(server.clients_manager.clients, { - "method": "set", - "name": variable, - "value": self.storage[message["project_id"]][variable] - }) + # Update variable + room_data["global_vars"][message['name']] = message["value"] + + # Broadcast the variable state + server.send_packet_multicast(room_data["clients"][scratch_protocol], { + "method": "set", + "name": message['name'], + "value": room_data["global_vars"][message['name']] + }) diff --git a/cloudlink/server.py b/cloudlink/server.py index 2c590a3..d796ac9 100644 --- a/cloudlink/server.py +++ b/cloudlink/server.py @@ -85,6 +85,7 @@ def __init__(self): self.on_error_events = async_event_manager(self) self.exception_handlers = dict() self.disabled_commands_handlers = dict() + self.protocol_identified_events = dict() # Create method handlers self.command_handlers = dict() @@ -188,6 +189,20 @@ def bind_event(func): # End on_error_specific binder return bind_event + def on_protocol_identified(self, schema): + def bind_event(func): + + # Create protocol identified event manager + if schema not in self.protocol_identified_events: + self.logger.info(f"Creating protocol identified event manager {schema.__qualname__}") + self.protocol_identified_events[schema] = async_event_manager(self) + + # Add function to the protocol identified event manager + self.protocol_identified_events[schema].bind(func) + + # End on_protocol_identified binder + return bind_event + # Event binder for on_message events def on_message(self, func): self.on_message_events.bind(func) @@ -356,6 +371,9 @@ async def message_processor(self, client, message): # Make the client's protocol known self.clients_manager.set_protocol(client, selected_protocol) + # Fire protocol identified events + self.asyncio.create_task(self.execute_protocol_identified_events(client, selected_protocol)) + else: self.logger.debug(f"Validating message from {client.snowflake} using protocol {client.protocol.__qualname__}") @@ -468,9 +486,6 @@ async def connection_handler(self, client): # Add to clients manager self.clients_manager.add(client) - # Add to default room - self.rooms_manager.subscribe(client, "default") - # Fire on_connect events self.asyncio.create_task(self.execute_on_connect_events(client)) @@ -485,8 +500,9 @@ async def connection_handler(self, client): # Fire on_disconnect events self.asyncio.create_task(self.execute_on_disconnect_events(client)) - async for room in self.async_iterable(client.rooms): - self.rooms_manager.unsubscribe(client, room) + # Unsubscribe from all rooms + async for room_id in self.async_iterable(self.copy(client.rooms)): + self.rooms_manager.unsubscribe(client, room_id) self.logger.debug(f"Client {client.snowflake} disconnected: Total lifespan of {time.monotonic() - client.birth_time} seconds.") @@ -583,6 +599,15 @@ async def execute_disabled_command_events(self, client, schema, cmd): # Fire events async for event in self.disabled_commands_handlers[schema]: await event(client, cmd) + + async def execute_protocol_identified_events(self, client, schema): + # Guard clauses + if schema not in self.protocol_identified_events: + return + + # Fire events + async for event in self.protocol_identified_events[schema]: + await event(client) # WebSocket-specific coroutines From 8c0ea9c22c299875c7a98c046ba971de551eb2f3 Mon Sep 17 00:00:00 2001 From: MikeDEV Date: Sat, 25 Mar 2023 14:03:37 -0400 Subject: [PATCH 18/59] Fix clients manager --- cloudlink/modules/clients_manager.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cloudlink/modules/clients_manager.py b/cloudlink/modules/clients_manager.py index 034e9c7..ea33a22 100644 --- a/cloudlink/modules/clients_manager.py +++ b/cloudlink/modules/clients_manager.py @@ -105,8 +105,8 @@ def remove(self, obj): self.usernames[obj.username].remove(obj) # Clean up unused usernames - if not len(self.usernames[obj.friendly_username]): - del self.usernames[obj.friendly_username] + if not len(self.usernames[obj.usernames]): + del self.usernames[obj.usernames] def set_username(self, obj, username): if not self.exists(obj): From d0208a905c8c0f63ebdac8c4e740669b57732780 Mon Sep 17 00:00:00 2001 From: MikeDEV Date: Sat, 25 Mar 2023 14:05:35 -0400 Subject: [PATCH 19/59] Fix typo --- cloudlink/modules/clients_manager.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cloudlink/modules/clients_manager.py b/cloudlink/modules/clients_manager.py index ea33a22..2202985 100644 --- a/cloudlink/modules/clients_manager.py +++ b/cloudlink/modules/clients_manager.py @@ -105,8 +105,8 @@ def remove(self, obj): self.usernames[obj.username].remove(obj) # Clean up unused usernames - if not len(self.usernames[obj.usernames]): - del self.usernames[obj.usernames] + if not len(self.usernames[obj.username]): + del self.usernames[obj.username] def set_username(self, obj, username): if not self.exists(obj): From d07981be34b80ea9f609909a69efdc5ba2d36aec Mon Sep 17 00:00:00 2001 From: MikeDEV Date: Sat, 25 Mar 2023 15:47:01 -0400 Subject: [PATCH 20/59] Clean up SSL support + Admin commands --- cloudlink/plugins/__init__.py | 1 + cloudlink/plugins/cl_admin.py | 190 +++++++++++++++++++++++++++++ cloudlink/protocols/clpv4/clpv4.py | 12 ++ cloudlink/server.py | 20 +-- main.py | 20 +-- 5 files changed, 217 insertions(+), 26 deletions(-) create mode 100644 cloudlink/plugins/__init__.py create mode 100644 cloudlink/plugins/cl_admin.py diff --git a/cloudlink/plugins/__init__.py b/cloudlink/plugins/__init__.py new file mode 100644 index 0000000..b173a71 --- /dev/null +++ b/cloudlink/plugins/__init__.py @@ -0,0 +1 @@ +from .cl_admin import * diff --git a/cloudlink/plugins/cl_admin.py b/cloudlink/plugins/cl_admin.py new file mode 100644 index 0000000..35370c6 --- /dev/null +++ b/cloudlink/plugins/cl_admin.py @@ -0,0 +1,190 @@ +# Disable CL commands +#server.disable_command(cmd=command, schema=clpv4.schema) + +# Enable CL commands +#server.enable_command(cmd=command, schema=clpv4.schema) + +# Set maximum number of users +#server.max_clients = -1 # -1 for unlimited + +class cl_admin: + def __init__(self, server, clpv4): + server.logging.info("Initializing CL Admin Extension...") + + # Example config + self.admin_users = { + "admin": { + "password": "cloudlink", + "permissions": { + "disable_commands": True, + "enable_commands": True, + "close_connections": True, + "change_user_limit": True, + "view_rooms": True, + "create_rooms": True, + "modify_rooms": True, + "delete_rooms": True, + "view_client_data": True, + "modify_client_data": True, + "delete_client_data": True, + "view_client_ip": True, + "add_admin_users": True, + "modify_admin_users": True, + "delete_admin_users": True + }, + "enforce_ip_whitelist": True, + "ip_whitelist": [ + "127.0.0.1" + ] + } + } + + # Extend the clpv4 protocol's schemas + clpv4.schema.admin_auth = { + "cmd": { + "type": "string", + "required": True + }, + "val": { + "type": "dict", + "required": True, + "schema": { + "username": {"type": "string", "required": True}, + "password": {"type": "string", "required": True} + } + } + } + + # Extend the clpv4 protocol's statuscodes + clpv4.statuscodes.admin_auth_ok = (clpv4.statuscodes.info, 200, "Admin authentication successful") + clpv4.statuscodes.invalid_admin_login = (clpv4.statuscodes.error, 201, "Invalid admin login") + clpv4.statuscodes.admin_auth_failure = (clpv4.statuscodes.error, 202, "Admin authentication failure") + clpv4.statuscodes.admin_session_exists = (clpv4.statuscodes.info, 203, "Admin session already exists") + clpv4.statuscodes.ip_not_whitelisted = (clpv4.statuscodes.info, 204, "IP address not whitelisted for admin authentication") + + # Add new commands to the protocol + @server.on_command(cmd="admin_auth", schema=clpv4.schema) + async def admin_auth(client, message): + # Validate message schema + if not clpv4.valid(client, message, clpv4.schema.admin_auth): + return + + # Check if the client is not already authenticated as an admin + if hasattr(client, "admin_user"): + clpv4.send_statuscode( + client, + clpv4.statuscodes.admin_session_exists, + details=f"You are currently authenticated as user {client.admin_user}. To logout, simply close the websocket connection." + ) + return + + # Get inputs + username = message["val"]["username"] + password = message["val"]["password"] + server.logging.info(f"Client {client.snowflake} attempting to admin authenticate as {username}...") + + # Verify username + if username not in self.admin_users: + clpv4.send_statuscode( + client, + clpv4.statuscodes.invalid_admin_login, + details=f"There is no registered admin user {username}. Please try again." + ) + return + + # Check if IP whitelist is being enforced + if self.admin_users[username]["enforce_ip_whitelist"] and (clpv4.get_client_ip(client) not in self.admin_users[username]["ip_whitelist"]): + clpv4.send_statuscode( + client, + clpv4.statuscodes.ip_not_whitelisted, + details=f"The user {username} has enabled IP whitelist enforcement. This IP address, {clpv4.get_client_ip(client)} is not whitelisted." + ) + return + + # Verify password + if password != self.admin_users[username]["password"]: + clpv4.send_statuscode( + client, + clpv4.statuscodes.admin_auth_failure, + details=f"Invalid password for admin user {username}. Please try again." + ) + return + + # Admin is authenticated + client.admin_user = username + clpv4.send_statuscode(client, clpv4.statuscodes.admin_auth_ok) + + #TODO: Add admin command for disabling commands + @server.on_command(cmd="admin_disable_command", schema=clpv4.schema) + async def disable_command(client, message): + pass + + # TODO: Add admin command for enabling commands + @server.on_command(cmd="admin_enable_command", schema=clpv4.schema) + async def enable_command(client, message): + pass + + # TODO: Add admin command for kicking clients + @server.on_command(cmd="admin_close_connection", schema=clpv4.schema) + async def close_connection(client, message): + pass + + # TODO: Add admin command for changing maximum number of users connecting + @server.on_command(cmd="admin_change_user_limit", schema=clpv4.schema) + async def change_user_limit(client, message): + pass + + # TODO: Add admin command for getting room state + @server.on_command(cmd="admin_get_room", schema=clpv4.schema) + async def get_room(client, message): + pass + + # TODO: Add admin command for creating rooms + @server.on_command(cmd="admin_create_room", schema=clpv4.schema) + async def create_room(client, message): + pass + + # TODO: Add admin command for modifying room state + @server.on_command(cmd="admin_edit_room", schema=clpv4.schema) + async def edit_room(client, message): + pass + + # TODO: Add admin command for deleting rooms + @server.on_command(cmd="admin_delete_room", schema=clpv4.schema) + async def delete_room(client, message): + pass + + # TODO: Add admin command for reading attributes from a client object + @server.on_command(cmd="admin_get_client_data", schema=clpv4.schema) + async def get_client_data(client, message): + pass + + # TODO: Add admin command for modifying attributes from a client object + @server.on_command(cmd="admin_edit_client_data", schema=clpv4.schema) + async def edit_client_data(client, message): + pass + + # TODO: Add admin command for deleting attributes from a client object + @server.on_command(cmd="admin_delete_client_data", schema=clpv4.schema) + async def get_room(client, message): + pass + + # TODO: Add admin command for retrieving ip address of a client object + @server.on_command(cmd="admin_get_client_ip", schema=clpv4.schema) + async def get_client_ip(client, message): + pass + + # TODO: Add admin command for creating admin users + @server.on_command(cmd="admin_add_admin_user", schema=clpv4.schema) + async def add_admin_user(client, message): + pass + + # TODO: Add admin command for modifying admin user permissions + @server.on_command(cmd="admin_modify_admin_user", schema=clpv4.schema) + async def modify_admin_user(client, message): + pass + + # TODO: Add admin command for deleting admin users + @server.on_command(cmd="admin_delete_admin_user", schema=clpv4.schema) + async def delete_admin_user(client, message): + pass diff --git a/cloudlink/protocols/clpv4/clpv4.py b/cloudlink/protocols/clpv4/clpv4.py index 4342261..3d709c2 100644 --- a/cloudlink/protocols/clpv4/clpv4.py +++ b/cloudlink/protocols/clpv4/clpv4.py @@ -54,6 +54,9 @@ class statuscodes: def generate(code: tuple): return f"{code[0]}:{code[1]} | {code[2]}", code[1] + # Expose statuscodes class for extension usage + self.statuscodes = statuscodes + # Identification of a client's IP address def get_client_ip(client): # Grab forwarded IP address @@ -71,6 +74,9 @@ def get_client_ip(client): # Default if none of the above work return client.remote_address + # Expose get_client_ip for extension usage + self.get_client_ip = get_client_ip + # valid(message, schema): Used to verify messages. def valid(client, message, schema): if server.validator(message, schema): @@ -80,6 +86,9 @@ def valid(client, message, schema): send_statuscode(client, statuscodes.syntax, details=dict(server.validator.errors)) return False + # Expose validator function for extension usage + self.valid = valid + # Simplify sending error/info messages def send_statuscode(client, code, details=None, listener=None, val=None): # Generate a statuscode @@ -104,6 +113,9 @@ def send_statuscode(client, code, details=None, listener=None, val=None): # Send the code server.send_packet(client, tmp_message) + # Expose the statuscode generator for extension usage + self.send_statuscode = send_statuscode + # Exception handlers @server.on_exception(exception_type=server.exceptions.ValidationError, schema=cl4_protocol) diff --git a/cloudlink/server.py b/cloudlink/server.py index d796ac9..fc8f90c 100644 --- a/cloudlink/server.py +++ b/cloudlink/server.py @@ -6,8 +6,9 @@ from copy import copy from snowflake import SnowflakeGenerator -# Import websocket engine +# Import websocket engine and SSL support import websockets +import ssl # Import CloudLink modules from cloudlink.modules.async_event_manager import async_event_manager @@ -101,10 +102,14 @@ def __init__(self): self.ssl_context = None # Enables SSL support - def enable_ssl(self, ctx): - self.ssl_context = ctx - self.ssl_enabled = True - self.logger.info(f"SSL Support enabled!") + def enable_ssl(self, certfile, keyfile): + try: + self.ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + self.ssl_context.load_cert_chain(certfile=certfile, keyfile=keyfile) + self.ssl_enabled = True + self.logger.info(f"SSL support initialized!") + except Exception as e: + self.logger.error(f"Failed to initialize SSL support! {e}") # Runs the server. def run(self, ip="127.0.0.1", port=3000): @@ -478,7 +483,6 @@ async def connection_handler(self, client): client.rooms = set() client.username_set = False client.username = str() - client.linked = False # Begin tracking the lifetime of the client client.birth_time = time.monotonic() @@ -554,7 +558,7 @@ async def __run__(self, ip, port): async with self.ws.serve(self.connection_handler, ip, port, ssl=self.ssl_context): await self.asyncio.Future() else: - # Run without security + # Run without SSL support async with self.ws.serve(self.connection_handler, ip, port): await self.asyncio.Future() @@ -615,7 +619,7 @@ async def execute_unicast(self, client, message): # Guard clause if type(message) not in [dict, str]: - raise TypeError("Supported datatypes for messages are dicts and strings, got type {type(message)}.") + raise TypeError(f"Supported datatypes for messages are dicts and strings, got type {type(message)}.") # Convert dict to JSON if type(message) == dict: diff --git a/main.py b/main.py index c551fd1..4807f3f 100644 --- a/main.py +++ b/main.py @@ -1,17 +1,10 @@ from cloudlink import server from cloudlink.protocols import clpv4, scratch -import ssl if __name__ == "__main__": # Initialize the server server = server() - # Initialize SSL - ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) - certfile = "cert.pem" - keyfile = "privkey.pem" - ssl_context.load_cert_chain(certfile=certfile, keyfile=keyfile) - # Configure logging settings server.logging.basicConfig( format="[%(asctime)s] %(levelname)s: %(message)s", @@ -21,18 +14,9 @@ # Load protocols clpv4 = clpv4(server) scratch = scratch(server) - - # Disable CL commands - for command in ["gmsg", "gvar"]: - server.disable_command(cmd=command, schema=clpv4.schema) - - # Create a demo command - @server.on_command(cmd="test", schema=clpv4.schema) - async def on_test(client, message): - print(message) - # Pass the SSL context to the server - server.enable_ssl(ssl_context) + # Init SSL + server.enable_ssl(certfile = "cert.pem", keyfile = "privkey.pem") # Start the server server.run(ip="0.0.0.0", port=3000) From a1a62eb0c2e9e9807806cf1d3f84e06775b72991 Mon Sep 17 00:00:00 2001 From: MikeDEV Date: Sat, 25 Mar 2023 19:53:30 -0400 Subject: [PATCH 21/59] Scratch Protocol is complete --- cloudlink/protocols/scratch/schema.py | 8 ++++ cloudlink/protocols/scratch/scratch.py | 66 ++++++++++++++++++++++---- 2 files changed, 65 insertions(+), 9 deletions(-) diff --git a/cloudlink/protocols/scratch/schema.py b/cloudlink/protocols/scratch/schema.py index 3e15bc7..9dd9990 100644 --- a/cloudlink/protocols/scratch/schema.py +++ b/cloudlink/protocols/scratch/schema.py @@ -14,6 +14,10 @@ class scratch_protocol: "type": "string", "required": False }, + "new_name": { + "type": "string", + "required": False + }, "value": { "type": [ "string", @@ -72,6 +76,10 @@ class scratch_protocol: "type": "string", "required": True }, + "new_name": { + "type": "string", + "required": False + }, "value": { "type": [ "string", diff --git a/cloudlink/protocols/scratch/scratch.py b/cloudlink/protocols/scratch/scratch.py index a9000b0..6606cb6 100644 --- a/cloudlink/protocols/scratch/scratch.py +++ b/cloudlink/protocols/scratch/scratch.py @@ -66,8 +66,8 @@ async def handshake(client, message): room_data = server.rooms_manager.get(message["project_id"]) # Sync project ID variable state + server.logger.debug(f"Synchronizing room {message['project_id']} state to client {client.id}") async for variable in server.async_iterable(room_data["global_vars"]): - server.logger.debug(f"Sending variable {variable} to client {client.id}") server.send_packet_unicast(client, { "method": "set", "name": variable, @@ -82,7 +82,7 @@ async def create_variable(client, message): return # Guard clause - Room must exist before adding to it - if not message["project_id"] in self.storage: + if not server.rooms_manager.exists(message["project_id"]): server.logger.warning(f"Error: room {message['project_id']} does not exist yet") # Abort the connection @@ -110,6 +110,39 @@ async def rename_variable(client, message): if not valid(client, message, scratch_protocol.method): return + # Guard clause - Room must exist before deleting values from it + if not server.rooms_manager.exists(message["project_id"]): + server.logger.warning(f"Error: room {message['project_id']} does not exist yet") + # Abort the connection + server.close_connection( + client, + code=statuscodes.unavailable, + reason=f"Invalid room ID: {message['project_id']}" + ) + return + + server.logger.debug(f"Renaming global variable {message['name']} to {message['new_name']} in {message['project_id']}") + + # Get values + room_data = server.rooms_manager.get(message["project_id"]) + + if message["name"] in room_data["global_vars"]: + # Copy variable + room_data["global_vars"][message["new_name"]] = server.copy(room_data["global_vars"][message["name"]]) + + # Delete old variable + room_data["global_vars"].pop(message['name']) + else: + # Create new variable (renamed from a value in a deleted room) + room_data["global_vars"][message["new_name"]] = str() + + # Broadcast the variable state + server.send_packet_multicast(room_data["clients"][scratch_protocol], { + "method": "rename", + "name": message['name'], + "new_name": message['new_name'] + }) + @server.on_command(cmd="delete", schema=scratch_protocol) async def create_variable(client, message): @@ -118,11 +151,15 @@ async def create_variable(client, message): return # Guard clause - Room must exist before deleting values from it - if not message["project_id"] in self.storage: + if not server.rooms_manager.exists(message["project_id"]): server.logger.warning(f"Error: room {message['project_id']} does not exist yet") - # Abort the connection - server.close_connection(client, code=statuscodes.unavailable, reason=f"Invalid room ID: {message['project_id']}") + server.close_connection( + client, + code=statuscodes.unavailable, + reason=f"Invalid room ID: {message['project_id']}" + ) + return server.logger.debug(f"Deleting global variable {message['name']} in {message['project_id']}") @@ -147,15 +184,26 @@ async def set_value(client, message): # Guard clause - Room must exist before adding to it if not server.rooms_manager.exists(message["project_id"]): - + server.logger.warning(f"Error: room {message['project_id']} does not exist yet") # Abort the connection - server.close_connection(client, code=statuscodes.unavailable, reason=f"Invalid room ID: {message['project_id']}") - - server.logger.debug(f"Updating global variable {message['name']} in {message['project_id']} to value {message['value']}") + server.close_connection( + client, + code=statuscodes.unavailable, + reason=f"Invalid room ID: {message['project_id']}" + ) + return # Get values room_data = server.rooms_manager.get(message["project_id"]) + # Don't re-broadcast values that are identical + if message["name"] in room_data["global_vars"]: + if room_data["global_vars"][message['name']] == message["value"]: + server.logger.debug(f"Not going to rebroadcast global variable {message['name']} in {message['project_id']}") + return + + server.logger.debug(f"Updating global variable {message['name']} in {message['project_id']} to value {message['value']}") + # Update variable room_data["global_vars"][message['name']] = message["value"] From 6ab3da757dcb3e9603ad7bd24fe8dfb4d82199f6 Mon Sep 17 00:00:00 2001 From: MikeDEV Date: Sun, 26 Mar 2023 00:31:29 -0400 Subject: [PATCH 22/59] Improvements + Almost done with CLPv4 * Rooms manager now has the same snowflake, UUID, and username searching functionality as clients manager * Reimplemented room linking/unlinking commands in the CLPv4 command set * Modified the behavior of gmsg and gvar commands so they respect rooms in the CLPv4 command set --- cloudlink/modules/rooms_manager.py | 154 +++++-- cloudlink/protocols/clpv4/clpv4.py | 567 +++++++++++++++++-------- cloudlink/protocols/clpv4/schema.py | 76 +++- cloudlink/protocols/scratch/scratch.py | 8 +- 4 files changed, 596 insertions(+), 209 deletions(-) diff --git a/cloudlink/modules/rooms_manager.py b/cloudlink/modules/rooms_manager.py index 1338e71..cb265f7 100644 --- a/cloudlink/modules/rooms_manager.py +++ b/cloudlink/modules/rooms_manager.py @@ -16,14 +16,6 @@ class RoomNotEmpty(Exception): """This exception is raised when attempting to delete a room that is not empty""" pass - class RoomAlreadyJoined(Exception): - """This exception is raised when a client attempts to join a room, but it was already joined.""" - pass - - class RoomNotJoined(Exception): - """This exception is raised when a client attempts to access a room not joined.""" - pass - class NoResultsFound(Exception): """This exception is raised when there are no results for a room search request.""" pass @@ -50,9 +42,14 @@ def __init__(self, parent): def get(self, room_id): try: - return self.find_obj(room_id) + return self.find_room(room_id) except self.exceptions.NoResultsFound: - return dict() + # Return default dict + return { + "clients": dict(), + "global_vars": dict(), + "private_vars": dict() + } def create(self, room_id): # Rooms may only have string names @@ -66,7 +63,6 @@ def create(self, room_id): # Create the room self.rooms[room_id] = { "clients": dict(), - "usernames": dict(), "global_vars": dict(), "private_vars": dict() } @@ -106,23 +102,34 @@ def subscribe(self, obj, room_id): if not self.exists(room_id): self.create(room_id) - # Check if client has not subscribed to a room - if obj in self.rooms[room_id]["clients"]: - raise self.exceptions.RoomAlreadyJoined + room = self.rooms[room_id] # Create room protocol categories - if obj.protocol not in self.rooms[room_id]["clients"]: - self.rooms[room_id]["clients"][obj.protocol] = set() + if obj.protocol not in room["clients"]: + room["clients"][obj.protocol] = { + "all": set(), + "uuids": dict(), + "snowflakes": dict(), + "usernames": dict() + } + + room_objs = self.rooms[room_id]["clients"][obj.protocol] + + # Exit if client has subscribed to the room already + if str(obj.id) in room_objs["uuids"]: + return # Add to room - self.rooms[room_id]["clients"][obj.protocol].add(obj) + room_objs["all"].add(obj) + room_objs["uuids"][str(obj.id)] = obj + room_objs["snowflakes"][obj.snowflake] = obj # Create room username reference - if obj.username not in self.rooms[room_id]["usernames"]: - self.rooms[room_id]["usernames"][obj.username] = set() + if obj.username not in room_objs["usernames"]: + room_objs["usernames"][obj.username] = set() # Add to usernames reference - self.rooms[room_id]["usernames"][obj.username].add(obj) + room_objs["usernames"][obj.username].add(obj) # Add room to client object obj.rooms.add(room_id) @@ -139,24 +146,28 @@ def unsubscribe(self, obj, room_id): if not self.exists(room_id): raise self.exceptions.RoomDoesNotExist + room = self.rooms[room_id]["clients"][obj.protocol] + # Check if a client has subscribed to a room - if obj not in self.rooms[room_id]["clients"][obj.protocol]: - raise self.exceptions.RoomNotJoined(f"Client was not found in room {room_id}!") + if str(obj.id) not in room["uuids"]: + return # Remove from room - self.rooms[room_id]["clients"][obj.protocol].remove(obj) + room["all"].remove(obj) + room["uuids"].pop(str(obj.id)) + room["snowflakes"].pop(obj.snowflake) + if obj in room["usernames"][obj.username]: + room["usernames"][obj.username].remove(obj) - # Clean up room protocol categories - if not len(self.rooms[room_id]["clients"][obj.protocol]): - self.rooms[room_id]["clients"].pop(obj.protocol) + # Remove empty username reference set + if not len(room["usernames"][obj.username]): + room["usernames"].pop(obj.username) - # Remove from username reference - if obj.username in self.rooms[room_id]["usernames"]: - self.rooms[room_id]["usernames"][obj.username].remove(obj) + room = self.rooms[room_id]["clients"] - # Remove empty username reference set - if not len(self.rooms[room_id]["usernames"][obj.username]): - self.rooms[room_id]["usernames"].pop(obj.username) + # Clean up room protocol categories + if not len(room[obj.protocol]): + room.pop(obj.protocol) # Remove room from client object obj.rooms.remove(room_id) @@ -165,11 +176,11 @@ def unsubscribe(self, obj, room_id): self.parent.logger.debug(f"Unsubscribed client {obj.snowflake} from room {room_id}") # Delete empty room - if not len(self.rooms[room_id]["clients"]): + if not len(room): self.parent.logger.debug(f"Deleting emptied room {room_id}...") self.delete(room_id) - def find_obj(self, query): + def find_room(self, query): # Rooms may only have string names if type(query) != str: raise TypeError("Searching for room objects requires a string for the query.") @@ -177,3 +188,78 @@ def find_obj(self, query): return self.rooms[query] else: raise self.exceptions.NoResultsFound + + def find_obj(self, query, room): + # Prevent accessing clients with usernames not being set + if not len(query): + raise self.exceptions.NoResultsFound + + # Locate client objects in room + if query in room["usernames"]: + return room["usernames"][query] # returns set of client objects + elif query in self.get_uuids(room): + return room["uuids"][query] # returns client object + elif query in self.get_snowflakes(room): + return room["snowflakes"][query] # returns client object + else: + raise self.exceptions.NoResultsFound + + def get_snowflakes(self, room): + return set(obj for obj in room["snowflakes"]) + + def get_uuids(self, room): + return set(obj for obj in room["uuids"]) + + async def get_all_in_rooms(self, rooms, protocol): + obj_set = set() + + # Validate types + if type(rooms) not in [list, set, str]: + raise TypeError(f"Gathering all user objects in rooms requires using a list, set, or string! Got {type(rooms)}.") + + # Convert to set + if type(rooms) == str: + rooms = {rooms} + if type(rooms) == list: + rooms = set(rooms) + + # Collect all user objects in rooms + async for room in self.parent.async_iterable(rooms): + if protocol not in self.get(room)["clients"]: + continue + obj_set.update(self.get(room)["clients"][protocol]["all"]) + + return obj_set + + async def get_specific_in_room(self, room, protocol, queries): + obj_set = set() + + # Validate types + if type(room) != str: + raise TypeError(f"Gathering specific clients in a room only supports strings for room IDs.") + if type(queries) not in [list, set, str]: + raise TypeError(f"Gathering all user objects in a room requires using a list, set, or string! Got {type(queries)}.") + + # Just return an empty set if the room doesn't exist + if not self.exists(room): + return set() + + room = self.get(room)["clients"][protocol] + + # Convert queries to set + if type(queries) == str: + queries = {queries} + if type(queries) == list: + queries = set(queries) + + async for query in self.parent.async_iterable(queries): + try: + obj = self.find_obj(query, room) + if type(obj) == set: + obj_set.update(obj) + else: + obj_set.add(obj) + except self.exceptions.NoResultsFound: + continue + + return obj_set diff --git a/cloudlink/protocols/clpv4/clpv4.py b/cloudlink/protocols/clpv4/clpv4.py index 3d709c2..7d4eaeb 100644 --- a/cloudlink/protocols/clpv4/clpv4.py +++ b/cloudlink/protocols/clpv4/clpv4.py @@ -49,6 +49,7 @@ class statuscodes: id_conflict = (error, 112, "ID conflict") too_large = (error, 113, "Too large") json_error = (error, 114, "JSON error") + room_not_joined = (error, 115, "Room not joined") @staticmethod def generate(code: tuple): @@ -116,6 +117,49 @@ def send_statuscode(client, code, details=None, listener=None, val=None): # Expose the statuscode generator for extension usage self.send_statuscode = send_statuscode + # Simplify alerting users that a command requires a username to be set + def require_username_set(client, message): + if not client.username_set: + # Attach listener + if "listener" in message: + send_statuscode( + client, + statuscodes.id_required, + details="This command requires setting a username.", + listener=message["listener"] + ) + else: + send_statuscode( + client, + statuscodes.id_required, + details="This command requires setting a username." + ) + + return client.username_set + + # Expose username requirement function for extension usage + self.require_username_set = require_username_set + + # Tool for gathering client rooms + def gather_rooms(client, message): + if "rooms" in message: + # Read value from message + rooms = message["rooms"] + + # Convert to set + if type(rooms) == str: + rooms = {rooms} + if type(rooms) == list: + rooms = set(rooms) + + return rooms + else: + # Use all subscribed rooms + return client.rooms + + # Expose rooms gatherer for extension usage + self.gather_rooms = gather_rooms + # Exception handlers @server.on_exception(exception_type=server.exceptions.ValidationError, schema=cl4_protocol) @@ -197,12 +241,10 @@ async def on_handshake(client, message): @server.on_command(cmd="ping", schema=cl4_protocol) async def on_ping(client, message): - listener = None - if "listener" in message: - listener = message["listener"] - - send_statuscode(client, statuscodes.ok, listener=listener) + send_statuscode(client, statuscodes.ok, listener=message["listener"]) + else: + send_statuscode(client, statuscodes.ok) @server.on_command(cmd="gmsg", schema=cl4_protocol) async def on_gmsg(client, message): @@ -210,78 +252,153 @@ async def on_gmsg(client, message): if not valid(client, message, cl4_protocol.gmsg): return - # Copy the current set of connected client objects - clients = server.copy(server.clients_manager.protocols[cl4_protocol]) + # Gather rooms to send to + rooms = gather_rooms(client, message) - # Attach listener (if present) and broadcast - if "listener" in message: + # Broadcast to all subscribed rooms + async for room in server.async_iterable(rooms): - # Remove originating client from broadcast - clients.remove(client) + # Prevent accessing rooms not joined + if room not in client.rooms: - # Define the message to broadcast - tmp_message = { - "cmd": "gmsg", - "val": message["val"] - } + # Attach listener + if "listener" in message: + send_statuscode( + client, + statuscodes.room_not_joined, + details=f'Attempted to access room {room} while not joined.', + listener=message["listener"] + ) + else: + send_statuscode( + client, + statuscodes.room_not_joined, + details=f'Attempted to access room {room} while not joined.' + ) - # Broadcast message - server.send_packet(clients, tmp_message) + # Stop gmsg command + return - # Define the message to send - tmp_message = { - "cmd": "gmsg", - "val": message["val"], - "listener": message["listener"] - } + clients = await server.rooms_manager.get_all_in_rooms(room, cl4_protocol) - # Unicast message - server.send_packet(client, tmp_message) - else: - # Define the message to broadcast - tmp_message = { - "cmd": "gmsg", - "val": message["val"] - } + # Attach listener (if present) and broadcast + if "listener" in message: - # Broadcast message - server.send_packet(clients, tmp_message) + # Remove originating client from broadcast + clients.remove(client) - @server.on_command(cmd="pmsg", schema=cl4_protocol) - async def on_pmsg(client, message): - # Require sending client to have set their username - if not client.username_set: - # Attach listener - if "listener" in message: - send_statuscode( - client, - statuscodes.id_required, - details="This command requires setting a username.", - listener=message["listener"] - ) - else: - send_statuscode( - client, - statuscodes.id_required, - details="This command requires setting a username." - ) + # Define the message to broadcast + tmp_message = { + "cmd": "gmsg", + "val": message["val"] + } - # End pmsg command handler - return + # Broadcast message + server.send_packet(clients, tmp_message) + + # Define the message to send + tmp_message = { + "cmd": "gmsg", + "val": message["val"], + "listener": message["listener"], + "room": room + } + + # Unicast message + server.send_packet(client, tmp_message) + else: + # Define the message to broadcast + tmp_message = { + "cmd": "gmsg", + "val": message["val"], + "room": room + } - tmp_client = None + # Broadcast message + server.send_packet(clients, tmp_message) + @server.on_command(cmd="pmsg", schema=cl4_protocol) + async def on_pmsg(client, message): # Validate schema if not valid(client, message, cl4_protocol.pmsg): return - # Find client - try: - tmp_client = server.clients_manager.find_obj(message['id']) + # Require sending client to have set their username + if not require_username_set(client, message): + return - # No objects found - except server.clients_manager.exceptions.NoResultsFound: + # Gather rooms + rooms = gather_rooms(client, message) + + # Search and send to all specified clients in rooms + any_results_found = False + async for room in server.async_iterable(rooms): + + # Prevent accessing rooms not joined + if room not in client.rooms: + + # Attach listener + if "listener" in message: + send_statuscode( + client, + statuscodes.room_not_joined, + details=f'Attempted to access room {room} while not joined.', + listener=message["listener"] + ) + else: + send_statuscode( + client, + statuscodes.room_not_joined, + details=f'Attempted to access room {room} while not joined.' + ) + + # Stop pmsg command + return + + clients = await server.rooms_manager.get_specific_in_room(room, cl4_protocol, message['id']) + + # Continue if no results are found + if not len(clients): + continue + + # Mark the full search OK + if not any_results_found: + any_results_found = True + + # Warn if multiple matches are found (mainly for username queries) + if self.warn_if_multiple_username_matches and len(clients) >> 1: + # Attach listener + if "listener" in message: + send_statuscode( + client, + statuscodes.id_not_specific, + details=f'Multiple matches found for {message["id"]}, found {len(clients)} matches. Please use Snowflakes, UUIDs, or client objects instead.', + listener=message["listener"] + ) + else: + send_statuscode( + client, + statuscodes.id_not_specific, + details=f'Multiple matches found for {message["id"]}, found {len(clients)} matches. Please use Snowflakes, UUIDs, or client objects instead.' + ) + + # Stop pmsg command + return + + # Send message + tmp_message = { + "cmd": "pmsg", + "val": message["val"], + "origin": { + "id": client.snowflake, + "username": client.username, + "uuid": str(client.id) + }, + "room": room + } + server.send_packet(clients, tmp_message) + if not any_results_found: # Attach listener if "listener" in message: send_statuscode( @@ -300,40 +417,7 @@ async def on_pmsg(client, message): # End pmsg command handler return - # Warn client if they are attempting to send to a username with multiple matches - if self.warn_if_multiple_username_matches: - if type(tmp_client) == set: - if len(tmp_client) >> 1: - # Attach listener - if "listener" in message: - send_statuscode( - client, - statuscodes.id_not_specific, - details=f'Multiple matches found for {message["id"]}, found {len(tmp_client)} matches. Please use Snowflakes, UUIDs, or client objects instead.', - listener=message["listener"] - ) - else: - send_statuscode( - client, - statuscodes.id_not_specific, - details=f'Multiple matches found for {message["id"]}, found {len(tmp_client)} matches. Please use Snowflakes, UUIDs, or client objects instead.' - ) - # End pmsg command handler - return - - # Broadcast message to client - tmp_message = { - "cmd": "pmsg", - "val": message["val"], - "origin": { - "id": client.snowflake, - "username": client.username, - "uuid": str(client.id) - } - } - server.send_packet(tmp_client, tmp_message) - - # Tell the origin client that the message sent successfully + # Results were found and sent successfully if "listener" in message: send_statuscode( client, @@ -353,60 +437,135 @@ async def on_gvar(client, message): if not valid(client, message, cl4_protocol.gvar): return - # Define the message to send - tmp_message = { - "cmd": "gvar", - "name": message["name"], - "val": message["val"] - } + # Gather rooms to send to + rooms = gather_rooms(client, message) - # Copy the current set of connected client objects - clients = server.copy(server.clients_manager.protocols[cl4_protocol]) + # Broadcast to all subscribed rooms + async for room in server.async_iterable(rooms): - # Attach listener (if present) and broadcast - if "listener" in message: - clients.remove(client) - server.send_packet(clients, tmp_message) - tmp_message["listener"] = message["listener"] - server.send_packet(client, tmp_message) - else: - server.send_packet(clients, tmp_message) + # Prevent accessing rooms not joined + if room not in client.rooms: - @server.on_command(cmd="pvar", schema=cl4_protocol) - async def on_pvar(client, message): - # Require sending client to have set their username - if not client.username_set: - # Attach listener - if "listener" in message: - send_statuscode( - client, - statuscodes.id_required, - details="This command requires setting a username.", - listener=message["listener"] - ) - else: - send_statuscode( - client, - statuscodes.id_required, - details="This command requires setting a username." - ) + # Attach listener + if "listener" in message: + send_statuscode( + client, + statuscodes.room_not_joined, + details=f'Attempted to access room {room} while not joined.', + listener=message["listener"] + ) + else: + send_statuscode( + client, + statuscodes.room_not_joined, + details=f'Attempted to access room {room} while not joined.' + ) - # End pmsg command handler - return + # Stop gvar command + return - tmp_client = None + clients = await server.rooms_manager.get_all_in_rooms(room, cl4_protocol) + # Define the message to send + tmp_message = { + "cmd": "gvar", + "name": message["name"], + "val": message["val"], + "room": room + } + + # Attach listener (if present) and broadcast + if "listener" in message: + clients.remove(client) + server.send_packet(clients, tmp_message) + tmp_message["listener"] = message["listener"] + server.send_packet(client, tmp_message) + else: + server.send_packet(clients, tmp_message) + + @server.on_command(cmd="pvar", schema=cl4_protocol) + async def on_pvar(client, message): # Validate schema if not valid(client, message, cl4_protocol.pvar): return - # Find client - try: - tmp_client = server.clients_manager.find_obj(message['id']) + # Require sending client to have set their username + if not require_username_set(client, message): + return - # No objects found - except server.clients_manager.exceptions.NoResultsFound: + # Gather rooms + rooms = gather_rooms(client, message) + + # Search and send to all specified clients in rooms + any_results_found = False + async for room in server.async_iterable(rooms): + + # Prevent accessing rooms not joined + if room not in client.rooms: + + # Attach listener + if "listener" in message: + send_statuscode( + client, + statuscodes.room_not_joined, + details=f'Attempted to access room {room} while not joined.', + listener=message["listener"] + ) + else: + send_statuscode( + client, + statuscodes.room_not_joined, + details=f'Attempted to access room {room} while not joined.' + ) + + # Stop pvar command + return + + clients = await server.rooms_manager.get_specific_in_room(room, cl4_protocol, message['id']) + + # Continue if no results are found + if not len(clients): + continue + + # Mark the full search OK + if not any_results_found: + any_results_found = True + + # Warn if multiple matches are found (mainly for username queries) + if self.warn_if_multiple_username_matches and len(clients) >> 1: + # Attach listener + if "listener" in message: + send_statuscode( + client, + statuscodes.id_not_specific, + details=f'Multiple matches found for {message["id"]}, found {len(clients)} matches. Please use Snowflakes, UUIDs, or client objects instead.', + listener=message["listener"] + ) + else: + send_statuscode( + client, + statuscodes.id_not_specific, + details=f'Multiple matches found for {message["id"]}, found {len(clients)} matches. Please use Snowflakes, UUIDs, or client objects instead.' + ) + + # Stop pvar command + return + + # Send message + tmp_message = { + "cmd": "pvar", + "name": message["name"], + "val": message["val"], + "origin": { + "id": client.snowflake, + "username": client.username, + "uuid": str(client.id) + }, + "room": room + } + server.send_packet(clients, tmp_message) + if not any_results_found: # Attach listener if "listener" in message: send_statuscode( @@ -422,23 +581,10 @@ async def on_pvar(client, message): details=f'No matches found: {message["id"]}' ) - # End pvar command handler + # End pmsg command handler return - # Broadcast message to client - tmp_message = { - "cmd": "pvar", - "name": message["name"], - "val": message["val"], - "origin": { - "id": client.snowflake, - "username": client.username, - "uuid": str(client.id) - } - } - server.send_packet(client, tmp_message) - - # Tell the origin client that the message sent successfully + # Results were found and sent successfully if "listener" in message: send_statuscode( client, @@ -466,7 +612,8 @@ async def on_setid(client, message): statuscodes.id_already_set, val={ "id": client.snowflake, - "username": client.username + "username": client.username, + "uuid": str(client.id) }, listener=message["listener"] ) @@ -476,16 +623,28 @@ async def on_setid(client, message): statuscodes.id_already_set, val={ "id": client.snowflake, - "username": client.username + "username": client.username, + "uuid": str(client.id) } ) # Exit setid command return + # Gather rooms + rooms = server.copy(client.rooms) + + # Leave all rooms + async for room in server.async_iterable(rooms): + server.rooms_manager.unsubscribe(client, room) + # Set the username server.clients_manager.set_username(client, message['val']) + # Re-join rooms + async for room in server.async_iterable(rooms): + server.rooms_manager.subscribe(client, room) + # Attach listener (if present) and broadcast if "listener" in message: send_statuscode( @@ -510,29 +669,101 @@ async def on_setid(client, message): @server.on_command(cmd="link", schema=cl4_protocol) async def on_link(client, message): - server.rooms_manager.subscribe(client, message["rooms"]) + # Validate schema + if not valid(client, message, cl4_protocol.linking): + return + + # Require sending client to have set their username + if not require_username_set(client, message): + return + + # Clear all rooms beforehand + async for room in server.async_iterable(client.rooms): + server.rooms_manager.unsubscribe(client, room) + + # Convert to set + if type(message["val"]) in [list, str]: + if type(message["val"]) == list: + message["val"] = set(message["val"]) + if type(message["val"]) == str: + message["val"] = {message["val"]} + + async for room in server.async_iterable(message["val"]): + server.rooms_manager.subscribe(client, room) @server.on_command(cmd="unlink", schema=cl4_protocol) async def on_unlink(client, message): - pass + # Validate schema + if not valid(client, message, cl4_protocol.linking): + return + + # Require sending client to have set their username + if not require_username_set(client, message): + return + + # Convert to set + if type(message["val"]) in [list, str]: + if type(message["val"]) == list: + message["val"] = set(message["val"]) + if type(message["val"]) == str: + message["val"] = {message["val"]} + + async for room in server.async_iterable(message["val"]): + server.rooms_manager.unsubscribe(client, room) + + # Re-link to default room if no rooms are joined + if not len(client.rooms): + server.rooms_manager.subscribe(client, "default") @server.on_command(cmd="direct", schema=cl4_protocol) async def on_direct(client, message): - pass + # Validate schema + if not valid(client, message, cl4_protocol.direct): + return - @server.on_command(cmd="bridge", schema=cl4_protocol) - async def on_bridge(client, message): - pass + try: + client = server.clients_manager.find_obj(message["id"]) - @server.on_command(cmd="echo", schema=cl4_protocol) - async def on_echo(client, message): - val = None - listener = None + tmp_msg = { + "cmd": "direct", + "val": message["val"] + } - if "val" in message: - val = message["val"] + if client.username_set: + tmp_msg["origin"] = { + "id": client.snowflake, + "username": client.username, + "uuid": str(client.id) + } - if "listener" in message: - listener = message["listener"] + else: + tmp_msg["origin"] = { + "id": client.snowflake, + "uuid": str(client.id) + } + + if "listener" in message: + tmp_msg["listener"] = message["listener"] + + server.send_packet_unicast(client, tmp_msg) + + except server.clients_manager.exceptions.NoResultsFound: + # Attach listener + if "listener" in message: + send_statuscode( + client, + statuscodes.id_not_found, + listener=message["listener"] + ) + else: + send_statuscode( + client, + statuscodes.id_not_found + ) + + # Stop direct command + return - send_statuscode(client, statuscodes.echo, val=val, listener=listener) + @server.on_command(cmd="bridge", schema=cl4_protocol) + async def on_bridge(client, message): + pass diff --git a/cloudlink/protocols/clpv4/schema.py b/cloudlink/protocols/clpv4/schema.py index 79dc45a..946a93e 100644 --- a/cloudlink/protocols/clpv4/schema.py +++ b/cloudlink/protocols/clpv4/schema.py @@ -30,7 +30,9 @@ class cl4_protocol: "id": { "type": [ "string", - "dict" + "dict", + "list", + "set" ], "required": False }, @@ -58,6 +60,36 @@ class cl4_protocol: } } + linking = { + "cmd": { + "type": "string", + "required": True + }, + "val": { + "type": [ + "string", + "integer", + "float", + "number", + "boolean", + "dict", + "list", + "set", + ], + "required": True, + }, + "listener": { + "type": [ + "string", + "integer", + "float", + "boolean", + "number" + ], + "required": False + } + } + setid = { "cmd": { "type": "string", @@ -175,7 +207,9 @@ class cl4_protocol: "id": { "type": [ "string", - "dict" + "dict", + "list", + "set" ], "required": True }, @@ -216,6 +250,40 @@ class cl4_protocol: } } + direct = { + "cmd": { + "type": "string", + "required": True + }, + "id": { + "type": "string", + "required": True + }, + "val": { + "type": [ + "string", + "integer", + "float", + "number", + "boolean", + "dict", + "list", + "set", + ], + "required": True + }, + "listener": { + "type": [ + "string", + "integer", + "float", + "boolean", + "number" + ], + "required": False + } + } + pvar = { "cmd": { "type": "string", @@ -228,7 +296,9 @@ class cl4_protocol: "id": { "type": [ "string", - "dict" + "dict", + "list", + "set" ], "required": True }, diff --git a/cloudlink/protocols/scratch/scratch.py b/cloudlink/protocols/scratch/scratch.py index 6606cb6..00c3bd6 100644 --- a/cloudlink/protocols/scratch/scratch.py +++ b/cloudlink/protocols/scratch/scratch.py @@ -97,7 +97,7 @@ async def create_variable(client, message): room_data["global_vars"][message['name']] = message["value"] # Broadcast the variable state - server.send_packet_multicast(room_data["clients"][scratch_protocol], { + server.send_packet_multicast(room_data["clients"][scratch_protocol]["all"], { "method": "create", "name": message['name'], "value": room_data["global_vars"][message['name']] @@ -137,7 +137,7 @@ async def rename_variable(client, message): room_data["global_vars"][message["new_name"]] = str() # Broadcast the variable state - server.send_packet_multicast(room_data["clients"][scratch_protocol], { + server.send_packet_multicast(room_data["clients"][scratch_protocol]["all"], { "method": "rename", "name": message['name'], "new_name": message['new_name'] @@ -170,7 +170,7 @@ async def create_variable(client, message): room_data["global_vars"].pop(message['name']) # Broadcast the variable state - server.send_packet_multicast(room_data["clients"][scratch_protocol], { + server.send_packet_multicast(room_data["clients"][scratch_protocol]["all"], { "method": "delete", "name": message['name'] }) @@ -208,7 +208,7 @@ async def set_value(client, message): room_data["global_vars"][message['name']] = message["value"] # Broadcast the variable state - server.send_packet_multicast(room_data["clients"][scratch_protocol], { + server.send_packet_multicast(room_data["clients"][scratch_protocol]["all"], { "method": "set", "name": message['name'], "value": room_data["global_vars"][message['name']] From 7dde1077346ddf58f030471a797a66eee642a9cb Mon Sep 17 00:00:00 2001 From: "Mike J. Renaker / \"MikeDEV" Date: Mon, 27 Mar 2023 13:21:32 -0400 Subject: [PATCH 23/59] More misc stuff --- cloudlink/modules/clients_manager.py | 7 +++++++ cloudlink/modules/rooms_manager.py | 17 +++++++++++++++++ cloudlink/protocols/clpv4/clpv4.py | 20 ++++++++++++++++++++ main.py | 15 +++++++++------ 4 files changed, 53 insertions(+), 6 deletions(-) diff --git a/cloudlink/modules/clients_manager.py b/cloudlink/modules/clients_manager.py index 2202985..9dcc5f5 100644 --- a/cloudlink/modules/clients_manager.py +++ b/cloudlink/modules/clients_manager.py @@ -108,6 +108,13 @@ def remove(self, obj): if not len(self.usernames[obj.username]): del self.usernames[obj.username] + def generate_user_object(self, obj): + return { + "id": obj.snowflake, + "username": obj.username, + "uuid": str(obj.id) + } + def set_username(self, obj, username): if not self.exists(obj): raise self.exceptions.ClientDoesNotExist diff --git a/cloudlink/modules/rooms_manager.py b/cloudlink/modules/rooms_manager.py index cb265f7..a021567 100644 --- a/cloudlink/modules/rooms_manager.py +++ b/cloudlink/modules/rooms_manager.py @@ -204,6 +204,23 @@ def find_obj(self, query, room): else: raise self.exceptions.NoResultsFound + def generate_userlist(self, room_id, protocol): + userlist = set() + + room = self.get(room_id)[protocol]["all"] + + for obj in room: + if not obj.username_set: + continue + + userlist.add({ + "id": obj.snowflake, + "username": obj.username, + "uuid": str(obj.id) + }) + + return list(userlist) + def get_snowflakes(self, room): return set(obj for obj in room["snowflakes"]) diff --git a/cloudlink/protocols/clpv4/clpv4.py b/cloudlink/protocols/clpv4/clpv4.py index 7d4eaeb..ff578a6 100644 --- a/cloudlink/protocols/clpv4/clpv4.py +++ b/cloudlink/protocols/clpv4/clpv4.py @@ -233,6 +233,16 @@ async def on_handshake(client, message): "val": client.snowflake }) + # Send userlists of any rooms + async for room in server.async_iterable(client.rooms): + server.send_packet(client, { + "cmd": "ulist", + "val": { + "mode": "set", + "val": server.rooms_manager.generate_userlist(room, cl4_protocol) + } + }) + # Attach listener if "listener" in message: send_statuscode(client, statuscodes.ok, listener=message["listener"]) @@ -645,6 +655,16 @@ async def on_setid(client, message): async for room in server.async_iterable(rooms): server.rooms_manager.subscribe(client, room) + # Broadcast userlist state + clients = await server.rooms_manager.get_all_in_rooms(room, cl4_protocol) + server.send_packet(clients, { + "cmd": "ulist", + "val": { + "mode": "add", + "val": server.clients_manager.generate_user_object(client) + } + }) + # Attach listener (if present) and broadcast if "listener" in message: send_statuscode( diff --git a/main.py b/main.py index 4807f3f..981c317 100644 --- a/main.py +++ b/main.py @@ -1,5 +1,6 @@ from cloudlink import server from cloudlink.protocols import clpv4, scratch +from cloudlink.plugins import cl_admin if __name__ == "__main__": # Initialize the server @@ -7,16 +8,18 @@ # Configure logging settings server.logging.basicConfig( - format="[%(asctime)s] %(levelname)s: %(message)s", - level=server.logging.INFO + level=server.logging.DEBUG ) # Load protocols clpv4 = clpv4(server) scratch = scratch(server) - - # Init SSL - server.enable_ssl(certfile = "cert.pem", keyfile = "privkey.pem") + + # Load plugins + cl_admin = cl_admin(server, clpv4) + + # Initialize SSL support + # server.enable_ssl(certfile="cert.pem", keyfile="privkey.pem") # Start the server - server.run(ip="0.0.0.0", port=3000) + server.run(ip="127.0.0.1", port=3000) From 613091bd7d5be3e46128df58793040d949ca8ed7 Mon Sep 17 00:00:00 2001 From: "Mike J. Renaker / \"MikeDEV" Date: Mon, 27 Mar 2023 13:54:04 -0400 Subject: [PATCH 24/59] Bugfix + Restructure ^ --- cloudlink/__init__.py | 2 +- cloudlink/modules/rooms_manager.py | 2 +- cloudlink/{server.py => server/__init__.py} | 103 +++++++++--------- cloudlink/{ => server}/plugins/__init__.py | 0 cloudlink/{ => server}/plugins/cl_admin.py | 0 cloudlink/{ => server}/protocols/__init__.py | 0 .../{ => server}/protocols/clpv4/__init__.py | 0 .../{ => server}/protocols/clpv4/clpv4.py | 0 .../{ => server}/protocols/clpv4/schema.py | 0 .../protocols/scratch/__init__.py | 0 .../{ => server}/protocols/scratch/schema.py | 0 .../{ => server}/protocols/scratch/scratch.py | 0 main.py | 4 +- 13 files changed, 58 insertions(+), 53 deletions(-) rename cloudlink/{server.py => server/__init__.py} (93%) rename cloudlink/{ => server}/plugins/__init__.py (100%) rename cloudlink/{ => server}/plugins/cl_admin.py (100%) rename cloudlink/{ => server}/protocols/__init__.py (100%) rename cloudlink/{ => server}/protocols/clpv4/__init__.py (100%) rename cloudlink/{ => server}/protocols/clpv4/clpv4.py (100%) rename cloudlink/{ => server}/protocols/clpv4/schema.py (100%) rename cloudlink/{ => server}/protocols/scratch/__init__.py (100%) rename cloudlink/{ => server}/protocols/scratch/schema.py (100%) rename cloudlink/{ => server}/protocols/scratch/scratch.py (100%) diff --git a/cloudlink/__init__.py b/cloudlink/__init__.py index 8d730e9..bf0c964 100644 --- a/cloudlink/__init__.py +++ b/cloudlink/__init__.py @@ -1 +1 @@ -from .server import server +from cloudlink.server import server diff --git a/cloudlink/modules/rooms_manager.py b/cloudlink/modules/rooms_manager.py index a021567..28e49b7 100644 --- a/cloudlink/modules/rooms_manager.py +++ b/cloudlink/modules/rooms_manager.py @@ -207,7 +207,7 @@ def find_obj(self, query, room): def generate_userlist(self, room_id, protocol): userlist = set() - room = self.get(room_id)[protocol]["all"] + room = self.get(room_id)["clients"][protocol]["all"] for obj in room: if not obj.username_set: diff --git a/cloudlink/server.py b/cloudlink/server/__init__.py similarity index 93% rename from cloudlink/server.py rename to cloudlink/server/__init__.py index fc8f90c..507c73d 100644 --- a/cloudlink/server.py +++ b/cloudlink/server/__init__.py @@ -1,4 +1,4 @@ -# Core components of the CloudLink engine +# Core components of the CloudLink server import asyncio import cerberus import logging @@ -6,7 +6,7 @@ from copy import copy from snowflake import SnowflakeGenerator -# Import websocket engine and SSL support +# Import websockets and SSL support import websockets import ssl @@ -20,7 +20,7 @@ try: import ujson except ImportError: - print("Failed to import UltraJSON, failing back to native JSON library.") + print("Server failed to import UltraJSON, failing back to native JSON library.") import json as ujson @@ -92,15 +92,15 @@ def __init__(self): self.command_handlers = dict() # Configure framework logging - self.supress_websocket_logs = True - + self.suppress_websocket_logs = True + # Set to -1 to allow as many client as possible self.max_clients = -1 - + # Configure SSL support self.ssl_enabled = False self.ssl_context = None - + # Enables SSL support def enable_ssl(self, certfile, keyfile): try: @@ -110,7 +110,7 @@ def enable_ssl(self, certfile, keyfile): self.logger.info(f"SSL support initialized!") except Exception as e: self.logger.error(f"Failed to initialize SSL support! {e}") - + # Runs the server. def run(self, ip="127.0.0.1", port=3000): try: @@ -124,17 +124,17 @@ def run(self, ip="127.0.0.1", port=3000): raise ValueError( "The max_clients value must be a integer value set to -1 (unlimited clients) or greater than zero!" ) - + # Startup message self.logger.info(f"CloudLink {self.version} - Now listening to {ip}:{port}") - - # Supress websocket library logging - if self.supress_websocket_logs: + + # Suppress websocket library logging + if self.suppress_websocket_logs: self.logging.getLogger('asyncio').setLevel(self.logging.ERROR) self.logging.getLogger('asyncio.coroutines').setLevel(self.logging.ERROR) self.logging.getLogger('websockets.server').setLevel(self.logging.ERROR) self.logging.getLogger('websockets.protocol').setLevel(self.logging.ERROR) - + # Start server self.asyncio.run(self.__run__(ip, port)) @@ -182,7 +182,6 @@ def bind_event(func): # Event binder for invalid command events with specific shemas/exception types def on_disabled_command(self, schema): def bind_event(func): - # Create disabled command event manager if schema not in self.disabled_commands_handlers: self.logger.info(f"Creating disabled command event manager {schema.__qualname__}") @@ -196,7 +195,6 @@ def bind_event(func): def on_protocol_identified(self, schema): def bind_event(func): - # Create protocol identified event manager if schema not in self.protocol_identified_events: self.logger.info(f"Creating protocol identified event manager {schema.__qualname__}") @@ -256,7 +254,8 @@ def disable_command(self, cmd, schema): # Check if the command isn't already disabled if cmd in self.disabled_commands[schema]: - raise ValueError(f"The command {cmd} is already disabled in protocol {schema.__qualname__}, or was enabled beforehand.") + raise ValueError( + f"The command {cmd} is already disabled in protocol {schema.__qualname__}, or was enabled beforehand.") # Disable the command self.disabled_commands[schema].add(cmd) @@ -270,7 +269,8 @@ def enable_command(self, cmd, schema): # Check if the command is disabled if cmd not in self.disabled_commands[schema]: - raise ValueError(f"The command {cmd} is already enabled in protocol {schema.__qualname__}, or wasn't disabled beforehand.") + raise ValueError( + f"The command {cmd} is already enabled in protocol {schema.__qualname__}, or wasn't disabled beforehand.") # Enable the command self.disabled_commands[schema].remove(cmd) @@ -282,11 +282,11 @@ def enable_command(self, cmd, schema): # Message processor async def message_processor(self, client, message): - + # Empty packet if not len(message): self.logger.debug(f"Client {client.snowflake} sent empty message ") - + # Fire on_error events asyncio.create_task(self.execute_on_error_events(client, self.exceptions.EmptyMessage)) @@ -307,14 +307,14 @@ async def message_processor(self, client, message): # End message_processor coroutine return - + # Parse JSON in message and convert to dict try: message = self.ujson.loads(message) - + except Exception as error: self.logger.debug(f"Client {client.snowflake} sent invalid JSON: {error}") - + # Fire on_error events self.asyncio.create_task(self.execute_on_error_events(client, error)) @@ -344,10 +344,10 @@ async def message_processor(self, client, message): # Client protocol is unknown if not client.protocol: self.logger.debug(f"Trying to identify client {client.snowflake}'s protocol") - + # Identify protocol errorlist = list() - + for schema in self.command_handlers: if self.validator(message, schema.default): valid = True @@ -380,7 +380,8 @@ async def message_processor(self, client, message): self.asyncio.create_task(self.execute_protocol_identified_events(client, selected_protocol)) else: - self.logger.debug(f"Validating message from {client.snowflake} using protocol {client.protocol.__qualname__}") + self.logger.debug( + f"Validating message from {client.snowflake} using protocol {client.protocol.__qualname__}") # Validate message using known protocol selected_protocol = client.protocol @@ -412,7 +413,8 @@ async def message_processor(self, client, message): if message[selected_protocol.command_key] not in self.command_handlers[selected_protocol]: # Log invalid command - self.logger.debug(f"Client {client.snowflake} sent an invalid command \"{message[selected_protocol.command_key]}\" in protocol {selected_protocol.__qualname__}") + self.logger.debug( + f"Client {client.snowflake} sent an invalid command \"{message[selected_protocol.command_key]}\" in protocol {selected_protocol.__qualname__}") # Fire on_error events self.asyncio.create_task(self.execute_on_error_events(client, "Invalid command")) @@ -434,7 +436,8 @@ async def message_processor(self, client, message): # Check if the command is disabled if selected_protocol in self.disabled_commands: if message[selected_protocol.command_key] in self.disabled_commands[selected_protocol]: - self.logger.debug(f"Client {client.snowflake} sent a disabled command \"{message[selected_protocol.command_key]}\" in protocol {selected_protocol.__qualname__}") + self.logger.debug( + f"Client {client.snowflake} sent a disabled command \"{message[selected_protocol.command_key]}\" in protocol {selected_protocol.__qualname__}") # Fire disabled command event self.asyncio.create_task( @@ -467,7 +470,7 @@ async def message_processor(self, client, message): # Connection handler async def connection_handler(self, client): - + # Limit the amount of clients connected if self.max_clients != -1: if len(self.clients_manager) >= self.max_clients: @@ -475,7 +478,7 @@ async def connection_handler(self, client): self.send_packet(client, "Server is full!") self.close_connection(client, reason="Server is full!") return - + # Startup client attributes client.snowflake = str(next(self.gen)) client.protocol = None @@ -492,12 +495,12 @@ async def connection_handler(self, client): # Fire on_connect events self.asyncio.create_task(self.execute_on_connect_events(client)) - + self.logger.debug(f"Client {client.snowflake} connected") - + # Run connection loop await self.connection_loop(client) - + # Remove from clients manager self.clients_manager.remove(client) @@ -508,8 +511,9 @@ async def connection_handler(self, client): async for room_id in self.async_iterable(self.copy(client.rooms)): self.rooms_manager.unsubscribe(client, room_id) - self.logger.debug(f"Client {client.snowflake} disconnected: Total lifespan of {time.monotonic() - client.birth_time} seconds.") - + self.logger.debug( + f"Client {client.snowflake} disconnected: Total lifespan of {time.monotonic() - client.birth_time} seconds.") + # Connection loop - Redefine for use with another outside library async def connection_loop(self, client): # Primary asyncio loop for the lifespan of the websocket connection @@ -523,7 +527,8 @@ async def connection_loop(self, client): await self.message_processor(client, message) # Log processing time - self.logger.debug(f"Done processing message from client {client.snowflake}. Processing took {time.perf_counter() - start} seconds.") + self.logger.debug( + f"Done processing message from client {client.snowflake}. Processing took {time.perf_counter() - start} seconds.") # Handle unexpected disconnects except self.ws.exceptions.ConnectionClosedError: @@ -550,7 +555,7 @@ async def connection_loop(self, client): details=f"Unexpected exception was raised: {e}" ) ) - + # WebSocket-specific server loop async def __run__(self, ip, port): if self.ssl_enabled: @@ -563,7 +568,7 @@ async def __run__(self, ip, port): await self.asyncio.Future() # Asyncio event-handling coroutines - + async def execute_on_disconnect_events(self, client): async for event in self.on_disconnect_events: await event(client) @@ -583,14 +588,14 @@ async def execute_on_command_events(self, client, message, schema): async def execute_on_error_events(self, client, errors): async for event in self.on_error_events: await event(client, errors) - + async def execute_exception_handlers(self, client, exception_type, schema, details): # Guard clauses if schema not in self.exception_handlers: return if exception_type not in self.exception_handlers[schema]: return - + # Fire events async for event in self.exception_handlers[schema][exception_type]: await event(client, details) @@ -612,19 +617,19 @@ async def execute_protocol_identified_events(self, client, schema): # Fire events async for event in self.protocol_identified_events[schema]: await event(client) - + # WebSocket-specific coroutines - + async def execute_unicast(self, client, message): - + # Guard clause if type(message) not in [dict, str]: raise TypeError(f"Supported datatypes for messages are dicts and strings, got type {type(message)}.") - + # Convert dict to JSON if type(message) == dict: message = self.ujson.dumps(message) - + # Attempt to send the packet try: await client.send(message) @@ -632,17 +637,17 @@ async def execute_unicast(self, client, message): self.logger.critical( f"Unexpected exception was raised while unicasting message to client {client.snowflake}: {e}" ) - + async def execute_multicast(self, clients, message): - + # Guard clause if type(message) not in [dict, str]: raise TypeError(f"Supported datatypes for messages are dicts and strings, got type {type(message)}.") - + # Convert dict to JSON if type(message) == dict: message = self.ujson.dumps(message) - + # Attempt to broadcast the packet async for client in self.async_iterable(clients): try: @@ -651,7 +656,7 @@ async def execute_multicast(self, clients, message): self.logger.critical( f"Unexpected exception was raised while multicasting message to client {client.snowflake}: {e}" ) - + async def execute_close_single(self, client, code=1000, reason=""): try: await client.close(code, reason) diff --git a/cloudlink/plugins/__init__.py b/cloudlink/server/plugins/__init__.py similarity index 100% rename from cloudlink/plugins/__init__.py rename to cloudlink/server/plugins/__init__.py diff --git a/cloudlink/plugins/cl_admin.py b/cloudlink/server/plugins/cl_admin.py similarity index 100% rename from cloudlink/plugins/cl_admin.py rename to cloudlink/server/plugins/cl_admin.py diff --git a/cloudlink/protocols/__init__.py b/cloudlink/server/protocols/__init__.py similarity index 100% rename from cloudlink/protocols/__init__.py rename to cloudlink/server/protocols/__init__.py diff --git a/cloudlink/protocols/clpv4/__init__.py b/cloudlink/server/protocols/clpv4/__init__.py similarity index 100% rename from cloudlink/protocols/clpv4/__init__.py rename to cloudlink/server/protocols/clpv4/__init__.py diff --git a/cloudlink/protocols/clpv4/clpv4.py b/cloudlink/server/protocols/clpv4/clpv4.py similarity index 100% rename from cloudlink/protocols/clpv4/clpv4.py rename to cloudlink/server/protocols/clpv4/clpv4.py diff --git a/cloudlink/protocols/clpv4/schema.py b/cloudlink/server/protocols/clpv4/schema.py similarity index 100% rename from cloudlink/protocols/clpv4/schema.py rename to cloudlink/server/protocols/clpv4/schema.py diff --git a/cloudlink/protocols/scratch/__init__.py b/cloudlink/server/protocols/scratch/__init__.py similarity index 100% rename from cloudlink/protocols/scratch/__init__.py rename to cloudlink/server/protocols/scratch/__init__.py diff --git a/cloudlink/protocols/scratch/schema.py b/cloudlink/server/protocols/scratch/schema.py similarity index 100% rename from cloudlink/protocols/scratch/schema.py rename to cloudlink/server/protocols/scratch/schema.py diff --git a/cloudlink/protocols/scratch/scratch.py b/cloudlink/server/protocols/scratch/scratch.py similarity index 100% rename from cloudlink/protocols/scratch/scratch.py rename to cloudlink/server/protocols/scratch/scratch.py diff --git a/main.py b/main.py index 981c317..17f3616 100644 --- a/main.py +++ b/main.py @@ -1,6 +1,6 @@ from cloudlink import server -from cloudlink.protocols import clpv4, scratch -from cloudlink.plugins import cl_admin +from cloudlink.server.plugins import cl_admin +from cloudlink.server.protocols import clpv4, scratch if __name__ == "__main__": # Initialize the server From 2ca892cf89b3a71a4aaafc3e074822ee75ae0099 Mon Sep 17 00:00:00 2001 From: "Mike J. Renaker / \"MikeDEV" Date: Mon, 27 Mar 2023 15:22:23 -0400 Subject: [PATCH 25/59] It be done * Finished CLPv4 support * Final restructure of code * Misc patches to Scratch protocol * Added new event manager - on_protocol_disconnect Just need to do some code cleanup/optimization and the server will be done --- cloudlink/server/__init__.py | 44 ++- cloudlink/server/modules/__init__.py | 2 + .../{ => server}/modules/clients_manager.py | 7 - .../{ => server}/modules/rooms_manager.py | 11 +- cloudlink/server/protocols/clpv4/clpv4.py | 318 +++++++++++++----- cloudlink/server/protocols/scratch/scratch.py | 29 ++ .../{modules => shared_modules}/__init__.py | 2 - .../async_event_manager.py | 0 .../async_iterables.py | 0 9 files changed, 303 insertions(+), 110 deletions(-) create mode 100644 cloudlink/server/modules/__init__.py rename cloudlink/{ => server}/modules/clients_manager.py (96%) rename cloudlink/{ => server}/modules/rooms_manager.py (97%) rename cloudlink/{modules => shared_modules}/__init__.py (61%) rename cloudlink/{modules => shared_modules}/async_event_manager.py (100%) rename cloudlink/{modules => shared_modules}/async_iterables.py (100%) diff --git a/cloudlink/server/__init__.py b/cloudlink/server/__init__.py index 507c73d..3127055 100644 --- a/cloudlink/server/__init__.py +++ b/cloudlink/server/__init__.py @@ -10,11 +10,13 @@ import websockets import ssl -# Import CloudLink modules -from cloudlink.modules.async_event_manager import async_event_manager -from cloudlink.modules.async_iterables import async_iterable -from cloudlink.modules.clients_manager import clients_manager -from cloudlink.modules.rooms_manager import rooms_manager +# Import shared modules +from cloudlink.shared_modules.async_event_manager import async_event_manager +from cloudlink.shared_modules.async_iterables import async_iterable + +# Import server-specific modules +from cloudlink.server.modules.clients_manager import clients_manager +from cloudlink.server.modules.rooms_manager import rooms_manager # Import JSON library - Prefer UltraJSON but use native JSON if failed try: @@ -87,6 +89,7 @@ def __init__(self): self.exception_handlers = dict() self.disabled_commands_handlers = dict() self.protocol_identified_events = dict() + self.protocol_disconnect_events = dict() # Create method handlers self.command_handlers = dict() @@ -206,6 +209,19 @@ def bind_event(func): # End on_protocol_identified binder return bind_event + def on_protocol_disconnect(self, schema): + def bind_event(func): + # Create protocol disconnect event manager + if schema not in self.protocol_disconnect_events: + self.logger.info(f"Creating protocol disconnect event manager {schema.__qualname__}") + self.protocol_disconnect_events[schema] = async_event_manager(self) + + # Add function to the protocol disconnect event manager + self.protocol_disconnect_events[schema].bind(func) + + # End on_protocol_disconnect binder + return bind_event + # Event binder for on_message events def on_message(self, func): self.on_message_events.bind(func) @@ -486,6 +502,7 @@ async def connection_handler(self, client): client.rooms = set() client.username_set = False client.username = str() + client.handshake = False # Begin tracking the lifetime of the client client.birth_time = time.monotonic() @@ -507,9 +524,11 @@ async def connection_handler(self, client): # Fire on_disconnect events self.asyncio.create_task(self.execute_on_disconnect_events(client)) - # Unsubscribe from all rooms - async for room_id in self.async_iterable(self.copy(client.rooms)): - self.rooms_manager.unsubscribe(client, room_id) + # Execute all protocol-specific disconnect events + if client.protocol_set: + self.asyncio.create_task( + self.execute_protocol_disconnect_events(client, client.protocol) + ) self.logger.debug( f"Client {client.snowflake} disconnected: Total lifespan of {time.monotonic() - client.birth_time} seconds.") @@ -618,6 +637,15 @@ async def execute_protocol_identified_events(self, client, schema): async for event in self.protocol_identified_events[schema]: await event(client) + async def execute_protocol_disconnect_events(self, client, schema): + # Guard clauses + if schema not in self.protocol_disconnect_events: + return + + # Fire events + async for event in self.protocol_disconnect_events[schema]: + await event(client) + # WebSocket-specific coroutines async def execute_unicast(self, client, message): diff --git a/cloudlink/server/modules/__init__.py b/cloudlink/server/modules/__init__.py new file mode 100644 index 0000000..81421e8 --- /dev/null +++ b/cloudlink/server/modules/__init__.py @@ -0,0 +1,2 @@ +from .clients_manager import * +from .rooms_manager import * diff --git a/cloudlink/modules/clients_manager.py b/cloudlink/server/modules/clients_manager.py similarity index 96% rename from cloudlink/modules/clients_manager.py rename to cloudlink/server/modules/clients_manager.py index 9dcc5f5..2202985 100644 --- a/cloudlink/modules/clients_manager.py +++ b/cloudlink/server/modules/clients_manager.py @@ -108,13 +108,6 @@ def remove(self, obj): if not len(self.usernames[obj.username]): del self.usernames[obj.username] - def generate_user_object(self, obj): - return { - "id": obj.snowflake, - "username": obj.username, - "uuid": str(obj.id) - } - def set_username(self, obj, username): if not self.exists(obj): raise self.exceptions.ClientDoesNotExist diff --git a/cloudlink/modules/rooms_manager.py b/cloudlink/server/modules/rooms_manager.py similarity index 97% rename from cloudlink/modules/rooms_manager.py rename to cloudlink/server/modules/rooms_manager.py index 28e49b7..9bdb7ab 100644 --- a/cloudlink/modules/rooms_manager.py +++ b/cloudlink/server/modules/rooms_manager.py @@ -67,6 +67,9 @@ def create(self, room_id): "private_vars": dict() } + # Log creation + self.parent.logger.debug(f"Created room {room_id}") + def delete(self, room_id): # Rooms may only have string names if type(room_id) != str: @@ -166,7 +169,7 @@ def unsubscribe(self, obj, room_id): room = self.rooms[room_id]["clients"] # Clean up room protocol categories - if not len(room[obj.protocol]): + if not len(room[obj.protocol]["all"]): room.pop(obj.protocol) # Remove room from client object @@ -205,7 +208,7 @@ def find_obj(self, query, room): raise self.exceptions.NoResultsFound def generate_userlist(self, room_id, protocol): - userlist = set() + userlist = list() room = self.get(room_id)["clients"][protocol]["all"] @@ -213,13 +216,13 @@ def generate_userlist(self, room_id, protocol): if not obj.username_set: continue - userlist.add({ + userlist.append({ "id": obj.snowflake, "username": obj.username, "uuid": str(obj.id) }) - return list(userlist) + return userlist def get_snowflakes(self, room): return set(obj for obj in room["snowflakes"]) diff --git a/cloudlink/server/protocols/clpv4/clpv4.py b/cloudlink/server/protocols/clpv4/clpv4.py index ff578a6..2153195 100644 --- a/cloudlink/server/protocols/clpv4/clpv4.py +++ b/cloudlink/server/protocols/clpv4/clpv4.py @@ -160,14 +160,81 @@ def gather_rooms(client, message): # Expose rooms gatherer for extension usage self.gather_rooms = gather_rooms + # Generate a user object + def generate_user_object(obj): + # Username set + if obj.username_set: + return { + "id": obj.snowflake, + "username": obj.username, + "uuid": str(obj.id) + } + + # Username not set + return { + "id": obj.snowflake, + "uuid": str(obj.id) + } + + # Expose username object generator function for extension usage + self.generate_user_object = generate_user_object + + # If the client has not explicitly used the handshake command, send them the handshake data + async def automatic_notify_handshake(client): + # Don't execute this if handshake was already done + if client.handshake: + return + client.handshake = True + + # Send client IP address + server.send_packet(client, { + "cmd": "client_ip", + "val": get_client_ip(client) + }) + + # Send server version + server.send_packet(client, { + "cmd": "server_version", + "val": server.version + }) + + # Send Message-Of-The-Day + if self.enable_motd: + server.send_packet(client, { + "cmd": "motd", + "val": self.motd_message + }) + + # Send client's Snowflake ID + server.send_packet(client, { + "cmd": "client_obj", + "val": generate_user_object(client) + }) + + # Send userlists of rooms + async for room in server.async_iterable(client.rooms): + server.send_packet(client, { + "cmd": "ulist", + "val": { + "mode": "set", + "val": server.rooms_manager.generate_userlist(room, cl4_protocol) + }, + "room": room + }) + + # Expose for extension usage + self.automatic_notify_handshake = automatic_notify_handshake + # Exception handlers @server.on_exception(exception_type=server.exceptions.ValidationError, schema=cl4_protocol) async def validation_failure(client, details): + await automatic_notify_handshake(client) send_statuscode(client, statuscodes.syntax, details=dict(details)) @server.on_exception(exception_type=server.exceptions.InvalidCommand, schema=cl4_protocol) async def invalid_command(client, details): + await automatic_notify_handshake(client) send_statuscode( client, statuscodes.invalid_command, @@ -176,6 +243,7 @@ async def invalid_command(client, details): @server.on_disabled_command(schema=cl4_protocol) async def disabled_command(client, details): + await automatic_notify_handshake(client) send_statuscode( client, statuscodes.disabled_command, @@ -184,6 +252,7 @@ async def disabled_command(client, details): @server.on_exception(exception_type=server.exceptions.JSONError, schema=cl4_protocol) async def json_exception(client, details): + await automatic_notify_handshake(client) send_statuscode( client, statuscodes.json_error, @@ -191,7 +260,8 @@ async def json_exception(client, details): ) @server.on_exception(exception_type=server.exceptions.EmptyMessage, schema=cl4_protocol) - async def empty_message(client, details): + async def empty_message(client): + await automatic_notify_handshake(client) send_statuscode( client, statuscodes.empty_packet, @@ -204,45 +274,36 @@ async def protocol_identified(client): server.logger.debug(f"Adding client {client.snowflake} to default room.") server.rooms_manager.subscribe(client, "default") - # The CLPv4 command set - - @server.on_command(cmd="handshake", schema=cl4_protocol) - async def on_handshake(client, message): - # Send client IP address - server.send_packet(client, { - "cmd": "client_ip", - "val": get_client_ip(client) - }) - - # Send server version - server.send_packet(client, { - "cmd": "server_version", - "val": server.version - }) + @server.on_protocol_disconnect(schema=cl4_protocol) + async def protocol_disconnect(client): + server.logger.debug(f"Removing client {client.snowflake} from rooms...") - # Send Message-Of-The-Day - if self.enable_motd: - server.send_packet(client, { - "cmd": "motd", - "val": self.motd_message - }) + # Unsubscribe from all rooms + async for room_id in server.async_iterable(server.copy(client.rooms)): + server.rooms_manager.unsubscribe(client, room_id) - # Send client's Snowflake ID - server.send_packet(client, { - "cmd": "client_id", - "val": client.snowflake - }) + # Don't bother with notifying if client username wasn't set + if not client.username_set: + continue - # Send userlists of any rooms - async for room in server.async_iterable(client.rooms): - server.send_packet(client, { + # Notify rooms of removed client + clients = await server.rooms_manager.get_all_in_rooms(room_id, cl4_protocol) + clients = server.copy(clients) + server.send_packet(clients, { "cmd": "ulist", "val": { - "mode": "set", - "val": server.rooms_manager.generate_userlist(room, cl4_protocol) - } + "mode": "remove", + "val": generate_user_object(client) + }, + "room": room_id }) + # The CLPv4 command set + + @server.on_command(cmd="handshake", schema=cl4_protocol) + async def on_handshake(client, message): + await automatic_notify_handshake(client) + # Attach listener if "listener" in message: send_statuscode(client, statuscodes.ok, listener=message["listener"]) @@ -251,6 +312,8 @@ async def on_handshake(client, message): @server.on_command(cmd="ping", schema=cl4_protocol) async def on_ping(client, message): + await automatic_notify_handshake(client) + if "listener" in message: send_statuscode(client, statuscodes.ok, listener=message["listener"]) else: @@ -258,6 +321,8 @@ async def on_ping(client, message): @server.on_command(cmd="gmsg", schema=cl4_protocol) async def on_gmsg(client, message): + await automatic_notify_handshake(client) + # Validate schema if not valid(client, message, cl4_protocol.gmsg): return @@ -329,6 +394,8 @@ async def on_gmsg(client, message): @server.on_command(cmd="pmsg", schema=cl4_protocol) async def on_pmsg(client, message): + await automatic_notify_handshake(client) + # Validate schema if not valid(client, message, cl4_protocol.pmsg): return @@ -399,11 +466,7 @@ async def on_pmsg(client, message): tmp_message = { "cmd": "pmsg", "val": message["val"], - "origin": { - "id": client.snowflake, - "username": client.username, - "uuid": str(client.id) - }, + "origin": generate_user_object(client), "room": room } server.send_packet(clients, tmp_message) @@ -442,6 +505,7 @@ async def on_pmsg(client, message): @server.on_command(cmd="gvar", schema=cl4_protocol) async def on_gvar(client, message): + await automatic_notify_handshake(client) # Validate schema if not valid(client, message, cl4_protocol.gvar): @@ -495,6 +559,8 @@ async def on_gvar(client, message): @server.on_command(cmd="pvar", schema=cl4_protocol) async def on_pvar(client, message): + await automatic_notify_handshake(client) + # Validate schema if not valid(client, message, cl4_protocol.pvar): return @@ -566,11 +632,7 @@ async def on_pvar(client, message): "cmd": "pvar", "name": message["name"], "val": message["val"], - "origin": { - "id": client.snowflake, - "username": client.username, - "uuid": str(client.id) - }, + "origin": generate_user_object(client), "room": room } server.send_packet(clients, tmp_message) @@ -609,6 +671,8 @@ async def on_pvar(client, message): @server.on_command(cmd="setid", schema=cl4_protocol) async def on_setid(client, message): + await automatic_notify_handshake(client) + # Validate schema if not valid(client, message, cl4_protocol.setid): return @@ -620,22 +684,14 @@ async def on_setid(client, message): send_statuscode( client, statuscodes.id_already_set, - val={ - "id": client.snowflake, - "username": client.username, - "uuid": str(client.id) - }, + val=generate_user_object(client), listener=message["listener"] ) else: send_statuscode( client, statuscodes.id_already_set, - val={ - "id": client.snowflake, - "username": client.username, - "uuid": str(client.id) - } + val=generate_user_object(client) ) # Exit setid command @@ -644,51 +700,56 @@ async def on_setid(client, message): # Gather rooms rooms = server.copy(client.rooms) - # Leave all rooms - async for room in server.async_iterable(rooms): - server.rooms_manager.unsubscribe(client, room) + # Leave default room + server.rooms_manager.unsubscribe(client, "default") # Set the username server.clients_manager.set_username(client, message['val']) - # Re-join rooms - async for room in server.async_iterable(rooms): - server.rooms_manager.subscribe(client, room) + # Re-join default room + server.rooms_manager.subscribe(client, "default") - # Broadcast userlist state - clients = await server.rooms_manager.get_all_in_rooms(room, cl4_protocol) - server.send_packet(clients, { - "cmd": "ulist", - "val": { - "mode": "add", - "val": server.clients_manager.generate_user_object(client) - } - }) + # Broadcast userlist state to existing members + clients = await server.rooms_manager.get_all_in_rooms("default", cl4_protocol) + clients = server.copy(clients) + clients.remove(client) + server.send_packet(clients, { + "cmd": "ulist", + "val": { + "mode": "add", + "val": generate_user_object(client) + }, + "room": "default" + }) + + # Notify client of current room state + server.send_packet(client, { + "cmd": "ulist", + "val": { + "mode": "set", + "val": server.rooms_manager.generate_userlist("default", cl4_protocol) + }, + "room": "default" + }) # Attach listener (if present) and broadcast if "listener" in message: send_statuscode( client, statuscodes.ok, - val={ - "id": client.snowflake, - "username": client.username, - "uuid": str(client.id) - }, + val=generate_user_object(client), listener=message["listener"]) else: send_statuscode( client, statuscodes.ok, - val={ - "id": client.snowflake, - "username": client.username, - "uuid": str(client.id) - }, + val=generate_user_object(client), ) @server.on_command(cmd="link", schema=cl4_protocol) async def on_link(client, message): + await automatic_notify_handshake(client) + # Validate schema if not valid(client, message, cl4_protocol.linking): return @@ -711,8 +772,45 @@ async def on_link(client, message): async for room in server.async_iterable(message["val"]): server.rooms_manager.subscribe(client, room) + # Broadcast userlist state to existing members + clients = await server.rooms_manager.get_all_in_rooms(room, cl4_protocol) + clients = server.copy(clients) + clients.remove(client) + server.send_packet(clients, { + "cmd": "ulist", + "val": { + "mode": "add", + "val": generate_user_object(client) + }, + "room": room + }) + + # Notify client of current room state + server.send_packet(client, { + "cmd": "ulist", + "val": { + "mode": "set", + "val": server.rooms_manager.generate_userlist(room, cl4_protocol) + }, + "room": room + }) + + # Attach listener (if present) and broadcast + if "listener" in message: + send_statuscode( + client, + statuscodes.ok, + listener=message["listener"]) + else: + send_statuscode( + client, + statuscodes.ok + ) + @server.on_command(cmd="unlink", schema=cl4_protocol) async def on_unlink(client, message): + await automatic_notify_handshake(client) + # Validate schema if not valid(client, message, cl4_protocol.linking): return @@ -731,18 +829,68 @@ async def on_unlink(client, message): async for room in server.async_iterable(message["val"]): server.rooms_manager.unsubscribe(client, room) + # Broadcast userlist state to existing members + clients = await server.rooms_manager.get_all_in_rooms(room, cl4_protocol) + clients = server.copy(clients) + clients.remove(client) + server.send_packet(clients, { + "cmd": "ulist", + "val": { + "mode": "remove", + "val": generate_user_object(client) + }, + "room": room + }) + # Re-link to default room if no rooms are joined if not len(client.rooms): server.rooms_manager.subscribe(client, "default") + # Broadcast userlist state to existing members + clients = await server.rooms_manager.get_all_in_rooms("default", cl4_protocol) + clients = server.copy(clients) + clients.remove(client) + server.send_packet(clients, { + "cmd": "ulist", + "val": { + "mode": "add", + "val": generate_user_object(client) + }, + "room": "default" + }) + + # Notify client of current room state + server.send_packet(client, { + "cmd": "ulist", + "val": { + "mode": "set", + "val": server.rooms_manager.generate_userlist("default", cl4_protocol) + }, + "room": "default" + }) + + # Attach listener (if present) and broadcast + if "listener" in message: + send_statuscode( + client, + statuscodes.ok, + listener=message["listener"]) + else: + send_statuscode( + client, + statuscodes.ok + ) + @server.on_command(cmd="direct", schema=cl4_protocol) async def on_direct(client, message): + await automatic_notify_handshake(client) + # Validate schema if not valid(client, message, cl4_protocol.direct): return try: - client = server.clients_manager.find_obj(message["id"]) + tmp_client = server.clients_manager.find_obj(message["id"]) tmp_msg = { "cmd": "direct", @@ -750,11 +898,7 @@ async def on_direct(client, message): } if client.username_set: - tmp_msg["origin"] = { - "id": client.snowflake, - "username": client.username, - "uuid": str(client.id) - } + tmp_msg["origin"] = generate_user_object(client) else: tmp_msg["origin"] = { @@ -765,7 +909,7 @@ async def on_direct(client, message): if "listener" in message: tmp_msg["listener"] = message["listener"] - server.send_packet_unicast(client, tmp_msg) + server.send_packet_unicast(tmp_client, tmp_msg) except server.clients_manager.exceptions.NoResultsFound: # Attach listener @@ -783,7 +927,3 @@ async def on_direct(client, message): # Stop direct command return - - @server.on_command(cmd="bridge", schema=cl4_protocol) - async def on_bridge(client, message): - pass diff --git a/cloudlink/server/protocols/scratch/scratch.py b/cloudlink/server/protocols/scratch/scratch.py index 00c3bd6..06a240a 100644 --- a/cloudlink/server/protocols/scratch/scratch.py +++ b/cloudlink/server/protocols/scratch/scratch.py @@ -31,9 +31,22 @@ def valid(client, message, schema): server.close_connection(client, code=statuscodes.connection_error, reason=f"Validation failed") return False + @server.on_protocol_disconnect(schema=scratch_protocol) + async def protocol_disconnect(client): + server.logger.debug(f"Removing client {client.snowflake} from rooms...") + + # Unsubscribe from all rooms + async for room_id in server.async_iterable(server.copy(client.rooms)): + server.rooms_manager.unsubscribe(client, room_id) + @server.on_command(cmd="handshake", schema=scratch_protocol) async def handshake(client, message): + # Don't execute this command if handshake was already done + if client.handshake: + return + client.handshake = True + # Safety first if ("scratchsessionsid" in client.request_headers) or ("scratchsessionsid" in client.response_headers): @@ -77,6 +90,10 @@ async def handshake(client, message): @server.on_command(cmd="create", schema=scratch_protocol) async def create_variable(client, message): + # Don't execute this command if handshake wasn't already done + if not client.handshake: + return + # Validate schema if not valid(client, message, scratch_protocol.method): return @@ -106,6 +123,10 @@ async def create_variable(client, message): @server.on_command(cmd="rename", schema=scratch_protocol) async def rename_variable(client, message): + # Don't execute this command if handshake wasn't already done + if not client.handshake: + return + # Validate schema if not valid(client, message, scratch_protocol.method): return @@ -146,6 +167,10 @@ async def rename_variable(client, message): @server.on_command(cmd="delete", schema=scratch_protocol) async def create_variable(client, message): + # Don't execute this command if handshake wasn't already done + if not client.handshake: + return + # Validate schema if not valid(client, message, scratch_protocol.method): return @@ -178,6 +203,10 @@ async def create_variable(client, message): @server.on_command(cmd="set", schema=scratch_protocol) async def set_value(client, message): + # Don't execute this command if handshake wasn't already done + if not client.handshake: + return + # Validate schema if not valid(client, message, scratch_protocol.method): return diff --git a/cloudlink/modules/__init__.py b/cloudlink/shared_modules/__init__.py similarity index 61% rename from cloudlink/modules/__init__.py rename to cloudlink/shared_modules/__init__.py index 534b898..06505fd 100644 --- a/cloudlink/modules/__init__.py +++ b/cloudlink/shared_modules/__init__.py @@ -1,4 +1,2 @@ from .async_event_manager import async_event_manager from .async_iterables import async_iterable -from .clients_manager import * -from .rooms_manager import * diff --git a/cloudlink/modules/async_event_manager.py b/cloudlink/shared_modules/async_event_manager.py similarity index 100% rename from cloudlink/modules/async_event_manager.py rename to cloudlink/shared_modules/async_event_manager.py diff --git a/cloudlink/modules/async_iterables.py b/cloudlink/shared_modules/async_iterables.py similarity index 100% rename from cloudlink/modules/async_iterables.py rename to cloudlink/shared_modules/async_iterables.py From 0f106d0d5b2fc1a17da0d7cfe31bd3197ecfded0 Mon Sep 17 00:00:00 2001 From: "Mike J. Renaker / \"MikeDEV" Date: Mon, 27 Mar 2023 15:40:45 -0400 Subject: [PATCH 26/59] Update clpv4.py * De-duplicate some code * Utilize a real_ip_header variable for getting client IPs using request headers, instead of hard-coding values --- cloudlink/server/protocols/clpv4/clpv4.py | 391 ++++++++-------------- 1 file changed, 144 insertions(+), 247 deletions(-) diff --git a/cloudlink/server/protocols/clpv4/clpv4.py b/cloudlink/server/protocols/clpv4/clpv4.py index 2153195..96310a1 100644 --- a/cloudlink/server/protocols/clpv4/clpv4.py +++ b/cloudlink/server/protocols/clpv4/clpv4.py @@ -13,14 +13,34 @@ class clpv4: def __init__(self, server): - # Configuration settings - # Warn origin client if it's attempting to send messages using a username that resolves more than one client. - self.warn_if_multiple_username_matches = True + """ + Configuration settings + + warn_if_multiple_username_matches: Boolean, Default: True + If True, the server will warn users if they are resolving multiple clients for a username search. + + enable_motd: Boolean, Default: False + If True, whenever a client sends the handshake command or whenever the client's protocol is identified, + the server will send the Message-Of-The-Day from whatever value motd_message is set to. + + motd_message: String, Default: Blank string + If enable_mod is True, this string will be sent as the server's Message-Of-The-Day. + + real_ip_header: String, Default: None + If you use CloudLink behind a tunneling service or reverse proxy, set this value to whatever + IP address-fetching request header to resolve valid IP addresses. When set to None, it will + utilize the host's incoming network for resolving IP addresses. - # Message of the day + Examples include: + * x-forwarded-for + * cf-connecting-ip + + """ + self.warn_if_multiple_username_matches = True self.enable_motd = False self.motd_message = str() + self.real_ip_header = None # Exposes the schema of the protocol. self.schema = cl4_protocol @@ -60,21 +80,15 @@ def generate(code: tuple): # Identification of a client's IP address def get_client_ip(client): - # Grab forwarded IP address - if "x-forwarded-for" in client.request_headers: - return client.request_headers.get("x-forwarded-for") - - # Grab Cloudflare IP address - if "cf-connecting-ip" in client.request_headers: - return client.request_headers.get("cf-connecting-ip") + # Grab IP address using headers + if self.real_ip_header: + if self.real_ip_header in client.request_headers: + return client.request_headers.get(self.real_ip_header) - # Grab host address, ignoring port info + # Use system identified IP address if type(client.remote_address) == tuple: return str(client.remote_address[0]) - # Default if none of the above work - return client.remote_address - # Expose get_client_ip for extension usage self.get_client_ip = get_client_ip @@ -91,7 +105,7 @@ def valid(client, message, schema): self.valid = valid # Simplify sending error/info messages - def send_statuscode(client, code, details=None, listener=None, val=None): + def send_statuscode(client, code, details=None, message=None, val=None): # Generate a statuscode code_human, code_id = statuscodes.generate(code) @@ -105,8 +119,9 @@ def send_statuscode(client, code, details=None, listener=None, val=None): if details: tmp_message["details"] = details - if listener: - tmp_message["listener"] = listener + if message: + if "listener" in message: + tmp_message["listener"] = message["listener"] if val: tmp_message["val"] = val @@ -117,23 +132,27 @@ def send_statuscode(client, code, details=None, listener=None, val=None): # Expose the statuscode generator for extension usage self.send_statuscode = send_statuscode + # Send messages with automatic listener attaching + def send_message(client, payload, message=None): + if message: + if "listener" in message: + payload["listener"] = message["listener"] + + # Send the code + server.send_packet(client, payload) + + # Expose the message sender for extension usage + self.send_message = send_message + # Simplify alerting users that a command requires a username to be set def require_username_set(client, message): if not client.username_set: - # Attach listener - if "listener" in message: - send_statuscode( - client, - statuscodes.id_required, - details="This command requires setting a username.", - listener=message["listener"] - ) - else: - send_statuscode( - client, - statuscodes.id_required, - details="This command requires setting a username." - ) + send_statuscode( + client, + statuscodes.id_required, + details="This command requires setting a username.", + message=message + ) return client.username_set @@ -303,21 +322,12 @@ async def protocol_disconnect(client): @server.on_command(cmd="handshake", schema=cl4_protocol) async def on_handshake(client, message): await automatic_notify_handshake(client) - - # Attach listener - if "listener" in message: - send_statuscode(client, statuscodes.ok, listener=message["listener"]) - else: - send_statuscode(client, statuscodes.ok) + send_statuscode(client, statuscodes.ok, message=message) @server.on_command(cmd="ping", schema=cl4_protocol) async def on_ping(client, message): await automatic_notify_handshake(client) - - if "listener" in message: - send_statuscode(client, statuscodes.ok, listener=message["listener"]) - else: - send_statuscode(client, statuscodes.ok) + send_statuscode(client, statuscodes.ok, message=message) @server.on_command(cmd="gmsg", schema=cl4_protocol) async def on_gmsg(client, message): @@ -335,26 +345,18 @@ async def on_gmsg(client, message): # Prevent accessing rooms not joined if room not in client.rooms: - - # Attach listener - if "listener" in message: - send_statuscode( - client, - statuscodes.room_not_joined, - details=f'Attempted to access room {room} while not joined.', - listener=message["listener"] - ) - else: - send_statuscode( - client, - statuscodes.room_not_joined, - details=f'Attempted to access room {room} while not joined.' - ) + send_statuscode( + client, + statuscodes.room_not_joined, + details=f'Attempted to access room {room} while not joined.', + message=message + ) # Stop gmsg command return clients = await server.rooms_manager.get_all_in_rooms(room, cl4_protocol) + clients = server.copy(clients) # Attach listener (if present) and broadcast if "listener" in message: @@ -382,15 +384,12 @@ async def on_gmsg(client, message): # Unicast message server.send_packet(client, tmp_message) else: - # Define the message to broadcast - tmp_message = { + # Broadcast message + server.send_packet(clients, { "cmd": "gmsg", "val": message["val"], "room": room - } - - # Broadcast message - server.send_packet(clients, tmp_message) + }) @server.on_command(cmd="pmsg", schema=cl4_protocol) async def on_pmsg(client, message): @@ -413,21 +412,12 @@ async def on_pmsg(client, message): # Prevent accessing rooms not joined if room not in client.rooms: - - # Attach listener - if "listener" in message: - send_statuscode( - client, - statuscodes.room_not_joined, - details=f'Attempted to access room {room} while not joined.', - listener=message["listener"] - ) - else: - send_statuscode( - client, - statuscodes.room_not_joined, - details=f'Attempted to access room {room} while not joined.' - ) + send_statuscode( + client, + statuscodes.room_not_joined, + details=f'Attempted to access room {room} while not joined.', + message=message + ) # Stop pmsg command return @@ -444,20 +434,12 @@ async def on_pmsg(client, message): # Warn if multiple matches are found (mainly for username queries) if self.warn_if_multiple_username_matches and len(clients) >> 1: - # Attach listener - if "listener" in message: - send_statuscode( - client, - statuscodes.id_not_specific, - details=f'Multiple matches found for {message["id"]}, found {len(clients)} matches. Please use Snowflakes, UUIDs, or client objects instead.', - listener=message["listener"] - ) - else: - send_statuscode( - client, - statuscodes.id_not_specific, - details=f'Multiple matches found for {message["id"]}, found {len(clients)} matches. Please use Snowflakes, UUIDs, or client objects instead.' - ) + send_statuscode( + client, + statuscodes.id_not_specific, + details=f'Multiple matches found for {message["id"]}, found {len(clients)} matches. Please use Snowflakes, UUIDs, or client objects instead.', + message=message + ) # Stop pmsg command return @@ -472,36 +454,22 @@ async def on_pmsg(client, message): server.send_packet(clients, tmp_message) if not any_results_found: - # Attach listener - if "listener" in message: - send_statuscode( - client, - statuscodes.id_not_found, - details=f'No matches found: {message["id"]}', - listener=message["listener"] - ) - else: - send_statuscode( - client, - statuscodes.id_not_found, - details=f'No matches found: {message["id"]}' - ) + send_statuscode( + client, + statuscodes.id_not_found, + details=f'No matches found: {message["id"]}', + message=message + ) # End pmsg command handler return # Results were found and sent successfully - if "listener" in message: - send_statuscode( - client, - statuscodes.ok, - listener=message["listener"] - ) - else: - send_statuscode( - client, - statuscodes.ok - ) + send_statuscode( + client, + statuscodes.ok, + message=message + ) @server.on_command(cmd="gvar", schema=cl4_protocol) async def on_gvar(client, message): @@ -519,26 +487,18 @@ async def on_gvar(client, message): # Prevent accessing rooms not joined if room not in client.rooms: - - # Attach listener - if "listener" in message: - send_statuscode( - client, - statuscodes.room_not_joined, - details=f'Attempted to access room {room} while not joined.', - listener=message["listener"] - ) - else: - send_statuscode( - client, - statuscodes.room_not_joined, - details=f'Attempted to access room {room} while not joined.' - ) + send_statuscode( + client, + statuscodes.room_not_joined, + details=f'Attempted to access room {room} while not joined.', + message=message + ) # Stop gvar command return clients = await server.rooms_manager.get_all_in_rooms(room, cl4_protocol) + clients = server.copy(clients) # Define the message to send tmp_message = { @@ -578,26 +538,18 @@ async def on_pvar(client, message): # Prevent accessing rooms not joined if room not in client.rooms: - - # Attach listener - if "listener" in message: - send_statuscode( - client, - statuscodes.room_not_joined, - details=f'Attempted to access room {room} while not joined.', - listener=message["listener"] - ) - else: - send_statuscode( - client, - statuscodes.room_not_joined, - details=f'Attempted to access room {room} while not joined.' - ) + send_statuscode( + client, + statuscodes.room_not_joined, + details=f'Attempted to access room {room} while not joined.', + message=message + ) # Stop pvar command return clients = await server.rooms_manager.get_specific_in_room(room, cl4_protocol, message['id']) + clients = server.copy(clients) # Continue if no results are found if not len(clients): @@ -609,20 +561,12 @@ async def on_pvar(client, message): # Warn if multiple matches are found (mainly for username queries) if self.warn_if_multiple_username_matches and len(clients) >> 1: - # Attach listener - if "listener" in message: - send_statuscode( - client, - statuscodes.id_not_specific, - details=f'Multiple matches found for {message["id"]}, found {len(clients)} matches. Please use Snowflakes, UUIDs, or client objects instead.', - listener=message["listener"] - ) - else: - send_statuscode( - client, - statuscodes.id_not_specific, - details=f'Multiple matches found for {message["id"]}, found {len(clients)} matches. Please use Snowflakes, UUIDs, or client objects instead.' - ) + send_statuscode( + client, + statuscodes.id_not_specific, + details=f'Multiple matches found for {message["id"]}, found {len(clients)} matches. Please use Snowflakes, UUIDs, or client objects instead.', + message=message + ) # Stop pvar command return @@ -638,36 +582,22 @@ async def on_pvar(client, message): server.send_packet(clients, tmp_message) if not any_results_found: - # Attach listener - if "listener" in message: - send_statuscode( - client, - statuscodes.id_not_found, - details=f'No matches found: {message["id"]}', - listener=message["listener"] - ) - else: - send_statuscode( - client, - statuscodes.id_not_found, - details=f'No matches found: {message["id"]}' - ) + send_statuscode( + client, + statuscodes.id_not_found, + details=f'No matches found: {message["id"]}', + message=message + ) # End pmsg command handler return # Results were found and sent successfully - if "listener" in message: - send_statuscode( - client, - statuscodes.ok, - listener=message["listener"] - ) - else: - send_statuscode( - client, - statuscodes.ok - ) + send_statuscode( + client, + statuscodes.ok, + message=message + ) @server.on_command(cmd="setid", schema=cl4_protocol) async def on_setid(client, message): @@ -680,26 +610,16 @@ async def on_setid(client, message): # Prevent setting the username more than once if client.username_set: server.logger.error(f"Client {client.snowflake} attempted to set username again!") - if "listener" in message: - send_statuscode( - client, - statuscodes.id_already_set, - val=generate_user_object(client), - listener=message["listener"] - ) - else: - send_statuscode( - client, - statuscodes.id_already_set, - val=generate_user_object(client) - ) + send_statuscode( + client, + statuscodes.id_already_set, + val=generate_user_object(client), + message=message + ) # Exit setid command return - # Gather rooms - rooms = server.copy(client.rooms) - # Leave default room server.rooms_manager.unsubscribe(client, "default") @@ -733,18 +653,12 @@ async def on_setid(client, message): }) # Attach listener (if present) and broadcast - if "listener" in message: - send_statuscode( - client, - statuscodes.ok, - val=generate_user_object(client), - listener=message["listener"]) - else: - send_statuscode( - client, - statuscodes.ok, - val=generate_user_object(client), - ) + send_statuscode( + client, + statuscodes.ok, + val=generate_user_object(client), + message=message + ) @server.on_command(cmd="link", schema=cl4_protocol) async def on_link(client, message): @@ -796,16 +710,11 @@ async def on_link(client, message): }) # Attach listener (if present) and broadcast - if "listener" in message: - send_statuscode( - client, - statuscodes.ok, - listener=message["listener"]) - else: - send_statuscode( - client, - statuscodes.ok - ) + send_statuscode( + client, + statuscodes.ok, + message=message + ) @server.on_command(cmd="unlink", schema=cl4_protocol) async def on_unlink(client, message): @@ -870,16 +779,11 @@ async def on_unlink(client, message): }) # Attach listener (if present) and broadcast - if "listener" in message: - send_statuscode( - client, - statuscodes.ok, - listener=message["listener"]) - else: - send_statuscode( - client, - statuscodes.ok - ) + send_statuscode( + client, + statuscodes.ok, + message=message + ) @server.on_command(cmd="direct", schema=cl4_protocol) async def on_direct(client, message): @@ -912,18 +816,11 @@ async def on_direct(client, message): server.send_packet_unicast(tmp_client, tmp_msg) except server.clients_manager.exceptions.NoResultsFound: - # Attach listener - if "listener" in message: - send_statuscode( - client, - statuscodes.id_not_found, - listener=message["listener"] - ) - else: - send_statuscode( - client, - statuscodes.id_not_found - ) + send_statuscode( + client, + statuscodes.id_not_found, + message=message + ) # Stop direct command return From 24df5b73a19964f1e1cff3f6f98d115fcc7e8e33 Mon Sep 17 00:00:00 2001 From: "Mike J. Renaker / \"MikeDEV" Date: Mon, 27 Mar 2023 15:46:28 -0400 Subject: [PATCH 27/59] Update sample code --- README.md | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 91a1036..cbf3062 100644 --- a/README.md +++ b/README.md @@ -38,28 +38,34 @@ when a client sends the message `{ "cmd": "foo" }` to the server. # Import the server from cloudlink import server +# Import default protocol +from cloudlink.server.protocols import clpv4 + # Instanciate the server object -cl = server() +server = server() # Set logging level -cl.logging.basicConfig( +server.logging.basicConfig( level=cl.logging.DEBUG ) +# Load default CL protocol +clpv4 = clpv4(server) + # Define the functions your plugin executes class myplugin: - def __init__(self, server): + def __init__(self, server, protocol): # Example command - client sends { "cmd": "foo" } to the server, this function will execute - @server.on_command(cmd="foo", schema=cl.schemas.clpv4) + @server.on_command(cmd="foo", schema=protocol.schema) async def foobar(client, message): print("Foobar!") # Load the plugin! -myplugin(cl) +myplugin(server, clpv4) # Start the server! -cl.run() +server.run() ``` # 🐈 A powerful extension for Scratch 3.0 From 1fb54bb03e636ccc0cac1ef35848c7fccc822474 Mon Sep 17 00:00:00 2001 From: "Mike J. Renaker / \"MikeDEV" Date: Mon, 27 Mar 2023 15:54:26 -0400 Subject: [PATCH 28/59] Update serverlist.json --- serverlist.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/serverlist.json b/serverlist.json index 57aa4c7..aa9dfaf 100644 --- a/serverlist.json +++ b/serverlist.json @@ -4,8 +4,8 @@ "url": "ws://127.0.0.1:3000/" }, "1": { - "id": "CloudLink 4 Demo Server - Provided by Meower Media Co.", - "url": "wss://cl4-test.meower.org/" + "id": "CL4 0.2.0 Demo Server - By MikeDEV", + "url": "wss://cloudlink.ddns.net:3000/" }, "2": { "id": "Cloudlink 4 - Suite demo server", From 72f18b7658717cae7b3a3535dc807ee8fd883990 Mon Sep 17 00:00:00 2001 From: "Mike J. Renaker / \"MikeDEV" Date: Mon, 27 Mar 2023 16:08:11 -0400 Subject: [PATCH 29/59] Update cl_admin.py --- cloudlink/server/plugins/cl_admin.py | 1 + 1 file changed, 1 insertion(+) diff --git a/cloudlink/server/plugins/cl_admin.py b/cloudlink/server/plugins/cl_admin.py index 35370c6..17f2eec 100644 --- a/cloudlink/server/plugins/cl_admin.py +++ b/cloudlink/server/plugins/cl_admin.py @@ -12,6 +12,7 @@ def __init__(self, server, clpv4): server.logging.info("Initializing CL Admin Extension...") # Example config + #TODO: make this secure self.admin_users = { "admin": { "password": "cloudlink", From 73478d9568a944a95cb74a8d79526dac49c555fd Mon Sep 17 00:00:00 2001 From: "Mike J. Renaker / \"MikeDEV" Date: Mon, 27 Mar 2023 16:54:12 -0400 Subject: [PATCH 30/59] client stupidity --- client.py | 20 + cloudlink/__init__.py | 1 + cloudlink/client/__init__.py | 542 +++++++++++++ cloudlink/client/protocols/__init__.py | 2 + cloudlink/client/protocols/clpv4/__init__.py | 1 + cloudlink/client/protocols/clpv4/clpv4.py | 753 +++++++++++++++++++ cloudlink/client/protocols/clpv4/schema.py | 340 +++++++++ main.py => server.py | 0 test.py | 23 - 9 files changed, 1659 insertions(+), 23 deletions(-) create mode 100644 client.py create mode 100644 cloudlink/client/__init__.py create mode 100644 cloudlink/client/protocols/__init__.py create mode 100644 cloudlink/client/protocols/clpv4/__init__.py create mode 100644 cloudlink/client/protocols/clpv4/clpv4.py create mode 100644 cloudlink/client/protocols/clpv4/schema.py rename main.py => server.py (100%) delete mode 100644 test.py diff --git a/client.py b/client.py new file mode 100644 index 0000000..2c79e27 --- /dev/null +++ b/client.py @@ -0,0 +1,20 @@ +from cloudlink import client +from cloudlink.client.protocols import clpv4 + +if __name__ == "__main__": + # Initialize the client + client = client() + + # Configure logging settings + client.logging.basicConfig( + level=client.logging.DEBUG + ) + + # Load protocols + clpv4 = clpv4(client) + + # Initialize SSL support + client.enable_ssl(certfile="cert.pem") + + # Start the server + client.run(host="wss://cloudlink.ddns.net:3000/") diff --git a/cloudlink/__init__.py b/cloudlink/__init__.py index bf0c964..8cf5a92 100644 --- a/cloudlink/__init__.py +++ b/cloudlink/__init__.py @@ -1 +1,2 @@ from cloudlink.server import server +from cloudlink.client import client diff --git a/cloudlink/client/__init__.py b/cloudlink/client/__init__.py new file mode 100644 index 0000000..aca0abe --- /dev/null +++ b/cloudlink/client/__init__.py @@ -0,0 +1,542 @@ +# Core components of the CloudLink client +import asyncio +import ssl + +import cerberus +import logging +import time +from copy import copy + +# Import websockets and SSL support +import websockets + +# Import shared modules +from cloudlink.shared_modules.async_event_manager import async_event_manager +from cloudlink.shared_modules.async_iterables import async_iterable + +# Import JSON library - Prefer UltraJSON but use native JSON if failed +try: + import ujson +except ImportError: + print("Server failed to import UltraJSON, failing back to native JSON library.") + import json as ujson + + +# Define server exceptions +class exceptions: + class EmptyMessage(Exception): + """This exception is raised when a client sends an empty packet.""" + pass + + class InvalidCommand(Exception): + """This exception is raised when a client sends an invalid command for it's determined protocol.""" + pass + + class JSONError(Exception): + """This exception is raised when the server fails to parse a message's JSON.""" + pass + + class ValidationError(Exception): + """This exception is raised when a client with a known protocol sends a message that fails validation before commands can execute.""" + pass + + class InternalError(Exception): + """This exception is raised when an unexpected and/or unhandled exception is raised.""" + pass + + class Overloaded(Exception): + """This exception is raised when the server believes it is overloaded.""" + pass + + +# Main server +class client: + def __init__(self): + self.version = "0.2.0" + + # Logging + self.logging = logging + self.logger = self.logging.getLogger(__name__) + + # Asyncio + self.asyncio = asyncio + + # Configure websocket framework + self.ws = websockets + + # Components + self.ujson = ujson + self.validator = cerberus.Validator() + self.async_iterable = async_iterable + self.copy = copy + self.exceptions = exceptions() + + # Dictionary containing protocols as keys and sets of commands as values + self.disabled_commands = dict() + + # Create event managers + self.on_connect_events = async_event_manager(self) + self.on_message_events = async_event_manager(self) + self.on_disconnect_events = async_event_manager(self) + self.on_error_events = async_event_manager(self) + self.exception_handlers = dict() + + # Create method handlers + self.command_handlers = dict() + + # Configure framework logging + self.suppress_websocket_logs = True + + # Configure SSL support + self.ssl_enabled = False + self.ssl_context = None + + # Enables SSL support + def enable_ssl(self, certfile): + try: + self.ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + self.ssl_context.load_verify_locations(certfile) + self.ssl_enabled = True + self.logger.info(f"SSL support initialized!") + except Exception as e: + self.logger.error(f"Failed to initialize SSL support! {e}") + + # Runs the client. + def run(self, host="ws://127.0.0.1:3000"): + try: + # Startup message + self.logger.info(f"CloudLink {self.version} - Now connecting to {host}") + + # Suppress websocket library logging + if self.suppress_websocket_logs: + self.logging.getLogger('asyncio').setLevel(self.logging.ERROR) + self.logging.getLogger('asyncio.coroutines').setLevel(self.logging.ERROR) + self.logging.getLogger('websockets.client').setLevel(self.logging.ERROR) + self.logging.getLogger('websockets.protocol').setLevel(self.logging.ERROR) + + # Start server + self.asyncio.run(self.__run__(host)) + + except KeyboardInterrupt: + pass + + # Event binder for on_command events + def on_command(self, cmd, schema): + def bind_event(func): + + # Create schema category for command event manager + if schema not in self.command_handlers: + self.logger.info(f"Creating protocol {schema.__qualname__} command event manager") + self.command_handlers[schema] = dict() + + # Create command event handler + if cmd not in self.command_handlers[schema]: + self.command_handlers[schema][cmd] = async_event_manager(self) + + # Add function to the command handler + self.command_handlers[schema][cmd].bind(func) + + # End on_command binder + return bind_event + + # Event binder for on_error events with specific shemas/exception types + def on_exception(self, exception_type, schema): + def bind_event(func): + + # Create schema category for error event manager + if schema not in self.exception_handlers: + self.logger.info(f"Creating protocol {schema.__qualname__} exception event manager") + self.exception_handlers[schema] = dict() + + # Create error event handler + if exception_type not in self.exception_handlers[schema]: + self.exception_handlers[schema][exception_type] = async_event_manager(self) + + # Add function to the error command handler + self.exception_handlers[schema][exception_type].bind(func) + + # End on_error_specific binder + return bind_event + + # Event binder for on_message events + def on_message(self, func): + self.on_message_events.bind(func) + + # Event binder for on_connect events. + def on_connect(self, func): + self.on_connect_events.bind(func) + + # Event binder for on_disconnect events. + def on_disconnect(self, func): + self.on_disconnect_events.bind(func) + + # Event binder for on_error events. + def on_error(self, func): + self.on_error_events.bind(func) + + # Send message + def send_packet(self, client, message): + # Create unicast task + self.asyncio.create_task(self.execute_send(client, message)) + + # Close the connection to client(s) + def close_connection(self, obj, code=1000, reason=""): + if type(obj) in [list, set]: + self.asyncio.create_task(self.execute_close_multi(obj, code, reason)) + else: + self.asyncio.create_task(self.execute_close_single(obj, code, reason)) + + # Command disabler + def disable_command(self, cmd, schema): + # Check if the schema has no disabled commands + if schema not in self.disabled_commands: + self.disabled_commands[schema] = set() + + # Check if the command isn't already disabled + if cmd in self.disabled_commands[schema]: + raise ValueError( + f"The command {cmd} is already disabled in protocol {schema.__qualname__}, or was enabled beforehand.") + + # Disable the command + self.disabled_commands[schema].add(cmd) + self.logger.debug(f"Disabled command {cmd} in protocol {schema.__qualname__}") + + # Command enabler + def enable_command(self, cmd, schema): + # Check if the schema has disabled commands + if schema not in self.disabled_commands: + raise ValueError(f"There are no commands to disable in protocol {schema.__qualname__}.") + + # Check if the command is disabled + if cmd not in self.disabled_commands[schema]: + raise ValueError( + f"The command {cmd} is already enabled in protocol {schema.__qualname__}, or wasn't disabled beforehand.") + + # Enable the command + self.disabled_commands[schema].remove(cmd) + self.logger.debug(f"Enabled command {cmd} in protocol {schema.__qualname__}") + + # Free up unused disablers + if not len(self.disabled_commands[schema]): + self.disabled_commands.pop(schema) + + # Message processor + async def message_processor(self, client, message): + + # Empty packet + if not len(message): + self.logger.debug(f"Client {client.snowflake} sent empty message ") + + # Fire on_error events + asyncio.create_task(self.execute_on_error_events(client, self.exceptions.EmptyMessage)) + + # Fire exception handling events + if client.protocol_set: + self.asyncio.create_task( + self.execute_exception_handlers( + client=client, + exception_type=self.exceptions.EmptyMessage, + schema=client.protocol, + details="Empty message" + ) + ) + else: + # Close the connection + self.send_packet(client, "Empty message") + self.close_connection(client, reason="Empty message") + + # End message_processor coroutine + return + + # Parse JSON in message and convert to dict + try: + message = self.ujson.loads(message) + + except Exception as error: + self.logger.debug(f"Client {client.snowflake} sent invalid JSON: {error}") + + # Fire on_error events + self.asyncio.create_task(self.execute_on_error_events(client, error)) + + # Fire exception handling events + if client.protocol_set: + self.asyncio.create_task( + self.execute_exception_handlers( + client=client, + exception_type=self.exceptions.JSONError, + schema=client.protocol, + details=error + ) + ) + + else: + # Close the connection + self.send_packet(client, "Invalid JSON") + self.close_connection(client, reason="Invalid JSON") + + # End message_processor coroutine + return + + # Begin validation + valid = False + selected_protocol = None + + # Client protocol is unknown + if not client.protocol: + self.logger.debug(f"Trying to identify client {client.snowflake}'s protocol") + + # Identify protocol + errorlist = list() + + for schema in self.command_handlers: + if self.validator(message, schema.default): + valid = True + selected_protocol = schema + break + else: + errorlist.append(self.validator.errors) + + if not valid: + # Log failed identification + self.logger.debug(f"Could not identify protocol used by client {client.snowflake}: {errorlist}") + + # Fire on_error events + self.asyncio.create_task(self.execute_on_error_events(client, "Unable to identify protocol")) + + # Close the connection + self.send_packet(client, "Unable to identify protocol") + self.close_connection(client, reason="Unable to identify protocol") + + # End message_processor coroutine + return + + # Log known protocol + self.logger.debug(f"Client {client.snowflake} is using protocol {selected_protocol.__qualname__}") + + # Make the client's protocol known + self.clients_manager.set_protocol(client, selected_protocol) + + # Fire protocol identified events + self.asyncio.create_task(self.execute_protocol_identified_events(client, selected_protocol)) + + else: + self.logger.debug( + f"Validating message from {client.snowflake} using protocol {client.protocol.__qualname__}") + + # Validate message using known protocol + selected_protocol = client.protocol + + if not self.validator(message, selected_protocol.default): + errors = self.validator.errors + + # Log failed validation + self.logger.debug(f"Client {client.snowflake} sent message that failed validation: {errors}") + + # Fire on_error events + self.asyncio.create_task(self.execute_on_error_events(client, errors)) + + # Fire exception handling events + if client.protocol_set: + self.asyncio.create_task( + self.execute_exception_handlers( + client=client, + exception_type=self.exceptions.ValidationError, + schema=client.protocol, + details=errors + ) + ) + + # End message_processor coroutine + return + + # Check if command exists + if message[selected_protocol.command_key] not in self.command_handlers[selected_protocol]: + + # Log invalid command + self.logger.debug( + f"Client {client.snowflake} sent an invalid command \"{message[selected_protocol.command_key]}\" in protocol {selected_protocol.__qualname__}") + + # Fire on_error events + self.asyncio.create_task(self.execute_on_error_events(client, "Invalid command")) + + # Fire exception handling events + if client.protocol_set: + self.asyncio.create_task( + self.execute_exception_handlers( + client=client, + exception_type=self.exceptions.InvalidCommand, + schema=client.protocol, + details=message[selected_protocol.command_key] + ) + ) + + # End message_processor coroutine + return + + # Check if the command is disabled + if selected_protocol in self.disabled_commands: + if message[selected_protocol.command_key] in self.disabled_commands[selected_protocol]: + self.logger.debug( + f"Client {client.snowflake} sent a disabled command \"{message[selected_protocol.command_key]}\" in protocol {selected_protocol.__qualname__}") + + # Fire disabled command event + self.asyncio.create_task( + self.execute_disabled_command_events( + client, + selected_protocol, + message[selected_protocol.command_key] + ) + ) + + # End message_processor coroutine + return + + # Fire on_command events + self.asyncio.create_task( + self.execute_on_command_events( + client, + message, + selected_protocol + ) + ) + + # Fire on_message events + self.asyncio.create_task( + self.execute_on_message_events( + client, + message + ) + ) + + # Connection handler + async def connection_handler(self, client): + + # Startup client attributes + client.snowflake = str() + client.protocol = None + client.protocol_set = False + client.rooms = set() + client.username_set = False + client.username = str() + client.handshake = False + + # Begin tracking the lifetime of the client + client.birth_time = time.monotonic() + + # Add to clients manager + self.clients_manager.add(client) + + # Fire on_connect events + self.asyncio.create_task(self.execute_on_connect_events(client)) + + self.logger.debug(f"Client {client.snowflake} connected") + + # Run connection loop + await self.connection_loop(client) + + # Remove from clients manager + self.clients_manager.remove(client) + + # Fire on_disconnect events + self.asyncio.create_task(self.execute_on_disconnect_events(client)) + + # Execute all protocol-specific disconnect events + if client.protocol_set: + self.asyncio.create_task( + self.execute_protocol_disconnect_events(client, client.protocol) + ) + + self.logger.debug( + f"Client {client.snowflake} disconnected: Total lifespan of {time.monotonic() - client.birth_time} seconds.") + + # Connection loop - Redefine for use with another outside library + async def connection_loop(self, client): + # Primary asyncio loop for the lifespan of the websocket connection + try: + async for message in client: + # Start keeping track of processing time + start = time.perf_counter() + self.logger.debug(f"Now processing message from client {client.snowflake}...") + + # Process the message + await self.message_processor(client, message) + + # Log processing time + self.logger.debug( + f"Done processing message from client {client.snowflake}. Processing took {time.perf_counter() - start} seconds.") + + # Handle unexpected disconnects + except self.ws.exceptions.ConnectionClosedError: + pass + + # Handle OK disconnects + except self.ws.exceptions.ConnectionClosedOK: + pass + + # Catch any unexpected exceptions + except Exception as e: + self.logger.critical(f"Unexpected exception was raised: {e}") + + # Fire on_error events + self.asyncio.create_task(self.execute_on_error_events(client, f"Unexpected exception was raised: {e}")) + + # Fire exception handling events + if client.protocol_set: + self.asyncio.create_task( + self.execute_exception_handlers( + client=client, + exception_type=self.exceptions.InternalError, + schema=client.protocol, + details=f"Unexpected exception was raised: {e}" + ) + ) + + # WebSocket-specific server loop + async def __run__(self, host): + async with self.ws.connect(host) as client: + message = await client.recv() + print(message) + + # Asyncio event-handling coroutines + + async def execute_on_disconnect_events(self, client): + async for event in self.on_disconnect_events: + await event(client) + + async def execute_on_connect_events(self, client): + async for event in self.on_connect_events: + await event(client) + + async def execute_on_message_events(self, client, message): + async for event in self.on_message_events: + await event(client, message) + + async def execute_on_command_events(self, client, message, schema): + async for event in self.command_handlers[schema][message[schema.command_key]]: + await event(client, message) + + async def execute_on_error_events(self, client, errors): + async for event in self.on_error_events: + await event(client, errors) + + async def execute_exception_handlers(self, client, exception_type, schema, details): + # Guard clauses + if schema not in self.exception_handlers: + return + if exception_type not in self.exception_handlers[schema]: + return + + # Fire events + async for event in self.exception_handlers[schema][exception_type]: + await event(client, details) + + + # WebSocket-specific coroutines + + async def execute_send(self, client, message): + + # Convert dict to JSON + if type(message) == dict: + message = self.ujson.dumps(message) + + await client.send(message) diff --git a/cloudlink/client/protocols/__init__.py b/cloudlink/client/protocols/__init__.py new file mode 100644 index 0000000..d3c9c22 --- /dev/null +++ b/cloudlink/client/protocols/__init__.py @@ -0,0 +1,2 @@ +from .clpv4.clpv4 import * + diff --git a/cloudlink/client/protocols/clpv4/__init__.py b/cloudlink/client/protocols/clpv4/__init__.py new file mode 100644 index 0000000..a9e2996 --- /dev/null +++ b/cloudlink/client/protocols/clpv4/__init__.py @@ -0,0 +1 @@ +from .clpv4 import * diff --git a/cloudlink/client/protocols/clpv4/clpv4.py b/cloudlink/client/protocols/clpv4/clpv4.py new file mode 100644 index 0000000..f4d9fc5 --- /dev/null +++ b/cloudlink/client/protocols/clpv4/clpv4.py @@ -0,0 +1,753 @@ +from .schema import cl4_protocol + +""" +This is the default protocol used for the CloudLink server. +The CloudLink 4.1 Protocol retains full support for CLPv4. + +Each packet format is compliant with UPLv2 formatting rules. + +Documentation for the CLPv4.1 protocol can be found here: +https://hackmd.io/@MikeDEV/HJiNYwOfo +""" + + +class clpv4: + def __init__(self, server): + + """ + Configuration settings + + warn_if_multiple_username_matches: Boolean, Default: True + If True, the server will warn users if they are resolving multiple clients for a username search. + + enable_motd: Boolean, Default: False + If True, whenever a client sends the handshake command or whenever the client's protocol is identified, + the server will send the Message-Of-The-Day from whatever value motd_message is set to. + + motd_message: String, Default: Blank string + If enable_mod is True, this string will be sent as the server's Message-Of-The-Day. + + real_ip_header: String, Default: None + If you use CloudLink behind a tunneling service or reverse proxy, set this value to whatever + IP address-fetching request header to resolve valid IP addresses. When set to None, it will + utilize the host's incoming network for resolving IP addresses. + + Examples include: + * x-forwarded-for + * cf-connecting-ip + + """ + self.warn_if_multiple_username_matches = True + self.enable_motd = False + self.motd_message = str() + self.real_ip_header = None + + # Exposes the schema of the protocol. + self.schema = cl4_protocol + + # Define various status codes for the protocol. + class statuscodes: + # Code type character + info = "I" + error = "E" + + # Error / info codes as tuples + test = (info, 0, "Test") + echo = (info, 1, "Echo") + ok = (info, 100, "OK") + syntax = (error, 101, "Syntax") + datatype = (error, 102, "Datatype") + id_not_found = (error, 103, "ID not found") + id_not_specific = (error, 104, "ID not specific enough") + internal_error = (error, 105, "Internal server error") + empty_packet = (error, 106, "Empty packet") + id_already_set = (error, 107, "ID already set") + refused = (error, 108, "Refused") + invalid_command = (error, 109, "Invalid command") + disabled_command = (error, 110, "Command disabled") + id_required = (error, 111, "ID required") + id_conflict = (error, 112, "ID conflict") + too_large = (error, 113, "Too large") + json_error = (error, 114, "JSON error") + room_not_joined = (error, 115, "Room not joined") + + @staticmethod + def generate(code: tuple): + return f"{code[0]}:{code[1]} | {code[2]}", code[1] + + # Expose statuscodes class for extension usage + self.statuscodes = statuscodes + + # Identification of a client's IP address + def get_client_ip(client): + # Grab IP address using headers + if self.real_ip_header: + if self.real_ip_header in client.request_headers: + return client.request_headers.get(self.real_ip_header) + + # Use system identified IP address + if type(client.remote_address) == tuple: + return str(client.remote_address[0]) + + # Expose get_client_ip for extension usage + self.get_client_ip = get_client_ip + + # valid(message, schema): Used to verify messages. + def valid(client, message, schema): + if server.validator(message, schema): + return True + + # Alert the client that the schema was invalid + send_statuscode(client, statuscodes.syntax, details=dict(server.validator.errors)) + return False + + # Expose validator function for extension usage + self.valid = valid + + # Simplify sending error/info messages + def send_statuscode(client, code, details=None, message=None, val=None): + # Generate a statuscode + code_human, code_id = statuscodes.generate(code) + + # Template the message + tmp_message = { + "cmd": "statuscode", + "code": code_human, + "code_id": code_id + } + + if details: + tmp_message["details"] = details + + if message: + if "listener" in message: + tmp_message["listener"] = message["listener"] + + if val: + tmp_message["val"] = val + + # Send the code + server.send_packet(client, tmp_message) + + # Expose the statuscode generator for extension usage + self.send_statuscode = send_statuscode + + # Send messages with automatic listener attaching + def send_message(client, payload, message=None): + if message: + if "listener" in message: + payload["listener"] = message["listener"] + + # Send the code + server.send_packet(client, payload) + + # Expose the message sender for extension usage + self.send_message = send_message + + # Simplify alerting users that a command requires a username to be set + def require_username_set(client, message): + if not client.username_set: + send_statuscode( + client, + statuscodes.id_required, + details="This command requires setting a username.", + message=message + ) + + return client.username_set + + # Expose username requirement function for extension usage + self.require_username_set = require_username_set + + # Tool for gathering client rooms + def gather_rooms(client, message): + if "rooms" in message: + # Read value from message + rooms = message["rooms"] + + # Convert to set + if type(rooms) == str: + rooms = {rooms} + if type(rooms) == list: + rooms = set(rooms) + + return rooms + else: + # Use all subscribed rooms + return client.rooms + + # Expose rooms gatherer for extension usage + self.gather_rooms = gather_rooms + + # Generate a user object + def generate_user_object(obj): + # Username set + if obj.username_set: + return { + "id": obj.snowflake, + "username": obj.username, + "uuid": str(obj.id) + } + + # Username not set + return { + "id": obj.snowflake, + "uuid": str(obj.id) + } + + # Expose username object generator function for extension usage + self.generate_user_object = generate_user_object + + # If the client has not explicitly used the handshake command, send them the handshake data + async def automatic_notify_handshake(client): + # Don't execute this if handshake was already done + if client.handshake: + return + client.handshake = True + + # Send client IP address + server.send_packet(client, { + "cmd": "client_ip", + "val": get_client_ip(client) + }) + + # Send server version + server.send_packet(client, { + "cmd": "server_version", + "val": server.version + }) + + # Send Message-Of-The-Day + if self.enable_motd: + server.send_packet(client, { + "cmd": "motd", + "val": self.motd_message + }) + + # Send client's Snowflake ID + server.send_packet(client, { + "cmd": "client_obj", + "val": generate_user_object(client) + }) + + # Send userlists of rooms + async for room in server.async_iterable(client.rooms): + server.send_packet(client, { + "cmd": "ulist", + "val": { + "mode": "set", + "val": server.rooms_manager.generate_userlist(room, cl4_protocol) + }, + "room": room + }) + + # Expose for extension usage + self.automatic_notify_handshake = automatic_notify_handshake + + # The CLPv4 command set + + @server.on_command(cmd="handshake", schema=cl4_protocol) + async def on_handshake(client, message): + await automatic_notify_handshake(client) + send_statuscode(client, statuscodes.ok, message=message) + + @server.on_command(cmd="ping", schema=cl4_protocol) + async def on_ping(client, message): + await automatic_notify_handshake(client) + send_statuscode(client, statuscodes.ok, message=message) + + @server.on_command(cmd="gmsg", schema=cl4_protocol) + async def on_gmsg(client, message): + await automatic_notify_handshake(client) + + # Validate schema + if not valid(client, message, cl4_protocol.gmsg): + return + + # Gather rooms to send to + rooms = gather_rooms(client, message) + + # Broadcast to all subscribed rooms + async for room in server.async_iterable(rooms): + + # Prevent accessing rooms not joined + if room not in client.rooms: + send_statuscode( + client, + statuscodes.room_not_joined, + details=f'Attempted to access room {room} while not joined.', + message=message + ) + + # Stop gmsg command + return + + clients = await server.rooms_manager.get_all_in_rooms(room, cl4_protocol) + clients = server.copy(clients) + + # Attach listener (if present) and broadcast + if "listener" in message: + + # Remove originating client from broadcast + clients.remove(client) + + # Define the message to broadcast + tmp_message = { + "cmd": "gmsg", + "val": message["val"] + } + + # Broadcast message + server.send_packet(clients, tmp_message) + + # Define the message to send + tmp_message = { + "cmd": "gmsg", + "val": message["val"], + "listener": message["listener"], + "room": room + } + + # Unicast message + server.send_packet(client, tmp_message) + else: + # Broadcast message + server.send_packet(clients, { + "cmd": "gmsg", + "val": message["val"], + "room": room + }) + + @server.on_command(cmd="pmsg", schema=cl4_protocol) + async def on_pmsg(client, message): + await automatic_notify_handshake(client) + + # Validate schema + if not valid(client, message, cl4_protocol.pmsg): + return + + # Require sending client to have set their username + if not require_username_set(client, message): + return + + # Gather rooms + rooms = gather_rooms(client, message) + + # Search and send to all specified clients in rooms + any_results_found = False + async for room in server.async_iterable(rooms): + + # Prevent accessing rooms not joined + if room not in client.rooms: + send_statuscode( + client, + statuscodes.room_not_joined, + details=f'Attempted to access room {room} while not joined.', + message=message + ) + + # Stop pmsg command + return + + clients = await server.rooms_manager.get_specific_in_room(room, cl4_protocol, message['id']) + + # Continue if no results are found + if not len(clients): + continue + + # Mark the full search OK + if not any_results_found: + any_results_found = True + + # Warn if multiple matches are found (mainly for username queries) + if self.warn_if_multiple_username_matches and len(clients) >> 1: + send_statuscode( + client, + statuscodes.id_not_specific, + details=f'Multiple matches found for {message["id"]}, found {len(clients)} matches. Please use Snowflakes, UUIDs, or client objects instead.', + message=message + ) + + # Stop pmsg command + return + + # Send message + tmp_message = { + "cmd": "pmsg", + "val": message["val"], + "origin": generate_user_object(client), + "room": room + } + server.send_packet(clients, tmp_message) + + if not any_results_found: + send_statuscode( + client, + statuscodes.id_not_found, + details=f'No matches found: {message["id"]}', + message=message + ) + + # End pmsg command handler + return + + # Results were found and sent successfully + send_statuscode( + client, + statuscodes.ok, + message=message + ) + + @server.on_command(cmd="gvar", schema=cl4_protocol) + async def on_gvar(client, message): + await automatic_notify_handshake(client) + + # Validate schema + if not valid(client, message, cl4_protocol.gvar): + return + + # Gather rooms to send to + rooms = gather_rooms(client, message) + + # Broadcast to all subscribed rooms + async for room in server.async_iterable(rooms): + + # Prevent accessing rooms not joined + if room not in client.rooms: + send_statuscode( + client, + statuscodes.room_not_joined, + details=f'Attempted to access room {room} while not joined.', + message=message + ) + + # Stop gvar command + return + + clients = await server.rooms_manager.get_all_in_rooms(room, cl4_protocol) + clients = server.copy(clients) + + # Define the message to send + tmp_message = { + "cmd": "gvar", + "name": message["name"], + "val": message["val"], + "room": room + } + + # Attach listener (if present) and broadcast + if "listener" in message: + clients.remove(client) + server.send_packet(clients, tmp_message) + tmp_message["listener"] = message["listener"] + server.send_packet(client, tmp_message) + else: + server.send_packet(clients, tmp_message) + + @server.on_command(cmd="pvar", schema=cl4_protocol) + async def on_pvar(client, message): + await automatic_notify_handshake(client) + + # Validate schema + if not valid(client, message, cl4_protocol.pvar): + return + + # Require sending client to have set their username + if not require_username_set(client, message): + return + + # Gather rooms + rooms = gather_rooms(client, message) + + # Search and send to all specified clients in rooms + any_results_found = False + async for room in server.async_iterable(rooms): + + # Prevent accessing rooms not joined + if room not in client.rooms: + send_statuscode( + client, + statuscodes.room_not_joined, + details=f'Attempted to access room {room} while not joined.', + message=message + ) + + # Stop pvar command + return + + clients = await server.rooms_manager.get_specific_in_room(room, cl4_protocol, message['id']) + clients = server.copy(clients) + + # Continue if no results are found + if not len(clients): + continue + + # Mark the full search OK + if not any_results_found: + any_results_found = True + + # Warn if multiple matches are found (mainly for username queries) + if self.warn_if_multiple_username_matches and len(clients) >> 1: + send_statuscode( + client, + statuscodes.id_not_specific, + details=f'Multiple matches found for {message["id"]}, found {len(clients)} matches. Please use Snowflakes, UUIDs, or client objects instead.', + message=message + ) + + # Stop pvar command + return + + # Send message + tmp_message = { + "cmd": "pvar", + "name": message["name"], + "val": message["val"], + "origin": generate_user_object(client), + "room": room + } + server.send_packet(clients, tmp_message) + + if not any_results_found: + send_statuscode( + client, + statuscodes.id_not_found, + details=f'No matches found: {message["id"]}', + message=message + ) + + # End pmsg command handler + return + + # Results were found and sent successfully + send_statuscode( + client, + statuscodes.ok, + message=message + ) + + @server.on_command(cmd="setid", schema=cl4_protocol) + async def on_setid(client, message): + await automatic_notify_handshake(client) + + # Validate schema + if not valid(client, message, cl4_protocol.setid): + return + + # Prevent setting the username more than once + if client.username_set: + server.logger.error(f"Client {client.snowflake} attempted to set username again!") + send_statuscode( + client, + statuscodes.id_already_set, + val=generate_user_object(client), + message=message + ) + + # Exit setid command + return + + # Leave default room + server.rooms_manager.unsubscribe(client, "default") + + # Set the username + server.clients_manager.set_username(client, message['val']) + + # Re-join default room + server.rooms_manager.subscribe(client, "default") + + # Broadcast userlist state to existing members + clients = await server.rooms_manager.get_all_in_rooms("default", cl4_protocol) + clients = server.copy(clients) + clients.remove(client) + server.send_packet(clients, { + "cmd": "ulist", + "val": { + "mode": "add", + "val": generate_user_object(client) + }, + "room": "default" + }) + + # Notify client of current room state + server.send_packet(client, { + "cmd": "ulist", + "val": { + "mode": "set", + "val": server.rooms_manager.generate_userlist("default", cl4_protocol) + }, + "room": "default" + }) + + # Attach listener (if present) and broadcast + send_statuscode( + client, + statuscodes.ok, + val=generate_user_object(client), + message=message + ) + + @server.on_command(cmd="link", schema=cl4_protocol) + async def on_link(client, message): + await automatic_notify_handshake(client) + + # Validate schema + if not valid(client, message, cl4_protocol.linking): + return + + # Require sending client to have set their username + if not require_username_set(client, message): + return + + # Clear all rooms beforehand + async for room in server.async_iterable(client.rooms): + server.rooms_manager.unsubscribe(client, room) + + # Convert to set + if type(message["val"]) in [list, str]: + if type(message["val"]) == list: + message["val"] = set(message["val"]) + if type(message["val"]) == str: + message["val"] = {message["val"]} + + async for room in server.async_iterable(message["val"]): + server.rooms_manager.subscribe(client, room) + + # Broadcast userlist state to existing members + clients = await server.rooms_manager.get_all_in_rooms(room, cl4_protocol) + clients = server.copy(clients) + clients.remove(client) + server.send_packet(clients, { + "cmd": "ulist", + "val": { + "mode": "add", + "val": generate_user_object(client) + }, + "room": room + }) + + # Notify client of current room state + server.send_packet(client, { + "cmd": "ulist", + "val": { + "mode": "set", + "val": server.rooms_manager.generate_userlist(room, cl4_protocol) + }, + "room": room + }) + + # Attach listener (if present) and broadcast + send_statuscode( + client, + statuscodes.ok, + message=message + ) + + @server.on_command(cmd="unlink", schema=cl4_protocol) + async def on_unlink(client, message): + await automatic_notify_handshake(client) + + # Validate schema + if not valid(client, message, cl4_protocol.linking): + return + + # Require sending client to have set their username + if not require_username_set(client, message): + return + + # Convert to set + if type(message["val"]) in [list, str]: + if type(message["val"]) == list: + message["val"] = set(message["val"]) + if type(message["val"]) == str: + message["val"] = {message["val"]} + + async for room in server.async_iterable(message["val"]): + server.rooms_manager.unsubscribe(client, room) + + # Broadcast userlist state to existing members + clients = await server.rooms_manager.get_all_in_rooms(room, cl4_protocol) + clients = server.copy(clients) + clients.remove(client) + server.send_packet(clients, { + "cmd": "ulist", + "val": { + "mode": "remove", + "val": generate_user_object(client) + }, + "room": room + }) + + # Re-link to default room if no rooms are joined + if not len(client.rooms): + server.rooms_manager.subscribe(client, "default") + + # Broadcast userlist state to existing members + clients = await server.rooms_manager.get_all_in_rooms("default", cl4_protocol) + clients = server.copy(clients) + clients.remove(client) + server.send_packet(clients, { + "cmd": "ulist", + "val": { + "mode": "add", + "val": generate_user_object(client) + }, + "room": "default" + }) + + # Notify client of current room state + server.send_packet(client, { + "cmd": "ulist", + "val": { + "mode": "set", + "val": server.rooms_manager.generate_userlist("default", cl4_protocol) + }, + "room": "default" + }) + + # Attach listener (if present) and broadcast + send_statuscode( + client, + statuscodes.ok, + message=message + ) + + @server.on_command(cmd="direct", schema=cl4_protocol) + async def on_direct(client, message): + await automatic_notify_handshake(client) + + # Validate schema + if not valid(client, message, cl4_protocol.direct): + return + + try: + tmp_client = server.clients_manager.find_obj(message["id"]) + + tmp_msg = { + "cmd": "direct", + "val": message["val"] + } + + if client.username_set: + tmp_msg["origin"] = generate_user_object(client) + + else: + tmp_msg["origin"] = { + "id": client.snowflake, + "uuid": str(client.id) + } + + if "listener" in message: + tmp_msg["listener"] = message["listener"] + + server.send_packet_unicast(tmp_client, tmp_msg) + + except server.clients_manager.exceptions.NoResultsFound: + send_statuscode( + client, + statuscodes.id_not_found, + message=message + ) + + # Stop direct command + return diff --git a/cloudlink/client/protocols/clpv4/schema.py b/cloudlink/client/protocols/clpv4/schema.py new file mode 100644 index 0000000..946a93e --- /dev/null +++ b/cloudlink/client/protocols/clpv4/schema.py @@ -0,0 +1,340 @@ +# Schema for interpreting the Cloudlink protocol v4.0 (CLPv4) command set +class cl4_protocol: + + # Required - Defines the keyword to use to define the command + command_key = "cmd" + + # Required - Defines the default schema to test against + default = { + "cmd": { + "type": "string", + "required": True + }, + "val": { + "type": [ + "string", + "integer", + "float", + "number", + "boolean", + "dict", + "list", + "set", + ], + "required": False, + }, + "name": { + "type": "string", + "required": False + }, + "id": { + "type": [ + "string", + "dict", + "list", + "set" + ], + "required": False + }, + "listener": { + "type": [ + "string", + "integer", + "float", + "boolean", + "number" + ], + "required": False + }, + "rooms": { + "type": [ + "string", + "integer", + "float", + "boolean", + "number", + "list", + "set" + ], + "required": False + } + } + + linking = { + "cmd": { + "type": "string", + "required": True + }, + "val": { + "type": [ + "string", + "integer", + "float", + "number", + "boolean", + "dict", + "list", + "set", + ], + "required": True, + }, + "listener": { + "type": [ + "string", + "integer", + "float", + "boolean", + "number" + ], + "required": False + } + } + + setid = { + "cmd": { + "type": "string", + "required": True + }, + "val": { + "type": "string", + "required": True + }, + "listener": { + "type": [ + "string", + "integer", + "float", + "boolean", + "number" + ], + "required": False + } + } + + gmsg = { + "cmd": { + "type": "string", + "required": True + }, + "val": { + "type": [ + "string", + "integer", + "float", + "number", + "boolean", + "dict", + "list", + "set", + ], + "required": True + }, + "listener": { + "type": [ + "string", + "integer", + "float", + "boolean", + "number" + ], + "required": False + }, + "rooms": { + "type": [ + "string", + "integer", + "float", + "boolean", + "number", + "list", + "set" + ], + "required": False + } + } + + gvar = { + "cmd": { + "type": "string", + "required": True + }, + "name": { + "type": "string", + "required": True + }, + "val": { + "type": [ + "string", + "integer", + "float", + "number", + "boolean", + "dict", + "list", + "set", + ], + "required": True + }, + "listener": { + "type": [ + "string", + "integer", + "float", + "boolean", + "number" + ], + "required": False + }, + "rooms": { + "type": [ + "string", + "integer", + "float", + "boolean", + "number", + "list", + "set" + ], + "required": False + } + } + + pmsg = { + "cmd": { + "type": "string", + "required": True + }, + "id": { + "type": [ + "string", + "dict", + "list", + "set" + ], + "required": True + }, + "val": { + "type": [ + "string", + "integer", + "float", + "number", + "boolean", + "dict", + "list", + "set", + ], + "required": True + }, + "listener": { + "type": [ + "string", + "integer", + "float", + "boolean", + "number" + ], + "required": False + }, + "rooms": { + "type": [ + "string", + "integer", + "float", + "boolean", + "number", + "list", + "set" + ], + "required": False + } + } + + direct = { + "cmd": { + "type": "string", + "required": True + }, + "id": { + "type": "string", + "required": True + }, + "val": { + "type": [ + "string", + "integer", + "float", + "number", + "boolean", + "dict", + "list", + "set", + ], + "required": True + }, + "listener": { + "type": [ + "string", + "integer", + "float", + "boolean", + "number" + ], + "required": False + } + } + + pvar = { + "cmd": { + "type": "string", + "required": True + }, + "name": { + "type": "string", + "required": True + }, + "id": { + "type": [ + "string", + "dict", + "list", + "set" + ], + "required": True + }, + "val": { + "type": [ + "string", + "integer", + "float", + "number", + "boolean", + "dict", + "list", + "set", + ], + "required": True + }, + "listener": { + "type": [ + "string", + "integer", + "float", + "boolean", + "number" + ], + "required": False + }, + "rooms": { + "type": [ + "string", + "integer", + "float", + "boolean", + "number", + "list", + "set" + ], + "required": False + } + } \ No newline at end of file diff --git a/main.py b/server.py similarity index 100% rename from main.py rename to server.py diff --git a/test.py b/test.py deleted file mode 100644 index 55e700d..0000000 --- a/test.py +++ /dev/null @@ -1,23 +0,0 @@ -from fastapi import FastAPI, WebSocket -import random -import uvicorn - -# Create application -app = FastAPI(title='WebSocket Example') - -@app.websocket("/") -async def websocket_endpoint(websocket: WebSocket): - await websocket.accept() - while True: - try: - # Wait for any message from the client - message = await websocket.receive_text() - print(message) - # echo - await websocket.send_text(message) - except Exception as e: - print('error:', e) - break - -if __name__ == "__main__": - uvicorn.run(app, port=3001, log_level="debug") \ No newline at end of file From c3c3b3763928d263a70310fb281aafedf7ff7718 Mon Sep 17 00:00:00 2001 From: "Mike J. Renaker / \"MikeDEV" Date: Mon, 27 Mar 2023 17:15:43 -0400 Subject: [PATCH 31/59] Fix rooms / Userlists They weren't CLPv4 compliant. --- cloudlink/server/protocols/clpv4/clpv4.py | 76 +++++++++-------------- 1 file changed, 30 insertions(+), 46 deletions(-) diff --git a/cloudlink/server/protocols/clpv4/clpv4.py b/cloudlink/server/protocols/clpv4/clpv4.py index 96310a1..b626a86 100644 --- a/cloudlink/server/protocols/clpv4/clpv4.py +++ b/cloudlink/server/protocols/clpv4/clpv4.py @@ -234,11 +234,9 @@ async def automatic_notify_handshake(client): async for room in server.async_iterable(client.rooms): server.send_packet(client, { "cmd": "ulist", - "val": { - "mode": "set", - "val": server.rooms_manager.generate_userlist(room, cl4_protocol) - }, - "room": room + "mode": "set", + "val": server.rooms_manager.generate_userlist(room, cl4_protocol) + "rooms": room }) # Expose for extension usage @@ -310,11 +308,9 @@ async def protocol_disconnect(client): clients = server.copy(clients) server.send_packet(clients, { "cmd": "ulist", - "val": { - "mode": "remove", - "val": generate_user_object(client) - }, - "room": room_id + "mode": "remove", + "val": generate_user_object(client), + "rooms": room_id }) # The CLPv4 command set @@ -378,7 +374,7 @@ async def on_gmsg(client, message): "cmd": "gmsg", "val": message["val"], "listener": message["listener"], - "room": room + "rooms": room } # Unicast message @@ -388,7 +384,7 @@ async def on_gmsg(client, message): server.send_packet(clients, { "cmd": "gmsg", "val": message["val"], - "room": room + "rooms": room }) @server.on_command(cmd="pmsg", schema=cl4_protocol) @@ -449,7 +445,7 @@ async def on_pmsg(client, message): "cmd": "pmsg", "val": message["val"], "origin": generate_user_object(client), - "room": room + "rooms": room } server.send_packet(clients, tmp_message) @@ -505,7 +501,7 @@ async def on_gvar(client, message): "cmd": "gvar", "name": message["name"], "val": message["val"], - "room": room + "rooms": room } # Attach listener (if present) and broadcast @@ -577,7 +573,7 @@ async def on_pvar(client, message): "name": message["name"], "val": message["val"], "origin": generate_user_object(client), - "room": room + "rooms": room } server.send_packet(clients, tmp_message) @@ -635,21 +631,17 @@ async def on_setid(client, message): clients.remove(client) server.send_packet(clients, { "cmd": "ulist", - "val": { - "mode": "add", - "val": generate_user_object(client) - }, - "room": "default" + "mode": "add", + "val": generate_user_object(client), + "rooms": "default" }) # Notify client of current room state server.send_packet(client, { "cmd": "ulist", - "val": { - "mode": "set", - "val": server.rooms_manager.generate_userlist("default", cl4_protocol) - }, - "room": "default" + "mode": "set", + "val": server.rooms_manager.generate_userlist("default", cl4_protocol), + "rooms": "default" }) # Attach listener (if present) and broadcast @@ -696,17 +688,15 @@ async def on_link(client, message): "mode": "add", "val": generate_user_object(client) }, - "room": room + "rooms": room }) # Notify client of current room state server.send_packet(client, { "cmd": "ulist", - "val": { - "mode": "set", - "val": server.rooms_manager.generate_userlist(room, cl4_protocol) - }, - "room": room + "mode": "set", + "val": server.rooms_manager.generate_userlist(room, cl4_protocol), + "rooms": room }) # Attach listener (if present) and broadcast @@ -744,11 +734,9 @@ async def on_unlink(client, message): clients.remove(client) server.send_packet(clients, { "cmd": "ulist", - "val": { - "mode": "remove", - "val": generate_user_object(client) - }, - "room": room + "mode": "remove", + "val": generate_user_object(client), + "rooms": room }) # Re-link to default room if no rooms are joined @@ -761,21 +749,17 @@ async def on_unlink(client, message): clients.remove(client) server.send_packet(clients, { "cmd": "ulist", - "val": { - "mode": "add", - "val": generate_user_object(client) - }, - "room": "default" + "mode": "add", + "val": generate_user_object(client), + "rooms": "default" }) # Notify client of current room state server.send_packet(client, { "cmd": "ulist", - "val": { - "mode": "set", - "val": server.rooms_manager.generate_userlist("default", cl4_protocol) - }, - "room": "default" + "mode": "set", + "val": server.rooms_manager.generate_userlist("default", cl4_protocol), + "rooms": "default" }) # Attach listener (if present) and broadcast From 71bd54b15caa928128235bb1de8c9c512521a95c Mon Sep 17 00:00:00 2001 From: "Mike J. Renaker / \"MikeDEV" Date: Mon, 27 Mar 2023 17:19:13 -0400 Subject: [PATCH 32/59] forgot a comma --- cloudlink/server/protocols/clpv4/clpv4.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cloudlink/server/protocols/clpv4/clpv4.py b/cloudlink/server/protocols/clpv4/clpv4.py index b626a86..5782aa3 100644 --- a/cloudlink/server/protocols/clpv4/clpv4.py +++ b/cloudlink/server/protocols/clpv4/clpv4.py @@ -235,7 +235,7 @@ async def automatic_notify_handshake(client): server.send_packet(client, { "cmd": "ulist", "mode": "set", - "val": server.rooms_manager.generate_userlist(room, cl4_protocol) + "val": server.rooms_manager.generate_userlist(room, cl4_protocol), "rooms": room }) From 59823c9d5e1a506eae213b93973fbf9379cd6155 Mon Sep 17 00:00:00 2001 From: "Mike J. Renaker / \"MikeDEV" Date: Mon, 27 Mar 2023 17:32:55 -0400 Subject: [PATCH 33/59] Hotfixes * Fix link broadcasts * Restore "unlink from all" rooms * Restore some legacy functionality with room linking --- cloudlink/server/protocols/clpv4/clpv4.py | 29 ++++++++++++++++------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/cloudlink/server/protocols/clpv4/clpv4.py b/cloudlink/server/protocols/clpv4/clpv4.py index 5782aa3..c430fdc 100644 --- a/cloudlink/server/protocols/clpv4/clpv4.py +++ b/cloudlink/server/protocols/clpv4/clpv4.py @@ -664,16 +664,27 @@ async def on_link(client, message): if not require_username_set(client, message): return - # Clear all rooms beforehand - async for room in server.async_iterable(client.rooms): - server.rooms_manager.unsubscribe(client, room) - # Convert to set if type(message["val"]) in [list, str]: if type(message["val"]) == list: message["val"] = set(message["val"]) if type(message["val"]) == str: message["val"] = {message["val"]} + + # Unsubscribe from default room if not mentioned + if not "default" in message["val"]: + server.rooms_manager.unsubscribe(client, "default") + + # Broadcast userlist state to existing members + clients = await server.rooms_manager.get_all_in_rooms("default", cl4_protocol) + clients = server.copy(clients) + clients.remove(client) + server.send_packet(clients, { + "cmd": "ulist", + "mode": "remove", + "val": generate_user_object(client), + "rooms": room + }) async for room in server.async_iterable(message["val"]): server.rooms_manager.subscribe(client, room) @@ -684,10 +695,8 @@ async def on_link(client, message): clients.remove(client) server.send_packet(clients, { "cmd": "ulist", - "val": { - "mode": "add", - "val": generate_user_object(client) - }, + "mode": "add", + "val": generate_user_object(client), "rooms": room }) @@ -718,6 +727,10 @@ async def on_unlink(client, message): if not require_username_set(client, message): return + # If blank, assume all rooms + if type(message["val"]) == str and not len(message["val"]): + message["val"] = client.rooms + # Convert to set if type(message["val"]) in [list, str]: if type(message["val"]) == list: From 05953f4eebdf2d45916165b2adb87b593b60fbc9 Mon Sep 17 00:00:00 2001 From: "Mike J. Renaker / \"MikeDEV" Date: Mon, 27 Mar 2023 17:39:55 -0400 Subject: [PATCH 34/59] More hotfixes * Fix for unlinking * Fix for links not working properly --- cloudlink/server/protocols/clpv4/clpv4.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/cloudlink/server/protocols/clpv4/clpv4.py b/cloudlink/server/protocols/clpv4/clpv4.py index c430fdc..07cb7c3 100644 --- a/cloudlink/server/protocols/clpv4/clpv4.py +++ b/cloudlink/server/protocols/clpv4/clpv4.py @@ -678,12 +678,11 @@ async def on_link(client, message): # Broadcast userlist state to existing members clients = await server.rooms_manager.get_all_in_rooms("default", cl4_protocol) clients = server.copy(clients) - clients.remove(client) server.send_packet(clients, { "cmd": "ulist", "mode": "remove", "val": generate_user_object(client), - "rooms": room + "rooms": "default" }) async for room in server.async_iterable(message["val"]): @@ -744,7 +743,6 @@ async def on_unlink(client, message): # Broadcast userlist state to existing members clients = await server.rooms_manager.get_all_in_rooms(room, cl4_protocol) clients = server.copy(clients) - clients.remove(client) server.send_packet(clients, { "cmd": "ulist", "mode": "remove", From 3bb50a813f4e2d53012ff5b38f4ec37574422f48 Mon Sep 17 00:00:00 2001 From: "Mike J. Renaker / \"MikeDEV" Date: Mon, 27 Mar 2023 20:42:18 -0400 Subject: [PATCH 35/59] More client WIP --- client.py | 8 +- cloudlink/client/__init__.py | 157 ++++++++++++----------------------- 2 files changed, 58 insertions(+), 107 deletions(-) diff --git a/client.py b/client.py index 2c79e27..06c37ae 100644 --- a/client.py +++ b/client.py @@ -13,8 +13,10 @@ # Load protocols clpv4 = clpv4(client) - # Initialize SSL support - client.enable_ssl(certfile="cert.pem") + @client.on_connect + async def on_connect(): + client.send_packet({"cmd": "handshake"}) + print("Connected") # Start the server - client.run(host="wss://cloudlink.ddns.net:3000/") + client.run(host="ws://127.0.0.1:3000/") diff --git a/cloudlink/client/__init__.py b/cloudlink/client/__init__.py index aca0abe..5e2e3c4 100644 --- a/cloudlink/client/__init__.py +++ b/cloudlink/client/__init__.py @@ -63,6 +63,7 @@ def __init__(self): # Configure websocket framework self.ws = websockets + self.client = None # Components self.ujson = ujson @@ -175,9 +176,9 @@ def on_error(self, func): self.on_error_events.bind(func) # Send message - def send_packet(self, client, message): + def send_packet(self, message): # Create unicast task - self.asyncio.create_task(self.execute_send(client, message)) + self.asyncio.create_task(self.execute_send(message)) # Close the connection to client(s) def close_connection(self, obj, code=1000, reason=""): @@ -225,25 +226,19 @@ async def message_processor(self, client, message): # Empty packet if not len(message): - self.logger.debug(f"Client {client.snowflake} sent empty message ") + self.logger.debug(f"Server sent empty message ") # Fire on_error events - asyncio.create_task(self.execute_on_error_events(client, self.exceptions.EmptyMessage)) + asyncio.create_task(self.execute_on_error_events(self.exceptions.EmptyMessage)) # Fire exception handling events - if client.protocol_set: - self.asyncio.create_task( - self.execute_exception_handlers( - client=client, - exception_type=self.exceptions.EmptyMessage, - schema=client.protocol, - details="Empty message" - ) + self.asyncio.create_task( + self.execute_exception_handlers( + exception_type=self.exceptions.EmptyMessage, + schema=client.protocol, + details="Empty message" ) - else: - # Close the connection - self.send_packet(client, "Empty message") - self.close_connection(client, reason="Empty message") + ) # End message_processor coroutine return @@ -253,16 +248,15 @@ async def message_processor(self, client, message): message = self.ujson.loads(message) except Exception as error: - self.logger.debug(f"Client {client.snowflake} sent invalid JSON: {error}") + self.logger.debug(f"Server sent invalid JSON: {error}") # Fire on_error events - self.asyncio.create_task(self.execute_on_error_events(client, error)) + self.asyncio.create_task(self.execute_on_error_events(error)) # Fire exception handling events if client.protocol_set: self.asyncio.create_task( self.execute_exception_handlers( - client=client, exception_type=self.exceptions.JSONError, schema=client.protocol, details=error @@ -271,7 +265,7 @@ async def message_processor(self, client, message): else: # Close the connection - self.send_packet(client, "Invalid JSON") + self.send_packet("Invalid JSON") self.close_connection(client, reason="Invalid JSON") # End message_processor coroutine @@ -283,7 +277,7 @@ async def message_processor(self, client, message): # Client protocol is unknown if not client.protocol: - self.logger.debug(f"Trying to identify client {client.snowflake}'s protocol") + self.logger.debug(f"Trying to identify server's protocol") # Identify protocol errorlist = list() @@ -298,30 +292,24 @@ async def message_processor(self, client, message): if not valid: # Log failed identification - self.logger.debug(f"Could not identify protocol used by client {client.snowflake}: {errorlist}") + self.logger.debug(f"Could not identify protocol used by server: {errorlist}") # Fire on_error events - self.asyncio.create_task(self.execute_on_error_events(client, "Unable to identify protocol")) + self.asyncio.create_task(self.execute_on_error_events("Unable to identify protocol")) # Close the connection - self.send_packet(client, "Unable to identify protocol") + self.send_packet("Unable to identify protocol") self.close_connection(client, reason="Unable to identify protocol") # End message_processor coroutine return # Log known protocol - self.logger.debug(f"Client {client.snowflake} is using protocol {selected_protocol.__qualname__}") - - # Make the client's protocol known - self.clients_manager.set_protocol(client, selected_protocol) - - # Fire protocol identified events - self.asyncio.create_task(self.execute_protocol_identified_events(client, selected_protocol)) + self.logger.debug(f"Server is using protocol {selected_protocol.__qualname__}") else: self.logger.debug( - f"Validating message from {client.snowflake} using protocol {client.protocol.__qualname__}") + f"Validating message from server using protocol {client.protocol.__qualname__}") # Validate message using known protocol selected_protocol = client.protocol @@ -330,16 +318,15 @@ async def message_processor(self, client, message): errors = self.validator.errors # Log failed validation - self.logger.debug(f"Client {client.snowflake} sent message that failed validation: {errors}") + self.logger.debug(f"Server sent message that failed validation: {errors}") # Fire on_error events - self.asyncio.create_task(self.execute_on_error_events(client, errors)) + self.asyncio.create_task(self.execute_on_error_events(errors)) # Fire exception handling events if client.protocol_set: self.asyncio.create_task( self.execute_exception_handlers( - client=client, exception_type=self.exceptions.ValidationError, schema=client.protocol, details=errors @@ -354,16 +341,15 @@ async def message_processor(self, client, message): # Log invalid command self.logger.debug( - f"Client {client.snowflake} sent an invalid command \"{message[selected_protocol.command_key]}\" in protocol {selected_protocol.__qualname__}") + f"Server sent an invalid command \"{message[selected_protocol.command_key]}\" in protocol {selected_protocol.__qualname__}") # Fire on_error events - self.asyncio.create_task(self.execute_on_error_events(client, "Invalid command")) + self.asyncio.create_task(self.execute_on_error_events("Invalid command")) # Fire exception handling events if client.protocol_set: self.asyncio.create_task( self.execute_exception_handlers( - client=client, exception_type=self.exceptions.InvalidCommand, schema=client.protocol, details=message[selected_protocol.command_key] @@ -373,28 +359,9 @@ async def message_processor(self, client, message): # End message_processor coroutine return - # Check if the command is disabled - if selected_protocol in self.disabled_commands: - if message[selected_protocol.command_key] in self.disabled_commands[selected_protocol]: - self.logger.debug( - f"Client {client.snowflake} sent a disabled command \"{message[selected_protocol.command_key]}\" in protocol {selected_protocol.__qualname__}") - - # Fire disabled command event - self.asyncio.create_task( - self.execute_disabled_command_events( - client, - selected_protocol, - message[selected_protocol.command_key] - ) - ) - - # End message_processor coroutine - return - # Fire on_command events self.asyncio.create_task( self.execute_on_command_events( - client, message, selected_protocol ) @@ -403,7 +370,6 @@ async def message_processor(self, client, message): # Fire on_message events self.asyncio.create_task( self.execute_on_message_events( - client, message ) ) @@ -423,31 +389,19 @@ async def connection_handler(self, client): # Begin tracking the lifetime of the client client.birth_time = time.monotonic() - # Add to clients manager - self.clients_manager.add(client) - # Fire on_connect events - self.asyncio.create_task(self.execute_on_connect_events(client)) + self.asyncio.create_task(self.execute_on_connect_events()) - self.logger.debug(f"Client {client.snowflake} connected") + self.logger.debug(f"Client connected") # Run connection loop await self.connection_loop(client) - # Remove from clients manager - self.clients_manager.remove(client) - # Fire on_disconnect events - self.asyncio.create_task(self.execute_on_disconnect_events(client)) - - # Execute all protocol-specific disconnect events - if client.protocol_set: - self.asyncio.create_task( - self.execute_protocol_disconnect_events(client, client.protocol) - ) + self.asyncio.create_task(self.execute_on_disconnect_events()) self.logger.debug( - f"Client {client.snowflake} disconnected: Total lifespan of {time.monotonic() - client.birth_time} seconds.") + f"Client disconnected: Total lifespan of {time.monotonic() - client.birth_time} seconds.") # Connection loop - Redefine for use with another outside library async def connection_loop(self, client): @@ -456,14 +410,14 @@ async def connection_loop(self, client): async for message in client: # Start keeping track of processing time start = time.perf_counter() - self.logger.debug(f"Now processing message from client {client.snowflake}...") + self.logger.debug(f"Now processing message from server...") # Process the message await self.message_processor(client, message) # Log processing time self.logger.debug( - f"Done processing message from client {client.snowflake}. Processing took {time.perf_counter() - start} seconds.") + f"Done processing message from server. Processing took {time.perf_counter() - start} seconds.") # Handle unexpected disconnects except self.ws.exceptions.ConnectionClosedError: @@ -478,48 +432,45 @@ async def connection_loop(self, client): self.logger.critical(f"Unexpected exception was raised: {e}") # Fire on_error events - self.asyncio.create_task(self.execute_on_error_events(client, f"Unexpected exception was raised: {e}")) + self.asyncio.create_task(self.execute_on_error_events(f"Unexpected exception was raised: {e}")) # Fire exception handling events - if client.protocol_set: - self.asyncio.create_task( - self.execute_exception_handlers( - client=client, - exception_type=self.exceptions.InternalError, - schema=client.protocol, - details=f"Unexpected exception was raised: {e}" - ) + self.asyncio.create_task( + self.execute_exception_handlers( + exception_type=self.exceptions.InternalError, + schema=client.protocol, + details=f"Unexpected exception was raised: {e}" ) + ) # WebSocket-specific server loop async def __run__(self, host): - async with self.ws.connect(host) as client: - message = await client.recv() - print(message) + async with self.ws.connect(host) as self.client: + await self.connection_handler(self.client) # Asyncio event-handling coroutines - async def execute_on_disconnect_events(self, client): + async def execute_on_disconnect_events(self): async for event in self.on_disconnect_events: - await event(client) + await event() - async def execute_on_connect_events(self, client): + async def execute_on_connect_events(self): async for event in self.on_connect_events: - await event(client) + await event() - async def execute_on_message_events(self, client, message): + async def execute_on_message_events(self, message): async for event in self.on_message_events: - await event(client, message) + await event(message) - async def execute_on_command_events(self, client, message, schema): + async def execute_on_command_events(self, message, schema): async for event in self.command_handlers[schema][message[schema.command_key]]: - await event(client, message) + await event(message) - async def execute_on_error_events(self, client, errors): + async def execute_on_error_events(self, errors): async for event in self.on_error_events: - await event(client, errors) + await event(errors) - async def execute_exception_handlers(self, client, exception_type, schema, details): + async def execute_exception_handlers(self, exception_type, schema, details): # Guard clauses if schema not in self.exception_handlers: return @@ -528,15 +479,13 @@ async def execute_exception_handlers(self, client, exception_type, schema, detai # Fire events async for event in self.exception_handlers[schema][exception_type]: - await event(client, details) + await event(details) # WebSocket-specific coroutines - async def execute_send(self, client, message): - + async def execute_send(self, message): # Convert dict to JSON if type(message) == dict: message = self.ujson.dumps(message) - - await client.send(message) + await self.client.send(message) From 094ed3c23803ebb83b8a28534478a8f3c77767c5 Mon Sep 17 00:00:00 2001 From: "Mike J. Renaker / \"MikeDEV" Date: Wed, 29 Mar 2023 18:00:42 -0400 Subject: [PATCH 36/59] Client is almost done ^ --- client.py | 6 +- cloudlink/client/__init__.py | 328 ++++---- cloudlink/client/protocol.py | 121 +++ cloudlink/client/protocols/__init__.py | 2 - cloudlink/client/protocols/clpv4/__init__.py | 1 - cloudlink/client/protocols/clpv4/clpv4.py | 753 ------------------ .../client/{protocols/clpv4 => }/schema.py | 14 +- 7 files changed, 287 insertions(+), 938 deletions(-) create mode 100644 cloudlink/client/protocol.py delete mode 100644 cloudlink/client/protocols/__init__.py delete mode 100644 cloudlink/client/protocols/clpv4/__init__.py delete mode 100644 cloudlink/client/protocols/clpv4/clpv4.py rename cloudlink/client/{protocols/clpv4 => }/schema.py (96%) diff --git a/client.py b/client.py index 06c37ae..77e0ef9 100644 --- a/client.py +++ b/client.py @@ -1,5 +1,4 @@ from cloudlink import client -from cloudlink.client.protocols import clpv4 if __name__ == "__main__": # Initialize the client @@ -10,12 +9,9 @@ level=client.logging.DEBUG ) - # Load protocols - clpv4 = clpv4(client) - + # Example of events @client.on_connect async def on_connect(): - client.send_packet({"cmd": "handshake"}) print("Connected") # Start the server diff --git a/cloudlink/client/__init__.py b/cloudlink/client/__init__.py index 5e2e3c4..370f8bd 100644 --- a/cloudlink/client/__init__.py +++ b/cloudlink/client/__init__.py @@ -1,11 +1,9 @@ # Core components of the CloudLink client import asyncio import ssl - import cerberus import logging import time -from copy import copy # Import websockets and SSL support import websockets @@ -21,33 +19,32 @@ print("Server failed to import UltraJSON, failing back to native JSON library.") import json as ujson +# Import required CL4 client protocol +from cloudlink.client import protocol, schema + # Define server exceptions class exceptions: class EmptyMessage(Exception): - """This exception is raised when a client sends an empty packet.""" + """This exception is raised when a client receives an empty packet.""" pass - class InvalidCommand(Exception): - """This exception is raised when a client sends an invalid command for it's determined protocol.""" + class UnknownCommand(Exception): + """This exception is raised when the server sends a command that the client does not recognize.""" pass class JSONError(Exception): - """This exception is raised when the server fails to parse a message's JSON.""" + """This exception is raised when the client fails to parse the server message's JSON.""" pass class ValidationError(Exception): - """This exception is raised when a client with a known protocol sends a message that fails validation before commands can execute.""" + """This exception is raised when the server sends a message that fails validation before execution.""" pass class InternalError(Exception): """This exception is raised when an unexpected and/or unhandled exception is raised.""" pass - class Overloaded(Exception): - """This exception is raised when the server believes it is overloaded.""" - pass - # Main server class client: @@ -69,18 +66,18 @@ def __init__(self): self.ujson = ujson self.validator = cerberus.Validator() self.async_iterable = async_iterable - self.copy = copy - self.exceptions = exceptions() - - # Dictionary containing protocols as keys and sets of commands as values - self.disabled_commands = dict() + self.exceptions = exceptions # Create event managers - self.on_connect_events = async_event_manager(self) + self.on_initial_connect_events = async_event_manager(self) + self.on_full_connect_events = async_event_manager(self) self.on_message_events = async_event_manager(self) self.on_disconnect_events = async_event_manager(self) self.on_error_events = async_event_manager(self) self.exception_handlers = dict() + self.listener_events_await_specific = dict() + self.listener_events_decorator_specific = dict() + # Create method handlers self.command_handlers = dict() @@ -92,6 +89,10 @@ def __init__(self): self.ssl_enabled = False self.ssl_context = None + # Load built-in protocol + self.schema = schema.schema + self.protocol = protocol.clpv4(self) + # Enables SSL support def enable_ssl(self, certfile): try: @@ -115,46 +116,72 @@ def run(self, host="ws://127.0.0.1:3000"): self.logging.getLogger('websockets.client').setLevel(self.logging.ERROR) self.logging.getLogger('websockets.protocol').setLevel(self.logging.ERROR) - # Start server + # Start client self.asyncio.run(self.__run__(host)) except KeyboardInterrupt: pass # Event binder for on_command events - def on_command(self, cmd, schema): + def on_command(self, cmd): def bind_event(func): - # Create schema category for command event manager - if schema not in self.command_handlers: - self.logger.info(f"Creating protocol {schema.__qualname__} command event manager") - self.command_handlers[schema] = dict() - # Create command event handler - if cmd not in self.command_handlers[schema]: - self.command_handlers[schema][cmd] = async_event_manager(self) + if cmd not in self.command_handlers: + self.command_handlers[cmd] = async_event_manager(self) # Add function to the command handler - self.command_handlers[schema][cmd].bind(func) + self.command_handlers[cmd].bind(func) # End on_command binder return bind_event - # Event binder for on_error events with specific shemas/exception types - def on_exception(self, exception_type, schema): + # Credit to @ShowierData9978 for this: Listen for messages containing specific "listener" keys + async def wait_for_listener(self, listener_id): + # Create a new event object. + event = self.asyncio.Event() + + # Register the event so that the client can continue listening for messages. + self.listener_events_await_specific[listener_id] = event + + # Create the waiter task. + task = self.asyncio.create_task( + self.listener_waiter( + listener_id, + event + ) + ) + + # Wait for the waiter task to finish. + await task + + # Remove from the listener events dict. + self.listener_events_await_specific.pop(listener_id) + + # Version of the wait for listener tool for decorator usage. + def on_listener(self, listener_id): def bind_event(func): - # Create schema category for error event manager - if schema not in self.exception_handlers: - self.logger.info(f"Creating protocol {schema.__qualname__} exception event manager") - self.exception_handlers[schema] = dict() + # Create listener event handler + if listener_id not in self.listener_events_decorator_specific: + self.listener_events_decorator_specific[listener_id] = async_event_manager(self) + + # Add function to the listener handler + self.listener_events_decorator_specific[listener_id].bind(func) + + # End on_listener binder + return bind_event + + # Event binder for on_error events with specific schemas/exception types + def on_exception(self, exception_type): + def bind_event(func): # Create error event handler - if exception_type not in self.exception_handlers[schema]: - self.exception_handlers[schema][exception_type] = async_event_manager(self) + if exception_type not in self.exception_handlers: + self.exception_handlers[exception_type] = async_event_manager(self) # Add function to the error command handler - self.exception_handlers[schema][exception_type].bind(func) + self.exception_handlers[exception_type].bind(func) # End on_error_specific binder return bind_event @@ -163,9 +190,13 @@ def bind_event(func): def on_message(self, func): self.on_message_events.bind(func) + # Event binder for starting up the client. + def on_initial_connect(self, func): + self.on_initial_connect_events.bind(func) + # Event binder for on_connect events. def on_connect(self, func): - self.on_connect_events.bind(func) + self.on_full_connect_events.bind(func) # Event binder for on_disconnect events. def on_disconnect(self, func): @@ -177,52 +208,20 @@ def on_error(self, func): # Send message def send_packet(self, message): - # Create unicast task self.asyncio.create_task(self.execute_send(message)) - # Close the connection to client(s) - def close_connection(self, obj, code=1000, reason=""): - if type(obj) in [list, set]: - self.asyncio.create_task(self.execute_close_multi(obj, code, reason)) - else: - self.asyncio.create_task(self.execute_close_single(obj, code, reason)) - - # Command disabler - def disable_command(self, cmd, schema): - # Check if the schema has no disabled commands - if schema not in self.disabled_commands: - self.disabled_commands[schema] = set() - - # Check if the command isn't already disabled - if cmd in self.disabled_commands[schema]: - raise ValueError( - f"The command {cmd} is already disabled in protocol {schema.__qualname__}, or was enabled beforehand.") - - # Disable the command - self.disabled_commands[schema].add(cmd) - self.logger.debug(f"Disabled command {cmd} in protocol {schema.__qualname__}") - - # Command enabler - def enable_command(self, cmd, schema): - # Check if the schema has disabled commands - if schema not in self.disabled_commands: - raise ValueError(f"There are no commands to disable in protocol {schema.__qualname__}.") - - # Check if the command is disabled - if cmd not in self.disabled_commands[schema]: - raise ValueError( - f"The command {cmd} is already enabled in protocol {schema.__qualname__}, or wasn't disabled beforehand.") - - # Enable the command - self.disabled_commands[schema].remove(cmd) - self.logger.debug(f"Enabled command {cmd} in protocol {schema.__qualname__}") - - # Free up unused disablers - if not len(self.disabled_commands[schema]): - self.disabled_commands.pop(schema) + # Send message and wait for a response + async def send_packet_and_wait(self, message): + self.logger.debug(f"Sending message containing listener {message['listener']}...") + await self.execute_send(message) + await self.wait_for_listener(message["listener"]) + + # Close the connection + def close_connection(self, code=1000, reason=""): + self.asyncio.create_task(self.execute_disconnect(code, reason)) # Message processor - async def message_processor(self, client, message): + async def message_processor(self, message): # Empty packet if not len(message): @@ -235,7 +234,6 @@ async def message_processor(self, client, message): self.asyncio.create_task( self.execute_exception_handlers( exception_type=self.exceptions.EmptyMessage, - schema=client.protocol, details="Empty message" ) ) @@ -254,11 +252,10 @@ async def message_processor(self, client, message): self.asyncio.create_task(self.execute_on_error_events(error)) # Fire exception handling events - if client.protocol_set: + if self.client.protocol_set: self.asyncio.create_task( self.execute_exception_handlers( exception_type=self.exceptions.JSONError, - schema=client.protocol, details=error ) ) @@ -266,104 +263,73 @@ async def message_processor(self, client, message): else: # Close the connection self.send_packet("Invalid JSON") - self.close_connection(client, reason="Invalid JSON") + self.close_connection(reason="Invalid JSON") # End message_processor coroutine return # Begin validation - valid = False - selected_protocol = None - - # Client protocol is unknown - if not client.protocol: - self.logger.debug(f"Trying to identify server's protocol") - - # Identify protocol - errorlist = list() + if not self.validator(message, self.schema.default): + errors = self.validator.errors - for schema in self.command_handlers: - if self.validator(message, schema.default): - valid = True - selected_protocol = schema - break - else: - errorlist.append(self.validator.errors) + # Log failed validation + self.logger.debug(f"Server sent message that failed validation: {errors}") - if not valid: - # Log failed identification - self.logger.debug(f"Could not identify protocol used by server: {errorlist}") - - # Fire on_error events - self.asyncio.create_task(self.execute_on_error_events("Unable to identify protocol")) - - # Close the connection - self.send_packet("Unable to identify protocol") - self.close_connection(client, reason="Unable to identify protocol") - - # End message_processor coroutine - return - - # Log known protocol - self.logger.debug(f"Server is using protocol {selected_protocol.__qualname__}") - - else: - self.logger.debug( - f"Validating message from server using protocol {client.protocol.__qualname__}") - - # Validate message using known protocol - selected_protocol = client.protocol - - if not self.validator(message, selected_protocol.default): - errors = self.validator.errors - - # Log failed validation - self.logger.debug(f"Server sent message that failed validation: {errors}") - - # Fire on_error events - self.asyncio.create_task(self.execute_on_error_events(errors)) + # Fire on_error events + self.asyncio.create_task(self.execute_on_error_events(errors)) - # Fire exception handling events - if client.protocol_set: - self.asyncio.create_task( - self.execute_exception_handlers( - exception_type=self.exceptions.ValidationError, - schema=client.protocol, - details=errors - ) + # Fire exception handling events + if self.client.protocol_set: + self.asyncio.create_task( + self.execute_exception_handlers( + exception_type=self.exceptions.ValidationError, + details=errors ) + ) - # End message_processor coroutine - return + # End message_processor coroutine + return # Check if command exists - if message[selected_protocol.command_key] not in self.command_handlers[selected_protocol]: + if message["cmd"] not in self.command_handlers: # Log invalid command - self.logger.debug( - f"Server sent an invalid command \"{message[selected_protocol.command_key]}\" in protocol {selected_protocol.__qualname__}") + self.logger.debug(f"Server sent an unknown command \"{message['cmd']}\"") # Fire on_error events - self.asyncio.create_task(self.execute_on_error_events("Invalid command")) + self.asyncio.create_task(self.execute_on_error_events("Unknown command")) # Fire exception handling events - if client.protocol_set: + if self.client.protocol_set: self.asyncio.create_task( self.execute_exception_handlers( exception_type=self.exceptions.InvalidCommand, - schema=client.protocol, - details=message[selected_protocol.command_key] + details=message["cmd"] ) ) # End message_processor coroutine return + # Check if the message contains listeners + if "listener" in message: + if message["listener"] in self.listener_events_await_specific: + # Fire awaiting listeners + self.logger.debug(f"Received message containing listener {message['listener']}!") + self.listener_events_await_specific[message["listener"]].set() + + elif message["listener"] in self.listener_events_decorator_specific: + # Fire all decorator-based listeners + self.asyncio.create_task( + self.execute_on_listener_events( + message + ) + ) + # Fire on_command events self.asyncio.create_task( self.execute_on_command_events( - message, - selected_protocol + message ) ) @@ -375,45 +341,45 @@ async def message_processor(self, client, message): ) # Connection handler - async def connection_handler(self, client): + async def connection_handler(self): # Startup client attributes - client.snowflake = str() - client.protocol = None - client.protocol_set = False - client.rooms = set() - client.username_set = False - client.username = str() - client.handshake = False + self.client.snowflake = str() + self.client.protocol = None + self.client.protocol_set = False + self.client.rooms = set() + self.client.username_set = False + self.client.username = str() + self.client.handshake = False # Begin tracking the lifetime of the client - client.birth_time = time.monotonic() + self.client.birth_time = time.monotonic() # Fire on_connect events - self.asyncio.create_task(self.execute_on_connect_events()) + self.asyncio.create_task(self.execute_on_initial_connect_events()) self.logger.debug(f"Client connected") # Run connection loop - await self.connection_loop(client) + await self.connection_loop() # Fire on_disconnect events self.asyncio.create_task(self.execute_on_disconnect_events()) self.logger.debug( - f"Client disconnected: Total lifespan of {time.monotonic() - client.birth_time} seconds.") + f"Client disconnected: Total lifespan of {time.monotonic() - self.client.birth_time} seconds.") # Connection loop - Redefine for use with another outside library - async def connection_loop(self, client): + async def connection_loop(self): # Primary asyncio loop for the lifespan of the websocket connection try: - async for message in client: + async for message in self.client: # Start keeping track of processing time start = time.perf_counter() self.logger.debug(f"Now processing message from server...") # Process the message - await self.message_processor(client, message) + await self.message_processor(message) # Log processing time self.logger.debug( @@ -438,7 +404,6 @@ async def connection_loop(self, client): self.asyncio.create_task( self.execute_exception_handlers( exception_type=self.exceptions.InternalError, - schema=client.protocol, details=f"Unexpected exception was raised: {e}" ) ) @@ -446,7 +411,7 @@ async def connection_loop(self, client): # WebSocket-specific server loop async def __run__(self, host): async with self.ws.connect(host) as self.client: - await self.connection_handler(self.client) + await self.connection_handler() # Asyncio event-handling coroutines @@ -454,36 +419,47 @@ async def execute_on_disconnect_events(self): async for event in self.on_disconnect_events: await event() - async def execute_on_connect_events(self): - async for event in self.on_connect_events: + async def execute_on_initial_connect_events(self): + async for event in self.on_initial_connect_events: + await event() + + async def execute_on_full_connect_events(self): + async for event in self.on_full_connect_events: await event() async def execute_on_message_events(self, message): async for event in self.on_message_events: await event(message) - async def execute_on_command_events(self, message, schema): - async for event in self.command_handlers[schema][message[schema.command_key]]: + async def execute_on_command_events(self, message): + async for event in self.command_handlers[message["cmd"]]: + await event(message) + + async def execute_on_listener_events(self, message): + async for event in self.listener_events_decorator_specific[message["listener"]]: await event(message) async def execute_on_error_events(self, errors): async for event in self.on_error_events: await event(errors) - async def execute_exception_handlers(self, exception_type, schema, details): + async def execute_exception_handlers(self, exception_type, details): # Guard clauses - if schema not in self.exception_handlers: - return - if exception_type not in self.exception_handlers[schema]: + if exception_type not in self.exception_handlers: return # Fire events - async for event in self.exception_handlers[schema][exception_type]: + async for event in self.exception_handlers[exception_type]: await event(details) + async def listener_waiter(self, listener_id, event): + await event.wait() # WebSocket-specific coroutines + async def execute_disconnect(self, code=1000, reason=""): + await self.client.close(code, reason) + async def execute_send(self, message): # Convert dict to JSON if type(message) == dict: diff --git a/cloudlink/client/protocol.py b/cloudlink/client/protocol.py new file mode 100644 index 0000000..d25f86e --- /dev/null +++ b/cloudlink/client/protocol.py @@ -0,0 +1,121 @@ +""" +This is the default protocol used for the CloudLink client. +The CloudLink 4.1 Protocol retains full support for CLPv4. + +Each packet format is compliant with UPLv2 formatting rules. + +Documentation for the CLPv4.1 protocol can be found here: +https://hackmd.io/@MikeDEV/HJiNYwOfo +""" + + +class clpv4: + def __init__(self, parent): + + # Define various status codes for the protocol. + class statuscodes: + # Code type character + info = "I" + error = "E" + + # Error / info codes as tuples + test = (info, 0, "Test") + echo = (info, 1, "Echo") + ok = (info, 100, "OK") + syntax = (error, 101, "Syntax") + datatype = (error, 102, "Datatype") + id_not_found = (error, 103, "ID not found") + id_not_specific = (error, 104, "ID not specific enough") + internal_error = (error, 105, "Internal server error") + empty_packet = (error, 106, "Empty packet") + id_already_set = (error, 107, "ID already set") + refused = (error, 108, "Refused") + invalid_command = (error, 109, "Invalid command") + disabled_command = (error, 110, "Command disabled") + id_required = (error, 111, "ID required") + id_conflict = (error, 112, "ID conflict") + too_large = (error, 113, "Too large") + json_error = (error, 114, "JSON error") + room_not_joined = (error, 115, "Room not joined") + + # Generate a user object + def generate_user_object(): + # Username set + if parent.client.username_set: + return { + "id": parent.client.snowflake, + "username": parent.client.username, + "uuid": str(parent.client.id) + } + + # Username not set + return { + "id": parent.client.snowflake, + "uuid": str(parent.client.id) + } + + # Expose username object generator function for extension usage + self.generate_user_object = generate_user_object + + # The CLPv4 command set + @parent.on_initial_connect + async def on_initial_connect(): + parent.logger.debug("Performing handshake with the server...") + + # Send the handshake request with a listener and wait for a response + await parent.send_packet_and_wait({ + "cmd": "handshake", + "listener": "init_handshake" + }) + + # Log the successful connection + parent.logger.info("Successfully connected to the server.") + + # Fire all on_connect events + parent.asyncio.create_task( + parent.execute_on_full_connect_events() + ) + + @parent.on_command(cmd="ping") + async def on_ping(message): + pass + + @parent.on_command(cmd="gmsg") + async def on_gmsg(message): + pass + + @parent.on_command(cmd="pmsg") + async def on_pmsg(message): + pass + + @parent.on_command(cmd="gvar") + async def on_gvar(message): + pass + + @parent.on_command(cmd="pvar") + async def on_pvar(message): + pass + + @parent.on_command(cmd="statuscode") + async def on_statuscode(message): + pass + + @parent.on_command(cmd="client_obj") + async def on_client_obj(message): + parent.logger.info(f"This client is known as ID {message['val']['id']} with UUID {message['val']['uuid']}.") + + @parent.on_command(cmd="client_ip") + async def on_client_ip(message): + parent.logger.debug(f"Client IP address is {message['val']}") + + @parent.on_command(cmd="server_version") + async def on_server_version(message): + parent.logger.info(f"Server is running Cloudlink v{message['val']}.") + + @parent.on_command(cmd="ulist") + async def on_ulist(message): + pass + + @parent.on_command(cmd="direct") + async def on_direct(message): + pass diff --git a/cloudlink/client/protocols/__init__.py b/cloudlink/client/protocols/__init__.py deleted file mode 100644 index d3c9c22..0000000 --- a/cloudlink/client/protocols/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from .clpv4.clpv4 import * - diff --git a/cloudlink/client/protocols/clpv4/__init__.py b/cloudlink/client/protocols/clpv4/__init__.py deleted file mode 100644 index a9e2996..0000000 --- a/cloudlink/client/protocols/clpv4/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .clpv4 import * diff --git a/cloudlink/client/protocols/clpv4/clpv4.py b/cloudlink/client/protocols/clpv4/clpv4.py deleted file mode 100644 index f4d9fc5..0000000 --- a/cloudlink/client/protocols/clpv4/clpv4.py +++ /dev/null @@ -1,753 +0,0 @@ -from .schema import cl4_protocol - -""" -This is the default protocol used for the CloudLink server. -The CloudLink 4.1 Protocol retains full support for CLPv4. - -Each packet format is compliant with UPLv2 formatting rules. - -Documentation for the CLPv4.1 protocol can be found here: -https://hackmd.io/@MikeDEV/HJiNYwOfo -""" - - -class clpv4: - def __init__(self, server): - - """ - Configuration settings - - warn_if_multiple_username_matches: Boolean, Default: True - If True, the server will warn users if they are resolving multiple clients for a username search. - - enable_motd: Boolean, Default: False - If True, whenever a client sends the handshake command or whenever the client's protocol is identified, - the server will send the Message-Of-The-Day from whatever value motd_message is set to. - - motd_message: String, Default: Blank string - If enable_mod is True, this string will be sent as the server's Message-Of-The-Day. - - real_ip_header: String, Default: None - If you use CloudLink behind a tunneling service or reverse proxy, set this value to whatever - IP address-fetching request header to resolve valid IP addresses. When set to None, it will - utilize the host's incoming network for resolving IP addresses. - - Examples include: - * x-forwarded-for - * cf-connecting-ip - - """ - self.warn_if_multiple_username_matches = True - self.enable_motd = False - self.motd_message = str() - self.real_ip_header = None - - # Exposes the schema of the protocol. - self.schema = cl4_protocol - - # Define various status codes for the protocol. - class statuscodes: - # Code type character - info = "I" - error = "E" - - # Error / info codes as tuples - test = (info, 0, "Test") - echo = (info, 1, "Echo") - ok = (info, 100, "OK") - syntax = (error, 101, "Syntax") - datatype = (error, 102, "Datatype") - id_not_found = (error, 103, "ID not found") - id_not_specific = (error, 104, "ID not specific enough") - internal_error = (error, 105, "Internal server error") - empty_packet = (error, 106, "Empty packet") - id_already_set = (error, 107, "ID already set") - refused = (error, 108, "Refused") - invalid_command = (error, 109, "Invalid command") - disabled_command = (error, 110, "Command disabled") - id_required = (error, 111, "ID required") - id_conflict = (error, 112, "ID conflict") - too_large = (error, 113, "Too large") - json_error = (error, 114, "JSON error") - room_not_joined = (error, 115, "Room not joined") - - @staticmethod - def generate(code: tuple): - return f"{code[0]}:{code[1]} | {code[2]}", code[1] - - # Expose statuscodes class for extension usage - self.statuscodes = statuscodes - - # Identification of a client's IP address - def get_client_ip(client): - # Grab IP address using headers - if self.real_ip_header: - if self.real_ip_header in client.request_headers: - return client.request_headers.get(self.real_ip_header) - - # Use system identified IP address - if type(client.remote_address) == tuple: - return str(client.remote_address[0]) - - # Expose get_client_ip for extension usage - self.get_client_ip = get_client_ip - - # valid(message, schema): Used to verify messages. - def valid(client, message, schema): - if server.validator(message, schema): - return True - - # Alert the client that the schema was invalid - send_statuscode(client, statuscodes.syntax, details=dict(server.validator.errors)) - return False - - # Expose validator function for extension usage - self.valid = valid - - # Simplify sending error/info messages - def send_statuscode(client, code, details=None, message=None, val=None): - # Generate a statuscode - code_human, code_id = statuscodes.generate(code) - - # Template the message - tmp_message = { - "cmd": "statuscode", - "code": code_human, - "code_id": code_id - } - - if details: - tmp_message["details"] = details - - if message: - if "listener" in message: - tmp_message["listener"] = message["listener"] - - if val: - tmp_message["val"] = val - - # Send the code - server.send_packet(client, tmp_message) - - # Expose the statuscode generator for extension usage - self.send_statuscode = send_statuscode - - # Send messages with automatic listener attaching - def send_message(client, payload, message=None): - if message: - if "listener" in message: - payload["listener"] = message["listener"] - - # Send the code - server.send_packet(client, payload) - - # Expose the message sender for extension usage - self.send_message = send_message - - # Simplify alerting users that a command requires a username to be set - def require_username_set(client, message): - if not client.username_set: - send_statuscode( - client, - statuscodes.id_required, - details="This command requires setting a username.", - message=message - ) - - return client.username_set - - # Expose username requirement function for extension usage - self.require_username_set = require_username_set - - # Tool for gathering client rooms - def gather_rooms(client, message): - if "rooms" in message: - # Read value from message - rooms = message["rooms"] - - # Convert to set - if type(rooms) == str: - rooms = {rooms} - if type(rooms) == list: - rooms = set(rooms) - - return rooms - else: - # Use all subscribed rooms - return client.rooms - - # Expose rooms gatherer for extension usage - self.gather_rooms = gather_rooms - - # Generate a user object - def generate_user_object(obj): - # Username set - if obj.username_set: - return { - "id": obj.snowflake, - "username": obj.username, - "uuid": str(obj.id) - } - - # Username not set - return { - "id": obj.snowflake, - "uuid": str(obj.id) - } - - # Expose username object generator function for extension usage - self.generate_user_object = generate_user_object - - # If the client has not explicitly used the handshake command, send them the handshake data - async def automatic_notify_handshake(client): - # Don't execute this if handshake was already done - if client.handshake: - return - client.handshake = True - - # Send client IP address - server.send_packet(client, { - "cmd": "client_ip", - "val": get_client_ip(client) - }) - - # Send server version - server.send_packet(client, { - "cmd": "server_version", - "val": server.version - }) - - # Send Message-Of-The-Day - if self.enable_motd: - server.send_packet(client, { - "cmd": "motd", - "val": self.motd_message - }) - - # Send client's Snowflake ID - server.send_packet(client, { - "cmd": "client_obj", - "val": generate_user_object(client) - }) - - # Send userlists of rooms - async for room in server.async_iterable(client.rooms): - server.send_packet(client, { - "cmd": "ulist", - "val": { - "mode": "set", - "val": server.rooms_manager.generate_userlist(room, cl4_protocol) - }, - "room": room - }) - - # Expose for extension usage - self.automatic_notify_handshake = automatic_notify_handshake - - # The CLPv4 command set - - @server.on_command(cmd="handshake", schema=cl4_protocol) - async def on_handshake(client, message): - await automatic_notify_handshake(client) - send_statuscode(client, statuscodes.ok, message=message) - - @server.on_command(cmd="ping", schema=cl4_protocol) - async def on_ping(client, message): - await automatic_notify_handshake(client) - send_statuscode(client, statuscodes.ok, message=message) - - @server.on_command(cmd="gmsg", schema=cl4_protocol) - async def on_gmsg(client, message): - await automatic_notify_handshake(client) - - # Validate schema - if not valid(client, message, cl4_protocol.gmsg): - return - - # Gather rooms to send to - rooms = gather_rooms(client, message) - - # Broadcast to all subscribed rooms - async for room in server.async_iterable(rooms): - - # Prevent accessing rooms not joined - if room not in client.rooms: - send_statuscode( - client, - statuscodes.room_not_joined, - details=f'Attempted to access room {room} while not joined.', - message=message - ) - - # Stop gmsg command - return - - clients = await server.rooms_manager.get_all_in_rooms(room, cl4_protocol) - clients = server.copy(clients) - - # Attach listener (if present) and broadcast - if "listener" in message: - - # Remove originating client from broadcast - clients.remove(client) - - # Define the message to broadcast - tmp_message = { - "cmd": "gmsg", - "val": message["val"] - } - - # Broadcast message - server.send_packet(clients, tmp_message) - - # Define the message to send - tmp_message = { - "cmd": "gmsg", - "val": message["val"], - "listener": message["listener"], - "room": room - } - - # Unicast message - server.send_packet(client, tmp_message) - else: - # Broadcast message - server.send_packet(clients, { - "cmd": "gmsg", - "val": message["val"], - "room": room - }) - - @server.on_command(cmd="pmsg", schema=cl4_protocol) - async def on_pmsg(client, message): - await automatic_notify_handshake(client) - - # Validate schema - if not valid(client, message, cl4_protocol.pmsg): - return - - # Require sending client to have set their username - if not require_username_set(client, message): - return - - # Gather rooms - rooms = gather_rooms(client, message) - - # Search and send to all specified clients in rooms - any_results_found = False - async for room in server.async_iterable(rooms): - - # Prevent accessing rooms not joined - if room not in client.rooms: - send_statuscode( - client, - statuscodes.room_not_joined, - details=f'Attempted to access room {room} while not joined.', - message=message - ) - - # Stop pmsg command - return - - clients = await server.rooms_manager.get_specific_in_room(room, cl4_protocol, message['id']) - - # Continue if no results are found - if not len(clients): - continue - - # Mark the full search OK - if not any_results_found: - any_results_found = True - - # Warn if multiple matches are found (mainly for username queries) - if self.warn_if_multiple_username_matches and len(clients) >> 1: - send_statuscode( - client, - statuscodes.id_not_specific, - details=f'Multiple matches found for {message["id"]}, found {len(clients)} matches. Please use Snowflakes, UUIDs, or client objects instead.', - message=message - ) - - # Stop pmsg command - return - - # Send message - tmp_message = { - "cmd": "pmsg", - "val": message["val"], - "origin": generate_user_object(client), - "room": room - } - server.send_packet(clients, tmp_message) - - if not any_results_found: - send_statuscode( - client, - statuscodes.id_not_found, - details=f'No matches found: {message["id"]}', - message=message - ) - - # End pmsg command handler - return - - # Results were found and sent successfully - send_statuscode( - client, - statuscodes.ok, - message=message - ) - - @server.on_command(cmd="gvar", schema=cl4_protocol) - async def on_gvar(client, message): - await automatic_notify_handshake(client) - - # Validate schema - if not valid(client, message, cl4_protocol.gvar): - return - - # Gather rooms to send to - rooms = gather_rooms(client, message) - - # Broadcast to all subscribed rooms - async for room in server.async_iterable(rooms): - - # Prevent accessing rooms not joined - if room not in client.rooms: - send_statuscode( - client, - statuscodes.room_not_joined, - details=f'Attempted to access room {room} while not joined.', - message=message - ) - - # Stop gvar command - return - - clients = await server.rooms_manager.get_all_in_rooms(room, cl4_protocol) - clients = server.copy(clients) - - # Define the message to send - tmp_message = { - "cmd": "gvar", - "name": message["name"], - "val": message["val"], - "room": room - } - - # Attach listener (if present) and broadcast - if "listener" in message: - clients.remove(client) - server.send_packet(clients, tmp_message) - tmp_message["listener"] = message["listener"] - server.send_packet(client, tmp_message) - else: - server.send_packet(clients, tmp_message) - - @server.on_command(cmd="pvar", schema=cl4_protocol) - async def on_pvar(client, message): - await automatic_notify_handshake(client) - - # Validate schema - if not valid(client, message, cl4_protocol.pvar): - return - - # Require sending client to have set their username - if not require_username_set(client, message): - return - - # Gather rooms - rooms = gather_rooms(client, message) - - # Search and send to all specified clients in rooms - any_results_found = False - async for room in server.async_iterable(rooms): - - # Prevent accessing rooms not joined - if room not in client.rooms: - send_statuscode( - client, - statuscodes.room_not_joined, - details=f'Attempted to access room {room} while not joined.', - message=message - ) - - # Stop pvar command - return - - clients = await server.rooms_manager.get_specific_in_room(room, cl4_protocol, message['id']) - clients = server.copy(clients) - - # Continue if no results are found - if not len(clients): - continue - - # Mark the full search OK - if not any_results_found: - any_results_found = True - - # Warn if multiple matches are found (mainly for username queries) - if self.warn_if_multiple_username_matches and len(clients) >> 1: - send_statuscode( - client, - statuscodes.id_not_specific, - details=f'Multiple matches found for {message["id"]}, found {len(clients)} matches. Please use Snowflakes, UUIDs, or client objects instead.', - message=message - ) - - # Stop pvar command - return - - # Send message - tmp_message = { - "cmd": "pvar", - "name": message["name"], - "val": message["val"], - "origin": generate_user_object(client), - "room": room - } - server.send_packet(clients, tmp_message) - - if not any_results_found: - send_statuscode( - client, - statuscodes.id_not_found, - details=f'No matches found: {message["id"]}', - message=message - ) - - # End pmsg command handler - return - - # Results were found and sent successfully - send_statuscode( - client, - statuscodes.ok, - message=message - ) - - @server.on_command(cmd="setid", schema=cl4_protocol) - async def on_setid(client, message): - await automatic_notify_handshake(client) - - # Validate schema - if not valid(client, message, cl4_protocol.setid): - return - - # Prevent setting the username more than once - if client.username_set: - server.logger.error(f"Client {client.snowflake} attempted to set username again!") - send_statuscode( - client, - statuscodes.id_already_set, - val=generate_user_object(client), - message=message - ) - - # Exit setid command - return - - # Leave default room - server.rooms_manager.unsubscribe(client, "default") - - # Set the username - server.clients_manager.set_username(client, message['val']) - - # Re-join default room - server.rooms_manager.subscribe(client, "default") - - # Broadcast userlist state to existing members - clients = await server.rooms_manager.get_all_in_rooms("default", cl4_protocol) - clients = server.copy(clients) - clients.remove(client) - server.send_packet(clients, { - "cmd": "ulist", - "val": { - "mode": "add", - "val": generate_user_object(client) - }, - "room": "default" - }) - - # Notify client of current room state - server.send_packet(client, { - "cmd": "ulist", - "val": { - "mode": "set", - "val": server.rooms_manager.generate_userlist("default", cl4_protocol) - }, - "room": "default" - }) - - # Attach listener (if present) and broadcast - send_statuscode( - client, - statuscodes.ok, - val=generate_user_object(client), - message=message - ) - - @server.on_command(cmd="link", schema=cl4_protocol) - async def on_link(client, message): - await automatic_notify_handshake(client) - - # Validate schema - if not valid(client, message, cl4_protocol.linking): - return - - # Require sending client to have set their username - if not require_username_set(client, message): - return - - # Clear all rooms beforehand - async for room in server.async_iterable(client.rooms): - server.rooms_manager.unsubscribe(client, room) - - # Convert to set - if type(message["val"]) in [list, str]: - if type(message["val"]) == list: - message["val"] = set(message["val"]) - if type(message["val"]) == str: - message["val"] = {message["val"]} - - async for room in server.async_iterable(message["val"]): - server.rooms_manager.subscribe(client, room) - - # Broadcast userlist state to existing members - clients = await server.rooms_manager.get_all_in_rooms(room, cl4_protocol) - clients = server.copy(clients) - clients.remove(client) - server.send_packet(clients, { - "cmd": "ulist", - "val": { - "mode": "add", - "val": generate_user_object(client) - }, - "room": room - }) - - # Notify client of current room state - server.send_packet(client, { - "cmd": "ulist", - "val": { - "mode": "set", - "val": server.rooms_manager.generate_userlist(room, cl4_protocol) - }, - "room": room - }) - - # Attach listener (if present) and broadcast - send_statuscode( - client, - statuscodes.ok, - message=message - ) - - @server.on_command(cmd="unlink", schema=cl4_protocol) - async def on_unlink(client, message): - await automatic_notify_handshake(client) - - # Validate schema - if not valid(client, message, cl4_protocol.linking): - return - - # Require sending client to have set their username - if not require_username_set(client, message): - return - - # Convert to set - if type(message["val"]) in [list, str]: - if type(message["val"]) == list: - message["val"] = set(message["val"]) - if type(message["val"]) == str: - message["val"] = {message["val"]} - - async for room in server.async_iterable(message["val"]): - server.rooms_manager.unsubscribe(client, room) - - # Broadcast userlist state to existing members - clients = await server.rooms_manager.get_all_in_rooms(room, cl4_protocol) - clients = server.copy(clients) - clients.remove(client) - server.send_packet(clients, { - "cmd": "ulist", - "val": { - "mode": "remove", - "val": generate_user_object(client) - }, - "room": room - }) - - # Re-link to default room if no rooms are joined - if not len(client.rooms): - server.rooms_manager.subscribe(client, "default") - - # Broadcast userlist state to existing members - clients = await server.rooms_manager.get_all_in_rooms("default", cl4_protocol) - clients = server.copy(clients) - clients.remove(client) - server.send_packet(clients, { - "cmd": "ulist", - "val": { - "mode": "add", - "val": generate_user_object(client) - }, - "room": "default" - }) - - # Notify client of current room state - server.send_packet(client, { - "cmd": "ulist", - "val": { - "mode": "set", - "val": server.rooms_manager.generate_userlist("default", cl4_protocol) - }, - "room": "default" - }) - - # Attach listener (if present) and broadcast - send_statuscode( - client, - statuscodes.ok, - message=message - ) - - @server.on_command(cmd="direct", schema=cl4_protocol) - async def on_direct(client, message): - await automatic_notify_handshake(client) - - # Validate schema - if not valid(client, message, cl4_protocol.direct): - return - - try: - tmp_client = server.clients_manager.find_obj(message["id"]) - - tmp_msg = { - "cmd": "direct", - "val": message["val"] - } - - if client.username_set: - tmp_msg["origin"] = generate_user_object(client) - - else: - tmp_msg["origin"] = { - "id": client.snowflake, - "uuid": str(client.id) - } - - if "listener" in message: - tmp_msg["listener"] = message["listener"] - - server.send_packet_unicast(tmp_client, tmp_msg) - - except server.clients_manager.exceptions.NoResultsFound: - send_statuscode( - client, - statuscodes.id_not_found, - message=message - ) - - # Stop direct command - return diff --git a/cloudlink/client/protocols/clpv4/schema.py b/cloudlink/client/schema.py similarity index 96% rename from cloudlink/client/protocols/clpv4/schema.py rename to cloudlink/client/schema.py index 946a93e..ddfabbe 100644 --- a/cloudlink/client/protocols/clpv4/schema.py +++ b/cloudlink/client/schema.py @@ -1,5 +1,5 @@ # Schema for interpreting the Cloudlink protocol v4.0 (CLPv4) command set -class cl4_protocol: +class schema: # Required - Defines the keyword to use to define the command command_key = "cmd" @@ -23,6 +23,18 @@ class cl4_protocol: ], "required": False, }, + "mode": { + "type": "string", + "required": False + }, + "code": { + "type": "string", + "required": False + }, + "code_id": { + "type": "integer", + "required": False + }, "name": { "type": "string", "required": False From 71adcdb9077f50c4b30478f4e2cbe1b68668d928 Mon Sep 17 00:00:00 2001 From: "Mike J. Renaker / \"MikeDEV" Date: Thu, 30 Mar 2023 12:10:24 -0400 Subject: [PATCH 37/59] Finished client --- client.py | 7 +++++-- cloudlink/client/__init__.py | 31 ++++++++++++++++++++++++++----- cloudlink/client/protocol.py | 24 +++++++++++++++++------- cloudlink/server/__init__.py | 4 ++-- 4 files changed, 50 insertions(+), 16 deletions(-) diff --git a/client.py b/client.py index 77e0ef9..3919025 100644 --- a/client.py +++ b/client.py @@ -13,6 +13,9 @@ @client.on_connect async def on_connect(): print("Connected") - - # Start the server + await client.asyncio.sleep(1) + print("Going away") + client.disconnect() + + # Start the client client.run(host="ws://127.0.0.1:3000/") diff --git a/cloudlink/client/__init__.py b/cloudlink/client/__init__.py index 370f8bd..15f519a 100644 --- a/cloudlink/client/__init__.py +++ b/cloudlink/client/__init__.py @@ -4,6 +4,7 @@ import cerberus import logging import time +from copy import copy # Import websockets and SSL support import websockets @@ -15,8 +16,8 @@ # Import JSON library - Prefer UltraJSON but use native JSON if failed try: import ujson -except ImportError: - print("Server failed to import UltraJSON, failing back to native JSON library.") +except Exception as e: + print(f"Client failed to import UltraJSON, failing back to native JSON library. Exception code: {e}") import json as ujson # Import required CL4 client protocol @@ -45,6 +46,10 @@ class InternalError(Exception): """This exception is raised when an unexpected and/or unhandled exception is raised.""" pass + class ListenerExists(Exception): + """This exception is raised when attempting to process a listener that already has an existing listener instance.""" + pass + # Main server class client: @@ -67,6 +72,7 @@ def __init__(self): self.validator = cerberus.Validator() self.async_iterable = async_iterable self.exceptions = exceptions + self.copy = copy # Create event managers self.on_initial_connect_events = async_event_manager(self) @@ -77,7 +83,7 @@ def __init__(self): self.exception_handlers = dict() self.listener_events_await_specific = dict() self.listener_events_decorator_specific = dict() - + self.listener_responses = dict() # Create method handlers self.command_handlers = dict() @@ -138,6 +144,10 @@ def bind_event(func): # Credit to @ShowierData9978 for this: Listen for messages containing specific "listener" keys async def wait_for_listener(self, listener_id): + # Prevent listener collision + if listener_id in self.listener_events_await_specific: + raise self.exceptions.ListenerExists(f"The listener {listener_id} is already being awaited. Please use a different listener ID.") + # Create a new event object. event = self.asyncio.Event() @@ -155,9 +165,18 @@ async def wait_for_listener(self, listener_id): # Wait for the waiter task to finish. await task + # Get the response + response = self.copy(self.listener_responses[listener_id]) + # Remove from the listener events dict. self.listener_events_await_specific.pop(listener_id) + # Free up listener responses + self.listener_responses.pop(listener_id) + + # Return the response + return response + # Version of the wait for listener tool for decorator usage. def on_listener(self, listener_id): def bind_event(func): @@ -214,10 +233,11 @@ def send_packet(self, message): async def send_packet_and_wait(self, message): self.logger.debug(f"Sending message containing listener {message['listener']}...") await self.execute_send(message) - await self.wait_for_listener(message["listener"]) + response = await self.wait_for_listener(message["listener"]) + return response # Close the connection - def close_connection(self, code=1000, reason=""): + def disconnect(self, code=1000, reason=""): self.asyncio.create_task(self.execute_disconnect(code, reason)) # Message processor @@ -316,6 +336,7 @@ async def message_processor(self, message): if message["listener"] in self.listener_events_await_specific: # Fire awaiting listeners self.logger.debug(f"Received message containing listener {message['listener']}!") + self.listener_responses[message["listener"]] = message self.listener_events_await_specific[message["listener"]].set() elif message["listener"] in self.listener_events_decorator_specific: diff --git a/cloudlink/client/protocol.py b/cloudlink/client/protocol.py index d25f86e..dbce96e 100644 --- a/cloudlink/client/protocol.py +++ b/cloudlink/client/protocol.py @@ -63,18 +63,28 @@ async def on_initial_connect(): parent.logger.debug("Performing handshake with the server...") # Send the handshake request with a listener and wait for a response - await parent.send_packet_and_wait({ + response = await parent.send_packet_and_wait({ "cmd": "handshake", "listener": "init_handshake" }) - # Log the successful connection - parent.logger.info("Successfully connected to the server.") + if response["code_id"] == statuscodes.ok[1]: + # Log the successful connection + parent.logger.info("Successfully connected to the server.") - # Fire all on_connect events - parent.asyncio.create_task( - parent.execute_on_full_connect_events() - ) + # Fire all on_connect events + parent.asyncio.create_task( + parent.execute_on_full_connect_events() + ) + + else: + # Log the connection error + parent.logger.error(f"Failed to connect to the server. Got response code: {message['code']}") + + # Disconnect + parent.asyncio.create_task( + parent.disconnect() + ) @parent.on_command(cmd="ping") async def on_ping(message): diff --git a/cloudlink/server/__init__.py b/cloudlink/server/__init__.py index 3127055..8d230ea 100644 --- a/cloudlink/server/__init__.py +++ b/cloudlink/server/__init__.py @@ -21,8 +21,8 @@ # Import JSON library - Prefer UltraJSON but use native JSON if failed try: import ujson -except ImportError: - print("Server failed to import UltraJSON, failing back to native JSON library.") +except Exception as e: + print(f"Server failed to import UltraJSON, failing back to native JSON library. Exception code: {e}") import json as ujson From fb37053dc4ecb8813b3557f069a043a58bc9414b Mon Sep 17 00:00:00 2001 From: "Mike J. Renaker / \"MikeDEV" Date: Thu, 30 Mar 2023 14:13:57 -0400 Subject: [PATCH 38/59] Various fixes * Fix for #106 * Removed the shared_modules directory since there's only the async_iterables module now. * Updated README and SECURITY files. --- README.md | 21 ++-- SECURITY.md | 12 +- .../{shared_modules => }/async_iterables.py | 0 cloudlink/client/__init__.py | 77 +++++++------ cloudlink/server/__init__.py | 103 ++++++++---------- cloudlink/shared_modules/__init__.py | 2 - .../shared_modules/async_event_manager.py | 55 ---------- 7 files changed, 106 insertions(+), 164 deletions(-) rename cloudlink/{shared_modules => }/async_iterables.py (100%) delete mode 100644 cloudlink/shared_modules/__init__.py delete mode 100644 cloudlink/shared_modules/async_event_manager.py diff --git a/README.md b/README.md index cbf3062..69349ee 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,12 @@ ![CLOUDLINK 4 BANNER](https://user-images.githubusercontent.com/12957745/188282246-a221e66a-5d8a-4516-9ae2-79212b745d91.png) -# Cloudlink v0.2.0 +# Cloudlink CloudLink is a free and open-source websocket solution optimized for Scratch. -Originally created as a cloud variables alternative, it can function as a multi-purpose websocket framework for other projects. -**THIS VERSION OF CLOUDLINK IS STILL UNDER DEVELOPMENT. DO NOT USE IN A PRODUCTION ENVIRONMENT.** +Originally created as a cloud variables alternative, it can function as a multipurpose websocket framework for other projects. # πŸ’‘ Features πŸ’‘ -### πŸͺΆ Fast and lightweight +### πŸͺΆ Fast and lightweight CloudLink can run on minimal resources. At least 25MB of RAM and any reasonably capable CPU can run a CloudLink server. ### 🌐 Essential networking tools @@ -41,7 +40,7 @@ from cloudlink import server # Import default protocol from cloudlink.server.protocols import clpv4 -# Instanciate the server object +# Instantiate the server object server = server() # Set logging level @@ -54,12 +53,12 @@ clpv4 = clpv4(server) # Define the functions your plugin executes class myplugin: - def __init__(self, server, protocol): + def __init__(self, server, protocol): # Example command - client sends { "cmd": "foo" } to the server, this function will execute - @server.on_command(cmd="foo", schema=protocol.schema) - async def foobar(client, message): - print("Foobar!") + @server.on_command(cmd="foo", schema=protocol.schema) + async def foobar(client, message): + print("Foobar!") # Load the plugin! myplugin(server, clpv4) @@ -73,9 +72,9 @@ You can learn about the protocol using the original Scratch 3.0 client extension Feel free to test-drive the extension in any of these Scratch mods: - [TurboWarp](https://turbowarp.org/editor?extension=https://extensions.turbowarp.org/cloudlink.js) -- [SheepTester's E 羊 icques](https://sheeptester.github.io/scratch-gui/?url=https://mikedev101.github.io/cloudlink/S4-0-nosuite.js) +- [SheepTester's E羊icques](https://sheeptester.github.io/scratch-gui/?url=https://mikedev101.github.io/cloudlink/S4-0-nosuite.js) - [Ogadaki's Adacraft](https://adacraft.org/studio/) - [Ogadaki's Adacraft (Beta)](https://beta.adacraft.org/studio/) # πŸ“ƒ The CloudLink Protocol πŸ“ƒ -You can learn more about the CloudLink protocol on [CL's official HackMD documentation page.](https://hackmd.io/g6BogABhT6ux1GA2oqaOXA) +Documentation of the CL4 protocol can be found in the Cloudlink Repository's Wiki page. \ No newline at end of file diff --git a/SECURITY.md b/SECURITY.md index 4965127..17abd83 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,17 +2,17 @@ You are to keep your instance of Cloudlink as up-to-date as possible. You are to assume that support can be discontinued at any time, with or without reason. ## Supported Versions -| Version number | Supported? | Note | -|--------------|--------------|------| -| 0.1.9.x | 🟒 Yes | Latest release | -| 0.1.8.x | πŸ”΄ End of life | Pre-CL4 optimized. Should be upgraded. | -| 0.1.7.x and older | πŸ”΄ End of life | CL3/CL Legacy - EOL, should NOT be used | +| Version number | Supported? | Note | +|-------------------|------------|----------------------------------------------------------------------| +| 0.2.0 | 🟒 Yes | Final CL4 rewrite. | +| 0.1.9.x | 🟑 Yes | CL4 Optimized. Extended support will be offered to upgrade to 0.2.0. | +| 0.1.8.x | πŸ”΄ No | Pre-CL4 optimized. EOL. | +| 0.1.7.x and older | πŸ”΄ No | CL3/CL Legacy - EOL. | ### Notice for public server hosts Public server hosts should maintain the latest version. If a public server host has been found to be running on a Deprecated release, or a version that has not been upgraded in over 30 days, your public host will be removed from the public server list and you will be notified to update your server. ## Reporting vulnerabilities - In the event that a vulnerability has been found, please use the following format to report said vulnerability: 1. A title of the vulnerability - Should be less than 20 words diff --git a/cloudlink/shared_modules/async_iterables.py b/cloudlink/async_iterables.py similarity index 100% rename from cloudlink/shared_modules/async_iterables.py rename to cloudlink/async_iterables.py diff --git a/cloudlink/client/__init__.py b/cloudlink/client/__init__.py index 15f519a..33c724d 100644 --- a/cloudlink/client/__init__.py +++ b/cloudlink/client/__init__.py @@ -9,9 +9,8 @@ # Import websockets and SSL support import websockets -# Import shared modules -from cloudlink.shared_modules.async_event_manager import async_event_manager -from cloudlink.shared_modules.async_iterables import async_iterable +# Import shared module +from cloudlink.async_iterables import async_iterable # Import JSON library - Prefer UltraJSON but use native JSON if failed try: @@ -75,11 +74,11 @@ def __init__(self): self.copy = copy # Create event managers - self.on_initial_connect_events = async_event_manager(self) - self.on_full_connect_events = async_event_manager(self) - self.on_message_events = async_event_manager(self) - self.on_disconnect_events = async_event_manager(self) - self.on_error_events = async_event_manager(self) + self.on_initial_connect_events = set() + self.on_full_connect_events = set() + self.on_message_events = set() + self.on_disconnect_events = set() + self.on_error_events = set() self.exception_handlers = dict() self.listener_events_await_specific = dict() self.listener_events_decorator_specific = dict() @@ -134,10 +133,10 @@ def bind_event(func): # Create command event handler if cmd not in self.command_handlers: - self.command_handlers[cmd] = async_event_manager(self) + self.command_handlers[cmd] = set() # Add function to the command handler - self.command_handlers[cmd].bind(func) + self.command_handlers[cmd].add(func) # End on_command binder return bind_event @@ -183,10 +182,10 @@ def bind_event(func): # Create listener event handler if listener_id not in self.listener_events_decorator_specific: - self.listener_events_decorator_specific[listener_id] = async_event_manager(self) + self.listener_events_decorator_specific[listener_id] = set() # Add function to the listener handler - self.listener_events_decorator_specific[listener_id].bind(func) + self.listener_events_decorator_specific[listener_id].add(func) # End on_listener binder return bind_event @@ -197,33 +196,33 @@ def bind_event(func): # Create error event handler if exception_type not in self.exception_handlers: - self.exception_handlers[exception_type] = async_event_manager(self) + self.exception_handlers[exception_type] = set() # Add function to the error command handler - self.exception_handlers[exception_type].bind(func) + self.exception_handlers[exception_type].add(func) # End on_error_specific binder return bind_event # Event binder for on_message events def on_message(self, func): - self.on_message_events.bind(func) + self.on_message_events.add(func) # Event binder for starting up the client. def on_initial_connect(self, func): - self.on_initial_connect_events.bind(func) + self.on_initial_connect_events.add(func) # Event binder for on_connect events. def on_connect(self, func): - self.on_full_connect_events.bind(func) + self.on_full_connect_events.add(func) # Event binder for on_disconnect events. def on_disconnect(self, func): - self.on_disconnect_events.bind(func) + self.on_disconnect_events.add(func) # Event binder for on_error events. def on_error(self, func): - self.on_error_events.bind(func) + self.on_error_events.add(func) # Send message def send_packet(self, message): @@ -437,32 +436,39 @@ async def __run__(self, host): # Asyncio event-handling coroutines async def execute_on_disconnect_events(self): - async for event in self.on_disconnect_events: - await event() + events = [event() for even in self.on_disconnect_events] + group = self.asyncio.gather(*events) + await group async def execute_on_initial_connect_events(self): - async for event in self.on_initial_connect_events: - await event() + events = [event() for event in self.on_initial_connect_events] + group = self.asyncio.gather(*events) + await group async def execute_on_full_connect_events(self): - async for event in self.on_full_connect_events: - await event() + events = [event() for event in self.on_full_connect_events] + group = self.asyncio.gather(*events) + await group async def execute_on_message_events(self, message): - async for event in self.on_message_events: - await event(message) + events = [event(message) for event in self.on_message_events] + group = self.asyncio.gather(*events) + await group async def execute_on_command_events(self, message): - async for event in self.command_handlers[message["cmd"]]: - await event(message) + events = [event(message) for event in self.command_handlers[message["cmd"]]] + group = self.asyncio.gather(*events) + await group async def execute_on_listener_events(self, message): - async for event in self.listener_events_decorator_specific[message["listener"]]: - await event(message) + events = [event(message) for event in self.listener_events_decorator_specific[message["listener"]]] + group = self.asyncio.gather(*events) + await group async def execute_on_error_events(self, errors): - async for event in self.on_error_events: - await event(errors) + events = [event(errors) for event in self.on_error_events] + group = self.asyncio.gather(*events) + await group async def execute_exception_handlers(self, exception_type, details): # Guard clauses @@ -470,8 +476,9 @@ async def execute_exception_handlers(self, exception_type, details): return # Fire events - async for event in self.exception_handlers[exception_type]: - await event(details) + events = [event(details) for event in self.exception_handlers[exception_type]] + group = self.asyncio.gather(*events) + await group async def listener_waiter(self, listener_id, event): await event.wait() diff --git a/cloudlink/server/__init__.py b/cloudlink/server/__init__.py index 8d230ea..0663f01 100644 --- a/cloudlink/server/__init__.py +++ b/cloudlink/server/__init__.py @@ -11,8 +11,7 @@ import ssl # Import shared modules -from cloudlink.shared_modules.async_event_manager import async_event_manager -from cloudlink.shared_modules.async_iterables import async_iterable +from cloudlink.async_iterables import async_iterable # Import server-specific modules from cloudlink.server.modules.clients_manager import clients_manager @@ -82,10 +81,10 @@ def __init__(self): self.disabled_commands = dict() # Create event managers - self.on_connect_events = async_event_manager(self) - self.on_message_events = async_event_manager(self) - self.on_disconnect_events = async_event_manager(self) - self.on_error_events = async_event_manager(self) + self.on_connect_events = set() + self.on_message_events = set() + self.on_disconnect_events = set() + self.on_error_events = set() self.exception_handlers = dict() self.disabled_commands_handlers = dict() self.protocol_identified_events = dict() @@ -155,10 +154,10 @@ def bind_event(func): # Create command event handler if cmd not in self.command_handlers[schema]: - self.command_handlers[schema][cmd] = async_event_manager(self) + self.command_handlers[schema][cmd] = set() # Add function to the command handler - self.command_handlers[schema][cmd].bind(func) + self.command_handlers[schema][cmd].add(func) # End on_command binder return bind_event @@ -174,10 +173,10 @@ def bind_event(func): # Create error event handler if exception_type not in self.exception_handlers[schema]: - self.exception_handlers[schema][exception_type] = async_event_manager(self) + self.exception_handlers[schema][exception_type] = set() # Add function to the error command handler - self.exception_handlers[schema][exception_type].bind(func) + self.exception_handlers[schema][exception_type].add(func) # End on_error_specific binder return bind_event @@ -188,10 +187,10 @@ def bind_event(func): # Create disabled command event manager if schema not in self.disabled_commands_handlers: self.logger.info(f"Creating disabled command event manager {schema.__qualname__}") - self.disabled_commands_handlers[schema] = async_event_manager(self) + self.disabled_commands_handlers[schema] = set() # Add function to the error command handler - self.disabled_commands_handlers[schema].bind(func) + self.disabled_commands_handlers[schema].add(func) # End on_error_specific binder return bind_event @@ -201,10 +200,10 @@ def bind_event(func): # Create protocol identified event manager if schema not in self.protocol_identified_events: self.logger.info(f"Creating protocol identified event manager {schema.__qualname__}") - self.protocol_identified_events[schema] = async_event_manager(self) + self.protocol_identified_events[schema] = set() # Add function to the protocol identified event manager - self.protocol_identified_events[schema].bind(func) + self.protocol_identified_events[schema].add(func) # End on_protocol_identified binder return bind_event @@ -214,29 +213,29 @@ def bind_event(func): # Create protocol disconnect event manager if schema not in self.protocol_disconnect_events: self.logger.info(f"Creating protocol disconnect event manager {schema.__qualname__}") - self.protocol_disconnect_events[schema] = async_event_manager(self) + self.protocol_disconnect_events[schema] = set() # Add function to the protocol disconnect event manager - self.protocol_disconnect_events[schema].bind(func) + self.protocol_disconnect_events[schema].add(func) # End on_protocol_disconnect binder return bind_event # Event binder for on_message events def on_message(self, func): - self.on_message_events.bind(func) + self.on_message_events.add(func) # Event binder for on_connect events. def on_connect(self, func): - self.on_connect_events.bind(func) + self.on_connect_events.add(func) # Event binder for on_disconnect events. def on_disconnect(self, func): - self.on_disconnect_events.bind(func) + self.on_disconnect_events.add(func) # Event binder for on_error events. def on_error(self, func): - self.on_error_events.bind(func) + self.on_error_events.add(func) # Friendly version of send_packet_unicast / send_packet_multicast def send_packet(self, obj, message): @@ -589,24 +588,29 @@ async def __run__(self, ip, port): # Asyncio event-handling coroutines async def execute_on_disconnect_events(self, client): - async for event in self.on_disconnect_events: - await event(client) + events = [event(client) for even in self.on_disconnect_events] + group = self.asyncio.gather(*events) + await group async def execute_on_connect_events(self, client): - async for event in self.on_connect_events: - await event(client) + events = [event(client) for event in self.on_connect_events] + group = self.asyncio.gather(*events) + await group async def execute_on_message_events(self, client, message): - async for event in self.on_message_events: - await event(client, message) + events = [event(client, message) for event in self.on_message_events] + group = self.asyncio.gather(*events) + await group async def execute_on_command_events(self, client, message, schema): - async for event in self.command_handlers[schema][message[schema.command_key]]: - await event(client, message) + events = [event(client, message) for event in self.command_handlers[schema][message[schema.command_key]]] + group = self.asyncio.gather(*events) + await group async def execute_on_error_events(self, client, errors): - async for event in self.on_error_events: - await event(client, errors) + events = [event(client, errors) for event in self.on_error_events] + group = self.asyncio.gather(*events) + await group async def execute_exception_handlers(self, client, exception_type, schema, details): # Guard clauses @@ -634,8 +638,9 @@ async def execute_protocol_identified_events(self, client, schema): return # Fire events - async for event in self.protocol_identified_events[schema]: - await event(client) + events = [event(client) for event in self.protocol_identified_events[schema]] + group = self.asyncio.gather(*events) + await group async def execute_protocol_disconnect_events(self, client, schema): # Guard clauses @@ -643,13 +648,13 @@ async def execute_protocol_disconnect_events(self, client, schema): return # Fire events - async for event in self.protocol_disconnect_events[schema]: - await event(client) + events = [event(client) for event in self.protocol_disconnect_events[schema]] + group = self.asyncio.gather(*events) + await group # WebSocket-specific coroutines async def execute_unicast(self, client, message): - # Guard clause if type(message) not in [dict, str]: raise TypeError(f"Supported datatypes for messages are dicts and strings, got type {type(message)}.") @@ -663,27 +668,14 @@ async def execute_unicast(self, client, message): await client.send(message) except Exception as e: self.logger.critical( - f"Unexpected exception was raised while unicasting message to client {client.snowflake}: {e}" + f"Unexpected exception was raised while sending message to client {client.snowflake}: {e}" ) async def execute_multicast(self, clients, message): - - # Guard clause - if type(message) not in [dict, str]: - raise TypeError(f"Supported datatypes for messages are dicts and strings, got type {type(message)}.") - - # Convert dict to JSON - if type(message) == dict: - message = self.ujson.dumps(message) - - # Attempt to broadcast the packet - async for client in self.async_iterable(clients): - try: - await self.execute_unicast(client, message) - except Exception as e: - self.logger.critical( - f"Unexpected exception was raised while multicasting message to client {client.snowflake}: {e}" - ) + # Multicast the message + events = [self.execute_unicast(client, message) for client in clients] + group = self.asyncio.gather(*events) + await group async def execute_close_single(self, client, code=1000, reason=""): try: @@ -694,5 +686,6 @@ async def execute_close_single(self, client, code=1000, reason=""): ) async def execute_close_multi(self, clients, code=1000, reason=""): - async for client in self.async_iterable(clients): - await self.execute_close_single(client, code, reason) + events = [self.execute_close_single(client, code, reason) for client in clients] + group = self.asyncio.gather(*events) + await group diff --git a/cloudlink/shared_modules/__init__.py b/cloudlink/shared_modules/__init__.py deleted file mode 100644 index 06505fd..0000000 --- a/cloudlink/shared_modules/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from .async_event_manager import async_event_manager -from .async_iterables import async_iterable diff --git a/cloudlink/shared_modules/async_event_manager.py b/cloudlink/shared_modules/async_event_manager.py deleted file mode 100644 index 0a26f95..0000000 --- a/cloudlink/shared_modules/async_event_manager.py +++ /dev/null @@ -1,55 +0,0 @@ -""" -async_event_manager - A more powerful way to fire callbacks and create decorator events with Cloudlink. - -bind(func: ) -* Adds an asyncio method to the event manager object. -* bind() should only be called during setup, and should not be called after the server is started. - -unbind(func: ) -* Removes an asyncio method from the event manager object. -* unbind() should only be called during setup, and should not be called after the server is started. - -reset() -* Clears all asyncio methods to be executed from the event manager object. -""" - - -class async_event_manager: - def __init__(self, parent): - # Declare constructor with initial state - self.iterator = 0 - self.events = set() - - # Init logger - self.logging = parent.logging - self.logger = self.logging.getLogger(__name__) - - # Add functions to event list - def bind(self, func): - self.events.add(func) - - # Remove functions from events list - def unbind(self, func): - self.events.remove(func) - - # Cleanup events list - def reset(self): - self.iterator = 0 - self.events = set() - - # Create instance of async iterator - def __aiter__(self): - return self - - # Return next awaitable - async def __anext__(self): - # Check for further events in the list of events - if self.iterator >= len(self.events): - self.iterator = 0 - raise StopAsyncIteration - - # Increment iterator - self.iterator += 1 - - # Execute async event - return list(self.events)[self.iterator - 1] From 8ac90c281751c28a95e4a62d3e785bd1cc07f912 Mon Sep 17 00:00:00 2001 From: "Mike J. Renaker / \"MikeDEV" Date: Fri, 31 Mar 2023 10:32:40 -0400 Subject: [PATCH 39/59] Update SECURITY.md --- SECURITY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SECURITY.md b/SECURITY.md index 17abd83..c8d1e41 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -5,7 +5,7 @@ You are to keep your instance of Cloudlink as up-to-date as possible. You are to | Version number | Supported? | Note | |-------------------|------------|----------------------------------------------------------------------| | 0.2.0 | 🟒 Yes | Final CL4 rewrite. | -| 0.1.9.x | 🟑 Yes | CL4 Optimized. Extended support will be offered to upgrade to 0.2.0. | +| 0.1.9.x | 🟑 Yes | CL4 Optimized. Support will end on April 30, 2023. | | 0.1.8.x | πŸ”΄ No | Pre-CL4 optimized. EOL. | | 0.1.7.x and older | πŸ”΄ No | CL3/CL Legacy - EOL. | From 62a0f28ba3378ecb98de164b40e16b09e68c3301 Mon Sep 17 00:00:00 2001 From: "Mike J. Renaker / \"MikeDEV" Date: Fri, 31 Mar 2023 11:30:11 -0400 Subject: [PATCH 40/59] typo --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 69349ee..0e30937 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ server = server() # Set logging level server.logging.basicConfig( - level=cl.logging.DEBUG + level=server.logging.DEBUG ) # Load default CL protocol @@ -56,7 +56,7 @@ class myplugin: def __init__(self, server, protocol): # Example command - client sends { "cmd": "foo" } to the server, this function will execute - @server.on_command(cmd="foo", schema=protocol.schema) + @server.on_command(cmd="foo", schema=clpv4.schema) async def foobar(client, message): print("Foobar!") @@ -77,4 +77,4 @@ Feel free to test-drive the extension in any of these Scratch mods: - [Ogadaki's Adacraft (Beta)](https://beta.adacraft.org/studio/) # πŸ“ƒ The CloudLink Protocol πŸ“ƒ -Documentation of the CL4 protocol can be found in the Cloudlink Repository's Wiki page. \ No newline at end of file +Documentation of the CL4 protocol can be found in the Cloudlink Repository's Wiki page. From 69c2acf9bc31e361d75e85a7923bbd1b11c1d28e Mon Sep 17 00:00:00 2001 From: "Mike J. Renaker / \"MikeDEV" Date: Fri, 31 Mar 2023 14:47:00 -0400 Subject: [PATCH 41/59] Modify validators --- cloudlink/client/__init__.py | 7 ++++--- cloudlink/server/__init__.py | 18 +++++++++++------- cloudlink/server/protocols/clpv4/clpv4.py | 9 ++++++--- 3 files changed, 21 insertions(+), 13 deletions(-) diff --git a/cloudlink/client/__init__.py b/cloudlink/client/__init__.py index 33c724d..354d016 100644 --- a/cloudlink/client/__init__.py +++ b/cloudlink/client/__init__.py @@ -68,7 +68,7 @@ def __init__(self): # Components self.ujson = ujson - self.validator = cerberus.Validator() + self.validator = cerberus.Validator self.async_iterable = async_iterable self.exceptions = exceptions self.copy = copy @@ -288,8 +288,9 @@ async def message_processor(self, message): return # Begin validation - if not self.validator(message, self.schema.default): - errors = self.validator.errors + validator = self.validator(self.schema.default, allow_unknown=True) + if not validator.validate(message): + errors = validator.errors # Log failed validation self.logger.debug(f"Server sent message that failed validation: {errors}") diff --git a/cloudlink/server/__init__.py b/cloudlink/server/__init__.py index 0663f01..177fee2 100644 --- a/cloudlink/server/__init__.py +++ b/cloudlink/server/__init__.py @@ -70,7 +70,7 @@ def __init__(self): # Components self.ujson = ujson self.gen = SnowflakeGenerator(42) - self.validator = cerberus.Validator() + self.validator = cerberus.Validator self.async_iterable = async_iterable self.copy = copy self.clients_manager = clients_manager(self) @@ -362,14 +362,15 @@ async def message_processor(self, client, message): # Identify protocol errorlist = list() - + for schema in self.command_handlers: - if self.validator(message, schema.default): + validator = self.validator(schema.default, allow_unknown=True) + if validator.validate(message): valid = True selected_protocol = schema break else: - errorlist.append(self.validator.errors) + errorlist.append(validator.errors) if not valid: # Log failed identification @@ -400,9 +401,12 @@ async def message_processor(self, client, message): # Validate message using known protocol selected_protocol = client.protocol - - if not self.validator(message, selected_protocol.default): - errors = self.validator.errors + + validator = self.validator(selected_protocol.default, allow_unknown=True) + + + if not validator.validate(message): + errors = validator.errors # Log failed validation self.logger.debug(f"Client {client.snowflake} sent message that failed validation: {errors}") diff --git a/cloudlink/server/protocols/clpv4/clpv4.py b/cloudlink/server/protocols/clpv4/clpv4.py index 07cb7c3..40553f3 100644 --- a/cloudlink/server/protocols/clpv4/clpv4.py +++ b/cloudlink/server/protocols/clpv4/clpv4.py @@ -93,12 +93,15 @@ def get_client_ip(client): self.get_client_ip = get_client_ip # valid(message, schema): Used to verify messages. - def valid(client, message, schema): - if server.validator(message, schema): + def valid(client, message, schema, allow_unknown=True): + + validator = server.validator(schema, allow_unknown=allow_unknown) + + if validator.validate(message): return True # Alert the client that the schema was invalid - send_statuscode(client, statuscodes.syntax, details=dict(server.validator.errors)) + send_statuscode(client, statuscodes.syntax, details=dict(validator.errors)) return False # Expose validator function for extension usage From bec61ee5936ec8cfbb4566835de6345870b6cd85 Mon Sep 17 00:00:00 2001 From: "Mike J. Renaker / \"MikeDEV" Date: Fri, 31 Mar 2023 22:30:16 -0400 Subject: [PATCH 42/59] Fix typo --- cloudlink/server/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cloudlink/server/__init__.py b/cloudlink/server/__init__.py index 177fee2..26c7187 100644 --- a/cloudlink/server/__init__.py +++ b/cloudlink/server/__init__.py @@ -592,7 +592,7 @@ async def __run__(self, ip, port): # Asyncio event-handling coroutines async def execute_on_disconnect_events(self, client): - events = [event(client) for even in self.on_disconnect_events] + events = [event(client) for event in self.on_disconnect_events] group = self.asyncio.gather(*events) await group From d7850e691f39f8cfb2773c2394e6125d10bb2cc1 Mon Sep 17 00:00:00 2001 From: "Mike J. Renaker / \"MikeDEV" Date: Fri, 31 Mar 2023 22:31:01 -0400 Subject: [PATCH 43/59] Fix another typo --- cloudlink/client/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cloudlink/client/__init__.py b/cloudlink/client/__init__.py index 354d016..a7a9f9c 100644 --- a/cloudlink/client/__init__.py +++ b/cloudlink/client/__init__.py @@ -437,7 +437,7 @@ async def __run__(self, host): # Asyncio event-handling coroutines async def execute_on_disconnect_events(self): - events = [event() for even in self.on_disconnect_events] + events = [event() for event in self.on_disconnect_events] group = self.asyncio.gather(*events) await group From 07d992efd79fcc46b0b5763424e9f6a1ba2baa0d Mon Sep 17 00:00:00 2001 From: "Mike J. Renaker / \"MikeDEV" Date: Fri, 31 Mar 2023 22:57:02 -0400 Subject: [PATCH 44/59] Fix imports --- cloudlink/__init__.py | 4 ++-- cloudlink/client/__init__.py | 4 ++-- cloudlink/server/__init__.py | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/cloudlink/__init__.py b/cloudlink/__init__.py index 8cf5a92..29f3165 100644 --- a/cloudlink/__init__.py +++ b/cloudlink/__init__.py @@ -1,2 +1,2 @@ -from cloudlink.server import server -from cloudlink.client import client +from .server import server +from .client import client diff --git a/cloudlink/client/__init__.py b/cloudlink/client/__init__.py index a7a9f9c..8485ece 100644 --- a/cloudlink/client/__init__.py +++ b/cloudlink/client/__init__.py @@ -10,7 +10,7 @@ import websockets # Import shared module -from cloudlink.async_iterables import async_iterable +from ..async_iterables import async_iterable # Import JSON library - Prefer UltraJSON but use native JSON if failed try: @@ -20,7 +20,7 @@ import json as ujson # Import required CL4 client protocol -from cloudlink.client import protocol, schema +from . import protocol, schema # Define server exceptions diff --git a/cloudlink/server/__init__.py b/cloudlink/server/__init__.py index 26c7187..ada918f 100644 --- a/cloudlink/server/__init__.py +++ b/cloudlink/server/__init__.py @@ -11,11 +11,11 @@ import ssl # Import shared modules -from cloudlink.async_iterables import async_iterable +from ..async_iterables import async_iterable # Import server-specific modules -from cloudlink.server.modules.clients_manager import clients_manager -from cloudlink.server.modules.rooms_manager import rooms_manager +from .modules.clients_manager import clients_manager +from .modules.rooms_manager import rooms_manager # Import JSON library - Prefer UltraJSON but use native JSON if failed try: From 9a091f2b3857a87e6c2b4d80f560dbfd48e2f677 Mon Sep 17 00:00:00 2001 From: "Mike J. Renaker / \"MikeDEV" Date: Sat, 1 Apr 2023 10:31:30 -0400 Subject: [PATCH 45/59] Fixes * Fix for exception handler not firing properly * Fix for disabled command handler not firing properly --- cloudlink/server/__init__.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/cloudlink/server/__init__.py b/cloudlink/server/__init__.py index ada918f..e4873b0 100644 --- a/cloudlink/server/__init__.py +++ b/cloudlink/server/__init__.py @@ -624,8 +624,9 @@ async def execute_exception_handlers(self, client, exception_type, schema, detai return # Fire events - async for event in self.exception_handlers[schema][exception_type]: - await event(client, details) + events = [event(client, details) for client in self.exception_handlers[schema][exception_type]] + group = self.asyncio.gather(*events) + await group async def execute_disabled_command_events(self, client, schema, cmd): # Guard clauses @@ -633,8 +634,9 @@ async def execute_disabled_command_events(self, client, schema, cmd): return # Fire events - async for event in self.disabled_commands_handlers[schema]: - await event(client, cmd) + events = [event(client, cmd) for client in self.disabled_commands_handlers[schema]] + group = self.asyncio.gather(*events) + await group async def execute_protocol_identified_events(self, client, schema): # Guard clauses From 83f3678f2e84439f5b3df4a45e9fcb84ed2414b5 Mon Sep 17 00:00:00 2001 From: "Mike J. Renaker / \"MikeDEV" Date: Sat, 1 Apr 2023 10:34:20 -0400 Subject: [PATCH 46/59] Typo --- cloudlink/server/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cloudlink/server/__init__.py b/cloudlink/server/__init__.py index e4873b0..031de3d 100644 --- a/cloudlink/server/__init__.py +++ b/cloudlink/server/__init__.py @@ -624,7 +624,7 @@ async def execute_exception_handlers(self, client, exception_type, schema, detai return # Fire events - events = [event(client, details) for client in self.exception_handlers[schema][exception_type]] + events = [event(client, details) for event in self.exception_handlers[schema][exception_type]] group = self.asyncio.gather(*events) await group @@ -634,7 +634,7 @@ async def execute_disabled_command_events(self, client, schema, cmd): return # Fire events - events = [event(client, cmd) for client in self.disabled_commands_handlers[schema]] + events = [event(client, cmd) for event in self.disabled_commands_handlers[schema]] group = self.asyncio.gather(*events) await group From 8c642e699925821a503a23e7e62d4bd1c11a0dde Mon Sep 17 00:00:00 2001 From: "Mike J. Renaker / \"MikeDEV" Date: Sat, 1 Apr 2023 11:08:55 -0400 Subject: [PATCH 47/59] New stuff + Behavior modifications * Added command unbinder * Modified CLPv4 to suppress handshake method (only gets called by the handshake command, as originally intended) --- cloudlink/server/__init__.py | 15 +++++++++++- cloudlink/server/protocols/clpv4/clpv4.py | 29 ++--------------------- 2 files changed, 16 insertions(+), 28 deletions(-) diff --git a/cloudlink/server/__init__.py b/cloudlink/server/__init__.py index 031de3d..13c4296 100644 --- a/cloudlink/server/__init__.py +++ b/cloudlink/server/__init__.py @@ -142,7 +142,15 @@ def run(self, ip="127.0.0.1", port=3000): except KeyboardInterrupt: pass - + + def unbind_command(self, cmd, schema): + if schema not in self.command_handlers: + raise ValueError + if cmd not in self.command_handlers[schema]: + raise ValueError + self.logger.debug(f"Unbinding command {cmd} from {schema.__qualname__} command event manager") + self.command_handlers[schema].pop(cmd) + # Event binder for on_command events def on_command(self, cmd, schema): def bind_event(func): @@ -157,6 +165,7 @@ def bind_event(func): self.command_handlers[schema][cmd] = set() # Add function to the command handler + self.logger.debug(f"Binding function {func.__name__} to command {cmd} in {schema.__qualname__} command event manager") self.command_handlers[schema][cmd].add(func) # End on_command binder @@ -176,6 +185,7 @@ def bind_event(func): self.exception_handlers[schema][exception_type] = set() # Add function to the error command handler + self.logger.debug(f"Binding function {func.__name__} to exception {exception_type} in {schema.__qualname__} exception event manager") self.exception_handlers[schema][exception_type].add(func) # End on_error_specific binder @@ -190,6 +200,7 @@ def bind_event(func): self.disabled_commands_handlers[schema] = set() # Add function to the error command handler + self.logger.debug(f"Binding function {func.__name__} to {schema.__qualname__} disabled command event manager") self.disabled_commands_handlers[schema].add(func) # End on_error_specific binder @@ -203,6 +214,7 @@ def bind_event(func): self.protocol_identified_events[schema] = set() # Add function to the protocol identified event manager + self.logger.debug(f"Binding function {func.__name__} to {schema.__qualname__} protocol identified event manager") self.protocol_identified_events[schema].add(func) # End on_protocol_identified binder @@ -216,6 +228,7 @@ def bind_event(func): self.protocol_disconnect_events[schema] = set() # Add function to the protocol disconnect event manager + self.logger.debug(f"Binding function {func.__name__} to {schema.__qualname__} protocol disconnected event manager") self.protocol_disconnect_events[schema].add(func) # End on_protocol_disconnect binder diff --git a/cloudlink/server/protocols/clpv4/clpv4.py b/cloudlink/server/protocols/clpv4/clpv4.py index 40553f3..09f5086 100644 --- a/cloudlink/server/protocols/clpv4/clpv4.py +++ b/cloudlink/server/protocols/clpv4/clpv4.py @@ -202,7 +202,7 @@ def generate_user_object(obj): self.generate_user_object = generate_user_object # If the client has not explicitly used the handshake command, send them the handshake data - async def automatic_notify_handshake(client): + async def notify_handshake(client): # Don't execute this if handshake was already done if client.handshake: return @@ -242,19 +242,14 @@ async def automatic_notify_handshake(client): "rooms": room }) - # Expose for extension usage - self.automatic_notify_handshake = automatic_notify_handshake - # Exception handlers @server.on_exception(exception_type=server.exceptions.ValidationError, schema=cl4_protocol) async def validation_failure(client, details): - await automatic_notify_handshake(client) send_statuscode(client, statuscodes.syntax, details=dict(details)) @server.on_exception(exception_type=server.exceptions.InvalidCommand, schema=cl4_protocol) async def invalid_command(client, details): - await automatic_notify_handshake(client) send_statuscode( client, statuscodes.invalid_command, @@ -263,7 +258,6 @@ async def invalid_command(client, details): @server.on_disabled_command(schema=cl4_protocol) async def disabled_command(client, details): - await automatic_notify_handshake(client) send_statuscode( client, statuscodes.disabled_command, @@ -272,7 +266,6 @@ async def disabled_command(client, details): @server.on_exception(exception_type=server.exceptions.JSONError, schema=cl4_protocol) async def json_exception(client, details): - await automatic_notify_handshake(client) send_statuscode( client, statuscodes.json_error, @@ -281,7 +274,6 @@ async def json_exception(client, details): @server.on_exception(exception_type=server.exceptions.EmptyMessage, schema=cl4_protocol) async def empty_message(client): - await automatic_notify_handshake(client) send_statuscode( client, statuscodes.empty_packet, @@ -320,18 +312,15 @@ async def protocol_disconnect(client): @server.on_command(cmd="handshake", schema=cl4_protocol) async def on_handshake(client, message): - await automatic_notify_handshake(client) + await notify_handshake(client) send_statuscode(client, statuscodes.ok, message=message) @server.on_command(cmd="ping", schema=cl4_protocol) async def on_ping(client, message): - await automatic_notify_handshake(client) send_statuscode(client, statuscodes.ok, message=message) @server.on_command(cmd="gmsg", schema=cl4_protocol) async def on_gmsg(client, message): - await automatic_notify_handshake(client) - # Validate schema if not valid(client, message, cl4_protocol.gmsg): return @@ -392,8 +381,6 @@ async def on_gmsg(client, message): @server.on_command(cmd="pmsg", schema=cl4_protocol) async def on_pmsg(client, message): - await automatic_notify_handshake(client) - # Validate schema if not valid(client, message, cl4_protocol.pmsg): return @@ -472,8 +459,6 @@ async def on_pmsg(client, message): @server.on_command(cmd="gvar", schema=cl4_protocol) async def on_gvar(client, message): - await automatic_notify_handshake(client) - # Validate schema if not valid(client, message, cl4_protocol.gvar): return @@ -518,8 +503,6 @@ async def on_gvar(client, message): @server.on_command(cmd="pvar", schema=cl4_protocol) async def on_pvar(client, message): - await automatic_notify_handshake(client) - # Validate schema if not valid(client, message, cl4_protocol.pvar): return @@ -600,8 +583,6 @@ async def on_pvar(client, message): @server.on_command(cmd="setid", schema=cl4_protocol) async def on_setid(client, message): - await automatic_notify_handshake(client) - # Validate schema if not valid(client, message, cl4_protocol.setid): return @@ -657,8 +638,6 @@ async def on_setid(client, message): @server.on_command(cmd="link", schema=cl4_protocol) async def on_link(client, message): - await automatic_notify_handshake(client) - # Validate schema if not valid(client, message, cl4_protocol.linking): return @@ -719,8 +698,6 @@ async def on_link(client, message): @server.on_command(cmd="unlink", schema=cl4_protocol) async def on_unlink(client, message): - await automatic_notify_handshake(client) - # Validate schema if not valid(client, message, cl4_protocol.linking): return @@ -785,8 +762,6 @@ async def on_unlink(client, message): @server.on_command(cmd="direct", schema=cl4_protocol) async def on_direct(client, message): - await automatic_notify_handshake(client) - # Validate schema if not valid(client, message, cl4_protocol.direct): return From e167a998b7d59df37b8375cf8b27a4bcc24b049a Mon Sep 17 00:00:00 2001 From: "Mike J. Renaker / \"MikeDEV" Date: Sat, 1 Apr 2023 13:59:39 -0400 Subject: [PATCH 48/59] Fixes * Bugfix Scratch protocol * Modified logging events --- cloudlink/server/__init__.py | 12 ++++++------ cloudlink/server/protocols/scratch/scratch.py | 8 +++++--- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/cloudlink/server/__init__.py b/cloudlink/server/__init__.py index 13c4296..1cc2f27 100644 --- a/cloudlink/server/__init__.py +++ b/cloudlink/server/__init__.py @@ -157,7 +157,7 @@ def bind_event(func): # Create schema category for command event manager if schema not in self.command_handlers: - self.logger.info(f"Creating protocol {schema.__qualname__} command event manager") + self.logger.debug(f"Creating protocol {schema.__qualname__} command event manager") self.command_handlers[schema] = dict() # Create command event handler @@ -177,7 +177,7 @@ def bind_event(func): # Create schema category for error event manager if schema not in self.exception_handlers: - self.logger.info(f"Creating protocol {schema.__qualname__} exception event manager") + self.logger.debug(f"Creating protocol {schema.__qualname__} exception event manager") self.exception_handlers[schema] = dict() # Create error event handler @@ -185,7 +185,7 @@ def bind_event(func): self.exception_handlers[schema][exception_type] = set() # Add function to the error command handler - self.logger.debug(f"Binding function {func.__name__} to exception {exception_type} in {schema.__qualname__} exception event manager") + self.logger.debug(f"Binding function {func.__name__} to exception {exception_type.__name__} in {schema.__qualname__} exception event manager") self.exception_handlers[schema][exception_type].add(func) # End on_error_specific binder @@ -196,7 +196,7 @@ def on_disabled_command(self, schema): def bind_event(func): # Create disabled command event manager if schema not in self.disabled_commands_handlers: - self.logger.info(f"Creating disabled command event manager {schema.__qualname__}") + self.logger.debug(f"Creating disabled command event manager {schema.__qualname__}") self.disabled_commands_handlers[schema] = set() # Add function to the error command handler @@ -210,7 +210,7 @@ def on_protocol_identified(self, schema): def bind_event(func): # Create protocol identified event manager if schema not in self.protocol_identified_events: - self.logger.info(f"Creating protocol identified event manager {schema.__qualname__}") + self.logger.debug(f"Creating protocol identified event manager {schema.__qualname__}") self.protocol_identified_events[schema] = set() # Add function to the protocol identified event manager @@ -224,7 +224,7 @@ def on_protocol_disconnect(self, schema): def bind_event(func): # Create protocol disconnect event manager if schema not in self.protocol_disconnect_events: - self.logger.info(f"Creating protocol disconnect event manager {schema.__qualname__}") + self.logger.debug(f"Creating protocol disconnect event manager {schema.__qualname__}") self.protocol_disconnect_events[schema] = set() # Add function to the protocol disconnect event manager diff --git a/cloudlink/server/protocols/scratch/scratch.py b/cloudlink/server/protocols/scratch/scratch.py index 06a240a..beb2753 100644 --- a/cloudlink/server/protocols/scratch/scratch.py +++ b/cloudlink/server/protocols/scratch/scratch.py @@ -21,11 +21,13 @@ class statuscodes: self.schema = scratch # valid(message, schema): Used to verify messages. - def valid(client, message, schema): - if server.validator(message, schema): + def valid(client, message, schema, allow_unknown=True): + validator = server.validator(schema, allow_unknown=allow_unknown) + + if validator.validate(message): return True else: - errors = server.validator.errors + errors = validator.errors server.logger.warning(f"Error: {errors}") server.send_packet_unicast(client, f"Validation failed: {dict(errors)}") server.close_connection(client, code=statuscodes.connection_error, reason=f"Validation failed") From f90c83f2aba9306bbf38ab593cd72ef75c6b950c Mon Sep 17 00:00:00 2001 From: "Mike J. Renaker / \"MikeDEV" Date: Tue, 2 May 2023 10:25:20 -0400 Subject: [PATCH 49/59] Update serverlist.json #111 --- serverlist.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/serverlist.json b/serverlist.json index aa9dfaf..34b40ea 100644 --- a/serverlist.json +++ b/serverlist.json @@ -13,6 +13,6 @@ }, "3": { "id": "Tnix's Oceania Server", - "url": "wss://cloudlink.tnix.software/" + "url": "wss://cl4.tnix.dev//" } } From e4de7148b8a70f55bcff3b0ec0b4027ec22f2b9c Mon Sep 17 00:00:00 2001 From: "Mike J. Renaker / \"MikeDEV" Date: Tue, 2 May 2023 10:32:24 -0400 Subject: [PATCH 50/59] Cloudlink -> CloudLink ^ --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 0e30937..8bbe104 100644 --- a/README.md +++ b/README.md @@ -77,4 +77,4 @@ Feel free to test-drive the extension in any of these Scratch mods: - [Ogadaki's Adacraft (Beta)](https://beta.adacraft.org/studio/) # πŸ“ƒ The CloudLink Protocol πŸ“ƒ -Documentation of the CL4 protocol can be found in the Cloudlink Repository's Wiki page. +Documentation of the CL4 protocol can be found in the CloudLink Repository's Wiki page. From b7ce18cb33b896a9bd8a89ea9f0c5440113a194c Mon Sep 17 00:00:00 2001 From: "Mike J. Renaker / \"MikeDEV" Date: Tue, 2 May 2023 10:58:45 -0400 Subject: [PATCH 51/59] Goodbye old code --- old/client-test-async.py | 97 -- old/client-test-old.py | 97 -- old/cloudlink/__init__.py | 1 - old/cloudlink/__main__.py | 23 - old/cloudlink/async_client/__init__.py | 1 - old/cloudlink/async_client/async_client.py | 632 ------------ old/cloudlink/cloudlink.py | 52 - old/cloudlink/docs/docs.txt | 1 - old/cloudlink/old_client/__init__.py | 1 - old/cloudlink/old_client/old_client.py | 601 ----------- old/cloudlink/server/__init__.py | 1 - old/cloudlink/server/protocols/__init__.py | 2 - old/cloudlink/server/protocols/cl_methods.py | 647 ------------ .../server/protocols/scratch_methods.py | 258 ----- old/cloudlink/server/server.py | 953 ------------------ old/cloudlink/supporter.py | 275 ----- old/requirements.txt | 2 - old/server_example.py | 108 -- 18 files changed, 3752 deletions(-) delete mode 100644 old/client-test-async.py delete mode 100644 old/client-test-old.py delete mode 100644 old/cloudlink/__init__.py delete mode 100644 old/cloudlink/__main__.py delete mode 100644 old/cloudlink/async_client/__init__.py delete mode 100644 old/cloudlink/async_client/async_client.py delete mode 100644 old/cloudlink/cloudlink.py delete mode 100644 old/cloudlink/docs/docs.txt delete mode 100644 old/cloudlink/old_client/__init__.py delete mode 100644 old/cloudlink/old_client/old_client.py delete mode 100644 old/cloudlink/server/__init__.py delete mode 100644 old/cloudlink/server/protocols/__init__.py delete mode 100644 old/cloudlink/server/protocols/cl_methods.py delete mode 100644 old/cloudlink/server/protocols/scratch_methods.py delete mode 100644 old/cloudlink/server/server.py delete mode 100644 old/cloudlink/supporter.py delete mode 100644 old/requirements.txt delete mode 100644 old/server_example.py diff --git a/old/client-test-async.py b/old/client-test-async.py deleted file mode 100644 index 08250e9..0000000 --- a/old/client-test-async.py +++ /dev/null @@ -1,97 +0,0 @@ -from cloudlink import cloudlink - - -class example_events: - def __init__(self): - pass - - async def on_connect(self, client): - print(f"Client {client.obj_id} connected") - await client.set_username(str(client.obj_id)) - await client.send_gmsg("test") - - async def on_close(self, client): - print(f"Client {client.obj_id} disconnected") - - async def username_set(self, client): - print(f"Client {client.obj_id}'s username was set!") - - async def on_gmsg(self, client, message, listener): - print(f"Client {client.obj_id} got gmsg {message['val']}") - - -if __name__ == "__main__": - # Initialize Cloudlink. You will only need to initialize one instance of the main cloudlink module. - cl = cloudlink() - - # Create examples for various ways to extend the functionality of Cloudlink Server. - example = example_events() - - # Example - Multiple clients. - multi_client = cl.multi_client(async_client=True, logs=True) - - # Spawns 5 clients. - for x in range(5): - # Create a new client object. This supports initializing many clients at once. - client = multi_client.spawn(x, "ws://127.0.0.1:3000/") - - # Binding events - This example binds functions to certain events - - # When a client connects, all functions bound to this event will fire. - client.bind_event( - client.events.on_connect, - example.on_connect - ) - - # When a client disconnects, all functions bound to this event will fire. - client.bind_event( - client.events.on_close, - example.on_close - ) - - # When a client disconnects, all functions bound to this event will fire. - client.bind_event( - client.events.on_username_set, - example.username_set - ) - - # Binding callbacks for commands - This example binds an event when a gmsg packet is handled. - client.bind_callback_method(client.cl_methods.gmsg, example.on_gmsg) - - print("Waking up now") - multi_client.run() - input("All clients are ready. Press enter to shutdown.") - multi_client.stop() - input("All clients have shut down. Press enter to exit.") - - # Example - Singular clients. - - # Create a new client object. - client = cl.client(async_client=True, logs=True) - client.obj_id = "Test" - - # Binding events - This example binds functions to certain events - - # When a client connects, all functions bound to this event will fire. - client.bind_event( - client.events.on_connect, - example.on_connect - ) - - # When a client disconnects, all functions bound to this event will fire. - client.bind_event( - client.events.on_close, - example.on_close - ) - - # When a client disconnects, all functions bound to this event will fire. - client.bind_event( - client.events.on_username_set, - example.username_set - ) - - # Binding callbacks for commands - This example binds an event when a gmsg packet is handled. - client.bind_callback_method(client.cl_methods.gmsg, example.on_gmsg) - - # Run the client. - client.run("ws://127.0.0.1:3000/") diff --git a/old/client-test-old.py b/old/client-test-old.py deleted file mode 100644 index 639eee8..0000000 --- a/old/client-test-old.py +++ /dev/null @@ -1,97 +0,0 @@ -from cloudlink import cloudlink - - -class example_events: - def __init__(self): - pass - - def on_connect(self, client): - print(f"Client {client.obj_id} connected") - client.set_username(str(client.obj_id)) - client.send_gmsg("test") - - def on_close(self, client): - print(f"Client {client.obj_id} disconnected") - - def username_set(self, client): - print(f"Client {client.obj_id}'s username was set!") - - def on_gmsg(self, client, message, listener): - print(f"Client {client.obj_id} got gmsg {message['val']}") - - -if __name__ == "__main__": - # Initialize Cloudlink. You will only need to initialize one instance of the main cloudlink module. - cl = cloudlink() - - # Create examples for various ways to extend the functionality of Cloudlink Server. - example = example_events() - - # Example - Multiple clients. - multi_client = cl.multi_client(async_client=False, logs=True) - - # Spawns 5 clients. - for x in range(5): - # Create a new client object. This supports initializing many clients at once. - client = multi_client.spawn(x, "ws://127.0.0.1:3000/") - - # Binding events - This example binds functions to certain events - - # When a client connects, all functions bound to this event will fire. - client.bind_event( - client.events.on_connect, - example.on_connect - ) - - # When a client disconnects, all functions bound to this event will fire. - client.bind_event( - client.events.on_close, - example.on_close - ) - - # When a client disconnects, all functions bound to this event will fire. - client.bind_event( - client.events.on_username_set, - example.username_set - ) - - # Binding callbacks for commands - This example binds an event when a gmsg packet is handled. - client.bind_callback_method(client.cl_methods.gmsg, example.on_gmsg) - - print("Waking up now") - multi_client.run() - input("All clients are ready. Press enter to shutdown.") - multi_client.stop() - input("All clients have shut down. Press enter to exit.") - - # Example - Singular clients. - client = cl.client(async_client=False, logs=True) - - # Object IDs - Sets a friendly name to a specific client object. - client.obj_id = "Test" - - # Binding events - This example binds functions to certain events - - # When a client connects, all functions bound to this event will fire. - client.bind_event( - client.events.on_connect, - example.on_connect - ) - - # When a client disconnects, all functions bound to this event will fire. - client.bind_event( - client.events.on_close, - example.on_close - ) - - # When a client disconnects, all functions bound to this event will fire. - client.bind_event( - client.events.on_username_set, - example.username_set - ) - - # Binding callbacks for commands - This example binds an event when a gmsg packet is handled. - client.bind_callback_method(client.cl_methods.gmsg, example.on_gmsg) - - # Run the client. - client.run("ws://127.0.0.1:3000/") diff --git a/old/cloudlink/__init__.py b/old/cloudlink/__init__.py deleted file mode 100644 index 83c28c3..0000000 --- a/old/cloudlink/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .cloudlink import * \ No newline at end of file diff --git a/old/cloudlink/__main__.py b/old/cloudlink/__main__.py deleted file mode 100644 index d72d331..0000000 --- a/old/cloudlink/__main__.py +++ /dev/null @@ -1,23 +0,0 @@ -from .cloudlink import cloudlink - - -class example_events: - def __init__(self): - pass - - async def on_close(self, client): - print("Client", client.id, "disconnected.") - - async def on_connect(self, client): - print("Client", client.id, "connected.") - - -if __name__ == "__main__": - cl = cloudlink() - server = cl.server(logs=True) - events = example_events() - server.set_motd("CL4 Demo Server", True) - server.bind_event(server.events.on_connect, events.on_connect) - server.bind_event(server.events.on_close, events.on_close) - print("Welcome to Cloudlink 4! See https://github.com/mikedev101/cloudlink for more info. Now running server on ws://127.0.0.1:3000/!") - server.run(ip="localhost", port=3000) \ No newline at end of file diff --git a/old/cloudlink/async_client/__init__.py b/old/cloudlink/async_client/__init__.py deleted file mode 100644 index 5b2fe66..0000000 --- a/old/cloudlink/async_client/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .async_client import * \ No newline at end of file diff --git a/old/cloudlink/async_client/async_client.py b/old/cloudlink/async_client/async_client.py deleted file mode 100644 index ceab1c2..0000000 --- a/old/cloudlink/async_client/async_client.py +++ /dev/null @@ -1,632 +0,0 @@ -import websockets -import asyncio -import json -from copy import copy -import threading - - -# Multi client -class multi_client: - def __init__(self, parent, logs: bool = True): - self.shutdown_flag = False - self.threads = set() - self.logs = logs - self.parent = parent - self.supporter = self.parent.supporter(self) - - self.clients_counter = int() - self.clients_present = set() - self.clients_dead = set() - - # Display version - self.supporter.log(f"Cloudlink asyncio multi client v{self.parent.version}") - - async def on_connect(self, client): - self.clients_present.add(client) - self.clients_dead.discard(client) - while not self.shutdown_flag: - await client.asyncio.sleep(0) - await client.shutdown() - - async def on_disconnect(self, client): - self.clients_present.discard(client) - self.clients_dead.add(client) - - def spawn(self, client_id: any, url: str = "ws://127.0.0.1:3000/"): - _client = client(self.parent, logs=self.logs, multi_silent=True) - _client.obj_id = client_id - _client.bind_event(_client.events.on_connect, self.on_connect) - _client.bind_event(_client.events.on_close, self.on_disconnect) - _client.bind_event(_client.events.on_fail, self.on_disconnect) - self.threads.add(threading.Thread( - target=_client.run, - args=[url], - daemon=True - )) - self.clients_counter = len(self.threads) - return _client - - def run(self): - self.supporter.log("Initializing all clients...") - for thread in self.threads: - thread.start() - while (len(self.clients_present) + len(self.clients_dead)) != self.clients_counter: - pass - self.supporter.log("All clients have initialized.") - - def stop(self): - self.shutdown_flag = True - self.clients_counter = self.clients_counter - len(self.clients_dead) - self.supporter.log("Waiting for all clients to shutdown...") - while len(self.clients_present) != 0: - pass - self.supporter.log("All clients have shut down.") - self.clients_present.clear() - self.clients_dead.clear() - self.threads.clear() - - -# Cloudlink Client -class client: - def __init__(self, parent, logs: bool = True, multi_silent:bool = False): - self.version = parent.version - - # Locally define the client object - self.client = None - - # Declare loop - self.loop = None - - # Initialize required libraries - self.asyncio = asyncio - - self.websockets = websockets - self.copy = copy - self.json = json - - # Other - self.enable_logs = logs - self.client_id = dict() - self.client_ip = str() - self.running = False - self.username_set = False - - # Built-in listeners - self.handshake_listener = "protocolset" - self.setid_listener = "usernameset" - self.ping_listener = "ping" - - # Storage of server stuff - self.motd_message = str() - self.server_version = str() - - # Managing methods - self.custom_methods = custom_methods() - self.disabled_methods = set() - self.method_callbacks = dict() - self.listener_callbacks = dict() - self.safe_methods = set() - - # Managing events - self.events = events() - self.event_callbacks = dict() - - # Initialize supporter - self.supporter = parent.supporter(self) - - # Initialize attributes from supporter component - self.log = self.supporter.log - self.validate = self.supporter.validate - self.load_custom_methods = self.supporter.load_custom_methods - self.detect_listener = self.supporter.detect_listener - self.generate_statuscode = self.supporter.generate_statuscode - - # Initialize rooms storage - self.rooms = rooms(self) - - # Initialize methods - self.cl_methods = cl_methods(self) - - # Load safe CLPv4 methods to mitigate vulnerabilities - self.supporter.init_builtin_cl_methods() - - if not multi_silent: - # Display version - self.supporter.log(f"Cloudlink asyncio client v{parent.version}") - - # == Public API functionality == - - # Runs the client. - def run(self, ip: str = "ws://127.0.0.1:3000/"): - try: - self.loop = self.asyncio.new_event_loop() - self.asyncio.run(self.__session__(ip)) - except KeyboardInterrupt: - pass - except: - print(self.supporter.full_stack()) - - # Disconnects the client. - async def shutdown(self): - if not self.client: - return - - if not self.client.open: - return - - await self.client.close() - - # Sends packets, you should use other methods. - async def send_packet(self, cmd: str, val: any = None, listener: str = None, room_id: str = None, - quirk: str = "quirk_embed_val"): - if not self.client: - return - - if not self.client.open: - return - - # Manage specific message quirks - message = {"cmd": cmd} - if val: - if quirk == self.supporter.quirk_update_msg: - message.update(val) - elif quirk == self.supporter.quirk_embed_val: - message["val"] = val - else: - raise TypeError("Unknown message quirk!") - - # Attach a listener request - if listener: - message["listener"] = listener - - # Attach the rooms key - if room_id: - message["rooms"] = room_id - - # Send payload - try: - await self.client.send(self.json.dumps(message)) - except self.websockets.exceptions.ConnectionClosedError: - self.log(f"Failed to send packet: Connection closed unexpectedly") - - # Sets the client's username and enables pmsg, pvar, direct, and link/unlink commands. - async def set_username(self, username): - if not self.client: - return - - if not self.client.open: - return - - if self.username_set: - return - - await self.send_packet(cmd="setid", val=username, listener=self.setid_listener, - quirk=self.supporter.quirk_embed_val) - - # Gives a ping, gets a pong. Keeps connections alive and healthy. This is recommended for servers with tunnels/reverse proxies that have connection timeouts. - async def ping(self, listener: any = None): - if not self.client: - return - - if not self.client.open: - return - - if listener: - await self.send_packet(cmd="ping", listener=listener) - else: - await self.send_packet(cmd="ping", listener=self.ping_listener) - - # Sends global message packets. - async def send_gmsg(self, value, listener: any = None): - if not self.client: - return - - if not self.client.open: - return - - await self.send_packet(cmd="gmsg", val=value, listener=listener, quirk=self.supporter.quirk_embed_val) - - # Sends global variable packets. This will sync with all Scratch cloud variables. - async def send_gvar(self, name, value, listener: any = None): - if not self.client: - return - - if not self.client.open: - return - - payload = { - "name": name, - "val": value - } - - await self.send_packet(cmd="gvar", val=payload, listener=listener, quirk=self.supporter.quirk_update_msg) - - # Sends private message packets. - async def send_pmsg(self, recipient, value, listener: any = None): - if not self.client: - return - - if not self.client.open: - return - - payload = { - "id": recipient, - "val": value - } - - await self.send_packet(cmd="pmsg", val=payload, listener=listener, quirk=self.supporter.quirk_update_msg) - - # Sends private variable packets. This does not sync with Scratch. - async def send_pvar(self, name, recipient, value, listener: any = None): - if not self.client: - return - - if not self.client.open: - return - - payload = { - "name": name, - "id": recipient, - "val": value - } - - await self.send_packet(cmd="pvar", val=payload, listener=listener, quirk=self.supporter.quirk_update_msg) - - # Sends direct variable packets. - async def send_direct(self, recipient: any = None, value: any = None, listener: any = None): - if not self.client: - return - - if not self.client.open: - return - - payload = dict() - if recipient: - payload["id"] = recipient - - if value: - payload.update(value) - - await self.send_packet(cmd="direct", val=payload, listener=listener, quirk=self.supporter.quirk_update_msg) - - # Binding method callbacks - Provides a programmer-friendly interface to run extra code after a cloudlink method has been executed. - def bind_callback_method(self, callback_method: type, function: type): - if hasattr(self.cl_methods, callback_method.__name__) or hasattr(self.custom_methods, callback_method.__name__): - if callback_method.__name__ not in self.method_callbacks: - self.method_callbacks[callback_method.__name__] = set() - self.method_callbacks[callback_method.__name__].add(function) - - # Binding listener callbacks - Provides a programmer-friendly interface to run extra code when a message listener was detected. - def bind_callback_listener(self, listener_str: str, function: type): - if listener_str not in self.listener_callbacks: - self.listener_callbacks[listener_str] = set() - self.listener_callbacks[listener_str].add(function) - - # Binding events - Provides a programmer-friendly interface to detect client connects, disconnects, and errors. - def bind_event(self, event_method: type, function: type): - if hasattr(self.events, event_method.__name__): - if event_method.__name__ not in self.event_callbacks: - self.event_callbacks[event_method.__name__] = set() - self.event_callbacks[event_method.__name__].add(function) - - # == Client functionality == - - def __fire_method_callbacks__(self, callback_method, message, listener): - if callback_method.__name__ in self.method_callbacks: - for _method in self.method_callbacks[callback_method.__name__]: - self.asyncio.create_task(_method(self, message, listener)) - - def __fire_method_listeners__(self, listener_str, message): - if listener_str in self.listener_callbacks: - for _method in self.listener_callbacks[listener_str]: - self.asyncio.create_task(_method(self, message)) - - def __fire_event__(self, event_method: type): - if event_method.__name__ in self.event_callbacks: - for _method in self.event_callbacks[event_method.__name__]: - self.asyncio.create_task(_method(self)) - - async def __method_handler__(self, message): - # Check if the message contains the cmd key, with a string datatype. - if self.validate({"cmd": str}, message) != self.supporter.valid: - return - - # Detect listeners - listener = self.detect_listener(message) - - # Detect and convert CLPv3 custom responses to CLPv4 - if (message["cmd"] == "direct") and ("val" in message): - if self.validate({"cmd": str, "val": self.supporter.keydefaults["val"]}, - message["val"]) == self.supporter.valid: - tmp_msg = { - "cmd": message["val"]["cmd"], - "val": message["val"]["val"] - } - message = self.copy(tmp_msg) - - # Check if the command is disabled - if message["cmd"] in self.disabled_methods: - return - - method = None - - # Check if the command method exists - if hasattr(self.cl_methods, message["cmd"]) and (message["cmd"] in self.safe_methods): - method = getattr(self.cl_methods, message["cmd"]) - - elif hasattr(self.custom_methods, message["cmd"]) and (message["cmd"] in self.safe_methods): - method = getattr(self.custom_methods, message["cmd"]) - - if method: - # Run the method - await method(message, listener) - self.__fire_event__(self.events.on_msg) - - async def __session__(self, ip): - try: - async with self.websockets.connect(ip) as self.client: - await self.send_packet(cmd="handshake", listener=self.handshake_listener) - try: - while self.client.open: - try: - message = json.loads(await self.client.recv()) - await self.__method_handler__(message) - except: - pass - finally: - self.__fire_event__(self.events.on_close) - except Exception as e: - self.log(f"Closing connection handler due to error: {e}") - self.__fire_event__(self.events.on_fail) - finally: - pass - - -# Class for binding events -class events: - def __init__(self): - pass - - def on_connect(self): - pass - - def on_msg(self): - pass - - def on_error(self): - pass - - def on_close(self): - pass - - def on_fail(self): - pass - - def on_username_set(self): - pass - - def on_pong(self): - pass - - -# Class for managing the Cloudlink Protocol -class cl_methods: - def __init__(self, parent): - self.parent = parent - self.supporter = parent.supporter - self.copy = parent.copy - self.rooms = parent.rooms - self.log = parent.log - self.loop = None - - async def gmsg(self, message, listener): - room_data = None - if "rooms" in message: - # Automatically create room and update the global data value - self.rooms.create(message["rooms"]) - room_data = self.rooms.get(message["rooms"]) - else: - # Assume a gmsg with no "rooms" value is the default room - room_data = self.rooms.get("default") - - # Update the room data - room_data.global_data_value = message["val"] - - # Fire callbacks for method and it's listener - self.parent.__fire_method_callbacks__(self.gmsg, message, listener) - if listener: - self.parent.__fire_method_listeners__(listener) - - async def pmsg(self, message, listener): - room_data = None - if "rooms" in message: - # Automatically create room and update the global data value - self.rooms.create(message["rooms"]) - room_data = self.rooms.get(message["rooms"]) - else: - # Assume a gmsg with no "rooms" value is the default room - room_data = self.rooms.get("default") - - # Update the room data - room_data.private_data_value["val"] = message["val"] - room_data.private_data_value["origin"] = message["origin"] - - # Fire callbacks for method and it's listener - self.parent.__fire_method_callbacks__(self.pmsg, message, listener) - if listener: - self.parent.__fire_method_listeners__(listener) - - async def gvar(self, message, listener): - room_data = None - if "rooms" in message: - # Automatically create room and update the global data value - self.rooms.create(message["rooms"]) - room_data = self.rooms.get(message["rooms"]) - else: - # Assume a gmsg with no "rooms" value is the default room - room_data = self.rooms.get("default") - - # Update the room data - room_data.global_vars[message["name"]] = message["val"] - - # Fire callbacks for method and it's listener - self.parent.__fire_method_callbacks__(self.gvar, message, listener) - if listener: - self.parent.__fire_method_listeners__(listener) - - async def pvar(self, message, listener): - room_data = None - if "rooms" in message: - # Automatically create room and update the global data value - self.rooms.create(message["rooms"]) - room_data = self.rooms.get(message["rooms"]) - else: - # Assume a gmsg with no "rooms" value is the default room - room_data = self.rooms.get("default") - - # Update the room data - room_data.private_vars[message["name"]] = { - "origin": message["origin"], - "val": message["val"] - } - - # Fire callbacks for method and it's listener - self.parent.__fire_method_callbacks__(self.pvar, message, listener) - if listener: - self.parent.__fire_method_listeners__(listener) - - async def direct(self, message, listener): - # Fire callbacks for method and it's listener - self.parent.__fire_method_callbacks__(self.direct, message, listener) - if listener: - self.parent.__fire_method_listeners__(listener) - - async def ulist(self, message, listener): - room_data = None - if "rooms" in message: - # Automatically create room and update the global data value - self.rooms.create(message["rooms"]) - room_data = self.rooms.get(message["rooms"]) - else: - # Assume a gmsg with no "rooms" value is the default room - room_data = self.rooms.get("default") - - # Interpret and execute ulist method - if "mode" in message: - if message["mode"] in ["set", "add", "remove"]: - match message["mode"]: - case "set": - room_data.userlist = message["val"] - case "add": - if not message["val"] in room_data.userlist: - room_data.userlist.append(message["val"]) - case "remove": - if message["val"] in room_data.userlist: - room_data.userlist.remove(message["val"]) - else: - self.log(f"Could not understand ulist method: {message['mode']}") - else: - # Assume old userlist method - room_data.userlist = set(message["val"]) - - # ulist will never return a listener - self.parent.__fire_method_callbacks__(self.ulist, message, listener) - - async def server_version(self, message, listener): - self.parent.server_version = message['val'] - - # server_version will never return a listener - self.parent.__fire_method_callbacks__(self.server_version, message, listener) - - async def motd(self, message, listener): - self.parent.motd_message = message['val'] - - # motd will never return a listener - self.parent.__fire_method_callbacks__(self.motd, message, listener) - - async def client_ip(self, message, listener): - self.parent.client_ip = message['val'] - - # client_ip will never return a listener - self.parent.__fire_method_callbacks__(self.client_ip, message, listener) - - async def statuscode(self, message, listener): - if listener: - if listener in [self.parent.handshake_listener, self.parent.setid_listener]: - human_ok, machine_ok = self.supporter.generate_statuscode("OK") - match listener: - case self.parent.handshake_listener: - if (message["code"] == human_ok) or (message["code_id"] == machine_ok): - self.parent.__fire_event__(self.parent.events.on_connect) - else: - await self.parent.shutdown() - case self.parent.setid_listener: - if (message["code"] == human_ok) or (message["code_id"] == machine_ok): - self.parent.username_set = True - self.parent.client_id = message["val"] - self.parent.__fire_event__(self.parent.events.on_username_set) - case self.parent.ping_listener: - self.parent.__fire_event__(self.parent.events.on_pong) - else: - self.parent.__fire_method_callbacks__(self.statuscode, message, listener) - self.parent.__fire_method_listeners__(listener) - else: - self.parent.__fire_method_callbacks__(self.statuscode, message, listener) - - -# Class to store custom methods -class custom_methods: - def __init__(self): - pass - - -# Class to store room data -class rooms: - def __init__(self, parent): - self.default = self.__room__() - self.__parent__ = parent - - def get_all(self): - tmp = self.__parent__.copy(self.__dict__) - - # Remove attributes that aren't client objects - del tmp["__parent__"] - - return tmp - - def exists(self, room_id: str): - return hasattr(self, str(room_id)) - - def create(self, room_id: str): - if not self.exists(str(room_id)): - setattr(self, str(room_id), self.__room__()) - - def delete(self, room_id: str): - if self.exists(str(room_id)): - delattr(self, str(room_id)) - - def get(self, room_id: str): - if self.exists(str(room_id)): - return getattr(self, str(room_id)) - else: - return None - - class __room__: - def __init__(self): - # Global data stream current value - self.global_data_value = str() - - # Private data stream current value - self.private_data_value = { - "origin": str(), - "val": None - } - - # Storage of all global variables / Scratch Cloud Variables - self.global_vars = dict() - - # Storage of all private variables - self.private_vars = dict() - - # User management - self.userlist = list() diff --git a/old/cloudlink/cloudlink.py b/old/cloudlink/cloudlink.py deleted file mode 100644 index bc9968b..0000000 --- a/old/cloudlink/cloudlink.py +++ /dev/null @@ -1,52 +0,0 @@ -from .supporter import supporter - -""" -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 - -Cloudlink is built upon https://github.com/aaugustin/websockets. - -Please see https://github.com/MikeDev101/cloudlink for more details. - -Cloudlink's dependencies are: -* websockets (for server and asyncio client) -* websocket-client (for non-asyncio client) - -These dependencies are built-in to Python. -* copy -* asyncio -* traceback -* datetime -* json -""" - - -class cloudlink: - def __init__(self): - self.version = "0.1.9.2" - self.supporter = supporter - - def server(self, logs: bool = False): - # Initialize Cloudlink server - from .server import server - return server(self, logs) - - def client(self, logs: bool = False, async_client: bool = True): - # Initialize Cloudlink client - if async_client: - from .async_client import async_client - return async_client.client(self, logs) - else: - from .old_client import old_client - return old_client.client(self, logs) - - def multi_client(self, logs: bool = False, async_client: bool = True): - # Initialize Cloudlink client - if async_client: - from .async_client import async_client - return async_client.multi_client(self, logs) - else: - from .old_client import old_client - return old_client.multi_client(self, logs) diff --git a/old/cloudlink/docs/docs.txt b/old/cloudlink/docs/docs.txt deleted file mode 100644 index c7f9328..0000000 --- a/old/cloudlink/docs/docs.txt +++ /dev/null @@ -1 +0,0 @@ -https://hackmd.io/g6BogABhT6ux1GA2oqaOXA \ No newline at end of file diff --git a/old/cloudlink/old_client/__init__.py b/old/cloudlink/old_client/__init__.py deleted file mode 100644 index 3193e87..0000000 --- a/old/cloudlink/old_client/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .old_client import * \ No newline at end of file diff --git a/old/cloudlink/old_client/old_client.py b/old/cloudlink/old_client/old_client.py deleted file mode 100644 index 5ce66e2..0000000 --- a/old/cloudlink/old_client/old_client.py +++ /dev/null @@ -1,601 +0,0 @@ -import websocket as old_websockets -import json -from copy import copy -import time -import threading - - -# Multi client -class multi_client: - def __init__(self, parent, logs: bool = True): - self.shutdown_flag = False - self.threads = set() - self.logs = logs - self.parent = parent - self.supporter = self.parent.supporter(self) - - self.clients_counter = int() - self.clients_present = set() - self.clients_dead = set() - - # Display version - self.supporter.log(f"Cloudlink non-asyncio multi client v{self.parent.version}") - - def on_connect(self, client): - self.clients_present.add(client) - self.clients_dead.discard(client) - while not self.shutdown_flag: - time.sleep(0) - client.shutdown() - - def on_disconnect(self, client): - self.clients_present.discard(client) - self.clients_dead.add(client) - - def spawn(self, client_id: any, url: str = "ws://127.0.0.1:3000/"): - _client = client(self.parent, logs=self.logs, multi_silent=True) - _client.obj_id = client_id - _client.bind_event(_client.events.on_connect, self.on_connect) - _client.bind_event(_client.events.on_close, self.on_disconnect) - _client.bind_event(_client.events.on_fail, self.on_disconnect) - self.threads.add(threading.Thread( - target=_client.run, - args=[url], - daemon=True - )) - self.clients_counter = len(self.threads) - return _client - - def run(self): - self.supporter.log("Initializing all clients...") - for thread in self.threads: - thread.start() - while (len(self.clients_present) + len(self.clients_dead)) != self.clients_counter: - pass - self.supporter.log("All clients have initialized.") - - def stop(self): - self.shutdown_flag = True - self.clients_counter = self.clients_counter - len(self.clients_dead) - self.supporter.log("Waiting for all clients to shutdown...") - while len(self.clients_present) != 0: - pass - self.supporter.log("All clients have shut down.") - self.clients_present.clear() - self.clients_dead.clear() - self.threads.clear() - - -# Cloudlink Client -class client: - def __init__(self, parent, logs: bool = True, multi_silent:bool = False): - self.version = parent.version - - # Locally define the client object - self.client = None - - self.websockets = old_websockets - self.copy = copy - self.json = json - - # Other - self.enable_logs = logs - self.client_id = dict() - self.client_ip = str() - self.running = False - self.username_set = False - - # Built-in listeners - self.handshake_listener = "protocolset" - self.setid_listener = "usernameset" - self.ping_listener = "ping" - - # Storage of server stuff - self.motd_message = str() - self.server_version = str() - - # Managing methods - self.custom_methods = custom_methods() - self.disabled_methods = set() - self.method_callbacks = dict() - self.listener_callbacks = dict() - self.safe_methods = set() - - # Managing events - self.events = events() - self.event_callbacks = dict() - - # Initialize supporter - self.supporter = parent.supporter(self) - - # Initialize attributes from supporter component - self.log = self.supporter.log - self.validate = self.supporter.validate - self.load_custom_methods = self.supporter.load_custom_methods - self.detect_listener = self.supporter.detect_listener - self.generate_statuscode = self.supporter.generate_statuscode - - # Initialize rooms storage - self.rooms = rooms(self) - - # Initialize methods - self.cl_methods = cl_methods(self) - - # Load safe CLPv4 methods to mitigate vulnerabilities - self.supporter.init_builtin_cl_methods() - - if not multi_silent: - # Display version - self.supporter.log(f"Cloudlink non-asyncio client v{parent.version}") - - # == Public API functionality == - - # Runs the client. - def run(self, ip: str = "ws://127.0.0.1:3000/"): - try: - self.client = old_websockets.WebSocketApp( - ip, - on_message=self.__session_on_message__, - on_error=self.__session_on_error__, - on_open=self.__session_on_connect__, - on_close=self.__session_on_close__ - ) - self.client.run_forever() - except Exception as e: - self.log(f"{e}: {self.supporter.full_stack()}") - - # Disconnects the client. - def shutdown(self): - if not self.client: - return - - self.client.close() - - # Sends packets, you should use other methods. - def send_packet(self, cmd: str, val: any = None, listener: str = None, room_id: str = None, - quirk: str = "quirk_embed_val"): - if not self.client: - return - - # Manage specific message quirks - message = {"cmd": cmd} - if val: - if quirk == self.supporter.quirk_update_msg: - message.update(val) - elif quirk == self.supporter.quirk_embed_val: - message["val"] = val - else: - raise TypeError("Unknown message quirk!") - - # Attach a listener request - if listener: - message["listener"] = listener - - # Attach the rooms key - if room_id: - message["rooms"] = room_id - - # Send payload - try: - self.client.send(self.json.dumps(message)) - except Exception as e: - self.log(f"Failed to send packet: {e}") - - # Sets the client's username and enables pmsg, pvar, direct, and link/unlink commands. - def set_username(self, username): - if not self.client: - return - - if self.username_set: - return - - self.send_packet(cmd="setid", val=username, listener=self.setid_listener, quirk=self.supporter.quirk_embed_val) - - # Gives a ping, gets a pong. Keeps connections alive and healthy. This is recommended for servers with tunnels/reverse proxies that have connection timeouts. - def ping(self, listener: any = None): - if not self.client: - return - - if listener: - self.send_packet(cmd="ping", listener=listener) - else: - self.send_packet(cmd="ping", listener=self.ping_listener) - - # Sends global message packets. - def send_gmsg(self, value, listener: any = None): - if not self.client: - return - - if listener: - self.send_packet(cmd="gmsg", val=value, listener=listener, quirk=self.supporter.quirk_embed_val) - else: - self.send_packet(cmd="gmsg", val=value, quirk=self.supporter.quirk_embed_val) - - # Sends global variable packets. This will sync with all Scratch cloud variables. - def send_gvar(self, name, value, listener: any = None): - if not self.client: - return - - payload = { - "name": name, - "val": value - } - - self.send_packet(cmd="gvar", val=payload, listener=listener, quirk=self.supporter.quirk_update_msg) - - # Sends private message packets. - def send_pmsg(self, recipient, value, listener: any = None): - if not self.client: - return - - payload = { - "id": recipient, - "val": value - } - - self.send_packet(cmd="pmsg", val=payload, listener=listener, quirk=self.supporter.quirk_update_msg) - - # Sends private variable packets. This does not sync with Scratch. - def send_pvar(self, name, recipient, value, listener: any = None): - if not self.client: - return - - payload = { - "name": name, - "id": recipient, - "val": value - } - - self.send_packet(cmd="pvar", val=payload, listener=listener, quirk=self.supporter.quirk_update_msg) - - # Sends direct variable packets. - def send_direct(self, recipient: any = None, value: any = None, listener: any = None): - if not self.client: - return - - payload = dict() - if recipient: - payload["id"] = recipient - - if value: - payload.update(value) - - self.send_packet(cmd="direct", val=payload, listener=listener, quirk=self.supporter.quirk_update_msg) - - # Binding method callbacks - Provides a programmer-friendly interface to run extra code after a cloudlink method has been executed. - def bind_callback_method(self, callback_method: type, function: type): - if hasattr(self.cl_methods, callback_method.__name__) or hasattr(self.custom_methods, callback_method.__name__): - if callback_method.__name__ not in self.method_callbacks: - self.method_callbacks[callback_method.__name__] = set() - self.method_callbacks[callback_method.__name__].add(function) - - # Binding listener callbacks - Provides a programmer-friendly interface to run extra code when a message listener was detected. - def bind_callback_listener(self, listener_str: str, function: type): - if listener_str not in self.listener_callbacks: - self.listener_callbacks[listener_str] = set() - self.listener_callbacks[listener_str].add(function) - - # Binding events - Provides a programmer-friendly interface to detect client connects, disconnects, and errors. - def bind_event(self, event_method: type, function: type): - if hasattr(self.events, event_method.__name__): - if event_method.__name__ not in self.event_callbacks: - self.event_callbacks[event_method.__name__] = set() - self.event_callbacks[event_method.__name__].add(function) - - # == Client functionality == - - def __fire_method_callbacks__(self, callback_method, message, listener): - if callback_method.__name__ in self.method_callbacks: - for _method in self.method_callbacks[callback_method.__name__]: - threading.Thread(target=_method, args=[self, message, listener], daemon=True).start() - - def __fire_method_listeners__(self, listener_str, message): - if listener_str in self.listener_callbacks: - for _method in self.listener_callbacks[listener_str]: - threading.Thread(target=_method, args=[self, message], daemon=True).start() - - def __fire_event__(self, event_method: type): - if event_method.__name__ in self.event_callbacks: - for _method in self.event_callbacks[event_method.__name__]: - threading.Thread(target=_method, args=[self], daemon=True).start() - - def __method_handler__(self, message): - # Check if the message contains the cmd key, with a string datatype. - if self.validate({"cmd": str}, message) != self.supporter.valid: - return - - # Detect listeners - listener = self.detect_listener(message) - - # Detect and convert CLPv3 custom responses to CLPv4 - if (message["cmd"] == "direct") and ("val" in message): - if self.validate({"cmd": str, "val": self.supporter.keydefaults["val"]}, - message["val"]) == self.supporter.valid: - tmp_msg = { - "cmd": message["val"]["cmd"], - "val": message["val"]["val"] - } - message = self.copy(tmp_msg) - - # Check if the command is disabled - if message["cmd"] in self.disabled_methods: - return - - method = None - - # Check if the command method exists - if hasattr(self.cl_methods, message["cmd"]) and (message["cmd"] in self.safe_methods): - method = getattr(self.cl_methods, message["cmd"]) - - elif hasattr(self.custom_methods, message["cmd"]) and (message["cmd"] in self.safe_methods): - method = getattr(self.custom_methods, message["cmd"]) - - if method: - # Run the method - method(message, listener) - self.__fire_event__(self.events.on_msg) - - def __session_on_message__(self, ws, message): - try: - message = json.loads(message) - self.__method_handler__(message) - except: - pass - - def __session_on_connect__(self, ws): - self.send_packet(cmd="handshake", listener=self.handshake_listener) - - def __session_on_error__(self, ws, error): - self.log(f"Connection handler encountered an error: {error}") - self.__fire_event__(self.events.on_error) - - def __session_on_close__(self, ws, close_status_code, close_msg): - self.__fire_event__(self.events.on_close) - -# Class for binding events -class events: - def __init__(self): - pass - - def on_connect(self): - pass - - def on_msg(self): - pass - - def on_error(self): - pass - - def on_close(self): - pass - - def on_fail(self): - pass - - def on_username_set(self): - pass - - def on_pong(self): - pass - - -# Class for managing the Cloudlink Protocol -class cl_methods: - def __init__(self, parent): - self.parent = parent - self.supporter = parent.supporter - self.copy = parent.copy - self.rooms = parent.rooms - self.log = parent.log - - def gmsg(self, message, listener): - room_data = None - if "rooms" in message: - # Automatically create room and update the global data value - self.rooms.create(message["rooms"]) - room_data = self.rooms.get(message["rooms"]) - else: - # Assume a gmsg with no "rooms" value is the default room - room_data = self.rooms.get("default") - - # Update the room data - room_data.global_data_value = message["val"] - - # Fire callbacks for method and it's listener - self.parent.__fire_method_callbacks__(self.gmsg, message, listener) - if listener: - self.parent.__fire_method_listeners__(listener) - - def pmsg(self, message, listener): - room_data = None - if "rooms" in message: - # Automatically create room and update the global data value - self.rooms.create(message["rooms"]) - room_data = self.rooms.get(message["rooms"]) - else: - # Assume a gmsg with no "rooms" value is the default room - room_data = self.rooms.get("default") - - # Update the room data - room_data.private_data_value["val"] = message["val"] - room_data.private_data_value["origin"] = message["origin"] - - # Fire callbacks for method and it's listener - self.parent.__fire_method_callbacks__(self.pmsg, message, listener) - if listener: - self.parent.__fire_method_listeners__(listener) - - def gvar(self, message, listener): - room_data = None - if "rooms" in message: - # Automatically create room and update the global data value - self.rooms.create(message["rooms"]) - room_data = self.rooms.get(message["rooms"]) - else: - # Assume a gmsg with no "rooms" value is the default room - room_data = self.rooms.get("default") - - # Update the room data - room_data.global_vars[message["name"]] = message["val"] - - # Fire callbacks for method and it's listener - self.parent.__fire_method_callbacks__(self.gvar, message, listener) - if listener: - self.parent.__fire_method_listeners__(listener) - - def pvar(self, message, listener): - room_data = None - if "rooms" in message: - # Automatically create room and update the global data value - self.rooms.create(message["rooms"]) - room_data = self.rooms.get(message["rooms"]) - else: - # Assume a gmsg with no "rooms" value is the default room - room_data = self.rooms.get("default") - - # Update the room data - room_data.private_vars[message["name"]] = { - "origin": message["origin"], - "val": message["val"] - } - - # Fire callbacks for method and it's listener - self.parent.__fire_method_callbacks__(self.pvar, message, listener) - if listener: - self.parent.__fire_method_listeners__(listener) - - def direct(self, message, listener): - # Fire callbacks for method and it's listener - self.parent.__fire_method_callbacks__(self.direct, message, listener) - if listener: - self.parent.__fire_method_listeners__(listener) - - def ulist(self, message, listener): - room_data = None - if "rooms" in message: - # Automatically create room and update the global data value - self.rooms.create(message["rooms"]) - room_data = self.rooms.get(message["rooms"]) - else: - # Assume a gmsg with no "rooms" value is the default room - room_data = self.rooms.get("default") - - # Interpret and execute ulist method - if "mode" in message: - if message["mode"] in ["set", "add", "remove"]: - match message["mode"]: - case "set": - room_data.userlist = message["val"] - case "add": - if not message["val"] in room_data.userlist: - room_data.userlist.append(message["val"]) - case "remove": - if message["val"] in room_data.userlist: - room_data.userlist.remove(message["val"]) - else: - self.log(f"Could not understand ulist method: {message['mode']}") - else: - # Assume old userlist method - room_data.userlist = set(message["val"]) - - # ulist will never return a listener - self.parent.__fire_method_callbacks__(self.ulist, message, listener) - - def server_version(self, message, listener): - self.parent.server_version = message['val'] - - # server_version will never return a listener - self.parent.__fire_method_callbacks__(self.server_version, message, listener) - - def motd(self, message, listener): - self.parent.motd_message = message['val'] - - # motd will never return a listener - self.parent.__fire_method_callbacks__(self.motd, message, listener) - - def client_ip(self, message, listener): - self.parent.client_ip = message['val'] - - # client_ip will never return a listener - self.parent.__fire_method_callbacks__(self.client_ip, message, listener) - - def statuscode(self, message, listener): - if listener: - if listener in [self.parent.handshake_listener, self.parent.setid_listener]: - human_ok, machine_ok = self.supporter.generate_statuscode("OK") - match listener: - case self.parent.handshake_listener: - if (message["code"] == human_ok) or (message["code_id"] == machine_ok): - self.parent.__fire_event__(self.parent.events.on_connect) - else: - self.parent.shutdown() - case self.parent.setid_listener: - if (message["code"] == human_ok) or (message["code_id"] == machine_ok): - self.parent.username_set = True - self.parent.client_id = message["val"] - self.parent.__fire_event__(self.parent.events.on_username_set) - case self.parent.ping_listener: - self.parent.__fire_event__(self.parent.events.on_pong) - else: - self.parent.__fire_method_callbacks__(self.statuscode, message, listener) - self.parent.__fire_method_listeners__(listener) - else: - self.parent.__fire_method_callbacks__(self.statuscode, message, listener) - - -# Class to store custom methods -class custom_methods: - def __init__(self): - pass - - -# Class to store room data -class rooms: - def __init__(self, parent): - self.default = self.__room__() - self.__parent__ = parent - - def get_all(self): - tmp = self.__parent__.copy(self.__dict__) - - # Remove attributes that aren't client objects - del tmp["__parent__"] - - return tmp - - def exists(self, room_id: str): - return hasattr(self, str(room_id)) - - def create(self, room_id: str): - if not self.exists(str(room_id)): - setattr(self, str(room_id), self.__room__()) - - def delete(self, room_id: str): - if self.exists(str(room_id)): - delattr(self, str(room_id)) - - def get(self, room_id: str): - if self.exists(str(room_id)): - return getattr(self, str(room_id)) - else: - return None - - class __room__: - def __init__(self): - # Global data stream current value - self.global_data_value = str() - - # Private data stream current value - self.private_data_value = { - "origin": str(), - "val": None - } - - # Storage of all global variables / Scratch Cloud Variables - self.global_vars = dict() - - # Storage of all private variables - self.private_vars = dict() - - # User management - self.userlist = list() diff --git a/old/cloudlink/server/__init__.py b/old/cloudlink/server/__init__.py deleted file mode 100644 index 2787d2b..0000000 --- a/old/cloudlink/server/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .server import * \ No newline at end of file diff --git a/old/cloudlink/server/protocols/__init__.py b/old/cloudlink/server/protocols/__init__.py deleted file mode 100644 index b19fa2e..0000000 --- a/old/cloudlink/server/protocols/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from .cl_methods import * -from .scratch_methods import * \ No newline at end of file diff --git a/old/cloudlink/server/protocols/cl_methods.py b/old/cloudlink/server/protocols/cl_methods.py deleted file mode 100644 index d76988e..0000000 --- a/old/cloudlink/server/protocols/cl_methods.py +++ /dev/null @@ -1,647 +0,0 @@ -class cl_methods: - def __init__(self, parent): - self.parent = parent - self.supporter = parent.supporter - self.copy = parent.copy - self.rooms = parent.rooms - self.get_rooms = parent.supporter.get_rooms - self.clients = parent.clients - - # Various ways to send messages - self.send_packet_unicast = parent.send_packet_unicast - self.send_packet_multicast = parent.send_packet_multicast - self.send_packet_multicast_variable = parent.send_packet_multicast_variable - self.send_code = parent.send_code - - # Get protocol types to allow cross-protocol data sync - self.proto_scratch_cloud = self.supporter.proto_scratch_cloud - self.proto_cloudlink = self.supporter.proto_cloudlink - - # Packet check definitions - Specifies required keys, their datatypes, and optional keys - self.validator = { - self.gmsg: { - "required": { - "val": self.supporter.keydefaults["val"], - "listener": self.supporter.keydefaults["listener"], - "rooms": self.supporter.keydefaults["rooms"] - }, - "optional": ["listener", "rooms"], - "sizes": { - "val": 1000 - } - }, - self.pmsg: { - "required": { - "val": self.supporter.keydefaults["val"], - "id": self.supporter.keydefaults["id"], - "listener": self.supporter.keydefaults["listener"], - "rooms": self.supporter.keydefaults["rooms"] - }, - "optional": ["listener", "rooms"], - "sizes": { - "val": 1000 - } - }, - self.gvar: { - "required": { - "val": self.supporter.keydefaults["val"], - "name": self.supporter.keydefaults["name"], - "listener": self.supporter.keydefaults["listener"], - "rooms": self.supporter.keydefaults["rooms"] - }, - "optional": ["listener", "rooms"], - "sizes": { - "val": 1000 - } - }, - self.pvar: { - "required": { - "val": self.supporter.keydefaults["val"], - "name": self.supporter.keydefaults["name"], - "id": self.supporter.keydefaults["id"], - "listener": self.supporter.keydefaults["listener"], - "rooms": self.supporter.keydefaults["rooms"] - }, - "optional": ["listener", "rooms"], - "sizes": { - "val": 1000 - } - }, - self.setid: { - "required": { - "val": self.supporter.keydefaults["val"], - "listener": self.supporter.keydefaults["listener"], - "rooms": self.supporter.keydefaults["rooms"] - }, - "optional": ["listener", "rooms"], - "sizes": { - "val": 1000 - } - }, - self.link: { - "required": { - "val": self.supporter.keydefaults["rooms"], - "listener": self.supporter.keydefaults["listener"] - }, - "optional": ["listener"], - "sizes": { - "val": 1000 - } - }, - self.unlink: { - "required": { - "val": self.supporter.keydefaults["rooms"], - "listener": self.supporter.keydefaults["listener"] - }, - "optional": ["val", "listener"], - "sizes": { - "val": 1000 - } - }, - self.direct: { - "required": { - "val": self.supporter.keydefaults["val"], - "id": self.supporter.keydefaults["id"], - "listener": self.supporter.keydefaults["listener"], - "rooms": self.supporter.keydefaults["rooms"] - }, - "optional": ["id", "listener", "rooms"], - "sizes": { - "val": 1000 - } - } - } - - async def __auto_validate__(self, validator, client, message, listener): - validation = self.supporter.validate( - keys=validator["required"], - payload=message, - optional=validator["optional"], - sizes=validator["sizes"] - ) - - match validation: - case self.supporter.invalid: - # Command datatype is invalid - await self.parent.send_code(client, "DataType", listener=listener) - return False - case self.supporter.missing_key: - # Command syntax is invalid - await self.parent.send_code(client, "Syntax", listener=listener) - return False - case self.supporter.too_large: - # Payload size overload - await self.parent.send_code(client, "TooLarge", listener=listener) - return False - - return True - - async def handshake(self, client, message, listener): - # Validation is not needed since this command takes no arguments - - if self.parent.check_ip_addresses: - # Report client's IP - await self.send_packet_unicast( - client=client, - cmd="client_ip", - val=client.full_ip, - quirk=self.supporter.quirk_embed_val - ) - - # Report server version - await self.send_packet_unicast( - client=client, - cmd="server_version", - val=self.parent.version, - quirk=self.supporter.quirk_embed_val - ) - - # Report server MOTD - if self.parent.enable_motd: - await self.send_packet_unicast( - client=client, - cmd="motd", - val=self.parent.motd_message, - quirk=self.supporter.quirk_embed_val - ) - - # Report the current userlist - room_data = self.parent.rooms.get("default") - await self.send_packet_unicast( - client=client, - cmd="ulist", - val={ - "mode": "set", - "val": room_data.userlist - }, - room_id="default", - quirk=self.supporter.quirk_update_msg - ) - - # Report the cached gmsg value - await self.send_packet_unicast( - client=client, - cmd="gmsg", - val=room_data.global_data_value, - room_id="default", - quirk=self.supporter.quirk_embed_val - ) - - # Tell the client that the cloudlink protocol was selected - await self.send_code( - client=client, - code="OK", - listener=listener - ) - - async def ping(self, client, message, listener): - # Validation is not needed since this command takes no arguments - # Command remains here for compatibility. - # CL4's websocket server apparently has a built-in keepalive, so this is somewhat redundant... - # Return ping - await self.send_code( - client=client, - code="OK", - listener=listener - ) - - async def gmsg(self, client, message, listener): - # Validate the message syntax and datatypes - if not await self.__auto_validate__(self.validator[self.gmsg], client, message, listener): - return - - # Get rooms - rooms = self.get_rooms(client, message) - - # Send to all rooms specified - exclude_client = None - if listener: - exclude_client = client - - # Cache the room's gmsg value - for room in self.copy(rooms): - self.rooms.get(room).global_data_value = message["val"] - - # Send to all rooms specified - for room in rooms: - await self.send_packet_multicast( - cmd="gmsg", - val=message["val"], - room_id=room, - exclude_client=exclude_client, - quirk=self.supporter.quirk_embed_val, - ) - - # Handle listeners - if listener: - await self.send_packet_unicast( - client=client, - cmd="gmsg", - val=message["val"], - room_id=room, - listener=listener - ) - - async def pmsg(self, client, message, listener): - # Validate the message syntax and datatypes - if not await self.__auto_validate__(self.validator[self.pmsg], client, message, listener): - return - - # Check if the client has set their username - if not client.username_set: - await self.send_code(client, "IDRequired", listener=listener) - return - - # Get rooms - rooms = self.get_rooms(client, message) - - # Get the origin of the request - origin = self.clients.convert_json(client) - - # Locate clients to send message to - clients = set() - for room in rooms: - clients.update(self.clients.find_multi_obj(message["id"], room)) - - # Can't send message since no clients were found - if len(clients) == 0: - await self.send_code(client, "IDNotFound", listener=listener) - return - - # Send to all rooms specified - for room in rooms: - await self.send_packet_multicast( - cmd="pmsg", - val={ - "val": message["val"], - "origin": origin, - "rooms": room - }, - clients=clients, - quirk=self.supporter.quirk_update_msg, - ) - - # Tell the client the message was sent OK - await self.send_code(client, "OK", listener=listener) - - async def gvar(self, client, message, listener): - # Validate the message syntax and datatypes - if not await self.__auto_validate__(self.validator[self.gvar], client, message, listener): - return - - # Get rooms - rooms = self.get_rooms(client, message) - - # Send to all rooms specified - exclude_client = None - if listener: - exclude_client = client - - # Cache the room's current gvar value - for room in self.copy(rooms): - room_data = self.rooms.get(room) - if message["name"] in room_data.global_vars: - room_data.global_vars[message["name"]] = message["val"] - else: - room_data.global_vars[message["name"]] = message["val"] - - # Send to all rooms specified (auto convert to scratch format) - for room in rooms: - room_data = self.parent.rooms.get(room) - - await self.send_packet_multicast_variable( - cmd="gvar", - name=message["name"], - val=message["val"], - room_id=room, - exclude_client=exclude_client - ) - - # Handle listeners - if listener and (len(rooms) != 0): - await self.send_packet_unicast( - client=client, - cmd="gvar", - val={ - "name": message["name"], - "val": message["val"] - }, - quirk=self.supporter.quirk_update_msg, - listener=listener - ) - - async def pvar(self, client, message, listener): - # Validate the message syntax and datatypes - if not await self.__auto_validate__(self.validator[self.pvar], client, message, listener): - return - - # Check if the client has set their username - if not client.username_set: - await self.send_code(client, "IDRequired", listener=listener) - return - - # Get rooms - rooms = self.get_rooms(client, message) - - # Get the origin of the request - origin = self.clients.convert_json(client) - - # Locate clients to send message to - clients = set() - for room in rooms: - clients.update(self.clients.find_multi_obj(message["id"], room)) - - # Can't send message since no clients were found - if len(clients) == 0: - await self.send_code(client, "IDNotFound", listener=listener) - return - - # Send to all rooms specified - for room in rooms: - await self.send_packet_multicast( - cmd="pvar", - val={ - "val": message["val"], - "name": message["name"], - "origin": origin, - "rooms": room - }, - clients=clients, - quirk=self.supporter.quirk_update_msg, - ) - - # Tell the client the message was sent OK - await self.send_code(client, "OK", listener=listener) - - async def setid(self, client, message, listener): - # Validate the message syntax and datatypes - if not await self.__auto_validate__(self.validator[self.setid], client, message, listener): - return - - if client.username_set: - await self.send_code(client, "IDSet", listener=listener) - return - - result = self.parent.clients.set_username(client, message["val"]) - if result == self.supporter.username_set: - await self.send_code(client, "OK", extra_data={"val": self.parent.clients.convert_json(client)}, - listener=listener) - else: - await self.send_code(client, "IDConflict", listener=listener) - return - - # Refresh room - self.parent.rooms.refresh(client, "default") - - # Update all userlists - await self.send_packet_multicast( - cmd="ulist", - val={ - "mode": "add", - "val": self.parent.clients.convert_json(client) - }, - quirk=self.supporter.quirk_update_msg, - room_id="default" - ) - - async def link(self, client, message, listener): - # Validate the message syntax and datatypes - if not await self.__auto_validate__(self.validator[self.link], client, message, listener): - return - - if not client.username_set: - await self.send_code(client, "IDRequired", listener=listener) - return - - # Prepare rooms - rooms = set() - if type(message["val"]) == str: - message["val"] = [message["val"]] - rooms.update(set(message["val"])) - - # Client cannot be linked to no rooms - if len(rooms) == 0: - rooms.update(["default"]) - - # Manage existing rooms - old_rooms = self.copy(client.rooms) - if ("default" in client.rooms) and ("default" not in rooms): - self.parent.rooms.unlink(client, "default") - for room in old_rooms: - self.parent.rooms.unlink(client, room) - - # Create rooms if they do not exist - for room in rooms: - if not self.parent.rooms.exists(room): - self.parent.rooms.create(room) - - # Link client to new rooms - for room in rooms: - self.parent.rooms.link(client, room) - new_rooms = self.copy(client.rooms) - - # Tell the client they have been linked - await self.send_code(client, "OK", listener=listener) - - # Update old userlist - for room in old_rooms: - # Prevent duplication - if room in new_rooms: - continue - - # Get the room data - room_data = self.parent.rooms.get(room) - if not room_data: - continue - - # Update the state - await self.send_packet_multicast( - cmd="ulist", - val={ - "mode": "set", - "val": room_data.userlist - }, - quirk=self.supporter.quirk_update_msg, - room_id=room - ) - - # Update new userlist - for room in new_rooms: - # Get the room data - room_data = self.parent.rooms.get(room) - if not room_data: - continue - - # Update the state - await self.send_packet_multicast( - cmd="ulist", - val={ - "mode": "set", - "val": room_data.userlist - }, - quirk=self.supporter.quirk_update_msg, - room_id=room - ) - - # Sync the global variable state - for tmp_room in client.rooms: - room_data = self.parent.rooms.get(tmp_room) - if len(room_data.global_vars.keys()) != 0: - # Update the client's state - for var in room_data.global_vars.keys(): - await self.send_packet_unicast( - client=client, - cmd="gvar", - val={ - "name": var, - "val": room_data.global_vars[var] - }, - quirk=self.supporter.quirk_update_msg, - room_id=tmp_room - ) - - # Report the room's cached gmsg value - await self.send_packet_unicast( - client=client, - cmd="gmsg", - val=room_data.global_data_value, - quirk=self.supporter.quirk_embed_val, - room_id=tmp_room - ) - - async def unlink(self, client, message, listener): - # Validate the message syntax and datatypes - if not await self.__auto_validate__(self.validator[self.unlink], client, message, listener): - return - - if not client.username_set: - await self.send_code(client, "IDRequired", listener=listener) - return - - # Prepare the rooms - rooms = set() - if ("val" not in message) or (("val" in message) and (len(message["val"]) == 0)): - # Unlink all rooms - rooms.update(self.copy(client.rooms)) - else: - # Unlink from a single room, or many rooms - if type(message["val"]) == list: - rooms.update(set(message["val"])) - else: - rooms.update(message["val"]) - - # Unlink client from rooms - old_rooms = self.copy(client.rooms) - for room in rooms: - self.parent.rooms.unlink(client, room) - if len(client.rooms) == 0: - # Reset to default room - self.parent.rooms.link(client, "default") - new_rooms = self.copy(client.rooms) - - # Tell the client they have been unlinked - await self.send_code(client, "OK", listener=listener) - - # Update old userlist - for room in old_rooms: - # Prevent duplication - if room in new_rooms: - continue - - # Get the room data - room_data = self.parent.rooms.get(room) - if not room_data: - continue - - # Update the state - await self.send_packet_multicast( - cmd="ulist", - val={ - "mode": "set", - "val": room_data.userlist - }, - quirk=self.supporter.quirk_update_msg, - room_id=room - ) - - # Update new userlist - for room in new_rooms: - # Get the room data - room_data = self.parent.rooms.get(room) - if not room_data: - continue - - # Update the state - await self.send_packet_multicast( - cmd="ulist", - val={ - "mode": "set", - "val": room_data.userlist - }, - quirk=self.supporter.quirk_update_msg, - room_id=room - ) - - # Sync the global variable state - for tmp_room in client.rooms: - room_data = self.parent.rooms.get(tmp_room) - if len(room_data.global_vars.keys()) != 0: - # Update the client's state - for var in room_data.global_vars.keys(): - await self.send_packet_unicast( - client=client, - cmd="gvar", - val={ - "name": var, - "val": room_data.global_vars[var] - }, - room_id=tmp_room, - quirk=self.supporter.quirk_update_msg - ) - - # Report the room's cached gmsg value - await self.send_packet_unicast( - client=client, - cmd="gmsg", - val=room_data.global_data_value, - quirk=self.supporter.quirk_embed_val, - room_id=tmp_room - ) - - async def direct(self, client, message, listener): - # Validate the message syntax and datatypes - if not await self.__auto_validate__(self.validator[self.direct], client, message, listener): - return - - # Legacy command formatting will be automatically converted to new format. - # This function defaults to doing nothing unless an ID is specified - - if "id" in message: - # Attempting to send a packet directly to someone/something - if not client.username_set: - await self.send_code(client, "IDRequired", listener=listener) - return - - # Get the origin of the request - origin = self.clients.convert_json(client) - - # Locate all clients to send the direct data to - clients = self.clients.find_multi_obj(message["id"], None) - - if not clients: - await self.send_code(client, "IDNotFound", listener=listener) - return - - if (type(clients) in [list, set]) and (len(clients) == 0): - await self.send_code(client, "IDNotFound", listener=listener) - return - - await self.send_packet_multicast( - cmd="direct", - val={ - "val": message["val"], - "origin": origin - }, - clients=clients, - quirk=self.supporter.quirk_update_msg - ) diff --git a/old/cloudlink/server/protocols/scratch_methods.py b/old/cloudlink/server/protocols/scratch_methods.py deleted file mode 100644 index a76ff79..0000000 --- a/old/cloudlink/server/protocols/scratch_methods.py +++ /dev/null @@ -1,258 +0,0 @@ -class scratch_methods: - def __init__(self, parent): - self.parent = parent - self.supporter = parent.supporter - self.json = parent.json - self.copy = parent.copy - self.log = parent.supporter.log - - # Various ways to send messages - self.send_packet_unicast = parent.send_packet_unicast - self.send_packet_multicast = parent.send_packet_multicast - self.send_packet_multicast_variable = parent.send_packet_multicast_variable - - # Get protocol types to allow cross-protocol data sync - self.proto_scratch_cloud = self.supporter.proto_scratch_cloud - self.proto_cloudlink = self.supporter.proto_cloudlink - - # Packet check definitions - Specifies required keys, their datatypes, and optional keys - self.validator = { - self.handshake: { - "required": { - "method": self.supporter.keydefaults["method"], - "project_id": self.supporter.keydefaults["project_id"], - "user": self.supporter.keydefaults["user"] - }, - "optional": [], - "sizes": { - "project_id": 100, - "user": 20 - } - }, - self.set: { - "required": { - "method": self.supporter.keydefaults["method"], - "name": self.supporter.keydefaults["name"], - "value": self.supporter.keydefaults["value"] - }, - "optional": [], - "sizes": { - "name": 21, - "value": 1000 - } - }, - self.create: { - "required": { - "method": self.supporter.keydefaults["method"], - "name": self.supporter.keydefaults["name"], - "value": self.supporter.keydefaults["value"] - }, - "optional": [], - "sizes": { - "name": 21, - "value": 1000 - } - }, - self.delete: { - "required": { - "method": self.supporter.keydefaults["method"], - "name": self.supporter.keydefaults["name"] - }, - "optional": [], - "sizes": { - "name": 21 - } - }, - self.rename: { - "required": { - "method": self.supporter.keydefaults["method"], - "name": self.supporter.keydefaults["name"], - "new_name": self.supporter.keydefaults["name"] - }, - "optional": [], - "sizes": { - "name": 21, - "new_name": 21 - } - } - } - - async def __auto_validate__(self, validator, client, message): - validation = self.supporter.validate( - keys=validator["required"], - payload=message, - optional=validator["optional"], - sizes=validator["sizes"] - ) - - match validation: - case self.supporter.invalid: - # Command datatype is invalid - await client.close(code=self.supporter.connection_error, reason="Invalid datatype error") - return False - case self.supporter.missing_key: - # Command syntax is invalid - await client.close(code=self.supporter.connection_error, reason="Syntax error") - return False - case self.supporter.too_large: - # Payload size overload - await client.close(code=self.supporter.connection_error, reason="Contents too large") - return False - - return True - - async def handshake(self, client, message): - # Validate the message syntax and datatypes - if not await self.__auto_validate__(self.validator[self.handshake], client, message): - return - - # According to the Scratch cloud variable spec, if the handshake is successful then there will be no response - # Handshake request will be determined "failed" if the server terminates the connection - - if not len(message["user"]) in range(1, 21): - await client.close(code=self.supporter.connection_error, reason=f"Invalid username: {message['user']}") - return - - if not len(message["project_id"]) in range(1, 101): - await client.close(code=self.supporter.connection_error, reason=f"Invalid room ID: {message['project_id']}") - return - - if not len(message["project_id"]) in range(1, 101): - await client.close(code=self.supporter.connection_error, reason=f"Invalid room ID: {message['project_id']}") - return - - if ("scratchsessionsid" in client.request_headers) or ("scratchsessionsid" in client.response_headers): - await client.send( - "The cloud data library you are using is putting your Scratch account at risk by sending us your login token for no reason. Change your Scratch password immediately, then contact the maintainers of that library for further information. This connection is being refused to protect your security.") - await self.parent.asyncio.sleep(0.1) - await client.close(code=self.supporter.refused_security, reason=f"Connection closed for security reasons") - return - - # Create the project room - if not self.parent.rooms.exists(message["project_id"]): - self.parent.rooms.create(message["project_id"]) - - # Get the room data - room = self.parent.rooms.get(message["project_id"]) - - # Add the user to the room - room.users.add(client) - - # Configure the client - client.rooms = [message["project_id"]] - - result = self.parent.clients.set_username(client, message["user"]) - if result != self.supporter.username_set: - await client.close(code=self.supporter.username_error, reason=f"Username conflict") - return - - if client.friendly_username in room.usernames: - await client.close(code=self.supporter.username_error, reason=f"Username conflict") - return - - room.usernames.add(client.friendly_username) - - # Sync the global variable state - room = self.parent.rooms.get(client.rooms[0]) - if len(room.global_vars.keys()) != 0: - # Wait for client to finish processing new state - await self.parent.asyncio.sleep(0.1) - - # Update the client's state - for var in room.global_vars.keys(): - message = { - "method": "set", - "name": var, - "value": room.global_vars[var] - } - await client.send(self.json.dumps(message)) - - async def set(self, client, message): - # Validate the message syntax and datatypes - if not await self.__auto_validate__(self.validator[self.set], client, message): - return - - room = self.parent.rooms.get(client.rooms[0]) - if not room: - await client.close(code=self.supporter.connection_error, reason="No room set up yet") - return - else: - # Don't update the value if it's already set - if message["name"] in room.global_vars: - if room.global_vars[message["name"]] == message["value"]: - return - - room.global_vars[message["name"]] = message["value"] - await self.send_packet_multicast_variable( - cmd="set", - name=message["name"], - val=message["value"], - room_id=client.rooms[0] - ) - - async def create(self, client, message): - # Validate the message syntax and datatypes - if not await self.__auto_validate__(self.validator[self.create], client, message): - return - - room = self.parent.rooms.get(client.rooms[0]) - if not room: - await client.close(code=self.supporter.connection_error, reason="No room set up yet") - return - else: - if message["name"] in room.global_vars: - return - - room.global_vars[message["name"]] = message["value"] - await self.send_packet_multicast_variable( - cmd="create", - name=message["name"], - val=message["value"], - room_id=client.rooms[0] - ) - - async def delete(self, client, message): - # Validate the message syntax and datatypes - if not await self.__auto_validate__(self.validator[self.delete], client, message): - return - - room = self.parent.rooms.get(client.rooms[0]) - if not room: - await client.close(code=self.supporter.connection_error, reason="No room set up yet") - return - else: - if not message["name"] in room.global_vars: - return - - del room.global_vars[message["name"]] - await self.send_packet_multicast_variable( - cmd="delete", - name=message["name"], - room_id=client.rooms[0] - ) - - async def rename(self, client, message): - # Validate the message syntax and datatypes - if not await self.__auto_validate__(self.validator[self.rename], client, message): - return - - room = self.parent.rooms.get(client.rooms[0]) - - if not room: - await client.close(code=self.supporter.connection_error, reason="No room set up yet") - return - else: - if not message["name"] in room.global_vars: - await client.close(code=self.supporter.connection_error, reason="Variable does not exist") - return - - # Copy the old room data to new one, and then delete - room.global_vars[message["new_name"]] = self.copy(room.global_vars[message["name"]]) - del room.global_vars[message["name"]] - - await self.send_packet_multicast_variable( - cmd="rename", - name=message["name"], - new_name=message["new_name"], - room_id=client.rooms[0] - ) diff --git a/old/cloudlink/server/server.py b/old/cloudlink/server/server.py deleted file mode 100644 index 4534ba5..0000000 --- a/old/cloudlink/server/server.py +++ /dev/null @@ -1,953 +0,0 @@ -from .protocols import cl_methods, scratch_methods - -import websockets -import asyncio -import json -from copy import copy - - -class server: - def __init__(self, parent, logs: bool = True): - self.version = parent.version - - # Initialize required libraries - self.asyncio = asyncio - self.websockets = websockets - self.copy = copy - self.json = json - - # Other - self.enable_logs = logs - self.id_counter = 0 - self.ip_blocklist = [] - - # Config - self.reject_clients = False - self.enable_scratch_support = True - self.check_ip_addresses = False - self.enable_motd = False - self.motd_message = str() - - # Managing methods - self.custom_methods = custom_methods() - self.disabled_methods = set() - self.method_callbacks = dict() - self.listener_callbacks = dict() - self.safe_methods = set() - - # Managing events - self.events = events() - self.event_callbacks = dict() - - # Initialize supporter - self.supporter = parent.supporter(self) - - # Initialize attributes from supporter component - This will also become part of the server's public API - self.log = self.supporter.log - self.validate = self.supporter.validate - self.load_custom_methods = self.supporter.load_custom_methods - self.disable_methods = self.supporter.disable_methods - self.is_json = self.supporter.is_json - self.get_client_ip = self.supporter.get_client_ip - self.detect_listener = self.supporter.detect_listener - self.generate_statuscode = self.supporter.generate_statuscode - - # Client and room management - self.rooms = rooms(self) - self.clients = clients(self) - - # Load Cloudlink methods - self.cl_methods = cl_methods(self) - - # Load safe CLPv4 methods to mitigate vulnerabilities - self.supporter.init_builtin_cl_methods() - - # Load Scratch cloud variable methods - self.scratch_methods = scratch_methods(self) - - # Display version - self.supporter.log(f"Cloudlink server v{parent.version}") - - # == Public API functionality == - - # Runs the server. - def run(self, ip: str = "localhost", port: int = 3000): - try: - self.asyncio.run(self.__run__(ip, port)) - except KeyboardInterrupt: - pass - - # Set the server's Message-of-the-Day. - def set_motd(self, message: str, enable: bool = True): - self.enable_motd = enable - self.motd_message = message - - # Sets the client's username and enables private messages/variables, room link/unlink and direct functionality. - def set_client_username(self, client: type, username: str): - result = self.clients.set_username(client, username) - if result: - self.rooms.refresh(client, "default") - - # Links clients to rooms. Supports strings and lists/sets. - def link_to_rooms(self, client: type, rooms_to_link=[]): - # Prepare rooms - rooms = set() - if type(rooms_to_link) == str: - rooms_to_link = [rooms_to_link] - rooms.update(set(rooms_to_link)) - - # Client cannot be linked to no rooms - if len(rooms) == 0: - rooms.update(["default"]) - - # Manage existing rooms - old_rooms = self.copy(client.rooms) - if ("default" in client.rooms) and ("default" not in rooms): - self.parent.rooms.unlink(client, "default") - for room in old_rooms: - self.parent.rooms.unlink(client, room) - - # Create rooms if they do not exist - for room in rooms: - if not self.parent.rooms.exists(room): - self.parent.rooms.create(room) - - # Link client to new rooms - for room in rooms: - self.parent.rooms.link(client, room) - new_rooms = self.copy(client.rooms) - - # Unlinks clients from rooms. Supports strings and lists/sets. - def unlink_from_rooms(self, client: type, rooms_to_unlink=None): - # Prepare the rooms - rooms = set() - if (not rooms_to_unlink) or (rooms_to_unlink and (len(rooms_to_unlink) == 0)): - # Unlink all rooms - rooms.update(self.copy(client.rooms)) - else: - # Unlink from a single room, or many rooms - if type(rooms_to_unlink) == list: - rooms.update(set(rooms_to_unlink)) - else: - rooms.update(rooms_to_unlink) - - # Unlink client from rooms - old_rooms = self.copy(client.rooms) - for room in rooms: - self.parent.rooms.unlink(client, room) - if len(client.rooms) == 0: - # Reset to default room - self.parent.rooms.link(client, "default") - new_rooms = self.copy(client.rooms) - - # Binding callbacks - Provides a programmer-friendly interface to run extra code after a cloudlink method has been executed. - def bind_callback(self, callback_method: type, function: type): - if hasattr(self.cl_methods, callback_method.__name__) or hasattr(self.custom_methods, callback_method.__name__): - if callback_method.__name__ not in self.method_callbacks: - self.method_callbacks[callback_method.__name__] = set() - self.method_callbacks[callback_method.__name__].add(function) - - # Binding events - Provides a programmer-friendly interface to detect client connects, disconnects, and errors. - def bind_event(self, event_method: type, function: type): - if hasattr(self.events, event_method.__name__): - if event_method.__name__ not in self.event_callbacks: - self.event_callbacks[event_method.__name__] = set() - self.event_callbacks[event_method.__name__].add(function) - - # Multicasting variables - Automatically translates Scratch and Cloudlink. Used only for gvar. - async def send_packet_multicast_variable(self, cmd: str, name: str, val: any = None, clients: type = None, - exclude_client: any = None, room_id: str = None, new_name: str = None): - # Get all clients present - tmp_clients = None - if clients: - tmp_clients = set(clients) - else: - tmp_clients = set(self.copy(self.rooms.get(room_id).users)) - if not tmp_clients: - return - - if exclude_client: - tmp_clients.discard(exclude_client) - clients_cl = set() - clients_scratch = set() - - # Filter clients by protocol type - for client in tmp_clients: - match client.protocol: - case self.supporter.proto_cloudlink: - clients_cl.add(client) - case self.supporter.proto_scratch_cloud: - clients_scratch.add(client) - - # Methods that are marked with NoneType will not be translated - translate_cl = { - "set": "gvar", - "create": "gvar", - "delete": None, - "rename": None - } - translate_scratch = { - "gvar": "set", - "pvar": None - } - - # Translate between Scratch and Cloudlink - if cmd in translate_cl: - # Send message to all scratch protocol clients - message = {"method": cmd, "name": name} - tmp = self.copy(message) - - if val: - message["value"] = val - - # Prevent crashing Scratch clients if the val is a dict / JSON object - tmp = self.copy(message) - if type(tmp["value"]) == dict: - tmp["value"] = self.json.dumps(tmp["value"]) - - if new_name: - message["new_name"] = new_name - - self.websockets.broadcast(clients_scratch, self.json.dumps(tmp)) - - if (translate_cl[cmd]) and (len(clients_cl) != 0): - # Send packet to only cloudlink protocol clients - message = {"cmd": translate_cl[cmd], "name": name, "rooms": room_id} - if val: - message["val"] = val - - # Translate JSON string to dict - if type(message["val"]) == str: - try: - message["val"] = self.json.loads(message["val"]) - except: - pass - - self.websockets.broadcast(clients_cl, self.json.dumps(message)) - - elif cmd in translate_scratch: - # Send packet to only cloudlink protocol clients - message = {"cmd": cmd, "name": name, "val": val, "rooms": room_id} - - # Translate JSON string to dict - if type(message["val"]) == str: - try: - message["val"] = self.json.loads(message["val"]) - except: - pass - - self.websockets.broadcast(clients_cl, self.json.dumps(message)) - - if (translate_scratch[cmd]) and (len(clients_scratch) != 0): - # Send message to all scratch protocol clients - message = {"method": translate_scratch[cmd], "name": name, "value": val} - - # Prevent crashing Scratch clients if the val is a dict / JSON object - if type(message["value"]) == dict: - message["value"] = self.json.dumps(message["value"]) - - self.websockets.broadcast(clients_scratch, self.json.dumps(message)) - else: - raise TypeError("Command is not translatable!") - - # Multicast data - Used for gmsg, gvar, or multicasted direct messages. - async def send_packet_multicast(self, cmd: str, val: any, clients: type = None, exclude_client: any = None, - room_id: str = None, quirk: str = "quirk_embed_val"): - # Get all clients present - tmp_clients = None - if clients: - tmp_clients = set(clients) - else: - tmp_clients = set(self.copy(self.rooms.get(room_id).users)) - if not tmp_clients: - return - - # Remove individual client - if exclude_client: - tmp_clients.discard(exclude_client) - - # Purge clients that aren't cloudlink - for client in self.copy(tmp_clients): - if client.protocol == self.supporter.proto_scratch_cloud: - tmp_clients.discard(client) - - # Send packet to only cloudlink protocol clients - message = {"cmd": cmd} - if quirk == self.supporter.quirk_update_msg: - message.update(val) - elif quirk == self.supporter.quirk_embed_val: - message["val"] = val - else: - raise ValueError("Unknown message quirk!") - - # Attach the rooms key - if room_id: - message["rooms"] = room_id - - self.supporter.log_debug(f"Multicasting payload: {message}") - - # Send payload - self.websockets.broadcast(tmp_clients, self.json.dumps(message)) - - # Unicast data - Used for pmsg, pvar, or unicasted direct messages. - async def send_packet_unicast(self, client: type, cmd: str, val: any, listener: str = None, room_id: str = None, - quirk: str = "quirk_embed_val"): - # Check client protocol - if client.protocol == self.supporter.proto_unset: - raise Exception("Cannot send packet to a client with an unset protocol!") - if client.protocol != self.supporter.proto_cloudlink: - raise TypeError("Unsupported protocol type!") - - # Manage specific message quirks - message = {"cmd": cmd} - if quirk == self.supporter.quirk_update_msg: - message.update(val) - elif quirk == self.supporter.quirk_embed_val: - message["val"] = val - else: - raise TypeError("Unknown message quirk!") - - # Attach a listener response - if listener: - message["listener"] = listener - - # Attach the rooms key - if room_id: - message["rooms"] = room_id - - self.supporter.log_debug(f"Unicasting payload: {message}") - - # Send payload - try: - await client.send(self.json.dumps(message)) - except self.websockets.exceptions.ConnectionClosedError: - self.supporter.log_error(f"Failed to send packet to client {client.id}: Connection closed unexpectedly") - - # Unicast status codes - Only used for statuscode. - async def send_code(self, client: type, code: str, extra_data: dict = None, listener: str = None): - # Check client protocol - if client.protocol == self.supporter.proto_unset: - raise Exception("Cannot send codes to a client with an unset protocol!") - if client.protocol != self.supporter.proto_cloudlink: - raise TypeError("Unsupported protocol type!") - - # Prepare message - human_code, machine_code = self.generate_statuscode(code) - message = { - "cmd": "statuscode", - "code": human_code, - "code_id": machine_code - } - - # Attach extra data - if extra_data: - message.update(extra_data) - - # Attach a listener response - if listener: - message["listener"] = listener - - self.supporter.log_debug(f"Sending payload: {message}") - - # Send payload - try: - await client.send(self.json.dumps(message)) - except self.websockets.exceptions.ConnectionClosedError: - self.supporter.log_error(f"Failed to send status code to client {client.id}: Connection closed unexpectedly") - - # == Server functionality == - - async def __run__(self, ip, port): - # Main event loop - async with self.websockets.serve(self.__handler__, ip, port): - await self.asyncio.Future() - - async def reject_client(self, client, reason): - await client.close(code=1001, reason=reason) - - def __fire_callbacks__(self, callback_method, client, message, listener): - if callback_method.__name__ in self.method_callbacks: - for _method in self.method_callbacks[callback_method.__name__]: - self.asyncio.create_task(_method(client, message, listener)) - - def __fire_event__(self, event_method, client): - if event_method.__name__ in self.event_callbacks: - for _method in self.event_callbacks[event_method.__name__]: - self.asyncio.create_task(_method(client)) - - async def __cl_method_handler__(self, client: type, message: dict): - # Check if the message contains the cmd key, with a string datatype. - match self.validate({"cmd": str}, message): - case self.supporter.invalid: - return self.supporter.invalid - case self.supporter.missing_key: - return self.supporter.missing_key - case self.supporter.not_a_dict: - raise TypeError - - # Detect and convert CLPv3 custom requests to CLPv4 - if (message["cmd"] == "direct") and ("val" in message): - if self.validate({"cmd": str, "val": self.supporter.keydefaults["val"]}, - message["val"]) == self.supporter.valid: - tmp_msg = { - "cmd": message["val"]["cmd"], - "val": message["val"]["val"] - } - message = self.copy(tmp_msg) - - # Detect listeners - listener = self.detect_listener(message) - - # Check if the command is disabled - if message["cmd"] in self.disabled_methods: - return self.supporter.disabled_method - - # Check if the command method exists - # Custom methods override / take precedence over builtin methods - if hasattr(self.custom_methods, message["cmd"]) and (message["cmd"] in self.safe_methods): - method = getattr(self.custom_methods, message["cmd"]) - - await method(client, message, listener) - self.__fire_callbacks__(method, client, message, listener) - - return self.supporter.valid - - # Run builtin method - elif hasattr(self.cl_methods, message["cmd"]) and (message["cmd"] in self.safe_methods): - method = getattr(self.cl_methods, message["cmd"]) - - await method(client, message, listener) - self.__fire_callbacks__(method, client, message, listener) - - return self.supporter.valid - - # Method not found - else: - return self.supporter.unknown_method - - async def __scratch_method_handler__(self, client: type, message: dict): - # Check if the message contains the method key, with a string datatype. - match self.validate({"method": str}, message): - case self.supporter.invalid: - return self.supporter.invalid - case self.supporter.missing_key: - return self.supporter.missing_key - case self.supporter.not_a_dict: - raise TypeError - - # Check if the command method exists - if not hasattr(self.scratch_methods, message["method"]): - return self.supporter.unknown_method - - # Run the method - await getattr(self.scratch_methods, message["method"])(client, message) - return self.supporter.valid - - async def __run_method__(self, client: type, message: dict): - # Detect connection protocol (supports Cloudlink CLPv4 or Scratch Cloud Variables Protocol) - if client.protocol == self.supporter.proto_unset: - if "cmd" in message: - # Update the client's protocol type and update the clients iterable - client.protocol = self.supporter.proto_cloudlink - self.clients.set_protocol(client, self.supporter.proto_cloudlink) - - # Link client to the default room - self.rooms.link(client, "default") - - return await self.__cl_method_handler__(client, message) - - elif "method" in message: - if not self.enable_scratch_support: - await client.close(code=1000, reason="Scratch protocol is disabled") - return - - # Update the client's protocol type and update the clients iterable - client.protocol = self.supporter.proto_scratch_cloud - self.clients.set_protocol(client, self.supporter.proto_scratch_cloud) - - return await self.__scratch_method_handler__(client, message) - else: - # Reject the client because the server does not understand the protocol being used - return self.supporter.unknown_protocol - - elif client.protocol == self.supporter.proto_cloudlink: - # Interpret and process CL commands - return await self.__cl_method_handler__(client, message) - - elif client.protocol == self.supporter.proto_scratch_cloud: - # Interpret and process Scratch commands - return await self.__scratch_method_handler__(client, message) - - else: - raise TypeError(f"Unknown protocol type: {client.protocol}") - - async def __handler__(self, client): - if self.check_ip_addresses: - # Get the IP address of client - client.full_ip = self.get_client_ip(client) - - rejected = False - if self.reject_clients: - rejected = True - await client.close(code=1013, reason="Reject mode is enabled") - self.supporter.log(f"Client disconnected in reject mode: {client.full_ip}") - elif self.check_ip_addresses and (client.full_ip in self.ip_blocklist): - rejected = True - self.supporter.log(f"Client rejected: IP address {client.full_ip} blocked") - await client.close(code=1008, reason="IP blocked") - - # Do absolutely nothing if the client was rejected - if not rejected: - # Set the initial protocol type - client.protocol = self.supporter.proto_unset - - # Assign an ID to the client - client.id = self.id_counter - self.id_counter += 1 - - # Register the client - self.clients.create(client) - - # Configure client - client.rooms = set() - client.username_set = False - client.linked = False - client.friendly_username = None - - # Log event - if self.check_ip_addresses: - self.supporter.log(f"Client {client.id} connected: {client.full_ip}") - else: - self.supporter.log(f"Client {client.id} connected") - - # Fire events - self.__fire_event__(self.events.on_connect, client) - - # Handle requests from the client - try: - async for tmp_msg in client: - # Handle empty payloads - if len(tmp_msg) == 0: - if client.protocol == self.supporter.proto_cloudlink: - await self.send_code(client, "EmptyPacket") - continue - elif client.protocol == self.supporter.proto_scratch_cloud: - await client.close(code=self.supporter.connection_error, reason="Empty message") - else: - await client.close(code=1002, reason="Empty message") - - # Convert/sanity check JSON - message = None - try: - message = self.json.loads(tmp_msg) - except: - if client.protocol == self.supporter.proto_cloudlink: - await self.send_code(client, "Syntax") - continue - elif client.protocol == self.supporter.proto_scratch_cloud: - await client.close(code=self.supporter.connection_error, reason="Corrupt/malformed JSON") - else: - await client.close(code=1002, reason="Corrupt/malformed JSON") - - # Fire events - self.__fire_event__(self.events.on_error, client) - - # Run handlers - if 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.is_json(message[key]): - message[key] = self.json.loads(message[key]) - - result = await self.__run_method__(client, message) - match result: - case self.supporter.disabled_method: - listener = self.supporter.detect_listener(message) - await self.send_code(client, "Disabled", listener=listener) - - case self.supporter.invalid: - if client.protocol == self.supporter.proto_cloudlink: - listener = self.supporter.detect_listener(message) - await self.send_code(client, "Syntax", listener=listener) - - elif client.protocol == self.proto_scratch_cloud: - await client.close(code=self.supporter.connection_error, reason="Bad method") - - case self.supporter.missing_key: - if client.protocol == self.supporter.proto_cloudlink: - listener = self.supporter.detect_listener(message) - await self.send_code(client, "Syntax", listener=listener) - - elif client.protocol == self.proto_scratch_cloud: - await client.close(code=self.supporter.connection_error, - reason="Missing method key in JSON") - - case self.supporter.unknown_method: - if client.protocol == self.supporter.proto_cloudlink: - listener = self.supporter.detect_listener(message) - await self.send_code(client, "Invalid", listener=listener) - - elif client.protocol == self.proto_scratch_cloud: - await client.close(code=self.supporter.connection_error, reason="Invalid method") - - case self.supporter.unknown_protocol: - await client.close(code=1002, reason="Unknown protocol") - - case _: - self.__fire_event__(self.events.on_msg, client) - - # Handle unexpected disconnects - except self.websockets.exceptions.ConnectionClosedError: - pass - - # Handle OK disconnects - except self.websockets.exceptions.ConnectionClosedOK: - pass - - # Handle unexpected exceptions - except Exception as e: - self.supporter.log_error(f"Exception was raised: \"{e}\"\n{self.supporter.full_stack()}") - await client.close(code=1011, reason="Unexpected exception was raised") - - # Gracefully shutdown the handler - finally: - if client.username_set and (client.protocol == self.supporter.proto_cloudlink): - # Alert clients that a client has disconnected - rooms = self.copy(client.rooms) - for room in rooms: - self.rooms.unlink(client, room) - room_data = self.rooms.get(room) - if not room_data: - continue - - # Update the state - await self.send_packet_multicast( - cmd="ulist", - val={ - "mode": "remove", - "val": self.clients.convert_json(client) - }, - quirk=self.supporter.quirk_update_msg, - room_id=room - ) - - # Dispose the client - self.clients.delete(client) - - # Fire events - self.__fire_event__(self.events.on_close, client) - - # Log event - if self.check_ip_addresses: - self.supporter.log(f"Client {client.id} disconnected: {client.full_ip} - Code {client.close_code} and reason \"{client.close_reason}\"") - else: - self.supporter.log(f"Client {client.id} disconnected: Code {client.close_code} and reason \"{client.close_reason}\"") - - -# Class to store custom methods -class custom_methods: - def __init__(self): - pass - - -# Clients management -class clients: - def __init__(self, parent): - self.__all_cl__ = set() - self.__all_scratch__ = set() - self.__proto_unset__ = parent.supporter.proto_unset - self.__proto_cloudlink__ = parent.supporter.proto_cloudlink - self.__proto_scratch_cloud__ = parent.supporter.proto_scratch_cloud - self.__parent__ = parent - self.__usernames__ = dict() - - def get_all_scratch(self): - return self.__all_scratch__ - - def get_all_cloudlink(self): - return self.__all_cl__ - - def get_all_usernames(self): - return self.__usernames__ - - def get_all(self): - tmp = self.__parent__.copy(self.__dict__) - - # Remove attributes that aren't client objects - del tmp["__all_cl__"] - del tmp["__all_scratch__"] - del tmp["__proto_unset__"] - del tmp["__proto_cloudlink__"] - del tmp["__proto_scratch_cloud__"] - del tmp["__usernames__"] - del tmp["__parent__"] - - return tmp - - def set_protocol(self, client: dict, protocol: str): - match protocol: - case self.__proto_cloudlink__: - self.__all_cl__.add(client) - case self.__proto_scratch_cloud__: - self.__all_scratch__.add(client) - case _: - raise TypeError(f"Unsupported protocol ID: {protocol}") - - if self.exists(client): - self.get(client).protocol = protocol - - else: - raise ValueError - - def exists(self, client: dict): - return hasattr(self, str(client.id)) - - def create(self, client: dict): - if not self.exists(client): - setattr(self, str(client.id), client) - - def set_username(self, client: dict, username: str): - # Abort if the client is invalid - if not self.exists(client): - return self.__parent__.supporter.username_not_set - - # Create new username set if not present - if not str(username) in self.__usernames__: - self.__usernames__[str(username)] = set() - - # Add pointer to client object - self.__usernames__[str(username)].add(client) - - # Client username has been set successfully - client.friendly_username = str(username) - client.username_set = True - return self.__parent__.supporter.username_set - - def delete(self, client: dict): - if self.exists(client): - # Remove user from client type lists - match self.get(client).protocol: - case self.__proto_cloudlink__: - self.__all_cl__.discard(client) - case self.__proto_scratch_cloud__: - self.__all_scratch__.discard(client) - case self.__proto_unset__: - pass - case _: - raise TypeError(f"Unsupported protocol ID: {self.get(client).protocol}") - - # Remove the username from the userlist - if client.username_set: - if str(client.friendly_username) in self.__usernames__: - # Remove client from the shared username set - if client in self.__usernames__[str(client.friendly_username)]: - self.__usernames__[str(client.friendly_username)].remove(client) - - # Delete the username if there are no more users present with that name - if len(self.__usernames__[str(client.friendly_username)]) == 0: - del self.__usernames__[str(client.friendly_username)] - - # Clean up rooms - if hasattr(client, "rooms") and client.rooms: - for room in self.__parent__.copy(client.rooms): - self.__parent__.rooms.unlink(client, room) - - # Dispose the client - delattr(self, str(client.id)) - - def get(self, client: dict): - if self.exists(client): - return getattr(self, str(client.id)) - else: - return None - - def convert_json(self, client: any): - return {"username": client.friendly_username, "id": client.id} - - def find_multi_obj(self, clients_to_find, rooms="default"): - if not type(rooms) in [set, list]: - rooms = {rooms} - - tmp = set() - - # Check if the input is iterable - if type(clients_to_find) in [list, set]: - for room in rooms: - for client in clients_to_find: - res = self.find_obj(client, room) - if not res: - continue - if type(res) in [list, set]: - tmp.update(res) - else: - tmp.add(res) - else: - for room in rooms: - res = self.find_obj(clients_to_find, room) - if not res: - continue - if type(res) in [list, set]: - tmp.update(res) - else: - tmp.add(res) - - return tmp - - def find_obj(self, client_to_find, room_id=None): - room_user_objs = set() - room_user_names = dict() - if room_id: - if not self.__parent__.rooms.exists(room_id): - return None - - room_user_objs = self.__parent__.rooms.get(room_id).clients - room_user_names = self.__parent__.rooms.get(room_id).usernames_searchable - else: - room_user_objs = self.get_all() - room_user_names = self.get_all_usernames() - - if type(client_to_find) == str: - if client_to_find in room_user_names: - return room_user_names[client_to_find] - - elif type(client_to_find) == dict: - if "id" in client_to_find: - if str(client_to_find["id"]) in room_user_objs: - return room_user_objs[str(client_to_find["id"])] - - elif type(client_to_find) == int: - if str(client_to_find) in room_user_objs: - return room_user_objs[str(client_to_find)] - - else: - return None - - -# Rooms management -class rooms: - def __init__(self, parent): - self.default = self.__room__() - self.__parent__ = parent - - def get_all(self): - tmp = self.__parent__.copy(self.__dict__) - - # Remove attributes that aren't client objects - del tmp["__parent__"] - - return tmp - - def exists(self, room_id: str): - return hasattr(self, str(room_id)) - - def create(self, room_id: str): - if not self.exists(str(room_id)): - setattr(self, str(room_id), self.__room__()) - - def delete(self, room_id: str): - if self.exists(str(room_id)): - delattr(self, str(room_id)) - - def get(self, room_id: str): - if self.exists(str(room_id)): - return getattr(self, str(room_id)) - else: - return None - - def unlink(self, client, room_id): - room_data = self.get(room_id) - if room_data: - # Remove the user from the room - if client in room_data.users: - room_data.users.remove(client) - - if client.username_set: - # Remove the user from the room's JSON-friendly userlist - user_json = self.__parent__.clients.convert_json(client) - if user_json in room_data.userlist: - room_data.userlist.remove(user_json) - - if client.friendly_username in room_data.usernames: - room_data.usernames.remove(client.friendly_username) - - if (not room_id == "default") and (len(room_data.users) == 0): - self.delete(room_id) - - if str(client.id) in room_data.clients: - del room_data.clients[str(client.id)] - - if client.friendly_username in room_data.usernames_searchable: - del room_data.usernames_searchable[client.friendly_username] - - if room_id in client.rooms: - client.rooms.remove(room_id) - - def link(self, client, room_id): - # Get the room data - room_data = self.get(room_id) - if room_data: - # Add the user to the room - room_data.users.add(client) - - if room_id not in client.rooms: - client.rooms.add(room_id) - - if client.username_set: - # Add the user to the room's JSON-friendly userlist - user_json = self.__parent__.clients.convert_json(client) - room_data.userlist.append(user_json) - - if client.friendly_username not in room_data.usernames: - room_data.usernames.add(client.friendly_username) - - if str(client.id) not in room_data.clients: - room_data.clients[str(client.id)] = client - - if client.friendly_username not in room_data.usernames_searchable: - room_data.usernames_searchable[client.friendly_username] = set() - - if client not in room_data.usernames_searchable[client.friendly_username]: - room_data.usernames_searchable[client.friendly_username].add(client) - - def refresh(self, client, room_id): - if self.exists(room_id): - self.unlink(client, room_id) - self.link(client, room_id) - - class __room__: - def __init__(self): - # Global data stream current value - self.global_data_value = str() - - # Storage of all global variables / Scratch Cloud Variables - self.global_vars = dict() - - # User management - self.users = set() - self.usernames = set() - self.userlist = list() - - # Client management - self.clients = dict() - - # Used only for string-based user lookup - self.usernames_searchable = dict() - - -# Class for binding events -class events: - def __init__(self): - pass - - def on_connect(self): - pass - - def on_error(self): - pass - - def on_close(self): - pass - - def on_msg(self): - pass diff --git a/old/cloudlink/supporter.py b/old/cloudlink/supporter.py deleted file mode 100644 index 140a932..0000000 --- a/old/cloudlink/supporter.py +++ /dev/null @@ -1,275 +0,0 @@ -import sys -import traceback -from datetime import datetime -import logging - -class supporter: - def __init__(self, parent): - self.parent = parent - - # Use logging library - self.logger = logging.getLogger("Cloudlink") - - # Reconfigure when needed - # should be in exapmles, not the lib itself. - logging.basicConfig(format="[%(asctime)s | %(created)f] (%(thread)d - %(threadName)s) %(levelname)s: %(message)s", level=self.logger.INFO) - - # Define protocol types - self.proto_unset = "proto_unset" - self.proto_cloudlink = "proto_cloudlink" - self.proto_scratch_cloud = "proto_scratch_cloud" - - # Multicasting message quirks - self.quirk_embed_val = "quirk_embed_val" - self.quirk_update_msg = "quirk_update_msg" - - # Case codes - self.valid = 0 - self.invalid = 1 - self.missing_key = 2 - self.not_a_dict = 3 - self.unknown_method = 4 - self.unknown_protocol = 5 - self.username_set = 6 - self.username_not_set = 7 - self.disabled_method = 8 - self.too_large = 9 - - # Scratch error codes - self.connection_error = 4000 - self.username_error = 4002 - self.overloaded = 4003 - self.unavailable = 4004 - self.refused_security = 4005 - - # Status codes - self.info = "I" - self.error = "E" - self.codes = { - "Test": (self.info, 0, "Test"), - "OK": (self.info, 100, "OK"), - "Syntax": (self.error, 101, "Syntax"), - "DataType": (self.error, 102, "Datatype"), - "IDNotFound": (self.error, 103, "ID not found"), - "IDNotSpecific": (self.error, 104, "ID not specific enough"), - "InternalServerError": (self.error, 105, "Internal server error"), - "EmptyPacket": (self.error, 106, "Empty packet"), - "IDSet": (self.error, 107, "ID already set"), - "Refused": (self.error, 108, "Refused"), - "Invalid": (self.error, 109, "Invalid command"), - "Disabled": (self.error, 110, "Command disabled"), - "IDRequired": (self.error, 111, "ID required"), - "IDConflict": (self.error, 112, "ID conflict"), - "TooLarge": (self.error, 113, "Too large") - } - - # Method default keys and permitted datatypes - self.keydefaults = { - "val": [str, int, float, dict], - "id": [str, int, dict, set, list], - "listener": [str, dict, float, int], - "rooms": [str, list], - "name": str, - "user": str, - "project_id": str, - "method": str, - "cmd": str, - "value": [str, int, float] - } - - # New and improved version of the message sanity checker. - def validate(self, keys: dict, payload: dict, optional=None, sizes: dict = None): - # Check if input datatypes are valid - if optional is None: - optional = [] - if (type(keys) != dict) or (type(payload) != dict): - return self.not_a_dict - - self.log_debug(f"Running validator: {keys}, {payload}, {optional}, {sizes}") - - for key in keys.keys(): - # Check if a key is present - if (key in payload) or (key in optional): - # Bypass checks if a key is optional and not present - if (key not in payload) and (key in optional): - self.log_debug(f"Validator: Payload {payload} key {key} is optional") - continue - - # Check if there are multiple supported datatypes for a key - if type(keys[key]) == list: - # Validate key datatype - if not type(payload[key]) in keys[key]: - self.log_debug(f"Validator: Payload {payload} key {key} value is invalid type. Expecting {keys[key]}, got {type(payload[key])}") - return self.invalid - - # Check if the size of the payload is too large - if sizes: - if (key in sizes.keys()) and (len(str(payload[key])) > sizes[key]): - self.log_debug(f"Validator: Payload {payload} key {key} value is too large") - return self.too_large - - else: - # Validate key datatype - if type(payload[key]) != keys[key]: - self.log_debug(f"Validator: Payload {payload} key {key} value is invalid type. Expecting {keys[key]}, got {type(payload[key])}") - return self.invalid - - # Check if the size of the payload is too large - if sizes: - if (key in sizes.keys()) and (len(str(payload[key])) > sizes[key]): - self.log_debug(f"Validator: Payload {payload} key {key} value is too large") - return self.too_large - else: - self.log_debug(f"Validator: Payload {payload} is missing key {key}") - return self.missing_key - - # Hooray, the message is sane - self.log_debug(f"Validator: Payload {payload} is valid") - return self.valid - - def full_stack(self): - exc = sys.exc_info()[0] - if exc is not None: - f = sys.exc_info()[-1].tb_frame.f_back - stack = traceback.extract_stack(f) - else: - stack = traceback.extract_stack()[:-1] - trc = 'Traceback (most recent call last):\n' - stackstr = trc + ''.join(traceback.format_list(stack)) - if exc is not None: - stackstr += ' ' + traceback.format_exc().lstrip(trc) - return stackstr - - def is_json(self, json_str): - is_valid_json = False - try: - if type(json_str) == dict: - is_valid_json = True - elif type(json_str) == str: - json_str = self.json.loads(json_str) - is_valid_json = True - except: - is_valid_json = False - return is_valid_json - - def get_client_ip(self, client: dict): - if "x-forwarded-for" in client.request_headers: - return client.request_headers.get("x-forwarded-for") - elif "cf-connecting-ip" in client.request_headers: - return client.request_headers.get("cf-connecting-ip") - else: - if type(client.remote_address) == tuple: - return str(client.remote_address[0]) - else: - return client.remote_address - - def generate_statuscode(self, code: str): - if code in self.codes: - c_type, c_code, c_msg = self.codes[code] - return f"{c_type}:{c_code} | {c_msg}", c_code - else: - raise ValueError - - # Determines if a method has a listener - def detect_listener(self, message): - validation = self.validate( - { - "listener": self.keydefaults["listener"] - }, - message - ) - - match validation: - case self.invalid: - return None - case self.missing_key: - return None - - return message["listener"] - - # Internal usage only, not for use in Public API - def get_rooms(self, client, message): - rooms = set() - if "rooms" not in message: - rooms.update(client.rooms) - else: - if type(message["rooms"]) == str: - message["rooms"] = [message["rooms"]] - rooms.update(set(message["rooms"])) - - # Filter rooms client doesn't have access to - for room in self.copy(rooms): - if room not in client.rooms: - rooms.remove(room) - return rooms - - # Disables methods. Supports disabling built-in methods for monkey-patching or for custom reimplementation. - def disable_methods(self, functions: list): - if type(functions) != list: - raise TypeError - - for function in functions: - if type(function) != str: - continue - - if function not in self.parent.disabled_methods: - self.parent.disabled_methods.add(function) - - self.parent.safe_methods.discard(function) - - # Support for loading custom methods. Automatically selects safe methods. - def load_custom_methods(self, _class): - for function in dir(_class): - # Ignore loading private methods - if "__" in function: - continue - - # Do not initialize server methods - if hasattr(self.parent, function): - continue - if hasattr(self.parent.supporter, function): - continue - - # Ignore loading commands marked as ignore - if hasattr(_class, "importer_ignore_functions"): - if function in _class.importer_ignore_functions: - continue - - setattr(self.parent.custom_methods, function, getattr(_class, function)) - self.parent.safe_methods.add(function) - - # This initializes methods that are guaranteed safe to use. This mitigates the possibility of clients accessing - # private or sensitive methods. - def init_builtin_cl_methods(self): - for function in dir(self.parent.cl_methods): - # Ignore loading private methods - if "__" in function: - continue - - # Do not initialize server methods - if hasattr(self.parent, function): - continue - if hasattr(self.parent.supporter, function): - continue - - # Ignore loading commands marked as ignore - if hasattr(self.parent.cl_methods, "importer_ignore_functions"): - if function in self.parent.cl_methods.importer_ignore_functions: - continue - - self.parent.safe_methods.add(function) - - def log(self, msg): - self.logger.info(msg) - - def log_debug(self, msg): - self.logger.debug(msg) - - def log_warning(self, msg): - self.logger.warning(msg) - - def log_critical(self, msg): - self.logger.critical(msg) - - def log_error(self, msg): - self.logger.error(msg) diff --git a/old/requirements.txt b/old/requirements.txt deleted file mode 100644 index 9f0a10d..0000000 --- a/old/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -websockets -websocket-client \ No newline at end of file diff --git a/old/server_example.py b/old/server_example.py deleted file mode 100644 index d79f10f..0000000 --- a/old/server_example.py +++ /dev/null @@ -1,108 +0,0 @@ -from cloudlink import cloudlink - - -class example_callbacks: - def __init__(self, parent): - self.parent = parent - - async def test1(self, client, message, listener): - print("Test1!") - await self.parent.asyncio.sleep(1) - print("Test1 after one second!") - - async def test2(self, client, message, listener): - print("Test2!") - await self.parent.asyncio.sleep(1) - print("Test2 after one second!") - - async def test3(self, client, message, listener): - print("Test3!") - - -class example_events: - def __init__(self): - pass - - async def on_close(self, client): - print("Client", client.id, "disconnected.") - - async def on_connect(self, client): - print("Client", client.id, "connected.") - - -class example_commands: - def __init__(self, parent): - self.parent = parent - self.supporter = parent.supporter - - # If you want to have commands with very specific formatting, use the validate() function. - self.validate = parent.validate - - # Various ways to send messages - self.send_packet_unicast = parent.send_packet_unicast - self.send_packet_multicast = parent.send_packet_multicast - self.send_packet_multicast_variable = parent.send_packet_multicast_variable - self.send_code = parent.send_code - - async def foobar(self, client, message, listener): - print("Foobar!") - - # Reading the IP address of the client is as easy as calling get_client_ip from the server object. - print(self.parent.get_client_ip(client)) - - # In case you need to report a status code, use send_code. - await self.send_code( - client=client, - code="OK", - listener=listener - ) - - -if __name__ == "__main__": - # Initialize Cloudlink. You will only need to initialize one instance of the main cloudlink module. - cl = cloudlink() - - # Create a new server object. This supports initializing many servers at once. - server = cl.server(logs=True) - - # Create examples for various ways to extend the functionality of Cloudlink Server. - callbacks = example_callbacks(server) - commands = example_commands(server) - events = example_events() - - # Set the message-of-the-day. - server.set_motd("CL4 Optimized! Gotta Go Fast!", True) - - # Here are some extra parameters you can specify to change the functionality of the server. - - # Defaults to empty list. Requires having check_ip_addresses set to True. - # server.ip_blocklist = ["127.0.0.1"] - - # Defaults to False. If True, the server will refuse all connections until False. - # server.reject_clients = False - - # Defaults to False. If True, client IP addresses will be resolved and stored until a client disconnects. - # server.check_ip_addresses = True - - # Defaults to True. If True, the server will support Scratch's cloud variable protocol. - # server.enable_scratch_support = False - - # Binding callbacks - This example binds the "handshake" command with example callbacks. - # You can bind as many functions as you want to a callback, but they must use async. - # To bind callbacks to built-in methods (example: gmsg), see cloudlink.cl_methods. - server.bind_callback(server.cl_methods.handshake, callbacks.test1) - server.bind_callback(server.cl_methods.handshake, callbacks.test2) - - # Binding events - This example will print a client connect/disconnect message. - # You can bind as many functions as you want to an event, but they must use async. - # To see all possible events for the server, see cloudlink.events. - server.bind_event(server.events.on_connect, events.on_connect) - server.bind_event(server.events.on_close, events.on_close) - - # Creating custom commands - This example adds a custom command "foobar" from example_commands - # and then binds the callback test3 to the new command. - server.load_custom_methods(commands) - server.bind_callback(commands.foobar, callbacks.test3) - - # Run the server. - server.run(ip="localhost", port=3000) From 3b758e03baef7d25b2e83a61ae2ca0b3c4ce3982 Mon Sep 17 00:00:00 2001 From: "Mike J. Renaker / \"MikeDEV" Date: Tue, 2 May 2023 12:42:11 -0400 Subject: [PATCH 52/59] Updates * Improve formatting * Rename S4-0-nosuite.js to S4-1-nosuite.js - Going to be updating extension code * Updated links * Added type hints --- S4-0-nosuite.js => S4-1-nosuite.js | 2 +- SECURITY.md | 4 ++-- cloudlink/server/modules/rooms_manager.py | 12 ++++++------ cloudlink/server/protocols/clpv4/clpv4.py | 2 +- cloudlink/server/protocols/clpv4/schema.py | 2 +- cloudlink/server/protocols/scratch/scratch.py | 3 +++ 6 files changed, 14 insertions(+), 11 deletions(-) rename S4-0-nosuite.js => S4-1-nosuite.js (99%) diff --git a/S4-0-nosuite.js b/S4-1-nosuite.js similarity index 99% rename from S4-0-nosuite.js rename to S4-1-nosuite.js index ca774b3..54b2a4c 100644 --- a/S4-0-nosuite.js +++ b/S4-1-nosuite.js @@ -110,7 +110,7 @@ class CloudLink { // Status stuff this.isRunning = false; this.isLinked = false; - this.version = "S4.0"; + this.version = "S4.1"; this.link_status = 0; this.username = ""; this.tmp_username = ""; diff --git a/SECURITY.md b/SECURITY.md index c8d1e41..abfe7ca 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,8 +4,8 @@ You are to keep your instance of Cloudlink as up-to-date as possible. You are to ## Supported Versions | Version number | Supported? | Note | |-------------------|------------|----------------------------------------------------------------------| -| 0.2.0 | 🟒 Yes | Final CL4 rewrite. | -| 0.1.9.x | 🟑 Yes | CL4 Optimized. Support will end on April 30, 2023. | +| 0.2.0 | 🟒 Yes | Latest version. | +| 0.1.9.x | 🟑 Yes | CL4 Optimized. Support will end on May 30, 2023. | | 0.1.8.x | πŸ”΄ No | Pre-CL4 optimized. EOL. | | 0.1.7.x and older | πŸ”΄ No | CL3/CL Legacy - EOL. | diff --git a/cloudlink/server/modules/rooms_manager.py b/cloudlink/server/modules/rooms_manager.py index 9bdb7ab..8d28405 100644 --- a/cloudlink/server/modules/rooms_manager.py +++ b/cloudlink/server/modules/rooms_manager.py @@ -89,7 +89,7 @@ def delete(self, room_id): # Log deletion self.parent.logger.debug(f"Deleted room {room_id}") - def exists(self, room_id): + def exists(self, room_id) -> bool: # Rooms may only have string names if type(room_id) != str: raise TypeError("Room IDs only support strings!") @@ -207,7 +207,7 @@ def find_obj(self, query, room): else: raise self.exceptions.NoResultsFound - def generate_userlist(self, room_id, protocol): + def generate_userlist(self, room_id, protocol) -> list: userlist = list() room = self.get(room_id)["clients"][protocol]["all"] @@ -224,13 +224,13 @@ def generate_userlist(self, room_id, protocol): return userlist - def get_snowflakes(self, room): + def get_snowflakes(self, room) -> set: return set(obj for obj in room["snowflakes"]) - def get_uuids(self, room): + def get_uuids(self, room) -> set: return set(obj for obj in room["uuids"]) - async def get_all_in_rooms(self, rooms, protocol): + async def get_all_in_rooms(self, rooms, protocol) -> set: obj_set = set() # Validate types @@ -251,7 +251,7 @@ async def get_all_in_rooms(self, rooms, protocol): return obj_set - async def get_specific_in_room(self, room, protocol, queries): + async def get_specific_in_room(self, room, protocol, queries) -> set: obj_set = set() # Validate types diff --git a/cloudlink/server/protocols/clpv4/clpv4.py b/cloudlink/server/protocols/clpv4/clpv4.py index 09f5086..774cae6 100644 --- a/cloudlink/server/protocols/clpv4/clpv4.py +++ b/cloudlink/server/protocols/clpv4/clpv4.py @@ -7,7 +7,7 @@ Each packet format is compliant with UPLv2 formatting rules. Documentation for the CLPv4.1 protocol can be found here: -https://hackmd.io/@MikeDEV/HJiNYwOfo +https://github.com/MikeDev101/cloudlink/wiki/The-CloudLink-Protocol """ diff --git a/cloudlink/server/protocols/clpv4/schema.py b/cloudlink/server/protocols/clpv4/schema.py index 946a93e..3592a67 100644 --- a/cloudlink/server/protocols/clpv4/schema.py +++ b/cloudlink/server/protocols/clpv4/schema.py @@ -337,4 +337,4 @@ class cl4_protocol: ], "required": False } - } \ No newline at end of file + } diff --git a/cloudlink/server/protocols/scratch/scratch.py b/cloudlink/server/protocols/scratch/scratch.py index beb2753..23394bd 100644 --- a/cloudlink/server/protocols/scratch/scratch.py +++ b/cloudlink/server/protocols/scratch/scratch.py @@ -136,6 +136,7 @@ async def rename_variable(client, message): # Guard clause - Room must exist before deleting values from it if not server.rooms_manager.exists(message["project_id"]): server.logger.warning(f"Error: room {message['project_id']} does not exist yet") + # Abort the connection server.close_connection( client, @@ -180,6 +181,7 @@ async def create_variable(client, message): # Guard clause - Room must exist before deleting values from it if not server.rooms_manager.exists(message["project_id"]): server.logger.warning(f"Error: room {message['project_id']} does not exist yet") + # Abort the connection server.close_connection( client, @@ -216,6 +218,7 @@ async def set_value(client, message): # Guard clause - Room must exist before adding to it if not server.rooms_manager.exists(message["project_id"]): server.logger.warning(f"Error: room {message['project_id']} does not exist yet") + # Abort the connection server.close_connection( client, From 76a83f68bcd0b8453fe522961c66459c36cb9636 Mon Sep 17 00:00:00 2001 From: "Mike J. Renaker / \"MikeDEV" Date: Thu, 28 Sep 2023 18:51:54 -0400 Subject: [PATCH 53/59] Update serverlist.json --- serverlist.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/serverlist.json b/serverlist.json index 34b40ea..4e857b1 100644 --- a/serverlist.json +++ b/serverlist.json @@ -4,15 +4,15 @@ "url": "ws://127.0.0.1:3000/" }, "1": { - "id": "CL4 0.2.0 Demo Server - By MikeDEV", - "url": "wss://cloudlink.ddns.net:3000/" + "id": "CL4 Demo - Hosted by Meower Media", + "url": "wss://cl4-test.meower.org/" }, "2": { - "id": "Cloudlink 4 - Suite demo server", + "id": "(Deprecated) CL4 Suite Demo", "url":"wss://Cloudlink.showierdata9971.repl.co" }, "3": { "id": "Tnix's Oceania Server", - "url": "wss://cl4.tnix.dev//" + "url": "wss://cl4.tnix.dev/" } } From 2a072604aa68d193222c719423f7248e503c7959 Mon Sep 17 00:00:00 2001 From: "Mike J. Renaker / \"MikeDEV" Date: Thu, 28 Sep 2023 19:18:11 -0400 Subject: [PATCH 54/59] Updates + Abandon CL Admin * CL Admin plugin was abandoned. * Update old documentation. * Add B3-0.js for old project support --- B3-0.js | 928 +++++++++++++++++++++++++++ README.md | 1 - SECURITY.md | 2 +- client.py | 20 +- cloudlink/client/protocol.py | 6 +- cloudlink/server/plugins/__init__.py | 1 - cloudlink/server/plugins/cl_admin.py | 191 ------ server.py | 5 +- 8 files changed, 952 insertions(+), 202 deletions(-) create mode 100644 B3-0.js delete mode 100644 cloudlink/server/plugins/__init__.py delete mode 100644 cloudlink/server/plugins/cl_admin.py diff --git a/B3-0.js b/B3-0.js new file mode 100644 index 0000000..35dc48f --- /dev/null +++ b/B3-0.js @@ -0,0 +1,928 @@ +const vers = 'B3.0'; // Suite version number +const defIP = "ws://127.0.0.1:3000/"; // Default IP address + +// CloudLink icons +const cl_icon = 'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4NCjwhLS0gR2VuZXJhdG9yOiBBZG9iZSBJbGx1c3RyYXRvciAyNS4yLjMsIFNWRyBFeHBvcnQgUGx1Zy1JbiAuIFNWRyBWZXJzaW9uOiA2LjAwIEJ1aWxkIDApICAtLT4NCjxzdmcgdmVyc2lvbj0iMS4xIiBpZD0iTGF5ZXJfMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgeD0iMHB4IiB5PSIwcHgiDQoJIHZpZXdCb3g9IjAgMCA0NSA0NSIgc3R5bGU9ImVuYWJsZS1iYWNrZ3JvdW5kOm5ldyAwIDAgNDUgNDU7IiB4bWw6c3BhY2U9InByZXNlcnZlIj4NCjxzdHlsZSB0eXBlPSJ0ZXh0L2NzcyI+DQoJLnN0MHtmaWxsOiMwRkJEOEM7fQ0KCS5zdDF7ZmlsbDpub25lO3N0cm9rZTojRkZGRkZGO3N0cm9rZS13aWR0aDo0O3N0cm9rZS1saW5lY2FwOnJvdW5kO3N0cm9rZS1saW5lam9pbjpyb3VuZDtzdHJva2UtbWl0ZXJsaW1pdDoxMDt9DQo8L3N0eWxlPg0KPGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTIxNy41MDAxNCwtMTU3LjUwMDEzKSI+DQoJPGc+DQoJCTxwYXRoIGNsYXNzPSJzdDAiIGQ9Ik0yMTcuNSwxODBjMC0xMi40LDEwLjEtMjIuNSwyMi41LTIyLjVzMjIuNSwxMC4xLDIyLjUsMjIuNXMtMTAuMSwyMi41LTIyLjUsMjIuNVMyMTcuNSwxOTIuNCwyMTcuNSwxODANCgkJCUwyMTcuNSwxODB6Ii8+DQoJCTxnPg0KCQkJPHBhdGggY2xhc3M9InN0MSIgZD0iTTIzMC4zLDE4MC4xYzUuNy00LjcsMTMuOS00LjcsMTkuNiwwIi8+DQoJCQk8cGF0aCBjbGFzcz0ic3QxIiBkPSJNMjI1LjMsMTc1LjFjOC40LTcuNCwyMS03LjQsMjkuNCwwIi8+DQoJCQk8cGF0aCBjbGFzcz0ic3QxIiBkPSJNMjM1LjIsMTg1YzIuOS0yLjEsNi44LTIuMSw5LjcsMCIvPg0KCQkJPHBhdGggY2xhc3M9InN0MSIgZD0iTTI0MCwxOTAuNEwyNDAsMTkwLjQiLz4NCgkJPC9nPg0KCTwvZz4NCjwvZz4NCjwvc3ZnPg0K'; +const cl_block = 'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4NCjwhLS0gR2VuZXJhdG9yOiBBZG9iZSBJbGx1c3RyYXRvciAyNS4yLjMsIFNWRyBFeHBvcnQgUGx1Zy1JbiAuIFNWRyBWZXJzaW9uOiA2LjAwIEJ1aWxkIDApICAtLT4NCjxzdmcgdmVyc2lvbj0iMS4xIiBpZD0iTGF5ZXJfMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgeD0iMHB4IiB5PSIwcHgiDQoJIHZpZXdCb3g9IjAgMCA0NSA0NSIgc3R5bGU9ImVuYWJsZS1iYWNrZ3JvdW5kOm5ldyAwIDAgNDUgNDU7IiB4bWw6c3BhY2U9InByZXNlcnZlIj4NCjxzdHlsZSB0eXBlPSJ0ZXh0L2NzcyI+DQoJLnN0MHtmaWxsOm5vbmU7c3Ryb2tlOiNGRkZGRkY7c3Ryb2tlLXdpZHRoOjQ7c3Ryb2tlLWxpbmVjYXA6cm91bmQ7c3Ryb2tlLWxpbmVqb2luOnJvdW5kO3N0cm9rZS1taXRlcmxpbWl0OjEwO30NCjwvc3R5bGU+DQo8Zz4NCgk8cGF0aCBjbGFzcz0ic3QwIiBkPSJNMTIuOCwyMi42YzUuNy00LjcsMTMuOS00LjcsMTkuNiwwIi8+DQoJPHBhdGggY2xhc3M9InN0MCIgZD0iTTcuOCwxNy42YzguNC03LjQsMjEtNy40LDI5LjQsMCIvPg0KCTxwYXRoIGNsYXNzPSJzdDAiIGQ9Ik0xNy43LDI3LjVjMi45LTIuMSw2LjgtMi4xLDkuNywwIi8+DQoJPHBhdGggY2xhc3M9InN0MCIgZD0iTTIyLjUsMzIuOUwyMi41LDMyLjkiLz4NCjwvZz4NCjwvc3ZnPg0K'; + +// Booleans for signifying an update to the global or private data streams, as well as the disk and coin data. +var gotNewGlobalData = false; +var gotNewPrivateData = false; + +// Variables storing global and private stream data transmitted from the server. +var sGData = ""; +var sPData = ""; + +// System variables needed for basic functionality +var sys_status = 0; // System status reporter, 0 = Ready, 1 = Connecting, 2 = Connected, 3 = Disconnected OK, 4 = Disconnected ERR +var userNames = ""; // Usernames list +var uList = ""; +var myName = ""; // Username reporter +var servIP = defIP; // Default server IP +var isRunning = false; // Boolean for determining if the connection is alive and well +var wss = null; // Websocket object that enables communications +var serverVersion = ''; // Diagnostics, gets the server's value for 'vers'. +var globalVars = {}; // Custom globally-readable variables. +var privateVars = {}; // Custom private variables. +var gotNewGlobalVarData = {}; // Booleans for checking if a new value has been written to a global var. +var gotNewPrivateVarData = {}; // Booleans for checking if a new value has been written to a private var. + +var motd = ""; // Message-of-the-day +var serverlist = ['']; // Server list +var serverips = ['']; // Server IP list +var servers = ""; // another server list + +var statusCode = ""; // Server status code +var gotNewStatusCode = false; // Bool to check if new status code has been written to the status code var. + +var directData = ""; // Direct data +var gotNewDirectData = false; // Bool to check if new direct data has been written to the direct data var. + +var clientip = ""; // Client IP address tracer for CloudLink Trusted Access & IP blocking + +// Get the client's IP address, requirement for Trusted Access to work correctly, https://api.ipify.org/ or https://api.meower.org/ip +var ipfetcherurl = "https://api.ipify.org/"; + +// Get the server URL list +try { + fetch('https://mikedev101.github.io/cloudlink/serverlist.json').then(response => { + return response.text(); + }).then(data => { + servers = data; + serverips = []; + serverlist = []; + dataloads = JSON.parse(data) + for (let i in dataloads) { + serverips.push(String(dataloads[i]['url'])); + serverlist.push(String(i)); + }; + }).catch(err => { + console.log(err); + serverlist = ['Error!']; + serverips = ['']; + }); +} catch(err) { + console.log(err); + serverlist = ['Error!']; + serverips = ['']; + servers = "Error!"; +}; + +// CloudLink class for the primary extension. +class cloudlink { + constructor(runtime, extensionId) { + this.runtime = runtime; + } + static get STATE_KEY() { + return 'Scratch.websockets'; + } + getInfo() { + return { + id: 'cloudlink', + name: 'CloudLink', + blockIconURI: cl_block, + menuIconURI: cl_icon, + blocks: [ + { + opcode: 'returnGlobalData', + blockType: Scratch.BlockType.REPORTER, + text: 'Global data', + }, { + opcode: 'returnPrivateData', + blockType: Scratch.BlockType.REPORTER, + text: 'Private data', + }, { + opcode: 'returnDirectData', + blockType: Scratch.BlockType.REPORTER, + text: 'Direct Data', + }, { + opcode: 'returnLinkData', + blockType: Scratch.BlockType.REPORTER, + text: 'Link Status', + }, { + opcode: 'returnStatusCode', + blockType: Scratch.BlockType.REPORTER, + text: 'Status Code', + }, { + opcode: 'returnUserListData', + blockType: Scratch.BlockType.REPORTER, + text: 'Usernames', + }, { + opcode: 'returnUsernameData', + blockType: Scratch.BlockType.REPORTER, + text: 'My Username', + }, { + opcode: 'returnVersionData', + blockType: Scratch.BlockType.REPORTER, + text: 'Extension Version', + }, { + opcode: 'returnServerVersion', + blockType: Scratch.BlockType.REPORTER, + text: 'Server Version', + }, { + opcode: 'returnServerList', + blockType: Scratch.BlockType.REPORTER, + text: 'Server List', + }, { + opcode: 'returnMOTD', + blockType: Scratch.BlockType.REPORTER, + text: 'Server MOTD', + }, { + opcode: 'returnClientIP', + blockType: Scratch.BlockType.REPORTER, + text: 'My IP Address', + }, { + opcode: 'returnVarData', + blockType: Scratch.BlockType.REPORTER, + text: '[TYPE] var [VAR] data', + arguments: { + VAR: { + type: Scratch.ArgumentType.STRING, + defaultValue: 'Apple', + }, + TYPE: { + type: Scratch.ArgumentType.STRING, + menu: 'varmenu', + defaultValue: 'Global', + }, + }, + }, { + opcode: 'fetchURL', + blockType: Scratch.BlockType.REPORTER, + text: 'Fetch data from URL [url]', + arguments: { + url: { + type: Scratch.ArgumentType.STRING, + defaultValue: 'https://mikedev101.github.io/cloudlink/fetch_test' + } + } + }, { + opcode: 'parseJSON', + blockType: Scratch.BlockType.REPORTER, + text: '[PATH] of [JSON_STRING]', + arguments: { + PATH: { + type: Scratch.ArgumentType.STRING, + defaultValue: 'fruit/apples', + }, + JSON_STRING: { + type: Scratch.ArgumentType.STRING, + defaultValue: '{"fruit": {"apples": 2, "bananas": 3}, "total_fruit": 5}', + }, + }, + }, { + opcode: 'getComState', + blockType: Scratch.BlockType.BOOLEAN, + text: 'Connected?', + }, { + opcode: 'getUsernameState', + blockType: Scratch.BlockType.BOOLEAN, + text: 'Username synced?', + }, { + opcode: 'returnIsNewData', + blockType: Scratch.BlockType.BOOLEAN, + text: 'Got New [TYPE] Data?', + arguments: { + TYPE: { + type: Scratch.ArgumentType.STRING, + menu: 'datamenu', + defaultValue: 'Global', + }, + }, + }, { + opcode: 'returnIsNewVarData', + blockType: Scratch.BlockType.BOOLEAN, + text: 'Got New [TYPE] Var [VAR] Data?', + arguments: { + VAR: { + type: Scratch.ArgumentType.STRING, + defaultValue: 'Apple', + }, + TYPE: { + type: Scratch.ArgumentType.STRING, + menu: 'varmenu', + defaultValue: 'Global', + }, + }, + }, { + opcode: 'checkForID', + blockType: Scratch.BlockType.BOOLEAN, + text: 'ID [ID] Connected?', + arguments: { + ID: { + type: Scratch.ArgumentType.STRING, + defaultValue: 'Another name', + }, + }, + }, { + opcode: 'changeIPFetcher', + blockType: Scratch.BlockType.COMMAND, + text: 'Get IP address using [url] fetcher', + arguments: { + url: { + type: Scratch.ArgumentType.STRING, + menu: 'ipfetchers', + defaultValue: 'Default' + } + } + }, { + opcode: 'openSocket', + blockType: Scratch.BlockType.COMMAND, + text: 'Connect to [IP]', + arguments: { + IP: { + type: Scratch.ArgumentType.STRING, + defaultValue: defIP, + }, + }, + }, { + opcode: 'openSocketPublicServers', + blockType: Scratch.BlockType.COMMAND, + text: 'Connect to Server [ID]', + arguments: { + ID: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: '', + }, + }, + }, { + opcode: 'closeSocket', + blockType: Scratch.BlockType.COMMAND, + text: 'Disconnect', + }, { + opcode: 'setMyName', + blockType: Scratch.BlockType.COMMAND, + text: 'Set [NAME] as username', + arguments: { + NAME: { + type: Scratch.ArgumentType.STRING, + defaultValue: 'A name', + }, + }, + }, { + opcode: 'sendGData', + blockType: Scratch.BlockType.COMMAND, + text: 'Send [DATA]', + arguments: { + DATA: { + type: Scratch.ArgumentType.STRING, + defaultValue: 'Apple', + }, + }, + }, { + opcode: 'sendPData', + blockType: Scratch.BlockType.COMMAND, + text: 'Send [DATA] to [ID]', + arguments: { + DATA: { + type: Scratch.ArgumentType.STRING, + defaultValue: 'Apple', + }, + ID: { + type: Scratch.ArgumentType.STRING, + defaultValue: 'A name', + }, + }, + }, { + opcode: 'sendGDataAsVar', + blockType: Scratch.BlockType.COMMAND, + text: 'Send Var [VAR] with Data [DATA]', + arguments: { + DATA: { + type: Scratch.ArgumentType.STRING, + defaultValue: 'Banana', + }, + VAR: { + type: Scratch.ArgumentType.STRING, + defaultValue: 'Apple', + }, + }, + }, { + opcode: 'sendPDataAsVar', + blockType: Scratch.BlockType.COMMAND, + text: 'Send Var [VAR] to [ID] with Data [DATA]', + arguments: { + DATA: { + type: Scratch.ArgumentType.STRING, + defaultValue: 'Banana', + }, + ID: { + type: Scratch.ArgumentType.STRING, + defaultValue: 'A name', + }, + VAR: { + type: Scratch.ArgumentType.STRING, + defaultValue: 'Apple', + }, + }, + }, { + opcode: 'resetNewData', + blockType: Scratch.BlockType.COMMAND, + text: 'Reset Got New [TYPE] Data', + arguments: { + TYPE: { + type: Scratch.ArgumentType.STRING, + menu: 'datamenu', + defaultValue: 'Global', + }, + }, + }, { + opcode: 'resetNewVarData', + blockType: Scratch.BlockType.COMMAND, + text: 'Reset Got New [TYPE] Var [VAR] Data', + arguments: { + TYPE: { + type: Scratch.ArgumentType.STRING, + menu: 'varmenu', + defaultValue: 'Global', + }, + VAR: { + type: Scratch.ArgumentType.STRING, + defaultValue: 'Apple', + }, + }, + }, { + opcode: 'runCMD', + blockType: Scratch.BlockType.COMMAND, + text: 'Send command [CMD] [ID] [DATA]', + arguments: { + CMD: { + type: Scratch.ArgumentType.STRING, + defaultValue: 'cmd', + }, + ID: { + type: Scratch.ArgumentType.STRING, + defaultValue: 'id', + }, + DATA: { + type: Scratch.ArgumentType.STRING, + defaultValue: 'val', + }, + }, + }, + ], + menus: { + coms: { + items: ["Connected", "Username Synced"] + }, + datamenu: { + items: ['Global', 'Private', 'Direct', 'Status Code',], + }, + varmenu: { + items: ['Global', 'Private'], + }, + ipfetchers: { + items: ['Default', 'Meower'], + }, + } + }; + }; + returnClientIP() { + return clientip; + }; + changeIPFetcher(args) { + if (args.url == "Default") { + ipfetcherurl = "https://api.ipify.org/"; + } else if (args.url == "Meower") { + ipfetcherurl = "https://api.meower.org/ip"; + }; + console.log("Getting client's IP address from " + String(ipfetcherurl)); + try { + fetch(ipfetcherurl).then(response => { + return response.text(); + }).then(data => { + console.log("Client's IP address: " + String(data)); + clientip = data; + }).catch(err => { + console.log("Error while getting client's IP address: " + String(err)); + clientip = ""; + }); + } catch(err) { + console.log(err); + }; + }; + returnDirectData() { + return directData; + } + returnStatusCode() { + return statusCode; + } + fetchURL(args) { + return fetch(args.url).then(response => response.text()) + } + openSocket(args) { + servIP = args.IP; // Begin the main updater scripts + if (!isRunning) { + sys_status = 1; + console.log("Establishing connection"); + try { + wss = new WebSocket(servIP); + wss.onopen = function(e) { + isRunning = true; + sys_status = 2; // Connected OK value + console.log("Connected"); + wss.send(JSON.stringify({"cmd": "direct", "val": {"cmd": "type", "val": "scratch"}})); // Tell the server that the client is Scratch, which needs stringified nested JSON + wss.send(JSON.stringify({"cmd": "direct", "val": {"cmd": "ip", "val": String(clientip)}})); // Tell the server the client's IP address + }; + wss.onmessage = function(event) { + var rawpacket = String(event.data); + var obj = JSON.parse(rawpacket); + + // console.log("Got new packet"); + console.log(obj); + + // Global Messages + if (obj["cmd"] == "gmsg") { + sGData = String(obj["val"]); + gotNewGlobalData = true; + }; + // Private Messages + if (obj["cmd"] == "pmsg") { + sPData = String(obj["val"]); + gotNewPrivateData = true; + }; + // Username List + if (obj["cmd"] == "ulist") { + userNames = String(obj["val"]); + userNames = userNames.split(";"); + userNames.pop(); + var uListTemp = ""; + var i; + for (i = 0; i < userNames.length; i++) { + if (!userNames[i].includes("%")) { + uListTemp = (String(uListTemp) + String(userNames[i])+ "; "); + }; + }; + uList = uListTemp; + }; + // Direct COMS + if (obj["cmd"] == "direct") { + var ddata = obj['val']; + if (ddata['cmd'] == "vers") { + serverVersion = ddata["val"]; + console.log("Server version: " + String(serverVersion)); + } else if (ddata['cmd'] == "motd") { + motd = ddata["val"]; + console.log("Server Message-of-the-day: " + String(motd)); + } else { + directData = obj["val"]; + gotNewDirectData = true; + }; + }; + // Global Variables + if (obj["cmd"] == "gvar") { + globalVars[obj["name"]] = obj["val"]; + gotNewGlobalVarData[obj["name"]] = true; + }; + // Private Variables + if (obj["cmd"] == "pvar") { + privateVars[obj["name"]] = obj["val"]; + gotNewPrivateVarData[obj["name"]] = true; + }; + // Status code + if (obj["cmd"] == "statuscode") { + statusCode = obj["val"]; + gotNewStatusCode = true; + }; + }; + wss.onclose = function(event) { + isRunning = false; + myName = ""; + motd = ""; + gotNewGlobalData = false; + gotNewPrivateData = false; + userNames = ""; + sGData = ""; + sPData = ""; + sys_status = 3; // Disconnected OK value + serverVersion = ''; + globalVars = {}; + privateVars = {}; + gotNewGlobalVarData = {}; + gotNewPrivateVarData = {}; + uList = ""; + wss = null; + statusCode = ""; + gotNewStatusCode = false; + directData = ""; + gotNewDirectData = false; + console.log("Disconnected"); + }; + } catch(err) { + throw(err) + sys_status = 3; + }; + }; + }; // end the updater scripts + closeSocket() { + if (isRunning) { + wss.close(1000); + isRunning = false; + myName = ""; + gotNewGlobalData = false; + gotNewPrivateData = false; + userNames = ""; + motd = ""; + sGData = ""; + sPData = ""; + sys_status = 3; // Disconnected OK value + serverVersion = ''; + globalVars = {}; + privateVars = {}; + gotNewGlobalVarData = {}; + gotNewPrivateVarData = {}; + uList = ""; + statusCode = ""; + gotNewStatusCode = false; + wss = null; + }; + }; + openSocketPublicServers(args) { + servIP = serverips[String(args.ID)-1]; + clientip = ""; + console.log(serverlist); + if ((servIP == "-1") || !(serverlist.includes(String(args.ID)))) { + console.log("Blocking attempt to connect to a nonexistent server #") + } + else if (!isRunning) { + sys_status = 1; + try { + wss = new WebSocket(servIP); + wss.onopen = function(e) { + isRunning = true; + sys_status = 2; // Connected OK value + console.log("Connected"); + wss.send(JSON.stringify({"cmd": "direct", "val": {"cmd": "type", "val": "scratch"}}),masked=true); // Tell the server that the client is Scratch, which needs stringified nested JSON + wss.send(JSON.stringify({"cmd": "direct", "val": {"cmd": "ip", "val": String(clientip)}}),masked=true); // Tell the server the client's IP address + }; + wss.onmessage = function(event) { + var rawpacket = String(event.data); + var obj = JSON.parse(rawpacket); + + // console.log("Got new packet"); + console.log(obj); + + // Global Messages + if (obj["cmd"] == "gmsg") { + sGData = String(obj["val"]); + gotNewGlobalData = true; + }; + // Private Messages + if (obj["cmd"] == "pmsg") { + sPData = String(obj["val"]); + gotNewPrivateData = true; + }; + // Username List + if (obj["cmd"] == "ulist") { + userNames = String(obj["val"]); + userNames = userNames.split(";"); + userNames.pop(); + var uListTemp = ""; + var i; + for (i = 0; i < userNames.length; i++) { + if (!userNames[i].includes("%")) { + uListTemp = (String(uListTemp) + String(userNames[i])+ "; "); + }; + }; + uList = uListTemp; + }; + // Direct COMS + if (obj["cmd"] == "direct") { + var ddata = obj['val']; + if (ddata['cmd'] == "vers") { + serverVersion = ddata["val"]; + console.log("Server version: " + String(serverVersion)); + } else if (ddata['cmd'] == "motd") { + motd = ddata["val"]; + console.log("Server Message-of-the-day: " + String(motd)); + } else { + directData = obj["val"]; + gotNewDirectData = true; + }; + }; + // Global Variables + if (obj["cmd"] == "gvar") { + globalVars[obj["name"]] = obj["val"]; + gotNewGlobalVarData[obj["name"]] = true; + }; + // Private Variables + if (obj["cmd"] == "pvar") { + privateVars[obj["name"]] = obj["val"]; + gotNewPrivateVarData[obj["name"]] = true; + }; + // Status code + if (obj["cmd"] == "statuscode") { + statusCode = obj["val"]; + gotNewStatusCode = true; + }; + }; + wss.onclose = function(event) { + isRunning = false; + myName = ""; + gotNewGlobalData = false; + gotNewPrivateData = false; + userNames = ""; + sGData = ""; + sPData = ""; + sys_status = 3; // Disconnected OK value + serverVersion = ''; + globalVars = {}; + privateVars = {}; + gotNewGlobalVarData = {}; + gotNewPrivateVarData = {}; + uList = ""; + wss = null; + statusCode = ""; + gotNewStatusCode = false; + directData = ""; + gotNewDirectData = false; + console.log("Disconnected"); + }; + } catch(err) { + throw(err) + sys_status = 3; + }; + }; + } + returnMOTD() { + return motd; + }; + getComState() { + return isRunning; + }; + checkForID(args) { + if (isRunning) { + return (userNames.indexOf(String(args.ID)) >= 0); + } else { + return false; + }; + }; + getUsernameState() { + if (isRunning) { + if (String(myName) != '') { + return (userNames.indexOf(String(myName)) >= 0); + } else { + return false; + } + } else { + return false; + }; + }; + runCMD(args) { + if (isRunning) { + if (!(String(args.DATA).length > 1000)) { + wss.send(JSON.stringify({ + cmd: args.CMD, + id: args.ID, + val: args.DATA + }),masked=true); + } else { + console.log("Blocking attempt to send packet larger than 1000 bytes (1 KB), packet is " + String(String(args.DATA).length) + " bytes"); + }; + }; + }; + sendGData(args) { + if (isRunning) { + if (!(String(args.DATA).length > 1000)) { + wss.send(JSON.stringify({ + cmd: "gmsg", + val: args.DATA + }),masked=true); + } else { + console.log("Blocking attempt to send packet larger than 1000 bytes (1 KB), packet is " + String(String(args.DATA).length) + " bytes"); + }; + }; + }; + sendPData(args) { + if (isRunning) { + if (String(myName) != "") { + if (userNames.indexOf(String(args.ID)) >= 0) { + if (!(String(args.DATA).length > 1000)) { + wss.send(JSON.stringify({ + cmd: "pmsg", + id: args.ID, + val: args.DATA, + origin: String(myName) + }),masked=true); + } else { + console.log("Blocking attempt to send packet larger than 1000 bytes (1 KB), packet is " + String(String(args.DATA).length) + " bytes"); + }; + } else { + console.log("Blocking attempt to send private packet to nonexistent ID"); + }; + }; + }; + }; + sendGDataAsVar(args) { + if (isRunning) { + if (!(String(args.DATA).length > 1000)) { + wss.send(JSON.stringify({ + cmd: "gvar", + name: args.VAR, + val: args.DATA + }),masked=true); + } else { + console.log("Blocking attempt to send packet larger than 1000 bytes (1 KB), packet is " + String(String(args.DATA).length) + " bytes"); + }; + }; + }; + sendPDataAsVar(args) { + if (isRunning) { + if (String(myName) != "") { + if (userNames.indexOf(String(args.ID)) >= 0) { + if (!(String(args.DATA).length > 1000)) { + wss.send(JSON.stringify({ + cmd: "pvar", + name: args.VAR, + id: args.ID, + val: args.DATA, + origin: String(myName) + }),masked=true); + } else { + console.log("Blocking attempt to send packet larger than 1000 bytes (1 KB), packet is " + String(String(args.DATA).length) + " bytes"); + }; + } else { + console.log("Blocking attempt to send private variable packet to nonexistent ID"); + }; + }; + }; + }; + returnGlobalData() { + return sGData; + }; + returnPrivateData() { + return sPData; + }; + returnGlobalLinkedData() { + return sGLinkedData; + }; + returnPrivateLinkedData() { + return sPLinkedData; + }; + returnVarData(args) { + if (args.TYPE == "Global") { + if (args.VAR in globalVars) { + return globalVars[args.VAR]; + } else { + return ""; + } + } else if (args.TYPE == "Private") { + if (args.VAR in privateVars) { + return privateVars[args.VAR]; + } else { + return ""; + } + } + }; + returnIsNewVarData(args) { + if (args.TYPE == "Global") { + if (args.VAR in globalVars) { + return gotNewGlobalVarData[args.VAR]; + } else { + return false; + } + } else if (args.TYPE == "Private") { + if (args.VAR in privateVars) { + return gotNewPrivateVarData[args.VAR]; + } else { + return false; + } + }; + }; + returnLinkData() { + return sys_status; + }; + returnUserListData() { + return uList; + }; + returnUsernameData() { + return myName; + }; + returnVersionData() { + return vers; + }; + returnServerList() { + return servers; + }; + returnServerVersion() { + return serverVersion; + }; + returnIsNewData(args) { + if (args.TYPE == "Global") { + return gotNewGlobalData; + }; + if (args.TYPE == "Private") { + return gotNewPrivateData; + }; + if (args.TYPE == "Global (Linked)") { + return gotNewGlobalLinkedData; + }; + if (args.TYPE == "Private (Linked)") { + return gotNewPrivateLinkedData; + }; + if (args.TYPE == "Direct") { + return gotNewDirectData; + }; + if (args.TYPE == "Status Code") { + return gotNewStatusCode; + }; + } + setMyName(args) { + if (isRunning) { + if (myName == ""){ + if (String(args.NAME) != "") { + if (!(userNames.indexOf(args.NAME) >= 0)) { + if ((!(String(args.NAME).length > 20))) { + if (!(args.NAME == "%CA%" || args.NAME == "%CC%" || args.NAME == "%CD%" || args.NAME == "%MS%")){ + wss.send(JSON.stringify({ + cmd: "setid", + val: String(args.NAME) + }),masked=true); + myName = args.NAME; + } else { + console.log("Blocking attempt to use reserved usernames"); + }; + } else { + console.log("Blocking attempt to use username larger than 20 characters, username is " + String(String(args.NAME).length) + " characters long"); + }; + } else { + console.log("Blocking attempt to use duplicate username"); + }; + } else { + console.log("Blocking attempt to use blank username"); + }; + } else { + console.log("Username already has been set"); + }; + }; + }; + parseJSON({ + PATH, + JSON_STRING + }) { + try { + const path = PATH.toString().split('/').map(prop => decodeURIComponent(prop)); + if (path[0] === '') path.splice(0, 1); + if (path[path.length - 1] === '') path.splice(-1, 1); + let json; + try { + json = JSON.parse(' ' + JSON_STRING); + } catch (e) { + return e.message; + } + path.forEach(prop => json = json[prop]); + if (json === null) return 'null'; + else if (json === undefined) return ''; + else if (typeof json === 'object') return JSON.stringify(json); + else return json.toString(); + } catch (err) { + return ''; + } + }; + resetNewData(args) { + if (args.TYPE == "Global") { + if (gotNewGlobalData == true) { + gotNewGlobalData = false; + }; + }; + if (args.TYPE == "Private") { + if (gotNewPrivateData == true) { + gotNewPrivateData = false; + }; + }; + if (args.TYPE == "Global (Linked)") { + if (gotNewGlobalLinkedData == true) { + gotNewGlobalLinkedData = false; + }; + }; + if (args.TYPE == "Private (Linked)") { + if (gotNewPrivateLinkedData == true) { + gotNewPrivateLinkedData = false; + }; + }; + if (args.TYPE == "Direct") { + if (gotNewDirectData == true) { + gotNewDirectData = false; + }; + }; + if (args.TYPE == "Status Code") { + if (gotNewStatusCode == true) { + gotNewStatusCode = false; + }; + }; + }; + resetNewVarData(args) { + if (args.TYPE == "Global") { + if (args.VAR in globalVars) { + gotNewGlobalVarData[args.VAR] = false; + } + } else if (args.TYPE == "Private") { + if (args.VAR in privateVars) { + gotNewPrivateVarData[args.VAR] = false; + } + } + }; +}; + +Scratch.extensions.register(new cloudlink()); + diff --git a/README.md b/README.md index 8bbe104..9850bd1 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,6 @@ CloudLink can run on minimal resources. At least 25MB of RAM and any reasonably * Unicast and multicast packets across clients * Expandable functionality with a built-in method loader * Support for bridging servers -* Admin functionality for management ### πŸ“¦ Minimal dependencies All dependencies below can be installed using `pip install -r requirements.txt`. diff --git a/SECURITY.md b/SECURITY.md index abfe7ca..beca3e6 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -5,7 +5,7 @@ You are to keep your instance of Cloudlink as up-to-date as possible. You are to | Version number | Supported? | Note | |-------------------|------------|----------------------------------------------------------------------| | 0.2.0 | 🟒 Yes | Latest version. | -| 0.1.9.x | 🟑 Yes | CL4 Optimized. Support will end on May 30, 2023. | +| 0.1.9.x | πŸ”΄ No | CL4 Optimized. EOL. | | 0.1.8.x | πŸ”΄ No | Pre-CL4 optimized. EOL. | | 0.1.7.x and older | πŸ”΄ No | CL3/CL Legacy - EOL. | diff --git a/client.py b/client.py index 3919025..793b131 100644 --- a/client.py +++ b/client.py @@ -12,10 +12,24 @@ # Example of events @client.on_connect async def on_connect(): - print("Connected") - await client.asyncio.sleep(1) - print("Going away") + print("Connected, press CTRL+C to disconnect.") + + # Wait for CTRL+C + try: + while True: pass + except KeyboardInterrupt: + print("Going away in a second") + await client.asyncio.sleep(1) + client.disconnect() + # Example use of decorator functions within the server. + @client.on_command(cmd="gmsg") + async def on_gmsg(message): + await client.send_packet({"cmd": "direct", "val": "Hello, server!"}) + + # Enable SSL support (if you use self-generated SSL certificates) + #client.enable_ssl(certfile="cert.pem") + # Start the client client.run(host="ws://127.0.0.1:3000/") diff --git a/cloudlink/client/protocol.py b/cloudlink/client/protocol.py index dbce96e..408fe33 100644 --- a/cloudlink/client/protocol.py +++ b/cloudlink/client/protocol.py @@ -65,6 +65,10 @@ async def on_initial_connect(): # Send the handshake request with a listener and wait for a response response = await parent.send_packet_and_wait({ "cmd": "handshake", + "val": { + "language": "Python", + "version": parent.version + }, "listener": "init_handshake" }) @@ -79,7 +83,7 @@ async def on_initial_connect(): else: # Log the connection error - parent.logger.error(f"Failed to connect to the server. Got response code: {message['code']}") + parent.logger.error(f"Failed to connect to the server. Got response code: {response['code']}") # Disconnect parent.asyncio.create_task( diff --git a/cloudlink/server/plugins/__init__.py b/cloudlink/server/plugins/__init__.py deleted file mode 100644 index b173a71..0000000 --- a/cloudlink/server/plugins/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .cl_admin import * diff --git a/cloudlink/server/plugins/cl_admin.py b/cloudlink/server/plugins/cl_admin.py deleted file mode 100644 index 17f2eec..0000000 --- a/cloudlink/server/plugins/cl_admin.py +++ /dev/null @@ -1,191 +0,0 @@ -# Disable CL commands -#server.disable_command(cmd=command, schema=clpv4.schema) - -# Enable CL commands -#server.enable_command(cmd=command, schema=clpv4.schema) - -# Set maximum number of users -#server.max_clients = -1 # -1 for unlimited - -class cl_admin: - def __init__(self, server, clpv4): - server.logging.info("Initializing CL Admin Extension...") - - # Example config - #TODO: make this secure - self.admin_users = { - "admin": { - "password": "cloudlink", - "permissions": { - "disable_commands": True, - "enable_commands": True, - "close_connections": True, - "change_user_limit": True, - "view_rooms": True, - "create_rooms": True, - "modify_rooms": True, - "delete_rooms": True, - "view_client_data": True, - "modify_client_data": True, - "delete_client_data": True, - "view_client_ip": True, - "add_admin_users": True, - "modify_admin_users": True, - "delete_admin_users": True - }, - "enforce_ip_whitelist": True, - "ip_whitelist": [ - "127.0.0.1" - ] - } - } - - # Extend the clpv4 protocol's schemas - clpv4.schema.admin_auth = { - "cmd": { - "type": "string", - "required": True - }, - "val": { - "type": "dict", - "required": True, - "schema": { - "username": {"type": "string", "required": True}, - "password": {"type": "string", "required": True} - } - } - } - - # Extend the clpv4 protocol's statuscodes - clpv4.statuscodes.admin_auth_ok = (clpv4.statuscodes.info, 200, "Admin authentication successful") - clpv4.statuscodes.invalid_admin_login = (clpv4.statuscodes.error, 201, "Invalid admin login") - clpv4.statuscodes.admin_auth_failure = (clpv4.statuscodes.error, 202, "Admin authentication failure") - clpv4.statuscodes.admin_session_exists = (clpv4.statuscodes.info, 203, "Admin session already exists") - clpv4.statuscodes.ip_not_whitelisted = (clpv4.statuscodes.info, 204, "IP address not whitelisted for admin authentication") - - # Add new commands to the protocol - @server.on_command(cmd="admin_auth", schema=clpv4.schema) - async def admin_auth(client, message): - # Validate message schema - if not clpv4.valid(client, message, clpv4.schema.admin_auth): - return - - # Check if the client is not already authenticated as an admin - if hasattr(client, "admin_user"): - clpv4.send_statuscode( - client, - clpv4.statuscodes.admin_session_exists, - details=f"You are currently authenticated as user {client.admin_user}. To logout, simply close the websocket connection." - ) - return - - # Get inputs - username = message["val"]["username"] - password = message["val"]["password"] - server.logging.info(f"Client {client.snowflake} attempting to admin authenticate as {username}...") - - # Verify username - if username not in self.admin_users: - clpv4.send_statuscode( - client, - clpv4.statuscodes.invalid_admin_login, - details=f"There is no registered admin user {username}. Please try again." - ) - return - - # Check if IP whitelist is being enforced - if self.admin_users[username]["enforce_ip_whitelist"] and (clpv4.get_client_ip(client) not in self.admin_users[username]["ip_whitelist"]): - clpv4.send_statuscode( - client, - clpv4.statuscodes.ip_not_whitelisted, - details=f"The user {username} has enabled IP whitelist enforcement. This IP address, {clpv4.get_client_ip(client)} is not whitelisted." - ) - return - - # Verify password - if password != self.admin_users[username]["password"]: - clpv4.send_statuscode( - client, - clpv4.statuscodes.admin_auth_failure, - details=f"Invalid password for admin user {username}. Please try again." - ) - return - - # Admin is authenticated - client.admin_user = username - clpv4.send_statuscode(client, clpv4.statuscodes.admin_auth_ok) - - #TODO: Add admin command for disabling commands - @server.on_command(cmd="admin_disable_command", schema=clpv4.schema) - async def disable_command(client, message): - pass - - # TODO: Add admin command for enabling commands - @server.on_command(cmd="admin_enable_command", schema=clpv4.schema) - async def enable_command(client, message): - pass - - # TODO: Add admin command for kicking clients - @server.on_command(cmd="admin_close_connection", schema=clpv4.schema) - async def close_connection(client, message): - pass - - # TODO: Add admin command for changing maximum number of users connecting - @server.on_command(cmd="admin_change_user_limit", schema=clpv4.schema) - async def change_user_limit(client, message): - pass - - # TODO: Add admin command for getting room state - @server.on_command(cmd="admin_get_room", schema=clpv4.schema) - async def get_room(client, message): - pass - - # TODO: Add admin command for creating rooms - @server.on_command(cmd="admin_create_room", schema=clpv4.schema) - async def create_room(client, message): - pass - - # TODO: Add admin command for modifying room state - @server.on_command(cmd="admin_edit_room", schema=clpv4.schema) - async def edit_room(client, message): - pass - - # TODO: Add admin command for deleting rooms - @server.on_command(cmd="admin_delete_room", schema=clpv4.schema) - async def delete_room(client, message): - pass - - # TODO: Add admin command for reading attributes from a client object - @server.on_command(cmd="admin_get_client_data", schema=clpv4.schema) - async def get_client_data(client, message): - pass - - # TODO: Add admin command for modifying attributes from a client object - @server.on_command(cmd="admin_edit_client_data", schema=clpv4.schema) - async def edit_client_data(client, message): - pass - - # TODO: Add admin command for deleting attributes from a client object - @server.on_command(cmd="admin_delete_client_data", schema=clpv4.schema) - async def get_room(client, message): - pass - - # TODO: Add admin command for retrieving ip address of a client object - @server.on_command(cmd="admin_get_client_ip", schema=clpv4.schema) - async def get_client_ip(client, message): - pass - - # TODO: Add admin command for creating admin users - @server.on_command(cmd="admin_add_admin_user", schema=clpv4.schema) - async def add_admin_user(client, message): - pass - - # TODO: Add admin command for modifying admin user permissions - @server.on_command(cmd="admin_modify_admin_user", schema=clpv4.schema) - async def modify_admin_user(client, message): - pass - - # TODO: Add admin command for deleting admin users - @server.on_command(cmd="admin_delete_admin_user", schema=clpv4.schema) - async def delete_admin_user(client, message): - pass diff --git a/server.py b/server.py index 17f3616..8c1c441 100644 --- a/server.py +++ b/server.py @@ -1,7 +1,7 @@ from cloudlink import server -from cloudlink.server.plugins import cl_admin from cloudlink.server.protocols import clpv4, scratch + if __name__ == "__main__": # Initialize the server server = server() @@ -15,9 +15,6 @@ clpv4 = clpv4(server) scratch = scratch(server) - # Load plugins - cl_admin = cl_admin(server, clpv4) - # Initialize SSL support # server.enable_ssl(certfile="cert.pem", keyfile="privkey.pem") From 9cbef47535c65677b6d244566b077ce742e6e0b4 Mon Sep 17 00:00:00 2001 From: "Mike J. Renaker / \"MikeDEV" Date: Thu, 28 Sep 2023 20:49:23 -0400 Subject: [PATCH 55/59] Pretty much finished * Finish implementing client (took me long enough, lol) * Reimplemented some legacy stuff from 0.1.9.2. * Rename server.py to server_example.py * Rename client.py to client_example.py --- client.py | 35 ------- client_example.py | 48 ++++++++++ cloudlink/client/__init__.py | 78 ++++++++++++++++ cloudlink/client/protocol.py | 59 ++++++++++-- cloudlink/server/__init__.py | 46 +++++++--- cloudlink/server/protocols/clpv4/clpv4.py | 1 + cloudlink/server/protocols/scratch/scratch.py | 1 + server.py | 22 ----- server_example.py | 92 +++++++++++++++++++ 9 files changed, 304 insertions(+), 78 deletions(-) delete mode 100644 client.py create mode 100644 client_example.py delete mode 100644 server.py create mode 100644 server_example.py diff --git a/client.py b/client.py deleted file mode 100644 index 793b131..0000000 --- a/client.py +++ /dev/null @@ -1,35 +0,0 @@ -from cloudlink import client - -if __name__ == "__main__": - # Initialize the client - client = client() - - # Configure logging settings - client.logging.basicConfig( - level=client.logging.DEBUG - ) - - # Example of events - @client.on_connect - async def on_connect(): - print("Connected, press CTRL+C to disconnect.") - - # Wait for CTRL+C - try: - while True: pass - except KeyboardInterrupt: - print("Going away in a second") - await client.asyncio.sleep(1) - - client.disconnect() - - # Example use of decorator functions within the server. - @client.on_command(cmd="gmsg") - async def on_gmsg(message): - await client.send_packet({"cmd": "direct", "val": "Hello, server!"}) - - # Enable SSL support (if you use self-generated SSL certificates) - #client.enable_ssl(certfile="cert.pem") - - # Start the client - client.run(host="ws://127.0.0.1:3000/") diff --git a/client_example.py b/client_example.py new file mode 100644 index 0000000..5885fe5 --- /dev/null +++ b/client_example.py @@ -0,0 +1,48 @@ +from cloudlink import client + +if __name__ == "__main__": + # Initialize the client + client = client() + + # Configure logging settings + client.logging.basicConfig( + level=client.logging.DEBUG + ) + + # Use this decorator to handle established connections. + @client.on_connect + async def on_connect(): + print("Connected!") + + # Ask for a username + await client.protocol.set_username(input("Please give me a username... ")) + + # Whenever a client is connected, you can call this function to gracefully disconnect. + # client.disconnect() + + # Use this decorator to handle disconnects. + @client.on_disconnect + async def on_disconnect(): + print("Disconnected!") + + # Use this decorator to handle username being set events. + @client.on_username_set + async def on_username_set(id, name, uuid): + print(f"My username has been set! ID: {id}, Name: {name}, UUID: {uuid}") + + # Example message-specific event handler. You can use different kinds of message types, + # such as pmsg, gvar, pvar, and more. + @client.on_gmsg + async def on_gmsg(message): + print(f"I got a global message! It says: \"{message['val']}\".") + + # Example use of on_command functions within the client. + @client.on_command(cmd="gmsg") + async def on_gmsg(message): + client.send_packet({"cmd": "direct", "val": "Hello, server!"}) + + # Enable SSL support (if you use self-generated SSL certificates) + #client.enable_ssl(certfile="cert.pem") + + # Start the client + client.run(host="ws://127.0.0.1:3000/") diff --git a/cloudlink/client/__init__.py b/cloudlink/client/__init__.py index 8485ece..00a869b 100644 --- a/cloudlink/client/__init__.py +++ b/cloudlink/client/__init__.py @@ -83,6 +83,24 @@ def __init__(self): self.listener_events_await_specific = dict() self.listener_events_decorator_specific = dict() self.listener_responses = dict() + self.on_username_set_events = set() + + # Prepare command event handlers + self.protocol_command_handlers = dict() + for cmd in [ + "ping", + "gmsg", + "gvar", + "pmsg", + "pvar", + "statuscode", + "client_obj", + "client_ip", + "server_version", + "ulist", + "direct" + ]: + self.protocol_command_handlers[cmd] = set() # Create method handlers self.command_handlers = dict() @@ -176,6 +194,9 @@ async def wait_for_listener(self, listener_id): # Return the response return response + def on_username_set(self, func): + self.on_username_set_events.add(func) + # Version of the wait for listener tool for decorator usage. def on_listener(self, listener_id): def bind_event(func): @@ -224,6 +245,58 @@ def on_disconnect(self, func): def on_error(self, func): self.on_error_events.add(func) + # CL4 client-specific command events + + # Event binder for gmsg events. + def on_gmsg(self, func): + self.logger.debug(f"Binding function {func.__name__} to gmsg command event manager") + self.protocol_command_handlers["gmsg"].add(func) + + # Event binder for pmsg events. + def on_pmsg(self, func): + self.logger.debug(f"Binding function {func.__name__} to pmsg command event manager") + self.protocol_command_handlers["pmsg"].add(func) + + # Event binder for gvar events. + def on_gvar(self, func): + self.logger.debug(f"Binding function {func.__name__} to gvar command event manager") + self.protocol_command_handlers["gvar"].add(func) + + # Event binder for pvar events. + def on_pvar(self, func): + self.logger.debug(f"Binding function {func.__name__} to pvar command event manager") + self.protocol_command_handlers["pvar"].add(func) + + # Event binder for direct events. + def on_direct(self, func): + self.logger.debug(f"Binding function {func.__name__} to direct command event manager") + self.protocol_command_handlers["direct"].add(func) + + # Event binder for statuscode events. + def on_statuscode(self, func): + self.logger.debug(f"Binding function {func.__name__} to statuscode command event manager") + self.protocol_command_handlers["statuscode"].add(func) + + # Event binder for client_obj events. + def on_client_obj(self, func): + self.logger.debug(f"Binding function {func.__name__} to client_obj command event manager") + self.protocol_command_handlers["client_obj"].add(func) + + # Event binder for client_ip events. + def on_client_ip(self, func): + self.logger.debug(f"Binding function {func.__name__} to client_ip command event manager") + self.protocol_command_handlers["client_ip"].add(func) + + # Event binder for server_version events. + def on_server_version(self, func): + self.logger.debug(f"Binding function {func.__name__} to server_version command event manager") + self.protocol_command_handlers["server_version"].add(func) + + # Event binder for ulist events. + def on_ulist(self, func): + self.logger.debug(f"Binding function {func.__name__} to ulist command event manager") + self.protocol_command_handlers["ulist"].add(func) + # Send message def send_packet(self, message): self.asyncio.create_task(self.execute_send(message)) @@ -436,6 +509,11 @@ async def __run__(self, host): # Asyncio event-handling coroutines + async def execute_on_username_set_events(self, id, username, uuid): + events = [event(id, username, uuid) for event in self.on_username_set_events] + group = self.asyncio.gather(*events) + await group + async def execute_on_disconnect_events(self): events = [event() for event in self.on_disconnect_events] group = self.asyncio.gather(*events) diff --git a/cloudlink/client/protocol.py b/cloudlink/client/protocol.py index 408fe33..ce6a23b 100644 --- a/cloudlink/client/protocol.py +++ b/cloudlink/client/protocol.py @@ -57,6 +57,33 @@ def generate_user_object(): # Expose username object generator function for extension usage self.generate_user_object = generate_user_object + async def set_username(username): + parent.logger.debug(f"Setting username to {username}...") + + # Send the set username request with a listener and wait for a response + response = await parent.send_packet_and_wait({ + "cmd": "setid", + "val": username, + "listener": "init_username" + }) + + if response["code_id"] == statuscodes.ok[1]: + # Log the successful connection + parent.logger.info(f"Successfully set username to {username}.") + + # Fire all on_connect events + val = response["val"] + parent.asyncio.create_task( + parent.execute_on_username_set_events(val["id"], val["username"], val["uuid"]) + ) + + else: + # Log the connection error + parent.logger.error(f"Failed to set username. Got response code: {response['code']}") + + # Expose the username set command + self.set_username = set_username + # The CLPv4 command set @parent.on_initial_connect async def on_initial_connect(): @@ -92,27 +119,39 @@ async def on_initial_connect(): @parent.on_command(cmd="ping") async def on_ping(message): - pass + events = [event(message) for event in parent.protocol_command_handlers["ping"]] + group = parent.asyncio.gather(*events) + await group @parent.on_command(cmd="gmsg") async def on_gmsg(message): - pass + events = [event(message) for event in parent.protocol_command_handlers["gmsg"]] + group = parent.asyncio.gather(*events) + await group @parent.on_command(cmd="pmsg") async def on_pmsg(message): - pass + events = [event(message) for event in parent.protocol_command_handlers["pmsg"]] + group = parent.asyncio.gather(*events) + await group @parent.on_command(cmd="gvar") async def on_gvar(message): - pass + events = [event(message) for event in parent.protocol_command_handlers["gvar"]] + group = parent.asyncio.gather(*events) + await group @parent.on_command(cmd="pvar") async def on_pvar(message): - pass + events = [event(message) for event in parent.protocol_command_handlers["pvar"]] + group = parent.asyncio.gather(*events) + await group @parent.on_command(cmd="statuscode") async def on_statuscode(message): - pass + events = [event(message) for event in parent.protocol_command_handlers["statuscode"]] + group = parent.asyncio.gather(*events) + await group @parent.on_command(cmd="client_obj") async def on_client_obj(message): @@ -128,8 +167,12 @@ async def on_server_version(message): @parent.on_command(cmd="ulist") async def on_ulist(message): - pass + events = [event(message) for event in parent.protocol_command_handlers["ulist"]] + group = parent.asyncio.gather(*events) + await group @parent.on_command(cmd="direct") async def on_direct(message): - pass + events = [event(message) for event in parent.protocol_command_handlers["direct"]] + group = parent.asyncio.gather(*events) + await group diff --git a/cloudlink/server/__init__.py b/cloudlink/server/__init__.py index 1cc2f27..8067c72 100644 --- a/cloudlink/server/__init__.py +++ b/cloudlink/server/__init__.py @@ -153,20 +153,9 @@ def unbind_command(self, cmd, schema): # Event binder for on_command events def on_command(self, cmd, schema): - def bind_event(func): - - # Create schema category for command event manager - if schema not in self.command_handlers: - self.logger.debug(f"Creating protocol {schema.__qualname__} command event manager") - self.command_handlers[schema] = dict() - - # Create command event handler - if cmd not in self.command_handlers[schema]: - self.command_handlers[schema][cmd] = set() - # Add function to the command handler - self.logger.debug(f"Binding function {func.__name__} to command {cmd} in {schema.__qualname__} command event manager") - self.command_handlers[schema][cmd].add(func) + def bind_event(func): + self.bind_callback(cmd, schema, func) # End on_command binder return bind_event @@ -236,18 +225,22 @@ def bind_event(func): # Event binder for on_message events def on_message(self, func): + self.logger.debug(f"Binding function {func.__name__} to on_message events") self.on_message_events.add(func) # Event binder for on_connect events. def on_connect(self, func): + self.logger.debug(f"Binding function {func.__name__} to on_connect events") self.on_connect_events.add(func) # Event binder for on_disconnect events. def on_disconnect(self, func): + self.logger.debug(f"Binding function {func.__name__} to on_disconnect events") self.on_disconnect_events.add(func) # Event binder for on_error events. def on_error(self, func): + self.logger.debug(f"Binding function {func.__name__} to on_error events") self.on_error_events.add(func) # Friendly version of send_packet_unicast / send_packet_multicast @@ -708,3 +701,30 @@ async def execute_close_multi(self, clients, code=1000, reason=""): events = [self.execute_close_single(client, code, reason) for client in clients] group = self.asyncio.gather(*events) await group + + # Deprecated. Provides semi-backwards compatibility for callback functions from 0.1.9.2. + def bind_callback(self, cmd, schema, method): + # Create schema category for command event manager + if schema not in self.command_handlers: + self.logger.debug(f"Creating protocol {schema.__qualname__} command event manager") + self.command_handlers[schema] = dict() + + # Create command event handler + if cmd not in self.command_handlers[schema]: + self.command_handlers[schema][cmd] = set() + + # Add function to the command handler + self.logger.debug(f"Binding function {method.__name__} to command {cmd} in {schema.__qualname__} command event manager") + self.command_handlers[schema][cmd].add(method) + + # Deprecated. Provides semi-backwards compatibility for event functions from 0.1.9.2. + def bind_event(self, event, func): + match (event): + case self.on_connect: + self.on_connect(func) + case self.on_disconnect: + self.on_disconnect(func) + case self.on_error: + self.on_error(func) + case self.on_message: + self.on_message(func) diff --git a/cloudlink/server/protocols/clpv4/clpv4.py b/cloudlink/server/protocols/clpv4/clpv4.py index 774cae6..131f7f8 100644 --- a/cloudlink/server/protocols/clpv4/clpv4.py +++ b/cloudlink/server/protocols/clpv4/clpv4.py @@ -44,6 +44,7 @@ def __init__(self, server): # Exposes the schema of the protocol. self.schema = cl4_protocol + self.__qualname__ = "clpv4" # Define various status codes for the protocol. class statuscodes: diff --git a/cloudlink/server/protocols/scratch/scratch.py b/cloudlink/server/protocols/scratch/scratch.py index 23394bd..7adc9f1 100644 --- a/cloudlink/server/protocols/scratch/scratch.py +++ b/cloudlink/server/protocols/scratch/scratch.py @@ -8,6 +8,7 @@ class scratch: def __init__(self, server): + self.__qualname__ = "scratch" # Define various status codes for the protocol. class statuscodes: diff --git a/server.py b/server.py deleted file mode 100644 index 8c1c441..0000000 --- a/server.py +++ /dev/null @@ -1,22 +0,0 @@ -from cloudlink import server -from cloudlink.server.protocols import clpv4, scratch - - -if __name__ == "__main__": - # Initialize the server - server = server() - - # Configure logging settings - server.logging.basicConfig( - level=server.logging.DEBUG - ) - - # Load protocols - clpv4 = clpv4(server) - scratch = scratch(server) - - # Initialize SSL support - # server.enable_ssl(certfile="cert.pem", keyfile="privkey.pem") - - # Start the server - server.run(ip="127.0.0.1", port=3000) diff --git a/server_example.py b/server_example.py new file mode 100644 index 0000000..a6a07ed --- /dev/null +++ b/server_example.py @@ -0,0 +1,92 @@ +from cloudlink import server +from cloudlink.server.protocols import clpv4, scratch +import asyncio + + +class example_callbacks: + def __init__(self, parent): + self.parent = parent + + async def test1(self, client, message): + print("Test1!") + await asyncio.sleep(1) + print("Test1 after one second!") + + async def test2(self, client, message): + print("Test2!") + await asyncio.sleep(1) + print("Test2 after one second!") + + async def test3(self, client, message): + print("Test3!") + + +class example_commands: + def __init__(self, parent, protocol): + + # Creating custom commands - This example adds a custom command called "foobar". + @server.on_command(cmd="foobar", schema=protocol.schema) + async def foobar(client, message): + print("Foobar!") + + # Reading the IP address of the client is as easy as calling get_client_ip from the clpv4 protocol object. + print(protocol.get_client_ip(client)) + + # In case you need to report a status code, use send_statuscode. + protocol.send_statuscode( + client=client, + code=protocol.statuscodes.ok, + message=message + ) + + +class example_events: + def __init__(self): + pass + + async def on_close(self, client): + print("Client", client.id, "disconnected.") + + async def on_connect(self, client): + print("Client", client.id, "connected.") + + +if __name__ == "__main__": + # Initialize the server + server = server() + + # Configure logging settings + server.logging.basicConfig( + level=server.logging.DEBUG + ) + + # Load protocols + clpv4 = clpv4(server) + scratch = scratch(server) + + # Load examples + callbacks = example_callbacks(server) + commands = example_commands(server, clpv4) + events = example_events() + + # Binding callbacks - This example binds the "handshake" command with example callbacks. + # You can bind as many functions as you want to a callback, but they must use async. + # To bind callbacks to built-in methods (example: gmsg), see cloudlink.cl_methods. + server.bind_callback(cmd="handshake", schema=clpv4.schema, method=callbacks.test1) + server.bind_callback(cmd="handshake", schema=clpv4.schema, method=callbacks.test2) + + # Binding events - This example will print a client connect/disconnect message. + # You can bind as many functions as you want to an event, but they must use async. + # To see all possible events for the server, see cloudlink.events. + server.bind_event(server.on_connect, events.on_connect) + server.bind_event(server.on_disconnect, events.on_close) + + # You can also bind an event to a custom command. We'll bind callbacks.test3 to our + # foobar command from earlier. + server.bind_callback(cmd="foobar", schema=clpv4.schema, method=callbacks.test3) + + # Initialize SSL support + # server.enable_ssl(certfile="cert.pem", keyfile="privkey.pem") + + # Start the server + server.run(ip="127.0.0.1", port=3000) From f10fff25e50f050d4436718f90444b46e758ff94 Mon Sep 17 00:00:00 2001 From: "Mike J. Renaker / \"MikeDEV" Date: Thu, 28 Sep 2023 20:50:38 -0400 Subject: [PATCH 56/59] How did I overlook that? --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 9850bd1..93aab68 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ class myplugin: def __init__(self, server, protocol): # Example command - client sends { "cmd": "foo" } to the server, this function will execute - @server.on_command(cmd="foo", schema=clpv4.schema) + @server.on_command(cmd="foo", schema=protocol.schema) async def foobar(client, message): print("Foobar!") From 4b7f5174ff4484b305c0d03390966655a0f7c0f6 Mon Sep 17 00:00:00 2001 From: "Mike J. Renaker / \"MikeDEV" Date: Thu, 28 Sep 2023 20:51:26 -0400 Subject: [PATCH 57/59] Update README.md --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 93aab68..e25b159 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,6 @@ CloudLink can run on minimal resources. At least 25MB of RAM and any reasonably ### 🌐 Essential networking tools * Unicast and multicast packets across clients * Expandable functionality with a built-in method loader -* Support for bridging servers ### πŸ“¦ Minimal dependencies All dependencies below can be installed using `pip install -r requirements.txt`. From 8638082292703bcbbea63922ad4b3d203223b76c Mon Sep 17 00:00:00 2001 From: "Mike J. Renaker / \"MikeDEV" Date: Thu, 28 Sep 2023 20:55:43 -0400 Subject: [PATCH 58/59] Update README.md --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e25b159..cd63dbb 100644 --- a/README.md +++ b/README.md @@ -69,10 +69,11 @@ server.run() You can learn about the protocol using the original Scratch 3.0 client extension. Feel free to test-drive the extension in any of these Scratch mods: -- [TurboWarp](https://turbowarp.org/editor?extension=https://extensions.turbowarp.org/cloudlink.js) -- [SheepTester's E羊icques](https://sheeptester.github.io/scratch-gui/?url=https://mikedev101.github.io/cloudlink/S4-0-nosuite.js) +- [TurboWarp](https://turbowarp.org/editor?extension=url=https://mikedev101.github.io/cloudlink/S4-1-nosuite.js) +- [SheepTester's E羊icques](https://sheeptester.github.io/scratch-gui/?url=https://mikedev101.github.io/cloudlink/S4-1-nosuite.js) - [Ogadaki's Adacraft](https://adacraft.org/studio/) - [Ogadaki's Adacraft (Beta)](https://beta.adacraft.org/studio/) +- [PenguinMod](https://studio.penguinmod.site/editor.html?extension=url=https://mikedev101.github.io/cloudlink/S4-1-nosuite.js) # πŸ“ƒ The CloudLink Protocol πŸ“ƒ Documentation of the CL4 protocol can be found in the CloudLink Repository's Wiki page. From 5a0e6fd2cd2dda544c2e10b5baa25ce4b0739950 Mon Sep 17 00:00:00 2001 From: "Mike J. Renaker / \"MikeDEV" Date: Thu, 28 Sep 2023 20:56:36 -0400 Subject: [PATCH 59/59] Add S4-0-nosuite.js --- S4-0-nosuite.js | 1710 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1710 insertions(+) create mode 100644 S4-0-nosuite.js diff --git a/S4-0-nosuite.js b/S4-0-nosuite.js new file mode 100644 index 0000000..5ea3a2e --- /dev/null +++ b/S4-0-nosuite.js @@ -0,0 +1,1710 @@ +var servers = {}; ; // Server list +let mWS = null; + +// Get the server URL list +try { + fetch('https://mikedev101.github.io/cloudlink/serverlist.json').then(response => { + return response.text(); + }).then(data => { + servers = JSON.parse(data); + }).catch(err => { + console.log(err); + servers = {}; + }); +} catch(err) { + console.log(err); + servers = {}; +}; + +function find_id(ID, ulist) { + // Thanks StackOverflow! + if (jsonCheck(ID) && (!intCheck(ID))) { + return ulist.some(o => ((o.username === JSON.parse(ID).username) && (o.id == JSON.parse(ID).id))); + } else { + return ulist.some(o => ((o.username === String(ID)) || (o.id == ID))); + }; +} + +function jsonCheck(JSON_STRING) { + try { + JSON.parse(JSON_STRING); + return true; + } catch (err) { + return false; + } +} + +function intCheck(value) { + return !isNaN(value); +} + +function autoConvert(value) { + // Check if the value is JSON / Dict first + try { + JSON.parse(value); + return JSON.parse(value); + } catch (err) {}; + + // Check if the value is an array + try { + tmp = value; + tmp = tmp.replace(/'/g, '"'); + JSON.parse(tmp); + return JSON.parse(tmp); + } catch (err) {}; + + // Check if an int/float + if (!isNaN(value)) { + return Number(value); + }; + + // Leave as the original value if none of the above work + return value; +} + +class CloudLink { + constructor (runtime, extensionId) { + // Extension stuff + this.runtime = runtime; + this.cl_icon = 'data:image/svg+xml;base64,PHN2ZyB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHdpZHRoPSIyMjUuMzU0OCIgaGVpZ2h0PSIyMjUuMzU0OCIgdmlld0JveD0iMCwwLDIyNS4zNTQ4LDIyNS4zNTQ4Ij48ZyB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMTI3LjMyMjYsLTY3LjMyMjYpIj48ZyBkYXRhLXBhcGVyLWRhdGE9InsmcXVvdDtpc1BhaW50aW5nTGF5ZXImcXVvdDs6dHJ1ZX0iIHN0cm9rZT0ibm9uZSIgc3Ryb2tlLWxpbmVjYXA9ImJ1dHQiIHN0cm9rZS1saW5lam9pbj0ibWl0ZXIiIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgc3Ryb2tlLWRhc2hhcnJheT0iIiBzdHJva2UtZGFzaG9mZnNldD0iMCIgc3R5bGU9Im1peC1ibGVuZC1tb2RlOiBub3JtYWwiPjxwYXRoIGQ9Ik0xMjcuMzIyNiwxODBjMCwtNjIuMjMwMDEgNTAuNDQ3MzksLTExMi42Nzc0IDExMi42Nzc0LC0xMTIuNjc3NGM2Mi4yMzAwMSwwIDExMi42Nzc0LDUwLjQ0NzM5IDExMi42Nzc0LDExMi42Nzc0YzAsNjIuMjMwMDEgLTUwLjQ0NzM5LDExMi42Nzc0IC0xMTIuNjc3NCwxMTIuNjc3NGMtNjIuMjMwMDEsMCAtMTEyLjY3NzQsLTUwLjQ0NzM5IC0xMTIuNjc3NCwtMTEyLjY3NzR6IiBmaWxsPSIjMDBjMjhjIiBmaWxsLXJ1bGU9Im5vbnplcm8iIHN0cm9rZS13aWR0aD0iMCIvPjxnIGZpbGwtcnVsZT0iZXZlbm9kZCIgc3Ryb2tlLXdpZHRoPSIxIj48cGF0aCBkPSJNMjg2LjEyMDM3LDE1MC41NTc5NWMyMy4yNDA4NiwwIDQyLjA3ODksMTguODM5NDYgNDIuMDc4OSw0Mi4wNzg5YzAsMjMuMjM5NDQgLTE4LjgzODAzLDQyLjA3ODkgLTQyLjA3ODksNDIuMDc4OWgtOTIuMjQwNzRjLTIzLjI0MDg2LDAgLTQyLjA3ODksLTE4LjgzOTQ2IC00Mi4wNzg5LC00Mi4wNzg5YzAsLTIzLjIzOTQ0IDE4LjgzODAzLC00Mi4wNzg5IDQyLjA3ODksLTQyLjA3ODloNC4xODg4N2MxLjgxMTUzLC0yMS41NzA1NSAxOS44OTM1NywtMzguNTEyODkgNDEuOTMxNSwtMzguNTEyODljMjIuMDM3OTMsMCA0MC4xMTk5NywxNi45NDIzNCA0MS45MzE1LDM4LjUxMjg5eiIgZmlsbD0iI2ZmZmZmZiIvPjxwYXRoIGQ9Ik0yODkuMDg2NTUsMjEwLjM0MTE0djkuMDQ2NjdoLTI2LjkxNjYzaC05LjA0NjY3di05LjA0NjY3di01NC41MDMzOWg5LjA0NjY3djU0LjUwMzM5eiIgZmlsbD0iIzAwYzI4YyIvPjxwYXRoIGQ9Ik0yMjIuNDA5MjUsMjE5LjM4NzgxYy04LjM1MzIsMCAtMTYuMzY0MzEsLTMuMzE4MzQgLTIyLjI3MDksLTkuMjI0OTJjLTUuOTA2NjEsLTUuOTA2NTggLTkuMjI0OTEsLTEzLjkxNzY4IC05LjIyNDkxLC0yMi4yNzA4OWMwLC04LjM1MzIgMy4zMTgyOSwtMTYuMzY0MzEgOS4yMjQ5MSwtMjIuMjcwOWM1LjkwNjU5LC01LjkwNjYxIDEzLjkxNzcsLTkuMjI0OTEgMjIuMjcwOSwtOS4yMjQ5MWgyMS4xMDg5djguOTM0OThoLTIxLjEwODl2MC4xMDI1N2MtNS45NTYyOCwwIC0xMS42Njg2NCwyLjM2NjE2IC0xNS44ODAzNyw2LjU3Nzg5Yy00LjIxMTczLDQuMjExNzMgLTYuNTc3ODksOS45MjQwOCAtNi41Nzc4OSwxNS44ODAzN2MwLDUuOTU2MjggMi4zNjYxNiwxMS42Njg2NCA2LjU3Nzg5LDE1Ljg4MDM3YzQuMjExNzMsNC4yMTE3MyA5LjkyNDA4LDYuNTc3OTMgMTUuODgwMzcsNi41Nzc5M3YwLjEwMjUzaDIxLjEwODl2OC45MzQ5OHoiIGZpbGw9IiMwMGMyOGMiLz48L2c+PC9nPjwvZz48L3N2Zz48IS0tcm90YXRpb25DZW50ZXI6MTEyLjY3NzQwNDA4NDA4MzkyOjExMi42Nzc0MDQwODQwODQwMy0tPg=='; + this.cl_block = 'data:image/svg+xml;base64,PHN2ZyB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHdpZHRoPSIxNzYuMzk4NTQiIGhlaWdodD0iMTIyLjY3MDY5IiB2aWV3Qm94PSIwLDAsMTc2LjM5ODU0LDEyMi42NzA2OSI+PGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTE1MS44MDA3MywtMTE4LjY2NDY2KSI+PGcgZGF0YS1wYXBlci1kYXRhPSJ7JnF1b3Q7aXNQYWludGluZ0xheWVyJnF1b3Q7OnRydWV9IiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIHN0cm9rZT0ibm9uZSIgc3Ryb2tlLXdpZHRoPSIxIiBzdHJva2UtbGluZWNhcD0iYnV0dCIgc3Ryb2tlLWxpbmVqb2luPSJtaXRlciIgc3Ryb2tlLW1pdGVybGltaXQ9IjEwIiBzdHJva2UtZGFzaGFycmF5PSIiIHN0cm9rZS1kYXNob2Zmc2V0PSIwIiBzdHlsZT0ibWl4LWJsZW5kLW1vZGU6IG5vcm1hbCI+PGc+PHBhdGggZD0iTTI4Ni4xMjAzNywxNTcuMTc3NTVjMjMuMjQwODYsMCA0Mi4wNzg5LDE4LjgzOTQ2IDQyLjA3ODksNDIuMDc4OWMwLDIzLjIzOTQ0IC0xOC44MzgwMyw0Mi4wNzg5IC00Mi4wNzg5LDQyLjA3ODloLTkyLjI0MDc0Yy0yMy4yNDA4NiwwIC00Mi4wNzg5LC0xOC44Mzk0NiAtNDIuMDc4OSwtNDIuMDc4OWMwLC0yMy4yMzk0NCAxOC44MzgwMywtNDIuMDc4OSA0Mi4wNzg5LC00Mi4wNzg5aDQuMTg4ODdjMS44MTE1MywtMjEuNTcwNTUgMTkuODkzNTcsLTM4LjUxMjg5IDQxLjkzMTUsLTM4LjUxMjg5YzIyLjAzNzkzLDAgNDAuMTE5OTcsMTYuOTQyMzQgNDEuOTMxNSwzOC41MTI4OXoiIGZpbGw9IiNmZmZmZmYiLz48cGF0aCBkPSJNMjg5LjA4NjU1LDIxNi45NjA3NHY5LjA0NjY3aC0yNi45MTY2M2gtOS4wNDY2N3YtOS4wNDY2N3YtNTQuNTAzMzloOS4wNDY2N3Y1NC41MDMzOXoiIGZpbGw9IiMwMGMyOGMiLz48cGF0aCBkPSJNMjIyLjQwOTI1LDIyNi4wMDc0MWMtOC4zNTMyLDAgLTE2LjM2NDMxLC0zLjMxODM0IC0yMi4yNzA5LC05LjIyNDkyYy01LjkwNjYxLC01LjkwNjU4IC05LjIyNDkxLC0xMy45MTc2OCAtOS4yMjQ5MSwtMjIuMjcwODljMCwtOC4zNTMyIDMuMzE4MjksLTE2LjM2NDMxIDkuMjI0OTEsLTIyLjI3MDljNS45MDY1OSwtNS45MDY2MSAxMy45MTc3LC05LjIyNDkxIDIyLjI3MDksLTkuMjI0OTFoMjEuMTA4OXY4LjkzNDk4aC0yMS4xMDg5djAuMTAyNTdjLTUuOTU2MjgsMCAtMTEuNjY4NjQsMi4zNjYxNiAtMTUuODgwMzcsNi41Nzc4OWMtNC4yMTE3Myw0LjIxMTczIC02LjU3Nzg5LDkuOTI0MDggLTYuNTc3ODksMTUuODgwMzdjMCw1Ljk1NjI4IDIuMzY2MTYsMTEuNjY4NjQgNi41Nzc4OSwxNS44ODAzN2M0LjIxMTczLDQuMjExNzMgOS45MjQwOCw2LjU3NzkzIDE1Ljg4MDM3LDYuNTc3OTN2MC4xMDI1M2gyMS4xMDg5djguOTM0OTh6IiBmaWxsPSIjMDBjMjhjIi8+PC9nPjwvZz48L2c+PC9zdmc+PCEtLXJvdGF0aW9uQ2VudGVyOjg4LjE5OTI2OTk5OTk5OTk4OjYxLjMzNTM0NDk5OTk5OTk5LS0+'; + + // Socket data + this.socketData = { + "gmsg": [], + "pmsg": [], + "direct": [], + "statuscode": [], + "gvar": [], + "pvar": [], + "motd": "", + "client_ip": "", + "ulist": [], + "server_version": "" + }; + this.varData = { + "gvar": {}, + "pvar": {} + }; + + this.queueableCmds = ["gmsg", "pmsg", "gvar", "pvar", "direct", "statuscode"]; + this.varCmds = ["gvar", "pvar"]; + + // Listeners + this.socketListeners = {}; + this.socketListenersData = {}; + this.newSocketData = { + "gmsg": false, + "pmsg": false, + "direct": false, + "statuscode": false, + "gvar": false, + "pvar": false + }; + + // Edge-triggered hat blocks + this.connect_hat = 0; + this.packet_hat = 0; + this.close_hat = 0; + + // Status stuff + this.isRunning = false; + this.isLinked = false; + this.version = "S4.0"; + this.link_status = 0; + this.username = ""; + this.tmp_username = ""; + this.isUsernameSyncing = false; + this.isUsernameSet = false; + this.disconnectWasClean = false; + this.wasConnectionDropped = false; + this.didConnectionFail = false; + this.protocolOk = false; + + // Listeners stuff + this.enableListener = false; + this.setListener = ""; + + // Rooms stuff + this.enableRoom = false; + this.isRoomSetting = false; + this.selectRoom = ""; + + // Remapping stuff + this.menuRemap = { + "Global data": "gmsg", + "Private data": "pmsg", + "Global variables": "gvar", + "Private variables": "pvar", + "Direct data": "direct", + "Status code": "statuscode", + "All data": "all" + }; + } + + getInfo () { + return { + "id": 'cloudlink', + "name": 'CloudLink', + "blockIconURI": this.cl_block, + "menuIconURI": this.cl_icon, + "blocks": [ + { + "opcode": 'returnGlobalData', + "blockType": "reporter", + "text": "Global data" + }, + { + "opcode": 'returnPrivateData', + "blockType": "reporter", + "text": "Private data" + }, + { + "opcode": 'returnDirectData', + "blockType": "reporter", + "text": "Direct Data" + }, + { + "opcode": 'returnLinkData', + "blockType": "reporter", + "text": "Link status" + }, + { + "opcode": 'returnStatusCode', + "blockType": "reporter", + "text": "Status code" + }, + { + "opcode": 'returnUserListData', + "blockType": "reporter", + "text": "Usernames" + }, + { + "opcode": "returnUsernameData", + "blockType": "reporter", + "text": "My username" + }, + { + "opcode": "returnVersionData", + "blockType": "reporter", + "text": "Extension version" + }, + { + "opcode": "returnServerVersion", + "blockType": "reporter", + "text": "Server version" + }, + { + "opcode": "returnServerList", + "blockType": "reporter", + "text": "Server list" + }, + { + "opcode": "returnMOTD", + "blockType": "reporter", + "text": "Server MOTD" + }, + { + "opcode": "returnClientIP", + "blockType": "reporter", + "text": "My IP address" + }, + { + "opcode": 'returnListenerData', + "blockType": "reporter", + "text": "Response for listener [ID]", + "arguments": { + "ID": { + "type": "string", + "defaultValue": "example-listener", + }, + }, + }, + { + "opcode": "readQueueSize", + "blockType": "reporter", + "text": "Size of queue for [TYPE]", + "arguments": { + "TYPE": { + "type": "string", + "menu": "allmenu", + "defaultValue": "All data", + }, + }, + }, + { + "opcode": "readQueueData", + "blockType": "reporter", + "text": "Packet queue for [TYPE]", + "arguments": { + "TYPE": { + "type": "string", + "menu": "allmenu", + "defaultValue": "All data", + }, + }, + }, + { + "opcode": 'returnVarData', + "blockType": "reporter", + "text": "[TYPE] [VAR] data", + "arguments": { + "VAR": { + "type": "string", + "defaultValue": "Apple", + }, + "TYPE": { + "type": "string", + "menu": "varmenu", + "defaultValue": "Global variables", + }, + }, + }, + { + "opcode": 'parseJSON', + "blockType": "reporter", + "text": '[PATH] of [JSON_STRING]', + "arguments": { + "PATH": { + "type": "string", + "defaultValue": 'fruit/apples', + }, + "JSON_STRING": { + "type": "string", + "defaultValue": '{"fruit": {"apples": 2, "bananas": 3}, "total_fruit": 5}', + }, + }, + }, + { + "opcode": 'getFromJSONArray', + "blockType": "reporter", + "text": 'Get [NUM] from JSON array [ARRAY]', + "arguments": { + "NUM": { + "type": "number", + "defaultValue": 0, + }, + "ARRAY": { + "type": "string", + "defaultValue": '["foo","bar"]', + } + } + }, + { + "opcode": 'fetchURL', + "blockType": "reporter", + "blockAllThreads": "true", + "text": "Fetch data from URL [url]", + "arguments": { + "url": { + "type": "string", + "defaultValue": "https://mikedev101.github.io/cloudlink/fetch_test", + }, + }, + }, + { + "opcode": 'requestURL', + "blockType": "reporter", + "blockAllThreads": "true", + "text": 'Send request with method [method] for URL [url] with data [data] and headers [headers]', + "arguments": { + "method": { + "type": "string", + "defaultValue": 'GET', + }, + "url": { + "type": "string", + "defaultValue": 'https://mikedev101.github.io/cloudlink/fetch_test', + }, + "data": { + "type": "string", + "defaultValue": '{}' + }, + "headers": { + "type": "string", + "defaultValue": '{}' + }, + } + }, + { + "opcode": 'makeJSON', + "blockType": "reporter", + "text": 'Convert [toBeJSONified] to JSON', + "arguments": { + "toBeJSONified": { + "type": "string", + "defaultValue": '{"test": true}', + }, + } + }, + { + "opcode": 'onConnect', + "blockType": "hat", + "text": 'When connected', + "blockAllThreads": "true" + }, + { + "opcode": 'onClose', + "blockType": "hat", + "text": 'When disconnected', + "blockAllThreads": "true" + }, + { + "opcode": 'onListener', + "blockType": "hat", + "text": 'When I receive new packet with listener [ID]', + "blockAllThreads": "true", + "arguments": { + "ID": { + "type": "string", + "defaultValue": "example-listener", + }, + }, + }, + { + "opcode": 'onNewPacket', + "blockType": "hat", + "text": 'When I receive new [TYPE] packet', + "blockAllThreads": "true", + "arguments": { + "TYPE": { + "type": "string", + "menu": "almostallmenu", + "defaultValue": 'Global data' + }, + }, + }, + { + "opcode": 'onNewVar', + "blockType": "hat", + "text": 'When I receive new [TYPE] data for [VAR]', + "blockAllThreads": "true", + "arguments": { + "TYPE": { + "type": "string", + "menu": "varmenu", + "defaultValue": 'Global variables', + }, + "VAR": { + "type": "string", + "defaultValue": 'Apple', + }, + }, + }, + { + "opcode": 'getComState', + "blockType": "Boolean", + "text": 'Connected?', + }, + { + "opcode": 'getRoomState', + "blockType": "Boolean", + "text": 'Linked to rooms?', + }, + { + "opcode": 'getComLostConnectionState', + "blockType": "Boolean", + "text": 'Lost connection?', + }, + { + "opcode": 'getComFailedConnectionState', + "blockType": "Boolean", + "text": 'Failed to connnect?', + }, + { + "opcode": 'getUsernameState', + "blockType": "Boolean", + "text": 'Username synced?', + }, + { + "opcode": 'returnIsNewData', + "blockType": "Boolean", + "text": 'Got New [TYPE]?', + "arguments": { + "TYPE": { + "type": "string", + "menu": "datamenu", + "defaultValue": 'Global data', + }, + }, + }, + { + "opcode": 'returnIsNewVarData', + "blockType": "Boolean", + "text": 'Got New [TYPE] data for variable [VAR]?', + "arguments": { + "TYPE": { + "type": "string", + "menu": "varmenu", + "defaultValue": 'Global variables', + }, + "VAR": { + "type": "string", + "defaultValue": 'Apple', + }, + }, + }, + { + "opcode": 'returnIsNewListener', + "blockType": "Boolean", + "text": 'Got new packet with listener [ID]?', + "blockAllThreads": "true", + "arguments": { + "ID": { + "type": "string", + "defaultValue": "example-listener", + }, + }, + }, + { + "opcode": 'checkForID', + "blockType": "Boolean", + "text": 'ID [ID] connected?', + "arguments": { + "ID": { + "type": "string", + "defaultValue": 'Another name', + }, + }, + }, + { + "opcode": 'isValidJSON', + "blockType": "Boolean", + "text": 'Is [JSON_STRING] valid JSON?', + "arguments": { + "JSON_STRING": { + "type": "string", + "defaultValue": '{"fruit": {"apples": 2, "bananas": 3}, "total_fruit": 5}', + }, + }, + }, + { + "opcode": 'openSocket', + "blockType": "command", + "text": 'Connect to [IP]', + "blockAllThreads": "true", + "arguments": { + "IP": { + "type": "string", + "defaultValue": 'ws://127.0.0.1:3000/', + }, + }, + }, + { + "opcode": 'openSocketPublicServers', + "blockType": "command", + "text": 'Connect to server [ID]', + "blockAllThreads": "true", + "arguments": { + "ID": { + "type": "number", + "defaultValue": '', + }, + }, + }, + { + "opcode": 'closeSocket', + "blockType": "command", + "blockAllThreads": "true", + "text": 'Disconnect', + }, + { + "opcode": 'setMyName', + "blockType": "command", + "text": 'Set [NAME] as username', + "blockAllThreads": "true", + "arguments": { + "NAME": { + "type": "string", + "defaultValue": "A name", + }, + }, + }, + { + "opcode": 'createListener', + "blockType": "command", + "text": 'Attach listener [ID] to next packet', + "blockAllThreads": "true", + "arguments": { + "ID": { + "type": "string", + "defaultValue": "example-listener", + }, + }, + }, + { + "opcode": 'linkToRooms', + "blockType": "command", + "text": 'Link to room(s) [ROOMS]', + "blockAllThreads": "true", + "arguments": { + "ROOMS": { + "type": "string", + "defaultValue": '["test"]', + }, + } + }, + { + "opcode": 'selectRoomsInNextPacket', + "blockType": "command", + "text": 'Select room(s) [ROOMS] for next packet', + "blockAllThreads": "true", + "arguments": { + "ROOMS": { + "type": "string", + "defaultValue": '["test"]', + }, + }, + }, + { + "opcode": 'unlinkFromRooms', + "blockType": "command", + "text": 'Unlink from all rooms', + "blockAllThreads": "true" + }, + { + "opcode": 'sendGData', + "blockType": "command", + "text": 'Send [DATA]', + "blockAllThreads": "true", + "arguments": { + "DATA": { + "type": "string", + "defaultValue": 'Apple' + } + } + }, + { + "opcode": 'sendPData', + "blockType": "command", + "text": 'Send [DATA] to [ID]', + "blockAllThreads": "true", + "arguments": { + "DATA": { + "type": "string", + "defaultValue": 'Apple' + }, + "ID": { + "type": "string", + "defaultValue": 'Another name' + } + } + }, + { + "opcode": 'sendGDataAsVar', + "blockType": "command", + "text": 'Send variable [VAR] with data [DATA]', + "blockAllThreads": "true", + "arguments": { + "DATA": { + "type": "string", + "defaultValue": 'Banana' + }, + "VAR": { + "type": "string", + "defaultValue": 'Apple' + } + } + }, + { + "opcode": 'sendPDataAsVar', + "blockType": "command", + "text": 'Send variable [VAR] to [ID] with data [DATA]', + "blockAllThreads": "true", + "arguments": { + "DATA": { + "type": "string", + "defaultValue": 'Banana' + }, + "ID": { + "type": "string", + "defaultValue": 'Another name' + }, + "VAR": { + "type": "string", + "defaultValue": 'Apple' + } + } + }, + { + "opcode": 'runCMDnoID', + "blockType": "command", + "text": 'Send command without ID [CMD] [DATA]', + "blockAllThreads": "true", + "arguments": { + "CMD": { + "type": "string", + "defaultValue": 'direct' + }, + "DATA": { + "type": "string", + "defaultValue": 'val' + } + } + }, + { + "opcode": 'runCMD', + "blockType": "command", + "text": 'Send command [CMD] [ID] [DATA]', + "blockAllThreads": "true", + "arguments": { + "CMD": { + "type": "string", + "defaultValue": 'direct' + }, + "ID": { + "type": "string", + "defaultValue": 'id' + }, + "DATA": { + "type": "string", + "defaultValue": 'val' + } + } + }, + { + "opcode": 'resetNewData', + "blockType": "command", + "text": 'Reset got new [TYPE] status', + "blockAllThreads": "true", + "arguments": { + "TYPE": { + "type": "string", + "menu": "datamenu", + "defaultValue": 'Global data' + } + } + }, + { + "opcode": 'resetNewVarData', + "blockType": "command", + "text": 'Reset got new [TYPE] [VAR] status', + "blockAllThreads": "true", + "arguments": { + "TYPE": { + "type": "string", + "menu": "varmenu", + "defaultValue": 'Global variables' + }, + "VAR": { + "type": "string", + "defaultValue": 'Apple' + } + } + }, + { + "opcode": 'resetNewListener', + "blockType": "command", + "text": 'Reset got new [ID] listener status', + "blockAllThreads": "true", + "arguments": { + "ID": { + "type": "string", + "defaultValue": 'example-listener' + } + } + }, + { + "opcode": 'clearAllPackets', + "blockType": "command", + "text": "Clear all packets for [TYPE]", + "arguments": { + "TYPE": { + "type": "string", + "menu": "allmenu", + "defaultValue": "All data" + }, + }, + } + ], + "menus": { + "coms": { + "items": ["Connected", "Username synced"] + }, + "datamenu": { + "items": ['Global data', 'Private data', 'Direct data', 'Status code'] + }, + "varmenu": { + "items": ['Global variables', 'Private variables'] + }, + "allmenu": { + "items": ['Global data', 'Private data', 'Direct data', 'Status code', "Global variables", "Private variables", "All data"] + }, + "almostallmenu": { + "items": ['Global data', 'Private data', 'Direct data', 'Status code', "Global variables", "Private variables"] + }, + }, + }; + }; + + // Code for blocks go here + + returnGlobalData() { + if (this.socketData.gmsg.length != 0) { + + let data = (this.socketData.gmsg[this.socketData.gmsg.length - 1].val); + + if (typeof(data) == "object") { + data = JSON.stringify(data); // Make the JSON safe for Scratch + } + + return data; + } else { + return ""; + }; + }; + + returnPrivateData() { + if (this.socketData.pmsg.length != 0) { + let data = (this.socketData.pmsg[this.socketData.pmsg.length - 1].val); + + if (typeof (data) == "object") { + data = JSON.stringify(data); // Make the JSON safe for Scratch + } + + return data; + } else { + return ""; + }; + }; + + returnDirectData() { + if (this.socketData.direct.length != 0) { + let data = (this.socketData.direct[this.socketData.direct.length - 1].val); + + if (typeof (data) == "object") { + data = JSON.stringify(data); // Make the JSON safe for Scratch + } + + return data; + } else { + return ""; + }; + }; + + returnLinkData() { + return String(this.link_status); + }; + + returnStatusCode() { + if (this.socketData.statuscode.length != 0) { + let data = (this.socketData.statuscode[this.socketData.statuscode.length - 1].code); + + if (typeof (data) == "object") { + data = JSON.stringify(data); // Make the JSON safe for Scratch + } + + return data; + } else { + return ""; + }; + }; + + returnUserListData() { + return JSON.stringify(this.socketData.ulist); + }; + + returnUsernameData() { + let data = this.username; + + if (typeof (data) == "object") { + data = JSON.stringify(data); // Make the JSON safe for Scratch + } + + return data; + }; + + returnVersionData() { + return String(this.version); + }; + + returnServerVersion() { + return String(this.socketData.server_version); + }; + + returnServerList() { + return JSON.stringify(servers); + }; + + returnMOTD() { + return String(this.socketData.motd); + }; + + returnClientIP() { + return String(this.socketData.client_ip); + }; + + returnListenerData({ID}) { + const self = this; + if ((this.isRunning) && (this.socketListeners.hasOwnProperty(String(ID)))) { + return JSON.stringify(this.socketListenersData[ID]); + } else { + return "{}"; + }; + }; + + readQueueSize({TYPE}) { + if (this.menuRemap[String(TYPE)] == "all") { + let tmp_size = 0; + tmp_size = tmp_size + this.socketData.gmsg.length; + tmp_size = tmp_size + this.socketData.pmsg.length; + tmp_size = tmp_size + this.socketData.direct.length; + tmp_size = tmp_size + this.socketData.statuscode.length; + tmp_size = tmp_size + this.socketData.gvar.length; + tmp_size = tmp_size + this.socketData.pvar.length; + return tmp_size; + } else { + return this.socketData[this.menuRemap[String(TYPE)]].length; + }; + }; + + readQueueData({TYPE}) { + if (this.menuRemap[String(TYPE)] == "all") { + let tmp_socketData = JSON.parse(JSON.stringify(this.socketData)); // Deep copy + + delete tmp_socketData.motd; + delete tmp_socketData.client_ip; + delete tmp_socketData.ulist; + delete tmp_socketData.server_version; + + return JSON.stringify(tmp_socketData); + } else { + return JSON.stringify(this.socketData[this.menuRemap[String(TYPE)]]); + }; + }; + + returnVarData({ TYPE, VAR }) { + if (this.isRunning) { + if (this.varData.hasOwnProperty(this.menuRemap[TYPE])) { + if (this.varData[this.menuRemap[TYPE]].hasOwnProperty(VAR)) { + return this.varData[this.menuRemap[TYPE]][VAR].value; + } else { + return ""; + }; + } else { + return ""; + }; + } else { + return ""; + }; + }; + + parseJSON({PATH, JSON_STRING}) { + try { + const path = PATH.toString().split('/').map(prop => decodeURIComponent(prop)); + if (path[0] === '') path.splice(0, 1); + if (path[path.length - 1] === '') path.splice(-1, 1); + let json; + try { + json = JSON.parse(' ' + JSON_STRING); + } catch (e) { + return e.message; + }; + path.forEach(prop => json = json[prop]); + if (json === null) return 'null'; + else if (json === undefined) return ''; + else if (typeof json === 'object') return JSON.stringify(json); + else return json.toString(); + } catch (err) { + return ''; + }; + }; + + getFromJSONArray({NUM, ARRAY}) { + var json_array = JSON.parse(ARRAY); + if (json_array[NUM] == "undefined") { + return ""; + } else { + let data = json_array[NUM]; + + if (typeof (data) == "object") { + data = JSON.stringify(data); // Make the JSON safe for Scratch + } + + return data; + } + }; + + fetchURL(args) { + return fetch(args.url, { + method: "GET" + }).then(response => response.text()); + }; + + requestURL(args) { + if (args.method == "GET" || args.method == "HEAD") { + return fetch(args.url, { + method: args.method, + headers: JSON.parse(args.headers) + }).then(response => response.text()); + } else { + return fetch(args.url, { + method: args.method, + headers: JSON.parse(args.headers), + body: JSON.parse(args.data) + }).then(response => response.text()); + } + }; + + isValidJSON({JSON_STRING}) { + return jsonCheck(JSON_STRING); + }; + + makeJSON({toBeJSONified}) { + if (typeof(toBeJSONified) == "string") { + try { + JSON.parse(toBeJSONified); + return String(toBeJSONified); + } catch(err) { + return "Not JSON!"; + } + } else if (typeof(toBeJSONified) == "object") { + return JSON.stringify(toBeJSONified); + } else { + return "Not JSON!"; + }; + }; + + onConnect() { + const self = this; + if (self.connect_hat == 0 && self.isRunning && self.protocolOk) { + self.connect_hat = 1; + return true; + } else { + return false; + }; + }; + + onClose() { + const self = this; + if (self.close_hat == 0 && !self.isRunning) { + self.close_hat = 1; + return true; + } else { + return false; + }; + }; + + onListener({ ID }) { + const self = this; + if ((this.isRunning) && (this.socketListeners.hasOwnProperty(String(ID)))) { + if (self.socketListeners[String(ID)]) { + self.socketListeners[String(ID)] = false; + return true; + } else { + return false; + }; + } else { + return false; + }; + }; + + onNewPacket({ TYPE }) { + const self = this; + if ((this.isRunning) && (this.newSocketData[this.menuRemap[String(TYPE)]])) { + self.newSocketData[this.menuRemap[String(TYPE)]] = false; + return true; + } else { + return false; + }; + }; + + onNewVar({ TYPE, VAR }) { + const self = this; + if (this.isRunning) { + if (this.varData.hasOwnProperty(this.menuRemap[TYPE])) { + if (this.varData[this.menuRemap[TYPE]].hasOwnProperty(VAR)) { + if (this.varData[this.menuRemap[TYPE]][VAR].isNew) { + self.varData[this.menuRemap[TYPE]][VAR].isNew = false; + return true; + } else { + return false; + } + } else { + return false; + }; + } else { + return false; + }; + } else { + return false; + }; + }; + + getComState(){ + return String((this.link_status == 2) || this.protocolOk); + }; + + getRoomState() { + return this.isLinked; + }; + + getComLostConnectionState() { + return this.wasConnectionDropped; + }; + + getComFailedConnectionState() { + return this.didConnectionFail; + }; + + getUsernameState(){ + return this.isUsernameSet; + }; + + returnIsNewData({TYPE}){ + if (this.isRunning) { + return this.newSocketData[this.menuRemap[String(TYPE)]]; + } else { + return false; + }; + }; + + returnIsNewVarData({ TYPE, VAR }) { + if (this.isRunning) { + if (this.varData.hasOwnProperty(this.menuRemap[TYPE])) { + if (this.varData[this.menuRemap[TYPE]].hasOwnProperty(VAR)) { + return this.varData[this.menuRemap[TYPE]][VAR].isNew; + } else { + return false; + }; + } else { + return false; + }; + } else { + return false; + }; + }; + + returnIsNewListener({ ID }) { + if (this.isRunning) { + if (this.socketListeners.hasOwnProperty(String(ID))) { + return this.socketListeners[ID]; + } else { + return false; + }; + } else { + return false; + }; + }; + + checkForID({ ID }) { + return find_id(ID, this.socketData.ulist); + }; + + openSocket({IP}) { + const self = this; + if (!self.isRunning) { + console.log("Starting socket."); + self.link_status = 1; + + self.disconnectWasClean = false; + self.wasConnectionDropped = false; + self.didConnectionFail = false; + + mWS = new WebSocket(String(IP)); + + mWS.onerror = function(){ + self.isRunning = false; + }; + + mWS.onopen = function(){ + self.isRunning = true; + self.packet_queue = {}; + self.link_status = 2; + + // Send the handshake request to get server to detect client protocol + mWS.send(JSON.stringify({"cmd": "handshake", "listener": "setprotocol"})) + + console.log("Successfully opened socket."); + }; + + mWS.onmessage = function(event){ + let tmp_socketData = JSON.parse(event.data); + console.log("RX:", tmp_socketData); + + if (self.queueableCmds.includes(tmp_socketData.cmd)) { + self.socketData[tmp_socketData.cmd].push(tmp_socketData); + } else { + if (tmp_socketData.cmd == "ulist") { + // ulist functionality has been changed in server 0.1.9 + if (tmp_socketData.hasOwnProperty("mode")) { + if (tmp_socketData.mode == "set") { + self.socketData["ulist"] = tmp_socketData.val; + } else if (tmp_socketData.mode == "add") { + if (!self.socketData.ulist.some(o => ((o.username === tmp_socketData.val.username) && (o.id == tmp_socketData.val.id)))) { + self.socketData["ulist"].push(tmp_socketData.val); + } else { + console.log("Could not perform ulist method add, client", tmp_socketData.val, "already exists"); + }; + } else if (tmp_socketData.mode == "remove") { + if (self.socketData.ulist.some(o => ((o.username === tmp_socketData.val.username) && (o.id == tmp_socketData.val.id)))) { + // This is by far the fugliest thing I have ever written in JS, or in any programming language... thanks I hate it + self.socketData["ulist"] = self.socketData["ulist"].filter(user => ((!(user.username === tmp_socketData.val.username)) && (!(user.id == tmp_socketData.val.id)))); + } else { + console.log("Could not perform ulist method remove, client", tmp_socketData.val, "was not found"); + }; + } else { + console.log("Could not understand ulist method:", tmp_socketData.mode); + }; + } else { + // Retain compatibility wtih existing servers + self.socketData["ulist"] = tmp_socketData.val; + }; + } else { + self.socketData[tmp_socketData.cmd] = tmp_socketData.val; + }; + }; + + if (self.newSocketData.hasOwnProperty(tmp_socketData.cmd)) { + self.newSocketData[tmp_socketData.cmd] = true; + }; + + if (self.varCmds.includes(tmp_socketData.cmd)) { + self.varData[tmp_socketData.cmd][tmp_socketData.name] = { + "value": tmp_socketData.val, + "isNew": true + }; + }; + if (tmp_socketData.hasOwnProperty("listener")) { + if (tmp_socketData.listener == "setusername") { + self.socketListeners["setusername"] = true; + if (tmp_socketData.code == "I:100 | OK") { + self.username = tmp_socketData.val; + self.isUsernameSyncing = false; + self.isUsernameSet = true; + console.log("Username was accepted by the server, and has been set to:", self.username); + } else { + console.warn("Username was rejected by the server. Error code:", String(tmp_socketData.code)); + self.isUsernameSyncing = false; + }; + } else if (tmp_socketData.listener == "roomLink") { + self.isRoomSetting = false; + self.socketListeners["roomLink"] = true; + if (tmp_socketData.code == "I:100 | OK") { + console.log("Linking to room(s) was accepted by the server!"); + self.isLinked = true; + } else { + console.warn("Linking to room(s) was rejected by the server. Error code:", String(tmp_socketData.code)); + self.enableRoom = false; + self.isLinked = false; + self.selectRoom = ""; + }; + } else if ((tmp_socketData.listener == "setprotocol") && (!this.protocolOk)) { + console.log("Server successfully set client protocol to cloudlink!"); + self.socketData.statuscode = []; + self.protocolOk = true; + self.socketListeners["setprotocol"] = true; + } else { + if (self.socketListeners.hasOwnProperty(tmp_socketData.listener)) { + self.socketListeners[tmp_socketData.listener] = true; + }; + }; + self.socketListenersData[tmp_socketData.listener] = tmp_socketData; + }; + self.packet_hat = 0; + }; + + mWS.onclose = function() { + self.isRunning = false; + self.connect_hat = 0; + self.packet_hat = 0; + self.protocolOk = false; + if (self.close_hat == 1) { + self.close_hat = 0; + }; + self.socketData = { + "gmsg": [], + "pmsg": [], + "direct": [], + "statuscode": [], + "gvar": [], + "pvar": [], + "motd": "", + "client_ip": "", + "ulist": [], + "server_version": "" + }; + self.newSocketData = { + "gmsg": false, + "pmsg": false, + "direct": false, + "statuscode": false, + "gvar": false, + "pvar": false + }; + self.socketListeners = {}; + self.username = ""; + self.tmp_username = ""; + self.isUsernameSyncing = false; + self.isUsernameSet = false; + self.enableListener = false; + self.setListener = ""; + self.enableRoom = false; + self.selectRoom = ""; + self.isLinked = false; + self.isRoomSetting = false; + + if (self.link_status != 1) { + if (self.disconnectWasClean) { + self.link_status = 3; + console.log("Socket closed."); + self.wasConnectionDropped = false; + self.didConnectionFail = false; + } else { + self.link_status = 4; + console.error("Lost connection to the server."); + self.wasConnectionDropped = true; + self.didConnectionFail = false; + }; + } else { + self.link_status = 4; + console.error("Failed to connect to server."); + self.wasConnectionDropped = false; + self.didConnectionFail = true; + }; + }; + } else { + console.warn("Socket is already open."); + }; + } + + openSocketPublicServers({ ID }){ + if (servers.hasOwnProperty(ID)) { + console.log("Connecting to:", servers[ID].url) + this.openSocket({"IP": servers[ID].url}); + }; + }; + + closeSocket(){ + const self = this; + if (this.isRunning) { + console.log("Closing socket..."); + mWS.close(1000,'script closure'); + self.disconnectWasClean = true; + } else { + console.warn("Socket is not open."); + }; + } + + setMyName({NAME}) { + const self = this; + if (this.isRunning) { + if (!this.isUsernameSyncing) { + if (!this.isUsernameSet){ + if (String(NAME) != "") { + if ((!(String(NAME).length > 20))) { + if (!(String(NAME) == "%CA%" || String(NAME) == "%CC%" || String(NAME) == "%CD%" || String(NAME) == "%MS%")){ + let tmp_msg = { + cmd: "setid", + val: String(NAME), + listener: "setusername" + }; + + console.log("TX:", tmp_msg); + mWS.send(JSON.stringify(tmp_msg)); + + self.tmp_username = String(NAME); + self.isUsernameSyncing = true; + + } else { + console.log("Blocking attempt to use reserved usernames"); + }; + } else { + console.log("Blocking attempt to use username larger than 20 characters, username is " + String(NAME).length + " characters long"); + }; + } else { + console.log("Blocking attempt to use blank username"); + }; + } else { + console.warn("Username already has been set!"); + }; + } else { + console.warn("Username is still syncing!"); + }; + }; + }; + + createListener({ ID }) { + self = this; + if (this.isRunning) { + if (!this.enableListener) { + self.enableListener = true; + self.setListener = String(ID); + } else { + console.warn("Listeners were already created!"); + }; + } else { + console.log("Cannot assign a listener to a packet while disconnected"); + }; + }; + + linkToRooms({ ROOMS }) { + const self = this; + + if (this.isRunning) { + if (!this.isRoomSetting) { + if (!(String(ROOMS).length > 1000)) { + let tmp_msg = { + cmd: "link", + val: autoConvert(ROOMS), + listener: "roomLink" + }; + + console.log("TX:", tmp_msg); + mWS.send(JSON.stringify(tmp_msg)); + + self.isRoomSetting = true; + + } else { + console.warn("Blocking attempt to send a room ID / room list larger than 1000 bytes (1 KB), room ID / room list is " + String(ROOMS).length + " bytes"); + }; + } else { + console.warn("Still linking to rooms!"); + }; + } else { + console.warn("Socket is not open."); + }; + }; + + selectRoomsInNextPacket({ROOMS}) { + const self = this; + if (this.isRunning) { + if (this.isLinked) { + if (!this.enableRoom) { + if (!(String(ROOMS).length > 1000)) { + self.enableRoom = true; + self.selectRoom = ROOMS; + } else { + console.warn("Blocking attempt to select a room ID / room list larger than 1000 bytes (1 KB), room ID / room list is " + String(ROOMS).length + " bytes"); + }; + } else { + console.warn("Rooms were already selected!"); + }; + } else { + console.warn("Not linked to any room(s)!"); + }; + } else { + console.warn("Socket is not open."); + }; + }; + + unlinkFromRooms() { + const self = this; + if (this.isRunning) { + if (this.isLinked) { + let tmp_msg = { + cmd: "unlink", + val: "" + }; + + if (this.enableListener) { + tmp_msg["listener"] = autoConvert(this.setListener); + }; + + console.log("TX:", tmp_msg); + mWS.send(JSON.stringify(tmp_msg)); + + if (this.enableListener) { + if (!self.socketListeners.hasOwnProperty(this.setListener)) { + self.socketListeners[this.setListener] = false; + }; + self.enableListener = false; + }; + + self.isLinked = false; + } else { + console.warn("Not linked to any rooms!"); + }; + } else { + console.warn("Socket is not open."); + }; + }; + + sendGData({DATA}){ + const self = this; + if (this.isRunning) { + if (!(String(DATA).length > 1000)) { + let tmp_msg = { + cmd: "gmsg", + val: autoConvert(DATA) + }; + + if (this.enableListener) { + tmp_msg["listener"] = String(this.setListener); + }; + + if (this.enableRoom) { + tmp_msg["rooms"] = autoConvert(this.selectRoom); + }; + + console.log("TX:", tmp_msg); + mWS.send(JSON.stringify(tmp_msg)); + + if (this.enableListener) { + if (!self.socketListeners.hasOwnProperty(this.setListener)) { + self.socketListeners[this.setListener] = false; + }; + self.enableListener = false; + }; + if (this.enableRoom) { + self.enableRoom = false; + self.selectRoom = ""; + }; + + } else { + console.warn("Blocking attempt to send packet larger than 1000 bytes (1 KB), packet is " + String(DATA).length + " bytes"); + }; + } else { + console.warn("Socket is not open."); + }; + }; + + sendPData({DATA, ID}) { + const self = this; + if (this.isRunning) { + if (!(String(DATA).length > 1000)) { + let tmp_msg = { + cmd: "pmsg", + val: autoConvert(DATA), + id: autoConvert(ID) + } + + if (this.enableListener) { + tmp_msg["listener"] = String(this.setListener); + }; + if (this.enableRoom) { + tmp_msg["rooms"] = autoConvert(this.selectRoom); + }; + + console.log("TX:", tmp_msg); + mWS.send(JSON.stringify(tmp_msg)); + + if (this.enableListener) { + if (!self.socketListeners.hasOwnProperty(this.setListener)) { + self.socketListeners[this.setListener] = false; + }; + self.enableListener = false; + }; + if (this.enableRoom) { + self.enableRoom = false; + self.selectRoom = ""; + }; + + } else { + console.warn("Blocking attempt to send packet larger than 1000 bytes (1 KB), packet is " + String(DATA).length + " bytes"); + }; + } else { + console.warn("Socket is not open."); + }; + }; + + sendGDataAsVar({VAR, DATA }) { + const self = this; + if (this.isRunning) { + if (!(String(DATA).length > 1000)) { + let tmp_msg = { + cmd: "gvar", + name: VAR, + val: autoConvert(DATA) + } + + if (this.enableListener) { + tmp_msg["listener"] = String(this.setListener); + }; + if (this.enableRoom) { + tmp_msg["rooms"] = autoConvert(this.selectRoom); + }; + + console.log("TX:", tmp_msg); + mWS.send(JSON.stringify(tmp_msg)); + + if (this.enableListener) { + if (!self.socketListeners.hasOwnProperty(this.setListener)) { + self.socketListeners[this.setListener] = false; + }; + self.enableListener = false; + }; + if (this.enableRoom) { + self.enableRoom = false; + self.selectRoom = ""; + }; + + } else { + console.warn("Blocking attempt to send packet larger than 1000 bytes (1 KB), packet is " + String(DATA).length + " bytes"); + }; + } else { + console.warn("Socket is not open."); + }; + }; + + sendPDataAsVar({VAR, ID, DATA}) { + const self = this; + if (this.isRunning) { + if (!(String(DATA).length > 1000)) { + let tmp_msg = { + cmd: "pvar", + name: VAR, + val: autoConvert(DATA), + id: autoConvert(ID) + } + + if (this.enableListener) { + tmp_msg["listener"] = String(this.setListener); + }; + if (this.enableRoom) { + tmp_msg["rooms"] = autoConvert(this.selectRoom); + }; + + console.log("TX:", tmp_msg); + mWS.send(JSON.stringify(tmp_msg)); + + if (this.enableListener) { + if (!self.socketListeners.hasOwnProperty(this.setListener)) { + self.socketListeners[this.setListener] = false; + }; + self.enableListener = false; + }; + if (this.enableRoom) { + self.enableRoom = false; + self.selectRoom = ""; + }; + + } else { + console.warn("Blocking attempt to send packet larger than 1000 bytes (1 KB), packet is " + String(DATA).length + " bytes"); + }; + } else { + console.warn("Socket is not open."); + }; + }; + + runCMDnoID({CMD, DATA}) { + const self = this; + if (this.isRunning) { + if (!(String(CMD).length > 100) || !(String(DATA).length > 1000)) { + let tmp_msg = { + cmd: String(CMD), + val: autoConvert(DATA) + } + + if (this.enableListener) { + tmp_msg["listener"] = String(this.setListener); + }; + if (this.enableRoom) { + tmp_msg["rooms"] = String(this.selectRoom); + }; + + console.log("TX:", tmp_msg); + mWS.send(JSON.stringify(tmp_msg)); + + if (this.enableListener) { + if (!self.socketListeners.hasOwnProperty(this.setListener)) { + self.socketListeners[this.setListener] = false; + }; + self.enableListener = false; + }; + if (this.enableRoom) { + self.enableRoom = false; + self.selectRoom = ""; + }; + + } else { + console.warn("Blocking attempt to send packet with questionably long arguments"); + }; + } else { + console.warn("Socket is not open."); + }; + }; + + runCMD({CMD, ID, DATA}) { + const self = this; + if (this.isRunning) { + if (!(String(CMD).length > 100) || !(String(ID).length > 20) || !(String(DATA).length > 1000)) { + let tmp_msg = { + cmd: String(CMD), + id: autoConvert(ID), + val: autoConvert(DATA) + } + + if (this.enableListener) { + tmp_msg["listener"] = String(this.setListener); + }; + if (this.enableRoom) { + tmp_msg["rooms"] = String(this.selectRoom); + }; + + console.log("TX:", tmp_msg); + mWS.send(JSON.stringify(tmp_msg)); + + if (this.enableListener) { + if (!self.socketListeners.hasOwnProperty(this.setListener)) { + self.socketListeners[this.setListener] = false; + }; + self.enableListener = false; + }; + if (this.enableRoom) { + self.enableRoom = false; + self.selectRoom = ""; + }; + + } else { + console.warn("Blocking attempt to send packet with questionably long arguments"); + }; + } else { + console.warn("Socket is not open."); + }; + }; + + resetNewData({TYPE}){ + const self = this; + if (this.isRunning) { + self.newSocketData[this.menuRemap[String(TYPE)]] = false; + }; + }; + + resetNewVarData({ TYPE, VAR }) { + const self = this; + if (this.isRunning) { + if (this.varData.hasOwnProperty(this.menuRemap[TYPE])) { + if (this.varData[this.menuRemap[TYPE]].hasOwnProperty(VAR)) { + self.varData[this.menuRemap[TYPE]][VAR].isNew = false; + }; + }; + }; + }; + + resetNewListener({ ID }) { + const self = this; + if (this.isRunning) { + if (this.socketListeners.hasOwnProperty(String(ID))) { + self.socketListeners[String(ID)] = false; + }; + }; + }; + + clearAllPackets({TYPE}){ + const self = this; + if (this.menuRemap[String(TYPE)] == "all") { + self.socketData.gmsg = []; + self.socketData.pmsg = []; + self.socketData.direct = []; + self.socketData.statuscode = []; + self.socketData.gvar = []; + self.socketData.pvar = []; + } else { + self.socketData[this.menuRemap[String(TYPE)]] = []; + }; + }; +}; + +(function() { + var extensionClass = CloudLink; + if (typeof window === "undefined" || !window.vm) { + Scratch.extensions.register(new extensionClass()); + console.log("CloudLink 4.0 loaded. Detecting sandboxed mode, performance will suffer. Please load CloudLink in Unsandboxed mode."); + } else { + var extensionInstance = new extensionClass(window.vm.extensionManager.runtime); + var serviceName = window.vm.extensionManager._registerInternalExtension(extensionInstance); + window.vm.extensionManager._loadedExtensions.set(extensionInstance.getInfo().id, serviceName); + console.log("CloudLink 4.0 loaded. Detecting unsandboxed mode."); + }; +})()