manyfold.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377
  1. """Manyfold integration routes (#1471).
  2. Browse and search a self-hosted Manyfold library and import its files into
  3. the Bambuddy library, from where they are sliced and printed like any other
  4. file. Files are only fetched when someone imports one; nothing is mirrored.
  5. One connection serves the whole install: an admin stores the Manyfold URL and
  6. an OAuth application's client ID and secret (``/manyfold/config``), and the
  7. application's owner in Manyfold decides which models Bambuddy can see.
  8. """
  9. from __future__ import annotations
  10. import logging
  11. import os
  12. from fastapi import APIRouter, Depends, HTTPException
  13. from fastapi.responses import Response
  14. from sqlalchemy import delete, select
  15. from sqlalchemy.ext.asyncio import AsyncSession
  16. from backend.app.api.routes.library import save_3mf_bytes_to_library, validate_print_file_upload
  17. from backend.app.api.routes.settings import set_setting
  18. from backend.app.core.auth import RequirePermissionIfAuthEnabled
  19. from backend.app.core.database import get_db
  20. from backend.app.core.permissions import Permission
  21. from backend.app.models.library import LibraryFile, LibraryFolder
  22. from backend.app.models.settings import Settings
  23. from backend.app.models.user import User
  24. from backend.app.schemas.manyfold import (
  25. ManyfoldConfigResponse,
  26. ManyfoldConfigUpdate,
  27. ManyfoldFile,
  28. ManyfoldImportRequest,
  29. ManyfoldImportResponse,
  30. ManyfoldLibraryRef,
  31. ManyfoldModel,
  32. ManyfoldModelList,
  33. ManyfoldStatus,
  34. ManyfoldTestRequest,
  35. ManyfoldTestResponse,
  36. )
  37. from backend.app.services.model_providers import manyfold_provider
  38. from backend.app.services.model_providers.base import ProviderResourceRef
  39. from backend.app.services.model_providers.manyfold.config import (
  40. CLIENT_ID_KEY,
  41. CLIENT_SECRET_KEY,
  42. CONFIG_KEYS,
  43. URL_KEY,
  44. ManyfoldConfig,
  45. load_config,
  46. normalize_url,
  47. )
  48. from backend.app.services.model_providers.manyfold.service import (
  49. ManyfoldError,
  50. ManyfoldNotFoundError,
  51. ManyfoldService,
  52. clear_token_cache,
  53. is_importable_filename,
  54. valid_id,
  55. )
  56. from backend.app.utils.filename import INVALID_FILENAME_CHARS, InvalidFilenameError, validate_print_filename
  57. logger = logging.getLogger(__name__)
  58. router = APIRouter(prefix="/manyfold", tags=["manyfold"])
  59. def _error(status_code: int, code: str, message: str) -> HTTPException:
  60. # Structured detail: the frontend shows its own translated text for the code.
  61. return HTTPException(status_code=status_code, detail={"code": code, "message": message})
  62. def _map_error(exc: ManyfoldError) -> HTTPException:
  63. """Manyfold's refusals are not Bambuddy's: never answer them with 401 or 403."""
  64. if isinstance(exc, ManyfoldNotFoundError):
  65. status_code = 404
  66. elif exc.code == "manyfold_not_configured":
  67. status_code = 409
  68. else:
  69. status_code = 502
  70. return _error(status_code, exc.code, str(exc))
  71. async def _service(db: AsyncSession) -> ManyfoldService:
  72. config = await load_config(db)
  73. if not config.configured:
  74. raise _error(409, "manyfold_not_configured", "Manyfold is not set up")
  75. return ManyfoldService(config)
  76. def _id(value: str) -> str:
  77. try:
  78. return valid_id(value)
  79. except ManyfoldError as exc:
  80. raise _map_error(exc) from exc
  81. def _library_filename(name: str, file_id: str) -> str:
  82. """The Manyfold file name, made safe for the printer's SD card."""
  83. cleaned = "".join("_" if ch in INVALID_FILENAME_CHARS or ord(ch) < 0x20 else ch for ch in name)
  84. cleaned = cleaned.rstrip(" .")
  85. try:
  86. validate_print_filename(cleaned)
  87. except InvalidFilenameError:
  88. cleaned = f"manyfold-{file_id}{os.path.splitext(name)[1].lower()}"
  89. return cleaned
  90. # ---- connection ---------------------------------------------------------
  91. def _config_response(config: ManyfoldConfig) -> ManyfoldConfigResponse:
  92. return ManyfoldConfigResponse(
  93. url=config.url,
  94. client_id=config.client_id,
  95. has_client_secret=bool(config.client_secret),
  96. configured=config.configured,
  97. )
  98. @router.get("/config", response_model=ManyfoldConfigResponse)
  99. async def get_config(
  100. db: AsyncSession = Depends(get_db),
  101. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
  102. ):
  103. """The stored connection, without the secret."""
  104. return _config_response(await load_config(db))
  105. @router.put("/config", response_model=ManyfoldConfigResponse)
  106. async def update_config(
  107. body: ManyfoldConfigUpdate,
  108. db: AsyncSession = Depends(get_db),
  109. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
  110. ):
  111. """Store the connection. An empty secret keeps the stored one."""
  112. try:
  113. url = normalize_url(body.url)
  114. except ValueError as exc:
  115. raise _error(400, "manyfold_bad_url", str(exc)) from exc
  116. stored = await load_config(db)
  117. secret = (body.client_secret or "").strip() or stored.client_secret
  118. if not secret:
  119. raise _error(400, "manyfold_secret_required", "Enter the client secret")
  120. await set_setting(db, URL_KEY, url)
  121. await set_setting(db, CLIENT_ID_KEY, body.client_id.strip())
  122. await set_setting(db, CLIENT_SECRET_KEY, secret)
  123. await db.commit()
  124. clear_token_cache()
  125. return _config_response(await load_config(db))
  126. @router.delete("/config", status_code=204)
  127. async def delete_config(
  128. db: AsyncSession = Depends(get_db),
  129. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
  130. ):
  131. """Disconnect Manyfold. Files imported earlier stay in the library."""
  132. await db.execute(delete(Settings).where(Settings.key.in_(CONFIG_KEYS)))
  133. await db.commit()
  134. clear_token_cache()
  135. @router.post("/config/test", response_model=ManyfoldTestResponse)
  136. async def test_config(
  137. body: ManyfoldTestRequest,
  138. db: AsyncSession = Depends(get_db),
  139. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
  140. ):
  141. """Sign in with the entered values and count the models Bambuddy can see.
  142. Nothing is stored. An empty secret tests the stored one, so an admin can
  143. re-test without typing it again.
  144. """
  145. try:
  146. url = normalize_url(body.url)
  147. except ValueError as exc:
  148. raise _error(400, "manyfold_bad_url", str(exc)) from exc
  149. secret = (body.client_secret or "").strip() or (await load_config(db)).client_secret
  150. if not secret:
  151. raise _error(400, "manyfold_secret_required", "Enter the client secret")
  152. service = ManyfoldService(ManyfoldConfig(url=url, client_id=body.client_id.strip(), client_secret=secret))
  153. try:
  154. return ManyfoldTestResponse(model_count=await service.check())
  155. except ManyfoldError as exc:
  156. raise _map_error(exc) from exc
  157. finally:
  158. await service.close()
  159. # ---- browsing -----------------------------------------------------------
  160. @router.get("/status", response_model=ManyfoldStatus)
  161. async def get_status(
  162. db: AsyncSession = Depends(get_db),
  163. _: User | None = RequirePermissionIfAuthEnabled(Permission.MANYFOLD_VIEW),
  164. ):
  165. """Whether Manyfold is set up, and where it is."""
  166. config = await load_config(db)
  167. return ManyfoldStatus(configured=config.configured, url=config.url if config.configured else "")
  168. @router.get("/models", response_model=ManyfoldModelList)
  169. async def list_models(
  170. q: str = "",
  171. page: int = 1,
  172. db: AsyncSession = Depends(get_db),
  173. _: User | None = RequirePermissionIfAuthEnabled(Permission.MANYFOLD_VIEW),
  174. ):
  175. """One page of Manyfold's models, optionally searched.
  176. ``q`` takes Manyfold's own search syntax; the page size is the
  177. application owner's Manyfold setting.
  178. """
  179. service = await _service(db)
  180. try:
  181. return ManyfoldModelList(**await service.list_models(query=q[:200], page=max(1, min(page, 100000))))
  182. except ManyfoldError as exc:
  183. raise _map_error(exc) from exc
  184. finally:
  185. await service.close()
  186. @router.get("/models/{model_id}", response_model=ManyfoldModel)
  187. async def get_model(
  188. model_id: str,
  189. db: AsyncSession = Depends(get_db),
  190. _: User | None = RequirePermissionIfAuthEnabled(Permission.MANYFOLD_VIEW),
  191. ):
  192. """A model's details and files, with the library file of each earlier import."""
  193. model_id = _id(model_id)
  194. service = await _service(db)
  195. try:
  196. model = await service.get_model(model_id)
  197. except ManyfoldError as exc:
  198. raise _map_error(exc) from exc
  199. finally:
  200. await service.close()
  201. imported: dict[str, ManyfoldLibraryRef] = {}
  202. rows = await db.execute(
  203. LibraryFile.active()
  204. .where(manyfold_provider.source_url_filter(LibraryFile.source_url, model_id))
  205. .order_by(LibraryFile.id)
  206. )
  207. for row in rows.scalars().all():
  208. file_id = (row.source_url or "").rsplit("/", 1)[-1]
  209. imported.setdefault(file_id, ManyfoldLibraryRef(id=row.id, filename=row.filename, folder_id=row.folder_id))
  210. return ManyfoldModel(
  211. id=model["id"],
  212. name=model["name"],
  213. caption=model["caption"],
  214. description=model["description"],
  215. license=model["license"],
  216. tags=model["tags"],
  217. url=model["url"],
  218. has_preview=model["preview_file_id"] is not None,
  219. files=[ManyfoldFile(**file, library_file=imported.get(file["id"])) for file in model["files"]],
  220. )
  221. @router.get("/models/{model_id}/preview")
  222. async def get_preview(
  223. model_id: str,
  224. db: AsyncSession = Depends(get_db),
  225. _: User | None = RequirePermissionIfAuthEnabled(Permission.MANYFOLD_VIEW),
  226. ):
  227. """The model's preview image, fetched from Manyfold.
  228. The browser can't send Manyfold's token, and the page's CSP only allows
  229. images from Bambuddy itself, so the image comes through here. The page
  230. fetches it rather than pointing an ``<img>`` at it: a model without a
  231. preview answers 404, and a failing ``<img>`` on a protected URL makes the
  232. app renew its media token.
  233. """
  234. model_id = _id(model_id)
  235. service = await _service(db)
  236. try:
  237. data, content_type = await service.fetch_preview(model_id)
  238. except ManyfoldError as exc:
  239. raise _map_error(exc) from exc
  240. finally:
  241. await service.close()
  242. return Response(content=data, media_type=content_type, headers={"Cache-Control": "private, max-age=3600"})
  243. # ---- import -------------------------------------------------------------
  244. async def _import_folder_id(db: AsyncSession, folder_id: int | None) -> int | None:
  245. """The chosen folder, or the top-level "Manyfold" folder (created on first use)."""
  246. if folder_id is not None:
  247. folder = (await db.execute(select(LibraryFolder).where(LibraryFolder.id == folder_id))).scalar_one_or_none()
  248. if folder is None:
  249. raise HTTPException(status_code=404, detail="Folder not found")
  250. if folder.is_external and folder.external_readonly:
  251. raise HTTPException(status_code=403, detail="Cannot import into a read-only external folder")
  252. return folder_id
  253. name = manyfold_provider.default_folder_name
  254. folder = (
  255. await db.execute(
  256. select(LibraryFolder).where(
  257. LibraryFolder.name == name,
  258. LibraryFolder.parent_id.is_(None),
  259. LibraryFolder.is_external.is_(False),
  260. )
  261. )
  262. ).scalar_one_or_none()
  263. if folder is None:
  264. folder = LibraryFolder(name=name, parent_id=None)
  265. db.add(folder)
  266. await db.flush()
  267. return folder.id
  268. @router.post("/import", response_model=ManyfoldImportResponse)
  269. async def import_file(
  270. body: ManyfoldImportRequest,
  271. db: AsyncSession = Depends(get_db),
  272. current_user: User | None = RequirePermissionIfAuthEnabled(Permission.MANYFOLD_IMPORT),
  273. ):
  274. """Download one Manyfold file into the library.
  275. A file imported before, and still in the library, is returned as it is
  276. rather than downloaded again.
  277. """
  278. model_id, file_id = _id(body.model_id), _id(body.file_id)
  279. source_url = manyfold_provider.canonical_url(
  280. ProviderResourceRef(source_type=manyfold_provider.source_type, external_id=model_id, sub_id=file_id)
  281. )
  282. existing = (
  283. await db.execute(LibraryFile.active().where(LibraryFile.source_url == source_url).limit(1))
  284. ).scalar_one_or_none()
  285. if existing is not None:
  286. return ManyfoldImportResponse(
  287. library_file_id=existing.id, filename=existing.filename, folder_id=existing.folder_id, was_existing=True
  288. )
  289. service = await _service(db)
  290. try:
  291. file = await service.get_file(model_id, file_id)
  292. if not is_importable_filename(file["filename"]):
  293. raise _error(
  294. 400,
  295. "manyfold_not_importable",
  296. "Only 3MF, STL and STEP files can be imported; Bambuddy can't slice or print the others",
  297. )
  298. data = await service.download_file(file)
  299. except ManyfoldError as exc:
  300. raise _map_error(exc) from exc
  301. finally:
  302. await service.close()
  303. filename = _library_filename(file["filename"], file_id)
  304. validate_print_file_upload(filename, data)
  305. folder_id = await _import_folder_id(db, body.folder_id)
  306. library_file, was_existing = await save_3mf_bytes_to_library(
  307. db,
  308. file_bytes=data,
  309. filename=filename,
  310. folder_id=folder_id,
  311. source_type=manyfold_provider.source_type,
  312. source_url=source_url,
  313. owner_id=current_user.id if current_user else None,
  314. )
  315. logger.info(
  316. "[MANYFOLD] Imported %s (model %s, file %s) as library file %s", filename, model_id, file_id, library_file.id
  317. )
  318. return ManyfoldImportResponse(
  319. library_file_id=library_file.id,
  320. filename=library_file.filename,
  321. folder_id=library_file.folder_id,
  322. was_existing=was_existing,
  323. )