makerworld.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472
  1. """MakerWorld integration routes.
  2. User pastes a MakerWorld URL → Bambuddy resolves it → shows plate list →
  3. one-click import/print. The URL-paste flow covers the actual discovery
  4. pattern (Reddit/YouTube/shared links) without needing to replicate
  5. MakerWorld's whole search UI.
  6. Search/browse endpoints are intentionally NOT exposed: the public-facing
  7. ``design/search`` endpoint returns empty results from server-originated
  8. requests (see memory/makerworld-integration.md for the investigation).
  9. """
  10. from __future__ import annotations
  11. import logging
  12. import os
  13. from urllib.parse import unquote
  14. from fastapi import APIRouter, Depends, HTTPException, Query
  15. from fastapi.responses import Response
  16. from sqlalchemy import select
  17. from sqlalchemy.ext.asyncio import AsyncSession
  18. from backend.app.api.routes.cloud import (
  19. get_stored_token,
  20. is_cloud_token_invalid,
  21. mark_cloud_token_invalid,
  22. resolve_api_key_cloud_owner,
  23. )
  24. from backend.app.api.routes.library import save_3mf_bytes_to_library
  25. from backend.app.core.auth import RequirePermissionIfAuthEnabled
  26. from backend.app.core.database import get_db
  27. from backend.app.core.permissions import Permission
  28. from backend.app.models.library import LibraryFile, LibraryFolder
  29. from backend.app.models.user import User
  30. from backend.app.schemas.makerworld import (
  31. MakerWorldImportRequest,
  32. MakerWorldImportResponse,
  33. MakerWorldRecentImport,
  34. MakerWorldResolvedModel,
  35. MakerWorldResolveRequest,
  36. MakerWorldStatus,
  37. )
  38. from backend.app.services.makerworld import (
  39. MakerWorldAuthError,
  40. MakerWorldError,
  41. MakerWorldForbiddenError,
  42. MakerWorldNotFoundError,
  43. MakerWorldService,
  44. MakerWorldUnavailableError,
  45. MakerWorldUrlError,
  46. )
  47. logger = logging.getLogger(__name__)
  48. router = APIRouter(prefix="/makerworld", tags=["makerworld"])
  49. _SOURCE_TYPE = "makerworld"
  50. async def _build_service(db: AsyncSession, user: User | None) -> MakerWorldService:
  51. """Construct a per-request MakerWorldService seeded with the caller's
  52. stored Bambu Cloud bearer token when available.
  53. Mirrors ``cloud.build_authenticated_cloud`` — the token is entirely
  54. optional; anonymous calls (metadata, URL resolution) still work — and,
  55. like it, records a rejected token so the whole app agrees the sign-in is
  56. dead rather than each feature failing on its own.
  57. """
  58. token, _email, _region = await get_stored_token(db, user)
  59. user_id = user.id if user is not None else None
  60. return MakerWorldService(
  61. auth_token=token,
  62. on_auth_failure=lambda: mark_cloud_token_invalid(user_id),
  63. )
  64. def _canonical_url(model_id: int, profile_id: int | None = None) -> str:
  65. """Build a stable source_url we use for dedupe.
  66. Dedupe is keyed per *plate* (profile) rather than per model, since the
  67. ``/iot-service/.../profile/{profileId}`` download returns a specific
  68. plate — not the full multi-plate zip — so two different plates of the
  69. same design should become two separate library entries. Canonical
  70. shape uses the locale-free path with the ``#profileId-`` fragment so
  71. all URL variants of the same plate still collapse (e.g. ``/en/models/
  72. 123-slug?from=search#profileId-456`` and ``/de/models/123#profileId-
  73. 456`` both map to ``https://makerworld.com/models/123#profileId-
  74. 456``). Plate-less imports (legacy or whole-design) keep the old
  75. model-only shape for backwards compatibility with existing rows.
  76. """
  77. if profile_id:
  78. return f"https://makerworld.com/models/{model_id}#profileId-{profile_id}"
  79. return f"https://makerworld.com/models/{model_id}"
  80. def _map_service_error(exc: MakerWorldError) -> HTTPException:
  81. """Translate service exceptions into HTTP responses."""
  82. if isinstance(exc, MakerWorldUrlError):
  83. return HTTPException(status_code=400, detail=str(exc))
  84. if isinstance(exc, MakerWorldAuthError):
  85. return HTTPException(status_code=401, detail=str(exc))
  86. if isinstance(exc, MakerWorldForbiddenError):
  87. # 403 forwards MakerWorld's own refusal message (content-gated,
  88. # region-locked, requires points, etc.) — UI surfaces it verbatim.
  89. return HTTPException(status_code=403, detail=str(exc))
  90. if isinstance(exc, MakerWorldNotFoundError):
  91. return HTTPException(status_code=404, detail=str(exc))
  92. if isinstance(exc, MakerWorldUnavailableError):
  93. return HTTPException(status_code=502, detail=str(exc))
  94. return HTTPException(status_code=500, detail=f"MakerWorld error: {exc}")
  95. @router.get("/thumbnail")
  96. async def proxy_thumbnail(
  97. url: str = Query(..., description="MakerWorld CDN image URL (makerworld.bblmw.com or public-cdn.bblmw.com)"),
  98. ):
  99. """Proxy a MakerWorld CDN thumbnail.
  100. The SPA's ``img-src`` CSP only allows ``'self' data: blob:`` — hotlinking
  101. from makerworld.bblmw.com is blocked. This endpoint refetches the image
  102. server-side and returns it with a long cache window.
  103. **Unauthenticated on purpose**: ``<img>`` tags can't send Authorization
  104. headers, so requiring a Bearer token here would break the whole feature
  105. (browsers would get 401 on every image, rendering as broken-image
  106. placeholders). The thumbnails being proxied are MakerWorld's *public*
  107. CDN — any visitor to makerworld.com can fetch them without auth — so no
  108. data is exposed. The SSRF guard inside ``fetch_thumbnail`` restricts
  109. the upstream host to the MakerWorld CDN allowlist, so this can't be
  110. abused as a generic open proxy.
  111. URLs are content-addressable (filename contains a hash), so the
  112. aggressive ``immutable`` cache-control is safe.
  113. """
  114. service = MakerWorldService()
  115. try:
  116. payload, content_type = await service.fetch_thumbnail(url)
  117. except MakerWorldError as exc:
  118. raise _map_service_error(exc) from exc
  119. finally:
  120. await service.close()
  121. return Response(
  122. content=payload,
  123. media_type=content_type,
  124. headers={
  125. "Cache-Control": "public, max-age=86400, immutable",
  126. },
  127. )
  128. @router.get("/status", response_model=MakerWorldStatus)
  129. async def get_status(
  130. db: AsyncSession = Depends(get_db),
  131. current_user: User | None = RequirePermissionIfAuthEnabled(Permission.MAKERWORLD_VIEW),
  132. api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
  133. ):
  134. """Report whether the caller can import 3MFs (needs a Bambu Cloud token).
  135. API-keyed callers (which return None from ``current_user``) get the
  136. owner User via ``resolve_api_key_cloud_owner`` when the key carries the
  137. cloud-access scope, so ``has_cloud_token`` reflects the owning user's
  138. stored token rather than always reporting ``False`` (#1777, same shape
  139. as the cloud-presets fix in #1182).
  140. """
  141. cloud_token_user = current_user or api_key_cloud_owner
  142. token, _email, _region = await get_stored_token(db, cloud_token_user)
  143. has_token = bool(token)
  144. # A token Bambu has already rejected downloads nothing. ``can_download``
  145. # used to be a bare alias for ``has_cloud_token``, so the import button
  146. # stayed enabled against a dead credential and the user found out via a
  147. # 401 toast (#2562 follow-up).
  148. expired = has_token and await is_cloud_token_invalid(db, cloud_token_user)
  149. return MakerWorldStatus(
  150. has_cloud_token=has_token,
  151. can_download=has_token and not expired,
  152. sign_in_expired=expired,
  153. )
  154. @router.post("/resolve", response_model=MakerWorldResolvedModel)
  155. async def resolve_url(
  156. body: MakerWorldResolveRequest,
  157. db: AsyncSession = Depends(get_db),
  158. current_user: User | None = RequirePermissionIfAuthEnabled(Permission.MAKERWORLD_VIEW),
  159. api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
  160. ):
  161. """Resolve a MakerWorld URL to full model metadata + plate list.
  162. The response also tells the caller which (if any) LibraryFile rows already
  163. exist for the same model URL, so the UI can show an "Already imported"
  164. badge and skip a redundant download.
  165. """
  166. try:
  167. model_id, profile_id = MakerWorldService.parse_url(body.url)
  168. except MakerWorldError as exc:
  169. raise _map_service_error(exc) from exc
  170. # API-keyed callers carry identity on the key, not in current_user — see
  171. # the /status handler comment and #1777 / #1182.
  172. cloud_token_user = current_user or api_key_cloud_owner
  173. service = await _build_service(db, cloud_token_user)
  174. try:
  175. design = await service.get_design(model_id)
  176. instances_envelope = await service.get_design_instances(model_id)
  177. except MakerWorldError as exc:
  178. raise _map_service_error(exc) from exc
  179. finally:
  180. await service.close()
  181. # MakerWorld's instances payload is ``{"total": N, "hits": [...]}``; callers
  182. # only care about the hits, and we normalise the null case to an empty list
  183. # so the frontend doesn't have to handle null vs [] both ways.
  184. instances = instances_envelope.get("hits") or []
  185. if not isinstance(instances, list):
  186. instances = []
  187. # /instances/hits omits the per-instance printer compatibility info that
  188. # /design.instances[].extention.modelInfo carries (compatibility +
  189. # otherCompatibility). Merge it in so the frontend can show "this
  190. # instance was sliced for A1" + "also marked compatible with: H2D, P1S,
  191. # …" before the user picks one — without that, every instance row looks
  192. # identical in the UI and users blindly pick the first one regardless of
  193. # whether it matches their printer.
  194. design_instances = design.get("instances") or []
  195. if isinstance(design_instances, list):
  196. compat_by_id = {}
  197. for di in design_instances:
  198. if not isinstance(di, dict):
  199. continue
  200. iid = di.get("id")
  201. if iid is None:
  202. continue
  203. ext = (di.get("extention") or {}).get("modelInfo") or {}
  204. compat_by_id[iid] = {
  205. "compatibility": ext.get("compatibility"),
  206. "otherCompatibility": ext.get("otherCompatibility"),
  207. }
  208. for inst in instances:
  209. if not isinstance(inst, dict):
  210. continue
  211. iid = inst.get("id")
  212. extra = compat_by_id.get(iid)
  213. if extra:
  214. inst["compatibility"] = extra["compatibility"]
  215. inst["otherCompatibility"] = extra["otherCompatibility"]
  216. # Find every library row whose source_url is either the model-level
  217. # canonical URL (legacy whole-model imports) or any plate-level URL
  218. # (``...#profileId-{n}``) under this model. The frontend surfaces this
  219. # to mark imported plates in the instance picker.
  220. model_prefix = _canonical_url(model_id)
  221. existing_q = await db.execute(
  222. select(LibraryFile.id).where(
  223. (LibraryFile.source_url == model_prefix) | (LibraryFile.source_url.like(f"{model_prefix}#profileId-%")),
  224. LibraryFile.deleted_at.is_(None),
  225. )
  226. )
  227. already_imported = [row[0] for row in existing_q.all()]
  228. return MakerWorldResolvedModel(
  229. model_id=model_id,
  230. profile_id=profile_id,
  231. design=design,
  232. instances=instances,
  233. already_imported_library_ids=already_imported,
  234. )
  235. @router.post("/import", response_model=MakerWorldImportResponse)
  236. async def import_instance(
  237. body: MakerWorldImportRequest,
  238. db: AsyncSession = Depends(get_db),
  239. current_user: User | None = RequirePermissionIfAuthEnabled(Permission.MAKERWORLD_IMPORT),
  240. api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
  241. ):
  242. """Download a specific MakerWorld instance (plate configuration) and save
  243. the 3MF into the library.
  244. De-duplicates by canonicalised source URL — if the same MakerWorld model
  245. was imported before (any plate), that existing LibraryFile is returned and
  246. no new download happens.
  247. """
  248. if body.folder_id is not None:
  249. folder_q = await db.execute(select(LibraryFolder).where(LibraryFolder.id == body.folder_id))
  250. target_folder = folder_q.scalar_one_or_none()
  251. if target_folder is None:
  252. raise HTTPException(status_code=404, detail="Folder not found")
  253. if target_folder.is_external and target_folder.external_readonly:
  254. raise HTTPException(
  255. status_code=403,
  256. detail="Cannot import into a read-only external folder",
  257. )
  258. effective_folder_id: int | None = body.folder_id
  259. else:
  260. # Default destination: a dedicated top-level "MakerWorld" folder. Keeps
  261. # imports out of the library root so power users can still organise
  262. # manually in subfolders, and auto-creates the folder on the first
  263. # import so users don't have to set it up themselves.
  264. mw_folder_q = await db.execute(
  265. select(LibraryFolder).where(
  266. LibraryFolder.name == "MakerWorld",
  267. LibraryFolder.parent_id.is_(None),
  268. LibraryFolder.is_external.is_(False),
  269. )
  270. )
  271. mw_folder = mw_folder_q.scalar_one_or_none()
  272. if mw_folder is None:
  273. mw_folder = LibraryFolder(name="MakerWorld", parent_id=None)
  274. db.add(mw_folder)
  275. await db.flush()
  276. effective_folder_id = mw_folder.id
  277. # API-keyed callers carry identity on the key, not in current_user — see
  278. # the /status handler comment and #1777 / #1182. The same resolved user
  279. # is reused for owner_id on save_3mf_bytes_to_library below so the
  280. # library row is attributed to the key's owner rather than NULL.
  281. cloud_token_user = current_user or api_key_cloud_owner
  282. service = await _build_service(db, cloud_token_user)
  283. # YASTL#51's iot-service endpoint needs the *alphanumeric* modelId
  284. # (e.g. "US2bb73b106683e5"), not the integer design id from /models/{N}.
  285. # Fetch design metadata to resolve it, and — in the same call — pick a
  286. # default profileId from the response if the frontend didn't specify one.
  287. try:
  288. design = await service.get_design(body.model_id)
  289. except MakerWorldError as exc:
  290. await service.close()
  291. raise _map_service_error(exc) from exc
  292. alphanumeric_model_id = design.get("modelId")
  293. if not isinstance(alphanumeric_model_id, str) or not alphanumeric_model_id:
  294. await service.close()
  295. raise HTTPException(
  296. status_code=502,
  297. detail="MakerWorld design metadata missing the modelId field",
  298. )
  299. profile_id = body.profile_id
  300. if profile_id is None:
  301. for instance in design.get("instances") or []:
  302. pid = instance.get("profileId")
  303. if isinstance(pid, int) and pid > 0:
  304. profile_id = pid
  305. break
  306. if profile_id is None:
  307. try:
  308. envelope = await service.get_design_instances(body.model_id)
  309. except MakerWorldError as exc:
  310. await service.close()
  311. raise _map_service_error(exc) from exc
  312. for hit in envelope.get("hits") or []:
  313. pid = hit.get("profileId")
  314. if isinstance(pid, int) and pid > 0:
  315. profile_id = pid
  316. break
  317. if profile_id is None:
  318. await service.close()
  319. raise HTTPException(
  320. status_code=502,
  321. detail="MakerWorld returned no instances for this model",
  322. )
  323. # Canonical URL includes profile_id so each plate gets its own library
  324. # entry (see ``_canonical_url`` docstring).
  325. source_url = _canonical_url(body.model_id, profile_id)
  326. try:
  327. manifest = await service.get_profile_download(profile_id, alphanumeric_model_id)
  328. except MakerWorldError as exc:
  329. await service.close()
  330. raise _map_service_error(exc) from exc
  331. signed_url = manifest.get("url")
  332. # Basename-strip any path components from the upstream filename so a
  333. # malicious response (``name: "../../evil.3mf"``) can't persist a suspect
  334. # string into the library row or the UI. On-disk storage uses a UUID
  335. # filename regardless (see library.py), so this is defence-in-depth.
  336. raw_name = manifest.get("name")
  337. if isinstance(raw_name, str) and raw_name.strip():
  338. # MakerWorld emits percent-encoded names (`%20` for spaces, etc.)
  339. # because the same string round-trips through HTTP URLs in the
  340. # CDN download path. Decode before persisting so the library
  341. # row, the slice toast, and every later UI surface show the
  342. # human-readable form.
  343. suggested_name = os.path.basename(unquote(raw_name.strip())) or f"makerworld-{body.model_id}.3mf"
  344. else:
  345. suggested_name = f"makerworld-{body.model_id}.3mf"
  346. if not signed_url or not isinstance(signed_url, str):
  347. await service.close()
  348. raise HTTPException(status_code=502, detail="MakerWorld did not return a download URL")
  349. # Dedupe check upfront so we don't burn bandwidth re-downloading.
  350. if source_url:
  351. existing_q = await db.execute(LibraryFile.active().where(LibraryFile.source_url == source_url).limit(1))
  352. existing_row = existing_q.scalar_one_or_none()
  353. if existing_row is not None:
  354. await service.close()
  355. return MakerWorldImportResponse(
  356. library_file_id=existing_row.id,
  357. filename=existing_row.filename,
  358. folder_id=existing_row.folder_id,
  359. profile_id=profile_id,
  360. was_existing=True,
  361. )
  362. try:
  363. file_bytes, download_filename = await service.download_3mf(signed_url)
  364. except MakerWorldError as exc:
  365. await service.close()
  366. raise _map_service_error(exc) from exc
  367. finally:
  368. await service.close()
  369. # Prefer the server-provided human-readable filename; the signed URL's
  370. # path ends in a UUID that's not meaningful to users. Decode the
  371. # fallback path-tail too — same percent-encoding round-trip applies
  372. # there as on the manifest-supplied name.
  373. filename = suggested_name if suggested_name.endswith(".3mf") else unquote(download_filename)
  374. library_file, was_existing = await save_3mf_bytes_to_library(
  375. db,
  376. file_bytes=file_bytes,
  377. filename=filename,
  378. folder_id=effective_folder_id,
  379. source_type=_SOURCE_TYPE,
  380. source_url=source_url,
  381. owner_id=cloud_token_user.id if cloud_token_user else None,
  382. )
  383. return MakerWorldImportResponse(
  384. library_file_id=library_file.id,
  385. filename=library_file.filename,
  386. folder_id=library_file.folder_id,
  387. profile_id=profile_id,
  388. was_existing=was_existing,
  389. )
  390. @router.get("/recent-imports", response_model=list[MakerWorldRecentImport])
  391. async def recent_imports(
  392. limit: int = 10,
  393. db: AsyncSession = Depends(get_db),
  394. current_user: User | None = RequirePermissionIfAuthEnabled(Permission.MAKERWORLD_VIEW),
  395. ):
  396. """Last N MakerWorld imports, newest first.
  397. Surfaces files whose ``source_type`` is ``"makerworld"`` so the MakerWorld
  398. page can show a 'Recent imports' sidebar that persists across resolves.
  399. ``limit`` is clamped to ``[1, 50]`` to keep payloads sensible.
  400. """
  401. _ = current_user # permission gate only
  402. capped = max(1, min(50, int(limit)))
  403. result = await db.execute(
  404. LibraryFile.active()
  405. .where(LibraryFile.source_type == _SOURCE_TYPE)
  406. .order_by(LibraryFile.created_at.desc())
  407. .limit(capped)
  408. )
  409. rows = result.scalars().all()
  410. return [
  411. MakerWorldRecentImport(
  412. library_file_id=row.id,
  413. filename=row.filename,
  414. folder_id=row.folder_id,
  415. thumbnail_path=row.thumbnail_path,
  416. source_url=row.source_url,
  417. created_at=row.created_at.isoformat() if row.created_at else "",
  418. )
  419. for row in rows
  420. ]