Files
2026-09-20 12:12:56 +02:00

167 lines
7.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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