provider.py 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117
  1. """MakerWorld model provider.
  2. Static descriptor + per-request service factory for makerworld.com. The
  3. ``MakerWorldProvider`` instance is what gets registered in the shared
  4. :class:`ModelProviderRegistry`; the actual API work lives in ``service.py``
  5. (the per-request :class:`ProviderService`) and ``url.py`` (URL parsing and
  6. canonicalisation). Credential handling is centralised here so route layers
  7. never touch MakerWorld specifics.
  8. """
  9. from __future__ import annotations
  10. from typing import TYPE_CHECKING
  11. import httpx
  12. from backend.app.core.permissions import Permission
  13. from backend.app.services.model_providers.base import (
  14. ModelProvider,
  15. ProviderAuthConfig,
  16. ProviderAuthType,
  17. ProviderResourceRef,
  18. ProviderService,
  19. )
  20. from backend.app.services.model_providers.makerworld import url as mw_url
  21. from backend.app.services.model_providers.makerworld.auth import (
  22. get_stored_token,
  23. mark_cloud_token_invalid,
  24. )
  25. from backend.app.services.model_providers.makerworld.http import MAKERWORLD_CDN_HOSTS
  26. from backend.app.services.model_providers.makerworld.service import MakerWorldService
  27. if TYPE_CHECKING:
  28. from sqlalchemy.ext.asyncio import AsyncSession
  29. from backend.app.models.user import User
  30. class MakerWorldProvider(ModelProvider):
  31. """MakerWorld descriptor: identity, URL routing, auth requirements, and the
  32. factory that builds a per-request :class:`MakerWorldService` seeded with the
  33. caller's stored Bambu Cloud bearer token.
  34. """
  35. source_type = "makerworld"
  36. display_name = "MakerWorld"
  37. host_patterns = ("makerworld.com",)
  38. auth = ProviderAuthConfig(
  39. auth_type=ProviderAuthType.BAMBU_CLOUD_BEARER,
  40. display_label="Bambu Cloud sign-in",
  41. description=(
  42. "MakerWorld downloads reuse the Bambu Cloud account already stored in Bambuddy — "
  43. "there is no separate MakerWorld sign-in."
  44. ),
  45. setup_hint="Open the Profiles page and sign in to Bambu Cloud.",
  46. )
  47. default_folder_name = "MakerWorld"
  48. view_permission = Permission.MAKERWORLD_VIEW
  49. import_permission = Permission.MAKERWORLD_IMPORT
  50. async def build_service(
  51. self,
  52. *,
  53. db: AsyncSession,
  54. user: User | None,
  55. api_key_owner: User | None = None,
  56. client: httpx.AsyncClient | None = None,
  57. ) -> ProviderService:
  58. """Build a per-request service seeded with the caller's stored Bambu
  59. Cloud bearer, mirroring ``cloud.build_authenticated_cloud``.
  60. ``api_key_owner`` is the API key's owning user for API-keyed calls
  61. (see ``resolve_api_key_cloud_owner``); MakerWorld uses it as the
  62. fallback identity when ``user`` is None. Like the cloud integration, a
  63. rejected token is recorded so the whole app agrees the sign-in is dead
  64. rather than each feature failing on its own — including auth-disabled
  65. single-user installs, where ``user_id=None`` records the *global*
  66. flag those installs read back on the status endpoints.
  67. """
  68. identity = user if user is not None else api_key_owner
  69. token, _email, _region = await get_stored_token(db, identity)
  70. user_id = identity.id if identity is not None else None
  71. return MakerWorldService(
  72. client=client,
  73. auth_token=token,
  74. user=identity,
  75. on_auth_failure=lambda: mark_cloud_token_invalid(user_id),
  76. # The SSRF allowlists are the provider's declared seams — the
  77. # service must not hardcode its own copies (symmetric pair,
  78. # ``fetch_thumbnail`` / ``download``).
  79. thumbnail_hosts=self.thumbnail_hosts(),
  80. download_hosts=self.download_hosts(),
  81. )
  82. def parse_url(self, url: str) -> ProviderResourceRef:
  83. return mw_url.parse_url(url)
  84. def canonical_url(self, ref: ProviderResourceRef) -> str:
  85. return mw_url.canonical_url(ref)
  86. def source_url_filter(self, column, external_id: str):
  87. """Whole-model key plus every per-plate key — MakerWorld's canonical
  88. shape appends ``#profileId-{n}`` for plate-level dedupe (see
  89. ``url.canonical_url``), so the already-imported detection must match
  90. both. The ``#profileId-`` fragment lives here with the descriptor
  91. because it is part of this provider's URL contract."""
  92. prefix = mw_url.canonical_url(ProviderResourceRef(source_type=self.source_type, external_id=external_id))
  93. return (column == prefix) | (column.like(f"{prefix}#profileId-%"))
  94. def thumbnail_hosts(self) -> tuple[str, ...]:
  95. return MAKERWORLD_CDN_HOSTS
  96. def download_hosts(self) -> tuple[str, ...]:
  97. return MAKERWORLD_CDN_HOSTS
  98. makerworld_provider = MakerWorldProvider()