Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
9863d50
feat(cloud_server): implement SSL secure websocket from semver2 into …
Boss-1s Aug 26, 2026
7e8fddd
fix(cloud_server): expose `init_ssl_cloud_sever` to top-level
Boss-1s Aug 26, 2026
bab2905
chore(eventhandlers._base): move mixin class to _base
Boss-1s Aug 26, 2026
5cd379d
fatal(eventhandlers._base): missing imports
Boss-1s Aug 26, 2026
2950297
export cloud server types
Boss-1s Sep 2, 2026
a1423ce
fix(sa.eventhandlers): clean up
Boss-1s Sep 2, 2026
8d5aa20
fix(sa.eventhandlers._base): ensure blocked_ips is always some list
Boss-1s Sep 2, 2026
70ffb36
feat: expose BaseCloudServer
Boss-1s Sep 2, 2026
92cc748
fatal: BaseCloudServer should not have ssl-related stuff
Boss-1s Sep 2, 2026
1924636
docstrings
Boss-1s Sep 2, 2026
2a5dfc9
resolve https://github.com/TimMcCool/scratchattach/pull/723#discussio…
Boss-1s Sep 2, 2026
5b0ff22
resolve
Boss-1s Sep 2, 2026
b80c260
revert
Boss-1s Sep 2, 2026
f81bce7
apply changes
TheCommCraft Sep 3, 2026
1d0bf6e
Merge branch 'TimMcCool:main' into semver3-secure-ws
Boss-1s Sep 3, 2026
e905eb8
.
Boss-1s Sep 3, 2026
16b58b7
replace all references to scratchattach/2.0.0
Boss-1s Sep 3, 2026
6243190
fatal: stale assignment to nonexistent arg websocketclass
Boss-1s Sep 3, 2026
c476784
Merge branch 'main' into semver3-secure-ws
Boss-1s Sep 3, 2026
65a4ddd
rename `skip_forward` and annotate type
TheCommCraft Sep 3, 2026
b77df00
use `serveonce` instead of `serveforever`
TheCommCraft Sep 3, 2026
3b96888
prevent 100% CPU usage
Boss-1s Sep 4, 2026
1b34b7c
revert 3b96888
Boss-1s Sep 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
50 changes: 43 additions & 7 deletions scratchattach/__init__.py
Original file line number Diff line number Diff line change
@@ -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 (
Expand All @@ -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
246 changes: 237 additions & 9 deletions scratchattach/eventhandlers/_base.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
from __future__ import annotations

import json
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


class BaseEventHandler(ABC):
_events: defaultdict[str, list[Callable]]
_threaded_events: defaultdict[str, list[Callable]]
Expand Down Expand Up @@ -41,8 +48,8 @@ def start(self, *, thread=True, ignore_exceptions=True):
else:
self._thread = None
self._updater()
def call_event(self, event_name, args : list = []):

def call_event(self, event_name, args: list = []):
try:
# print(f"Calling for {event_name}...")
if event_name in self._threaded_events:
Expand All @@ -56,20 +63,18 @@ def call_event(self, event_name, args : list = []):
func(*args)
except Exception as e:
if self.ignore_exceptions:
print(
f"Warning: Caught error in event '{event_name}' - Full error below"
)
print(f"Warning: Caught error in event '{event_name}' - Full error below")
try:
traceback.print_exc()
except Exception:
print(e)
else:
raise(e)
raise (e)

@abstractmethod
def _updater(self):
pass

def __del__(self):
self.stop()

Expand Down Expand Up @@ -108,6 +113,7 @@ def event(self, function=None, *, thread=False):
"""
Decorator function. Adds an event.
"""

def inner(function):
# called directly if the decorator provides arguments
if thread is True:
Expand All @@ -120,4 +126,226 @@ def inner(function):
return inner
else:
# => the decorator doesn't provide arguments
inner(function)
inner(function)


class BaseCloudServer(BaseEventHandler):
Comment thread
Boss-1s marked this conversation as resolved.
"""
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 client information."
tw_variables: dict[str, dict[str, Any]]
"Dictionary containing 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

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

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 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[project_id].update(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/3",
"timestamp": time.time() * 1000,
"user": user,
}
)
for varname in data
]
)
)

def set_var(
self,
project_id: Any,
var_name: str,
value: Any,
*,
user: str = "@server",
skip_broadcast_for: WebSocket | None = None,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we add documentation or something of the sort for this argument?

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

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_broadcast_for:
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}")
while self.running:
self.serveonce()
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()
2 changes: 1 addition & 1 deletion scratchattach/eventhandlers/cloud_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down
Loading