Files
bambuddy/backend/app/services/makerworld.py
T
MartinNYHC 5da403ba0c Feature/makerworld (#1099)
* feat(makerworld): URL-paste import and print for MakerWorld models

  Add a dedicated /makerworld sidebar page where users paste a MakerWorld
  model URL and get the full plate list + one-click "Import to Library" or
  "Print Now". Closes the workflow gap that kept LAN-only users on the
  Bambu Handy app solely for MakerWorld download-and-send.

  The authenticated tier reuses the existing Bambu Cloud token that
  Bambuddy already stores for firmware checks and slicer settings --
  MakerWorld shares the same auth backend, so the same JWT works there.
  No separate OAuth flow, no companion browser extension, no credential
  hijack. Anonymous users can still paste a URL and see model metadata;
  the 3MF download itself requires the Cloud login.

  Print Now hands off to the existing PrintModal (plate picker + AMS
  mapping + dispatch) so multi-filament models work via the same code
  path as library-file prints. Imported 3MFs are stored through a new
  shared save_3mf_bytes_to_library() helper so the multipart upload
  route and the MakerWorld import route don't duplicate 3MF parsing +
  thumbnail extraction logic.

  LibraryFile gains indexed source_type + source_url columns. Re-pasting
  a URL for a model already in the library returns the existing row
  instead of re-downloading -- dedupe is by canonicalised URL, not SHA256,
  because MakerWorld's download URLs are signed and change per request.

  Thumbnail proxy (/makerworld/thumbnail) hot-links through the backend
  instead of directly to makerworld.bblmw.com -- the SPA's img-src CSP
  stays strict and users' IPs don't hit MakerWorld's CDN logs. The
  endpoint is intentionally unauthenticated since <img> tags can't carry
  a Bearer token; SSRF-guarded by a CDN host allowlist so it can't be
  used as a generic proxy.

  Search and browse-catalogue are explicitly out of scope. The public
  design/search endpoint returns empty results from server-originated
  requests (likely needs csrf/session state reproducible only from a
  real browser), and the __NEXT_DATA__ HTML fallback is blocked by
  Cloudflare. URL-paste covers the realistic discovery pattern (Reddit /
  YouTube / shared links).

  Headers match kloshi-io/makerworld-api-reverse's production-tested set
  (User-Agent: 3d-printing-service/1.0, x-bbl-* client identifiers,
  Referer). The /instance/{id}/f3mf call includes ?type=download which
  community userscripts use to signal legitimate download intent. 418
  responses (MakerWorld's CAPTCHA gate) retry once with backoff and then
  surface a clear actionable error with an "Open on MakerWorld" fallback
  link; we never try to evade bot detection.

  Permissions: new makerworld:view (browse metadata, view thumbnails) and
  makerworld:import (save 3MFs to library). Administrators and Operators
  get both; Viewers get view-only. Migration grants these to existing
  groups based on whether they already have library:upload / library:read.

  Disclaimer in the UI and wiki page mirrors kloshi's framing: not
  affiliated with or endorsed by MakerWorld or Bambu Lab, interoperability
  only, not intended to circumvent access controls.

  Tests: 30 backend (service + routes) + 4 frontend. Full backend suite
  (1931 tests) clean. Frontend build clean.

* feat(makerworld): ship working URL-paste import via api.bambulab.com iot-service

  The MakerWorld integration shipped in 0.2.4b1 dev was broken for most
  public models: the makerworld.com/design-service path returns "Please
  log in to download models" even with a valid Bambu Cloud bearer,
  because it's cookie-gated behind Cloudflare. Published reverse-
  engineering projects work around this by pasting browser cookies; we
  route around it entirely by using the api.bambulab.com/iot-service
  endpoint (documented by Pr0zak/YASTL#51), which accepts the same
  bearer Bambuddy already has and returns a presigned S3 URL.

  Working flow:
    GET api.bambulab.com/v1/design-service/design/{id}  → metadata
    GET api.bambulab.com/v1/iot-service/api/user/profile/{pid}?model_id=<str>
         Authorization: Bearer {cloud_token}             → signed S3 URL
    urllib.request (no redirects, no query re-encoding)  → bytes

  Notes on each step:
    - The model_id query param is the alphanumeric string from the
      design response (e.g. US2bb73b106683e5), NOT the integer designId
      from the /models/{N} URL. The import route fetches design metadata
      first to get it.
    - S3 presigned URLs MUST be fetched with urllib (not httpx/curl_cffi)
      because the signature is computed over exact query-string bytes;
      any normalising encoder breaks it with SignatureDoesNotMatch 400s
      (YASTL#52 hit the same issue). Wrapped in a no-redirect opener so
      the .amazonaws.com host allowlist guarantee isn't bypassed by a
      302 elsewhere.
    - The canonical source_url now includes profile_id so different
      plates of the same model get distinct library entries. Older rows
      from dev builds keep the model-level URL; the resolve endpoint's
      "already imported" check LIKEs both shapes.

  UI rebuild:
    - Per-plate Save + Save & Slice in Bambu Studio / OrcaSlicer (the
      plate is unsliced source, so "Print Now" was misleading and is
      replaced by an explicit slicer hand-off).
    - Import all plates with sequential progress.
    - Folder picker (default: auto-created top-level "MakerWorld"
      folder, created on first import, folder tree invalidated so
      File Manager shows it immediately).
    - Image gallery per plate with keyboard-navigable lightbox.
    - Recent imports sidebar (sticky on lg+, vertical list with
      jump-to-library / slicer / open-on-makerworld icons).
    - Inline follow-up actions on imported plate rows so the user
      doesn't scroll back to a top-of-page card.
    - Per-plate delete via the standard ConfirmModal (no window.confirm).
    - Elapsed-time + phase label during import so the 10-30s synchronous
      POST doesn't feel frozen.
    - URL-change detection drops the preview when the pasted URL
      diverges from the resolved one.

  Security hardening (found in review):
    - DOMPurify.sanitize on the MakerWorld HTML summary before
      dangerouslySetInnerHTML (user-authored content).
    - <img> tags in that HTML routed through the thumbnail proxy so
      the SPA's img-src 'self' data: blob: CSP isn't widened.
    - /makerworld/thumbnail uses follow_redirects=False (the host
      allowlist only covers the initial URL).
    - 3MF CDN fetch strips the bearer (signed URL is the credential).
    - S3 fetch uses a no-op HTTPRedirectHandler for the same reason.
    - Upstream filename is os.path.basename'd before persisting.

  Tests: 46 backend service unit tests, 19 route tests, 12 frontend
  tests — all passing. All user-facing strings localised across the
  8 UI languages.

* - frontend/src/App.tsx — removed the 3 stale <AdminRoute> lines (kept the 3 <PermissionRoute> equivalents). TSC + Vite both clean.
  - backend/tests/integration/test_auth_api.py — added # pragma: allowlist secret + # noqa: S106 on the test fixture line that GitGuardian flagged.
2026-04-23 14:10:14 +02:00

556 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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
from typing import Any
from urllib.parse import urlparse
import httpx
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",)
# Browser-like headers. ``api.bambulab.com`` accepts minimal headers cleanly;
# the Referer is kept so MakerWorld origin checks don't fail anywhere the
# same client hits ``makerworld.com``.
_CLIENT_HEADERS = {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:147.0) Gecko/20100101 Firefox/147.0",
"Accept": "text/html,application/json,*/*",
"Accept-Language": "en-US,en;q=0.9",
"Referer": "https://makerworld.com/",
}
_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 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, 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
opener = build_opener(_NoRedirect)
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,
):
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()
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:
upstream = _extract_upstream_error(response)
raise MakerWorldAuthError(upstream or f"MakerWorld rejected the Bambu Cloud token 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:
# MakerWorld'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.
upstream = _extract_upstream_error(response)
if upstream and "robot" in upstream.lower():
raise MakerWorldUnavailableError(
f"MakerWorld is challenging this IP with a CAPTCHA ({upstream}). "
"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:
upstream = _extract_upstream_error(response)
raise MakerWorldAuthError(
upstream or "Bambu Lab rejected the token — sign in again in Settings → Bambu Cloud"
)
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