@follenfang/fupload 0.0.8 → 0.0.10

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.
@@ -0,0 +1,768 @@
1
+ """ModUs Creator plugin/project provider.
2
+
3
+ The Creator desktop client stores its bearer token in a DPAPI CurrentUser
4
+ file. This module deliberately keeps the token and presigned URL out of
5
+ returned documents and exception messages.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import ctypes
11
+ from ctypes import wintypes
12
+ import hashlib
13
+ import http.client
14
+ import json
15
+ import os
16
+ import ssl
17
+ import time
18
+ import urllib.error
19
+ import urllib.parse
20
+ import urllib.request
21
+ import zipfile
22
+ from pathlib import Path
23
+ from typing import Any, Dict, Mapping, Optional
24
+
25
+ from .errors import FuploadError, ValidationError, redact
26
+ from .state_machine import COMPLETE, ProjectStateMachine
27
+ from .modus_zip import parse_modus_zip
28
+
29
+
30
+ API_BASE = "https://app.modus.cool/api/"
31
+ TOKEN_PATH = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData/Local")) / "ModUs.Creator" / "auth" / "token.dat"
32
+ TOKEN_ENTROPY = b"ModUs.Creator.TokenStore.v1"
33
+ MAX_PACKAGE_BYTES = 200 * 1024 * 1024
34
+ _SECRET_KEYS = {"token", "access_token", "authorization", "cookie", "signedurl", "signed_url", "upload_url"}
35
+ _WIRE_NAMES = {
36
+ "project_id": "projectId", "file_id": "fileId", "alt_name": "altName",
37
+ "synchronization_type": "synchronizationType", "required_tier_id": "requiredTierId",
38
+ "repo_url": "repoUrl", "supported_game_versions": "supportedGameVersionsReqs",
39
+ "zip_size": "zipSize", "unzip_size": "unzipSize", "toc_version": "tocVersion",
40
+ }
41
+
42
+
43
+ def _license_json(value: Any, document: Mapping[str, Any]) -> str:
44
+ """Build the JSON string emitted by Creator's BuildLicenseJson helper.
45
+
46
+ The desktop client receives a ProjectLicenseContent object and only adds
47
+ non-empty ``type``, ``holder``, ``year`` and ``content`` properties. CLI
48
+ callers may provide either that object, a JSON object string, or the
49
+ compact license name shown by the UI.
50
+ """
51
+ source: Dict[str, Any] = {}
52
+ if isinstance(value, Mapping):
53
+ source.update(value)
54
+ elif isinstance(value, str):
55
+ text = value.strip()
56
+ if text.startswith("{"):
57
+ try:
58
+ parsed = json.loads(text)
59
+ except (TypeError, ValueError):
60
+ parsed = None
61
+ if isinstance(parsed, Mapping):
62
+ source.update(parsed)
63
+ else:
64
+ source["type"] = value
65
+ elif text:
66
+ source["type"] = value
67
+ # These aliases are used by the project detail reader and the Creator UI.
68
+ aliases = {
69
+ "type": ("type", "license_name", "licenseName"),
70
+ "holder": ("holder", "copyright_holder", "copyrightHolder"),
71
+ "year": ("year", "copyright_year", "copyrightYear"),
72
+ "content": ("content", "license_content", "licenseContent"),
73
+ }
74
+ result: Dict[str, str] = {}
75
+ for target, names in aliases.items():
76
+ candidate = next((source.get(name) for name in names if source.get(name) is not None), None)
77
+ if candidate is None:
78
+ candidate = next((document.get(name) for name in names if document.get(name) is not None), None)
79
+ if candidate is not None and str(candidate).strip():
80
+ result[target] = str(candidate)
81
+ return json.dumps(result, ensure_ascii=False, separators=(",", ":"))
82
+
83
+
84
+ def _category_ids(value: Any) -> Any:
85
+ """Project category selections to the integer IDs accepted by the API."""
86
+ if value is None:
87
+ return []
88
+ values = value if isinstance(value, (list, tuple, set)) else str(value).split(",")
89
+ output = []
90
+ for item in values:
91
+ if isinstance(item, Mapping):
92
+ item = item.get("id") or item.get("categoryId") or item.get("value")
93
+ if item is None or str(item).strip() == "":
94
+ continue
95
+ text = str(item).strip()
96
+ try:
97
+ output.append(int(text))
98
+ except ValueError:
99
+ output.append(text)
100
+ return output
101
+
102
+
103
+ def _sync_type(value: Any) -> Any:
104
+ if value is None:
105
+ return 0
106
+ text = str(value).strip()
107
+ try:
108
+ return int(text)
109
+ except ValueError:
110
+ return value
111
+
112
+
113
+ def _null_marker(value: Any) -> str:
114
+ if value is None or (isinstance(value, str) and not value.strip()):
115
+ return "<null>"
116
+ return str(value).strip() if isinstance(value, str) else str(value)
117
+
118
+
119
+ def _image_ops(value: Any) -> list[Dict[str, str]]:
120
+ if not isinstance(value, list) or not value:
121
+ raise ValidationError("image_ops must be a non-empty list", path="image_ops")
122
+ result: list[Dict[str, str]] = []
123
+ for index, item in enumerate(value):
124
+ path = "image_ops[%d]" % index
125
+ if not isinstance(item, Mapping):
126
+ raise ValidationError("image operation must be an object", path=path)
127
+ unknown = sorted(set(item) - {"op", "name", "base64"})
128
+ if unknown:
129
+ raise ValidationError("unknown image operation field(s): %s" % ", ".join(unknown), path=path + "." + unknown[0])
130
+ op = item.get("op")
131
+ name = item.get("name")
132
+ if op not in ("upload", "delete"):
133
+ raise ValidationError("image operation must be upload or delete", path=path + ".op")
134
+ if not isinstance(name, str) or not name.strip():
135
+ raise ValidationError("image operation name must be non-empty", path=path + ".name")
136
+ operation = {"op": op, "name": name.strip()}
137
+ if op == "upload":
138
+ payload = item.get("base64")
139
+ if not isinstance(payload, str) or not payload.strip():
140
+ raise ValidationError("upload image operation requires base64", path=path + ".base64")
141
+ operation["base64"] = payload
142
+ elif "base64" in item:
143
+ raise ValidationError("delete image operation must not include base64", path=path + ".base64")
144
+ result.append(operation)
145
+ return result
146
+
147
+
148
+ def _positive_id(value: Any, *, field: str) -> int:
149
+ """Coerce an API identifier and reject ambiguous/non-positive targets."""
150
+ if isinstance(value, bool):
151
+ raise ValidationError("%s must be a positive integer" % field, path=field)
152
+ try:
153
+ result = int(value)
154
+ except (TypeError, ValueError) as exc:
155
+ raise ValidationError("%s must be a positive integer" % field, path=field) from exc
156
+ if result <= 0:
157
+ raise ValidationError("%s must be a positive integer" % field, path=field)
158
+ return result
159
+
160
+
161
+ def _supported_game_versions(value: Any) -> list[Dict[str, str]]:
162
+ """Map CLI aliases to Creator's anonymous {gameVersion, server} objects.
163
+
164
+ ``server`` is intentionally required: the Creator client sends it as a
165
+ string and no safe default can be inferred from a display game version.
166
+ """
167
+ if value is None:
168
+ return []
169
+ if not isinstance(value, (list, tuple)):
170
+ raise ValidationError("supported_game_versions must be a list", path="supported_game_versions")
171
+ result: list[Dict[str, str]] = []
172
+ for index, item in enumerate(value):
173
+ if not isinstance(item, Mapping):
174
+ raise ValidationError(
175
+ "each supported game version must include game_version and server",
176
+ path="supported_game_versions[%d]" % index,
177
+ )
178
+ game_version = item.get("gameVersion", item.get("game_version"))
179
+ server = item.get("server")
180
+ if not str(game_version or "").strip() or not str(server or "").strip():
181
+ raise ValidationError(
182
+ "each supported game version must include game_version and server",
183
+ path="supported_game_versions[%d]" % index,
184
+ )
185
+ result.append({"gameVersion": str(game_version), "server": str(server)})
186
+ return result
187
+
188
+
189
+ def _release_wire(value: Mapping[str, Any]) -> Dict[str, Any]:
190
+ """Build the exact create/update release request shape from CLI fields."""
191
+ result: Dict[str, Any] = {}
192
+ aliases = {
193
+ "project_id": "projectId", "file_id": "fileId", "zip_size": "zipSize",
194
+ "unzip_size": "unzipSize", "toc_version": "tocVersion",
195
+ }
196
+ for key, wire_name in aliases.items():
197
+ if key in value and value[key] is not None:
198
+ result[wire_name] = _positive_id(value[key], field=key) if key in ("project_id", "file_id") else value[key]
199
+ for key in ("md5", "type", "version", "path", "changelog"):
200
+ if key in value and value[key] is not None:
201
+ result[key] = value[key]
202
+ if "supported_game_versions" in value:
203
+ result["supportedGameVersionsReqs"] = _supported_game_versions(value["supported_game_versions"])
204
+ elif "supportedGameVersionsReqs" in value:
205
+ result["supportedGameVersionsReqs"] = _supported_game_versions(value["supportedGameVersionsReqs"])
206
+ return result
207
+
208
+
209
+ def _dependency_query_wire(value: Any) -> Dict[str, Any]:
210
+ """Build one of Creator's two dependency-query request objects.
211
+
212
+ The desktop client exposes overloads for a name search and for resolving
213
+ project IDs. Both delegate to the same object-payload POST overload.
214
+ """
215
+ if isinstance(value, str):
216
+ text = value.strip()
217
+ if not text:
218
+ raise ValidationError("dependency query must not be empty", path="$.query")
219
+ if text.startswith(("{", "[")):
220
+ try:
221
+ value = json.loads(text)
222
+ except ValueError as exc:
223
+ raise ValidationError("dependency query JSON is invalid", path="$.query") from exc
224
+ else:
225
+ return {"name": text}
226
+ if isinstance(value, bool) or value is None:
227
+ raise ValidationError("dependency query requires a name or project IDs", path="$.query")
228
+ if isinstance(value, int):
229
+ project_id = _positive_id(value, field="$.project_id")
230
+ if project_id > 2147483647:
231
+ raise ValidationError("project ID exceeds Int32 range", path="$.project_id")
232
+ return {"projectIds": [project_id]}
233
+ if isinstance(value, (list, tuple)):
234
+ values = value
235
+ path = "$.project_ids"
236
+ elif isinstance(value, Mapping):
237
+ unknown = sorted(set(value) - {"name", "projectIds", "project_ids"})
238
+ if unknown:
239
+ raise ValidationError("unknown dependency query field", path="$.query.%s" % unknown[0])
240
+ modes = [key for key in ("name", "projectIds", "project_ids") if key in value]
241
+ if len(modes) != 1:
242
+ raise ValidationError("dependency query requires exactly one mode", path="$.query")
243
+ if modes[0] == "name":
244
+ name = value["name"]
245
+ if not isinstance(name, str) or not name.strip():
246
+ raise ValidationError("dependency name must be a nonempty string", path="$.query.name")
247
+ return {"name": name.strip()}
248
+ values = value[modes[0]]
249
+ path = "$.query.%s" % modes[0]
250
+ else:
251
+ raise ValidationError("dependency query requires a name or project IDs", path="$.query")
252
+ if not isinstance(values, (list, tuple)) or not values:
253
+ raise ValidationError("project IDs must be a nonempty array", path=path)
254
+ project_ids = []
255
+ for index, item in enumerate(values):
256
+ item_path = "%s[%d]" % (path, index)
257
+ project_id = _positive_id(item, field=item_path)
258
+ if project_id > 2147483647:
259
+ raise ValidationError("project ID exceeds Int32 range", path=item_path)
260
+ project_ids.append(project_id)
261
+ return {"projectIds": project_ids}
262
+
263
+
264
+ def _wire(value: Mapping[str, Any]) -> Dict[str, Any]:
265
+ result = {_WIRE_NAMES.get(str(key), str(key)): item for key, item in value.items()
266
+ if key not in ("schema", "file")}
267
+ if "screenshot_base64s" in value:
268
+ result["images"] = int(value.get("images") or 0)
269
+ result["screenshotBase64sReqs"] = {"name": "logo.webp", "screenshotBase64s": value["screenshot_base64s"]}
270
+ return result
271
+
272
+
273
+ def _project_wire(value: Mapping[str, Any], *, create: bool = False) -> Dict[str, Any]:
274
+ """Project project create/update fields to Creator's request shape."""
275
+ result: Dict[str, Any] = {}
276
+ for key in ("name", "alt_name", "summary", "repo_url"):
277
+ if key in value and value[key] is not None:
278
+ wire_name = _WIRE_NAMES.get(key, key)
279
+ if create:
280
+ if key == "repo_url" and not str(value[key]).strip():
281
+ continue
282
+ result[wire_name] = value[key]
283
+ else:
284
+ result[wire_name] = value[key] if key == "name" else _null_marker(value[key])
285
+ if "project_id" in value and value["project_id"] is not None:
286
+ result["projectId"] = int(value["project_id"])
287
+ if create or "categories" in value:
288
+ category_ids = _category_ids(value.get("categories"))
289
+ result["categories"] = ",".join(str(item) for item in category_ids)
290
+ if create or "synchronization_type" in value:
291
+ result["synchronizationType"] = _sync_type(value.get("synchronization_type"))
292
+ if create or "license" in value:
293
+ result["license"] = _license_json(value.get("license"), value)
294
+ if create:
295
+ logo_base64 = value.get("logo_base64")
296
+ if logo_base64 is None:
297
+ screenshots = value.get("screenshot_base64s") or []
298
+ logo_base64 = screenshots[0] if isinstance(screenshots, (list, tuple)) and screenshots else ""
299
+ result["images"] = 0
300
+ result["screenshotBase64sReqs"] = {"name": "logo.webp", "screenshotBase64s": logo_base64 or ""}
301
+ elif "image_ops" in value:
302
+ if "images" not in value:
303
+ raise ValidationError("images is required when image_ops is supplied", path="images")
304
+ result["images"] = int(value["images"])
305
+ result["imagesOps"] = _image_ops(value["image_ops"])
306
+ if "required_tier_id" in value:
307
+ if value["required_tier_id"] is not None:
308
+ result["requiredTierId"] = int(value["required_tier_id"]) if create else str(int(value["required_tier_id"]))
309
+ elif not create:
310
+ result["requiredTierId"] = "<null>"
311
+ if not create and "description" in value:
312
+ result["description"] = _null_marker(value["description"])
313
+ if not create and "required_dependencies" in value:
314
+ result["requiredDependencies"] = _null_marker(value["required_dependencies"])
315
+ return result
316
+
317
+
318
+ def _project_document(value: Mapping[str, Any]) -> Dict[str, Any]:
319
+ """Resolve a persisted Creator form snapshot before building request JSON."""
320
+ document = dict(value)
321
+ snapshot = document.get("project_state")
322
+ if snapshot is None:
323
+ raise ValidationError(
324
+ "completed project_state is required; submit choose_game, basic_info, then license",
325
+ path="$.project_state",
326
+ )
327
+ machine = ProjectStateMachine.from_snapshot(snapshot)
328
+ if machine.state != COMPLETE:
329
+ raise ValidationError("project state must be complete before submission", path="$.project_state.state")
330
+ merged = dict(machine.basic_info)
331
+ merged.update({key: item for key, item in document.items() if key not in {"project_state", "basic_info", "license"}})
332
+ merged["license"] = machine.license
333
+ return merged
334
+
335
+
336
+ def _business_code(payload: Mapping[str, Any]) -> Optional[int]:
337
+ code = payload.get("code")
338
+ if isinstance(code, bool) or code is None:
339
+ return None
340
+ try:
341
+ return int(str(code).strip())
342
+ except (TypeError, ValueError):
343
+ return None
344
+
345
+
346
+ def _safe(value: Any) -> Any:
347
+ """Recursively redact credentials and presigned URLs in API results."""
348
+ if isinstance(value, Mapping):
349
+ result = {}
350
+ for key, item in value.items():
351
+ normalized = str(key).replace("-", "_").lower()
352
+ result[str(key)] = "[REDACTED]" if normalized in _SECRET_KEYS else _safe(item)
353
+ return result
354
+ if isinstance(value, list):
355
+ return [_safe(item) for item in value]
356
+ if isinstance(value, str):
357
+ return redact(value)
358
+ return value
359
+
360
+
361
+ def _dpapi_unprotect(cipher: bytes) -> bytes:
362
+ if os.name != "nt":
363
+ raise FuploadError("ModUs Creator token reuse requires Windows DPAPI", kind="authentication_error")
364
+ class DATA_BLOB(ctypes.Structure):
365
+ _fields_ = [("cbData", wintypes.DWORD), ("pbData", ctypes.POINTER(ctypes.c_byte))]
366
+
367
+ def blob(data: bytes):
368
+ buf = ctypes.create_string_buffer(data)
369
+ return DATA_BLOB(len(data), ctypes.cast(buf, ctypes.POINTER(ctypes.c_byte))), buf
370
+
371
+ inp, inp_buf = blob(cipher)
372
+ ent, ent_buf = blob(TOKEN_ENTROPY)
373
+ out = DATA_BLOB()
374
+ crypt = ctypes.windll.crypt32.CryptUnprotectData
375
+ crypt.argtypes = [ctypes.POINTER(DATA_BLOB), ctypes.c_void_p, ctypes.POINTER(DATA_BLOB), ctypes.c_void_p, ctypes.c_void_p, wintypes.DWORD, ctypes.POINTER(DATA_BLOB)]
376
+ crypt.restype = wintypes.BOOL
377
+ if not crypt(ctypes.byref(inp), None, ctypes.byref(ent), None, None, 0, ctypes.byref(out)):
378
+ raise FuploadError("ModUs Creator token decryption failed for the current Windows user", kind="authentication_error")
379
+ try:
380
+ return ctypes.string_at(out.pbData, out.cbData)
381
+ finally:
382
+ ctypes.windll.kernel32.LocalFree(out.pbData)
383
+
384
+
385
+ def load_token(path: Optional[Path] = None) -> str:
386
+ """Read and decrypt the local Creator token without printing it."""
387
+ selected = path or TOKEN_PATH
388
+ if not selected.is_file():
389
+ legacy = selected.with_name("token.json")
390
+ if legacy.is_file():
391
+ selected = legacy
392
+ else:
393
+ raise FuploadError("ModUs Creator login token was not found", kind="authentication_error", details={"path": str(selected)})
394
+ try:
395
+ raw = selected.read_bytes()
396
+ plain = _dpapi_unprotect(raw) if selected.name.endswith(".dat") else raw
397
+ if selected.name == "token.json":
398
+ parsed = json.loads(plain.decode("utf-8"))
399
+ plain = str(parsed.get("token") or parsed.get("accessToken") or "").encode()
400
+ token = plain.decode("utf-8").strip()
401
+ except FuploadError:
402
+ raise
403
+ except Exception as exc:
404
+ raise FuploadError("ModUs Creator login token could not be read", kind="authentication_error") from exc
405
+ if not token:
406
+ raise FuploadError("ModUs Creator login token is empty", kind="authentication_error")
407
+ return token
408
+
409
+
410
+ def _unwrap(payload: Any) -> Any:
411
+ if isinstance(payload, Mapping) and "data" in payload:
412
+ return payload["data"]
413
+ return payload
414
+
415
+
416
+ class ModUs:
417
+ """Authenticated ModUs Creator API client for WoW plugin publishing."""
418
+
419
+ def __init__(
420
+ self,
421
+ token: Optional[str] = None,
422
+ *,
423
+ base_url: str = API_BASE,
424
+ timeout: int = 60,
425
+ token_path: Optional[Path] = None,
426
+ authenticate: bool = True,
427
+ ) -> None:
428
+ self.base_url = base_url.rstrip("/") + "/"
429
+ self.timeout = timeout
430
+ self.token_path = token_path or TOKEN_PATH
431
+ self.token = token if token is not None else (load_token(self.token_path) if authenticate else "")
432
+
433
+ def _url(self, path: str) -> str:
434
+ return urllib.parse.urljoin(self.base_url, path.lstrip("/"))
435
+
436
+ @staticmethod
437
+ def _request_stage(method: str, path: str) -> str:
438
+ if "file/upload/signature" in path:
439
+ return "signature"
440
+ if path.endswith("/fileId/") or "/fileId/" in path:
441
+ return "file_id"
442
+ if "/project/file/upload" in path or path.endswith("/project/upload"):
443
+ return "release_metadata"
444
+ if "/project/file/update" in path:
445
+ return "release_metadata_update"
446
+ if "/project/delete" in path:
447
+ return "delete"
448
+ if path.endswith("/project/release"):
449
+ return "project_create"
450
+ if path.endswith("/project/update"):
451
+ return "project_update"
452
+ return "request"
453
+
454
+ def _request(self, method: str, path: str, body: Any = None, *, headers: Optional[Mapping[str, str]] = None) -> Any:
455
+ url = self._url(path)
456
+ stage = self._request_stage(method, path)
457
+ request_headers = {"Accept": "application/json", "Authorization": "Bearer " + self.token}
458
+ if headers:
459
+ request_headers.update(headers)
460
+ data = None
461
+ if body is not None:
462
+ data = json.dumps(body, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
463
+ request_headers["Content-Type"] = "application/json"
464
+ request = urllib.request.Request(url, data=data, method=method, headers=request_headers)
465
+ try:
466
+ with urllib.request.urlopen(request, timeout=self.timeout) as response:
467
+ raw, status = response.read(), response.status
468
+ except urllib.error.HTTPError as exc:
469
+ raw = exc.read()
470
+ try:
471
+ detail = json.loads(raw.decode("utf-8"))
472
+ except Exception:
473
+ detail = "HTTP %d" % exc.code
474
+ raise FuploadError(redact(str(detail)), endpoint=url, http_status=exc.code, kind="platform_error", stage=stage) from exc
475
+ except (OSError, urllib.error.URLError) as exc:
476
+ raise FuploadError("ModUs request failed: %s" % exc, endpoint=url, verification_required=method != "GET", stage=stage) from exc
477
+ if status < 200 or status >= 300:
478
+ raise FuploadError("ModUs returned HTTP %d" % status, endpoint=url, http_status=status, stage=stage)
479
+ if not raw:
480
+ return {}
481
+ try:
482
+ payload = json.loads(raw.decode("utf-8"))
483
+ except ValueError as exc:
484
+ raise FuploadError("ModUs response was not valid JSON", endpoint=url, http_status=status, stage=stage) from exc
485
+ if isinstance(payload, Mapping) and payload.get("success") is False:
486
+ raise FuploadError(str(payload.get("message") or "ModUs API rejected the request"), endpoint=url, business_code=payload.get("code"), kind="platform_error", stage=stage)
487
+ if isinstance(payload, Mapping):
488
+ business_code = _business_code(payload)
489
+ if business_code is not None and business_code >= 400:
490
+ raise FuploadError(
491
+ str(payload.get("msg") or payload.get("message") or "ModUs API rejected the request"),
492
+ endpoint=url, business_code=business_code, kind="platform_error", stage=stage,
493
+ )
494
+ return payload
495
+
496
+ def doctor(self) -> Dict[str, Any]:
497
+ selected = self.token_path
498
+ if not selected.is_file() and selected.with_name("token.json").is_file():
499
+ selected = selected.with_name("token.json")
500
+ present = selected.is_file()
501
+ result = {
502
+ "token_present": present,
503
+ "token_decrypted": False,
504
+ "token_nonempty": False,
505
+ "api_ready": False,
506
+ }
507
+ if not present:
508
+ return result
509
+ try:
510
+ raw = selected.read_bytes()
511
+ plain = _dpapi_unprotect(raw) if selected.name.endswith(".dat") else raw
512
+ result["token_decrypted"] = True
513
+ if selected.name == "token.json":
514
+ parsed = json.loads(plain.decode("utf-8"))
515
+ plain = str(parsed.get("token") or parsed.get("accessToken") or "").encode()
516
+ token = plain.decode("utf-8").strip()
517
+ except (FuploadError, OSError, UnicodeError, ValueError, TypeError):
518
+ return result
519
+ result["token_nonempty"] = bool(token)
520
+ if not token:
521
+ return result
522
+ previous_token = self.token
523
+ self.token = token
524
+ try:
525
+ self.user_info()
526
+ result["api_ready"] = True
527
+ except FuploadError:
528
+ pass
529
+ finally:
530
+ self.token = previous_token
531
+ return result
532
+
533
+ def project_list(self, *, page_num: int = 1, page_size: int = 50, **extra: Any) -> Any:
534
+ body = {"pageNum": page_num, "pageSize": page_size, **extra}
535
+ return _safe(_unwrap(self._request("POST", "game/data/author/project/list", body)))
536
+
537
+ def project_detail(self, project_id: int) -> Any:
538
+ return _safe(_unwrap(self._request("GET", "game/data/author/project/detail/%s" % _positive_id(project_id, field="project_id"))))
539
+
540
+ def project_create(self, doc: Mapping[str, Any]) -> Any:
541
+ document = _project_document(doc)
542
+ return _safe(_unwrap(self._request("POST", "game/data/author/project/release", _project_wire(document, create=True))))
543
+
544
+ def project_update(self, doc: Mapping[str, Any]) -> Any:
545
+ document = _project_document(doc)
546
+ return _safe(_unwrap(self._request("POST", "game/data/author/project/update", _project_wire(document, create=False))))
547
+
548
+ def project_delete(self, project_id: int) -> Any:
549
+ return _safe(_unwrap(self._request("POST", "game/data/author/project/delete/project/%s" % _positive_id(project_id, field="project_id"))))
550
+
551
+ def release_file_id(self, project_id: int) -> Any:
552
+ project_id = _positive_id(project_id, field="project_id")
553
+ value = _unwrap(self._request("GET", "game/data/author/project/fileId/%s" % project_id))
554
+ if isinstance(value, Mapping):
555
+ value = value.get("fileId") or value.get("id") or value.get("data")
556
+ if not value:
557
+ raise FuploadError("ModUs did not return a release file ID", kind="platform_data_error")
558
+ return _positive_id(value, field="file_id")
559
+
560
+ def release_list(self, project_id: int, *, page_num: int = 1, page_size: int = 50) -> Any:
561
+ return _safe(_unwrap(self._request("POST", "game/data/author/project/file/list", {"projectId": _positive_id(project_id, field="project_id"), "pageNum": page_num, "pageSize": page_size})))
562
+
563
+ def release_detail(self, project_id: int, file_id: int) -> Any:
564
+ # Creator's GetReleaseFileDetailAsync accepts only the reserved file ID.
565
+ return _safe(_unwrap(self._request("GET", "game/data/author/project/file/detail/%s" % _positive_id(file_id, field="file_id"))))
566
+
567
+ def release_metadata(self, doc: Mapping[str, Any], *, update: bool = False) -> Any:
568
+ route = "game/data/author/project/file/update" if update else "game/data/author/project/upload"
569
+ wire = _release_wire(doc)
570
+ if not update:
571
+ # UploadReleaseAsync's create payload has no fileId. The ID is
572
+ # reserved by GetReleaseFileIdAsync and is consumed by signing.
573
+ wire.pop("fileId", None)
574
+ return _safe(_unwrap(self._request("POST", route, wire)))
575
+
576
+ def user_info(self) -> Any:
577
+ return _safe(_unwrap(self._request("GET", "system/user/getInfo")))
578
+
579
+ def active_subscription_count(self) -> Any:
580
+ return _safe(_unwrap(self._request("GET", "user/author/subscription/active/count")))
581
+
582
+ def project_statistics(self) -> Any:
583
+ return _safe(_unwrap(self._request("GET", "game/data/author/project/statistics")))
584
+
585
+ def addon_info(self, directories: Any, *, server_type: int = 1) -> Any:
586
+ values = directories if isinstance(directories, (list, tuple)) else [directories]
587
+ body = {"pluginList": [str(value) for value in values]}
588
+ return _safe(_unwrap(self._request("POST", "plugin/list/info", body, headers={"X-Server-Type": str(int(server_type))})))
589
+
590
+ def addon_project_info(self, project_ids: Any, *, server_type: int = 1) -> Any:
591
+ values = project_ids if isinstance(project_ids, (list, tuple)) else [project_ids]
592
+ body = {"projectIds": [int(value) for value in values]}
593
+ return _safe(_unwrap(self._request("POST", "plugin/list/detail", body, headers={"X-Server-Type": str(int(server_type))})))
594
+
595
+ def addon_history(self, project_id: int, *, page_num: int = 1, page_size: int = 5, server_type: int = 1) -> Any:
596
+ body = {"projectIds": [int(project_id)], "pageNum": int(page_num), "pageSize": int(page_size)}
597
+ return _safe(_unwrap(self._request("POST", "plugin/project/history", body, headers={"X-Server-Type": str(int(server_type))})))
598
+
599
+ def project_dependencies(self, query: Any) -> Any:
600
+ body = _dependency_query_wire(query)
601
+ return _safe(_unwrap(self._request("POST", "game/data/author/project/dependency/query", body)))
602
+
603
+ def options(self, action: str, *, keys: Optional[Any] = None) -> Any:
604
+ routes = {
605
+ # These paths are the routes used by ModUs.Creator's ApiService.
606
+ "categories": "plugin/list/Categories",
607
+ "subscription-tiers": "user/author/subscription/tiers",
608
+ }
609
+ if action == "game-versions":
610
+ # Creator posts the requested config keys. An empty list asks for
611
+ # no valid request on the live service, so require an explicit key.
612
+ requested = keys if isinstance(keys, list) else ([] if keys is None else [keys])
613
+ requested = [str(item).strip() for item in requested if str(item).strip()]
614
+ if not requested:
615
+ raise ValidationError("at least one game config key is required", path="$.keys")
616
+ return _safe(_unwrap(self._request("POST", "game/data/config/detail", {"keys": requested})))
617
+ if action not in routes:
618
+ raise ValidationError("unsupported ModUs options operation")
619
+ return _safe(_unwrap(self._request("GET", routes[action])))
620
+
621
+ def release_signature(self, project_id: int, file_id: int) -> str:
622
+ result = _unwrap(self._request("GET", "game/data/author/project/file/upload/signature/%s/%s" % (_positive_id(project_id, field="project_id"), _positive_id(file_id, field="file_id"))))
623
+ url = result.get("signedUrl") if isinstance(result, Mapping) else result
624
+ if not isinstance(url, str) or not url.startswith(("https://", "http://")):
625
+ raise FuploadError("ModUs did not return a valid release upload URL", kind="platform_data_error")
626
+ return url
627
+
628
+ def upload_zip(self, signed_url: str, file_path: str) -> Dict[str, Any]:
629
+ path = Path(file_path)
630
+ if not path.is_file():
631
+ raise ValidationError("release ZIP file does not exist", path=file_path)
632
+ size = path.stat().st_size
633
+ if size > MAX_PACKAGE_BYTES:
634
+ raise ValidationError("release ZIP exceeds the 200 MB upload limit", path=file_path)
635
+ parsed = urllib.parse.urlsplit(signed_url)
636
+ conn_cls = http.client.HTTPSConnection if parsed.scheme == "https" else http.client.HTTPConnection
637
+ if parsed.scheme == "https":
638
+ conn = conn_cls(parsed.hostname, parsed.port, timeout=max(self.timeout, 600), context=ssl.create_default_context())
639
+ else:
640
+ conn = conn_cls(parsed.hostname, parsed.port, timeout=max(self.timeout, 600))
641
+ try:
642
+ conn.putrequest("PUT", urllib.parse.urlunsplit(("", "", parsed.path or "/", parsed.query, "")))
643
+ conn.putheader("Content-Type", "application/zip")
644
+ conn.putheader("Content-Length", str(size))
645
+ conn.endheaders()
646
+ with path.open("rb") as handle:
647
+ while True:
648
+ chunk = handle.read(1024 * 1024)
649
+ if not chunk:
650
+ break
651
+ conn.send(chunk)
652
+ response = conn.getresponse()
653
+ response.read()
654
+ if response.status < 200 or response.status >= 300:
655
+ raise FuploadError("ModUs binary upload returned HTTP %d" % response.status, http_status=response.status, verification_required=True, stage="binary_upload")
656
+ except FuploadError:
657
+ raise
658
+ except OSError as exc:
659
+ raise FuploadError("ModUs binary upload failed: %s" % exc, verification_required=True, stage="binary_upload") from exc
660
+ finally:
661
+ conn.close()
662
+ return {"status": "uploaded", "archive": path.name, "bytes": size}
663
+
664
+ def release_delete(self, project_id: int, file_id: int) -> Any:
665
+ return _safe(_unwrap(self._request("POST", "game/data/author/project/delete", {"projectId": _positive_id(project_id, field="project_id"), "fileId": _positive_id(file_id, field="file_id")})))
666
+
667
+ def publish(self, doc: Mapping[str, Any], *, update: bool = False) -> Dict[str, Any]:
668
+ project_id = _positive_id(doc["project_id"], field="project_id")
669
+ allocated_file_id = not bool(doc.get("file_id"))
670
+ file_id = _positive_id(doc.get("file_id") or self.release_file_id(project_id), field="file_id")
671
+ metadata = {k: doc[k] for k in ("project_id", "version", "type", "supported_game_versions", "toc_version", "changelog", "path") if k in doc}
672
+ metadata["file_id"] = file_id
673
+ file_path = doc.get("file")
674
+ if file_path is None and not update:
675
+ raise ValidationError("release ZIP file is required", path="file")
676
+ archive = Path(str(file_path)) if file_path is not None else None
677
+ if archive is not None:
678
+ if not archive.is_file():
679
+ raise ValidationError("release ZIP file does not exist", path=str(file_path))
680
+ size = archive.stat().st_size
681
+ if size > MAX_PACKAGE_BYTES:
682
+ raise ValidationError("release ZIP exceeds the 200 MB upload limit", path=str(file_path))
683
+ md5 = hashlib.md5(archive.read_bytes()).hexdigest()
684
+ try:
685
+ unzip_size = sum(info.file_size for info in zipfile.ZipFile(str(archive)).infolist())
686
+ except (OSError, zipfile.BadZipFile) as exc:
687
+ raise ValidationError("release file is not a valid ZIP", path=str(file_path)) from exc
688
+ derived = parse_modus_zip(archive)
689
+ supplied_toc = doc.get("toc_version")
690
+ supplied_games = doc.get("supported_game_versions")
691
+ if supplied_toc is not None and str(supplied_toc) != derived["toc_version"]:
692
+ raise ValidationError("toc_version does not match the ZIP Interface field", path="$.toc_version")
693
+ if supplied_games is not None and _supported_game_versions(supplied_games) != derived["supported_game_versions"]:
694
+ raise ValidationError("supported_game_versions does not match the ZIP Interface field", path="$.supported_game_versions")
695
+ metadata.update({
696
+ "md5": md5,
697
+ "zip_size": size,
698
+ "unzip_size": unzip_size,
699
+ "toc_version": derived["toc_version"],
700
+ "supported_game_versions": derived["supported_game_versions"],
701
+ "path": doc.get("path") or archive.name,
702
+ })
703
+ transaction: Dict[str, Any] = {
704
+ "schema": "fupload.v1.modus.upload-transaction",
705
+ "created_at": int(time.time()),
706
+ "project_id": project_id,
707
+ "file_id": file_id,
708
+ "update": bool(update),
709
+ "archive": str(archive) if archive is not None else None,
710
+ "stages": ["file_id"] if allocated_file_id else [],
711
+ }
712
+ transaction_path = Path(str(doc.get("transaction_log") or ((str(archive) + ".modus-transaction.json") if archive else "modus-transaction.json")))
713
+
714
+ def save_transaction() -> None:
715
+ transaction_path.write_text(json.dumps(transaction, ensure_ascii=False, indent=2), encoding="utf-8")
716
+
717
+ try:
718
+ transaction["stages"].append("release_metadata_update" if update else "release_metadata")
719
+ result = self.release_metadata(metadata, update=update)
720
+ if archive is None:
721
+ transaction.update({"completed": True, "upload": None})
722
+ save_transaction()
723
+ return {"project_id": project_id, "file_id": file_id, "metadata": result, "upload": None, "transaction": _safe(transaction)}
724
+ transaction["stages"].append("signature")
725
+ signed = self.release_signature(project_id, file_id)
726
+ transaction["stages"].append("binary_upload")
727
+ uploaded = self.upload_zip(signed, str(archive))
728
+ transaction.update({"completed": True, "upload": uploaded})
729
+ save_transaction()
730
+ return {"project_id": project_id, "file_id": file_id, "metadata": result, "upload": uploaded, "transaction": _safe(transaction)}
731
+ except FuploadError as exc:
732
+ transaction["failed_stage"] = exc.stage or transaction["stages"][-1] if transaction["stages"] else "prepare"
733
+ transaction["error"] = exc.as_dict()
734
+ transaction["retained_archive"] = bool(archive and archive.is_file())
735
+ try:
736
+ save_transaction()
737
+ except OSError:
738
+ pass
739
+ raise
740
+
741
+ def execute_read(self, resource: str, action: str, args: Any = None) -> Any:
742
+ if resource == "session" and action == "doctor": return self.doctor()
743
+ doc = vars(args) if args is not None and hasattr(args, "__dict__") else (args or {})
744
+ if resource == "account" and action == "info": return self.user_info()
745
+ if resource == "account" and action == "subscription-count": return self.active_subscription_count()
746
+ if resource == "account" and action == "statistics": return self.project_statistics()
747
+ if resource == "addon" and action == "info": return self.addon_info(doc.get("directories", []), server_type=doc.get("server_type", 1))
748
+ if resource == "addon" and action == "project-info": return self.addon_project_info(doc.get("project_ids", []), server_type=doc.get("server_type", 1))
749
+ if resource == "addon" and action == "history": return self.addon_history(doc["project_id"], page_num=doc.get("page_num", 1), page_size=doc.get("page_size", 5), server_type=doc.get("server_type", 1))
750
+ if resource == "project" and action == "dependencies": return self.project_dependencies(doc.get("query") or doc.get("project_ids") or doc.get("project_id"))
751
+ if resource == "options": return self.options(action, keys=doc.get("keys"))
752
+ if resource == "project" and action == "list": return self.project_list(**doc)
753
+ if resource == "project" and action in ("get", "detail"): return self.project_detail(doc["project_id"])
754
+ if resource in ("plugin", "release") and action in ("list", "versions"): return self.release_list(doc["project_id"], page_num=doc.get("page_num", 1), page_size=doc.get("page_size", 50))
755
+ if resource in ("plugin", "release") and action == "get": return self.release_detail(doc["project_id"], doc["file_id"])
756
+ raise ValidationError("unsupported ModUs read operation")
757
+
758
+ def execute_write(self, resource: str, action: str, doc: Mapping[str, Any]) -> Any:
759
+ if resource == "project" and action in ("create", "release"): return self.project_create(doc)
760
+ if resource == "project" and action in ("update", "edit"): return self.project_update(doc)
761
+ if resource == "project" and action == "delete": return self.project_delete(int(doc["project_id"]))
762
+ if resource in ("plugin", "release") and action in ("upload", "create"): return self.publish(doc)
763
+ if resource in ("plugin", "release") and action in ("update", "edit"): return self.publish(doc, update=True)
764
+ if resource in ("plugin", "release") and action == "delete": return self.release_delete(doc["project_id"], doc["file_id"])
765
+ raise ValidationError("unsupported ModUs write operation")
766
+
767
+
768
+ Modus = ModUs