@follenfang/fupload 0.0.12 → 0.0.16
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 +11 -1
- package/fupload/references/dd.md +18 -3
- 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.py +17 -0
- package/fupload/scripts/fupload_cli/dd_broker.py +58 -14
- package/fupload/scripts/fupload_cli/dd_sidecar.py +67 -8
- package/fupload/scripts/fupload_cli/io.py +20 -2
- 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 +26 -26
- package/package.json +1 -1
|
@@ -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"),
|