Browse Source

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.
MartinNYHC 1 month ago
parent
commit
5da403ba0c

+ 7 - 0
CHANGELOG.md

@@ -4,6 +4,13 @@ All notable changes to Bambuddy will be documented in this file.
 
 ## [0.2.4b1] - Unreleased
 
+### Added
+- **MakerWorld Integration** — Paste any `makerworld.com/models/…` URL on the new MakerWorld sidebar page to pull the full model metadata, plate list, creator/license info, and per-plate images, then one-click **Save** or **Save & Slice in Bambu Studio / OrcaSlicer** per plate. Closes the last workflow gap for LAN-only users who still had to keep the Bambu Handy app installed solely to send MakerWorld models to their printers. Reuses the existing Bambu Cloud login token for download authentication — no separate OAuth flow, no companion browser extension, no cookie paste. `LibraryFile` now tracks `source_type` + `source_url`, so re-importing the same plate dedupes to the existing library entry. Search / browse-catalogue is intentionally out of scope because MakerWorld's public search endpoint isn't reachable from a server-originated request; the URL-paste flow covers the actual discovery pattern (Reddit / YouTube / shared links).
+  **Endpoint route (non-obvious, ~1 day of reverse engineering)** — Pr0zak/YASTL#51 documented that `makerworld.com`-hosted design-service endpoints are cookie-gated (Cloudflare WAF serves a generic "Please log in to download models" to any non-browser bearer request), but the same backend is exposed unblocked at `api.bambulab.com`. The working path turned out to be `GET https://api.bambulab.com/v1/iot-service/api/user/profile/{profileId}?model_id={alphanumericModelId}` with `Authorization: Bearer <cloud_token>` — a different service (`iot-service`, not `design-service`) and a different host, accepting the same bearer the user already signs in with. Response carries a 5-minute-TTL presigned S3 URL (``s3.us-west-2.amazonaws.com/…?at=…&exp=…&key=…``). The `modelId` query param is the alphanumeric identifier (e.g. ``US2bb73b106683e5``) that only appears in the design response body, *not* the integer ``designId`` from the ``/models/{N}`` URL — so the import flow fetches design metadata first, reads `modelId`, then calls iot-service. S3 presigned URLs must be fetched with ``urllib.request`` (not httpx / curl_cffi) because the signature is computed over the exact query-string bytes and any normalising encoder breaks it with ``SignatureDoesNotMatch`` 400s (YASTL#52 describes the same issue). Every other published reverse-engineering project we evaluated (schwarztim/bambu-mcp, kata-kas/MMP) solved the gating by shipping "paste your browser cookie" flows; reusing the existing Bambu Cloud bearer is a substantially cleaner UX and the only fully-automated path.
+  **UI and UX features** — per-plate picker with inline **Save** / **Save & Slice in Bambu Studio / OrcaSlicer** buttons, **Import all** to batch-import every plate sequentially, folder picker on the page (default: auto-created top-level "MakerWorld" folder), image gallery lightbox per plate (keyboard ←/→/Esc), two-column sticky layout with Recent imports sidebar (last 10 MakerWorld imports), per-plate inline follow-up actions after import (**View in File Manager** / **Open in Bambu Studio** / **Open in OrcaSlicer** / **Remove from library**), per-plate delete via the standard Bambuddy confirm modal (no browser `confirm()`), elapsed-time + phase label ("Resolving … 3 s", "Downloading … 18 s") during the synchronous import POST so users see progress on large 3MFs, URL-change detection that drops the preview when the pasted URL diverges from the resolved one (fixes a class of "I thought I was importing model B but got A" dedupe confusion), rich error toasts per-phase, and the slicer-open path reuses Bambuddy's existing token-embedded library download (`/library/files/{id}/dl/{token}/{filename}`) so the handoff works even with auth enabled. Localised across all eight UI languages.
+  **Security hardening** — the MakerWorld description HTML is user-authored and goes through `DOMPurify.sanitize()` before `dangerouslySetInnerHTML`. `<img>` tags inside summaries are rewritten to route through Bambuddy's ``/makerworld/thumbnail`` proxy so the SPA's ``img-src 'self' data: blob:`` CSP stays unwidened. Thumbnail proxy now uses ``follow_redirects=False`` (the host-allowlist guarantee is only meaningful on the initial URL — a 302 to `169.254.169.254` would otherwise bypass it). The 3MF CDN fetch sends only `User-Agent` — the Bambu Cloud bearer is never forwarded to the CDN. S3 presigned-URL fetch uses a `urllib.request` opener with a no-op ``HTTPRedirectHandler`` for the same reason. Filenames from MakerWorld responses are `os.path.basename`'d before persisting, so a malicious ``name: "../../evil.3mf"`` cannot surface a path-traversal string into the DB / UI (on-disk storage uses a UUID filename regardless). New routes respect the `MAKERWORLD_VIEW` (resolve / recent-imports / status) and `MAKERWORLD_IMPORT` (import) permissions. SSRF guard on downloads rejects any host that isn't `makerworld.bblmw.com`, `public-cdn.bblmw.com`, or a `.amazonaws.com` subdomain.
+  **Test coverage** — 46 unit tests for `services/makerworld.py` (header shape, API base, `get_design`/`get_design_instances`/`get_profile`, `get_profile_download` 200/401/403/404/no-token, `download_3mf` SSRF rejection of 4 hostile hosts, S3 path delegation, CDN path with minimal headers, size-cap, `_download_s3_urllib` happy/redirect/size/network paths, `fetch_thumbnail` with `follow_redirects=False`); 19 route tests (`/resolve`, `/import` with folder autocreation + explicit folder + dedupe + filename basename + profile_id response, `/recent-imports` with empty-list / ordering / pydantic shape / limit clamping, `_canonical_url` unit); 12 frontend tests (button labels, slicer-name interpolation, URL-change detection, inline post-import actions, Recent imports rendering, DOMPurify `<script>` strip).
+
 ### Changed
 - **Settings page: permission-gated instead of admin-only** — the Settings sidebar entry has always been visible to any user holding `settings:read`, but the route guard required admin role, so a non-admin with `settings:read` would see the entry, click it, and get silently redirected back to the dashboard. The route guard now matches the sidebar: any user with `settings:read` can open the page, and the individual tabs / cards continue to enforce their own per-feature permissions (`users:read`, `groups:update`, `oidc:*`, etc. — many of them admin-only, some not). Group editor routes moved to permission-based guards too (`groups:create` for `/groups/new`, `groups:update` for `/groups/:id/edit`), so permission delegation works end-to-end. Admins retain full access since admins implicitly hold every permission.
 

+ 10 - 0
README.md

@@ -168,6 +168,16 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - Duplicate detection via file hash
 - Mobile-friendly with always-visible action buttons
 
+### 🌍 MakerWorld Integration
+- Paste any `makerworld.com/models/…` URL → preview, plate picker, and import without leaving Bambuddy
+- Per-plate **Save** or **Save & Slice in Bambu Studio / OrcaSlicer** (your preferred slicer from Settings)
+- **Import all plates** button for multi-plate models
+- Auto-creates a "MakerWorld" folder in File Manager; override with any existing folder via the picker
+- Per-plate image gallery with keyboard-navigable lightbox
+- Recent imports sidebar — last 10 MakerWorld imports with one-click jump to File Manager or slicer
+- Remove-from-library for imported plates with confirm modal (no LAN cookie paste, no browser extension)
+- Reuses your existing Bambu Cloud login — no separate OAuth flow or browser extension to install
+
 ### 📁 Projects
 - Group related prints (e.g., "Voron Build")
 - Track plates (print jobs) and parts separately

+ 103 - 0
backend/app/api/routes/library.py

@@ -129,6 +129,109 @@ def calculate_file_hash(file_path: Path) -> str:
     return sha256_hash.hexdigest()
 
 
+def _clean_3mf_metadata(obj):
+    """Strip bytes and thumbnail-carrier keys so the payload is JSON-storable.
+
+    Shared by ``upload_file`` and :func:`save_3mf_bytes_to_library` — the
+    ``ThreeMFParser`` output embeds the thumbnail bytes under
+    ``_thumbnail_data``/``_thumbnail_ext`` and may also include raw bytes in
+    other fields, none of which can be JSON-encoded.
+    """
+    if isinstance(obj, dict):
+        return {
+            k: _clean_3mf_metadata(v)
+            for k, v in obj.items()
+            if not isinstance(v, bytes) and k not in ("_thumbnail_data", "_thumbnail_ext")
+        }
+    if isinstance(obj, list):
+        return [_clean_3mf_metadata(i) for i in obj if not isinstance(i, bytes)]
+    if isinstance(obj, bytes):
+        return None
+    return obj
+
+
+async def save_3mf_bytes_to_library(
+    db: AsyncSession,
+    *,
+    file_bytes: bytes,
+    filename: str,
+    folder_id: int | None = None,
+    source_type: str | None = None,
+    source_url: str | None = None,
+    owner_id: int | None = None,
+) -> tuple[LibraryFile, bool]:
+    """Save a 3MF blob into the library and return ``(library_file, was_existing)``.
+
+    Used by routes that receive a 3MF in-process rather than as a multipart
+    upload (currently: MakerWorld import; reusable for any future source that
+    fetches bytes server-side). Deduplicates by ``source_url`` when provided —
+    if a LibraryFile with the same source_url already exists, the existing
+    row is returned and the bytes are NOT re-saved (MakerWorld signed URLs
+    change each download, so hash-based dedupe alone would miss re-imports).
+
+    Parses 3MF metadata + thumbnail the same way the multipart upload route
+    does, via :class:`ThreeMFParser`. Paths are stored as relative so the
+    library is portable across installs.
+    """
+    # Source-URL-based dedupe: return the existing row untouched.
+    if source_url:
+        existing = await db.execute(select(LibraryFile).where(LibraryFile.source_url == source_url).limit(1))
+        existing_row = existing.scalar_one_or_none()
+        if existing_row is not None:
+            return existing_row, True
+
+    # Persist bytes to disk under a UUID-scoped filename; keep the original
+    # extension so downstream logic (ThreeMFParser, thumbnail viewer) works.
+    ext = os.path.splitext(filename)[1].lower() or ".3mf"
+    unique_filename = f"{uuid.uuid4().hex}{ext}"
+    file_path = get_library_files_dir() / unique_filename
+    with open(file_path, "wb") as fh:
+        fh.write(file_bytes)
+
+    file_hash = calculate_file_hash(file_path)
+
+    # Extract metadata + thumbnail from the 3MF.
+    metadata: dict | None = None
+    thumbnail_path: str | None = None
+    if ext == ".3mf":
+        try:
+            parser = ThreeMFParser(str(file_path))
+            raw_metadata = parser.parse()
+            thumb_data = raw_metadata.get("_thumbnail_data")
+            thumb_ext = raw_metadata.get("_thumbnail_ext", ".png")
+            if thumb_data:
+                thumbs_dir = get_library_thumbnails_dir()
+                thumb_filename = f"{uuid.uuid4().hex}{thumb_ext}"
+                thumb_path = thumbs_dir / thumb_filename
+                with open(thumb_path, "wb") as fh:
+                    fh.write(thumb_data)
+                thumbnail_path = str(thumb_path)
+            metadata = _clean_3mf_metadata(raw_metadata) or None
+        except Exception as exc:
+            # Matches the multipart upload route's behaviour — a bad 3MF should
+            # still land in the library so the user can see / delete it rather
+            # than failing the whole request.
+            logger.warning("Failed to parse 3MF %s: %s", filename, exc)
+
+    library_file = LibraryFile(
+        folder_id=folder_id,
+        filename=filename,
+        file_path=to_relative_path(file_path),
+        file_type=ext[1:] if ext else "unknown",
+        file_size=len(file_bytes),
+        file_hash=file_hash,
+        thumbnail_path=to_relative_path(thumbnail_path) if thumbnail_path else None,
+        file_metadata=metadata,
+        source_type=source_type,
+        source_url=source_url,
+        created_by_id=owner_id,
+    )
+    db.add(library_file)
+    await db.commit()
+    await db.refresh(library_file)
+    return library_file, False
+
+
 def extract_gcode_thumbnail(file_path: Path) -> bytes | None:
     """Extract embedded thumbnail from gcode file.
 

+ 394 - 0
backend/app/api/routes/makerworld.py

@@ -0,0 +1,394 @@
+"""MakerWorld integration routes.
+
+User pastes a MakerWorld URL → 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
+MakerWorld'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).
+"""
+
+from __future__ import annotations
+
+import logging
+import os
+
+from fastapi import APIRouter, Depends, HTTPException, Query
+from fastapi.responses import Response
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.api.routes.cloud import get_stored_token
+from backend.app.api.routes.library import save_3mf_bytes_to_library
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
+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.makerworld import (
+    MakerWorldAuthError,
+    MakerWorldError,
+    MakerWorldForbiddenError,
+    MakerWorldNotFoundError,
+    MakerWorldService,
+    MakerWorldUnavailableError,
+    MakerWorldUrlError,
+)
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/makerworld", tags=["makerworld"])
+
+_SOURCE_TYPE = "makerworld"
+
+
+async def _build_service(db: AsyncSession, user: User | None) -> MakerWorldService:
+    """Construct a per-request MakerWorldService seeded with the caller's
+    stored Bambu Cloud bearer token when available.
+
+    Mirrors ``cloud.build_authenticated_cloud`` — the token is entirely
+    optional; anonymous calls (metadata, URL resolution) still work.
+    """
+    token, _email, _region = await get_stored_token(db, user)
+    return MakerWorldService(auth_token=token)
+
+
+def _canonical_url(model_id: int, profile_id: int | None = None) -> str:
+    """Build a stable source_url we use for dedupe.
+
+    Dedupe is keyed per *plate* (profile) rather than per model, since the
+    ``/iot-service/.../profile/{profileId}`` download returns a specific
+    plate — not the full multi-plate zip — so two different plates of the
+    same design should become two separate library entries. Canonical
+    shape uses the locale-free path with the ``#profileId-`` fragment so
+    all URL variants of the same plate still collapse (e.g. ``/en/models/
+    123-slug?from=search#profileId-456`` and ``/de/models/123#profileId-
+    456`` both map to ``https://makerworld.com/models/123#profileId-
+    456``). Plate-less imports (legacy or whole-design) keep the old
+    model-only shape for backwards compatibility with existing rows.
+    """
+    if profile_id:
+        return f"https://makerworld.com/models/{model_id}#profileId-{profile_id}"
+    return f"https://makerworld.com/models/{model_id}"
+
+
+def _map_service_error(exc: MakerWorldError) -> HTTPException:
+    """Translate service exceptions into HTTP responses."""
+    if isinstance(exc, MakerWorldUrlError):
+        return HTTPException(status_code=400, detail=str(exc))
+    if isinstance(exc, MakerWorldAuthError):
+        return HTTPException(status_code=401, detail=str(exc))
+    if isinstance(exc, MakerWorldForbiddenError):
+        # 403 forwards MakerWorld'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, MakerWorldNotFoundError):
+        return HTTPException(status_code=404, detail=str(exc))
+    if isinstance(exc, MakerWorldUnavailableError):
+        return HTTPException(status_code=502, detail=str(exc))
+    return HTTPException(status_code=500, detail=f"MakerWorld 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**: ``<img>`` 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()
+    try:
+        payload, content_type = await service.fetch_thumbnail(url)
+    except MakerWorldError 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(Permission.MAKERWORLD_VIEW),
+):
+    """Report whether the caller can import 3MFs (needs a Bambu Cloud token)."""
+    token, _email, _region = await get_stored_token(db, current_user)
+    has_token = bool(token)
+    return MakerWorldStatus(has_cloud_token=has_token, can_download=has_token)
+
+
+@router.post("/resolve", response_model=MakerWorldResolvedModel)
+async def resolve_url(
+    body: MakerWorldResolveRequest,
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.MAKERWORLD_VIEW),
+):
+    """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.
+    """
+    try:
+        model_id, profile_id = MakerWorldService.parse_url(body.url)
+    except MakerWorldError as exc:
+        raise _map_service_error(exc) from exc
+
+    service = await _build_service(db, current_user)
+    try:
+        design = await service.get_design(model_id)
+        instances_envelope = await service.get_design_instances(model_id)
+    except MakerWorldError as exc:
+        raise _map_service_error(exc) from exc
+    finally:
+        await service.close()
+
+    # MakerWorld's instances payload is ``{"total": N, "hits": [...]}``; callers
+    # only care about the hits, and we 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 = []
+
+    # Find every library row whose source_url is either the model-level
+    # canonical URL (legacy whole-model imports) or any plate-level URL
+    # (``...#profileId-{n}``) under this model. The frontend surfaces this
+    # to mark imported plates in the instance picker.
+    model_prefix = _canonical_url(model_id)
+    existing_q = await db.execute(
+        select(LibraryFile.id).where(
+            (LibraryFile.source_url == model_prefix) | (LibraryFile.source_url.like(f"{model_prefix}#profileId-%"))
+        )
+    )
+    already_imported = [row[0] for row in existing_q.all()]
+
+    return MakerWorldResolvedModel(
+        model_id=model_id,
+        profile_id=profile_id,
+        design=design,
+        instances=instances,
+        already_imported_library_ids=already_imported,
+    )
+
+
+@router.post("/import", response_model=MakerWorldImportResponse)
+async def import_instance(
+    body: MakerWorldImportRequest,
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.MAKERWORLD_IMPORT),
+):
+    """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.
+    """
+    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: a dedicated top-level "MakerWorld" 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.
+        mw_folder_q = await db.execute(
+            select(LibraryFolder).where(
+                LibraryFolder.name == "MakerWorld",
+                LibraryFolder.parent_id.is_(None),
+                LibraryFolder.is_external.is_(False),
+            )
+        )
+        mw_folder = mw_folder_q.scalar_one_or_none()
+        if mw_folder is None:
+            mw_folder = LibraryFolder(name="MakerWorld", parent_id=None)
+            db.add(mw_folder)
+            await db.flush()
+        effective_folder_id = mw_folder.id
+
+    service = await _build_service(db, current_user)
+
+    # YASTL#51's iot-service endpoint needs the *alphanumeric* modelId
+    # (e.g. "US2bb73b106683e5"), not the integer design id from /models/{N}.
+    # Fetch design metadata to resolve it, and — in the same call — pick a
+    # default profileId from the response if the frontend didn't specify one.
+    try:
+        design = await service.get_design(body.model_id)
+    except MakerWorldError as exc:
+        await service.close()
+        raise _map_service_error(exc) from exc
+
+    alphanumeric_model_id = design.get("modelId")
+    if not isinstance(alphanumeric_model_id, str) or not alphanumeric_model_id:
+        await service.close()
+        raise HTTPException(
+            status_code=502,
+            detail="MakerWorld design metadata missing the modelId field",
+        )
+
+    profile_id = body.profile_id
+    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:
+            try:
+                envelope = await service.get_design_instances(body.model_id)
+            except MakerWorldError as exc:
+                await service.close()
+                raise _map_service_error(exc) from exc
+            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:
+            await service.close()
+            raise HTTPException(
+                status_code=502,
+                detail="MakerWorld returned no instances for this model",
+            )
+
+    # Canonical URL includes profile_id so each plate gets its own library
+    # entry (see ``_canonical_url`` docstring).
+    source_url = _canonical_url(body.model_id, profile_id)
+
+    try:
+        manifest = await service.get_profile_download(profile_id, alphanumeric_model_id)
+    except MakerWorldError as exc:
+        await service.close()
+        raise _map_service_error(exc) from exc
+
+    signed_url = manifest.get("url")
+    # 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 = manifest.get("name")
+    if isinstance(raw_name, str) and raw_name.strip():
+        suggested_name = os.path.basename(raw_name.strip()) or f"makerworld-{body.model_id}.3mf"
+    else:
+        suggested_name = f"makerworld-{body.model_id}.3mf"
+    if not signed_url or not isinstance(signed_url, str):
+        await service.close()
+        raise HTTPException(status_code=502, detail="MakerWorld did not return a download URL")
+
+    # Dedupe check upfront so we don't burn bandwidth re-downloading.
+    if source_url:
+        existing_q = await db.execute(select(LibraryFile).where(LibraryFile.source_url == source_url).limit(1))
+        existing_row = existing_q.scalar_one_or_none()
+        if existing_row is not None:
+            await service.close()
+            return MakerWorldImportResponse(
+                library_file_id=existing_row.id,
+                filename=existing_row.filename,
+                folder_id=existing_row.folder_id,
+                profile_id=profile_id,
+                was_existing=True,
+            )
+
+    try:
+        file_bytes, download_filename = await service.download_3mf(signed_url)
+    except MakerWorldError as exc:
+        await service.close()
+        raise _map_service_error(exc) from exc
+    finally:
+        await service.close()
+
+    # Prefer the server-provided human-readable filename; the signed URL's
+    # path ends in a UUID that's not meaningful to users.
+    filename = suggested_name if suggested_name.endswith(".3mf") else download_filename
+
+    library_file, was_existing = await save_3mf_bytes_to_library(
+        db,
+        file_bytes=file_bytes,
+        filename=filename,
+        folder_id=effective_folder_id,
+        source_type=_SOURCE_TYPE,
+        source_url=source_url,
+        owner_id=current_user.id if current_user else None,
+    )
+
+    return MakerWorldImportResponse(
+        library_file_id=library_file.id,
+        filename=library_file.filename,
+        folder_id=library_file.folder_id,
+        profile_id=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(Permission.MAKERWORLD_VIEW),
+):
+    """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.
+    ``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(
+        select(LibraryFile)
+        .where(LibraryFile.source_type == _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
+    ]

+ 36 - 0
backend/app/core/database.py

@@ -1495,6 +1495,16 @@ async def run_migrations(conn):
         "UPDATE users SET password_changed_at = created_at WHERE password_changed_at IS NULL",
     )
 
