makerworld.py 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617
  1. """MakerWorld API service.
  2. Thin async client for MakerWorld's ``/api/v1/design-service/*`` endpoints.
  3. Lets Bambuddy resolve a MakerWorld URL, enumerate plate/profile metadata, and
  4. download the 3MF bundle so users can import and print MakerWorld models
  5. without leaving the app.
  6. The endpoints and header set were reverse-engineered from the
  7. `kloshi-io/makerworld-api-reverse` TypeScript project (Apache-2.0) and
  8. cross-validated against live MakerWorld traffic. Authenticated calls reuse
  9. Bambuddy's existing Bambu Cloud bearer token (same SSO backend — no separate
  10. OAuth flow needed).
  11. Only interoperability — not affiliated with or endorsed by MakerWorld or
  12. Bambu Lab, and not intended to circumvent any access control.
  13. """
  14. from __future__ import annotations
  15. import asyncio
  16. import logging
  17. import re
  18. import ssl
  19. from collections.abc import Awaitable, Callable
  20. from typing import Any
  21. from urllib.parse import urlparse
  22. import certifi
  23. import httpx
  24. logger = logging.getLogger(__name__)
  25. # API base: ``api.bambulab.com/v1/design-service`` — the same Bambu Cloud
  26. # backend that the MakerWorld web UI talks to, but not behind Cloudflare
  27. # (the website ``makerworld.com`` is, and plain httpx requests there get
  28. # fingerprinted as bot traffic and served "Please log in"). Confirmed by
  29. # Pr0zak/YASTL#51 and verified with direct curl.
  30. MAKERWORLD_API_BASE = "https://api.bambulab.com/v1/design-service"
  31. MAKERWORLD_HOST = "makerworld.com" # Used only for URL parsing (input validation)
  32. MAKERWORLD_CDN_HOSTS = ("makerworld.bblmw.com", "public-cdn.bblmw.com")
  33. # Hosts that the iot-service download endpoint may return presigned URLs
  34. # for. Besides MakerWorld's own CDN, Bambu Cloud also issues AWS S3
  35. # presigned URLs (e.g. ``s3.us-west-2.amazonaws.com``) — confirmed by
  36. # Pr0zak/YASTL#52. The suffix check matches any regional S3 endpoint.
  37. _ALLOWED_DOWNLOAD_SUFFIXES = (".amazonaws.com",)
  38. # Client identity sent to MakerWorld / api.bambulab.com. We identify honestly
  39. # as Bambuddy with a source URL so Bambu can distinguish our traffic from
  40. # impersonators — the opposite of what the OrcaSlicer fork was called out for
  41. # in the May 2026 Bambu Lab blog post on cloud access. Verified 2026-05-12 via
  42. # curl that MakerWorld treats this UA identically to a Firefox UA at the
  43. # Cloudflare edge (same response shape on /api/v1/design-service/* paths).
  44. # The Referer is kept because MakerWorld's CSRF / origin-check middleware uses
  45. # it on some endpoints — that's distinct from client impersonation.
  46. _CLIENT_HEADERS = {
  47. "User-Agent": "Bambuddy/1.0 (+https://github.com/maziggy/bambuddy)",
  48. "Accept": "text/html,application/json,*/*",
  49. "Accept-Language": "en-US,en;q=0.9",
  50. "Referer": "https://makerworld.com/",
  51. }
  52. # Shown whenever Bambu rejects the stored bearer. Bambu's own 401 body is
  53. # ``{"code":4,"error":"Please login.","message":""}`` and we used to forward that
  54. # string verbatim, which surfaced as a "Please login." toast on a UI that was
  55. # simultaneously reporting the user as connected — maximally confusing, and it
  56. # named no page to go to. Say what happened and where to fix it. Bambu Cloud
  57. # sign-in lives on the Profiles page (ProfilesPage.tsx, "Cloud Profiles" tab);
  58. # there is no Settings → Bambu Cloud page, which is what the old fallback text
  59. # told people to look for.
  60. _SIGN_IN_EXPIRED_MESSAGE = (
  61. "Your Bambu Cloud sign-in has expired. Open the Profiles page and sign in to Bambu Cloud again."
  62. )
  63. _MODEL_ID_RE = re.compile(r"/models/(\d+)")
  64. _PROFILE_ID_RE = re.compile(r"#profileId[-=](\d+)")
  65. _MAX_3MF_BYTES = 200 * 1024 * 1024 # 200 MB hard cap
  66. _MAX_THUMBNAIL_BYTES = 10 * 1024 * 1024 # 10 MB hard cap — MakerWorld's "thumbnails" can be 2–3 MB source images
  67. _IMAGE_EXT_TO_MIME = {
  68. ".png": "image/png",
  69. ".jpg": "image/jpeg",
  70. ".jpeg": "image/jpeg",
  71. ".gif": "image/gif",
  72. ".webp": "image/webp",
  73. ".bmp": "image/bmp",
  74. }
  75. # Content types we refuse even if the URL extension looks image-y — prevents
  76. # forwarding an upstream error page or JSON blob with image framing.
  77. _REFUSED_THUMBNAIL_MIMES = ("text/html", "text/plain", "application/json")
  78. _shared_http_client: httpx.AsyncClient | None = None
  79. def _s3_ssl_context() -> ssl.SSLContext:
  80. """Build the TLS context used for the S3 presigned download (#2562).
  81. ``urllib.request`` verifies against the *OS* trust store, while httpx —
  82. every other network call in Bambuddy — verifies against the bundled
  83. ``certifi`` CA bundle. On Windows those two disagree: Python's
  84. ``ssl.load_default_certs()`` only enumerates the roots already cached in
  85. the Windows ROOT store, and Windows populates that store lazily via
  86. CryptoAPI's auto-update, which Python never triggers. If the Amazon root
  87. signing the S3 chain isn't cached on that machine yet, verification fails
  88. with ``unable to get local issuer certificate`` — even though the
  89. api.bambulab.com calls that preceded it (httpx) succeeded.
  90. Pinning urllib to certifi makes the S3 hop trust exactly what the rest of
  91. the app already trusts. Built per call rather than at import so a certifi
  92. refresh doesn't require a restart; construction is cheap relative to the
  93. download that follows.
  94. """
  95. return ssl.create_default_context(cafile=certifi.where())
  96. def set_shared_http_client(client: httpx.AsyncClient | None) -> None:
  97. """Register an app-scoped ``httpx.AsyncClient`` for service reuse.
  98. Same pattern as ``bambu_cloud.set_shared_http_client`` — lets the FastAPI
  99. lifespan share one connection pool across per-request service instances.
  100. """
  101. global _shared_http_client
  102. _shared_http_client = client
  103. class MakerWorldError(Exception):
  104. """Base exception for MakerWorld API errors."""
  105. class MakerWorldAuthError(MakerWorldError):
  106. """Raised when the endpoint requires a Bambu Cloud token and we don't have
  107. one (or the one we sent was rejected). True auth failure."""
  108. class MakerWorldForbiddenError(MakerWorldError):
  109. """Raised when MakerWorld refuses access despite valid authentication —
  110. content-gated (points required, purchase required, region restricted,
  111. early-access, etc.). The message includes MakerWorld's own reason text
  112. when provided."""
  113. class MakerWorldNotFoundError(MakerWorldError):
  114. """Raised when a design / profile / instance doesn't exist."""
  115. class MakerWorldUnavailableError(MakerWorldError):
  116. """Raised on 5xx, network errors, or malformed payloads."""
  117. class MakerWorldUrlError(MakerWorldError):
  118. """Raised when a URL isn't a makerworld.com model page."""
  119. async def _download_s3_urllib(url: str, filename_fallback: str) -> tuple[bytes, str]:
  120. """Fetch an AWS S3 presigned URL without touching the query string.
  121. ``urllib.request`` passes the URL to the transport verbatim — which is
  122. essential for S3 presigned URLs where the signature is computed over
  123. the exact query-string bytes. httpx's ``URL`` class and curl_cffi's
  124. libcurl layer both normalise encodings and produce
  125. ``SignatureDoesNotMatch`` 400s from S3.
  126. Runs the blocking urllib call in a thread executor so we don't stall
  127. the event loop.
  128. """
  129. from urllib.request import HTTPRedirectHandler, HTTPSHandler, Request, build_opener
  130. # Don't follow redirects: the host allowlist above is only enforced on
  131. # the initial URL. A 302 from S3 to any other host would otherwise
  132. # transparently bypass the allowlist — so insist S3 resolve directly.
  133. class _NoRedirect(HTTPRedirectHandler):
  134. def redirect_request(self, *args, **kwargs): # type: ignore[override]
  135. return None
  136. # HTTPSHandler swaps only the TLS context — the URL still reaches the
  137. # transport verbatim, which is what the S3 signature depends on.
  138. opener = build_opener(_NoRedirect, HTTPSHandler(context=_s3_ssl_context()))
  139. def _blocking_fetch() -> bytes:
  140. req = Request(url, headers={"User-Agent": _CLIENT_HEADERS["User-Agent"]})
  141. with opener.open(req, timeout=60.0) as resp:
  142. if resp.status != 200:
  143. raise MakerWorldUnavailableError(f"3MF download returned HTTP {resp.status}")
  144. data = b""
  145. while True:
  146. chunk = resp.read(65536)
  147. if not chunk:
  148. break
  149. data += chunk
  150. if len(data) > _MAX_3MF_BYTES:
  151. raise MakerWorldUnavailableError(f"3MF exceeds {_MAX_3MF_BYTES // (1024 * 1024)} MB cap")
  152. return data
  153. try:
  154. data = await asyncio.to_thread(_blocking_fetch)
  155. except MakerWorldUnavailableError:
  156. raise
  157. except Exception as exc: # noqa: BLE001 — urllib throws a zoo of exceptions
  158. raise MakerWorldUnavailableError(f"S3 download failed: {exc}") from exc
  159. return data, filename_fallback
  160. def _extract_upstream_error(response: httpx.Response) -> str | None:
  161. """Pull MakerWorld's own error text out of a 4xx/5xx response body.
  162. MakerWorld returns ``{"code": N, "error": "text"}`` on auth/perm failures
  163. and sometimes ``{"message": "..."}`` on other errors. Returns ``None`` if
  164. the body isn't JSON or doesn't have a recognised error field — callers
  165. should fall back to a generic message in that case.
  166. """
  167. try:
  168. data = response.json()
  169. except ValueError:
  170. return None
  171. if not isinstance(data, dict):
  172. return None
  173. for key in ("error", "message", "detail"):
  174. value = data.get(key)
  175. if isinstance(value, str) and value.strip():
  176. return value.strip()
  177. return None
  178. class MakerWorldService:
  179. """Per-request MakerWorld API client.
  180. Mirrors ``BambuCloudService``'s construction pattern so callers can
  181. instantiate per request, reuse the shared connection pool in production,
  182. inject a client in tests, and close the client only if they own it.
  183. """
  184. def __init__(
  185. self,
  186. client: httpx.AsyncClient | None = None,
  187. auth_token: str | None = None,
  188. on_auth_failure: Callable[[], Awaitable[None]] | None = None,
  189. ):
  190. # Fired when Bambu rejects the stored token (401). MakerWorld runs on the
  191. # same Bambu Cloud bearer as everything else, so a rejection here means
  192. # the credential is dead app-wide — see ``build_authenticated_cloud``.
  193. self._on_auth_failure = on_auth_failure
  194. self._auth_failure_reported = False
  195. if client is not None:
  196. self._client = client
  197. self._owns_client = False
  198. elif _shared_http_client is not None:
  199. self._client = _shared_http_client
  200. self._owns_client = False
  201. else:
  202. self._client = httpx.AsyncClient(timeout=30.0)
  203. self._owns_client = True
  204. self._auth_token = auth_token
  205. async def close(self) -> None:
  206. if self._owns_client:
  207. await self._client.aclose()
  208. async def _note_auth_failure(self) -> None:
  209. """Record that Bambu rejected the token we sent. Best-effort, once."""
  210. if self._on_auth_failure is None or self._auth_failure_reported:
  211. return
  212. self._auth_failure_reported = True
  213. try:
  214. await self._on_auth_failure()
  215. except Exception:
  216. logger.exception("Failed to record Bambu Cloud auth failure from MakerWorld")
  217. def _headers(self) -> dict[str, str]:
  218. headers = dict(_CLIENT_HEADERS)
  219. if self._auth_token:
  220. headers["Authorization"] = f"Bearer {self._auth_token}"
  221. return headers
  222. async def _get_json(self, path: str) -> dict[str, Any]:
  223. """GET ``{MAKERWORLD_API_BASE}{path}`` returning the decoded JSON body.
  224. Raises ``MakerWorld{Auth,Forbidden,NotFound,Unavailable}Error`` based
  225. on status. Retries once on 418 (Cloudflare bot-detection) with a
  226. short backoff — that flagging is often request-scoped and clears on
  227. a subsequent call; hammering beyond one retry provokes a stronger
  228. block, so we stop there and surface a useful error.
  229. """
  230. url = f"{MAKERWORLD_API_BASE}{path}"
  231. for attempt in range(2):
  232. try:
  233. response = await self._client.get(url, headers=self._headers(), timeout=30.0)
  234. except httpx.TimeoutException as exc:
  235. raise MakerWorldUnavailableError(f"MakerWorld request timed out: {exc}") from exc
  236. except httpx.HTTPError as exc:
  237. raise MakerWorldUnavailableError(f"MakerWorld request failed: {exc}") from exc
  238. if response.status_code == 418 and attempt == 0:
  239. logger.info("MakerWorld returned 418 for %s; retrying once after backoff", path)
  240. await asyncio.sleep(1.5)
  241. continue
  242. break
  243. # 401: genuine auth failure — token expired, malformed, not accepted.
  244. # 403: MakerWorld accepted the token but refuses the specific resource
  245. # — usually content gating (points-redeemable, purchase-required,
  246. # region-restricted, early-access). These must surface differently
  247. # because the UI remedy is completely different: 401 → re-login,
  248. # 403 → user has to go to MakerWorld and meet the access requirement.
  249. if response.status_code == 401:
  250. if self._auth_token:
  251. # We sent a token and Bambu refused it — the credential is dead,
  252. # not merely absent. Record that before raising so the rest of the
  253. # app stops claiming the user is connected.
  254. await self._note_auth_failure()
  255. raise MakerWorldAuthError(_SIGN_IN_EXPIRED_MESSAGE)
  256. raise MakerWorldAuthError(f"Signing in to Bambu Cloud is required for {path}")
  257. if response.status_code == 403:
  258. upstream = _extract_upstream_error(response)
  259. raise MakerWorldForbiddenError(
  260. upstream
  261. or f"MakerWorld refused access to {path} — the model may require purchase, points redemption, or be region-restricted"
  262. )
  263. if response.status_code == 404:
  264. raise MakerWorldNotFoundError(f"MakerWorld resource not found: {path}")
  265. if response.status_code == 418:
  266. # MakerWorld's anti-abuse layer challenges the source IP with a
  267. # CAPTCHA (``{"captchaId":"...","error":"We need to confirm..."}``).
  268. # This is application-level, not Cloudflare-edge, and clears
  269. # on its own within 1–4 hours of quiet traffic. There's no
  270. # server-side solve — CAPTCHAs are intentionally unsolvable
  271. # without a real browser. Surface the upstream message so the
  272. # user can recognise it and reach for the "Open on MakerWorld"
  273. # fallback instead of thinking the feature is broken.
  274. upstream = _extract_upstream_error(response)
  275. if upstream and "robot" in upstream.lower():
  276. raise MakerWorldUnavailableError(
  277. f"MakerWorld is challenging this IP with a CAPTCHA ({upstream}). "
  278. "This usually clears within a few hours. In the meantime, use "
  279. "'Open on MakerWorld' below to download the 3MF manually."
  280. )
  281. raise MakerWorldUnavailableError(
  282. f"MakerWorld blocked the request (HTTP 418) for {path}. "
  283. "Try again in a few minutes, or use 'Open on MakerWorld' to import manually."
  284. )
  285. if response.status_code == 429:
  286. raise MakerWorldUnavailableError(
  287. f"MakerWorld rate-limited the request (HTTP 429) for {path}. Try again shortly."
  288. )
  289. if response.status_code >= 500:
  290. raise MakerWorldUnavailableError(f"MakerWorld server error (HTTP {response.status_code}) for {path}")
  291. if response.status_code != 200:
  292. raise MakerWorldUnavailableError(f"MakerWorld unexpected status {response.status_code} for {path}")
  293. try:
  294. data = response.json()
  295. except ValueError as exc:
  296. raise MakerWorldUnavailableError(f"MakerWorld returned non-JSON for {path}") from exc
  297. if not isinstance(data, dict):
  298. raise MakerWorldUnavailableError(
  299. f"MakerWorld returned unexpected JSON shape for {path}: {type(data).__name__}"
  300. )
  301. return data
  302. # ------------------------------------------------------------------ URL parse
  303. @staticmethod
  304. def parse_url(url: str) -> tuple[int, int | None]:
  305. """Extract ``(model_id, profile_id_or_None)`` from a MakerWorld URL.
  306. Accepts any of:
  307. - ``https://makerworld.com/en/models/1400373``
  308. - ``https://makerworld.com/en/models/1400373-slug-with-dashes``
  309. - ``https://makerworld.com/en/models/1400373#profileId-1452154``
  310. - ``makerworld.com/models/1400373`` (scheme optional)
  311. Rejects non-makerworld hosts.
  312. """
  313. if not url or not isinstance(url, str):
  314. raise MakerWorldUrlError("URL is empty or not a string")
  315. candidate = url.strip()
  316. if "://" not in candidate:
  317. candidate = "https://" + candidate
  318. try:
  319. parsed = urlparse(candidate)
  320. except ValueError as exc:
  321. raise MakerWorldUrlError(f"Could not parse URL: {exc}") from exc
  322. host = (parsed.hostname or "").lower()
  323. if host != MAKERWORLD_HOST and not host.endswith("." + MAKERWORLD_HOST):
  324. raise MakerWorldUrlError(f"Not a MakerWorld URL (host={host!r}); expected makerworld.com")
  325. model_match = _MODEL_ID_RE.search(parsed.path)
  326. if not model_match:
  327. raise MakerWorldUrlError("URL does not contain a /models/{id} segment")
  328. model_id = int(model_match.group(1))
  329. profile_id: int | None = None
  330. if parsed.fragment:
  331. profile_match = _PROFILE_ID_RE.search("#" + parsed.fragment)
  332. if profile_match:
  333. profile_id = int(profile_match.group(1))
  334. return model_id, profile_id
  335. # ---------------------------------------------------------------- endpoints
  336. async def get_design(self, model_id: int) -> dict[str, Any]:
  337. """Fetch full model metadata. Works anonymously.
  338. Returns the MakerWorld ``design`` object — title, summary, creator,
  339. license, tags, coverUrl, instances[] with profileId+cover per plate,
  340. categories, etc.
  341. """
  342. return await self._get_json(f"/design/{int(model_id)}")
  343. async def get_design_instances(self, model_id: int) -> dict[str, Any]:
  344. """Fetch list of profiles/instances for a model. Works anonymously.
  345. Returns ``{"total": N, "hits": [{id, profileId, title, cover,
  346. instanceCreator, instanceFilaments, needAms, ...}, ...]}``.
  347. """
  348. return await self._get_json(f"/design/{int(model_id)}/instances")
  349. async def get_profile(self, profile_id: int) -> dict[str, Any]:
  350. """Fetch a single profile's summary (designId/modelId/title/cover/
  351. instanceId). Works anonymously.
  352. """
  353. return await self._get_json(f"/profile/{int(profile_id)}")
  354. async def get_profile_download(self, profile_id: int, model_id: str) -> dict[str, Any]:
  355. """Fetch the signed 3MF download URL for a specific MakerWorld profile.
  356. Note on ``model_id`` — this is MakerWorld's internal alphanumeric
  357. identifier (e.g. ``"US2bb73b106683e5"``), **not** the integer
  358. ``designId`` that appears in the ``/models/{N}`` URL. Callers must
  359. fetch the design first (``get_design(design_id)``) and pass the
  360. ``modelId`` field from the response.
  361. Returns ``{"url": "https://makerworld.bblmw.com/...?at=<unix>
  362. &exp=<unix>&key=<hmac>&uid=<int>", ...}``. URL is short-lived (~5
  363. min); download immediately.
  364. Hits ``api.bambulab.com/v1/iot-service/api/user/profile/{profileId}
  365. ?model_id={modelId}`` with the stored Bambu Cloud bearer. This is the
  366. endpoint Pr0zak/YASTL#51 reverse-engineered — it lives on the
  367. ``api.bambulab.com`` backend (not Cloudflare-protected
  368. ``makerworld.com``), accepts the same long-lived bearer users already
  369. sign in with, and mints the signed CDN URL that the browser would
  370. otherwise fetch via session cookies. This is the only known non-
  371. cookie path to a download URL, after ruling out ``/design-service/``
  372. endpoints on ``makerworld.com`` (cookie-gated) and the now-dead
  373. ``/instance/{id}/f3mf?type=download`` shape.
  374. """
  375. if not self._auth_token:
  376. raise MakerWorldAuthError("Downloading files from MakerWorld requires a Bambu Cloud login")
  377. url = f"https://api.bambulab.com/v1/iot-service/api/user/profile/{int(profile_id)}"
  378. headers = dict(_CLIENT_HEADERS)
  379. headers["Authorization"] = f"Bearer {self._auth_token}"
  380. try:
  381. response = await self._client.get(
  382. url,
  383. headers=headers,
  384. params={"model_id": str(model_id)},
  385. timeout=30.0,
  386. )
  387. except httpx.TimeoutException as exc:
  388. raise MakerWorldUnavailableError(f"Bambu Lab API request timed out: {exc}") from exc
  389. except httpx.HTTPError as exc:
  390. raise MakerWorldUnavailableError(f"Bambu Lab API request failed: {exc}") from exc
  391. if response.status_code == 401:
  392. await self._note_auth_failure()
  393. raise MakerWorldAuthError(_SIGN_IN_EXPIRED_MESSAGE)
  394. if response.status_code == 403:
  395. upstream = _extract_upstream_error(response)
  396. raise MakerWorldForbiddenError(upstream or f"Bambu Lab refused access to profile {profile_id}")
  397. if response.status_code == 404:
  398. raise MakerWorldNotFoundError(f"MakerWorld profile not found: {profile_id}")
  399. if response.status_code != 200:
  400. raise MakerWorldUnavailableError(
  401. f"Bambu Lab API unexpected status {response.status_code} for profile {profile_id}"
  402. )
  403. try:
  404. data = response.json()
  405. except ValueError as exc:
  406. raise MakerWorldUnavailableError(f"Bambu Lab API returned non-JSON for profile {profile_id}") from exc
  407. if not isinstance(data, dict):
  408. raise MakerWorldUnavailableError(f"Bambu Lab API returned unexpected JSON shape for profile {profile_id}")
  409. return data
  410. async def download_3mf(self, signed_url: str) -> tuple[bytes, str]:
  411. """Fetch the 3MF bytes from a signed MakerWorld CDN URL.
  412. Validates that the URL's host is one of the known MakerWorld CDN hosts
  413. (SSRF guard — pattern matches ``_spoolman_helpers.assert_safe_spoolman_url``).
  414. Enforces a 200 MB cap so a single bad response can't exhaust disk.
  415. Returns ``(file_bytes, suggested_filename)``.
  416. """
  417. try:
  418. parsed = urlparse(signed_url)
  419. except ValueError as exc:
  420. raise MakerWorldUrlError(f"Invalid download URL: {exc}") from exc
  421. host = (parsed.hostname or "").lower()
  422. is_allowed = host in MAKERWORLD_CDN_HOSTS or any(host.endswith(suffix) for suffix in _ALLOWED_DOWNLOAD_SUFFIXES)
  423. if not is_allowed:
  424. raise MakerWorldUrlError(f"Refusing to download from non-MakerWorld host: {host!r}")
  425. # Filename fallback from the signed path (before query string)
  426. path_tail = parsed.path.rsplit("/", 1)[-1] or "model.3mf"
  427. # Presigned S3 URLs (``s3.<region>.amazonaws.com``) compute the
  428. # signature over exact query-string bytes. Both httpx and curl_cffi
  429. # re-serialize the URL through ``urllib.parse.urlencode`` which
  430. # normalises encodings — breaks the signature and yields HTTP 400
  431. # ``SignatureDoesNotMatch`` (confirmed, and matches Pr0zak/YASTL#52's
  432. # analysis). ``urllib.request`` transmits the URL verbatim, so we
  433. # use it for S3 hosts and keep httpx for MakerWorld's own CDN.
  434. if host.endswith(".amazonaws.com"):
  435. return await _download_s3_urllib(signed_url, path_tail)
  436. # The signed URL's query-string IS the credential — don't send the
  437. # Bambu Cloud bearer to the CDN too. Strips Authorization/x-bbl-* and
  438. # keeps only User-Agent, matching what ``_download_s3_urllib`` does.
  439. cdn_headers = {"User-Agent": _CLIENT_HEADERS["User-Agent"]}
  440. try:
  441. async with self._client.stream(
  442. "GET", signed_url, headers=cdn_headers, timeout=60.0, follow_redirects=False
  443. ) as response:
  444. if response.status_code != 200:
  445. raise MakerWorldUnavailableError(f"3MF download returned HTTP {response.status_code}")
  446. chunks: list[bytes] = []
  447. total = 0
  448. async for chunk in response.aiter_bytes():
  449. total += len(chunk)
  450. if total > _MAX_3MF_BYTES:
  451. raise MakerWorldUnavailableError(f"3MF exceeds {_MAX_3MF_BYTES // (1024 * 1024)} MB cap")
  452. chunks.append(chunk)
  453. return b"".join(chunks), path_tail
  454. except httpx.TimeoutException as exc:
  455. raise MakerWorldUnavailableError(f"3MF download timed out: {exc}") from exc
  456. except httpx.HTTPError as exc:
  457. raise MakerWorldUnavailableError(f"3MF download failed: {exc}") from exc
  458. async def fetch_thumbnail(self, url: str) -> tuple[bytes, str]:
  459. """Fetch a MakerWorld CDN image (thumbnail / cover / plate preview).
  460. Used by the ``/makerworld/thumbnail`` proxy so the frontend doesn't
  461. have to hotlink MakerWorld's CDN directly — avoids loosening the
  462. SPA's ``img-src`` CSP and keeps users' IP addresses out of
  463. MakerWorld's access logs.
  464. Validates that the URL's host is one of the known MakerWorld CDN
  465. hosts (SSRF guard — same allowlist as :meth:`download_3mf`). Caps
  466. payload at 5 MB. Returns ``(bytes, content_type)``; content type
  467. defaults to ``image/jpeg`` if the upstream didn't set one.
  468. """
  469. try:
  470. parsed = urlparse(url)
  471. except ValueError as exc:
  472. raise MakerWorldUrlError(f"Invalid thumbnail URL: {exc}") from exc
  473. host = (parsed.hostname or "").lower()
  474. if host not in MAKERWORLD_CDN_HOSTS:
  475. raise MakerWorldUrlError(f"Refusing to fetch thumbnail from non-MakerWorld host: {host!r}")
  476. # ``follow_redirects=False``: the host allowlist above is only
  477. # meaningful on the initial URL. A 302 from the CDN to any other host
  478. # would otherwise be followed transparently (including RFC1918 /
  479. # metadata endpoints), so we insist upstream resolve the asset
  480. # directly. A redirect response surfaces as ``MakerWorldUnavailable``
  481. # below.
  482. try:
  483. response = await self._client.get(url, headers=self._headers(), timeout=20.0, follow_redirects=False)
  484. except httpx.TimeoutException as exc:
  485. raise MakerWorldUnavailableError(f"Thumbnail request timed out: {exc}") from exc
  486. except httpx.HTTPError as exc:
  487. raise MakerWorldUnavailableError(f"Thumbnail request failed: {exc}") from exc
  488. if response.status_code != 200:
  489. raise MakerWorldUnavailableError(f"Thumbnail fetch returned HTTP {response.status_code}")
  490. # MakerWorld's CDN serves real PNG/JPG files with
  491. # ``Content-Type: application/octet-stream`` (they use
  492. # ``Content-Disposition: attachment; filename="...png"`` instead). So
  493. # we can't just trust the header — derive the MIME from the URL's
  494. # file extension and only fall back to the header if the URL doesn't
  495. # carry one. Reject text/* / json outright regardless of extension
  496. # so an upstream error page can't slip through as "image/png".
  497. upstream_type = response.headers.get("content-type", "").split(";")[0].strip().lower()
  498. if upstream_type in _REFUSED_THUMBNAIL_MIMES:
  499. raise MakerWorldUnavailableError(f"Thumbnail upstream returned non-image content-type: {upstream_type!r}")
  500. path_lower = parsed.path.lower()
  501. ext_mime: str | None = None
  502. for ext, mime in _IMAGE_EXT_TO_MIME.items():
  503. if path_lower.endswith(ext):
  504. ext_mime = mime
  505. break
  506. if upstream_type.startswith("image/"):
  507. content_type = upstream_type
  508. elif ext_mime is not None:
  509. content_type = ext_mime
  510. else:
  511. # No image extension and no image/* content-type — can't confidently
  512. # serve this as an image, so refuse.
  513. raise MakerWorldUnavailableError(
  514. f"Thumbnail upstream returned {upstream_type!r} and URL has no image extension"
  515. )
  516. payload = response.content
  517. if len(payload) > _MAX_THUMBNAIL_BYTES:
  518. raise MakerWorldUnavailableError(f"Thumbnail exceeds {_MAX_THUMBNAIL_BYTES // (1024 * 1024)} MB cap")
  519. return payload, content_type