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