@follenfang/fupload 0.0.6 → 0.0.8
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.
- package/fupload/SKILL.md +4 -4
- package/fupload/references/blackbox.md +3 -1
- package/fupload/scripts/fupload_cli/__init__.py +1 -1
- package/fupload/scripts/fupload_cli/blackbox.py +129 -101
- package/fupload/scripts/fupload_cli/blackbox_web.py +522 -0
- package/fupload/scripts/fupload_cli/cli.py +19 -9
- package/fupload/scripts/fupload_cli/schema.py +14 -3
- package/npm/bin/fupload.mjs +3 -1
- package/npm/lib/python-requirements.txt +1 -0
- package/npm/lib/python.mjs +59 -19
- package/npm/skill-manifest.json +17 -17
- package/package.json +1 -1
- package/fupload/scripts/fupload_cli/blackbox_auth.py +0 -159
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
"""Persistent browser session and protocol for Heybox Workshop."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import secrets
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
from enum import Enum
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Callable, Mapping
|
|
14
|
+
from urllib.parse import parse_qs, urlencode, urlsplit
|
|
15
|
+
|
|
16
|
+
from .errors import FuploadError, redact
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
WORKSHOP_URL = "https://open.xiaoheihe.cn/zh_cn/workshop"
|
|
20
|
+
LOGIN_ORIGIN = "https://login.xiaoheihe.cn"
|
|
21
|
+
API_ORIGIN = "https://workshopapi.xiaoheihe.cn"
|
|
22
|
+
API_ROUTES = frozenset({
|
|
23
|
+
("GET", "/wow/open_platform/module/list/"),
|
|
24
|
+
("GET", "/wow/open_platform/module/detail/"),
|
|
25
|
+
("POST", "/wow/open_platform/module/update/"),
|
|
26
|
+
("GET", "/wow/open_platform/module_version/list/"),
|
|
27
|
+
("POST", "/wow/open_platform/module_version/upsert/"),
|
|
28
|
+
("POST", "/wow/open_platform/module_version/delete/"),
|
|
29
|
+
("POST", "/wow/cos/upload/token/"),
|
|
30
|
+
})
|
|
31
|
+
API_PATHS = frozenset(path for _method, path in API_ROUTES)
|
|
32
|
+
WEB_QUERY_KEYS = frozenset({
|
|
33
|
+
"_time", "app", "device_id", "heybox_id", "hkey", "nonce", "os_type",
|
|
34
|
+
"version", "web_version", "x_app", "x_client_type", "x_os_type",
|
|
35
|
+
"x_xhh_tokenid",
|
|
36
|
+
})
|
|
37
|
+
_ALPHABET = "AB45STUVWZEFGJ6CH01D237IXYPQRKLMN89"
|
|
38
|
+
_INTERACTIVE_STATUSES = frozenset({
|
|
39
|
+
"lack_token", "show_captcha", "name_verify", "need_alipay_verify",
|
|
40
|
+
"need_bind_phone", "need_phone_code",
|
|
41
|
+
})
|
|
42
|
+
_SECRET_MARKERS = (
|
|
43
|
+
"token", "secret", "cookie", "nonce", "hkey", "pkey", "credential",
|
|
44
|
+
"signature", "authorization", "authentication", "password", "device_id",
|
|
45
|
+
"signed_url", "upload_url", "presigned",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class WebSessionState(str, Enum):
|
|
50
|
+
HEADLESS_PROBE = "headless_probe"
|
|
51
|
+
HEADED_LOGIN = "headed_login"
|
|
52
|
+
READY = "ready"
|
|
53
|
+
EXPIRED = "expired"
|
|
54
|
+
FAILED = "failed"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def managed_profile_path() -> Path:
|
|
58
|
+
"""Return the private browser profile owned by Fuploader."""
|
|
59
|
+
if os.name == "nt":
|
|
60
|
+
base = Path(os.environ.get("LOCALAPPDATA") or (Path.home() / "AppData/Local"))
|
|
61
|
+
return base / "Fuploader" / "blackbox-chromium"
|
|
62
|
+
return Path.home() / ".fupload" / "blackbox-chromium"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def managed_state_path() -> Path:
|
|
66
|
+
return managed_profile_path().parent / "blackbox-web-state.json"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _n8(value: str, limit: int) -> str:
|
|
70
|
+
table = _ALPHABET[:limit]
|
|
71
|
+
return "".join(table[ord(char) % len(table)] for char in value)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _i8(value: str) -> str:
|
|
75
|
+
return "".join(_ALPHABET[ord(char) % len(_ALPHABET)] for char in value)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _interleave(parts: list[str]) -> str:
|
|
79
|
+
width = max(map(len, parts))
|
|
80
|
+
return "".join(part[index] for index in range(width) for part in parts if index < len(part))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _mix(values: list[int]) -> list[int]:
|
|
84
|
+
def p(value: int) -> int:
|
|
85
|
+
return ((value << 1) ^ 27) & 255 if value & 128 else (value << 1) & 255
|
|
86
|
+
|
|
87
|
+
def hm(value: int) -> int:
|
|
88
|
+
return p(value) ^ value
|
|
89
|
+
|
|
90
|
+
def qg(value: int) -> int:
|
|
91
|
+
return hm(p(value))
|
|
92
|
+
|
|
93
|
+
def dx(value: int) -> int:
|
|
94
|
+
return qg(hm(p(value)))
|
|
95
|
+
|
|
96
|
+
def mw(value: int) -> int:
|
|
97
|
+
return dx(value) ^ qg(value) ^ hm(value)
|
|
98
|
+
|
|
99
|
+
a, b, c, d, *rest = values
|
|
100
|
+
return [
|
|
101
|
+
mw(a) ^ dx(b) ^ qg(c) ^ hm(d),
|
|
102
|
+
hm(a) ^ mw(b) ^ dx(c) ^ qg(d),
|
|
103
|
+
qg(a) ^ hm(b) ^ mw(c) ^ dx(d),
|
|
104
|
+
dx(a) ^ qg(b) ^ hm(c) ^ mw(d),
|
|
105
|
+
*rest,
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def web_hkey(path: str, timestamp: int, nonce: str) -> str:
|
|
110
|
+
normalized = "/" + "/".join(part for part in path.split("/") if part) + "/"
|
|
111
|
+
source = _interleave([_n8(str(timestamp), -2), _i8(normalized), _i8(nonce)])
|
|
112
|
+
digest = hashlib.md5(source.encode()).hexdigest()
|
|
113
|
+
return _n8(digest[:5], -4) + f"{sum(_mix([ord(char) for char in digest[-6:]])) % 100:02d}"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def redact_recursive(value: Any) -> Any:
|
|
117
|
+
"""Remove browser credentials and signed query strings from diagnostics."""
|
|
118
|
+
if isinstance(value, Mapping):
|
|
119
|
+
result = {}
|
|
120
|
+
for key, item in value.items():
|
|
121
|
+
if any(marker in str(key).lower() for marker in _SECRET_MARKERS):
|
|
122
|
+
result[key] = "<redacted>"
|
|
123
|
+
else:
|
|
124
|
+
result[key] = redact_recursive(item)
|
|
125
|
+
return result
|
|
126
|
+
if isinstance(value, (list, tuple, set)):
|
|
127
|
+
return [redact_recursive(item) for item in value]
|
|
128
|
+
if isinstance(value, str):
|
|
129
|
+
parsed = urlsplit(value)
|
|
130
|
+
if parsed.scheme in {"http", "https"} and parsed.netloc and parsed.query:
|
|
131
|
+
return f"{parsed.scheme}://{parsed.netloc}{parsed.path}?<redacted>"
|
|
132
|
+
return redact(value)
|
|
133
|
+
return value
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class BlackboxWebSession:
|
|
137
|
+
"""Own a persistent Chromium profile and expose the Workshop protocol."""
|
|
138
|
+
|
|
139
|
+
def __init__(
|
|
140
|
+
self,
|
|
141
|
+
*,
|
|
142
|
+
login_timeout: float = 300,
|
|
143
|
+
poll_interval: float = 1,
|
|
144
|
+
browser_launcher: Callable[[bool, Path], Any] | None = None,
|
|
145
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
146
|
+
monotonic: Callable[[], float] = time.monotonic,
|
|
147
|
+
) -> None:
|
|
148
|
+
self.state = WebSessionState.HEADLESS_PROBE
|
|
149
|
+
self.login_timeout = max(0.0, float(login_timeout))
|
|
150
|
+
self.poll_interval = max(0.0, float(poll_interval))
|
|
151
|
+
self._browser_launcher = browser_launcher
|
|
152
|
+
self._sleep = sleep
|
|
153
|
+
self._monotonic = monotonic
|
|
154
|
+
self._context = None
|
|
155
|
+
self._playwright = None
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def profile_path(self) -> Path:
|
|
159
|
+
return managed_profile_path()
|
|
160
|
+
|
|
161
|
+
def close(self) -> None:
|
|
162
|
+
self._close_context()
|
|
163
|
+
|
|
164
|
+
def __enter__(self) -> "BlackboxWebSession":
|
|
165
|
+
return self
|
|
166
|
+
|
|
167
|
+
def __exit__(self, *args: Any) -> None:
|
|
168
|
+
self.close()
|
|
169
|
+
|
|
170
|
+
def ensure_ready(self) -> None:
|
|
171
|
+
if self.state == WebSessionState.READY and self._context is not None:
|
|
172
|
+
return
|
|
173
|
+
self._close_context()
|
|
174
|
+
self._set_state(WebSessionState.HEADLESS_PROBE)
|
|
175
|
+
try:
|
|
176
|
+
self._context = self._launch(headless=True)
|
|
177
|
+
self._open_workshop(self._context)
|
|
178
|
+
if self._probe(self._context):
|
|
179
|
+
self._set_state(WebSessionState.READY)
|
|
180
|
+
return
|
|
181
|
+
self._set_state(WebSessionState.EXPIRED, "web session requires login")
|
|
182
|
+
self._login()
|
|
183
|
+
except FuploadError:
|
|
184
|
+
if self.state not in {WebSessionState.EXPIRED, WebSessionState.HEADED_LOGIN}:
|
|
185
|
+
self._set_state(WebSessionState.FAILED, "browser session failed")
|
|
186
|
+
raise
|
|
187
|
+
except Exception as exc:
|
|
188
|
+
self._set_state(WebSessionState.FAILED, "browser session failed")
|
|
189
|
+
raise self._error("Workshop browser session failed", exc) from exc
|
|
190
|
+
|
|
191
|
+
def request(
|
|
192
|
+
self,
|
|
193
|
+
method: str,
|
|
194
|
+
path: str,
|
|
195
|
+
body: Mapping[str, Any] | None = None,
|
|
196
|
+
query: Mapping[str, Any] | None = None,
|
|
197
|
+
) -> dict[str, Any]:
|
|
198
|
+
method, path = self._validate_request(method, path)
|
|
199
|
+
self.ensure_ready()
|
|
200
|
+
payload, response = self._protocol(self._context, method, path, body, query)
|
|
201
|
+
if self._authenticated(payload, response):
|
|
202
|
+
return payload
|
|
203
|
+
if not self._needs_interaction(payload, response):
|
|
204
|
+
raise self._response_error(path, payload, response, verification_required=method == "POST")
|
|
205
|
+
|
|
206
|
+
self._set_state(WebSessionState.EXPIRED, "web session expired")
|
|
207
|
+
if method == "POST":
|
|
208
|
+
raise self._response_error(path, payload, response, verification_required=True)
|
|
209
|
+
self._login()
|
|
210
|
+
payload, response = self._protocol(self._context, method, path, body, query)
|
|
211
|
+
if self._authenticated(payload, response):
|
|
212
|
+
return payload
|
|
213
|
+
if self._needs_interaction(payload, response):
|
|
214
|
+
self._set_state(WebSessionState.EXPIRED, "web session remained expired")
|
|
215
|
+
raise self._response_error(path, payload, response)
|
|
216
|
+
|
|
217
|
+
def _login(self) -> None:
|
|
218
|
+
self._close_context()
|
|
219
|
+
self._set_state(WebSessionState.HEADED_LOGIN)
|
|
220
|
+
try:
|
|
221
|
+
context = self._launch(headless=False)
|
|
222
|
+
self._context = context
|
|
223
|
+
self._open_workshop(context)
|
|
224
|
+
deadline = self._monotonic() + self.login_timeout
|
|
225
|
+
while True:
|
|
226
|
+
if self._headed_window_closed(context):
|
|
227
|
+
self._set_state(WebSessionState.EXPIRED, "headed login window closed")
|
|
228
|
+
raise FuploadError(
|
|
229
|
+
"Workshop login window was closed",
|
|
230
|
+
kind="authentication_error",
|
|
231
|
+
stage="headed_login",
|
|
232
|
+
)
|
|
233
|
+
try:
|
|
234
|
+
ready = self._probe(context)
|
|
235
|
+
except FuploadError as exc:
|
|
236
|
+
if exc.business_code != "network_error":
|
|
237
|
+
raise
|
|
238
|
+
ready = False
|
|
239
|
+
if ready:
|
|
240
|
+
break
|
|
241
|
+
if self._monotonic() >= deadline:
|
|
242
|
+
self._set_state(WebSessionState.EXPIRED, "headed login timed out")
|
|
243
|
+
raise FuploadError(
|
|
244
|
+
"Workshop web login timed out",
|
|
245
|
+
kind="authentication_error",
|
|
246
|
+
stage="headed_login",
|
|
247
|
+
)
|
|
248
|
+
self._sleep(self.poll_interval)
|
|
249
|
+
|
|
250
|
+
self._close_context()
|
|
251
|
+
self._set_state(WebSessionState.HEADLESS_PROBE)
|
|
252
|
+
self._context = self._launch(headless=True)
|
|
253
|
+
self._open_workshop(self._context)
|
|
254
|
+
if not self._probe(self._context):
|
|
255
|
+
self._set_state(WebSessionState.EXPIRED, "web login did not persist")
|
|
256
|
+
raise FuploadError(
|
|
257
|
+
"Workshop web login did not persist",
|
|
258
|
+
kind="authentication_error",
|
|
259
|
+
stage="headless_probe",
|
|
260
|
+
)
|
|
261
|
+
self._set_state(WebSessionState.READY)
|
|
262
|
+
except FuploadError:
|
|
263
|
+
self._close_context()
|
|
264
|
+
raise
|
|
265
|
+
except Exception as exc:
|
|
266
|
+
self._set_state(WebSessionState.FAILED, "headed login failed")
|
|
267
|
+
self._close_context()
|
|
268
|
+
raise self._error("Workshop headed login failed", exc) from exc
|
|
269
|
+
|
|
270
|
+
def _launch(self, *, headless: bool) -> Any:
|
|
271
|
+
profile = self.profile_path
|
|
272
|
+
profile.mkdir(parents=True, exist_ok=True)
|
|
273
|
+
if self._browser_launcher is not None:
|
|
274
|
+
return self._browser_launcher(headless, profile)
|
|
275
|
+
try:
|
|
276
|
+
from playwright.sync_api import sync_playwright
|
|
277
|
+
except ImportError as exc:
|
|
278
|
+
raise FuploadError(
|
|
279
|
+
"Playwright is required for Workshop web login",
|
|
280
|
+
kind="environment_error",
|
|
281
|
+
stage="browser_launch",
|
|
282
|
+
details={"dependency": "playwright"},
|
|
283
|
+
) from exc
|
|
284
|
+
try:
|
|
285
|
+
self._playwright = sync_playwright().start()
|
|
286
|
+
return self._playwright.chromium.launch_persistent_context(
|
|
287
|
+
user_data_dir=str(profile),
|
|
288
|
+
headless=headless,
|
|
289
|
+
)
|
|
290
|
+
except Exception:
|
|
291
|
+
if self._playwright is not None:
|
|
292
|
+
self._playwright.stop()
|
|
293
|
+
self._playwright = None
|
|
294
|
+
raise
|
|
295
|
+
|
|
296
|
+
@staticmethod
|
|
297
|
+
def _open_workshop(context: Any) -> None:
|
|
298
|
+
page = context.pages[0] if getattr(context, "pages", None) else context.new_page()
|
|
299
|
+
page.goto(WORKSHOP_URL, wait_until="domcontentloaded")
|
|
300
|
+
current = str(getattr(page, "url", "") or "")
|
|
301
|
+
if current and not BlackboxWebSession._allowed_navigation(current):
|
|
302
|
+
raise FuploadError(
|
|
303
|
+
"Workshop browser navigated outside the fixed login flow",
|
|
304
|
+
kind="authentication_error",
|
|
305
|
+
stage="browser_navigation",
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
def _probe(self, context: Any) -> bool:
|
|
309
|
+
payload, response = self._protocol(
|
|
310
|
+
context, "GET", "/wow/open_platform/module/list/", None, None,
|
|
311
|
+
)
|
|
312
|
+
if self._authenticated(payload, response):
|
|
313
|
+
modules = (payload.get("result") or {}).get("moduleList")
|
|
314
|
+
if isinstance(modules, list):
|
|
315
|
+
return True
|
|
316
|
+
raise self._response_error(
|
|
317
|
+
"/wow/open_platform/module/list/", payload, response,
|
|
318
|
+
message="Workshop readiness response is invalid",
|
|
319
|
+
)
|
|
320
|
+
if self._needs_interaction(payload, response):
|
|
321
|
+
return False
|
|
322
|
+
raise self._response_error("/wow/open_platform/module/list/", payload, response)
|
|
323
|
+
|
|
324
|
+
@staticmethod
|
|
325
|
+
def _protocol(
|
|
326
|
+
context: Any,
|
|
327
|
+
method: str,
|
|
328
|
+
path: str,
|
|
329
|
+
body: Mapping[str, Any] | None,
|
|
330
|
+
query: Mapping[str, Any] | None,
|
|
331
|
+
) -> tuple[dict[str, Any], Any]:
|
|
332
|
+
caller_query = dict(query or {})
|
|
333
|
+
reserved = sorted(WEB_QUERY_KEYS.intersection(caller_query))
|
|
334
|
+
if reserved:
|
|
335
|
+
raise FuploadError(
|
|
336
|
+
"Workshop query overrides a managed protocol field",
|
|
337
|
+
kind="validation_error",
|
|
338
|
+
stage="protocol_validation",
|
|
339
|
+
details={"fields": reserved},
|
|
340
|
+
)
|
|
341
|
+
cookie_rows = context.cookies([WORKSHOP_URL, API_ORIGIN])
|
|
342
|
+
cookies = {str(item.get("name")): str(item.get("value") or "") for item in cookie_rows}
|
|
343
|
+
heybox_id = cookies.get("user_heybox_id") or cookies.get("heybox_id") or ""
|
|
344
|
+
risk_token = cookies.get("x_xhh_tokenid") or ""
|
|
345
|
+
timestamp = int(time.time())
|
|
346
|
+
nonce = hashlib.md5(
|
|
347
|
+
f"{timestamp}{time.time_ns()}{secrets.token_hex(8)}".encode(),
|
|
348
|
+
).hexdigest().upper()
|
|
349
|
+
params = {
|
|
350
|
+
"app": "heybox",
|
|
351
|
+
"heybox_id": heybox_id,
|
|
352
|
+
"os_type": "web",
|
|
353
|
+
"x_app": "heybox_website",
|
|
354
|
+
"x_client_type": "weboutapp",
|
|
355
|
+
"x_os_type": BlackboxWebSession._platform_name(),
|
|
356
|
+
"web_version": "",
|
|
357
|
+
"device_id": risk_token,
|
|
358
|
+
"version": "999.0.4",
|
|
359
|
+
"hkey": web_hkey(path, timestamp + 1, nonce),
|
|
360
|
+
"_time": timestamp,
|
|
361
|
+
"nonce": nonce,
|
|
362
|
+
"x_xhh_tokenid": risk_token,
|
|
363
|
+
**caller_query,
|
|
364
|
+
}
|
|
365
|
+
url = API_ORIGIN + path
|
|
366
|
+
headers = {"Referer": WORKSHOP_URL}
|
|
367
|
+
try:
|
|
368
|
+
if method == "GET":
|
|
369
|
+
response = context.request.get(url, params=params, headers=headers)
|
|
370
|
+
else:
|
|
371
|
+
response = context.request.post(
|
|
372
|
+
url,
|
|
373
|
+
params=params,
|
|
374
|
+
data=urlencode(dict(body or {}), doseq=True),
|
|
375
|
+
headers={
|
|
376
|
+
**headers,
|
|
377
|
+
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
|
|
378
|
+
},
|
|
379
|
+
)
|
|
380
|
+
except Exception as exc:
|
|
381
|
+
raise FuploadError(
|
|
382
|
+
"Workshop web request failed",
|
|
383
|
+
kind="operation_failed",
|
|
384
|
+
stage="web_protocol",
|
|
385
|
+
endpoint=path,
|
|
386
|
+
business_code="network_error",
|
|
387
|
+
verification_required=method == "POST",
|
|
388
|
+
details=redact_recursive({"error": str(exc)}),
|
|
389
|
+
) from exc
|
|
390
|
+
try:
|
|
391
|
+
payload = response.json()
|
|
392
|
+
except Exception:
|
|
393
|
+
payload = {"status": "network_error"}
|
|
394
|
+
return (payload if isinstance(payload, dict) else {}), response
|
|
395
|
+
|
|
396
|
+
@staticmethod
|
|
397
|
+
def _authenticated(payload: Mapping[str, Any], response: Any) -> bool:
|
|
398
|
+
return bool(getattr(response, "ok", False)) and payload.get("status") == "ok"
|
|
399
|
+
|
|
400
|
+
@staticmethod
|
|
401
|
+
def _needs_interaction(payload: Mapping[str, Any], response: Any) -> bool:
|
|
402
|
+
return (
|
|
403
|
+
payload.get("status") in {"login", "relogin", "unauthorized", *_INTERACTIVE_STATUSES}
|
|
404
|
+
or getattr(response, "status", 0) in {401, 403}
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
@staticmethod
|
|
408
|
+
def _validate_request(method: str, path: str) -> tuple[str, str]:
|
|
409
|
+
normalized_method = str(method).upper()
|
|
410
|
+
if not isinstance(path, str) or (normalized_method, path) not in API_ROUTES:
|
|
411
|
+
raise FuploadError(
|
|
412
|
+
"Workshop protocol route is not allowed",
|
|
413
|
+
kind="validation_error",
|
|
414
|
+
stage="protocol_validation",
|
|
415
|
+
)
|
|
416
|
+
return normalized_method, path
|
|
417
|
+
|
|
418
|
+
@staticmethod
|
|
419
|
+
def _allowed_navigation(url: str) -> bool:
|
|
420
|
+
parsed = urlsplit(url)
|
|
421
|
+
origin = f"{parsed.scheme}://{parsed.netloc}"
|
|
422
|
+
if origin == "https://open.xiaoheihe.cn":
|
|
423
|
+
return parsed.path in {"/zh_cn/workshop", "/zh_cn/workshop/"}
|
|
424
|
+
if origin != LOGIN_ORIGIN or parsed.path != "/":
|
|
425
|
+
return False
|
|
426
|
+
query = parse_qs(parsed.query)
|
|
427
|
+
return (
|
|
428
|
+
query.get("origin") == ["heybox_open"]
|
|
429
|
+
and query.get("redirect_url") in ([WORKSHOP_URL], [WORKSHOP_URL + "/"])
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
@staticmethod
|
|
433
|
+
def _headed_window_closed(context: Any) -> bool:
|
|
434
|
+
pages = getattr(context, "pages", None)
|
|
435
|
+
if pages is None:
|
|
436
|
+
return False
|
|
437
|
+
if not pages:
|
|
438
|
+
return True
|
|
439
|
+
return all(callable(getattr(page, "is_closed", None)) and page.is_closed() for page in pages)
|
|
440
|
+
|
|
441
|
+
@staticmethod
|
|
442
|
+
def _platform_name() -> str:
|
|
443
|
+
if os.name == "nt":
|
|
444
|
+
return "Windows"
|
|
445
|
+
if sys.platform == "darwin":
|
|
446
|
+
return "macOS"
|
|
447
|
+
return "Linux"
|
|
448
|
+
|
|
449
|
+
def _set_state(self, state: WebSessionState, reason: str | None = None) -> None:
|
|
450
|
+
self.state = state
|
|
451
|
+
path = managed_state_path()
|
|
452
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
453
|
+
payload = {
|
|
454
|
+
"schema": "fupload.blackbox.web-state.v1",
|
|
455
|
+
"state": state.value,
|
|
456
|
+
"updated_at": int(time.time()),
|
|
457
|
+
}
|
|
458
|
+
if reason:
|
|
459
|
+
payload["reason"] = redact(reason)
|
|
460
|
+
temporary = path.with_suffix(path.suffix + ".tmp")
|
|
461
|
+
temporary.write_text(json.dumps(payload, sort_keys=True) + "\n", encoding="utf-8")
|
|
462
|
+
temporary.replace(path)
|
|
463
|
+
|
|
464
|
+
def _close_context(self) -> None:
|
|
465
|
+
context, self._context = self._context, None
|
|
466
|
+
if context is not None:
|
|
467
|
+
try:
|
|
468
|
+
context.close()
|
|
469
|
+
except Exception:
|
|
470
|
+
pass
|
|
471
|
+
playwright, self._playwright = self._playwright, None
|
|
472
|
+
if playwright is not None:
|
|
473
|
+
try:
|
|
474
|
+
playwright.stop()
|
|
475
|
+
except Exception:
|
|
476
|
+
pass
|
|
477
|
+
|
|
478
|
+
@staticmethod
|
|
479
|
+
def _response_error(
|
|
480
|
+
path: str,
|
|
481
|
+
payload: Mapping[str, Any],
|
|
482
|
+
response: Any,
|
|
483
|
+
*,
|
|
484
|
+
message: str = "Workshop web request was rejected",
|
|
485
|
+
verification_required: bool = False,
|
|
486
|
+
) -> FuploadError:
|
|
487
|
+
status = payload.get("status")
|
|
488
|
+
is_auth = BlackboxWebSession._needs_interaction(payload, response)
|
|
489
|
+
return FuploadError(
|
|
490
|
+
message,
|
|
491
|
+
kind="authentication_error" if is_auth else "operation_failed",
|
|
492
|
+
stage="web_protocol",
|
|
493
|
+
endpoint=path,
|
|
494
|
+
http_status=getattr(response, "status", None),
|
|
495
|
+
business_code=status,
|
|
496
|
+
verification_required=verification_required,
|
|
497
|
+
details=redact_recursive({"response": payload}),
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
@staticmethod
|
|
501
|
+
def _error(message: str, exc: Exception) -> FuploadError:
|
|
502
|
+
return FuploadError(
|
|
503
|
+
message,
|
|
504
|
+
kind="environment_error",
|
|
505
|
+
stage="browser_session",
|
|
506
|
+
details=redact_recursive({"error": str(exc)}),
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
__all__ = [
|
|
511
|
+
"API_ORIGIN",
|
|
512
|
+
"API_PATHS",
|
|
513
|
+
"API_ROUTES",
|
|
514
|
+
"WEB_QUERY_KEYS",
|
|
515
|
+
"WORKSHOP_URL",
|
|
516
|
+
"BlackboxWebSession",
|
|
517
|
+
"WebSessionState",
|
|
518
|
+
"managed_profile_path",
|
|
519
|
+
"managed_state_path",
|
|
520
|
+
"redact_recursive",
|
|
521
|
+
"web_hkey",
|
|
522
|
+
]
|
|
@@ -206,7 +206,7 @@ def _blackbox_tree(platforms: argparse._SubParsersAction) -> None:
|
|
|
206
206
|
root = platforms.add_parser(
|
|
207
207
|
"blackbox",
|
|
208
208
|
help="Heybox Workshop plugin management",
|
|
209
|
-
description="
|
|
209
|
+
description="Use a managed Heybox Workshop web session; an interactive browser opens when login is required.",
|
|
210
210
|
)
|
|
211
211
|
groups = root.add_subparsers(dest="resource_command", required=True)
|
|
212
212
|
plugin = groups.add_parser("plugin", help="Heybox Workshop plugin metadata and versions").add_subparsers(dest="action_command", required=True)
|
|
@@ -281,17 +281,27 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
|
281
281
|
write_output(platform, operation, _dry_run_data(doc, schema.name), dry_run=True)
|
|
282
282
|
return 0
|
|
283
283
|
provider = NewBee() if platform == "newbee" else (DD() if platform == "dd" else (Blackbox() if platform == "blackbox" else CurseForge()))
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
284
|
+
try:
|
|
285
|
+
if platform == "dd":
|
|
286
|
+
data = provider.execute_write(resource, action, doc, getattr(args, "session", None))
|
|
287
|
+
else:
|
|
288
|
+
data = provider.execute_write(resource, action, doc)
|
|
289
|
+
finally:
|
|
290
|
+
close = getattr(provider, "close", None)
|
|
291
|
+
if close:
|
|
292
|
+
close()
|
|
288
293
|
write_output(platform, operation, data)
|
|
289
294
|
return 0
|
|
290
295
|
provider = NewBee() if platform == "newbee" else (DD() if platform == "dd" else (Blackbox() if platform == "blackbox" else CurseForge()))
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
296
|
+
try:
|
|
297
|
+
if platform == "dd":
|
|
298
|
+
data = provider.execute_read(resource, action, args, getattr(args, "session", None))
|
|
299
|
+
else:
|
|
300
|
+
data = provider.execute_read(resource, action, args)
|
|
301
|
+
finally:
|
|
302
|
+
close = getattr(provider, "close", None)
|
|
303
|
+
if close:
|
|
304
|
+
close()
|
|
295
305
|
write_output(platform, operation, data)
|
|
296
306
|
return 0
|
|
297
307
|
except (FuploadError, OSError, ValueError) as exc:
|
|
@@ -93,7 +93,7 @@ class Schema:
|
|
|
93
93
|
return checked
|
|
94
94
|
|
|
95
95
|
def _validate_conditionals(self, value: Dict[str, Any]) -> None:
|
|
96
|
-
for name in ("id", "mod_id", "file_id", "content_id", "source_id", "module_id", "game_version_id", "cloud_id"):
|
|
96
|
+
for name in ("id", "mod_id", "file_id", "content_id", "source_id", "module_id", "version_id", "game_version_id", "cloud_id"):
|
|
97
97
|
if name in value and isinstance(value[name], int) and value[name] <= 0:
|
|
98
98
|
raise ValidationError("must be greater than zero", path="$.%s" % name)
|
|
99
99
|
if value.get("public") is True and value.get("submit_for_review") is not True:
|
|
@@ -340,6 +340,17 @@ class Schema:
|
|
|
340
340
|
for field_name in ("game_versions", "game_version_names"):
|
|
341
341
|
if field_name in value:
|
|
342
342
|
raise ValidationError("must be omitted when parent_file_id is set", path="$.%s" % field_name)
|
|
343
|
+
if self.name.startswith("fupload.v1.blackbox"):
|
|
344
|
+
scalar_array("game_versions", (str,), "array must contain nonempty game-version strings")
|
|
345
|
+
if "game_versions" in value and any(not item.strip() for item in value["game_versions"]):
|
|
346
|
+
raise ValidationError("array must contain nonempty game-version strings", path="$.game_versions")
|
|
347
|
+
if "category_ids" in value and any(isinstance(item, bool) or not isinstance(item, int) or item <= 0 for item in value["category_ids"]):
|
|
348
|
+
raise ValidationError("array must contain positive integer IDs", path="$.category_ids")
|
|
349
|
+
scalar_array("core_folders", (str,), "array must contain nonempty folder names")
|
|
350
|
+
if "core_folders" in value and any(not item.strip() for item in value["core_folders"]):
|
|
351
|
+
raise ValidationError("array must contain nonempty folder names", path="$.core_folders")
|
|
352
|
+
if "file" in value and not zipfile.is_zipfile(value["file"]):
|
|
353
|
+
raise ValidationError("file must be a valid ZIP archive", path="$.file")
|
|
343
354
|
|
|
344
355
|
|
|
345
356
|
def f(type_name: str, **kwargs: Any) -> Field:
|
|
@@ -559,12 +570,12 @@ register("blackbox", "plugin", "edit", required({
|
|
|
559
570
|
}, ("id",)))
|
|
560
571
|
register("blackbox", "plugin", "update", required({
|
|
561
572
|
"module_id": f("integer"), "name": f("string", nonempty=True), "type": f("integer", choices=(1, 2, 3)),
|
|
562
|
-
"game_versions": f("array", nonempty=True), "file": f("string", local_file=True), "file_url": f("string"),
|
|
573
|
+
"game_versions": f("array", nonempty=True), "file": f("string", local_file=True), "file_url": f("string", nonempty=True),
|
|
563
574
|
}, ("module_id", "name", "type", "game_versions", "file")))
|
|
564
575
|
register("blackbox", "version", "edit", required({
|
|
565
576
|
"version_id": f("integer"), "module_id": f("integer"), "name": f("string", nonempty=True),
|
|
566
577
|
"type": f("integer", choices=(1, 2, 3)), "game_versions": f("array", nonempty=True),
|
|
567
|
-
"file": f("string", local_file=True), "file_url": f("string"),
|
|
578
|
+
"file": f("string", local_file=True), "file_url": f("string", nonempty=True),
|
|
568
579
|
}, ("version_id", "module_id", "name", "type", "game_versions")))
|
|
569
580
|
register("blackbox", "version", "delete", required({
|
|
570
581
|
"version_id": f("integer"), "module_id": f("integer"),
|
package/npm/bin/fupload.mjs
CHANGED
|
@@ -76,7 +76,9 @@ async function main() {
|
|
|
76
76
|
return 1;
|
|
77
77
|
}
|
|
78
78
|
const script = path.join(target, "scripts", "fupload.py");
|
|
79
|
-
const result = runPython(runtime.python, script, options.forwarded
|
|
79
|
+
const result = runPython(runtime.python, script, options.forwarded, {
|
|
80
|
+
runtimeRoot: runtime.root,
|
|
81
|
+
});
|
|
80
82
|
if (result.error) {
|
|
81
83
|
emitError("PYTHON_LAUNCH_FAILED", result.error.message);
|
|
82
84
|
return 1;
|