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 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)