mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 19:21:33 +02:00
328 lines
12 KiB
Python
328 lines
12 KiB
Python
"""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).
|
|
"""
|