base.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327
  1. """Model-provider interface.
  2. A *model provider* is a website that hosts 3D printer models (MakerWorld,
  3. Thingiverse, Printables, ...) whose files Bambuddy can resolve and import
  4. into the library. This module defines the contract every provider must
  5. fulfil — the split being:
  6. * :class:`ModelProvider` — the static, provider-wide descriptor: identity
  7. (``source_type``, ``display_name``), URL routing (``host_patterns``),
  8. the auth it needs (or explicitly doesn't), and a factory that builds a
  9. per-request :class:`ProviderService` seeded with the caller's stored
  10. credentials.
  11. * :class:`ProviderService` — one HTTP client per request, mirroring the
  12. ``BambuCloudService`` construction pattern: resolve a model URL to
  13. metadata + importable files, resolve + fetch a concrete download, and
  14. proxy thumbnail images. Providers are *thin transports*: shared concerns
  15. (library dedupe, folder auto-creation, ``save_3mf_bytes_to_library``)
  16. stay in the route layer so every provider benefits from them.
  17. The interface deliberately covers everything the MakerWorld integration
  18. needs today (see ``model_providers/makerworld/``) so that adding a new site
  19. is: implement ``ModelProvider`` + ``ProviderService``, register it, and the
  20. shared import API routes pasted URLs to it via ``registry.find_for_url``.
  21. Only interoperability — not affiliated with or endorsed by MakerWorld or any
  22. other provider, and not intended to circumvent any access control.
  23. """
  24. from __future__ import annotations
  25. from abc import ABC, abstractmethod
  26. from dataclasses import dataclass, field
  27. from typing import TYPE_CHECKING, Any
  28. from urllib.parse import urlparse
  29. import httpx
  30. from backend.app.core.compat import StrEnum
  31. if TYPE_CHECKING:
  32. from sqlalchemy.ext.asyncio import AsyncSession
  33. from backend.app.core.permissions import Permission
  34. from backend.app.models.user import User
  35. class ProviderAuthType(StrEnum):
  36. """The kind of credentials a model provider may (optionally) require."""
  37. NONE = "none"
  38. ACCESS_TOKEN = "access_token"
  39. USERNAME_PASSWORD = "username_password"
  40. BAMBU_CLOUD_BEARER = "bambu_cloud_bearer" # MakerWorld today: shared Bambu Cloud token
  41. COOKIE = "cookie" # reserved for sites without a first-party API
  42. @dataclass(frozen=True)
  43. class ProviderAuthConfig:
  44. """Declarative description of a provider's authentication requirement.
  45. Describes *what* the provider needs so the UI can prompt for it; the
  46. actual storage/retrieval of credentials stays provider-specific for now
  47. (MakerWorld reads the Bambu Cloud token the user already configured).
  48. ``credential_fields`` names the inputs a future generic credential vault
  49. would collect (e.g. ``("access_token",)`` or ``("username", "password")``).
  50. """
  51. auth_type: ProviderAuthType
  52. display_label: str
  53. description: str = ""
  54. credential_fields: tuple[str, ...] = ()
  55. setup_hint: str = ""
  56. @dataclass(frozen=True)
  57. class ProviderResourceRef:
  58. """Provider-agnostic handle for one model resource.
  59. ``external_id`` is the provider-native model identifier (MakerWorld's
  60. integer design id as a string); ``sub_id`` is an optional secondary key
  61. such as MakerWorld's ``profileId`` for a specific plate.
  62. Both ids must be **numeric strings** today: the shared route layer casts
  63. them with ``int()`` when shaping API responses. Providers whose native
  64. ids are not numeric need route-layer changes first — keep this contract
  65. in mind when implementing one.
  66. """
  67. source_type: str
  68. external_id: str
  69. sub_id: str | None = None
  70. original_url: str | None = None
  71. @dataclass
  72. class ProviderStatus:
  73. """Whether the caller can use this provider right now.
  74. ``auth_error`` carries a human-readable reason when the caller is signed
  75. in but the stored credential has been rejected (e.g. expired); ``None``
  76. when there is no error to report. ``credential_rejected`` is the
  77. machine-readable counterpart — set exactly when the stored credential
  78. exists *and* was refused by the provider — so callers (e.g. a route
  79. reporting "sign-in expired") never have to infer it from ``auth_error``,
  80. which may legitimately be set for other failures (network, rate limit).
  81. """
  82. authenticated: bool
  83. can_download: bool
  84. auth_error: str | None = None
  85. credential_rejected: bool = False
  86. @dataclass
  87. class ProviderResolvedModel:
  88. """Result of resolving a model URL.
  89. ``design`` and ``instances`` are provider-specific dicts passed through
  90. verbatim — the frontend reads fields a provider may add over time, so we
  91. don't re-shape them here. Which library rows already hold this resource
  92. is the route layer's concern (it owns the library query) and stays out of
  93. the resolved payload.
  94. """
  95. ref: ProviderResourceRef
  96. design: dict[str, Any]
  97. instances: list[dict[str, Any]] = field(default_factory=list)
  98. @dataclass(frozen=True)
  99. class ProviderDownloadInfo:
  100. """A concrete, short-lived download for one file/plate.
  101. ``ref`` may be enriched by the provider with the ``sub_id`` it resolved
  102. (e.g. the actual MakerWorld profile selected when the caller omitted
  103. one) so the route can build the canonical dedupe URL.
  104. """
  105. ref: ProviderResourceRef
  106. url: str
  107. suggested_filename: str
  108. @dataclass
  109. class ProviderDownload:
  110. """Downloaded file bytes plus the final suggested filename."""
  111. file_bytes: bytes
  112. filename: str
  113. class ProviderError(Exception):
  114. """Base exception for model-provider API errors."""
  115. class ProviderAuthError(ProviderError):
  116. """Raised when a provider requires credentials and we have none (or the
  117. stored one was rejected). True auth failure."""
  118. class ProviderForbiddenError(ProviderError):
  119. """Raised when a provider refuses access despite valid authentication —
  120. content-gated (purchase/points required, region restricted, ...)."""
  121. class ProviderNotFoundError(ProviderError):
  122. """Raised when a model / file / profile doesn't exist."""
  123. class ProviderUnavailableError(ProviderError):
  124. """Raised on 5xx, network errors, or malformed payloads."""
  125. class ProviderUrlError(ProviderError):
  126. """Raised when a URL isn't a model page of this provider."""
  127. class ModelProvider(ABC):
  128. """Static descriptor + factory for one model-hosting site.
  129. Instances are shared (one per provider); all mutable state lives in the
  130. per-request :class:`ProviderService` built by :meth:`build_service`.
  131. """
  132. source_type: str
  133. display_name: str
  134. host_patterns: tuple[str, ...] = ()
  135. auth: ProviderAuthConfig | None = None
  136. #: Top-level library folder imports land in when the caller names no
  137. #: folder. ``None`` imports into the library root — the route will not
  138. #: mint a folder without a name.
  139. default_folder_name: str | None = None
  140. #: The permissions the routes enforce for this provider's read and import
  141. #: operations. Optional only so the base class has a default: a provider
  142. #: that leaves them unset is refused at the gate rather than treated as
  143. #: unrestricted (see ``makerworld._authorize_for_provider``).
  144. view_permission: Permission | None = None
  145. import_permission: Permission | None = None
  146. @abstractmethod
  147. async def build_service(
  148. self,
  149. *,
  150. db: AsyncSession,
  151. user: User | None,
  152. api_key_owner: User | None = None,
  153. client: httpx.AsyncClient | None = None,
  154. ) -> ProviderService:
  155. """Build a per-request service seeded with the caller's credentials.
  156. ``api_key_owner`` is the API key's owning user for API-keyed calls
  157. (see ``resolve_api_key_cloud_owner``); providers use it as the
  158. fallback identity when ``user`` is None.
  159. """
  160. @abstractmethod
  161. def parse_url(self, url: str) -> ProviderResourceRef:
  162. """Extract a :class:`ProviderResourceRef` from a model URL.
  163. Raises :class:`ProviderUrlError` when the URL isn't a model page of
  164. this provider.
  165. """
  166. @abstractmethod
  167. def canonical_url(self, ref: ProviderResourceRef) -> str:
  168. """Stable dedupe key for a resource (library ``source_url``).
  169. All URL variants of the same resource must collapse to this string;
  170. different resources (e.g. different plates of one model) must differ.
  171. """
  172. def source_url_filter(self, column: Any, external_id: str) -> Any:
  173. """SQL predicate over ``LibraryFile.source_url`` selecting every row
  174. that belongs to this resource — the whole-model canonical URL plus,
  175. when the provider keys dedupe per sub-resource (plate/profile), every
  176. such variant. Drives the resolve flow's already-imported detection.
  177. The default matches the model-level canonical URL only; providers with
  178. recognisable per-plate URL shapes override this (see MakerWorld).
  179. """
  180. prefix = self.canonical_url(ProviderResourceRef(source_type=self.source_type, external_id=external_id))
  181. return column == prefix
  182. def supports_url(self, url: str) -> bool:
  183. """Whether ``url`` points at this provider (host-suffix match).
  184. Accepts scheme-less input (``makerworld.com/models/1``) the same way
  185. :meth:`parse_url` does, so ``find_for_url`` routes exactly the URLs
  186. the provider will then accept.
  187. """
  188. if not url or not isinstance(url, str):
  189. return False
  190. candidate = url.strip()
  191. if "://" not in candidate:
  192. candidate = "https://" + candidate
  193. try:
  194. host = (urlparse(candidate).hostname or "").lower()
  195. except ValueError:
  196. return False
  197. return any(host == pattern or host.endswith("." + pattern) for pattern in self.host_patterns)
  198. def thumbnail_hosts(self) -> tuple[str, ...]:
  199. """Hosts whose image URLs may be proxied by ``fetch_thumbnail``.
  200. Serves as the SSRF allowlist for the provider's image proxy; empty
  201. means the provider has no server-side thumbnail proxy.
  202. """
  203. return ()
  204. def download_hosts(self) -> tuple[str, ...]:
  205. """Hosts whose file URLs may be fetched by the download path.
  206. Serves as the SSRF allowlist for :meth:`ProviderService.download`,
  207. symmetric to :meth:`thumbnail_hosts`; empty means the provider has no
  208. server-side file fetch (so no allowlist constraint applies). Providers
  209. whose service fetches files must override this — a new provider gets
  210. the same structural hint the thumbnail proxy gives its counterpart.
  211. """
  212. return ()
  213. class ProviderService(ABC):
  214. """Per-request client for a single provider.
  215. Built by :meth:`ModelProvider.build_service`, never constructed directly.
  216. Providers must be closed after use (:meth:`close`); the shared connection
  217. pool is only closed by the owner.
  218. """
  219. @abstractmethod
  220. async def close(self) -> None:
  221. """Close the client if this service instance owns it."""
  222. @abstractmethod
  223. async def get_status(self, db: AsyncSession) -> ProviderStatus:
  224. """Report whether the caller can use this provider (credential state)."""
  225. @abstractmethod
  226. async def resolve(self, ref: ProviderResourceRef) -> ProviderResolvedModel:
  227. """Fetch metadata + the importable file/plate list for a resource."""
  228. @abstractmethod
  229. async def get_download(self, ref: ProviderResourceRef) -> ProviderDownloadInfo:
  230. """Resolve the concrete download for a resource/file.
  231. May need provider-specific lookups (e.g. MakerWorld's alphanumeric
  232. ``modelId``) and must enrich ``ref.sub_id`` with the actually-resolved
  233. file/plate so the route can build the canonical dedupe key.
  234. Raises ``ProviderAuthError`` when the provider requires credentials
  235. and the caller has none.
  236. """
  237. @abstractmethod
  238. async def download(self, info: ProviderDownloadInfo) -> ProviderDownload:
  239. """Fetch the file bytes for a :class:`ProviderDownloadInfo`.
  240. Must restrict the upstream URL host to :meth:`ModelProvider.download_hosts`
  241. (SSRF guard — the symmetric counterpart to ``fetch_thumbnail``).
  242. """
  243. @abstractmethod
  244. async def fetch_thumbnail(self, url: str) -> tuple[bytes, str]:
  245. """Proxy a provider CDN image, returning ``(bytes, content_type)``.
  246. Must restrict the upstream host to :meth:`ModelProvider.thumbnail_hosts`
  247. (SSRF guard).
  248. """