@follenfang/fupload 0.0.6 → 0.0.7

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 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 client-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.
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.6"
5
+ version: "0.0.7"
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 client-session authentication.
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-in desktop client's local session automatically. `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 accept or print cookies, tokens, signatures, or temporary COS credentials, and do not delete a whole module.
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 reuses the signed-in Heybox desktop client's local login state. It never accepts credentials, cookies, tokens, signatures, or COS temporary credentials as input.
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,3 +1,3 @@
1
1
  """Fupload Python CLI."""
2
2
 
3
- __version__ = "0.0.6"
3
+ __version__ = "0.0.7"
@@ -1,45 +1,43 @@
1
1
  """Heybox Workshop plugin provider."""
2
2
  from __future__ import annotations
3
- import hashlib, json, secrets, time
3
+ import hashlib, json, time, zipfile
4
4
  from pathlib import Path
5
- from urllib.parse import urlencode
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 .blackbox_auth import API_BASE, CLIENT_VERSION, hkey, load_session
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: return self._transport(method, path, body or {}, query or {})
20
- cookies, identity = load_session(self.profile); ts=int(time.time()); nonce=hashlib.md5((str(ts)+secrets.token_hex(16)).encode()).hexdigest().upper()
21
- version = identity.get("version") or CLIENT_VERSION
22
- params={
23
- "app": "heybox",
24
- "os_type": "Windows",
25
- "web_version": "",
26
- **identity,
27
- "version": version,
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
- versions=self._result(self._request("GET","/wow/open_platform/module_version/list/",query={"moduleId":module_id,"offset":0,"limit":100}))
65
- return {"module":self._redact(detail.get("module") or detail),"versions":[self._redact(x) for x in (versions.get("versionList") or [])]}
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.plugin_get(module_id)["module"]
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
- actual=self.plugin_get(module_id)["module"]
81
- mismatches=[]
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 and "fileUrl" not 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
- if existing and existing.get("fileUrlHeybox"):
98
- normalized["fileUrl"] = existing["fileUrlHeybox"]
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
- found=self._wait_version(body["moduleId"],body.get("versionId"),body["name"],
107
- expected={"name":body["name"],"type":body["type"],"gameVersions":list(map(str,games))})
108
- mismatches=[key for key,wanted in {"name":body["name"],"type":body["type"],"gameVersions":list(map(str,games))}.items() if found.get(key)!=wanted]
109
- if not found.get("fileUrlHeybox"):
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
- return self._result(self._request("GET","/wow/open_platform/module_version/list/",query={"moduleId":module_id,"offset":0,"limit":100})).get("versionList") or []
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
- return next((x for x in self._version_rows(module_id) if x.get("id") == version_id), None)
136
- def _wait_version(self,module_id,version_id,name,deleted=False,expected=None):
137
- for attempt in range(int(self.config.get("verify_attempts",20))):
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
- found=next((x for x in rows if (version_id is not None and x.get("id")==version_id) or (version_id is None and x.get("name")==name)),None)
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
- (list(map(str, found.get(key) or [])) if key == "gameVersions" else found.get(key)) == wanted
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",20)): time.sleep(float(self.config.get("verify_interval",2)))
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): return {k:("<redacted>" if any(s in k.lower() for s in ("token","secret","cookie","nonce","hkey")) else Blackbox._redact(v)) for k,v in value.items()}
150
- if isinstance(value,list): return [Blackbox._redact(v) for v in value]
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
- try:
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