mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-08 15:11:21 +02:00
593 lines
28 KiB
Python
593 lines
28 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; see ``model_providers/makerworld/auth.py``).
|
||
|
||
Implements the :class:`ProviderService` interface — the route layer drives it
|
||
through ``resolve`` / ``get_download`` / ``download`` so the same flow can be
|
||
reused for future providers.
|
||
|
||
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
|
||
from collections.abc import Awaitable, Callable
|
||
from dataclasses import replace
|
||
from typing import Any
|
||
from urllib.parse import urlparse
|
||
|
||
import httpx
|
||
|
||
from backend.app.services.bambu_cloud import is_captcha_challenge, is_expiry_401
|
||
from backend.app.services.model_providers.base import (
|
||
ProviderDownload,
|
||
ProviderDownloadInfo,
|
||
ProviderResolvedModel,
|
||
ProviderResourceRef,
|
||
ProviderService,
|
||
ProviderStatus,
|
||
)
|
||
from backend.app.services.model_providers.makerworld.auth import is_cloud_token_invalid
|
||
from backend.app.services.model_providers.makerworld.errors import (
|
||
MakerWorldAuthError,
|
||
MakerWorldForbiddenError,
|
||
MakerWorldNotFoundError,
|
||
MakerWorldUnavailableError,
|
||
MakerWorldUrlError,
|
||
)
|
||
from backend.app.services.model_providers.makerworld.http import (
|
||
_ALLOWED_DOWNLOAD_SUFFIXES,
|
||
_CLIENT_HEADERS,
|
||
_IMAGE_EXT_TO_MIME,
|
||
_MAX_3MF_BYTES,
|
||
_MAX_THUMBNAIL_BYTES,
|
||
_REFUSED_THUMBNAIL_MIMES,
|
||
MAKERWORLD_API_BASE,
|
||
MAKERWORLD_CDN_HOSTS,
|
||
_download_s3_urllib,
|
||
_extract_upstream_error,
|
||
)
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
_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.
|
||
Must live in the same module as the service class so ``__init__`` reads
|
||
the live value rather than an import-time snapshot.
|
||
"""
|
||
global _shared_http_client
|
||
_shared_http_client = client
|
||
|
||
|
||
# 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."
|
||
)
|
||
|
||
|
||
class MakerWorldService(ProviderService):
|
||
"""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,
|
||
user: Any | None = None,
|
||
on_auth_failure: Callable[[], Awaitable[None]] | None = None,
|
||
thumbnail_hosts: tuple[str, ...] = MAKERWORLD_CDN_HOSTS,
|
||
download_hosts: tuple[str, ...] = MAKERWORLD_CDN_HOSTS,
|
||
):
|
||
# 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
|
||
# SSRF allowlists for the thumbnail proxy and the 3MF download guard.
|
||
# Default to MakerWorld's CDN hosts; ``MakerWorldProvider.build_service``
|
||
# passes ``ModelProvider.thumbnail_hosts()`` / ``download_hosts()`` so
|
||
# the guards are driven by the provider descriptor rather than enforced
|
||
# by coincidence (interface contract on ``ProviderService``).
|
||
self._thumbnail_hosts = tuple(thumbnail_hosts)
|
||
self._download_hosts = tuple(download_hosts)
|
||
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
|
||
self._user = user
|
||
|
||
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
|
||
|
||
# ------------------------------------------------------------- interface
|
||
|
||
async def get_status(self, db: Any) -> ProviderStatus:
|
||
"""Whether the caller can download: needs a stored, non-rejected Bambu
|
||
Cloud token. ``credential_rejected`` is the machine-readable expired
|
||
state; ``auth_error`` names it for humans so the UI can say "your
|
||
sign-in expired" rather than a bare "sign in"."""
|
||
has_token = bool(self._auth_token)
|
||
expired = has_token and await is_cloud_token_invalid(db, self._user)
|
||
return ProviderStatus(
|
||
authenticated=has_token,
|
||
can_download=has_token and not expired,
|
||
auth_error=_SIGN_IN_EXPIRED_MESSAGE if expired else None,
|
||
credential_rejected=expired,
|
||
)
|
||
|
||
async def resolve(self, ref: ProviderResourceRef) -> ProviderResolvedModel:
|
||
"""Fetch full model metadata + the plate list, merging per-instance
|
||
printer compatibility so the frontend can show "sliced for A1 / also
|
||
compatible with H2D, P1S" before the user picks a plate."""
|
||
model_id = int(ref.external_id)
|
||
design = await self.get_design(model_id)
|
||
instances_envelope = await self.get_design_instances(model_id)
|
||
|
||
# MakerWorld's instances payload is ``{"total": N, "hits": [...]}``;
|
||
# normalise the null case to an empty list so the frontend doesn't
|
||
# have to handle null vs [] both ways.
|
||
instances = instances_envelope.get("hits") or []
|
||
if not isinstance(instances, list):
|
||
instances = []
|
||
|
||
# /instances/hits omits the per-instance printer compatibility info
|
||
# that /design.instances[].extention.modelInfo carries. Merge it in.
|
||
design_instances = design.get("instances") or []
|
||
if isinstance(design_instances, list):
|
||
compat_by_id = {}
|
||
for di in design_instances:
|
||
if not isinstance(di, dict):
|
||
continue
|
||
iid = di.get("id")
|
||
if iid is None:
|
||
continue
|
||
ext = (di.get("extention") or {}).get("modelInfo") or {}
|
||
compat_by_id[iid] = {
|
||
"compatibility": ext.get("compatibility"),
|
||
"otherCompatibility": ext.get("otherCompatibility"),
|
||
}
|
||
for inst in instances:
|
||
if not isinstance(inst, dict):
|
||
continue
|
||
iid = inst.get("id")
|
||
extra = compat_by_id.get(iid)
|
||
if extra:
|
||
inst["compatibility"] = extra["compatibility"]
|
||
inst["otherCompatibility"] = extra["otherCompatibility"]
|
||
|
||
return ProviderResolvedModel(ref=ref, design=design, instances=instances)
|
||
|
||
async def get_download(self, ref: ProviderResourceRef) -> ProviderDownloadInfo:
|
||
"""Resolve the signed 3MF download for a specific MakerWorld profile.
|
||
|
||
Handles the provider-specific dance: the iot-service endpoint needs
|
||
the *alphanumeric* ``modelId`` (e.g. ``"US2bb73b106683e5"``) from the
|
||
design, not the integer design id, and picks a default profile when
|
||
the caller didn't specify one. Enriches ``ref.sub_id`` with the actual
|
||
profile used so the route can build the per-plate dedupe key.
|
||
"""
|
||
model_id = int(ref.external_id)
|
||
design = await self.get_design(model_id)
|
||
|
||
alphanumeric_model_id = design.get("modelId")
|
||
if not isinstance(alphanumeric_model_id, str) or not alphanumeric_model_id:
|
||
raise MakerWorldUnavailableError("MakerWorld design metadata missing the modelId field")
|
||
|
||
profile_id = int(ref.sub_id) if ref.sub_id else None
|
||
if profile_id is None:
|
||
for instance in design.get("instances") or []:
|
||
pid = instance.get("profileId")
|
||
if isinstance(pid, int) and pid > 0:
|
||
profile_id = pid
|
||
break
|
||
if profile_id is None:
|
||
envelope = await self.get_design_instances(model_id)
|
||
for hit in envelope.get("hits") or []:
|
||
pid = hit.get("profileId")
|
||
if isinstance(pid, int) and pid > 0:
|
||
profile_id = pid
|
||
break
|
||
if profile_id is None:
|
||
raise MakerWorldUnavailableError("MakerWorld returned no instances for this model")
|
||
|
||
manifest = await self.get_profile_download(profile_id, alphanumeric_model_id)
|
||
|
||
signed_url = manifest.get("url")
|
||
if not signed_url or not isinstance(signed_url, str):
|
||
raise MakerWorldUnavailableError("MakerWorld did not return a download URL")
|
||
|
||
# Raw upstream name — the route layer basenames / percent-decodes it
|
||
# as defence-in-depth before persisting.
|
||
raw_name = manifest.get("name")
|
||
suggested_filename = raw_name if isinstance(raw_name, str) and raw_name.strip() else ""
|
||
|
||
return ProviderDownloadInfo(
|
||
ref=replace(ref, sub_id=str(profile_id)),
|
||
url=signed_url,
|
||
suggested_filename=suggested_filename,
|
||
)
|
||
|
||
async def download(self, info: ProviderDownloadInfo) -> ProviderDownload:
|
||
"""Fetch the 3MF bytes for a signed URL, returning ``(bytes, filename)``."""
|
||
file_bytes, download_filename = await self.download_3mf(info.url)
|
||
return ProviderDownload(file_bytes=file_bytes, filename=download_filename)
|
||
|
||
# ---------------------------------------------------------------- endpoints
|
||
|
||
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
|
||
|
||
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 declared download hosts
|
||
(SSRF guard — driven by ``ModelProvider.download_hosts()`` via
|
||
``build_service``, the symmetric counterpart to the thumbnail
|
||
allowlist) *or* matches ``_ALLOWED_DOWNLOAD_SUFFIXES``, Bambu's S3
|
||
regional endpoints, which are this provider's own signed-URL family
|
||
rather than part of the injectable seam; 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 self._download_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 declared thumbnail hosts
|
||
(SSRF guard — symmetric to :meth:`download_3mf`; both allowlists are
|
||
fed from the provider descriptor by ``build_service``). Caps
|
||
payload at 10 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 self._thumbnail_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
|