| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327 |
- """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).
- """
|