+    # Migration: Provenance columns on library_files for MakerWorld imports.
+    # source_url is indexed so "already imported" dedupe lookups stay O(log N)
+    # as the library grows.
+    await _safe_execute(conn, "ALTER TABLE library_files ADD COLUMN source_type VARCHAR(32)")
+    await _safe_execute(conn, "ALTER TABLE library_files ADD COLUMN source_url VARCHAR(512)")
+    await _safe_execute(
+        conn,
+        "CREATE INDEX IF NOT EXISTS ix_library_files_source_url ON library_files(source_url)",
+    )
+
     # Seed default settings keys that must exist on fresh install
     default_settings = [
         ("advanced_auth_enabled", "false"),
@@ -1670,6 +1680,32 @@ async def seed_default_groups():
                 logger.info("Added printers:clear_plate to group '%s' (has printers:control)", group.name)
         await session.commit()
 
+        # Migrate new permissions for MakerWorld integration: groups that
+        # already have library:upload (i.e. can write to the library) are
+        # the correct audience for makerworld:view + makerworld:import, and
+        # groups that only have library:read get makerworld:view (browse
+        # only). Matches the intent of DEFAULT_GROUPS without clobbering
+        # any user-customised permission lists.
+        result = await session.execute(select(Group))
+        for group in result.scalars().all():
+            if not group.permissions:
+                continue
+            perms = list(group.permissions)
+            changed = False
+            if "library:upload" in perms:
+                for new_perm in ("makerworld:view", "makerworld:import"):
+                    if new_perm not in perms:
+                        perms.append(new_perm)
+                        changed = True
+                        logger.info("Added %s to group '%s' (has library:upload)", new_perm, group.name)
+            elif "library:read" in perms and "makerworld:view" not in perms:
+                perms.append("makerworld:view")
+                changed = True
+                logger.info("Added makerworld:view to group '%s' (has library:read)", group.name)
+            if changed:
+                group.permissions = perms
+        await session.commit()
+
         # Migrate existing users to groups if they're not already in any group
         if groups_created:
             # Refresh to get newly created groups

+ 13 - 0
backend/app/core/permissions.py

@@ -138,6 +138,10 @@ class Permission(StrEnum):
     # Cloud Auth (admin-level)
     CLOUD_AUTH = "cloud:auth"
 
+    # MakerWorld Integration
+    MAKERWORLD_VIEW = "makerworld:view"  # Resolve MakerWorld URLs and view model metadata
+    MAKERWORLD_IMPORT = "makerworld:import"  # Download 3MFs from MakerWorld into the library
+
     # API Keys (admin-level)
     API_KEYS_READ = "api_keys:read"
     API_KEYS_CREATE = "api_keys:create"
@@ -283,6 +287,10 @@ PERMISSION_CATEGORIES = {
     "Cloud": [
         Permission.CLOUD_AUTH,
     ],
+    "MakerWorld": [
+        Permission.MAKERWORLD_VIEW,
+        Permission.MAKERWORLD_IMPORT,
+    ],
     "API Keys": [
         Permission.API_KEYS_READ,
         Permission.API_KEYS_CREATE,
@@ -345,6 +353,9 @@ DEFAULT_GROUPS = {
             Permission.LIBRARY_UPLOAD.value,
             Permission.LIBRARY_UPDATE_OWN.value,
             Permission.LIBRARY_DELETE_OWN.value,
+            # MakerWorld integration
+            Permission.MAKERWORLD_VIEW.value,
+            Permission.MAKERWORLD_IMPORT.value,
             # Projects - full access
             Permission.PROJECTS_READ.value,
             Permission.PROJECTS_CREATE.value,
@@ -432,6 +443,8 @@ DEFAULT_GROUPS = {
             Permission.SYSTEM_READ.value,
             Permission.SETTINGS_READ.value,
             Permission.WEBSOCKET_CONNECT.value,
+            # MakerWorld browsing only (no import — that writes to library)
+            Permission.MAKERWORLD_VIEW.value,
         ],
         "is_system": True,
     },

+ 9 - 0
backend/app/main.py

@@ -33,6 +33,7 @@ from backend.app.api.routes import (
     local_backup,
     local_presets,
     maintenance,
+    makerworld,
     metrics,
     mfa,
     notification_templates,
@@ -3998,9 +3999,15 @@ async def lifespan(app: FastAPI):
     import httpx as _httpx
 
     from backend.app.services.bambu_cloud import set_shared_http_client
+    from backend.app.services.makerworld import (
+        set_shared_http_client as set_shared_makerworld_http_client,
+    )
 
     _shared_cloud_http_client = _httpx.AsyncClient(timeout=30.0)
     set_shared_http_client(_shared_cloud_http_client)
+    # Reuse the same connection pool for MakerWorld — different host, same
+    # keep-alive pool saves a TLS handshake per request.
+    set_shared_makerworld_http_client(_shared_cloud_http_client)
 
     # Fix queue items stuck with invalid "aborted" status (should be "cancelled").
     # This can happen when a print was cancelled mid-print on versions before this fix.
@@ -4234,6 +4241,7 @@ async def lifespan(app: FastAPI):
 
     # Drop the shared Bambu Cloud HTTP client we registered at startup.
     set_shared_http_client(None)
+    set_shared_makerworld_http_client(None)
     await _shared_cloud_http_client.aclose()
 
     # Checkpoint WAL (SQLite only) and close all database connections
@@ -4518,6 +4526,7 @@ app.include_router(camera.router, prefix=app_settings.api_prefix)
 app.include_router(external_links.router, prefix=app_settings.api_prefix)
 app.include_router(projects.router, prefix=app_settings.api_prefix)
 app.include_router(library.router, prefix=app_settings.api_prefix)
+app.include_router(makerworld.router, prefix=app_settings.api_prefix)
 app.include_router(api_keys.router, prefix=app_settings.api_prefix)
 app.include_router(webhook.router, prefix=app_settings.api_prefix)
 app.include_router(ams_history.router, prefix=app_settings.api_prefix)

+ 8 - 0
backend/app/models/library.py

@@ -82,6 +82,14 @@ class LibraryFile(Base):
     # User notes
     notes: Mapped[str | None] = mapped_column(Text, nullable=True)
 
+    # Provenance — when the file was imported from an external source (e.g.
+    # MakerWorld), ``source_type`` identifies the source and ``source_url`` is
+    # the canonical public URL. Used for "already imported" detection and
+    # "re-open on MakerWorld" affordances. Index on source_url so the
+    # dedupe lookup is O(log N).
+    source_type: Mapped[str | None] = mapped_column(String(32), nullable=True)
+    source_url: Mapped[str | None] = mapped_column(String(512), nullable=True, index=True)
+
     # User tracking (Issue #206)
     created_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
 

+ 111 - 0
backend/app/schemas/makerworld.py

@@ -0,0 +1,111 @@
+"""Pydantic schemas for the MakerWorld integration routes."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from pydantic import BaseModel, Field
+
+
+class MakerWorldResolveRequest(BaseModel):
+    """Body for POST /makerworld/resolve."""
+
+    url: str = Field(..., description="Any MakerWorld model URL (scheme optional)")
+
+
+class MakerWorldResolvedModel(BaseModel):
+    """Structured result of URL resolution.
+
+    ``design`` and ``instances`` are passed through verbatim from MakerWorld's
+    API — we don't re-shape them because the frontend needs access to fields
+    MakerWorld may add over time (badges, license variants, etc.). Keeping
+    them as opaque dicts avoids brittle coupling.
+    """
+
+    model_id: int
+    profile_id: int | None = Field(
+        default=None,
+        description="Specific profile from the URL's #profileId- fragment, if any",
+    )
+    design: dict[str, Any]
+    instances: list[dict[str, Any]]
+    already_imported_library_ids: list[int] = Field(
+        default_factory=list,
+        description="LibraryFile IDs that were previously imported from this model URL",
+    )
+
+
+class MakerWorldImportRequest(BaseModel):
+    """Body for POST /makerworld/import."""
+
+    model_id: int = Field(
+        ...,
+        description="The MakerWorld design ID (the number in /models/{id}).",
+    )
+    profile_id: int | None = Field(
+        default=None,
+        description=(
+            "The profileId of the selected instance (plate configuration). Each "
+            "instance in `/design/{id}/instances` carries a `profileId` field — "
+            "the frontend forwards the picked one here. If omitted, the backend "
+            "falls back to the first available instance of the model."
+        ),
+    )
+    instance_id: int | None = Field(
+        default=None,
+        description="Retained for backwards compatibility; no longer used by the download flow.",
+    )
+    folder_id: int | None = Field(default=None, description="Target library folder; null = root")
+
+
+class MakerWorldRecentImport(BaseModel):
+    """One row in the 'recent MakerWorld imports' list."""
+
+    library_file_id: int
+    filename: str
+    folder_id: int | None
+    thumbnail_path: str | None = Field(
+        default=None,
+        description="Relative path under /api/v1/library/files/{id}/thumbnail — "
+        "the frontend wraps it with a stream token to render.",
+    )
+    source_url: str | None = Field(
+        default=None,
+        description="Canonical MakerWorld URL (``https://makerworld.com/models/{id}"
+        "#profileId-{pid}``). The frontend uses it to build an 'Open on MakerWorld' "
+        "link and to extract model/profile ids without a second API round-trip.",
+    )
+    created_at: str
+
+
+class MakerWorldImportResponse(BaseModel):
+    """Result of a MakerWorld import."""
+
+    library_file_id: int
+    filename: str
+    folder_id: int | None = Field(
+        default=None,
+        description=(
+            "Folder the file was saved to — the auto-created 'MakerWorld' folder "
+            "by default, or whichever folder the caller specified. Surfaced so the "
+            "frontend can deep-link to File Manager → that folder after import."
+        ),
+    )
+    profile_id: int | None = Field(
+        default=None,
+        description=(
+            "The MakerWorld profile (plate) id that was imported. Surfaced so the "
+            "frontend can match the response back to the plate row in the UI and "
+            "render inline 'view in library' / 'open in slicer' controls there."
+        ),
+    )
+    was_existing: bool = Field(
+        description="True if a prior import from the same source URL was reused (no re-download)"
+    )
+
+
+class MakerWorldStatus(BaseModel):
+    """Integration health + auth status surfaced to the frontend."""
+
+    has_cloud_token: bool = Field(description="Whether the caller's account has a stored Bambu Cloud token")
+    can_download: bool = Field(description="Shortcut: has_cloud_token AND it looks valid. Downloads require it.")

+ 555 - 0
backend/app/services/makerworld.py

@@ -0,0 +1,555 @@
+"""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

+ 2 - 1
backend/tests/integration/test_auth_api.py

@@ -98,7 +98,8 @@ class TestAuthSetupAPI:
 
         existing = User(
             username="existing_admin",
-            password_hash=get_password_hash("DoesNotMatter1!"),
+            # pragma: allowlist secret — test fixture only, not a real credential
+            password_hash=get_password_hash("DoesNotMatter1!"),  # noqa: S106
             role="admin",
             is_active=True,
         )

+ 625 - 0
backend/tests/unit/services/test_makerworld.py

@@ -0,0 +1,625 @@
+"""Tests for the MakerWorldService."""
+
+from __future__ import annotations
+
+from unittest.mock import AsyncMock, MagicMock, patch
+from urllib.error import HTTPError, URLError
+
+import httpx
+import pytest
+
+from backend.app.services.makerworld import (
+    _MAX_3MF_BYTES,
+    MAKERWORLD_API_BASE,
+    MakerWorldAuthError,
+    MakerWorldForbiddenError,
+    MakerWorldNotFoundError,
+    MakerWorldService,
+    MakerWorldUnavailableError,
+    MakerWorldUrlError,
+)
+
+
+class TestParseUrl:
+    """MakerWorld URL extraction."""
+
+    def test_strips_locale_prefix_and_slug(self):
+        model, profile = MakerWorldService.parse_url(
+            "https://makerworld.com/en/models/1400373-self-watering-seed-starter"
+        )
+        assert model == 1400373
+        assert profile is None
+
+    def test_extracts_profile_id_from_fragment(self):
+        model, profile = MakerWorldService.parse_url("https://makerworld.com/en/models/1400373-slug#profileId-1452154")
+        assert model == 1400373
+        assert profile == 1452154
+
+    def test_accepts_scheme_omitted(self):
+        model, profile = MakerWorldService.parse_url("makerworld.com/models/999")
+        assert model == 999
+        assert profile is None
+
+    def test_accepts_subdomain(self):
+        # Defensive: if MakerWorld ever stands up a regional subdomain, still accept it
+        model, _ = MakerWorldService.parse_url("https://www.makerworld.com/en/models/42")
+        assert model == 42
+
+    def test_rejects_non_makerworld_host(self):
+        with pytest.raises(MakerWorldUrlError):
+            MakerWorldService.parse_url("https://thingiverse.com/things/123")
+
+    def test_rejects_malformed_url(self):
+        # No /models/ segment anywhere in path
+        with pytest.raises(MakerWorldUrlError):
+            MakerWorldService.parse_url("https://makerworld.com/en/creators/foo")
+
+    def test_rejects_empty(self):
+        with pytest.raises(MakerWorldUrlError):
+            MakerWorldService.parse_url("")
+
+
+class TestApiBase:
+    """Sanity check on the module-level constant — changing it is a deploy-risk."""
+
+    def test_api_base_targets_bambulab_backend(self):
+        # ``api.bambulab.com`` is not Cloudflare-fronted; ``makerworld.com`` is
+        # and returns empty JSON to plain httpx. Regressing this constant
+        # silently breaks the whole integration.
+        assert MAKERWORLD_API_BASE == "https://api.bambulab.com/v1/design-service"
+
+
+class TestGetDesign:
+    """Metadata endpoint happy-path + error mapping."""
+
+    @pytest.fixture
+    def service(self):
+        # Use a MagicMock for the client so each call can be individually stubbed
+        svc = MakerWorldService(client=MagicMock(spec=httpx.AsyncClient))
+        svc._client.get = AsyncMock()
+        return svc
+
+    @pytest.mark.asyncio
+    async def test_returns_decoded_json(self, service):
+        resp = MagicMock()
+        resp.status_code = 200
+        resp.json.return_value = {"id": 1400373, "title": "Benchy"}
+        service._client.get.return_value = resp
+
+        data = await service.get_design(1400373)
+        assert data == {"id": 1400373, "title": "Benchy"}
+
+    @pytest.mark.asyncio
+    async def test_hits_bambulab_api_base(self, service):
+        resp = MagicMock()
+        resp.status_code = 200
+        resp.json.return_value = {"id": 1}
+        service._client.get.return_value = resp
+
+        await service.get_design(1)
+        call = service._client.get.call_args
+        # First positional arg is the URL — must be on the api.bambulab.com
+        # backend, not the Cloudflare-fronted makerworld.com host.
+        url = call.args[0] if call.args else call.kwargs.get("url")
+        assert url == "https://api.bambulab.com/v1/design-service/design/1"
+
+    @pytest.mark.asyncio
+    async def test_sends_browser_like_headers(self, service):
+        """Post-refactor the client uses a minimal Firefox-ish header set.
+        The old ``x-bbl-*`` Bambu-app identification headers are gone —
+        ``api.bambulab.com`` accepts browser-like headers cleanly."""
+        resp = MagicMock()
+        resp.status_code = 200
+        resp.json.return_value = {"id": 1}
+        service._client.get.return_value = resp
+
+        await service.get_design(1)
+        headers = service._client.get.call_args.kwargs["headers"]
+        assert "Firefox" in headers["User-Agent"]
+        assert headers["Accept-Language"].startswith("en-US")
+        assert headers["Referer"] == "https://makerworld.com/"
+        assert "Accept" in headers
+        # The deprecated Bambu-identification headers must no longer be sent.
+        for dead_header in (
+            "x-bbl-client-type",
+            "x-bbl-client-version",
+            "x-bbl-app-source",
+            "x-bbl-client-name",
+        ):
+            assert dead_header not in headers
+
+    @pytest.mark.asyncio
+    async def test_maps_404_to_not_found(self, service):
+        resp = MagicMock()
+        resp.status_code = 404
+        service._client.get.return_value = resp
+
+        with pytest.raises(MakerWorldNotFoundError):
+            await service.get_design(404)
+
+    @pytest.mark.asyncio
+    async def test_maps_401_to_auth_error(self, service):
+        resp = MagicMock()
+        resp.status_code = 401
+        resp.json.return_value = {"code": 1, "error": "Please log in"}
+        service._client.get.return_value = resp
+
+        with pytest.raises(MakerWorldAuthError) as exc_info:
+            await service.get_design(1)
+        # Upstream's own message is surfaced to the caller
+        assert "Please log in" in str(exc_info.value)
+
+    @pytest.mark.asyncio
+    async def test_maps_403_to_forbidden_with_upstream_reason(self, service):
+        """403 is distinct from 401: auth was valid, MakerWorld refuses the
+        specific resource (content-gated, region-locked, etc.). The upstream
+        reason must reach the user so they know what to do."""
+        resp = MagicMock()
+        resp.status_code = 403
+        resp.json.return_value = {
+            "code": 15001,
+            "error": "This model is only available to members",
+        }
+        service._client.get.return_value = resp
+
+        with pytest.raises(MakerWorldForbiddenError) as exc_info:
+            await service.get_design(1)
+        assert "members" in str(exc_info.value)
+
+    @pytest.mark.asyncio
+    async def test_maps_5xx_to_unavailable(self, service):
+        resp = MagicMock()
+        resp.status_code = 503
+        service._client.get.return_value = resp
+
+        with pytest.raises(MakerWorldUnavailableError):
+            await service.get_design(1)
+
+    @pytest.mark.asyncio
+    async def test_maps_timeout_to_unavailable(self, service):
+        service._client.get.side_effect = httpx.TimeoutException("tooo slow")
+
+        with pytest.raises(MakerWorldUnavailableError):
+            await service.get_design(1)
+
+    @pytest.mark.asyncio
+    async def test_rejects_non_dict_json(self, service):
+        resp = MagicMock()
+        resp.status_code = 200
+        resp.json.return_value = [1, 2, 3]  # list, not dict
+        service._client.get.return_value = resp
+
+        with pytest.raises(MakerWorldUnavailableError):
+            await service.get_design(1)
+
+
+class TestGetProfileDownload:
+    """The new auth-gated 3MF manifest endpoint on the Bambu iot-service.
+
+    Replaces the removed ``get_instance_download`` / ``get_model_download``
+    helpers — YASTL#51's endpoint mints the signed CDN URL from the same
+    long-lived Bambu Cloud bearer users already have.
+    """
+
+    def _make_service(self, *, auth_token: str | None = "tok-abc") -> MakerWorldService:
+        svc = MakerWorldService(client=MagicMock(spec=httpx.AsyncClient), auth_token=auth_token)
+        svc._client.get = AsyncMock()
+        return svc
+
+    @pytest.mark.asyncio
+    async def test_requires_auth_token(self):
+        svc = self._make_service(auth_token=None)
+        with pytest.raises(MakerWorldAuthError):
+            await svc.get_profile_download(1452154, "US2bb73b106683e5")
+
+    @pytest.mark.asyncio
+    async def test_returns_signed_manifest(self):
+        svc = self._make_service()
+        resp = MagicMock()
+        resp.status_code = 200
+        resp.json.return_value = {
+            "name": "benchy.3mf",
+            "url": "https://makerworld.bblmw.com/makerworld/model/X/Y/f.3mf?exp=1&key=k",
+        }
+        svc._client.get.return_value = resp
+
+        manifest = await svc.get_profile_download(1452154, "US2bb73b106683e5")
+        assert manifest["url"].startswith("https://makerworld.bblmw.com/")
+        assert manifest["name"] == "benchy.3mf"
+
+    @pytest.mark.asyncio
+    async def test_sends_bearer_and_model_id_query(self):
+        """Auth goes in ``Authorization`` and the alphanumeric modelId as a
+        ``model_id`` query param — this is what YASTL#51 reverse-engineered."""
+        svc = self._make_service(auth_token="tok-abc")
+        resp = MagicMock()
+        resp.status_code = 200
+        resp.json.return_value = {"url": "https://makerworld.bblmw.com/x.3mf"}
+        svc._client.get.return_value = resp
+
+        await svc.get_profile_download(1452154, "US2bb73b106683e5")
+        call = svc._client.get.call_args
+        url = call.args[0] if call.args else call.kwargs.get("url")
+        assert url == "https://api.bambulab.com/v1/iot-service/api/user/profile/1452154"
+        assert call.kwargs["headers"]["Authorization"] == "Bearer tok-abc"
+        assert call.kwargs["params"] == {"model_id": "US2bb73b106683e5"}
+
+    @pytest.mark.asyncio
+    async def test_maps_401_to_auth_error(self):
+        svc = self._make_service()
+        resp = MagicMock()
+        resp.status_code = 401
+        resp.json.return_value = {"error": "token expired"}
+        svc._client.get.return_value = resp
+
+        with pytest.raises(MakerWorldAuthError):
+            await svc.get_profile_download(1, "M1")
+
+    @pytest.mark.asyncio
+    async def test_maps_403_to_forbidden(self):
+        svc = self._make_service()
+        resp = MagicMock()
+        resp.status_code = 403
+        resp.json.return_value = {"error": "paid model"}
+        svc._client.get.return_value = resp
+
+        with pytest.raises(MakerWorldForbiddenError) as exc_info:
+            await svc.get_profile_download(1, "M1")
+        assert "paid model" in str(exc_info.value)
+
+    @pytest.mark.asyncio
+    async def test_maps_404_to_not_found(self):
+        svc = self._make_service()
+        resp = MagicMock()
+        resp.status_code = 404
+        svc._client.get.return_value = resp
+
+        with pytest.raises(MakerWorldNotFoundError):
+            await svc.get_profile_download(1, "M1")
+
+    @pytest.mark.asyncio
+    async def test_maps_timeout_to_unavailable(self):
+        svc = self._make_service()
+        svc._client.get.side_effect = httpx.TimeoutException("nope")
+
+        with pytest.raises(MakerWorldUnavailableError):
+            await svc.get_profile_download(1, "M1")
+
+    @pytest.mark.asyncio
+    async def test_rejects_non_dict_json(self):
+        svc = self._make_service()
+        resp = MagicMock()
+        resp.status_code = 200
+        resp.json.return_value = ["not", "a", "dict"]
+        svc._client.get.return_value = resp
+
+        with pytest.raises(MakerWorldUnavailableError):
+            await svc.get_profile_download(1, "M1")
+
+
+class TestDownload3MF:
+    """SSRF guard + size cap + streaming behaviour."""
+
+    def _stream_ctx(self, resp):
+        ctx = MagicMock()
+        ctx.__aenter__ = AsyncMock(return_value=resp)
+        ctx.__aexit__ = AsyncMock(return_value=None)
+        return ctx
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize(
+        "url",
+        [
+            "https://example.com/steal.3mf",
+            "https://169.254.169.254/meta",  # EC2 metadata
+            "http://internal.host/loot",
+            "http://127.0.0.1/loot",
+        ],
+    )
+    async def test_rejects_non_allowed_hosts(self, url):
+        svc = MakerWorldService(client=MagicMock(spec=httpx.AsyncClient))
+        with pytest.raises(MakerWorldUrlError):
+            await svc.download_3mf(url)
+
+    @pytest.mark.asyncio
+    async def test_s3_host_delegates_to_urllib_path(self):
+        svc = MakerWorldService(client=MagicMock(spec=httpx.AsyncClient))
+        with patch(
+            "backend.app.services.makerworld._download_s3_urllib",
+            new=AsyncMock(return_value=(b"payload", "file.3mf")),
+        ) as mocked:
+            payload, filename = await svc.download_3mf(
+                "https://s3.us-west-2.amazonaws.com/bucket/key/file.3mf?X-Amz-Signature=abc"
+            )
+        mocked.assert_awaited_once()
+        # First arg is the verbatim URL — must NOT be round-tripped through
+        # httpx/urlparse.urlencode since that breaks S3 SigV4.
+        args = mocked.await_args.args
+        assert args[0] == ("https://s3.us-west-2.amazonaws.com/bucket/key/file.3mf?X-Amz-Signature=abc")
+        assert payload == b"payload"
+        assert filename == "file.3mf"
+
+    @pytest.mark.asyncio
+    async def test_cdn_url_uses_httpx_with_minimal_headers(self):
+        """Signed CDN URLs already carry the auth in the query string — don't
+        leak the Bambu Cloud bearer to the CDN too. The client is reduced to a
+        single ``User-Agent`` header; no ``Authorization``, no ``x-bbl-*``."""
+        svc = MakerWorldService(client=MagicMock(spec=httpx.AsyncClient), auth_token="tok-abc")
+
+        resp = MagicMock()
+        resp.status_code = 200
+
+        async def _chunks():
+            yield b"PK\x03\x04"
+
+        resp.aiter_bytes = lambda: _chunks()
+        svc._client.stream = MagicMock(return_value=self._stream_ctx(resp))
+
+        await svc.download_3mf("https://makerworld.bblmw.com/makerworld/model/X/Y/foo.3mf?exp=1&key=k")
+
+        call = svc._client.stream.call_args
+        headers = call.kwargs["headers"]
+        # Minimal: UA only. No bearer to the CDN.
+        assert "Authorization" not in headers
+        assert all(not k.startswith("x-bbl") for k in headers)
+        assert "User-Agent" in headers
+        # Redirects off — host allowlist is only meaningful on the initial URL.
+        assert call.kwargs["follow_redirects"] is False
+
+    @pytest.mark.asyncio
+    async def test_happy_path_streams_bytes(self):
+        svc = MakerWorldService(client=MagicMock(spec=httpx.AsyncClient))
+
+        resp = MagicMock()
+        resp.status_code = 200
+
+        async def _chunks():
+            yield b"PK\x03\x04"  # 3MF = zip magic
+            yield b"rest of file"
+
+        resp.aiter_bytes = lambda: _chunks()
+        svc._client.stream = MagicMock(return_value=self._stream_ctx(resp))
+
+        payload, filename = await svc.download_3mf(
+            "https://makerworld.bblmw.com/makerworld/model/X/Y/foo.3mf?exp=1&key=k"
+        )
+        assert payload.startswith(b"PK\x03\x04")
+        assert filename == "foo.3mf"
+
+    @pytest.mark.asyncio
+    async def test_http_error_on_cdn_path_raises_unavailable(self):
+        svc = MakerWorldService(client=MagicMock(spec=httpx.AsyncClient))
+        resp = MagicMock()
+        resp.status_code = 500
+        resp.aiter_bytes = lambda: (_ for _ in ())
+        svc._client.stream = MagicMock(return_value=self._stream_ctx(resp))
+
+        with pytest.raises(MakerWorldUnavailableError):
+            await svc.download_3mf("https://makerworld.bblmw.com/makerworld/model/X/Y/foo.3mf?exp=1&key=k")
+
+    @pytest.mark.asyncio
+    async def test_exceeds_size_cap_raises(self):
+        svc = MakerWorldService(client=MagicMock(spec=httpx.AsyncClient))
+        resp = MagicMock()
+        resp.status_code = 200
+
+        # Cap is 200 MB — emit one "chunk" that reports exceeding it.
+        oversized = _MAX_3MF_BYTES + 1
+
+        async def _chunks():
+            # Emit a bytes object whose ``len()`` is oversized, without
+            # actually allocating 200 MB in the test process.
+            yield b"\x00" * oversized
+
+        resp.aiter_bytes = lambda: _chunks()
+        svc._client.stream = MagicMock(return_value=self._stream_ctx(resp))
+
+        with pytest.raises(MakerWorldUnavailableError, match="cap"):
+            await svc.download_3mf("https://makerworld.bblmw.com/makerworld/model/X/Y/foo.3mf?exp=1&key=k")
+
+
+class TestS3UrllibDownload:
+    """Module-level ``_download_s3_urllib`` — the verbatim-URL path for S3."""
+
+    @pytest.mark.asyncio
+    async def test_returns_bytes_and_filename(self):
+        from backend.app.services.makerworld import _download_s3_urllib
+
+        fake_resp = MagicMock()
+        fake_resp.status = 200
+        # Simulate urllib's file-like ``read(n)`` interface.
+        fake_resp.read = MagicMock(side_effect=[b"hello", b""])
+        fake_resp.__enter__ = MagicMock(return_value=fake_resp)
+        fake_resp.__exit__ = MagicMock(return_value=None)
+
+        fake_opener = MagicMock()
+        fake_opener.open = MagicMock(return_value=fake_resp)
+
+        with patch("urllib.request.build_opener", return_value=fake_opener):
+            data, filename = await _download_s3_urllib(
+                "https://s3.us-west-2.amazonaws.com/b/k/file.3mf?sig=abc",
+                "fallback.3mf",
+            )
+        assert data == b"hello"
+        assert filename == "fallback.3mf"
+
+    @pytest.mark.asyncio
+    async def test_redirect_is_treated_as_error(self):
+        """The ``_NoRedirect`` handler returns ``None`` from ``redirect_request``,
+        which makes ``urllib`` raise ``HTTPError`` instead of following. The
+        wrapper must surface that as ``MakerWorldUnavailableError``."""
+        from backend.app.services.makerworld import _download_s3_urllib
+
+        fake_opener = MagicMock()
+        fake_opener.open = MagicMock(
+            side_effect=HTTPError(
+                "https://s3.example/redirect",
+                302,
+                "Found",
+                {},  # type: ignore[arg-type]
+                None,
+            )
+        )
+
+        with (
+            patch("urllib.request.build_opener", return_value=fake_opener),
+            pytest.raises(MakerWorldUnavailableError),
+        ):
+            await _download_s3_urllib(
+                "https://s3.us-west-2.amazonaws.com/b/k/file.3mf?sig=abc",
+                "fallback.3mf",
+            )
+
+    @pytest.mark.asyncio
+    async def test_non_200_raises_unavailable(self):
+        from backend.app.services.makerworld import _download_s3_urllib
+
+        fake_resp = MagicMock()
+        fake_resp.status = 403
+        fake_resp.read = MagicMock(return_value=b"")
+        fake_resp.__enter__ = MagicMock(return_value=fake_resp)
+        fake_resp.__exit__ = MagicMock(return_value=None)
+
+        fake_opener = MagicMock()
+        fake_opener.open = MagicMock(return_value=fake_resp)
+
+        with (
+            patch("urllib.request.build_opener", return_value=fake_opener),
+            pytest.raises(MakerWorldUnavailableError),
+        ):
+            await _download_s3_urllib(
+                "https://s3.us-west-2.amazonaws.com/b/k/file.3mf?sig=abc",
+                "fallback.3mf",
+            )
+
+    @pytest.mark.asyncio
+    async def test_size_cap_enforced(self):
+        from backend.app.services.makerworld import _download_s3_urllib
+
+        fake_resp = MagicMock()
+        fake_resp.status = 200
+        # A single oversized chunk trips the cap on the first iteration.
+        fake_resp.read = MagicMock(side_effect=[b"\x00" * (_MAX_3MF_BYTES + 1), b""])
+        fake_resp.__enter__ = MagicMock(return_value=fake_resp)
+        fake_resp.__exit__ = MagicMock(return_value=None)
+
+        fake_opener = MagicMock()
+        fake_opener.open = MagicMock(return_value=fake_resp)
+
+        with (
+            patch("urllib.request.build_opener", return_value=fake_opener),
+            pytest.raises(MakerWorldUnavailableError, match="cap"),
+        ):
+            await _download_s3_urllib(
+                "https://s3.us-west-2.amazonaws.com/b/k/file.3mf?sig=abc",
+                "fallback.3mf",
+            )
+
+    @pytest.mark.asyncio
+    async def test_network_error_mapped_to_unavailable(self):
+        from backend.app.services.makerworld import _download_s3_urllib
+
+        fake_opener = MagicMock()
+        fake_opener.open = MagicMock(side_effect=URLError("dns fail"))
+
+        with (
+            patch("urllib.request.build_opener", return_value=fake_opener),
+            pytest.raises(MakerWorldUnavailableError),
+        ):
+            await _download_s3_urllib(
+                "https://s3.us-west-2.amazonaws.com/b/k/file.3mf?sig=abc",
+                "fallback.3mf",
+            )
+
+
+class TestFetchThumbnail:
+    """Proxy the CDN thumbnails so img-src CSP doesn't need to allow external hosts."""
+
+    @pytest.fixture
+    def service(self):
+        svc = MakerWorldService(client=MagicMock(spec=httpx.AsyncClient))
+        svc._client.get = AsyncMock()
+        return svc
+
+    @pytest.mark.asyncio
+    async def test_rejects_non_cdn_host(self, service):
+        with pytest.raises(MakerWorldUrlError):
+            await service.fetch_thumbnail("https://evil.example.com/img.jpg")
+
+    @pytest.mark.asyncio
+    async def test_rejects_loopback(self, service):
+        # SSRF: don't let anyone abuse this as an open proxy toward 127.0.0.1
+        with pytest.raises(MakerWorldUrlError):
+            await service.fetch_thumbnail("http://127.0.0.1/secret.jpg")
+
+    @pytest.mark.asyncio
+    async def test_does_not_follow_redirects(self, service):
+        """Host allowlist is only enforced on the initial URL — a 302 from the
+        CDN to any other host would otherwise bypass the allowlist. ``follow_
+        redirects=False`` pins that behaviour in the wire contract."""
+        resp = MagicMock()
+        resp.status_code = 200
+        resp.headers = {"content-type": "image/jpeg"}
+        resp.content = b"\xff\xd8\xff\xe0JFIF"
+        service._client.get.return_value = resp
+
+        await service.fetch_thumbnail("https://makerworld.bblmw.com/makerworld/model/X/cover.jpg")
+        assert service._client.get.call_args.kwargs["follow_redirects"] is False
+
+    @pytest.mark.asyncio
+    async def test_rejects_html_content_type_even_with_image_extension(self, service):
+        # An upstream error page (HTML) at a .jpg URL must be refused —
+        # otherwise we'd forward it to the browser under an image framing.
+        resp = MagicMock()
+        resp.status_code = 200
+        resp.headers = {"content-type": "text/html"}
+        resp.content = b"<html>error page</html>"
+        service._client.get.return_value = resp
+
+        with pytest.raises(MakerWorldUnavailableError):
+            await service.fetch_thumbnail("https://makerworld.bblmw.com/makerworld/model/X/cover.jpg")
+
+    @pytest.mark.asyncio
+    async def test_happy_path_with_proper_image_content_type(self, service):
+        resp = MagicMock()
+        resp.status_code = 200
+        resp.headers = {"content-type": "image/jpeg; charset=binary"}
+        resp.content = b"\xff\xd8\xff\xe0JFIF"  # JPEG magic bytes
+        service._client.get.return_value = resp
+
+        payload, content_type = await service.fetch_thumbnail(
+            "https://makerworld.bblmw.com/makerworld/model/X/cover.jpg"
+        )
+        assert payload == b"\xff\xd8\xff\xe0JFIF"
+        # Semi-colon params stripped
+        assert content_type == "image/jpeg"
+
+    @pytest.mark.asyncio
+    async def test_infers_mime_from_extension_when_cdn_lies(self, service):
+        """MakerWorld's CDN returns application/octet-stream for real PNG/JPG
+        files. Relying on upstream content-type alone would fail every
+        thumbnail request; fall back to the URL extension."""
+        resp = MagicMock()
+        resp.status_code = 200
+        resp.headers = {"content-type": "application/octet-stream"}
+        resp.content = b"\x89PNG\r\n\x1a\n"  # PNG magic bytes
+        service._client.get.return_value = resp
+
+        payload, content_type = await service.fetch_thumbnail(
+            "https://makerworld.bblmw.com/makerworld/model/X/design/abc.png"
+        )
+        assert payload.startswith(b"\x89PNG")
+        assert content_type == "image/png"
+
+    @pytest.mark.asyncio
+    async def test_refuses_when_no_extension_and_non_image_type(self, service):
+        """If the URL carries no image extension AND upstream doesn't declare
+        image/*, we can't confidently serve it as an image — refuse."""
+        resp = MagicMock()
+        resp.status_code = 200
+        resp.headers = {"content-type": "application/octet-stream"}
+        resp.content = b"who knows what this is"
+        service._client.get.return_value = resp
+
+        with pytest.raises(MakerWorldUnavailableError):
+            await service.fetch_thumbnail("https://makerworld.bblmw.com/makerworld/model/X/blob")

+ 446 - 0
backend/tests/unit/test_makerworld_routes.py

@@ -0,0 +1,446 @@
+"""Tests for the /makerworld/* route handlers.
+
+Mocks ``MakerWorldService`` so tests don't hit the real MakerWorld API. We
+still cover: URL validation, metadata passthrough, already-imported detection,
+source-URL-based dedupe on import, auto-creation of the MakerWorld default
+folder, canonical URL shape, filename basenaming, and the ``/recent-imports``
+listing endpoint.
+"""
+
+from __future__ import annotations
+
+from datetime import datetime, timedelta
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from backend.app.api.routes.makerworld import _canonical_url
+from backend.app.models.library import LibraryFile, LibraryFolder
+
+
+def _fake_service(**stubs):
+    """Build an AsyncMock MakerWorldService with the given async method stubs."""
+    svc = AsyncMock()
+    svc.close = AsyncMock()
+    for name, value in stubs.items():
+        if callable(value) and not isinstance(value, AsyncMock):
+            setattr(svc, name, AsyncMock(side_effect=value))
+        else:
+            setattr(svc, name, AsyncMock(return_value=value))
+    return svc
+
+
+def _default_design(alphanumeric: str = "US2bb73b106683e5", model_id: int = 1400373):
+    """Shape the backend needs from ``/design/{id}``: the alphanumeric
+    ``modelId`` field that iot-service requires, plus at least one instance
+    so the importer has a ``profile_id`` to fall back on."""
+    return {
+        "id": model_id,
+        "modelId": alphanumeric,
+        "title": "Seed Starter",
+        "instances": [{"profileId": 298919107, "title": "9 cells"}],
+    }
+
+
+def _default_manifest(name: str = "benchy.3mf"):
+    return {
+        "name": name,
+        "url": "https://makerworld.bblmw.com/makerworld/model/X/Y/f.3mf?exp=1&key=k",
+    }
+
+
+class TestCanonicalUrl:
+    """Unit test the dedupe-key builder directly — regressions break dedupe
+    silently so it's worth pinning the exact shape."""
+
+    def test_without_profile_id(self):
+        assert _canonical_url(1400373) == "https://makerworld.com/models/1400373"
+
+    def test_without_profile_id_when_none(self):
+        assert _canonical_url(1400373, None) == "https://makerworld.com/models/1400373"
+
+    def test_with_profile_id(self):
+        assert _canonical_url(1400373, 298919107) == ("https://makerworld.com/models/1400373#profileId-298919107")
+
+
+class TestStatus:
+    @pytest.mark.asyncio
+    async def test_status_reports_no_token_by_default(self, async_client, db_session):
+        resp = await async_client.get("/api/v1/makerworld/status")
+        assert resp.status_code == 200
+        body = resp.json()
+        # Fresh in-memory DB has no stored token, so can_download must be false
+        assert body == {"has_cloud_token": False, "can_download": False}
+
+
+class TestResolve:
+    @pytest.mark.asyncio
+    async def test_rejects_non_makerworld_url(self, async_client):
+        resp = await async_client.post(
+            "/api/v1/makerworld/resolve",
+            json={"url": "https://thingiverse.com/thing/1"},
+        )
+        assert resp.status_code == 400
+        assert "makerworld" in resp.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    async def test_happy_path_returns_design_and_instances(self, async_client):
+        design_payload = {"id": 1400373, "title": "Seed Starter"}
+        instances_payload = {
+            "total": 2,
+            "hits": [
+                {"id": 1452154, "profileId": 298919107, "title": "9 cells"},
+                {"id": 1452158, "profileId": 298919564, "title": "12 cells"},
+            ],
+        }
+        svc = _fake_service(get_design=design_payload, get_design_instances=instances_payload)
+
+        with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
+            resp = await async_client.post(
+                "/api/v1/makerworld/resolve",
+                json={"url": "https://makerworld.com/en/models/1400373-slug#profileId-1452154"},
+            )
+        assert resp.status_code == 200, resp.text
+        body = resp.json()
+        assert body["model_id"] == 1400373
+        assert body["profile_id"] == 1452154
+        assert body["design"] == design_payload
+        assert len(body["instances"]) == 2
+        assert body["already_imported_library_ids"] == []
+
+    @pytest.mark.asyncio
+    async def test_flags_already_imported_library_ids(self, async_client, db_session):
+        # Seed a matching LibraryFile so resolve() reports it back
+        existing = LibraryFile(
+            filename="prev.3mf",
+            file_path="library/files/prev.3mf",
+            file_type="3mf",
+            file_size=100,
+            source_type="makerworld",
+            source_url="https://makerworld.com/models/1400373",
+        )
+        db_session.add(existing)
+        await db_session.commit()
+        await db_session.refresh(existing)
+
+        svc = _fake_service(
+            get_design={"id": 1400373},
+            get_design_instances={"total": 0, "hits": []},
+        )
+
+        with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
+            resp = await async_client.post(
+                "/api/v1/makerworld/resolve",
+                json={"url": "https://makerworld.com/en/models/1400373"},
+            )
+        assert resp.status_code == 200, resp.text
+        assert resp.json()["already_imported_library_ids"] == [existing.id]
+
+
+class TestImport:
+    """End-to-end of POST /makerworld/import — mocks the service but exercises
+    real DB writes, real ``save_3mf_bytes_to_library``, real folder auto-creation."""
+
+    _FAKE_3MF_BYTES = b"PK\x03\x04not-a-real-3mf"
+
+    @pytest.mark.asyncio
+    async def test_returns_existing_on_source_url_match(self, async_client, db_session):
+        """Re-importing a model we already have must NOT re-download.
+
+        Dedupe key is ``{model_id}#profileId-{profile_id}`` — matches the
+        canonical URL the route constructs, not the legacy model-only shape.
+        """
+        existing = LibraryFile(
+            filename="already-here.3mf",
+            file_path="library/files/already.3mf",
+            file_type="3mf",
+            file_size=500,
+            source_type="makerworld",
+            source_url="https://makerworld.com/models/1400373#profileId-298919107",
+        )
+        db_session.add(existing)
+        await db_session.commit()
+        await db_session.refresh(existing)
+
+        svc = _fake_service(
+            get_design=_default_design(),
+            get_profile_download=_default_manifest(),
+        )
+        svc.download_3mf = AsyncMock()  # must remain uncalled
+
+        with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
+            resp = await async_client.post(
+                "/api/v1/makerworld/import",
+                json={"model_id": 1400373, "profile_id": 298919107},
+            )
+
+        assert resp.status_code == 200, resp.text
+        body = resp.json()
+        assert body["library_file_id"] == existing.id
+        assert body["was_existing"] is True
+        assert body["profile_id"] == 298919107
+        svc.download_3mf.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_autocreates_makerworld_folder_when_folder_id_none(self, async_client, db_session):
+        """Default destination — a top-level "MakerWorld" folder — is created
+        on first import so users don't have to set it up."""
+        svc = _fake_service(
+            get_design=_default_design(),
+            get_profile_download=_default_manifest(),
+            download_3mf=(self._FAKE_3MF_BYTES, "benchy.3mf"),
+        )
+
+        with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
+            resp = await async_client.post(
+                "/api/v1/makerworld/import",
+                json={"model_id": 1400373, "profile_id": 298919107, "folder_id": None},
+            )
+        assert resp.status_code == 200, resp.text
+
+        # The new folder should exist, at the root.
+        from sqlalchemy import select
+
+        result = await db_session.execute(
+            select(LibraryFolder).where(LibraryFolder.name == "MakerWorld", LibraryFolder.parent_id.is_(None))
+        )
+        folder = result.scalar_one()
+        assert resp.json()["folder_id"] == folder.id
+
+    @pytest.mark.asyncio
+    async def test_uses_existing_folder_when_folder_id_provided(self, async_client, db_session):
+        """Caller-supplied ``folder_id`` must be honoured even if the default
+        ``MakerWorld`` folder also exists — no silent hijacking."""
+        folder = LibraryFolder(name="MyCustomFolder", parent_id=None)
+        db_session.add(folder)
+        await db_session.commit()
+        await db_session.refresh(folder)
+
+        svc = _fake_service(
+            get_design=_default_design(),
+            get_profile_download=_default_manifest(),
+            download_3mf=(self._FAKE_3MF_BYTES, "benchy.3mf"),
+        )
+
+        with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
+            resp = await async_client.post(
+                "/api/v1/makerworld/import",
+                json={"model_id": 1400373, "profile_id": 298919107, "folder_id": folder.id},
+            )
+        assert resp.status_code == 200, resp.text
+        assert resp.json()["folder_id"] == folder.id
+
+    @pytest.mark.asyncio
+    async def test_canonical_source_url_includes_profile_id(self, async_client, db_session):
+        """The saved row's ``source_url`` must include ``#profileId-`` so two
+        plates of the same model become two library rows (dedupe is per-plate)."""
+        svc = _fake_service(
+            get_design=_default_design(),
+            get_profile_download=_default_manifest(),
+            download_3mf=(self._FAKE_3MF_BYTES, "benchy.3mf"),
+        )
+
+        with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
+            resp = await async_client.post(
+                "/api/v1/makerworld/import",
+                json={"model_id": 1400373, "profile_id": 298919107},
+            )
+        assert resp.status_code == 200, resp.text
+
+        from sqlalchemy import select
+
+        row = (
+            await db_session.execute(select(LibraryFile).where(LibraryFile.id == resp.json()["library_file_id"]))
+        ).scalar_one()
+        assert row.source_url == "https://makerworld.com/models/1400373#profileId-298919107"
+
+    @pytest.mark.asyncio
+    async def test_filename_from_upstream_is_basenamed(self, async_client, db_session):
+        """Defence-in-depth: a malicious ``name`` from the upstream manifest
+        (e.g. ``"../../evil.3mf"``) must not persist path components into the
+        library row. On-disk storage uses a UUID already, this is belt-and-
+        braces protection for the human-readable field."""
+        svc = _fake_service(
+            get_design=_default_design(),
+            get_profile_download={
+                "name": "../../evil.3mf",
+                "url": "https://makerworld.bblmw.com/makerworld/model/X/Y/f.3mf?exp=1&key=k",
+            },
+            download_3mf=(self._FAKE_3MF_BYTES, "fallback.3mf"),
+        )
+
+        with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
+            resp = await async_client.post(
+                "/api/v1/makerworld/import",
+                json={"model_id": 1400373, "profile_id": 298919107},
+            )
+        assert resp.status_code == 200, resp.text
+        assert resp.json()["filename"] == "evil.3mf"
+
+    @pytest.mark.asyncio
+    async def test_response_includes_profile_id(self, async_client, db_session):
+        """UI matches imports back to the plate row via ``profile_id`` — the
+        response field must always be populated, even when the caller provided
+        it explicitly (rather than the backend falling back to design defaults)."""
+        svc = _fake_service(
+            get_design=_default_design(),
+            get_profile_download=_default_manifest(),
+            download_3mf=(self._FAKE_3MF_BYTES, "benchy.3mf"),
+        )
+
+        with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
+            resp = await async_client.post(
+                "/api/v1/makerworld/import",
+                json={"model_id": 1400373, "profile_id": 298919107},
+            )
+        assert resp.status_code == 200, resp.text
+        assert resp.json()["profile_id"] == 298919107
+
+
+class TestRecentImports:
+    """GET /makerworld/recent-imports — sidebar feed on the MakerWorld page."""
+
+    @pytest.mark.asyncio
+    async def test_empty_when_no_makerworld_imports(self, async_client):
+        resp = await async_client.get("/api/v1/makerworld/recent-imports")
+        assert resp.status_code == 200
+        assert resp.json() == []
+
+    @pytest.mark.asyncio
+    async def test_returns_items_newest_first(self, async_client, db_session):
+        # Seed three rows with explicit, decreasing created_at timestamps so
+        # ordering doesn't depend on auto-increment PK ordering.
+        base = datetime(2025, 1, 1, 12, 0, 0)
+        older = LibraryFile(
+            filename="older.3mf",
+            file_path="library/older.3mf",
+            file_type="3mf",
+            file_size=10,
+            source_type="makerworld",
+            source_url="https://makerworld.com/models/1",
+            created_at=base,
+        )
+        middle = LibraryFile(
+            filename="middle.3mf",
+            file_path="library/middle.3mf",
+            file_type="3mf",
+            file_size=10,
+            source_type="makerworld",
+            source_url="https://makerworld.com/models/2",
+            created_at=base + timedelta(hours=1),
+        )
+        newer = LibraryFile(
+            filename="newer.3mf",
+            file_path="library/newer.3mf",
+            file_type="3mf",
+            file_size=10,
+            source_type="makerworld",
+            source_url="https://makerworld.com/models/3",
+            created_at=base + timedelta(hours=2),
+        )
+        # Unrelated non-MakerWorld file must NOT show up.
+        other = LibraryFile(
+            filename="manual.3mf",
+            file_path="library/manual.3mf",
+            file_type="3mf",
+            file_size=10,
+            source_type=None,
+            source_url=None,
+            created_at=base + timedelta(hours=3),
+        )
+        db_session.add_all([older, middle, newer, other])
+        await db_session.commit()
+
+        resp = await async_client.get("/api/v1/makerworld/recent-imports")
+        assert resp.status_code == 200, resp.text
+        body = resp.json()
+        names = [row["filename"] for row in body]
+        assert names == ["newer.3mf", "middle.3mf", "older.3mf"]
+
+    @pytest.mark.asyncio
+    async def test_response_matches_pydantic_shape(self, async_client, db_session):
+        """Lock the exact key set so the frontend's typed ``MakerworldRecentImport``
+        doesn't silently fall out of sync with the backend schema."""
+        row = LibraryFile(
+            filename="x.3mf",
+            file_path="library/x.3mf",
+            file_type="3mf",
+            file_size=10,
+            source_type="makerworld",
+            source_url="https://makerworld.com/models/1#profileId-2",
+        )
+        db_session.add(row)
+        await db_session.commit()
+
+        resp = await async_client.get("/api/v1/makerworld/recent-imports")
+        assert resp.status_code == 200, resp.text
+        item = resp.json()[0]
+        assert set(item.keys()) == {
+            "library_file_id",
+            "filename",
+            "folder_id",
+            "thumbnail_path",
+            "source_url",
+            "created_at",
+        }
+        assert item["source_url"] == "https://makerworld.com/models/1#profileId-2"
+
+    @pytest.mark.asyncio
+    async def test_limit_is_honoured(self, async_client, db_session):
+        for i in range(5):
+            db_session.add(
+                LibraryFile(
+                    filename=f"f{i}.3mf",
+                    file_path=f"library/f{i}.3mf",
+                    file_type="3mf",
+                    file_size=10,
+                    source_type="makerworld",
+                    source_url=f"https://makerworld.com/models/{i}",
+                )
+            )
+        await db_session.commit()
+
+        resp = await async_client.get("/api/v1/makerworld/recent-imports?limit=2")
+        assert resp.status_code == 200
+        assert len(resp.json()) == 2
+
+    @pytest.mark.asyncio
+    async def test_limit_clamped_to_minimum(self, async_client, db_session):
+        """``limit=0`` or negative must clamp to 1 — a zero limit would be
+        silently swallowed by SQL and return nothing, which is surprising."""
+        db_session.add(
+            LibraryFile(
+                filename="one.3mf",
+                file_path="library/one.3mf",
+                file_type="3mf",
+                file_size=10,
+                source_type="makerworld",
+                source_url="https://makerworld.com/models/1",
+            )
+        )
+        await db_session.commit()
+
+        resp = await async_client.get("/api/v1/makerworld/recent-imports?limit=0")
+        assert resp.status_code == 200
+        assert len(resp.json()) == 1
+
+    @pytest.mark.asyncio
+    async def test_limit_clamped_to_maximum(self, async_client, db_session):
+        """``limit`` is clamped to 50 so a pathological client can't request
+        the whole table. We seed 60 rows and assert the response is capped."""
+        for i in range(60):
+            db_session.add(
+                LibraryFile(
+                    filename=f"f{i}.3mf",
+                    file_path=f"library/f{i}.3mf",
+                    file_type="3mf",
+                    file_size=10,
+                    source_type="makerworld",
+                    source_url=f"https://makerworld.com/models/{i}",
+                )
+            )
+        await db_session.commit()
+
+        resp = await async_client.get("/api/v1/makerworld/recent-imports?limit=9999")
+        assert resp.status_code == 200
+        assert len(resp.json()) == 50

+ 2 - 0
frontend/src/App.tsx

@@ -17,6 +17,7 @@ import { StreamOverlayPage } from './pages/StreamOverlayPage';
 import { ExternalLinkPage } from './pages/ExternalLinkPage';
 import { GroupEditPage } from './pages/GroupEditPage';
 import InventoryPage from './pages/InventoryPage';
+import { MakerworldPage } from './pages/MakerworldPage';
 import { SystemInfoPage } from './pages/SystemInfoPage';
 import { LoginPage } from './pages/LoginPage';
 import { SetupPage } from './pages/SetupPage';
@@ -192,6 +193,7 @@ function App() {
                   <Route path="projects/:id" element={<ProjectDetailPage />} />
                   <Route path="inventory" element={<InventoryPage />} />
                   <Route path="files" element={<FileManagerPage />} />
+                  <Route path="makerworld" element={<MakerworldPage />} />
                   <Route path="settings" element={<PermissionRoute permission="settings:read"><SettingsPage /></PermissionRoute>} />
                   <Route path="groups/new" element={<PermissionRoute permission="groups:create"><GroupEditPage /></PermissionRoute>} />
                   <Route path="groups/:id/edit" element={<PermissionRoute permission="groups:update"><GroupEditPage /></PermissionRoute>} />

+ 321 - 0
frontend/src/__tests__/pages/MakerworldPage.test.tsx

@@ -0,0 +1,321 @@
+/**
+ * Tests for the MakerworldPage URL-paste flow.
+ *
+ * Covers: status-driven warning banner, resolve round-trip populates design +
+ * plate list, "Already imported" badge appears when the backend reports prior
+ * imports, button labels (Save / Save & Slice in <slicer>), URL-change detection,
+ * inline imported-plate action buttons, the Recent imports sidebar, and
+ * DOMPurify sanitising of user-authored summary HTML.
+ */
+
+import { describe, it, expect, afterEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { http, HttpResponse } from 'msw';
+import { render } from '../utils';
+import { server } from '../mocks/server';
+import { MakerworldPage } from '../../pages/MakerworldPage';
+
+const statusNoToken = { has_cloud_token: false, can_download: false };
+const statusWithToken = { has_cloud_token: true, can_download: true };
+
+function resolveResponse(overrides: Partial<Record<string, unknown>> = {}) {
+  return {
+    model_id: 1400373,
+    profile_id: 1452154,
+    design: {
+      id: 1400373,
+      title: 'Seed Starter',
+      designCreator: { name: 'Meyui', avatar: '' },
+      coverUrl: 'https://makerworld.bblmw.com/img/cover.png',
+      license: 'Standard',
+      downloadCount: 1234,
+      summary: '<p>A seed starter</p>',
+    },
+    instances: [
+      {
+        id: 1452154,
+        profileId: 298919107,
+        title: '9 cells',
+        cover: '',
+        materialCnt: 1,
+        needAms: false,
+        downloadCount: 500,
+      },
+      {
+        id: 1452158,
+        profileId: 298919564,
+        title: '12 cells',
+        cover: '',
+        materialCnt: 2,
+        needAms: true,
+        downloadCount: 120,
+      },
+    ],
+    already_imported_library_ids: [],
+    ...overrides,
+  };
+}
+
+// Helper: seed all the handlers a "signed-in, happy-path" render needs.
+// Individual tests layer extra handlers on top via ``server.use``.
+function useAuthedHandlers(opts: {
+  slicer?: 'bambu_studio' | 'orcaslicer';
+  recent?: Array<Record<string, unknown>>;
+} = {}) {
+  const slicer = opts.slicer ?? 'bambu_studio';
+  const recent = opts.recent ?? [];
+  server.use(
+    http.get('*/makerworld/status', () => HttpResponse.json(statusWithToken)),
+    http.get('*/makerworld/recent-imports', () => HttpResponse.json(recent)),
+    http.get('*/library/folders', () => HttpResponse.json([])),
+    http.get('*/settings/', () =>
+      HttpResponse.json({
+        auto_archive: true,
+        save_thumbnails: true,
+        preferred_slicer: slicer,
+      }),
+    ),
+  );
+}
+
+afterEach(() => server.resetHandlers());
+
+describe('MakerworldPage', () => {
+  it('renders the sign-in-required banner when no Bambu Cloud token is stored', async () => {
+    server.use(
+      http.get('*/makerworld/status', () => HttpResponse.json(statusNoToken)),
+    );
+    render(<MakerworldPage />);
+    expect(await screen.findByText(/Bambu Cloud sign-in required/i)).toBeInTheDocument();
+  });
+
+  it('hides the sign-in banner when a cloud token is present', async () => {
+    useAuthedHandlers();
+    render(<MakerworldPage />);
+    // Page header is always visible; wait for status to settle
+    await screen.findByRole('heading', { name: 'MakerWorld' });
+    await waitFor(() => {
+      expect(screen.queryByText(/Bambu Cloud sign-in required/i)).not.toBeInTheDocument();
+    });
+  });
+
+  it('renders design + plate list after the user pastes a URL', async () => {
+    useAuthedHandlers();
+    server.use(
+      http.post('*/makerworld/resolve', async ({ request }) => {
+        const body = (await request.json()) as { url: string };
+        expect(body.url).toContain('makerworld.com/en/models/1400373');
+        return HttpResponse.json(resolveResponse());
+      }),
+    );
+    render(<MakerworldPage />);
+
+    const input = await screen.findByPlaceholderText(/https:\/\/makerworld\.com/i);
+    await userEvent.type(input, 'https://makerworld.com/en/models/1400373-slug#profileId-1452154');
+    await userEvent.click(screen.getByRole('button', { name: /Resolve/i }));
+
+    expect(await screen.findByText('Seed Starter')).toBeInTheDocument();
+    expect(screen.getByText('9 cells')).toBeInTheDocument();
+    expect(screen.getByText('12 cells')).toBeInTheDocument();
+  });
+
+  it('shows the "Already in library" badge when backend reports prior imports', async () => {
+    useAuthedHandlers();
+    server.use(
+      http.post('*/makerworld/resolve', () =>
+        HttpResponse.json(resolveResponse({ already_imported_library_ids: [42] })),
+      ),
+    );
+    render(<MakerworldPage />);
+    await userEvent.type(
+      await screen.findByPlaceholderText(/https:\/\/makerworld\.com/i),
+      'https://makerworld.com/en/models/1400373',
+    );
+    await userEvent.click(screen.getByRole('button', { name: /Resolve/i }));
+    expect(await screen.findByText(/Already in library/i)).toBeInTheDocument();
+  });
+
+  it('labels the per-plate import button "Save" (not "Import")', async () => {
+    useAuthedHandlers();
+    server.use(
+      http.post('*/makerworld/resolve', () => HttpResponse.json(resolveResponse())),
+    );
+    render(<MakerworldPage />);
+    await userEvent.type(
+      await screen.findByPlaceholderText(/https:\/\/makerworld\.com/i),
+      'https://makerworld.com/en/models/1400373',
+    );
+    await userEvent.click(screen.getByRole('button', { name: /Resolve/i }));
+
+    await screen.findByText('Seed Starter');
+    // Two plates → two Save buttons.
+    const saveButtons = await screen.findAllByRole('button', { name: /^Save$/ });
+    expect(saveButtons.length).toBe(2);
+  });
+
+  it('interpolates the slicer name into the slice button (Bambu Studio by default)', async () => {
+    useAuthedHandlers({ slicer: 'bambu_studio' });
+    server.use(
+      http.post('*/makerworld/resolve', () => HttpResponse.json(resolveResponse())),
+    );
+    render(<MakerworldPage />);
+    await userEvent.type(
+      await screen.findByPlaceholderText(/https:\/\/makerworld\.com/i),
+      'https://makerworld.com/en/models/1400373',
+    );
+    await userEvent.click(screen.getByRole('button', { name: /Resolve/i }));
+
+    const sliceButtons = await screen.findAllByRole('button', {
+      name: /Save & Slice in Bambu Studio/,
+    });
+    expect(sliceButtons.length).toBe(2);
+  });
+
+  it('interpolates OrcaSlicer when that is the configured preferred slicer', async () => {
+    useAuthedHandlers({ slicer: 'orcaslicer' });
+    server.use(
+      http.post('*/makerworld/resolve', () => HttpResponse.json(resolveResponse())),
+    );
+    render(<MakerworldPage />);
+    await userEvent.type(
+      await screen.findByPlaceholderText(/https:\/\/makerworld\.com/i),
+      'https://makerworld.com/en/models/1400373',
+    );
+    await userEvent.click(screen.getByRole('button', { name: /Resolve/i }));
+
+    const sliceButtons = await screen.findAllByRole('button', {
+      name: /Save & Slice in OrcaSlicer/,
+    });
+    expect(sliceButtons.length).toBe(2);
+  });
+
+  it('clears the resolved preview when the URL input is edited after resolve', async () => {
+    useAuthedHandlers();
+    server.use(
+      http.post('*/makerworld/resolve', () => HttpResponse.json(resolveResponse())),
+    );
+    render(<MakerworldPage />);
+
+    const input = await screen.findByPlaceholderText(/https:\/\/makerworld\.com/i);
+    await userEvent.type(input, 'https://makerworld.com/en/models/1400373');
+    await userEvent.click(screen.getByRole('button', { name: /Resolve/i }));
+
+    expect(await screen.findByText('Seed Starter')).toBeInTheDocument();
+
+    // User retypes — preview must go away so they can't accidentally submit the
+    // previous model.
+    await userEvent.clear(input);
+    await userEvent.type(input, 'https://makerworld.com/en/models/9999999');
+
+    await waitFor(() => {
+      expect(screen.queryByText('Seed Starter')).not.toBeInTheDocument();
+    });
+  });
+
+  it('renders inline imported-plate actions after a successful import', async () => {
+    useAuthedHandlers();
+    server.use(
+      http.post('*/makerworld/resolve', () => HttpResponse.json(resolveResponse())),
+      http.post('*/makerworld/import', () =>
+        HttpResponse.json({
+          library_file_id: 99,
+          filename: 'benchy.3mf',
+          folder_id: 7,
+          profile_id: 298919107,
+          was_existing: false,
+        }),
+      ),
+    );
+    render(<MakerworldPage />);
+
+    await userEvent.type(
+      await screen.findByPlaceholderText(/https:\/\/makerworld\.com/i),
+      'https://makerworld.com/en/models/1400373',
+    );
+    await userEvent.click(screen.getByRole('button', { name: /Resolve/i }));
+
+    const saveButtons = await screen.findAllByRole('button', { name: /^Save$/ });
+    await userEvent.click(saveButtons[0]);
+
+    // Inline post-import row for that plate shows the "View in File Manager"
+    // and the two slicer open buttons.
+    await screen.findByRole('button', { name: /View in File Manager/i });
+    expect(screen.getByRole('button', { name: /Open in Bambu Studio/i })).toBeInTheDocument();
+    expect(screen.getByRole('button', { name: /Open in OrcaSlicer/i })).toBeInTheDocument();
+  });
+
+  it('renders the Recent imports sidebar when the query returns items', async () => {
+    useAuthedHandlers({
+      recent: [
+        {
+          library_file_id: 11,
+          filename: 'first-import.3mf',
+          folder_id: 2,
+          thumbnail_path: null,
+          source_url: 'https://makerworld.com/models/1#profileId-2',
+          created_at: '2025-01-01T12:00:00',
+        },
+        {
+          library_file_id: 12,
+          filename: 'second-import.3mf',
+          folder_id: 2,
+          thumbnail_path: null,
+          source_url: null,
+          created_at: '2025-01-02T12:00:00',
+        },
+      ],
+    });
+    render(<MakerworldPage />);
+
+    await screen.findByText(/Recent imports/i);
+    expect(await screen.findByText('first-import.3mf')).toBeInTheDocument();
+    expect(screen.getByText('second-import.3mf')).toBeInTheDocument();
+  });
+
+  it('omits the Recent imports sidebar when the feed is empty', async () => {
+    // Defensive — the CardHeader uses the same translation as a potential
+    // inline header; only the sidebar should surface "Recent imports".
+    useAuthedHandlers({ recent: [] });
+    render(<MakerworldPage />);
+
+    await screen.findByRole('heading', { name: 'MakerWorld' });
+    await waitFor(() => {
+      expect(screen.queryByText(/Recent imports/i)).not.toBeInTheDocument();
+    });
+  });
+
+  it('sanitises the design summary HTML via DOMPurify', async () => {
+    // User-authored summaries are not trusted; a ``<script>`` tag must be
+    // stripped before it lands in the DOM via dangerouslySetInnerHTML.
+    useAuthedHandlers();
+    server.use(
+      http.post('*/makerworld/resolve', () =>
+        HttpResponse.json(
+          resolveResponse({
+            design: {
+              ...resolveResponse().design,
+              summary:
+                '<p>Clean prose <b>here</b></p><script>window.__pwned = true;</script>',
+            },
+          }),
+        ),
+      ),
+    );
+    render(<MakerworldPage />);
+    await userEvent.type(
+      await screen.findByPlaceholderText(/https:\/\/makerworld\.com/i),
+      'https://makerworld.com/en/models/1400373',
+    );
+    await userEvent.click(screen.getByRole('button', { name: /Resolve/i }));
+
+    await screen.findByText('Seed Starter');
+    // Safe content survives.
+    expect(screen.getByText(/Clean prose/i)).toBeInTheDocument();
+    // Hostile content is stripped — DOMPurify drops <script> entirely, so the
+    // side-effect assignment can't have run.
+    expect((window as unknown as { __pwned?: boolean }).__pwned).toBeUndefined();
+    // And no literal ``<script>`` text leaks into the document.
+    expect(document.body.innerHTML).not.toContain('window.__pwned');
+  });
+});

+ 60 - 0
frontend/src/api/client.ts

@@ -970,6 +970,39 @@ export interface CloudLoginResponse {
   tfa_key?: string | null;
 }
 
+// MakerWorld integration. Full metadata/instance shapes come back as
+// Record<string, unknown> — MakerWorld's API adds fields over time, so we
+// pass them through verbatim rather than maintaining a brittle mirror.
+export interface MakerworldStatus {
+  has_cloud_token: boolean;
+  can_download: boolean;
+}
+
+export interface MakerworldResolvedModel {
+  model_id: number;
+  profile_id: number | null;
+  design: Record<string, unknown>;
+  instances: Array<Record<string, unknown>>;
+  already_imported_library_ids: number[];
+}
+
+export interface MakerworldImportResponse {
+  library_file_id: number;
+  filename: string;
+  folder_id: number | null;
+  profile_id: number | null;
+  was_existing: boolean;
+}
+
+export interface MakerworldRecentImport {
+  library_file_id: number;
+  filename: string;
+  folder_id: number | null;
+  thumbnail_path: string | null;
+  source_url: string | null;
+  created_at: string;
+}
+
 export interface SlicerSetting {
   setting_id: string;
   name: string;
@@ -2289,6 +2322,7 @@ export type Permission =
   | 'settings:read' | 'settings:update' | 'settings:backup' | 'settings:restore'
   | 'github:backup' | 'github:restore'
   | 'cloud:auth'
+  | 'makerworld:view' | 'makerworld:import'
   | 'api_keys:read' | 'api_keys:create' | 'api_keys:update' | 'api_keys:delete'
   | 'users:read' | 'users:create' | 'users:update' | 'users:delete'
   | 'groups:read' | 'groups:create' | 'groups:update' | 'groups:delete'
@@ -3663,6 +3697,32 @@ export const api = {
     request<BuiltinFilament[]>('/cloud/builtin-filaments'),
   getFilamentIdMap: () =>
     request<Record<string, string>>('/cloud/filament-id-map'),
+
+  // MakerWorld URL-paste import flow.
+  getMakerworldStatus: () =>
+    request<MakerworldStatus>('/makerworld/status'),
+  resolveMakerworldUrl: (url: string) =>
+    request<MakerworldResolvedModel>('/makerworld/resolve', {
+      method: 'POST',
+      body: JSON.stringify({ url }),
+    }),
+  getMakerworldRecentImports: (limit = 10) =>
+    request<MakerworldRecentImport[]>(`/makerworld/recent-imports?limit=${limit}`),
+  importMakerworldInstance: (
+    model_id: number,
+    instance_id: number | null,
+    profile_id?: number | null,
+    folder_id?: number | null,
+  ) =>
+    request<MakerworldImportResponse>('/makerworld/import', {
+      method: 'POST',
+      body: JSON.stringify({
+        model_id,
+        instance_id: instance_id ?? null,
+        profile_id: profile_id ?? null,
+        folder_id: folder_id ?? null,
+      }),
+    }),
   getCloudSettingDetail: (settingId: string) =>
     request<SlicerSettingDetail>(`/cloud/settings/${settingId}`),
   createCloudSetting: (data: SlicerSettingCreate) =>

+ 2 - 1
frontend/src/components/Layout.tsx

@@ -1,6 +1,6 @@
 import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
 import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
-import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, Key, Loader2, Disc3, ShieldAlert, Bell, type LucideIcon } from 'lucide-react';
+import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, Key, Loader2, Disc3, ShieldAlert, Bell, Globe, type LucideIcon } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import { useTheme } from '../contexts/ThemeContext';
 import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
@@ -36,6 +36,7 @@ export const defaultNavItems: NavItem[] = [
   { id: 'projects', to: '/projects', icon: FolderKanban, labelKey: 'nav.projects' },
   { id: 'inventory', to: '/inventory', icon: Disc3, labelKey: 'nav.inventory' },
   { id: 'files', to: '/files', icon: FolderOpen, labelKey: 'nav.files' },
+  { id: 'makerworld', to: '/makerworld', icon: Globe, labelKey: 'nav.makerworld' },
   // User-account features: kept adjacent to Settings intentionally
   { id: 'notifications', to: '/notifications', icon: Bell, labelKey: 'nav.notifications' },
   { id: 'settings', to: '/settings', icon: Settings, labelKey: 'nav.settings' },

+ 51 - 0
frontend/src/i18n/locales/de.ts

@@ -10,6 +10,7 @@ export default {
     projects: 'Projekte',
     inventory: 'Filament',
     files: 'Dateimanager',
+    makerworld: 'MakerWorld',
     notifications: 'Benachrichtigungen',
     settings: 'Einstellungen',
     system: 'System',
@@ -5083,4 +5084,54 @@ export default {
     historyTitle: 'Letzte Erkennungen',
     noHistory: 'Noch keine Erkennungen.',
   },
+
+  makerworld: {
+    title: 'MakerWorld',
+    description: 'Füge eine MakerWorld-Modell-URL ein, um es direkt aus Bambuddy zu importieren und zu drucken — ohne die Bambu Handy App zu öffnen.',
+    pasteUrlHeader: 'Von MakerWorld importieren',
+    pasteUrlPlaceholder: 'https://makerworld.com/de/models/… oder beliebigen MakerWorld-Link einfügen',
+    resolveButton: 'Laden',
+    signInRequiredTitle: 'Bambu-Cloud-Anmeldung für Download erforderlich',
+    signInRequiredBody: 'Modell-Details können anonym angezeigt werden, aber MakerWorld verlangt eine Bambu-Cloud-Anmeldung zum Herunterladen der 3MF-Dateien.',
+    openCloudSettings: 'Cloud-Einstellungen öffnen',
+    untitledModel: 'Unbenanntes Modell',
+    byCreator: 'von {{name}}',
+    downloadsCount: '{{count}} Downloads',
+    licensePrefix: 'Lizenz',
+    alreadyImported: 'Bereits in Bibliothek',
+    openOnMakerworld: 'Auf MakerWorld öffnen',
+    alreadyInLibrary: 'Dieses Modell ist bereits in deiner Bibliothek — zu finden im Dateimanager → MakerWorld',
+    importSuccess: '{{filename}} importiert — gespeichert im Dateimanager → MakerWorld',
+    platesHeader: 'Platten ({{count}})',
+    plateDefaultName: 'Platte {{n}}',
+    materialCount: '{{count}} Filamente',
+    amsRequired: 'AMS erforderlich',
+    importToLibrary: 'Speichern',
+    sliceIn: 'Speichern & in {{slicer}} öffnen',
+    disclaimer: 'Die MakerWorld-Integration verwendet von der Community dokumentierte API-Endpunkte. Bambuddy ist nicht mit MakerWorld oder Bambu Lab verbunden oder von diesen unterstützt.',
+    lastImportSuccess: 'In deine Bibliothek importiert',
+    lastImportAlreadyInLibrary: 'Bereits in deiner Bibliothek',
+    viewInLibrary: 'Im Dateimanager anzeigen',
+    openInBambuStudio: 'In Bambu Studio öffnen',
+    openInOrcaSlicer: 'In OrcaSlicer öffnen',
+    importTo: 'In Dateimanager importieren',
+    recentImportsHeader: 'Zuletzt importiert',
+    phaseResolving: 'Auflösen',
+    phaseDownloading: 'Lade herunter',
+    folderAuto: 'MakerWorld (Standard)',
+    importAll: 'Alle importieren',
+    importAllProgress: 'Importiere {{current}}/{{total}}',
+    openGallery: 'Bildergalerie öffnen',
+    galleryPrev: 'Vorheriges Bild',
+    galleryNext: 'Nächstes Bild',
+    deleteImport: 'Aus Bibliothek entfernen',
+    importDeleting: 'Wird entfernt…',
+    importDeleted: 'Aus Bibliothek entfernt',
+    confirmDelete: '{{filename}} aus der Bibliothek entfernen? Die lokale Datei wird gelöscht, die Platte kann aber erneut von MakerWorld importiert werden.',
+    errors: {
+      resolveFailed: 'Diese MakerWorld-URL konnte nicht aufgelöst werden.',
+      downloadFailed: 'Download fehlgeschlagen. Bitte erneut versuchen.',
+      deleteFailed: 'Datei konnte nicht aus der Bibliothek entfernt werden.',
+    },
+  },
 };

+ 51 - 0
frontend/src/i18n/locales/en.ts

@@ -10,6 +10,7 @@ export default {
     projects: 'Projects',
     inventory: 'Filament',
     files: 'File Manager',
+    makerworld: 'MakerWorld',
     notifications: 'Notifications',
     settings: 'Settings',
     system: 'System',
@@ -5091,4 +5092,54 @@ export default {
     historyTitle: 'Recent Detections',
     noHistory: 'No detections yet.',
   },
+
+  makerworld: {
+    title: 'MakerWorld',
+    description: 'Paste a MakerWorld model URL to import and print it directly from Bambuddy — without leaving for the Bambu Handy app.',
+    pasteUrlHeader: 'Import from MakerWorld',
+    pasteUrlPlaceholder: 'https://makerworld.com/en/models/… or paste any MakerWorld link',
+    resolveButton: 'Resolve',
+    signInRequiredTitle: 'Bambu Cloud sign-in required to download',
+    signInRequiredBody: 'You can browse model details anonymously, but MakerWorld requires a Bambu Cloud account to download 3MF files.',
+    openCloudSettings: 'Open Cloud settings',
+    untitledModel: 'Untitled model',
+    byCreator: 'by {{name}}',
+    downloadsCount: '{{count}} downloads',
+    licensePrefix: 'License',
+    alreadyImported: 'Already in library',
+    openOnMakerworld: 'Open on MakerWorld',
+    alreadyInLibrary: 'This model is already in your library — find it in File Manager → MakerWorld',
+    importSuccess: 'Imported {{filename}} — saved to File Manager → MakerWorld',
+    platesHeader: 'Plates ({{count}})',
+    plateDefaultName: 'Plate {{n}}',
+    materialCount: '{{count}} filaments',
+    amsRequired: 'AMS required',
+    importToLibrary: 'Save',
+    sliceIn: 'Save & Slice in {{slicer}}',
+    disclaimer: 'MakerWorld integration uses community-documented API endpoints. Bambuddy is not affiliated with or endorsed by MakerWorld or Bambu Lab.',
+    lastImportSuccess: 'Imported to your library',
+    lastImportAlreadyInLibrary: 'Already in your library',
+    viewInLibrary: 'View in File Manager',
+    openInBambuStudio: 'Open in Bambu Studio',
+    openInOrcaSlicer: 'Open in OrcaSlicer',
+    importTo: 'Import to file manager',
+    recentImportsHeader: 'Recent imports',
+    phaseResolving: 'Resolving',
+    phaseDownloading: 'Downloading',
+    folderAuto: 'MakerWorld (default)',
+    importAll: 'Import all',
+    importAllProgress: 'Importing {{current}}/{{total}}',
+    openGallery: 'Open image gallery',
+    galleryPrev: 'Previous image',
+    galleryNext: 'Next image',
+    deleteImport: 'Remove from library',
+    importDeleting: 'Removing…',
+    importDeleted: 'Removed from library',
+    confirmDelete: 'Remove {{filename}} from the library? This deletes the local file but the plate can be re-imported from MakerWorld.',
+    errors: {
+      resolveFailed: 'Could not resolve that MakerWorld URL.',
+      downloadFailed: 'Download failed. Please try again.',
+      deleteFailed: 'Could not remove the file from the library.',
+    },
+  },
 };

+ 50 - 0
frontend/src/i18n/locales/fr.ts

@@ -10,6 +10,7 @@ export default {
     projects: 'Projets',
     inventory: 'Filament',
     files: 'Gestionnaire de fichiers',
+    makerworld: 'MakerWorld',
     notifications: 'Notifications',
     settings: 'Paramètres',
     system: 'Système',
@@ -4997,4 +4998,53 @@ export default {
     historyTitle: 'Détections récentes',
     noHistory: 'Aucune détection pour le moment.',
   },
+  makerworld: {
+    title: 'MakerWorld',
+    description: 'Collez une URL de modèle MakerWorld pour l\'importer et l\'imprimer directement depuis Bambuddy — sans passer par l\'application Bambu Handy.',
+    pasteUrlHeader: 'Importer depuis MakerWorld',
+    pasteUrlPlaceholder: 'https://makerworld.com/en/models/… ou collez n\'importe quel lien MakerWorld',
+    resolveButton: 'Résoudre',
+    signInRequiredTitle: 'Connexion Bambu Cloud requise pour télécharger',
+    signInRequiredBody: 'Vous pouvez consulter les détails du modèle anonymement, mais MakerWorld nécessite un compte Bambu Cloud pour télécharger les fichiers 3MF.',
+    openCloudSettings: 'Ouvrir les paramètres Cloud',
+    untitledModel: 'Modèle sans titre',
+    byCreator: 'par {{name}}',
+    downloadsCount: '{{count}} téléchargements',
+    licensePrefix: 'Licence',
+    alreadyImported: 'Déjà dans la bibliothèque',
+    openOnMakerworld: 'Ouvrir sur MakerWorld',
+    alreadyInLibrary: 'Ce modèle est déjà dans votre bibliothèque — retrouvez-le dans Gestionnaire de fichiers → MakerWorld',
+    importSuccess: '{{filename}} importé — enregistré dans Gestionnaire de fichiers → MakerWorld',
+    platesHeader: 'Plateaux ({{count}})',
+    plateDefaultName: 'Plateau {{n}}',
+    materialCount: '{{count}} filaments',
+    amsRequired: 'AMS requis',
+    importToLibrary: 'Enregistrer',
+    sliceIn: 'Enregistrer et découper dans {{slicer}}',
+    disclaimer: 'L\'intégration MakerWorld utilise des points de terminaison API documentés par la communauté. Bambuddy n\'est ni affilié ni approuvé par MakerWorld ou Bambu Lab.',
+    lastImportSuccess: 'Importé dans votre bibliothèque',
+    lastImportAlreadyInLibrary: 'Déjà dans votre bibliothèque',
+    viewInLibrary: 'Voir dans le Gestionnaire de fichiers',
+    openInBambuStudio: 'Ouvrir dans Bambu Studio',
+    openInOrcaSlicer: 'Ouvrir dans OrcaSlicer',
+    importTo: 'Importer dans le Gestionnaire de fichiers',
+    recentImportsHeader: 'Imports récents',
+    phaseResolving: 'Résolution',
+    phaseDownloading: 'Téléchargement',
+    folderAuto: 'MakerWorld (par défaut)',
+    importAll: 'Tout importer',
+    importAllProgress: 'Importation de {{current}}/{{total}}',
+    openGallery: 'Ouvrir la galerie d\'images',
+    galleryPrev: 'Image précédente',
+    galleryNext: 'Image suivante',
+    deleteImport: 'Retirer de la bibliothèque',
+    importDeleting: 'Suppression…',
+    importDeleted: 'Retiré de la bibliothèque',
+    confirmDelete: 'Retirer {{filename}} de la bibliothèque ? Le fichier local est supprimé, mais la plaque peut être ré-importée depuis MakerWorld.',
+    errors: {
+      resolveFailed: 'Impossible de résoudre cette URL MakerWorld.',
+      downloadFailed: 'Le téléchargement a échoué. Veuillez réessayer.',
+      deleteFailed: 'Impossible de retirer le fichier de la bibliothèque.',
+    },
+  },
 };

+ 50 - 0
frontend/src/i18n/locales/it.ts

@@ -10,6 +10,7 @@ export default {
     projects: 'Progetti',
     inventory: 'Filamento',
     files: 'File',
+    makerworld: 'MakerWorld',
     notifications: 'Notifiche',
     settings: 'Impostazioni',
     system: 'Sistema',
@@ -4996,4 +4997,53 @@ export default {
     historyTitle: 'Rilevamenti recenti',
     noHistory: 'Nessun rilevamento finora.',
   },
+  makerworld: {
+    title: 'MakerWorld',
+    description: 'Incolla un URL di un modello MakerWorld per importarlo e stamparlo direttamente da Bambuddy — senza passare dall\'app Bambu Handy.',
+    pasteUrlHeader: 'Importa da MakerWorld',
+    pasteUrlPlaceholder: 'https://makerworld.com/en/models/… o incolla qualsiasi link MakerWorld',
+    resolveButton: 'Risolvi',
+    signInRequiredTitle: 'Accesso Bambu Cloud richiesto per scaricare',
+    signInRequiredBody: 'Puoi consultare i dettagli del modello in modo anonimo, ma MakerWorld richiede un account Bambu Cloud per scaricare i file 3MF.',
+    openCloudSettings: 'Apri impostazioni Cloud',
+    untitledModel: 'Modello senza titolo',
+    byCreator: 'di {{name}}',
+    downloadsCount: '{{count}} download',
+    licensePrefix: 'Licenza',
+    alreadyImported: 'Già nella libreria',
+    openOnMakerworld: 'Apri su MakerWorld',
+    alreadyInLibrary: 'Questo modello è già nella tua libreria — lo trovi in Gestione file → MakerWorld',
+    importSuccess: '{{filename}} importato — salvato in Gestione file → MakerWorld',
+    platesHeader: 'Piatti ({{count}})',
+    plateDefaultName: 'Piatto {{n}}',
+    materialCount: '{{count}} filamenti',
+    amsRequired: 'AMS richiesto',
+    importToLibrary: 'Salva',
+    sliceIn: 'Salva e affetta in {{slicer}}',
+    disclaimer: 'L\'integrazione MakerWorld utilizza endpoint API documentati dalla community. Bambuddy non è affiliato né approvato da MakerWorld o Bambu Lab.',
+    lastImportSuccess: 'Importato nella tua libreria',
+    lastImportAlreadyInLibrary: 'Già nella tua libreria',
+    viewInLibrary: 'Visualizza in Gestione file',
+    openInBambuStudio: 'Apri in Bambu Studio',
+    openInOrcaSlicer: 'Apri in OrcaSlicer',
+    importTo: 'Importa in Gestione file',
+    recentImportsHeader: 'Importazioni recenti',
+    phaseResolving: 'Risoluzione',
+    phaseDownloading: 'Download',
+    folderAuto: 'MakerWorld (predefinita)',
+    importAll: 'Importa tutto',
+    importAllProgress: 'Importazione {{current}}/{{total}}',
+    openGallery: 'Apri galleria immagini',
+    galleryPrev: 'Immagine precedente',
+    galleryNext: 'Immagine successiva',
+    deleteImport: 'Rimuovi dalla libreria',
+    importDeleting: 'Rimozione…',
+    importDeleted: 'Rimosso dalla libreria',
+    confirmDelete: 'Rimuovere {{filename}} dalla libreria? Il file locale verrà eliminato, ma il piatto può essere re-importato da MakerWorld.',
+    errors: {
+      resolveFailed: 'Impossibile risolvere questo URL MakerWorld.',
+      downloadFailed: 'Download non riuscito. Riprova.',
+      deleteFailed: 'Impossibile rimuovere il file dalla libreria.',
+    },
+  },
 };

+ 50 - 0
frontend/src/i18n/locales/ja.ts

@@ -10,6 +10,7 @@ export default {
     projects: 'プロジェクト',
     inventory: 'フィラメント',
     files: 'ファイル管理',
+    makerworld: 'MakerWorld',
     notifications: '通知',
     settings: '設定',
     system: 'システム',
@@ -5035,4 +5036,53 @@ export default {
     historyTitle: '最近の検出',
     noHistory: 'まだ検出はありません。',
   },
+  makerworld: {
+    title: 'MakerWorld',
+    description: 'MakerWorld モデルの URL を貼り付けると、Bambu Handy アプリを開かなくても Bambuddy から直接インポート・印刷できます。',
+    pasteUrlHeader: 'MakerWorld からインポート',
+    pasteUrlPlaceholder: 'https://makerworld.com/en/models/… または任意の MakerWorld リンク',
+    resolveButton: '読み込む',
+    signInRequiredTitle: 'ダウンロードには Bambu Cloud へのサインインが必要です',
+    signInRequiredBody: 'モデルの詳細は匿名で閲覧できますが、3MF ファイルをダウンロードするには Bambu Cloud アカウントが必要です。',
+    openCloudSettings: 'Cloud 設定を開く',
+    untitledModel: '無題のモデル',
+    byCreator: '作成者: {{name}}',
+    downloadsCount: 'ダウンロード数: {{count}}',
+    licensePrefix: 'ライセンス',
+    alreadyImported: 'ライブラリに登録済み',
+    openOnMakerworld: 'MakerWorld で開く',
+    alreadyInLibrary: 'このモデルはすでにライブラリにあります — ファイルマネージャー → MakerWorld で確認できます',
+    importSuccess: '{{filename}} をインポートしました — ファイルマネージャー → MakerWorld に保存済み',
+    platesHeader: 'プレート ({{count}})',
+    plateDefaultName: 'プレート {{n}}',
+    materialCount: 'フィラメント {{count}} 本',
+    amsRequired: 'AMS が必要',
+    importToLibrary: '保存',
+    sliceIn: '保存して {{slicer}} でスライス',
+    disclaimer: 'MakerWorld 連携はコミュニティで文書化された API エンドポイントを使用しています。Bambuddy は MakerWorld または Bambu Lab との提携・承認関係はありません。',
+    lastImportSuccess: 'ライブラリにインポートしました',
+    lastImportAlreadyInLibrary: '既にライブラリに存在します',
+    viewInLibrary: 'ファイルマネージャーで表示',
+    openInBambuStudio: 'Bambu Studio で開く',
+    openInOrcaSlicer: 'OrcaSlicer で開く',
+    importTo: 'ファイルマネージャーへのインポート先',
+    recentImportsHeader: '最近のインポート',
+    phaseResolving: '解決中',
+    phaseDownloading: 'ダウンロード中',
+    folderAuto: 'MakerWorld (デフォルト)',
+    importAll: 'すべてインポート',
+    importAllProgress: 'インポート中 {{current}}/{{total}}',
+    openGallery: '画像ギャラリーを開く',
+    galleryPrev: '前の画像',
+    galleryNext: '次の画像',
+    deleteImport: 'ライブラリから削除',
+    importDeleting: '削除中…',
+    importDeleted: 'ライブラリから削除しました',
+    confirmDelete: '{{filename}} をライブラリから削除しますか?ローカルファイルは削除されますが、MakerWorld から再インポートできます。',
+    errors: {
+      resolveFailed: 'この MakerWorld URL を解決できませんでした。',
+      downloadFailed: 'ダウンロードに失敗しました。もう一度お試しください。',
+      deleteFailed: 'ライブラリからファイルを削除できませんでした。',
+    },
+  },
 };

+ 50 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -10,6 +10,7 @@ export default {
     projects: 'Projetos',
     inventory: 'Inventário',
     files: 'Gerenciador de Arquivos',
+    makerworld: 'MakerWorld',
     notifications: 'Notificações',
     settings: 'Configurações',
     system: 'Sistema',
@@ -5010,4 +5011,53 @@ export default {
     historyTitle: 'Detecções recentes',
     noHistory: 'Nenhuma detecção ainda.',
   },
+  makerworld: {
+    title: 'MakerWorld',
+    description: 'Cole uma URL de modelo do MakerWorld para importar e imprimir diretamente do Bambuddy — sem precisar abrir o app Bambu Handy.',
+    pasteUrlHeader: 'Importar do MakerWorld',
+    pasteUrlPlaceholder: 'https://makerworld.com/en/models/… ou cole qualquer link do MakerWorld',
+    resolveButton: 'Resolver',
+    signInRequiredTitle: 'Login no Bambu Cloud necessário para baixar',
+    signInRequiredBody: 'Você pode navegar pelos detalhes do modelo anonimamente, mas o MakerWorld exige uma conta Bambu Cloud para baixar arquivos 3MF.',
+    openCloudSettings: 'Abrir configurações do Cloud',
+    untitledModel: 'Modelo sem título',
+    byCreator: 'por {{name}}',
+    downloadsCount: '{{count}} downloads',
+    licensePrefix: 'Licença',
+    alreadyImported: 'Já na biblioteca',
+    openOnMakerworld: 'Abrir no MakerWorld',
+    alreadyInLibrary: 'Este modelo já está na sua biblioteca — encontre-o em Gerenciador de Arquivos → MakerWorld',
+    importSuccess: '{{filename}} importado — salvo em Gerenciador de Arquivos → MakerWorld',
+    platesHeader: 'Placas ({{count}})',
+    plateDefaultName: 'Placa {{n}}',
+    materialCount: '{{count}} filamentos',
+    amsRequired: 'AMS necessário',
+    importToLibrary: 'Salvar',
+    sliceIn: 'Salvar e fatiar no {{slicer}}',
+    disclaimer: 'A integração com o MakerWorld usa endpoints de API documentados pela comunidade. Bambuddy não é afiliado nem endossado pelo MakerWorld ou pela Bambu Lab.',
+    lastImportSuccess: 'Importado para sua biblioteca',
+    lastImportAlreadyInLibrary: 'Já na sua biblioteca',
+    viewInLibrary: 'Ver no Gerenciador de Arquivos',
+    openInBambuStudio: 'Abrir no Bambu Studio',
+    openInOrcaSlicer: 'Abrir no OrcaSlicer',
+    importTo: 'Importar para o Gerenciador de Arquivos',
+    recentImportsHeader: 'Importações recentes',
+    phaseResolving: 'Resolvendo',
+    phaseDownloading: 'Baixando',
+    folderAuto: 'MakerWorld (padrão)',
+    importAll: 'Importar tudo',
+    importAllProgress: 'Importando {{current}}/{{total}}',
+    openGallery: 'Abrir galeria de imagens',
+    galleryPrev: 'Imagem anterior',
+    galleryNext: 'Próxima imagem',
+    deleteImport: 'Remover da biblioteca',
+    importDeleting: 'Removendo…',
+    importDeleted: 'Removido da biblioteca',
+    confirmDelete: 'Remover {{filename}} da biblioteca? O arquivo local será excluído, mas a placa pode ser reimportada do MakerWorld.',
+    errors: {
+      resolveFailed: 'Não foi possível resolver essa URL do MakerWorld.',
+      downloadFailed: 'Falha no download. Tente novamente.',
+      deleteFailed: 'Não foi possível remover o arquivo da biblioteca.',
+    },
+  },
 };

+ 50 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -10,6 +10,7 @@ export default {
     projects: '项目',
     inventory: '耗材',
     files: '文件管理器',
+    makerworld: 'MakerWorld',
     notifications: '通知',
     settings: '设置',
     system: '系统',
@@ -5074,4 +5075,53 @@ export default {
     historyTitle: '最近检测',
     noHistory: '暂无检测记录。',
   },
+  makerworld: {
+    title: 'MakerWorld',
+    description: '粘贴 MakerWorld 模型链接,即可直接在 Bambuddy 中导入并打印 —— 无需切换到 Bambu Handy 应用。',
+    pasteUrlHeader: '从 MakerWorld 导入',
+    pasteUrlPlaceholder: 'https://makerworld.com/en/models/… 或粘贴任意 MakerWorld 链接',
+    resolveButton: '解析',
+    signInRequiredTitle: '下载需要登录 Bambu Cloud',
+    signInRequiredBody: '您可以匿名浏览模型详情,但下载 3MF 文件需要 Bambu Cloud 账户。',
+    openCloudSettings: '打开云设置',
+    untitledModel: '无标题模型',
+    byCreator: '作者: {{name}}',
+    downloadsCount: '{{count}} 次下载',
+    licensePrefix: '许可协议',
+    alreadyImported: '已在资料库中',
+    openOnMakerworld: '在 MakerWorld 中打开',
+    alreadyInLibrary: '此模型已在您的资料库中 —— 可在文件管理器 → MakerWorld 中找到',
+    importSuccess: '已导入 {{filename}} —— 已保存到文件管理器 → MakerWorld',
+    platesHeader: '打印板 ({{count}})',
+    plateDefaultName: '打印板 {{n}}',
+    materialCount: '{{count}} 种耗材',
+    amsRequired: '需要 AMS',
+    importToLibrary: '保存',
+    sliceIn: '保存并在 {{slicer}} 中切片',
+    disclaimer: 'MakerWorld 集成使用由社区记录的 API 接口。Bambuddy 与 MakerWorld 或 Bambu Lab 没有从属或认可关系。',
+    lastImportSuccess: '已导入到您的资料库',
+    lastImportAlreadyInLibrary: '已存在于您的资料库中',
+    viewInLibrary: '在文件管理器中查看',
+    openInBambuStudio: '在 Bambu Studio 中打开',
+    openInOrcaSlicer: '在 OrcaSlicer 中打开',
+    importTo: '导入到文件管理器',
+    recentImportsHeader: '最近导入',
+    phaseResolving: '解析中',
+    phaseDownloading: '下载中',
+    folderAuto: 'MakerWorld (默认)',
+    importAll: '全部导入',
+    importAllProgress: '正在导入 {{current}}/{{total}}',
+    openGallery: '打开图片库',
+    galleryPrev: '上一张',
+    galleryNext: '下一张',
+    deleteImport: '从资料库中移除',
+    importDeleting: '正在移除…',
+    importDeleted: '已从资料库中移除',
+    confirmDelete: '从资料库中移除 {{filename}}?本地文件将被删除,但可以从 MakerWorld 重新导入。',
+    errors: {
+      resolveFailed: '无法解析该 MakerWorld 链接。',
+      downloadFailed: '下载失败。请重试。',
+      deleteFailed: '无法从资料库中移除文件。',
+    },
+  },
 };

+ 50 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -10,6 +10,7 @@ export default {
     projects: '專案',
     inventory: '耗材',
     files: '檔案管理器',
+    makerworld: 'MakerWorld',
     notifications: '通知',
     settings: '設定',
     system: '系統',
@@ -5074,4 +5075,53 @@ export default {
     historyTitle: '最近檢測',
     noHistory: '尚無檢測紀錄。',
   },
+  makerworld: {
+    title: 'MakerWorld',
+    description: '貼上 MakerWorld 模型連結,即可直接在 Bambuddy 中匯入並列印 —— 無需切換至 Bambu Handy 應用程式。',
+    pasteUrlHeader: '從 MakerWorld 匯入',
+    pasteUrlPlaceholder: 'https://makerworld.com/en/models/… 或貼上任意 MakerWorld 連結',
+    resolveButton: '解析',
+    signInRequiredTitle: '下載需要登入 Bambu Cloud',
+    signInRequiredBody: '您可以匿名瀏覽模型詳情,但下載 3MF 檔案需要 Bambu Cloud 帳戶。',
+    openCloudSettings: '開啟雲端設定',
+    untitledModel: '無標題模型',
+    byCreator: '作者: {{name}}',
+    downloadsCount: '{{count}} 次下載',
+    licensePrefix: '授權條款',
+    alreadyImported: '已在資料庫中',
+    openOnMakerworld: '在 MakerWorld 中開啟',
+    alreadyInLibrary: '此模型已在您的資料庫中 —— 可在檔案管理員 → MakerWorld 中找到',
+    importSuccess: '已匯入 {{filename}} —— 已儲存至檔案管理員 → MakerWorld',
+    platesHeader: '列印板 ({{count}})',
+    plateDefaultName: '列印板 {{n}}',
+    materialCount: '{{count}} 種耗材',
+    amsRequired: '需要 AMS',
+    importToLibrary: '儲存',
+    sliceIn: '儲存並在 {{slicer}} 中切片',
+    disclaimer: 'MakerWorld 整合使用由社群記錄的 API 介面。Bambuddy 與 MakerWorld 或 Bambu Lab 無從屬或認可關係。',
+    lastImportSuccess: '已匯入您的資料庫',
+    lastImportAlreadyInLibrary: '已存在於您的資料庫中',
+    viewInLibrary: '在檔案管理員中查看',
+    openInBambuStudio: '在 Bambu Studio 中開啟',
+    openInOrcaSlicer: '在 OrcaSlicer 中開啟',
+    importTo: '匯入至檔案管理員',
+    recentImportsHeader: '最近匯入',
+    phaseResolving: '解析中',
+    phaseDownloading: '下載中',
+    folderAuto: 'MakerWorld (預設)',
+    importAll: '全部匯入',
+    importAllProgress: '正在匯入 {{current}}/{{total}}',
+    openGallery: '開啟圖片庫',
+    galleryPrev: '上一張',
+    galleryNext: '下一張',
+    deleteImport: '從資料庫中移除',
+    importDeleting: '正在移除…',
+    importDeleted: '已從資料庫中移除',
+    confirmDelete: '從資料庫中移除 {{filename}}?本機檔案將被刪除,但可以從 MakerWorld 重新匯入。',
+    errors: {
+      resolveFailed: '無法解析該 MakerWorld 連結。',
+      downloadFailed: '下載失敗。請重試。',
+      deleteFailed: '無法從資料庫中移除檔案。',
+    },
+  },
 };

+ 926 - 0
frontend/src/pages/MakerworldPage.tsx

@@ -0,0 +1,926 @@
+import { useEffect, useMemo, useState } from 'react';
+import DOMPurify from 'dompurify';
+import { Link } from 'react-router-dom';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import { AlertCircle, ArrowRight, Check, ChevronLeft, ChevronRight, Download, ExternalLink, FolderOpen, Globe, Images, Loader2, Trash2, X } from 'lucide-react';
+
+import {
+  api,
+  type MakerworldImportResponse,
+  type MakerworldRecentImport,
+  type MakerworldResolvedModel,
+} from '../api/client';
+import { openInSlicer, type SlicerType } from '../utils/slicer';
+import { Button } from '../components/Button';
+import { Card, CardContent, CardHeader } from '../components/Card';
+import { ConfirmModal } from '../components/ConfirmModal';
+import { useAuth } from '../contexts/AuthContext';
+import { useToast } from '../contexts/ToastContext';
+
+// MakerWorld's API payloads are passed through as opaque dicts; these helpers
+// pull known fields out in a type-safe way so a missing/renamed field shows
+// up as an empty string rather than crashing the render.
+function pickString(obj: Record<string, unknown> | undefined, key: string): string {
+  const value = obj?.[key];
+  return typeof value === 'string' ? value : '';
+}
+
+// Rewrite MakerWorld CDN URLs inside HTML content (design summary, etc.) to
+// use Bambuddy's thumbnail proxy. MakerWorld summaries are authored HTML and
+// commonly contain ``<img src="https://makerworld.bblmw.com/...">`` tags;
+// Bambuddy's img-src CSP only allows ``'self' data: blob:``, so these would
+// otherwise be blocked. Pairs with ``proxyCdn`` below for explicit <img>
+// renders.
+function proxyCdnUrlsInHtml(html: string): string {
+  return html.replace(
+    /(https?:\/\/(?:makerworld|public-cdn)\.bblmw\.com\/[^\s"']+)/gi,
+    (match) => `/api/v1/makerworld/thumbnail?url=${encodeURIComponent(match)}`,
+  );
+}
+
+// MakerWorld CDN images can't be hotlinked — Bambuddy's img-src CSP blocks
+// external hosts. Route them through the /makerworld/thumbnail proxy.
+// Empty string in → empty string out so the ``{coverUrl && ...}`` checks
+// in the render keep short-circuiting.
+function proxyCdn(url: string): string {
+  if (!url) return '';
+  if (!/^https?:\/\/(makerworld|public-cdn)\.bblmw\.com\//i.test(url)) return url;
+  return `/api/v1/makerworld/thumbnail?url=${encodeURIComponent(url)}`;
+}
+function pickNumber(obj: Record<string, unknown> | undefined, key: string): number | null {
+  const value = obj?.[key];
+  return typeof value === 'number' ? value : null;
+}
+function pickObject(obj: Record<string, unknown> | undefined, key: string): Record<string, unknown> | undefined {
+  const value = obj?.[key];
+  return value && typeof value === 'object' && !Array.isArray(value)
+    ? (value as Record<string, unknown>)
+    : undefined;
+}
+
+// Depth-first flatten of the library folder tree so it can be rendered in a
+// single <select>. Each entry carries its ``depth`` so the UI can indent the
+// option label.
+type FlatFolder = { folder: import('../api/client').LibraryFolderTree; depth: number };
+function flattenFolderTree(
+  tree: import('../api/client').LibraryFolderTree,
+  depth = 0,
+  out: FlatFolder[] = [],
+): FlatFolder[] {
+  out.push({ folder: tree, depth });
+  for (const child of tree.children ?? []) {
+    flattenFolderTree(child, depth + 1, out);
+  }
+  return out;
+}
+
+// Time-based phase heuristic for the import progress indicator. The backend
+// does the work as one synchronous HTTP request (no streaming progress), so
+// we guess the phase from elapsed wall-clock time. These numbers reflect
+// typical 3MF downloads (5–30 s total, dominated by the S3 GET):
+//   0–1 s:  metadata fetch (fast, just the iot-service + design lookups)
+//   1–<end> s: downloading the 3MF bytes
+//   The last moment also flashes "Saving…" but we can't actually observe
+//   the save step on the wire, so we let the download phase run until the
+//   mutation resolves.
+function phaseLabelForElapsed(elapsedSec: number, t: (k: string) => string): string {
+  if (elapsedSec < 1) return t('makerworld.phaseResolving');
+  return t('makerworld.phaseDownloading');
+}
+
+function useElapsedSeconds(active: boolean): number {
+  const [elapsed, setElapsed] = useState(0);
+  useEffect(() => {
+    if (!active) {
+      setElapsed(0);
+      return;
+    }
+    const start = Date.now();
+    const tick = () => setElapsed(Math.floor((Date.now() - start) / 1000));
+    tick();
+    const id = window.setInterval(tick, 1000);
+    return () => window.clearInterval(id);
+  }, [active]);
+  return elapsed;
+}
+
+export function MakerworldPage() {
+  const { t } = useTranslation();
+  const { hasPermission } = useAuth();
+  const { showToast } = useToast();
+  const queryClient = useQueryClient();
+
+  const canImport = hasPermission('makerworld:import');
+
+  const [urlInput, setUrlInput] = useState('');
+  const [resolved, setResolved] = useState<MakerworldResolvedModel | null>(null);
+  // Selected target folder. ``null`` means "let the backend use the default
+  // MakerWorld folder" (auto-created if missing). Any other value is the id
+  // of a user-selected folder; external read-only folders are filtered out
+  // of the picker because the backend rejects those with 403.
+  const [selectedFolderId, setSelectedFolderId] = useState<number | null>(null);
+  // Bulk-import progress. ``null`` when idle; ``{current, total}`` while
+  // the "Import all" button is walking through ``instances[]``.
+  const [bulkProgress, setBulkProgress] = useState<{ current: number; total: number } | null>(null);
+  // Pending delete confirmation. ``null`` when no modal is open; otherwise
+  // carries the ids/filename needed to run the delete when the user confirms.
+  // Kept separate from the mutation state so the modal renders as soon as the
+  // user clicks the trash icon, not only while the request is in flight.
+  const [pendingDelete, setPendingDelete] = useState<
+    | { libraryFileId: number; profileId: number; filename: string }
+    | null
+  >(null);
+  // Lightbox state for the image gallery. When ``null`` the lightbox is closed.
+  // ``images`` is the set of {name, url} captured at click-time (we don't mutate
+  // it while the lightbox is open, so navigation is stable even if the underlying
+  // instance array changes underneath).
+  const [lightbox, setLightbox] = useState<
+    | { images: Array<{ name: string; url: string }>; index: number }
+    | null
+  >(null);
+  // Which URL the current ``resolved`` state was fetched for. When the user
+  // edits ``urlInput`` away from this, we clear ``resolved`` — otherwise the
+  // stale preview stays on screen and the Import button would submit the
+  // *previous* model_id, dedupe'ing against the wrong row.
+  const [resolvedForUrl, setResolvedForUrl] = useState<string>('');
+  // All successful imports done during this resolved-model session, keyed
+  // by the plate's ``profileId``. Used to render inline 'View in Library'
+  // / 'Open in slicer' buttons directly on each imported plate row so the
+  // user sees the follow-up actions right where they clicked (instead of
+  // having to scroll back to a top-of-page card). Cleared when the user
+  // resolves a fresh URL or edits the pasted URL.
+  const [importsByProfile, setImportsByProfile] = useState<
+    Record<number, MakerworldImportResponse>
+  >({});
+
+  const statusQuery = useQuery({
+    queryKey: ['makerworld-status'],
+    queryFn: () => api.getMakerworldStatus(),
+  });
+
+  const foldersQuery = useQuery({
+    queryKey: ['library-folders'],
+    queryFn: () => api.getLibraryFolders(),
+  });
+
+  const recentQuery = useQuery({
+    queryKey: ['makerworld-recent-imports'],
+    queryFn: () => api.getMakerworldRecentImports(10),
+  });
+
+  const settingsQuery = useQuery({
+    queryKey: ['settings'],
+    queryFn: () => api.getSettings(),
+  });
+  // MakerWorld plates are unsliced project files — they can't be sent
+  // directly to a printer. The "slice in slicer" action below imports the
+  // 3MF and hands it to the user's configured slicer; from there the
+  // slicer's own "send to printer" flow takes over.
+  const preferredSlicer: SlicerType = settingsQuery.data?.preferred_slicer || 'bambu_studio';
+  const preferredSlicerName =
+    preferredSlicer === 'orcaslicer' ? 'OrcaSlicer' : 'Bambu Studio';
+
+  const resolveMutation = useMutation({
+    mutationFn: (url: string) => api.resolveMakerworldUrl(url),
+    onSuccess: (data, url) => {
+      setResolved(data);
+      setResolvedForUrl(url);
+      // Fresh resolve — clear any success card from a previous model.
+      setImportsByProfile({});
+    },
+    onError: (err: Error) => showToast(err.message || t('makerworld.errors.resolveFailed'), 'error'),
+  });
+
+  // URL-change detection: if the user edits the URL input away from what
+  // ``resolved`` was fetched for, drop the stale preview so they can't
+  // accidentally import the previous model. Whitespace-only differences
+  // don't count.
+  useEffect(() => {
+    if (resolved !== null && urlInput.trim() !== resolvedForUrl.trim()) {
+      setResolved(null);
+      setResolvedForUrl('');
+      setImportsByProfile({});
+    }
+  }, [urlInput, resolved, resolvedForUrl]);
+
+  const importMutation = useMutation({
+    mutationFn: ({ instanceId, profileId }: { instanceId: number; profileId: number | null }) =>
+      api.importMakerworldInstance(resolved?.model_id ?? 0, instanceId, profileId, selectedFolderId),
+    onSuccess: (data) => {
+      queryClient.invalidateQueries({ queryKey: ['library-files'] });
+      // Backend auto-creates a "MakerWorld" folder on first import; refresh
+      // the folder tree so users see it without having to reload the page.
+      queryClient.invalidateQueries({ queryKey: ['library-folders'] });
+      // Track by profile_id so each plate's row can render its own inline
+      // follow-up buttons even after multiple imports in the same session.
+      if (data.profile_id) {
+        setImportsByProfile((prev) => ({ ...prev, [data.profile_id!]: data }));
+      }
+      showToast(
+        data.was_existing ? t('makerworld.alreadyInLibrary') : t('makerworld.importSuccess', { filename: data.filename }),
+        'success',
+      );
+    },
+    onError: (err: Error) => showToast(err.message || t('makerworld.errors.downloadFailed'), 'error'),
+  });
+
+  // "Print Now" is a two-step mutation: import to library, then open the
+  // existing PrintModal. We chain manually rather than composing mutations
+  // so the modal gets the library_file_id the moment it lands.
+  // Per-plate delete: removes a previously-imported plate from the library
+  // (file + DB row). Used by the inline trash-icon button on imported plates
+  // so users can quickly undo an accidental import without navigating to
+  // File Manager. ``profileId`` is only used for local state cleanup.
+  const deleteImportMutation = useMutation({
+    mutationFn: ({ libraryFileId }: { libraryFileId: number; profileId: number }) =>
+      api.deleteLibraryFile(libraryFileId),
+    onSuccess: (_data, { profileId }) => {
+      queryClient.invalidateQueries({ queryKey: ['library-files'] });
+      queryClient.invalidateQueries({ queryKey: ['library-folders'] });
+      queryClient.invalidateQueries({ queryKey: ['makerworld-recent-imports'] });
+      setImportsByProfile((prev) => {
+        const next = { ...prev };
+        delete next[profileId];
+        return next;
+      });
+      setPendingDelete(null);
+      showToast(t('makerworld.importDeleted'), 'success');
+    },
+    onError: (err: Error) => {
+      setPendingDelete(null);
+      showToast(err.message || t('makerworld.errors.deleteFailed'), 'error');
+    },
+  });
+
+  // "Slice in BambuStudio / OrcaSlicer" — imports the plate then hands the
+  // file off to the configured slicer. MakerWorld plates are unsliced source
+  // files, so we can't send them straight to the printer; the slicer is the
+  // user's actual "I want to print this" destination. Mirrors MakerWorld's
+  // own "Download and Open" button behaviour.
+  const sliceMutation = useMutation({
+    mutationFn: ({ instanceId, profileId }: { instanceId: number; profileId: number | null }) =>
+      api.importMakerworldInstance(resolved?.model_id ?? 0, instanceId, profileId, selectedFolderId),
+    onSuccess: async (data: MakerworldImportResponse) => {
+      queryClient.invalidateQueries({ queryKey: ['library-files'] });
+      queryClient.invalidateQueries({ queryKey: ['library-folders'] });
+      queryClient.invalidateQueries({ queryKey: ['makerworld-recent-imports'] });
+      if (data.profile_id) {
+        setImportsByProfile((prev) => ({ ...prev, [data.profile_id!]: data }));
+      }
+      await handleOpenInSlicer(data.library_file_id, data.filename, preferredSlicer);
+    },
+    onError: (err: Error) => showToast(err.message || t('makerworld.errors.downloadFailed'), 'error'),
+  });
+
+  // Tick while an import is in-flight so we can show "Downloading… (12 s)"
+  // instead of a bare spinner. Only one import runs at a time (bulk is
+  // sequential), so a single counter covers both the per-row button label
+  // and the bulk-import progress label.
+  const importElapsed = useElapsedSeconds(importMutation.isPending || sliceMutation.isPending);
+  const importPhaseLabel = phaseLabelForElapsed(importElapsed, t);
+
+  const handleResolve = (e?: React.FormEvent) => {
+    e?.preventDefault();
+    const trimmed = urlInput.trim();
+    if (!trimmed) return;
+    resolveMutation.mutate(trimmed);
+  };
+
+  // Keyboard navigation for the lightbox (Escape closes, arrows navigate).
+  useEffect(() => {
+    if (!lightbox) return;
+    const handler = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') setLightbox(null);
+      else if (e.key === 'ArrowLeft') {
+        setLightbox((prev) => (prev && prev.index > 0 ? { ...prev, index: prev.index - 1 } : prev));
+      } else if (e.key === 'ArrowRight') {
+        setLightbox((prev) =>
+          prev && prev.index < prev.images.length - 1 ? { ...prev, index: prev.index + 1 } : prev,
+        );
+      }
+    };
+    window.addEventListener('keydown', handler);
+    return () => window.removeEventListener('keydown', handler);
+  }, [lightbox]);
+
+  // Extract the gallery images for a plate. MakerWorld returns an ``instance.pictures``
+  // array of {name, url, isRealLifePhoto}; falls back to the single ``cover`` URL
+  // when pictures is empty so the lightbox still shows something.
+  const getInstanceImages = (inst: Record<string, unknown>): Array<{ name: string; url: string }> => {
+    const pictures = Array.isArray(inst['pictures']) ? (inst['pictures'] as unknown[]) : [];
+    const fromPictures = pictures
+      .filter((p): p is Record<string, unknown> => p !== null && typeof p === 'object')
+      .map((p) => ({ name: pickString(p, 'name') || 'image', url: pickString(p, 'url') }))
+      .filter((p) => p.url);
+    if (fromPictures.length > 0) return fromPictures;
+    const cover = pickString(inst, 'cover');
+    return cover ? [{ name: 'cover', url: cover }] : [];
+  };
+
+  // "Import all plates" — walks through ``instances[]`` sequentially (not
+  // in parallel) so we don't hammer the Bambu API. Skips plates that have
+  // already been imported in this session. On per-plate failure, shows the
+  // error toast but continues with the next plate (partial success is
+  // better than a whole-batch abort).
+  const handleImportAll = async () => {
+    if (!resolved) return;
+    const plates = resolved.instances.filter((inst) => {
+      const pid = pickNumber(inst, 'profileId');
+      return pid !== null && !importsByProfile[pid];
+    });
+    if (plates.length === 0) return;
+
+    setBulkProgress({ current: 0, total: plates.length });
+    try {
+      for (let i = 0; i < plates.length; i += 1) {
+        const inst = plates[i];
+        const instanceId = pickNumber(inst, 'id');
+        const profileId = pickNumber(inst, 'profileId');
+        if (instanceId === null || profileId === null) continue;
+        setBulkProgress({ current: i + 1, total: plates.length });
+        try {
+          await importMutation.mutateAsync({ instanceId, profileId });
+        } catch {
+          // Per-plate failure already surfaces a toast via ``onError``; we
+          // just continue so a flaky single profile doesn't kill the batch.
+        }
+      }
+    } finally {
+      setBulkProgress(null);
+    }
+  };
+
+  const handleOpenInSlicer = async (
+    fileId: number,
+    filename: string,
+    slicer: 'bambu_studio' | 'orcaslicer',
+  ) => {
+    // Slicer protocol handlers can't send Authorization headers, so we mint a
+    // short-lived single-use path-embedded token and hand the slicer that URL
+    // instead of the auth-gated /download endpoint. Mirrors ArchivesPage's
+    // ``openInSlicerWithToken`` pattern.
+    try {
+      const { token } = await api.createLibrarySlicerToken(fileId);
+      const path = api.getLibrarySlicerDownloadUrl(fileId, token, filename);
+      openInSlicer(`${window.location.origin}${path}`, slicer);
+    } catch {
+      // Auth-disabled fallback — the plain download URL is already public
+      // in that case.
+      const path = api.getLibraryFileDownloadUrl(fileId);
+      openInSlicer(`${window.location.origin}${path}`, slicer);
+    }
+  };
+
+  const design = resolved?.design;
+  const creator = pickObject(design, 'designCreator');
+  const instances = resolved?.instances ?? [];
+  const alreadyImported = (resolved?.already_imported_library_ids.length ?? 0) > 0;
+
+  const hasToken = statusQuery.data?.has_cloud_token ?? false;
+  // Only block Print Now / Import actions on an import-capable login.
+  // Browse/resolve works anonymously.
+  const canDownload = statusQuery.data?.can_download ?? false;
+
+  const coverUrl = useMemo(() => pickString(design, 'coverUrl'), [design]);
+  const title = pickString(design, 'title');
+  const summaryHtml = pickString(design, 'summary');
+  const license = pickString(design, 'license');
+  const downloadCount = pickNumber(design, 'downloadCount');
+
+  return (
+    <div className="p-6 max-w-screen-2xl mx-auto space-y-6">
+      <div className="flex items-center gap-3">
+        <Globe className="w-7 h-7 text-brand-500" />
+        <h1 className="text-2xl font-bold">{t('makerworld.title')}</h1>
+      </div>
+
+      <p className="text-sm text-gray-600 dark:text-gray-400">
+        {t('makerworld.description')}
+      </p>
+
+      {/* Two-column layout: main flow on the left, sticky "Recent imports"
+          sidebar on the right at lg+. Collapses to single column on narrow
+          screens (tablet/phone), with the sidebar tucked below the main flow. */}
+      <div className="grid gap-6 lg:grid-cols-[1fr_20rem]">
+        <div className="space-y-6 min-w-0">
+      {!hasToken && (
+        <Card className="border-amber-300 dark:border-amber-700 bg-amber-50 dark:bg-amber-900/20">
+          <CardContent>
+            <div className="flex items-start gap-3 py-2">
+              <AlertCircle className="w-5 h-5 text-amber-600 dark:text-amber-400 mt-0.5 shrink-0" />
+              <div className="text-sm">
+                <p className="font-medium text-amber-900 dark:text-amber-100">
+                  {t('makerworld.signInRequiredTitle')}
+                </p>
+                <p className="text-amber-800 dark:text-amber-200 mt-1">
+                  {t('makerworld.signInRequiredBody')}{' '}
+                  <Link to="/settings?tab=cloud" className="underline">
+                    {t('makerworld.openCloudSettings')}
+                  </Link>
+                </p>
+              </div>
+            </div>
+          </CardContent>
+        </Card>
+      )}
+
+      <Card>
+        <CardHeader>
+          <h2 className="text-lg font-semibold">{t('makerworld.pasteUrlHeader')}</h2>
+        </CardHeader>
+        <CardContent>
+          <form onSubmit={handleResolve} className="flex gap-2">
+            <input
+              type="text"
+              value={urlInput}
+              onChange={(e) => setUrlInput(e.target.value)}
+              placeholder={t('makerworld.pasteUrlPlaceholder')}
+              className="flex-1 min-w-0 px-3 py-2 border rounded bg-white dark:bg-gray-800 border-gray-300 dark:border-gray-700"
+              autoComplete="off"
+            />
+            <Button
+              type="submit"
+              variant="primary"
+              disabled={!urlInput.trim() || resolveMutation.isPending}
+            >
+              {resolveMutation.isPending ? (
+                <Loader2 className="w-4 h-4 animate-spin" />
+              ) : (
+                <ArrowRight className="w-4 h-4" />
+              )}
+              <span className="ml-2">{t('makerworld.resolveButton')}</span>
+            </Button>
+          </form>
+        </CardContent>
+      </Card>
+
+      {resolved && (
+        <Card>
+          <CardContent>
+            <div className="flex gap-4 py-2">
+              {coverUrl && (
+                <img
+                  src={proxyCdn(coverUrl)}
+                  alt={title}
+                  className="w-32 h-32 object-cover rounded"
+                  loading="lazy"
+                />
+              )}
+              <div className="flex-1 min-w-0">
+                <h3 className="text-xl font-semibold truncate">{title || t('makerworld.untitledModel')}</h3>
+                {creator && (
+                  <p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
+                    {t('makerworld.byCreator', { name: pickString(creator, 'name') })}
+                  </p>
+                )}
+                <div className="flex flex-wrap gap-3 mt-2 text-xs text-gray-500 dark:text-gray-400">
+                  {downloadCount !== null && (
+                    <span>{t('makerworld.downloadsCount', { count: downloadCount })}</span>
+                  )}
+                  {license && <span>{t('makerworld.licensePrefix')}: {license}</span>}
+                  {alreadyImported && (
+                    <span className="inline-flex items-center gap-1 text-emerald-600 dark:text-emerald-400">
+                      <Check className="w-3 h-3" /> {t('makerworld.alreadyImported')}
+                    </span>
+                  )}
+                </div>
+                {summaryHtml && (
+                  <div
+                    className="mt-3 text-sm prose prose-sm max-w-none dark:prose-invert line-clamp-3"
+                    // Two-stage processing:
+                    //   1. ``proxyCdnUrlsInHtml`` rewrites <img src="…bblmw.com…">
+                    //      so CSP allows the image load.
+                    //   2. ``DOMPurify.sanitize`` strips scripts, event handlers,
+                    //      javascript: URLs, and other XSS vectors. MakerWorld
+                    //      summaries are user-authored and cannot be trusted.
+                    dangerouslySetInnerHTML={{
+                      __html: DOMPurify.sanitize(proxyCdnUrlsInHtml(summaryHtml)),
+                    }}
+                  />
+                )}
+                {resolved && (
+                  <a
+                    href={`https://makerworld.com/models/${resolved.model_id}${resolved.profile_id ? `#profileId-${resolved.profile_id}` : ''}`}
+                    target="_blank"
+                    rel="noopener noreferrer"
+                    className="mt-3 inline-flex items-center gap-1 text-xs text-brand-500 hover:underline"
+                  >
+                    <ExternalLink className="w-3 h-3" /> {t('makerworld.openOnMakerworld')}
+                  </a>
+                )}
+              </div>
+            </div>
+          </CardContent>
+        </Card>
+      )}
+
+      {resolved && instances.length > 0 && (
+        <Card>
+          <CardHeader>
+            <div className="flex flex-wrap items-center justify-between gap-3">
+              <h2 className="text-lg font-semibold">{t('makerworld.platesHeader', { count: instances.length })}</h2>
+              <div className="flex flex-wrap items-center gap-2">
+                <label className="text-xs text-gray-600 dark:text-gray-400">
+                  {t('makerworld.importTo')}
+                </label>
+                <select
+                  value={selectedFolderId ?? ''}
+                  onChange={(e) => setSelectedFolderId(e.target.value ? Number(e.target.value) : null)}
+                  className="text-sm px-2 py-1 border rounded bg-white dark:bg-gray-800 border-gray-300 dark:border-gray-700"
+                  disabled={bulkProgress !== null}
+                >
+                  <option value="">{t('makerworld.folderAuto')}</option>
+                  {(foldersQuery.data ?? [])
+                    .filter((f) => !(f.is_external && f.external_readonly))
+                    .flatMap((f) => flattenFolderTree(f))
+                    .map(({ folder, depth }) => (
+                      <option key={folder.id} value={folder.id}>
+                        {`${'— '.repeat(depth)}${folder.name}`}
+                      </option>
+                    ))}
+                </select>
+                <Button
+                  variant="primary"
+                  size="sm"
+                  disabled={
+                    !canImport ||
+                    !canDownload ||
+                    bulkProgress !== null ||
+                    importMutation.isPending ||
+                    sliceMutation.isPending
+                  }
+                  onClick={handleImportAll}
+                >
+                  {bulkProgress !== null ? (
+                    <>
+                      <Loader2 className="w-4 h-4 animate-spin" />
+                      <span className="ml-2">
+                        {t('makerworld.importAllProgress', { current: bulkProgress.current, total: bulkProgress.total })}
+                        {importElapsed > 0 && ` · ${importPhaseLabel} · ${importElapsed}s`}
+                      </span>
+                    </>
+                  ) : (
+                    <>
+                      <Download className="w-4 h-4" />
+                      <span className="ml-2">{t('makerworld.importAll')}</span>
+                    </>
+                  )}
+                </Button>
+              </div>
+            </div>
+          </CardHeader>
+          <CardContent>
+            <div className="grid gap-3">
+              {instances.map((inst, idx) => {
+                const instanceId = pickNumber(inst, 'id');
+                const profileId = pickNumber(inst, 'profileId');
+                const instanceTitle = pickString(inst, 'title');
+                const cover = pickString(inst, 'cover');
+                const materialCnt = pickNumber(inst, 'materialCnt');
+                const needAms = inst?.['needAms'] === true;
+                const downloadsOnInstance = pickNumber(inst, 'downloadCount');
+                if (instanceId == null) return null;
+                const isImporting = importMutation.isPending && importMutation.variables?.instanceId === instanceId;
+                const isPrinting = sliceMutation.isPending && sliceMutation.variables?.instanceId === instanceId;
+                const imported = profileId !== null ? importsByProfile[profileId] : undefined;
+                return (
+                  <div
+                    key={instanceId}
+                    className="flex flex-col gap-2 p-3 border rounded border-gray-200 dark:border-gray-700"
+                  >
+                    <div className="flex gap-3 items-center">
+                      {(() => {
+                        const gallery = getInstanceImages(inst);
+                        const canOpen = gallery.length > 0;
+                        return (
+                          <button
+                            type="button"
+                            disabled={!canOpen}
+                            onClick={() => canOpen && setLightbox({ images: gallery, index: 0 })}
+                            className="relative w-16 h-16 shrink-0 rounded overflow-hidden group"
+                            aria-label={t('makerworld.openGallery')}
+                          >
+                            {cover ? (
+                              <img
+                                src={proxyCdn(cover)}
+                                alt=""
+                                className="w-16 h-16 object-cover"
+                                loading="lazy"
+                              />
+                            ) : (
+                              <div className="w-16 h-16 bg-gray-100 dark:bg-gray-800" />
+                            )}
+                            {gallery.length > 1 && (
+                              <span className="absolute bottom-0.5 right-0.5 bg-black/70 text-white text-[10px] px-1.5 py-0.5 rounded flex items-center gap-1">
+                                <Images className="w-2.5 h-2.5" />
+                                {gallery.length}
+                              </span>
+                            )}
+                          </button>
+                        );
+                      })()}
+                      <div className="flex-1 min-w-0">
+                        <p className="font-medium truncate">
+                          {instanceTitle || t('makerworld.plateDefaultName', { n: idx + 1 })}
+                        </p>
+                        <div className="flex flex-wrap gap-3 text-xs text-gray-500 dark:text-gray-400 mt-1">
+                          {materialCnt !== null && (
+                            <span>{t('makerworld.materialCount', { count: materialCnt })}</span>
+                          )}
+                          {needAms && <span>{t('makerworld.amsRequired')}</span>}
+                          {downloadsOnInstance !== null && (
+                            <span>{t('makerworld.downloadsCount', { count: downloadsOnInstance })}</span>
+                          )}
+                        </div>
+                      </div>
+                      <div className="flex gap-2 shrink-0">
+                        <Button
+                          variant="ghost"
+                          size="sm"
+                          disabled={!canImport || !canDownload || isImporting || isPrinting || bulkProgress !== null}
+                          onClick={() => importMutation.mutate({ instanceId, profileId })}
+                          title={!canDownload ? t('makerworld.signInRequiredTitle') : undefined}
+                        >
+                          {isImporting ? (
+                            <>
+                              <Loader2 className="w-4 h-4 animate-spin" />
+                              <span className="ml-2">
+                                {importPhaseLabel}
+                                {importElapsed > 0 && ` · ${importElapsed}s`}
+                              </span>
+                            </>
+                          ) : (
+                            <>
+                              <Download className="w-4 h-4" />
+                              <span className="ml-2">{t('makerworld.importToLibrary')}</span>
+                            </>
+                          )}
+                        </Button>
+                        <Button
+                          variant="primary"
+                          size="sm"
+                          disabled={!canImport || !canDownload || isImporting || isPrinting || bulkProgress !== null}
+                          onClick={() => sliceMutation.mutate({ instanceId, profileId })}
+                          title={!canDownload ? t('makerworld.signInRequiredTitle') : undefined}
+                        >
+                          {isPrinting ? (
+                            <>
+                              <Loader2 className="w-4 h-4 animate-spin" />
+                              <span className="ml-2">
+                                {importPhaseLabel}
+                                {importElapsed > 0 && ` · ${importElapsed}s`}
+                              </span>
+                            </>
+                          ) : (
+                            <>
+                              <ExternalLink className="w-4 h-4" />
+                              <span className="ml-2">
+                                {t('makerworld.sliceIn', { slicer: preferredSlicerName })}
+                              </span>
+                            </>
+                          )}
+                        </Button>
+                      </div>
+                    </div>
+                    {imported && (
+                      <div className="flex items-center gap-2 pl-20 text-xs">
+                        <Check className="w-3.5 h-3.5 text-emerald-600 dark:text-emerald-400 shrink-0" />
+                        <span className="text-emerald-700 dark:text-emerald-300">
+                          {imported.was_existing
+                            ? t('makerworld.lastImportAlreadyInLibrary')
+                            : t('makerworld.lastImportSuccess')}
+                        </span>
+                        <Button
+                          variant="ghost"
+                          size="sm"
+                          onClick={() => {
+                            const target = imported.folder_id
+                              ? `/files?folder=${imported.folder_id}`
+                              : '/files';
+                            window.location.assign(target);
+                          }}
+                        >
+                          <FolderOpen className="w-3.5 h-3.5" />
+                          <span className="ml-1.5">{t('makerworld.viewInLibrary')}</span>
+                        </Button>
+                        <Button
+                          variant="ghost"
+                          size="sm"
+                          onClick={() =>
+                            handleOpenInSlicer(imported.library_file_id, imported.filename, 'bambu_studio')
+                          }
+                        >
+                          <ExternalLink className="w-3.5 h-3.5" />
+                          <span className="ml-1.5">{t('makerworld.openInBambuStudio')}</span>
+                        </Button>
+                        <Button
+                          variant="ghost"
+                          size="sm"
+                          onClick={() =>
+                            handleOpenInSlicer(imported.library_file_id, imported.filename, 'orcaslicer')
+                          }
+                        >
+                          <ExternalLink className="w-3.5 h-3.5" />
+                          <span className="ml-1.5">{t('makerworld.openInOrcaSlicer')}</span>
+                        </Button>
+                        <div className="ml-auto">
+                          <Button
+                            variant="ghost"
+                            size="sm"
+                            disabled={
+                              deleteImportMutation.isPending &&
+                              deleteImportMutation.variables?.profileId === profileId
+                            }
+                            onClick={() => {
+                              if (profileId === null) return;
+                              setPendingDelete({
+                                libraryFileId: imported.library_file_id,
+                                profileId,
+                                filename: imported.filename,
+                              });
+                            }}
+                            title={t('makerworld.deleteImport')}
+                          >
+                            {deleteImportMutation.isPending &&
+                            deleteImportMutation.variables?.profileId === profileId ? (
+                              <Loader2 className="w-3.5 h-3.5 animate-spin" />
+                            ) : (
+                              <Trash2 className="w-3.5 h-3.5 text-red-500" />
+                            )}
+                          </Button>
+                        </div>
+                      </div>
+                    )}
+                  </div>
+                );
+              })}
+            </div>
+          </CardContent>
+        </Card>
+      )}
+
+        </div>
+
+        {/* Right column — Recent imports sidebar. Sticky at lg+ so it stays
+            reachable while browsing long plate lists. Vertical list here,
+            not the horizontal scroll we used in the bottom-of-page layout. */}
+        <aside className="lg:sticky lg:top-6 lg:self-start min-w-0">
+          {recentQuery.data && recentQuery.data.length > 0 && (
+            <Card>
+              <CardHeader>
+                <h2 className="text-base font-semibold">{t('makerworld.recentImportsHeader')}</h2>
+              </CardHeader>
+              <CardContent>
+                <div className="flex flex-col gap-2 max-h-[28rem] overflow-y-auto -mx-2 px-2">
+                  {recentQuery.data.map((item: MakerworldRecentImport) => (
+                    <div
+                      key={item.library_file_id}
+                      className="flex gap-2 p-2 border rounded border-gray-200 dark:border-gray-700"
+                    >
+                      {item.thumbnail_path ? (
+                        <img
+                          src={api.getLibraryFileThumbnailUrl(item.library_file_id)}
+                          alt=""
+                          className="w-12 h-12 shrink-0 object-cover rounded bg-gray-100 dark:bg-gray-800"
+                          loading="lazy"
+                        />
+                      ) : (
+                        <div className="w-12 h-12 shrink-0 rounded bg-gray-100 dark:bg-gray-800" />
+                      )}
+                      <div className="flex-1 min-w-0 flex flex-col gap-1">
+                        <p className="text-xs font-medium truncate" title={item.filename}>
+                          {item.filename}
+                        </p>
+                        <div className="flex gap-0.5">
+                          <Button
+                            variant="ghost"
+                            size="sm"
+                            onClick={() => {
+                              const target = item.folder_id
+                                ? `/files?folder=${item.folder_id}`
+                                : '/files';
+                              window.location.assign(target);
+                            }}
+                            title={t('makerworld.viewInLibrary')}
+                          >
+                            <FolderOpen className="w-3.5 h-3.5" />
+                          </Button>
+                          <Button
+                            variant="ghost"
+                            size="sm"
+                            onClick={() =>
+                              handleOpenInSlicer(item.library_file_id, item.filename, 'bambu_studio')
+                            }
+                            title={t('makerworld.openInBambuStudio')}
+                          >
+                            <ExternalLink className="w-3.5 h-3.5" />
+                          </Button>
+                          {item.source_url && (
+                            <a
+                              href={item.source_url}
+                              target="_blank"
+                              rel="noopener noreferrer"
+                              className="inline-flex items-center justify-center h-7 w-7 rounded text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
+                              title={t('makerworld.openOnMakerworld')}
+                            >
+                              <Globe className="w-3.5 h-3.5" />
+                            </a>
+                          )}
+                        </div>
+                      </div>
+                    </div>
+                  ))}
+                </div>
+              </CardContent>
+            </Card>
+          )}
+        </aside>
+      </div>
+
+      <p className="text-xs text-gray-500 dark:text-gray-400 pt-4 border-t border-gray-200 dark:border-gray-700">
+        {t('makerworld.disclaimer')}
+      </p>
+
+      {pendingDelete && (
+        <ConfirmModal
+          title={t('makerworld.deleteImport')}
+          message={t('makerworld.confirmDelete', { filename: pendingDelete.filename })}
+          confirmText={t('makerworld.deleteImport')}
+          variant="danger"
+          isLoading={deleteImportMutation.isPending}
+          loadingText={t('makerworld.importDeleting')}
+          onCancel={() => setPendingDelete(null)}
+          onConfirm={() =>
+            deleteImportMutation.mutate({
+              libraryFileId: pendingDelete.libraryFileId,
+              profileId: pendingDelete.profileId,
+            })
+          }
+        />
+      )}
+
+      {lightbox && (
+        <div
+          className="fixed inset-0 bg-black/90 flex items-center justify-center z-50"
+          onClick={() => setLightbox(null)}
+          role="dialog"
+          aria-modal="true"
+        >
+          <button
+            type="button"
+            className="absolute top-4 right-4 p-2 bg-white/10 hover:bg-white/20 rounded-full text-white"
+            onClick={(e) => {
+              e.stopPropagation();
+              setLightbox(null);
+            }}
+            aria-label={t('common.close', 'Close')}
+          >
+            <X className="w-5 h-5" />
+          </button>
+          {lightbox.images.length > 1 && (
+            <>
+              <button
+                type="button"
+                className="absolute left-4 p-2 bg-white/10 hover:bg-white/20 rounded-full text-white disabled:opacity-30"
+                disabled={lightbox.index === 0}
+                onClick={(e) => {
+                  e.stopPropagation();
+                  setLightbox((prev) => (prev ? { ...prev, index: Math.max(0, prev.index - 1) } : prev));
+                }}
+                aria-label={t('makerworld.galleryPrev')}
+              >
+                <ChevronLeft className="w-6 h-6" />
+              </button>
+              <button
+                type="button"
+                className="absolute right-4 p-2 bg-white/10 hover:bg-white/20 rounded-full text-white disabled:opacity-30"
+                disabled={lightbox.index >= lightbox.images.length - 1}
+                onClick={(e) => {
+                  e.stopPropagation();
+                  setLightbox((prev) =>
+                    prev ? { ...prev, index: Math.min(prev.images.length - 1, prev.index + 1) } : prev,
+                  );
+                }}
+                aria-label={t('makerworld.galleryNext')}
+              >
+                <ChevronRight className="w-6 h-6" />
+              </button>
+            </>
+          )}
+          <img
+            src={proxyCdn(lightbox.images[lightbox.index].url)}
+            alt={lightbox.images[lightbox.index].name}
+            className="max-w-[90vw] max-h-[90vh] object-contain"
+            onClick={(e) => e.stopPropagation()}
+          />
+          {lightbox.images.length > 1 && (
+            <div className="absolute bottom-6 left-1/2 -translate-x-1/2 text-white bg-black/60 px-3 py-1 rounded text-xs">
+              {lightbox.index + 1} / {lightbox.images.length}
+            </div>
+          )}
+        </div>
+      )}
+    </div>
+  );
+}

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-CiCRNaHx.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-DequwckK.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-DpKB60dO.js


Some files were not shown because too many files changed in this diff