service.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522
  1. """Manyfold API client (#1471).
  2. Talks to a self-hosted Manyfold install through its v0 API: list and search
  3. models, read a model's files, download one file and fetch a model's preview
  4. image. Bambuddy signs in with an OAuth application's client ID and secret
  5. (the client-credentials flow) and asks only for the ``public read`` scopes,
  6. which cover everything here; it never writes to Manyfold.
  7. Manyfold limits the token endpoint to 10 requests in 3 minutes, so the token
  8. is cached until shortly before it expires (two hours by default) and shared
  9. by every request.
  10. Request URLs are always built from the configured base URL and validated ids.
  11. The ``@id`` and ``contentUrl`` links in Manyfold's responses are read for the
  12. ids and the file name they carry, never fetched as given.
  13. """
  14. from __future__ import annotations
  15. import asyncio
  16. import hashlib
  17. import logging
  18. import os
  19. import re
  20. import time
  21. from typing import Any
  22. from urllib.parse import unquote, urlsplit
  23. import httpx
  24. from backend.app.api.routes._url_safety import assert_safe_lan_service_url
  25. from backend.app.services.model_providers.base import (
  26. ProviderAuthError,
  27. ProviderDownload,
  28. ProviderDownloadInfo,
  29. ProviderError,
  30. ProviderForbiddenError,
  31. ProviderNotFoundError,
  32. ProviderResolvedModel,
  33. ProviderResourceRef,
  34. ProviderService,
  35. ProviderStatus,
  36. ProviderUnavailableError,
  37. )
  38. from backend.app.services.model_providers.manyfold.config import ManyfoldConfig
  39. logger = logging.getLogger(__name__)
  40. API_MEDIA_TYPE = "application/vnd.manyfold.v0+json"
  41. SCOPES = "public read"
  42. # Manyfold ids are short public ids such as "x7q3k2pa".
  43. _ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
  44. _MODEL_ID_IN_URL = re.compile(r"/models/([A-Za-z0-9_-]{1,64})(?:/|$)")
  45. _FILE_ID_IN_URL = re.compile(r"/model_files/([A-Za-z0-9_-]{1,64})(?:[/.?]|$)")
  46. # What Bambuddy can slice or print, by Manyfold's MIME type and by extension.
  47. IMPORTABLE_MIME_TYPES = frozenset({"model/3mf", "model/stl", "model/step"})
  48. IMPORTABLE_EXTENSIONS = (".3mf", ".stl", ".step", ".stp")
  49. MAX_FILE_BYTES = 200 * 1024 * 1024
  50. _MAX_REDIRECTS = 5
  51. MAX_PREVIEW_BYTES = 10 * 1024 * 1024
  52. # Renew this long before Manyfold's own expiry, so a request never starts
  53. # with a token that dies on the way.
  54. _TOKEN_MARGIN_SECONDS = 120
  55. _IMAGE_SIGNATURES = (
  56. (b"\x89PNG\r\n\x1a\n", "image/png"),
  57. (b"\xff\xd8\xff", "image/jpeg"),
  58. (b"GIF87a", "image/gif"),
  59. (b"GIF89a", "image/gif"),
  60. )
  61. # (base url, client id, secret digest) -> (token, monotonic expiry)
  62. _token_cache: dict[tuple[str, str, str], tuple[str, float]] = {}
  63. # Credentials Manyfold just refused, and until when that answer is reused.
  64. # Without it, every request of a page or a bulk import would try to sign in
  65. # again with the same wrong secret and use up Manyfold's 10 sign-ins in 3
  66. # minutes, so the corrected secret would then be refused as well.
  67. _refused: dict[tuple[str, str, str], tuple[ProviderError, float]] = {}
  68. _REFUSED_FOR_SECONDS = 30.0
  69. # One sign-in at a time, so a page of previews opening at once asks for one
  70. # token rather than a dozen. Bound to the running loop, recreated if it changes.
  71. _token_lock: asyncio.Lock | None = None
  72. _token_lock_loop: asyncio.AbstractEventLoop | None = None
  73. def _sign_in_lock() -> asyncio.Lock:
  74. global _token_lock, _token_lock_loop
  75. loop = asyncio.get_running_loop()
  76. if _token_lock is None or _token_lock_loop is not loop:
  77. _token_lock = asyncio.Lock()
  78. _token_lock_loop = loop
  79. return _token_lock
  80. class ManyfoldError(ProviderError):
  81. """Base exception for Manyfold API errors.
  82. ``code`` names the failure for the frontend, which shows its own
  83. translated text for it; the message is the English fallback.
  84. """
  85. default_code = "manyfold_failed"
  86. def __init__(self, message: str, code: str | None = None):
  87. super().__init__(message)
  88. self.code = code or self.default_code
  89. class ManyfoldAuthError(ProviderAuthError, ManyfoldError):
  90. """Manyfold refused the configured credentials, or none are configured."""
  91. default_code = "manyfold_credentials"
  92. class ManyfoldForbiddenError(ProviderForbiddenError, ManyfoldError):
  93. """Manyfold accepted the token but refused this request."""
  94. default_code = "manyfold_forbidden"
  95. class ManyfoldNotFoundError(ProviderNotFoundError, ManyfoldError):
  96. """The model or file doesn't exist, or isn't visible to the application's owner."""
  97. default_code = "manyfold_not_found"
  98. class ManyfoldUnavailableError(ProviderUnavailableError, ManyfoldError):
  99. """Manyfold is unreachable, failing, or sent something unexpected."""
  100. default_code = "manyfold_failed"
  101. def clear_token_cache() -> None:
  102. """Forget cached tokens and refusals, e.g. after the credentials were changed."""
  103. _token_cache.clear()
  104. _refused.clear()
  105. def valid_id(value: str) -> str:
  106. """Return ``value`` if it is a well-formed Manyfold id, else raise."""
  107. if not isinstance(value, str) or not _ID_RE.match(value):
  108. raise ManyfoldNotFoundError("Not a valid Manyfold id")
  109. return value
  110. def _id_from(url: Any, pattern: re.Pattern[str]) -> str | None:
  111. if not isinstance(url, str):
  112. return None
  113. match = pattern.search(urlsplit(url).path)
  114. return match.group(1) if match else None
  115. def _image_type(head: bytes) -> str | None:
  116. for signature, mime in _IMAGE_SIGNATURES:
  117. if head.startswith(signature):
  118. return mime
  119. if head[:4] == b"RIFF" and head[8:12] == b"WEBP":
  120. return "image/webp"
  121. return None
  122. def is_importable_filename(filename: str) -> bool:
  123. return filename.lower().endswith(IMPORTABLE_EXTENSIONS)
  124. class ManyfoldService(ProviderService):
  125. """Per-request Manyfold client."""
  126. def __init__(self, config: ManyfoldConfig, *, client: httpx.AsyncClient | None = None):
  127. self._config = config
  128. self._base = config.url.rstrip("/")
  129. self._host = (urlsplit(self._base).hostname or "").lower()
  130. if client is not None:
  131. self._client = client
  132. self._owns_client = False
  133. else:
  134. self._client = httpx.AsyncClient(timeout=30.0)
  135. self._owns_client = True
  136. async def close(self) -> None:
  137. if self._owns_client:
  138. await self._client.aclose()
  139. # ---- sign-in -------------------------------------------------------
  140. def _cache_key(self) -> tuple[str, str, str]:
  141. digest = hashlib.sha256(self._config.client_secret.encode()).hexdigest()
  142. return (self._base, self._config.client_id, digest)
  143. async def _token(self, *, renew: bool = False) -> str:
  144. if not self._config.configured:
  145. raise ManyfoldAuthError(
  146. "Manyfold is not set up. Enter its URL, client ID and secret first.", "manyfold_not_configured"
  147. )
  148. key = self._cache_key()
  149. async with _sign_in_lock():
  150. cached = _token_cache.get(key)
  151. if cached and not renew and cached[1] > time.monotonic():
  152. return cached[0]
  153. refused = _refused.get(key)
  154. if refused and refused[1] > time.monotonic():
  155. earlier = refused[0]
  156. raise type(earlier)(str(earlier), getattr(earlier, "code", None))
  157. try:
  158. return await self._sign_in(key)
  159. except ManyfoldAuthError as exc:
  160. _refused[key] = (exc, time.monotonic() + _REFUSED_FOR_SECONDS)
  161. raise
  162. async def _sign_in(self, key: tuple[str, str, str]) -> str:
  163. """Ask Manyfold for a token; the caller holds the sign-in lock."""
  164. try:
  165. response = await self._client.post(
  166. f"{self._base}/oauth/token",
  167. data={
  168. "grant_type": "client_credentials",
  169. "client_id": self._config.client_id,
  170. "client_secret": self._config.client_secret,
  171. "scope": SCOPES,
  172. },
  173. headers={"Accept": "application/json"},
  174. )
  175. except httpx.HTTPError as exc:
  176. raise ManyfoldUnavailableError(
  177. f"Could not reach Manyfold at {self._base}: {exc}", "manyfold_unreachable"
  178. ) from exc
  179. if response.status_code == 429:
  180. raise ManyfoldUnavailableError(
  181. "Manyfold is limiting sign-ins. Try again in a few minutes.", "manyfold_rate_limited"
  182. )
  183. body = self._json_or_none(response)
  184. error = body.get("error") if isinstance(body, dict) else None
  185. if error == "invalid_scope":
  186. raise ManyfoldAuthError(
  187. "The Manyfold application lacks the 'read' scope. Edit it in Manyfold under Settings -> API.",
  188. "manyfold_scope",
  189. )
  190. if response.status_code in (400, 401) or error in ("invalid_client", "unauthorized_client"):
  191. raise ManyfoldAuthError("Manyfold did not accept the client ID or secret.")
  192. if response.status_code != 200 or not isinstance(body, dict):
  193. raise ManyfoldUnavailableError(f"Manyfold sign-in failed with HTTP {response.status_code}")
  194. token = body.get("access_token")
  195. if not isinstance(token, str) or not token:
  196. raise ManyfoldUnavailableError("Manyfold sign-in returned no token")
  197. granted = str(body.get("scope") or "").split()
  198. if granted and "read" not in granted:
  199. raise ManyfoldAuthError(
  200. "The Manyfold application lacks the 'read' scope. Edit it in Manyfold under Settings -> API.",
  201. "manyfold_scope",
  202. )
  203. try:
  204. lifetime = float(body.get("expires_in") or 7200)
  205. except (TypeError, ValueError):
  206. lifetime = 7200.0
  207. _token_cache[key] = (token, time.monotonic() + max(0.0, lifetime - _TOKEN_MARGIN_SECONDS))
  208. return token
  209. @staticmethod
  210. def _json_or_none(response: httpx.Response) -> Any:
  211. try:
  212. return response.json()
  213. except ValueError:
  214. return None
  215. # ---- requests ------------------------------------------------------
  216. def _raise_for(self, response: httpx.Response, what: str) -> None:
  217. status = response.status_code
  218. if status == 401:
  219. raise ManyfoldAuthError("Manyfold did not accept Bambuddy's sign-in.")
  220. if status == 403:
  221. raise ManyfoldForbiddenError(
  222. f"Manyfold refused access to {what}. Check that the application has the 'read' scope "
  223. "and that its owner can see this model."
  224. )
  225. if status == 404:
  226. raise ManyfoldNotFoundError(f"Manyfold could not find {what}")
  227. if status >= 400:
  228. raise ManyfoldUnavailableError(f"Manyfold answered HTTP {status} for {what}")
  229. async def _send(
  230. self,
  231. url: str,
  232. *,
  233. accept: str | None = None,
  234. params: dict[str, Any] | None = None,
  235. follow_redirects: bool = False,
  236. ) -> httpx.Response:
  237. """GET with the token, renewing it once if Manyfold refuses it.
  238. The response is streamed; the caller reads and closes it.
  239. Redirects (Manyfold may hand files over to object storage) are
  240. followed here rather than by httpx, so every hop passes the same
  241. LAN-service check as the configured URL, and the token is only sent
  242. to Manyfold's own host.
  243. """
  244. headers = {"Accept": accept} if accept else {}
  245. for attempt in range(2):
  246. headers["Authorization"] = f"Bearer {await self._token(renew=attempt > 0)}"
  247. try:
  248. request = self._client.build_request("GET", url, headers=headers, params=params)
  249. response = await self._client.send(request, stream=True)
  250. hops = 0
  251. while follow_redirects and response.is_redirect and response.next_request is not None:
  252. hops += 1
  253. following = response.next_request
  254. await response.aclose()
  255. if hops > _MAX_REDIRECTS:
  256. raise ManyfoldUnavailableError("Manyfold redirected too often")
  257. try:
  258. assert_safe_lan_service_url(str(following.url), label="Manyfold redirect")
  259. except ValueError as exc:
  260. raise ManyfoldUnavailableError(f"Refusing Manyfold's redirect: {exc}") from exc
  261. if (following.url.host or "").lower() != self._host:
  262. following.headers.pop("Authorization", None)
  263. response = await self._client.send(following, stream=True)
  264. except httpx.HTTPError as exc:
  265. raise ManyfoldUnavailableError(f"Could not reach Manyfold: {exc}", "manyfold_unreachable") from exc
  266. # Only Manyfold's own 401 means the token was refused; object
  267. # storage behind a redirect never saw it.
  268. if response.status_code == 401 and attempt == 0 and (response.url.host or "").lower() == self._host:
  269. await response.aclose()
  270. continue
  271. return response
  272. return response # pragma: no cover - the loop always returns
  273. async def _get_json(self, path: str, what: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
  274. response = await self._send(f"{self._base}{path}", accept=API_MEDIA_TYPE, params=params)
  275. try:
  276. await response.aread()
  277. finally:
  278. await response.aclose()
  279. self._raise_for(response, what)
  280. body = self._json_or_none(response)
  281. if not isinstance(body, dict):
  282. raise ManyfoldUnavailableError(f"Manyfold sent an unexpected answer for {what}")
  283. return body
  284. async def _read_capped(self, response: httpx.Response, cap: int, what: str) -> bytes:
  285. chunks: list[bytes] = []
  286. size = 0
  287. async for chunk in response.aiter_bytes():
  288. size += len(chunk)
  289. if size > cap:
  290. raise ManyfoldUnavailableError(f"{what} is larger than {cap // (1024 * 1024)} MB", "manyfold_too_large")
  291. chunks.append(chunk)
  292. return b"".join(chunks)
  293. # ---- models --------------------------------------------------------
  294. async def list_models(self, *, query: str = "", page: int = 1) -> dict[str, Any]:
  295. params: dict[str, Any] = {"page": max(1, page)}
  296. if query.strip():
  297. params["q"] = query.strip()
  298. body = await self._get_json("/models", "the model list", params)
  299. members = body.get("member") if isinstance(body.get("member"), list) else []
  300. models = []
  301. for member in members:
  302. if not isinstance(member, dict):
  303. continue
  304. model_id = _id_from(member.get("@id"), _MODEL_ID_IN_URL)
  305. if model_id:
  306. models.append({"id": model_id, "name": str(member.get("name") or model_id)})
  307. view = body.get("view") if isinstance(body.get("view"), dict) else {}
  308. total = body.get("totalItems")
  309. return {
  310. "total": total if isinstance(total, int) else len(models),
  311. "page": params["page"],
  312. "has_next": bool(view.get("next")),
  313. "has_previous": bool(view.get("previous")),
  314. "models": models,
  315. }
  316. async def get_model(self, model_id: str) -> dict[str, Any]:
  317. model_id = valid_id(model_id)
  318. body = await self._get_json(f"/models/{model_id}", "this model")
  319. files = []
  320. for part in body.get("hasPart") or []:
  321. if not isinstance(part, dict):
  322. continue
  323. file_id = _id_from(part.get("@id"), _FILE_ID_IN_URL)
  324. if not file_id:
  325. continue
  326. mime = str(part.get("encodingFormat") or "")
  327. files.append(
  328. {
  329. "id": file_id,
  330. "name": str(part.get("name") or file_id),
  331. "mime": mime,
  332. "importable": mime in IMPORTABLE_MIME_TYPES,
  333. }
  334. )
  335. license_info = body.get("spdx:license")
  336. keywords = body.get("keywords")
  337. preview = body.get("preview_file")
  338. return {
  339. "id": model_id,
  340. "name": str(body.get("name") or model_id),
  341. "caption": body.get("caption") if isinstance(body.get("caption"), str) else None,
  342. "description": body.get("description") if isinstance(body.get("description"), str) else None,
  343. "license": license_info.get("licenseId") if isinstance(license_info, dict) else None,
  344. "tags": [str(tag) for tag in keywords] if isinstance(keywords, list) else [],
  345. "url": f"{self._base}/models/{model_id}",
  346. "preview_file_id": _id_from(preview.get("@id"), _FILE_ID_IN_URL) if isinstance(preview, dict) else None,
  347. "files": files,
  348. }
  349. async def get_file(self, model_id: str, file_id: str) -> dict[str, Any]:
  350. """One file's details, with the raw-download path Manyfold gives for it."""
  351. model_id, file_id = valid_id(model_id), valid_id(file_id)
  352. body = await self._get_json(f"/models/{model_id}/model_files/{file_id}", "this file")
  353. content_path = urlsplit(str(body.get("contentUrl") or "")).path
  354. marker = f"/models/{model_id}/raw/"
  355. position = content_path.find(marker)
  356. tail = content_path[position + len(marker) :] if position >= 0 else ""
  357. segments = unquote(tail).split("/")
  358. if not tail or any(segment in ("", ".", "..") for segment in segments):
  359. raise ManyfoldUnavailableError("Manyfold sent no usable download link for this file")
  360. size = body.get("contentSize")
  361. return {
  362. "id": file_id,
  363. "model_id": model_id,
  364. "name": str(body.get("name") or file_id),
  365. "filename": os.path.basename(unquote(tail)),
  366. "mime": str(body.get("encodingFormat") or ""),
  367. "size": size if isinstance(size, int) else None,
  368. "raw_path": f"/models/{model_id}/raw/{tail}",
  369. }
  370. async def check(self) -> int:
  371. """Sign in and read the first page of models; returns the model count."""
  372. return int((await self.list_models())["total"])
  373. # ---- files and previews ------------------------------------------
  374. async def download_file(self, file: dict[str, Any]) -> bytes:
  375. response = await self._send(f"{self._base}{file['raw_path']}", follow_redirects=True)
  376. try:
  377. self._raise_for(response, "this file")
  378. return await self._read_capped(response, MAX_FILE_BYTES, "The file")
  379. finally:
  380. await response.aclose()
  381. async def fetch_preview(self, model_id: str) -> tuple[bytes, str]:
  382. """The model's preview as an image, from Manyfold's own derivatives.
  383. An image preview comes as Manyfold's small "preview" copy, a 3D file
  384. as its rendered picture when Manyfold made one. Without a picture
  385. Manyfold sends the original file instead, so anything that doesn't
  386. start like an image is refused after its first bytes.
  387. """
  388. model = await self.get_model(model_id)
  389. file_id = model["preview_file_id"]
  390. if not file_id:
  391. raise ManyfoldNotFoundError("This model has no preview")
  392. file = await self.get_file(model["id"], file_id)
  393. extension = os.path.splitext(file["filename"])[1].lower()
  394. mime = file["mime"]
  395. if mime.startswith("image/"):
  396. derivative = "preview"
  397. elif mime.startswith("model/"):
  398. derivative = "render"
  399. else:
  400. raise ManyfoldNotFoundError("This model has no preview")
  401. if not re.fullmatch(r"\.[a-z0-9]{1,10}", extension):
  402. raise ManyfoldNotFoundError("This model has no preview")
  403. response = await self._send(
  404. f"{self._base}/models/{model['id']}/model_files/{file_id}{extension}",
  405. params={"derivative": derivative},
  406. follow_redirects=True,
  407. )
  408. try:
  409. self._raise_for(response, "the preview")
  410. data = bytearray()
  411. content_type: str | None = None
  412. async for chunk in response.aiter_bytes():
  413. data += chunk
  414. if content_type is None and len(data) >= 12:
  415. content_type = _image_type(bytes(data[:12]))
  416. if content_type is None:
  417. raise ManyfoldNotFoundError("This model has no preview")
  418. if len(data) > MAX_PREVIEW_BYTES:
  419. raise ManyfoldNotFoundError("This model's preview is too large")
  420. content_type = content_type or _image_type(bytes(data[:12]))
  421. if content_type is None:
  422. raise ManyfoldNotFoundError("This model has no preview")
  423. return bytes(data), content_type
  424. finally:
  425. await response.aclose()
  426. # ---- ProviderService -----------------------------------------------
  427. async def get_status(self, db) -> ProviderStatus:
  428. configured = self._config.configured
  429. return ProviderStatus(authenticated=configured, can_download=configured)
  430. async def resolve(self, ref: ProviderResourceRef) -> ProviderResolvedModel:
  431. model = await self.get_model(ref.external_id)
  432. return ProviderResolvedModel(ref=ref, design=model, instances=model["files"])
  433. async def get_download(self, ref: ProviderResourceRef) -> ProviderDownloadInfo:
  434. if not ref.sub_id:
  435. raise ManyfoldNotFoundError("Name the file to download")
  436. file = await self.get_file(ref.external_id, ref.sub_id)
  437. return ProviderDownloadInfo(ref=ref, url=f"{self._base}{file['raw_path']}", suggested_filename=file["filename"])
  438. async def download(self, info: ProviderDownloadInfo) -> ProviderDownload:
  439. if (urlsplit(info.url).hostname or "").lower() != self._host or not info.url.startswith(self._base + "/"):
  440. raise ManyfoldUnavailableError("Refusing to download from outside the Manyfold install")
  441. data = await self.download_file({"raw_path": info.url[len(self._base) :]})
  442. return ProviderDownload(file_bytes=data, filename=info.suggested_filename)
  443. async def fetch_thumbnail(self, url: str) -> tuple[bytes, str]:
  444. model_id = _id_from(url, _MODEL_ID_IN_URL)
  445. if (urlsplit(url).hostname or "").lower() != self._host or not model_id:
  446. raise ManyfoldUnavailableError("Refusing to fetch an image from outside the Manyfold install")
  447. return await self.fetch_preview(model_id)