@follenfang/fupload 0.0.6 → 0.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/fupload/SKILL.md +4 -4
- package/fupload/references/blackbox.md +3 -1
- package/fupload/scripts/fupload_cli/__init__.py +1 -1
- package/fupload/scripts/fupload_cli/blackbox.py +129 -101
- package/fupload/scripts/fupload_cli/blackbox_web.py +522 -0
- package/fupload/scripts/fupload_cli/cli.py +19 -9
- package/fupload/scripts/fupload_cli/schema.py +14 -3
- package/npm/bin/fupload.mjs +3 -1
- package/npm/lib/python-requirements.txt +1 -0
- package/npm/lib/python.mjs +59 -19
- package/npm/skill-manifest.json +17 -17
- package/package.json +1 -1
- package/fupload/scripts/fupload_cli/blackbox_auth.py +0 -159
package/fupload/SKILL.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: fupload
|
|
3
|
-
description: Explicit author-publishing workflow for World of Warcraft plugins, configuration shares, and WA/strings on NewBeeBox, NetEase DD, CurseForge, and Heybox Workshop, including CurseForge author project lookup, plugin ZIP upload, and Heybox
|
|
3
|
+
description: Explicit author-publishing workflow for World of Warcraft plugins, configuration shares, and WA/strings on NewBeeBox, NetEase DD, CurseForge, and Heybox Workshop, including CurseForge author project lookup, plugin ZIP upload, and Heybox web-session version management. 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, plugins, configurations, or WA.
|
|
4
4
|
metadata:
|
|
5
|
-
version: "0.0.
|
|
5
|
+
version: "0.0.8"
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Fupload
|
|
@@ -69,7 +69,7 @@ For npm package maintenance, use `fupload update` to update the CLI, managed Pyt
|
|
|
69
69
|
- Read [references/newbee.md](references/newbee.md) only when the user explicitly selected the third-party Python NewBeeBox channel.
|
|
70
70
|
- Read [references/dd.md](references/dd.md) only for DD.
|
|
71
71
|
- Read [references/curseforge.md](references/curseforge.md) for every CurseForge lookup or upload.
|
|
72
|
-
- Read [references/blackbox.md](references/blackbox.md) for Heybox Workshop plugin list/detail, metadata edit, version upload/edit/delete, and
|
|
72
|
+
- Read [references/blackbox.md](references/blackbox.md) for Heybox Workshop plugin list/detail, metadata edit, version upload/edit/delete, and managed web-session authentication.
|
|
73
73
|
- For Python, run the exact leaf command with `--help` before creating its input. Treat help as the executable schema contract.
|
|
74
74
|
|
|
75
75
|
## Investigate
|
|
@@ -88,7 +88,7 @@ For DD, use the one active task session to read game types/builds and the resour
|
|
|
88
88
|
|
|
89
89
|
For every existing DD update or edit, GET the detail first and rebuild the official full form before preparing the write. If a legacy record lacks a field now required by the official submit validator, such as `creation_statement`, stop before upload and collect that value. When the missing field belongs to a different action allowlist, plan and confirm the prerequisite edit first, read it back, then run the content update. Do not send `null`, create defaults, or guessed values to bypass the missing field.
|
|
90
90
|
|
|
91
|
-
For Heybox Workshop, use the signed
|
|
91
|
+
For Heybox Workshop, use Fuploader's managed Chromium web profile. If the profile is missing or the creator API reports an expired session, Fuploader opens the Workshop login page visibly for the user to complete login, then reuses the same profile headlessly for signed protocol requests. Never read the desktop client config or import another browser profile, and never accept a caller-supplied profile, cookie, token, signature, or endpoint. `blackbox plugin list`, `get`, and `versions` are read-only; `plugin edit` preserves omitted module fields by reading the complete module first; `plugin update` and `version-edit` upload or reuse a ZIP URL and verify the version readback; `version-delete` waits for asynchronous soft deletion and retries once when the audit state temporarily returns active. Do not print cookies, tokens, signatures, or temporary COS credentials, and do not delete a whole module.
|
|
92
92
|
|
|
93
93
|
For DD WA create, collect user choices before generating JSON, then let the Python CLI supply only the official create defaults for omitted form values: seven-day share and purchase lifetimes, `need_buy=false`, category `ui_original`, `Interface/Addons`, empty VIP levels, and version `0`. Submit category IDs as strings even when a discovery response represents them numerically. WA create/update versions contain digits only; update must be numerically greater than the current remote value. Every `!WA:2!` create/update/edit is reparsed by the installed official `WowUIInterface.parseWa` chain, including unchanged edit content. Do not carry create defaults into WA update/edit; omitted existing fields preserve their remote value.
|
|
94
94
|
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# Heybox Workshop
|
|
2
2
|
|
|
3
|
-
The blackbox provider
|
|
3
|
+
The blackbox provider uses a Fuploader-managed persistent Chromium profile for the Workshop web session. It probes that profile headlessly. If the profile is missing or expired, it opens the Workshop login page visibly and waits for the user to finish login, then resumes through the same profile in headless mode.
|
|
4
|
+
|
|
5
|
+
The Chromium profile lives under Fuploader's product state directory and is managed with the package's private Python venv. Fuploader does not read the Heybox desktop config or import Chrome, Edge, Electron, or another Playwright profile, and never accepts credentials, profile paths, cookies, tokens, signatures, endpoints, or COS temporary credentials as input. API calls use a fixed allowlisted web protocol and redact authentication and signed-upload material from output.
|
|
4
6
|
|
|
5
7
|
The npm installer manages Tencent's official COS SDK in a Fuploader-only Python venv. `fupload update` synchronizes it, the launcher repairs a missing or stale runtime before executing the Python CLI, and `fupload uninstall` removes it. No system-wide `pip install` is required.
|
|
6
8
|
|
|
@@ -1,45 +1,43 @@
|
|
|
1
1
|
"""Heybox Workshop plugin provider."""
|
|
2
2
|
from __future__ import annotations
|
|
3
|
-
import hashlib, json,
|
|
3
|
+
import hashlib, json, time, zipfile
|
|
4
4
|
from pathlib import Path
|
|
5
|
-
from urllib.parse import
|
|
6
|
-
from urllib.request import Request, urlopen
|
|
5
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
7
6
|
from typing import Any, Mapping
|
|
8
7
|
from .errors import FuploadError, ValidationError
|
|
9
|
-
from .
|
|
8
|
+
from .blackbox_web import API_ORIGIN, BlackboxWebSession
|
|
10
9
|
|
|
11
10
|
API_MISC_BASE = "https://api.xiaoheihe.cn"
|
|
11
|
+
API_BASE = API_ORIGIN
|
|
12
12
|
|
|
13
13
|
class Blackbox:
|
|
14
|
-
def __init__(self, config: Mapping[str, Any] | None = None, transport=None):
|
|
14
|
+
def __init__(self, config: Mapping[str, Any] | None = None, transport=None, *, web_session=None, web_session_factory=None):
|
|
15
15
|
self.config = dict(config or {})
|
|
16
|
-
self.profile = Path(self.config.get("client_profile") or Path.home() / "AppData/Roaming/heybox-pc-launcher")
|
|
17
16
|
self._transport = transport
|
|
17
|
+
self._web_session = web_session
|
|
18
|
+
self._web_session_factory = web_session_factory or BlackboxWebSession
|
|
19
|
+
|
|
20
|
+
def close(self):
|
|
21
|
+
if self._web_session is not None:
|
|
22
|
+
self._web_session.close()
|
|
23
|
+
self._web_session = None
|
|
24
|
+
|
|
25
|
+
def __enter__(self):
|
|
26
|
+
return self
|
|
27
|
+
|
|
28
|
+
def __exit__(self, *args):
|
|
29
|
+
self.close()
|
|
30
|
+
|
|
18
31
|
def _request(self, method, path, body=None, query=None, *, base=API_BASE):
|
|
19
|
-
if self._transport:
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
"
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
"hkey": hkey(path,ts + 1,nonce),
|
|
29
|
-
"_time": ts,
|
|
30
|
-
"_chat_time": int(time.time()*1000),
|
|
31
|
-
"nonce": nonce,
|
|
32
|
-
**(query or {}),
|
|
33
|
-
}
|
|
34
|
-
json_body = base == API_MISC_BASE or path.startswith("/bbs/app/api/qcloud/")
|
|
35
|
-
headers={"Referer":"https://chat.xiaoheihe.cn","User-Agent":f"HeyboxApp/{identity.get('exe_version') or version}","x_xhh_tokenid":cookies["x_xhh_tokenid"],"Cookie":" ".join(f"{k}={v};" for k,v in cookies.items()),"Content-Type":("application/json;charset=utf-8" if json_body else "application/x-www-form-urlencoded;charset=utf-8")}
|
|
36
|
-
url=base.rstrip("/")+path+"?"+urlencode(params)
|
|
37
|
-
data=(json.dumps(body or {}, separators=(",", ":")).encode() if json_body else urlencode(body or {},doseq=True).encode()) if method=="POST" else None
|
|
38
|
-
try:
|
|
39
|
-
with urlopen(Request(url,data=data,headers=headers,method=method),timeout=60) as r: result=json.loads(r.read().decode())
|
|
40
|
-
except Exception as exc: raise FuploadError("Workshop API request failed", endpoint=path, verification_required=method=="POST") from exc
|
|
41
|
-
if not isinstance(result,dict) or result.get("status")!="ok": raise FuploadError("Workshop API rejected request", endpoint=path, details={"status":result.get("status") if isinstance(result,dict) else None})
|
|
42
|
-
return result
|
|
32
|
+
if self._transport:
|
|
33
|
+
if base != API_BASE:
|
|
34
|
+
raise ValidationError("API origin is fixed to Workshop web protocol", path="$.base")
|
|
35
|
+
return self._transport(method, path, body or {}, query or {})
|
|
36
|
+
if base != API_BASE:
|
|
37
|
+
raise ValidationError("API origin is fixed to Workshop web protocol", path="$.base")
|
|
38
|
+
if self._web_session is None:
|
|
39
|
+
self._web_session = self._web_session_factory()
|
|
40
|
+
return self._web_session.request(method, path, body=body, query=query)
|
|
43
41
|
@staticmethod
|
|
44
42
|
def _result(payload): return payload.get("result") or {}
|
|
45
43
|
def execute_read(self, resource: str, action: str, args: Any):
|
|
@@ -60,15 +58,18 @@ class Blackbox:
|
|
|
60
58
|
rows=self._result(self._request("GET","/wow/open_platform/module/list/")).get("moduleList") or []
|
|
61
59
|
return {"total_count":len(rows),"plugins":[self._redact(x) for x in rows if isinstance(x,dict)]}
|
|
62
60
|
def plugin_get(self,module_id:int):
|
|
61
|
+
detail=self._module_detail(module_id)
|
|
62
|
+
return {"module":self._redact(detail),"versions":[self._redact(x) for x in self._version_rows(module_id)]}
|
|
63
|
+
def _module_detail(self,module_id:int):
|
|
63
64
|
detail=self._result(self._request("GET","/wow/open_platform/module/detail/",query={"moduleId":module_id}))
|
|
64
|
-
|
|
65
|
-
return
|
|
65
|
+
module=detail.get("module") or detail
|
|
66
|
+
return module if isinstance(module,dict) else {}
|
|
66
67
|
def module_edit(self,doc):
|
|
67
68
|
aliases={"module_id":"id","logo_url":"logoUrl","category_ids":"categoryIds","official_url":"officialUrl","core_folders":"coreFolders"}
|
|
68
69
|
normalized={aliases.get(k,k):v for k,v in doc.items() if k not in {"schema","dry_run"}}
|
|
69
70
|
if "id" not in normalized: raise ValidationError("id is required",path="$.id")
|
|
70
71
|
module_id = int(normalized["id"])
|
|
71
|
-
current = self.
|
|
72
|
+
current = self._module_detail(module_id)
|
|
72
73
|
# The web client sends a complete module object; preserve omitted fields.
|
|
73
74
|
defaults = {"name":"", "logoUrl":"", "id":module_id, "categoryIds":[],
|
|
74
75
|
"type":1, "desc":"", "official":"", "officialUrl":"", "coreFolders":""}
|
|
@@ -77,36 +78,36 @@ class Blackbox:
|
|
|
77
78
|
fields["id"] = module_id
|
|
78
79
|
if isinstance(fields.get("coreFolders"),list): fields["coreFolders"]=",".join(map(str,fields["coreFolders"]))
|
|
79
80
|
response=self._request("POST","/wow/open_platform/module/update/",body=fields)
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
for key,wanted in ((aliases.get(key,key), value) for key,value in normalized.items() if key in aliases or key in fields):
|
|
83
|
-
observed=actual.get(key)
|
|
84
|
-
if key=="coreFolders" and isinstance(observed,list): wanted=[x for x in str(wanted).split(",") if x]
|
|
85
|
-
if observed != wanted: mismatches.append(key)
|
|
86
|
-
if mismatches: raise FuploadError("module update readback mismatch",kind="verification_required",verification_required=True,details={"fields":mismatches})
|
|
81
|
+
expected={aliases.get(key,key):value for key,value in normalized.items() if key in aliases or key in fields}
|
|
82
|
+
self._wait_module(module_id,expected)
|
|
87
83
|
return {"accepted":True,"verified":True,"module_id":module_id,"response":self._redact(response)}
|
|
88
84
|
def version_upsert(self,doc):
|
|
89
85
|
aliases={"module_id":"moduleId","version_id":"versionId","game_versions":"gameVersions","file_url":"fileUrl"}
|
|
90
86
|
normalized={aliases.get(k,k):v for k,v in doc.items() if k not in {"schema","dry_run"}}
|
|
91
87
|
upload_result = None
|
|
92
|
-
if "file" in normalized
|
|
88
|
+
if "file" in normalized:
|
|
93
89
|
upload_result=self.upload_zip(int(normalized["moduleId"]),str(normalized["file"]))
|
|
94
90
|
normalized["fileUrl"]=upload_result["url"]
|
|
95
91
|
if "fileUrl" not in normalized and "versionId" in normalized:
|
|
96
92
|
existing = self._find_version(int(normalized["moduleId"]), int(normalized["versionId"]))
|
|
97
|
-
|
|
98
|
-
|
|
93
|
+
current_url=self._archive_url(existing or {})
|
|
94
|
+
if current_url: normalized["fileUrl"]=current_url
|
|
99
95
|
required=("moduleId","name","type","gameVersions","fileUrl")
|
|
100
96
|
missing=[k for k in required if k not in normalized]
|
|
101
97
|
if missing: raise ValidationError("missing field(s): %s"%", ".join(missing))
|
|
102
98
|
games=normalized["gameVersions"] if isinstance(normalized["gameVersions"],list) else [x for x in str(normalized["gameVersions"]).split(",") if x]
|
|
103
99
|
body={"moduleId":int(normalized["moduleId"]),"name":normalized["name"],"type":int(normalized["type"]),"gameVersions":",".join(map(str,games)),"fileUrl":normalized["fileUrl"]}
|
|
104
100
|
if "versionId" in normalized: body["versionId"]=int(normalized["versionId"])
|
|
101
|
+
existing_ids={self._id_value(row.get("id")) for row in self._version_rows(body["moduleId"])} if "versionId" not in body else set()
|
|
105
102
|
response=self._request("POST","/wow/open_platform/module_version/upsert/",body=body)
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
103
|
+
response_result=self._result(response)
|
|
104
|
+
response_id=(response_result.get("versionId") or response_result.get("id")) if isinstance(response_result,dict) else None
|
|
105
|
+
target_id=body.get("versionId") or (self._id_value(response_id) if response_id is not None else None)
|
|
106
|
+
expected = {"name":body["name"],"type":body["type"],"gameVersions":list(map(str,games))}
|
|
107
|
+
found=self._wait_version(body["moduleId"],target_id,body["name"],excluded_ids=existing_ids,
|
|
108
|
+
expected=expected,expected_archive=body["fileUrl"])
|
|
109
|
+
mismatches=[key for key,wanted in {"name":body["name"],"type":body["type"],"gameVersions":list(map(str,games))}.items() if self._comparable(key,found.get(key))!=self._comparable(key,wanted)]
|
|
110
|
+
if self._comparable("fileUrl", self._archive_url(found)) != self._comparable("fileUrl", body["fileUrl"]):
|
|
110
111
|
mismatches.append("fileUrl")
|
|
111
112
|
if mismatches: raise FuploadError("version upsert readback mismatch",kind="verification_required",verification_required=True,details={"fields":mismatches})
|
|
112
113
|
result={"accepted":True,"verified":True,"module_id":body["moduleId"],"version_id":found.get("id"),"readback":self._redact(found),"response":self._redact(response)}
|
|
@@ -127,78 +128,105 @@ class Blackbox:
|
|
|
127
128
|
retry = True
|
|
128
129
|
response = self._request("POST","/wow/open_platform/module_version/delete/",body={"versionId":int(version_id),"moduleId":int(module_id)})
|
|
129
130
|
found = self._wait_version(int(module_id),int(version_id),None,deleted=True)
|
|
131
|
+
time.sleep(settle)
|
|
132
|
+
current = self._find_version(int(module_id), int(version_id))
|
|
133
|
+
if current is not None and current.get("auditState") != 4:
|
|
134
|
+
raise FuploadError("version delete did not remain stable",kind="verification_required",verification_required=True)
|
|
130
135
|
return {"accepted":True,"verified":True,"module_id":int(module_id),"version_id":int(version_id),"audit_state":found.get("auditState") if found else None,"retry":retry,"response":self._redact(response)}
|
|
131
136
|
def _version_rows(self,module_id):
|
|
132
|
-
|
|
137
|
+
limit = int(self.config.get("version_page_size", 100))
|
|
138
|
+
max_pages = int(self.config.get("version_max_pages", 100))
|
|
139
|
+
rows = []
|
|
140
|
+
page_fingerprints = set()
|
|
141
|
+
for page_index in range(max_pages):
|
|
142
|
+
offset = page_index * limit
|
|
143
|
+
result = self._result(self._request(
|
|
144
|
+
"GET", "/wow/open_platform/module_version/list/",
|
|
145
|
+
query={"moduleId":module_id,"offset":offset,"limit":limit},
|
|
146
|
+
))
|
|
147
|
+
page = result.get("versionList") or []
|
|
148
|
+
if not isinstance(page, list):
|
|
149
|
+
raise FuploadError("version list response is invalid", kind="verification_required", verification_required=True)
|
|
150
|
+
fingerprint = tuple(self._id_value(item.get("id")) for item in page if isinstance(item, Mapping))
|
|
151
|
+
if page and fingerprint in page_fingerprints:
|
|
152
|
+
raise FuploadError("version list pagination did not advance", kind="verification_required", verification_required=True)
|
|
153
|
+
page_fingerprints.add(fingerprint)
|
|
154
|
+
rows.extend(item for item in page if isinstance(item, dict))
|
|
155
|
+
total = next((result.get(key) for key in ("totalCount", "total_count", "total", "count") if isinstance(result.get(key), int)), None)
|
|
156
|
+
if not page or len(page) < limit or (total is not None and len(rows) >= total):
|
|
157
|
+
return rows
|
|
158
|
+
raise FuploadError("version list pagination exceeded the supported limit", kind="verification_required", verification_required=True)
|
|
133
159
|
|
|
134
160
|
def _find_version(self, module_id, version_id):
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
161
|
+
wanted=self._id_value(version_id)
|
|
162
|
+
return next((x for x in self._version_rows(module_id) if self._id_value(x.get("id"))==wanted),None)
|
|
163
|
+
def _wait_module(self,module_id,expected):
|
|
164
|
+
attempts=int(self.config.get("verify_attempts",30))
|
|
165
|
+
mismatches=list(expected)
|
|
166
|
+
for attempt in range(attempts):
|
|
167
|
+
actual=self._module_detail(module_id)
|
|
168
|
+
mismatches=[key for key,wanted in expected.items() if self._comparable(key,actual.get(key))!=self._comparable(key,wanted)]
|
|
169
|
+
if not mismatches: return actual
|
|
170
|
+
if attempt+1<attempts: time.sleep(float(self.config.get("verify_interval",2)))
|
|
171
|
+
raise FuploadError("module update readback mismatch",kind="verification_required",verification_required=True,details={"fields":mismatches})
|
|
172
|
+
def _wait_version(self,module_id,version_id,name,deleted=False,expected=None,excluded_ids=None,expected_archive=None):
|
|
173
|
+
excluded_ids=excluded_ids or set()
|
|
174
|
+
for attempt in range(int(self.config.get("verify_attempts",30))):
|
|
138
175
|
rows=self._version_rows(module_id)
|
|
139
|
-
|
|
176
|
+
if version_id is not None:
|
|
177
|
+
candidates=[x for x in rows if self._id_value(x.get("id"))==self._id_value(version_id)]
|
|
178
|
+
else:
|
|
179
|
+
candidates=[x for x in rows if x.get("name")==name and self._id_value(x.get("id")) not in excluded_ids]
|
|
180
|
+
if len(candidates)>1: raise FuploadError("version write readback is ambiguous",kind="verification_required",verification_required=True)
|
|
181
|
+
found=candidates[0] if candidates else None
|
|
140
182
|
matches_expected = found and all(
|
|
141
|
-
(
|
|
183
|
+
self._comparable(key,found.get(key))==self._comparable(key,wanted)
|
|
142
184
|
for key, wanted in (expected or {}).items()
|
|
143
185
|
)
|
|
186
|
+
if matches_expected and expected_archive is not None:
|
|
187
|
+
matches_expected = self._comparable("fileUrl", self._archive_url(found)) == self._comparable("fileUrl", expected_archive)
|
|
144
188
|
if (deleted and (found is None or found.get("auditState")==4)) or (not deleted and found and matches_expected): return found or {}
|
|
145
|
-
if attempt+1<int(self.config.get("verify_attempts",
|
|
189
|
+
if attempt+1<int(self.config.get("verify_attempts",30)): time.sleep(float(self.config.get("verify_interval",2)))
|
|
146
190
|
raise FuploadError("version write was not confirmed by readback",kind="verification_required",verification_required=True)
|
|
147
191
|
@staticmethod
|
|
148
192
|
def _redact(value):
|
|
149
|
-
if isinstance(value,dict):
|
|
150
|
-
|
|
193
|
+
if isinstance(value,dict):
|
|
194
|
+
markers=("token","secret","cookie","nonce","hkey","pkey","credential","signature","authorization","authentication","device_id","signed_url","upload_url","presigned")
|
|
195
|
+
return {k:("<redacted>" if any(s in str(k).lower() for s in markers) else Blackbox._redact(v)) for k,v in value.items()}
|
|
196
|
+
if isinstance(value,(list,tuple)): return [Blackbox._redact(v) for v in value]
|
|
197
|
+
if isinstance(value,str):
|
|
198
|
+
parsed=urlsplit(value)
|
|
199
|
+
if parsed.scheme in {"http","https"} and parsed.netloc and parsed.query:
|
|
200
|
+
return urlunsplit((parsed.scheme,parsed.netloc,parsed.path,"<redacted>",parsed.fragment))
|
|
201
|
+
return value
|
|
202
|
+
|
|
203
|
+
@staticmethod
|
|
204
|
+
def _id_value(value):
|
|
205
|
+
try: return int(value)
|
|
206
|
+
except (TypeError,ValueError): return value
|
|
207
|
+
@staticmethod
|
|
208
|
+
def _archive_url(row): return row.get("fileUrlHeybox") or row.get("fileUrl") or row.get("file_url")
|
|
209
|
+
@staticmethod
|
|
210
|
+
def _comparable(key,value):
|
|
211
|
+
if key in {"categoryIds","gameVersions","coreFolders"}:
|
|
212
|
+
if isinstance(value,str): items=[item for item in value.split(",") if item]
|
|
213
|
+
elif isinstance(value,(list,tuple,set)): items=list(value)
|
|
214
|
+
else: items=[] if value is None else [value]
|
|
215
|
+
normalized=[]
|
|
216
|
+
for item in items:
|
|
217
|
+
if isinstance(item,Mapping): item=item.get("id",item.get("value"))
|
|
218
|
+
if item not in (None, ""):
|
|
219
|
+
normalized.append(str(item))
|
|
220
|
+
return tuple(sorted(normalized))
|
|
221
|
+
if key in {"id","type"}: return Blackbox._id_value(value)
|
|
151
222
|
return value
|
|
152
223
|
|
|
153
224
|
def upload_zip(self, module_id: int, file_path: str, *, dry_run=False):
|
|
154
225
|
path=Path(file_path)
|
|
155
226
|
if not path.is_file(): raise ValidationError("file does not exist",path="$.file")
|
|
227
|
+
if not zipfile.is_zipfile(path): raise ValidationError("file must be a valid ZIP archive",path="$.file")
|
|
156
228
|
if dry_run: return {"dry_run":True,"bytes":path.stat().st_size,"sha256":hashlib.sha256(path.read_bytes()).hexdigest()}
|
|
157
|
-
|
|
158
|
-
return self._upload_zip_v2(path)
|
|
159
|
-
except FuploadError:
|
|
160
|
-
# The web creator still exposes the legacy Workshop token route.
|
|
161
|
-
# Fall back to it when the desktop generic uploader is unavailable.
|
|
162
|
-
return self._upload_zip_legacy(path)
|
|
163
|
-
|
|
164
|
-
def _upload_zip_v2(self, path: Path):
|
|
165
|
-
file_info = {"name": path.name, "mimetype": "application/zip", "fsize": path.stat().st_size}
|
|
166
|
-
info = self._result(self._request(
|
|
167
|
-
"POST", "/bbs/app/api/qcloud/cos/upload/info/v2", base=API_MISC_BASE,
|
|
168
|
-
body={"file_infos": json.dumps([file_info], separators=(",", ":")), "scope": "any", "need_cache": 0},
|
|
169
|
-
))
|
|
170
|
-
keys = info.get("keys") or []
|
|
171
|
-
if not keys or not info.get("bucket"):
|
|
172
|
-
raise FuploadError("Current COS upload info response is incomplete", endpoint="/bbs/app/api/qcloud/cos/upload/info/v2", verification_required=True)
|
|
173
|
-
key = str(keys[0])
|
|
174
|
-
token = self._result(self._request(
|
|
175
|
-
"POST", "/bbs/app/api/qcloud/cos/upload/token/v2", base=API_MISC_BASE,
|
|
176
|
-
body={"bucket": info["bucket"], "keys": json.dumps([key], separators=(",", ":")), "mimetypes": json.dumps([file_info["mimetype"]]), "is_multipart_upload": 0},
|
|
177
|
-
))
|
|
178
|
-
credentials = token.get("credentials") or {}
|
|
179
|
-
secret_id = credentials.get("tmpSecretId") or credentials.get("TmpSecretID")
|
|
180
|
-
secret_key = credentials.get("tmpSecretKey") or credentials.get("TmpSecretKey")
|
|
181
|
-
session_token = credentials.get("sessionToken") or credentials.get("Token")
|
|
182
|
-
if not all((secret_id, secret_key, session_token)):
|
|
183
|
-
raise FuploadError("Current COS upload credentials are incomplete", endpoint="/bbs/app/api/qcloud/cos/upload/token/v2", verification_required=True)
|
|
184
|
-
try:
|
|
185
|
-
from qcloud_cos import CosConfig, CosS3Client
|
|
186
|
-
except ModuleNotFoundError as exc:
|
|
187
|
-
raise FuploadError("The managed Fuploader Python runtime is missing the Heybox COS SDK", kind="environment_error", details={"repair_command":"fupload update"}) from exc
|
|
188
|
-
try:
|
|
189
|
-
cos=CosS3Client(CosConfig(Region=info.get("region") or "ap-shanghai",SecretId=secret_id,SecretKey=secret_key,Token=session_token,Scheme="https"))
|
|
190
|
-
with path.open("rb") as stream: cos.put_object(Bucket=info["bucket"],Key=key,Body=stream)
|
|
191
|
-
except Exception as exc:
|
|
192
|
-
raise FuploadError("COS upload failed", endpoint="/bbs/app/api/qcloud/cos/upload/token/v2", verification_required=True) from exc
|
|
193
|
-
callback = self._result(self._request(
|
|
194
|
-
"POST", "/bbs/app/api/qcloud/cos/upload/callback/v2", base=API_MISC_BASE,
|
|
195
|
-
query={"is_finished": "true"}, body={"keys": json.dumps([key], separators=(",", ":"))},
|
|
196
|
-
))
|
|
197
|
-
urls = callback.get("preview_urls") or callback.get("previewUrls") or []
|
|
198
|
-
url = urls[0] if urls else callback.get("url")
|
|
199
|
-
if not url:
|
|
200
|
-
raise FuploadError("Current COS upload callback did not return a URL", endpoint="/bbs/app/api/qcloud/cos/upload/callback/v2", verification_required=True)
|
|
201
|
-
return {"url":url,"bytes":path.stat().st_size,"sha256":hashlib.sha256(path.read_bytes()).hexdigest(),"protocol":"v2"}
|
|
229
|
+
return self._upload_zip_legacy(path)
|
|
202
230
|
|
|
203
231
|
def _upload_zip_legacy(self, path: Path):
|
|
204
232
|
size_mb=path.stat().st_size/1024/1024
|