provider.py 4.9 KB

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