@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.
@@ -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
- unknown = sorted(set(item) - {"op", "name", "base64"})
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 = item.get("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
- result[str(key)] = "[REDACTED]" if normalized in _SECRET_KEYS else _safe(item)
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)] = source[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
- wire = self._share_wire(doc)
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)] = source[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
- object_array("image_ops", {"op", "name", "base64"}, ("op", "name"))
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
- if operation["op"] not in ("upload", "delete"):
448
- raise ValidationError("must be upload or delete", path="$.image_ops[%d].op" % index)
449
- if not isinstance(operation["name"], str) or not operation["name"].strip():
450
- raise ValidationError("must not be empty", path="$.image_ops[%d].name" % index)
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", nonempty=True, max_length=10000),
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
- content_type = mimetypes.guess_type(filename)[0] or "application/octet-stream"
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"),