diff --git a/.gitignore b/.gitignore index dba52881..04b5e93e 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,13 @@ identifier.sqlite obfuscated.sb3 .pytest_cache .ruff_cache + +.coverage +certfile.pem +coverage.xml +keyfile.pem +.test/test.py +.test/teststststs.py +.test/ws_client_test.py +tests/test_tw_cloud_debug_compliance.py .vscode diff --git a/scratchattach/__init__.py b/scratchattach/__init__.py index a2329989..acdcc8ec 100644 --- a/scratchattach/__init__.py +++ b/scratchattach/__init__.py @@ -1,15 +1,33 @@ -from .cloud.cloud import CustomCloud, ScratchCloud, TwCloud, get_cloud, get_scratch_cloud, get_tw_cloud +from .cloud.cloud import ( + CustomCloud, + ScratchCloud, + TwCloud, + get_cloud, + get_scratch_cloud, + get_tw_cloud, +) from .cloud._base import BaseCloud, AnyCloud -from .eventhandlers.cloud_server import init_cloud_server -from .eventhandlers._base import BaseEventHandler +from .eventhandlers.cloud_server import ( + init_cloud_server, + init_ssl_cloud_server, + TwCloudSocket, + TwCloudServer, + TwSSLCloudServer, +) +from .eventhandlers._base import BaseEventHandler, BaseCloudServer from .eventhandlers.filterbot import Filterbot, HardFilter, SoftFilter, SpamFilter from .eventhandlers.cloud_storage import Database from .eventhandlers.combine import MultiEventHandler from .other.other_apis import * - -# from .other.project_json_capabilities import ProjectBody, get_empty_project_pb, get_pb_from_dict, read_sb3_file, download_asset +# from .other.project_json_capabilities import ( +# ProjectBody, +# get_empty_project_pb, +# get_pb_from_dict, +# read_sb3_file, +# download_asset, +# ) from .utils.encoder import Encoding from .utils.enums import Languages, TTSVoices from .utils.exceptions import ( @@ -27,11 +45,29 @@ from .site.cloud_activity import CloudActivity from .site.forum import ForumPost, ForumTopic, get_topic, get_topic_list, youtube_link_to_scratch from .site.project import Project, get_project, search_projects, explore_projects -from .site.session import Session, login, login_by_id, login_by_session_string, login_by_io, login_by_file, login_from_browser +from .site.session import ( + Session, + login, + login_by_id, + login_by_session_string, + login_by_io, + login_by_file, + login_from_browser, +) from .site.studio import Studio, get_studio, search_studios, explore_studios from .site.classroom import Classroom, get_classroom from .site.user import User, get_user, Rank from .site._base import BaseSiteComponent -from .site.browser_cookies import Browser, ANY, FIREFOX, CHROME, CHROMIUM, VIVALDI, EDGE, EDGE_DEV, SAFARI +from .site.browser_cookies import ( + Browser, + ANY, + FIREFOX, + CHROME, + CHROMIUM, + VIVALDI, + EDGE, + EDGE_DEV, + SAFARI, +) from . import editor diff --git a/scratchattach/cloud/_base.py b/scratchattach/cloud/_base.py index e0e49de2..b7e84f94 100644 --- a/scratchattach/cloud/_base.py +++ b/scratchattach/cloud/_base.py @@ -1,4 +1,6 @@ from __future__ import annotations +import queue +import threading import traceback import json @@ -13,6 +15,7 @@ from scratchattach.cloud import cloud as cloud_module if TYPE_CHECKING: + from scratchattach.eventhandlers import cloud_server from _typeshed import SupportsRead else: T = TypeVar("T") @@ -106,7 +109,7 @@ def set_vars( """ @abstractmethod - def get_var(self, var, *, recorder_initial_values: Optional[dict[str, Any]] = None) -> T: + def get_var(self, var, *, recorder_initial_values: Optional[dict[str, Any]] = None) -> T | None: pass @abstractmethod @@ -146,7 +149,7 @@ def create_event_stream(self) -> EventStream: pass -class DummyCloud(AnyCloud[Any]): +class DummyCloud(AnyCloud[T]): class DummyEventStream(EventStream): def read(self, length=...): return iter(()) @@ -174,12 +177,12 @@ def set_vars( ): pass - def get_var(self, var, *, recorder_initial_values: Optional[dict[str, Any]] = None) -> Any: - pass + def get_var(self, var, *, recorder_initial_values: Optional[dict[str, T]] = None) -> T | None: + return None def get_all_vars( - self, *, recorder_initial_values: Optional[dict[str, Any]] = None - ) -> dict[str, Any]: + self, *, recorder_initial_values: Optional[dict[str, T]] = None + ) -> dict[str, T]: return {} @@ -301,7 +304,7 @@ def close(self) -> None: self.source_cloud.disconnect() -class BaseCloud(AnyCloud[Union[str, int]]): +class BaseCloud(AnyCloud[Union[str, int, float]]): """ Base class for a project's cloud variables. Represents a cloud. @@ -557,12 +560,16 @@ def _ensure_recorder_running( time.sleep(0.01) return recorder - def get_var(self, var, *, recorder_initial_values: Optional[dict[str, Any]] = None): + def get_var( + self, var, *, recorder_initial_values: Optional[dict[str, Any]] = None + ) -> str | int | float | None: var = "☁ " + var.removeprefix("☁ ") recorder = self._ensure_recorder_running(recorder_initial_values=recorder_initial_values) return recorder.get_var(var) - def get_all_vars(self, *, recorder_initial_values: Optional[dict[str, Any]] = None): + def get_all_vars( + self, *, recorder_initial_values: Optional[dict[str, Any]] = None + ) -> dict[str, str | int | float]: recorder = self._ensure_recorder_running(recorder_initial_values=recorder_initial_values) return recorder.get_all_vars() @@ -623,3 +630,127 @@ def _get_cloud_var_initial_data_or_none(project_id: Union[str, int]) -> Optional return _get_cloud_var_initial_data(project_id) except Exception: return None + + +class CloudServerAdapter(AnyCloud[str | int | float]): + server: "cloud_server.BaseCloudServer" + disconnected: threading.Event + project_id: str | int + connected_event_stream_queues: dict[int, queue.Queue[dict[str, Any]]] + + def __init__(self, server: "cloud_server.BaseCloudServer", project_id: str | int): + self.server = server + self.disconnected = threading.Event() + self.project_id = project_id + self.connected_event_stream_queues = {} + + @self.server.event + def on_outgoing_packet(event): + for listener in self.connected_event_stream_queues.values(): + listener.put(event) + + def connect(self): + self.active_connection = True + self.disconnected.clear() + + def disconnect(self): + self.active_connection = False + self.disconnected.set() + + def reconnect(self): + self.disconnect() + time.sleep(0.1) + self.connect() + + def _enforce_ratelimit(self, *, n: int) -> None: + pass + + def set_var(self, variable: str, value: str | int | float, *, max_retries: int = 2) -> None: + """ + Sets a cloud variable. + + Args: + variable (str): The name of the cloud variable that should be set (provided without the cloud emoji) + value (Any): The value the cloud variable should be set to + + Kwargs: + max_retries (int) : Maximum number of times to retry setting the var if setting fails before raising an exception + """ + if self.disconnected.is_set(): + return + self.server.set_var( + self.project_id, + variable, + value, + ) + + def set_vars( + self, + var_value_dict: dict[str, str | int | float], + *, + intelligent_waits: bool = True, + max_retries: int = 2, + ): + """ + Sets multiple cloud variables at once (works for an unlimited amount of variables). + + Args: + var_value_dict (dict): variable:value dictionary with the variables / values to set. The dict should like this: {"var1":"value1", "var2":"value2", ...} + + Kwargs: + intelligent_waits (boolean): When enabled, the method will automatically decide how long to wait before performing this cloud variable set, to make sure no rate limits are triggered + max_retries (int) : Maximum number of times to retry setting the var if setting fails before raising an exception + """ + if self.disconnected.is_set(): + return + self.server.set_project_vars(self.project_id, var_value_dict) + + def get_var( + self, var, *, recorder_initial_values: Optional[dict[str, Any]] = None + ) -> str | int | float | None: + return self.server.get_var(self.project_id, var) + + def get_all_vars( + self, *, recorder_initial_values: Optional[dict[str, Any]] = None + ) -> dict[str, str | int | float]: + return self.server.get_project_vars(self.project_id).copy() + + def create_event_stream(self) -> CloudServerAdapterEventStream: + return CloudServerAdapterEventStream(self) + + +class CloudServerAdapterEventStream(EventStream): + adapter: CloudServerAdapter + disconnected: threading.Event + _queue: queue.Queue[dict[str, Any]] + + def __init__(self, adapter: CloudServerAdapter): + self.adapter = adapter + self.disconnected = threading.Event() + self._queue = queue.Queue() + self.adapter.connected_event_stream_queues[id(self)] = self._queue + + def read(self, amount: int = -1) -> Iterator[dict[str, Any]]: + if self.disconnected.is_set() or self.adapter.disconnected.is_set(): + return + end_time = time.time() + self.timeout if self.timeout is not None else None + progress = 0 + while (progress < amount if amount >= 0 else progress == 0) and ( + time.time() < end_time if end_time is not None else True + ): + try: + yield self._queue.get( + self.timeout is None or self.timeout > 0.0, + max(end_time - time.time(), 0) if end_time is not None else None, + ) + progress += 1 + except queue.Empty: + pass + + def __del__(self): + self.close() + + def close(self) -> None: + if not self.disconnected.is_set(): + del self.adapter.connected_event_stream_queues[id(self)] + self.disconnected.set() diff --git a/scratchattach/eventhandlers/_base.py b/scratchattach/eventhandlers/_base.py index 5eb01e70..d7381151 100644 --- a/scratchattach/eventhandlers/_base.py +++ b/scratchattach/eventhandlers/_base.py @@ -1,11 +1,20 @@ from __future__ import annotations +import json +import time +import ssl +import socket from abc import ABC, abstractmethod -from typing import Optional +from typing import Optional, Any, TYPE_CHECKING from collections import defaultdict from threading import Thread, Event from collections.abc import Callable import traceback + +from SimpleWebSocketServer import WebSocket + +if TYPE_CHECKING: + import scratchattach.cloud._base as cloud_base from scratchattach.utils.requests import requests from scratchattach.utils import exceptions @@ -125,3 +134,248 @@ def inner(function): else: # => the decorator doesn't provide arguments inner(function) + + +CLIENT_SEND_QUEUE_LIMIT = 1000 # if a client has more than this many messages in their send queue, disconnect them to prevent lag + + +class BaseCloudServer(BaseEventHandler): + """ + Base class for all sa cloud servers. + + If you are developing a custom cloud server with sa, please inherit from this class + and change up the methods as needed. + """ + + hostname: str + "IP address or domain name of the host to bind the server to." + port: int + "Port to bind the server to." + tw_clients: dict[tuple[str, int], dict[str, Any]] + "Dictionary containing information on connected clients." + tw_variables: dict[str, dict[str, Any]] + "Dictionary containing states and data for existing cloud variables." + allow_non_numeric: bool + "Whether or not non-numeric characters are allowed in cloud variable values." + whitelisted_projects: set[str] | None + "Optional list of whitelisted projects." + length_limit: int | None + "Optional limit on the length of cloud variable values." + allow_nonscratch_names: bool + "Whether or not usernames that do not exist on scratch are allowed." + blocked_ips: list[str] + "List of blocked IP addresses." + sync_players: bool + log_var_sets: bool + linked_clouds: dict[str, "cloud_base.CloudServerAdapter"] + + def __init__( + self, + hostname: str, + *, + port: int, + length_limit: int | None = None, + allow_non_numeric: bool = True, + whitelisted_projects: list[Any] | None = None, + allow_nonscratch_names: bool = True, + blocked_ips: list[str] | None = None, + sync_players: bool = True, + log_var_sets: bool = True, + ): + + if blocked_ips is None: + blocked_ips = [] + + BaseEventHandler.__init__(self) + + self.tw_clients = {} # saves connected clients + self.tw_variables = {} # holds cloud variable states + + self.hostname = hostname + self.port = port + + # server config + self.allow_non_numeric = allow_non_numeric + self.whitelisted_projects = ( + {str(i) for i in whitelisted_projects} if whitelisted_projects else None + ) + self.length_limit = length_limit + self.allow_nonscratch_names = allow_nonscratch_names + self.blocked_ips = blocked_ips + self.sync_players = sync_players + self.log_var_sets = log_var_sets + + self.linked_clouds = {} + + def check_for_ip_ban(self, client): + if ( + client.address[0] in self.blocked_ips + or client.address[0] + ":" + str(client.address[1]) in self.blocked_ips + or client.address in self.blocked_ips + ): + client.sendMessage("You have been banned from this server") + client.close(4002) + print(client.address[0] + ":" + str(client.address[1]), "(IP-banned) was disconnected") + return True + return False + + def active_projects(self): + only_active = {} + for project_id in self.tw_variables: + if self.active_user_ips(project_id) != []: + only_active[project_id] = self.tw_variables[project_id] + return only_active + + def active_user_names(self, project_id): + return [self.tw_clients[user]["username"] for user in self.active_user_ips(project_id)] + + def active_user_ips(self, project_id: Any): + project_id = str(project_id) + return [ + user + for user in self.tw_clients + if str(self.tw_clients[user]["project_id"]) == project_id + ] + + def get_global_vars(self): + return self.tw_variables + + def get_project_vars(self, project_id: Any): + project_id = str(project_id) + return self.tw_variables.get(project_id, {}) + + def get_var(self, project_id: Any, var_name: str, *, no_prefix: bool = False): + project_id = str(project_id) + if not no_prefix: + var_name = "☁ " + var_name.removeprefix("☁ ") + if project_id in self.tw_variables: + if var_name in self.tw_variables[project_id]: + return self.tw_variables[project_id][var_name] + else: + return None + else: + return None + + def set_global_vars( + self, + data: dict[str, dict[str, Any]], + no_prefix: bool = False, + ): + for project_id, project_data in data.items(): + self.set_project_vars(project_id, project_data, no_prefix=no_prefix) + + def _distribute_to_clients(self, project_id, json_string, *, skip_broadcast_for=None): + for client in [self.tw_clients[ip]["client"] for ip in self.active_user_ips(project_id)]: + if client == skip_broadcast_for: + continue + if ( + len(client.sendq) > CLIENT_SEND_QUEUE_LIMIT + ): # if the client has a large send queue, disconnect them to prevent lag + client.sendq.clear() # free the memory now + try: + client.client.shutdown(socket.SHUT_RDWR) + except OSError: + pass + continue + client.sendMessage(json_string) + + def set_project_vars( + self, + project_id: Any, + data: dict[str, Any], + *, + user: str = "@server", + no_prefix: bool = False, + ): + project_id = str(project_id) + if not no_prefix: + data = {"☁ " + key.removeprefix("☁ "): value for key, value in data.items()} + self.tw_variables.setdefault(project_id, {}).update(data) + packets = [ + ( + { + "method": "set", + "project_id": project_id, + "name": varname, + "value": data[varname], + "server": "scratchattach/3", + "timestamp": time.time() * 1000, + "user": user, + } + ) + for varname in data + ] + json_string = "\n".join(json.dumps(packet) for packet in packets) + self._distribute_to_clients(project_id, json_string) + for packet in packets: + self.call_event("outgoing_packet", [packet]) + + def set_var( + self, + project_id: Any, + var_name: str, + value: Any, + *, + user: str = "@server", + skip_broadcast_for: WebSocket | None = None, + no_prefix: bool = False, + ): + if not no_prefix: + var_name = "☁ " + var_name.removeprefix("☁ ") + project_id = str(project_id) + if project_id not in self.tw_variables: + self.tw_variables[project_id] = {} + self.tw_variables[project_id][var_name] = value + packet = { + "method": "set", + "project_id": project_id, + "name": var_name, + "value": value, + "timestamp": time.time() * 1000, + "user": user, + } + json_string = json.dumps(packet) + if self.sync_players: + self._distribute_to_clients( + project_id, json_string, skip_broadcast_for=skip_broadcast_for + ) + self.call_event("outgoing_packet", [packet]) + + def _check_value(self, value): + # Checks if a received cloud value satisfies the server's constraints + if self.length_limit is not None: + if len(str(value)) > self.length_limit: + return False + if not self.allow_non_numeric: + x = value.replace(".", "") + x = x.replace("-", "") + if not (x.isnumeric() or x == ""): + return False + return True + + def _updater(self): + try: + # Function called when .start() is executed (.start is inherited from BaseEventHandler) + print(f"Serving websocket server: ws://{self.hostname}:{self.port}") + while self.running: + self.serveonce() + except Exception as e: + raise exceptions.WebsocketServerError(str(e)) + + def get_project_cloud(self, project_id: Any) -> "cloud_base.CloudServerAdapter": + project_id = str(project_id) + if project_id not in self.linked_clouds: + from scratchattach.cloud import _base as cloud_base + + self.linked_clouds[project_id] = cloud_base.CloudServerAdapter(self, project_id) + return self.linked_clouds[project_id] + + def pause(self): + self.running = False + + def resume(self): + self.running = True + + def stop(self, wait_call_threads: bool = True): + BaseEventHandler.stop(self, wait_call_threads) + self.close() diff --git a/scratchattach/eventhandlers/cloud_events.py b/scratchattach/eventhandlers/cloud_events.py index 64b3281a..3d89e64c 100644 --- a/scratchattach/eventhandlers/cloud_events.py +++ b/scratchattach/eventhandlers/cloud_events.py @@ -63,7 +63,7 @@ def _updater(self): # continue cloud_activity_dict = cast(CloudActivityDict, data) cloud_activity_dict["variable_name"] = cloud_activity_dict["name"] - cloud_activity_dict["name"] = cloud_activity_dict["variable_name"].replace("☁ ", "") + cloud_activity_dict["name"] = cloud_activity_dict["variable_name"].removeprefix("☁ ") _a._update_from_dict(cloud_activity_dict) # print(f"sending event {_a}") self.call_event(f"on_{_a.type}", [_a]) diff --git a/scratchattach/eventhandlers/cloud_recorder.py b/scratchattach/eventhandlers/cloud_recorder.py index 1dcaf36e..e556af26 100644 --- a/scratchattach/eventhandlers/cloud_recorder.py +++ b/scratchattach/eventhandlers/cloud_recorder.py @@ -24,12 +24,12 @@ def __init__(self, cloud, *, initial_values: Optional[dict[str, Any]] = None): self.has_data.set() self.event(self.on_set) - def get_var(self, var): + def get_var(self, var) -> Any | None: if var not in self.cloud_values: return None return self.cloud_values[var] - def get_all_vars(self): + def get_all_vars(self) -> dict[str, Any]: return self.cloud_values.copy() def on_set(self, activity: cloud_activity.CloudActivity): diff --git a/scratchattach/eventhandlers/cloud_server.py b/scratchattach/eventhandlers/cloud_server.py index a1b69fee..24f018e9 100644 --- a/scratchattach/eventhandlers/cloud_server.py +++ b/scratchattach/eventhandlers/cloud_server.py @@ -1,41 +1,47 @@ from __future__ import annotations -import socket +from scratchattach.site.typed_dicts import CloudActivityDict -from SimpleWebSocketServer import SimpleWebSocketServer, WebSocket -from threading import Thread -from scratchattach.utils import exceptions import json import time -from scratchattach.site import cloud_activity -from scratchattach.site.user import User -from ._base import BaseEventHandler +import ssl import traceback +from typing import Any +from threading import Thread +import warnings -CLIENT_SEND_QUEUE_LIMIT = 1000 # if a client has more than this many messages in their send queue, disconnect them to prevent lag +from SimpleWebSocketServer import SimpleSSLWebSocketServer, SimpleWebSocketServer, WebSocket + +from scratchattach.utils import exceptions +from scratchattach.site import cloud_activity +from scratchattach.site.user import User +from scratchattach.cloud import BaseCloud, DummyCloud, AnyCloud +from ._base import BaseCloudServer class TwCloudSocket(WebSocket): - server: TwCloudServer + server: TwCloudServer | TwSSLCloudServer - def handle_set(self, data: dict): + def handle_set(self, data: dict[Any, Any]): # cloud variable set received # check if client handshaked and username is confirmed client_info = self.server.tw_clients.get(self.address) if client_info is None or not client_info["name_confirmed"]: return # check if project_id is in whitelisted projects (if there's a list of whitelisted projects) - if self.server.whitelisted_projects is not None: - if data["project_id"] not in self.server.whitelisted_projects: - self.close(4002) - if self.server.log_var_sets: - print( - self.address[0] + ":" + str(self.address[1]), - "tried to set a var on non-whitelisted project and was disconnected, project:", - data["project_id"], - "user:", - data["user"], - ) - return + if ( + self.server.whitelisted_projects is not None + and str(data["project_id"]) not in self.server.whitelisted_projects + ): + self.close(4002) + if self.server.log_var_sets: + print( + self.address[0] + ":" + str(self.address[1]), + "tried to set a var on non-whitelisted project and was disconnected, project:", + data["project_id"], + "user:", + data["user"], + ) + return # check if value is valid if not self.server._check_value(data["value"]): if self.server.log_var_sets: @@ -51,20 +57,22 @@ def handle_set(self, data: dict): data["user"], ) self.server.set_var( - data["project_id"], data["name"], data["value"], user=data["user"], skip_forward=self + data["project_id"], + data["name"], + data["value"], + user=data["user"], + skip_broadcast_for=self, + no_prefix=True, ) - send_to_clients = { + send_to_clients: CloudActivityDict = { "method": "set", - "user": data["user"], "project_id": data["project_id"], "name": data["name"], "value": data["value"], - "timestamp": round(time.time() * 1000), - "server": "scratchattach/3.0.0", + "cloud": self.server.get_project_cloud(data["project_id"]), } # raise event - _a = cloud_activity.CloudActivity(timestamp=time.time() * 1000) - data["name"] = data["name"].replace("☁ ", "") + _a = cloud_activity.CloudActivity(username=data["user"], timestamp=time.time() * 1000) _a._update_from_dict(send_to_clients) self.server.call_event("on_set", [_a, self]) @@ -99,7 +107,7 @@ def _user_existence_check(self, username: str): ) self.close(4002) - def handle_handshake(self, data: dict): + def handle_handshake(self, data: dict[Any, Any]): # check if handshake is valid if not "user" in data: print( @@ -129,7 +137,7 @@ def handle_handshake(self, data: dict): data["user"], ) return - # check if project_id is in username is allowed + # check if username is allowed if not self.server.allow_nonscratch_names: Thread(target=self._user_existence_check, args=(data["user"],), daemon=True).start() else: @@ -150,16 +158,16 @@ def handle_handshake(self, data: dict): { "method": "set", "project_id": data["project_id"], - "name": "☁ " + varname, + "name": varname, "value": self.server.tw_variables[str(data["project_id"])][varname], - "server": "scratchattach/3.0.0", + "server": "scratchattach/3", } ) for varname in self.server.get_project_vars(str(data["project_id"])) ] ) ) - self.sendMessage("This server uses @TimMcCool's scratchattach 3.0.0") + self.sendMessage("This server uses @TimMcCool's scratchattach v3.") # raise event if self.server.allow_nonscratch_names: # if username checks are enabled, the on_handshake event will be raised after the username existence check is completed (in _user_existence_check) self.server.call_event("on_handshake", [data["user"], data["project_id"], self]) @@ -184,7 +192,6 @@ def handleMessage(self): self.address[0] + ":" + str(self.address[1]), "sent a message without providing a valid method (set, handshake)", ) - except Exception as e: print("Internal error in handleMessage:", e, traceback.format_exc()) @@ -221,196 +228,103 @@ def handleClose(self): print("Internal error in handleClose:", e) -class TwCloudServer(SimpleWebSocketServer, BaseEventHandler): +class TwCloudServer(BaseCloudServer, SimpleWebSocketServer): def __init__( self, - hostname, + hostname: str, *, - port, - websocketclass, - length_limit=None, - allow_non_numeric=True, - whitelisted_projects=None, - allow_nonscratch_names=True, - blocked_ips=None, - sync_players=True, - log_var_sets=True, + port: int, + websocketclass: type[WebSocket], + length_limit: int | None = None, + allow_non_numeric: bool = True, + whitelisted_projects: list[Any] | None = None, + allow_nonscratch_names: bool = True, + blocked_ips: list[str] | None = None, + sync_players: bool = True, + log_var_sets: bool = True, ): if blocked_ips is None: blocked_ips = [] SimpleWebSocketServer.__init__(self, hostname, port=port, websocketclass=websocketclass) - BaseEventHandler.__init__(self) - - self.running = False - - self.tw_clients = {} # saves connected clients - self.tw_variables = {} # holds cloud variable states - self.hostname = hostname - self.port = port - - # server config - self.allow_non_numeric = allow_non_numeric - self.whitelisted_projects = whitelisted_projects - self.length_limit = length_limit - self.allow_nonscratch_names = allow_nonscratch_names - self.blocked_ips = blocked_ips - self.sync_players = sync_players - self.log_var_sets = log_var_sets - - def check_for_ip_ban(self, client): - if ( - client.address[0] in self.blocked_ips - or client.address[0] + ":" + str(client.address[1]) in self.blocked_ips - or client.address in self.blocked_ips - ): - client.sendMessage("You have been banned from this server") - client.close(4002) - print(client.address[0] + ":" + str(client.address[1]), "(IP-banned) was disconnected") - return True - return False - - def active_projects(self): - only_active = {} - for project_id in self.tw_variables: - if self.active_user_ips(project_id) != []: - only_active[project_id] = self.tw_variables[project_id] - return only_active - - def active_user_names(self, project_id): - return [self.tw_clients[user]["username"] for user in self.active_user_ips(project_id)] - - def active_user_ips(self, project_id): - return list( - filter( - lambda user: str(self.tw_clients[user]["project_id"]) == str(project_id), - self.tw_clients, - ) + BaseCloudServer.__init__( + self, + hostname=hostname, + port=port, + length_limit=length_limit, + allow_non_numeric=allow_non_numeric, + whitelisted_projects=whitelisted_projects, + allow_nonscratch_names=allow_nonscratch_names, + blocked_ips=blocked_ips, + sync_players=sync_players, + log_var_sets=log_var_sets, ) - def get_global_vars(self): - return self.tw_variables - def get_project_vars(self, project_id): - project_id = str(project_id) - if project_id in self.tw_variables: - return self.tw_variables[project_id] - else: - return {} - - def get_var(self, project_id, var_name): - project_id = str(project_id) - var_name = var_name.replace("☁ ", "") - if project_id in self.tw_variables: - if var_name in self.tw_variables[project_id]: - return self.tw_variables[project_id][var_name] - else: - return None - else: - return None - - def set_global_vars(self, data): - for project_id in data: - self.set_project_vars(project_id, data[project_id]) - - def _distribute_to_clients(self, project_id, json_string, *, skip_forward=None): - for client in [self.tw_clients[ip]["client"] for ip in self.active_user_ips(project_id)]: - if client == skip_forward: - continue - if ( - len(client.sendq) > CLIENT_SEND_QUEUE_LIMIT - ): # if the client has a large send queue, disconnect them to prevent lag - client.sendq.clear() # free the memory now - try: - client.client.shutdown(socket.SHUT_RDWR) - except OSError: - pass - continue - client.sendMessage(json_string) - - def set_project_vars(self, project_id, data, *, user="@server"): - project_id = str(project_id) - self.tw_variables[project_id] = data - json_string = "\n".join( - [ - json.dumps( - { - "method": "set", - "project_id": project_id, - "name": "☁ " + varname, - "value": data[varname], - "server": "scratchattach/3.0.0", - "timestamp": time.time() * 1000, - "user": user, - } - ) - for varname in data - ] +class TwSSLCloudServer(BaseCloudServer, SimpleSSLWebSocketServer): + def __init__( + self, + hostname: str, + *, + certfile: str | None = None, + keyfile: str | None = None, + ssl_version: int = ssl.PROTOCOL_TLSv1_2, + ssl_context: ssl.SSLContext | None = None, + port: int, + websocketclass: type[WebSocket], + length_limit: int | None = None, + allow_non_numeric: bool = True, + whitelisted_projects: list[Any] | None = None, + allow_nonscratch_names: bool = True, + blocked_ips: list[str] | None = None, + sync_players: bool = True, + log_var_sets: bool = True, + ): + SimpleSSLWebSocketServer.__init__( + self, + hostname, + port=port, + websocketclass=websocketclass, + certfile=certfile, + keyfile=keyfile, + version=ssl_version, + ssl_context=ssl_context, ) - self._distribute_to_clients(project_id, json_string) - def set_var(self, project_id, var_name, value, *, user="@server", skip_forward=None): - var_name = var_name.replace("☁ ", "") - project_id = str(project_id) - if project_id not in self.tw_variables: - self.tw_variables[project_id] = {} - self.tw_variables[project_id][var_name] = value - json_string = json.dumps( - { - "method": "set", - "project_id": project_id, - "name": "☁ " + var_name, - "value": value, - "timestamp": time.time() * 1000, - "user": user, - } + BaseCloudServer.__init__( + self, + hostname=hostname, + port=port, + length_limit=length_limit, + allow_non_numeric=allow_non_numeric, + whitelisted_projects=whitelisted_projects, + allow_nonscratch_names=allow_nonscratch_names, + blocked_ips=blocked_ips, + sync_players=sync_players, + log_var_sets=log_var_sets, ) - if self.sync_players: - self._distribute_to_clients(project_id, json_string, skip_forward=skip_forward) - - def _check_value(self, value): - # Checks if a received cloud value satisfies the server's constraints - if self.length_limit is not None: - if len(str(value)) > self.length_limit: - return False - if not self.allow_non_numeric: - x = value.replace(".", "") - x = x.replace("-", "") - if not (x.isnumeric() or x == ""): - return False - return True def _updater(self): try: # Function called when .start() is executed (.start is inherited from BaseEventHandler) - print(f"Serving websocket server: ws://{self.hostname}:{self.port}") + print(f"Serving websocket server: wss://{self.hostname}:{self.port}") self.serveforever() except Exception as e: raise exceptions.WebsocketServerError(str(e)) - def pause(self): - self.running = False - - def resume(self): - self.running = True - - def stop(self, wait_call_threads: bool = True): - BaseEventHandler.stop(self, wait_call_threads) - self.close() - def init_cloud_server( - hostname="127.0.0.1", - port=8080, + hostname: str = "127.0.0.1", + port: int = 8080, *, - length_limit=None, - allow_non_numeric=True, - whitelisted_projects=None, - allow_nonscratch_names=True, - blocked_ips=None, - sync_players=True, - log_var_sets=True, + length_limit: int | None = None, + allow_non_numeric: bool = True, + whitelisted_projects: list[Any] | None = None, + allow_nonscratch_names: bool = True, + blocked_ips: list[str] | None = None, + sync_players: bool = True, + log_var_sets: bool = True, ): """ Inits a websocket server which can be used with TurboWarp's ?cloud_host URL parameter. @@ -432,3 +346,48 @@ def init_cloud_server( sync_players=sync_players, log_var_sets=log_var_sets, ) + + +def init_ssl_cloud_server( + hostname: str = "127.0.0.1", + port: int = 8080, + *, + certfile: str | None = None, + keyfile: str | None = None, + ssl_version: int = ssl.PROTOCOL_TLSv1_2, + ssl_context: ssl.SSLContext | None = None, + length_limit: int | None = None, + allow_non_numeric: bool = True, + whitelisted_projects: list[Any] | None = None, + allow_nonscratch_names: bool = True, + blocked_ips: list[str] | None = None, + sync_players: bool = True, + log_var_sets: bool = True, +) -> TwSSLCloudServer: + """ + Inits a websocket server which can be used with TurboWarp's ?cloud_host URL parameter. + + Prints out the websocket address in the console. + """ + if (certfile is None or keyfile is None) and ssl_context is None: + warnings.warn( + "To init a ssl cloud server, you need provide `certfile` and " + + "`keyfile` or `ssl_context`." + ) + + return TwSSLCloudServer( + hostname, + port=port, + websocketclass=TwCloudSocket, + certfile=certfile, + keyfile=keyfile, + ssl_version=ssl_version, + ssl_context=ssl_context, + length_limit=length_limit, + allow_non_numeric=allow_non_numeric, + whitelisted_projects=whitelisted_projects, + allow_nonscratch_names=allow_nonscratch_names, + blocked_ips=blocked_ips, + sync_players=sync_players, + log_var_sets=log_var_sets, + )