scratchstore 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
scratchstore/__init__.py
ADDED
|
@@ -0,0 +1,1100 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import html
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import re
|
|
8
|
+
import random
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
import urllib.parse
|
|
12
|
+
import zlib
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from queue import Empty, Queue
|
|
16
|
+
from typing import Any, Callable, Iterable
|
|
17
|
+
|
|
18
|
+
__version__ = "0.3.0"
|
|
19
|
+
|
|
20
|
+
LOG = logging.getLogger("scratchstore")
|
|
21
|
+
|
|
22
|
+
# Two-digit, one-based line numbers from list.txt. The mapping is shared with
|
|
23
|
+
# the Scratch client; every supported character has exactly one code.
|
|
24
|
+
_SOURCE_LINES = list("abcdefghijklmnopqrstuvwxyz1234567890!@#$%^&*()_+{}|:\"<>?-=[]\\;',./`~ ")
|
|
25
|
+
CHAR_TO_CODE: dict[str, str] = {
|
|
26
|
+
char: f"{line_number:02d}"
|
|
27
|
+
for line_number, char in enumerate(_SOURCE_LINES, start=1)
|
|
28
|
+
}
|
|
29
|
+
CODE_TO_CHAR: dict[str, str] = {
|
|
30
|
+
code: char for char, code in CHAR_TO_CODE.items()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
CLOUD_VALUE_LIMIT = 256
|
|
34
|
+
SEED_LENGTH = 5
|
|
35
|
+
SEED_MIN = 10_000
|
|
36
|
+
SEED_MAX = 99_999
|
|
37
|
+
FRAME_DATA_LIMIT = 250 # full 250 encoded digits; cloud storage preserves leading zeroes
|
|
38
|
+
RESPONSE_PAGE_LANES = 9
|
|
39
|
+
RESPONSE_LANE_NAMES = tuple(f"FH_{i}" for i in range(1, 10))
|
|
40
|
+
REQUEST_VARIABLE = "TH"
|
|
41
|
+
RESERVED = set(":|;\\")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ScratchStoreError(Exception):
|
|
45
|
+
"""Base exception for ScratchStore."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class NewScratcherError(ScratchStoreError):
|
|
49
|
+
"""Raised when the configured Scratch account is still a New Scratcher."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class UnsupportedCharacterError(ScratchStoreError):
|
|
53
|
+
"""Raised when a string contains a character absent from the supplied map."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ProtocolError(ScratchStoreError):
|
|
57
|
+
"""Raised for malformed protocol packets or requests."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class ScratchAuthenticationError(ScratchStoreError):
|
|
61
|
+
"""Raised when Scratch rejects a login or session validation request."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def encode_text(text: str) -> str:
|
|
65
|
+
"""Encode text as two decimal digits per character using list.txt's map."""
|
|
66
|
+
out: list[str] = []
|
|
67
|
+
# The wire alphabet intentionally contains lowercase letters only. Normalize
|
|
68
|
+
# text here as a final guard for usernames and any other mixed-case metadata.
|
|
69
|
+
for char in str(text).lower():
|
|
70
|
+
code = CHAR_TO_CODE.get(char)
|
|
71
|
+
if code is None:
|
|
72
|
+
raise UnsupportedCharacterError(
|
|
73
|
+
f"Character {char!r} is not in list.txt's encoding map."
|
|
74
|
+
)
|
|
75
|
+
out.append(code)
|
|
76
|
+
encoded = "".join(out)
|
|
77
|
+
if len(encoded) % 2:
|
|
78
|
+
raise AssertionError("The encoder produced an odd-length stream.")
|
|
79
|
+
return encoded
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def decode_text(encoded: str) -> str:
|
|
83
|
+
"""Decode an even-length digit stream made by encode_text()."""
|
|
84
|
+
if len(encoded) % 2:
|
|
85
|
+
raise ProtocolError("Encoded streams must contain an even number of digits.")
|
|
86
|
+
if encoded and not encoded.isdigit():
|
|
87
|
+
raise ProtocolError("Encoded streams may contain decimal digits only.")
|
|
88
|
+
out: list[str] = []
|
|
89
|
+
for pos in range(0, len(encoded), 2):
|
|
90
|
+
code = encoded[pos:pos + 2]
|
|
91
|
+
char = CODE_TO_CHAR.get(code)
|
|
92
|
+
if char is None:
|
|
93
|
+
raise ProtocolError(f"Unknown character code {code!r}.")
|
|
94
|
+
out.append(char)
|
|
95
|
+
return "".join(out)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def escape_field(value: Any) -> str:
|
|
99
|
+
"""Escape protocol delimiters in a database/key/value field."""
|
|
100
|
+
text = str(value)
|
|
101
|
+
return "".join(("\\" + ch) if ch in RESERVED else ch for ch in text)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def split_escaped(text: str, delimiter: str, *, maxsplit: int = -1) -> list[str]:
|
|
105
|
+
"""Split on unescaped delimiters while keeping escape pairs intact."""
|
|
106
|
+
if len(delimiter) != 1:
|
|
107
|
+
raise ValueError("delimiter must be one character")
|
|
108
|
+
parts: list[str] = []
|
|
109
|
+
current: list[str] = []
|
|
110
|
+
escaped = False
|
|
111
|
+
splits = 0
|
|
112
|
+
for char in text:
|
|
113
|
+
if escaped:
|
|
114
|
+
current.extend(("\\", char))
|
|
115
|
+
escaped = False
|
|
116
|
+
elif char == "\\":
|
|
117
|
+
escaped = True
|
|
118
|
+
elif char == delimiter and (maxsplit < 0 or splits < maxsplit):
|
|
119
|
+
parts.append("".join(current))
|
|
120
|
+
current.clear()
|
|
121
|
+
splits += 1
|
|
122
|
+
else:
|
|
123
|
+
current.append(char)
|
|
124
|
+
if escaped:
|
|
125
|
+
current.append("\\")
|
|
126
|
+
parts.append("".join(current))
|
|
127
|
+
return parts
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def unescape_field(text: str) -> str:
|
|
131
|
+
"""Remove backslash escaping from one protocol field."""
|
|
132
|
+
out: list[str] = []
|
|
133
|
+
i = 0
|
|
134
|
+
while i < len(text):
|
|
135
|
+
if text[i] == "\\" and i + 1 < len(text):
|
|
136
|
+
i += 1
|
|
137
|
+
out.append(text[i])
|
|
138
|
+
i += 1
|
|
139
|
+
return "".join(out)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _decode_scratch_session_id(session_id: str) -> dict[str, Any]:
|
|
143
|
+
"""Decode the username/token payload embedded in Scratch's session cookie.
|
|
144
|
+
|
|
145
|
+
This follows scratchattach's ``decode_session_id`` implementation:
|
|
146
|
+
``<base64-json>:<base62-timestamp>:<signature>``. Some session IDs use a
|
|
147
|
+
zlib-compressed first component, indicated by the ``".`` prefix.
|
|
148
|
+
"""
|
|
149
|
+
parts = str(session_id).split(":")
|
|
150
|
+
if len(parts) != 3:
|
|
151
|
+
raise ScratchAuthenticationError(
|
|
152
|
+
"Scratch returned a session cookie with an unexpected format."
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
first = parts[0]
|
|
156
|
+
try:
|
|
157
|
+
# Match scratchattach's exact padding convention. The cookie can
|
|
158
|
+
# retain surrounding quote characters, which the base64 decoder skips.
|
|
159
|
+
decoded = base64.urlsafe_b64decode(first + "==")
|
|
160
|
+
if first.startswith('".'):
|
|
161
|
+
decoded = zlib.decompress(decoded)
|
|
162
|
+
payload = json.loads(decoded.decode("utf-8"))
|
|
163
|
+
except (ValueError, UnicodeDecodeError, json.JSONDecodeError, zlib.error) as exc:
|
|
164
|
+
raise ScratchAuthenticationError(
|
|
165
|
+
"Scratch returned a session cookie whose embedded data could not be decoded."
|
|
166
|
+
) from exc
|
|
167
|
+
|
|
168
|
+
if not isinstance(payload, dict):
|
|
169
|
+
raise ScratchAuthenticationError("Scratch's session cookie did not contain a JSON object.")
|
|
170
|
+
if not isinstance(payload.get("username"), str) or not payload["username"]:
|
|
171
|
+
raise ScratchAuthenticationError("Scratch's session cookie did not contain a username.")
|
|
172
|
+
if not isinstance(payload.get("token"), str) or not payload["token"]:
|
|
173
|
+
raise ScratchAuthenticationError("Scratch's session cookie did not contain an X-Token.")
|
|
174
|
+
return payload
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _extract_session_cookie(response: Any, http_session: Any) -> str | None:
|
|
178
|
+
"""Extract scratchsessionsid while preserving quotes used by Scratch."""
|
|
179
|
+
set_cookie = getattr(response, "headers", {}).get("Set-Cookie", "") or ""
|
|
180
|
+
match = re.search(
|
|
181
|
+
r'(?:^|[,;]\s*)scratchsessionsid=("[^"]+"|[^;,\s]+)',
|
|
182
|
+
set_cookie,
|
|
183
|
+
flags=re.IGNORECASE,
|
|
184
|
+
)
|
|
185
|
+
if match:
|
|
186
|
+
return match.group(1).strip()
|
|
187
|
+
|
|
188
|
+
# Requests' cookie jar is a useful fallback when Set-Cookie was normalized.
|
|
189
|
+
try:
|
|
190
|
+
value = http_session.cookies.get("scratchsessionsid")
|
|
191
|
+
if value:
|
|
192
|
+
return str(value)
|
|
193
|
+
except Exception:
|
|
194
|
+
pass
|
|
195
|
+
try:
|
|
196
|
+
value = response.cookies.get("scratchsessionsid")
|
|
197
|
+
if value:
|
|
198
|
+
return str(value)
|
|
199
|
+
except Exception:
|
|
200
|
+
pass
|
|
201
|
+
return None
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _detect_new_scratcher_from_profile(http_session: Any, username: str, headers: dict[str, str], cookies: dict[str, str]) -> bool | None:
|
|
205
|
+
"""Fallback badge check using the public profile's group label.
|
|
206
|
+
|
|
207
|
+
Scratchattach tracks this as a fallback because the public user API does
|
|
208
|
+
not expose the ``permissions.new_scratcher`` field. The profile HTML
|
|
209
|
+
structure is unofficial and may change, so ``None`` means inconclusive.
|
|
210
|
+
"""
|
|
211
|
+
url = f"https://scratch.mit.edu/users/{urllib.parse.quote(username, safe='')}/"
|
|
212
|
+
profile_headers = dict(headers)
|
|
213
|
+
profile_headers.pop("X-Token", None)
|
|
214
|
+
profile_headers["Accept"] = "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
|
|
215
|
+
try:
|
|
216
|
+
response = http_session.get(url, headers=profile_headers, cookies=cookies, timeout=15)
|
|
217
|
+
if response.status_code != 200:
|
|
218
|
+
return None
|
|
219
|
+
# Match the same visible group badge used by ScratchAttach's fallback
|
|
220
|
+
# check. Strip nested HTML and decode entities before comparing.
|
|
221
|
+
match = re.search(
|
|
222
|
+
r'<span\b[^>]*class=["\'][^"\']*\bgroup\b[^"\']*["\'][^>]*>(.*?)</span>',
|
|
223
|
+
response.text,
|
|
224
|
+
flags=re.IGNORECASE | re.DOTALL,
|
|
225
|
+
)
|
|
226
|
+
if not match:
|
|
227
|
+
return None
|
|
228
|
+
label = re.sub(r"<[^>]+>", " ", match.group(1))
|
|
229
|
+
label = html.unescape(label).strip().casefold()
|
|
230
|
+
if "new scratcher" in label:
|
|
231
|
+
return True
|
|
232
|
+
if "scratcher" in label:
|
|
233
|
+
return False
|
|
234
|
+
except Exception as exc:
|
|
235
|
+
LOG.debug("Could not check the public Scratch profile badge: %s", exc)
|
|
236
|
+
return None
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _split_cloud_value(value: Any) -> tuple[str, str] | None:
|
|
240
|
+
"""Return (payload, seed) from a cloud value ending in .12345."""
|
|
241
|
+
raw = str(value).strip()
|
|
242
|
+
payload, dot, seed = raw.rpartition(".")
|
|
243
|
+
if not dot or len(seed) != SEED_LENGTH or not seed.isdigit():
|
|
244
|
+
return None
|
|
245
|
+
if len(payload) > 250 or len(payload) % 2 or not payload.isdigit():
|
|
246
|
+
return None
|
|
247
|
+
if not SEED_MIN <= int(seed) <= SEED_MAX:
|
|
248
|
+
return None
|
|
249
|
+
return payload, seed
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@dataclass
|
|
253
|
+
class Database:
|
|
254
|
+
"""A JSON-backed database registered with ScratchStore."""
|
|
255
|
+
name: str
|
|
256
|
+
file: Path
|
|
257
|
+
data: dict[str, Any] = field(default_factory=dict)
|
|
258
|
+
_lock: threading.RLock = field(default_factory=threading.RLock, repr=False)
|
|
259
|
+
|
|
260
|
+
def __post_init__(self) -> None:
|
|
261
|
+
self.name = str(self.name).lower()
|
|
262
|
+
self.file = Path(self.file).expanduser()
|
|
263
|
+
self._load_or_create()
|
|
264
|
+
|
|
265
|
+
def _load_or_create(self) -> None:
|
|
266
|
+
self.file.parent.mkdir(parents=True, exist_ok=True)
|
|
267
|
+
if not self.file.exists():
|
|
268
|
+
self.data = {}
|
|
269
|
+
self.save()
|
|
270
|
+
return
|
|
271
|
+
try:
|
|
272
|
+
raw = json.loads(self.file.read_text(encoding="utf-8"))
|
|
273
|
+
except json.JSONDecodeError as exc:
|
|
274
|
+
raise ScratchStoreError(f"Database file {self.file} is not valid JSON: {exc}") from exc
|
|
275
|
+
if not isinstance(raw, dict):
|
|
276
|
+
raise ScratchStoreError(f"Database file {self.file} must contain a JSON object.")
|
|
277
|
+
if isinstance(raw.get("data"), dict):
|
|
278
|
+
source = raw["data"]
|
|
279
|
+
else:
|
|
280
|
+
# Accept a simple flat JSON object as an existing database too.
|
|
281
|
+
source = raw
|
|
282
|
+
|
|
283
|
+
normalized: dict[str, Any] = {}
|
|
284
|
+
for key, value in source.items():
|
|
285
|
+
lowered_key = str(key).lower()
|
|
286
|
+
if lowered_key in normalized:
|
|
287
|
+
raise ScratchStoreError(
|
|
288
|
+
f"Database file {self.file} contains keys that collide after lowercasing: {lowered_key!r}."
|
|
289
|
+
)
|
|
290
|
+
normalized_value = value.lower() if isinstance(value, str) else value
|
|
291
|
+
normalized[lowered_key] = normalized_value
|
|
292
|
+
self.data = normalized
|
|
293
|
+
if normalized != source:
|
|
294
|
+
# Persist the migration so older database files stay normalized
|
|
295
|
+
# even before the next Scratch write occurs.
|
|
296
|
+
self.save()
|
|
297
|
+
|
|
298
|
+
def save(self) -> None:
|
|
299
|
+
with self._lock:
|
|
300
|
+
document = {"format": "scratchstore", "version": 1, "name": self.name, "data": self.data}
|
|
301
|
+
temporary = self.file.with_name(self.file.name + ".tmp")
|
|
302
|
+
temporary.write_text(json.dumps(document, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
303
|
+
temporary.replace(self.file)
|
|
304
|
+
|
|
305
|
+
def keys(self) -> list[str]:
|
|
306
|
+
with self._lock:
|
|
307
|
+
return list(self.data.keys())
|
|
308
|
+
|
|
309
|
+
def get(self, key: str) -> Any:
|
|
310
|
+
with self._lock:
|
|
311
|
+
return self.data.get(str(key).lower(), None)
|
|
312
|
+
|
|
313
|
+
def set(self, key: str, value: str) -> None:
|
|
314
|
+
with self._lock:
|
|
315
|
+
self.data[str(key).lower()] = str(value).lower()
|
|
316
|
+
self.save()
|
|
317
|
+
|
|
318
|
+
def delete(self, key: str) -> bool:
|
|
319
|
+
normalized_key = str(key).lower()
|
|
320
|
+
with self._lock:
|
|
321
|
+
if normalized_key not in self.data:
|
|
322
|
+
return False
|
|
323
|
+
del self.data[normalized_key]
|
|
324
|
+
self.save()
|
|
325
|
+
return True
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
class _EventManager:
|
|
329
|
+
"""Thread-safe registry for the public ``@events.event`` decorator.
|
|
330
|
+
|
|
331
|
+
Handler names identify their event. Currently supported handlers are
|
|
332
|
+
``on_set`` and ``on_get``. Decorating a function does not wrap or replace it.
|
|
333
|
+
"""
|
|
334
|
+
|
|
335
|
+
_SUPPORTED = {"on_set", "on_get"}
|
|
336
|
+
|
|
337
|
+
def __init__(self) -> None:
|
|
338
|
+
self._handlers: dict[str, list[Callable[..., Any]]] = {
|
|
339
|
+
name: [] for name in self._SUPPORTED
|
|
340
|
+
}
|
|
341
|
+
self._lock = threading.RLock()
|
|
342
|
+
|
|
343
|
+
def event(self, function: Callable[..., Any]) -> Callable[..., Any]:
|
|
344
|
+
if not callable(function):
|
|
345
|
+
raise TypeError("@events.event can only decorate a callable")
|
|
346
|
+
event_name = getattr(function, "__name__", "")
|
|
347
|
+
if event_name not in self._SUPPORTED:
|
|
348
|
+
supported = ", ".join(sorted(self._SUPPORTED))
|
|
349
|
+
raise ValueError(
|
|
350
|
+
f"Event handler must be named one of: {supported} (received {event_name!r})"
|
|
351
|
+
)
|
|
352
|
+
with self._lock:
|
|
353
|
+
self._handlers[event_name].append(function)
|
|
354
|
+
return function
|
|
355
|
+
|
|
356
|
+
def _dispatch(self, event_name: str, *args: Any) -> None:
|
|
357
|
+
with self._lock:
|
|
358
|
+
handlers = tuple(self._handlers.get(event_name, ()))
|
|
359
|
+
for handler in handlers:
|
|
360
|
+
try:
|
|
361
|
+
handler(*args)
|
|
362
|
+
except Exception:
|
|
363
|
+
LOG.exception("An %s event handler failed", event_name)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
# Exposed as ``from scratchstore import events``.
|
|
367
|
+
events = _EventManager()
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
@dataclass
|
|
371
|
+
class _ProjectConfig:
|
|
372
|
+
project_id: str
|
|
373
|
+
username: str
|
|
374
|
+
password: str
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
class _Connection:
|
|
378
|
+
def __init__(self) -> None:
|
|
379
|
+
self._project: _ProjectConfig | None = None
|
|
380
|
+
self._databases: list[Database] = []
|
|
381
|
+
self._lock = threading.RLock()
|
|
382
|
+
|
|
383
|
+
def setup(self, _id: int | str, username: str, password: str) -> None:
|
|
384
|
+
if isinstance(_id, bool) or not isinstance(_id, (int, str)):
|
|
385
|
+
raise TypeError("_id must be a string or integer")
|
|
386
|
+
if not str(_id).strip():
|
|
387
|
+
raise ValueError("_id cannot be empty")
|
|
388
|
+
if not username or not isinstance(username, str):
|
|
389
|
+
raise ValueError("username must be a non-empty string")
|
|
390
|
+
if not password or not isinstance(password, str):
|
|
391
|
+
raise ValueError("password must be a non-empty string")
|
|
392
|
+
with self._lock:
|
|
393
|
+
self._project = _ProjectConfig(str(_id), username, password)
|
|
394
|
+
|
|
395
|
+
def use(self, databases: Iterable[Database] | Database) -> None:
|
|
396
|
+
if isinstance(databases, Database):
|
|
397
|
+
databases = [databases]
|
|
398
|
+
with self._lock:
|
|
399
|
+
for database in databases:
|
|
400
|
+
if not isinstance(database, Database):
|
|
401
|
+
raise TypeError("connection.use() expects Database objects returned by scratchstore.add()")
|
|
402
|
+
database.name = str(database.name).lower()
|
|
403
|
+
if any(existing.name.lower() == database.name for existing in self._databases):
|
|
404
|
+
raise ValueError(f"A database named {database.name!r} is already registered")
|
|
405
|
+
self._databases.append(database)
|
|
406
|
+
|
|
407
|
+
def snapshot(self) -> tuple[_ProjectConfig | None, list[Database]]:
|
|
408
|
+
with self._lock:
|
|
409
|
+
return self._project, list(self._databases)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
class _Server:
|
|
413
|
+
def __init__(
|
|
414
|
+
self,
|
|
415
|
+
project: _ProjectConfig,
|
|
416
|
+
databases: list[Database],
|
|
417
|
+
*,
|
|
418
|
+
automatic: bool = True,
|
|
419
|
+
) -> None:
|
|
420
|
+
self.project = project
|
|
421
|
+
self.automatic = bool(automatic)
|
|
422
|
+
self.databases = {database.name: database for database in databases}
|
|
423
|
+
self._database_order = [database.name for database in databases]
|
|
424
|
+
self._buffers: dict[str, dict[str, Any]] = {}
|
|
425
|
+
self._buffer_lock = threading.RLock()
|
|
426
|
+
self._response_lock = threading.Lock()
|
|
427
|
+
self._send_lock = threading.Lock()
|
|
428
|
+
self._queue: Queue[tuple[str, str] | None] = Queue()
|
|
429
|
+
self._stop_event = threading.Event()
|
|
430
|
+
self._worker: threading.Thread | None = None
|
|
431
|
+
self._receiver: threading.Thread | None = None
|
|
432
|
+
self._http: Any = None
|
|
433
|
+
self._session_id: str | None = None
|
|
434
|
+
self._session_token: str | None = None
|
|
435
|
+
self._authenticated_username: str | None = None
|
|
436
|
+
self._new_scratcher: bool | None = None
|
|
437
|
+
self._cookie_header: str | None = None
|
|
438
|
+
self._ws: Any = None
|
|
439
|
+
self._connection_ready = threading.Event()
|
|
440
|
+
self._ignore_initial_th_snapshot = True
|
|
441
|
+
self._last_response_seed: str | None = None
|
|
442
|
+
|
|
443
|
+
def _authenticate(self) -> None:
|
|
444
|
+
"""Authenticate using Scratch's native login/session-cookie flow.
|
|
445
|
+
|
|
446
|
+
This mirrors scratchattach's current implementation without importing
|
|
447
|
+
scratchattach: POST /login/, extract scratchsessionsid, decode the
|
|
448
|
+
username and X-Token from that cookie, then *optionally* refresh account
|
|
449
|
+
permissions through POST /session (no trailing slash).
|
|
450
|
+
"""
|
|
451
|
+
try:
|
|
452
|
+
import requests
|
|
453
|
+
except ImportError as exc:
|
|
454
|
+
raise ScratchStoreError(
|
|
455
|
+
"The requests package is missing. Run: python -m pip install -r requirements.txt"
|
|
456
|
+
) from exc
|
|
457
|
+
|
|
458
|
+
http = requests.Session()
|
|
459
|
+
csrf_token = "a"
|
|
460
|
+
# These headers are based on scratchattach.utils.commons.headers and
|
|
461
|
+
# scratchattach.site.session.login(). The Cookie header is important:
|
|
462
|
+
# Scratch's login endpoint expects the CSRF cookie to match X-CSRFToken.
|
|
463
|
+
login_headers = {
|
|
464
|
+
"User-Agent": (
|
|
465
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
|
466
|
+
"(KHTML, like Gecko) Chrome/75.0.3770.142 Safari/537.36"
|
|
467
|
+
),
|
|
468
|
+
"X-CSRFToken": csrf_token,
|
|
469
|
+
"X-Requested-With": "XMLHttpRequest",
|
|
470
|
+
"Referer": "https://scratch.mit.edu",
|
|
471
|
+
"Origin": "https://scratch.mit.edu",
|
|
472
|
+
"Cookie": "scratchcsrftoken=a;scratchlanguage=en;",
|
|
473
|
+
}
|
|
474
|
+
LOG.info("Authenticating directly with Scratch as %s", self.project.username)
|
|
475
|
+
|
|
476
|
+
try:
|
|
477
|
+
# scratchattach's current login() uses /login/, not /accounts/login/.
|
|
478
|
+
# It relies on Set-Cookie rather than treating the response body as
|
|
479
|
+
# the authoritative source of the session.
|
|
480
|
+
login_response = http.post(
|
|
481
|
+
"https://scratch.mit.edu/login/",
|
|
482
|
+
json={
|
|
483
|
+
"username": self.project.username,
|
|
484
|
+
"password": self.project.password,
|
|
485
|
+
},
|
|
486
|
+
headers=login_headers,
|
|
487
|
+
timeout=20,
|
|
488
|
+
)
|
|
489
|
+
except requests.RequestException as exc:
|
|
490
|
+
http.close()
|
|
491
|
+
raise ScratchAuthenticationError(
|
|
492
|
+
f"Could not contact Scratch's login endpoint: {exc}"
|
|
493
|
+
) from exc
|
|
494
|
+
|
|
495
|
+
session_id = _extract_session_cookie(login_response, http)
|
|
496
|
+
if not session_id:
|
|
497
|
+
status = getattr(login_response, "status_code", "unknown")
|
|
498
|
+
# Do not print response bodies here; login responses can contain
|
|
499
|
+
# account/security details and should not be copied into logs.
|
|
500
|
+
http.close()
|
|
501
|
+
raise ScratchAuthenticationError(
|
|
502
|
+
f"Scratch login did not provide a scratchsessionsid cookie (HTTP {status}). "
|
|
503
|
+
"The credentials may be invalid, or Scratch may have challenged/blocked this request."
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
# scratchattach decodes this cookie immediately. In particular, the
|
|
507
|
+
# X-Token is embedded in the cookie; it is not necessary to obtain it
|
|
508
|
+
# from /session before opening the cloud WebSocket.
|
|
509
|
+
try:
|
|
510
|
+
session_payload = _decode_scratch_session_id(session_id)
|
|
511
|
+
except ScratchAuthenticationError:
|
|
512
|
+
http.close()
|
|
513
|
+
raise
|
|
514
|
+
actual_username = str(session_payload["username"])
|
|
515
|
+
session_token = str(session_payload["token"])
|
|
516
|
+
if actual_username.casefold() != self.project.username.casefold():
|
|
517
|
+
LOG.warning(
|
|
518
|
+
"Scratch authenticated as %s rather than the configured username %s",
|
|
519
|
+
actual_username,
|
|
520
|
+
self.project.username,
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
session_cookies = {
|
|
524
|
+
"scratchsessionsid": session_id,
|
|
525
|
+
"scratchcsrftoken": csrf_token,
|
|
526
|
+
"scratchlanguage": "en",
|
|
527
|
+
}
|
|
528
|
+
session_headers = dict(login_headers)
|
|
529
|
+
session_headers.pop("Cookie", None)
|
|
530
|
+
session_headers["X-Token"] = session_token
|
|
531
|
+
session_headers["Accept"] = "application/json"
|
|
532
|
+
session_headers["Content-Type"] = "application/json"
|
|
533
|
+
|
|
534
|
+
# This refresh is used only to obtain account permission metadata.
|
|
535
|
+
# scratchattach's Session.update() posts to /session (no trailing
|
|
536
|
+
# slash), with the decoded X-Token and session cookies. A failure here
|
|
537
|
+
# must not discard an otherwise valid session cookie.
|
|
538
|
+
new_scratcher: bool | None = None
|
|
539
|
+
try:
|
|
540
|
+
session_response = http.post(
|
|
541
|
+
"https://scratch.mit.edu/session",
|
|
542
|
+
headers=session_headers,
|
|
543
|
+
cookies=session_cookies,
|
|
544
|
+
timeout=20,
|
|
545
|
+
)
|
|
546
|
+
if session_response.status_code == 200:
|
|
547
|
+
session_data = session_response.json()
|
|
548
|
+
permissions = session_data.get("permissions", {}) if isinstance(session_data, dict) else {}
|
|
549
|
+
user_data = session_data.get("user", {}) if isinstance(session_data, dict) else {}
|
|
550
|
+
flag = permissions.get("new_scratcher") if isinstance(permissions, dict) else None
|
|
551
|
+
if isinstance(flag, bool):
|
|
552
|
+
new_scratcher = flag
|
|
553
|
+
refreshed_username = user_data.get("username") if isinstance(user_data, dict) else None
|
|
554
|
+
if isinstance(refreshed_username, str) and refreshed_username:
|
|
555
|
+
actual_username = refreshed_username
|
|
556
|
+
refreshed_token = user_data.get("token") if isinstance(user_data, dict) else None
|
|
557
|
+
if isinstance(refreshed_token, str) and refreshed_token:
|
|
558
|
+
session_token = refreshed_token
|
|
559
|
+
session_headers["X-Token"] = refreshed_token
|
|
560
|
+
else:
|
|
561
|
+
LOG.warning(
|
|
562
|
+
"Scratch authenticated the session cookie, but POST /session returned HTTP %s; "
|
|
563
|
+
"permission metadata will be checked separately.",
|
|
564
|
+
session_response.status_code,
|
|
565
|
+
)
|
|
566
|
+
except (requests.RequestException, ValueError, TypeError) as exc:
|
|
567
|
+
LOG.warning(
|
|
568
|
+
"Scratch authenticated the session cookie, but account permissions could not be refreshed: %s",
|
|
569
|
+
exc,
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
# /session is the authoritative source for permissions when available.
|
|
573
|
+
# If it is unavailable (e.g. HTTP 403), use the public profile badge as
|
|
574
|
+
# a best-effort fallback. An unknown result is not treated as proof that
|
|
575
|
+
# the account is a New Scratcher or proof that it is a Scratcher.
|
|
576
|
+
if new_scratcher is None:
|
|
577
|
+
new_scratcher = _detect_new_scratcher_from_profile(
|
|
578
|
+
http, actual_username, session_headers, session_cookies
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
if new_scratcher is True:
|
|
582
|
+
http.close()
|
|
583
|
+
self._new_scratcher = True
|
|
584
|
+
raise NewScratcherError(
|
|
585
|
+
"This account is a New Scratcher. Scratch cloud variables require Scratcher status."
|
|
586
|
+
)
|
|
587
|
+
if new_scratcher is None:
|
|
588
|
+
LOG.warning(
|
|
589
|
+
"Scratch login succeeded, but Scratch did not disclose the account's Scratcher badge status. "
|
|
590
|
+
"Continuing with the authenticated session; the cloud server will determine whether cloud access is allowed."
|
|
591
|
+
)
|
|
592
|
+
|
|
593
|
+
self._http = http
|
|
594
|
+
self._session_id = session_id
|
|
595
|
+
self._session_token = session_token
|
|
596
|
+
self._authenticated_username = actual_username
|
|
597
|
+
self._new_scratcher = new_scratcher
|
|
598
|
+
# scratchattach's ScratchCloud sends just the session cookie to the
|
|
599
|
+
# cloud WebSocket, plus the Scratch origin. The X-Token is not a WS cookie.
|
|
600
|
+
self._cookie_header = f"scratchsessionsid={session_id};"
|
|
601
|
+
LOG.info("Logged into Scratch as %s", self._authenticated_username)
|
|
602
|
+
LOG.info(
|
|
603
|
+
"Scratch authentication succeeded",
|
|
604
|
+
"unknown" if self._new_scratcher is None else self._new_scratcher,
|
|
605
|
+
)
|
|
606
|
+
|
|
607
|
+
def _open_cloud_socket(self) -> Any:
|
|
608
|
+
"""Open Scratch's real cloud WebSocket and send its project handshake."""
|
|
609
|
+
try:
|
|
610
|
+
import websocket
|
|
611
|
+
except ImportError as exc:
|
|
612
|
+
raise ScratchStoreError(
|
|
613
|
+
"The websocket-client package is missing. Run: python -m pip install -r requirements.txt"
|
|
614
|
+
) from exc
|
|
615
|
+
|
|
616
|
+
ws = None
|
|
617
|
+
try:
|
|
618
|
+
ws = websocket.create_connection(
|
|
619
|
+
"wss://clouddata.scratch.mit.edu/",
|
|
620
|
+
cookie=self._cookie_header,
|
|
621
|
+
origin="https://scratch.mit.edu",
|
|
622
|
+
timeout=5,
|
|
623
|
+
enable_multithread=True,
|
|
624
|
+
)
|
|
625
|
+
handshake = {
|
|
626
|
+
"method": "handshake",
|
|
627
|
+
"user": self._authenticated_username,
|
|
628
|
+
"project_id": self.project.project_id,
|
|
629
|
+
}
|
|
630
|
+
ws.send(json.dumps(handshake, separators=(",", ":")) + "\n")
|
|
631
|
+
ws.settimeout(1.0)
|
|
632
|
+
except Exception as exc:
|
|
633
|
+
if ws is not None:
|
|
634
|
+
try:
|
|
635
|
+
ws.close()
|
|
636
|
+
except Exception:
|
|
637
|
+
pass
|
|
638
|
+
raise ScratchStoreError(f"Could not connect to Scratch's cloud WebSocket: {exc}") from exc
|
|
639
|
+
|
|
640
|
+
self._ws = ws
|
|
641
|
+
self._connection_ready.set()
|
|
642
|
+
self._ignore_initial_th_snapshot = True
|
|
643
|
+
LOG.info("Connected to Scratch clouddata for project %s", self.project.project_id)
|
|
644
|
+
return ws
|
|
645
|
+
|
|
646
|
+
def connect(self) -> None:
|
|
647
|
+
self._authenticate()
|
|
648
|
+
self._open_cloud_socket()
|
|
649
|
+
self._worker = threading.Thread(
|
|
650
|
+
target=self._worker_loop, name="ScratchStore-worker", daemon=True
|
|
651
|
+
)
|
|
652
|
+
self._receiver = threading.Thread(
|
|
653
|
+
target=self._receiver_loop, name="ScratchStore-cloud-receiver", daemon=True
|
|
654
|
+
)
|
|
655
|
+
self._worker.start()
|
|
656
|
+
self._receiver.start()
|
|
657
|
+
|
|
658
|
+
def _receiver_loop(self) -> None:
|
|
659
|
+
"""Read newline-delimited cloud events and reconnect if the socket drops."""
|
|
660
|
+
try:
|
|
661
|
+
import websocket
|
|
662
|
+
except ImportError:
|
|
663
|
+
return
|
|
664
|
+
|
|
665
|
+
while not self._stop_event.is_set():
|
|
666
|
+
ws = self._ws
|
|
667
|
+
if ws is None:
|
|
668
|
+
if not self.automatic:
|
|
669
|
+
self._stop_event.set()
|
|
670
|
+
break
|
|
671
|
+
if self._stop_event.wait(0.5):
|
|
672
|
+
break
|
|
673
|
+
try:
|
|
674
|
+
self._open_cloud_socket()
|
|
675
|
+
except Exception as reconnect_error:
|
|
676
|
+
LOG.error("Scratch cloud reconnect failed: %s", reconnect_error)
|
|
677
|
+
self._stop_event.wait(3.0)
|
|
678
|
+
continue
|
|
679
|
+
try:
|
|
680
|
+
message = ws.recv()
|
|
681
|
+
if message is None or message == "":
|
|
682
|
+
raise ConnectionError("Scratch cloud WebSocket closed")
|
|
683
|
+
for raw_line in str(message).splitlines():
|
|
684
|
+
if not raw_line.strip():
|
|
685
|
+
continue
|
|
686
|
+
try:
|
|
687
|
+
packet = json.loads(raw_line)
|
|
688
|
+
except json.JSONDecodeError:
|
|
689
|
+
LOG.debug("Ignoring non-JSON cloud frame: %r", raw_line[:160])
|
|
690
|
+
continue
|
|
691
|
+
if isinstance(packet, dict):
|
|
692
|
+
self._handle_cloud_packet(packet)
|
|
693
|
+
except websocket.WebSocketTimeoutException:
|
|
694
|
+
continue
|
|
695
|
+
except Exception as exc:
|
|
696
|
+
if self._stop_event.is_set():
|
|
697
|
+
break
|
|
698
|
+
self._connection_ready.clear()
|
|
699
|
+
try:
|
|
700
|
+
ws.close()
|
|
701
|
+
except Exception:
|
|
702
|
+
pass
|
|
703
|
+
if self._ws is ws:
|
|
704
|
+
self._ws = None
|
|
705
|
+
|
|
706
|
+
if not self.automatic:
|
|
707
|
+
LOG.error("Scratch cloud socket disconnected; automatic reconnect is disabled")
|
|
708
|
+
self._stop_event.set()
|
|
709
|
+
break
|
|
710
|
+
|
|
711
|
+
LOG.warning("Scratch cloud socket disconnected (%s); reconnecting", exc)
|
|
712
|
+
self._stop_event.wait(1.0)
|
|
713
|
+
if self._stop_event.is_set():
|
|
714
|
+
break
|
|
715
|
+
try:
|
|
716
|
+
self._open_cloud_socket()
|
|
717
|
+
except Exception as reconnect_error:
|
|
718
|
+
LOG.error("Scratch cloud reconnect failed: %s", reconnect_error)
|
|
719
|
+
self._stop_event.wait(3.0)
|
|
720
|
+
|
|
721
|
+
def _lookup_log_username(self, variable_name: str, value: str) -> str | None:
|
|
722
|
+
"""Resolve the writer from Scratch's cloud activity log.
|
|
723
|
+
|
|
724
|
+
Scratch's live WebSocket broadcasts variable updates without a writer
|
|
725
|
+
field. The cloud log endpoint includes the actor, so match its newest
|
|
726
|
+
exact name/value record instead of guessing the writer.
|
|
727
|
+
"""
|
|
728
|
+
if self._http is None:
|
|
729
|
+
return None
|
|
730
|
+
normalized_name = variable_name if variable_name.startswith("☁ ") else f"☁ {variable_name}"
|
|
731
|
+
for attempt in range(4):
|
|
732
|
+
try:
|
|
733
|
+
response = self._http.get(
|
|
734
|
+
"https://clouddata.scratch.mit.edu/logs",
|
|
735
|
+
params={"projectid": self.project.project_id, "limit": 50, "offset": 0},
|
|
736
|
+
timeout=10,
|
|
737
|
+
)
|
|
738
|
+
response.raise_for_status()
|
|
739
|
+
records = response.json()
|
|
740
|
+
if isinstance(records, list):
|
|
741
|
+
for record in records:
|
|
742
|
+
if not isinstance(record, dict):
|
|
743
|
+
continue
|
|
744
|
+
record_name = str(record.get("name", ""))
|
|
745
|
+
record_value = str(record.get("value", ""))
|
|
746
|
+
if record_name == normalized_name and record_value == value:
|
|
747
|
+
actor = record.get("user") or record.get("username")
|
|
748
|
+
if isinstance(actor, dict):
|
|
749
|
+
actor = actor.get("username")
|
|
750
|
+
if actor:
|
|
751
|
+
return str(actor)
|
|
752
|
+
# The cloud log can briefly lag the live WebSocket event.
|
|
753
|
+
except Exception as exc:
|
|
754
|
+
LOG.debug("Cloud log actor lookup attempt %d failed: %s", attempt + 1, exc)
|
|
755
|
+
if attempt < 3:
|
|
756
|
+
time.sleep(0.12)
|
|
757
|
+
return None
|
|
758
|
+
|
|
759
|
+
def _handle_cloud_packet(self, packet: dict[str, Any]) -> None:
|
|
760
|
+
if packet.get("method") != "set":
|
|
761
|
+
return
|
|
762
|
+
variable = str(packet.get("name", "")).removeprefix("☁ ")
|
|
763
|
+
if variable != REQUEST_VARIABLE or "value" not in packet:
|
|
764
|
+
return
|
|
765
|
+
value = str(packet.get("value", ""))
|
|
766
|
+
|
|
767
|
+
# The handshake sends the project's current variable values as an
|
|
768
|
+
# initial snapshot. Do not mistake the old TH value for a new request.
|
|
769
|
+
if self._ignore_initial_th_snapshot:
|
|
770
|
+
self._ignore_initial_th_snapshot = False
|
|
771
|
+
LOG.debug("Ignoring the initial TH cloud-variable snapshot")
|
|
772
|
+
return
|
|
773
|
+
|
|
774
|
+
username = packet.get("user") or packet.get("username")
|
|
775
|
+
if not username:
|
|
776
|
+
username = self._lookup_log_username(variable, value)
|
|
777
|
+
if not username:
|
|
778
|
+
LOG.warning("Could not resolve the Scratch username for a TH update; ignoring it")
|
|
779
|
+
return
|
|
780
|
+
|
|
781
|
+
parsed = _split_cloud_value(value)
|
|
782
|
+
if parsed is None:
|
|
783
|
+
LOG.debug("Ignoring malformed TH packet from %s", username)
|
|
784
|
+
return
|
|
785
|
+
payload, seed = parsed
|
|
786
|
+
now = time.monotonic()
|
|
787
|
+
completed: str | None = None
|
|
788
|
+
with self._buffer_lock:
|
|
789
|
+
username_key = str(username).lower()
|
|
790
|
+
buffer = self._buffers.get(username_key)
|
|
791
|
+
if buffer is None or now - buffer["updated"] > 8.0:
|
|
792
|
+
buffer = {"parts": [], "seeds": set(), "updated": now}
|
|
793
|
+
self._buffers[username_key] = buffer
|
|
794
|
+
if seed in buffer["seeds"]:
|
|
795
|
+
return
|
|
796
|
+
buffer["seeds"].add(seed)
|
|
797
|
+
buffer["parts"].append(payload)
|
|
798
|
+
buffer["updated"] = now
|
|
799
|
+
encoded = "".join(buffer["parts"])
|
|
800
|
+
try:
|
|
801
|
+
decoded = decode_text(encoded)
|
|
802
|
+
except ProtocolError:
|
|
803
|
+
LOG.warning("Discarding malformed request stream from %s", username)
|
|
804
|
+
self._buffers.pop(username_key, None)
|
|
805
|
+
return
|
|
806
|
+
if decoded.endswith("stop:"):
|
|
807
|
+
completed = decoded[:-5]
|
|
808
|
+
self._buffers.pop(username_key, None)
|
|
809
|
+
if completed is not None:
|
|
810
|
+
self._queue.put((str(username), completed))
|
|
811
|
+
|
|
812
|
+
def _worker_loop(self) -> None:
|
|
813
|
+
"""Process completed client requests and send their responses.
|
|
814
|
+
|
|
815
|
+
The cloud receiver only assembles TH messages and places completed
|
|
816
|
+
requests on ``self._queue``. This worker performs database operations
|
|
817
|
+
serially so two requests cannot interleave their shared FH responses.
|
|
818
|
+
"""
|
|
819
|
+
while not self._stop_event.is_set():
|
|
820
|
+
try:
|
|
821
|
+
item = self._queue.get(timeout=0.5)
|
|
822
|
+
except Empty:
|
|
823
|
+
continue
|
|
824
|
+
|
|
825
|
+
try:
|
|
826
|
+
if item is None:
|
|
827
|
+
return
|
|
828
|
+
|
|
829
|
+
username, command = item
|
|
830
|
+
try:
|
|
831
|
+
response = self._execute(command, username=username)
|
|
832
|
+
except ProtocolError as exc:
|
|
833
|
+
LOG.warning("Invalid request from %s: %s", username, exc)
|
|
834
|
+
response = "error:bad_request"
|
|
835
|
+
except UnsupportedCharacterError as exc:
|
|
836
|
+
LOG.warning("Unsupported data in request from %s: %s", username, exc)
|
|
837
|
+
response = "error:unsupported_character"
|
|
838
|
+
except Exception:
|
|
839
|
+
# Keep detailed diagnostics on the host, but do not expose
|
|
840
|
+
# exception text or filesystem paths to Scratch clients.
|
|
841
|
+
LOG.exception("Request from %s failed", username)
|
|
842
|
+
response = "error:internal"
|
|
843
|
+
|
|
844
|
+
try:
|
|
845
|
+
self._send_response(response, username)
|
|
846
|
+
except Exception:
|
|
847
|
+
LOG.exception("Could not send the response to %s", username)
|
|
848
|
+
finally:
|
|
849
|
+
self._queue.task_done()
|
|
850
|
+
|
|
851
|
+
def _send_cloud_values(self, values: dict[str, str]) -> None:
|
|
852
|
+
"""Send multiple cloud-variable set packets over our own WebSocket."""
|
|
853
|
+
packets = []
|
|
854
|
+
for variable, value in values.items():
|
|
855
|
+
value = str(value)
|
|
856
|
+
if len(value) > CLOUD_VALUE_LIMIT or not value.replace(".", "").replace("-", "").isnumeric():
|
|
857
|
+
raise ProtocolError(f"Invalid Scratch cloud value for {variable!r}")
|
|
858
|
+
packets.append({
|
|
859
|
+
"method": "set",
|
|
860
|
+
"user": self._authenticated_username,
|
|
861
|
+
"project_id": self.project.project_id,
|
|
862
|
+
"name": variable if variable.startswith("☁ ") else f"☁ {variable}",
|
|
863
|
+
"value": value,
|
|
864
|
+
})
|
|
865
|
+
payload = "".join(json.dumps(packet, separators=(",", ":"), ensure_ascii=False) + "\n" for packet in packets)
|
|
866
|
+
if not self._connection_ready.wait(timeout=10):
|
|
867
|
+
raise ScratchStoreError("Scratch cloud WebSocket is not connected")
|
|
868
|
+
with self._send_lock:
|
|
869
|
+
ws = self._ws
|
|
870
|
+
if ws is None:
|
|
871
|
+
raise ScratchStoreError("Scratch cloud WebSocket disconnected before send")
|
|
872
|
+
try:
|
|
873
|
+
# One WebSocket frame can carry several newline-delimited JSON
|
|
874
|
+
# cloud packets; this keeps an FH page together.
|
|
875
|
+
ws.send(payload)
|
|
876
|
+
except Exception as exc:
|
|
877
|
+
self._connection_ready.clear()
|
|
878
|
+
raise ScratchStoreError(f"Could not send cloud-variable updates: {exc}") from exc
|
|
879
|
+
|
|
880
|
+
def _execute(self, command: str, username: str = "unknown") -> str:
|
|
881
|
+
head = split_escaped(command, ":", maxsplit=1)
|
|
882
|
+
if len(head) != 2:
|
|
883
|
+
raise ProtocolError("Request is missing its command separator.")
|
|
884
|
+
operation, raw_args = head
|
|
885
|
+
actor = str(username).lower()
|
|
886
|
+
|
|
887
|
+
if operation == "ping":
|
|
888
|
+
LOG.info("Ping operation from %s", actor)
|
|
889
|
+
return "pong"
|
|
890
|
+
if operation == "getalldb":
|
|
891
|
+
LOG.info("Get-all-databases operation from %s", actor)
|
|
892
|
+
events._dispatch("on_get", 3, None, None, actor)
|
|
893
|
+
return "|".join(escape_field(name) for name in self._database_order)
|
|
894
|
+
|
|
895
|
+
raw_fields = split_escaped(raw_args, "|")
|
|
896
|
+
fields = [unescape_field(field) for field in raw_fields]
|
|
897
|
+
|
|
898
|
+
if operation == "set":
|
|
899
|
+
if len(fields) != 3:
|
|
900
|
+
raise ProtocolError("set requires database, key, and value")
|
|
901
|
+
database = self._get_database(fields[0])
|
|
902
|
+
LOG.info(
|
|
903
|
+
"Set operation from %s: database=%s key=%s",
|
|
904
|
+
actor, database.name, fields[1].lower(),
|
|
905
|
+
)
|
|
906
|
+
database.set(fields[1], fields[2])
|
|
907
|
+
events._dispatch(
|
|
908
|
+
"on_set", 1, database.name, fields[1].lower(), fields[2].lower(), actor
|
|
909
|
+
)
|
|
910
|
+
return "saved"
|
|
911
|
+
|
|
912
|
+
if operation == "get":
|
|
913
|
+
if len(fields) == 1:
|
|
914
|
+
database = self._get_database(fields[0])
|
|
915
|
+
LOG.info(
|
|
916
|
+
"Get-keys operation from %s: database=%s",
|
|
917
|
+
actor, database.name,
|
|
918
|
+
)
|
|
919
|
+
events._dispatch("on_get", 2, database.name, None, actor)
|
|
920
|
+
return "|".join(escape_field(key) for key in database.keys())
|
|
921
|
+
if len(fields) == 2:
|
|
922
|
+
database = self._get_database(fields[0])
|
|
923
|
+
LOG.info(
|
|
924
|
+
"Get-value operation from %s: database=%s key=%s",
|
|
925
|
+
actor, database.name, fields[1].lower(),
|
|
926
|
+
)
|
|
927
|
+
value = database.get(fields[1])
|
|
928
|
+
if value is None:
|
|
929
|
+
return "not_found"
|
|
930
|
+
events._dispatch("on_get", 1, database.name, fields[1].lower(), actor)
|
|
931
|
+
return escape_field(value)
|
|
932
|
+
raise ProtocolError("get requires a database, optionally followed by a key")
|
|
933
|
+
|
|
934
|
+
if operation in {"del", "delete"}:
|
|
935
|
+
if len(fields) != 2:
|
|
936
|
+
raise ProtocolError("del requires database and key")
|
|
937
|
+
database = self._get_database(fields[0])
|
|
938
|
+
LOG.info(
|
|
939
|
+
"Delete operation from %s: database=%s key=%s",
|
|
940
|
+
actor, database.name, fields[1].lower(),
|
|
941
|
+
)
|
|
942
|
+
deleted_value = database.get(fields[1])
|
|
943
|
+
deleted = database.delete(fields[1])
|
|
944
|
+
if deleted:
|
|
945
|
+
events._dispatch(
|
|
946
|
+
"on_set", 2, database.name, fields[1].lower(), deleted_value, actor
|
|
947
|
+
)
|
|
948
|
+
return "deleted"
|
|
949
|
+
return "not_found"
|
|
950
|
+
|
|
951
|
+
LOG.warning("Unknown operation from %s: %s", actor, operation)
|
|
952
|
+
raise ProtocolError(f"Unknown operation {operation!r}")
|
|
953
|
+
|
|
954
|
+
def _get_database(self, name: str) -> Database:
|
|
955
|
+
database = self.databases.get(str(name).lower())
|
|
956
|
+
if database is None:
|
|
957
|
+
raise ProtocolError(f"Unknown database {name!r}")
|
|
958
|
+
return database
|
|
959
|
+
|
|
960
|
+
def _send_response(self, body: str, username: str) -> None:
|
|
961
|
+
# Keep the two username fields separate: the first belongs to the data
|
|
962
|
+
# response, while stop;username is the end-of-stream marker.
|
|
963
|
+
# Scratch usernames can contain uppercase letters, but the wire alphabet
|
|
964
|
+
# is deliberately lowercase. The client lowercases USERNAME() as well.
|
|
965
|
+
username = str(username).lower()
|
|
966
|
+
response_text = f"{str(body).lower()};username{username}stop;{username}"
|
|
967
|
+
encoded = encode_text(response_text)
|
|
968
|
+
chunks = [encoded[i:i + FRAME_DATA_LIMIT] for i in range(0, len(encoded), FRAME_DATA_LIMIT)]
|
|
969
|
+
if not chunks:
|
|
970
|
+
chunks = [""]
|
|
971
|
+
|
|
972
|
+
with self._response_lock:
|
|
973
|
+
for page_start in range(0, len(chunks), RESPONSE_PAGE_LANES):
|
|
974
|
+
page_chunks = chunks[page_start:page_start + RESPONSE_PAGE_LANES]
|
|
975
|
+
lane_count = len(page_chunks)
|
|
976
|
+
# The first seed digit tells the Scratch client how many FH
|
|
977
|
+
# lanes belong to this page. The remaining four digits keep
|
|
978
|
+
# each page uniquely identifiable without touching unused lanes.
|
|
979
|
+
seed = f"{lane_count}{random.randint(0, 9999):04d}"
|
|
980
|
+
while seed == self._last_response_seed:
|
|
981
|
+
seed = f"{lane_count}{random.randint(0, 9999):04d}"
|
|
982
|
+
self._last_response_seed = seed
|
|
983
|
+
values: dict[str, str] = {}
|
|
984
|
+
for lane_name, payload in zip(RESPONSE_LANE_NAMES, page_chunks):
|
|
985
|
+
# Only write populated lanes; later FH variables retain
|
|
986
|
+
# their previous values and are ignored by the client.
|
|
987
|
+
value = f"{payload}.{seed}"
|
|
988
|
+
if len(value) > CLOUD_VALUE_LIMIT:
|
|
989
|
+
raise AssertionError("A cloud value exceeded Scratch's 256-character limit")
|
|
990
|
+
values[lane_name] = value
|
|
991
|
+
self._send_cloud_values(values)
|
|
992
|
+
# Long responses are streamed page-by-page, with 0.5-second
|
|
993
|
+
# inter-page spacing to reduce bursts of cloud variable updates.
|
|
994
|
+
if page_start + RESPONSE_PAGE_LANES < len(chunks):
|
|
995
|
+
time.sleep(1)
|
|
996
|
+
# Leave the final response visible briefly before the next queued
|
|
997
|
+
# request overwrites the shared FH variables.
|
|
998
|
+
time.sleep(1)
|
|
999
|
+
|
|
1000
|
+
def run_forever(self) -> None:
|
|
1001
|
+
self.connect()
|
|
1002
|
+
LOG.info("ScratchStore is running. Press Ctrl+C to stop.")
|
|
1003
|
+
try:
|
|
1004
|
+
while not self._stop_event.wait(0.5):
|
|
1005
|
+
pass
|
|
1006
|
+
except KeyboardInterrupt:
|
|
1007
|
+
LOG.info("Stopping ScratchStore...")
|
|
1008
|
+
finally:
|
|
1009
|
+
self.stop()
|
|
1010
|
+
|
|
1011
|
+
def stop(self) -> None:
|
|
1012
|
+
self._stop_event.set()
|
|
1013
|
+
try:
|
|
1014
|
+
self._queue.put_nowait(None)
|
|
1015
|
+
except Exception:
|
|
1016
|
+
pass
|
|
1017
|
+
self._connection_ready.clear()
|
|
1018
|
+
ws = self._ws
|
|
1019
|
+
self._ws = None
|
|
1020
|
+
if ws is not None:
|
|
1021
|
+
try:
|
|
1022
|
+
ws.close()
|
|
1023
|
+
except Exception:
|
|
1024
|
+
pass
|
|
1025
|
+
for thread in (self._receiver, self._worker):
|
|
1026
|
+
if thread is not None and thread.is_alive() and thread is not threading.current_thread():
|
|
1027
|
+
thread.join(timeout=2.0)
|
|
1028
|
+
if self._http is not None:
|
|
1029
|
+
try:
|
|
1030
|
+
self._http.close()
|
|
1031
|
+
except Exception:
|
|
1032
|
+
pass
|
|
1033
|
+
|
|
1034
|
+
|
|
1035
|
+
def add(name: str, file: str | Path) -> Database:
|
|
1036
|
+
"""Open an existing JSON database or create an empty one."""
|
|
1037
|
+
if not isinstance(name, str) or not name:
|
|
1038
|
+
raise ValueError("name must be a non-empty string")
|
|
1039
|
+
return Database(name=name, file=Path(file))
|
|
1040
|
+
|
|
1041
|
+
|
|
1042
|
+
connection = _Connection()
|
|
1043
|
+
_active_server: _Server | None = None
|
|
1044
|
+
|
|
1045
|
+
|
|
1046
|
+
def start(verbose: bool = False, automatic: bool = True) -> None:
|
|
1047
|
+
"""Start the Scratch server.
|
|
1048
|
+
|
|
1049
|
+
Args:
|
|
1050
|
+
verbose: Enable informational server logs when true.
|
|
1051
|
+
automatic: Reconnect to the cloud WebSocket after disconnects when true.
|
|
1052
|
+
"""
|
|
1053
|
+
global _active_server
|
|
1054
|
+
if not isinstance(verbose, bool):
|
|
1055
|
+
raise TypeError("verbose must be a bool")
|
|
1056
|
+
if not isinstance(automatic, bool):
|
|
1057
|
+
raise TypeError("automatic must be a bool")
|
|
1058
|
+
|
|
1059
|
+
project, databases = connection.snapshot()
|
|
1060
|
+
if project is None:
|
|
1061
|
+
raise ScratchStoreError(
|
|
1062
|
+
"Call scratchstore.connection.setup(...) before scratchstore.start()."
|
|
1063
|
+
)
|
|
1064
|
+
if not databases:
|
|
1065
|
+
raise ScratchStoreError(
|
|
1066
|
+
"Register at least one database with scratchstore.connection.use(databases=[...])."
|
|
1067
|
+
)
|
|
1068
|
+
if len(databases) > 100:
|
|
1069
|
+
raise ScratchStoreError("This version supports up to 100 databases.")
|
|
1070
|
+
|
|
1071
|
+
# Keep application-provided logging configuration intact.
|
|
1072
|
+
if not logging.getLogger().handlers:
|
|
1073
|
+
logging.basicConfig(
|
|
1074
|
+
level=logging.INFO if verbose else logging.WARNING,
|
|
1075
|
+
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
|
1076
|
+
)
|
|
1077
|
+
LOG.setLevel(logging.INFO if verbose else logging.WARNING)
|
|
1078
|
+
|
|
1079
|
+
_active_server = _Server(project, databases, automatic=automatic)
|
|
1080
|
+
_active_server.run_forever()
|
|
1081
|
+
|
|
1082
|
+
|
|
1083
|
+
def stop() -> None:
|
|
1084
|
+
"""Stop the active server, if one is running."""
|
|
1085
|
+
if _active_server is not None:
|
|
1086
|
+
_active_server.stop()
|
|
1087
|
+
|
|
1088
|
+
|
|
1089
|
+
__all__ = [
|
|
1090
|
+
"Database", "ScratchStoreError", "NewScratcherError",
|
|
1091
|
+
"UnsupportedCharacterError", "ProtocolError", "ScratchAuthenticationError",
|
|
1092
|
+
"connection", "events", "add", "start", "stop",
|
|
1093
|
+
"encode_text", "decode_text",
|
|
1094
|
+
]
|
|
1095
|
+
|
|
1096
|
+
|
|
1097
|
+
if __name__ == "__main__":
|
|
1098
|
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
|
1099
|
+
print("ScratchStore server module imported successfully.")
|
|
1100
|
+
print("Configure scratchstore.connection, register databases, then call scratchstore.start().")
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: scratchstore
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: A short summary of what your module does
|
|
5
|
+
Project-URL: Homepage, https://github.com/gixtuh/scratchstore
|
|
6
|
+
Author: gixtuh
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Requires-Python: >=3.9
|
|
12
|
+
Requires-Dist: requests>=2.31.0
|
|
13
|
+
Requires-Dist: websocket-client>=1.8.0
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# scratchstore
|
|
17
|
+
ScratchStore (inspired by the word "data store") is a cloud data store/database engine which lets you write anything into a data store file using Python and Scratch with the power of cloud variables.
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
```bash
|
|
21
|
+
pip install scratchstore
|
|
22
|
+
```
|
|
23
|
+
... is all that's required to install ScratchStore
|
|
24
|
+
|
|
25
|
+
**ScratchStore is still in active development, expect bugs!**
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
scratchstore/__init__.py,sha256=3yWUlauWHU8WnjOg5CnmzcUss5y0KOxVjy76YyJ_Tsc,45305
|
|
2
|
+
scratchstore-1.0.0.dist-info/METADATA,sha256=N-d3uPbGXNYLIZ3CmnTiN5YcTzJb42Tmwkh17AjxPz0,868
|
|
3
|
+
scratchstore-1.0.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
4
|
+
scratchstore-1.0.0.dist-info/licenses/LICENSE,sha256=gFaoemo3q8MXu-GvXvXonMPTw8f9_Cs-XfT48epyaPs,1048
|
|
5
|
+
scratchstore-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
Copyright (c) 2026 gixtuh
|
|
2
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
3
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
4
|
+
in the Software without restriction, including without limitation the rights
|
|
5
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
6
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
7
|
+
furnished to do so, subject to the following conditions:
|
|
8
|
+
|
|
9
|
+
The above copyright notice and this permission notice shall be included in all
|
|
10
|
+
copies or substantial portions of the Software.
|
|
11
|
+
|
|
12
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
13
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
14
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
15
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
16
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
17
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
18
|
+
SOFTWARE.
|