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