mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-19 07:33:18 +02:00
Merge pull request #2845 from pascalheidmann/refactor/modular-import
(Refactor): modularize import ("Makerworld tab")
This commit is contained in:
@@ -98,3 +98,8 @@ security/
|
||||
|
||||
test_pipeline_archive_source.3mf
|
||||
test_pipeline_run_1.3mf
|
||||
|
||||
# Python coverage artifacts
|
||||
.coverage
|
||||
.coverage.*
|
||||
htmlcov/
|
||||
|
||||
@@ -348,10 +348,8 @@ async def setup_auth(request: SetupRequest, db: AsyncSession = Depends(get_db)):
|
||||
# (#2530). Only migrate when there is exactly one obvious owner:
|
||||
# handing another admin's session a Bambu credential is not a
|
||||
# guess worth making.
|
||||
from backend.app.api.routes.cloud import (
|
||||
get_stored_token,
|
||||
migrate_global_cloud_token_to_user,
|
||||
)
|
||||
from backend.app.api.routes.cloud import migrate_global_cloud_token_to_user
|
||||
from backend.app.services.bambu_cloud_credentials import get_stored_token
|
||||
|
||||
if admin_created:
|
||||
cloud_owner = admin_user
|
||||
|
||||
@@ -7,7 +7,6 @@ Handles authentication and profile management with Bambu Cloud.
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
@@ -23,7 +22,7 @@ from backend.app.core.auth import (
|
||||
require_permission_if_auth_enabled,
|
||||
security,
|
||||
)
|
||||
from backend.app.core.database import async_session, get_db
|
||||
from backend.app.core.database import get_db
|
||||
from backend.app.core.permissions import Permission
|
||||
from backend.app.models.api_key import APIKey
|
||||
from backend.app.models.settings import Settings
|
||||
@@ -50,6 +49,22 @@ from backend.app.services.bambu_cloud import (
|
||||
BambuCloudService,
|
||||
invalidate_validation_cache,
|
||||
)
|
||||
|
||||
# Credential read/write lives in the services layer so feature packages can
|
||||
# consume it without importing the route layer. Imported here for this
|
||||
# module's own use; consumers should import from bambu_cloud_credentials
|
||||
# directly rather than through this route module.
|
||||
from backend.app.services.bambu_cloud_credentials import (
|
||||
CLOUD_EMAIL_KEY,
|
||||
CLOUD_REGION_KEY,
|
||||
CLOUD_TOKEN_INVALID_KEY,
|
||||
CLOUD_TOKEN_KEY,
|
||||
_clear_cloud_token_invalid,
|
||||
_normalise_region,
|
||||
get_stored_token,
|
||||
is_cloud_token_invalid,
|
||||
mark_cloud_token_invalid,
|
||||
)
|
||||
from backend.app.utils.filament_ids import filament_id_to_setting_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -166,100 +181,6 @@ async def resolve_api_key_cloud_owner(
|
||||
router = APIRouter(prefix="/cloud", tags=["cloud"], dependencies=[Depends(_cloud_api_key_gate)])
|
||||
|
||||
|
||||
# Keys for storing cloud credentials in settings
|
||||
CLOUD_TOKEN_KEY = "bambu_cloud_token"
|
||||
CLOUD_EMAIL_KEY = "bambu_cloud_email"
|
||||
CLOUD_REGION_KEY = "bambu_cloud_region"
|
||||
# Global (auth-disabled) counterpart of ``User.cloud_token_invalid_at``. Stores
|
||||
# an ISO timestamp; absent/empty means "not known to be dead".
|
||||
CLOUD_TOKEN_INVALID_KEY = "bambu_cloud_token_invalid_at"
|
||||
|
||||
|
||||
def _normalise_region(region: str | None) -> str:
|
||||
"""Treat NULL/empty as 'global' for legacy rows that predate the region column."""
|
||||
return region if region in ("global", "china") else "global"
|
||||
|
||||
|
||||
async def is_cloud_token_invalid(db: AsyncSession, user: User | None = None) -> bool:
|
||||
"""Whether the stored Bambu token is known to have been rejected.
|
||||
|
||||
Set by :func:`mark_cloud_token_invalid` the first time Bambu answers 401,
|
||||
cleared on a fresh login/logout. This is the only durable record we have:
|
||||
Bambu's access token is opaque (no readable expiry) and Bambuddy does not
|
||||
persist the refresh token, so without this flag a dead credential looks
|
||||
exactly like a live one.
|
||||
"""
|
||||
if user is not None:
|
||||
return user.cloud_token_invalid_at is not None
|
||||
result = await db.execute(select(Settings).where(Settings.key == CLOUD_TOKEN_INVALID_KEY))
|
||||
row = result.scalar_one_or_none()
|
||||
return bool(row and row.value)
|
||||
|
||||
|
||||
async def mark_cloud_token_invalid(user_id: int | None) -> None:
|
||||
"""Record that Bambu rejected the stored token.
|
||||
|
||||
Opens its own session on purpose. This runs from
|
||||
``BambuCloudService._on_auth_failure``, i.e. in the middle of a route that
|
||||
is about to fail — writing through that route's session would tie the flag
|
||||
to a transaction the route may still roll back, and the fact that the
|
||||
credential is dead is true regardless of how the request ends.
|
||||
|
||||
Best-effort: a bookkeeping failure must never replace the 401 the caller
|
||||
actually needs to see.
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
try:
|
||||
async with async_session() as db:
|
||||
if user_id is not None:
|
||||
await db.execute(update(User).where(User.id == user_id).values(cloud_token_invalid_at=now))
|
||||
else:
|
||||
result = await db.execute(select(Settings).where(Settings.key == CLOUD_TOKEN_INVALID_KEY))
|
||||
row = result.scalar_one_or_none()
|
||||
if row:
|
||||
row.value = now.isoformat()
|
||||
else:
|
||||
db.add(Settings(key=CLOUD_TOKEN_INVALID_KEY, value=now.isoformat()))
|
||||
await db.commit()
|
||||
logger.warning("Bambu Cloud rejected the stored token (user_id=%s) — marking the sign-in as expired", user_id)
|
||||
except Exception:
|
||||
logger.exception("Could not record the Bambu Cloud token as invalid")
|
||||
|
||||
|
||||
async def _clear_cloud_token_invalid(db: AsyncSession, user: User | None) -> None:
|
||||
"""Clear the rejected-token flag — called on every fresh login and logout."""
|
||||
if user is not None:
|
||||
await db.execute(update(User).where(User.id == user.id).values(cloud_token_invalid_at=None))
|
||||
return
|
||||
result = await db.execute(select(Settings).where(Settings.key == CLOUD_TOKEN_INVALID_KEY))
|
||||
row = result.scalar_one_or_none()
|
||||
if row:
|
||||
await db.delete(row)
|
||||
|
||||
|
||||
async def get_stored_token(db: AsyncSession, user: User | None = None) -> tuple[str | None, str | None, str]:
|
||||
"""Get stored cloud token, email, and region.
|
||||
|
||||
When a user is provided (auth enabled), returns that user's per-user credentials.
|
||||
When user is None (auth disabled), falls back to global Settings table.
|
||||
Region defaults to ``"global"`` when unset (including for rows that predate
|
||||
the ``cloud_region`` column).
|
||||
"""
|
||||
if user is not None:
|
||||
return user.cloud_token, user.cloud_email, _normalise_region(user.cloud_region)
|
||||
|
||||
# Fallback: global storage (auth disabled)
|
||||
result = await db.execute(
|
||||
select(Settings).where(Settings.key.in_([CLOUD_TOKEN_KEY, CLOUD_EMAIL_KEY, CLOUD_REGION_KEY]))
|
||||
)
|
||||
settings = {s.key: s.value for s in result.scalars().all()}
|
||||
return (
|
||||
settings.get(CLOUD_TOKEN_KEY),
|
||||
settings.get(CLOUD_EMAIL_KEY),
|
||||
_normalise_region(settings.get(CLOUD_REGION_KEY)),
|
||||
)
|
||||
|
||||
|
||||
async def store_token(db: AsyncSession, token: str, email: str, region: str, user: User | None = None) -> None:
|
||||
"""Store cloud token, email, and region.
|
||||
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
"""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.
|
||||
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
|
||||
@@ -16,19 +23,20 @@ import logging
|
||||
import os
|
||||
from urllib.parse import unquote
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
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 (
|
||||
get_stored_token,
|
||||
is_cloud_token_invalid,
|
||||
mark_cloud_token_invalid,
|
||||
resolve_api_key_cloud_owner,
|
||||
)
|
||||
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
|
||||
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
|
||||
@@ -41,74 +49,119 @@ from backend.app.schemas.makerworld import (
|
||||
MakerWorldResolveRequest,
|
||||
MakerWorldStatus,
|
||||
)
|
||||
from backend.app.services.makerworld import (
|
||||
MakerWorldAuthError,
|
||||
MakerWorldError,
|
||||
MakerWorldForbiddenError,
|
||||
MakerWorldNotFoundError,
|
||||
MakerWorldService,
|
||||
MakerWorldUnavailableError,
|
||||
MakerWorldUrlError,
|
||||
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"])
|
||||
|
||||
_SOURCE_TYPE = "makerworld"
|
||||
|
||||
def _provider_for_url(url: str) -> ModelProvider:
|
||||
"""Return the registered model provider that claims *url*.
|
||||
|
||||
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 — and,
|
||||
like it, records a rejected token so the whole app agrees the sign-in is
|
||||
dead rather than each feature failing on its own.
|
||||
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.
|
||||
"""
|
||||
token, _email, _region = await get_stored_token(db, user)
|
||||
user_id = user.id if user is not None else None
|
||||
return MakerWorldService(
|
||||
auth_token=token,
|
||||
on_auth_failure=lambda: mark_cloud_token_invalid(user_id),
|
||||
)
|
||||
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 _canonical_url(model_id: int, profile_id: int | None = None) -> str:
|
||||
"""Build a stable source_url we use for dedupe.
|
||||
def _provider_for_source(source_type: str) -> ModelProvider:
|
||||
"""Return the registered model provider with this ``source_type``.
|
||||
|
||||
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.
|
||||
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.
|
||||
"""
|
||||
if profile_id:
|
||||
return f"https://makerworld.com/models/{model_id}#profileId-{profile_id}"
|
||||
return f"https://makerworld.com/models/{model_id}"
|
||||
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
|
||||
|
||||
|
||||
def _map_service_error(exc: MakerWorldError) -> HTTPException:
|
||||
"""Translate service exceptions into HTTP responses."""
|
||||
if isinstance(exc, MakerWorldUrlError):
|
||||
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, MakerWorldAuthError):
|
||||
if isinstance(exc, ProviderAuthError):
|
||||
return HTTPException(status_code=401, detail=str(exc))
|
||||
if isinstance(exc, MakerWorldForbiddenError):
|
||||
# 403 forwards MakerWorld's own refusal message (content-gated,
|
||||
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, MakerWorldNotFoundError):
|
||||
if isinstance(exc, ProviderNotFoundError):
|
||||
return HTTPException(status_code=404, detail=str(exc))
|
||||
if isinstance(exc, MakerWorldUnavailableError):
|
||||
if isinstance(exc, ProviderUnavailableError):
|
||||
return HTTPException(status_code=502, detail=str(exc))
|
||||
return HTTPException(status_code=500, detail=f"MakerWorld error: {exc}")
|
||||
return HTTPException(status_code=500, detail=f"Model provider error: {exc}")
|
||||
|
||||
|
||||
@router.get("/thumbnail")
|
||||
@@ -133,10 +186,10 @@ async def proxy_thumbnail(
|
||||
URLs are content-addressable (filename contains a hash), so the
|
||||
aggressive ``immutable`` cache-control is safe.
|
||||
"""
|
||||
service = MakerWorldService()
|
||||
service = MakerWorldService(thumbnail_hosts=makerworld_provider.thumbnail_hosts())
|
||||
try:
|
||||
payload, content_type = await service.fetch_thumbnail(url)
|
||||
except MakerWorldError as exc:
|
||||
except ProviderError as exc:
|
||||
raise _map_service_error(exc) from exc
|
||||
finally:
|
||||
await service.close()
|
||||
@@ -153,7 +206,7 @@ async def proxy_thumbnail(
|
||||
@router.get("/status", response_model=MakerWorldStatus)
|
||||
async def get_status(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.MAKERWORLD_VIEW),
|
||||
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).
|
||||
@@ -164,26 +217,37 @@ async def get_status(
|
||||
stored token rather than always reporting ``False`` (#1777, same shape
|
||||
as the cloud-presets fix in #1182).
|
||||
"""
|
||||
cloud_token_user = current_user or api_key_cloud_owner
|
||||
token, _email, _region = await get_stored_token(db, cloud_token_user)
|
||||
has_token = bool(token)
|
||||
# A token Bambu has already rejected downloads nothing. ``can_download``
|
||||
# used to be a bare alias for ``has_cloud_token``, so the import button
|
||||
# stayed enabled against a dead credential and the user found out via a
|
||||
# 401 toast (#2562 follow-up).
|
||||
expired = has_token and await is_cloud_token_invalid(db, cloud_token_user)
|
||||
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=has_token,
|
||||
can_download=has_token and not expired,
|
||||
sign_in_expired=expired,
|
||||
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)
|
||||
@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),
|
||||
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.MAKERWORLD_VIEW),
|
||||
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.
|
||||
@@ -192,68 +256,34 @@ async def resolve_url(
|
||||
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:
|
||||
model_id, profile_id = MakerWorldService.parse_url(body.url)
|
||||
except MakerWorldError as exc:
|
||||
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
|
||||
|
||||
# API-keyed callers carry identity on the key, not in current_user — see
|
||||
# the /status handler comment and #1777 / #1182.
|
||||
cloud_token_user = current_user or api_key_cloud_owner
|
||||
service = await _build_service(db, cloud_token_user)
|
||||
service = await _build_service(db, provider, current_user, api_key_cloud_owner)
|
||||
try:
|
||||
design = await service.get_design(model_id)
|
||||
instances_envelope = await service.get_design_instances(model_id)
|
||||
except MakerWorldError as exc:
|
||||
resolved = await service.resolve(ref)
|
||||
except ProviderError 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 = []
|
||||
|
||||
# /instances/hits omits the per-instance printer compatibility info that
|
||||
# /design.instances[].extention.modelInfo carries (compatibility +
|
||||
# otherCompatibility). Merge it in so the frontend can show "this
|
||||
# instance was sliced for A1" + "also marked compatible with: H2D, P1S,
|
||||
# …" before the user picks one — without that, every instance row looks
|
||||
# identical in the UI and users blindly pick the first one regardless of
|
||||
# whether it matches their printer.
|
||||
design_instances = design.get("instances") or []
|
||||
if isinstance(design_instances, list):
|
||||
compat_by_id = {}
|
||||
for di in design_instances:
|
||||
if not isinstance(di, dict):
|
||||
continue
|
||||
iid = di.get("id")
|
||||
if iid is None:
|
||||
continue
|
||||
ext = (di.get("extention") or {}).get("modelInfo") or {}
|
||||
compat_by_id[iid] = {
|
||||
"compatibility": ext.get("compatibility"),
|
||||
"otherCompatibility": ext.get("otherCompatibility"),
|
||||
}
|
||||
for inst in instances:
|
||||
if not isinstance(inst, dict):
|
||||
continue
|
||||
iid = inst.get("id")
|
||||
extra = compat_by_id.get(iid)
|
||||
if extra:
|
||||
inst["compatibility"] = extra["compatibility"]
|
||||
inst["otherCompatibility"] = extra["otherCompatibility"]
|
||||
|
||||
# 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
|
||||
# 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.
|
||||
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-%")),
|
||||
provider.source_url_filter(LibraryFile.source_url, str(model_id)),
|
||||
LibraryFile.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
@@ -262,17 +292,24 @@ async def resolve_url(
|
||||
return MakerWorldResolvedModel(
|
||||
model_id=model_id,
|
||||
profile_id=profile_id,
|
||||
design=design,
|
||||
instances=instances,
|
||||
design=resolved.design,
|
||||
instances=resolved.instances,
|
||||
already_imported_library_ids=already_imported,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/import", response_model=MakerWorldImportResponse)
|
||||
@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),
|
||||
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.MAKERWORLD_IMPORT),
|
||||
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
|
||||
@@ -282,6 +319,15 @@ async def import_instance(
|
||||
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()
|
||||
@@ -294,90 +340,78 @@ async def import_instance(
|
||||
)
|
||||
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),
|
||||
# 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),
|
||||
)
|
||||
)
|
||||
)
|
||||
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
|
||||
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
|
||||
|
||||
# API-keyed callers carry identity on the key, not in current_user — see
|
||||
# the /status handler comment and #1777 / #1182. The same resolved user
|
||||
# is reused for owner_id on save_3mf_bytes_to_library below so the
|
||||
# library row is attributed to the key's owner rather than NULL.
|
||||
cloud_token_user = current_user or api_key_cloud_owner
|
||||
service = await _build_service(db, cloud_token_user)
|
||||
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}.
|
||||
# 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.
|
||||
# (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:
|
||||
design = await service.get_design(body.model_id)
|
||||
except MakerWorldError as exc:
|
||||
await service.close()
|
||||
raise _map_service_error(exc) from exc
|
||||
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
|
||||
|
||||
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",
|
||||
)
|
||||
# Canonical URL includes profile_id so each plate gets its own library
|
||||
# entry (see ``ModelProvider.canonical_url``).
|
||||
source_url = provider.canonical_url(info.ref)
|
||||
|
||||
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",
|
||||
# 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,
|
||||
)
|
||||
|
||||
# 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()
|
||||
download = await service.download(info)
|
||||
except ProviderError as exc:
|
||||
raise _map_service_error(exc) from exc
|
||||
finally:
|
||||
await service.close()
|
||||
|
||||
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")
|
||||
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
|
||||
@@ -387,44 +421,24 @@ async def import_instance(
|
||||
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"
|
||||
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(LibraryFile.active().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. 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)
|
||||
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=file_bytes,
|
||||
file_bytes=download.file_bytes,
|
||||
filename=filename,
|
||||
folder_id=effective_folder_id,
|
||||
source_type=_SOURCE_TYPE,
|
||||
source_type=provider.source_type,
|
||||
source_url=source_url,
|
||||
owner_id=cloud_token_user.id if cloud_token_user else None,
|
||||
)
|
||||
@@ -433,7 +447,7 @@ async def import_instance(
|
||||
library_file_id=library_file.id,
|
||||
filename=library_file.filename,
|
||||
folder_id=library_file.folder_id,
|
||||
profile_id=profile_id,
|
||||
profile_id=resolved_profile_id,
|
||||
was_existing=was_existing,
|
||||
)
|
||||
|
||||
@@ -442,23 +456,27 @@ async def import_instance(
|
||||
async def recent_imports(
|
||||
limit: int = 10,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.MAKERWORLD_VIEW),
|
||||
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 == _SOURCE_TYPE)
|
||||
.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,
|
||||
|
||||
@@ -20,7 +20,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.api.routes.cloud import get_stored_token, resolve_api_key_cloud_owner
|
||||
from backend.app.api.routes.cloud import resolve_api_key_cloud_owner
|
||||
from backend.app.api.routes.orca_cloud import (
|
||||
_ORCA_TYPE_TO_BAMBU,
|
||||
_build_authenticated_service as _build_orca_service,
|
||||
@@ -43,6 +43,7 @@ from backend.app.services.bambu_cloud import (
|
||||
BambuCloudError,
|
||||
BambuCloudService,
|
||||
)
|
||||
from backend.app.services.bambu_cloud_credentials import get_stored_token
|
||||
from backend.app.services.orca_cloud import (
|
||||
OrcaCloudAuthError,
|
||||
OrcaCloudError,
|
||||
|
||||
+1
-1
@@ -8925,7 +8925,7 @@ 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 (
|
||||
from backend.app.services.model_providers.makerworld.service import (
|
||||
set_shared_http_client as set_shared_makerworld_http_client,
|
||||
)
|
||||
from backend.app.services.orca_cloud import (
|
||||
|
||||
@@ -42,6 +42,16 @@ class MakerWorldImportRequest(BaseModel):
|
||||
...,
|
||||
description="The MakerWorld design ID (the number in /models/{id}).",
|
||||
)
|
||||
source_type: str = Field(
|
||||
default="makerworld",
|
||||
description=(
|
||||
"Which registered model provider owns the resource. Import "
|
||||
"identifies a model by numeric id rather than URL, so there is no "
|
||||
"URL for the provider registry to route on — the caller names the "
|
||||
"provider instead. Defaults to 'makerworld' so existing callers "
|
||||
"stay unchanged."
|
||||
),
|
||||
)
|
||||
profile_id: int | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Bambu Cloud credential storage.
|
||||
|
||||
Single seam for reading and bookkeeping the stored Bambu Cloud bearer token:
|
||||
per-user columns when auth is enabled, global ``Settings`` rows otherwise
|
||||
(auth-disabled single-user installs). Lives in the services layer so feature
|
||||
packages (e.g. ``model_providers``) can consume credentials without importing
|
||||
the route layer — routes are just one consumer among several here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.core.database import async_session
|
||||
from backend.app.models.settings import Settings
|
||||
from backend.app.models.user import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Keys for storing cloud credentials in settings
|
||||
CLOUD_TOKEN_KEY = "bambu_cloud_token"
|
||||
CLOUD_EMAIL_KEY = "bambu_cloud_email"
|
||||
CLOUD_REGION_KEY = "bambu_cloud_region"
|
||||
# Global (auth-disabled) counterpart of ``User.cloud_token_invalid_at``. Stores
|
||||
# an ISO timestamp; absent/empty means "not known to be dead".
|
||||
CLOUD_TOKEN_INVALID_KEY = "bambu_cloud_token_invalid_at"
|
||||
|
||||
|
||||
def _normalise_region(region: str | None) -> str:
|
||||
"""Treat NULL/empty as 'global' for legacy rows that predate the region column."""
|
||||
return region if region in ("global", "china") else "global"
|
||||
|
||||
|
||||
async def is_cloud_token_invalid(db: AsyncSession, user: User | None = None) -> bool:
|
||||
"""Whether the stored Bambu token is known to have been rejected.
|
||||
|
||||
Set by :func:`mark_cloud_token_invalid` the first time Bambu answers 401,
|
||||
cleared on a fresh login/logout. This is the only durable record we have:
|
||||
Bambu's access token is opaque (no readable expiry) and Bambuddy does not
|
||||
persist the refresh token, so without this flag a dead credential looks
|
||||
exactly like a live one.
|
||||
"""
|
||||
if user is not None:
|
||||
return user.cloud_token_invalid_at is not None
|
||||
result = await db.execute(select(Settings).where(Settings.key == CLOUD_TOKEN_INVALID_KEY))
|
||||
row = result.scalar_one_or_none()
|
||||
return bool(row and row.value)
|
||||
|
||||
|
||||
async def mark_cloud_token_invalid(user_id: int | None) -> None:
|
||||
"""Record that Bambu rejected the stored token.
|
||||
|
||||
Opens its own session on purpose. This runs from
|
||||
``BambuCloudService._on_auth_failure``, i.e. in the middle of a route that
|
||||
is about to fail — writing through that route's session would tie the flag
|
||||
to a transaction the route may still roll back, and the fact that the
|
||||
credential is dead is true regardless of how the request ends.
|
||||
|
||||
Best-effort: a bookkeeping failure must never replace the 401 the caller
|
||||
actually needs to see. ``user_id=None`` (auth-disabled single-user setup)
|
||||
records the global flag — those installs *do* hold a token
|
||||
(:func:`get_stored_token` reads it from ``Settings``), so the rejection
|
||||
must land somewhere the status endpoints can see it.
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
try:
|
||||
async with async_session() as db:
|
||||
if user_id is not None:
|
||||
await db.execute(update(User).where(User.id == user_id).values(cloud_token_invalid_at=now))
|
||||
else:
|
||||
result = await db.execute(select(Settings).where(Settings.key == CLOUD_TOKEN_INVALID_KEY))
|
||||
row = result.scalar_one_or_none()
|
||||
if row:
|
||||
row.value = now.isoformat()
|
||||
else:
|
||||
db.add(Settings(key=CLOUD_TOKEN_INVALID_KEY, value=now.isoformat()))
|
||||
await db.commit()
|
||||
logger.warning("Bambu Cloud rejected the stored token (user_id=%s) — marking the sign-in as expired", user_id)
|
||||
except Exception:
|
||||
logger.exception("Could not record the Bambu Cloud token as invalid")
|
||||
|
||||
|
||||
async def _clear_cloud_token_invalid(db: AsyncSession, user: User | None) -> None:
|
||||
"""Clear the rejected-token flag — called on every fresh login and logout."""
|
||||
if user is not None:
|
||||
await db.execute(update(User).where(User.id == user.id).values(cloud_token_invalid_at=None))
|
||||
return
|
||||
result = await db.execute(select(Settings).where(Settings.key == CLOUD_TOKEN_INVALID_KEY))
|
||||
row = result.scalar_one_or_none()
|
||||
if row:
|
||||
await db.delete(row)
|
||||
|
||||
|
||||
async def get_stored_token(db: AsyncSession, user: User | None = None) -> tuple[str | None, str | None, str]:
|
||||
"""Get stored cloud token, email, and region.
|
||||
|
||||
When a user is provided (auth enabled), returns that user's per-user credentials.
|
||||
When user is None (auth disabled), falls back to global Settings table.
|
||||
Region defaults to ``"global"`` when unset (including for rows that predate the
|
||||
``cloud_region`` column).
|
||||
"""
|
||||
if user is not None:
|
||||
return user.cloud_token, user.cloud_email, _normalise_region(user.cloud_region)
|
||||
|
||||
# Fallback: global storage (auth disabled)
|
||||
result = await db.execute(
|
||||
select(Settings).where(Settings.key.in_([CLOUD_TOKEN_KEY, CLOUD_EMAIL_KEY, CLOUD_REGION_KEY]))
|
||||
)
|
||||
settings = {s.key: s.value for s in result.scalars().all()}
|
||||
return (
|
||||
settings.get(CLOUD_TOKEN_KEY),
|
||||
settings.get(CLOUD_EMAIL_KEY),
|
||||
_normalise_region(settings.get(CLOUD_REGION_KEY)),
|
||||
)
|
||||
@@ -535,8 +535,8 @@ class GitHubBackupService:
|
||||
Both stores are read regardless: a ``Settings`` row survives enabling
|
||||
auth later, and dropping it silently would lose that account's presets.
|
||||
"""
|
||||
from backend.app.api.routes.cloud import get_stored_token
|
||||
from backend.app.api.routes.orca_cloud import _load_credentials
|
||||
from backend.app.services.bambu_cloud_credentials import get_stored_token
|
||||
|
||||
bambu: list = []
|
||||
orca: list = []
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Model-provider interface + registry.
|
||||
|
||||
The shared seam for "import a model from a 3D model website". Providers
|
||||
implement the interface (a ``ModelProvider`` descriptor + a per-request
|
||||
``ProviderService`` transport) and register an instance here; the registry
|
||||
routes pasted URLs to the owning provider via ``find_for_url``. MakerWorld is
|
||||
the first (and currently only) registered provider.
|
||||
"""
|
||||
|
||||
from backend.app.services.model_providers.base import (
|
||||
ModelProvider,
|
||||
ProviderAuthConfig,
|
||||
ProviderAuthError,
|
||||
ProviderAuthType,
|
||||
ProviderDownload,
|
||||
ProviderDownloadInfo,
|
||||
ProviderError,
|
||||
ProviderForbiddenError,
|
||||
ProviderNotFoundError,
|
||||
ProviderResolvedModel,
|
||||
ProviderResourceRef,
|
||||
ProviderService,
|
||||
ProviderStatus,
|
||||
ProviderUnavailableError,
|
||||
ProviderUrlError,
|
||||
)
|
||||
from backend.app.services.model_providers.makerworld import makerworld_provider
|
||||
from backend.app.services.model_providers.registry import ModelProviderRegistry, registry
|
||||
|
||||
registry.register(makerworld_provider)
|
||||
|
||||
__all__ = [
|
||||
"ModelProvider",
|
||||
"ModelProviderRegistry",
|
||||
"ProviderAuthConfig",
|
||||
"ProviderAuthError",
|
||||
"ProviderAuthType",
|
||||
"ProviderDownload",
|
||||
"ProviderDownloadInfo",
|
||||
"ProviderError",
|
||||
"ProviderForbiddenError",
|
||||
"ProviderNotFoundError",
|
||||
"ProviderResolvedModel",
|
||||
"ProviderResourceRef",
|
||||
"ProviderService",
|
||||
"ProviderStatus",
|
||||
"ProviderUnavailableError",
|
||||
"ProviderUrlError",
|
||||
"makerworld_provider",
|
||||
"registry",
|
||||
]
|
||||
@@ -0,0 +1,327 @@
|
||||
"""Model-provider interface.
|
||||
|
||||
A *model provider* is a website that hosts 3D printer models (MakerWorld,
|
||||
Thingiverse, Printables, ...) whose files Bambuddy can resolve and import
|
||||
into the library. This module defines the contract every provider must
|
||||
fulfil — the split being:
|
||||
|
||||
* :class:`ModelProvider` — the static, provider-wide descriptor: identity
|
||||
(``source_type``, ``display_name``), URL routing (``host_patterns``),
|
||||
the auth it needs (or explicitly doesn't), and a factory that builds a
|
||||
per-request :class:`ProviderService` seeded with the caller's stored
|
||||
credentials.
|
||||
* :class:`ProviderService` — one HTTP client per request, mirroring the
|
||||
``BambuCloudService`` construction pattern: resolve a model URL to
|
||||
metadata + importable files, resolve + fetch a concrete download, and
|
||||
proxy thumbnail images. Providers are *thin transports*: shared concerns
|
||||
(library dedupe, folder auto-creation, ``save_3mf_bytes_to_library``)
|
||||
stay in the route layer so every provider benefits from them.
|
||||
|
||||
The interface deliberately covers everything the MakerWorld integration
|
||||
needs today (see ``model_providers/makerworld/``) so that adding a new site
|
||||
is: implement ``ModelProvider`` + ``ProviderService``, register it, and the
|
||||
shared import API routes pasted URLs to it via ``registry.find_for_url``.
|
||||
|
||||
Only interoperability — not affiliated with or endorsed by MakerWorld or any
|
||||
other provider, and not intended to circumvent any access control.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
from backend.app.core.compat import StrEnum
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.core.permissions import Permission
|
||||
from backend.app.models.user import User
|
||||
|
||||
|
||||
class ProviderAuthType(StrEnum):
|
||||
"""The kind of credentials a model provider may (optionally) require."""
|
||||
|
||||
NONE = "none"
|
||||
ACCESS_TOKEN = "access_token"
|
||||
USERNAME_PASSWORD = "username_password"
|
||||
BAMBU_CLOUD_BEARER = "bambu_cloud_bearer" # MakerWorld today: shared Bambu Cloud token
|
||||
COOKIE = "cookie" # reserved for sites without a first-party API
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProviderAuthConfig:
|
||||
"""Declarative description of a provider's authentication requirement.
|
||||
|
||||
Describes *what* the provider needs so the UI can prompt for it; the
|
||||
actual storage/retrieval of credentials stays provider-specific for now
|
||||
(MakerWorld reads the Bambu Cloud token the user already configured).
|
||||
``credential_fields`` names the inputs a future generic credential vault
|
||||
would collect (e.g. ``("access_token",)`` or ``("username", "password")``).
|
||||
"""
|
||||
|
||||
auth_type: ProviderAuthType
|
||||
display_label: str
|
||||
description: str = ""
|
||||
credential_fields: tuple[str, ...] = ()
|
||||
setup_hint: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProviderResourceRef:
|
||||
"""Provider-agnostic handle for one model resource.
|
||||
|
||||
``external_id`` is the provider-native model identifier (MakerWorld's
|
||||
integer design id as a string); ``sub_id`` is an optional secondary key
|
||||
such as MakerWorld's ``profileId`` for a specific plate.
|
||||
|
||||
Both ids must be **numeric strings** today: the shared route layer casts
|
||||
them with ``int()`` when shaping API responses. Providers whose native
|
||||
ids are not numeric need route-layer changes first — keep this contract
|
||||
in mind when implementing one.
|
||||
"""
|
||||
|
||||
source_type: str
|
||||
external_id: str
|
||||
sub_id: str | None = None
|
||||
original_url: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProviderStatus:
|
||||
"""Whether the caller can use this provider right now.
|
||||
|
||||
``auth_error`` carries a human-readable reason when the caller is signed
|
||||
in but the stored credential has been rejected (e.g. expired); ``None``
|
||||
when there is no error to report. ``credential_rejected`` is the
|
||||
machine-readable counterpart — set exactly when the stored credential
|
||||
exists *and* was refused by the provider — so callers (e.g. a route
|
||||
reporting "sign-in expired") never have to infer it from ``auth_error``,
|
||||
which may legitimately be set for other failures (network, rate limit).
|
||||
"""
|
||||
|
||||
authenticated: bool
|
||||
can_download: bool
|
||||
auth_error: str | None = None
|
||||
credential_rejected: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProviderResolvedModel:
|
||||
"""Result of resolving a model URL.
|
||||
|
||||
``design`` and ``instances`` are provider-specific dicts passed through
|
||||
verbatim — the frontend reads fields a provider may add over time, so we
|
||||
don't re-shape them here. Which library rows already hold this resource
|
||||
is the route layer's concern (it owns the library query) and stays out of
|
||||
the resolved payload.
|
||||
"""
|
||||
|
||||
ref: ProviderResourceRef
|
||||
design: dict[str, Any]
|
||||
instances: list[dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProviderDownloadInfo:
|
||||
"""A concrete, short-lived download for one file/plate.
|
||||
|
||||
``ref`` may be enriched by the provider with the ``sub_id`` it resolved
|
||||
(e.g. the actual MakerWorld profile selected when the caller omitted
|
||||
one) so the route can build the canonical dedupe URL.
|
||||
"""
|
||||
|
||||
ref: ProviderResourceRef
|
||||
url: str
|
||||
suggested_filename: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProviderDownload:
|
||||
"""Downloaded file bytes plus the final suggested filename."""
|
||||
|
||||
file_bytes: bytes
|
||||
filename: str
|
||||
|
||||
|
||||
class ProviderError(Exception):
|
||||
"""Base exception for model-provider API errors."""
|
||||
|
||||
|
||||
class ProviderAuthError(ProviderError):
|
||||
"""Raised when a provider requires credentials and we have none (or the
|
||||
stored one was rejected). True auth failure."""
|
||||
|
||||
|
||||
class ProviderForbiddenError(ProviderError):
|
||||
"""Raised when a provider refuses access despite valid authentication —
|
||||
content-gated (purchase/points required, region restricted, ...)."""
|
||||
|
||||
|
||||
class ProviderNotFoundError(ProviderError):
|
||||
"""Raised when a model / file / profile doesn't exist."""
|
||||
|
||||
|
||||
class ProviderUnavailableError(ProviderError):
|
||||
"""Raised on 5xx, network errors, or malformed payloads."""
|
||||
|
||||
|
||||
class ProviderUrlError(ProviderError):
|
||||
"""Raised when a URL isn't a model page of this provider."""
|
||||
|
||||
|
||||
class ModelProvider(ABC):
|
||||
"""Static descriptor + factory for one model-hosting site.
|
||||
|
||||
Instances are shared (one per provider); all mutable state lives in the
|
||||
per-request :class:`ProviderService` built by :meth:`build_service`.
|
||||
"""
|
||||
|
||||
source_type: str
|
||||
display_name: str
|
||||
host_patterns: tuple[str, ...] = ()
|
||||
auth: ProviderAuthConfig | None = None
|
||||
#: Top-level library folder imports land in when the caller names no
|
||||
#: folder. ``None`` imports into the library root — the route will not
|
||||
#: mint a folder without a name.
|
||||
default_folder_name: str | None = None
|
||||
#: The permissions the routes enforce for this provider's read and import
|
||||
#: operations. Optional only so the base class has a default: a provider
|
||||
#: that leaves them unset is refused at the gate rather than treated as
|
||||
#: unrestricted (see ``makerworld._authorize_for_provider``).
|
||||
view_permission: Permission | None = None
|
||||
import_permission: Permission | None = None
|
||||
|
||||
@abstractmethod
|
||||
async def build_service(
|
||||
self,
|
||||
*,
|
||||
db: AsyncSession,
|
||||
user: User | None,
|
||||
api_key_owner: User | None = None,
|
||||
client: httpx.AsyncClient | None = None,
|
||||
) -> ProviderService:
|
||||
"""Build a per-request service seeded with the caller's credentials.
|
||||
|
||||
``api_key_owner`` is the API key's owning user for API-keyed calls
|
||||
(see ``resolve_api_key_cloud_owner``); providers use it as the
|
||||
fallback identity when ``user`` is None.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def parse_url(self, url: str) -> ProviderResourceRef:
|
||||
"""Extract a :class:`ProviderResourceRef` from a model URL.
|
||||
|
||||
Raises :class:`ProviderUrlError` when the URL isn't a model page of
|
||||
this provider.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def canonical_url(self, ref: ProviderResourceRef) -> str:
|
||||
"""Stable dedupe key for a resource (library ``source_url``).
|
||||
|
||||
All URL variants of the same resource must collapse to this string;
|
||||
different resources (e.g. different plates of one model) must differ.
|
||||
"""
|
||||
|
||||
def source_url_filter(self, column: Any, external_id: str) -> Any:
|
||||
"""SQL predicate over ``LibraryFile.source_url`` selecting every row
|
||||
that belongs to this resource — the whole-model canonical URL plus,
|
||||
when the provider keys dedupe per sub-resource (plate/profile), every
|
||||
such variant. Drives the resolve flow's already-imported detection.
|
||||
|
||||
The default matches the model-level canonical URL only; providers with
|
||||
recognisable per-plate URL shapes override this (see MakerWorld).
|
||||
"""
|
||||
prefix = self.canonical_url(ProviderResourceRef(source_type=self.source_type, external_id=external_id))
|
||||
return column == prefix
|
||||
|
||||
def supports_url(self, url: str) -> bool:
|
||||
"""Whether ``url`` points at this provider (host-suffix match).
|
||||
|
||||
Accepts scheme-less input (``makerworld.com/models/1``) the same way
|
||||
:meth:`parse_url` does, so ``find_for_url`` routes exactly the URLs
|
||||
the provider will then accept.
|
||||
"""
|
||||
if not url or not isinstance(url, str):
|
||||
return False
|
||||
candidate = url.strip()
|
||||
if "://" not in candidate:
|
||||
candidate = "https://" + candidate
|
||||
try:
|
||||
host = (urlparse(candidate).hostname or "").lower()
|
||||
except ValueError:
|
||||
return False
|
||||
return any(host == pattern or host.endswith("." + pattern) for pattern in self.host_patterns)
|
||||
|
||||
def thumbnail_hosts(self) -> tuple[str, ...]:
|
||||
"""Hosts whose image URLs may be proxied by ``fetch_thumbnail``.
|
||||
|
||||
Serves as the SSRF allowlist for the provider's image proxy; empty
|
||||
means the provider has no server-side thumbnail proxy.
|
||||
"""
|
||||
return ()
|
||||
|
||||
def download_hosts(self) -> tuple[str, ...]:
|
||||
"""Hosts whose file URLs may be fetched by the download path.
|
||||
|
||||
Serves as the SSRF allowlist for :meth:`ProviderService.download`,
|
||||
symmetric to :meth:`thumbnail_hosts`; empty means the provider has no
|
||||
server-side file fetch (so no allowlist constraint applies). Providers
|
||||
whose service fetches files must override this — a new provider gets
|
||||
the same structural hint the thumbnail proxy gives its counterpart.
|
||||
"""
|
||||
return ()
|
||||
|
||||
|
||||
class ProviderService(ABC):
|
||||
"""Per-request client for a single provider.
|
||||
|
||||
Built by :meth:`ModelProvider.build_service`, never constructed directly.
|
||||
Providers must be closed after use (:meth:`close`); the shared connection
|
||||
pool is only closed by the owner.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def close(self) -> None:
|
||||
"""Close the client if this service instance owns it."""
|
||||
|
||||
@abstractmethod
|
||||
async def get_status(self, db: AsyncSession) -> ProviderStatus:
|
||||
"""Report whether the caller can use this provider (credential state)."""
|
||||
|
||||
@abstractmethod
|
||||
async def resolve(self, ref: ProviderResourceRef) -> ProviderResolvedModel:
|
||||
"""Fetch metadata + the importable file/plate list for a resource."""
|
||||
|
||||
@abstractmethod
|
||||
async def get_download(self, ref: ProviderResourceRef) -> ProviderDownloadInfo:
|
||||
"""Resolve the concrete download for a resource/file.
|
||||
|
||||
May need provider-specific lookups (e.g. MakerWorld's alphanumeric
|
||||
``modelId``) and must enrich ``ref.sub_id`` with the actually-resolved
|
||||
file/plate so the route can build the canonical dedupe key.
|
||||
Raises ``ProviderAuthError`` when the provider requires credentials
|
||||
and the caller has none.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def download(self, info: ProviderDownloadInfo) -> ProviderDownload:
|
||||
"""Fetch the file bytes for a :class:`ProviderDownloadInfo`.
|
||||
|
||||
Must restrict the upstream URL host to :meth:`ModelProvider.download_hosts`
|
||||
(SSRF guard — the symmetric counterpart to ``fetch_thumbnail``).
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def fetch_thumbnail(self, url: str) -> tuple[bytes, str]:
|
||||
"""Proxy a provider CDN image, returning ``(bytes, content_type)``.
|
||||
|
||||
Must restrict the upstream host to :meth:`ModelProvider.thumbnail_hosts`
|
||||
(SSRF guard).
|
||||
"""
|
||||
@@ -0,0 +1,12 @@
|
||||
"""MakerWorld model provider package.
|
||||
|
||||
Exports the provider instance the registry consumes; the implementation lives
|
||||
in the sibling modules (``service``, ``http``, ``url``, ``errors``, ``auth``).
|
||||
"""
|
||||
|
||||
from backend.app.services.model_providers.makerworld.provider import (
|
||||
MakerWorldProvider,
|
||||
makerworld_provider,
|
||||
)
|
||||
|
||||
__all__ = ["MakerWorldProvider", "makerworld_provider"]
|
||||
@@ -0,0 +1,18 @@
|
||||
"""MakerWorld credential handling.
|
||||
|
||||
MakerWorld downloads run on the same Bambu Cloud bearer token as the rest of
|
||||
the Bambu cloud integration — there is no separate MakerWorld OAuth flow. This
|
||||
module is the single seam where the MakerWorld provider reads the caller's
|
||||
stored token, reports a rejected/expired credential, and records a 401 so the
|
||||
whole app agrees the sign-in is dead (see ``cloud.mark_cloud_token_invalid``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from backend.app.services.bambu_cloud_credentials import (
|
||||
get_stored_token,
|
||||
is_cloud_token_invalid,
|
||||
mark_cloud_token_invalid,
|
||||
)
|
||||
|
||||
__all__ = ["get_stored_token", "is_cloud_token_invalid", "mark_cloud_token_invalid"]
|
||||
@@ -0,0 +1,44 @@
|
||||
"""MakerWorld error types.
|
||||
|
||||
Subclasses of the generic provider hierarchy so route layers can map errors
|
||||
with the provider-agnostic classes (:class:`ProviderError` and friends) while
|
||||
callers that know they're talking to MakerWorld get the specific types.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from backend.app.services.model_providers.base import (
|
||||
ProviderAuthError,
|
||||
ProviderError,
|
||||
ProviderForbiddenError,
|
||||
ProviderNotFoundError,
|
||||
ProviderUnavailableError,
|
||||
ProviderUrlError,
|
||||
)
|
||||
|
||||
|
||||
class MakerWorldError(ProviderError):
|
||||
"""Base exception for MakerWorld API errors."""
|
||||
|
||||
|
||||
class MakerWorldAuthError(ProviderAuthError, MakerWorldError):
|
||||
"""Raised when MakerWorld requires a Bambu Cloud token and we don't have
|
||||
one (or the one we sent was rejected). True auth failure."""
|
||||
|
||||
|
||||
class MakerWorldForbiddenError(ProviderForbiddenError, MakerWorldError):
|
||||
"""Raised when MakerWorld refuses access despite valid authentication —
|
||||
content-gated (points required, purchase required, region restricted,
|
||||
early-access, etc.)."""
|
||||
|
||||
|
||||
class MakerWorldNotFoundError(ProviderNotFoundError, MakerWorldError):
|
||||
"""Raised when a design / profile / instance doesn't exist."""
|
||||
|
||||
|
||||
class MakerWorldUnavailableError(ProviderUnavailableError, MakerWorldError):
|
||||
"""Raised on 5xx, network errors, or malformed payloads."""
|
||||
|
||||
|
||||
class MakerWorldUrlError(ProviderUrlError, MakerWorldError):
|
||||
"""Raised when a URL isn't a makerworld.com model page."""
|
||||
@@ -0,0 +1,166 @@
|
||||
"""MakerWorld HTTP layer.
|
||||
|
||||
Constants and the low-level transport helpers for the MakerWorld / Bambu Lab
|
||||
APIs: the S3 presigned-download path that must reach the transport
|
||||
byte-for-byte, upstream error extraction, and the CDN SSRF guard helpers used
|
||||
by :class:`MakerWorldService`.
|
||||
|
||||
The app-scoped shared ``httpx`` client lives with its consumer instead (see
|
||||
``service.set_shared_http_client``) — same-module so the service reads the
|
||||
live value, matching ``bambu_cloud`` / ``orca_cloud`` / ``slicer_api``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import ssl
|
||||
|
||||
import certifi
|
||||
import httpx
|
||||
|
||||
from backend.app.services.model_providers.makerworld.errors import MakerWorldUnavailableError
|
||||
|
||||
# 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").
|
||||
MAKERWORLD_API_BASE = "https://api.bambulab.com/v1/design-service"
|
||||
|
||||
# Besides MakerWorld's own CDN, Bambu Cloud also issues AWS S3 presigned
|
||||
# URLs (e.g. ``s3.us-west-2.amazonaws.com``) from the iot-service download
|
||||
# endpoint. The suffix check matches any regional S3 endpoint.
|
||||
#
|
||||
# Deliberately NOT part of the ``download_hosts()`` seam: that seam is an
|
||||
# exact-host allowlist a provider declares, and this is a suffix family
|
||||
# belonging to Bambu's signed-URL infrastructure specifically. It stays a
|
||||
# constant of *this* provider's transport: ``download_3mf`` accepts the
|
||||
# injected hosts or an S3 endpoint, while a second provider brings its own
|
||||
# service and declares its own ``download_hosts()``.
|
||||
_ALLOWED_DOWNLOAD_SUFFIXES = (".amazonaws.com",)
|
||||
|
||||
# The shared default SSRF allowlist for MakerWorld CDN traffic. The thumbnail
|
||||
# proxy and the 3MF download path are both driven by the provider descriptor
|
||||
# instead — ``build_service`` feeds the runner's ``ModelProvider.thumbnail_hosts()``
|
||||
# and ``download_hosts()`` into ``MakerWorldService`` — and this tuple is what
|
||||
# those methods return by default. Lives here with the other transport guards
|
||||
# so the allowlist is in one place.
|
||||
MAKERWORLD_CDN_HOSTS = ("makerworld.bblmw.com", "public-cdn.bblmw.com")
|
||||
|
||||
# Client identity sent to MakerWorld / api.bambulab.com. We identify honestly
|
||||
# as Bambuddy with a source URL so Bambu can distinguish our traffic from
|
||||
# impersonators — the opposite of what the OrcaSlicer fork was called out for
|
||||
# in the May 2026 Bambu Lab blog post on cloud access. The Referer is kept
|
||||
# because MakerWorld's CSRF / origin-check middleware uses it on some
|
||||
# endpoints — that's distinct from client impersonation.
|
||||
_CLIENT_HEADERS = {
|
||||
"User-Agent": "Bambuddy/1.0 (+https://github.com/maziggy/bambuddy)",
|
||||
"Accept": "text/html,application/json,*/*",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
"Referer": "https://makerworld.com/",
|
||||
}
|
||||
|
||||
_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")
|
||||
|
||||
|
||||
def _s3_ssl_context() -> ssl.SSLContext:
|
||||
"""Build the TLS context used for the S3 presigned download (#2562).
|
||||
|
||||
``urllib.request`` verifies against the *OS* trust store, while httpx —
|
||||
every other network call in Bambuddy — verifies against the bundled
|
||||
``certifi`` CA bundle. On Windows those two disagree: Python's
|
||||
``ssl.load_default_certs()`` only enumerates the roots already cached in
|
||||
the Windows ROOT store, and Windows populates that store lazily via
|
||||
CryptoAPI's auto-update, which Python never triggers. If the Amazon root
|
||||
signing the S3 chain isn't cached on that machine yet, verification fails
|
||||
with ``unable to get local issuer certificate`` — even though the
|
||||
api.bambulab.com calls that preceded it (httpx) succeeded.
|
||||
|
||||
Pinning urllib to certifi makes the S3 hop trust exactly what the rest of
|
||||
the app already trusts. Built per call rather than at import so a certifi
|
||||
refresh doesn't require a restart; construction is cheap relative to the
|
||||
download that follows.
|
||||
"""
|
||||
return ssl.create_default_context(cafile=certifi.where())
|
||||
|
||||
|
||||
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, HTTPSHandler, Request, build_opener
|
||||
|
||||
# Don't follow redirects: the host allowlist 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
|
||||
|
||||
# HTTPSHandler swaps only the TLS context — the URL still reaches the
|
||||
# transport verbatim, which is what the S3 signature depends on.
|
||||
opener = build_opener(_NoRedirect, HTTPSHandler(context=_s3_ssl_context()))
|
||||
|
||||
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
|
||||
@@ -0,0 +1,117 @@
|
||||
"""MakerWorld model provider.
|
||||
|
||||
Static descriptor + per-request service factory for makerworld.com. The
|
||||
``MakerWorldProvider`` instance is what gets registered in the shared
|
||||
:class:`ModelProviderRegistry`; the actual API work lives in ``service.py``
|
||||
(the per-request :class:`ProviderService`) and ``url.py`` (URL parsing and
|
||||
canonicalisation). Credential handling is centralised here so route layers
|
||||
never touch MakerWorld specifics.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import httpx
|
||||
|
||||
from backend.app.core.permissions import Permission
|
||||
from backend.app.services.model_providers.base import (
|
||||
ModelProvider,
|
||||
ProviderAuthConfig,
|
||||
ProviderAuthType,
|
||||
ProviderResourceRef,
|
||||
ProviderService,
|
||||
)
|
||||
from backend.app.services.model_providers.makerworld import url as mw_url
|
||||
from backend.app.services.model_providers.makerworld.auth import (
|
||||
get_stored_token,
|
||||
mark_cloud_token_invalid,
|
||||
)
|
||||
from backend.app.services.model_providers.makerworld.http import MAKERWORLD_CDN_HOSTS
|
||||
from backend.app.services.model_providers.makerworld.service import MakerWorldService
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.models.user import User
|
||||
|
||||
|
||||
class MakerWorldProvider(ModelProvider):
|
||||
"""MakerWorld descriptor: identity, URL routing, auth requirements, and the
|
||||
factory that builds a per-request :class:`MakerWorldService` seeded with the
|
||||
caller's stored Bambu Cloud bearer token.
|
||||
"""
|
||||
|
||||
source_type = "makerworld"
|
||||
display_name = "MakerWorld"
|
||||
host_patterns = ("makerworld.com",)
|
||||
auth = ProviderAuthConfig(
|
||||
auth_type=ProviderAuthType.BAMBU_CLOUD_BEARER,
|
||||
display_label="Bambu Cloud sign-in",
|
||||
description=(
|
||||
"MakerWorld downloads reuse the Bambu Cloud account already stored in Bambuddy — "
|
||||
"there is no separate MakerWorld sign-in."
|
||||
),
|
||||
setup_hint="Open the Profiles page and sign in to Bambu Cloud.",
|
||||
)
|
||||
default_folder_name = "MakerWorld"
|
||||
view_permission = Permission.MAKERWORLD_VIEW
|
||||
import_permission = Permission.MAKERWORLD_IMPORT
|
||||
|
||||
async def build_service(
|
||||
self,
|
||||
*,
|
||||
db: AsyncSession,
|
||||
user: User | None,
|
||||
api_key_owner: User | None = None,
|
||||
client: httpx.AsyncClient | None = None,
|
||||
) -> ProviderService:
|
||||
"""Build a per-request service seeded with the caller's stored Bambu
|
||||
Cloud bearer, mirroring ``cloud.build_authenticated_cloud``.
|
||||
|
||||
``api_key_owner`` is the API key's owning user for API-keyed calls
|
||||
(see ``resolve_api_key_cloud_owner``); MakerWorld uses it as the
|
||||
fallback identity when ``user`` is None. Like the cloud integration, a
|
||||
rejected token is recorded so the whole app agrees the sign-in is dead
|
||||
rather than each feature failing on its own — including auth-disabled
|
||||
single-user installs, where ``user_id=None`` records the *global*
|
||||
flag those installs read back on the status endpoints.
|
||||
"""
|
||||
identity = user if user is not None else api_key_owner
|
||||
token, _email, _region = await get_stored_token(db, identity)
|
||||
user_id = identity.id if identity is not None else None
|
||||
return MakerWorldService(
|
||||
client=client,
|
||||
auth_token=token,
|
||||
user=identity,
|
||||
on_auth_failure=lambda: mark_cloud_token_invalid(user_id),
|
||||
# The SSRF allowlists are the provider's declared seams — the
|
||||
# service must not hardcode its own copies (symmetric pair,
|
||||
# ``fetch_thumbnail`` / ``download``).
|
||||
thumbnail_hosts=self.thumbnail_hosts(),
|
||||
download_hosts=self.download_hosts(),
|
||||
)
|
||||
|
||||
def parse_url(self, url: str) -> ProviderResourceRef:
|
||||
return mw_url.parse_url(url)
|
||||
|
||||
def canonical_url(self, ref: ProviderResourceRef) -> str:
|
||||
return mw_url.canonical_url(ref)
|
||||
|
||||
def source_url_filter(self, column, external_id: str):
|
||||
"""Whole-model key plus every per-plate key — MakerWorld's canonical
|
||||
shape appends ``#profileId-{n}`` for plate-level dedupe (see
|
||||
``url.canonical_url``), so the already-imported detection must match
|
||||
both. The ``#profileId-`` fragment lives here with the descriptor
|
||||
because it is part of this provider's URL contract."""
|
||||
prefix = mw_url.canonical_url(ProviderResourceRef(source_type=self.source_type, external_id=external_id))
|
||||
return (column == prefix) | (column.like(f"{prefix}#profileId-%"))
|
||||
|
||||
def thumbnail_hosts(self) -> tuple[str, ...]:
|
||||
return MAKERWORLD_CDN_HOSTS
|
||||
|
||||
def download_hosts(self) -> tuple[str, ...]:
|
||||
return MAKERWORLD_CDN_HOSTS
|
||||
|
||||
|
||||
makerworld_provider = MakerWorldProvider()
|
||||
+185
-228
@@ -9,7 +9,11 @@ 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).
|
||||
OAuth flow needed; see ``model_providers/makerworld/auth.py``).
|
||||
|
||||
Implements the :class:`ProviderService` interface — the route layer drives it
|
||||
through ``resolve`` / ``get_download`` / ``download`` so the same flow can be
|
||||
reused for future providers.
|
||||
|
||||
Only interoperability — not affiliated with or endorsed by MakerWorld or
|
||||
Bambu Lab, and not intended to circumvent any access control.
|
||||
@@ -19,49 +23,59 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import ssl
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import replace
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import certifi
|
||||
import httpx
|
||||
|
||||
from backend.app.services.bambu_cloud import is_captcha_challenge, is_expiry_401
|
||||
from backend.app.services.model_providers.base import (
|
||||
ProviderDownload,
|
||||
ProviderDownloadInfo,
|
||||
ProviderResolvedModel,
|
||||
ProviderResourceRef,
|
||||
ProviderService,
|
||||
ProviderStatus,
|
||||
)
|
||||
from backend.app.services.model_providers.makerworld.auth import is_cloud_token_invalid
|
||||
from backend.app.services.model_providers.makerworld.errors import (
|
||||
MakerWorldAuthError,
|
||||
MakerWorldForbiddenError,
|
||||
MakerWorldNotFoundError,
|
||||
MakerWorldUnavailableError,
|
||||
MakerWorldUrlError,
|
||||
)
|
||||
from backend.app.services.model_providers.makerworld.http import (
|
||||
_ALLOWED_DOWNLOAD_SUFFIXES,
|
||||
_CLIENT_HEADERS,
|
||||
_IMAGE_EXT_TO_MIME,
|
||||
_MAX_3MF_BYTES,
|
||||
_MAX_THUMBNAIL_BYTES,
|
||||
_REFUSED_THUMBNAIL_MIMES,
|
||||
MAKERWORLD_API_BASE,
|
||||
MAKERWORLD_CDN_HOSTS,
|
||||
_download_s3_urllib,
|
||||
_extract_upstream_error,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_shared_http_client: httpx.AsyncClient | None = None
|
||||
|
||||
# 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",)
|
||||
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.
|
||||
Must live in the same module as the service class so ``__init__`` reads
|
||||
the live value rather than an import-time snapshot.
|
||||
"""
|
||||
global _shared_http_client
|
||||
_shared_http_client = client
|
||||
|
||||
# Client identity sent to MakerWorld / api.bambulab.com. We identify honestly
|
||||
# as Bambuddy with a source URL so Bambu can distinguish our traffic from
|
||||
# impersonators — the opposite of what the OrcaSlicer fork was called out for
|
||||
# in the May 2026 Bambu Lab blog post on cloud access. Verified 2026-05-12 via
|
||||
# curl that MakerWorld treats this UA identically to a Firefox UA at the
|
||||
# Cloudflare edge (same response shape on /api/v1/design-service/* paths).
|
||||
# The Referer is kept because MakerWorld's CSRF / origin-check middleware uses
|
||||
# it on some endpoints — that's distinct from client impersonation.
|
||||
_CLIENT_HEADERS = {
|
||||
"User-Agent": "Bambuddy/1.0 (+https://github.com/maziggy/bambuddy)",
|
||||
"Accept": "text/html,application/json,*/*",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
"Referer": "https://makerworld.com/",
|
||||
}
|
||||
|
||||
# Shown whenever Bambu rejects the stored bearer. Bambu's own 401 body is
|
||||
# ``{"code":4,"error":"Please login.","message":""}`` and we used to forward that
|
||||
@@ -75,155 +89,8 @@ _SIGN_IN_EXPIRED_MESSAGE = (
|
||||
"Your Bambu Cloud sign-in has expired. Open the Profiles page and sign in to Bambu Cloud again."
|
||||
)
|
||||
|
||||
_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 _s3_ssl_context() -> ssl.SSLContext:
|
||||
"""Build the TLS context used for the S3 presigned download (#2562).
|
||||
|
||||
``urllib.request`` verifies against the *OS* trust store, while httpx —
|
||||
every other network call in Bambuddy — verifies against the bundled
|
||||
``certifi`` CA bundle. On Windows those two disagree: Python's
|
||||
``ssl.load_default_certs()`` only enumerates the roots already cached in
|
||||
the Windows ROOT store, and Windows populates that store lazily via
|
||||
CryptoAPI's auto-update, which Python never triggers. If the Amazon root
|
||||
signing the S3 chain isn't cached on that machine yet, verification fails
|
||||
with ``unable to get local issuer certificate`` — even though the
|
||||
api.bambulab.com calls that preceded it (httpx) succeeded.
|
||||
|
||||
Pinning urllib to certifi makes the S3 hop trust exactly what the rest of
|
||||
the app already trusts. Built per call rather than at import so a certifi
|
||||
refresh doesn't require a restart; construction is cheap relative to the
|
||||
download that follows.
|
||||
"""
|
||||
return ssl.create_default_context(cafile=certifi.where())
|
||||
|
||||
|
||||
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, HTTPSHandler, 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
|
||||
|
||||
# HTTPSHandler swaps only the TLS context — the URL still reaches the
|
||||
# transport verbatim, which is what the S3 signature depends on.
|
||||
opener = build_opener(_NoRedirect, HTTPSHandler(context=_s3_ssl_context()))
|
||||
|
||||
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:
|
||||
class MakerWorldService(ProviderService):
|
||||
"""Per-request MakerWorld API client.
|
||||
|
||||
Mirrors ``BambuCloudService``'s construction pattern so callers can
|
||||
@@ -233,15 +100,26 @@ class MakerWorldService:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
client: httpx.AsyncClient | None = None,
|
||||
auth_token: str | None = None,
|
||||
user: Any | None = None,
|
||||
on_auth_failure: Callable[[], Awaitable[None]] | None = None,
|
||||
thumbnail_hosts: tuple[str, ...] = MAKERWORLD_CDN_HOSTS,
|
||||
download_hosts: tuple[str, ...] = MAKERWORLD_CDN_HOSTS,
|
||||
):
|
||||
# Fired when Bambu rejects the stored token (401). MakerWorld runs on the
|
||||
# same Bambu Cloud bearer as everything else, so a rejection here means
|
||||
# the credential is dead app-wide — see ``build_authenticated_cloud``.
|
||||
self._on_auth_failure = on_auth_failure
|
||||
self._auth_failure_reported = False
|
||||
# SSRF allowlists for the thumbnail proxy and the 3MF download guard.
|
||||
# Default to MakerWorld's CDN hosts; ``MakerWorldProvider.build_service``
|
||||
# passes ``ModelProvider.thumbnail_hosts()`` / ``download_hosts()`` so
|
||||
# the guards are driven by the provider descriptor rather than enforced
|
||||
# by coincidence (interface contract on ``ProviderService``).
|
||||
self._thumbnail_hosts = tuple(thumbnail_hosts)
|
||||
self._download_hosts = tuple(download_hosts)
|
||||
if client is not None:
|
||||
self._client = client
|
||||
self._owns_client = False
|
||||
@@ -252,6 +130,7 @@ class MakerWorldService:
|
||||
self._client = httpx.AsyncClient(timeout=30.0)
|
||||
self._owns_client = True
|
||||
self._auth_token = auth_token
|
||||
self._user = user
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._owns_client:
|
||||
@@ -283,6 +162,121 @@ class MakerWorldService:
|
||||
headers["Authorization"] = f"Bearer {self._auth_token}"
|
||||
return headers
|
||||
|
||||
# ------------------------------------------------------------- interface
|
||||
|
||||
async def get_status(self, db: Any) -> ProviderStatus:
|
||||
"""Whether the caller can download: needs a stored, non-rejected Bambu
|
||||
Cloud token. ``credential_rejected`` is the machine-readable expired
|
||||
state; ``auth_error`` names it for humans so the UI can say "your
|
||||
sign-in expired" rather than a bare "sign in"."""
|
||||
has_token = bool(self._auth_token)
|
||||
expired = has_token and await is_cloud_token_invalid(db, self._user)
|
||||
return ProviderStatus(
|
||||
authenticated=has_token,
|
||||
can_download=has_token and not expired,
|
||||
auth_error=_SIGN_IN_EXPIRED_MESSAGE if expired else None,
|
||||
credential_rejected=expired,
|
||||
)
|
||||
|
||||
async def resolve(self, ref: ProviderResourceRef) -> ProviderResolvedModel:
|
||||
"""Fetch full model metadata + the plate list, merging per-instance
|
||||
printer compatibility so the frontend can show "sliced for A1 / also
|
||||
compatible with H2D, P1S" before the user picks a plate."""
|
||||
model_id = int(ref.external_id)
|
||||
design = await self.get_design(model_id)
|
||||
instances_envelope = await self.get_design_instances(model_id)
|
||||
|
||||
# MakerWorld's instances payload is ``{"total": N, "hits": [...]}``;
|
||||
# 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 = []
|
||||
|
||||
# /instances/hits omits the per-instance printer compatibility info
|
||||
# that /design.instances[].extention.modelInfo carries. Merge it in.
|
||||
design_instances = design.get("instances") or []
|
||||
if isinstance(design_instances, list):
|
||||
compat_by_id = {}
|
||||
for di in design_instances:
|
||||
if not isinstance(di, dict):
|
||||
continue
|
||||
iid = di.get("id")
|
||||
if iid is None:
|
||||
continue
|
||||
ext = (di.get("extention") or {}).get("modelInfo") or {}
|
||||
compat_by_id[iid] = {
|
||||
"compatibility": ext.get("compatibility"),
|
||||
"otherCompatibility": ext.get("otherCompatibility"),
|
||||
}
|
||||
for inst in instances:
|
||||
if not isinstance(inst, dict):
|
||||
continue
|
||||
iid = inst.get("id")
|
||||
extra = compat_by_id.get(iid)
|
||||
if extra:
|
||||
inst["compatibility"] = extra["compatibility"]
|
||||
inst["otherCompatibility"] = extra["otherCompatibility"]
|
||||
|
||||
return ProviderResolvedModel(ref=ref, design=design, instances=instances)
|
||||
|
||||
async def get_download(self, ref: ProviderResourceRef) -> ProviderDownloadInfo:
|
||||
"""Resolve the signed 3MF download for a specific MakerWorld profile.
|
||||
|
||||
Handles the provider-specific dance: the iot-service endpoint needs
|
||||
the *alphanumeric* ``modelId`` (e.g. ``"US2bb73b106683e5"``) from the
|
||||
design, not the integer design id, and picks a default profile when
|
||||
the caller didn't specify one. Enriches ``ref.sub_id`` with the actual
|
||||
profile used so the route can build the per-plate dedupe key.
|
||||
"""
|
||||
model_id = int(ref.external_id)
|
||||
design = await self.get_design(model_id)
|
||||
|
||||
alphanumeric_model_id = design.get("modelId")
|
||||
if not isinstance(alphanumeric_model_id, str) or not alphanumeric_model_id:
|
||||
raise MakerWorldUnavailableError("MakerWorld design metadata missing the modelId field")
|
||||
|
||||
profile_id = int(ref.sub_id) if ref.sub_id else None
|
||||
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:
|
||||
envelope = await self.get_design_instances(model_id)
|
||||
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:
|
||||
raise MakerWorldUnavailableError("MakerWorld returned no instances for this model")
|
||||
|
||||
manifest = await self.get_profile_download(profile_id, alphanumeric_model_id)
|
||||
|
||||
signed_url = manifest.get("url")
|
||||
if not signed_url or not isinstance(signed_url, str):
|
||||
raise MakerWorldUnavailableError("MakerWorld did not return a download URL")
|
||||
|
||||
# Raw upstream name — the route layer basenames / percent-decodes it
|
||||
# as defence-in-depth before persisting.
|
||||
raw_name = manifest.get("name")
|
||||
suggested_filename = raw_name if isinstance(raw_name, str) and raw_name.strip() else ""
|
||||
|
||||
return ProviderDownloadInfo(
|
||||
ref=replace(ref, sub_id=str(profile_id)),
|
||||
url=signed_url,
|
||||
suggested_filename=suggested_filename,
|
||||
)
|
||||
|
||||
async def download(self, info: ProviderDownloadInfo) -> ProviderDownload:
|
||||
"""Fetch the 3MF bytes for a signed URL, returning ``(bytes, filename)``."""
|
||||
file_bytes, download_filename = await self.download_3mf(info.url)
|
||||
return ProviderDownload(file_bytes=file_bytes, filename=download_filename)
|
||||
|
||||
# ---------------------------------------------------------------- endpoints
|
||||
|
||||
async def _get_json(self, path: str) -> dict[str, Any]:
|
||||
"""GET ``{MAKERWORLD_API_BASE}{path}`` returning the decoded JSON body.
|
||||
|
||||
@@ -376,49 +370,6 @@ class MakerWorldService:
|
||||
)
|
||||
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.
|
||||
|
||||
@@ -510,8 +461,13 @@ class MakerWorldService:
|
||||
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``).
|
||||
Validates that the URL's host is one of the declared download hosts
|
||||
(SSRF guard — driven by ``ModelProvider.download_hosts()`` via
|
||||
``build_service``, the symmetric counterpart to the thumbnail
|
||||
allowlist) *or* matches ``_ALLOWED_DOWNLOAD_SUFFIXES``, Bambu's S3
|
||||
regional endpoints, which are this provider's own signed-URL family
|
||||
rather than part of the injectable seam; 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)``.
|
||||
@@ -522,7 +478,7 @@ class MakerWorldService:
|
||||
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)
|
||||
is_allowed = host in self._download_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}")
|
||||
|
||||
@@ -570,8 +526,9 @@ class MakerWorldService:
|
||||
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
|
||||
Validates that the URL's host is one of the declared thumbnail hosts
|
||||
(SSRF guard — symmetric to :meth:`download_3mf`; both allowlists are
|
||||
fed from the provider descriptor by ``build_service``). Caps
|
||||
payload at 5 MB. Returns ``(bytes, content_type)``; content type
|
||||
defaults to ``image/jpeg`` if the upstream didn't set one.
|
||||
"""
|
||||
@@ -581,7 +538,7 @@ class MakerWorldService:
|
||||
raise MakerWorldUrlError(f"Invalid thumbnail URL: {exc}") from exc
|
||||
|
||||
host = (parsed.hostname or "").lower()
|
||||
if host not in MAKERWORLD_CDN_HOSTS:
|
||||
if host not in self._thumbnail_hosts:
|
||||
raise MakerWorldUrlError(f"Refusing to fetch thumbnail from non-MakerWorld host: {host!r}")
|
||||
|
||||
# ``follow_redirects=False``: the host allowlist above is only
|
||||
@@ -0,0 +1,82 @@
|
||||
"""MakerWorld URL parsing and canonicalisation.
|
||||
|
||||
Extracts ``(model_id, profile_id_or_None)`` from model URLs and builds the
|
||||
stable dedupe key used as the library ``source_url``. Rejects non-makerworld
|
||||
hosts — this is the input-validation surface, so it is deliberately strict.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from backend.app.services.model_providers.base import ProviderResourceRef
|
||||
from backend.app.services.model_providers.makerworld.errors import MakerWorldUrlError
|
||||
|
||||
MAKERWORLD_HOST = "makerworld.com" # Used only for URL parsing (input validation)
|
||||
|
||||
_MODEL_ID_RE = re.compile(r"/models/(\d+)")
|
||||
_PROFILE_ID_RE = re.compile(r"#profileId[-=](\d+)")
|
||||
|
||||
|
||||
def parse_url(url: str) -> ProviderResourceRef:
|
||||
"""Extract a :class:`ProviderResourceRef` 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 ProviderResourceRef(
|
||||
source_type="makerworld",
|
||||
external_id=str(model_id),
|
||||
sub_id=str(profile_id) if profile_id is not None else None,
|
||||
original_url=url,
|
||||
)
|
||||
|
||||
|
||||
def canonical_url(ref: ProviderResourceRef) -> str:
|
||||
"""Build a stable dedupe key for a MakerWorld resource.
|
||||
|
||||
Dedupe is keyed per *plate* (profile) rather than per model, since the
|
||||
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 ref.sub_id:
|
||||
return f"https://makerworld.com/models/{ref.external_id}#profileId-{ref.sub_id}"
|
||||
return f"https://makerworld.com/models/{ref.external_id}"
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Provider registry — maps ``source_type`` / URLs to model providers.
|
||||
|
||||
The registry is the routing layer a future *shared* import API uses: a pasted
|
||||
URL goes through :meth:`ModelProviderRegistry.find_for_url`, which asks each
|
||||
registered provider ``supports_url`` and returns the one that owns it. Today
|
||||
the route layer still calls the MakerWorld provider directly (endpoints stay
|
||||
at ``/makerworld/*``), but registering providers here keeps the seam ready.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from backend.app.services.model_providers.base import ModelProvider
|
||||
|
||||
|
||||
class ModelProviderRegistry:
|
||||
"""Holds the registered :class:`ModelProvider` instances.
|
||||
|
||||
Registering is idempotent per provider instance; registering a *different*
|
||||
provider under an already-taken ``source_type`` is an error.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._providers: dict[str, ModelProvider] = {}
|
||||
|
||||
def register(self, provider: ModelProvider) -> None:
|
||||
existing = self._providers.get(provider.source_type)
|
||||
if existing is not None and existing is not provider:
|
||||
raise ValueError(f"A model provider for source_type {provider.source_type!r} is already registered")
|
||||
self._providers[provider.source_type] = provider
|
||||
|
||||
def get(self, source_type: str) -> ModelProvider:
|
||||
try:
|
||||
return self._providers[source_type]
|
||||
except KeyError as exc:
|
||||
raise KeyError(f"No model provider registered for source_type {source_type!r}") from exc
|
||||
|
||||
def all(self) -> tuple[ModelProvider, ...]:
|
||||
return tuple(self._providers.values())
|
||||
|
||||
def find_for_url(self, url: str) -> ModelProvider | None:
|
||||
"""Return the provider that claims ``url``, or ``None`` if none do.
|
||||
|
||||
Iterates in registration (dict insertion) order; when more than one
|
||||
provider ``supports_url`` the *first registered* one wins. Providers
|
||||
overlap rarely (``host_patterns`` are usually disjoint), so this
|
||||
tie-break is documented rather than policed — a "generic" provider
|
||||
must register after the specific ones it might shadow.
|
||||
"""
|
||||
for provider in self._providers.values():
|
||||
if provider.supports_url(url):
|
||||
return provider
|
||||
return None
|
||||
|
||||
|
||||
# App-wide registry. Providers register themselves on package import (see
|
||||
# ``backend/app/services/model_providers/__init__.py``).
|
||||
registry = ModelProviderRegistry()
|
||||
@@ -29,7 +29,6 @@ import logging
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.api.routes.cloud import get_stored_token
|
||||
from backend.app.api.routes.orca_cloud import _build_authenticated_service as _build_orca_service
|
||||
from backend.app.core.permissions import Permission
|
||||
from backend.app.models.local_preset import LocalPreset
|
||||
@@ -40,6 +39,7 @@ from backend.app.services.bambu_cloud import (
|
||||
BambuCloudError,
|
||||
BambuCloudService,
|
||||
)
|
||||
from backend.app.services.bambu_cloud_credentials import get_stored_token
|
||||
from backend.app.services.orca_cloud import OrcaCloudAuthError, OrcaCloudError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -265,7 +265,7 @@ class TestCloudTokenStorage:
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_stored_token_returns_none_when_no_user_no_global(self, db_session):
|
||||
"""get_stored_token with user=None and no global token returns (None, None)."""
|
||||
from backend.app.api.routes.cloud import get_stored_token
|
||||
from backend.app.services.bambu_cloud_credentials import get_stored_token
|
||||
|
||||
token, email, region = await get_stored_token(db_session, user=None)
|
||||
assert token is None
|
||||
@@ -275,7 +275,8 @@ class TestCloudTokenStorage:
|
||||
@pytest.mark.asyncio
|
||||
async def test_store_and_get_global_token(self, db_session):
|
||||
"""store_token with user=None stores in global Settings table."""
|
||||
from backend.app.api.routes.cloud import get_stored_token, store_token
|
||||
from backend.app.api.routes.cloud import store_token
|
||||
from backend.app.services.bambu_cloud_credentials import get_stored_token
|
||||
|
||||
await store_token(db_session, "test-token-123", "test@example.com", "global", user=None)
|
||||
token, email, region = await get_stored_token(db_session, user=None)
|
||||
@@ -286,9 +287,10 @@ class TestCloudTokenStorage:
|
||||
@pytest.mark.asyncio
|
||||
async def test_store_and_get_per_user_token(self, db_session):
|
||||
"""store_token with user stores on the user record."""
|
||||
from backend.app.api.routes.cloud import get_stored_token, store_token
|
||||
from backend.app.api.routes.cloud import store_token
|
||||
from backend.app.core.auth import get_password_hash
|
||||
from backend.app.models.user import User
|
||||
from backend.app.services.bambu_cloud_credentials import get_stored_token
|
||||
|
||||
user = User(username="tokentest", password_hash=get_password_hash("pass"), role="user")
|
||||
db_session.add(user)
|
||||
@@ -309,9 +311,10 @@ class TestCloudTokenStorage:
|
||||
@pytest.mark.asyncio
|
||||
async def test_per_user_token_does_not_affect_global(self, db_session):
|
||||
"""Storing per-user token should not affect global Settings."""
|
||||
from backend.app.api.routes.cloud import get_stored_token, store_token
|
||||
from backend.app.api.routes.cloud import store_token
|
||||
from backend.app.core.auth import get_password_hash
|
||||
from backend.app.models.user import User
|
||||
from backend.app.services.bambu_cloud_credentials import get_stored_token
|
||||
|
||||
user = User(username="isolationtest", password_hash=get_password_hash("pass"), role="user")
|
||||
db_session.add(user)
|
||||
@@ -352,7 +355,8 @@ class TestCloudTokenStorage:
|
||||
@pytest.mark.asyncio
|
||||
async def test_clear_global_token(self, db_session):
|
||||
"""clear_token with user=None clears from global Settings."""
|
||||
from backend.app.api.routes.cloud import clear_token, get_stored_token, store_token
|
||||
from backend.app.api.routes.cloud import clear_token, store_token
|
||||
from backend.app.services.bambu_cloud_credentials import get_stored_token
|
||||
|
||||
await store_token(db_session, "global-token", "global@test.com", "global", user=None)
|
||||
await clear_token(db_session, user=None)
|
||||
@@ -365,9 +369,10 @@ class TestCloudTokenStorage:
|
||||
@pytest.mark.asyncio
|
||||
async def test_two_users_independent_tokens(self, db_session):
|
||||
"""Two users should have completely independent cloud tokens and regions."""
|
||||
from backend.app.api.routes.cloud import get_stored_token, store_token
|
||||
from backend.app.api.routes.cloud import store_token
|
||||
from backend.app.core.auth import get_password_hash
|
||||
from backend.app.models.user import User
|
||||
from backend.app.services.bambu_cloud_credentials import get_stored_token
|
||||
|
||||
user_a = User(username="user_a", password_hash=get_password_hash("pass"), role="user")
|
||||
user_b = User(username="user_b", password_hash=get_password_hash("pass"), role="user")
|
||||
@@ -406,9 +411,10 @@ class TestCloudRegionPersistence:
|
||||
@pytest.mark.asyncio
|
||||
async def test_region_survives_roundtrip_per_user(self, db_session):
|
||||
"""Stored China region is returned on subsequent get_stored_token calls."""
|
||||
from backend.app.api.routes.cloud import get_stored_token, store_token
|
||||
from backend.app.api.routes.cloud import store_token
|
||||
from backend.app.core.auth import get_password_hash
|
||||
from backend.app.models.user import User
|
||||
from backend.app.services.bambu_cloud_credentials import get_stored_token
|
||||
|
||||
user = User(username="region-user", password_hash=get_password_hash("pass"), role="user")
|
||||
db_session.add(user)
|
||||
@@ -429,7 +435,8 @@ class TestCloudRegionPersistence:
|
||||
@pytest.mark.asyncio
|
||||
async def test_region_survives_roundtrip_global_fallback(self, db_session):
|
||||
"""Stored China region in auth-disabled Settings fallback survives too."""
|
||||
from backend.app.api.routes.cloud import get_stored_token, store_token
|
||||
from backend.app.api.routes.cloud import store_token
|
||||
from backend.app.services.bambu_cloud_credentials import get_stored_token
|
||||
|
||||
await store_token(db_session, "cn-token", "token-auth", "china", user=None)
|
||||
_token, _email, region = await get_stored_token(db_session, user=None)
|
||||
@@ -438,7 +445,8 @@ class TestCloudRegionPersistence:
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_region_is_normalised_to_global(self, db_session):
|
||||
"""Unknown region values fall back to 'global' rather than mis-route."""
|
||||
from backend.app.api.routes.cloud import get_stored_token, store_token
|
||||
from backend.app.api.routes.cloud import store_token
|
||||
from backend.app.services.bambu_cloud_credentials import get_stored_token
|
||||
|
||||
await store_token(db_session, "t", "x@test.com", "mars", user=None)
|
||||
_token, _email, region = await get_stored_token(db_session, user=None)
|
||||
|
||||
@@ -20,16 +20,16 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.api.routes import cloud as cloud_routes
|
||||
from backend.app.api.routes.cloud import (
|
||||
from backend.app.core.auth import get_password_hash
|
||||
from backend.app.models.settings import Settings
|
||||
from backend.app.models.user import User
|
||||
from backend.app.services.bambu_cloud import BambuCloudError
|
||||
from backend.app.services.bambu_cloud_credentials import (
|
||||
CLOUD_EMAIL_KEY,
|
||||
CLOUD_REGION_KEY,
|
||||
CLOUD_TOKEN_KEY,
|
||||
get_stored_token,
|
||||
)
|
||||
from backend.app.core.auth import get_password_hash
|
||||
from backend.app.models.settings import Settings
|
||||
from backend.app.models.user import User
|
||||
from backend.app.services.bambu_cloud import BambuCloudError
|
||||
|
||||
|
||||
async def _seed_global_token(db: AsyncSession, token: str = "tok-global", region: str = "china") -> None:
|
||||
|
||||
@@ -29,6 +29,12 @@ from backend.app.core.auth import generate_api_key
|
||||
from backend.app.models.api_key import APIKey
|
||||
from backend.app.models.library import LibraryFile
|
||||
from backend.app.models.user import User
|
||||
from backend.app.services.model_providers.base import (
|
||||
ProviderDownload,
|
||||
ProviderDownloadInfo,
|
||||
ProviderResolvedModel,
|
||||
ProviderResourceRef,
|
||||
)
|
||||
|
||||
|
||||
async def _setup_auth_with_admin(client: AsyncClient) -> str:
|
||||
@@ -104,6 +110,20 @@ def _fake_service(**stubs):
|
||||
return svc
|
||||
|
||||
|
||||
def _download_info(
|
||||
model_id: int = 1400373,
|
||||
profile_id: int = 298919107,
|
||||
name: str = "cube.3mf",
|
||||
) -> ProviderDownloadInfo:
|
||||
"""What ``service.get_download`` hands the route — signed URL + raw
|
||||
upstream name + enriched ref (sub_id carries the resolved profile)."""
|
||||
return ProviderDownloadInfo(
|
||||
ref=ProviderResourceRef(source_type="makerworld", external_id=str(model_id), sub_id=str(profile_id)),
|
||||
url="https://makerworld.bblmw.com/makerworld/model/X/Y/cube.3mf?exp=1&key=k",
|
||||
suggested_filename=name,
|
||||
)
|
||||
|
||||
|
||||
class TestStatusEndpoint:
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
@@ -161,9 +181,13 @@ class TestResolveEndpoint:
|
||||
admin = await _store_admin_cloud_token(db_session, "mwadmin", token="fake-bambu-token")
|
||||
key = await _make_key(db_session, owner=admin, name="resolve-cloud")
|
||||
|
||||
design = {"id": 1400373, "modelId": "US2bb73b106683e5", "title": "Cube", "instances": []}
|
||||
instances = {"total": 0, "hits": []}
|
||||
svc = _fake_service(get_design=design, get_design_instances=instances)
|
||||
svc = _fake_service(
|
||||
resolve=ProviderResolvedModel(
|
||||
ref=ProviderResourceRef(source_type="makerworld", external_id="1400373"),
|
||||
design={"id": 1400373, "title": "Cube"},
|
||||
instances=[],
|
||||
)
|
||||
)
|
||||
build = AsyncMock(return_value=svc)
|
||||
|
||||
with patch("backend.app.api.routes.makerworld._build_service", build):
|
||||
@@ -173,14 +197,22 @@ class TestResolveEndpoint:
|
||||
headers={"X-API-Key": key},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
# _build_service receives (db, user); the user arg must be the owning admin.
|
||||
# Without the fix it'd be None (the API-key dep value).
|
||||
# _build_service receives (db, provider, current_user, api_key_cloud_owner).
|
||||
# Identity resolution lives in the provider now: for an API-keyed
|
||||
# call current_user is None by design and the key's owner must arrive
|
||||
# via api_key_cloud_owner — without the fix it'd be dropped entirely.
|
||||
assert build.await_count == 1
|
||||
passed_user = (
|
||||
build.await_args.args[1] if len(build.await_args.args) > 1 else build.await_args.kwargs.get("user")
|
||||
jwt_user = (
|
||||
build.await_args.args[2] if len(build.await_args.args) > 2 else build.await_args.kwargs.get("current_user")
|
||||
)
|
||||
assert passed_user is not None, "resolve_url must pass the API-key owner, not None"
|
||||
assert passed_user.id == admin.id
|
||||
key_owner = (
|
||||
build.await_args.args[3]
|
||||
if len(build.await_args.args) > 3
|
||||
else build.await_args.kwargs.get("api_key_cloud_owner")
|
||||
)
|
||||
assert jwt_user is None, "API-keyed callers present no JWT user"
|
||||
assert key_owner is not None, "resolve_url must forward the API-key owner to the provider"
|
||||
assert key_owner.id == admin.id
|
||||
|
||||
|
||||
class TestImportEndpoint:
|
||||
@@ -195,23 +227,12 @@ class TestImportEndpoint:
|
||||
admin = await _store_admin_cloud_token(db_session, "mwadmin", token="fake-bambu-token")
|
||||
key = await _make_key(db_session, owner=admin, name="import-cloud")
|
||||
|
||||
design = {
|
||||
"id": 1400373,
|
||||
"modelId": "US2bb73b106683e5",
|
||||
"title": "Cube",
|
||||
"instances": [{"profileId": 298919107, "title": "default"}],
|
||||
}
|
||||
manifest = {
|
||||
"name": "cube.3mf",
|
||||
"url": "https://makerworld.bblmw.com/makerworld/model/X/Y/cube.3mf?exp=1&key=k",
|
||||
}
|
||||
# 3MF download returns (bytes, filename). The bytes don't have to be a
|
||||
# valid zip — save_3mf_bytes_to_library stores them as-is and the
|
||||
# downstream thumbnail extractor swallows errors.
|
||||
# The 3MF bytes don't have to be a valid zip —
|
||||
# save_3mf_bytes_to_library stores them as-is and the downstream
|
||||
# thumbnail extractor swallows errors.
|
||||
svc = _fake_service(
|
||||
get_design=design,
|
||||
get_profile_download=manifest,
|
||||
download_3mf=(b"PK\x03\x04fake-3mf-bytes", "cube.3mf"),
|
||||
get_download=_download_info(),
|
||||
download=ProviderDownload(file_bytes=b"PK\x03\x04fake-3mf-bytes", filename="cube.3mf"),
|
||||
)
|
||||
|
||||
with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
|
||||
@@ -249,16 +270,9 @@ class TestImportEndpoint:
|
||||
admin = await _store_admin_cloud_token(db_session, "mwadmin", token="fake-bambu-token")
|
||||
key = await _make_key(db_session, owner=admin, name="import-no-cloud", can_access_cloud=False)
|
||||
|
||||
design = {
|
||||
"id": 1400373,
|
||||
"modelId": "US2bb73b106683e5",
|
||||
"instances": [{"profileId": 298919107}],
|
||||
}
|
||||
manifest = {"name": "cube.3mf", "url": "https://makerworld.bblmw.com/x.3mf"}
|
||||
svc = _fake_service(
|
||||
get_design=design,
|
||||
get_profile_download=manifest,
|
||||
download_3mf=(b"PK\x03\x04fake", "cube.3mf"),
|
||||
get_download=_download_info(),
|
||||
download=ProviderDownload(file_bytes=b"PK\x03\x04fake", filename="cube.3mf"),
|
||||
)
|
||||
|
||||
with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)) as build:
|
||||
@@ -270,11 +284,19 @@ class TestImportEndpoint:
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
|
||||
# _build_service got None — same as before the PR for non-cloud keys.
|
||||
passed_user = (
|
||||
build.await_args.args[1] if len(build.await_args.args) > 1 else build.await_args.kwargs.get("user")
|
||||
# Both identity slots are None — same as before the PR for non-cloud
|
||||
# keys: no JWT user, and the cloud-scope fence keeps the key's owner
|
||||
# back, so the provider builds an anonymous service.
|
||||
jwt_user = (
|
||||
build.await_args.args[2] if len(build.await_args.args) > 2 else build.await_args.kwargs.get("current_user")
|
||||
)
|
||||
assert passed_user is None
|
||||
key_owner = (
|
||||
build.await_args.args[3]
|
||||
if len(build.await_args.args) > 3
|
||||
else build.await_args.kwargs.get("api_key_cloud_owner")
|
||||
)
|
||||
assert jwt_user is None
|
||||
assert key_owner is None
|
||||
|
||||
# And owner_id is NULL because the cloud-scope fence said no.
|
||||
result = await db_session.execute(select(LibraryFile).where(LibraryFile.id == body["library_file_id"]))
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
"""The /makerworld/* permission gate with auth enabled.
|
||||
|
||||
The gate moved out of the route signature and into the handler: the provider
|
||||
that a request actually uses comes from the body (``source_type`` on import,
|
||||
the pasted URL on resolve), and FastAPI resolves dependencies before the body
|
||||
exists, so a dependency could only ever name one provider's permission. What
|
||||
must not change is the enforcement itself, so these pin the outcomes rather
|
||||
than the wiring: anonymous callers are still refused before the body is read,
|
||||
and a signed-in user without the permission still gets a 403.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from backend.app.services.model_providers.base import (
|
||||
ProviderDownload,
|
||||
ProviderDownloadInfo,
|
||||
ProviderResolvedModel,
|
||||
ProviderResourceRef,
|
||||
)
|
||||
|
||||
|
||||
async def _setup_auth_with_admin(client: AsyncClient) -> str:
|
||||
await client.post(
|
||||
"/api/v1/auth/setup",
|
||||
json={"auth_enabled": True, "admin_username": "mwadmin", "admin_password": "AdminPass1!"},
|
||||
)
|
||||
login = await client.post("/api/v1/auth/login", json={"username": "mwadmin", "password": "AdminPass1!"})
|
||||
assert login.status_code == 200, login.text
|
||||
return login.json()["access_token"]
|
||||
|
||||
|
||||
async def _make_user(client: AsyncClient, admin_jwt: str, *, username: str, permissions: list[str]) -> str:
|
||||
"""Create a user in a fresh group holding exactly *permissions*."""
|
||||
group = await client.post(
|
||||
"/api/v1/groups/",
|
||||
headers={"Authorization": f"Bearer {admin_jwt}"},
|
||||
json={"name": f"grp_{username}", "permissions": permissions},
|
||||
)
|
||||
assert group.status_code in (200, 201), group.text
|
||||
created = await client.post(
|
||||
"/api/v1/users/",
|
||||
headers={"Authorization": f"Bearer {admin_jwt}"},
|
||||
json={"username": username, "password": "UserPass1!", "group_ids": [group.json()["id"]]},
|
||||
)
|
||||
assert created.status_code in (200, 201), created.text
|
||||
login = await client.post("/api/v1/auth/login", json={"username": username, "password": "UserPass1!"})
|
||||
assert login.status_code == 200, login.text
|
||||
return login.json()["access_token"]
|
||||
|
||||
|
||||
def _fake_service(**stubs):
|
||||
svc = AsyncMock()
|
||||
svc.close = AsyncMock()
|
||||
for name, value in stubs.items():
|
||||
setattr(svc, name, AsyncMock(return_value=value))
|
||||
return svc
|
||||
|
||||
|
||||
def _import_service():
|
||||
return _fake_service(
|
||||
get_download=ProviderDownloadInfo(
|
||||
ref=ProviderResourceRef(source_type="makerworld", external_id="1400373", sub_id="298919107"),
|
||||
url="https://makerworld.bblmw.com/makerworld/model/X/Y/cube.3mf?exp=1&key=k",
|
||||
suggested_filename="cube.3mf",
|
||||
),
|
||||
download=ProviderDownload(file_bytes=b"PK\x03\x04fake-3mf-bytes", filename="cube.3mf"),
|
||||
)
|
||||
|
||||
|
||||
class TestAnonymousIsRefusedFirst:
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_anonymous_import_is_401(self, async_client: AsyncClient):
|
||||
await _setup_auth_with_admin(async_client)
|
||||
resp = await async_client.post("/api/v1/makerworld/import", json={"model_id": 1400373})
|
||||
assert resp.status_code == 401, resp.text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_anonymous_resolve_is_401(self, async_client: AsyncClient):
|
||||
await _setup_auth_with_admin(async_client)
|
||||
resp = await async_client.post(
|
||||
"/api/v1/makerworld/resolve",
|
||||
json={"url": "https://makerworld.com/en/models/1400373"},
|
||||
)
|
||||
assert resp.status_code == 401, resp.text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_anonymous_with_a_malformed_body_is_still_401_not_422(self, async_client: AsyncClient):
|
||||
"""The permission moved into the handler, but authentication stayed a
|
||||
route dependency precisely so an unauthenticated caller cannot probe
|
||||
the request schema through validation errors."""
|
||||
await _setup_auth_with_admin(async_client)
|
||||
resp = await async_client.post("/api/v1/makerworld/import", json={"nonsense": True})
|
||||
assert resp.status_code == 401, resp.text
|
||||
|
||||
|
||||
class TestPermissionStillBites:
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_view_only_user_cannot_import(self, async_client: AsyncClient):
|
||||
admin = await _setup_auth_with_admin(async_client)
|
||||
jwt = await _make_user(async_client, admin, username="mwviewer", permissions=["makerworld:view"])
|
||||
|
||||
with patch(
|
||||
"backend.app.api.routes.makerworld._build_service",
|
||||
AsyncMock(return_value=_import_service()),
|
||||
):
|
||||
resp = await async_client.post(
|
||||
"/api/v1/makerworld/import",
|
||||
json={"model_id": 1400373},
|
||||
headers={"Authorization": f"Bearer {jwt}"},
|
||||
)
|
||||
assert resp.status_code == 403, resp.text
|
||||
assert "makerworld:import" in resp.json()["detail"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_user_without_view_cannot_resolve(self, async_client: AsyncClient):
|
||||
admin = await _setup_auth_with_admin(async_client)
|
||||
jwt = await _make_user(async_client, admin, username="mwnoview", permissions=["printers:read"])
|
||||
|
||||
resp = await async_client.post(
|
||||
"/api/v1/makerworld/resolve",
|
||||
json={"url": "https://makerworld.com/en/models/1400373"},
|
||||
headers={"Authorization": f"Bearer {jwt}"},
|
||||
)
|
||||
assert resp.status_code == 403, resp.text
|
||||
assert "makerworld:view" in resp.json()["detail"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_user_holding_the_permission_gets_through(self, async_client: AsyncClient):
|
||||
admin = await _setup_auth_with_admin(async_client)
|
||||
jwt = await _make_user(
|
||||
async_client,
|
||||
admin,
|
||||
username="mwimporter",
|
||||
permissions=["makerworld:view", "makerworld:import"],
|
||||
)
|
||||
|
||||
with patch(
|
||||
"backend.app.api.routes.makerworld._build_service",
|
||||
AsyncMock(return_value=_import_service()),
|
||||
):
|
||||
resp = await async_client.post(
|
||||
"/api/v1/makerworld/import",
|
||||
json={"model_id": 1400373},
|
||||
headers={"Authorization": f"Bearer {jwt}"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["was_existing"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_resolve_passes_for_a_viewer(self, async_client: AsyncClient):
|
||||
admin = await _setup_auth_with_admin(async_client)
|
||||
jwt = await _make_user(async_client, admin, username="mwviewer2", permissions=["makerworld:view"])
|
||||
|
||||
svc = _fake_service(
|
||||
resolve=ProviderResolvedModel(
|
||||
ref=ProviderResourceRef(source_type="makerworld", external_id="1400373"),
|
||||
design={"id": 1400373},
|
||||
instances=[],
|
||||
)
|
||||
)
|
||||
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"},
|
||||
headers={"Authorization": f"Bearer {jwt}"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["model_id"] == 1400373
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Tests for ``services/bambu_cloud_credentials`` — the credential seam.
|
||||
|
||||
The read paths are covered indirectly by the cloud-token expiry and
|
||||
migration suites; these pin the write path that review blocker 5 hinged on:
|
||||
``mark_cloud_token_invalid`` must record a rejection for *both* identity
|
||||
shapes, because auth-disabled single-user installs (the default) hold their
|
||||
token in global ``Settings`` — ``user_id=None`` is a real, expected input,
|
||||
not a degenerate one.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from backend.app.core.auth import get_password_hash
|
||||
from backend.app.models.settings import Settings
|
||||
from backend.app.models.user import User
|
||||
from backend.app.services import bambu_cloud_credentials as creds
|
||||
from backend.app.services.bambu_cloud_credentials import (
|
||||
CLOUD_TOKEN_INVALID_KEY,
|
||||
mark_cloud_token_invalid,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
class _SharedSessionCtx:
|
||||
"""Route ``mark`` through the fixture's in-memory session: the function
|
||||
normally opens its own session against the configured database, which in
|
||||
tests is a different SQLite than ``db_session``'s in-memory one."""
|
||||
|
||||
def __init__(self, session):
|
||||
self._session = session
|
||||
|
||||
async def __aenter__(self):
|
||||
return self._session
|
||||
|
||||
async def __aexit__(self, *exc):
|
||||
return False
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def shared_session(db_session, monkeypatch):
|
||||
monkeypatch.setattr(creds, "async_session", lambda: _SharedSessionCtx(db_session))
|
||||
|
||||
|
||||
async def _make_user(db, username: str = "cred-user") -> User:
|
||||
user = User(
|
||||
username=username,
|
||||
password_hash=get_password_hash("AdminPass1!"),
|
||||
role="admin",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
async def test_mark_sets_the_per_user_flag(db_session):
|
||||
"""user_id set → the rejection lands on that user's column."""
|
||||
user = await _make_user(db_session)
|
||||
|
||||
await mark_cloud_token_invalid(user.id)
|
||||
await db_session.refresh(user)
|
||||
|
||||
assert user.cloud_token_invalid_at is not None
|
||||
|
||||
|
||||
async def test_mark_none_writes_the_global_settings_flag(db_session):
|
||||
"""user_id=None (auth-disabled install) → the global ``Settings`` row.
|
||||
A second call updates the existing row rather than adding another."""
|
||||
await mark_cloud_token_invalid(None)
|
||||
|
||||
result = await db_session.execute(select(Settings).where(Settings.key == CLOUD_TOKEN_INVALID_KEY))
|
||||
rows = result.scalars().all()
|
||||
assert len(rows) == 1
|
||||
# Stored value parses as ISO — the status endpoints compare it as a date.
|
||||
datetime.fromisoformat(rows[0].value)
|
||||
|
||||
first_value = rows[0].value
|
||||
await mark_cloud_token_invalid(None)
|
||||
result = await db_session.execute(select(Settings).where(Settings.key == CLOUD_TOKEN_INVALID_KEY))
|
||||
rows = result.scalars().all()
|
||||
assert len(rows) == 1
|
||||
assert rows[0].value >= first_value
|
||||
|
||||
|
||||
async def test_mark_is_best_effort(db_session, monkeypatch):
|
||||
"""A bookkeeping failure must never replace the 401 the caller needs to
|
||||
see — the function swallows everything."""
|
||||
|
||||
class _Boom:
|
||||
async def __aenter__(self):
|
||||
raise RuntimeError("db gone")
|
||||
|
||||
async def __aexit__(self, *exc):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(creds, "async_session", lambda: _Boom())
|
||||
|
||||
# Must not raise.
|
||||
await mark_cloud_token_invalid(None)
|
||||
@@ -8,55 +8,55 @@ from urllib.error import HTTPError, URLError
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from backend.app.services.makerworld import (
|
||||
_MAX_3MF_BYTES,
|
||||
MAKERWORLD_API_BASE,
|
||||
from backend.app.services.model_providers.base import ProviderResourceRef
|
||||
from backend.app.services.model_providers.makerworld.errors import (
|
||||
MakerWorldAuthError,
|
||||
MakerWorldForbiddenError,
|
||||
MakerWorldNotFoundError,
|
||||
MakerWorldService,
|
||||
MakerWorldUnavailableError,
|
||||
MakerWorldUrlError,
|
||||
)
|
||||
from backend.app.services.model_providers.makerworld.http import _MAX_3MF_BYTES, MAKERWORLD_API_BASE
|
||||
from backend.app.services.model_providers.makerworld.service import MakerWorldService, set_shared_http_client
|
||||
from backend.app.services.model_providers.makerworld.url import parse_url
|
||||
|
||||
|
||||
class TestParseUrl:
|
||||
"""MakerWorld URL extraction."""
|
||||
"""MakerWorld URL extraction — tests parse_url directly."""
|
||||
|
||||
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
|
||||
ref = parse_url("https://makerworld.com/en/models/1400373-self-watering-seed-starter")
|
||||
assert ref.external_id == "1400373"
|
||||
assert ref.sub_id 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
|
||||
ref = parse_url("https://makerworld.com/en/models/1400373-slug#profileId-1452154")
|
||||
assert ref.external_id == "1400373"
|
||||
assert ref.sub_id == "1452154"
|
||||
|
||||
def test_accepts_scheme_omitted(self):
|
||||
model, profile = MakerWorldService.parse_url("makerworld.com/models/999")
|
||||
assert model == 999
|
||||
assert profile is None
|
||||
ref = parse_url("makerworld.com/models/999")
|
||||
assert ref.external_id == "999"
|
||||
assert ref.sub_id 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
|
||||
ref = parse_url("https://www.makerworld.com/en/models/42")
|
||||
assert ref.external_id == "42"
|
||||
assert ref.sub_id is None
|
||||
|
||||
def test_rejects_non_makerworld_host(self):
|
||||
with pytest.raises(MakerWorldUrlError):
|
||||
MakerWorldService.parse_url("https://thingiverse.com/things/123")
|
||||
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")
|
||||
parse_url("https://makerworld.com/en/creators/foo")
|
||||
|
||||
def test_rejects_empty(self):
|
||||
with pytest.raises(MakerWorldUrlError):
|
||||
MakerWorldService.parse_url("")
|
||||
parse_url("")
|
||||
|
||||
|
||||
class TestApiBase:
|
||||
@@ -269,6 +269,195 @@ class TestGetDesign:
|
||||
await service.get_design(1)
|
||||
|
||||
|
||||
class TestResolve:
|
||||
"""``resolve`` — the interface-level "URL → metadata + plate list" flow the
|
||||
/makerworld/resolve route drives. The per-instance printer-compatibility
|
||||
merge lives here (not in the route) so every future provider gets it from
|
||||
its own ``resolve`` implementation."""
|
||||
|
||||
@pytest.fixture
|
||||
def service(self):
|
||||
return MakerWorldService(client=MagicMock(spec=httpx.AsyncClient))
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_merges_compatibility_from_design_into_instances(self, service):
|
||||
"""Per-instance printer compatibility info lives on
|
||||
``design.instances[].extention.modelInfo`` but not on
|
||||
``/instances/hits``. Resolve enriches each hit with both
|
||||
``compatibility`` (primary printer the instance was sliced for) and
|
||||
``otherCompatibility`` (extra printers the uploader marked it
|
||||
compatible with) so the frontend can show "sliced for A1 / also
|
||||
marked compatible with: H2D, P1S".
|
||||
"""
|
||||
design_payload = {
|
||||
"id": 1400373,
|
||||
"title": "Seed Starter",
|
||||
"instances": [
|
||||
{
|
||||
"id": 1452154,
|
||||
"extention": {
|
||||
"modelInfo": {
|
||||
"compatibility": ["A1"],
|
||||
"otherCompatibility": ["H2D", "P1S"],
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": 1452158,
|
||||
"extention": {
|
||||
"modelInfo": {
|
||||
"compatibility": ["X1 Carbon"],
|
||||
"otherCompatibility": [],
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
instances_payload = {
|
||||
"total": 2,
|
||||
"hits": [
|
||||
{"id": 1452154, "profileId": 298919107, "title": "9 cells"},
|
||||
{"id": 1452158, "profileId": 298919564, "title": "12 cells"},
|
||||
],
|
||||
}
|
||||
service.get_design = AsyncMock(return_value=design_payload)
|
||||
service.get_design_instances = AsyncMock(return_value=instances_payload)
|
||||
|
||||
resolved = await service.resolve(ProviderResourceRef(source_type="makerworld", external_id="1400373"))
|
||||
by_id = {i["id"]: i for i in resolved.instances}
|
||||
assert by_id[1452154]["compatibility"] == ["A1"]
|
||||
assert by_id[1452154]["otherCompatibility"] == ["H2D", "P1S"]
|
||||
assert by_id[1452158]["compatibility"] == ["X1 Carbon"]
|
||||
assert by_id[1452158]["otherCompatibility"] == []
|
||||
assert resolved.design == design_payload
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handles_missing_compatibility_gracefully(self, service):
|
||||
"""Older designs (or hits without a matching design.instances entry)
|
||||
must not crash resolve — they just don't get the compat fields."""
|
||||
design_payload = {"id": 1400373, "instances": [{"id": 1452154}]} # no extention
|
||||
instances_payload = {
|
||||
"total": 2,
|
||||
"hits": [
|
||||
{"id": 1452154, "profileId": 298919107},
|
||||
{"id": 9999999, "profileId": 298919999}, # no design.instances match
|
||||
],
|
||||
}
|
||||
service.get_design = AsyncMock(return_value=design_payload)
|
||||
service.get_design_instances = AsyncMock(return_value=instances_payload)
|
||||
|
||||
resolved = await service.resolve(ProviderResourceRef(source_type="makerworld", external_id="1400373"))
|
||||
# First instance: design entry exists but no extention → fields absent or None.
|
||||
first = next(i for i in resolved.instances if i["id"] == 1452154)
|
||||
assert first.get("compatibility") is None
|
||||
assert first.get("otherCompatibility") is None
|
||||
# Second instance: no design entry at all → no enrichment, no crash.
|
||||
second = next(i for i in resolved.instances if i["id"] == 9999999)
|
||||
assert "compatibility" not in second or second["compatibility"] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_normalises_null_and_non_list_hits_to_empty(self, service):
|
||||
service.get_design = AsyncMock(return_value={"id": 1400373})
|
||||
service.get_design_instances = AsyncMock(return_value={"total": 0, "hits": None})
|
||||
|
||||
resolved = await service.resolve(ProviderResourceRef(source_type="makerworld", external_id="1400373"))
|
||||
assert resolved.instances == []
|
||||
|
||||
|
||||
class TestGetDownload:
|
||||
"""``get_download`` — the interface-level "resource → signed 3MF URL"
|
||||
flow the /makerworld/import route drives. The provider-specific dance
|
||||
lives here: the iot-service endpoint needs the *alphanumeric* modelId
|
||||
(not the integer design id), the profile falls back in two tiers, and
|
||||
three malformed-upstream shapes must map to UnavailableError (502)."""
|
||||
|
||||
@pytest.fixture
|
||||
def service(self):
|
||||
return MakerWorldService(client=MagicMock(spec=httpx.AsyncClient))
|
||||
|
||||
def _design(self, **overrides):
|
||||
design = {
|
||||
"id": 1400373,
|
||||
"modelId": "US2bb73b106683e5",
|
||||
"instances": [{"profileId": 298919107, "title": "9 cells"}],
|
||||
}
|
||||
design.update(overrides)
|
||||
return design
|
||||
|
||||
def _manifest(self, url="https://makerworld.bblmw.com/x.3mf?exp=1", name="benchy.3mf"):
|
||||
return {"url": url, "name": name}
|
||||
|
||||
async def _run(self, service, ref):
|
||||
return await service.get_download(ref)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolves_alphanumeric_model_id_and_explicit_profile(self, service):
|
||||
"""Explicit profile_id flows through; get_profile_download receives
|
||||
the alphanumeric modelId from the design, not the integer id."""
|
||||
service.get_design = AsyncMock(return_value=self._design())
|
||||
manifest = self._manifest()
|
||||
service.get_profile_download = AsyncMock(return_value=manifest)
|
||||
|
||||
info = await self._run(
|
||||
service, ProviderResourceRef(source_type="makerworld", external_id="1400373", sub_id="298919107")
|
||||
)
|
||||
|
||||
service.get_profile_download.assert_awaited_once_with(298919107, "US2bb73b106683e5")
|
||||
assert info.url == manifest["url"]
|
||||
assert info.suggested_filename == "benchy.3mf"
|
||||
# The enriched ref carries the resolved profile for the dedupe key.
|
||||
assert info.ref.sub_id == "298919107"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_falls_back_to_first_design_instance_profile(self, service):
|
||||
"""No profile given → first ``design.instances[].profileId`` wins."""
|
||||
service.get_design = AsyncMock(return_value=self._design())
|
||||
service.get_profile_download = AsyncMock(return_value=self._manifest())
|
||||
|
||||
info = await self._run(service, ProviderResourceRef(source_type="makerworld", external_id="1400373"))
|
||||
|
||||
service.get_profile_download.assert_awaited_once_with(298919107, "US2bb73b106683e5")
|
||||
assert info.ref.sub_id == "298919107"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_second_tier_falls_back_to_instances_envelope(self, service):
|
||||
"""Design carries no usable profileId → the ``/design/{id}/instances``
|
||||
envelope is consulted before giving up."""
|
||||
service.get_design = AsyncMock(return_value=self._design(instances=[{"title": "no profileId here"}]))
|
||||
service.get_design_instances = AsyncMock(return_value={"total": 1, "hits": [{"profileId": 298919564}]})
|
||||
service.get_profile_download = AsyncMock(return_value=self._manifest())
|
||||
|
||||
info = await self._run(service, ProviderResourceRef(source_type="makerworld", external_id="1400373"))
|
||||
|
||||
service.get_design_instances.assert_awaited_once_with(1400373)
|
||||
service.get_profile_download.assert_awaited_once_with(298919564, "US2bb73b106683e5")
|
||||
assert info.ref.sub_id == "298919564"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_alphanumeric_model_id_is_unavailable(self, service):
|
||||
"""A design without the ``modelId`` field can't reach iot-service."""
|
||||
service.get_design = AsyncMock(return_value={"id": 1400373})
|
||||
|
||||
with pytest.raises(MakerWorldUnavailableError, match="modelId"):
|
||||
await self._run(service, ProviderResourceRef(source_type="makerworld", external_id="1400373"))
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_profiles_anywhere_is_unavailable(self, service):
|
||||
service.get_design = AsyncMock(return_value=self._design(instances=[]))
|
||||
service.get_design_instances = AsyncMock(return_value={"total": 0, "hits": []})
|
||||
|
||||
with pytest.raises(MakerWorldUnavailableError, match="no instances"):
|
||||
await self._run(service, ProviderResourceRef(source_type="makerworld", external_id="1400373"))
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manifest_without_url_is_unavailable(self, service):
|
||||
service.get_design = AsyncMock(return_value=self._design())
|
||||
service.get_profile_download = AsyncMock(return_value={"name": "benchy.3mf"})
|
||||
|
||||
with pytest.raises(MakerWorldUnavailableError, match="download URL"):
|
||||
await self._run(service, ProviderResourceRef(source_type="makerworld", external_id="1400373"))
|
||||
|
||||
|
||||
class TestGetProfileDownload:
|
||||
"""The new auth-gated 3MF manifest endpoint on the Bambu iot-service.
|
||||
|
||||
@@ -397,11 +586,52 @@ class TestDownload3MF:
|
||||
with pytest.raises(MakerWorldUrlError):
|
||||
await svc.download_3mf(url)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_allowlist_is_driven_by_injected_hosts(self):
|
||||
"""``download_hosts`` is the SSRF seam, not a hardcoded constant (review
|
||||
round 3 note 2). ``build_service`` passes ``ModelProvider.download_hosts()``
|
||||
into ``MakerWorldService`` — prove the injection is live by accepting a
|
||||
host inside a custom allowlist and refusing a MakerWorld CDN host that
|
||||
isn't in it."""
|
||||
svc = MakerWorldService(client=MagicMock(spec=httpx.AsyncClient), download_hosts=("cdn.example.com",))
|
||||
|
||||
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))
|
||||
|
||||
payload, _ = await svc.download_3mf("https://cdn.example.com/m/foo.3mf?exp=1&key=k")
|
||||
assert payload == b"PK\x03\x04"
|
||||
|
||||
with pytest.raises(MakerWorldUrlError):
|
||||
await svc.download_3mf("https://makerworld.bblmw.com/makerworld/model/X/Y/foo.3mf?exp=1&key=k")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_s3_suffix_family_is_allowed_regardless_of_injected_hosts(self):
|
||||
"""``_ALLOWED_DOWNLOAD_SUFFIXES`` is deliberately outside the
|
||||
``download_hosts()`` seam: Bambu's presigned S3 endpoints are this
|
||||
provider's own signed-URL family, not an exact-host allowlist a
|
||||
provider declares. Pinned so narrowing the injected hosts can never
|
||||
silently take the S3 download path with it."""
|
||||
svc = MakerWorldService(client=MagicMock(spec=httpx.AsyncClient), download_hosts=("cdn.example.com",))
|
||||
with patch(
|
||||
"backend.app.services.model_providers.makerworld.service._download_s3_urllib",
|
||||
AsyncMock(return_value=(b"PK\x03\x04", "plate.3mf")),
|
||||
) as s3:
|
||||
payload, name = await svc.download_3mf("https://s3.us-west-2.amazonaws.com/bucket/plate.3mf?sig=1")
|
||||
assert payload == b"PK\x03\x04"
|
||||
assert name == "plate.3mf"
|
||||
assert s3.await_count == 1
|
||||
|
||||
@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",
|
||||
"backend.app.services.model_providers.makerworld.service._download_s3_urllib",
|
||||
new=AsyncMock(return_value=(b"payload", "file.3mf")),
|
||||
) as mocked:
|
||||
payload, filename = await svc.download_3mf(
|
||||
@@ -499,7 +729,7 @@ class TestS3UrllibDownload:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_bytes_and_filename(self):
|
||||
from backend.app.services.makerworld import _download_s3_urllib
|
||||
from backend.app.services.model_providers.makerworld.http import _download_s3_urllib
|
||||
|
||||
fake_resp = MagicMock()
|
||||
fake_resp.status = 200
|
||||
@@ -524,7 +754,7 @@ class TestS3UrllibDownload:
|
||||
"""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
|
||||
from backend.app.services.model_providers.makerworld.http import _download_s3_urllib
|
||||
|
||||
fake_opener = MagicMock()
|
||||
fake_opener.open = MagicMock(
|
||||
@@ -548,7 +778,7 @@ class TestS3UrllibDownload:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_200_raises_unavailable(self):
|
||||
from backend.app.services.makerworld import _download_s3_urllib
|
||||
from backend.app.services.model_providers.makerworld.http import _download_s3_urllib
|
||||
|
||||
fake_resp = MagicMock()
|
||||
fake_resp.status = 403
|
||||
@@ -570,7 +800,7 @@ class TestS3UrllibDownload:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_size_cap_enforced(self):
|
||||
from backend.app.services.makerworld import _download_s3_urllib
|
||||
from backend.app.services.model_providers.makerworld.http import _download_s3_urllib
|
||||
|
||||
fake_resp = MagicMock()
|
||||
fake_resp.status = 200
|
||||
@@ -593,7 +823,7 @@ class TestS3UrllibDownload:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_network_error_mapped_to_unavailable(self):
|
||||
from backend.app.services.makerworld import _download_s3_urllib
|
||||
from backend.app.services.model_providers.makerworld.http import _download_s3_urllib
|
||||
|
||||
fake_opener = MagicMock()
|
||||
fake_opener.open = MagicMock(side_effect=URLError("dns fail"))
|
||||
@@ -699,3 +929,33 @@ class TestFetchThumbnail:
|
||||
|
||||
with pytest.raises(MakerWorldUnavailableError):
|
||||
await service.fetch_thumbnail("https://makerworld.bblmw.com/makerworld/model/X/blob")
|
||||
|
||||
|
||||
class TestSharedHttpClient:
|
||||
"""The app-scoped httpx client registered via ``set_shared_http_client``
|
||||
must be reused by per-request services (one shared connection pool, same
|
||||
pattern as ``bambu_cloud``). The setter has to live in the same module as
|
||||
the service class, or the import-time snapshot never sees the lifespan's
|
||||
late registration and every request spins up its own client."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reuses_registered_client(self):
|
||||
client = MagicMock(spec=httpx.AsyncClient)
|
||||
set_shared_http_client(client)
|
||||
try:
|
||||
svc = MakerWorldService()
|
||||
assert svc._client is client
|
||||
assert svc._owns_client is False
|
||||
# close() must NOT close a client it doesn't own
|
||||
await svc.close()
|
||||
client.aclose.assert_not_called()
|
||||
finally:
|
||||
set_shared_http_client(None)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_creates_and_owns_own_client_when_none_registered(self):
|
||||
set_shared_http_client(None)
|
||||
svc = MakerWorldService()
|
||||
assert svc._owns_client is True
|
||||
await svc.close()
|
||||
assert svc._client.is_closed
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
"""Tests for the MakerWorld provider descriptor (``provider.py``).
|
||||
|
||||
The descriptor is the interface between the route layer and the per-request
|
||||
service: identity, URL routing, auth requirements, and the factory that seeds
|
||||
a service with the caller's stored Bambu Cloud bearer token.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.app.core.permissions import Permission
|
||||
from backend.app.services.model_providers import makerworld_provider
|
||||
from backend.app.services.model_providers.base import ModelProvider
|
||||
from backend.app.services.model_providers.makerworld.service import MakerWorldService
|
||||
|
||||
|
||||
class TestMakerWorldProviderDescriptor:
|
||||
def test_identity_fields(self):
|
||||
assert makerworld_provider.source_type == "makerworld"
|
||||
assert makerworld_provider.display_name == "MakerWorld"
|
||||
assert makerworld_provider.host_patterns == ("makerworld.com",)
|
||||
assert makerworld_provider.default_folder_name == "MakerWorld"
|
||||
|
||||
def test_permissions(self):
|
||||
assert makerworld_provider.view_permission == Permission.MAKERWORLD_VIEW
|
||||
assert makerworld_provider.import_permission == Permission.MAKERWORLD_IMPORT
|
||||
|
||||
def test_auth_descriptor(self):
|
||||
assert makerworld_provider.auth is not None
|
||||
assert makerworld_provider.auth.auth_type == "bambu_cloud_bearer"
|
||||
assert makerworld_provider.auth.credential_fields == ()
|
||||
|
||||
def test_supports_url_host_suffix_match(self):
|
||||
assert makerworld_provider.supports_url("https://makerworld.com/en/models/1400373")
|
||||
assert makerworld_provider.supports_url("https://www.makerworld.com/models/1400373")
|
||||
assert makerworld_provider.supports_url("makerworld.com/models/1")
|
||||
|
||||
def test_rejects_foreign_hosts_and_garbage(self):
|
||||
assert not makerworld_provider.supports_url("https://thingiverse.com/thing/123")
|
||||
assert not makerworld_provider.supports_url("https://makerworld.com.evil.example/x")
|
||||
assert not makerworld_provider.supports_url("")
|
||||
assert not makerworld_provider.supports_url(None) # type: ignore[arg-type]
|
||||
assert not makerworld_provider.supports_url(123) # type: ignore[arg-type]
|
||||
|
||||
def test_thumbnail_hosts_is_the_cdn_allowlist(self):
|
||||
assert "makerworld.bblmw.com" in makerworld_provider.thumbnail_hosts()
|
||||
assert "public-cdn.bblmw.com" in makerworld_provider.thumbnail_hosts()
|
||||
|
||||
def test_download_hosts_is_the_cdn_allowlist(self):
|
||||
"""The download-guard SSRF seam mirrors the thumbnail one — a provider
|
||||
that fetches files server-side declares the hosts its service may
|
||||
fetch from (review round 3, note 2)."""
|
||||
assert "makerworld.bblmw.com" in makerworld_provider.download_hosts()
|
||||
assert "public-cdn.bblmw.com" in makerworld_provider.download_hosts()
|
||||
|
||||
def test_parse_and_canonical_roundtrip(self):
|
||||
ref = makerworld_provider.parse_url("https://makerworld.com/en/models/1400373-slug#profileId-1452154")
|
||||
assert ref.external_id == "1400373"
|
||||
assert ref.sub_id == "1452154"
|
||||
assert ref.source_type == "makerworld"
|
||||
assert makerworld_provider.canonical_url(ref) == "https://makerworld.com/models/1400373#profileId-1452154"
|
||||
|
||||
def test_canonical_plate_less_shape(self):
|
||||
ref = makerworld_provider.parse_url("https://makerworld.com/models/999")
|
||||
assert makerworld_provider.canonical_url(ref) == "https://makerworld.com/models/999"
|
||||
|
||||
def test_source_url_filter_matches_model_and_any_plate(self):
|
||||
"""The already-imported predicate must cover the whole-model key and
|
||||
every per-plate key — MakerWorld's canonical shape appends
|
||||
``#profileId-{n}`` (review round 3: shape knowledge belongs to the
|
||||
provider, not the route)."""
|
||||
from sqlalchemy import String, column as sa_column
|
||||
|
||||
expr = makerworld_provider.source_url_filter(sa_column("source_url", String), "1400373")
|
||||
sql = str(expr.compile(compile_kwargs={"literal_binds": True}))
|
||||
assert "source_url = 'https://makerworld.com/models/1400373'" in sql
|
||||
assert "LIKE 'https://makerworld.com/models/1400373#profileId-%'" in sql
|
||||
|
||||
def test_default_source_url_filter_is_exact_match_only(self):
|
||||
"""A provider without plate-shaped keys inherits the default: exact
|
||||
match on its whole-model canonical URL, nothing else."""
|
||||
from sqlalchemy import String, column as sa_column
|
||||
|
||||
expr = _WholeModelProvider().source_url_filter(sa_column("source_url", String), "42")
|
||||
sql = str(expr.compile(compile_kwargs={"literal_binds": True}))
|
||||
assert sql == "source_url = 'https://example.com/models/42'"
|
||||
assert "LIKE" not in sql
|
||||
|
||||
|
||||
class _WholeModelProvider(ModelProvider):
|
||||
"""Minimal concrete provider that keys dedupe at whole-model granularity
|
||||
only — exercises the inherited default ``source_url_filter``."""
|
||||
|
||||
source_type = "wholemodel"
|
||||
display_name = "WholeModel"
|
||||
|
||||
async def build_service(self, *, db, user, api_key_owner=None, client=None):
|
||||
raise NotImplementedError
|
||||
|
||||
def parse_url(self, url):
|
||||
raise NotImplementedError
|
||||
|
||||
def canonical_url(self, ref):
|
||||
return f"https://example.com/models/{ref.external_id}"
|
||||
|
||||
|
||||
class TestBuildService:
|
||||
"""build_service must reproduce exactly what the old route helper did:
|
||||
read the caller's stored Bambu Cloud token and wire the rejected-token
|
||||
callback so a 401 invalidates the shared credential app-wide."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_seeds_token_and_auth_failure_callback(self):
|
||||
db = AsyncMock()
|
||||
user = AsyncMock()
|
||||
user.id = 7
|
||||
|
||||
with (
|
||||
patch(
|
||||
"backend.app.services.model_providers.makerworld.provider.get_stored_token",
|
||||
AsyncMock(return_value=("tok-abc", "e@x.com", "global")),
|
||||
),
|
||||
patch(
|
||||
"backend.app.services.model_providers.makerworld.provider.mark_cloud_token_invalid",
|
||||
AsyncMock(),
|
||||
) as mark_invalid,
|
||||
):
|
||||
svc = await makerworld_provider.build_service(db=db, user=user)
|
||||
assert isinstance(svc, MakerWorldService)
|
||||
assert svc._auth_token == "tok-abc"
|
||||
assert svc._user is user
|
||||
await svc._on_auth_failure()
|
||||
mark_invalid.assert_awaited_once_with(7)
|
||||
await svc.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_anonymous_user_still_gets_auth_callback(self):
|
||||
"""No user ≠ nothing to invalidate. Auth-disabled single-user installs
|
||||
hold their token in global Settings (``get_stored_token(db, None)``
|
||||
reads it), so a rejection must still be recorded — ``user_id=None``
|
||||
writes the global flag (review blocker 5). The callback stays wired;
|
||||
only a *stray* non-expiry 401 keeps it a no-op."""
|
||||
db = AsyncMock()
|
||||
with (
|
||||
patch(
|
||||
"backend.app.services.model_providers.makerworld.provider.get_stored_token",
|
||||
AsyncMock(return_value=(None, None, "global")),
|
||||
),
|
||||
patch(
|
||||
"backend.app.services.model_providers.makerworld.provider.mark_cloud_token_invalid",
|
||||
AsyncMock(),
|
||||
) as mark_invalid,
|
||||
):
|
||||
svc = await makerworld_provider.build_service(db=db, user=None)
|
||||
|
||||
assert svc._auth_token is None
|
||||
assert svc._on_auth_failure is not None
|
||||
# Firing it records the *global* flag (user_id=None), not a per-user row.
|
||||
await svc._on_auth_failure()
|
||||
mark_invalid.assert_awaited_once_with(None)
|
||||
await svc.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_service_passes_declared_thumbnail_hosts(self):
|
||||
"""The SSRF seam contract: ``fetch_thumbnail``'s allowlist must come
|
||||
from ``ModelProvider.thumbnail_hosts()`` via build_service — not from
|
||||
a hardcoded copy inside the service (review round 2, item 1)."""
|
||||
db = AsyncMock()
|
||||
with patch(
|
||||
"backend.app.services.model_providers.makerworld.provider.get_stored_token",
|
||||
AsyncMock(return_value=(None, None, "global")),
|
||||
):
|
||||
svc = await makerworld_provider.build_service(db=db, user=None)
|
||||
|
||||
assert svc._thumbnail_hosts == makerworld_provider.thumbnail_hosts()
|
||||
assert len(svc._thumbnail_hosts) > 0
|
||||
await svc.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_service_passes_declared_download_hosts(self):
|
||||
"""Symmetric SSRF seam contract: ``download``'s allowlist must come
|
||||
from ``ModelProvider.download_hosts()`` via build_service — not from
|
||||
a hardcoded copy inside the service (review round 3, note 2)."""
|
||||
db = AsyncMock()
|
||||
with patch(
|
||||
"backend.app.services.model_providers.makerworld.provider.get_stored_token",
|
||||
AsyncMock(return_value=(None, None, "global")),
|
||||
):
|
||||
svc = await makerworld_provider.build_service(db=db, user=None)
|
||||
|
||||
assert svc._download_hosts == makerworld_provider.download_hosts()
|
||||
assert len(svc._download_hosts) > 0
|
||||
await svc.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_key_owner_is_the_fallback_identity(self):
|
||||
"""API-keyed callers carry identity on the key (#1777) — build_service
|
||||
must use the key's owner when ``user`` is None."""
|
||||
db = AsyncMock()
|
||||
owner = AsyncMock()
|
||||
owner.id = 11
|
||||
|
||||
with (
|
||||
patch(
|
||||
"backend.app.services.model_providers.makerworld.provider.get_stored_token",
|
||||
AsyncMock(return_value=("owner-tok", "owner@x.com", "global")),
|
||||
),
|
||||
patch(
|
||||
"backend.app.services.model_providers.makerworld.provider.mark_cloud_token_invalid",
|
||||
AsyncMock(),
|
||||
) as mark_invalid,
|
||||
):
|
||||
svc = await makerworld_provider.build_service(db=db, user=None, api_key_owner=owner)
|
||||
assert svc._auth_token == "owner-tok"
|
||||
assert svc._user is owner
|
||||
await svc._on_auth_failure()
|
||||
mark_invalid.assert_awaited_once_with(11)
|
||||
await svc.close()
|
||||
@@ -205,7 +205,8 @@ class TestMakerWorldSharesTheDetector:
|
||||
async def test_a_challenge_worded_differently_is_still_named(self):
|
||||
"""MakerWorld used to require the literal word "robot" in the error text
|
||||
and reported anything else as an unexplained block."""
|
||||
from backend.app.services.makerworld import MakerWorldService, MakerWorldUnavailableError
|
||||
from backend.app.services.model_providers.makerworld.errors import MakerWorldUnavailableError
|
||||
from backend.app.services.model_providers.makerworld.service import MakerWorldService
|
||||
|
||||
svc = MakerWorldService(client=MagicMock(spec=httpx.AsyncClient), auth_token="tok")
|
||||
svc._client.get = AsyncMock(return_value=_response(418, {"captchaId": "abc", "error": "verification required"}))
|
||||
|
||||
@@ -23,18 +23,17 @@ from unittest.mock import AsyncMock, MagicMock
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from backend.app.api.routes.cloud import (
|
||||
from backend.app.api.routes.cloud import clear_token, store_token
|
||||
from backend.app.models.settings import Settings
|
||||
from backend.app.services import bambu_cloud as bc
|
||||
from backend.app.services.bambu_cloud import BambuCloudService
|
||||
from backend.app.services.bambu_cloud_credentials import (
|
||||
CLOUD_EMAIL_KEY,
|
||||
CLOUD_REGION_KEY,
|
||||
CLOUD_TOKEN_INVALID_KEY,
|
||||
CLOUD_TOKEN_KEY,
|
||||
clear_token,
|
||||
is_cloud_token_invalid,
|
||||
store_token,
|
||||
)
|
||||
from backend.app.models.settings import Settings
|
||||
from backend.app.services import bambu_cloud as bc
|
||||
from backend.app.services.bambu_cloud import BambuCloudService
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
|
||||
@@ -83,7 +83,7 @@ class TestCloudAccountEnumeration:
|
||||
Settings table and the account is keyed ``global``."""
|
||||
with (
|
||||
patch(
|
||||
"backend.app.api.routes.cloud.get_stored_token",
|
||||
"backend.app.services.bambu_cloud_credentials.get_stored_token",
|
||||
new_callable=AsyncMock,
|
||||
return_value=("bambu-token", "a@b.c", "global"),
|
||||
),
|
||||
@@ -113,7 +113,7 @@ class TestCloudAccountEnumeration:
|
||||
|
||||
with (
|
||||
patch(
|
||||
"backend.app.api.routes.cloud.get_stored_token",
|
||||
"backend.app.services.bambu_cloud_credentials.get_stored_token",
|
||||
new_callable=AsyncMock,
|
||||
return_value=(None, None, "global"),
|
||||
),
|
||||
@@ -137,7 +137,7 @@ class TestCloudAccountEnumeration:
|
||||
|
||||
with (
|
||||
patch(
|
||||
"backend.app.api.routes.cloud.get_stored_token",
|
||||
"backend.app.services.bambu_cloud_credentials.get_stored_token",
|
||||
new_callable=AsyncMock,
|
||||
return_value=("legacy-global", None, "global"),
|
||||
),
|
||||
@@ -405,7 +405,7 @@ class TestCollectorAndMetadata:
|
||||
files: dict = {}
|
||||
with (
|
||||
patch(
|
||||
"backend.app.api.routes.cloud.get_stored_token",
|
||||
"backend.app.services.bambu_cloud_credentials.get_stored_token",
|
||||
new_callable=AsyncMock,
|
||||
return_value=(None, None, "global"),
|
||||
),
|
||||
@@ -435,7 +435,7 @@ class TestCollectorAndMetadata:
|
||||
files: dict = {}
|
||||
with (
|
||||
patch(
|
||||
"backend.app.api.routes.cloud.get_stored_token",
|
||||
"backend.app.services.bambu_cloud_credentials.get_stored_token",
|
||||
new_callable=AsyncMock,
|
||||
return_value=(None, None, "global"),
|
||||
),
|
||||
@@ -500,7 +500,7 @@ class TestSettingsFallbackIsStillHonoured:
|
||||
await db_session.commit()
|
||||
|
||||
with patch(
|
||||
"backend.app.api.routes.cloud.get_stored_token",
|
||||
"backend.app.services.bambu_cloud_credentials.get_stored_token",
|
||||
new_callable=AsyncMock,
|
||||
return_value=(None, None, "global"),
|
||||
):
|
||||
|
||||
@@ -14,8 +14,33 @@ from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.app.api.routes.makerworld import _canonical_url
|
||||
from backend.app.api.routes import makerworld as makerworld_routes
|
||||
from backend.app.core.permissions import Permission
|
||||
from backend.app.models.library import LibraryFile, LibraryFolder
|
||||
from backend.app.services.model_providers.base import (
|
||||
ModelProvider,
|
||||
ProviderDownload,
|
||||
ProviderDownloadInfo,
|
||||
ProviderResolvedModel,
|
||||
ProviderResourceRef,
|
||||
ProviderStatus,
|
||||
)
|
||||
from backend.app.services.model_providers.makerworld import makerworld_provider
|
||||
|
||||
|
||||
def _download_info(
|
||||
model_id: int = 1400373,
|
||||
profile_id: int = 298919107,
|
||||
name: str = "benchy.3mf",
|
||||
url: str = "https://makerworld.bblmw.com/makerworld/model/X/Y/f.3mf?exp=1&key=k",
|
||||
) -> ProviderDownloadInfo:
|
||||
"""What ``service.get_download`` hands the route: signed URL + raw upstream
|
||||
name + the enriched resource ref (``sub_id`` carries the resolved profile)."""
|
||||
return ProviderDownloadInfo(
|
||||
ref=ProviderResourceRef(source_type="makerworld", external_id=str(model_id), sub_id=str(profile_id)),
|
||||
url=url,
|
||||
suggested_filename=name,
|
||||
)
|
||||
|
||||
|
||||
def _fake_service(**stubs):
|
||||
@@ -30,37 +55,117 @@ def _fake_service(**stubs):
|
||||
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"}],
|
||||
}
|
||||
class _DummyProvider(ModelProvider):
|
||||
"""Stand-in for a second registered model provider.
|
||||
|
||||
Lets the route tests exercise behaviour that differs from the MakerWorld
|
||||
singleton — a provider-specific default folder name (or none at all), and
|
||||
its own permissions — without registering anything in the app-wide
|
||||
registry. The permissions deliberately are *not* the MakerWorld ones: the
|
||||
routes must gate on whichever provider the request resolved to.
|
||||
"""
|
||||
|
||||
source_type = "dummy"
|
||||
display_name = "Dummy"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
default_folder_name: str | None = "Dummy Imports",
|
||||
view_permission: Permission | None = Permission.LIBRARY_READ,
|
||||
import_permission: Permission | None = Permission.LIBRARY_UPLOAD,
|
||||
):
|
||||
self.default_folder_name = default_folder_name
|
||||
self.view_permission = view_permission
|
||||
self.import_permission = import_permission
|
||||
|
||||
async def build_service(self, *, db, user, api_key_owner=None, client=None):
|
||||
raise NotImplementedError
|
||||
|
||||
def parse_url(self, url):
|
||||
return ProviderResourceRef(source_type=self.source_type, external_id="1400373", original_url=url)
|
||||
|
||||
def canonical_url(self, ref):
|
||||
return f"https://dummy.example.com/models/{ref.external_id}"
|
||||
|
||||
|
||||
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",
|
||||
}
|
||||
def _permission_spy():
|
||||
"""Record which permission the route hands the shared gate.
|
||||
|
||||
The gate itself still runs — the spy delegates to the real factory — so a
|
||||
test using it proves the wiring without loosening the check.
|
||||
"""
|
||||
seen: list = []
|
||||
real = makerworld_routes.require_permission_if_auth_enabled
|
||||
|
||||
def factory(*permissions):
|
||||
seen.extend(permissions)
|
||||
return real(*permissions)
|
||||
|
||||
return seen, factory
|
||||
|
||||
|
||||
class TestCanonicalUrl:
|
||||
"""Unit test the dedupe-key builder directly — regressions break dedupe
|
||||
silently so it's worth pinning the exact shape."""
|
||||
class TestThumbnail:
|
||||
"""GET /makerworld/thumbnail — the anonymous CDN image proxy."""
|
||||
|
||||
def test_without_profile_id(self):
|
||||
assert _canonical_url(1400373) == "https://makerworld.com/models/1400373"
|
||||
def _patch_service(self, svc):
|
||||
return patch("backend.app.api.routes.makerworld.MakerWorldService", return_value=svc)
|
||||
|
||||
def test_without_profile_id_when_none(self):
|
||||
assert _canonical_url(1400373, None) == "https://makerworld.com/models/1400373"
|
||||
@pytest.mark.asyncio
|
||||
async def test_proxies_image_with_immutable_cache(self, async_client):
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
def test_with_profile_id(self):
|
||||
assert _canonical_url(1400373, 298919107) == ("https://makerworld.com/models/1400373#profileId-298919107")
|
||||
svc = MagicMock()
|
||||
svc.fetch_thumbnail = AsyncMock(return_value=(b"png-bytes", "image/png"))
|
||||
svc.close = AsyncMock()
|
||||
|
||||
with self._patch_service(svc):
|
||||
resp = await async_client.get(
|
||||
"/api/v1/makerworld/thumbnail",
|
||||
params={"url": "https://makerworld.bblmw.com/img/x.png"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.content == b"png-bytes"
|
||||
assert resp.headers["content-type"] == "image/png"
|
||||
assert "immutable" in resp.headers["cache-control"]
|
||||
# The SSRF allowlist is the provider's declared seam, not a local
|
||||
# copy inside the route (review round 2).
|
||||
assert svc.fetch_thumbnail.await_args.args[0] == "https://makerworld.bblmw.com/img/x.png"
|
||||
svc.close.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_allowlist_comes_from_the_provider_descriptor(self, async_client):
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
svc = MagicMock()
|
||||
svc.fetch_thumbnail = AsyncMock(return_value=(b"x", "image/png"))
|
||||
svc.close = AsyncMock()
|
||||
|
||||
with self._patch_service(svc) as cls:
|
||||
await async_client.get(
|
||||
"/api/v1/makerworld/thumbnail",
|
||||
params={"url": "https://makerworld.bblmw.com/img/x.png"},
|
||||
)
|
||||
assert cls.call_args.kwargs["thumbnail_hosts"] == makerworld_provider.thumbnail_hosts()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_cdn_host_is_a_clean_400(self, async_client):
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from backend.app.services.model_providers.makerworld.errors import MakerWorldUrlError
|
||||
|
||||
svc = MagicMock()
|
||||
svc.fetch_thumbnail = AsyncMock(
|
||||
side_effect=MakerWorldUrlError("Refusing to fetch thumbnail from non-MakerWorld host: 'evil.example'")
|
||||
)
|
||||
svc.close = AsyncMock()
|
||||
|
||||
with self._patch_service(svc):
|
||||
resp = await async_client.get(
|
||||
"/api/v1/makerworld/thumbnail",
|
||||
params={"url": "https://evil.example/x.png"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
svc.close.assert_awaited_once()
|
||||
|
||||
|
||||
class TestStatus:
|
||||
@@ -79,8 +184,8 @@ class TestStatus:
|
||||
used to be a bare alias for ``has_cloud_token``, so the import button
|
||||
stayed live against a dead credential and the user only found out via a
|
||||
401 toast."""
|
||||
from backend.app.api.routes.cloud import CLOUD_TOKEN_INVALID_KEY, CLOUD_TOKEN_KEY
|
||||
from backend.app.models.settings import Settings
|
||||
from backend.app.services.bambu_cloud_credentials import CLOUD_TOKEN_INVALID_KEY, CLOUD_TOKEN_KEY
|
||||
|
||||
db_session.add(Settings(key=CLOUD_TOKEN_KEY, value="dead-token"))
|
||||
db_session.add(Settings(key=CLOUD_TOKEN_INVALID_KEY, value="2026-07-14T07:00:00+00:00"))
|
||||
@@ -94,6 +199,33 @@ class TestStatus:
|
||||
"sign_in_expired": True,
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sign_in_expired_reads_credential_rejected_not_auth_error(self, async_client):
|
||||
"""The route keys ``sign_in_expired`` off the machine-readable
|
||||
``credential_rejected`` flag, not ``auth_error`` (review round 3 note 1):
|
||||
``auth_error`` is the human-readable reason and may be set for non-
|
||||
credential failures too. A service reporting an expired credential
|
||||
*without* a message must still surface ``sign_in_expired=True``."""
|
||||
svc = AsyncMock()
|
||||
svc.close = AsyncMock()
|
||||
svc.get_status = AsyncMock(
|
||||
return_value=ProviderStatus(
|
||||
authenticated=True,
|
||||
can_download=False,
|
||||
auth_error=None,
|
||||
credential_rejected=True,
|
||||
)
|
||||
)
|
||||
|
||||
with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
|
||||
resp = await async_client.get("/api/v1/makerworld/status")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {
|
||||
"has_cloud_token": True,
|
||||
"can_download": False,
|
||||
"sign_in_expired": True,
|
||||
}
|
||||
|
||||
|
||||
class TestResolve:
|
||||
@pytest.mark.asyncio
|
||||
@@ -102,20 +234,25 @@ class TestResolve:
|
||||
"/api/v1/makerworld/resolve",
|
||||
json={"url": "https://thingiverse.com/thing/1"},
|
||||
)
|
||||
# A pasted link for an unsupported host is a clean client-input 400,
|
||||
# never a 500 — the registry guard runs before any provider call.
|
||||
assert resp.status_code == 400
|
||||
assert "makerworld" in resp.json()["detail"].lower()
|
||||
assert "provider" 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)
|
||||
instances_payload = [
|
||||
{"id": 1452154, "profileId": 298919107, "title": "9 cells"},
|
||||
{"id": 1452158, "profileId": 298919564, "title": "12 cells"},
|
||||
]
|
||||
svc = _fake_service(
|
||||
resolve=ProviderResolvedModel(
|
||||
ref=ProviderResourceRef(source_type="makerworld", external_id="1400373", sub_id="1452154"),
|
||||
design=design_payload,
|
||||
instances=instances_payload,
|
||||
)
|
||||
)
|
||||
|
||||
with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
|
||||
resp = await async_client.post(
|
||||
@@ -132,8 +269,10 @@ class TestResolve:
|
||||
|
||||
@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(
|
||||
"""Both dedupe shapes must be found through the provider's
|
||||
``source_url_filter``: the whole-model canonical URL *and* any
|
||||
plate-level ``#profileId-`` row."""
|
||||
model_row = LibraryFile(
|
||||
filename="prev.3mf",
|
||||
file_path="library/files/prev.3mf",
|
||||
file_type="3mf",
|
||||
@@ -141,13 +280,25 @@ class TestResolve:
|
||||
source_type="makerworld",
|
||||
source_url="https://makerworld.com/models/1400373",
|
||||
)
|
||||
db_session.add(existing)
|
||||
plate_row = LibraryFile(
|
||||
filename="prev-plate.3mf",
|
||||
file_path="library/files/prev-plate.3mf",
|
||||
file_type="3mf",
|
||||
file_size=100,
|
||||
source_type="makerworld",
|
||||
source_url="https://makerworld.com/models/1400373#profileId-298919107",
|
||||
)
|
||||
db_session.add_all([model_row, plate_row])
|
||||
await db_session.commit()
|
||||
await db_session.refresh(existing)
|
||||
await db_session.refresh(model_row)
|
||||
await db_session.refresh(plate_row)
|
||||
|
||||
svc = _fake_service(
|
||||
get_design={"id": 1400373},
|
||||
get_design_instances={"total": 0, "hits": []},
|
||||
resolve=ProviderResolvedModel(
|
||||
ref=ProviderResourceRef(source_type="makerworld", external_id="1400373"),
|
||||
design={"id": 1400373},
|
||||
instances=[],
|
||||
)
|
||||
)
|
||||
|
||||
with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
|
||||
@@ -156,93 +307,34 @@ class TestResolve:
|
||||
json={"url": "https://makerworld.com/en/models/1400373"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["already_imported_library_ids"] == [existing.id]
|
||||
assert sorted(resp.json()["already_imported_library_ids"]) == sorted([model_row.id, plate_row.id])
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_merges_compatibility_from_design_into_instances(self, async_client):
|
||||
"""Per-instance printer compatibility info lives on
|
||||
``design.instances[].extention.modelInfo`` but not on
|
||||
``/instances/hits``. Resolve enriches each hit with both
|
||||
``compatibility`` (primary printer the instance was sliced for) and
|
||||
``otherCompatibility`` (extra printers the uploader marked it
|
||||
compatible with) so the frontend can show "sliced for A1 / also
|
||||
marked compatible with: H2D, P1S".
|
||||
"""
|
||||
design_payload = {
|
||||
"id": 1400373,
|
||||
"title": "Seed Starter",
|
||||
"instances": [
|
||||
{
|
||||
"id": 1452154,
|
||||
"extention": {
|
||||
"modelInfo": {
|
||||
"compatibility": ["A1"],
|
||||
"otherCompatibility": ["H2D", "P1S"],
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": 1452158,
|
||||
"extention": {
|
||||
"modelInfo": {
|
||||
"compatibility": ["X1 Carbon"],
|
||||
"otherCompatibility": [],
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
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)
|
||||
async def test_gate_uses_the_permission_of_the_provider_the_url_routes_to(self, async_client):
|
||||
"""Same rule as import, keyed off the pasted URL instead of
|
||||
``source_type``: a link that routes to another provider is gated on
|
||||
that provider's view permission, not ``makerworld:view``."""
|
||||
seen, factory = _permission_spy()
|
||||
dummy = _DummyProvider()
|
||||
svc = _fake_service(
|
||||
resolve=ProviderResolvedModel(
|
||||
ref=ProviderResourceRef(source_type="dummy", external_id="1400373"),
|
||||
design={"id": 1400373},
|
||||
instances=[],
|
||||
)
|
||||
)
|
||||
|
||||
with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
|
||||
with (
|
||||
patch("backend.app.api.routes.makerworld.require_permission_if_auth_enabled", factory),
|
||||
patch("backend.app.api.routes.makerworld._provider_for_url", return_value=dummy),
|
||||
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"},
|
||||
json={"url": "https://dummy.example.com/models/1400373"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
instances = resp.json()["instances"]
|
||||
by_id = {i["id"]: i for i in instances}
|
||||
assert by_id[1452154]["compatibility"] == ["A1"]
|
||||
assert by_id[1452154]["otherCompatibility"] == ["H2D", "P1S"]
|
||||
assert by_id[1452158]["compatibility"] == ["X1 Carbon"]
|
||||
assert by_id[1452158]["otherCompatibility"] == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_handles_missing_compatibility_gracefully(self, async_client):
|
||||
"""Older designs (or hits without a matching design.instances entry)
|
||||
must not crash the resolve response — they just don't get the
|
||||
compat fields."""
|
||||
design_payload = {"id": 1400373, "instances": [{"id": 1452154}]} # no extention
|
||||
instances_payload = {
|
||||
"total": 2,
|
||||
"hits": [
|
||||
{"id": 1452154, "profileId": 298919107},
|
||||
{"id": 9999999, "profileId": 298919999}, # no design.instances match
|
||||
],
|
||||
}
|
||||
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"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
instances = resp.json()["instances"]
|
||||
# First instance: design entry exists but no extention → fields absent or None.
|
||||
first = next(i for i in instances if i["id"] == 1452154)
|
||||
assert first.get("compatibility") is None
|
||||
assert first.get("otherCompatibility") is None
|
||||
# Second instance: no design entry at all → no enrichment, no crash.
|
||||
second = next(i for i in instances if i["id"] == 9999999)
|
||||
assert "compatibility" not in second or second["compatibility"] is None
|
||||
assert seen == [Permission.LIBRARY_READ]
|
||||
|
||||
|
||||
class TestImport:
|
||||
@@ -270,11 +362,8 @@ class TestImport:
|
||||
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
|
||||
svc = _fake_service(get_download=_download_info())
|
||||
svc.download = AsyncMock() # must remain uncalled
|
||||
|
||||
with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
|
||||
resp = await async_client.post(
|
||||
@@ -287,16 +376,52 @@ class TestImport:
|
||||
assert body["library_file_id"] == existing.id
|
||||
assert body["was_existing"] is True
|
||||
assert body["profile_id"] == 298919107
|
||||
svc.download_3mf.assert_not_called()
|
||||
svc.download.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unknown_source_type_is_a_clean_400(self, async_client, db_session):
|
||||
"""``source_type`` names the provider (there is no URL to route on);
|
||||
an unregistered value is a client-input problem — 400 before any
|
||||
service is built or bytes downloaded."""
|
||||
svc = _fake_service(
|
||||
get_download=_download_info(),
|
||||
download=ProviderDownload(file_bytes=self._FAKE_3MF_BYTES, filename="benchy.3mf"),
|
||||
)
|
||||
svc.download = AsyncMock()
|
||||
|
||||
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, "source_type": "thingiverse"},
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "thingiverse" in resp.json()["detail"].lower()
|
||||
svc.download.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unknown_source_type_creates_no_folder_side_effect(self, async_client, db_session):
|
||||
"""Provider resolution must precede destination handling — a rejected
|
||||
request must not leave an auto-created default folder behind."""
|
||||
from sqlalchemy import select
|
||||
|
||||
svc = _fake_service(get_download=_download_info())
|
||||
|
||||
with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
|
||||
await async_client.post(
|
||||
"/api/v1/makerworld/import",
|
||||
json={"model_id": 1400373, "source_type": "bogus"},
|
||||
)
|
||||
|
||||
result = await db_session.execute(select(LibraryFolder))
|
||||
assert result.scalars().all() == []
|
||||
|
||||
@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"),
|
||||
get_download=_download_info(),
|
||||
download=ProviderDownload(file_bytes=self._FAKE_3MF_BYTES, filename="benchy.3mf"),
|
||||
)
|
||||
|
||||
with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
|
||||
@@ -315,6 +440,141 @@ class TestImport:
|
||||
folder = result.scalar_one()
|
||||
assert resp.json()["folder_id"] == folder.id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_folder_comes_from_resolved_provider(self, async_client, db_session):
|
||||
"""``import_instance`` must read ``default_folder_name`` off the provider
|
||||
it resolved — not the MakerWorld singleton (review round 3 fix). Latent
|
||||
with one provider, but the difference is visible behind a stand-in: a
|
||||
second provider's import lands in *its* folder, not "MakerWorld"."""
|
||||
dummy = _DummyProvider(default_folder_name="Dummy Imports")
|
||||
svc = _fake_service(
|
||||
get_download=_download_info(),
|
||||
download=ProviderDownload(file_bytes=self._FAKE_3MF_BYTES, filename="benchy.3mf"),
|
||||
)
|
||||
|
||||
with (
|
||||
patch("backend.app.api.routes.makerworld._provider_for_source", return_value=dummy),
|
||||
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, "source_type": "dummy"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
result = await db_session.execute(
|
||||
select(LibraryFolder).where(LibraryFolder.name == "Dummy Imports", LibraryFolder.parent_id.is_(None))
|
||||
)
|
||||
assert result.scalar_one_or_none() is not None
|
||||
# The MakerWorld singleton's folder must NOT be auto-created instead.
|
||||
assert (
|
||||
await db_session.execute(
|
||||
select(LibraryFolder).where(LibraryFolder.name == "MakerWorld", LibraryFolder.parent_id.is_(None))
|
||||
)
|
||||
).scalar_one_or_none() is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_none_default_folder_name_imports_to_library_root(self, async_client, db_session):
|
||||
"""A provider that leaves ``default_folder_name`` unset imports into the
|
||||
library root rather than minting a NULL-named folder (review round 3,
|
||||
note 3)."""
|
||||
dummy = _DummyProvider(default_folder_name=None)
|
||||
svc = _fake_service(
|
||||
get_download=_download_info(),
|
||||
download=ProviderDownload(file_bytes=self._FAKE_3MF_BYTES, filename="benchy.3mf"),
|
||||
)
|
||||
|
||||
with (
|
||||
patch("backend.app.api.routes.makerworld._provider_for_source", return_value=dummy),
|
||||
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, "source_type": "dummy", "folder_id": None},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["folder_id"] is None
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
result = await db_session.execute(select(LibraryFolder))
|
||||
assert result.scalars().all() == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gate_uses_the_makerworld_permission_for_makerworld(self, async_client):
|
||||
"""Control for the test below: the default ``source_type`` still gates
|
||||
on ``makerworld:import``, exactly as the route decorator used to."""
|
||||
seen, factory = _permission_spy()
|
||||
svc = _fake_service(
|
||||
get_download=_download_info(),
|
||||
download=ProviderDownload(file_bytes=self._FAKE_3MF_BYTES, filename="benchy.3mf"),
|
||||
)
|
||||
|
||||
with (
|
||||
patch("backend.app.api.routes.makerworld.require_permission_if_auth_enabled", factory),
|
||||
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 seen == [Permission.MAKERWORLD_IMPORT]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gate_uses_the_resolved_providers_permission(self, async_client):
|
||||
"""The permission is the resolved provider's, not the MakerWorld
|
||||
singleton's. It cannot be a route dependency — FastAPI resolves those
|
||||
before the body exists, so the decorator could only ever name one
|
||||
provider, and importing from a second one would be gated on
|
||||
``makerworld:import``."""
|
||||
seen, factory = _permission_spy()
|
||||
dummy = _DummyProvider()
|
||||
svc = _fake_service(
|
||||
get_download=_download_info(),
|
||||
download=ProviderDownload(file_bytes=self._FAKE_3MF_BYTES, filename="benchy.3mf"),
|
||||
)
|
||||
|
||||
with (
|
||||
patch("backend.app.api.routes.makerworld.require_permission_if_auth_enabled", factory),
|
||||
patch("backend.app.api.routes.makerworld._provider_for_source", return_value=dummy),
|
||||
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, "source_type": "dummy"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert seen == [Permission.LIBRARY_UPLOAD]
|
||||
assert Permission.MAKERWORLD_IMPORT not in seen
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_provider_without_a_permission_is_refused_not_waved_through(self, async_client, db_session):
|
||||
"""``import_permission`` is optional on the descriptor, so "unset" must
|
||||
fail closed rather than read as "unrestricted"."""
|
||||
dummy = _DummyProvider(import_permission=None)
|
||||
svc = _fake_service(
|
||||
get_download=_download_info(),
|
||||
download=ProviderDownload(file_bytes=self._FAKE_3MF_BYTES, filename="benchy.3mf"),
|
||||
)
|
||||
|
||||
with (
|
||||
patch("backend.app.api.routes.makerworld._provider_for_source", return_value=dummy),
|
||||
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, "source_type": "dummy"},
|
||||
)
|
||||
assert resp.status_code == 500
|
||||
assert "declares no permission" in resp.json()["detail"]
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
assert (await db_session.execute(select(LibraryFile))).scalars().all() == []
|
||||
|
||||
@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
|
||||
@@ -325,9 +585,8 @@ class TestImport:
|
||||
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"),
|
||||
get_download=_download_info(),
|
||||
download=ProviderDownload(file_bytes=self._FAKE_3MF_BYTES, filename="benchy.3mf"),
|
||||
)
|
||||
|
||||
with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
|
||||
@@ -343,9 +602,8 @@ class TestImport:
|
||||
"""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"),
|
||||
get_download=_download_info(),
|
||||
download=ProviderDownload(file_bytes=self._FAKE_3MF_BYTES, filename="benchy.3mf"),
|
||||
)
|
||||
|
||||
with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
|
||||
@@ -369,12 +627,8 @@ class TestImport:
|
||||
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"),
|
||||
get_download=_download_info(name="../../evil.3mf"),
|
||||
download=ProviderDownload(file_bytes=self._FAKE_3MF_BYTES, filename="fallback.3mf"),
|
||||
)
|
||||
|
||||
with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
|
||||
@@ -391,9 +645,8 @@ class TestImport:
|
||||
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"),
|
||||
get_download=_download_info(),
|
||||
download=ProviderDownload(file_bytes=self._FAKE_3MF_BYTES, filename="benchy.3mf"),
|
||||
)
|
||||
|
||||
with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
|
||||
@@ -426,9 +679,8 @@ class TestImport:
|
||||
await db_session.refresh(folder)
|
||||
|
||||
svc = _fake_service(
|
||||
get_design=_default_design(),
|
||||
get_profile_download=_default_manifest("seed-starter.3mf"),
|
||||
download_3mf=(self._FAKE_3MF_BYTES, "seed-starter.3mf"),
|
||||
get_download=_download_info(name="seed-starter.3mf"),
|
||||
download=ProviderDownload(file_bytes=self._FAKE_3MF_BYTES, filename="seed-starter.3mf"),
|
||||
)
|
||||
|
||||
with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
|
||||
@@ -453,8 +705,8 @@ class TestImport:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_to_readonly_external_rejected_at_route(self, async_client, db_session, tmp_path):
|
||||
"""The route-layer gate at ``makerworld.py:256-260`` rejects read-only
|
||||
externals with 403 before any download happens — so MakerWorld
|
||||
"""The route-layer gate in ``import_instance`` rejects read-only
|
||||
external folders with 403 before any download happens — so MakerWorld
|
||||
credentials and the upstream download bandwidth aren't wasted."""
|
||||
ext_dir = tmp_path / "nas-readonly"
|
||||
ext_dir.mkdir()
|
||||
@@ -469,11 +721,8 @@ class TestImport:
|
||||
await db_session.commit()
|
||||
await db_session.refresh(folder)
|
||||
|
||||
svc = _fake_service(
|
||||
get_design=_default_design(),
|
||||
get_profile_download=_default_manifest(),
|
||||
)
|
||||
svc.download_3mf = AsyncMock()
|
||||
svc = _fake_service(get_download=_download_info())
|
||||
svc.download = AsyncMock()
|
||||
|
||||
with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
|
||||
resp = await async_client.post(
|
||||
@@ -481,7 +730,7 @@ class TestImport:
|
||||
json={"model_id": 1400373, "profile_id": 298919107, "folder_id": folder.id},
|
||||
)
|
||||
assert resp.status_code == 403, resp.text
|
||||
svc.download_3mf.assert_not_called()
|
||||
svc.download.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_to_external_with_missing_path_returns_400(self, async_client, db_session, tmp_path):
|
||||
@@ -501,9 +750,8 @@ class TestImport:
|
||||
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"),
|
||||
get_download=_download_info(),
|
||||
download=ProviderDownload(file_bytes=self._FAKE_3MF_BYTES, filename="benchy.3mf"),
|
||||
)
|
||||
|
||||
with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
|
||||
@@ -535,9 +783,8 @@ class TestImport:
|
||||
await db_session.refresh(folder)
|
||||
|
||||
svc = _fake_service(
|
||||
get_design=_default_design(),
|
||||
get_profile_download=_default_manifest("benchy.3mf"),
|
||||
download_3mf=(self._FAKE_3MF_BYTES, "benchy.3mf"),
|
||||
get_download=_download_info(name="benchy.3mf"),
|
||||
download=ProviderDownload(file_bytes=self._FAKE_3MF_BYTES, filename="benchy.3mf"),
|
||||
)
|
||||
|
||||
with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Tests for the S3 presigned-download path in ``services/makerworld.py``.
|
||||
"""Tests for the S3 presigned-download path in ``model_providers/makerworld/http.py``.
|
||||
|
||||
MakerWorld hands back an AWS presigned URL for the 3MF, and we fetch that one
|
||||
with ``urllib.request`` rather than httpx — httpx re-encodes the query string
|
||||
@@ -25,7 +25,7 @@ from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
from cryptography.x509.oid import NameOID
|
||||
|
||||
from backend.app.services import makerworld as mw
|
||||
from backend.app.services.model_providers.makerworld import http as mw
|
||||
|
||||
# A presigned URL in the shape Bambu Cloud actually mints: the signature is
|
||||
# computed over these exact query-string bytes, so any re-encoding breaks it.
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Tests for the model-provider registry.
|
||||
|
||||
Pins the routing seam that a future *shared* import API will use: pasted URLs
|
||||
go through ``find_for_url`` and land on the provider that owns them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.app.services.model_providers import makerworld_provider, registry
|
||||
from backend.app.services.model_providers.base import ModelProvider
|
||||
from backend.app.services.model_providers.registry import ModelProviderRegistry
|
||||
|
||||
|
||||
class _DummyProvider(ModelProvider):
|
||||
source_type = "dummy"
|
||||
display_name = "Dummy"
|
||||
|
||||
async def build_service(self, *, db, user, api_key_owner=None, client=None):
|
||||
raise NotImplementedError
|
||||
|
||||
def parse_url(self, url):
|
||||
raise NotImplementedError
|
||||
|
||||
def canonical_url(self, ref):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class TestAppRegistry:
|
||||
"""The app-wide singleton auto-registers MakerWorld on import."""
|
||||
|
||||
def test_makerworld_is_registered(self):
|
||||
assert registry.get("makerworld") is makerworld_provider
|
||||
assert registry.get("makerworld").display_name == "MakerWorld"
|
||||
|
||||
def test_unknown_source_type_raises_keyerror(self):
|
||||
with pytest.raises(KeyError):
|
||||
registry.get("thingiverse")
|
||||
|
||||
def test_find_for_url_routes_makerworld_urls(self):
|
||||
provider = registry.find_for_url("https://makerworld.com/en/models/1400373#profileId-1452154")
|
||||
assert provider is makerworld_provider
|
||||
|
||||
def test_find_for_url_returns_none_for_foreign_hosts(self):
|
||||
assert registry.find_for_url("https://thingiverse.com/thing/123") is None
|
||||
assert registry.find_for_url("") is None
|
||||
assert registry.find_for_url(None) is None # type: ignore[arg-type]
|
||||
|
||||
|
||||
class TestModelProviderRegistry:
|
||||
def test_register_is_idempotent_per_instance(self):
|
||||
reg = ModelProviderRegistry()
|
||||
provider = _DummyProvider()
|
||||
reg.register(provider)
|
||||
reg.register(provider)
|
||||
assert reg.all() == (provider,)
|
||||
|
||||
def test_register_duplicate_source_type_rejected(self):
|
||||
reg = ModelProviderRegistry()
|
||||
reg.register(_DummyProvider())
|
||||
with pytest.raises(ValueError):
|
||||
reg.register(_DummyProvider())
|
||||
|
||||
def test_all_returns_registered_providers(self):
|
||||
reg = ModelProviderRegistry()
|
||||
provider = _DummyProvider()
|
||||
reg.register(provider)
|
||||
assert provider in reg.all()
|
||||
assert len(reg.all()) == 1
|
||||
Reference in New Issue
Block a user