@follenfang/fupload 0.0.10 → 0.0.12
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 +1 -1
- package/fupload/references/modus.md +121 -4
- package/fupload/scripts/fupload_cli/__init__.py +1 -1
- package/fupload/scripts/fupload_cli/cli.py +46 -10
- package/fupload/scripts/fupload_cli/modus.py +425 -58
- package/fupload/scripts/fupload_cli/schema.py +53 -1
- package/fupload/scripts/fupload_cli/state_machine.py +40 -12
- package/npm/skill-manifest.json +15 -15
- package/package.json +1 -1
package/fupload/SKILL.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: fupload
|
|
3
3
|
description: Explicit author-publishing workflow for World of Warcraft plugins, configuration shares, and WA/strings on NewBeeBox, NetEase DD, CurseForge, Heybox Workshop, and ModUs.Creator, including local Creator login reuse and plugin ZIP publishing. Use only when the user explicitly invokes `$fupload`, explicitly asks to use the Fupload Skill, or loads this Skill by path. Do not trigger from ordinary mentions of publishing, NewBeeBox, DD, CurseForge, Heybox, ModUs, plugins, configurations, or WA.
|
|
4
4
|
metadata:
|
|
5
|
-
version: "0.0.
|
|
5
|
+
version: "0.0.12"
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Fupload
|
|
@@ -19,6 +19,11 @@ game/data/config/detail` with a JSON `{"keys": [...]}` body. Repeat `--key`
|
|
|
19
19
|
to request selected server-defined configuration keys; at least one key is
|
|
20
20
|
required because the service rejects an empty list.
|
|
21
21
|
|
|
22
|
+
Main-client form choices are available through `modus options config-tags`,
|
|
23
|
+
`modus options wa-tags`, and `modus options wa-support-addons`. These commands
|
|
24
|
+
read the official ModUs static client-data origin without sending the local
|
|
25
|
+
login token.
|
|
26
|
+
|
|
22
27
|
## Project writes
|
|
23
28
|
|
|
24
29
|
`modus project create|edit|delete` uses versioned JSON through `--input`.
|
|
@@ -27,10 +32,43 @@ The complete project model includes `project_id`, `name`, `alt_name`, `summary`,
|
|
|
27
32
|
`required_tier_id`, `required_dependencies`, `cf_url`, returned `logo`, `status`,
|
|
28
33
|
`game`, and `description`. Write inputs use `project_state`, `publish_platforms`,
|
|
29
34
|
`logo_base64`, the compatibility `screenshot_base64s`, and edit-only `image_ops`.
|
|
35
|
+
|
|
36
|
+
### Project field contract
|
|
37
|
+
|
|
38
|
+
| CLI/state field | Creator wire/readback | JSON type | Required/default | Source and state dependency | Successful readback |
|
|
39
|
+
| --- | --- | --- | --- | --- | --- |
|
|
40
|
+
| `schema` | none | string | required by every write document | versioned CLI envelope | not sent |
|
|
41
|
+
| `project_id` | `projectId` | positive integer | edit/delete required; create assigned by server | outer write document, not form state | `projectId` |
|
|
42
|
+
| `game` | none confirmed | object or non-empty string | required; no default | `choose_game`; object needs a stable ID/key | preserved in state snapshot |
|
|
43
|
+
| `name` | `name` | non-empty string | required; no default | `basic_info` | `name` |
|
|
44
|
+
| `alt_name` | `altName` | string/null | optional; omitted preserves value | `basic_info`; edit clear uses `<null>` | `altName` |
|
|
45
|
+
| `summary` | `summary` | non-empty string | required; no default | `basic_info` | `summary` |
|
|
46
|
+
| `categories` | comma-delimited `categories` | 1-5 positive integer IDs | required; no default | service `categories` enum; `basic_info`; ID 998 requires BigFoot only | comma-delimited `categories` |
|
|
47
|
+
| `publish_platforms` | derived `synchronizationType` | non-empty unique enum array | required; no default | `basic_info`; values `modus`, `bigfoot`, or both | integer `1`, `2`, or `3` |
|
|
48
|
+
| `synchronization_type` | `synchronizationType` | integer | derived; caller value is replaced | derived from platform toggles in `basic_info` | `synchronizationType` |
|
|
49
|
+
| `required_tier_id` | `requiredTierId` | nullable positive integer input | omitted means no tier | service `subscription-tiers` enum; current result is `[]`; BigFoot requires no tier | omitted from the wire for no tier |
|
|
50
|
+
| `repo_url` | `repoUrl` | string/null | optional; omitted preserves value | `basic_info`; create empty is omitted; edit clear uses `<null>` | `repoUrl` |
|
|
51
|
+
| `logo_base64` | `screenshotBase64sReqs.screenshotBase64s` | base64 string | optional; default empty string | create-only `basic_info`; sent as `logo.webp` | server-managed `logo` path |
|
|
52
|
+
| `screenshot_base64s` | same create logo payload | string array | compatibility alias; default empty array | create-only `basic_info`; first value is used when `logo_base64` is absent | server-managed `logo` path |
|
|
53
|
+
| `license.type` | `license` JSON string `type` | non-empty string | required; no default | separate `license` step | parsed `license` |
|
|
54
|
+
| `license.holder/year/content` | fields inside `license` JSON string | non-empty strings | optional except custom content | separate `license` step; custom requires content | parsed `license` |
|
|
55
|
+
| `description` | `description` | string/null | edit-only; omitted preserves value | edit `basic_info`; clear uses `<null>` | `description` |
|
|
56
|
+
| `required_dependencies` | `requiredDependencies` | string/null | edit-only; omitted preserves value | edit `basic_info`; clear uses `<null>`; dependency query is dynamic | `requiredDependencies` |
|
|
57
|
+
| `images` | `images` | non-negative integer | edit-only; required with `image_ops` | edit `basic_info` | `images` count |
|
|
58
|
+
| `image_ops` | `imagesOps` | operation array | edit-only; no default | edit `basic_info`; upload requires base64, delete forbids it | `images` count |
|
|
59
|
+
| `cf_url` | `cfUrl` | string/null | read-only | known detail field may be preserved in a snapshot but is never sent | `cfUrl` |
|
|
60
|
+
| `logo` | `logo` | string/object/null | read-only | server-managed detail field; create uses `logo_base64` instead | `logo` |
|
|
61
|
+
| `status` | `status` | integer/null | read-only | server-managed detail field | `status` |
|
|
62
|
+
| `project_state` | none | object | required and must be `complete` | wraps `choose_game -> basic_info -> license`; unknown nested fields are rejected | local resumable snapshot |
|
|
63
|
+
|
|
64
|
+
Create-only, edit-only, and read-only fields may be preserved in a resumable
|
|
65
|
+
snapshot, but `_project_wire` sends only fields supported by the selected API
|
|
66
|
+
operation. The state machine rejects unknown `basic_info` and `license` fields
|
|
67
|
+
before changing state.
|
|
30
68
|
`publish_platforms` is a non-empty array containing `modus`,
|
|
31
69
|
`bigfoot`, or both; the two values are not mutually exclusive. Creator derives
|
|
32
70
|
wire `synchronizationType` as 1 for ModUs, 2 for BigFoot, and 3 for both.
|
|
33
|
-
When BigFoot is selected, `required_tier_id` must be null. Category ID 998 is
|
|
71
|
+
When BigFoot is selected, `required_tier_id` must be null or omitted. Category ID 998 is
|
|
34
72
|
BigFoot-exclusive and forces BigFoot as the only platform. `categories` contains
|
|
35
73
|
one to five positive integer IDs.
|
|
36
74
|
|
|
@@ -51,8 +89,9 @@ local form state, not project update keys.
|
|
|
51
89
|
`project_state` is a resumable `choose_game` -> `basic_info` -> `license` ->
|
|
52
90
|
`complete` snapshot and is required for both create and edit; incomplete or
|
|
53
91
|
missing snapshots are rejected before dry-run or network access. Each step preserves
|
|
54
|
-
its state on validation failure. `required_tier_id` is null for the
|
|
55
|
-
no-tier branch
|
|
92
|
+
its state on validation failure. `required_tier_id` is null or omitted for the
|
|
93
|
+
no-tier branch and is omitted from the HTTP payload; a selected tier is a
|
|
94
|
+
positive dropdown ID.
|
|
56
95
|
The write document also accepts `confirm` for destructive operations.
|
|
57
96
|
Deletion requires `confirm: "DELETE"`.
|
|
58
97
|
|
|
@@ -64,7 +103,10 @@ Deletion requires `confirm: "DELETE"`.
|
|
|
64
103
|
`transaction_log` optionally selects the redacted transaction record path.
|
|
65
104
|
Successful records include the completed `file_id`, metadata, signature, and
|
|
66
105
|
binary upload stages that ran; failed records include `failed_stage`, a redacted
|
|
67
|
-
error, and whether the ZIP was retained. The
|
|
106
|
+
error, and whether the ZIP was retained. The transaction record is established
|
|
107
|
+
before file-ID allocation and ZIP preflight, so those early failures are also
|
|
108
|
+
durable. Binary-upload errors expose only a query-free endpoint identifier,
|
|
109
|
+
status when available, and a bounded redacted response summary. The provider parses every ZIP `.toc`
|
|
68
110
|
`Interface` value to derive
|
|
69
111
|
`toc_version` and `supported_game_versions`; explicit caller values must match.
|
|
70
112
|
The provider validates the ZIP, computes `md5`, `zip_size`, and `unzip_size`,
|
|
@@ -75,3 +117,78 @@ never substituted for the binary upload. Release deletion requires
|
|
|
75
117
|
|
|
76
118
|
All write commands support `--dry-run`, which validates the versioned JSON and
|
|
77
119
|
local files without constructing an authenticated provider or making a request.
|
|
120
|
+
|
|
121
|
+
## Main ModUs client: configuration shares
|
|
122
|
+
|
|
123
|
+
The installed main client stores its Chromium session under `%APPDATA%\modus\Local Storage\leveldb`.
|
|
124
|
+
Fupload reads the persisted `token`/`deviceId` pair and calls the fixed
|
|
125
|
+
`https://app.modus.cool/api` origin with the raw `Authorization` value and
|
|
126
|
+
`X-Device-Id`; values are never printed. The Creator DPAPI store remains a
|
|
127
|
+
separate authentication source for `project` and `plugin` commands.
|
|
128
|
+
|
|
129
|
+
The configuration surface uses:
|
|
130
|
+
|
|
131
|
+
* `GET /system/user/backup/list`, `GET /system/user/backup/detail/{id}`
|
|
132
|
+
* `POST /system/user/backup/update`, `DELETE /system/user/backup/delete/{id}`
|
|
133
|
+
* `POST /system/user/share/list`, `POST /system/user/share/detail`
|
|
134
|
+
* `POST /system/user/share/create`, `PUT /system/user/share/update`,
|
|
135
|
+
`DELETE /system/user/share/delete/{id}`
|
|
136
|
+
|
|
137
|
+
Share write fields are `share_id`, `addons_id`, `account_name`, `backup_id`,
|
|
138
|
+
`content`, `content_text`, `image_url`, `is_paid`, `is_public`, `price`,
|
|
139
|
+
`share_type`, `tags`, `title`, `exclude_wtf`, `role_name`,
|
|
140
|
+
`required_tier_id`, `sub_type`, optional low-level `platform` /
|
|
141
|
+
`synchronization_type`, and
|
|
142
|
+
`server_type`. `exclude_wtf` is a binary state: when it is `1`, account and
|
|
143
|
+
role are empty; when it is `0`, an account is required and a selected role is
|
|
144
|
+
optional. `sub_type=0` is the normal subscription mode; `sub_type=1` is the
|
|
145
|
+
season mode. Both modes allow the "none" tier represented by null or omitted
|
|
146
|
+
`required_tier_id`; Fupload omits `requiredTierId` from the wire in that branch.
|
|
147
|
+
A selected tier must be a positive ID returned by the service. The official
|
|
148
|
+
configuration form's submit object omits `platform` and `synchronizationType`.
|
|
149
|
+
Fupload's low-level API surface nevertheless exposes the backend fields: list
|
|
150
|
+
defaults `platform=0`, create defaults `platform=1` and
|
|
151
|
+
`synchronizationType=1`, and explicit low-level values are passed through.
|
|
152
|
+
`backup_id`, content, cover, title, and tags are required for create;
|
|
153
|
+
update is presence-aware and uses `share_id`. `addons_id` must be non-empty,
|
|
154
|
+
the trimmed title must contain at least 6 characters, tags contain 1 to 3
|
|
155
|
+
comma-delimited IDs, and plain `content_text` must contain more than 20
|
|
156
|
+
characters.
|
|
157
|
+
|
|
158
|
+
Backup rename uses `backup_id`, `backup_name`, and `server_type`; backup
|
|
159
|
+
deletion uses `backup_id`, `confirm`, and `server_type`.
|
|
160
|
+
|
|
161
|
+
## Main ModUs client: strings
|
|
162
|
+
|
|
163
|
+
String articles use:
|
|
164
|
+
|
|
165
|
+
* `POST /system/user/import/list`, `POST /system/user/import/detail`
|
|
166
|
+
* `POST /system/user/import/create`, `POST /system/user/import/update`
|
|
167
|
+
* `DELETE /system/user/import/delete/{id}`
|
|
168
|
+
* `POST /system/user/import/version/publish`
|
|
169
|
+
* `DELETE /system/user/import/version/delete?versionId={id}`
|
|
170
|
+
|
|
171
|
+
String fields are `import_id`, `code_text`, `content`, `addons_id`,
|
|
172
|
+
`content_text`, `file_path`, `image_url`, `is_paid`, `is_public`, `price`,
|
|
173
|
+
`share_type`, `support_addon`, `tags`, `title`, `version`, `required_tier_id`,
|
|
174
|
+
`sub_type`, and `server_type`. The client requires a supported addon,
|
|
175
|
+
non-empty rendered/plain article content, cover, title, tags, and version.
|
|
176
|
+
`sub_type` and `required_tier_id` follow the same normal/season state machine.
|
|
177
|
+
The current account uses the no-tier branch, so `requiredTierId` is absent from
|
|
178
|
+
the wire. The official string form's submit object omits `platform` and
|
|
179
|
+
`synchronizationType`; Fupload's low-level API surface defaults list
|
|
180
|
+
`platform=0` and create/update `platform=1` / `synchronizationType=1`, while
|
|
181
|
+
preserving explicit values.
|
|
182
|
+
The trimmed title must contain at least 6 characters, tags contain 1 to 3
|
|
183
|
+
comma-delimited IDs, plain `content_text` must contain more than 20 characters,
|
|
184
|
+
and `file_path` is the client's fixed empty string. Version history is separate
|
|
185
|
+
from article metadata:
|
|
186
|
+
`version-publish` takes `import_id`, `version`, `code_text`, and optional
|
|
187
|
+
`changelog`; `version-delete` takes `version_id` and `confirm: "DELETE"`.
|
|
188
|
+
|
|
189
|
+
Both main-client modules support explicit `confirm: "DELETE"` on destructive
|
|
190
|
+
commands. List operations send only server fields (`pageNum`, `pageSize`,
|
|
191
|
+
`keyword`, status/filter values), not the local CLI envelope or `server_type`.
|
|
192
|
+
### Main-client resource IDs and deletion
|
|
193
|
+
|
|
194
|
+
Configuration-share and string/import IDs are opaque decimal strings (for example, 16-digit IDs), not the numeric backup/project IDs. Fupload preserves them as strings. ModUs delete is a soft-delete: a successful `DELETE` returns code `200`, and a subsequent detail may return the record with `status=4` and `isPublic=0`; regression treats that state as deleted.
|
|
@@ -30,11 +30,11 @@ schema exposes them. The calling Skill must show the complete plan and obtain co
|
|
|
30
30
|
"""
|
|
31
31
|
|
|
32
32
|
|
|
33
|
-
def _modus_provider(*, authenticate: bool = True) -> Any:
|
|
33
|
+
def _modus_provider(*, authenticate: bool = True, main_session: bool = False) -> Any:
|
|
34
34
|
# Keep ModUs optional at import time so existing platform commands remain
|
|
35
35
|
# usable while the provider is installed or upgraded independently.
|
|
36
36
|
from .modus import Modus
|
|
37
|
-
return Modus(authenticate=authenticate)
|
|
37
|
+
return Modus(authenticate=authenticate, main_session=main_session)
|
|
38
38
|
|
|
39
39
|
|
|
40
40
|
def _parser(**kwargs: Any) -> argparse.ArgumentParser:
|
|
@@ -88,6 +88,19 @@ def _read_leaf(parent: argparse._SubParsersAction, name: str, summary: str, **de
|
|
|
88
88
|
return leaf
|
|
89
89
|
|
|
90
90
|
|
|
91
|
+
def _modus_server_flag(parser: argparse.ArgumentParser) -> None:
|
|
92
|
+
"""Select the WoW Build sent as ``server`` and ``X-Server-Type``.
|
|
93
|
+
|
|
94
|
+
``None`` deliberately means "use the main client's current Build". Build
|
|
95
|
+
zero is valid, so this is an integer flag rather than ``_positive``.
|
|
96
|
+
``--build`` is a concise alias; both names write the same input field.
|
|
97
|
+
"""
|
|
98
|
+
parser.add_argument(
|
|
99
|
+
"--server-type", "--build", dest="server_type", type=int, default=None,
|
|
100
|
+
metavar="BUILD_ID", help="WoW Build id; omitted follows the local ModUs current Build (0-4).",
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
91
104
|
def _newbee_relationship_tree(parent: argparse.ArgumentParser, resource: str, label: str) -> None:
|
|
92
105
|
authors = parent.add_parser("co-author", help="Search, list, or replace %s co-authors" % label).add_subparsers(dest="author_action", required=True)
|
|
93
106
|
leaf = _read_leaf(authors, "search", "Search current co-author candidates.", platform="newbee", resource=resource, action="co-author-search"); leaf.add_argument("--keyword", required=True)
|
|
@@ -236,6 +249,7 @@ def _modus_tree(platforms: argparse._SubParsersAction) -> None:
|
|
|
236
249
|
groups = root.add_subparsers(dest="resource_command", required=True)
|
|
237
250
|
session = groups.add_parser("session", help="Local authentication diagnostics").add_subparsers(dest="action_command", required=True)
|
|
238
251
|
_read_leaf(session, "doctor", "Check the local ModUs.Creator token store, DPAPI decryption, and authenticated API readiness without exposing credentials.", platform="modus", resource="session", action="doctor")
|
|
252
|
+
_read_leaf(groups, "builds", "List the fixed ModUs WoW Build ids, codes, names, and the locally selected current Build.", platform="modus", resource="builds", action="list")
|
|
239
253
|
|
|
240
254
|
account = groups.add_parser("account", help="ModUs author account and statistics").add_subparsers(dest="action_command", required=True)
|
|
241
255
|
_read_leaf(account, "info", "Read the current ModUs account capability flags.", platform="modus", resource="account", action="info")
|
|
@@ -245,13 +259,13 @@ def _modus_tree(platforms: argparse._SubParsersAction) -> None:
|
|
|
245
259
|
addon = groups.add_parser("addon", help="ModUs addon discovery and history APIs").add_subparsers(dest="action_command", required=True)
|
|
246
260
|
leaf = _read_leaf(addon, "info", "Resolve addon directories to project records.", platform="modus", resource="addon", action="info")
|
|
247
261
|
leaf.add_argument("--directory", dest="directories", action="append", required=True)
|
|
248
|
-
leaf
|
|
262
|
+
_modus_server_flag(leaf)
|
|
249
263
|
leaf = _read_leaf(addon, "project-info", "Resolve addon project IDs to names.", platform="modus", resource="addon", action="project-info")
|
|
250
264
|
leaf.add_argument("--project-id", dest="project_ids", action="append", type=_positive, required=True)
|
|
251
|
-
leaf
|
|
265
|
+
_modus_server_flag(leaf)
|
|
252
266
|
leaf = _read_leaf(addon, "history", "Read addon project version history.", platform="modus", resource="addon", action="history")
|
|
253
267
|
leaf.add_argument("--project-id", type=_positive, required=True)
|
|
254
|
-
leaf
|
|
268
|
+
_modus_server_flag(leaf)
|
|
255
269
|
_page_flags(leaf)
|
|
256
270
|
|
|
257
271
|
options = groups.add_parser("options", help="Read ModUs dynamic choices").add_subparsers(dest="option_action", required=True)
|
|
@@ -259,6 +273,9 @@ def _modus_tree(platforms: argparse._SubParsersAction) -> None:
|
|
|
259
273
|
game_versions = _read_leaf(options, "game-versions", "List supported ModUs game versions.", platform="modus", resource="options", action="game-versions")
|
|
260
274
|
game_versions.add_argument("--key", dest="keys", action="append", required=True, help="Request one game config key; repeatable.")
|
|
261
275
|
_read_leaf(options, "subscription-tiers", "List author subscription tiers returned by ModUs.", platform="modus", resource="options", action="subscription-tiers")
|
|
276
|
+
_read_leaf(options, "config-tags", "List configuration-share tag choices from the official ModUs client data.", platform="modus", resource="options", action="config-tags")
|
|
277
|
+
_read_leaf(options, "wa-tags", "List string tag choices from the official ModUs client data.", platform="modus", resource="options", action="wa-tags")
|
|
278
|
+
_read_leaf(options, "wa-support-addons", "List applicable-addon choices from the official ModUs client data.", platform="modus", resource="options", action="wa-support-addons")
|
|
262
279
|
|
|
263
280
|
project = groups.add_parser("project", help="ModUs plugin project records").add_subparsers(dest="action_command", required=True)
|
|
264
281
|
for action, text in (
|
|
@@ -282,9 +299,23 @@ def _modus_tree(platforms: argparse._SubParsersAction) -> None:
|
|
|
282
299
|
("delete", "Delete one explicitly confirmed ModUs plugin release."),
|
|
283
300
|
):
|
|
284
301
|
_write_leaf(plugin, "modus", "plugin", action, text)
|
|
285
|
-
leaf = _read_leaf(plugin, "list", "List releases for one ModUs plugin project.", platform="modus", resource="plugin", action="list"); leaf.add_argument("--project-id", type=_positive, required=True); _page_flags(leaf)
|
|
286
|
-
leaf = _read_leaf(plugin, "get", "Read one ModUs plugin release detail.", platform="modus", resource="plugin", action="get"); leaf.add_argument("--project-id", type=_positive, required=True); leaf.add_argument("--file-id", type=_positive, required=True)
|
|
287
|
-
leaf = _read_leaf(plugin, "versions", "List releases for one ModUs plugin project.", platform="modus", resource="plugin", action="versions"); leaf.add_argument("--project-id", type=_positive, required=True); _page_flags(leaf)
|
|
302
|
+
leaf = _read_leaf(plugin, "list", "List releases for one ModUs plugin project.", platform="modus", resource="plugin", action="list"); leaf.add_argument("--project-id", type=_positive, required=True); _modus_server_flag(leaf); _page_flags(leaf)
|
|
303
|
+
leaf = _read_leaf(plugin, "get", "Read one ModUs plugin release detail.", platform="modus", resource="plugin", action="get"); leaf.add_argument("--project-id", type=_positive, required=True); leaf.add_argument("--file-id", type=_positive, required=True); _modus_server_flag(leaf)
|
|
304
|
+
leaf = _read_leaf(plugin, "versions", "List releases for one ModUs plugin project.", platform="modus", resource="plugin", action="versions"); leaf.add_argument("--project-id", type=_positive, required=True); _modus_server_flag(leaf); _page_flags(leaf)
|
|
305
|
+
|
|
306
|
+
config = groups.add_parser("config", help="ModUs main-client configuration shares and backups").add_subparsers(dest="action_command", required=True)
|
|
307
|
+
for action, text in (("create", "Create a configuration share from an existing ModUs cloud backup."), ("update", "Update a configuration share."), ("edit", "Edit a configuration share."), ("delete", "Delete one explicitly confirmed configuration share."), ("backup-edit", "Rename an existing ModUs cloud backup."), ("backup-delete", "Delete one explicitly confirmed ModUs cloud backup.")):
|
|
308
|
+
_write_leaf(config, "modus", "config", action, text)
|
|
309
|
+
leaf = _read_leaf(config, "list", "List ModUs configuration shares.", platform="modus", resource="config", action="list"); _modus_server_flag(leaf); leaf.add_argument("--page-num", type=int); leaf.add_argument("--page-size", type=int); leaf.add_argument("--keyword"); leaf.add_argument("--status", type=int); leaf.add_argument("--share-type", type=int); leaf.add_argument("--order-by"); leaf.add_argument("--mine", action=argparse.BooleanOptionalAction); leaf.add_argument("--is-public", type=int, choices=(0, 1)); leaf.add_argument("--is-paid", type=int, choices=(0, 1)); leaf.add_argument("--tags")
|
|
310
|
+
leaf = _read_leaf(config, "get", "Read a ModUs configuration share detail.", platform="modus", resource="config", action="get"); leaf.add_argument("--share-id", required=True); _modus_server_flag(leaf)
|
|
311
|
+
leaf = _read_leaf(config, "backups", "List existing ModUs cloud backups.", platform="modus", resource="config", action="backups"); _modus_server_flag(leaf)
|
|
312
|
+
leaf = _read_leaf(config, "backup-get", "Read one ModUs cloud backup detail.", platform="modus", resource="config", action="backup-get"); leaf.add_argument("--backup-id", type=_positive, required=True); _modus_server_flag(leaf)
|
|
313
|
+
|
|
314
|
+
wa = groups.add_parser("wa", help="ModUs main-client string articles").add_subparsers(dest="action_command", required=True)
|
|
315
|
+
for action, text in (("create", "Create a ModUs string article."), ("update", "Update a ModUs string article."), ("edit", "Edit a ModUs string article."), ("delete", "Delete one explicitly confirmed string article."), ("version-publish", "Publish a new string article version."), ("version-delete", "Delete one explicitly confirmed historical string version.")):
|
|
316
|
+
_write_leaf(wa, "modus", "wa", action, text)
|
|
317
|
+
leaf = _read_leaf(wa, "list", "List ModUs string articles.", platform="modus", resource="wa", action="list"); _modus_server_flag(leaf); leaf.add_argument("--page-num", type=int); leaf.add_argument("--page-size", type=int); leaf.add_argument("--keyword"); leaf.add_argument("--status", type=int); leaf.add_argument("--order-by"); leaf.add_argument("--mine", action=argparse.BooleanOptionalAction); leaf.add_argument("--support-addon"); leaf.add_argument("--tags"); leaf.add_argument("--is-paid", type=int, choices=(0, 1))
|
|
318
|
+
leaf = _read_leaf(wa, "get", "Read one ModUs string article detail.", platform="modus", resource="wa", action="get"); leaf.add_argument("--import-id", required=True); _modus_server_flag(leaf)
|
|
288
319
|
|
|
289
320
|
|
|
290
321
|
def build_parser() -> argparse.ArgumentParser:
|
|
@@ -347,7 +378,7 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
|
347
378
|
if args.dry_run:
|
|
348
379
|
write_output(platform, operation, _dry_run_data(doc, schema.name), dry_run=True)
|
|
349
380
|
return 0
|
|
350
|
-
provider = NewBee() if platform == "newbee" else (DD() if platform == "dd" else (Blackbox() if platform == "blackbox" else (CurseForge() if platform == "curseforge" else _modus_provider())))
|
|
381
|
+
provider = NewBee() if platform == "newbee" else (DD() if platform == "dd" else (Blackbox() if platform == "blackbox" else (CurseForge() if platform == "curseforge" else _modus_provider(main_session=resource in ("config", "wa")))))
|
|
351
382
|
try:
|
|
352
383
|
if platform == "dd":
|
|
353
384
|
data = provider.execute_write(resource, action, doc, getattr(args, "session", None))
|
|
@@ -360,10 +391,15 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
|
360
391
|
write_output(platform, operation, data)
|
|
361
392
|
return 0
|
|
362
393
|
modus_doctor = platform == "modus" and resource == "session" and action == "doctor"
|
|
363
|
-
provider = NewBee() if platform == "newbee" else (DD() if platform == "dd" else (Blackbox() if platform == "blackbox" else (CurseForge() if platform == "curseforge" else _modus_provider(authenticate=not modus_doctor))))
|
|
394
|
+
provider = NewBee() if platform == "newbee" else (DD() if platform == "dd" else (Blackbox() if platform == "blackbox" else (CurseForge() if platform == "curseforge" else _modus_provider(authenticate=not modus_doctor, main_session=resource in ("addon", "plugin", "config", "wa", "builds")))))
|
|
364
395
|
try:
|
|
365
396
|
if platform == "dd":
|
|
366
397
|
data = provider.execute_read(resource, action, args, getattr(args, "session", None))
|
|
398
|
+
elif platform == "modus" and resource == "builds" and action == "list":
|
|
399
|
+
# Build discovery is local and deterministic; it must not
|
|
400
|
+
# accidentally hit a platform endpoint or confuse platform
|
|
401
|
+
# selection with the Build id sent on CRUD requests.
|
|
402
|
+
data = provider.builds()
|
|
367
403
|
else:
|
|
368
404
|
data = provider.execute_read(resource, action, args)
|
|
369
405
|
finally:
|
|
@@ -25,12 +25,22 @@ from typing import Any, Dict, Mapping, Optional
|
|
|
25
25
|
from .errors import FuploadError, ValidationError, redact
|
|
26
26
|
from .state_machine import COMPLETE, ProjectStateMachine
|
|
27
27
|
from .modus_zip import parse_modus_zip
|
|
28
|
+
from .transport import json_request
|
|
28
29
|
|
|
29
30
|
|
|
30
31
|
API_BASE = "https://app.modus.cool/api/"
|
|
32
|
+
STATIC_API_BASE = "https://cdn.modus.cool/modus/client_static_api/"
|
|
31
33
|
TOKEN_PATH = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData/Local")) / "ModUs.Creator" / "auth" / "token.dat"
|
|
32
34
|
TOKEN_ENTROPY = b"ModUs.Creator.TokenStore.v1"
|
|
35
|
+
MODUS_APPDATA = Path(os.environ.get("APPDATA", Path.home() / "AppData/Roaming")) / "modus"
|
|
33
36
|
MAX_PACKAGE_BYTES = 200 * 1024 * 1024
|
|
37
|
+
MODUS_BUILDS = (
|
|
38
|
+
{"id": 0, "code": "retail", "name": "至暗之夜", "label": "正式服-至暗之夜"},
|
|
39
|
+
{"id": 1, "code": "classic_era", "name": "经典旧世", "label": "怀旧服-经典旧世"},
|
|
40
|
+
{"id": 2, "code": "classic", "name": "熊猫人之谜", "label": "怀旧服-熊猫人之谜"},
|
|
41
|
+
{"id": 3, "code": "classic_titan", "name": "泰坦重铸", "label": "时光服-泰坦重铸"},
|
|
42
|
+
{"id": 4, "code": "anniversary", "name": "燃烧的远征", "label": "周年服-燃烧的远征"},
|
|
43
|
+
)
|
|
34
44
|
_SECRET_KEYS = {"token", "access_token", "authorization", "cookie", "signedurl", "signed_url", "upload_url"}
|
|
35
45
|
_WIRE_NAMES = {
|
|
36
46
|
"project_id": "projectId", "file_id": "fileId", "alt_name": "altName",
|
|
@@ -158,6 +168,16 @@ def _positive_id(value: Any, *, field: str) -> int:
|
|
|
158
168
|
return result
|
|
159
169
|
|
|
160
170
|
|
|
171
|
+
def _resource_id(value: Any, *, field: str) -> str:
|
|
172
|
+
"""ModUs share/import IDs are opaque positive decimal strings."""
|
|
173
|
+
if isinstance(value, bool) or value is None:
|
|
174
|
+
raise ValidationError("%s must be a non-empty identifier" % field, path=field)
|
|
175
|
+
text = str(value).strip()
|
|
176
|
+
if not text or (text.isdigit() and int(text) <= 0):
|
|
177
|
+
raise ValidationError("%s must be a non-empty identifier" % field, path=field)
|
|
178
|
+
return text
|
|
179
|
+
|
|
180
|
+
|
|
161
181
|
def _supported_game_versions(value: Any) -> list[Dict[str, str]]:
|
|
162
182
|
"""Map CLI aliases to Creator's anonymous {gameVersion, server} objects.
|
|
163
183
|
|
|
@@ -407,6 +427,94 @@ def load_token(path: Optional[Path] = None) -> str:
|
|
|
407
427
|
return token
|
|
408
428
|
|
|
409
429
|
|
|
430
|
+
def load_main_session(root: Optional[Path] = None) -> Dict[str, str]:
|
|
431
|
+
"""Recover the main ModUs renderer session from Chromium local storage.
|
|
432
|
+
|
|
433
|
+
Chromium LevelDB values are length-prefixed and may be locked by the
|
|
434
|
+
running client. We therefore scan shared-read bytes for JSON fragments,
|
|
435
|
+
accepting only the known persisted token/device keys and never returning
|
|
436
|
+
unrelated storage values.
|
|
437
|
+
"""
|
|
438
|
+
base = root or MODUS_APPDATA
|
|
439
|
+
leveldb = base / "Local Storage" / "leveldb"
|
|
440
|
+
if not leveldb.is_dir():
|
|
441
|
+
raise FuploadError("ModUs main-client local storage was not found", kind="authentication_error")
|
|
442
|
+
token = ""
|
|
443
|
+
device = ""
|
|
444
|
+
for path in sorted(leveldb.iterdir()):
|
|
445
|
+
if not path.is_file() or path.name in {"LOCK", "LOG", "LOG.old", "CURRENT", "MANIFEST-000001"}:
|
|
446
|
+
continue
|
|
447
|
+
try:
|
|
448
|
+
with path.open("rb", buffering=0) as handle:
|
|
449
|
+
raw = handle.read()
|
|
450
|
+
except OSError:
|
|
451
|
+
continue
|
|
452
|
+
text = raw.decode("utf-8", errors="ignore")
|
|
453
|
+
# Pinia persistence is JSON, but the LevelDB record can contain a
|
|
454
|
+
# prefix/suffix. Try JSON objects first, then bounded key/value pairs.
|
|
455
|
+
candidates = [text]
|
|
456
|
+
# Persisted Pinia user JSON contains nested wallet objects, so a
|
|
457
|
+
# shallow-brace regex is insufficient. Extract only scalar credential
|
|
458
|
+
# fields from the record instead of attempting to parse the whole log.
|
|
459
|
+
import re
|
|
460
|
+
token_match = re.search(r'"(?:token|accessToken|access_token)"\s*:\s*"([^"\\]*(?:\\.[^"\\]*)*)"', text)
|
|
461
|
+
device_match = re.search(r'"(?:deviceId|device_id)"\s*:\s*"([^"\\]*)"', text)
|
|
462
|
+
if token_match and not token:
|
|
463
|
+
token = bytes(token_match.group(1), "utf-8").decode("unicode_escape")
|
|
464
|
+
if device_match and not device:
|
|
465
|
+
device = device_match.group(1)
|
|
466
|
+
for candidate in candidates:
|
|
467
|
+
try:
|
|
468
|
+
parsed = json.loads(candidate)
|
|
469
|
+
except (TypeError, ValueError):
|
|
470
|
+
continue
|
|
471
|
+
stack = [parsed]
|
|
472
|
+
while stack:
|
|
473
|
+
item = stack.pop()
|
|
474
|
+
if isinstance(item, Mapping):
|
|
475
|
+
for key, value in item.items():
|
|
476
|
+
normalized = str(key).lower()
|
|
477
|
+
if isinstance(value, str) and value.strip():
|
|
478
|
+
if normalized in {"token", "accesstoken", "access_token"} and not token:
|
|
479
|
+
token = value.strip()
|
|
480
|
+
elif normalized in {"deviceid", "device_id"} and not device:
|
|
481
|
+
device = value.strip()
|
|
482
|
+
elif isinstance(value, (Mapping, list)):
|
|
483
|
+
stack.append(value)
|
|
484
|
+
elif isinstance(item, list):
|
|
485
|
+
stack.extend(item)
|
|
486
|
+
if token and device:
|
|
487
|
+
break
|
|
488
|
+
if not token:
|
|
489
|
+
raise FuploadError("ModUs main-client login token was not found", kind="authentication_error")
|
|
490
|
+
return {"token": token, **({"device_id": device} if device else {})}
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def load_current_build(root: Optional[Path] = None) -> Optional[int]:
|
|
494
|
+
"""Read the persisted main-client currentGameWow id when available."""
|
|
495
|
+
base = root or MODUS_APPDATA
|
|
496
|
+
leveldb = base / "Local Storage" / "leveldb"
|
|
497
|
+
if not leveldb.is_dir():
|
|
498
|
+
return None
|
|
499
|
+
import re
|
|
500
|
+
for path in sorted(leveldb.iterdir()):
|
|
501
|
+
if not path.is_file() or path.name in {"LOCK", "LOG", "LOG.old", "CURRENT", "MANIFEST-000001"}:
|
|
502
|
+
continue
|
|
503
|
+
try:
|
|
504
|
+
with path.open("rb", buffering=0) as handle:
|
|
505
|
+
text = handle.read().decode("utf-8", errors="ignore")
|
|
506
|
+
except OSError:
|
|
507
|
+
continue
|
|
508
|
+
# Pinia persistence may be split across records; accept only the scalar id.
|
|
509
|
+
for pattern in (r'currentGameWow[^{}]{0,120}?"id"\s*:\s*(\d+)', r'"currentGameWowId"\s*:\s*(\d+)'):
|
|
510
|
+
match = re.search(pattern, text)
|
|
511
|
+
if match:
|
|
512
|
+
value = int(match.group(1))
|
|
513
|
+
if 0 <= value <= 4:
|
|
514
|
+
return value
|
|
515
|
+
return None
|
|
516
|
+
|
|
517
|
+
|
|
410
518
|
def _unwrap(payload: Any) -> Any:
|
|
411
519
|
if isinstance(payload, Mapping) and "data" in payload:
|
|
412
520
|
return payload["data"]
|
|
@@ -423,12 +531,28 @@ class ModUs:
|
|
|
423
531
|
base_url: str = API_BASE,
|
|
424
532
|
timeout: int = 60,
|
|
425
533
|
token_path: Optional[Path] = None,
|
|
534
|
+
device_id: Optional[str] = None,
|
|
535
|
+
main_session: bool = False,
|
|
426
536
|
authenticate: bool = True,
|
|
427
537
|
) -> None:
|
|
428
538
|
self.base_url = base_url.rstrip("/") + "/"
|
|
429
539
|
self.timeout = timeout
|
|
430
540
|
self.token_path = token_path or TOKEN_PATH
|
|
431
|
-
self.
|
|
541
|
+
self.device_id = device_id
|
|
542
|
+
self.main_session = main_session
|
|
543
|
+
self.current_build = load_current_build() if main_session else None
|
|
544
|
+
self.authorization_scheme = ""
|
|
545
|
+
if token is not None:
|
|
546
|
+
self.token = token
|
|
547
|
+
elif authenticate:
|
|
548
|
+
if main_session:
|
|
549
|
+
session = load_main_session()
|
|
550
|
+
self.token, self.device_id = session["token"], session.get("device_id")
|
|
551
|
+
self.authorization_scheme = ""
|
|
552
|
+
else:
|
|
553
|
+
self.token = load_token(self.token_path)
|
|
554
|
+
else:
|
|
555
|
+
self.token = ""
|
|
432
556
|
|
|
433
557
|
def _url(self, path: str) -> str:
|
|
434
558
|
return urllib.parse.urljoin(self.base_url, path.lstrip("/"))
|
|
@@ -454,7 +578,10 @@ class ModUs:
|
|
|
454
578
|
def _request(self, method: str, path: str, body: Any = None, *, headers: Optional[Mapping[str, str]] = None) -> Any:
|
|
455
579
|
url = self._url(path)
|
|
456
580
|
stage = self._request_stage(method, path)
|
|
457
|
-
|
|
581
|
+
authorization = self.token if self.authorization_scheme == "" else self.authorization_scheme + self.token
|
|
582
|
+
request_headers = {"Accept": "application/json", "Authorization": authorization}
|
|
583
|
+
if self.device_id:
|
|
584
|
+
request_headers["X-Device-Id"] = self.device_id
|
|
458
585
|
if headers:
|
|
459
586
|
request_headers.update(headers)
|
|
460
587
|
data = None
|
|
@@ -576,31 +703,57 @@ class ModUs:
|
|
|
576
703
|
def user_info(self) -> Any:
|
|
577
704
|
return _safe(_unwrap(self._request("GET", "system/user/getInfo")))
|
|
578
705
|
|
|
706
|
+
def builds(self) -> Any:
|
|
707
|
+
"""Return the fixed WoW Build choices used by the ModUs main client."""
|
|
708
|
+
current = self.current_build
|
|
709
|
+
return {"current": current, "builds": [dict(item) for item in MODUS_BUILDS]}
|
|
710
|
+
|
|
711
|
+
def _build(self, value: Optional[int]) -> int:
|
|
712
|
+
selected = self.current_build if value is None else value
|
|
713
|
+
if selected is None:
|
|
714
|
+
# The desktop wrapper uses getCurrentWow?.id || 0.
|
|
715
|
+
selected = 0
|
|
716
|
+
selected = int(selected)
|
|
717
|
+
if selected < 0 or selected > 4:
|
|
718
|
+
raise ValidationError("server_type must be a known ModUs Build id", path="server_type")
|
|
719
|
+
return selected
|
|
720
|
+
|
|
579
721
|
def active_subscription_count(self) -> Any:
|
|
580
722
|
return _safe(_unwrap(self._request("GET", "user/author/subscription/active/count")))
|
|
581
723
|
|
|
582
724
|
def project_statistics(self) -> Any:
|
|
583
725
|
return _safe(_unwrap(self._request("GET", "game/data/author/project/statistics")))
|
|
584
726
|
|
|
585
|
-
def addon_info(self, directories: Any, *, server_type: int =
|
|
727
|
+
def addon_info(self, directories: Any, *, server_type: Optional[int] = None) -> Any:
|
|
586
728
|
values = directories if isinstance(directories, (list, tuple)) else [directories]
|
|
587
729
|
body = {"pluginList": [str(value) for value in values]}
|
|
588
|
-
return _safe(_unwrap(self._request("POST", "plugin/list/info", body, headers={"X-Server-Type": str(
|
|
730
|
+
return _safe(_unwrap(self._request("POST", "plugin/list/info", body, headers={"X-Server-Type": str(self._build(server_type))})))
|
|
589
731
|
|
|
590
|
-
def addon_project_info(self, project_ids: Any, *, server_type: int =
|
|
732
|
+
def addon_project_info(self, project_ids: Any, *, server_type: Optional[int] = None) -> Any:
|
|
591
733
|
values = project_ids if isinstance(project_ids, (list, tuple)) else [project_ids]
|
|
592
734
|
body = {"projectIds": [int(value) for value in values]}
|
|
593
|
-
return _safe(_unwrap(self._request("POST", "plugin/list/detail", body, headers={"X-Server-Type": str(
|
|
735
|
+
return _safe(_unwrap(self._request("POST", "plugin/list/detail", body, headers={"X-Server-Type": str(self._build(server_type))})))
|
|
594
736
|
|
|
595
|
-
def addon_history(self, project_id: int, *, page_num: int = 1, page_size: int = 5, server_type: int =
|
|
737
|
+
def addon_history(self, project_id: int, *, page_num: int = 1, page_size: int = 5, server_type: Optional[int] = None) -> Any:
|
|
596
738
|
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(
|
|
739
|
+
return _safe(_unwrap(self._request("POST", "plugin/project/history", body, headers={"X-Server-Type": str(self._build(server_type))})))
|
|
598
740
|
|
|
599
741
|
def project_dependencies(self, query: Any) -> Any:
|
|
600
742
|
body = _dependency_query_wire(query)
|
|
601
743
|
return _safe(_unwrap(self._request("POST", "game/data/author/project/dependency/query", body)))
|
|
602
744
|
|
|
603
745
|
def options(self, action: str, *, keys: Optional[Any] = None) -> Any:
|
|
746
|
+
static_files = {
|
|
747
|
+
"config-tags": "share_tags.json",
|
|
748
|
+
"wa-tags": "imports_tags.json",
|
|
749
|
+
"wa-support-addons": "imports_support_addons.json",
|
|
750
|
+
}
|
|
751
|
+
if action in static_files:
|
|
752
|
+
url = urllib.parse.urljoin(STATIC_API_BASE, static_files[action])
|
|
753
|
+
payload = json_request(url)
|
|
754
|
+
if isinstance(payload, Mapping) and "rows" in payload:
|
|
755
|
+
payload = payload["rows"]
|
|
756
|
+
return _safe(_unwrap(payload))
|
|
604
757
|
routes = {
|
|
605
758
|
# These paths are the routes used by ModUs.Creator's ApiService.
|
|
606
759
|
"categories": "plugin/list/Categories",
|
|
@@ -618,6 +771,123 @@ class ModUs:
|
|
|
618
771
|
raise ValidationError("unsupported ModUs options operation")
|
|
619
772
|
return _safe(_unwrap(self._request("GET", routes[action])))
|
|
620
773
|
|
|
774
|
+
# Main ModUs client: configuration shares and string articles.
|
|
775
|
+
def cloud_backups(self, *, server_type: Optional[int] = None) -> Any:
|
|
776
|
+
return _safe(_unwrap(self._request("GET", "system/user/backup/list", headers={"X-Server-Type": str(self._build(server_type))})))
|
|
777
|
+
|
|
778
|
+
def cloud_backup_detail(self, backup_id: int, *, server_type: Optional[int] = None) -> Any:
|
|
779
|
+
return _safe(_unwrap(self._request("GET", "system/user/backup/detail/%s" % _positive_id(backup_id, field="backup_id"), headers={"X-Server-Type": str(self._build(server_type))})))
|
|
780
|
+
|
|
781
|
+
def cloud_backup_update(self, doc: Mapping[str, Any], *, server_type: Optional[int] = None) -> Any:
|
|
782
|
+
body = {"id": _positive_id(doc["backup_id"], field="backup_id"), "backupName": doc["backup_name"]}
|
|
783
|
+
return _safe(_unwrap(self._request("POST", "system/user/backup/update", body, headers={"X-Server-Type": str(self._build(server_type))})))
|
|
784
|
+
|
|
785
|
+
def cloud_backup_delete(self, backup_id: int, *, server_type: Optional[int] = None) -> Any:
|
|
786
|
+
return _safe(_unwrap(self._request("DELETE", "system/user/backup/delete/%s" % _positive_id(backup_id, field="backup_id"), headers={"X-Server-Type": str(self._build(server_type))})))
|
|
787
|
+
|
|
788
|
+
@staticmethod
|
|
789
|
+
def _share_wire(doc: Mapping[str, Any]) -> Dict[str, Any]:
|
|
790
|
+
aliases = {"share_id": "id", "addons_id": "addonsId", "backup_id": "backupId", "account_name": "accountName", "content_text": "contentText", "image_url": "imageUrl", "is_paid": "isPaid", "is_public": "isPublic", "share_type": "shareType", "exclude_wtf": "excludeWtf", "role_name": "roleName", "required_tier_id": "requiredTierId", "sub_type": "subType", "synchronization_type": "synchronizationType"}
|
|
791
|
+
allowed = {"id", "addonsId", "backupId", "accountName", "content", "contentText", "imageUrl", "isPaid", "isPublic", "price", "shareType", "tags", "title", "excludeWtf", "roleName", "requiredTierId", "subType", "platform", "synchronizationType"}
|
|
792
|
+
result = {}
|
|
793
|
+
for key, value in doc.items():
|
|
794
|
+
wire = aliases.get(key, key)
|
|
795
|
+
if wire in allowed and value is not None:
|
|
796
|
+
result[wire] = value
|
|
797
|
+
result.setdefault("platform", 1)
|
|
798
|
+
exclude = result.get("excludeWtf", 0)
|
|
799
|
+
result["excludeWtf"] = 1 if str(exclude).strip().lower() in {"1", "true"} else 0
|
|
800
|
+
return result
|
|
801
|
+
|
|
802
|
+
def share_list(self, body: Optional[Mapping[str, Any]] = None, *, server_type: Optional[int] = None) -> Any:
|
|
803
|
+
source = body or {}
|
|
804
|
+
selected_build = self._build(source.get("server", server_type))
|
|
805
|
+
mine = source.get("mine")
|
|
806
|
+
share_type = source.get("share_type")
|
|
807
|
+
wire = {
|
|
808
|
+
"pageNum": int(source.get("page_num") or 1),
|
|
809
|
+
"pageSize": int(source.get("page_size") or 20),
|
|
810
|
+
"server": selected_build,
|
|
811
|
+
"mine": False if mine is None else bool(mine),
|
|
812
|
+
"shareType": 0 if share_type is None else share_type,
|
|
813
|
+
}
|
|
814
|
+
platform = source.get("platform")
|
|
815
|
+
wire["platform"] = int(platform) if isinstance(platform, (int, float)) and not isinstance(platform, bool) else 0
|
|
816
|
+
for key in ("keyword", "status", "tags", "order_by", "is_public", "is_paid"):
|
|
817
|
+
if key in source and source[key] is not None:
|
|
818
|
+
wire[{"order_by": "orderBy", "is_public": "isPublic", "is_paid": "isPaid"}.get(key, key)] = source[key]
|
|
819
|
+
return _safe(_unwrap(self._request("POST", "system/user/share/list", wire, headers={"X-Server-Type": str(selected_build)})))
|
|
820
|
+
|
|
821
|
+
def share_detail(self, share_ids: Any, *, server_type: Optional[int] = None) -> Any:
|
|
822
|
+
ids = share_ids if isinstance(share_ids, list) else [share_ids]
|
|
823
|
+
return _safe(_unwrap(self._request("POST", "system/user/share/detail", {"shareIds": [_resource_id(x, field="share_id") for x in ids]}, headers={"X-Server-Type": str(self._build(server_type))})))
|
|
824
|
+
|
|
825
|
+
def share_create(self, doc: Mapping[str, Any], *, server_type: Optional[int] = None) -> Any:
|
|
826
|
+
wire = self._share_wire(doc)
|
|
827
|
+
wire.setdefault("synchronizationType", 3 if wire["platform"] == 3 else 1)
|
|
828
|
+
return _safe(_unwrap(self._request("POST", "system/user/share/create", wire, headers={"X-Server-Type": str(self._build(server_type))})))
|
|
829
|
+
|
|
830
|
+
def share_update(self, doc: Mapping[str, Any], *, server_type: Optional[int] = None) -> Any:
|
|
831
|
+
return _safe(_unwrap(self._request("PUT", "system/user/share/update", self._share_wire(doc), headers={"X-Server-Type": str(self._build(server_type))})))
|
|
832
|
+
|
|
833
|
+
def share_delete(self, share_id: Any, *, server_type: Optional[int] = None) -> Any:
|
|
834
|
+
return _safe(_unwrap(self._request("DELETE", "system/user/share/delete/%s" % urllib.parse.quote(_resource_id(share_id, field="share_id"), safe=""), headers={"X-Server-Type": str(self._build(server_type))})))
|
|
835
|
+
|
|
836
|
+
@staticmethod
|
|
837
|
+
def _import_wire(doc: Mapping[str, Any]) -> Dict[str, Any]:
|
|
838
|
+
aliases = {"import_id": "id", "code_text": "codeText", "addons_id": "addonsId", "content_text": "contentText", "file_path": "filePath", "image_url": "imageUrl", "is_paid": "isPaid", "is_public": "isPublic", "share_type": "shareType", "support_addon": "supportAddon", "required_tier_id": "requiredTierId", "sub_type": "subType", "synchronization_type": "synchronizationType"}
|
|
839
|
+
allowed = {"id", "codeText", "content", "addonsId", "contentText", "filePath", "imageUrl", "isPaid", "isPublic", "price", "shareType", "supportAddon", "tags", "title", "version", "requiredTierId", "subType", "platform", "synchronizationType"}
|
|
840
|
+
result = {aliases.get(key, key): value for key, value in doc.items() if aliases.get(key, key) in allowed and value is not None}
|
|
841
|
+
result.setdefault("platform", 1)
|
|
842
|
+
result.setdefault("synchronizationType", 3 if result["platform"] == 3 else 1)
|
|
843
|
+
return result
|
|
844
|
+
|
|
845
|
+
def import_list(self, body: Optional[Mapping[str, Any]] = None, *, server_type: Optional[int] = None) -> Any:
|
|
846
|
+
source = body or {}
|
|
847
|
+
selected_build = self._build(source.get("server", server_type))
|
|
848
|
+
mine = source.get("mine")
|
|
849
|
+
status = source.get("status")
|
|
850
|
+
wire = {
|
|
851
|
+
"pageNum": int(source.get("page_num") or 1),
|
|
852
|
+
"pageSize": int(source.get("page_size") or 10),
|
|
853
|
+
"server": selected_build,
|
|
854
|
+
"mine": False if mine is None else bool(mine),
|
|
855
|
+
"status": 1 if status is None else status,
|
|
856
|
+
}
|
|
857
|
+
platform = source.get("platform")
|
|
858
|
+
wire["platform"] = int(platform) if isinstance(platform, (int, float)) and not isinstance(platform, bool) else 0
|
|
859
|
+
for key in ("keyword", "support_addon", "tags", "is_paid", "order_by"):
|
|
860
|
+
if key in source and source[key] is not None:
|
|
861
|
+
wire[{"support_addon": "supportAddon", "is_paid": "isPaid", "order_by": "orderBy"}.get(key, key)] = source[key]
|
|
862
|
+
return _safe(_unwrap(self._request("POST", "system/user/import/list", wire, headers={"X-Server-Type": str(selected_build)})))
|
|
863
|
+
|
|
864
|
+
def import_detail(self, import_ids: Any, *, server_type: Optional[int] = None) -> Any:
|
|
865
|
+
ids = import_ids if isinstance(import_ids, list) else [import_ids]
|
|
866
|
+
return _safe(_unwrap(self._request("POST", "system/user/import/detail", {"importIds": [_resource_id(x, field="import_id") for x in ids]}, headers={"X-Server-Type": str(self._build(server_type))})))
|
|
867
|
+
|
|
868
|
+
def import_create(self, doc: Mapping[str, Any], *, server_type: Optional[int] = None) -> Any:
|
|
869
|
+
return _safe(_unwrap(self._request("POST", "system/user/import/create", self._import_wire(doc), headers={"X-Server-Type": str(self._build(server_type))})))
|
|
870
|
+
|
|
871
|
+
def import_update(self, doc: Mapping[str, Any], *, server_type: Optional[int] = None) -> Any:
|
|
872
|
+
return _safe(_unwrap(self._request("POST", "system/user/import/update", self._import_wire(doc), headers={"X-Server-Type": str(self._build(server_type))})))
|
|
873
|
+
|
|
874
|
+
def import_delete(self, import_id: Any, *, server_type: Optional[int] = None) -> Any:
|
|
875
|
+
return _safe(_unwrap(self._request("DELETE", "system/user/import/delete/%s" % urllib.parse.quote(_resource_id(import_id, field="import_id"), safe=""), headers={"X-Server-Type": str(self._build(server_type))})))
|
|
876
|
+
|
|
877
|
+
def import_version_publish(self, doc: Mapping[str, Any], *, server_type: Optional[int] = None) -> Any:
|
|
878
|
+
body = {
|
|
879
|
+
"importId": _resource_id(doc["import_id"], field="import_id"),
|
|
880
|
+
"version": str(doc["version"]).strip(),
|
|
881
|
+
"codeText": str(doc["code_text"]).strip(),
|
|
882
|
+
}
|
|
883
|
+
changelog = str(doc.get("changelog") or "").strip()
|
|
884
|
+
if changelog:
|
|
885
|
+
body["changelog"] = changelog
|
|
886
|
+
return _safe(_unwrap(self._request("POST", "system/user/import/version/publish", body, headers={"X-Server-Type": str(self._build(server_type))})))
|
|
887
|
+
|
|
888
|
+
def import_version_delete(self, version_id: Any, *, server_type: Optional[int] = None) -> Any:
|
|
889
|
+
return _safe(_unwrap(self._request("DELETE", "system/user/import/version/delete?versionId=%s" % urllib.parse.quote(str(version_id), safe=""), headers={"X-Server-Type": str(self._build(server_type))})))
|
|
890
|
+
|
|
621
891
|
def release_signature(self, project_id: int, file_id: int) -> str:
|
|
622
892
|
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
893
|
url = result.get("signedUrl") if isinstance(result, Mapping) else result
|
|
@@ -633,12 +903,21 @@ class ModUs:
|
|
|
633
903
|
if size > MAX_PACKAGE_BYTES:
|
|
634
904
|
raise ValidationError("release ZIP exceeds the 200 MB upload limit", path=file_path)
|
|
635
905
|
parsed = urllib.parse.urlsplit(signed_url)
|
|
906
|
+
if parsed.scheme not in ("http", "https") or not parsed.hostname:
|
|
907
|
+
raise ValidationError("release upload URL must be HTTP(S)", path="signed_url")
|
|
908
|
+
# The query carries the object-store signature. Keep a useful endpoint
|
|
909
|
+
# in errors without ever persisting that credential material.
|
|
910
|
+
endpoint_host = parsed.hostname
|
|
911
|
+
if parsed.port is not None:
|
|
912
|
+
endpoint_host = "%s:%d" % (endpoint_host, parsed.port)
|
|
913
|
+
endpoint = urllib.parse.urlunsplit((parsed.scheme, endpoint_host, parsed.path or "/", "", ""))
|
|
636
914
|
conn_cls = http.client.HTTPSConnection if parsed.scheme == "https" else http.client.HTTPConnection
|
|
637
|
-
|
|
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))
|
|
915
|
+
conn = None
|
|
641
916
|
try:
|
|
917
|
+
if parsed.scheme == "https":
|
|
918
|
+
conn = conn_cls(parsed.hostname, parsed.port, timeout=max(self.timeout, 600), context=ssl.create_default_context())
|
|
919
|
+
else:
|
|
920
|
+
conn = conn_cls(parsed.hostname, parsed.port, timeout=max(self.timeout, 600))
|
|
642
921
|
conn.putrequest("PUT", urllib.parse.urlunsplit(("", "", parsed.path or "/", parsed.query, "")))
|
|
643
922
|
conn.putheader("Content-Type", "application/zip")
|
|
644
923
|
conn.putheader("Content-Length", str(size))
|
|
@@ -650,15 +929,33 @@ class ModUs:
|
|
|
650
929
|
break
|
|
651
930
|
conn.send(chunk)
|
|
652
931
|
response = conn.getresponse()
|
|
653
|
-
response.read()
|
|
932
|
+
raw = response.read(4096)
|
|
654
933
|
if response.status < 200 or response.status >= 300:
|
|
655
|
-
|
|
934
|
+
summary = redact(raw.decode("utf-8", errors="replace")) if raw else "<empty>"
|
|
935
|
+
raise FuploadError(
|
|
936
|
+
"ModUs binary upload returned HTTP %d" % response.status,
|
|
937
|
+
endpoint=endpoint,
|
|
938
|
+
http_status=response.status,
|
|
939
|
+
verification_required=True,
|
|
940
|
+
stage="binary_upload",
|
|
941
|
+
details={"response_summary": summary},
|
|
942
|
+
)
|
|
656
943
|
except FuploadError:
|
|
657
944
|
raise
|
|
658
|
-
except OSError as exc:
|
|
659
|
-
raise FuploadError(
|
|
945
|
+
except (OSError, http.client.HTTPException) as exc:
|
|
946
|
+
raise FuploadError(
|
|
947
|
+
"ModUs binary upload failed",
|
|
948
|
+
endpoint=endpoint,
|
|
949
|
+
verification_required=True,
|
|
950
|
+
stage="binary_upload",
|
|
951
|
+
details={"response_summary": redact(str(exc)) or exc.__class__.__name__},
|
|
952
|
+
) from exc
|
|
660
953
|
finally:
|
|
661
|
-
conn
|
|
954
|
+
if conn is not None:
|
|
955
|
+
try:
|
|
956
|
+
conn.close()
|
|
957
|
+
except OSError:
|
|
958
|
+
pass
|
|
662
959
|
return {"status": "uploaded", "archive": path.name, "bytes": size}
|
|
663
960
|
|
|
664
961
|
def release_delete(self, project_id: int, file_id: int) -> Any:
|
|
@@ -666,70 +963,108 @@ class ModUs:
|
|
|
666
963
|
|
|
667
964
|
def publish(self, doc: Mapping[str, Any], *, update: bool = False) -> Dict[str, Any]:
|
|
668
965
|
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
966
|
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
967
|
archive = Path(str(file_path)) if file_path is not None else None
|
|
677
|
-
|
|
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
|
-
})
|
|
968
|
+
supplied_file_id = doc.get("file_id")
|
|
703
969
|
transaction: Dict[str, Any] = {
|
|
704
970
|
"schema": "fupload.v1.modus.upload-transaction",
|
|
705
971
|
"created_at": int(time.time()),
|
|
706
972
|
"project_id": project_id,
|
|
707
|
-
"file_id":
|
|
973
|
+
"file_id": None,
|
|
708
974
|
"update": bool(update),
|
|
709
975
|
"archive": str(archive) if archive is not None else None,
|
|
710
|
-
"stages": [
|
|
976
|
+
"stages": [],
|
|
711
977
|
}
|
|
712
978
|
transaction_path = Path(str(doc.get("transaction_log") or ((str(archive) + ".modus-transaction.json") if archive else "modus-transaction.json")))
|
|
713
979
|
|
|
714
980
|
def save_transaction() -> None:
|
|
715
981
|
transaction_path.write_text(json.dumps(transaction, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
716
982
|
|
|
983
|
+
# Establish the audit record before reserving a file ID or validating
|
|
984
|
+
# the archive so every publish failure has a durable transaction.
|
|
985
|
+
try:
|
|
986
|
+
save_transaction()
|
|
987
|
+
except OSError as exc:
|
|
988
|
+
raise FuploadError(
|
|
989
|
+
"ModUs upload transaction could not be written",
|
|
990
|
+
stage="transaction_log",
|
|
991
|
+
details={"path": str(transaction_path)},
|
|
992
|
+
) from exc
|
|
993
|
+
|
|
994
|
+
current_stage = "prepare"
|
|
717
995
|
try:
|
|
718
|
-
|
|
996
|
+
allocated_file_id = not bool(supplied_file_id)
|
|
997
|
+
current_stage = "file_id"
|
|
998
|
+
transaction["active_stage"] = current_stage
|
|
999
|
+
save_transaction()
|
|
1000
|
+
if allocated_file_id:
|
|
1001
|
+
transaction["stages"].append("file_id")
|
|
1002
|
+
file_id = _positive_id(self.release_file_id(project_id), field="file_id")
|
|
1003
|
+
else:
|
|
1004
|
+
file_id = _positive_id(supplied_file_id, field="file_id")
|
|
1005
|
+
transaction["file_id"] = file_id
|
|
1006
|
+
|
|
1007
|
+
metadata = {k: doc[k] for k in ("project_id", "version", "type", "supported_game_versions", "toc_version", "changelog", "path") if k in doc}
|
|
1008
|
+
metadata["file_id"] = file_id
|
|
1009
|
+
current_stage = "zip_preflight"
|
|
1010
|
+
transaction["active_stage"] = current_stage
|
|
1011
|
+
save_transaction()
|
|
1012
|
+
if archive is None and not update:
|
|
1013
|
+
raise ValidationError("release ZIP file is required", path="file")
|
|
1014
|
+
if archive is not None:
|
|
1015
|
+
if not archive.is_file():
|
|
1016
|
+
raise ValidationError("release ZIP file does not exist", path=str(file_path))
|
|
1017
|
+
size = archive.stat().st_size
|
|
1018
|
+
if size > MAX_PACKAGE_BYTES:
|
|
1019
|
+
raise ValidationError("release ZIP exceeds the 200 MB upload limit", path=str(file_path))
|
|
1020
|
+
md5 = hashlib.md5(archive.read_bytes()).hexdigest()
|
|
1021
|
+
try:
|
|
1022
|
+
unzip_size = sum(info.file_size for info in zipfile.ZipFile(str(archive)).infolist())
|
|
1023
|
+
except (OSError, zipfile.BadZipFile) as exc:
|
|
1024
|
+
raise ValidationError("release file is not a valid ZIP", path=str(file_path)) from exc
|
|
1025
|
+
derived = parse_modus_zip(archive)
|
|
1026
|
+
supplied_toc = doc.get("toc_version")
|
|
1027
|
+
supplied_games = doc.get("supported_game_versions")
|
|
1028
|
+
if supplied_toc is not None and str(supplied_toc) != derived["toc_version"]:
|
|
1029
|
+
raise ValidationError("toc_version does not match the ZIP Interface field", path="$.toc_version")
|
|
1030
|
+
if supplied_games is not None and _supported_game_versions(supplied_games) != derived["supported_game_versions"]:
|
|
1031
|
+
raise ValidationError("supported_game_versions does not match the ZIP Interface field", path="$.supported_game_versions")
|
|
1032
|
+
metadata.update({
|
|
1033
|
+
"md5": md5,
|
|
1034
|
+
"zip_size": size,
|
|
1035
|
+
"unzip_size": unzip_size,
|
|
1036
|
+
"toc_version": derived["toc_version"],
|
|
1037
|
+
"supported_game_versions": derived["supported_game_versions"],
|
|
1038
|
+
"path": doc.get("path") or archive.name,
|
|
1039
|
+
})
|
|
1040
|
+
current_stage = "release_metadata_update" if update else "release_metadata"
|
|
1041
|
+
transaction["stages"].append(current_stage)
|
|
1042
|
+
transaction["active_stage"] = current_stage
|
|
1043
|
+
save_transaction()
|
|
719
1044
|
result = self.release_metadata(metadata, update=update)
|
|
720
1045
|
if archive is None:
|
|
1046
|
+
transaction.pop("active_stage", None)
|
|
721
1047
|
transaction.update({"completed": True, "upload": None})
|
|
722
1048
|
save_transaction()
|
|
723
1049
|
return {"project_id": project_id, "file_id": file_id, "metadata": result, "upload": None, "transaction": _safe(transaction)}
|
|
724
|
-
|
|
1050
|
+
current_stage = "signature"
|
|
1051
|
+
transaction["stages"].append(current_stage)
|
|
1052
|
+
transaction["active_stage"] = current_stage
|
|
1053
|
+
save_transaction()
|
|
725
1054
|
signed = self.release_signature(project_id, file_id)
|
|
726
|
-
|
|
1055
|
+
current_stage = "binary_upload"
|
|
1056
|
+
transaction["stages"].append(current_stage)
|
|
1057
|
+
transaction["active_stage"] = current_stage
|
|
1058
|
+
save_transaction()
|
|
727
1059
|
uploaded = self.upload_zip(signed, str(archive))
|
|
1060
|
+
transaction.pop("active_stage", None)
|
|
728
1061
|
transaction.update({"completed": True, "upload": uploaded})
|
|
729
1062
|
save_transaction()
|
|
730
1063
|
return {"project_id": project_id, "file_id": file_id, "metadata": result, "upload": uploaded, "transaction": _safe(transaction)}
|
|
731
1064
|
except FuploadError as exc:
|
|
732
|
-
|
|
1065
|
+
if exc.stage in (None, "dependency_get"):
|
|
1066
|
+
exc.stage = current_stage
|
|
1067
|
+
transaction["failed_stage"] = exc.stage or current_stage
|
|
733
1068
|
transaction["error"] = exc.as_dict()
|
|
734
1069
|
transaction["retained_archive"] = bool(archive and archive.is_file())
|
|
735
1070
|
try:
|
|
@@ -737,18 +1072,40 @@ class ModUs:
|
|
|
737
1072
|
except OSError:
|
|
738
1073
|
pass
|
|
739
1074
|
raise
|
|
1075
|
+
except OSError as exc:
|
|
1076
|
+
wrapped = FuploadError(
|
|
1077
|
+
"ModUs upload preparation failed",
|
|
1078
|
+
stage=current_stage,
|
|
1079
|
+
details={"response_summary": redact(str(exc)) or exc.__class__.__name__},
|
|
1080
|
+
)
|
|
1081
|
+
transaction["failed_stage"] = current_stage
|
|
1082
|
+
transaction["error"] = wrapped.as_dict()
|
|
1083
|
+
transaction["retained_archive"] = bool(archive and archive.is_file())
|
|
1084
|
+
try:
|
|
1085
|
+
save_transaction()
|
|
1086
|
+
except OSError:
|
|
1087
|
+
pass
|
|
1088
|
+
raise wrapped from exc
|
|
740
1089
|
|
|
741
1090
|
def execute_read(self, resource: str, action: str, args: Any = None) -> Any:
|
|
742
1091
|
if resource == "session" and action == "doctor": return self.doctor()
|
|
1092
|
+
if resource == "options" and action == "builds": return self.builds()
|
|
1093
|
+
if resource == "builds" and action == "list": return self.builds()
|
|
743
1094
|
doc = vars(args) if args is not None and hasattr(args, "__dict__") else (args or {})
|
|
744
1095
|
if resource == "account" and action == "info": return self.user_info()
|
|
745
1096
|
if resource == "account" and action == "subscription-count": return self.active_subscription_count()
|
|
746
1097
|
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"
|
|
748
|
-
if resource == "addon" and action == "project-info": return self.addon_project_info(doc.get("project_ids", []), server_type=doc.get("server_type"
|
|
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"
|
|
1098
|
+
if resource == "addon" and action == "info": return self.addon_info(doc.get("directories", []), server_type=doc.get("server_type"))
|
|
1099
|
+
if resource == "addon" and action == "project-info": return self.addon_project_info(doc.get("project_ids", []), server_type=doc.get("server_type"))
|
|
1100
|
+
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"))
|
|
750
1101
|
if resource == "project" and action == "dependencies": return self.project_dependencies(doc.get("query") or doc.get("project_ids") or doc.get("project_id"))
|
|
751
1102
|
if resource == "options": return self.options(action, keys=doc.get("keys"))
|
|
1103
|
+
if resource == "config" and action == "backups": return self.cloud_backups(server_type=doc.get("server_type"))
|
|
1104
|
+
if resource == "config" and action == "backup-get": return self.cloud_backup_detail(doc["backup_id"], server_type=doc.get("server_type"))
|
|
1105
|
+
if resource == "config" and action == "list": return self.share_list(doc, server_type=doc.get("server_type"))
|
|
1106
|
+
if resource == "config" and action == "get": return self.share_detail(doc["share_id"], server_type=doc.get("server_type"))
|
|
1107
|
+
if resource == "wa" and action == "list": return self.import_list(doc, server_type=doc.get("server_type"))
|
|
1108
|
+
if resource == "wa" and action == "get": return self.import_detail(doc["import_id"], server_type=doc.get("server_type"))
|
|
752
1109
|
if resource == "project" and action == "list": return self.project_list(**doc)
|
|
753
1110
|
if resource == "project" and action in ("get", "detail"): return self.project_detail(doc["project_id"])
|
|
754
1111
|
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))
|
|
@@ -756,6 +1113,16 @@ class ModUs:
|
|
|
756
1113
|
raise ValidationError("unsupported ModUs read operation")
|
|
757
1114
|
|
|
758
1115
|
def execute_write(self, resource: str, action: str, doc: Mapping[str, Any]) -> Any:
|
|
1116
|
+
if resource == "config" and action == "create": return self.share_create(doc, server_type=doc.get("server_type"))
|
|
1117
|
+
if resource == "config" and action in ("update", "edit"): return self.share_update(doc, server_type=doc.get("server_type"))
|
|
1118
|
+
if resource == "config" and action == "delete": return self.share_delete(doc["share_id"], server_type=doc.get("server_type"))
|
|
1119
|
+
if resource == "config" and action == "backup-edit": return self.cloud_backup_update(doc, server_type=doc.get("server_type"))
|
|
1120
|
+
if resource == "config" and action == "backup-delete": return self.cloud_backup_delete(doc["backup_id"], server_type=doc.get("server_type"))
|
|
1121
|
+
if resource == "wa" and action == "create": return self.import_create(doc, server_type=doc.get("server_type"))
|
|
1122
|
+
if resource == "wa" and action in ("update", "edit"): return self.import_update(doc, server_type=doc.get("server_type"))
|
|
1123
|
+
if resource == "wa" and action == "delete": return self.import_delete(doc["import_id"], server_type=doc.get("server_type"))
|
|
1124
|
+
if resource == "wa" and action == "version-publish": return self.import_version_publish(doc, server_type=doc.get("server_type"))
|
|
1125
|
+
if resource == "wa" and action == "version-delete": return self.import_version_delete(doc["version_id"], server_type=doc.get("server_type"))
|
|
759
1126
|
if resource == "project" and action in ("create", "release"): return self.project_create(doc)
|
|
760
1127
|
if resource == "project" and action in ("update", "edit"): return self.project_update(doc)
|
|
761
1128
|
if resource == "project" and action == "delete": return self.project_delete(int(doc["project_id"]))
|
|
@@ -17,6 +17,7 @@ JSON_TYPES = {
|
|
|
17
17
|
"boolean": (bool,),
|
|
18
18
|
"array": (list,),
|
|
19
19
|
"object": (dict,),
|
|
20
|
+
"identifier": (int, str),
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
|
|
@@ -98,6 +99,21 @@ class Schema:
|
|
|
98
99
|
return checked
|
|
99
100
|
|
|
100
101
|
def _validate_conditionals(self, value: Dict[str, Any]) -> None:
|
|
102
|
+
if self.name.startswith("fupload.v1.modus.config"):
|
|
103
|
+
exclude = value.get("exclude_wtf")
|
|
104
|
+
if exclude == 1 and (value.get("account_name") or value.get("role_name")):
|
|
105
|
+
raise ValidationError("account_name and role_name must be empty when exclude_wtf=1", path="$.account_name")
|
|
106
|
+
if exclude == 0 and not str(value.get("account_name") or "").strip():
|
|
107
|
+
raise ValidationError("account_name is required when exclude_wtf=0", path="$.account_name")
|
|
108
|
+
if self.name.startswith(("fupload.v1.modus.config", "fupload.v1.modus.wa")):
|
|
109
|
+
if "title" in value and len(str(value["title"]).strip()) < 6:
|
|
110
|
+
raise ValidationError("must contain at least 6 characters", path="$.title")
|
|
111
|
+
if "content_text" in value and len(str(value["content_text"]).strip()) <= 20:
|
|
112
|
+
raise ValidationError("must contain more than 20 characters", path="$.content_text")
|
|
113
|
+
if "tags" in value:
|
|
114
|
+
tags = [item.strip() for item in str(value["tags"]).split(",")]
|
|
115
|
+
if any(not item for item in tags) or not 1 <= len(tags) <= 3:
|
|
116
|
+
raise ValidationError("must contain 1 to 3 comma-delimited tag IDs", path="$.tags")
|
|
101
117
|
if self.name in ("fupload.v1.modus.project.create", "fupload.v1.modus.project.edit"):
|
|
102
118
|
snapshot = value.get("project_state")
|
|
103
119
|
if snapshot is None:
|
|
@@ -693,6 +709,31 @@ MODUS_RELEASE = {
|
|
|
693
709
|
"file": f("string", local_file=True),
|
|
694
710
|
"transaction_log": f("string", max_length=1000),
|
|
695
711
|
}
|
|
712
|
+
MODUS_BUILD_ID = f("integer", minimum=0, maximum=4)
|
|
713
|
+
MODUS_SHARE = {
|
|
714
|
+
"share_id": f("identifier", nonempty=True), "addons_id": f("string", nonempty=True, max_length=10000),
|
|
715
|
+
"account_name": f("string", nullable=True, max_length=120), "backup_id": f("integer", minimum=1),
|
|
716
|
+
"content": f("string", nonempty=True), "content_text": f("string", nonempty=True),
|
|
717
|
+
"image_url": f("string", nonempty=True, max_length=1000), "is_paid": f("integer", choices=(0, 1)),
|
|
718
|
+
"is_public": f("integer", choices=(0, 1)), "price": f("number", minimum=0),
|
|
719
|
+
"share_type": f("integer", minimum=0), "tags": f("string", nonempty=True, max_length=1000),
|
|
720
|
+
"title": f("string", nonempty=True, max_length=120), "exclude_wtf": f("integer", choices=(0, 1)),
|
|
721
|
+
"role_name": f("string", nullable=True, max_length=120), "required_tier_id": f("integer", nullable=True, minimum=1),
|
|
722
|
+
"sub_type": f("integer", choices=(0, 1)), "platform": f("integer", choices=(1, 3)),
|
|
723
|
+
"synchronization_type": f("integer", choices=(1, 3)), "server_type": MODUS_BUILD_ID,
|
|
724
|
+
}
|
|
725
|
+
MODUS_IMPORT = {
|
|
726
|
+
"import_id": f("identifier", nonempty=True), "code_text": f("string", nonempty=True),
|
|
727
|
+
"content": f("string", nonempty=True), "addons_id": f("string", nonempty=True, max_length=10000),
|
|
728
|
+
"content_text": f("string", nonempty=True), "file_path": f("string", choices=("",)),
|
|
729
|
+
"image_url": f("string", nonempty=True, max_length=1000), "is_paid": f("integer", choices=(0, 1)),
|
|
730
|
+
"is_public": f("integer", choices=(0, 1)), "price": f("number", minimum=0),
|
|
731
|
+
"share_type": f("integer", minimum=0), "support_addon": f("string", nonempty=True, max_length=120),
|
|
732
|
+
"tags": f("string", nonempty=True, max_length=1000), "title": f("string", nonempty=True, max_length=120),
|
|
733
|
+
"version": f("string", nonempty=True, max_length=120), "required_tier_id": f("integer", nullable=True, minimum=1),
|
|
734
|
+
"sub_type": f("integer", choices=(0, 1)), "platform": f("integer", choices=(1, 3)),
|
|
735
|
+
"synchronization_type": f("integer", choices=(1, 3)), "server_type": MODUS_BUILD_ID,
|
|
736
|
+
}
|
|
696
737
|
register("modus", "project", "create", required(MODUS_PROJECT_CREATE, ("project_state",)))
|
|
697
738
|
register("modus", "project", "edit", required(with_id(MODUS_PROJECT_EDIT, "project_id"), ("project_id", "project_state")))
|
|
698
739
|
register("modus", "project", "delete", required({"project_id": f("integer", minimum=1), "confirm": f("string", choices=("DELETE",))}, ("project_id", "confirm")))
|
|
@@ -701,7 +742,18 @@ register("modus", "plugin", "upload", required(MODUS_RELEASE, ("project_id", "fi
|
|
|
701
742
|
register("modus", "plugin", "update", required(MODUS_RELEASE, ("project_id", "file_id")))
|
|
702
743
|
register("modus", "plugin", "edit", required(MODUS_RELEASE, ("project_id", "file_id")))
|
|
703
744
|
register("modus", "plugin", "delete", required({"project_id": f("integer", minimum=1), "file_id": f("integer", minimum=1), "confirm": f("string", choices=("DELETE",))}, ("project_id", "file_id", "confirm")))
|
|
704
|
-
|
|
745
|
+
register("modus", "config", "create", required(MODUS_SHARE, ("addons_id", "backup_id", "content", "content_text", "image_url", "tags", "title", "exclude_wtf")))
|
|
746
|
+
register("modus", "config", "update", required(MODUS_SHARE, ("share_id",)))
|
|
747
|
+
register("modus", "config", "edit", required(MODUS_SHARE, ("share_id",)))
|
|
748
|
+
register("modus", "config", "delete", required({"share_id": f("identifier", nonempty=True), "confirm": f("string", choices=("DELETE",)), "server_type": MODUS_BUILD_ID}, ("share_id", "confirm")))
|
|
749
|
+
register("modus", "config", "backup-edit", required({"backup_id": f("integer", minimum=1), "backup_name": f("string", nonempty=True, max_length=200), "server_type": MODUS_BUILD_ID}, ("backup_id", "backup_name")))
|
|
750
|
+
register("modus", "config", "backup-delete", required({"backup_id": f("integer", minimum=1), "confirm": f("string", choices=("DELETE",)), "server_type": MODUS_BUILD_ID}, ("backup_id", "confirm")))
|
|
751
|
+
register("modus", "wa", "create", required(MODUS_IMPORT, ("code_text", "content", "addons_id", "content_text", "image_url", "support_addon", "tags", "title", "version")))
|
|
752
|
+
register("modus", "wa", "update", required(MODUS_IMPORT, ("import_id",)))
|
|
753
|
+
register("modus", "wa", "edit", required(MODUS_IMPORT, ("import_id",)))
|
|
754
|
+
register("modus", "wa", "delete", required({"import_id": f("identifier", nonempty=True), "confirm": f("string", choices=("DELETE",)), "server_type": MODUS_BUILD_ID}, ("import_id", "confirm")))
|
|
755
|
+
register("modus", "wa", "version-publish", required({"import_id": f("identifier", nonempty=True), "version": f("string", nonempty=True, max_length=120), "code_text": f("string", nonempty=True), "changelog": f("string", max_length=10000), "server_type": MODUS_BUILD_ID}, ("import_id", "version", "code_text")))
|
|
756
|
+
register("modus", "wa", "version-delete", required({"version_id": f("identifier", nonempty=True), "confirm": f("string", choices=("DELETE",)), "server_type": MODUS_BUILD_ID}, ("version_id", "confirm")))
|
|
705
757
|
register("blackbox", "plugin", "edit", required({
|
|
706
758
|
"id": f("integer"), "name": f("string"), "logo_url": f("string"), "category_ids": f("array"),
|
|
707
759
|
"type": f("integer", choices=(1, 9)), "desc": f("string"), "official": f("string"),
|
|
@@ -41,6 +41,17 @@ COMPLETE = ProjectStep.COMPLETE.value
|
|
|
41
41
|
_SCHEMA = "fupload.v1.modus.project-state"
|
|
42
42
|
_PLATFORMS = ("modus", "bigfoot")
|
|
43
43
|
_BIGFOOT_EXCLUSIVE_CATEGORY_ID = 998
|
|
44
|
+
_BASIC_INFO_KEYS = {
|
|
45
|
+
# Shared create/edit form fields.
|
|
46
|
+
"schema", "name", "project_name", "alt_name", "summary", "categories",
|
|
47
|
+
"synchronization_type", "publish_platforms", "publishPlatforms",
|
|
48
|
+
"required_tier_id", "requiredTierId", "repo_url",
|
|
49
|
+
# Create-only image fields and edit-only detail/image fields.
|
|
50
|
+
"logo_base64", "screenshot_base64s", "description",
|
|
51
|
+
"required_dependencies", "images", "image_ops",
|
|
52
|
+
# Known project-detail readback fields preserved in resumable snapshots.
|
|
53
|
+
"cf_url", "logo", "status",
|
|
54
|
+
}
|
|
44
55
|
_LICENSE_KEYS = {
|
|
45
56
|
"type", "template", "license_template", "licenseTemplate",
|
|
46
57
|
"holder", "copyright_holder", "copyrightHolder",
|
|
@@ -49,6 +60,15 @@ _LICENSE_KEYS = {
|
|
|
49
60
|
}
|
|
50
61
|
|
|
51
62
|
|
|
63
|
+
def _basic_info_fields(value: Mapping[str, Any]) -> None:
|
|
64
|
+
unknown = sorted(set(value) - _BASIC_INFO_KEYS)
|
|
65
|
+
if unknown:
|
|
66
|
+
raise ValidationError(
|
|
67
|
+
"unknown basic_info field(s): %s" % ", ".join(unknown),
|
|
68
|
+
path="$.basic_info.%s" % unknown[0],
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
52
72
|
def _nonempty_text(value: Any, *, path: str) -> str:
|
|
53
73
|
if not isinstance(value, str) or not value.strip():
|
|
54
74
|
raise ValidationError("value must be a non-empty string", path=path)
|
|
@@ -194,6 +214,7 @@ class ProjectStateMachine:
|
|
|
194
214
|
self._require(ProjectStep.BASIC_INFO)
|
|
195
215
|
value: Dict[str, Any] = dict(info or {})
|
|
196
216
|
value.update(fields)
|
|
217
|
+
_basic_info_fields(value)
|
|
197
218
|
name = value.get("name", value.get("project_name"))
|
|
198
219
|
summary = value.get("summary")
|
|
199
220
|
_nonempty_text(name, path="$.basic_info.name")
|
|
@@ -288,26 +309,33 @@ class ProjectStateMachine:
|
|
|
288
309
|
game = snapshot.get("game")
|
|
289
310
|
basic = snapshot.get("basic_info") or {}
|
|
290
311
|
lic = snapshot.get("license") or {}
|
|
312
|
+
restored_game: Any = None
|
|
313
|
+
restored_basic: Dict[str, Any] = {}
|
|
314
|
+
restored_license: Dict[str, Any] = {}
|
|
291
315
|
if state != CHOOSE_GAME:
|
|
292
|
-
|
|
316
|
+
restored_game = _game(game)
|
|
293
317
|
if state in {LICENSE, COMPLETE}:
|
|
294
318
|
if not isinstance(basic, Mapping):
|
|
295
319
|
raise ValidationError("basic_info must be an object", path="$.basic_info")
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
_nonempty_text(
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
320
|
+
restored_basic = dict(basic)
|
|
321
|
+
_basic_info_fields(restored_basic)
|
|
322
|
+
_nonempty_text(restored_basic.get("name"), path="$.basic_info.name")
|
|
323
|
+
_nonempty_text(restored_basic.get("summary"), path="$.basic_info.summary")
|
|
324
|
+
restored_basic["publish_platforms"] = _platforms(restored_basic.get("publish_platforms"))
|
|
325
|
+
restored_basic["categories"] = _categories(restored_basic.get("categories"))
|
|
326
|
+
restored_basic["required_tier_id"] = _required_tier(restored_basic.get("required_tier_id"))
|
|
327
|
+
if _BIGFOOT_EXCLUSIVE_CATEGORY_ID in restored_basic["categories"] and restored_basic["publish_platforms"] != ["bigfoot"]:
|
|
303
328
|
raise ValidationError("category 998 requires bigfoot as the only publish platform", path="$.basic_info.publish_platforms")
|
|
304
|
-
if "bigfoot" in
|
|
329
|
+
if "bigfoot" in restored_basic["publish_platforms"] and restored_basic["required_tier_id"] is not None:
|
|
305
330
|
raise ValidationError("required_tier_id must be null when bigfoot is selected", path="$.basic_info.required_tier_id")
|
|
306
|
-
derived_sync_type = (1 if "modus" in
|
|
307
|
-
if
|
|
331
|
+
derived_sync_type = (1 if "modus" in restored_basic["publish_platforms"] else 0) | (2 if "bigfoot" in restored_basic["publish_platforms"] else 0)
|
|
332
|
+
if restored_basic.get("synchronization_type") != derived_sync_type:
|
|
308
333
|
raise ValidationError("synchronization_type must match publish_platforms", path="$.basic_info.synchronization_type")
|
|
309
334
|
if state == COMPLETE:
|
|
310
|
-
|
|
335
|
+
restored_license = _license(lic)
|
|
336
|
+
self._game = restored_game
|
|
337
|
+
self._basic_info = restored_basic
|
|
338
|
+
self._license = restored_license
|
|
311
339
|
self._step = str(state)
|
|
312
340
|
|
|
313
341
|
def save(self, path: Union[str, os.PathLike[str]]) -> Path:
|
package/npm/skill-manifest.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema": "fupload.npm-skill-manifest.v1",
|
|
3
3
|
"package_name": "@follenfang/fupload",
|
|
4
|
-
"package_version": "0.0.
|
|
5
|
-
"skill_version": "0.0.
|
|
6
|
-
"tree_sha256": "
|
|
4
|
+
"package_version": "0.0.12",
|
|
5
|
+
"skill_version": "0.0.12",
|
|
6
|
+
"tree_sha256": "f1e559aef665537c4b2b24f15dab01b9e4140378bd3a70a00331d56358d2071a",
|
|
7
7
|
"files": [
|
|
8
8
|
{
|
|
9
9
|
"path": "agents/openai.yaml",
|
|
@@ -92,8 +92,8 @@
|
|
|
92
92
|
},
|
|
93
93
|
{
|
|
94
94
|
"path": "references/modus.md",
|
|
95
|
-
"bytes":
|
|
96
|
-
"sha256": "
|
|
95
|
+
"bytes": 13492,
|
|
96
|
+
"sha256": "919b8b097fd1ec14d38583e84f4d33a5320d24af03703dc1c06a072dbab33e68"
|
|
97
97
|
},
|
|
98
98
|
{
|
|
99
99
|
"path": "references/newbee-official-cli.md",
|
|
@@ -113,7 +113,7 @@
|
|
|
113
113
|
{
|
|
114
114
|
"path": "scripts/fupload_cli/__init__.py",
|
|
115
115
|
"bytes": 50,
|
|
116
|
-
"sha256": "
|
|
116
|
+
"sha256": "cad1ed5cd2ed3020e1fc98c2036d22b47e1138772e9829f5dd61c39cc3bd810f"
|
|
117
117
|
},
|
|
118
118
|
{
|
|
119
119
|
"path": "scripts/fupload_cli/blackbox_web.py",
|
|
@@ -127,8 +127,8 @@
|
|
|
127
127
|
},
|
|
128
128
|
{
|
|
129
129
|
"path": "scripts/fupload_cli/cli.py",
|
|
130
|
-
"bytes":
|
|
131
|
-
"sha256": "
|
|
130
|
+
"bytes": 36811,
|
|
131
|
+
"sha256": "bdd159c7d86c009685417e7b4a25194d98a49547e036606b920ff44090b1619e"
|
|
132
132
|
},
|
|
133
133
|
{
|
|
134
134
|
"path": "scripts/fupload_cli/curseforge.py",
|
|
@@ -167,8 +167,8 @@
|
|
|
167
167
|
},
|
|
168
168
|
{
|
|
169
169
|
"path": "scripts/fupload_cli/modus.py",
|
|
170
|
-
"bytes":
|
|
171
|
-
"sha256": "
|
|
170
|
+
"bytes": 60792,
|
|
171
|
+
"sha256": "9cf3d8127e2b7583f1760a74f88512b81ab272ea116bf9ca185c7531eba960d2"
|
|
172
172
|
},
|
|
173
173
|
{
|
|
174
174
|
"path": "scripts/fupload_cli/newbee_auth.py",
|
|
@@ -182,13 +182,13 @@
|
|
|
182
182
|
},
|
|
183
183
|
{
|
|
184
184
|
"path": "scripts/fupload_cli/schema.py",
|
|
185
|
-
"bytes":
|
|
186
|
-
"sha256": "
|
|
185
|
+
"bytes": 51541,
|
|
186
|
+
"sha256": "aca30dc7997f47645b3da154fa3656fefa1179733e1d61e7ff0de326734f2575"
|
|
187
187
|
},
|
|
188
188
|
{
|
|
189
189
|
"path": "scripts/fupload_cli/state_machine.py",
|
|
190
|
-
"bytes":
|
|
191
|
-
"sha256": "
|
|
190
|
+
"bytes": 16450,
|
|
191
|
+
"sha256": "da26b63be4e085b9b39aea4d13a2c58206836d98fa46400b243113ce4b1ff4a9"
|
|
192
192
|
},
|
|
193
193
|
{
|
|
194
194
|
"path": "scripts/fupload_cli/transport.py",
|
|
@@ -208,7 +208,7 @@
|
|
|
208
208
|
{
|
|
209
209
|
"path": "SKILL.md",
|
|
210
210
|
"bytes": 25720,
|
|
211
|
-
"sha256": "
|
|
211
|
+
"sha256": "fe14cba8f02167dce36034ce8b73784793c92ed3de37dda1ab982a5a8a9cc8de"
|
|
212
212
|
}
|
|
213
213
|
]
|
|
214
214
|
}
|