@follenfang/fupload 0.0.1 → 0.0.4

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.
@@ -0,0 +1,151 @@
1
+ """Heybox Workshop plugin provider."""
2
+ from __future__ import annotations
3
+ import hashlib, json, secrets, time
4
+ from pathlib import Path
5
+ from urllib.parse import urlencode
6
+ from urllib.request import Request, urlopen
7
+ from typing import Any, Mapping
8
+ from .errors import FuploadError, ValidationError
9
+ from .blackbox_auth import API_BASE, hkey, load_session
10
+
11
+ class Blackbox:
12
+ def __init__(self, config: Mapping[str, Any] | None = None, transport=None):
13
+ self.config = dict(config or {})
14
+ self.profile = Path(self.config.get("client_profile") or Path.home() / "AppData/Roaming/heybox-pc-launcher")
15
+ self._transport = transport
16
+ def _request(self, method, path, body=None, query=None):
17
+ if self._transport: return self._transport(method, path, body or {}, query or {})
18
+ cookies, identity = load_session(self.profile); ts=int(time.time()); nonce=hashlib.md5((str(ts)+secrets.token_hex(16)).encode()).hexdigest().upper()
19
+ params={**identity,"version":identity.get("version","1.12.0"),"hkey":hkey(path,ts,nonce),"_time":ts,"_chat_time":int(time.time()*1000),"nonce":nonce,**(query or {})}
20
+ headers={"User-Agent":"HeyboxApp/1.12.0","x_xhh_tokenid":cookies["x_xhh_tokenid"],"Cookie":"; ".join(f"{k}={v}" for k,v in cookies.items()),"Content-Type":"application/x-www-form-urlencoded"}
21
+ url=API_BASE+path+"?"+urlencode(params); data=urlencode(body or {},doseq=True).encode() if method=="POST" else None
22
+ try:
23
+ with urlopen(Request(url,data=data,headers=headers,method=method),timeout=60) as r: result=json.loads(r.read().decode())
24
+ except Exception as exc: raise FuploadError("Workshop API request failed", endpoint=path, verification_required=method=="POST") from exc
25
+ 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})
26
+ return result
27
+ @staticmethod
28
+ def _result(payload): return payload.get("result") or {}
29
+ def execute_read(self, resource: str, action: str, args: Any):
30
+ if resource == "plugin" and action in ("list", "get", "versions"):
31
+ if action == "list": return self.plugin_list()
32
+ module_id = getattr(args,"module_id",None) if not isinstance(args,Mapping) else args.get("module_id")
33
+ result = self.plugin_get(int(module_id))
34
+ return result["versions"] if action == "versions" else result
35
+ raise ValidationError("unsupported blackbox read operation")
36
+ def execute_write(self, resource: str, action: str, doc: Mapping[str,Any]):
37
+ if doc.get("dry_run"): return {"dry_run":True,"operation":f"{resource}.{action}","fields":sorted(k for k in doc if k not in {"dry_run","schema"})}
38
+ if resource == "plugin" and action == "edit": return self.module_edit(doc)
39
+ if resource == "plugin" and action == "update": return self.version_upsert(doc)
40
+ if resource=="version" and action in ("create","update","edit"): return self.version_upsert(doc)
41
+ if resource=="version" and action=="delete": return self.version_delete(doc)
42
+ raise ValidationError("unsupported blackbox write operation")
43
+ def plugin_list(self):
44
+ rows=self._result(self._request("GET","/wow/open_platform/module/list/")).get("moduleList") or []
45
+ return {"total_count":len(rows),"plugins":[self._redact(x) for x in rows if isinstance(x,dict)]}
46
+ def plugin_get(self,module_id:int):
47
+ detail=self._result(self._request("GET","/wow/open_platform/module/detail/",query={"moduleId":module_id}))
48
+ versions=self._result(self._request("GET","/wow/open_platform/module_version/list/",query={"moduleId":module_id,"offset":0,"limit":100}))
49
+ return {"module":self._redact(detail.get("module") or detail),"versions":[self._redact(x) for x in (versions.get("versionList") or [])]}
50
+ def module_edit(self,doc):
51
+ aliases={"module_id":"id","logo_url":"logoUrl","category_ids":"categoryIds","official_url":"officialUrl","core_folders":"coreFolders"}
52
+ normalized={aliases.get(k,k):v for k,v in doc.items() if k not in {"schema","dry_run"}}
53
+ if "id" not in normalized: raise ValidationError("id is required",path="$.id")
54
+ module_id = int(normalized["id"])
55
+ current = self.plugin_get(module_id)["module"]
56
+ # The web client sends a complete module object; preserve omitted fields.
57
+ defaults = {"name":"", "logoUrl":"", "id":module_id, "categoryIds":[],
58
+ "type":1, "desc":"", "official":"", "officialUrl":"", "coreFolders":""}
59
+ fields={key: normalized.get(key, current.get(key, defaults[key]))
60
+ for key in ("name","logoUrl","id","categoryIds","type","desc","official","officialUrl","coreFolders")}
61
+ fields["id"] = module_id
62
+ if isinstance(fields.get("coreFolders"),list): fields["coreFolders"]=",".join(map(str,fields["coreFolders"]))
63
+ response=self._request("POST","/wow/open_platform/module/update/",body=fields)
64
+ actual=self.plugin_get(module_id)["module"]
65
+ mismatches=[]
66
+ for key,wanted in ((aliases.get(key,key), value) for key,value in normalized.items() if key in aliases or key in fields):
67
+ observed=actual.get(key)
68
+ if key=="coreFolders" and isinstance(observed,list): wanted=[x for x in str(wanted).split(",") if x]
69
+ if observed != wanted: mismatches.append(key)
70
+ if mismatches: raise FuploadError("module update readback mismatch",kind="verification_required",verification_required=True,details={"fields":mismatches})
71
+ return {"accepted":True,"verified":True,"module_id":module_id,"response":self._redact(response)}
72
+ def version_upsert(self,doc):
73
+ aliases={"module_id":"moduleId","version_id":"versionId","game_versions":"gameVersions","file_url":"fileUrl"}
74
+ normalized={aliases.get(k,k):v for k,v in doc.items() if k not in {"schema","dry_run"}}
75
+ if "file" in normalized and "fileUrl" not in normalized:
76
+ normalized["fileUrl"]=self.upload_zip(int(normalized["moduleId"]),str(normalized["file"]))["url"]
77
+ if "fileUrl" not in normalized and "versionId" in normalized:
78
+ existing = self._find_version(int(normalized["moduleId"]), int(normalized["versionId"]))
79
+ if existing and existing.get("fileUrlHeybox"):
80
+ normalized["fileUrl"] = existing["fileUrlHeybox"]
81
+ required=("moduleId","name","type","gameVersions","fileUrl")
82
+ missing=[k for k in required if k not in normalized]
83
+ if missing: raise ValidationError("missing field(s): %s"%", ".join(missing))
84
+ games=normalized["gameVersions"] if isinstance(normalized["gameVersions"],list) else [x for x in str(normalized["gameVersions"]).split(",") if x]
85
+ body={"moduleId":int(normalized["moduleId"]),"name":normalized["name"],"type":int(normalized["type"]),"gameVersions":",".join(map(str,games)),"fileUrl":normalized["fileUrl"]}
86
+ if "versionId" in normalized: body["versionId"]=int(normalized["versionId"])
87
+ response=self._request("POST","/wow/open_platform/module_version/upsert/",body=body)
88
+ found=self._wait_version(body["moduleId"],body.get("versionId"),body["name"],
89
+ expected={"name":body["name"],"type":body["type"],"gameVersions":list(map(str,games))})
90
+ mismatches=[key for key,wanted in {"name":body["name"],"type":body["type"],"gameVersions":list(map(str,games))}.items() if found.get(key)!=wanted]
91
+ if not found.get("fileUrlHeybox"):
92
+ mismatches.append("fileUrl")
93
+ if mismatches: raise FuploadError("version upsert readback mismatch",kind="verification_required",verification_required=True,details={"fields":mismatches})
94
+ return {"accepted":True,"verified":True,"module_id":body["moduleId"],"version_id":found.get("id"),"readback":self._redact(found),"response":self._redact(response)}
95
+ def version_delete(self,doc):
96
+ version_id=doc.get("versionId",doc.get("version_id")); module_id=doc.get("moduleId",doc.get("module_id"))
97
+ if version_id is None or module_id is None: raise ValidationError("versionId and moduleId are required")
98
+ response=self._request("POST","/wow/open_platform/module_version/delete/",body={"versionId":int(version_id),"moduleId":int(module_id)})
99
+ found=self._wait_version(int(module_id),int(version_id),None,deleted=True)
100
+ retry = False
101
+ settle = float(self.config.get("delete_settle_seconds", 10))
102
+ if settle:
103
+ time.sleep(settle)
104
+ current = self._find_version(int(module_id), int(version_id))
105
+ if current is not None and current.get("auditState") != 4:
106
+ retry = True
107
+ response = self._request("POST","/wow/open_platform/module_version/delete/",body={"versionId":int(version_id),"moduleId":int(module_id)})
108
+ found = self._wait_version(int(module_id),int(version_id),None,deleted=True)
109
+ 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)}
110
+ def _version_rows(self,module_id):
111
+ return self._result(self._request("GET","/wow/open_platform/module_version/list/",query={"moduleId":module_id,"offset":0,"limit":100})).get("versionList") or []
112
+
113
+ def _find_version(self, module_id, version_id):
114
+ return next((x for x in self._version_rows(module_id) if x.get("id") == version_id), None)
115
+ def _wait_version(self,module_id,version_id,name,deleted=False,expected=None):
116
+ for attempt in range(int(self.config.get("verify_attempts",20))):
117
+ rows=self._version_rows(module_id)
118
+ 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)
119
+ matches_expected = found and all(
120
+ (list(map(str, found.get(key) or [])) if key == "gameVersions" else found.get(key)) == wanted
121
+ for key, wanted in (expected or {}).items()
122
+ )
123
+ if (deleted and (found is None or found.get("auditState")==4)) or (not deleted and found and matches_expected): return found or {}
124
+ if attempt+1<int(self.config.get("verify_attempts",20)): time.sleep(float(self.config.get("verify_interval",2)))
125
+ raise FuploadError("version write was not confirmed by readback",kind="verification_required",verification_required=True)
126
+ @staticmethod
127
+ def _redact(value):
128
+ 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()}
129
+ if isinstance(value,list): return [Blackbox._redact(v) for v in value]
130
+ return value
131
+
132
+ def upload_zip(self, module_id: int, file_path: str, *, dry_run=False):
133
+ path=Path(file_path)
134
+ if not path.is_file(): raise ValidationError("file does not exist",path="$.file")
135
+ if dry_run: return {"dry_run":True,"bytes":path.stat().st_size,"sha256":hashlib.sha256(path.read_bytes()).hexdigest()}
136
+ size_mb=path.stat().st_size/1024/1024
137
+ token=self._result(self._request("POST","/wow/cos/upload/token/",body={"jsonData":json.dumps({"upload_infos":[{"filename":path.name,"file_size":size_mb,"type":"module"}]},separators=(",",":"))}))
138
+ info=token["info"]; f=token["files"][0]; creds=info["Credentials"].get("Credentials",info["Credentials"])
139
+ try:
140
+ from qcloud_cos import CosConfig, CosS3Client
141
+ except ModuleNotFoundError as exc:
142
+ raise FuploadError(
143
+ "Heybox ZIP upload requires cos-python-sdk-v5",
144
+ kind="environment_error",
145
+ details={"install_command":"python -m pip install cos-python-sdk-v5"},
146
+ ) from exc
147
+ try:
148
+ cos=CosS3Client(CosConfig(Region=info.get("region") or "ap-shanghai",SecretId=creds["TmpSecretID"],SecretKey=creds["TmpSecretKey"],Token=creds["Token"],Scheme="https"))
149
+ with path.open("rb") as stream: cos.put_object(Bucket=info["bucket"],Key=f["key"],Body=stream)
150
+ except Exception as exc: raise FuploadError("COS upload failed",verification_required=True) from exc
151
+ return {"url":"https://%s/%s"%(info["host"],f["key"]),"bytes":path.stat().st_size,"sha256":hashlib.sha256(path.read_bytes()).hexdigest()}
@@ -0,0 +1,54 @@
1
+ """Authentication helpers for the Heybox desktop Workshop client."""
2
+ from __future__ import annotations
3
+ import hashlib, json, secrets, sqlite3, time
4
+ from pathlib import Path
5
+ from urllib.parse import parse_qs, urlparse
6
+ from .errors import FuploadError
7
+
8
+ API_BASE = "https://workshopapi.xiaoheihe.cn"
9
+ _ALPHABET = "AB45STUVWZEFGJ6CH01D237IXYPQRKLMN89"
10
+
11
+ def _n8(value: str, limit: int) -> str:
12
+ table = _ALPHABET[:limit]
13
+ return "".join(table[ord(ch) % len(table)] for ch in value)
14
+ def _i8(value: str) -> str:
15
+ return "".join(_ALPHABET[ord(ch) % len(_ALPHABET)] for ch in value)
16
+ def _interleave(parts):
17
+ return "".join(part[i] for i in range(max(map(len, parts))) for part in parts if i < len(part))
18
+ def _mix(values):
19
+ def p(x): return ((x << 1) ^ 27) & 255 if x & 128 else (x << 1) & 255
20
+ def hm(x): return p(x) ^ x
21
+ def qg(x): return hm(p(x))
22
+ def dx(x): return qg(hm(p(x)))
23
+ def mw(x): return dx(x) ^ qg(x) ^ hm(x)
24
+ a,b,c,d,*rest=values
25
+ return [mw(a)^dx(b)^qg(c)^hm(d), hm(a)^mw(b)^dx(c)^qg(d), qg(a)^hm(b)^mw(c)^dx(d), dx(a)^qg(b)^hm(c)^mw(d), *rest]
26
+ def hkey(path: str, timestamp: int, nonce: str) -> str:
27
+ normalized = "/" + "/".join(x for x in path.split("/") if x) + "/"
28
+ digest = hashlib.md5(_interleave([_n8(str(timestamp), -2), _i8(normalized), _i8(nonce)]).encode()).hexdigest()
29
+ return _n8(digest[:5], -4) + "%02d" % (sum(_mix([ord(x) for x in digest[-6:]])) % 100)
30
+
31
+ def load_session(profile: Path | None = None):
32
+ profile = profile or Path.home() / "AppData/Roaming/heybox-pc-launcher"
33
+ db = profile / "Network/Cookies"
34
+ try:
35
+ with sqlite3.connect("file:%s?mode=ro" % db, uri=True) as con:
36
+ rows = con.execute("select name,value from cookies where host_key like '%xiaoheihe.cn'").fetchall()
37
+ except (OSError, sqlite3.Error) as exc:
38
+ raise FuploadError("Heybox desktop Cookie DB is missing", kind="authentication_error") from exc
39
+ cookies = {str(k): str(v) for k,v in rows if k in {"user_heybox_id","user_pkey","x_xhh_tokenid"} and v}
40
+ if not {"user_heybox_id","user_pkey","x_xhh_tokenid"} <= set(cookies):
41
+ raise FuploadError("Heybox desktop login state is incomplete", kind="authentication_error")
42
+ identity = {}
43
+ scope = profile / "sentry/scope_v3.json"
44
+ try:
45
+ crumbs = json.loads(scope.read_text(encoding="utf-8")).get("scope",{}).get("breadcrumbs",[])
46
+ for crumb in reversed(crumbs):
47
+ raw = crumb.get("data",{}).get("url","")
48
+ if "x_app=heybox_pc" in raw:
49
+ query = parse_qs(urlparse(raw).query)
50
+ allowed = {"x_client_type","x_os_type","x_app","version","exe_version","os_version","device_id","channel","heybox_id"}
51
+ identity = {k:v[-1] for k,v in query.items() if k in allowed and v}; break
52
+ except (OSError, ValueError, TypeError):
53
+ pass
54
+ return cookies, identity
@@ -10,6 +10,8 @@ from typing import Any, Callable, Dict, Optional, Sequence, Tuple
10
10
 
