service.py 28 KB

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