makerworld.py 25 KB

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