"""MakerWorld HTTP layer. Constants and the low-level transport helpers for the MakerWorld / Bambu Lab APIs: the S3 presigned-download path that must reach the transport byte-for-byte, upstream error extraction, and the CDN SSRF guard helpers used by :class:`MakerWorldService`. The app-scoped shared ``httpx`` client lives with its consumer instead (see ``service.set_shared_http_client``) — same-module so the service reads the live value, matching ``bambu_cloud`` / ``orca_cloud`` / ``slicer_api``. """ from __future__ import annotations import asyncio import ssl import certifi import httpx from backend.app.services.model_providers.makerworld.errors import MakerWorldUnavailableError # API base: ``api.bambulab.com/v1/design-service`` — the same Bambu Cloud # backend that the MakerWorld web UI talks to, but not behind Cloudflare # (the website ``makerworld.com`` is, and plain httpx requests there get # fingerprinted as bot traffic and served "Please log in"). MAKERWORLD_API_BASE = "https://api.bambulab.com/v1/design-service" # Besides MakerWorld's own CDN, Bambu Cloud also issues AWS S3 presigned # URLs (e.g. ``s3.us-west-2.amazonaws.com``) from the iot-service download # endpoint. The suffix check matches any regional S3 endpoint. # # Deliberately NOT part of the ``download_hosts()`` seam: that seam is an # exact-host allowlist a provider declares, and this is a suffix family # belonging to Bambu's signed-URL infrastructure specifically. It stays a # constant of *this* provider's transport: ``download_3mf`` accepts the # injected hosts or an S3 endpoint, while a second provider brings its own # service and declares its own ``download_hosts()``. _ALLOWED_DOWNLOAD_SUFFIXES = (".amazonaws.com",) # The shared default SSRF allowlist for MakerWorld CDN traffic. The thumbnail # proxy and the 3MF download path are both driven by the provider descriptor # instead — ``build_service`` feeds the runner's ``ModelProvider.thumbnail_hosts()`` # and ``download_hosts()`` into ``MakerWorldService`` — and this tuple is what # those methods return by default. Lives here with the other transport guards # so the allowlist is in one place. MAKERWORLD_CDN_HOSTS = ("makerworld.bblmw.com", "public-cdn.bblmw.com") # Client identity sent to MakerWorld / api.bambulab.com. We identify honestly # as Bambuddy with a source URL so Bambu can distinguish our traffic from # impersonators — the opposite of what the OrcaSlicer fork was called out for # in the May 2026 Bambu Lab blog post on cloud access. The Referer is kept # because MakerWorld's CSRF / origin-check middleware uses it on some # endpoints — that's distinct from client impersonation. _CLIENT_HEADERS = { "User-Agent": "Bambuddy/1.0 (+https://github.com/maziggy/bambuddy)", "Accept": "text/html,application/json,*/*", "Accept-Language": "en-US,en;q=0.9", "Referer": "https://makerworld.com/", } _MAX_3MF_BYTES = 200 * 1024 * 1024 # 200 MB hard cap _MAX_THUMBNAIL_BYTES = 10 * 1024 * 1024 # 10 MB hard cap — MakerWorld's "thumbnails" can be 2–3 MB source images _IMAGE_EXT_TO_MIME = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", ".bmp": "image/bmp", } # Content types we refuse even if the URL extension looks image-y — prevents # forwarding an upstream error page or JSON blob with image framing. _REFUSED_THUMBNAIL_MIMES = ("text/html", "text/plain", "application/json") def _s3_ssl_context() -> ssl.SSLContext: """Build the TLS context used for the S3 presigned download (#2562). ``urllib.request`` verifies against the *OS* trust store, while httpx — every other network call in Bambuddy — verifies against the bundled ``certifi`` CA bundle. On Windows those two disagree: Python's ``ssl.load_default_certs()`` only enumerates the roots already cached in the Windows ROOT store, and Windows populates that store lazily via CryptoAPI's auto-update, which Python never triggers. If the Amazon root signing the S3 chain isn't cached on that machine yet, verification fails with ``unable to get local issuer certificate`` — even though the api.bambulab.com calls that preceded it (httpx) succeeded. Pinning urllib to certifi makes the S3 hop trust exactly what the rest of the app already trusts. Built per call rather than at import so a certifi refresh doesn't require a restart; construction is cheap relative to the download that follows. """ return ssl.create_default_context(cafile=certifi.where()) async def _download_s3_urllib(url: str, filename_fallback: str) -> tuple[bytes, str]: """Fetch an AWS S3 presigned URL without touching the query string. ``urllib.request`` passes the URL to the transport verbatim — which is essential for S3 presigned URLs where the signature is computed over the exact query-string bytes. httpx's ``URL`` class and curl_cffi's libcurl layer both normalise encodings and produce ``SignatureDoesNotMatch`` 400s from S3. Runs the blocking urllib call in a thread executor so we don't stall the event loop. """ from urllib.request import HTTPRedirectHandler, HTTPSHandler, Request, build_opener # Don't follow redirects: the host allowlist is only enforced on # the initial URL. A 302 from S3 to any other host would otherwise # transparently bypass the allowlist — so insist S3 resolve directly. class _NoRedirect(HTTPRedirectHandler): def redirect_request(self, *args, **kwargs): # type: ignore[override] return None # HTTPSHandler swaps only the TLS context — the URL still reaches the # transport verbatim, which is what the S3 signature depends on. opener = build_opener(_NoRedirect, HTTPSHandler(context=_s3_ssl_context())) def _blocking_fetch() -> bytes: req = Request(url, headers={"User-Agent": _CLIENT_HEADERS["User-Agent"]}) with opener.open(req, timeout=60.0) as resp: if resp.status != 200: raise MakerWorldUnavailableError(f"3MF download returned HTTP {resp.status}") data = b"" while True: chunk = resp.read(65536) if not chunk: break data += chunk if len(data) > _MAX_3MF_BYTES: raise MakerWorldUnavailableError(f"3MF exceeds {_MAX_3MF_BYTES // (1024 * 1024)} MB cap") return data try: data = await asyncio.to_thread(_blocking_fetch) except MakerWorldUnavailableError: raise except Exception as exc: # noqa: BLE001 — urllib throws a zoo of exceptions raise MakerWorldUnavailableError(f"S3 download failed: {exc}") from exc return data, filename_fallback def _extract_upstream_error(response: httpx.Response) -> str | None: """Pull MakerWorld's own error text out of a 4xx/5xx response body. MakerWorld returns ``{"code": N, "error": "text"}`` on auth/perm failures and sometimes ``{"message": "..."}`` on other errors. Returns ``None`` if the body isn't JSON or doesn't have a recognised error field — callers should fall back to a generic message in that case. """ try: data = response.json() except ValueError: return None if not isinstance(data, dict): return None for key in ("error", "message", "detail"): value = data.get(key) if isinstance(value, str) and value.strip(): return value.strip() return None