@follenfang/fupload 0.0.12 → 0.0.15
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 +228 -64
- package/fupload/scripts/fupload_cli/__init__.py +1 -1
- package/fupload/scripts/fupload_cli/cli.py +15 -3
- package/fupload/scripts/fupload_cli/dd_broker.py +42 -14
- package/fupload/scripts/fupload_cli/io.py +17 -1
- package/fupload/scripts/fupload_cli/modus.py +279 -14
- package/fupload/scripts/fupload_cli/schema.py +53 -7
- package/fupload/scripts/fupload_cli/transport.py +13 -1
- package/npm/skill-manifest.json +19 -19
- 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.15"
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Fupload
|
|
@@ -35,31 +35,42 @@ The complete project model includes `project_id`, `name`, `alt_name`, `summary`,
|
|
|
35
35
|
|
|
36
36
|
### Project field contract
|
|
37
37
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
|
45
|
-
|
|
|
46
|
-
| `
|
|
47
|
-
| `
|
|
48
|
-
| `
|
|
49
|
-
| `
|
|
50
|
-
| `
|
|
51
|
-
| `
|
|
52
|
-
| `
|
|
53
|
-
| `
|
|
54
|
-
| `
|
|
55
|
-
| `
|
|
56
|
-
| `
|
|
57
|
-
| `
|
|
58
|
-
| `
|
|
59
|
-
| `
|
|
60
|
-
| `
|
|
61
|
-
| `
|
|
62
|
-
| `
|
|
38
|
+
The table below is the A2 field inventory. `C` means create, `U` update/edit,
|
|
39
|
+
and `D` delete. The current CLI's Creator endpoints are `C POST
|
|
40
|
+
/game/data/author/project/release`, `U POST /game/data/author/project/update`,
|
|
41
|
+
`D POST /game/data/author/project/delete/project/{projectId}`, and readback is
|
|
42
|
+
`GET /game/data/author/project/detail/{projectId}` unless stated otherwise.
|
|
43
|
+
|
|
44
|
+
| CLI/state field | Creator wire name | JSON type | Required/default | Enum/source | State/linkage rule | Write endpoint | Detail/readback location |
|
|
45
|
+
| --- | --- | --- | --- | --- | --- | --- | --- |
|
|
46
|
+
| `schema` | not sent | string | required for each CLI write | schema registry | must match action | local validation only | not applicable |
|
|
47
|
+
| `project_id` | `projectId` | positive integer | U/D required; C server-assigned | project list/detail | outer document, not `project_state` | U/D | `projectId` |
|
|
48
|
+
| `game` | not sent by current CLI | object or non-empty string | required; no default | Creator game selector | first `choose_game` state; stable key/ID required | local state only | `project_state.game`; server version binding is separate |
|
|
49
|
+
| `name` | `name` | non-empty string, max 120 | required; no default | free text | `basic_info` | C/U | `name` |
|
|
50
|
+
| `alt_name` | `altName` | string/null, max 120 | optional; omitted preserves | free text | U clear becomes literal `<null>` | C/U | `altName` |
|
|
51
|
+
| `summary` | `summary` | non-empty string, max 500 | required; no default | free text | `basic_info` | C/U | `summary` |
|
|
52
|
+
| `categories` | comma-delimited `categories` | array of 1-5 positive IDs | required; no default | `GET /plugin/list/Categories` | ID `998` forces BigFoot-only | C/U | comma-delimited `categories` / category detail |
|
|
53
|
+
| `publish_platforms` | derives `synchronizationType` | unique array | required; no default | fixed `modus`, `bigfoot` | at least one; `[modus]=>1`, `[bigfoot]=>2`, both `=>3` | C/U through derived field | `synchronizationType` |
|
|
54
|
+
| `synchronization_type` | `synchronizationType` | integer `1..3` | derived; caller value replaced | derived from `publish_platforms` | must equal platform selection | C/U | `synchronizationType` |
|
|
55
|
+
| `required_tier_id` | `requiredTierId` | positive integer/null | default none: C omits; U null sends `<null>` | `GET /user/author/subscription/tiers` | BigFoot branch requires none | C/U | `requiredTierId`; absence is no-tier |
|
|
56
|
+
| `repo_url` | `repoUrl` | string/null, max 500 | optional | free URL text | C empty omitted; U clear sends `<null>` | C/U | `repoUrl` |
|
|
57
|
+
| `logo_base64` | `screenshotBase64sReqs.screenshotBase64s` | non-empty base64 string | C optional; default `""` | local image | C only; companion name fixed `logo.webp` | C | server-managed `logo` path |
|
|
58
|
+
| `screenshot_base64s` | same create logo member | base64 string array | compatibility alias; default `[]` | local images | current CLI consumes only first item when `logo_base64` absent | C | server-managed `logo` path |
|
|
59
|
+
| `license.type` | JSON-string `license.type` | non-empty string | required | Creator license selector | final `license` state | C/U | parse JSON-string `license` |
|
|
60
|
+
| `license.holder` | JSON-string `license.holder` | non-empty string | optional | free text | license step | C/U | parse JSON-string `license.holder` |
|
|
61
|
+
| `license.year` | JSON-string `license.year` | non-empty string | optional | free text | license step | C/U | parse JSON-string `license.year` |
|
|
62
|
+
| `license.content` | JSON-string `license.content` | non-empty string | custom license requires content | free text | license step/custom branch | C/U | parse JSON-string `license.content` |
|
|
63
|
+
| `description` | `description` | string/null, max 100000 | U only; omitted preserves | rich-text editor | clear sends `<null>` | U | `description` |
|
|
64
|
+
| `required_dependencies` | `requiredDependencies` | string/null, max 4000 | U only; omitted preserves | `POST /game/data/author/project/dependency/query` | clear sends `<null>` | U | `requiredDependencies` |
|
|
65
|
+
| `images` | `images` | non-negative integer | U required with `image_ops` | current detail count | count must agree with requested operations | U | `images` |
|
|
66
|
+
| `image_ops[].op` | `imagesOps[].op` | enum string | U operation required | fixed `upload`, `delete`, `rename` | upload/delete use `name`; rename uses `from/to` | U | `images`, logo/image paths |
|
|
67
|
+
| `image_ops[].name` | `imagesOps[].name` | non-empty string | upload/delete required | current object name | forbidden for rename | U | resulting image path/count |
|
|
68
|
+
| `image_ops[].base64` | `imagesOps[].base64` | base64 string | upload required; delete forbidden | local image | upload only | U | resulting image path/count |
|
|
69
|
+
| `image_ops[].from/to` | `imagesOps[].from/to` | non-empty strings | rename required | current/target name | rename only | U | resulting image paths |
|
|
70
|
+
| `cf_url` | `cfUrl` | string/null | read-only | server | never sent | none | `cfUrl` |
|
|
71
|
+
| `logo` | `logo` | string/object/null | read-only | server | C uses base64 input instead | none | `logo` |
|
|
72
|
+
| `status` | `status` | integer/null | read-only | server status enum | delete/publication state | none | `status` |
|
|
73
|
+
| `project_state` | not sent | object | required; state=`complete` | CLI state machine | exact order `choose_game -> basic_info -> license`; unknown fields rejected | local validation only | persisted CLI snapshot |
|
|
63
74
|
|
|
64
75
|
Create-only, edit-only, and read-only fields may be preserved in a resumable
|
|
65
76
|
snapshot, but `_project_wire` sends only fields supported by the selected API
|
|
@@ -82,8 +93,9 @@ named `logo.webp`; `screenshot_base64s` remains the compatibility alias.
|
|
|
82
93
|
Edit sends only changed Creator fields plus `projectId`. `description`, cleared
|
|
83
94
|
`repo_url`, cleared `alt_name`, null `required_tier_id`, and cleared
|
|
84
95
|
`required_dependencies` use Creator's literal `<null>` marker. `image_ops` maps
|
|
85
|
-
to `imagesOps` entries with `op` (`upload` or `
|
|
86
|
-
`base64`; `images` is required with
|
|
96
|
+
to `imagesOps` entries with `op` (`upload`, `delete`, or `rename`), `name` and
|
|
97
|
+
upload-only `base64`, or rename-only `from`/`to`; `images` is required with
|
|
98
|
+
image operations. `cf_url`, returned `logo`,
|
|
87
99
|
and `status` are read-only/server-managed. `publish_platforms` and `game` are
|
|
88
100
|
local form state, not project update keys.
|
|
89
101
|
`project_state` is a resumable `choose_game` -> `basic_info` -> `license` ->
|
|
@@ -101,6 +113,35 @@ Deletion requires `confirm: "DELETE"`.
|
|
|
101
113
|
`project_id`, `file_id`, `version`, `type`, `supported_game_versions`, `md5`,
|
|
102
114
|
`zip_size`, `unzip_size`, `path`, `toc_version`, `changelog`, and `file`.
|
|
103
115
|
`transaction_log` optionally selects the redacted transaction record path.
|
|
116
|
+
|
|
117
|
+
### Creator release/version field contract
|
|
118
|
+
|
|
119
|
+
This is the A4 inventory. Metadata create uses `POST
|
|
120
|
+
/game/data/author/project/upload`; metadata update/edit uses `POST
|
|
121
|
+
/game/data/author/project/file/update`; delete uses `POST
|
|
122
|
+
/game/data/author/project/delete`; detail is `GET
|
|
123
|
+
/game/data/author/project/file/detail/{fileId}` and history/list is `POST
|
|
124
|
+
/game/data/author/project/file/list`. File-ID allocation and the binary object
|
|
125
|
+
PUT are separate stages.
|
|
126
|
+
|
|
127
|
+
| CLI field | Creator wire name | JSON type | Required/default | Enum/source | State/linkage rule | Write endpoint | Detail/readback location |
|
|
128
|
+
| --- | --- | --- | --- | --- | --- | --- | --- |
|
|
129
|
+
| `schema` | not sent | string | required | schema registry | must match release action | local validation | not applicable |
|
|
130
|
+
| `project_id` | `projectId` | positive integer | all release writes required | project detail | parent project must exist | metadata create/update/delete; signature path | `projectId` in list/detail |
|
|
131
|
+
| `file_id` | `fileId` | positive integer | update/edit/delete required; upload allocates | `GET /game/data/author/project/fileId/{projectId}` | allocated ID is used by signature; create metadata omits it | update/delete/signature | `id`/`fileId` in release detail |
|
|
132
|
+
| `version` | `version` | non-empty string, max 120 | create/upload required after ZIP preflight | ZIP `.toc` plus caller label | caller value preserved | metadata create/update | `version` |
|
|
133
|
+
| `type` | `type` | non-empty string, max 40 | create/upload required | Creator release-type choices/API | must be a server-supported value | metadata create/update | `type` |
|
|
134
|
+
| `supported_game_versions` | `supportedGameVersionsReqs[]` | non-empty object array | required; ZIP-derived | installed `.toc` `Interface` values and game config | each entry requires string `gameVersion` and `server`; explicit caller value must match ZIP | metadata create/update | `supportedGameVersionsReqs`/version compatibility rows |
|
|
135
|
+
| `md5` | `md5` | hex string, max 64 | derived from exact ZIP bytes | local ZIP preflight | must describe bytes later PUT | metadata create/update | `md5` |
|
|
136
|
+
| `zip_size` | `zipSize` | non-negative integer | derived | local ZIP stat | must describe exact ZIP | metadata create/update | `zipSize` |
|
|
137
|
+
| `unzip_size` | `unzipSize` | non-negative integer | derived | ZIP central directory | bounded by ZIP preflight | metadata create/update | `unzipSize` |
|
|
138
|
+
| `path` | `path` | string, max 500 | metadata optional | Creator/object response | local path is never binary upload | metadata create/update | `path` |
|
|
139
|
+
| `toc_version` | `tocVersion` | string, max 80 | derived | every ZIP `.toc` `Interface` | incompatible/missing/mixed values fail before write | metadata create/update | `tocVersion` |
|
|
140
|
+
| `changelog` | `changelog` | string/null, max 10000 | optional | free text | omitted/empty behavior is action-specific | metadata create/update | `changelog` |
|
|
141
|
+
| `file` | not in JSON; raw bytes | local path | create/upload required | local filesystem | valid ZIP, max 200 MiB; bytes PUT only after metadata/signature | signed URL from `GET .../upload/signature/{projectId}/{fileId}`, then HTTP `PUT` | object upload status plus release detail hashes/sizes |
|
|
142
|
+
| `transaction_log` | not sent | local path string | optional | caller | redacted durable stage record | local record only | transaction JSON |
|
|
143
|
+
| derived signature URL | not returned to caller | URL | required for PUT | signature endpoint | query/userinfo redacted and never persisted | object-store `PUT application/zip` | HTTP status only; then release detail |
|
|
144
|
+
|
|
104
145
|
Successful records include the completed `file_id`, metadata, signature, and
|
|
105
146
|
binary upload stages that ran; failed records include `failed_stage`, a redacted
|
|
106
147
|
error, and whether the ZIP was retained. The transaction record is established
|
|
@@ -134,26 +175,55 @@ The configuration surface uses:
|
|
|
134
175
|
* `POST /system/user/share/create`, `PUT /system/user/share/update`,
|
|
135
176
|
`DELETE /system/user/share/delete/{id}`
|
|
136
177
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
`
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
`
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
`
|
|
152
|
-
`
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
178
|
+
### Configuration list/filter contract
|
|
179
|
+
|
|
180
|
+
| CLI field | Wire/header name | JSON type | Required/default | Enum/source | State/linkage rule | Request endpoint | Readback location |
|
|
181
|
+
| --- | --- | --- | --- | --- | --- | --- | --- |
|
|
182
|
+
| `page_num` | `pageNum` | positive integer | default `1` | caller | list only | `POST /system/user/share/list` | pagination response |
|
|
183
|
+
| `page_size` | `pageSize` | positive integer | default `20` | caller | list only | same | pagination response |
|
|
184
|
+
| `server_type` / `server` | body `server`; header `X-Server-Type` | integer | local selected Build, fallback `0` | fixed Build table below | body and header must be identical | list; header on all config calls | list/detail records by Build |
|
|
185
|
+
| `mine` | `mine` | boolean | default `false` | fixed boolean | list only | list | returned rows |
|
|
186
|
+
| `share_type` | `shareType` | non-negative integer | default `0` | service/UI | list filter | list | returned rows |
|
|
187
|
+
| `platform` | `platform` | integer | list default `0` | service; write choices `1`,`3` | not Build | list | `platform` |
|
|
188
|
+
| `keyword` | `keyword` | string | optional | caller | list filter | list | returned rows |
|
|
189
|
+
| `status` | `status` | integer | optional | service status values | list filter | list | `status` |
|
|
190
|
+
| `tags` | `tags` | string | optional | `config-tags` static rows | list filter | list | tags on rows/detail |
|
|
191
|
+
| `order_by` | `orderBy` | service value | optional | service/UI | list sort | list | result order |
|
|
192
|
+
| `is_public` | `isPublic` | integer `0/1` | optional | fixed boolean | list filter | list | `isPublic` |
|
|
193
|
+
| `is_paid` | `isPaid` | integer `0/1` | optional | fixed boolean | list filter | list | `isPaid` |
|
|
194
|
+
|
|
195
|
+
### Configuration write field contract
|
|
196
|
+
|
|
197
|
+
This is the A6 inventory. Create is `POST /system/user/share/create`, update/edit
|
|
198
|
+
is `PUT /system/user/share/update`, delete is `DELETE
|
|
199
|
+
/system/user/share/delete/{id}`, and readback is `POST
|
|
200
|
+
/system/user/share/detail` with `{"shareIds":[id]}`. Every call carries the
|
|
201
|
+
selected Build in `X-Server-Type`.
|
|
202
|
+
|
|
203
|
+
| CLI field | Wire name | JSON type | Required/default | Enum/source | State/linkage rule | Write endpoint | Detail/readback location |
|
|
204
|
+
| --- | --- | --- | --- | --- | --- | --- | --- |
|
|
205
|
+
| `schema` | not sent | string | required | schema registry | must match action | local validation | not applicable |
|
|
206
|
+
| `share_id` | `id` / path ID | opaque decimal string | U/D required; C server-assigned | create response/detail | preserve as string | U/D | detail `id` |
|
|
207
|
+
| `addons_id` | `addonsId` | string, max 10000 | C required | selected backup `knownAddons.projectids` | must correspond to backup selection | C/U | `addonsId` |
|
|
208
|
+
| `backup_id` | `backupId` | positive integer | C required | `GET /system/user/backup/list` | account/role choices derive from this backup | C/U | `backupId` |
|
|
209
|
+
| `account_name` | `accountName` | string/null, max 120 | conditional | selected backup `wtfAccounts` | required and non-empty when `exclude_wtf=0`; empty when `1` | C/U | `accountName` |
|
|
210
|
+
| `role_name` | `roleName` | string/null, max 120 | optional in schema; UI requires selection when chosen account has roles | backup account roles | empty when `exclude_wtf=1` | C/U | `roleName` |
|
|
211
|
+
| `exclude_wtf` | `excludeWtf` | integer `0/1` | C required; provider normalizes missing U to `0` | fixed boolean | `1` forbids account/role; `0` requires account | C/U | `excludeWtf` |
|
|
212
|
+
| `content` | `content` | non-empty HTML string | C required | rich-text editor | editor image keys are uploaded before submit | C/U | `content` hash/length, not raw evidence |
|
|
213
|
+
| `content_text` | `contentText` | non-empty string | C required; trimmed length >20 | derived editor plain text | must correspond to `content` | C/U | `contentText` hash/length |
|
|
214
|
+
| `image_url` | `imageUrl` | non-empty comma-delimited key string, max 1000 | C required; 1-10 images in 1.2.16 UI | `modus media upload` results | trim/filter keys; first is cover; delete/reorder updates CSV, no blob-delete API observed | C/U | `imageUrl` |
|
|
215
|
+
| `is_paid` | `isPaid` | integer `0` | CLI optional; 1.2.16 form fixes `0` | installed client fixed value | nonzero values fail before mutation | C/U | `isPaid` |
|
|
216
|
+
| `price` | `price` | number `0` | CLI optional; 1.2.16 form fixes `0` | installed client fixed value | nonzero values fail before mutation | C/U | `price` |
|
|
217
|
+
| `is_public` | `isPublic` | integer `0/1` | CLI optional; 1.2.16 form defaults public | fixed boolean | visibility/soft-delete state | C/U | `isPublic` |
|
|
218
|
+
| `share_type` | `shareType` | integer `0` | CLI optional; 1.2.16 form fixes `0` | installed client fixed value | nonzero values fail before mutation | C/U | `shareType` |
|
|
219
|
+
| `tags` | comma-delimited `tags` | string of 1-3 IDs | C required | `modus options config-tags` | no empty/duplicate positions | C/U | `tags` and/or `modusShareTags` |
|
|
220
|
+
| `title` | `title` | string, max 120 | C required; trimmed length >=6 | free text | none | C/U | `title` |
|
|
221
|
+
| `required_tier_id` | `requiredTierId` | positive integer/null | default none: omitted | normal subscription or season option | selected tier disables BigFoot platform; ID from active options | C/U | `requiredTierId`; absence means none |
|
|
222
|
+
| `sub_type` | `subType` | integer `0/1` | helper default `0` | fixed normal=`0`, season=`1` | determines tier option source | C/U | `subType` |
|
|
223
|
+
| `platform` | `platform` | integer `1/3` | provider C/U default `1` | ModUs=`1`, ModUs+BigFoot=`3` | at least ModUs; tier selection forces `1` | C/U | `platform` |
|
|
224
|
+
| `synchronization_type` | `synchronizationType` | integer `1/3` | C defaults from platform; U omitted unless explicit | derived from platform | C should equal platform; 1.2.16 config U form omits it | C/U | `synchronizationType` if returned |
|
|
225
|
+
| `server_type` | header `X-Server-Type` | integer `0..4` | local selected Build or fallback `0` | fixed Build table | not `platform`; applies to target namespace | C/U/D/detail | record returned under same Build |
|
|
226
|
+
| `confirm` | not sent | literal `"DELETE"` | D required | fixed CLI guard | destructive action only | local guard then D | detail `status=4,isPublic=0`; absent from active list |
|
|
157
227
|
|
|
158
228
|
Backup rename uses `backup_id`, `backup_name`, and `server_type`; backup
|
|
159
229
|
deletion uses `backup_id`, `confirm`, and `server_type`.
|
|
@@ -168,23 +238,117 @@ String articles use:
|
|
|
168
238
|
* `POST /system/user/import/version/publish`
|
|
169
239
|
* `DELETE /system/user/import/version/delete?versionId={id}`
|
|
170
240
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
`
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
`
|
|
180
|
-
`platform
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
241
|
+
### WA/string list/filter contract
|
|
242
|
+
|
|
243
|
+
| CLI field | Wire/header name | JSON type | Required/default | Enum/source | State/linkage rule | Request endpoint | Readback location |
|
|
244
|
+
| --- | --- | --- | --- | --- | --- | --- | --- |
|
|
245
|
+
| `page_num` | `pageNum` | positive integer | default `1` | caller | list only | `POST /system/user/import/list` | pagination response |
|
|
246
|
+
| `page_size` | `pageSize` | positive integer | default `10` | caller | list only | same | pagination response |
|
|
247
|
+
| `server_type` / `server` | body `server`; header `X-Server-Type` | integer | local Build, fallback `0` | fixed Build table | body/header must match | list; header on every WA call | list/detail records by Build |
|
|
248
|
+
| `mine` | `mine` | boolean | default `false` | fixed boolean | list only | list | rows |
|
|
249
|
+
| `status` | `status` | integer | default `1` | service status enum | list only | list | `status` |
|
|
250
|
+
| `platform` | `platform` | integer | list default `0` | service; write `1/3` | not Build | list | `platform` |
|
|
251
|
+
| `keyword` | `keyword` | string | optional | caller | list filter | list | rows |
|
|
252
|
+
| `support_addon` | `supportAddon` | string | optional | `wa-support-addons` static rows | list filter | list | `supportAddon` |
|
|
253
|
+
| `tags` | `tags` | string | optional | `wa-tags` static rows | list filter | list | tags on rows/detail |
|
|
254
|
+
| `is_paid` | `isPaid` | integer `0/1` | optional | fixed boolean | list filter | list | `isPaid` |
|
|
255
|
+
| `order_by` | `orderBy` | service value | optional | service/UI | list sort | list | result order |
|
|
256
|
+
|
|
257
|
+
### WA/string write field contract
|
|
258
|
+
|
|
259
|
+
This is the A8 inventory. Create is `POST /system/user/import/create`,
|
|
260
|
+
update/edit is `POST /system/user/import/update`, delete is `DELETE
|
|
261
|
+
/system/user/import/delete/{id}`, and readback is `POST
|
|
262
|
+
/system/user/import/detail` with `{"importIds":[id]}`.
|
|
263
|
+
|
|
264
|
+
| CLI field | Wire name | JSON type | Required/default | Enum/source | State/linkage rule | Write endpoint | Detail/readback location |
|
|
265
|
+
| --- | --- | --- | --- | --- | --- | --- | --- |
|
|
266
|
+
| `schema` | not sent | string | required | schema registry | must match action | local validation | not applicable |
|
|
267
|
+
| `import_id` | `id` / path ID | opaque decimal string | U/D required; C server-assigned | create response/detail | preserve as string | U/D | detail `id` |
|
|
268
|
+
| `support_addon` | `supportAddon` | non-empty string, max 120 | C required | `modus options wa-support-addons` | chosen name must resolve to an addon row | C/U | `supportAddon` |
|
|
269
|
+
| `addons_id` | `addonsId` | non-empty string, max 10000 | C required | ID on selected support-addon row | must match `support_addon` | C/U | `addonsId` |
|
|
270
|
+
| `code_text` | `codeText` | non-empty string | C required; version publish required | WA/string input | create version and later version payload must be deliberate | C/U/version publish | current top-level `codeText` is not echoed; after the next publish, the previous current value is read from `versionList[].codeText` |
|
|
271
|
+
| `version` | `version` | non-empty string, max 120 | C required; U form preserves current | free text/version list | new immutable versions use version-publish | C/U/version publish | `version`, `versionList[].version` |
|
|
272
|
+
| `content` | `content` | non-empty HTML string | C required | rich-text editor | editor images uploaded before submit | C/U | `content` hash/length |
|
|
273
|
+
| `content_text` | `contentText` | non-empty string | C required; trimmed length >20 | derived plain text | must correspond to HTML content | C/U | `contentText` hash/length |
|
|
274
|
+
| `file_path` | `filePath` | literal empty string | optional in CLI; UI fixed `""` | fixed client behavior | only `""` accepted | C/U | `filePath` |
|
|
275
|
+
| `image_url` | `imageUrl` | non-empty comma-delimited key string, max 1000 | C required; 1-10 images in 1.2.16 UI | `modus media upload` results | first key is cover; delete/reorder rewrites CSV, no blob-delete API observed | C/U | `imageUrl` |
|
|
276
|
+
| `tags` | comma-delimited `tags` | string of 1-3 IDs | C required | `modus options wa-tags` | no empty positions | C/U | `tags` |
|
|
277
|
+
| `title` | `title` | string, max 120 | C required; trimmed length >=6 | free text | none | C/U | `title` |
|
|
278
|
+
| `is_paid` | `isPaid` | integer `0` | CLI optional; 1.2.16 form fixes `0` | installed client fixed value | nonzero values fail before mutation | C/U | `isPaid` |
|
|
279
|
+
| `price` | `price` | number `0` | CLI optional; 1.2.16 form fixes `0` | installed client fixed value | nonzero values fail before mutation | C/U | `price` |
|
|
280
|
+
| `is_public` | `isPublic` | integer `0/1` | CLI optional; 1.2.16 form fixes `1` | fixed boolean | soft-delete later forces private | C/U | `isPublic` |
|
|
281
|
+
| `share_type` | `shareType` | integer `0` | CLI optional; 1.2.16 form fixes `0` | installed client fixed value | nonzero values fail before mutation | C/U | `shareType` |
|
|
282
|
+
| `required_tier_id` | `requiredTierId` | positive integer/null | default none: omitted | normal/season tier options | selected tier disables BigFoot | C/U | `requiredTierId`; absence means none |
|
|
283
|
+
| `sub_type` | `subType` | integer `0/1` | helper default `0` | normal=`0`, season=`1` | selects tier option source | C/U | `subType` |
|
|
284
|
+
| `platform` | `platform` | integer `1/3` | C/U default `1` | ModUs=`1`, ModUs+BigFoot=`3` | at least ModUs; tier selection forces `1` | C/U | `platform` |
|
|
285
|
+
| `synchronization_type` | `synchronizationType` | integer `1/3` | C/U defaults from platform | derived | 1.2.16 sends it on both C and U | C/U | `synchronizationType` |
|
|
286
|
+
| `server_type` | header `X-Server-Type` | integer `0..4` | local Build or fallback `0` | fixed Build table | target namespace, not platform | C/U/D/detail/version | record under same Build |
|
|
287
|
+
| `confirm` | not sent | literal `"DELETE"` | D required | fixed CLI guard | destructive action only | local guard then D | `status=4,isPublic=0`; absent active list |
|
|
288
|
+
|
|
289
|
+
Version history is separate from article metadata:
|
|
290
|
+
|
|
291
|
+
| CLI field | Wire name | JSON type | Required/default | Enum/source | State/linkage rule | Write endpoint | Detail/readback location |
|
|
292
|
+
| --- | --- | --- | --- | --- | --- | --- | --- |
|
|
293
|
+
| `import_id` | `importId` | opaque decimal string | publish required | article detail | parent must exist | `POST /system/user/import/version/publish` | `versionList[].importId`/parent detail |
|
|
294
|
+
| `version` | `version` | non-empty string, max 120 | publish required | caller | expected immutable version label | same | `versionList[].version` |
|
|
295
|
+
| `code_text` | `codeText` | non-empty string | publish required | caller | content hash/length must match evidence | same | current top-level value is not echoed; publish the next version, then read the previous value from `versionList[].codeText` |
|
|
296
|
+
| `changelog` | `changelog` | string, max 10000 | optional; empty omitted | caller | version-specific | same | `versionList[].changelog` |
|
|
297
|
+
| `version_id` | query `versionId` | opaque identifier | delete required | `versionList[].versionId`/`id` | target exact version | `DELETE /system/user/import/version/delete?versionId={id}` | version absent from refreshed list |
|
|
298
|
+
| `confirm` | not sent | literal `"DELETE"` | version delete required | fixed CLI guard | destructive action only | local guard | refreshed `versionList` |
|
|
299
|
+
|
|
300
|
+
## Image upload and delete contract
|
|
301
|
+
|
|
302
|
+
There are three different image surfaces; they must not be collapsed into one
|
|
303
|
+
generic "image URL" test.
|
|
304
|
+
|
|
305
|
+
| Surface/CLI field | Official 1.2.16 wire | Input/preprocessing | Required headers | Response/reference | Delete behavior | Current CLI status/readback |
|
|
306
|
+
| --- | --- | --- | --- | --- | --- | --- |
|
|
307
|
+
| Creator logo: `logo_base64` / `screenshot_base64s` | C project JSON `screenshotBase64sReqs:{name:"logo.webp",screenshotBase64s:<base64>}` | prepared WebP base64 | Creator JSON auth | project `logo` path | U `imagesOps` upload/delete `logo.webp` | live create, replace, delete, restore and detail readback passed |
|
|
308
|
+
| Creator screenshots | U project JSON `imagesOps` | prepared WebP base64 for upload; names for delete/rename | Creator JSON auth | project `images` count | upload/delete use `name`; rename uses `from`/`to` | two-image upload, rename, single delete and final delete all passed with detail counts |
|
|
309
|
+
| Config cover/gallery: `image_url` | `POST /game/data/file/upload/file/image`, multipart field exactly `file`; one request per image | original local file; up to 10 comma-delimited references | raw `Authorization`, optional `X-Device-Id`; no `X-Server-Type` | key priority `cosStoreKey`, `cosStoreUrl`, `key`, then pathname of `downloadUrl/url/fileUrl`; CSV first key is cover | remove/reorder key in CSV and submit config U; no object-delete endpoint observed | two distinct local images uploaded with byte hashes; returned keys passed config C/U detail readback |
|
|
310
|
+
| WA cover/gallery: `image_url` | same endpoint and multipart `file`; one request per image | original local file; up to 10 comma-delimited references | same; no `X-Server-Type` | same key priority and CSV ordering | remove/reorder key in CSV and submit WA U; no object-delete endpoint observed | two distinct local images uploaded with byte hashes; returned keys passed WA C/U detail readback |
|
|
311
|
+
|
|
312
|
+
The 1.2.16 upload helper is `FormData.append("file", File)` and sets
|
|
313
|
+
`Content-Type: multipart/form-data`; the browser adapter supplies the boundary,
|
|
314
|
+
filename, and part MIME. The CLI multipart helper supplies a generated boundary,
|
|
315
|
+
uses the local basename, derives MIME through `mimetypes.guess_type`, and fixes
|
|
316
|
+
`.webp` to `image/webp`. A key-only response is accepted and its display URL is
|
|
317
|
+
synthesized. The CLI does not reproduce the UI's optional client-side image
|
|
318
|
+
compression; the final implementation's original-file upload was accepted by
|
|
319
|
+
the live service and the returned object keys were used in real C/U requests.
|
|
320
|
+
|
|
321
|
+
## Build and linked-state regression matrix
|
|
322
|
+
|
|
323
|
+
This is the A10 branch inventory. Positive rows were sent/read back where the
|
|
324
|
+
installed clients expose a writable branch; negative rows fail locally before
|
|
325
|
+
remote mutation.
|
|
326
|
+
|
|
327
|
+
| Branch | Positive cases | Negative case | Current implementation/gate |
|
|
328
|
+
| --- | --- | --- | --- |
|
|
329
|
+
| Build | `0 retail`, `1 classic_era`, `2 classic`, `3 classic_titan`, `4 anniversary`; for each, backup/config/WA list sends identical body `server` and `X-Server-Type` | `<0`, `>4`, non-integer | `_build` and schema reject outside `0..4`; all five live list triplets passed |
|
|
330
|
+
| Creator publish platform | ModUs-only `1`, BigFoot-only `2`, both `3` | empty, unknown, duplicate | state machine covers at-least-one/enum/duplicate |
|
|
331
|
+
| Creator category | 1-5 IDs from live categories | empty, >5, unknown/non-ID; `998` with ModUs/both | local count/linkage plus write preflight membership against `plugin/list/Categories`; real unknown positive ID fails before project mutation |
|
|
332
|
+
| Config/WA platform | omitted defaults ModUs `1`; explicit `1`; explicit ModUs+BigFoot `3` | `0`, `2`, other; mismatched platform/synchronization pairs | schema choices cover numeric invalids; write normalization requires the pair to be present together and equal; it is distinct from Build |
|
|
333
|
+
| Tier mode | normal `subType=0`, season `1`; each allows none by omitting `requiredTierId`; selected tier is positive dynamic ID | zero/negative/non-ID; ID absent from current normal/season options | write preflight reads the selected tier option set and rejects unknown IDs before mutation; the tested account exposes no tier, so real positive writes cover the no-tier branch |
|
|
334
|
+
| Tier + platform | no-tier allows platform `1/3`; selected tier forces platform `1` in UI | selected tier with platform/synchronization `3` | schema rejects tier+BigFoot; current account's live options are empty so real writes use the confirmed no-tier branch |
|
|
335
|
+
| Config WTF | `exclude_wtf=1` with empty account/role; `0` with an account and, when available, one of its roles | `1` with account/role; `0` without account; backup/account/role absent from the selected Build hierarchy; missing role when the account has roles | write preflight reads the selected Build backup hierarchy and rejects every invalid linkage before mutation |
|
|
336
|
+
| Tags | 1, 2, and 3 dynamic IDs | 0, 4, empty/duplicate CSV position, ID absent from the current config/WA tag options | schema covers count/shape/duplicates; write preflight binds every ID to the selected Build's current config/WA options before mutation |
|
|
337
|
+
| Paid/price | installed 1.2.16 forms submit fixed free `is_paid=0,price=0,share_type=0` | paid value, positive/negative price, or nonzero share type | schema/write preflight enforce the installed UI's fixed free branch; real field cycles cover exactly those values and do not claim an unavailable paid branch |
|
|
338
|
+
| Visibility | public `1`, private `0` where UI exposes it | other integer | schema enum covered; WA 1.2.16 form fixes public |
|
|
339
|
+
| Config/WA image CSV | 1 and 10 keys; first key is cover | empty C, >10, empty CSV entries, upload business code >=400 | schema enforces 1-10 non-empty references; media error and live C/U readback covered |
|
|
340
|
+
| Creator image ops | upload, delete, rename with matching required members | missing/extra member, upload without base64, delete with base64 | local validation plus live upload/rename/delete/readback covered |
|
|
341
|
+
|
|
342
|
+
### Acceptance coverage crosswalk
|
|
343
|
+
|
|
344
|
+
| Acceptance item | Matrix/evidence required here | Documentation status |
|
|
345
|
+
| --- | --- | --- |
|
|
346
|
+
| A2/A3 | Creator project table plus per-field C/U/readback/restore and logo/screenshot/image operations | `analyze/modus-creator/iteration4-live-regression.json`: passed, 140 steps; SHA-256 `7C7BE87959024A1431BC154B13830178815FFF32D23D55E4EAAED75B96B56E4B`; 126 positive exits `0`, 14 expected negative/not-found exits `2`, and unknown positive category membership fails before project mutation; CDN bytes matched local image SHA-256 after create/replace/delete/restore, then final project deletion made the restored logo return 404 |
|
|
347
|
+
| A4 | allocation, metadata, two distinct ZIP PUTs, detail, type/version/path edit/restore and cleanup | same evidence; ZIP SHA-256 and changed MD5/size recorded |
|
|
348
|
+
| A5-A9 | five Builds, config/WA per-field cycles, media upload, version rollover/readback/delete and cleanup | `analyze/modus/live-main-crud-builds-20260826.json`: passed, 209 steps/40 field checks; SHA-256 `298CD33D8E9B72E5AE7B63B6A82627622493CB2DC7476AFE61434E395312BB78`; 191 positive exits `0` and 18 negative exits `2`/`validation_error`; four media uploads carry nonempty byte/SHA-256 evidence for two different binaries, while account/role evidence stores presence only |
|
|
349
|
+
| A10 | linked-state table above, dynamic options and invalid combinations | live option selection plus schema/negative regression passed |
|
|
350
|
+
| A11 | commands, redacted input/response, exit status and content/image/ZIP digest evidence | both evidence records retain hashes instead of credentials or raw content |
|
|
351
|
+
| A19 | complete field names, wire names, types, defaults, sources, dependencies, endpoints and readback locations | matrices in this document |
|
|
188
352
|
|
|
189
353
|
Both main-client modules support explicit `confirm: "DELETE"` on destructive
|
|
190
354
|
commands. List operations send only server fields (`pageNum`, `pageSize`,
|
|
@@ -248,7 +248,13 @@ def _modus_tree(platforms: argparse._SubParsersAction) -> None:
|
|
|
248
248
|
)
|
|
249
249
|
groups = root.add_subparsers(dest="resource_command", required=True)
|
|
250
250
|
session = groups.add_parser("session", help="Local authentication diagnostics").add_subparsers(dest="action_command", required=True)
|
|
251
|
-
_read_leaf(session, "doctor", "Check
|
|
251
|
+
doctor = _read_leaf(session, "doctor", "Check one local ModUs token store, DPAPI decryption, and authenticated API readiness without exposing credentials.", platform="modus", resource="session", action="doctor")
|
|
252
|
+
doctor.add_argument(
|
|
253
|
+
"--client",
|
|
254
|
+
choices=("creator", "main"),
|
|
255
|
+
default="creator",
|
|
256
|
+
help="Authenticated client session to diagnose (technical default: creator).",
|
|
257
|
+
)
|
|
252
258
|
_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")
|
|
253
259
|
|
|
254
260
|
account = groups.add_parser("account", help="ModUs author account and statistics").add_subparsers(dest="action_command", required=True)
|
|
@@ -303,6 +309,9 @@ def _modus_tree(platforms: argparse._SubParsersAction) -> None:
|
|
|
303
309
|
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
310
|
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
311
|
|
|
312
|
+
media = groups.add_parser("media", help="ModUs main-client image uploads").add_subparsers(dest="action_command", required=True)
|
|
313
|
+
_write_leaf(media, "modus", "media", "upload", "Upload one local image and return its reusable ModUs object key and URL.")
|
|
314
|
+
|
|
306
315
|
config = groups.add_parser("config", help="ModUs main-client configuration shares and backups").add_subparsers(dest="action_command", required=True)
|
|
307
316
|
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
317
|
_write_leaf(config, "modus", "config", action, text)
|
|
@@ -378,7 +387,7 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
|
378
387
|
if args.dry_run:
|
|
379
388
|
write_output(platform, operation, _dry_run_data(doc, schema.name), dry_run=True)
|
|
380
389
|
return 0
|
|
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")))))
|
|
390
|
+
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 ("media", "config", "wa")))))
|
|
382
391
|
try:
|
|
383
392
|
if platform == "dd":
|
|
384
393
|
data = provider.execute_write(resource, action, doc, getattr(args, "session", None))
|
|
@@ -391,7 +400,10 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
|
391
400
|
write_output(platform, operation, data)
|
|
392
401
|
return 0
|
|
393
402
|
modus_doctor = platform == "modus" and resource == "session" and action == "doctor"
|
|
394
|
-
|
|
403
|
+
modus_main_session = resource in ("addon", "plugin", "config", "wa", "builds") or (
|
|
404
|
+
modus_doctor and getattr(args, "client", "creator") == "main"
|
|
405
|
+
)
|
|
406
|
+
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=modus_main_session))))
|
|
395
407
|
try:
|
|
396
408
|
if platform == "dd":
|
|
397
409
|
data = provider.execute_read(resource, action, args, getattr(args, "session", None))
|
|
@@ -282,6 +282,31 @@ def _load_live_state() -> Optional[Dict[str, Any]]:
|
|
|
282
282
|
return value
|
|
283
283
|
|
|
284
284
|
|
|
285
|
+
def _remove_session_state(path: Path, session_id: str, timeout: float = 5) -> bool:
|
|
286
|
+
deadline = time.monotonic() + timeout
|
|
287
|
+
while True:
|
|
288
|
+
try:
|
|
289
|
+
current = _read_json(path)
|
|
290
|
+
except FuploadError as exc:
|
|
291
|
+
if isinstance(exc.__cause__, FileNotFoundError):
|
|
292
|
+
return True
|
|
293
|
+
if not isinstance(exc.__cause__, OSError):
|
|
294
|
+
return False
|
|
295
|
+
else:
|
|
296
|
+
if current.get("session_id") != session_id:
|
|
297
|
+
return False
|
|
298
|
+
try:
|
|
299
|
+
path.unlink()
|
|
300
|
+
return True
|
|
301
|
+
except FileNotFoundError:
|
|
302
|
+
return True
|
|
303
|
+
except OSError:
|
|
304
|
+
pass
|
|
305
|
+
if time.monotonic() >= deadline:
|
|
306
|
+
return False
|
|
307
|
+
time.sleep(0.05)
|
|
308
|
+
|
|
309
|
+
|
|
285
310
|
def doctor() -> Dict[str, Any]:
|
|
286
311
|
dd_dir, signature = _dd_module().discover_dd_info()
|
|
287
312
|
processes = running_dd_processes()
|
|
@@ -471,15 +496,23 @@ def status(session_id: Optional[str] = None) -> Dict[str, Any]:
|
|
|
471
496
|
|
|
472
497
|
def stop(session_id: str) -> Dict[str, Any]:
|
|
473
498
|
data = _send({"session_id": session_id, "command": "stop"}, timeout=30)
|
|
474
|
-
deadline = time.
|
|
475
|
-
while
|
|
499
|
+
deadline = time.monotonic() + 30
|
|
500
|
+
while True:
|
|
501
|
+
try:
|
|
502
|
+
state = _load_live_state()
|
|
503
|
+
except FuploadError as exc:
|
|
504
|
+
if exc.kind != "session_error" or not isinstance(exc.__cause__, OSError):
|
|
505
|
+
raise
|
|
506
|
+
else:
|
|
507
|
+
if not state:
|
|
508
|
+
break
|
|
509
|
+
if time.monotonic() >= deadline:
|
|
510
|
+
raise FuploadError(
|
|
511
|
+
"DD task session acknowledged stop but did not finish cleanup",
|
|
512
|
+
kind="session_stop_failed",
|
|
513
|
+
stage="session",
|
|
514
|
+
)
|
|
476
515
|
time.sleep(0.05)
|
|
477
|
-
if _load_live_state():
|
|
478
|
-
raise FuploadError(
|
|
479
|
-
"DD task session acknowledged stop but did not finish cleanup",
|
|
480
|
-
kind="session_stop_failed",
|
|
481
|
-
stage="session",
|
|
482
|
-
)
|
|
483
516
|
result = dict(data or {})
|
|
484
517
|
result["cleanup_complete"] = True
|
|
485
518
|
return result
|
|
@@ -615,12 +648,7 @@ def _serve(startup_id: str) -> int:
|
|
|
615
648
|
listener.close()
|
|
616
649
|
if sidecar is not None:
|
|
617
650
|
sidecar.__exit__(None, None, None)
|
|
618
|
-
|
|
619
|
-
current = _read_json(state_path)
|
|
620
|
-
if current.get("session_id") == session_id:
|
|
621
|
-
state_path.unlink()
|
|
622
|
-
except (FuploadError, OSError):
|
|
623
|
-
pass
|
|
651
|
+
_remove_session_state(state_path, session_id)
|
|
624
652
|
|
|
625
653
|
|
|
626
654
|
def main(argv: Optional[List[str]] = None) -> int:
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import base64
|
|
6
|
+
import binascii
|
|
5
7
|
import hashlib
|
|
6
8
|
import json
|
|
7
9
|
import sys
|
|
@@ -22,8 +24,10 @@ _SENSITIVE_KEYS = {
|
|
|
22
24
|
}
|
|
23
25
|
_RAW_CONTENT_KEYS = {
|
|
24
26
|
"content", "wa_str", "t_wa_str", "raw_wtf", "wtf_zip", "download_url",
|
|
25
|
-
"import_string",
|
|
27
|
+
"import_string", "content_text", "contenttext", "code_text", "codetext",
|
|
28
|
+
"description", "changelog", "license_content", "licensecontent",
|
|
26
29
|
}
|
|
30
|
+
_BASE64_CONTENT_KEYS = {"base64", "logo_base64", "screenshot_base64s"}
|
|
27
31
|
|
|
28
32
|
|
|
29
33
|
class _DuplicateKey(ValueError):
|
|
@@ -102,6 +106,18 @@ def sanitize_output(value: Any) -> Any:
|
|
|
102
106
|
"bytes": len(text),
|
|
103
107
|
"sha256": hashlib.sha256(text).hexdigest(),
|
|
104
108
|
}
|
|
109
|
+
elif normalized in _BASE64_CONTENT_KEYS and isinstance(item, str):
|
|
110
|
+
try:
|
|
111
|
+
content = base64.b64decode(item, validate=True)
|
|
112
|
+
encoding = "base64"
|
|
113
|
+
except (binascii.Error, ValueError):
|
|
114
|
+
content = item.encode("utf-8")
|
|
115
|
+
encoding = "utf-8"
|
|
116
|
+
result[str(key) + "_summary"] = {
|
|
117
|
+
"bytes": len(content),
|
|
118
|
+
"sha256": hashlib.sha256(content).hexdigest(),
|
|
119
|
+
"source_encoding": encoding,
|
|
120
|
+
}
|
|
105
121
|
else:
|
|
106
122
|
result[key] = sanitize_output(item)
|
|
107
123
|
return result
|
|
@@ -25,11 +25,12 @@ 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
|
+
from .transport import json_request, multipart_request
|
|
29
29
|
|
|
30
30
|
|
|
31
31
|
API_BASE = "https://app.modus.cool/api/"
|
|
32
32
|
STATIC_API_BASE = "https://cdn.modus.cool/modus/client_static_api/"
|
|
33
|
+
RESOURCE_BASE = "https://cdn.modus.cool/"
|
|
33
34
|
TOKEN_PATH = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData/Local")) / "ModUs.Creator" / "auth" / "token.dat"
|
|
34
35
|
TOKEN_ENTROPY = b"ModUs.Creator.TokenStore.v1"
|
|
35
36
|
MODUS_APPDATA = Path(os.environ.get("APPDATA", Path.home() / "AppData/Roaming")) / "modus"
|
|
@@ -110,6 +111,14 @@ def _category_ids(value: Any) -> Any:
|
|
|
110
111
|
return output
|
|
111
112
|
|
|
112
113
|
|
|
114
|
+
def _tag_filter(value: Any) -> list[str]:
|
|
115
|
+
values = value if isinstance(value, (list, tuple, set)) else str(value).split(",")
|
|
116
|
+
result = [str(item).strip() for item in values if str(item).strip()]
|
|
117
|
+
if not result:
|
|
118
|
+
raise ValidationError("tag filter must contain at least one ID", path="tags")
|
|
119
|
+
return result
|
|
120
|
+
|
|
121
|
+
|
|
113
122
|
def _sync_type(value: Any) -> Any:
|
|
114
123
|
if value is None:
|
|
115
124
|
return 0
|
|
@@ -134,13 +143,22 @@ def _image_ops(value: Any) -> list[Dict[str, str]]:
|
|
|
134
143
|
path = "image_ops[%d]" % index
|
|
135
144
|
if not isinstance(item, Mapping):
|
|
136
145
|
raise ValidationError("image operation must be an object", path=path)
|
|
137
|
-
|
|
146
|
+
op = item.get("op")
|
|
147
|
+
if op not in ("upload", "delete", "rename"):
|
|
148
|
+
raise ValidationError("image operation must be upload, delete, or rename", path=path + ".op")
|
|
149
|
+
allowed = {"op", "from", "to"} if op == "rename" else {"op", "name", "base64"}
|
|
150
|
+
unknown = sorted(set(item) - allowed)
|
|
138
151
|
if unknown:
|
|
139
152
|
raise ValidationError("unknown image operation field(s): %s" % ", ".join(unknown), path=path + "." + unknown[0])
|
|
140
|
-
op
|
|
153
|
+
if op == "rename":
|
|
154
|
+
source, target = item.get("from"), item.get("to")
|
|
155
|
+
if not isinstance(source, str) or not source.strip():
|
|
156
|
+
raise ValidationError("rename image operation requires from", path=path + ".from")
|
|
157
|
+
if not isinstance(target, str) or not target.strip():
|
|
158
|
+
raise ValidationError("rename image operation requires to", path=path + ".to")
|
|
159
|
+
result.append({"op": op, "from": source.strip(), "to": target.strip()})
|
|
160
|
+
continue
|
|
141
161
|
name = item.get("name")
|
|
142
|
-
if op not in ("upload", "delete"):
|
|
143
|
-
raise ValidationError("image operation must be upload or delete", path=path + ".op")
|
|
144
162
|
if not isinstance(name, str) or not name.strip():
|
|
145
163
|
raise ValidationError("image operation name must be non-empty", path=path + ".name")
|
|
146
164
|
operation = {"op": op, "name": name.strip()}
|
|
@@ -294,7 +312,7 @@ def _project_wire(value: Mapping[str, Any], *, create: bool = False) -> Dict[str
|
|
|
294
312
|
"""Project project create/update fields to Creator's request shape."""
|
|
295
313
|
result: Dict[str, Any] = {}
|
|
296
314
|
for key in ("name", "alt_name", "summary", "repo_url"):
|
|
297
|
-
if key in value and value[key] is not None:
|
|
315
|
+
if key in value and (value[key] is not None or not create):
|
|
298
316
|
wire_name = _WIRE_NAMES.get(key, key)
|
|
299
317
|
if create:
|
|
300
318
|
if key == "repo_url" and not str(value[key]).strip():
|
|
@@ -363,13 +381,56 @@ def _business_code(payload: Mapping[str, Any]) -> Optional[int]:
|
|
|
363
381
|
return None
|
|
364
382
|
|
|
365
383
|
|
|
384
|
+
def _image_upload_record(payload: Any) -> Dict[str, str]:
|
|
385
|
+
"""Extract the reusable key and display URL used by the main client."""
|
|
386
|
+
data = _unwrap(payload)
|
|
387
|
+
if not isinstance(data, Mapping):
|
|
388
|
+
raise FuploadError(
|
|
389
|
+
"ModUs image upload returned no media record",
|
|
390
|
+
kind="platform_data_error",
|
|
391
|
+
stage="media_upload",
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
download_url = data.get("downloadUrl") or data.get("url") or data.get("fileUrl")
|
|
395
|
+
reference = data.get("cosStoreKey") or data.get("cosStoreUrl") or data.get("key")
|
|
396
|
+
if not reference and isinstance(download_url, str) and download_url.strip():
|
|
397
|
+
parsed = urllib.parse.urlsplit(download_url.strip())
|
|
398
|
+
reference = urllib.parse.unquote(parsed.path.lstrip("/"))
|
|
399
|
+
if not isinstance(reference, str) or not reference.strip():
|
|
400
|
+
raise FuploadError(
|
|
401
|
+
"ModUs image upload omitted its object key",
|
|
402
|
+
kind="platform_data_error",
|
|
403
|
+
stage="media_upload",
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
reference = reference.strip()
|
|
407
|
+
if isinstance(download_url, str) and download_url.strip():
|
|
408
|
+
display_url = download_url.strip()
|
|
409
|
+
elif reference.startswith(("https://", "http://")):
|
|
410
|
+
display_url = reference
|
|
411
|
+
else:
|
|
412
|
+
display_url = urllib.parse.urljoin(RESOURCE_BASE, reference.lstrip("/"))
|
|
413
|
+
return {"key": reference, "url": display_url, "reference": reference}
|
|
414
|
+
|
|
415
|
+
|
|
366
416
|
def _safe(value: Any) -> Any:
|
|
367
417
|
"""Recursively redact credentials and presigned URLs in API results."""
|
|
368
418
|
if isinstance(value, Mapping):
|
|
369
419
|
result = {}
|
|
370
420
|
for key, item in value.items():
|
|
371
421
|
normalized = str(key).replace("-", "_").lower()
|
|
372
|
-
|
|
422
|
+
if normalized in _SECRET_KEYS:
|
|
423
|
+
result[str(key)] = "[REDACTED]"
|
|
424
|
+
elif normalized in {
|
|
425
|
+
"content", "contenttext", "codetext", "changelog",
|
|
426
|
+
"description", "licensecontent",
|
|
427
|
+
}:
|
|
428
|
+
# write_output() hashes these fields before serializing JSON.
|
|
429
|
+
# Preserve the original here so the digest represents the
|
|
430
|
+
# service value instead of a generic redaction placeholder.
|
|
431
|
+
result[str(key)] = item
|
|
432
|
+
else:
|
|
433
|
+
result[str(key)] = _safe(item)
|
|
373
434
|
return result
|
|
374
435
|
if isinstance(value, list):
|
|
375
436
|
return [_safe(item) for item in value]
|
|
@@ -599,7 +660,7 @@ class ModUs:
|
|
|
599
660
|
except Exception:
|
|
600
661
|
detail = "HTTP %d" % exc.code
|
|
601
662
|
raise FuploadError(redact(str(detail)), endpoint=url, http_status=exc.code, kind="platform_error", stage=stage) from exc
|
|
602
|
-
except (OSError, urllib.error.URLError) as exc:
|
|
663
|
+
except (OSError, urllib.error.URLError, http.client.IncompleteRead) as exc:
|
|
603
664
|
raise FuploadError("ModUs request failed: %s" % exc, endpoint=url, verification_required=method != "GET", stage=stage) from exc
|
|
604
665
|
if status < 200 or status >= 300:
|
|
605
666
|
raise FuploadError("ModUs returned HTTP %d" % status, endpoint=url, http_status=status, stage=stage)
|
|
@@ -621,6 +682,32 @@ class ModUs:
|
|
|
621
682
|
return payload
|
|
622
683
|
|
|
623
684
|
def doctor(self) -> Dict[str, Any]:
|
|
685
|
+
if self.main_session:
|
|
686
|
+
result = {
|
|
687
|
+
"token_present": False,
|
|
688
|
+
"token_decrypted": False,
|
|
689
|
+
"token_nonempty": False,
|
|
690
|
+
"api_ready": False,
|
|
691
|
+
}
|
|
692
|
+
try:
|
|
693
|
+
session = load_main_session()
|
|
694
|
+
token = str(session.get("token") or "").strip()
|
|
695
|
+
result["token_present"] = bool(token)
|
|
696
|
+
result["token_decrypted"] = bool(token)
|
|
697
|
+
result["token_nonempty"] = bool(token)
|
|
698
|
+
if not token:
|
|
699
|
+
return result
|
|
700
|
+
previous_token, previous_device = self.token, self.device_id
|
|
701
|
+
self.token = token
|
|
702
|
+
self.device_id = session.get("device_id")
|
|
703
|
+
try:
|
|
704
|
+
self.user_info()
|
|
705
|
+
result["api_ready"] = True
|
|
706
|
+
finally:
|
|
707
|
+
self.token, self.device_id = previous_token, previous_device
|
|
708
|
+
except (FuploadError, OSError, UnicodeError, ValueError, TypeError):
|
|
709
|
+
pass
|
|
710
|
+
return result
|
|
624
711
|
selected = self.token_path
|
|
625
712
|
if not selected.is_file() and selected.with_name("token.json").is_file():
|
|
626
713
|
selected = selected.with_name("token.json")
|
|
@@ -771,6 +858,143 @@ class ModUs:
|
|
|
771
858
|
raise ValidationError("unsupported ModUs options operation")
|
|
772
859
|
return _safe(_unwrap(self._request("GET", routes[action])))
|
|
773
860
|
|
|
861
|
+
@staticmethod
|
|
862
|
+
def _option_rows(value: Any) -> list[Mapping[str, Any]]:
|
|
863
|
+
if isinstance(value, Mapping):
|
|
864
|
+
value = value.get("rows", value.get("data", []))
|
|
865
|
+
return [item for item in value if isinstance(item, Mapping)] if isinstance(value, list) else []
|
|
866
|
+
|
|
867
|
+
@classmethod
|
|
868
|
+
def _nested_option_rows(cls, value: Any) -> list[Mapping[str, Any]]:
|
|
869
|
+
rows: list[Mapping[str, Any]] = []
|
|
870
|
+
pending = list(cls._option_rows(value))
|
|
871
|
+
while pending:
|
|
872
|
+
item = pending.pop(0)
|
|
873
|
+
rows.append(item)
|
|
874
|
+
pending.extend(cls._option_rows(item.get("children", [])))
|
|
875
|
+
return rows
|
|
876
|
+
|
|
877
|
+
@staticmethod
|
|
878
|
+
def _csv_values(value: Any) -> list[str]:
|
|
879
|
+
return [item.strip() for item in str(value or "").split(",") if item.strip()]
|
|
880
|
+
|
|
881
|
+
@staticmethod
|
|
882
|
+
def _account_rows(backup: Mapping[str, Any]) -> list[Mapping[str, Any]]:
|
|
883
|
+
value = backup.get("wtfAccounts") or []
|
|
884
|
+
if isinstance(value, str):
|
|
885
|
+
try:
|
|
886
|
+
value = json.loads(value)
|
|
887
|
+
except (TypeError, ValueError):
|
|
888
|
+
value = []
|
|
889
|
+
return [item for item in value if isinstance(item, Mapping)] if isinstance(value, list) else []
|
|
890
|
+
|
|
891
|
+
@staticmethod
|
|
892
|
+
def _backup_addons_id(backup: Mapping[str, Any]) -> Optional[str]:
|
|
893
|
+
value = backup.get("knownAddons")
|
|
894
|
+
if not isinstance(value, str) or not value.strip():
|
|
895
|
+
return None
|
|
896
|
+
try:
|
|
897
|
+
parsed = json.loads(value)
|
|
898
|
+
except (TypeError, ValueError):
|
|
899
|
+
return None
|
|
900
|
+
project_ids = parsed.get("projectids") if isinstance(parsed, Mapping) else None
|
|
901
|
+
return None if project_ids is None else str(project_ids)
|
|
902
|
+
|
|
903
|
+
def _validate_project_write(self, action: str, doc: Mapping[str, Any]) -> None:
|
|
904
|
+
if action not in {"create", "release", "update", "edit"}:
|
|
905
|
+
return
|
|
906
|
+
document = _project_document(doc)
|
|
907
|
+
allowed_categories = {
|
|
908
|
+
int(item["id"])
|
|
909
|
+
for item in self._nested_option_rows(self.options("categories"))
|
|
910
|
+
if item.get("id") is not None
|
|
911
|
+
}
|
|
912
|
+
unknown = [item for item in document.get("categories", []) if item not in allowed_categories]
|
|
913
|
+
if unknown:
|
|
914
|
+
raise ValidationError(
|
|
915
|
+
"category is not present in current Creator options",
|
|
916
|
+
path="$.project_state.basic_info.categories",
|
|
917
|
+
)
|
|
918
|
+
|
|
919
|
+
def _validate_main_write(self, resource: str, action: str, doc: Mapping[str, Any]) -> None:
|
|
920
|
+
if resource not in {"config", "wa"} or action not in {"create", "update", "edit"}:
|
|
921
|
+
return
|
|
922
|
+
|
|
923
|
+
if "tags" in doc:
|
|
924
|
+
option_name = "config-tags" if resource == "config" else "wa-tags"
|
|
925
|
+
allowed_tags = {str(item.get("id")) for item in self._option_rows(self.options(option_name)) if item.get("id") is not None}
|
|
926
|
+
unknown = [item for item in self._csv_values(doc.get("tags")) if item not in allowed_tags]
|
|
927
|
+
if unknown:
|
|
928
|
+
raise ValidationError("tag is not present in current %s options" % option_name, path="$.tags")
|
|
929
|
+
|
|
930
|
+
tier_id = doc.get("required_tier_id")
|
|
931
|
+
if tier_id is not None:
|
|
932
|
+
tiers = self._option_rows(self.options("subscription-tiers"))
|
|
933
|
+
allowed_tiers = {
|
|
934
|
+
str(item.get("id"))
|
|
935
|
+
for item in tiers
|
|
936
|
+
if item.get("id") is not None and item.get("isEnabled", item.get("is_enabled", 1)) not in (0, False)
|
|
937
|
+
}
|
|
938
|
+
if str(tier_id) not in allowed_tiers:
|
|
939
|
+
raise ValidationError("required_tier_id is not present in current tier options", path="$.required_tier_id")
|
|
940
|
+
|
|
941
|
+
if resource == "wa" and ("support_addon" in doc or "addons_id" in doc):
|
|
942
|
+
selected_doc = dict(doc)
|
|
943
|
+
if action in {"update", "edit"} and (
|
|
944
|
+
"support_addon" not in selected_doc or "addons_id" not in selected_doc
|
|
945
|
+
):
|
|
946
|
+
current = self.import_detail(doc["import_id"], server_type=doc.get("server_type"))
|
|
947
|
+
current = current[0] if isinstance(current, list) and current else current
|
|
948
|
+
if isinstance(current, Mapping):
|
|
949
|
+
selected_doc.setdefault("support_addon", current.get("supportAddon"))
|
|
950
|
+
selected_doc.setdefault("addons_id", current.get("addonsId"))
|
|
951
|
+
support_rows = self._option_rows(self.options("wa-support-addons"))
|
|
952
|
+
selected = next((item for item in support_rows if str(item.get("name")) == str(selected_doc.get("support_addon"))), None)
|
|
953
|
+
if selected is None or str(selected.get("id")) != str(selected_doc.get("addons_id")):
|
|
954
|
+
raise ValidationError("support_addon and addons_id must match current options", path="$.support_addon")
|
|
955
|
+
return
|
|
956
|
+
|
|
957
|
+
if resource != "config":
|
|
958
|
+
return
|
|
959
|
+
linkage_fields = {"backup_id", "addons_id", "account_name", "role_name", "exclude_wtf"}
|
|
960
|
+
if not linkage_fields.intersection(doc):
|
|
961
|
+
return
|
|
962
|
+
merged = dict(doc)
|
|
963
|
+
if action in {"update", "edit"} and "backup_id" not in merged:
|
|
964
|
+
current = self.share_detail(doc["share_id"], server_type=doc.get("server_type"))
|
|
965
|
+
current = current[0] if isinstance(current, list) and current else current
|
|
966
|
+
if isinstance(current, Mapping):
|
|
967
|
+
aliases = {
|
|
968
|
+
"backupId": "backup_id", "addonsId": "addons_id", "accountName": "account_name",
|
|
969
|
+
"roleName": "role_name", "excludeWtf": "exclude_wtf",
|
|
970
|
+
}
|
|
971
|
+
for wire, local in aliases.items():
|
|
972
|
+
if local not in merged and wire in current:
|
|
973
|
+
merged[local] = current[wire]
|
|
974
|
+
backup_id = merged.get("backup_id")
|
|
975
|
+
backups = self._option_rows(self.cloud_backups(server_type=merged.get("server_type")))
|
|
976
|
+
backup = next((item for item in backups if str(item.get("id")) == str(backup_id)), None)
|
|
977
|
+
if backup is None:
|
|
978
|
+
raise ValidationError("backup_id is not present in the selected Build", path="$.backup_id")
|
|
979
|
+
expected_addons = self._backup_addons_id(backup)
|
|
980
|
+
if expected_addons is not None and "addons_id" in merged and str(merged.get("addons_id")) != expected_addons:
|
|
981
|
+
raise ValidationError("addons_id does not match selected backup knownAddons.projectids", path="$.addons_id")
|
|
982
|
+
if int(merged.get("exclude_wtf") or 0) == 1:
|
|
983
|
+
return
|
|
984
|
+
account_name = str(merged.get("account_name") or "")
|
|
985
|
+
account = next((item for item in self._account_rows(backup) if account_name in {
|
|
986
|
+
str(item.get("id") or ""), str(item.get("accountId") or ""),
|
|
987
|
+
str(item.get("name") or ""), str(item.get("characterName") or ""),
|
|
988
|
+
}), None)
|
|
989
|
+
if account is None:
|
|
990
|
+
raise ValidationError("account_name is not present in selected backup", path="$.account_name")
|
|
991
|
+
roles = [str(item) for item in account.get("roles") or [] if str(item)]
|
|
992
|
+
role_name = str(merged.get("role_name") or "")
|
|
993
|
+
if roles and not role_name:
|
|
994
|
+
raise ValidationError("role_name is required for selected account", path="$.role_name")
|
|
995
|
+
if role_name and role_name not in roles:
|
|
996
|
+
raise ValidationError("role_name is not present in selected account", path="$.role_name")
|
|
997
|
+
|
|
774
998
|
# Main ModUs client: configuration shares and string articles.
|
|
775
999
|
def cloud_backups(self, *, server_type: Optional[int] = None) -> Any:
|
|
776
1000
|
return _safe(_unwrap(self._request("GET", "system/user/backup/list", headers={"X-Server-Type": str(self._build(server_type))})))
|
|
@@ -785,6 +1009,40 @@ class ModUs:
|
|
|
785
1009
|
def cloud_backup_delete(self, backup_id: int, *, server_type: Optional[int] = None) -> Any:
|
|
786
1010
|
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
1011
|
|
|
1012
|
+
def image_upload(self, file_path: str) -> Dict[str, Any]:
|
|
1013
|
+
"""Upload an image through the exact ModUs main-client multipart API."""
|
|
1014
|
+
path = Path(file_path)
|
|
1015
|
+
if not path.is_file():
|
|
1016
|
+
raise ValidationError("image file does not exist", path="$.file")
|
|
1017
|
+
authorization = self.token if self.authorization_scheme == "" else self.authorization_scheme + self.token
|
|
1018
|
+
headers = {"Accept": "application/json", "Authorization": authorization}
|
|
1019
|
+
if self.device_id:
|
|
1020
|
+
headers["X-Device-Id"] = self.device_id
|
|
1021
|
+
payload = multipart_request(
|
|
1022
|
+
self._url("game/data/file/upload/file/image"),
|
|
1023
|
+
str(path),
|
|
1024
|
+
file_field="file",
|
|
1025
|
+
headers=headers,
|
|
1026
|
+
timeout=max(self.timeout, 600),
|
|
1027
|
+
)
|
|
1028
|
+
if isinstance(payload, Mapping):
|
|
1029
|
+
business_code = _business_code(payload)
|
|
1030
|
+
if payload.get("success") is False or (business_code is not None and business_code != 200):
|
|
1031
|
+
raise FuploadError(
|
|
1032
|
+
str(payload.get("msg") or payload.get("message") or "ModUs image upload was rejected"),
|
|
1033
|
+
endpoint=self._url("game/data/file/upload/file/image"),
|
|
1034
|
+
business_code=business_code,
|
|
1035
|
+
kind="platform_error",
|
|
1036
|
+
stage="media_upload",
|
|
1037
|
+
)
|
|
1038
|
+
record = _image_upload_record(payload)
|
|
1039
|
+
content = path.read_bytes()
|
|
1040
|
+
return {
|
|
1041
|
+
**record,
|
|
1042
|
+
"bytes": len(content),
|
|
1043
|
+
"sha256": hashlib.sha256(content).hexdigest(),
|
|
1044
|
+
}
|
|
1045
|
+
|
|
788
1046
|
@staticmethod
|
|
789
1047
|
def _share_wire(doc: Mapping[str, Any]) -> Dict[str, Any]:
|
|
790
1048
|
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"}
|
|
@@ -793,7 +1051,7 @@ class ModUs:
|
|
|
793
1051
|
for key, value in doc.items():
|
|
794
1052
|
wire = aliases.get(key, key)
|
|
795
1053
|
if wire in allowed and value is not None:
|
|
796
|
-
result[wire] = value
|
|
1054
|
+
result[wire] = str(value) if wire == "addonsId" else value
|
|
797
1055
|
result.setdefault("platform", 1)
|
|
798
1056
|
exclude = result.get("excludeWtf", 0)
|
|
799
1057
|
result["excludeWtf"] = 1 if str(exclude).strip().lower() in {"1", "true"} else 0
|
|
@@ -815,7 +1073,9 @@ class ModUs:
|
|
|
815
1073
|
wire["platform"] = int(platform) if isinstance(platform, (int, float)) and not isinstance(platform, bool) else 0
|
|
816
1074
|
for key in ("keyword", "status", "tags", "order_by", "is_public", "is_paid"):
|
|
817
1075
|
if key in source and source[key] is not None:
|
|
818
|
-
wire[{"order_by": "orderBy", "is_public": "isPublic", "is_paid": "isPaid"}.get(key, key)] =
|
|
1076
|
+
wire[{"order_by": "orderBy", "is_public": "isPublic", "is_paid": "isPaid"}.get(key, key)] = (
|
|
1077
|
+
_tag_filter(source[key]) if key == "tags" else source[key]
|
|
1078
|
+
)
|
|
819
1079
|
return _safe(_unwrap(self._request("POST", "system/user/share/list", wire, headers={"X-Server-Type": str(selected_build)})))
|
|
820
1080
|
|
|
821
1081
|
def share_detail(self, share_ids: Any, *, server_type: Optional[int] = None) -> Any:
|
|
@@ -823,9 +1083,7 @@ class ModUs:
|
|
|
823
1083
|
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
1084
|
|
|
825
1085
|
def share_create(self, doc: Mapping[str, Any], *, server_type: Optional[int] = None) -> Any:
|
|
826
|
-
|
|
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))})))
|
|
1086
|
+
return _safe(_unwrap(self._request("POST", "system/user/share/create", self._share_wire(doc), headers={"X-Server-Type": str(self._build(server_type))})))
|
|
829
1087
|
|
|
830
1088
|
def share_update(self, doc: Mapping[str, Any], *, server_type: Optional[int] = None) -> Any:
|
|
831
1089
|
return _safe(_unwrap(self._request("PUT", "system/user/share/update", self._share_wire(doc), headers={"X-Server-Type": str(self._build(server_type))})))
|
|
@@ -838,6 +1096,8 @@ class ModUs:
|
|
|
838
1096
|
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
1097
|
allowed = {"id", "codeText", "content", "addonsId", "contentText", "filePath", "imageUrl", "isPaid", "isPublic", "price", "shareType", "supportAddon", "tags", "title", "version", "requiredTierId", "subType", "platform", "synchronizationType"}
|
|
840
1098
|
result = {aliases.get(key, key): value for key, value in doc.items() if aliases.get(key, key) in allowed and value is not None}
|
|
1099
|
+
if "addonsId" in result:
|
|
1100
|
+
result["addonsId"] = str(result["addonsId"])
|
|
841
1101
|
result.setdefault("platform", 1)
|
|
842
1102
|
result.setdefault("synchronizationType", 3 if result["platform"] == 3 else 1)
|
|
843
1103
|
return result
|
|
@@ -858,7 +1118,9 @@ class ModUs:
|
|
|
858
1118
|
wire["platform"] = int(platform) if isinstance(platform, (int, float)) and not isinstance(platform, bool) else 0
|
|
859
1119
|
for key in ("keyword", "support_addon", "tags", "is_paid", "order_by"):
|
|
860
1120
|
if key in source and source[key] is not None:
|
|
861
|
-
wire[{"support_addon": "supportAddon", "is_paid": "isPaid", "order_by": "orderBy"}.get(key, key)] =
|
|
1121
|
+
wire[{"support_addon": "supportAddon", "is_paid": "isPaid", "order_by": "orderBy"}.get(key, key)] = (
|
|
1122
|
+
_tag_filter(source[key]) if key == "tags" else source[key]
|
|
1123
|
+
)
|
|
862
1124
|
return _safe(_unwrap(self._request("POST", "system/user/import/list", wire, headers={"X-Server-Type": str(selected_build)})))
|
|
863
1125
|
|
|
864
1126
|
def import_detail(self, import_ids: Any, *, server_type: Optional[int] = None) -> Any:
|
|
@@ -1113,6 +1375,9 @@ class ModUs:
|
|
|
1113
1375
|
raise ValidationError("unsupported ModUs read operation")
|
|
1114
1376
|
|
|
1115
1377
|
def execute_write(self, resource: str, action: str, doc: Mapping[str, Any]) -> Any:
|
|
1378
|
+
if resource == "media" and action == "upload": return self.image_upload(str(doc["file"]))
|
|
1379
|
+
if resource == "project": self._validate_project_write(action, doc)
|
|
1380
|
+
self._validate_main_write(resource, action, doc)
|
|
1116
1381
|
if resource == "config" and action == "create": return self.share_create(doc, server_type=doc.get("server_type"))
|
|
1117
1382
|
if resource == "config" and action in ("update", "edit"): return self.share_update(doc, server_type=doc.get("server_type"))
|
|
1118
1383
|
if resource == "config" and action == "delete": return self.share_delete(doc["share_id"], server_type=doc.get("server_type"))
|
|
@@ -114,6 +114,37 @@ class Schema:
|
|
|
114
114
|
tags = [item.strip() for item in str(value["tags"]).split(",")]
|
|
115
115
|
if any(not item for item in tags) or not 1 <= len(tags) <= 3:
|
|
116
116
|
raise ValidationError("must contain 1 to 3 comma-delimited tag IDs", path="$.tags")
|
|
117
|
+
if len(set(tags)) != len(tags):
|
|
118
|
+
raise ValidationError("must not contain duplicate tag IDs", path="$.tags")
|
|
119
|
+
if "image_url" in value:
|
|
120
|
+
images = [item.strip() for item in str(value["image_url"]).split(",")]
|
|
121
|
+
if any(not item for item in images) or not 1 <= len(images) <= 10:
|
|
122
|
+
raise ValidationError("must contain 1 to 10 comma-delimited image references", path="$.image_url")
|
|
123
|
+
if value.get("required_tier_id") is not None and (
|
|
124
|
+
value.get("platform") == 3 or value.get("synchronization_type") == 3
|
|
125
|
+
):
|
|
126
|
+
raise ValidationError(
|
|
127
|
+
"required_tier_id cannot be combined with BigFoot synchronization",
|
|
128
|
+
path="$.required_tier_id",
|
|
129
|
+
)
|
|
130
|
+
if value.get("is_paid", 0) != 0:
|
|
131
|
+
raise ValidationError("current ModUs form requires is_paid=0", path="$.is_paid")
|
|
132
|
+
if value.get("price", 0) != 0:
|
|
133
|
+
raise ValidationError("current ModUs form requires price=0", path="$.price")
|
|
134
|
+
if value.get("share_type", 0) != 0:
|
|
135
|
+
raise ValidationError("current ModUs form requires share_type=0", path="$.share_type")
|
|
136
|
+
platform = value.get("platform")
|
|
137
|
+
synchronization = value.get("synchronization_type")
|
|
138
|
+
if (platform is None) != (synchronization is None):
|
|
139
|
+
raise ValidationError(
|
|
140
|
+
"platform and synchronization_type must be provided together",
|
|
141
|
+
path="$.synchronization_type" if synchronization is None else "$.platform",
|
|
142
|
+
)
|
|
143
|
+
if platform is not None and synchronization is not None and platform != synchronization:
|
|
144
|
+
raise ValidationError(
|
|
145
|
+
"platform and synchronization_type must select the same ModUs/BigFoot targets",
|
|
146
|
+
path="$.synchronization_type",
|
|
147
|
+
)
|
|
117
148
|
if self.name in ("fupload.v1.modus.project.create", "fupload.v1.modus.project.edit"):
|
|
118
149
|
snapshot = value.get("project_state")
|
|
119
150
|
if snapshot is None:
|
|
@@ -440,14 +471,28 @@ class Schema:
|
|
|
440
471
|
if "categories" in value and len(value["categories"]) > 5:
|
|
441
472
|
raise ValidationError("must contain at most 5 items", path="$.categories")
|
|
442
473
|
if "image_ops" in value:
|
|
443
|
-
|
|
474
|
+
if not isinstance(value["image_ops"], list) or not value["image_ops"]:
|
|
475
|
+
raise ValidationError("expected nonempty array", path="$.image_ops")
|
|
444
476
|
if "images" not in value:
|
|
445
477
|
raise ValidationError("images is required when image_ops is supplied", path="$.images")
|
|
446
478
|
for index, operation in enumerate(value["image_ops"]):
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
479
|
+
path = "$.image_ops[%d]" % index
|
|
480
|
+
if not isinstance(operation, dict):
|
|
481
|
+
raise ValidationError("expected object", path=path)
|
|
482
|
+
op = operation.get("op")
|
|
483
|
+
if op not in ("upload", "delete", "rename"):
|
|
484
|
+
raise ValidationError("must be upload, delete, or rename", path=path + ".op")
|
|
485
|
+
allowed = {"op", "from", "to"} if op == "rename" else {"op", "name", "base64"}
|
|
486
|
+
unknown = sorted(set(operation) - allowed)
|
|
487
|
+
if unknown:
|
|
488
|
+
raise ValidationError("unknown field", path=path + "." + unknown[0])
|
|
489
|
+
if op == "rename":
|
|
490
|
+
for name in ("from", "to"):
|
|
491
|
+
if not isinstance(operation.get(name), str) or not operation[name].strip():
|
|
492
|
+
raise ValidationError("must not be empty", path=path + "." + name)
|
|
493
|
+
continue
|
|
494
|
+
if not isinstance(operation.get("name"), str) or not operation["name"].strip():
|
|
495
|
+
raise ValidationError("must not be empty", path=path + ".name")
|
|
451
496
|
if operation["op"] == "upload" and (not isinstance(operation.get("base64"), str) or not operation["base64"].strip()):
|
|
452
497
|
raise ValidationError("base64 is required for upload", path="$.image_ops[%d].base64" % index)
|
|
453
498
|
if operation["op"] == "delete" and "base64" in operation:
|
|
@@ -455,7 +500,7 @@ class Schema:
|
|
|
455
500
|
for name in ("version", "type"):
|
|
456
501
|
if name in value and isinstance(value[name], str) and not value[name].strip():
|
|
457
502
|
raise ValidationError("must not be empty", path="$.%s" % name)
|
|
458
|
-
if "file" in value and not zipfile.is_zipfile(value["file"]):
|
|
503
|
+
if self.name.startswith("fupload.v1.modus.plugin") and "file" in value and not zipfile.is_zipfile(value["file"]):
|
|
459
504
|
raise ValidationError("file must be a valid ZIP archive", path="$.file")
|
|
460
505
|
|
|
461
506
|
|
|
@@ -711,7 +756,7 @@ MODUS_RELEASE = {
|
|
|
711
756
|
}
|
|
712
757
|
MODUS_BUILD_ID = f("integer", minimum=0, maximum=4)
|
|
713
758
|
MODUS_SHARE = {
|
|
714
|
-
"share_id": f("identifier", nonempty=True), "addons_id": f("string",
|
|
759
|
+
"share_id": f("identifier", nonempty=True), "addons_id": f("string", max_length=10000),
|
|
715
760
|
"account_name": f("string", nullable=True, max_length=120), "backup_id": f("integer", minimum=1),
|
|
716
761
|
"content": f("string", nonempty=True), "content_text": f("string", nonempty=True),
|
|
717
762
|
"image_url": f("string", nonempty=True, max_length=1000), "is_paid": f("integer", choices=(0, 1)),
|
|
@@ -742,6 +787,7 @@ register("modus", "plugin", "upload", required(MODUS_RELEASE, ("project_id", "fi
|
|
|
742
787
|
register("modus", "plugin", "update", required(MODUS_RELEASE, ("project_id", "file_id")))
|
|
743
788
|
register("modus", "plugin", "edit", required(MODUS_RELEASE, ("project_id", "file_id")))
|
|
744
789
|
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")))
|
|
790
|
+
register("modus", "media", "upload", required({"file": f("string", local_file=True)}, ("file",)))
|
|
745
791
|
register("modus", "config", "create", required(MODUS_SHARE, ("addons_id", "backup_id", "content", "content_text", "image_url", "tags", "title", "exclude_wtf")))
|
|
746
792
|
register("modus", "config", "update", required(MODUS_SHARE, ("share_id",)))
|
|
747
793
|
register("modus", "config", "edit", required(MODUS_SHARE, ("share_id",)))
|
|
@@ -14,6 +14,17 @@ from .errors import FuploadError
|
|
|
14
14
|
from .trust import official_opener, require_official_url
|
|
15
15
|
|
|
16
16
|
|
|
17
|
+
_IMAGE_CONTENT_TYPES = {
|
|
18
|
+
".bmp": "image/bmp",
|
|
19
|
+
".gif": "image/gif",
|
|
20
|
+
".jpeg": "image/jpeg",
|
|
21
|
+
".jpg": "image/jpeg",
|
|
22
|
+
".png": "image/png",
|
|
23
|
+
".svg": "image/svg+xml",
|
|
24
|
+
".webp": "image/webp",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
17
28
|
def _http_error_details(raw: bytes, fallback: str) -> tuple[str, Any]:
|
|
18
29
|
"""Extract a server error only from a conforming UTF-8 JSON object."""
|
|
19
30
|
try:
|
|
@@ -88,7 +99,8 @@ def multipart_request(
|
|
|
88
99
|
str(value).encode("utf-8"), b"\r\n",
|
|
89
100
|
])
|
|
90
101
|
filename = os.path.basename(file_path)
|
|
91
|
-
|
|
102
|
+
suffix = os.path.splitext(filename)[1].lower()
|
|
103
|
+
content_type = _IMAGE_CONTENT_TYPES.get(suffix) or mimetypes.guess_type(filename)[0] or "application/octet-stream"
|
|
92
104
|
chunks.extend([
|
|
93
105
|
("--%s\r\n" % boundary).encode(),
|
|
94
106
|
('Content-Disposition: form-data; name="%s"; filename="%s"\r\n' % (file_field, filename)).encode("utf-8"),
|
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.15",
|
|
5
|
+
"skill_version": "0.0.15",
|
|
6
|
+
"tree_sha256": "cfccc3347d2013188a37b120b94282c839ad81eb4fa92f8617e0cfa12dfbbaef",
|
|
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": 37142,
|
|
96
|
+
"sha256": "84e1524bc3404de3c3bc0582d85651b884c616ff009b1d01839fb84e348917b3"
|
|
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": "9159fa55aa8ef07864abf206b64aabd44ea78c2e987ead8b51ec9384b5d1cebf"
|
|
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": 37420,
|
|
131
|
+
"sha256": "3a052ec53c29ca95eafc9ba845d448ef45e4bd0231bf41ec1906e55281aa1650"
|
|
132
132
|
},
|
|
133
133
|
{
|
|
134
134
|
"path": "scripts/fupload_cli/curseforge.py",
|
|
@@ -137,8 +137,8 @@
|
|
|
137
137
|
},
|
|
138
138
|
{
|
|
139
139
|
"path": "scripts/fupload_cli/dd_broker.py",
|
|
140
|
-
"bytes":
|
|
141
|
-
"sha256": "
|
|
140
|
+
"bytes": 26638,
|
|
141
|
+
"sha256": "a23fb486dd1ca9d0a1d2f2ca642690535a28e05051ce828ce066cdbc67b37b37"
|
|
142
142
|
},
|
|
143
143
|
{
|
|
144
144
|
"path": "scripts/fupload_cli/dd_sidecar.py",
|
|
@@ -157,8 +157,8 @@
|
|
|
157
157
|
},
|
|
158
158
|
{
|
|
159
159
|
"path": "scripts/fupload_cli/io.py",
|
|
160
|
-
"bytes":
|
|
161
|
-
"sha256": "
|
|
160
|
+
"bytes": 5319,
|
|
161
|
+
"sha256": "57af3921d016815ed5747788881f86c8a6ce04a939e9f6f6571bd6a3f7383a4d"
|
|
162
162
|
},
|
|
163
163
|
{
|
|
164
164
|
"path": "scripts/fupload_cli/modus_zip.py",
|
|
@@ -167,8 +167,8 @@
|
|
|
167
167
|
},
|
|
168
168
|
{
|
|
169
169
|
"path": "scripts/fupload_cli/modus.py",
|
|
170
|
-
"bytes":
|
|
171
|
-
"sha256": "
|
|
170
|
+
"bytes": 74134,
|
|
171
|
+
"sha256": "9841ce7188083b62c1c217d89436b3c3ddc3ecf726719d3c44c0b83027172fac"
|
|
172
172
|
},
|
|
173
173
|
{
|
|
174
174
|
"path": "scripts/fupload_cli/newbee_auth.py",
|
|
@@ -182,8 +182,8 @@
|
|
|
182
182
|
},
|
|
183
183
|
{
|
|
184
184
|
"path": "scripts/fupload_cli/schema.py",
|
|
185
|
-
"bytes":
|
|
186
|
-
"sha256": "
|
|
185
|
+
"bytes": 54561,
|
|
186
|
+
"sha256": "17d57a9c9c53e556faf9e415020d47f84965283680c66f3f1c33beef6b83d104"
|
|
187
187
|
},
|
|
188
188
|
{
|
|
189
189
|
"path": "scripts/fupload_cli/state_machine.py",
|
|
@@ -192,8 +192,8 @@
|
|
|
192
192
|
},
|
|
193
193
|
{
|
|
194
194
|
"path": "scripts/fupload_cli/transport.py",
|
|
195
|
-
"bytes":
|
|
196
|
-
"sha256": "
|
|
195
|
+
"bytes": 5195,
|
|
196
|
+
"sha256": "37eb742c1cf75184d14f4f8ae85117222cad3d0748ab4c211452e78b5da0c279"
|
|
197
197
|
},
|
|
198
198
|
{
|
|
199
199
|
"path": "scripts/fupload_cli/trust.py",
|
|
@@ -208,7 +208,7 @@
|
|
|
208
208
|
{
|
|
209
209
|
"path": "SKILL.md",
|
|
210
210
|
"bytes": 25720,
|
|
211
|
-
"sha256": "
|
|
211
|
+
"sha256": "fae17edc0a59ea74dc70a5744b5aef46a057c23a9056e6d6b1e60990fa54782f"
|
|
212
212
|
}
|
|
213
213
|
]
|
|
214
214
|
}
|