rootme-sdk 0.3.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.
- rootme_sdk/__init__.py +56 -0
- rootme_sdk/authentication/__init__.py +1 -0
- rootme_sdk/authentication/browser.py +328 -0
- rootme_sdk/authentication/credentials.py +49 -0
- rootme_sdk/authentication/session.py +166 -0
- rootme_sdk/client.py +388 -0
- rootme_sdk/errors.py +56 -0
- rootme_sdk/models.py +120 -0
- rootme_sdk/parsers/__init__.py +1 -0
- rootme_sdk/parsers/api.py +101 -0
- rootme_sdk/parsers/web.py +199 -0
- rootme_sdk/py.typed +0 -0
- rootme_sdk/responses.py +47 -0
- rootme_sdk/transport.py +176 -0
- rootme_sdk/urls.py +27 -0
- rootme_sdk-0.3.0.dist-info/METADATA +127 -0
- rootme_sdk-0.3.0.dist-info/RECORD +19 -0
- rootme_sdk-0.3.0.dist-info/WHEEL +4 -0
- rootme_sdk-0.3.0.dist-info/licenses/LICENSE +21 -0
rootme_sdk/__init__.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from .authentication.session import Session, SessionCookie
|
|
2
|
+
from .client import RootMeClient
|
|
3
|
+
from .errors import (
|
|
4
|
+
AuthenticationRequiredError,
|
|
5
|
+
BrowserUnavailableError,
|
|
6
|
+
HumanInterventionRequiredError,
|
|
7
|
+
NetworkError,
|
|
8
|
+
NotFoundError,
|
|
9
|
+
PermissionDeniedError,
|
|
10
|
+
RateLimitedError,
|
|
11
|
+
RootMeError,
|
|
12
|
+
UnexpectedResponseError,
|
|
13
|
+
)
|
|
14
|
+
from .models import (
|
|
15
|
+
Category,
|
|
16
|
+
Challenge,
|
|
17
|
+
Collection,
|
|
18
|
+
FormField,
|
|
19
|
+
JSONObject,
|
|
20
|
+
JSONValue,
|
|
21
|
+
Resource,
|
|
22
|
+
SubmissionResult,
|
|
23
|
+
SubmissionStatus,
|
|
24
|
+
Upload,
|
|
25
|
+
UserProfile,
|
|
26
|
+
WebForm,
|
|
27
|
+
WebPage,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"AuthenticationRequiredError",
|
|
32
|
+
"BrowserUnavailableError",
|
|
33
|
+
"Category",
|
|
34
|
+
"Collection",
|
|
35
|
+
"Challenge",
|
|
36
|
+
"FormField",
|
|
37
|
+
"HumanInterventionRequiredError",
|
|
38
|
+
"JSONObject",
|
|
39
|
+
"JSONValue",
|
|
40
|
+
"NetworkError",
|
|
41
|
+
"NotFoundError",
|
|
42
|
+
"PermissionDeniedError",
|
|
43
|
+
"RateLimitedError",
|
|
44
|
+
"Resource",
|
|
45
|
+
"RootMeError",
|
|
46
|
+
"RootMeClient",
|
|
47
|
+
"Session",
|
|
48
|
+
"SessionCookie",
|
|
49
|
+
"SubmissionResult",
|
|
50
|
+
"SubmissionStatus",
|
|
51
|
+
"UnexpectedResponseError",
|
|
52
|
+
"Upload",
|
|
53
|
+
"UserProfile",
|
|
54
|
+
"WebForm",
|
|
55
|
+
"WebPage",
|
|
56
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__all__: list[str] = []
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"""Managed Playwright assistance for Root-Me's JavaScript-dependent website."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from base64 import b64decode, b64encode
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import TYPE_CHECKING, cast
|
|
12
|
+
from urllib.parse import parse_qs, urljoin, urlsplit
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from ..errors import (
|
|
17
|
+
AuthenticationRequiredError,
|
|
18
|
+
BrowserUnavailableError,
|
|
19
|
+
HumanInterventionRequiredError,
|
|
20
|
+
NetworkError,
|
|
21
|
+
RootMeError,
|
|
22
|
+
UnexpectedResponseError,
|
|
23
|
+
)
|
|
24
|
+
from ..responses import check_response
|
|
25
|
+
from ..urls import platform_url
|
|
26
|
+
from .session import DEFAULT_AGENT, WEB_HOST, Session, SessionCookie
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from playwright.sync_api import Response as BrowserResponse
|
|
30
|
+
|
|
31
|
+
_FETCH_SCRIPT = """async ({url, method, body, contentType, timeout}) => {
|
|
32
|
+
const bytes = body === null ? undefined
|
|
33
|
+
: Uint8Array.from(atob(body), c => c.charCodeAt(0));
|
|
34
|
+
const r = await fetch(url, {method, body: bytes, credentials: 'include',
|
|
35
|
+
redirect: 'manual',
|
|
36
|
+
signal: AbortSignal.timeout(timeout), headers: {'Content-Type': contentType}});
|
|
37
|
+
const result = new Uint8Array(await r.arrayBuffer());
|
|
38
|
+
let text = ''; for (const value of result) text += String.fromCharCode(value);
|
|
39
|
+
return {status: r.status, body: btoa(text),
|
|
40
|
+
retryAfter: r.headers.get('Retry-After') || ''};
|
|
41
|
+
}"""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class BrowserSession:
|
|
45
|
+
"""An isolated managed browser kept alive for JS-dependent website operations."""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
session: Session,
|
|
50
|
+
*,
|
|
51
|
+
executable_path: str | None = None,
|
|
52
|
+
timeout: float = 180,
|
|
53
|
+
) -> None:
|
|
54
|
+
"""Open an isolated browser without using the user's ordinary browser profile."""
|
|
55
|
+
from playwright.sync_api import Error as PlaywrightError
|
|
56
|
+
from playwright.sync_api import sync_playwright
|
|
57
|
+
|
|
58
|
+
if timeout <= 0:
|
|
59
|
+
raise ValueError("Browser timeout must be positive.")
|
|
60
|
+
_require_display()
|
|
61
|
+
self.session, self.timeout = session, timeout
|
|
62
|
+
try:
|
|
63
|
+
self.engine = sync_playwright().start()
|
|
64
|
+
except PlaywrightError:
|
|
65
|
+
raise BrowserUnavailableError("Could not start the browser runtime.") from None
|
|
66
|
+
try:
|
|
67
|
+
self._start(executable_path)
|
|
68
|
+
except Exception:
|
|
69
|
+
self.engine.stop()
|
|
70
|
+
raise
|
|
71
|
+
|
|
72
|
+
def _start(self, executable_path: str | None) -> None:
|
|
73
|
+
"""Start only headed Chromium and translate expected browser startup failures."""
|
|
74
|
+
from playwright.sync_api import Error as PlaywrightError
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
executable = _executable(
|
|
78
|
+
self.engine.chromium.executable_path, executable_path, self.timeout
|
|
79
|
+
)
|
|
80
|
+
self.browser = self.engine.chromium.launch(headless=False, executable_path=executable)
|
|
81
|
+
agent = self.session.user_agent if self.session.user_agent != DEFAULT_AGENT else None
|
|
82
|
+
self.context = self.browser.new_context(user_agent=agent)
|
|
83
|
+
self._install_cookies()
|
|
84
|
+
self.page = self.context.new_page()
|
|
85
|
+
self.page.set_default_timeout(self.timeout * 1000)
|
|
86
|
+
except PlaywrightError:
|
|
87
|
+
raise BrowserUnavailableError(
|
|
88
|
+
"Could not start headed Chromium; check the display and system dependencies."
|
|
89
|
+
) from None
|
|
90
|
+
|
|
91
|
+
def _install_cookies(self) -> None:
|
|
92
|
+
"""Constrain every imported browser cookie to the website host."""
|
|
93
|
+
self.context.add_cookies(
|
|
94
|
+
[
|
|
95
|
+
{
|
|
96
|
+
"name": c.name,
|
|
97
|
+
"value": c.value,
|
|
98
|
+
"domain": WEB_HOST,
|
|
99
|
+
"path": c.path,
|
|
100
|
+
"expires": c.expires if c.expires is not None else -1,
|
|
101
|
+
"secure": True,
|
|
102
|
+
}
|
|
103
|
+
for c in self.session.cookies
|
|
104
|
+
if c.valid()
|
|
105
|
+
]
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
def close(self) -> None:
|
|
109
|
+
"""Release the browser and Playwright process, including on context exit."""
|
|
110
|
+
self.engine.stop()
|
|
111
|
+
|
|
112
|
+
def prepare(self) -> Session:
|
|
113
|
+
"""Prepare anonymous JS-capable website access without requiring account login."""
|
|
114
|
+
self.request(httpx.Request("GET", "https://www.root-me.org/"))
|
|
115
|
+
return self.session
|
|
116
|
+
|
|
117
|
+
def authenticate(self, username: str, password: str) -> Session:
|
|
118
|
+
"""Authenticate without allowing Playwright call logs to reveal supplied passwords."""
|
|
119
|
+
from playwright.sync_api import Error as PlaywrightError
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
return self._authenticate(username, password)
|
|
123
|
+
except PlaywrightError:
|
|
124
|
+
raise NetworkError("Browser authentication failed; retry explicitly.") from None
|
|
125
|
+
|
|
126
|
+
def _authenticate(self, username: str, password: str) -> Session:
|
|
127
|
+
"""Await the native login response and verify account access independently of UI."""
|
|
128
|
+
check_response(self._get(httpx.Request("GET", f"https://{WEB_HOST}/?page=login&lang=en")))
|
|
129
|
+
self.page.wait_for_load_state("load")
|
|
130
|
+
self._settle_login()
|
|
131
|
+
self.page.locator('#formulaire_login input[name="var_login"]').fill(username)
|
|
132
|
+
self.page.locator('#formulaire_login input[name="password"]').fill(password)
|
|
133
|
+
self._settle_login()
|
|
134
|
+
with self.page.expect_response(_login_response, timeout=self.timeout * 1000) as pending:
|
|
135
|
+
self.page.locator('#formulaire_login input[type="submit"]').click()
|
|
136
|
+
self._complete_login(pending.value)
|
|
137
|
+
self._confirm_login()
|
|
138
|
+
return self.session
|
|
139
|
+
|
|
140
|
+
def _complete_login(self, response: BrowserResponse) -> None:
|
|
141
|
+
"""Await decoded AJAX responses or native redirects without relying on menu updates."""
|
|
142
|
+
content = b"" if 300 <= response.status < 400 else response.body()
|
|
143
|
+
check_response(
|
|
144
|
+
httpx.Response(
|
|
145
|
+
response.status,
|
|
146
|
+
headers=_decoded_headers(response),
|
|
147
|
+
content=content,
|
|
148
|
+
request=httpx.Request("POST", response.url),
|
|
149
|
+
)
|
|
150
|
+
)
|
|
151
|
+
if 300 <= response.status < 400:
|
|
152
|
+
target = platform_url(urljoin(response.url, response.all_headers().get("location", "")))
|
|
153
|
+
self.page.wait_for_url(
|
|
154
|
+
target, wait_until="domcontentloaded", timeout=self.timeout * 1000
|
|
155
|
+
)
|
|
156
|
+
self._settle_login()
|
|
157
|
+
|
|
158
|
+
def _settle_login(self) -> None:
|
|
159
|
+
"""Await page initialization and pending identity AJAX before advancing login."""
|
|
160
|
+
self.page.wait_for_function(
|
|
161
|
+
"window.jQuery && jQuery.isReady && jQuery.active === 0 && "
|
|
162
|
+
"(!window.login_info || !login_info.informe_auteur_en_cours)",
|
|
163
|
+
timeout=self.timeout * 1000,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
def _confirm_login(self) -> None:
|
|
167
|
+
"""Verify account-only access within the browser tab that completed authentication."""
|
|
168
|
+
check_response(
|
|
169
|
+
self._get(httpx.Request("GET", f"https://{WEB_HOST}/?page=preferences&lang=en"))
|
|
170
|
+
)
|
|
171
|
+
editable = self.page.locator(
|
|
172
|
+
'input[name="formulaire_action"][value="modifier_auteur"]'
|
|
173
|
+
).count()
|
|
174
|
+
if not self.session.spip_session or not editable:
|
|
175
|
+
raise AuthenticationRequiredError(
|
|
176
|
+
"Login did not grant account access.", reason="rejected"
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
def _verification(self) -> None:
|
|
180
|
+
"""Wait for the site's own JavaScript or human verification to finish."""
|
|
181
|
+
from playwright.sync_api import TimeoutError as PlaywrightTimeoutError
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
self.page.wait_for_function(
|
|
185
|
+
"!document.querySelector('#anubis_challenge, #anubis_version, [id^=cf-chl-]')",
|
|
186
|
+
timeout=self.timeout * 1000,
|
|
187
|
+
)
|
|
188
|
+
except PlaywrightTimeoutError:
|
|
189
|
+
raise HumanInterventionRequiredError("https://www.root-me.org/") from None
|
|
190
|
+
self._sync()
|
|
191
|
+
|
|
192
|
+
def _sync(self) -> None:
|
|
193
|
+
"""Refresh isolated browser cookies and preserve its actual user agent."""
|
|
194
|
+
self.session.cookies = tuple(
|
|
195
|
+
SessionCookie(
|
|
196
|
+
c["name"],
|
|
197
|
+
c["value"],
|
|
198
|
+
WEB_HOST,
|
|
199
|
+
c["path"],
|
|
200
|
+
c["expires"] if c["expires"] != -1 else None,
|
|
201
|
+
)
|
|
202
|
+
for c in self.context.cookies("https://www.root-me.org/")
|
|
203
|
+
)
|
|
204
|
+
self.session.user_agent = str(self.page.evaluate("navigator.userAgent"))
|
|
205
|
+
|
|
206
|
+
def request(self, request: httpx.Request) -> httpx.Response:
|
|
207
|
+
"""Navigate for reads; send writes once without automatically following redirects."""
|
|
208
|
+
from playwright.sync_api import Error as PlaywrightError
|
|
209
|
+
|
|
210
|
+
platform_url(str(request.url))
|
|
211
|
+
if request.url.host != WEB_HOST:
|
|
212
|
+
raise ValueError("Browser requests are limited to the website.")
|
|
213
|
+
try:
|
|
214
|
+
return self._get(request) if request.method == "GET" else self._fetch(request)
|
|
215
|
+
except PlaywrightError:
|
|
216
|
+
raise NetworkError("Browser request failed; its outcome may be unknown.") from None
|
|
217
|
+
|
|
218
|
+
def _get(self, request: httpx.Request) -> httpx.Response:
|
|
219
|
+
"""Return the rendered document after native browser verification."""
|
|
220
|
+
response = self.page.goto(str(request.url), wait_until="domcontentloaded")
|
|
221
|
+
self._verification()
|
|
222
|
+
platform_url(self.page.url)
|
|
223
|
+
if response is None:
|
|
224
|
+
raise UnexpectedResponseError("Browser navigation produced no response.")
|
|
225
|
+
return httpx.Response(
|
|
226
|
+
response.status,
|
|
227
|
+
headers=_decoded_headers(response),
|
|
228
|
+
text=self.page.content(),
|
|
229
|
+
request=httpx.Request("GET", self.page.url),
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
def _fetch(self, request: httpx.Request) -> httpx.Response:
|
|
233
|
+
"""Send encoded or multipart bodies with an abort deadline and no write redirects."""
|
|
234
|
+
body = b64encode(request.read()).decode() if request.method != "GET" else None
|
|
235
|
+
data = cast(
|
|
236
|
+
dict[str, str | int],
|
|
237
|
+
self.page.evaluate(
|
|
238
|
+
_FETCH_SCRIPT,
|
|
239
|
+
{
|
|
240
|
+
"url": str(request.url),
|
|
241
|
+
"method": request.method,
|
|
242
|
+
"body": body,
|
|
243
|
+
"contentType": request.headers.get("content-type", "application/octet-stream"),
|
|
244
|
+
"timeout": self.timeout * 1000,
|
|
245
|
+
},
|
|
246
|
+
),
|
|
247
|
+
)
|
|
248
|
+
self._sync()
|
|
249
|
+
if data["status"] == 0:
|
|
250
|
+
raise UnexpectedResponseError("Write redirected; its outcome requires confirmation.")
|
|
251
|
+
headers = {"Retry-After": str(data["retryAfter"])} if data.get("retryAfter") else {}
|
|
252
|
+
return httpx.Response(
|
|
253
|
+
int(data["status"]),
|
|
254
|
+
headers=headers,
|
|
255
|
+
content=b64decode(str(data["body"])),
|
|
256
|
+
request=request,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
def download(self, url: str) -> httpx.Response:
|
|
260
|
+
"""Read a same-origin binary attachment without converting it into rendered HTML."""
|
|
261
|
+
platform_url(url)
|
|
262
|
+
if httpx.URL(url).host != WEB_HOST:
|
|
263
|
+
raise ValueError("Browser downloads must use the website host.")
|
|
264
|
+
from playwright.sync_api import Error as PlaywrightError
|
|
265
|
+
|
|
266
|
+
try:
|
|
267
|
+
return self._fetch(httpx.Request("GET", url))
|
|
268
|
+
except PlaywrightError:
|
|
269
|
+
raise NetworkError("Browser download failed.") from None
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _decoded_headers(response: BrowserResponse) -> dict[str, str]:
|
|
273
|
+
"""Preserve rate-limit headers without decoding browser response bodies a second time."""
|
|
274
|
+
return {
|
|
275
|
+
name: value
|
|
276
|
+
for name, value in response.all_headers().items()
|
|
277
|
+
if name.lower() not in {"content-encoding", "content-length"}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _require_display() -> None:
|
|
282
|
+
"""Reject unsupported Linux environments before starting or downloading a browser."""
|
|
283
|
+
if sys.platform == "linux" and not os.environ.get("DISPLAY"):
|
|
284
|
+
raise BrowserUnavailableError("Headed Chromium requires an X11 display (DISPLAY) on Linux.")
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _login_response(response: BrowserResponse) -> bool:
|
|
288
|
+
"""Identify the website's native login POST in the isolated page."""
|
|
289
|
+
return (
|
|
290
|
+
response.request.method == "POST"
|
|
291
|
+
and urlsplit(response.url).hostname == WEB_HOST
|
|
292
|
+
and parse_qs(response.request.post_data or "").get("formulaire_action") == ["login"]
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _executable(default: str, requested: str | None, timeout: float) -> str:
|
|
297
|
+
"""Find installed Chromium, or install its Playwright-managed binary on demand."""
|
|
298
|
+
if requested is not None:
|
|
299
|
+
return requested
|
|
300
|
+
installed = _system_chromium()
|
|
301
|
+
if installed:
|
|
302
|
+
return installed
|
|
303
|
+
if not Path(default).is_file():
|
|
304
|
+
try:
|
|
305
|
+
subprocess.run(
|
|
306
|
+
[sys.executable, "-m", "playwright", "install", "chromium", "--no-shell"],
|
|
307
|
+
check=True,
|
|
308
|
+
stdout=subprocess.DEVNULL,
|
|
309
|
+
stderr=subprocess.DEVNULL,
|
|
310
|
+
timeout=timeout,
|
|
311
|
+
)
|
|
312
|
+
except (OSError, subprocess.SubprocessError):
|
|
313
|
+
raise RootMeError("Automatic Chromium setup failed.") from None
|
|
314
|
+
if not Path(default).is_file():
|
|
315
|
+
raise RootMeError("Automatic Chromium setup did not produce a browser.")
|
|
316
|
+
return default
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _system_chromium() -> str | None:
|
|
320
|
+
"""Locate common Chrome/Chromium installations without reading browser profiles."""
|
|
321
|
+
for name in ("google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "msedge"):
|
|
322
|
+
if executable := shutil.which(name):
|
|
323
|
+
return executable
|
|
324
|
+
locations = [Path("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome")]
|
|
325
|
+
for variable in ("LOCALAPPDATA", "PROGRAMFILES", "PROGRAMFILES(X86)"):
|
|
326
|
+
if directory := os.environ.get(variable):
|
|
327
|
+
locations.append(Path(directory) / "Google/Chrome/Application/chrome.exe")
|
|
328
|
+
return next((str(path) for path in locations if path.is_file()), None)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Validate in-memory credentials or a local JSON credential file."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class Credentials:
|
|
12
|
+
"""Transient login values that never reveal the password in their representation."""
|
|
13
|
+
|
|
14
|
+
username: str
|
|
15
|
+
password: str = field(repr=False)
|
|
16
|
+
|
|
17
|
+
def __post_init__(self) -> None:
|
|
18
|
+
"""Require nonempty strings without including invalid values in errors."""
|
|
19
|
+
if not isinstance(self.username, str) or not self.username:
|
|
20
|
+
raise ValueError("Login must be a nonempty string.")
|
|
21
|
+
if not isinstance(self.password, str) or not self.password:
|
|
22
|
+
raise ValueError("Password must be a nonempty string.")
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
def load(
|
|
26
|
+
cls,
|
|
27
|
+
username: str | None,
|
|
28
|
+
password: str | None,
|
|
29
|
+
credentials_file: str | Path | None,
|
|
30
|
+
) -> Credentials:
|
|
31
|
+
"""Read exactly one credential source without retaining its file path."""
|
|
32
|
+
if credentials_file is not None:
|
|
33
|
+
if username is not None or password is not None:
|
|
34
|
+
raise ValueError("Supply a credentials file or login/password, not both.")
|
|
35
|
+
return cls._from_file(credentials_file)
|
|
36
|
+
if username is None or password is None:
|
|
37
|
+
raise ValueError("Supply both login and password.")
|
|
38
|
+
return cls(username, password)
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def _from_file(cls, path: str | Path) -> Credentials:
|
|
42
|
+
"""Parse a JSON object containing login and password, keeping errors private."""
|
|
43
|
+
try:
|
|
44
|
+
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
45
|
+
except (OSError, ValueError):
|
|
46
|
+
raise ValueError("Cannot read a valid JSON credentials file.") from None
|
|
47
|
+
if not isinstance(data, dict) or set(data) != {"login", "password"}:
|
|
48
|
+
raise ValueError("Credentials file must contain only login and password.")
|
|
49
|
+
return cls(data["login"], data["password"])
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Scoped authentication state and explicit private-file persistence."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
import os
|
|
8
|
+
import tempfile
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from time import time
|
|
12
|
+
|
|
13
|
+
from ..errors import UnexpectedResponseError
|
|
14
|
+
|
|
15
|
+
WEB_HOST = "www.root-me.org"
|
|
16
|
+
API_HOST = "api.www.root-me.org"
|
|
17
|
+
DEFAULT_AGENT = "rootme-sdk/0.1 (+https://github.com/Thomas97460/rootme-sdk)"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class SessionCookie:
|
|
22
|
+
"""A secret cookie limited to the Root-Me platform hosts."""
|
|
23
|
+
|
|
24
|
+
name: str
|
|
25
|
+
value: str = field(repr=False)
|
|
26
|
+
domain: str = WEB_HOST
|
|
27
|
+
path: str = "/"
|
|
28
|
+
expires: float | None = None
|
|
29
|
+
|
|
30
|
+
def __post_init__(self) -> None:
|
|
31
|
+
"""Reject unsafe cookie syntax and non-platform domains at construction."""
|
|
32
|
+
if not self.name or any(c in self.name + self.value for c in "\r\n;\0"):
|
|
33
|
+
raise ValueError("Invalid cookie syntax.")
|
|
34
|
+
if not (self.name + self.value).isascii() or not _web_domain(self.domain):
|
|
35
|
+
raise ValueError("Invalid platform cookie.")
|
|
36
|
+
if (
|
|
37
|
+
not self.path.startswith("/")
|
|
38
|
+
or self.expires is not None
|
|
39
|
+
and not math.isfinite(self.expires)
|
|
40
|
+
):
|
|
41
|
+
raise ValueError("Invalid cookie path or expiry.")
|
|
42
|
+
|
|
43
|
+
def valid(self) -> bool:
|
|
44
|
+
"""Indicate whether the cookie is unexpired."""
|
|
45
|
+
return self.expires is None or self.expires > time()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class Session:
|
|
50
|
+
"""Reusable state containing credentials but never a login password."""
|
|
51
|
+
|
|
52
|
+
cookies: tuple[SessionCookie, ...] = field(default=(), repr=False)
|
|
53
|
+
user_agent: str = DEFAULT_AGENT
|
|
54
|
+
|
|
55
|
+
def __post_init__(self) -> None:
|
|
56
|
+
"""Validate secret header values without including them in an error."""
|
|
57
|
+
if not self.user_agent.isascii() or any(c in self.user_agent for c in "\r\n\0"):
|
|
58
|
+
raise ValueError("Invalid user agent.")
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def spip_session(self) -> str | None:
|
|
62
|
+
"""Return a valid web login cookie if one has been supplied."""
|
|
63
|
+
return next((c.value for c in self.cookies if c.name == "spip_session" and c.valid()), None)
|
|
64
|
+
|
|
65
|
+
def cookie_header(self, host: str, path: str) -> str:
|
|
66
|
+
"""Build cookies for approved exact hosts, never challenge servers."""
|
|
67
|
+
if host == API_HOST:
|
|
68
|
+
return f"spip_session={self.spip_session}" if self.spip_session else ""
|
|
69
|
+
if host != WEB_HOST:
|
|
70
|
+
return ""
|
|
71
|
+
return "; ".join(
|
|
72
|
+
f"{c.name}={c.value}"
|
|
73
|
+
for c in self.cookies
|
|
74
|
+
if c.valid()
|
|
75
|
+
and (path == c.path or path.startswith(c.path.rstrip("/") + "/"))
|
|
76
|
+
and _web_domain(c.domain)
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
def save(self, path: str | Path) -> None:
|
|
80
|
+
"""Atomically persist state, with owner-only file permissions on POSIX."""
|
|
81
|
+
target = Path(path)
|
|
82
|
+
payload = {
|
|
83
|
+
"version": 1,
|
|
84
|
+
"user_agent": self.user_agent,
|
|
85
|
+
"cookies": [
|
|
86
|
+
{
|
|
87
|
+
"name": c.name,
|
|
88
|
+
"value": c.value,
|
|
89
|
+
"domain": c.domain,
|
|
90
|
+
"path": c.path,
|
|
91
|
+
"expires": c.expires,
|
|
92
|
+
}
|
|
93
|
+
for c in self.cookies
|
|
94
|
+
],
|
|
95
|
+
}
|
|
96
|
+
descriptor, temporary = tempfile.mkstemp(dir=target.parent)
|
|
97
|
+
try:
|
|
98
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
|
|
99
|
+
json.dump(payload, stream)
|
|
100
|
+
os.replace(temporary, target)
|
|
101
|
+
finally:
|
|
102
|
+
Path(temporary).unlink(missing_ok=True)
|
|
103
|
+
|
|
104
|
+
@classmethod
|
|
105
|
+
def load(cls, path: str | Path) -> Session:
|
|
106
|
+
"""Read and validate an SDK state file without echoing invalid contents."""
|
|
107
|
+
try:
|
|
108
|
+
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
109
|
+
if data["version"] != 1:
|
|
110
|
+
raise ValueError
|
|
111
|
+
return _state(data)
|
|
112
|
+
except (KeyError, TypeError, ValueError):
|
|
113
|
+
raise UnexpectedResponseError("Invalid SDK session file.") from None
|
|
114
|
+
|
|
115
|
+
@classmethod
|
|
116
|
+
def from_browser_state(cls, path: str | Path, *, user_agent: str) -> Session:
|
|
117
|
+
"""Import Playwright cookies while discarding all non-platform cookies."""
|
|
118
|
+
try:
|
|
119
|
+
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
120
|
+
return cls(cookies=_cookies(data["cookies"]), user_agent=user_agent)
|
|
121
|
+
except (KeyError, TypeError, ValueError):
|
|
122
|
+
raise UnexpectedResponseError("Invalid browser session file.") from None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _web_domain(domain: str) -> bool:
|
|
126
|
+
"""Accept domains applicable to the website without granting other hosts access."""
|
|
127
|
+
return domain.lstrip(".") in {"root-me.org", WEB_HOST}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _cookies(data: object) -> tuple[SessionCookie, ...]:
|
|
131
|
+
"""Validate an external list of cookies and constrain their scope."""
|
|
132
|
+
if not isinstance(data, list):
|
|
133
|
+
raise ValueError
|
|
134
|
+
result = []
|
|
135
|
+
for item in data:
|
|
136
|
+
if not isinstance(item, dict):
|
|
137
|
+
raise ValueError
|
|
138
|
+
name, value, domain = item["name"], item["value"], item["domain"]
|
|
139
|
+
path, expires = item.get("path", "/"), item.get("expires")
|
|
140
|
+
if not all(isinstance(v, str) for v in (name, value, domain, path)):
|
|
141
|
+
raise ValueError
|
|
142
|
+
if expires is not None and not isinstance(expires, (int, float)):
|
|
143
|
+
raise ValueError
|
|
144
|
+
if any(ch in name + value for ch in "\r\n;"):
|
|
145
|
+
raise ValueError
|
|
146
|
+
if _web_domain(domain):
|
|
147
|
+
result.append(
|
|
148
|
+
SessionCookie(
|
|
149
|
+
name,
|
|
150
|
+
value,
|
|
151
|
+
domain,
|
|
152
|
+
path,
|
|
153
|
+
expires if expires != -1 else None,
|
|
154
|
+
)
|
|
155
|
+
)
|
|
156
|
+
return tuple(result)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _state(data: object) -> Session:
|
|
160
|
+
"""Validate the remaining fields of a saved SDK session."""
|
|
161
|
+
if not isinstance(data, dict):
|
|
162
|
+
raise ValueError
|
|
163
|
+
agent = data["user_agent"]
|
|
164
|
+
if not isinstance(agent, str):
|
|
165
|
+
raise ValueError
|
|
166
|
+
return Session(cookies=_cookies(data["cookies"]), user_agent=agent)
|