manyfold.py 13 KB

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