11
11
  from . import __version__
12
12
  from .dd import DD
13
+ from .curseforge import CurseForge
14
+ from .blackbox import Blackbox
13
15
  from .errors import FuploadError, ValidationError
14
16
  from .io import read_json, write_error, write_output
15
17
  from .newbee import NewBee
@@ -57,9 +59,10 @@ def _list_flags(parser: argparse.ArgumentParser, *, offset: bool = False, game_t
57
59
  parser.add_argument("--game-type", type=_positive, required=True, help="DD game type selected from `dd options game-types`.")
58
60
 
59
61
 
60
- def _write_leaf(parent: argparse._SubParsersAction, platform: str, resource: str, action: str, summary: str) -> None:
62
+ def _write_leaf(parent: argparse._SubParsersAction, platform: str, resource: str, action: str, summary: str, *, command: Optional[str] = None) -> None:
63
+ command = command or action
61
64
  leaf = parent.add_parser(
62
- action, help=summary, description=summary + "\n\n" + WRITE_HELP,
65
+ command, help=summary, description=summary + "\n\n" + WRITE_HELP,
63
66
  epilog=schema_help(platform, resource, action),
64
67
  formatter_class=argparse.RawDescriptionHelpFormatter,
65
68
  )
@@ -186,16 +189,50 @@ def _dd_tree(platforms: argparse._SubParsersAction) -> None:
186
189
  leaf = _read_leaf(wa, "categories", "List DD WA category choices for a game type.", platform="dd", resource="wa", action="categories"); leaf.add_argument("--game-type", type=_positive, required=True)
187
190
 
188
191
 
192
+ def _curseforge_tree(platforms: argparse._SubParsersAction) -> None:
193
+ root = platforms.add_parser("curseforge", help="CurseForge public project lookup and author uploads")
194
+ groups = root.add_subparsers(dest="resource_command", required=True)
195
+ session = groups.add_parser("session", help="Configuration diagnostics").add_subparsers(dest="action_command", required=True)
196
+ _read_leaf(session, "doctor", "Check whether the fixed CurseForge configuration fields exist without revealing their values.", platform="curseforge", resource="session", action="doctor")
197
+ project = groups.add_parser("project", help="Public project lookup").add_subparsers(dest="action_command", required=True)
198
+ leaf = _read_leaf(project, "list", "List public WoW projects for one CurseForge author ID.", platform="curseforge", resource="project", action="list")
199
+ leaf.add_argument("--author-id", type=_positive, help="Override CURSEFORGE_AUTHOR_ID for this lookup.")
200
+ plugin = groups.add_parser("plugin", help="WoW plugin versions and uploads").add_subparsers(dest="action_command", required=True)
201
+ _read_leaf(plugin, "game-versions", "List CurseForge Upload API game-version choices.", platform="curseforge", resource="plugin", action="game-versions")
202
+ _write_leaf(plugin, "curseforge", "plugin", "upload", "Upload one plugin archive to an existing CurseForge project.")
203
+
204
+
205
+ def _blackbox_tree(platforms: argparse._SubParsersAction) -> None:
206
+ root = platforms.add_parser(
207
+ "blackbox",
208
+ help="Heybox Workshop plugin management",
209
+ description="Reuse the signed-in Heybox desktop client login state; no credential input is accepted.",
210
+ )
211
+ groups = root.add_subparsers(dest="resource_command", required=True)
212
+ plugin = groups.add_parser("plugin", help="Heybox Workshop plugin metadata and versions").add_subparsers(dest="action_command", required=True)
213
+ _read_leaf(plugin, "list", "List plugins managed by the current Heybox Workshop account.", platform="blackbox", resource="plugin", action="list")
214
+ leaf = _read_leaf(plugin, "get", "Read one Heybox Workshop plugin and its versions.", platform="blackbox", resource="plugin", action="get")
215
+ leaf.add_argument("--module-id", type=_positive, required=True)
216
+ leaf = _read_leaf(plugin, "versions", "List versions for one Heybox Workshop plugin.", platform="blackbox", resource="plugin", action="versions")
217
+ leaf.add_argument("--module-id", type=_positive, required=True)
218
+ _write_leaf(plugin, "blackbox", "plugin", "edit", "Edit Heybox Workshop plugin metadata and verify the module readback.")
219
+ _write_leaf(plugin, "blackbox", "plugin", "update", "Upload a ZIP and create a new Heybox Workshop plugin version.")
220
+ _write_leaf(plugin, "blackbox", "version", "edit", "Edit an existing Heybox Workshop plugin version and verify its readback.", command="version-edit")
221
+ _write_leaf(plugin, "blackbox", "version", "delete", "Soft-delete one Heybox Workshop plugin version and verify its deleted state.", command="version-delete")
222
+
223
+
189
224
  def build_parser() -> argparse.ArgumentParser:
190
225
  parser = _parser(
191
226
  prog="fupload",
192
- description="Atomic World of Warcraft author publishing CLI for NewBeeBox and NetEase DD.",
227
+ description="Atomic World of Warcraft author publishing CLI for NewBeeBox, NetEase DD, and CurseForge.",
193
228
  epilog="All output is JSON. Write commands require versioned JSON through --input and never prompt.",
194
229
  )
195
230
  parser.add_argument("--version", action="version", version="%(prog)s " + __version__)
196
231
  platforms = parser.add_subparsers(dest="platform_command", required=True)
197
232
  _newbee_tree(platforms)
198
233
  _dd_tree(platforms)
234
+ _curseforge_tree(platforms)
235
+ _blackbox_tree(platforms)
199
236
  return parser
200
237
 
201
238
 
@@ -243,14 +280,14 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
243
280
  if args.dry_run:
244
281
  write_output(platform, operation, _dry_run_data(doc, schema.name), dry_run=True)
245
282
  return 0
246
- provider = NewBee() if platform == "newbee" else DD()
283
+ provider = NewBee() if platform == "newbee" else (DD() if platform == "dd" else (Blackbox() if platform == "blackbox" else CurseForge()))
247
284
  if platform == "dd":
248
285
  data = provider.execute_write(resource, action, doc, getattr(args, "session", None))
249
286
  else:
250
287
  data = provider.execute_write(resource, action, doc)
251
288
  write_output(platform, operation, data)
252
289
  return 0
253
- provider = NewBee() if platform == "newbee" else DD()
290
+ provider = NewBee() if platform == "newbee" else (DD() if platform == "dd" else (Blackbox() if platform == "blackbox" else CurseForge()))
254
291
  if platform == "dd":
255
292
  data = provider.execute_read(resource, action, args, getattr(args, "session", None))
256
293
  else:
@@ -0,0 +1,186 @@
1
+ """CurseForge public project lookup and author upload provider."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import urllib.parse
8
+ from pathlib import Path
9
+ from typing import Any, Dict, Mapping, Optional
10
+
11
+ from .errors import FuploadError, ValidationError
12
+ from .transport import json_request, multipart_request
13
+
14
+
15
+ CORE_BASE = "https://api.curseforge.com"
16
+ UPLOAD_BASE = "https://wow.curseforge.com"
17
+ CONFIG_KEYS = (
18
+ "CURSEFORGE_AUTHOR_ID",
19
+ "CURSEFORGE_API_KEY",
20
+ "CURSEFORGE_UPLOAD_TOKEN",
21
+ )
22
+
23
+
24
+ def config_path() -> Path:
25
+ return Path.home() / ".fupload" / "curseforge.env"
26
+
27
+
28
+ def load_config(path: Optional[Path] = None) -> Dict[str, str]:
29
+ """Load only the fixed CurseForge fields, with process env taking precedence."""
30
+ source = path or config_path()
31
+ values: Dict[str, str] = {}
32
+ if source.is_file():
33
+ try:
34
+ lines = source.read_text(encoding="utf-8-sig").splitlines()
35
+ except OSError as exc:
36
+ raise FuploadError("cannot read CurseForge configuration: %s" % exc, stage="dependency_get") from exc
37
+ for number, raw in enumerate(lines, 1):
38
+ line = raw.strip()
39
+ if not line or line.startswith("#"):
40
+ continue
41
+ if "=" not in line:
42
+ raise ValidationError("expected NAME=VALUE", path="%s:%d" % (source, number))
43
+ name, value = line.split("=", 1)
44
+ name, value = name.strip(), value.strip()
45
+ if name not in CONFIG_KEYS:
46
+ raise ValidationError("unknown CurseForge configuration field", path="%s:%d" % (source, number))
47
+ if name in values:
48
+ raise ValidationError("duplicate CurseForge configuration field", path="%s:%d" % (source, number))
49
+ values[name] = value
50
+ for name in CONFIG_KEYS:
51
+ environment_value = os.environ.get(name, "").strip()
52
+ if environment_value:
53
+ values[name] = environment_value
54
+ return values
55
+
56
+
57
+ def _required(config: Mapping[str, str], *names: str) -> None:
58
+ missing = [name for name in names if not config.get(name)]
59
+ if missing:
60
+ raise FuploadError(
61
+ "missing CurseForge configuration field(s): %s" % ", ".join(missing),
62
+ kind="authentication_error", stage="dependency_get",
63
+ details={"config_path": str(config_path()), "missing": missing},
64
+ )
65
+
66
+
67
+ def _author_id(value: Any) -> int:
68
+ try:
69
+ result = int(value)
70
+ except (TypeError, ValueError) as exc:
71
+ raise ValidationError("author ID must be a positive integer", path="--author-id") from exc
72
+ if result <= 0:
73
+ raise ValidationError("author ID must be a positive integer", path="--author-id")
74
+ return result
75
+
76
+
77
+ class CurseForge:
78
+ def __init__(self, config: Optional[Mapping[str, str]] = None) -> None:
79
+ self.config = dict(config) if config is not None else load_config()
80
+
81
+ def execute_read(self, resource: str, action: str, args: Any) -> Any:
82
+ if resource == "session" and action == "doctor":
83
+ return self.doctor()
84
+ if resource == "project" and action == "list":
85
+ return self.project_list(getattr(args, "author_id", None))
86
+ if resource == "plugin" and action == "game-versions":
87
+ return self.game_versions()
88
+ raise ValidationError("unsupported CurseForge read operation")
89
+
90
+ def execute_write(self, resource: str, action: str, doc: Mapping[str, Any]) -> Any:
91
+ if resource == "plugin" and action == "upload":
92
+ return self.upload(doc)
93
+ raise ValidationError("unsupported CurseForge write operation")
94
+
95
+ def doctor(self) -> Dict[str, Any]:
96
+ fields = [{"name": name, "present": bool(self.config.get(name))} for name in CONFIG_KEYS]
97
+ return {
98
+ "config_path": str(config_path()),
99
+ "fields": fields,
100
+ "ready": all(field["present"] for field in fields),
101
+ }
102
+
103
+ def project_list(self, author_id: Optional[int]) -> Dict[str, Any]:
104
+ _required(self.config, "CURSEFORGE_API_KEY")
105
+ selected = _author_id(author_id if author_id is not None else self.config.get("CURSEFORGE_AUTHOR_ID"))
106
+ query = urllib.parse.urlencode({"gameId": 1, "authorId": selected, "index": 0, "pageSize": 50})
107
+ url = CORE_BASE + "/v1/mods/search?" + query
108
+ payload = json_request(url, headers={"x-api-key": self.config["CURSEFORGE_API_KEY"]})
109
+ if not isinstance(payload, dict) or not isinstance(payload.get("data"), list):
110
+ raise FuploadError("CurseForge project response did not contain a data array", kind="platform_data_error", endpoint=url)
111
+ pagination = payload.get("pagination") if isinstance(payload.get("pagination"), dict) else {}
112
+ projects = []
113
+ for item in payload["data"]:
114
+ if not isinstance(item, dict):
115
+ raise FuploadError("CurseForge project response contained a non-object item", kind="platform_data_error", endpoint=url)
116
+ projects.append({
117
+ "id": item.get("id"),
118
+ "name": item.get("name"),
119
+ "slug": item.get("slug"),
120
+ "status": item.get("status"),
121
+ "dateCreated": item.get("dateCreated"),
122
+ "dateModified": item.get("dateModified"),
123
+ })
124
+ total_count = pagination.get("totalCount")
125
+ if isinstance(total_count, bool) or not isinstance(total_count, int) or total_count < 0:
126
+ total_count = len(projects)
127
+ return {
128
+ "author_id": selected,
129
+ "game_id": 1,
130
+ "total_count": total_count,
131
+ "projects": projects,
132
+ "pagination": pagination,
133
+ }
134
+
135
+ def game_versions(self) -> Any:
136
+ _required(self.config, "CURSEFORGE_UPLOAD_TOKEN")
137
+ return json_request(
138
+ UPLOAD_BASE + "/api/game/versions",
139
+ headers={"X-Api-Token": self.config["CURSEFORGE_UPLOAD_TOKEN"]},
140
+ )
141
+
142
+ def upload(self, doc: Mapping[str, Any]) -> Dict[str, Any]:
143
+ _required(self.config, "CURSEFORGE_UPLOAD_TOKEN")
144
+ project_id = int(doc["project_id"])
145
+ file_path = str(doc["file"])
146
+ field_names = {
147
+ "changelog": "changelog",
148
+ "changelog_type": "changelogType",
149
+ "display_name": "displayName",
150
+ "game_versions": "gameVersions",
151
+ "game_version_names": "gameVersionNames",
152
+ "release_type": "releaseType",
153
+ "parent_file_id": "parentFileID",
154
+ "is_marked_for_manual_release": "isMarkedForManualRelease",
155
+ }
156
+ metadata = {wire: doc[name] for name, wire in field_names.items() if name in doc}
157
+ if "relations" in doc:
158
+ projects = []
159
+ for item in doc["relations"]["projects"]:
160
+ relation = {"slug": item["slug"], "type": item["type"]}
161
+ if "project_id" in item:
162
+ relation["projectID"] = item["project_id"]
163
+ projects.append(relation)
164
+ metadata["relations"] = {"projects": projects}
165
+ url = UPLOAD_BASE + "/api/projects/%d/upload-file" % project_id
166
+ response = multipart_request(
167
+ url, file_path, file_field="file",
168
+ fields={"metadata": json.dumps(metadata, ensure_ascii=False, separators=(",", ":"))},
169
+ headers={"X-Api-Token": self.config["CURSEFORGE_UPLOAD_TOKEN"]},
170
+ )
171
+ if (
172
+ not isinstance(response, dict)
173
+ or isinstance(response.get("id"), bool)
174
+ or not isinstance(response.get("id"), int)
175
+ or response["id"] <= 0
176
+ ):
177
+ raise FuploadError(
178
+ "CurseForge upload response did not contain a positive integer id",
179
+ kind="platform_data_error", endpoint=url,
180
+ )
181
+ return {
182
+ "file_id": response["id"],
183
+ "project_id": project_id,
184
+ "archive": Path(file_path).name,
185
+ "status": "uploaded",
186
+ }
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import os
6
+ import zipfile
6
7
  from dataclasses import dataclass, field
7
8
  from typing import Any, Dict, Iterable, Mapping, Optional, Sequence, Tuple
8
9
 
@@ -306,6 +307,39 @@ class Schema:
306
307
  raise ValidationError("expected nonempty string or null", path="$.retail_ui_config.default_edit_mode_selector")
307
308
  if "enable_dd_setup_wizard" in retail and not isinstance(retail["enable_dd_setup_wizard"], bool):
308
309
  raise ValidationError("expected boolean", path="$.retail_ui_config.enable_dd_setup_wizard")
310
+ if self.name == "fupload.v1.curseforge.plugin.upload":
311
+ if not zipfile.is_zipfile(value["file"]):
312
+ raise ValidationError("file must be a valid ZIP archive", path="$.file")
313
+ if "game_versions" in value and any(isinstance(item, bool) or not isinstance(item, int) or item <= 0 for item in value["game_versions"]):
314
+ raise ValidationError("array must contain positive integer IDs", path="$.game_versions")
315
+ if "game_version_names" in value and any(not isinstance(item, str) or not item.strip() for item in value["game_version_names"]):
316
+ raise ValidationError("array must contain nonempty strings", path="$.game_version_names")
317
+ relations = value.get("relations")
318
+ if relations is not None:
319
+ if set(relations) != {"projects"}:
320
+ unknown = sorted(set(relations) - {"projects"})
321
+ message = "unknown field(s): %s" % ", ".join(unknown) if unknown else "projects is required"
322
+ raise ValidationError(message, path="$.relations")
323
+ if not isinstance(relations["projects"], list):
324
+ raise ValidationError("expected array", path="$.relations.projects")
325
+ for index, relation in enumerate((relations or {}).get("projects") or []):
326
+ if not isinstance(relation, dict):
327
+ raise ValidationError("expected object", path="$.relations.projects[%d]" % index)
328
+ unknown = sorted(set(relation) - {"slug", "type", "project_id"})
329
+ if unknown:
330
+ raise ValidationError("unknown field(s): %s" % ", ".join(unknown), path="$.relations.projects[%d].%s" % (index, unknown[0]))
331
+ if not {"slug", "type"}.issubset(relation):
332
+ raise ValidationError("slug and type are required", path="$.relations.projects[%d]" % index)
333
+ if not isinstance(relation["slug"], str) or not relation["slug"].strip():
334
+ raise ValidationError("expected nonempty string", path="$.relations.projects[%d].slug" % index)
335
+ if relation["type"] not in ("embeddedLibrary", "incompatible", "optionalDependency", "requiredDependency", "tool"):
336
+ raise ValidationError("unsupported relation type", path="$.relations.projects[%d].type" % index)
337
+ if "project_id" in relation and (isinstance(relation["project_id"], bool) or not isinstance(relation["project_id"], int) or relation["project_id"] <= 0):
338
+ raise ValidationError("must be a positive integer", path="$.relations.projects[%d].project_id" % index)
339
+ if "parent_file_id" in value:
340
+ for field_name in ("game_versions", "game_version_names"):
341
+ if field_name in value:
342
+ raise ValidationError("must be omitted when parent_file_id is set", path="$.%s" % field_name)
309
343
 
310
344
 
311
345
  def f(type_name: str, **kwargs: Any) -> Field:
@@ -504,6 +538,38 @@ for _resource in ("plugin", "config", "wa"):
504
538
  "confirm_delete": f("boolean", choices=(True,)),
505
539
  }, ("sn", "confirm_delete")))
506
540
 
541
+ register("curseforge", "plugin", "upload", required({
542
+ "project_id": f("integer", minimum=1),
543
+ "file": f("string", local_file=True),
544
+ "changelog": f("string"),
545
+ "changelog_type": f("string", choices=("text", "html", "markdown")),
546
+ "display_name": f("string", nonempty=True),
547
+ "game_versions": f("array", nonempty=True),
548
+ "game_version_names": f("array"),
549
+ "release_type": f("string", choices=("alpha", "beta", "release")),
550
+ "parent_file_id": f("integer", minimum=1),
551
+ "relations": f("object"),
552
+ "is_marked_for_manual_release": f("boolean"),
553
+ }, ("project_id", "file", "changelog", "release_type")))
554
+
555
+ register("blackbox", "plugin", "edit", required({
556
+ "id": f("integer"), "name": f("string"), "logo_url": f("string"), "category_ids": f("array"),
557
+ "type": f("integer", choices=(1, 9)), "desc": f("string"), "official": f("string"),
558
+ "official_url": f("string"), "core_folders": f("array"),
559
+ }, ("id",)))
560
+ register("blackbox", "plugin", "update", required({
561
+ "module_id": f("integer"), "name": f("string", nonempty=True), "type": f("integer", choices=(1, 2, 3)),
562
+ "game_versions": f("array", nonempty=True), "file": f("string", local_file=True), "file_url": f("string"),
563
+ }, ("module_id", "name", "type", "game_versions", "file")))
564
+ register("blackbox", "version", "edit", required({
565
+ "version_id": f("integer"), "module_id": f("integer"), "name": f("string", nonempty=True),
566
+ "type": f("integer", choices=(1, 2, 3)), "game_versions": f("array", nonempty=True),
567
+ "file": f("string", local_file=True), "file_url": f("string"),
568
+ }, ("version_id", "module_id", "name", "type", "game_versions")))
569
+ register("blackbox", "version", "delete", required({
570
+ "version_id": f("integer"), "module_id": f("integer"),
571
+ }, ("version_id", "module_id")))
572
+
507
573
 
508
574
  def get_schema(platform: str, resource: str, action: str) -> Schema:
509
575
  try:
@@ -3,6 +3,7 @@
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
 
6
+ import { ensureCurseForgeEnv } from "../lib/curseforge-config.mjs";
6
7
  import { recordManagedSkill } from "../lib/managed-install.mjs";
7
8
  import { parseLauncherOptions, resolveSkillDirectory } from "../lib/options.mjs";
8
9
  import { discoverPython, runPython } from "../lib/python.mjs";
@@ -52,6 +53,7 @@ async function main() {
52
53
 
53
54
  let ensured;
54
55
  try {
56
+ ensureCurseForgeEnv();
55
57
  ensured = await ensureSkill({ packageRoot, target });
56
58
  recordManagedSkill(target);
57
59
  } catch (error) {
@@ -0,0 +1,36 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ export const CURSEFORGE_ENV_TEMPLATE = [
6
+ "CURSEFORGE_AUTHOR_ID=",
7
+ "CURSEFORGE_API_KEY=",
8
+ "CURSEFORGE_UPLOAD_TOKEN=",
9
+ "",
10
+ ].join("\n");
11
+
12
+ export function curseForgeEnvPath({ home = os.homedir() } = {}) {
13
+ return path.join(home, ".fupload", "curseforge.env");
14
+ }
15
+
16
+ export function ensureCurseForgeEnv({ home = os.homedir(), platform = process.platform } = {}) {
17
+ const filename = curseForgeEnvPath({ home });
18
+ fs.mkdirSync(path.dirname(filename), { recursive: true, mode: 0o700 });
19
+
20
+ try {
21
+ fs.writeFileSync(filename, CURSEFORGE_ENV_TEMPLATE, {
22
+ encoding: "utf8",
23
+ flag: "wx",
24
+ mode: 0o600,
25
+ });
26
+ if (platform !== "win32") {
27
+ fs.chmodSync(filename, 0o600);
28
+ }
29
+ return { path: filename, status: "created" };
30
+ } catch (error) {
31
+ if (error?.code === "EEXIST") {
32
+ return { path: filename, status: "preserved" };
33
+ }
34
+ throw error;
35
+ }
36
+ }