From 252d27690d2d7fe1504ae68674f4097eb67bdcff Mon Sep 17 00:00:00 2001 From: Boss_1s <95505913+Boss-1s@users.noreply.github.com> Date: Thu, 7 May 2026 17:23:01 -0500 Subject: [PATCH 01/14] refactor: Add try-except blocks for loading json on connect and reading data #537 Signed-off-by: Boss_1s <95505913+Boss-1s@users.noreply.github.com> --- scratchattach/eventhandlers/cloud_server.py | 32 ++++++++++++++------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/scratchattach/eventhandlers/cloud_server.py b/scratchattach/eventhandlers/cloud_server.py index d950f56f..10c78502 100644 --- a/scratchattach/eventhandlers/cloud_server.py +++ b/scratchattach/eventhandlers/cloud_server.py @@ -131,18 +131,30 @@ def handleMessage(self): if self.server.check_for_ip_ban(self): return - data = json.loads(self.data) + try: + data = json.loads(self.data) + except json.decoder.JSONDecodeError: + print(f"Warning! Client {self.address[0] + ":" + str(self.address[1])} sent invalid JSON to the server. ", + "The client may be unsafe, please stay alert." + ) print(data) - if data["method"] == "set": - self.handle_set(data) - elif data["method"] == "handshake": - self.handle_handshake(data) - else: + try: + if data["method"] == "set": + self.handle_set(data) + elif data["method"] == "handshake": + self.handle_handshake(data) + else: + print( + "Error:", + self.address[0] + ":" + str(self.address[1]), + "sent a message without providing a valid method (set, handshake)", + ) + except KeyError: print( - "Error:", - self.address[0] + ":" + str(self.address[1]), - "sent a message without providing a valid method (set, handshake)", + "Error:", + self.address[0] + ":" + str(self.address[1]), + "sent a message without providing a valid method (set, handshake)", ) except Exception as e: @@ -344,7 +356,7 @@ def resume(self): self.running = True def stop(self, wait_call_threads: bool = True): - BaseEventHandler.stop(self, wait_call_threads) + BaseEventHandler.stop(self, wait_call_threads) # wait_call_threads does not exist in BaseEventHandler.stop self.close() From 7fdfe0fcef8e0ca85cf78b24a3f1d90945b7af33 Mon Sep 17 00:00:00 2001 From: Boss_1s <95505913+Boss-1s@users.noreply.github.com> Date: Thu, 7 May 2026 18:10:37 -0500 Subject: [PATCH 02/14] Finish debug for json loading and reading 'method' key Signed-off-by: Boss_1s <95505913+Boss-1s@users.noreply.github.com> --- scratchattach/eventhandlers/cloud_server.py | 24 +++++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/scratchattach/eventhandlers/cloud_server.py b/scratchattach/eventhandlers/cloud_server.py index 10c78502..621b23e9 100644 --- a/scratchattach/eventhandlers/cloud_server.py +++ b/scratchattach/eventhandlers/cloud_server.py @@ -137,9 +137,17 @@ def handleMessage(self): print(f"Warning! Client {self.address[0] + ":" + str(self.address[1])} sent invalid JSON to the server. ", "The client may be unsafe, please stay alert." ) - print(data) + return - try: + print(f"Data recieved: {data}") + if data == {}: + print( + "Error:", + self.address[0] + ":" + str(self.address[1]), + "sent a blank JSON message. If this seems suspicious, ban the IP.", + ) + return + if 'method' in data: if data["method"] == "set": self.handle_set(data) elif data["method"] == "handshake": @@ -149,16 +157,18 @@ def handleMessage(self): "Error:", self.address[0] + ":" + str(self.address[1]), "sent a message without providing a valid method (set, handshake)", + f"but provided method {list(data.values())[0]} instead.", ) - except KeyError: + else: print( - "Error:", - self.address[0] + ":" + str(self.address[1]), - "sent a message without providing a valid method (set, handshake)", + "Error:", + self.address[0] + ":" + str(self.address[1]), + "sent a message without providing a valid 'method' key,", + f"but provided key {list(data.keys())[0]} instead.", ) except Exception as e: - print("Internal error in handleMessage:", e, traceback.format_exc()) + print("Internal error in handleMessage:", e, "\n", traceback.format_exc()) def handleConnected(self): if not self.server.running: From a44c3f5be3d3da946d696c960123009b0b1a2891 Mon Sep 17 00:00:00 2001 From: Boss_1s <95505913+Boss-1s@users.noreply.github.com> Date: Fri, 8 May 2026 15:52:51 -0500 Subject: [PATCH 03/14] a little more context on the ip banned message Signed-off-by: Boss_1s <95505913+Boss-1s@users.noreply.github.com> --- scratchattach/eventhandlers/cloud_server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scratchattach/eventhandlers/cloud_server.py b/scratchattach/eventhandlers/cloud_server.py index 621b23e9..33834d71 100644 --- a/scratchattach/eventhandlers/cloud_server.py +++ b/scratchattach/eventhandlers/cloud_server.py @@ -250,7 +250,7 @@ def check_for_ip_ban(self, client): ): client.sendMessage("You have been banned from this server") client.close(4002) - print(client.address[0] + ":" + str(client.address[1]), "(IP-banned) was disconnected") + print(client.address[0] + ":" + str(client.address[1]), "(IP-banned) was forced disconnected") return True return False From a05828e4661eb9301048c8f2110712644117ae11 Mon Sep 17 00:00:00 2001 From: Boss_1s <95505913+Boss-1s@users.noreply.github.com> Date: Fri, 8 May 2026 21:14:30 -0500 Subject: [PATCH 04/14] [UNIMPORTANT] notated unsued import Signed-off-by: Boss_1s <95505913+Boss-1s@users.noreply.github.com> --- scratchattach/eventhandlers/cloud_server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scratchattach/eventhandlers/cloud_server.py b/scratchattach/eventhandlers/cloud_server.py index 33834d71..12bc7cbe 100644 --- a/scratchattach/eventhandlers/cloud_server.py +++ b/scratchattach/eventhandlers/cloud_server.py @@ -1,7 +1,7 @@ from __future__ import annotations from SimpleWebSocketServer import SimpleWebSocketServer, WebSocket -from threading import Thread +from threading import Thread # unused threading.Thread? not changing, just noting -Boss_1s from scratchattach.utils import exceptions import json import time From 435d5f524dfa7023481b3db9fe94d41dbafb034b Mon Sep 17 00:00:00 2001 From: Boss_1s <95505913+Boss-1s@users.noreply.github.com> Date: Tue, 25 Aug 2026 19:19:16 +0000 Subject: [PATCH 05/14] feat: rich Signed-off-by: GitHub --- scratchattach/eventhandlers/cloud_server.py | 146 +++++++++++--------- 1 file changed, 82 insertions(+), 64 deletions(-) diff --git a/scratchattach/eventhandlers/cloud_server.py b/scratchattach/eventhandlers/cloud_server.py index 12bc7cbe..2bdf8cfa 100644 --- a/scratchattach/eventhandlers/cloud_server.py +++ b/scratchattach/eventhandlers/cloud_server.py @@ -1,14 +1,15 @@ from __future__ import annotations -from SimpleWebSocketServer import SimpleWebSocketServer, WebSocket -from threading import Thread # unused threading.Thread? not changing, just noting -Boss_1s -from scratchattach.utils import exceptions import json import time +import traceback +from threading import Thread # NOTE: unused threading.Thread? not changing, just noting -Boss_1s +from SimpleWebSocketServer import SimpleWebSocketServer, WebSocket +from rich import print +from scratchattach.utils import exceptions from scratchattach.site import cloud_activity from scratchattach.site.user import User from ._base import BaseEventHandler -import traceback class TwCloudSocket(WebSocket): @@ -21,29 +22,36 @@ def handle_set(self, data: dict): 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"], + print("[red]Error: "+ + self.address[0] + ":" + str(self.address[1])+ + " with username "+ + data["user"]+ + " tried to set a var on non-whitelisted project ID "+ + data["project_id"]+ + " and was disconnected.[/]" ) return # check if value is valid if not self.server._check_value(data["value"]): if self.server.log_var_sets: - print(self.address[0] + ":" + str(self.address[1]), "sent an invalid var value") + print("[yellow]Warning: "+ + self.address[0] + ":" + str(self.address[1])+ + " sent an invalid variable value.[/]\n"+ + f" Value: {data["value"]}") return # perform cloud var and forward to other players if self.server.log_var_sets: print( - self.address[0] + ":" + str(self.address[1]), - f"set {data['name']} to {data['value']}, project:", - str(data["project_id"]), - "user:", - data["user"], + self.address[0] + ":" + str(self.address[1])+ + f" with username {data['user']}"+ + f" sucessfully set {data['name']} to {data['value']} in project "+ + f"{str(data['project_id'])}." ) - self.server.set_var(data["project_id"], data["name"], data["value"], user=data["user"], skip_forward=self) + self.server.set_var(data["project_id"], + data["name"], + data["value"], + user=data["user"], + skip_forward=self) send_to_clients = { "method": "set", "user": data["user"], @@ -61,45 +69,53 @@ def handle_set(self, data: dict): def handle_handshake(self, data: dict): # check if handshake is valid - if not "user" in data: - print(self.address[0] + ":" + str(self.address[1]), "tried to handshake without providing a username") + if not data["user"]: + print("[red]Error: "+ + str(self.address[0]) + ":" + str(self.address[1])+ + " tried to handshake without providing a username.[/]") self.close(4002) return - if not "project_id" in data: - print(self.address[0] + ":" + str(self.address[1]), "tried to handshake without providing a project_id") + if not data["project_id"]: + print("[red]Error: "+ + str(self.address[0]) + ":" + str(self.address[1])+ + " tried to handshake without providing a project_id.[/]") self.close(4002) return + # check if project_id is in username is allowed - if self.server.allow_nonscratch_names is False: + if not self.server.allow_nonscratch_names: if not User(username=data["user"]).does_exist(): - print( - self.address[0] + ":" + str(self.address[1]), - "tried to handshake using a username not existing on Scratch, project:", - data["project_id"], - "user:", - data["user"], + print("[red]Error: "+ + str(self.address[0]) + ":" + str(self.address[1])+ + " tried to handshake with non-existent Scratch username "+ + data["user"]+ + ".[/]" ) self.close(4002) 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 str(data["project_id"]) not in self.server.whitelisted_projects: self.close(4002) - print( - self.address[0] + ":" + str(self.address[1]), - "tried to handshake on a non-whitelisted project:", - data["project_id"], - "user:", - data["user"], + print("[red]Error: "+ + str(self.address[0]) + ":" + str(self.address[1])+ + " with username "+ + data["user"]+ + " tried to handshake on a non-whitelisted project with ID "+ + data["project_id"]+ + ".[/]" ) return # register handshake in users list (save username and project_id) - print( - self.address[0] + ":" + str(self.address[1]), - "handshaked, project:", - data["project_id"], - "user:", - data["user"], + print("[green b]Handshake successful![/]\n"+ + "[green] Address "+ + str(self.address[0]) + ":" + str(self.address[1])+ + " under username [b]"+ + data["user"]+ + "[/] and project ID [b]"+ + data["project_id"]+ + " sucessfully handshaked with the server.[/green]" ) self.server.tw_clients[self.address]["username"] = data["user"] self.server.tw_clients[self.address]["project_id"] = data["project_id"] @@ -134,17 +150,16 @@ def handleMessage(self): try: data = json.loads(self.data) except json.decoder.JSONDecodeError: - print(f"Warning! Client {self.address[0] + ":" + str(self.address[1])} sent invalid JSON to the server. ", - "The client may be unsafe, please stay alert." - ) + print(f"[yellow]Warning: Client {str(self.address[0]) + ':' + str(self.address[1])} sent"+ + " invalid JSON to the server. The client may be unsafe, please stay alert.[/]\n"+ + f" [b]Data received:[/] {self.data}") return - print(f"Data recieved: {data}") if data == {}: print( - "Error:", - self.address[0] + ":" + str(self.address[1]), - "sent a blank JSON message. If this seems suspicious, ban the IP.", + "[yellow]Warning: "+ + str(self.address[0]) + ":" + str(self.address[1])+ + " sent a blank JSON message. [b]If this seems suspicious, ban the IP.[/][/]", ) return if 'method' in data: @@ -154,21 +169,22 @@ def handleMessage(self): self.handle_handshake(data) else: print( - "Error:", - self.address[0] + ":" + str(self.address[1]), - "sent a message without providing a valid method (set, handshake)", - f"but provided method {list(data.values())[0]} instead.", + "[yellow]Warning: "+ + str(self.address[0]) + ":" + str(self.address[1]), + " sent a message without providing a valid method (either [b]set[/b] or [b]handshake[/b]),"+ + f"but provided method '{list(data.values())[0]}' instead.[/]\n", + f" [b]Data received:[/] {self.data}" ) else: print( - "Error:", - self.address[0] + ":" + str(self.address[1]), - "sent a message without providing a valid 'method' key,", - f"but provided key {list(data.keys())[0]} instead.", + "[yellow]Warning: "+ + str(self.address[0]) + ":" + str(self.address[1])+ + " sent a message without providing a valid [b]'method'[/b] key,"+ + f" but provided key '{list(data.keys())[0]}' instead.[/]\n", + f" [b]Data received:[/] {self.data}" ) - except Exception as e: - print("Internal error in handleMessage:", e, "\n", traceback.format_exc()) + print(f"[red]Internal error in handleMessage: {e}[/]\n", traceback.format_exc()) def handleConnected(self): if not self.server.running: @@ -177,19 +193,20 @@ def handleConnected(self): if self.server.check_for_ip_ban(self): return - print(self.address[0] + ":" + str(self.address[1]), "connected") + print("[green]New client " + str(self.address[0]) + ":" + str(self.address[1]) + " connected![/]") self.server.tw_clients[self.address] = {"client": self, "username": None, "project_id": None} - # raise event + # raise connect event self.server.call_event("on_connect", [self]) except Exception as e: - print("Internal error in handleConntected:", e) + print(f"[red]Internal error in handleConnected: {e} [/]\n", traceback.format_exc()) def handleClose(self): if not self.server.running: return + try: if self.address in self.server.tw_clients: - # raise event + # raise disconnect event self.server.call_event( "on_disconnect", [ @@ -198,9 +215,9 @@ def handleClose(self): self, ], ) - print(self.address[0] + ":" + str(self.address[1]), "disconnected") + print(f"[blue]Client {self.address[0]}:{self.address[1]} disconnected from server sucessfully.[/]") except Exception as e: - print("Internal error in handleClose:", e) + print(f"[red]Internal error in handleClose: {e} [/]\n", traceback.format_exc()) class TwCloudServer(SimpleWebSocketServer, BaseEventHandler): @@ -250,7 +267,8 @@ def check_for_ip_ban(self, client): ): client.sendMessage("You have been banned from this server") client.close(4002) - print(client.address[0] + ":" + str(client.address[1]), "(IP-banned) was forced disconnected") + print(f"[yellow]Client {client.address[0]}:{client.address[1]} was forced disconnected "+ + "due to IP ban. [b]If this dosen't look right, remove them from the list.[/][/]") return True return False From db50b0bf55194beda4cc39b4c88548030ecc3daa Mon Sep 17 00:00:00 2001 From: Boss_1s <95505913+Boss-1s@users.noreply.github.com> Date: Tue, 25 Aug 2026 19:19:32 +0000 Subject: [PATCH 06/14] bump ruff up a patch Signed-off-by: GitHub --- uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uv.lock b/uv.lock index 813c5014..db8d358f 100644 --- a/uv.lock +++ b/uv.lock @@ -833,7 +833,7 @@ requires-dist = [ provides-extras = ["cli", "lark"] [package.metadata.requires-dev] -dev = [{ name = "ruff", specifier = ">=0.16.0" }] +dev = [{ name = "ruff", specifier = ">=0.16.1" }] [[package]] name = "shadowcopy" From 9863d507ae56c3672427875a839709e787a745fe Mon Sep 17 00:00:00 2001 From: Boss_1s <95505913+Boss-1s@users.noreply.github.com> Date: Wed, 26 Aug 2026 03:11:24 +0000 Subject: [PATCH 07/14] feat(cloud_server): implement SSL secure websocket from semver2 into semver3 Signed-off-by: GitHub --- scratchattach/eventhandlers/cloud_server.py | 403 +++++++++++++------- 1 file changed, 263 insertions(+), 140 deletions(-) diff --git a/scratchattach/eventhandlers/cloud_server.py b/scratchattach/eventhandlers/cloud_server.py index 0bda1f84..89edb2df 100644 --- a/scratchattach/eventhandlers/cloud_server.py +++ b/scratchattach/eventhandlers/cloud_server.py @@ -1,6 +1,8 @@ from __future__ import annotations +import ssl +from typing import Any -from SimpleWebSocketServer import SimpleWebSocketServer, WebSocket +from SimpleWebSocketServer import SimpleSSLWebSocketServer, SimpleWebSocketServer, WebSocket from threading import Thread from scratchattach.utils import exceptions import json @@ -10,6 +12,170 @@ from ._base import BaseEventHandler import traceback +class _SaCloudServer(BaseEventHandler): + 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: 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): + + BaseEventHandler.__init__(self) + + self.running = False + self._events = {} # saves event functions called on cloud updates + + 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)) + + 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 set_project_vars(self, project_id, data, *, user="@server"): + project_id = str(project_id) + self.tw_variables[project_id] = data + for client in [self.tw_clients[ip]["client"] for ip in self.active_user_ips(project_id)]: + client.sendMessage( + "\n".join( + [ + json.dumps( + { + "method": "set", + "project_id": project_id, + "name": "☁ " + varname, + "value": data[varname], + "server": "scratchattach/2.0.0", + "timestamp": time.time() * 1000, + "user": user, + } + ) + for varname in data + ] + ) + ) + + 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 + + if self.sync_players is True: + for client in [self.tw_clients[ip]["client"] for ip in self.active_user_ips(project_id)]: + if client == skip_forward: + continue + client.sendMessage( + json.dumps( + { + "method": "set", + "project_id": project_id, + "name": "☁ " + var_name, + "value": value, + "timestamp": time.time() * 1000, + "user": user, + } + ) + ) + + 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 self.allow_non_numeric is False: + 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}") + 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() class TwCloudSocket(WebSocket): server: TwCloudServer @@ -181,7 +347,7 @@ def handleClose(self): print("Internal error in handleClose:", e) -class TwCloudServer(SimpleWebSocketServer, BaseEventHandler): +class TwCloudServer(_SaCloudServer, SimpleWebSocketServer): def __init__( self, hostname, @@ -200,154 +366,69 @@ def __init__( blocked_ips = [] SimpleWebSocketServer.__init__(self, hostname, port=port, websocketclass=websocketclass) - BaseEventHandler.__init__(self) - - self.running = False - self._events = {} # saves event functions called on cloud updates - - 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)) - - 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 set_project_vars(self, project_id, data, *, user="@server"): - project_id = str(project_id) - self.tw_variables[project_id] = data - for client in [self.tw_clients[ip]["client"] for ip in self.active_user_ips(project_id)]: - client.sendMessage( - "\n".join( - [ - json.dumps( - { - "method": "set", - "project_id": project_id, - "name": "☁ " + varname, - "value": data[varname], - "server": "scratchattach/2.0.0", - "timestamp": time.time() * 1000, - "user": user, - } - ) - for varname in data - ] - ) - ) - - 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 - - if self.sync_players is True: - for client in [self.tw_clients[ip]["client"] for ip in self.active_user_ips(project_id)]: - if client == skip_forward: - continue - client.sendMessage( - json.dumps( - { - "method": "set", - "project_id": project_id, - "name": "☁ " + var_name, - "value": value, - "timestamp": time.time() * 1000, - "user": user, - } - ) - ) + _SaCloudServer.__init__(self, + hostname=hostname, + port=port, + websocketclass=websocketclass, + 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) + +class TwSSLCloudServer(_SaCloudServer, SimpleSSLWebSocketServer): + def __init__( + self, + hostname: str, + *, + certfile=None, + keyfile=None, + ssl_version=ssl.PROTOCOL_TLSv1_2, + ssl_context=None, + 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 + ): + SimpleSSLWebSocketServer.__init__( + self, + hostname, + port=port, + websocketclass=websocketclass, + certfile=certfile, + keyfile=keyfile, + version=ssl_version, + ssl_context=ssl_context, + ) - 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 self.allow_non_numeric is False: - x = value.replace(".", "") - x = x.replace("-", "") - if not (x.isnumeric() or x == ""): - return False - return True + _SaCloudServer.__init__(self, + hostname=hostname, + port=port, + websocketclass=websocketclass, + 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 _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, @@ -380,3 +461,45 @@ 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=None, + keyfile=None, + ssl_version=ssl.PROTOCOL_TLSv1_2, + ssl_context=None, + length_limit=None, + allow_non_numeric=True, + whitelisted_projects=None, + allow_nonscratch_names=True, + blocked_ips=None, + sync_players=True, + log_var_sets=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: + print("[yellow]WARNING: 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 + ) From 7e8fdddce81fb81e38adfc0d3769fc3c5ed93e7b Mon Sep 17 00:00:00 2001 From: Boss_1s <95505913+Boss-1s@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:36:26 +0000 Subject: [PATCH 08/14] fix(cloud_server): expose `init_ssl_cloud_sever` to top-level Signed-off-by: GitHub --- scratchattach/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scratchattach/__init__.py b/scratchattach/__init__.py index a2329989..94805f83 100644 --- a/scratchattach/__init__.py +++ b/scratchattach/__init__.py @@ -1,7 +1,7 @@ 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.cloud_server import init_cloud_server, init_ssl_cloud_server from .eventhandlers._base import BaseEventHandler from .eventhandlers.filterbot import Filterbot, HardFilter, SoftFilter, SpamFilter from .eventhandlers.cloud_storage import Database From bab29051255a56b8ca7fd1b3f1dc7bcaf10a7b44 Mon Sep 17 00:00:00 2001 From: Boss_1s <95505913+Boss-1s@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:58:57 +0000 Subject: [PATCH 09/14] chore(eventhandlers._base): move mixin class to _base - Moved `BaseCloudServer` (Formerly `_SaCloudServer`) to sa.eventhandlers._base Signed-off-by: GitHub --- scratchattach/eventhandlers/_base.py | 175 +++++++++++++++++- scratchattach/eventhandlers/cloud_server.py | 185 ++------------------ 2 files changed, 182 insertions(+), 178 deletions(-) diff --git a/scratchattach/eventhandlers/_base.py b/scratchattach/eventhandlers/_base.py index 4e4236ac..b8cdb10f 100644 --- a/scratchattach/eventhandlers/_base.py +++ b/scratchattach/eventhandlers/_base.py @@ -1,11 +1,15 @@ from __future__ import annotations +import json +import time +import ssl from abc import ABC, abstractmethod from typing import Optional from collections import defaultdict from threading import Thread, Event from collections.abc import Callable import traceback + from scratchattach.utils.requests import requests from scratchattach.utils import exceptions @@ -41,7 +45,7 @@ def start(self, *, thread=True, ignore_exceptions=True): else: self._thread = None self._updater() - + def call_event(self, event_name, args : list = []): try: # print(f"Calling for {event_name}...") @@ -69,7 +73,7 @@ def call_event(self, event_name, args : list = []): @abstractmethod def _updater(self): pass - + def __del__(self): self.stop() @@ -120,4 +124,169 @@ def inner(function): return inner else: # => the decorator doesn't provide arguments - inner(function) \ No newline at end of file + inner(function) + +class BaseCloudServer(BaseEventHandler): + 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: 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): + + BaseEventHandler.__init__(self) + + self.running = False + self._events = {} # saves event functions called on cloud updates + + 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)) + + 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 set_project_vars(self, project_id, data, *, user="@server"): + project_id = str(project_id) + self.tw_variables[project_id] = data + for client in [self.tw_clients[ip]["client"] for ip in self.active_user_ips(project_id)]: + client.sendMessage( + "\n".join( + [ + json.dumps( + { + "method": "set", + "project_id": project_id, + "name": "☁ " + varname, + "value": data[varname], + "server": "scratchattach/2.0.0", + "timestamp": time.time() * 1000, + "user": user, + } + ) + for varname in data + ] + ) + ) + + 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 + + if self.sync_players is True: + for client in [self.tw_clients[ip]["client"] for ip in self.active_user_ips(project_id)]: + if client == skip_forward: + continue + client.sendMessage( + json.dumps( + { + "method": "set", + "project_id": project_id, + "name": "☁ " + var_name, + "value": value, + "timestamp": time.time() * 1000, + "user": user, + } + ) + ) + + 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 self.allow_non_numeric is False: + 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}") + 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() diff --git a/scratchattach/eventhandlers/cloud_server.py b/scratchattach/eventhandlers/cloud_server.py index 89edb2df..7c4646b0 100644 --- a/scratchattach/eventhandlers/cloud_server.py +++ b/scratchattach/eventhandlers/cloud_server.py @@ -1,181 +1,16 @@ from __future__ import annotations -import ssl -from typing import Any -from SimpleWebSocketServer import SimpleSSLWebSocketServer, 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 -class _SaCloudServer(BaseEventHandler): - 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: 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): - - BaseEventHandler.__init__(self) - - self.running = False - self._events = {} # saves event functions called on cloud updates - - 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)) - - 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 set_project_vars(self, project_id, data, *, user="@server"): - project_id = str(project_id) - self.tw_variables[project_id] = data - for client in [self.tw_clients[ip]["client"] for ip in self.active_user_ips(project_id)]: - client.sendMessage( - "\n".join( - [ - json.dumps( - { - "method": "set", - "project_id": project_id, - "name": "☁ " + varname, - "value": data[varname], - "server": "scratchattach/2.0.0", - "timestamp": time.time() * 1000, - "user": user, - } - ) - for varname in data - ] - ) - ) - - 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 - - if self.sync_players is True: - for client in [self.tw_clients[ip]["client"] for ip in self.active_user_ips(project_id)]: - if client == skip_forward: - continue - client.sendMessage( - json.dumps( - { - "method": "set", - "project_id": project_id, - "name": "☁ " + var_name, - "value": value, - "timestamp": time.time() * 1000, - "user": user, - } - ) - ) - - 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 self.allow_non_numeric is False: - 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}") - self.serveforever() - except Exception as e: - raise exceptions.WebsocketServerError(str(e)) - - def pause(self): - self.running = False - - def resume(self): - self.running = True +from SimpleWebSocketServer import SimpleSSLWebSocketServer, SimpleWebSocketServer, WebSocket - def stop(self, wait_call_threads: bool = True): - BaseEventHandler.stop(self, wait_call_threads) - self.close() +from scratchattach.utils import exceptions +from scratchattach.site import cloud_activity +from scratchattach.site.user import User +from ._base import BaseCloudServer class TwCloudSocket(WebSocket): server: TwCloudServer @@ -347,7 +182,7 @@ def handleClose(self): print("Internal error in handleClose:", e) -class TwCloudServer(_SaCloudServer, SimpleWebSocketServer): +class TwCloudServer(BaseCloudServer, SimpleWebSocketServer): def __init__( self, hostname, @@ -367,7 +202,7 @@ def __init__( SimpleWebSocketServer.__init__(self, hostname, port=port, websocketclass=websocketclass) - _SaCloudServer.__init__(self, + BaseCloudServer.__init__(self, hostname=hostname, port=port, websocketclass=websocketclass, @@ -379,7 +214,7 @@ def __init__( sync_players=sync_players, log_var_sets=log_var_sets) -class TwSSLCloudServer(_SaCloudServer, SimpleSSLWebSocketServer): +class TwSSLCloudServer(BaseCloudServer, SimpleSSLWebSocketServer): def __init__( self, hostname: str, @@ -409,7 +244,7 @@ def __init__( ssl_context=ssl_context, ) - _SaCloudServer.__init__(self, + BaseCloudServer.__init__(self, hostname=hostname, port=port, websocketclass=websocketclass, From 5cd379d93f7a6f2c4ba747bd2e4d3d790894f05c Mon Sep 17 00:00:00 2001 From: Boss_1s <95505913+Boss-1s@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:38:01 +0000 Subject: [PATCH 10/14] fatal(eventhandlers._base): missing imports Signed-off-by: GitHub --- scratchattach/eventhandlers/_base.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scratchattach/eventhandlers/_base.py b/scratchattach/eventhandlers/_base.py index b8cdb10f..79a59edb 100644 --- a/scratchattach/eventhandlers/_base.py +++ b/scratchattach/eventhandlers/_base.py @@ -4,12 +4,14 @@ import time import ssl from abc import ABC, abstractmethod -from typing import Optional +from typing import Optional, Any from collections import defaultdict from threading import Thread, Event from collections.abc import Callable import traceback +from SimpleWebSocketServer import WebSocket + from scratchattach.utils.requests import requests from scratchattach.utils import exceptions From 4740cc87cc9ff6d4ef1850d2e02b436216cb23dd Mon Sep 17 00:00:00 2001 From: Boss_1s <95505913+Boss-1s@users.noreply.github.com> Date: Wed, 26 Aug 2026 13:37:16 -0500 Subject: [PATCH 11/14] fix(eventhandlers._base): remove stale reference Signed-off-by: Boss_1s <95505913+Boss-1s@users.noreply.github.com> --- scratchattach/eventhandlers/_base.py | 1 - 1 file changed, 1 deletion(-) diff --git a/scratchattach/eventhandlers/_base.py b/scratchattach/eventhandlers/_base.py index 4e8f6d79..28b12298 100644 --- a/scratchattach/eventhandlers/_base.py +++ b/scratchattach/eventhandlers/_base.py @@ -146,7 +146,6 @@ def __init__( if blocked_ips is None: blocked_ips = [] - SimpleWebSocketServer.__init__(self, hostname, port=port, websocketclass=websocketclass) BaseEventHandler.__init__(self) self.running = False From d3c67fdb1543a53ddfb2d50b706129e2ebcb746f Mon Sep 17 00:00:00 2001 From: Boss_1s <95505913+Boss-1s@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:35:36 +0000 Subject: [PATCH 12/14] chore(eventhandlers): rich cleanup Signed-off-by: GitHub --- scratchattach/eventhandlers/_base.py | 3 +-- scratchattach/eventhandlers/cloud_server.py | 1 + 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/scratchattach/eventhandlers/_base.py b/scratchattach/eventhandlers/_base.py index 28b12298..cc957921 100644 --- a/scratchattach/eventhandlers/_base.py +++ b/scratchattach/eventhandlers/_base.py @@ -9,8 +9,7 @@ from threading import Thread, Event from collections.abc import Callable import traceback - -from SimpleWebSocketServer import WebSocket +from rich import print from scratchattach.utils.requests import requests from scratchattach.utils import exceptions diff --git a/scratchattach/eventhandlers/cloud_server.py b/scratchattach/eventhandlers/cloud_server.py index 808790c4..27ec9d6e 100644 --- a/scratchattach/eventhandlers/cloud_server.py +++ b/scratchattach/eventhandlers/cloud_server.py @@ -6,6 +6,7 @@ import traceback from SimpleWebSocketServer import SimpleSSLWebSocketServer, SimpleWebSocketServer, WebSocket +from rich import print from scratchattach.utils import exceptions from scratchattach.site import cloud_activity From 38f71354efa70aef2478ba97868edad47d95fbbc Mon Sep 17 00:00:00 2001 From: Boss_1s <95505913+Boss-1s@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:50:30 +0000 Subject: [PATCH 13/14] . @Boss-1s revert this commit when pr is ready Signed-off-by: GitHub --- .gitignore | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.gitignore b/.gitignore index 0fcda0ef..b407c3f9 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,12 @@ 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 From a54c42876e3e3d16517da85154e7e3bc56ea597e Mon Sep 17 00:00:00 2001 From: Boss_1s <95505913+Boss-1s@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:57:07 +0000 Subject: [PATCH 14/14] chore(cloud._base): anything in websocketeventstream counts towards refactor Signed-off-by: GitHub --- scratchattach/cloud/_base.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scratchattach/cloud/_base.py b/scratchattach/cloud/_base.py index ca9fb4f3..ec58709e 100644 --- a/scratchattach/cloud/_base.py +++ b/scratchattach/cloud/_base.py @@ -9,6 +9,7 @@ from abc import ABC, abstractmethod, ABCMeta from threading import Lock from collections.abc import Iterator +from rich import print from scratchattach.cloud import cloud as cloud_module @@ -242,7 +243,8 @@ def read(self, amount: int = -1) -> Iterator[dict[str, Any]]: except json.JSONDecodeError as e: # this could happen e.g. when the scratchattach server sends the message # "This server uses @TimMcCool's scratchattach 2.0.0" - warnings.warn(f"Invalid JSON sent from server: {e}") + print(f"[yellow]Warning: Cloud events handler received invalid JSON.[/]") + print(f" [b]Data received:[/] \"{self.packets_left}\"") except Exception: # NOTE: at the very least for `except Exception`, let's print the traceback # ideally we would never even use `except Exception`. Maybe this is technical debt.