mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
A reporter tried to connect to Bambu Cloud and got "We need you to confirm you
are not a robot" as an error toast, with no CAPTCHA anywhere to answer and
nothing to click. That sentence is Bambu's, not ours. Their anti-abuse layer had
flagged the network and was answering the sign-in with HTTP 418 and a challenge
body: {"captchaId": "...", "error": "We need you to confirm you are not a
robot"}.
Bambuddy had no idea what that was. The reply is well-formed JSON, so
_detect_cloudflare_challenge -- which triggers on an unparseable body, CF
markers, 403+cf-mitigated or 503+cf-ray -- never fired on it, and login_request
fell through to its generic error path, which lifts data["message"] or
data["error"] out and hands it to the UI verbatim. The user was left to conclude
their password was wrong or that Bambuddy was broken. Four sign-in attempts
inside eighteen seconds appear in their log, each one more evidence for the
thing that had flagged them.
is_captcha_challenge matches on the 418 status plus a challenge marker in the
body -- captchaId is the reliable one, the wording is matched too because Bambu
has shipped it under more than one phrasing. A bare 418 with no marker is
has shipped it under more than one phrasing. A bare 418 with no marker is
deliberately NOT reported as a CAPTCHA: telling someone to solve a challenge
that was never offered is the exact confusion this issue is about.
login_request, verify_code and verify_totp now return reason="captcha" with an
explanation covering the three things the reporter had no way to find out: the
credentials are not the problem, the block is keyed to the public IP address
rather than the account, and it clears by itself within a few hours.
Sign-in requests are then held back for 300s so Bambuddy stops deepening the
block. Keyed per origin, not per service: TOTP verification posts to
bambulab.com while everything else posts to api.bambulab.com, and a challenge
seen on one must not strand somebody halfway through a two-factor sign-in on the
other. Entries expire on read, so the map cannot grow past one per region. The
token endpoint is deliberately left ungated -- it is the way out.
The UI shows a persistent panel rather than a toast. A toast names a problem the
user cannot act on and then vanishes; this one stays put and carries a one-click
route to "Use access token instead", which is the only thing that works while
the challenge lasts, since that path does not touch the challenged endpoint.
MakerWorld meets the same challenge from the same edge and now shares the
detection. It used to require the literal word "robot" in the error text and
reported any other wording as an unexplained block.
The System Health scanner gets a bambu-cloud-captcha signature. The reporter's
bundle came back with zero findings while their log was full of the failure.
Its advice for a failed FTPS handshake was corrected at the same time: it still
blamed firewalls and outdated firmware, which the #2780 investigation ruled out
last release -- it is the printer's own file service wedging, and the fix is to
restart the printer. The wiki said so already; the health panel did not.
636 lines
29 KiB
Python
636 lines
29 KiB
Python
"""MakerWorld API service.
|
||
|
||
Thin async client for MakerWorld's ``/api/v1/design-service/*`` endpoints.
|
||
Lets Bambuddy resolve a MakerWorld URL, enumerate plate/profile metadata, and
|
||
download the 3MF bundle so users can import and print MakerWorld models
|
||
without leaving the app.
|
||
|
||
The endpoints and header set were reverse-engineered from the
|
||
`kloshi-io/makerworld-api-reverse` TypeScript project (Apache-2.0) and
|
||
cross-validated against live MakerWorld traffic. Authenticated calls reuse
|
||
Bambuddy's existing Bambu Cloud bearer token (same SSO backend — no separate
|
||
OAuth flow needed).
|
||
|
||
Only interoperability — not affiliated with or endorsed by MakerWorld or
|
||
Bambu Lab, and not intended to circumvent any access control.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import logging
|
||
import re
|
||
import ssl
|
||
from collections.abc import Awaitable, Callable
|
||
from typing import Any
|
||
from urllib.parse import urlparse
|
||
|
||
import certifi
|
||
import httpx
|
||
|
||
from backend.app.services.bambu_cloud import is_captcha_challenge, is_expiry_401
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
# API base: ``api.bambulab.com/v1/design-service`` — the same Bambu Cloud
|
||
# backend that the MakerWorld web UI talks to, but not behind Cloudflare
|
||
# (the website ``makerworld.com`` is, and plain httpx requests there get
|
||
# fingerprinted as bot traffic and served "Please log in"). Confirmed by
|
||
# Pr0zak/YASTL#51 and verified with direct curl.
|
||
MAKERWORLD_API_BASE = "https://api.bambulab.com/v1/design-service"
|
||
MAKERWORLD_HOST = "makerworld.com" # Used only for URL parsing (input validation)
|
||
MAKERWORLD_CDN_HOSTS = ("makerworld.bblmw.com", "public-cdn.bblmw.com")
|
||
|
||
# Hosts that the iot-service download endpoint may return presigned URLs
|
||
# for. Besides MakerWorld's own CDN, Bambu Cloud also issues AWS S3
|
||
# presigned URLs (e.g. ``s3.us-west-2.amazonaws.com``) — confirmed by
|
||
# Pr0zak/YASTL#52. The suffix check matches any regional S3 endpoint.
|
||
_ALLOWED_DOWNLOAD_SUFFIXES = (".amazonaws.com",)
|
||
|
||
# Client identity sent to MakerWorld / api.bambulab.com. We identify honestly
|
||
# as Bambuddy with a source URL so Bambu can distinguish our traffic from
|
||
# impersonators — the opposite of what the OrcaSlicer fork was called out for
|
||
# in the May 2026 Bambu Lab blog post on cloud access. Verified 2026-05-12 via
|
||
# curl that MakerWorld treats this UA identically to a Firefox UA at the
|
||
# Cloudflare edge (same response shape on /api/v1/design-service/* paths).
|
||
# The Referer is kept because MakerWorld's CSRF / origin-check middleware uses
|
||
# it on some endpoints — that's distinct from client impersonation.
|
||
_CLIENT_HEADERS = {
|
||
"User-Agent": "Bambuddy/1.0 (+https://github.com/maziggy/bambuddy)",
|
||
"Accept": "text/html,application/json,*/*",
|
||
"Accept-Language": "en-US,en;q=0.9",
|
||
"Referer": "https://makerworld.com/",
|
||
}
|
||
|
||
# Shown whenever Bambu rejects the stored bearer. Bambu's own 401 body is
|
||
# ``{"code":4,"error":"Please login.","message":""}`` and we used to forward that
|
||
# string verbatim, which surfaced as a "Please login." toast on a UI that was
|
||
# simultaneously reporting the user as connected — maximally confusing, and it
|
||
# named no page to go to. Say what happened and where to fix it. Bambu Cloud
|
||
# sign-in lives on the Profiles page (ProfilesPage.tsx, "Cloud Profiles" tab);
|
||
# there is no Settings → Bambu Cloud page, which is what the old fallback text
|
||
# told people to look for.
|
||
_SIGN_IN_EXPIRED_MESSAGE = (
|
||
"Your Bambu Cloud sign-in has expired. Open the Profiles page and sign in to Bambu Cloud again."
|
||
)
|
||
|
||
_MODEL_ID_RE = re.compile(r"/models/(\d+)")
|
||
_PROFILE_ID_RE = re.compile(r"#profileId[-=](\d+)")
|
||
_MAX_3MF_BYTES = 200 * 1024 * 1024 # 200 MB hard cap
|
||
_MAX_THUMBNAIL_BYTES = 10 * 1024 * 1024 # 10 MB hard cap — MakerWorld's "thumbnails" can be 2–3 MB source images
|
||
_IMAGE_EXT_TO_MIME = {
|
||
".png": "image/png",
|
||
".jpg": "image/jpeg",
|
||
".jpeg": "image/jpeg",
|
||
".gif": "image/gif",
|
||
".webp": "image/webp",
|
||
".bmp": "image/bmp",
|
||
}
|
||
# Content types we refuse even if the URL extension looks image-y — prevents
|
||
# forwarding an upstream error page or JSON blob with image framing.
|
||
_REFUSED_THUMBNAIL_MIMES = ("text/html", "text/plain", "application/json")
|
||
|
||
_shared_http_client: httpx.AsyncClient | None = None
|
||
|
||
|
||
def _s3_ssl_context() -> ssl.SSLContext:
|
||
"""Build the TLS context used for the S3 presigned download (#2562).
|
||
|
||
``urllib.request`` verifies against the *OS* trust store, while httpx —
|
||
every other network call in Bambuddy — verifies against the bundled
|
||
``certifi`` CA bundle. On Windows those two disagree: Python's
|
||
``ssl.load_default_certs()`` only enumerates the roots already cached in
|
||
the Windows ROOT store, and Windows populates that store lazily via
|
||
CryptoAPI's auto-update, which Python never triggers. If the Amazon root
|
||
signing the S3 chain isn't cached on that machine yet, verification fails
|
||
with ``unable to get local issuer certificate`` — even though the
|
||
api.bambulab.com calls that preceded it (httpx) succeeded.
|
||
|
||
Pinning urllib to certifi makes the S3 hop trust exactly what the rest of
|
||
the app already trusts. Built per call rather than at import so a certifi
|
||
refresh doesn't require a restart; construction is cheap relative to the
|
||
download that follows.
|
||
"""
|
||
return ssl.create_default_context(cafile=certifi.where())
|
||
|
||
|
||
def set_shared_http_client(client: httpx.AsyncClient | None) -> None:
|
||
"""Register an app-scoped ``httpx.AsyncClient`` for service reuse.
|
||
|
||
Same pattern as ``bambu_cloud.set_shared_http_client`` — lets the FastAPI
|
||
lifespan share one connection pool across per-request service instances.
|
||
"""
|
||
global _shared_http_client
|
||
_shared_http_client = client
|
||
|
||
|
||
class MakerWorldError(Exception):
|
||
"""Base exception for MakerWorld API errors."""
|
||
|
||
|
||
class MakerWorldAuthError(MakerWorldError):
|
||
"""Raised when the endpoint requires a Bambu Cloud token and we don't have
|
||
one (or the one we sent was rejected). True auth failure."""
|
||
|
||
|
||
class MakerWorldForbiddenError(MakerWorldError):
|
||
"""Raised when MakerWorld refuses access despite valid authentication —
|
||
content-gated (points required, purchase required, region restricted,
|
||
early-access, etc.). The message includes MakerWorld's own reason text
|
||
when provided."""
|
||
|
||
|
||
class MakerWorldNotFoundError(MakerWorldError):
|
||
"""Raised when a design / profile / instance doesn't exist."""
|
||
|
||
|
||
class MakerWorldUnavailableError(MakerWorldError):
|
||
"""Raised on 5xx, network errors, or malformed payloads."""
|
||
|
||
|
||
class MakerWorldUrlError(MakerWorldError):
|
||
"""Raised when a URL isn't a makerworld.com model page."""
|
||
|
||
|
||
async def _download_s3_urllib(url: str, filename_fallback: str) -> tuple[bytes, str]:
|
||
"""Fetch an AWS S3 presigned URL without touching the query string.
|
||
|
||
``urllib.request`` passes the URL to the transport verbatim — which is
|
||
essential for S3 presigned URLs where the signature is computed over
|
||
the exact query-string bytes. httpx's ``URL`` class and curl_cffi's
|
||
libcurl layer both normalise encodings and produce
|
||
``SignatureDoesNotMatch`` 400s from S3.
|
||
|
||
Runs the blocking urllib call in a thread executor so we don't stall
|
||
the event loop.
|
||
"""
|
||
from urllib.request import HTTPRedirectHandler, HTTPSHandler, Request, build_opener
|
||
|
||
# Don't follow redirects: the host allowlist above is only enforced on
|
||
# the initial URL. A 302 from S3 to any other host would otherwise
|
||
# transparently bypass the allowlist — so insist S3 resolve directly.
|
||
class _NoRedirect(HTTPRedirectHandler):
|
||
def redirect_request(self, *args, **kwargs): # type: ignore[override]
|
||
return None
|
||
|
||
# HTTPSHandler swaps only the TLS context — the URL still reaches the
|
||
# transport verbatim, which is what the S3 signature depends on.
|
||
opener = build_opener(_NoRedirect, HTTPSHandler(context=_s3_ssl_context()))
|
||
|
||
def _blocking_fetch() -> bytes:
|
||
req = Request(url, headers={"User-Agent": _CLIENT_HEADERS["User-Agent"]})
|
||
with opener.open(req, timeout=60.0) as resp:
|
||
if resp.status != 200:
|
||
raise MakerWorldUnavailableError(f"3MF download returned HTTP {resp.status}")
|
||
data = b""
|
||
while True:
|
||
chunk = resp.read(65536)
|
||
if not chunk:
|
||
break
|
||
data += chunk
|
||
if len(data) > _MAX_3MF_BYTES:
|
||
raise MakerWorldUnavailableError(f"3MF exceeds {_MAX_3MF_BYTES // (1024 * 1024)} MB cap")
|
||
return data
|
||
|
||
try:
|
||
data = await asyncio.to_thread(_blocking_fetch)
|
||
except MakerWorldUnavailableError:
|
||
raise
|
||
except Exception as exc: # noqa: BLE001 — urllib throws a zoo of exceptions
|
||
raise MakerWorldUnavailableError(f"S3 download failed: {exc}") from exc
|
||
return data, filename_fallback
|
||
|
||
|
||
def _extract_upstream_error(response: httpx.Response) -> str | None:
|
||
"""Pull MakerWorld's own error text out of a 4xx/5xx response body.
|
||
|
||
MakerWorld returns ``{"code": N, "error": "text"}`` on auth/perm failures
|
||
and sometimes ``{"message": "..."}`` on other errors. Returns ``None`` if
|
||
the body isn't JSON or doesn't have a recognised error field — callers
|
||
should fall back to a generic message in that case.
|
||
"""
|
||
try:
|
||
data = response.json()
|
||
except ValueError:
|
||
return None
|
||
if not isinstance(data, dict):
|
||
return None
|
||
for key in ("error", "message", "detail"):
|
||
value = data.get(key)
|
||
if isinstance(value, str) and value.strip():
|
||
return value.strip()
|
||
return None
|
||
|
||
|
||
class MakerWorldService:
|
||
"""Per-request MakerWorld API client.
|
||
|
||
Mirrors ``BambuCloudService``'s construction pattern so callers can
|
||
instantiate per request, reuse the shared connection pool in production,
|
||
inject a client in tests, and close the client only if they own it.
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
client: httpx.AsyncClient | None = None,
|
||
auth_token: str | None = None,
|
||
on_auth_failure: Callable[[], Awaitable[None]] | None = None,
|
||
):
|
||
# Fired when Bambu rejects the stored token (401). MakerWorld runs on the
|
||
# same Bambu Cloud bearer as everything else, so a rejection here means
|
||
# the credential is dead app-wide — see ``build_authenticated_cloud``.
|
||
self._on_auth_failure = on_auth_failure
|
||
self._auth_failure_reported = False
|
||
if client is not None:
|
||
self._client = client
|
||
self._owns_client = False
|
||
elif _shared_http_client is not None:
|
||
self._client = _shared_http_client
|
||
self._owns_client = False
|
||
else:
|
||
self._client = httpx.AsyncClient(timeout=30.0)
|
||
self._owns_client = True
|
||
self._auth_token = auth_token
|
||
|
||
async def close(self) -> None:
|
||
if self._owns_client:
|
||
await self._client.aclose()
|
||
|
||
async def _note_auth_failure(self, response: httpx.Response) -> None:
|
||
"""Durably record a dead credential — only for Bambu's genuine expiry 401.
|
||
|
||
A MakerWorld 401 without the ``{"code":4,"error":"Please login."}``
|
||
signature is endpoint- or edge-specific noise, not an expired token;
|
||
invalidating on it would sign the user out of the whole cloud
|
||
integration on a single stray rejection (the #2562 follow-up
|
||
regression). Best-effort, once per service instance.
|
||
"""
|
||
if not is_expiry_401(response):
|
||
logger.info("MakerWorld returned 401 without the expiry signature — not signing the stored token out")
|
||
return
|
||
if self._on_auth_failure is None or self._auth_failure_reported:
|
||
return
|
||
self._auth_failure_reported = True
|
||
try:
|
||
await self._on_auth_failure()
|
||
except Exception:
|
||
logger.exception("Failed to record Bambu Cloud auth failure from MakerWorld")
|
||
|
||
def _headers(self) -> dict[str, str]:
|
||
headers = dict(_CLIENT_HEADERS)
|
||
if self._auth_token:
|
||
headers["Authorization"] = f"Bearer {self._auth_token}"
|
||
return headers
|
||
|
||
async def _get_json(self, path: str) -> dict[str, Any]:
|
||
"""GET ``{MAKERWORLD_API_BASE}{path}`` returning the decoded JSON body.
|
||
|
||
Raises ``MakerWorld{Auth,Forbidden,NotFound,Unavailable}Error`` based
|
||
on status. Retries once on 418 (Cloudflare bot-detection) with a
|
||
short backoff — that flagging is often request-scoped and clears on
|
||
a subsequent call; hammering beyond one retry provokes a stronger
|
||
block, so we stop there and surface a useful error.
|
||
"""
|
||
url = f"{MAKERWORLD_API_BASE}{path}"
|
||
|
||
for attempt in range(2):
|
||
try:
|
||
response = await self._client.get(url, headers=self._headers(), timeout=30.0)
|
||
except httpx.TimeoutException as exc:
|
||
raise MakerWorldUnavailableError(f"MakerWorld request timed out: {exc}") from exc
|
||
except httpx.HTTPError as exc:
|
||
raise MakerWorldUnavailableError(f"MakerWorld request failed: {exc}") from exc
|
||
|
||
if response.status_code == 418 and attempt == 0:
|
||
logger.info("MakerWorld returned 418 for %s; retrying once after backoff", path)
|
||
await asyncio.sleep(1.5)
|
||
continue
|
||
break
|
||
|
||
# 401: genuine auth failure — token expired, malformed, not accepted.
|
||
# 403: MakerWorld accepted the token but refuses the specific resource
|
||
# — usually content gating (points-redeemable, purchase-required,
|
||
# region-restricted, early-access). These must surface differently
|
||
# because the UI remedy is completely different: 401 → re-login,
|
||
# 403 → user has to go to MakerWorld and meet the access requirement.
|
||
if response.status_code == 401:
|
||
if self._auth_token:
|
||
# We sent a token and Bambu refused it — the credential is dead,
|
||
# not merely absent. Record that before raising so the rest of the
|
||
# app stops claiming the user is connected.
|
||
await self._note_auth_failure(response)
|
||
raise MakerWorldAuthError(_SIGN_IN_EXPIRED_MESSAGE)
|
||
raise MakerWorldAuthError(f"Signing in to Bambu Cloud is required for {path}")
|
||
if response.status_code == 403:
|
||
upstream = _extract_upstream_error(response)
|
||
raise MakerWorldForbiddenError(
|
||
upstream
|
||
or f"MakerWorld refused access to {path} — the model may require purchase, points redemption, or be region-restricted"
|
||
)
|
||
if response.status_code == 404:
|
||
raise MakerWorldNotFoundError(f"MakerWorld resource not found: {path}")
|
||
if response.status_code == 418:
|
||
# Bambu's anti-abuse layer challenges the source IP with a CAPTCHA
|
||
# (``{"captchaId":"...","error":"We need to confirm..."}``). This is
|
||
# application-level, not Cloudflare-edge, and clears on its own
|
||
# within 1–4 hours of quiet traffic. There's no server-side solve —
|
||
# CAPTCHAs are intentionally unsolvable without a real browser.
|
||
# Surface the upstream message so the user can recognise it and
|
||
# reach for the "Open on MakerWorld" fallback instead of thinking
|
||
# the feature is broken.
|
||
#
|
||
# The same challenge also lands on the Bambu Cloud sign-in endpoint,
|
||
# so the shape test lives in ``bambu_cloud`` and is shared (#2790).
|
||
# It used to be a bare "robot" substring check on the error text,
|
||
# which missed a challenge worded any other way.
|
||
if is_captcha_challenge(response):
|
||
upstream = _extract_upstream_error(response)
|
||
detail = f" ({upstream})" if upstream else ""
|
||
raise MakerWorldUnavailableError(
|
||
f"MakerWorld is challenging this IP with a CAPTCHA{detail}. "
|
||
"This usually clears within a few hours. In the meantime, use "
|
||
"'Open on MakerWorld' below to download the 3MF manually."
|
||
)
|
||
raise MakerWorldUnavailableError(
|
||
f"MakerWorld blocked the request (HTTP 418) for {path}. "
|
||
"Try again in a few minutes, or use 'Open on MakerWorld' to import manually."
|
||
)
|
||
if response.status_code == 429:
|
||
raise MakerWorldUnavailableError(
|
||
f"MakerWorld rate-limited the request (HTTP 429) for {path}. Try again shortly."
|
||
)
|
||
if response.status_code >= 500:
|
||
raise MakerWorldUnavailableError(f"MakerWorld server error (HTTP {response.status_code}) for {path}")
|
||
if response.status_code != 200:
|
||
raise MakerWorldUnavailableError(f"MakerWorld unexpected status {response.status_code} for {path}")
|
||
|
||
try:
|
||
data = response.json()
|
||
except ValueError as exc:
|
||
raise MakerWorldUnavailableError(f"MakerWorld returned non-JSON for {path}") from exc
|
||
|
||
if not isinstance(data, dict):
|
||
raise MakerWorldUnavailableError(
|
||
f"MakerWorld returned unexpected JSON shape for {path}: {type(data).__name__}"
|
||
)
|
||
return data
|
||
|
||
# ------------------------------------------------------------------ URL parse
|
||
|
||
@staticmethod
|
||
def parse_url(url: str) -> tuple[int, int | None]:
|
||
"""Extract ``(model_id, profile_id_or_None)`` from a MakerWorld URL.
|
||
|
||
Accepts any of:
|
||
- ``https://makerworld.com/en/models/1400373``
|
||
- ``https://makerworld.com/en/models/1400373-slug-with-dashes``
|
||
- ``https://makerworld.com/en/models/1400373#profileId-1452154``
|
||
- ``makerworld.com/models/1400373`` (scheme optional)
|
||
|
||
Rejects non-makerworld hosts.
|
||
"""
|
||
if not url or not isinstance(url, str):
|
||
raise MakerWorldUrlError("URL is empty or not a string")
|
||
candidate = url.strip()
|
||
if "://" not in candidate:
|
||
candidate = "https://" + candidate
|
||
try:
|
||
parsed = urlparse(candidate)
|
||
except ValueError as exc:
|
||
raise MakerWorldUrlError(f"Could not parse URL: {exc}") from exc
|
||
|
||
host = (parsed.hostname or "").lower()
|
||
if host != MAKERWORLD_HOST and not host.endswith("." + MAKERWORLD_HOST):
|
||
raise MakerWorldUrlError(f"Not a MakerWorld URL (host={host!r}); expected makerworld.com")
|
||
|
||
model_match = _MODEL_ID_RE.search(parsed.path)
|
||
if not model_match:
|
||
raise MakerWorldUrlError("URL does not contain a /models/{id} segment")
|
||
model_id = int(model_match.group(1))
|
||
|
||
profile_id: int | None = None
|
||
if parsed.fragment:
|
||
profile_match = _PROFILE_ID_RE.search("#" + parsed.fragment)
|
||
if profile_match:
|
||
profile_id = int(profile_match.group(1))
|
||
|
||
return model_id, profile_id
|
||
|
||
# ---------------------------------------------------------------- endpoints
|
||
|
||
async def get_design(self, model_id: int) -> dict[str, Any]:
|
||
"""Fetch full model metadata. Works anonymously.
|
||
|
||
Returns the MakerWorld ``design`` object — title, summary, creator,
|
||
license, tags, coverUrl, instances[] with profileId+cover per plate,
|
||
categories, etc.
|
||
"""
|
||
return await self._get_json(f"/design/{int(model_id)}")
|
||
|
||
async def get_design_instances(self, model_id: int) -> dict[str, Any]:
|
||
"""Fetch list of profiles/instances for a model. Works anonymously.
|
||
|
||
Returns ``{"total": N, "hits": [{id, profileId, title, cover,
|
||
instanceCreator, instanceFilaments, needAms, ...}, ...]}``.
|
||
"""
|
||
return await self._get_json(f"/design/{int(model_id)}/instances")
|
||
|
||
async def get_profile(self, profile_id: int) -> dict[str, Any]:
|
||
"""Fetch a single profile's summary (designId/modelId/title/cover/
|
||
instanceId). Works anonymously.
|
||
"""
|
||
return await self._get_json(f"/profile/{int(profile_id)}")
|
||
|
||
async def get_profile_download(self, profile_id: int, model_id: str) -> dict[str, Any]:
|
||
"""Fetch the signed 3MF download URL for a specific MakerWorld profile.
|
||
|
||
Note on ``model_id`` — this is MakerWorld's internal alphanumeric
|
||
identifier (e.g. ``"US2bb73b106683e5"``), **not** the integer
|
||
``designId`` that appears in the ``/models/{N}`` URL. Callers must
|
||
fetch the design first (``get_design(design_id)``) and pass the
|
||
``modelId`` field from the response.
|
||
|
||
|
||
Returns ``{"url": "https://makerworld.bblmw.com/...?at=<unix>
|
||
&exp=<unix>&key=<hmac>&uid=<int>", ...}``. URL is short-lived (~5
|
||
min); download immediately.
|
||
|
||
Hits ``api.bambulab.com/v1/iot-service/api/user/profile/{profileId}
|
||
?model_id={modelId}`` with the stored Bambu Cloud bearer. This is the
|
||
endpoint Pr0zak/YASTL#51 reverse-engineered — it lives on the
|
||
``api.bambulab.com`` backend (not Cloudflare-protected
|
||
``makerworld.com``), accepts the same long-lived bearer users already
|
||
sign in with, and mints the signed CDN URL that the browser would
|
||
otherwise fetch via session cookies. This is the only known non-
|
||
cookie path to a download URL, after ruling out ``/design-service/``
|
||
endpoints on ``makerworld.com`` (cookie-gated) and the now-dead
|
||
``/instance/{id}/f3mf?type=download`` shape.
|
||
"""
|
||
if not self._auth_token:
|
||
raise MakerWorldAuthError("Downloading files from MakerWorld requires a Bambu Cloud login")
|
||
|
||
url = f"https://api.bambulab.com/v1/iot-service/api/user/profile/{int(profile_id)}"
|
||
headers = dict(_CLIENT_HEADERS)
|
||
headers["Authorization"] = f"Bearer {self._auth_token}"
|
||
|
||
try:
|
||
response = await self._client.get(
|
||
url,
|
||
headers=headers,
|
||
params={"model_id": str(model_id)},
|
||
timeout=30.0,
|
||
)
|
||
except httpx.TimeoutException as exc:
|
||
raise MakerWorldUnavailableError(f"Bambu Lab API request timed out: {exc}") from exc
|
||
except httpx.HTTPError as exc:
|
||
raise MakerWorldUnavailableError(f"Bambu Lab API request failed: {exc}") from exc
|
||
|
||
if response.status_code == 401:
|
||
await self._note_auth_failure(response)
|
||
raise MakerWorldAuthError(_SIGN_IN_EXPIRED_MESSAGE)
|
||
if response.status_code == 403:
|
||
upstream = _extract_upstream_error(response)
|
||
raise MakerWorldForbiddenError(upstream or f"Bambu Lab refused access to profile {profile_id}")
|
||
if response.status_code == 404:
|
||
raise MakerWorldNotFoundError(f"MakerWorld profile not found: {profile_id}")
|
||
if response.status_code != 200:
|
||
raise MakerWorldUnavailableError(
|
||
f"Bambu Lab API unexpected status {response.status_code} for profile {profile_id}"
|
||
)
|
||
|
||
try:
|
||
data = response.json()
|
||
except ValueError as exc:
|
||
raise MakerWorldUnavailableError(f"Bambu Lab API returned non-JSON for profile {profile_id}") from exc
|
||
if not isinstance(data, dict):
|
||
raise MakerWorldUnavailableError(f"Bambu Lab API returned unexpected JSON shape for profile {profile_id}")
|
||
return data
|
||
|
||
async def download_3mf(self, signed_url: str) -> tuple[bytes, str]:
|
||
"""Fetch the 3MF bytes from a signed MakerWorld CDN URL.
|
||
|
||
Validates that the URL's host is one of the known MakerWorld CDN hosts
|
||
(SSRF guard — pattern matches ``_spoolman_helpers.assert_safe_spoolman_url``).
|
||
Enforces a 200 MB cap so a single bad response can't exhaust disk.
|
||
|
||
Returns ``(file_bytes, suggested_filename)``.
|
||
"""
|
||
try:
|
||
parsed = urlparse(signed_url)
|
||
except ValueError as exc:
|
||
raise MakerWorldUrlError(f"Invalid download URL: {exc}") from exc
|
||
|
||
host = (parsed.hostname or "").lower()
|
||
is_allowed = host in MAKERWORLD_CDN_HOSTS or any(host.endswith(suffix) for suffix in _ALLOWED_DOWNLOAD_SUFFIXES)
|
||
if not is_allowed:
|
||
raise MakerWorldUrlError(f"Refusing to download from non-MakerWorld host: {host!r}")
|
||
|
||
# Filename fallback from the signed path (before query string)
|
||
path_tail = parsed.path.rsplit("/", 1)[-1] or "model.3mf"
|
||
|
||
# Presigned S3 URLs (``s3.<region>.amazonaws.com``) compute the
|
||
# signature over exact query-string bytes. Both httpx and curl_cffi
|
||
# re-serialize the URL through ``urllib.parse.urlencode`` which
|
||
# normalises encodings — breaks the signature and yields HTTP 400
|
||
# ``SignatureDoesNotMatch`` (confirmed, and matches Pr0zak/YASTL#52's
|
||
# analysis). ``urllib.request`` transmits the URL verbatim, so we
|
||
# use it for S3 hosts and keep httpx for MakerWorld's own CDN.
|
||
if host.endswith(".amazonaws.com"):
|
||
return await _download_s3_urllib(signed_url, path_tail)
|
||
|
||
# The signed URL's query-string IS the credential — don't send the
|
||
# Bambu Cloud bearer to the CDN too. Strips Authorization/x-bbl-* and
|
||
# keeps only User-Agent, matching what ``_download_s3_urllib`` does.
|
||
cdn_headers = {"User-Agent": _CLIENT_HEADERS["User-Agent"]}
|
||
try:
|
||
async with self._client.stream(
|
||
"GET", signed_url, headers=cdn_headers, timeout=60.0, follow_redirects=False
|
||
) as response:
|
||
if response.status_code != 200:
|
||
raise MakerWorldUnavailableError(f"3MF download returned HTTP {response.status_code}")
|
||
chunks: list[bytes] = []
|
||
total = 0
|
||
async for chunk in response.aiter_bytes():
|
||
total += len(chunk)
|
||
if total > _MAX_3MF_BYTES:
|
||
raise MakerWorldUnavailableError(f"3MF exceeds {_MAX_3MF_BYTES // (1024 * 1024)} MB cap")
|
||
chunks.append(chunk)
|
||
return b"".join(chunks), path_tail
|
||
except httpx.TimeoutException as exc:
|
||
raise MakerWorldUnavailableError(f"3MF download timed out: {exc}") from exc
|
||
except httpx.HTTPError as exc:
|
||
raise MakerWorldUnavailableError(f"3MF download failed: {exc}") from exc
|
||
|
||
async def fetch_thumbnail(self, url: str) -> tuple[bytes, str]:
|
||
"""Fetch a MakerWorld CDN image (thumbnail / cover / plate preview).
|
||
|
||
Used by the ``/makerworld/thumbnail`` proxy so the frontend doesn't
|
||
have to hotlink MakerWorld's CDN directly — avoids loosening the
|
||
SPA's ``img-src`` CSP and keeps users' IP addresses out of
|
||
MakerWorld's access logs.
|
||
|
||
Validates that the URL's host is one of the known MakerWorld CDN
|
||
hosts (SSRF guard — same allowlist as :meth:`download_3mf`). Caps
|
||
payload at 5 MB. Returns ``(bytes, content_type)``; content type
|
||
defaults to ``image/jpeg`` if the upstream didn't set one.
|
||
"""
|
||
try:
|
||
parsed = urlparse(url)
|
||
except ValueError as exc:
|
||
raise MakerWorldUrlError(f"Invalid thumbnail URL: {exc}") from exc
|
||
|
||
host = (parsed.hostname or "").lower()
|
||
if host not in MAKERWORLD_CDN_HOSTS:
|
||
raise MakerWorldUrlError(f"Refusing to fetch thumbnail from non-MakerWorld host: {host!r}")
|
||
|
||
# ``follow_redirects=False``: the host allowlist above is only
|
||
# meaningful on the initial URL. A 302 from the CDN to any other host
|
||
# would otherwise be followed transparently (including RFC1918 /
|
||
# metadata endpoints), so we insist upstream resolve the asset
|
||
# directly. A redirect response surfaces as ``MakerWorldUnavailable``
|
||
# below.
|
||
try:
|
||
response = await self._client.get(url, headers=self._headers(), timeout=20.0, follow_redirects=False)
|
||
except httpx.TimeoutException as exc:
|
||
raise MakerWorldUnavailableError(f"Thumbnail request timed out: {exc}") from exc
|
||
except httpx.HTTPError as exc:
|
||
raise MakerWorldUnavailableError(f"Thumbnail request failed: {exc}") from exc
|
||
|
||
if response.status_code != 200:
|
||
raise MakerWorldUnavailableError(f"Thumbnail fetch returned HTTP {response.status_code}")
|
||
|
||
# MakerWorld's CDN serves real PNG/JPG files with
|
||
# ``Content-Type: application/octet-stream`` (they use
|
||
# ``Content-Disposition: attachment; filename="...png"`` instead). So
|
||
# we can't just trust the header — derive the MIME from the URL's
|
||
# file extension and only fall back to the header if the URL doesn't
|
||
# carry one. Reject text/* / json outright regardless of extension
|
||
# so an upstream error page can't slip through as "image/png".
|
||
upstream_type = response.headers.get("content-type", "").split(";")[0].strip().lower()
|
||
if upstream_type in _REFUSED_THUMBNAIL_MIMES:
|
||
raise MakerWorldUnavailableError(f"Thumbnail upstream returned non-image content-type: {upstream_type!r}")
|
||
|
||
path_lower = parsed.path.lower()
|
||
ext_mime: str | None = None
|
||
for ext, mime in _IMAGE_EXT_TO_MIME.items():
|
||
if path_lower.endswith(ext):
|
||
ext_mime = mime
|
||
break
|
||
|
||
if upstream_type.startswith("image/"):
|
||
content_type = upstream_type
|
||
elif ext_mime is not None:
|
||
content_type = ext_mime
|
||
else:
|
||
# No image extension and no image/* content-type — can't confidently
|
||
# serve this as an image, so refuse.
|
||
raise MakerWorldUnavailableError(
|
||
f"Thumbnail upstream returned {upstream_type!r} and URL has no image extension"
|
||
)
|
||
|
||
payload = response.content
|
||
if len(payload) > _MAX_THUMBNAIL_BYTES:
|
||
raise MakerWorldUnavailableError(f"Thumbnail exceeds {_MAX_THUMBNAIL_BYTES // (1024 * 1024)} MB cap")
|
||
return payload, content_type
|