"""MakerWorld integration routes. User pastes a model URL (MakerWorld or other supported host) → Bambuddy resolves it → shows plate list → one-click import/print. The URL-paste flow covers the actual discovery pattern (Reddit/YouTube/shared links) without needing to replicate the host's whole search UI. Search/browse endpoints are intentionally NOT exposed: the public-facing ``design/search`` endpoint returns empty results from server-originated requests (see memory/makerworld-integration.md for the investigation). These are still the *MakerWorld* routes: they consult the shared seams where one exists — URL routing via :class:`ModelProviderRegistry`, permissions and folder naming from the provider descriptor, already-imported matching via :meth:`ModelProvider.source_url_filter` — but request/response shapes remain MakerWorld-specific. The fully shared import API that makes new hosts work with zero route changes arrives with #2793. """ from __future__ import annotations import logging import os from urllib.parse import unquote from fastapi import APIRouter, Depends, Header, HTTPException, Query from fastapi.responses import Response from fastapi.security import HTTPAuthorizationCredentials from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from backend.app.api.routes.cloud import resolve_api_key_cloud_owner from backend.app.api.routes.library import save_3mf_bytes_to_library from backend.app.core.auth import ( RequirePermissionIfAuthEnabled, require_auth_if_enabled, require_permission_if_auth_enabled, security, ) from backend.app.core.database import get_db from backend.app.core.permissions import Permission from backend.app.models.library import LibraryFile, LibraryFolder from backend.app.models.user import User from backend.app.schemas.makerworld import ( MakerWorldImportRequest, MakerWorldImportResponse, MakerWorldRecentImport, MakerWorldResolvedModel, MakerWorldResolveRequest, MakerWorldStatus, ) from backend.app.services.model_providers import makerworld_provider, registry from backend.app.services.model_providers.base import ( ModelProvider, ProviderAuthError, ProviderError, ProviderForbiddenError, ProviderNotFoundError, ProviderResourceRef, ProviderService, ProviderUnavailableError, ProviderUrlError, ) from backend.app.services.model_providers.makerworld.service import MakerWorldService logger = logging.getLogger(__name__) router = APIRouter(prefix="/makerworld", tags=["makerworld"]) def _provider_for_url(url: str) -> ModelProvider: """Return the registered model provider that claims *url*. A pasted link for an unsupported host is a clean 400 — the registry is the routing seam, and "nobody supports this URL" is a client-input problem, not a server error. """ provider = registry.find_for_url(url) if provider is None: msg = f"No registered model provider supports {url!r}" raise HTTPException(status_code=400, detail=msg) return provider def _provider_for_source(source_type: str) -> ModelProvider: """Return the registered model provider with this ``source_type``. Import identifies a resource by numeric id, not by URL, so there is nothing to route on except the source type the caller names. The detail is built here rather than via ``str(KeyError)`` — KeyError's ``__str__`` is the *repr* of its argument and would ship the quotes to the client. """ try: return registry.get(source_type) except KeyError as exc: msg = f"No model provider registered for source_type {source_type!r}" raise HTTPException(status_code=400, detail=msg) from exc async def _authorize_for_provider( provider: ModelProvider, permission: Permission | None, credentials: HTTPAuthorizationCredentials | None, x_api_key: str | None, ) -> User | None: """Apply *provider*'s own permission to a request that named it. This cannot live in the route signature. FastAPI resolves dependencies before the body exists, so a dependency can only ever bake in one provider's permission — MakerWorld's — while the provider actually being used comes from the request (``source_type`` on import, the pasted URL on resolve). Importing from a second provider would then be gated on ``makerworld:import``, which is nobody's intent. The check runs through the same ``require_permission_if_auth_enabled`` the decorator would have built, so JWT users, API keys (scope gate plus the owner-outranks-key rule) and auth-disabled installs behave exactly as before. The routes keep a permission-free ``require_auth_if_enabled`` dependency so an anonymous caller is still refused before the body is read. A provider that declares no permission is refused rather than waved through: the descriptor's permission fields are optional, and "unset" must not read as "unrestricted". """ if permission is None: raise HTTPException( status_code=500, detail=f"Model provider {provider.source_type!r} declares no permission for this operation", ) checker = require_permission_if_auth_enabled(permission) return await checker(credentials=credentials, x_api_key=x_api_key) async def _build_service( db: AsyncSession, provider: ModelProvider, current_user: User | None, api_key_cloud_owner: User | None = None, ) -> ProviderService: """Construct a per-request service via *provider*. Identity resolution (JWT user vs API-key owner vs anonymous) and credential seeding live inside ``provider.build_service`` — the single place every provider resolves them, so the routes never re-implement it. """ return await provider.build_service(db=db, user=current_user, api_key_owner=api_key_cloud_owner) def _map_service_error(exc: ProviderError) -> HTTPException: """Translate provider service exceptions into HTTP responses.""" if isinstance(exc, ProviderUrlError): return HTTPException(status_code=400, detail=str(exc)) if isinstance(exc, ProviderAuthError): return HTTPException(status_code=401, detail=str(exc)) if isinstance(exc, ProviderForbiddenError): # 403 forwards the provider's own refusal message (content-gated, # region-locked, requires points, etc.) — UI surfaces it verbatim. return HTTPException(status_code=403, detail=str(exc)) if isinstance(exc, ProviderNotFoundError): return HTTPException(status_code=404, detail=str(exc)) if isinstance(exc, ProviderUnavailableError): return HTTPException(status_code=502, detail=str(exc)) return HTTPException(status_code=500, detail=f"Model provider error: {exc}") @router.get("/thumbnail") async def proxy_thumbnail( url: str = Query(..., description="MakerWorld CDN image URL (makerworld.bblmw.com or public-cdn.bblmw.com)"), ): """Proxy a MakerWorld CDN thumbnail. The SPA's ``img-src`` CSP only allows ``'self' data: blob:`` — hotlinking from makerworld.bblmw.com is blocked. This endpoint refetches the image server-side and returns it with a long cache window. **Unauthenticated on purpose**: ```` tags can't send Authorization headers, so requiring a Bearer token here would break the whole feature (browsers would get 401 on every image, rendering as broken-image placeholders). The thumbnails being proxied are MakerWorld's *public* CDN — any visitor to makerworld.com can fetch them without auth — so no data is exposed. The SSRF guard inside ``fetch_thumbnail`` restricts the upstream host to the MakerWorld CDN allowlist, so this can't be abused as a generic open proxy. URLs are content-addressable (filename contains a hash), so the aggressive ``immutable`` cache-control is safe. """ service = MakerWorldService(thumbnail_hosts=makerworld_provider.thumbnail_hosts()) try: payload, content_type = await service.fetch_thumbnail(url) except ProviderError as exc: raise _map_service_error(exc) from exc finally: await service.close() return Response( content=payload, media_type=content_type, headers={ "Cache-Control": "public, max-age=86400, immutable", }, ) @router.get("/status", response_model=MakerWorldStatus) async def get_status( db: AsyncSession = Depends(get_db), current_user: User | None = RequirePermissionIfAuthEnabled(makerworld_provider.view_permission), api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner), ): """Report whether the caller can import 3MFs (needs a Bambu Cloud token). API-keyed callers (which return None from ``current_user``) get the owner User via ``resolve_api_key_cloud_owner`` when the key carries the cloud-access scope, so ``has_cloud_token`` reflects the owning user's stored token rather than always reporting ``False`` (#1777, same shape as the cloud-presets fix in #1182). """ service = await _build_service(db, makerworld_provider, current_user, api_key_cloud_owner) try: status = await service.get_status(db) finally: await service.close() return MakerWorldStatus( has_cloud_token=status.authenticated, can_download=status.can_download, # ``credential_rejected`` is the machine-readable "your sign-in # expired" state the provider set exactly when a stored token exists # *and* was rejected — no token means there is no sign-in to have # expired. It is read instead of ``auth_error is not None`` because # the latter is a human-readable reason that providers may also set # for non-credential failures (network, rate limit). sign_in_expired=status.credential_rejected, ) @router.post( "/resolve", response_model=MakerWorldResolvedModel, # Authentication only — the permission belongs to whichever provider the # pasted URL routes to, which is not known until the body is parsed (see # ``_authorize_for_provider``). dependencies=[Depends(require_auth_if_enabled)], ) async def resolve_url( body: MakerWorldResolveRequest, db: AsyncSession = Depends(get_db), credentials: HTTPAuthorizationCredentials | None = Depends(security), x_api_key: str | None = Header(default=None, alias="X-API-Key"), api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner), ): """Resolve a MakerWorld URL to full model metadata + plate list. The response also tells the caller which (if any) LibraryFile rows already exist for the same model URL, so the UI can show an "Already imported" badge and skip a redundant download. """ # Strategy pattern: select provider based on URL instead of hardcoding. # Routing runs before the permission check because the permission *is* the # provider's; all an unpermitted caller learns from the ordering is which # hosts Bambuddy supports, which the UI states anyway. provider = _provider_for_url(body.url) current_user = await _authorize_for_provider(provider, provider.view_permission, credentials, x_api_key) try: ref = provider.parse_url(body.url) except ProviderError as exc: raise _map_service_error(exc) from exc model_id = int(ref.external_id) profile_id = int(ref.sub_id) if ref.sub_id else None service = await _build_service(db, provider, current_user, api_key_cloud_owner) try: resolved = await service.resolve(ref) except ProviderError as exc: raise _map_service_error(exc) from exc finally: await service.close() # Find every library row whose source_url belongs to this resource — # the provider's :meth:`source_url_filter` owns what "belongs" means # (whole-model key, per-plate keys, ...). The frontend surfaces the ids # to mark imported plates in the instance picker. existing_q = await db.execute( select(LibraryFile.id).where( provider.source_url_filter(LibraryFile.source_url, str(model_id)), LibraryFile.deleted_at.is_(None), ) ) already_imported = [row[0] for row in existing_q.all()] return MakerWorldResolvedModel( model_id=model_id, profile_id=profile_id, design=resolved.design, instances=resolved.instances, already_imported_library_ids=already_imported, ) @router.post( "/import", response_model=MakerWorldImportResponse, # Authentication only — the permission belongs to the provider named by # ``source_type`` (see ``_authorize_for_provider``). dependencies=[Depends(require_auth_if_enabled)], ) async def import_instance( body: MakerWorldImportRequest, db: AsyncSession = Depends(get_db), credentials: HTTPAuthorizationCredentials | None = Depends(security), x_api_key: str | None = Header(default=None, alias="X-API-Key"), api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner), ): """Download a specific MakerWorld instance (plate configuration) and save the 3MF into the library. De-duplicates by canonicalised source URL — if the same MakerWorld model was imported before (any plate), that existing LibraryFile is returned and no new download happens. """ # Resolve the provider first: an unknown ``source_type`` must 400 before # the default-destination folder gets auto-created as a side effect — and # the permission that applies is the resolved provider's, not MakerWorld's, # so it cannot be checked any earlier. All that costs is telling an # authenticated-but-unpermitted caller which source types are registered, # which the UI lists anyway; anonymous callers never get this far. provider = _provider_for_source(body.source_type) current_user = await _authorize_for_provider(provider, provider.import_permission, credentials, x_api_key) if body.folder_id is not None: folder_q = await db.execute(select(LibraryFolder).where(LibraryFolder.id == body.folder_id)) target_folder = folder_q.scalar_one_or_none() if target_folder is None: raise HTTPException(status_code=404, detail="Folder not found") if target_folder.is_external and target_folder.external_readonly: raise HTTPException( status_code=403, detail="Cannot import into a read-only external folder", ) effective_folder_id: int | None = body.folder_id else: # Default destination: the resolved provider's dedicated top-level # folder (``default_folder_name`` — read off *provider*, not the # MakerWorld singleton, so the second provider lands in its own # folder). Keeps imports out of the library root so power users can # still organise manually in subfolders, and auto-creates the folder # on the first import so users don't have to set it up themselves. A # provider that leaves it unset imports into the library root rather # than minting a NULL-named folder. default_folder_name = provider.default_folder_name if default_folder_name is None: effective_folder_id = None else: default_folder_q = await db.execute( select(LibraryFolder).where( LibraryFolder.name == default_folder_name, LibraryFolder.parent_id.is_(None), LibraryFolder.is_external.is_(False), ) ) default_folder = default_folder_q.scalar_one_or_none() if default_folder is None: default_folder = LibraryFolder(name=default_folder_name, parent_id=None) db.add(default_folder) await db.flush() effective_folder_id = default_folder.id service = await _build_service(db, provider, current_user, api_key_cloud_owner) # YASTL#51's iot-service endpoint needs the *alphanumeric* modelId # (e.g. "US2bb73b106683e5"), not the integer design id from /models/{N} — # resolving that, plus picking a default profile when the frontend didn't # specify one, lives inside ``get_download``. The route only orchestrates # dedupe + persistence so every provider shares those concerns here. ref = ProviderResourceRef( source_type=provider.source_type, external_id=str(body.model_id), sub_id=str(body.profile_id) if body.profile_id else None, ) try: info = await service.get_download(ref) # The provider enriches ``sub_id`` with the actually-resolved profile # when the caller omitted one. resolved_profile_id = int(info.ref.sub_id) if info.ref.sub_id else None # Canonical URL includes profile_id so each plate gets its own library # entry (see ``ModelProvider.canonical_url``). source_url = provider.canonical_url(info.ref) # Dedupe check upfront so we don't burn bandwidth re-downloading. existing_q = await db.execute(LibraryFile.active().where(LibraryFile.source_url == source_url).limit(1)) existing_row = existing_q.scalar_one_or_none() if existing_row is not None: return MakerWorldImportResponse( library_file_id=existing_row.id, filename=existing_row.filename, folder_id=existing_row.folder_id, profile_id=resolved_profile_id, was_existing=True, ) download = await service.download(info) except ProviderError as exc: raise _map_service_error(exc) from exc finally: await service.close() # Basename-strip any path components from the upstream filename so a # malicious response (``name: "../../evil.3mf"``) can't persist a suspect # string into the library row or the UI. On-disk storage uses a UUID # filename regardless (see library.py), so this is defence-in-depth. raw_name = info.suggested_filename if isinstance(raw_name, str) and raw_name.strip(): # MakerWorld emits percent-encoded names (`%20` for spaces, etc.) # because the same string round-trips through HTTP URLs in the # CDN download path. Decode before persisting so the library # row, the slice toast, and every later UI surface show the # human-readable form. suggested_name = os.path.basename(unquote(raw_name.strip())) or f"makerworld-{body.model_id}.3mf" else: suggested_name = f"makerworld-{body.model_id}.3mf" # Prefer the server-provided human-readable filename; the signed URL's # path ends in a UUID that's not meaningful to users. Decode the # fallback path-tail too — same percent-encoding round-trip applies # there as on the manifest-supplied name. filename = suggested_name if suggested_name.endswith(".3mf") else unquote(download.filename) # API-keyed callers carry identity on the key, not in current_user (#1777); # this collapse stays route-side solely so the library row is attributed # to the key's owner rather than NULL. Credential identity is resolved # inside the provider. cloud_token_user = current_user or api_key_cloud_owner library_file, was_existing = await save_3mf_bytes_to_library( db, file_bytes=download.file_bytes, filename=filename, folder_id=effective_folder_id, source_type=provider.source_type, source_url=source_url, owner_id=cloud_token_user.id if cloud_token_user else None, ) return MakerWorldImportResponse( library_file_id=library_file.id, filename=library_file.filename, folder_id=library_file.folder_id, profile_id=resolved_profile_id, was_existing=was_existing, ) @router.get("/recent-imports", response_model=list[MakerWorldRecentImport]) async def recent_imports( limit: int = 10, db: AsyncSession = Depends(get_db), current_user: User | None = RequirePermissionIfAuthEnabled(makerworld_provider.view_permission), ): """Last N MakerWorld imports, newest first. Surfaces files whose ``source_type`` is ``"makerworld"`` so the MakerWorld page can show a 'Recent imports' sidebar that persists across resolves. Widening this to all registered providers is a behaviour change that belongs with the provider that needs it. ``limit`` is clamped to ``[1, 50]`` to keep payloads sensible. """ _ = current_user # permission gate only capped = max(1, min(50, int(limit))) result = await db.execute( LibraryFile.active() .where(LibraryFile.source_type == makerworld_provider.source_type) .order_by(LibraryFile.created_at.desc()) .limit(capped) ) rows = result.scalars().all() return [ MakerWorldRecentImport( library_file_id=row.id, filename=row.filename, folder_id=row.folder_id, thumbnail_path=row.thumbnail_path, source_url=row.source_url, created_at=row.created_at.isoformat() if row.created_at else "", ) for row in rows ]