makerworld.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490
  1. """MakerWorld integration routes.
  2. User pastes a model URL (MakerWorld or other supported host) → Bambuddy resolves
  3. it → shows plate list → one-click import/print. The URL-paste flow covers the
  4. actual discovery pattern (Reddit/YouTube/shared links) without needing to
  5. replicate the host'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. These are still the *MakerWorld* routes: they consult the shared seams where
  10. one exists — URL routing via :class:`ModelProviderRegistry`, permissions and
  11. folder naming from the provider descriptor, already-imported matching via
  12. :meth:`ModelProvider.source_url_filter` — but request/response shapes remain
  13. MakerWorld-specific. The fully shared import API that makes new hosts work
  14. with zero route changes arrives with #2793.
  15. """
  16. from __future__ import annotations
  17. import logging
  18. import os
  19. from urllib.parse import unquote
  20. from fastapi import APIRouter, Depends, Header, HTTPException, Query
  21. from fastapi.responses import Response
  22. from fastapi.security import HTTPAuthorizationCredentials
  23. from sqlalchemy import select
  24. from sqlalchemy.ext.asyncio import AsyncSession
  25. from backend.app.api.routes.cloud import resolve_api_key_cloud_owner
  26. from backend.app.api.routes.library import save_3mf_bytes_to_library
  27. from backend.app.core.auth import (
  28. RequirePermissionIfAuthEnabled,
  29. require_auth_if_enabled,
  30. require_permission_if_auth_enabled,
  31. security,
  32. )
  33. from backend.app.core.database import get_db
  34. from backend.app.core.permissions import Permission
  35. from backend.app.models.library import LibraryFile, LibraryFolder
  36. from backend.app.models.user import User
  37. from backend.app.schemas.makerworld import (
  38. MakerWorldImportRequest,
  39. MakerWorldImportResponse,
  40. MakerWorldRecentImport,
  41. MakerWorldResolvedModel,
  42. MakerWorldResolveRequest,
  43. MakerWorldStatus,
  44. )
  45. from backend.app.services.model_providers import makerworld_provider, registry
  46. from backend.app.services.model_providers.base import (
  47. ModelProvider,
  48. ProviderAuthError,
  49. ProviderError,
  50. ProviderForbiddenError,
  51. ProviderNotFoundError,
  52. ProviderResourceRef,
  53. ProviderService,
  54. ProviderUnavailableError,
  55. ProviderUrlError,
  56. )
  57. from backend.app.services.model_providers.makerworld.service import MakerWorldService
  58. logger = logging.getLogger(__name__)
  59. router = APIRouter(prefix="/makerworld", tags=["makerworld"])
  60. def _provider_for_url(url: str) -> ModelProvider:
  61. """Return the registered model provider that claims *url*.
  62. A pasted link for an unsupported host is a clean 400 — the registry is
  63. the routing seam, and "nobody supports this URL" is a client-input
  64. problem, not a server error.
  65. """
  66. provider = registry.find_for_url(url)
  67. if provider is None:
  68. msg = f"No registered model provider supports {url!r}"
  69. raise HTTPException(status_code=400, detail=msg)
  70. return provider
  71. def _provider_for_source(source_type: str) -> ModelProvider:
  72. """Return the registered model provider with this ``source_type``.
  73. Import identifies a resource by numeric id, not by URL, so there is
  74. nothing to route on except the source type the caller names. The detail
  75. is built here rather than via ``str(KeyError)`` — KeyError's ``__str__``
  76. is the *repr* of its argument and would ship the quotes to the client.
  77. """
  78. try:
  79. return registry.get(source_type)
  80. except KeyError as exc:
  81. msg = f"No model provider registered for source_type {source_type!r}"
  82. raise HTTPException(status_code=400, detail=msg) from exc
  83. async def _authorize_for_provider(
  84. provider: ModelProvider,
  85. permission: Permission | None,
  86. credentials: HTTPAuthorizationCredentials | None,
  87. x_api_key: str | None,
  88. ) -> User | None:
  89. """Apply *provider*'s own permission to a request that named it.
  90. This cannot live in the route signature. FastAPI resolves dependencies
  91. before the body exists, so a dependency can only ever bake in one
  92. provider's permission — MakerWorld's — while the provider actually being
  93. used comes from the request (``source_type`` on import, the pasted URL on
  94. resolve). Importing from a second provider would then be gated on
  95. ``makerworld:import``, which is nobody's intent.
  96. The check runs through the same ``require_permission_if_auth_enabled``
  97. the decorator would have built, so JWT users, API keys (scope gate plus
  98. the owner-outranks-key rule) and auth-disabled installs behave exactly as
  99. before. The routes keep a permission-free ``require_auth_if_enabled``
  100. dependency so an anonymous caller is still refused before the body is
  101. read.
  102. A provider that declares no permission is refused rather than waved
  103. through: the descriptor's permission fields are optional, and "unset"
  104. must not read as "unrestricted".
  105. """
  106. if permission is None:
  107. raise HTTPException(
  108. status_code=500,
  109. detail=f"Model provider {provider.source_type!r} declares no permission for this operation",
  110. )
  111. checker = require_permission_if_auth_enabled(permission)
  112. return await checker(credentials=credentials, x_api_key=x_api_key)
  113. async def _build_service(
  114. db: AsyncSession,
  115. provider: ModelProvider,
  116. current_user: User | None,
  117. api_key_cloud_owner: User | None = None,
  118. ) -> ProviderService:
  119. """Construct a per-request service via *provider*.
  120. Identity resolution (JWT user vs API-key owner vs anonymous) and
  121. credential seeding live inside ``provider.build_service`` — the single
  122. place every provider resolves them, so the routes never re-implement it.
  123. """
  124. return await provider.build_service(db=db, user=current_user, api_key_owner=api_key_cloud_owner)
  125. def _map_service_error(exc: ProviderError) -> HTTPException:
  126. """Translate provider service exceptions into HTTP responses."""
  127. if isinstance(exc, ProviderUrlError):
  128. return HTTPException(status_code=400, detail=str(exc))
  129. if isinstance(exc, ProviderAuthError):
  130. return HTTPException(status_code=401, detail=str(exc))
  131. if isinstance(exc, ProviderForbiddenError):
  132. # 403 forwards the provider's own refusal message (content-gated,
  133. # region-locked, requires points, etc.) — UI surfaces it verbatim.
  134. return HTTPException(status_code=403, detail=str(exc))
  135. if isinstance(exc, ProviderNotFoundError):
  136. return HTTPException(status_code=404, detail=str(exc))
  137. if isinstance(exc, ProviderUnavailableError):
  138. return HTTPException(status_code=502, detail=str(exc))
  139. return HTTPException(status_code=500, detail=f"Model provider error: {exc}")
  140. @router.get("/thumbnail")
  141. async def proxy_thumbnail(
  142. url: str = Query(..., description="MakerWorld CDN image URL (makerworld.bblmw.com or public-cdn.bblmw.com)"),
  143. ):
  144. """Proxy a MakerWorld CDN thumbnail.
  145. The SPA's ``img-src`` CSP only allows ``'self' data: blob:`` — hotlinking
  146. from makerworld.bblmw.com is blocked. This endpoint refetches the image
  147. server-side and returns it with a long cache window.
  148. **Unauthenticated on purpose**: ``<img>`` tags can't send Authorization
  149. headers, so requiring a Bearer token here would break the whole feature
  150. (browsers would get 401 on every image, rendering as broken-image
  151. placeholders). The thumbnails being proxied are MakerWorld's *public*
  152. CDN — any visitor to makerworld.com can fetch them without auth — so no
  153. data is exposed. The SSRF guard inside ``fetch_thumbnail`` restricts
  154. the upstream host to the MakerWorld CDN allowlist, so this can't be
  155. abused as a generic open proxy.
  156. URLs are content-addressable (filename contains a hash), so the
  157. aggressive ``immutable`` cache-control is safe.
  158. """
  159. service = MakerWorldService(thumbnail_hosts=makerworld_provider.thumbnail_hosts())
  160. try:
  161. payload, content_type = await service.fetch_thumbnail(url)
  162. except ProviderError as exc:
  163. raise _map_service_error(exc) from exc
  164. finally:
  165. await service.close()
  166. return Response(
  167. content=payload,
  168. media_type=content_type,
  169. headers={
  170. "Cache-Control": "public, max-age=86400, immutable",
  171. },
  172. )
  173. @router.get("/status", response_model=MakerWorldStatus)
  174. async def get_status(
  175. db: AsyncSession = Depends(get_db),
  176. current_user: User | None = RequirePermissionIfAuthEnabled(makerworld_provider.view_permission),
  177. api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
  178. ):
  179. """Report whether the caller can import 3MFs (needs a Bambu Cloud token).
  180. API-keyed callers (which return None from ``current_user``) get the
  181. owner User via ``resolve_api_key_cloud_owner`` when the key carries the
  182. cloud-access scope, so ``has_cloud_token`` reflects the owning user's
  183. stored token rather than always reporting ``False`` (#1777, same shape
  184. as the cloud-presets fix in #1182).
  185. """
  186. service = await _build_service(db, makerworld_provider, current_user, api_key_cloud_owner)
  187. try:
  188. status = await service.get_status(db)
  189. finally:
  190. await service.close()
  191. return MakerWorldStatus(
  192. has_cloud_token=status.authenticated,
  193. can_download=status.can_download,
  194. # ``credential_rejected`` is the machine-readable "your sign-in
  195. # expired" state the provider set exactly when a stored token exists
  196. # *and* was rejected — no token means there is no sign-in to have
  197. # expired. It is read instead of ``auth_error is not None`` because
  198. # the latter is a human-readable reason that providers may also set
  199. # for non-credential failures (network, rate limit).
  200. sign_in_expired=status.credential_rejected,
  201. )
  202. @router.post(
  203. "/resolve",
  204. response_model=MakerWorldResolvedModel,
  205. # Authentication only — the permission belongs to whichever provider the
  206. # pasted URL routes to, which is not known until the body is parsed (see
  207. # ``_authorize_for_provider``).
  208. dependencies=[Depends(require_auth_if_enabled)],
  209. )
  210. async def resolve_url(
  211. body: MakerWorldResolveRequest,
  212. db: AsyncSession = Depends(get_db),
  213. credentials: HTTPAuthorizationCredentials | None = Depends(security),
  214. x_api_key: str | None = Header(default=None, alias="X-API-Key"),
  215. api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
  216. ):
  217. """Resolve a MakerWorld URL to full model metadata + plate list.
  218. The response also tells the caller which (if any) LibraryFile rows already
  219. exist for the same model URL, so the UI can show an "Already imported"
  220. badge and skip a redundant download.
  221. """
  222. # Strategy pattern: select provider based on URL instead of hardcoding.
  223. # Routing runs before the permission check because the permission *is* the
  224. # provider's; all an unpermitted caller learns from the ordering is which
  225. # hosts Bambuddy supports, which the UI states anyway.
  226. provider = _provider_for_url(body.url)
  227. current_user = await _authorize_for_provider(provider, provider.view_permission, credentials, x_api_key)
  228. try:
  229. ref = provider.parse_url(body.url)
  230. except ProviderError as exc:
  231. raise _map_service_error(exc) from exc
  232. model_id = int(ref.external_id)
  233. profile_id = int(ref.sub_id) if ref.sub_id else None
  234. service = await _build_service(db, provider, current_user, api_key_cloud_owner)
  235. try:
  236. resolved = await service.resolve(ref)
  237. except ProviderError as exc:
  238. raise _map_service_error(exc) from exc
  239. finally:
  240. await service.close()
  241. # Find every library row whose source_url belongs to this resource —
  242. # the provider's :meth:`source_url_filter` owns what "belongs" means
  243. # (whole-model key, per-plate keys, ...). The frontend surfaces the ids
  244. # to mark imported plates in the instance picker.
  245. existing_q = await db.execute(
  246. select(LibraryFile.id).where(
  247. provider.source_url_filter(LibraryFile.source_url, str(model_id)),
  248. LibraryFile.deleted_at.is_(None),
  249. )
  250. )
  251. already_imported = [row[0] for row in existing_q.all()]
  252. return MakerWorldResolvedModel(
  253. model_id=model_id,
  254. profile_id=profile_id,
  255. design=resolved.design,
  256. instances=resolved.instances,
  257. already_imported_library_ids=already_imported,
  258. )
  259. @router.post(
  260. "/import",
  261. response_model=MakerWorldImportResponse,
  262. # Authentication only — the permission belongs to the provider named by
  263. # ``source_type`` (see ``_authorize_for_provider``).
  264. dependencies=[Depends(require_auth_if_enabled)],
  265. )
  266. async def import_instance(
  267. body: MakerWorldImportRequest,
  268. db: AsyncSession = Depends(get_db),
  269. credentials: HTTPAuthorizationCredentials | None = Depends(security),
  270. x_api_key: str | None = Header(default=None, alias="X-API-Key"),
  271. api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
  272. ):
  273. """Download a specific MakerWorld instance (plate configuration) and save
  274. the 3MF into the library.
  275. De-duplicates by canonicalised source URL — if the same MakerWorld model
  276. was imported before (any plate), that existing LibraryFile is returned and
  277. no new download happens.
  278. """
  279. # Resolve the provider first: an unknown ``source_type`` must 400 before
  280. # the default-destination folder gets auto-created as a side effect — and
  281. # the permission that applies is the resolved provider's, not MakerWorld's,
  282. # so it cannot be checked any earlier. All that costs is telling an
  283. # authenticated-but-unpermitted caller which source types are registered,
  284. # which the UI lists anyway; anonymous callers never get this far.
  285. provider = _provider_for_source(body.source_type)
  286. current_user = await _authorize_for_provider(provider, provider.import_permission, credentials, x_api_key)
  287. if body.folder_id is not None:
  288. folder_q = await db.execute(select(LibraryFolder).where(LibraryFolder.id == body.folder_id))
  289. target_folder = folder_q.scalar_one_or_none()
  290. if target_folder is None:
  291. raise HTTPException(status_code=404, detail="Folder not found")
  292. if target_folder.is_external and target_folder.external_readonly:
  293. raise HTTPException(
  294. status_code=403,
  295. detail="Cannot import into a read-only external folder",
  296. )
  297. effective_folder_id: int | None = body.folder_id
  298. else:
  299. # Default destination: the resolved provider's dedicated top-level
  300. # folder (``default_folder_name`` — read off *provider*, not the
  301. # MakerWorld singleton, so the second provider lands in its own
  302. # folder). Keeps imports out of the library root so power users can
  303. # still organise manually in subfolders, and auto-creates the folder
  304. # on the first import so users don't have to set it up themselves. A
  305. # provider that leaves it unset imports into the library root rather
  306. # than minting a NULL-named folder.
  307. default_folder_name = provider.default_folder_name
  308. if default_folder_name is None:
  309. effective_folder_id = None
  310. else:
  311. default_folder_q = await db.execute(
  312. select(LibraryFolder).where(
  313. LibraryFolder.name == default_folder_name,
  314. LibraryFolder.parent_id.is_(None),
  315. LibraryFolder.is_external.is_(False),
  316. )
  317. )
  318. default_folder = default_folder_q.scalar_one_or_none()
  319. if default_folder is None:
  320. default_folder = LibraryFolder(name=default_folder_name, parent_id=None)
  321. db.add(default_folder)
  322. await db.flush()
  323. effective_folder_id = default_folder.id
  324. service = await _build_service(db, provider, current_user, api_key_cloud_owner)
  325. # YASTL#51's iot-service endpoint needs the *alphanumeric* modelId
  326. # (e.g. "US2bb73b106683e5"), not the integer design id from /models/{N} —
  327. # resolving that, plus picking a default profile when the frontend didn't
  328. # specify one, lives inside ``get_download``. The route only orchestrates
  329. # dedupe + persistence so every provider shares those concerns here.
  330. ref = ProviderResourceRef(
  331. source_type=provider.source_type,
  332. external_id=str(body.model_id),
  333. sub_id=str(body.profile_id) if body.profile_id else None,
  334. )
  335. try:
  336. info = await service.get_download(ref)
  337. # The provider enriches ``sub_id`` with the actually-resolved profile
  338. # when the caller omitted one.
  339. resolved_profile_id = int(info.ref.sub_id) if info.ref.sub_id else None
  340. # Canonical URL includes profile_id so each plate gets its own library
  341. # entry (see ``ModelProvider.canonical_url``).
  342. source_url = provider.canonical_url(info.ref)
  343. # Dedupe check upfront so we don't burn bandwidth re-downloading.
  344. existing_q = await db.execute(LibraryFile.active().where(LibraryFile.source_url == source_url).limit(1))
  345. existing_row = existing_q.scalar_one_or_none()
  346. if existing_row is not None:
  347. return MakerWorldImportResponse(
  348. library_file_id=existing_row.id,
  349. filename=existing_row.filename,
  350. folder_id=existing_row.folder_id,
  351. profile_id=resolved_profile_id,
  352. was_existing=True,
  353. )
  354. download = await service.download(info)
  355. except ProviderError as exc:
  356. raise _map_service_error(exc) from exc
  357. finally:
  358. await service.close()
  359. # Basename-strip any path components from the upstream filename so a
  360. # malicious response (``name: "../../evil.3mf"``) can't persist a suspect
  361. # string into the library row or the UI. On-disk storage uses a UUID
  362. # filename regardless (see library.py), so this is defence-in-depth.
  363. raw_name = info.suggested_filename
  364. if isinstance(raw_name, str) and raw_name.strip():
  365. # MakerWorld emits percent-encoded names (`%20` for spaces, etc.)
  366. # because the same string round-trips through HTTP URLs in the
  367. # CDN download path. Decode before persisting so the library
  368. # row, the slice toast, and every later UI surface show the
  369. # human-readable form.
  370. suggested_name = os.path.basename(unquote(raw_name.strip())) or f"makerworld-{body.model_id}.3mf"
  371. else:
  372. suggested_name = f"makerworld-{body.model_id}.3mf"
  373. # Prefer the server-provided human-readable filename; the signed URL's
  374. # path ends in a UUID that's not meaningful to users. Decode the
  375. # fallback path-tail too — same percent-encoding round-trip applies
  376. # there as on the manifest-supplied name.
  377. filename = suggested_name if suggested_name.endswith(".3mf") else unquote(download.filename)
  378. # API-keyed callers carry identity on the key, not in current_user (#1777);
  379. # this collapse stays route-side solely so the library row is attributed
  380. # to the key's owner rather than NULL. Credential identity is resolved
  381. # inside the provider.
  382. cloud_token_user = current_user or api_key_cloud_owner
  383. library_file, was_existing = await save_3mf_bytes_to_library(
  384. db,
  385. file_bytes=download.file_bytes,
  386. filename=filename,
  387. folder_id=effective_folder_id,
  388. source_type=provider.source_type,
  389. source_url=source_url,
  390. owner_id=cloud_token_user.id if cloud_token_user else None,
  391. )
  392. return MakerWorldImportResponse(
  393. library_file_id=library_file.id,
  394. filename=library_file.filename,
  395. folder_id=library_file.folder_id,
  396. profile_id=resolved_profile_id,
  397. was_existing=was_existing,
  398. )
  399. @router.get("/recent-imports", response_model=list[MakerWorldRecentImport])
  400. async def recent_imports(
  401. limit: int = 10,
  402. db: AsyncSession = Depends(get_db),
  403. current_user: User | None = RequirePermissionIfAuthEnabled(makerworld_provider.view_permission),
  404. ):
  405. """Last N MakerWorld imports, newest first.
  406. Surfaces files whose ``source_type`` is ``"makerworld"`` so the MakerWorld
  407. page can show a 'Recent imports' sidebar that persists across resolves.
  408. Widening this to all registered providers is a behaviour change that
  409. belongs with the provider that needs it.
  410. ``limit`` is clamped to ``[1, 50]`` to keep payloads sensible.
  411. """
  412. _ = current_user # permission gate only
  413. capped = max(1, min(50, int(limit)))
  414. result = await db.execute(
  415. LibraryFile.active()
  416. .where(LibraryFile.source_type == makerworld_provider.source_type)
  417. .order_by(LibraryFile.created_at.desc())
  418. .limit(capped)
  419. )
  420. rows = result.scalars().all()
  421. return [
  422. MakerWorldRecentImport(
  423. library_file_id=row.id,
  424. filename=row.filename,
  425. folder_id=row.folder_id,
  426. thumbnail_path=row.thumbnail_path,
  427. source_url=row.source_url,
  428. created_at=row.created_at.isoformat() if row.created_at else "",
  429. )
  430. for row in rows
  431. ]