http.py 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166
  1. """MakerWorld HTTP layer.
  2. Constants and the low-level transport helpers for the MakerWorld / Bambu Lab
  3. APIs: the S3 presigned-download path that must reach the transport
  4. byte-for-byte, upstream error extraction, and the CDN SSRF guard helpers used
  5. by :class:`MakerWorldService`.
  6. The app-scoped shared ``httpx`` client lives with its consumer instead (see
  7. ``service.set_shared_http_client``) — same-module so the service reads the
  8. live value, matching ``bambu_cloud`` / ``orca_cloud`` / ``slicer_api``.
  9. """
  10. from __future__ import annotations
  11. import asyncio
  12. import ssl
  13. import certifi
  14. import httpx
  15. from backend.app.services.model_providers.makerworld.errors import MakerWorldUnavailableError
  16. # API base: ``api.bambulab.com/v1/design-service`` — the same Bambu Cloud
  17. # backend that the MakerWorld web UI talks to, but not behind Cloudflare
  18. # (the website ``makerworld.com`` is, and plain httpx requests there get
  19. # fingerprinted as bot traffic and served "Please log in").
  20. MAKERWORLD_API_BASE = "https://api.bambulab.com/v1/design-service"
  21. # Besides MakerWorld's own CDN, Bambu Cloud also issues AWS S3 presigned
  22. # URLs (e.g. ``s3.us-west-2.amazonaws.com``) from the iot-service download
  23. # endpoint. The suffix check matches any regional S3 endpoint.
  24. #
  25. # Deliberately NOT part of the ``download_hosts()`` seam: that seam is an
  26. # exact-host allowlist a provider declares, and this is a suffix family
  27. # belonging to Bambu's signed-URL infrastructure specifically. It stays a
  28. # constant of *this* provider's transport: ``download_3mf`` accepts the
  29. # injected hosts or an S3 endpoint, while a second provider brings its own
  30. # service and declares its own ``download_hosts()``.
  31. _ALLOWED_DOWNLOAD_SUFFIXES = (".amazonaws.com",)
  32. # The shared default SSRF allowlist for MakerWorld CDN traffic. The thumbnail
  33. # proxy and the 3MF download path are both driven by the provider descriptor
  34. # instead — ``build_service`` feeds the runner's ``ModelProvider.thumbnail_hosts()``
  35. # and ``download_hosts()`` into ``MakerWorldService`` — and this tuple is what
  36. # those methods return by default. Lives here with the other transport guards
  37. # so the allowlist is in one place.
  38. MAKERWORLD_CDN_HOSTS = ("makerworld.bblmw.com", "public-cdn.bblmw.com")
  39. # Client identity sent to MakerWorld / api.bambulab.com. We identify honestly
  40. # as Bambuddy with a source URL so Bambu can distinguish our traffic from
  41. # impersonators — the opposite of what the OrcaSlicer fork was called out for
  42. # in the May 2026 Bambu Lab blog post on cloud access. The Referer is kept
  43. # because MakerWorld's CSRF / origin-check middleware uses it on some
  44. # endpoints — that's distinct from client impersonation.
  45. _CLIENT_HEADERS = {
  46. "User-Agent": "Bambuddy/1.0 (+https://github.com/maziggy/bambuddy)",
  47. "Accept": "text/html,application/json,*/*",
  48. "Accept-Language": "en-US,en;q=0.9",
  49. "Referer": "https://makerworld.com/",
  50. }
  51. _MAX_3MF_BYTES = 200 * 1024 * 1024 # 200 MB hard cap
  52. _MAX_THUMBNAIL_BYTES = 10 * 1024 * 1024 # 10 MB hard cap — MakerWorld's "thumbnails" can be 2–3 MB source images
  53. _IMAGE_EXT_TO_MIME = {
  54. ".png": "image/png",
  55. ".jpg": "image/jpeg",
  56. ".jpeg": "image/jpeg",
  57. ".gif": "image/gif",
  58. ".webp": "image/webp",
  59. ".bmp": "image/bmp",
  60. }
  61. # Content types we refuse even if the URL extension looks image-y — prevents
  62. # forwarding an upstream error page or JSON blob with image framing.
  63. _REFUSED_THUMBNAIL_MIMES = ("text/html", "text/plain", "application/json")
  64. def _s3_ssl_context() -> ssl.SSLContext:
  65. """Build the TLS context used for the S3 presigned download (#2562).
  66. ``urllib.request`` verifies against the *OS* trust store, while httpx —
  67. every other network call in Bambuddy — verifies against the bundled
  68. ``certifi`` CA bundle. On Windows those two disagree: Python's
  69. ``ssl.load_default_certs()`` only enumerates the roots already cached in
  70. the Windows ROOT store, and Windows populates that store lazily via
  71. CryptoAPI's auto-update, which Python never triggers. If the Amazon root
  72. signing the S3 chain isn't cached on that machine yet, verification fails
  73. with ``unable to get local issuer certificate`` — even though the
  74. api.bambulab.com calls that preceded it (httpx) succeeded.
  75. Pinning urllib to certifi makes the S3 hop trust exactly what the rest of
  76. the app already trusts. Built per call rather than at import so a certifi
  77. refresh doesn't require a restart; construction is cheap relative to the
  78. download that follows.
  79. """
  80. return ssl.create_default_context(cafile=certifi.where())
  81. async def _download_s3_urllib(url: str, filename_fallback: str) -> tuple[bytes, str]:
  82. """Fetch an AWS S3 presigned URL without touching the query string.
  83. ``urllib.request`` passes the URL to the transport verbatim — which is
  84. essential for S3 presigned URLs where the signature is computed over
  85. the exact query-string bytes. httpx's ``URL`` class and curl_cffi's
  86. libcurl layer both normalise encodings and produce
  87. ``SignatureDoesNotMatch`` 400s from S3.
  88. Runs the blocking urllib call in a thread executor so we don't stall
  89. the event loop.
  90. """
  91. from urllib.request import HTTPRedirectHandler, HTTPSHandler, Request, build_opener
  92. # Don't follow redirects: the host allowlist is only enforced on
  93. # the initial URL. A 302 from S3 to any other host would otherwise
  94. # transparently bypass the allowlist — so insist S3 resolve directly.
  95. class _NoRedirect(HTTPRedirectHandler):
  96. def redirect_request(self, *args, **kwargs): # type: ignore[override]
  97. return None
  98. # HTTPSHandler swaps only the TLS context — the URL still reaches the
  99. # transport verbatim, which is what the S3 signature depends on.
  100. opener = build_opener(_NoRedirect, HTTPSHandler(context=_s3_ssl_context()))
  101. def _blocking_fetch() -> bytes:
  102. req = Request(url, headers={"User-Agent": _CLIENT_HEADERS["User-Agent"]})
  103. with opener.open(req, timeout=60.0) as resp:
  104. if resp.status != 200:
  105. raise MakerWorldUnavailableError(f"3MF download returned HTTP {resp.status}")
  106. data = b""
  107. while True:
  108. chunk = resp.read(65536)
  109. if not chunk:
  110. break
  111. data += chunk
  112. if len(data) > _MAX_3MF_BYTES:
  113. raise MakerWorldUnavailableError(f"3MF exceeds {_MAX_3MF_BYTES // (1024 * 1024)} MB cap")
  114. return data
  115. try:
  116. data = await asyncio.to_thread(_blocking_fetch)
  117. except MakerWorldUnavailableError:
  118. raise
  119. except Exception as exc: # noqa: BLE001 — urllib throws a zoo of exceptions
  120. raise MakerWorldUnavailableError(f"S3 download failed: {exc}") from exc
  121. return data, filename_fallback
  122. def _extract_upstream_error(response: httpx.Response) -> str | None:
  123. """Pull MakerWorld's own error text out of a 4xx/5xx response body.
  124. MakerWorld returns ``{"code": N, "error": "text"}`` on auth/perm failures
  125. and sometimes ``{"message": "..."}`` on other errors. Returns ``None`` if
  126. the body isn't JSON or doesn't have a recognised error field — callers
  127. should fall back to a generic message in that case.
  128. """
  129. try:
  130. data = response.json()
  131. except ValueError:
  132. return None
  133. if not isinstance(data, dict):
  134. return None
  135. for key in ("error", "message", "detail"):
  136. value = data.get(key)
  137. if isinstance(value, str) and value.strip():
  138. return value.strip()
  139. return None