@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.
- package/README.md +1 -1
- package/fupload/SKILL.md +17 -4
- package/fupload/references/modus.md +77 -0
- package/fupload/scripts/fupload_cli/__init__.py +1 -1
- package/fupload/scripts/fupload_cli/cli.py +70 -2
- package/fupload/scripts/fupload_cli/io.py +3 -1
- package/fupload/scripts/fupload_cli/modus.py +768 -0
- package/fupload/scripts/fupload_cli/modus_zip.py +175 -0
- package/fupload/scripts/fupload_cli/schema.py +141 -2
- package/fupload/scripts/fupload_cli/state_machine.py +346 -0
- package/npm/lib/python.mjs +56 -7
- package/npm/skill-manifest.json +33 -13
- package/package.json +1 -1
|
@@ -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
|