service.py 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608
  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; see ``model_providers/makerworld/auth.py``).
  11. Implements the :class:`ProviderService` interface — the route layer drives it
  12. through ``resolve`` / ``get_download`` / ``download`` so the same flow can be
  13. reused for future providers.
  14. Only interoperability — not affiliated with or endorsed by MakerWorld or
  15. Bambu Lab, and not intended to circumvent any access control.
  16. """
  17. from __future__ import annotations
  18. import asyncio
  19. import io
  20. import logging
  21. import zipfile
  22. from collections.abc import Awaitable, Callable
  23. from dataclasses import replace
  24. from typing import Any
  25. from urllib.parse import urlparse
  26. import httpx
  27. from backend.app.services.bambu_cloud import is_captcha_challenge, is_expiry_401
  28. from backend.app.services.model_providers.base import (
  29. ProviderDownload,
  30. ProviderDownloadInfo,
  31. ProviderResolvedModel,
  32. ProviderResourceRef,
  33. ProviderService,
  34. ProviderStatus,
  35. )
  36. from backend.app.services.model_providers.makerworld.auth import is_cloud_token_invalid
  37. from backend.app.services.model_providers.makerworld.errors import (
  38. MakerWorldAuthError,
  39. MakerWorldForbiddenError,
  40. MakerWorldNotFoundError,
  41. MakerWorldUnavailableError,
  42. MakerWorldUrlError,
  43. )
  44. from backend.app.services.model_providers.makerworld.http import (
  45. _ALLOWED_DOWNLOAD_SUFFIXES,
  46. _CLIENT_HEADERS,
  47. _IMAGE_EXT_TO_MIME,
  48. _MAX_3MF_BYTES,
  49. _MAX_THUMBNAIL_BYTES,
  50. _REFUSED_THUMBNAIL_MIMES,
  51. MAKERWORLD_API_BASE,
  52. MAKERWORLD_CDN_HOSTS,
  53. _download_s3_urllib,
  54. _extract_upstream_error,
  55. )
  56. logger = logging.getLogger(__name__)
  57. _shared_http_client: httpx.AsyncClient | None = None
  58. def set_shared_http_client(client: httpx.AsyncClient | None) -> None:
  59. """Register an app-scoped ``httpx.AsyncClient`` for service reuse.
  60. Same pattern as ``bambu_cloud.set_shared_http_client`` — lets the FastAPI
  61. lifespan share one connection pool across per-request service instances.
  62. Must live in the same module as the service class so ``__init__`` reads
  63. the live value rather than an import-time snapshot.
  64. """
  65. global _shared_http_client
  66. _shared_http_client = client
  67. # Shown whenever Bambu rejects the stored bearer. Bambu's own 401 body is
  68. # ``{"code":4,"error":"Please login.","message":""}`` and we used to forward that
  69. # string verbatim, which surfaced as a "Please login." toast on a UI that was
  70. # simultaneously reporting the user as connected — maximally confusing, and it
  71. # named no page to go to. Say what happened and where to fix it. Bambu Cloud
  72. # sign-in lives on the Profiles page (ProfilesPage.tsx, "Cloud Profiles" tab);
  73. # there is no Settings → Bambu Cloud page, which is what the old fallback text
  74. # told people to look for.
  75. _SIGN_IN_EXPIRED_MESSAGE = (
  76. "Your Bambu Cloud sign-in has expired. Open the Profiles page and sign in to Bambu Cloud again."
  77. )
  78. def _is_readable_zip(data: bytes) -> bool:
  79. """Whether ``data`` is a zip whose directory can be read."""
  80. try:
  81. with zipfile.ZipFile(io.BytesIO(data)) as archive:
  82. archive.namelist()
  83. return True
  84. except (zipfile.BadZipFile, ValueError, OSError, EOFError):
  85. return False
  86. class MakerWorldService(ProviderService):
  87. """Per-request MakerWorld API client.
  88. Mirrors ``BambuCloudService``'s construction pattern so callers can
  89. instantiate per request, reuse the shared connection pool in production,
  90. inject a client in tests, and close the client only if they own it.
  91. """
  92. def __init__(
  93. self,
  94. *,
  95. client: httpx.AsyncClient | None = None,
  96. auth_token: str | None = None,
  97. user: Any | None = None,
  98. on_auth_failure: Callable[[], Awaitable[None]] | None = None,
  99. thumbnail_hosts: tuple[str, ...] = MAKERWORLD_CDN_HOSTS,
  100. download_hosts: tuple[str, ...] = MAKERWORLD_CDN_HOSTS,
  101. ):
  102. # Fired when Bambu rejects the stored token (401). MakerWorld runs on the
  103. # same Bambu Cloud bearer as everything else, so a rejection here means
  104. # the credential is dead app-wide — see ``build_authenticated_cloud``.
  105. self._on_auth_failure = on_auth_failure
  106. self._auth_failure_reported = False
  107. # SSRF allowlists for the thumbnail proxy and the 3MF download guard.
  108. # Default to MakerWorld's CDN hosts; ``MakerWorldProvider.build_service``
  109. # passes ``ModelProvider.thumbnail_hosts()`` / ``download_hosts()`` so
  110. # the guards are driven by the provider descriptor rather than enforced
  111. # by coincidence (interface contract on ``ProviderService``).
  112. self._thumbnail_hosts = tuple(thumbnail_hosts)
  113. self._download_hosts = tuple(download_hosts)
  114. if client is not None:
  115. self._client = client
  116. self._owns_client = False
  117. elif _shared_http_client is not None:
  118. self._client = _shared_http_client
  119. self._owns_client = False
  120. else:
  121. self._client = httpx.AsyncClient(timeout=30.0)
  122. self._owns_client = True
  123. self._auth_token = auth_token
  124. self._user = user
  125. async def close(self) -> None:
  126. if self._owns_client:
  127. await self._client.aclose()
  128. async def _note_auth_failure(self, response: httpx.Response) -> None:
  129. """Durably record a dead credential — only for Bambu's genuine expiry 401.
  130. A MakerWorld 401 without the ``{"code":4,"error":"Please login."}``
  131. signature is endpoint- or edge-specific noise, not an expired token;
  132. invalidating on it would sign the user out of the whole cloud
  133. integration on a single stray rejection (the #2562 follow-up
  134. regression). Best-effort, once per service instance.
  135. """
  136. if not is_expiry_401(response):
  137. logger.info("MakerWorld returned 401 without the expiry signature — not signing the stored token out")
  138. return
  139. if self._on_auth_failure is None or self._auth_failure_reported:
  140. return
  141. self._auth_failure_reported = True
  142. try:
  143. await self._on_auth_failure()
  144. except Exception:
  145. logger.exception("Failed to record Bambu Cloud auth failure from MakerWorld")
  146. def _headers(self) -> dict[str, str]:
  147. headers = dict(_CLIENT_HEADERS)
  148. if self._auth_token:
  149. headers["Authorization"] = f"Bearer {self._auth_token}"
  150. return headers
  151. # ------------------------------------------------------------- interface
  152. async def get_status(self, db: Any) -> ProviderStatus:
  153. """Whether the caller can download: needs a stored, non-rejected Bambu
  154. Cloud token. ``credential_rejected`` is the machine-readable expired
  155. state; ``auth_error`` names it for humans so the UI can say "your
  156. sign-in expired" rather than a bare "sign in"."""
  157. has_token = bool(self._auth_token)
  158. expired = has_token and await is_cloud_token_invalid(db, self._user)
  159. return ProviderStatus(
  160. authenticated=has_token,
  161. can_download=has_token and not expired,
  162. auth_error=_SIGN_IN_EXPIRED_MESSAGE if expired else None,
  163. credential_rejected=expired,
  164. )
  165. async def resolve(self, ref: ProviderResourceRef) -> ProviderResolvedModel:
  166. """Fetch full model metadata + the plate list, merging per-instance
  167. printer compatibility so the frontend can show "sliced for A1 / also
  168. compatible with H2D, P1S" before the user picks a plate."""
  169. model_id = int(ref.external_id)
  170. design = await self.get_design(model_id)
  171. instances_envelope = await self.get_design_instances(model_id)
  172. # MakerWorld's instances payload is ``{"total": N, "hits": [...]}``;
  173. # normalise the null case to an empty list so the frontend doesn't
  174. # have to handle null vs [] both ways.
  175. instances = instances_envelope.get("hits") or []
  176. if not isinstance(instances, list):
  177. instances = []
  178. # /instances/hits omits the per-instance printer compatibility info
  179. # that /design.instances[].extention.modelInfo carries. Merge it in.
  180. design_instances = design.get("instances") or []
  181. if isinstance(design_instances, list):
  182. compat_by_id = {}
  183. for di in design_instances:
  184. if not isinstance(di, dict):
  185. continue
  186. iid = di.get("id")
  187. if iid is None:
  188. continue
  189. ext = (di.get("extention") or {}).get("modelInfo") or {}
  190. compat_by_id[iid] = {
  191. "compatibility": ext.get("compatibility"),
  192. "otherCompatibility": ext.get("otherCompatibility"),
  193. }
  194. for inst in instances:
  195. if not isinstance(inst, dict):
  196. continue
  197. iid = inst.get("id")
  198. extra = compat_by_id.get(iid)
  199. if extra:
  200. inst["compatibility"] = extra["compatibility"]
  201. inst["otherCompatibility"] = extra["otherCompatibility"]
  202. return ProviderResolvedModel(ref=ref, design=design, instances=instances)
  203. async def get_download(self, ref: ProviderResourceRef) -> ProviderDownloadInfo:
  204. """Resolve the signed 3MF download for a specific MakerWorld profile.
  205. Handles the provider-specific dance: the iot-service endpoint needs
  206. the *alphanumeric* ``modelId`` (e.g. ``"US2bb73b106683e5"``) from the
  207. design, not the integer design id, and picks a default profile when
  208. the caller didn't specify one. Enriches ``ref.sub_id`` with the actual
  209. profile used so the route can build the per-plate dedupe key.
  210. """
  211. model_id = int(ref.external_id)
  212. design = await self.get_design(model_id)
  213. alphanumeric_model_id = design.get("modelId")
  214. if not isinstance(alphanumeric_model_id, str) or not alphanumeric_model_id:
  215. raise MakerWorldUnavailableError("MakerWorld design metadata missing the modelId field")
  216. profile_id = int(ref.sub_id) if ref.sub_id else None
  217. if profile_id is None:
  218. for instance in design.get("instances") or []:
  219. pid = instance.get("profileId")
  220. if isinstance(pid, int) and pid > 0:
  221. profile_id = pid
  222. break
  223. if profile_id is None:
  224. envelope = await self.get_design_instances(model_id)
  225. for hit in envelope.get("hits") or []:
  226. pid = hit.get("profileId")
  227. if isinstance(pid, int) and pid > 0:
  228. profile_id = pid
  229. break
  230. if profile_id is None:
  231. raise MakerWorldUnavailableError("MakerWorld returned no instances for this model")
  232. manifest = await self.get_profile_download(profile_id, alphanumeric_model_id)
  233. signed_url = manifest.get("url")
  234. if not signed_url or not isinstance(signed_url, str):
  235. raise MakerWorldUnavailableError("MakerWorld did not return a download URL")
  236. # Raw upstream name — the route layer basenames / percent-decodes it
  237. # as defence-in-depth before persisting.
  238. raw_name = manifest.get("name")
  239. suggested_filename = raw_name if isinstance(raw_name, str) and raw_name.strip() else ""
  240. return ProviderDownloadInfo(
  241. ref=replace(ref, sub_id=str(profile_id)),
  242. url=signed_url,
  243. suggested_filename=suggested_filename,
  244. )
  245. async def download(self, info: ProviderDownloadInfo) -> ProviderDownload:
  246. """Fetch the 3MF bytes for a signed URL, returning ``(bytes, filename)``."""
  247. file_bytes, download_filename = await self.download_3mf(info.url)
  248. # A 3MF is a zip. A truncated body or an error page served with 200
  249. # would otherwise be stored, and kept, as the model's file.
  250. if not _is_readable_zip(file_bytes):
  251. raise MakerWorldUnavailableError("MakerWorld sent a file that is not a valid 3MF")
  252. return ProviderDownload(file_bytes=file_bytes, filename=download_filename)
  253. # ---------------------------------------------------------------- endpoints
  254. async def _get_json(self, path: str) -> dict[str, Any]:
  255. """GET ``{MAKERWORLD_API_BASE}{path}`` returning the decoded JSON body.
  256. Raises ``MakerWorld{Auth,Forbidden,NotFound,Unavailable}Error`` based
  257. on status. Retries once on 418 (Cloudflare bot-detection) with a
  258. short backoff — that flagging is often request-scoped and clears on
  259. a subsequent call; hammering beyond one retry provokes a stronger
  260. block, so we stop there and surface a useful error.
  261. """
  262. url = f"{MAKERWORLD_API_BASE}{path}"
  263. for attempt in range(2):
  264. try:
  265. response = await self._client.get(url, headers=self._headers(), timeout=30.0)
  266. except httpx.TimeoutException as exc:
  267. raise MakerWorldUnavailableError(f"MakerWorld request timed out: {exc}") from exc
  268. except httpx.HTTPError as exc:
  269. raise MakerWorldUnavailableError(f"MakerWorld request failed: {exc}") from exc
  270. if response.status_code == 418 and attempt == 0:
  271. logger.info("MakerWorld returned 418 for %s; retrying once after backoff", path)
  272. await asyncio.sleep(1.5)
  273. continue
  274. break
  275. # 401: genuine auth failure — token expired, malformed, not accepted.
  276. # 403: MakerWorld accepted the token but refuses the specific resource
  277. # — usually content gating (points-redeemable, purchase-required,
  278. # region-restricted, early-access). These must surface differently
  279. # because the UI remedy is completely different: 401 → re-login,
  280. # 403 → user has to go to MakerWorld and meet the access requirement.
  281. if response.status_code == 401:
  282. if self._auth_token:
  283. # We sent a token and Bambu refused it — the credential is dead,
  284. # not merely absent. Record that before raising so the rest of the
  285. # app stops claiming the user is connected.
  286. await self._note_auth_failure(response)
  287. raise MakerWorldAuthError(_SIGN_IN_EXPIRED_MESSAGE)
  288. raise MakerWorldAuthError(f"Signing in to Bambu Cloud is required for {path}")
  289. if response.status_code == 403:
  290. upstream = _extract_upstream_error(response)
  291. raise MakerWorldForbiddenError(
  292. upstream
  293. or f"MakerWorld refused access to {path} — the model may require purchase, points redemption, or be region-restricted"
  294. )
  295. if response.status_code == 404:
  296. raise MakerWorldNotFoundError(f"MakerWorld resource not found: {path}")
  297. if response.status_code == 418:
  298. # Bambu's anti-abuse layer challenges the source IP with a CAPTCHA
  299. # (``{"captchaId":"...","error":"We need to confirm..."}``). This is
  300. # application-level, not Cloudflare-edge, and clears on its own
  301. # within 1–4 hours of quiet traffic. There's no server-side solve —
  302. # CAPTCHAs are intentionally unsolvable without a real browser.
  303. # Surface the upstream message so the user can recognise it and
  304. # reach for the "Open on MakerWorld" fallback instead of thinking
  305. # the feature is broken.
  306. #
  307. # The same challenge also lands on the Bambu Cloud sign-in endpoint,
  308. # so the shape test lives in ``bambu_cloud`` and is shared (#2790).
  309. # It used to be a bare "robot" substring check on the error text,
  310. # which missed a challenge worded any other way.
  311. if is_captcha_challenge(response):
  312. upstream = _extract_upstream_error(response)
  313. detail = f" ({upstream})" if upstream else ""
  314. raise MakerWorldUnavailableError(
  315. f"MakerWorld is challenging this IP with a CAPTCHA{detail}. "
  316. "This usually clears within a few hours. In the meantime, use "
  317. "'Open on MakerWorld' below to download the 3MF manually."
  318. )
  319. raise MakerWorldUnavailableError(
  320. f"MakerWorld blocked the request (HTTP 418) for {path}. "
  321. "Try again in a few minutes, or use 'Open on MakerWorld' to import manually."
  322. )
  323. if response.status_code == 429:
  324. raise MakerWorldUnavailableError(
  325. f"MakerWorld rate-limited the request (HTTP 429) for {path}. Try again shortly."
  326. )
  327. if response.status_code >= 500:
  328. raise MakerWorldUnavailableError(f"MakerWorld server error (HTTP {response.status_code}) for {path}")
  329. if response.status_code != 200:
  330. raise MakerWorldUnavailableError(f"MakerWorld unexpected status {response.status_code} for {path}")
  331. try:
  332. data = response.json()
  333. except ValueError as exc:
  334. raise MakerWorldUnavailableError(f"MakerWorld returned non-JSON for {path}") from exc
  335. if not isinstance(data, dict):
  336. raise MakerWorldUnavailableError(
  337. f"MakerWorld returned unexpected JSON shape for {path}: {type(data).__name__}"
  338. )
  339. return data
  340. async def get_design(self, model_id: int) -> dict[str, Any]:
  341. """Fetch full model metadata. Works anonymously.
  342. Returns the MakerWorld ``design`` object — title, summary, creator,
  343. license, tags, coverUrl, instances[] with profileId+cover per plate,
  344. categories, etc.
  345. """
  346. return await self._get_json(f"/design/{int(model_id)}")
  347. async def get_design_instances(self, model_id: int) -> dict[str, Any]:
  348. """Fetch list of profiles/instances for a model. Works anonymously.
  349. Returns ``{"total": N, "hits": [{id, profileId, title, cover,
  350. instanceCreator, instanceFilaments, needAms, ...}, ...]}``.
  351. """
  352. return await self._get_json(f"/design/{int(model_id)}/instances")
  353. async def get_profile(self, profile_id: int) -> dict[str, Any]:
  354. """Fetch a single profile's summary (designId/modelId/title/cover/
  355. instanceId). Works anonymously.
  356. """
  357. return await self._get_json(f"/profile/{int(profile_id)}")
  358. async def get_profile_download(self, profile_id: int, model_id: str) -> dict[str, Any]:
  359. """Fetch the signed 3MF download URL for a specific MakerWorld profile.
  360. Note on ``model_id`` — this is MakerWorld's internal alphanumeric
  361. identifier (e.g. ``"US2bb73b106683e5"``), **not** the integer
  362. ``designId`` that appears in the ``/models/{N}`` URL. Callers must
  363. fetch the design first (``get_design(design_id)``) and pass the
  364. ``modelId`` field from the response.
  365. Returns ``{"url": "https://makerworld.bblmw.com/...?at=<unix>
  366. &exp=<unix>&key=<hmac>&uid=<int>", ...}``. URL is short-lived (~5
  367. min); download immediately.
  368. Hits ``api.bambulab.com/v1/iot-service/api/user/profile/{profileId}
  369. ?model_id={modelId}`` with the stored Bambu Cloud bearer. This is the
  370. endpoint Pr0zak/YASTL#51 reverse-engineered — it lives on the
  371. ``api.bambulab.com`` backend (not Cloudflare-protected
  372. ``makerworld.com``), accepts the same long-lived bearer users already
  373. sign in with, and mints the signed CDN URL that the browser would
  374. otherwise fetch via session cookies. This is the only known non-
  375. cookie path to a download URL, after ruling out ``/design-service/``
  376. endpoints on ``makerworld.com`` (cookie-gated) and the now-dead
  377. ``/instance/{id}/f3mf?type=download`` shape.
  378. """
  379. if not self._auth_token:
  380. raise MakerWorldAuthError("Downloading files from MakerWorld requires a Bambu Cloud login")
  381. url = f"https://api.bambulab.com/v1/iot-service/api/user/profile/{int(profile_id)}"
  382. headers = dict(_CLIENT_HEADERS)
  383. headers["Authorization"] = f"Bearer {self._auth_token}"
  384. try:
  385. response = await self._client.get(
  386. url,
  387. headers=headers,
  388. params={"model_id": str(model_id)},
  389. timeout=30.0,
  390. )
  391. except httpx.TimeoutException as exc:
  392. raise MakerWorldUnavailableError(f"Bambu Lab API request timed out: {exc}") from exc
  393. except httpx.HTTPError as exc:
  394. raise MakerWorldUnavailableError(f"Bambu Lab API request failed: {exc}") from exc
  395. if response.status_code == 401:
  396. await self._note_auth_failure(response)
  397. raise MakerWorldAuthError(_SIGN_IN_EXPIRED_MESSAGE)
  398. if response.status_code == 403:
  399. upstream = _extract_upstream_error(response)
  400. raise MakerWorldForbiddenError(upstream or f"Bambu Lab refused access to profile {profile_id}")
  401. if response.status_code == 404:
  402. raise MakerWorldNotFoundError(f"MakerWorld profile not found: {profile_id}")
  403. if response.status_code != 200:
  404. raise MakerWorldUnavailableError(
  405. f"Bambu Lab API unexpected status {response.status_code} for profile {profile_id}"
  406. )
  407. try:
  408. data = response.json()
  409. except ValueError as exc:
  410. raise MakerWorldUnavailableError(f"Bambu Lab API returned non-JSON for profile {profile_id}") from exc
  411. if not isinstance(data, dict):
  412. raise MakerWorldUnavailableError(f"Bambu Lab API returned unexpected JSON shape for profile {profile_id}")
  413. return data
  414. async def download_3mf(self, signed_url: str) -> tuple[bytes, str]:
  415. """Fetch the 3MF bytes from a signed MakerWorld CDN URL.
  416. Validates that the URL's host is one of the declared download hosts
  417. (SSRF guard — driven by ``ModelProvider.download_hosts()`` via
  418. ``build_service``, the symmetric counterpart to the thumbnail
  419. allowlist) *or* matches ``_ALLOWED_DOWNLOAD_SUFFIXES``, Bambu's S3
  420. regional endpoints, which are this provider's own signed-URL family
  421. rather than part of the injectable seam; pattern matches
  422. ``_spoolman_helpers.assert_safe_spoolman_url``.
  423. Enforces a 200 MB cap so a single bad response can't exhaust disk.
  424. Returns ``(file_bytes, suggested_filename)``.
  425. """
  426. try:
  427. parsed = urlparse(signed_url)
  428. except ValueError as exc:
  429. raise MakerWorldUrlError(f"Invalid download URL: {exc}") from exc
  430. host = (parsed.hostname or "").lower()
  431. is_allowed = host in self._download_hosts or any(host.endswith(suffix) for suffix in _ALLOWED_DOWNLOAD_SUFFIXES)
  432. if not is_allowed:
  433. raise MakerWorldUrlError(f"Refusing to download from non-MakerWorld host: {host!r}")
  434. # Filename fallback from the signed path (before query string)
  435. path_tail = parsed.path.rsplit("/", 1)[-1] or "model.3mf"
  436. # Presigned S3 URLs (``s3.<region>.amazonaws.com``) compute the
  437. # signature over exact query-string bytes. Both httpx and curl_cffi
  438. # re-serialize the URL through ``urllib.parse.urlencode`` which
  439. # normalises encodings — breaks the signature and yields HTTP 400
  440. # ``SignatureDoesNotMatch`` (confirmed, and matches Pr0zak/YASTL#52's
  441. # analysis). ``urllib.request`` transmits the URL verbatim, so we
  442. # use it for S3 hosts and keep httpx for MakerWorld's own CDN.
  443. if host.endswith(".amazonaws.com"):
  444. return await _download_s3_urllib(signed_url, path_tail)
  445. # The signed URL's query-string IS the credential — don't send the
  446. # Bambu Cloud bearer to the CDN too. Strips Authorization/x-bbl-* and
  447. # keeps only User-Agent, matching what ``_download_s3_urllib`` does.
  448. cdn_headers = {"User-Agent": _CLIENT_HEADERS["User-Agent"]}
  449. try:
  450. async with self._client.stream(
  451. "GET", signed_url, headers=cdn_headers, timeout=60.0, follow_redirects=False
  452. ) as response:
  453. if response.status_code != 200:
  454. raise MakerWorldUnavailableError(f"3MF download returned HTTP {response.status_code}")
  455. chunks: list[bytes] = []
  456. total = 0
  457. async for chunk in response.aiter_bytes():
  458. total += len(chunk)
  459. if total > _MAX_3MF_BYTES:
  460. raise MakerWorldUnavailableError(f"3MF exceeds {_MAX_3MF_BYTES // (1024 * 1024)} MB cap")
  461. chunks.append(chunk)
  462. return b"".join(chunks), path_tail
  463. except httpx.TimeoutException as exc:
  464. raise MakerWorldUnavailableError(f"3MF download timed out: {exc}") from exc
  465. except httpx.HTTPError as exc:
  466. raise MakerWorldUnavailableError(f"3MF download failed: {exc}") from exc
  467. async def fetch_thumbnail(self, url: str) -> tuple[bytes, str]:
  468. """Fetch a MakerWorld CDN image (thumbnail / cover / plate preview).
  469. Used by the ``/makerworld/thumbnail`` proxy so the frontend doesn't
  470. have to hotlink MakerWorld's CDN directly — avoids loosening the
  471. SPA's ``img-src`` CSP and keeps users' IP addresses out of
  472. MakerWorld's access logs.
  473. Validates that the URL's host is one of the declared thumbnail hosts
  474. (SSRF guard — symmetric to :meth:`download_3mf`; both allowlists are
  475. fed from the provider descriptor by ``build_service``). Caps
  476. payload at 10 MB. Returns ``(bytes, content_type)``; content type
  477. defaults to ``image/jpeg`` if the upstream didn't set one.
  478. """
  479. try:
  480. parsed = urlparse(url)
  481. except ValueError as exc:
  482. raise MakerWorldUrlError(f"Invalid thumbnail URL: {exc}") from exc
  483. host = (parsed.hostname or "").lower()
  484. if host not in self._thumbnail_hosts:
  485. raise MakerWorldUrlError(f"Refusing to fetch thumbnail from non-MakerWorld host: {host!r}")
  486. # ``follow_redirects=False``: the host allowlist above is only
  487. # meaningful on the initial URL. A 302 from the CDN to any other host
  488. # would otherwise be followed transparently (including RFC1918 /
  489. # metadata endpoints), so we insist upstream resolve the asset
  490. # directly. A redirect response surfaces as ``MakerWorldUnavailable``
  491. # below.
  492. try:
  493. response = await self._client.get(url, headers=self._headers(), timeout=20.0, follow_redirects=False)
  494. except httpx.TimeoutException as exc:
  495. raise MakerWorldUnavailableError(f"Thumbnail request timed out: {exc}") from exc
  496. except httpx.HTTPError as exc:
  497. raise MakerWorldUnavailableError(f"Thumbnail request failed: {exc}") from exc
  498. if response.status_code != 200:
  499. raise MakerWorldUnavailableError(f"Thumbnail fetch returned HTTP {response.status_code}")
  500. # MakerWorld's CDN serves real PNG/JPG files with
  501. # ``Content-Type: application/octet-stream`` (they use
  502. # ``Content-Disposition: attachment; filename="...png"`` instead). So
  503. # we can't just trust the header — derive the MIME from the URL's
  504. # file extension and only fall back to the header if the URL doesn't
  505. # carry one. Reject text/* / json outright regardless of extension
  506. # so an upstream error page can't slip through as "image/png".
  507. upstream_type = response.headers.get("content-type", "").split(";")[0].strip().lower()
  508. if upstream_type in _REFUSED_THUMBNAIL_MIMES:
  509. raise MakerWorldUnavailableError(f"Thumbnail upstream returned non-image content-type: {upstream_type!r}")
  510. path_lower = parsed.path.lower()
  511. ext_mime: str | None = None
  512. for ext, mime in _IMAGE_EXT_TO_MIME.items():
  513. if path_lower.endswith(ext):
  514. ext_mime = mime
  515. break
  516. if upstream_type.startswith("image/"):
  517. content_type = upstream_type
  518. elif ext_mime is not None:
  519. content_type = ext_mime
  520. else:
  521. # No image extension and no image/* content-type — can't confidently
  522. # serve this as an image, so refuse.
  523. raise MakerWorldUnavailableError(
  524. f"Thumbnail upstream returned {upstream_type!r} and URL has no image extension"
  525. )
  526. payload = response.content
  527. if len(payload) > _MAX_THUMBNAIL_BYTES:
  528. raise MakerWorldUnavailableError(f"Thumbnail exceeds {_MAX_THUMBNAIL_BYTES // (1024 * 1024)} MB cap")
  529. return payload, content_type