websocket.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452
  1. import asyncio
  2. import json
  3. import logging
  4. from typing import Any
  5. from fastapi import WebSocket
  6. logger = logging.getLogger(__name__)
  7. def _message_printer_id(message: dict[str, Any]) -> int | None:
  8. """The printer a broadcast is about, if any: top-level or inside ``data``."""
  9. printer_id = message.get("printer_id")
  10. if printer_id is None:
  11. data = message.get("data")
  12. if isinstance(data, dict):
  13. printer_id = data.get("printer_id")
  14. return printer_id if isinstance(printer_id, int) else None
  15. def _may_receive(connection: WebSocket, printer_id: int | None) -> bool:
  16. """Whether ``connection``'s printer scope (#1727) covers ``printer_id``.
  17. The scope is stamped on the socket at connect (``routes/websocket.py``).
  18. A socket without one is refused anything printer-bound, so a connection
  19. that slipped past the stamping can't receive every printer's events.
  20. """
  21. if printer_id is None:
  22. return True
  23. scope = getattr(connection.state, "bambuddy_printer_scope", None)
  24. return scope is not None and scope.allows(printer_id)
  25. # A printer id no limited scope holds: what an archive event carries when its
  26. # printer cannot be told, so only sockets that see every printer receive it.
  27. _UNKNOWN_PRINTER = -1
  28. async def _archive_printer_ids(archive_id: Any) -> tuple[int | None, set[int]]:
  29. """The printer an archive belongs to (None: printer-less, seen by everyone),
  30. and the printers its prints ran on: plates of one archive print on
  31. several, and each one's sockets see it (archive redesign)."""
  32. from sqlalchemy import select
  33. from backend.app.core.database import async_session
  34. from backend.app.models.archive import PrintArchive
  35. from backend.app.models.print_log import PrintLogEntry
  36. if not isinstance(archive_id, int):
  37. return _UNKNOWN_PRINTER, set()
  38. try:
  39. async with async_session() as db:
  40. row = (await db.execute(select(PrintArchive.printer_id).where(PrintArchive.id == archive_id))).first()
  41. # The table: the ORM leaves printing runs out, and they count.
  42. runs = PrintLogEntry.__table__
  43. printed_on = set(
  44. (
  45. await db.scalars(
  46. select(runs.c.printer_id)
  47. .where(runs.c.archive_id == archive_id, runs.c.printer_id.is_not(None))
  48. .distinct()
  49. )
  50. ).all()
  51. )
  52. except Exception:
  53. logger.exception("Could not look up the printer of archive %s for a live update", archive_id)
  54. return _UNKNOWN_PRINTER, set()
  55. if row is None:
  56. return _UNKNOWN_PRINTER, set()
  57. return row[0], printed_on
  58. class ConnectionManager:
  59. """Manages WebSocket connections and broadcasts."""
  60. def __init__(self):
  61. self.active_connections: list[WebSocket] = []
  62. self._lock = asyncio.Lock()
  63. async def connect(self, websocket: WebSocket):
  64. """Accept a new WebSocket connection."""
  65. await websocket.accept()
  66. async with self._lock:
  67. self.active_connections.append(websocket)
  68. async def disconnect(self, websocket: WebSocket):
  69. """Remove a WebSocket connection."""
  70. async with self._lock:
  71. if websocket in self.active_connections:
  72. self.active_connections.remove(websocket)
  73. async def broadcast(
  74. self,
  75. message: dict[str, Any],
  76. *,
  77. also_printers: set[int] | None = None,
  78. also_message: dict[str, Any] | None = None,
  79. ):
  80. """Broadcast a message to all connected clients. ``also_printers``:
  81. printers besides the message's whose sockets receive it too, as
  82. ``also_message`` when given (one that doesn't name the message's
  83. printer, which may be outside their scope)."""
  84. if not self.active_connections:
  85. return
  86. data = json.dumps(message)
  87. also_data = json.dumps(also_message) if also_message is not None else data
  88. printer_id = _message_printer_id(message)
  89. async with self._lock:
  90. disconnected = []
  91. for connection in self.active_connections:
  92. if _may_receive(connection, printer_id):
  93. text = data
  94. elif any(_may_receive(connection, other) for other in also_printers or ()):
  95. text = also_data
  96. else:
  97. continue
  98. try:
  99. await connection.send_text(text)
  100. except Exception:
  101. disconnected.append(connection)
  102. # Clean up disconnected clients
  103. for conn in disconnected:
  104. if conn in self.active_connections:
  105. self.active_connections.remove(conn)
  106. async def broadcast_to_user(self, user_id: int | None, message: dict[str, Any]):
  107. """Send a message to every connection authenticated as the given user.
  108. When ``user_id`` is None the message fans out to all connections —
  109. this is the auth-disabled single-user path, where neither the queue
  110. item's ``created_by_id`` nor the WS principal is set, and the
  111. existing fan-out semantics are exactly what the user wants.
  112. Per-user routing reads ``websocket.state.bambuddy_principal_user_id``
  113. stamped at connect time (``routes/websocket.py``). Connections
  114. without a stamped id are skipped on the targeted path so an
  115. anonymous reader never receives another user's dispatch toast.
  116. """
  117. if user_id is None:
  118. await self.broadcast(message)
  119. return
  120. if not self.active_connections:
  121. return
  122. data = json.dumps(message)
  123. printer_id = _message_printer_id(message)
  124. async with self._lock:
  125. disconnected = []
  126. for connection in self.active_connections:
  127. conn_uid = getattr(connection.state, "bambuddy_principal_user_id", None)
  128. if conn_uid != user_id or not _may_receive(connection, printer_id):
  129. continue
  130. try:
  131. await connection.send_text(data)
  132. except Exception:
  133. disconnected.append(connection)
  134. for conn in disconnected:
  135. if conn in self.active_connections:
  136. self.active_connections.remove(conn)
  137. async def broadcast_to_users(self, user_ids: set[int], message: dict[str, Any]):
  138. """Send a message to every connection of any of ``user_ids``, as
  139. ``broadcast_to_user`` does for one."""
  140. if not self.active_connections:
  141. return
  142. data = json.dumps(message)
  143. printer_id = _message_printer_id(message)
  144. async with self._lock:
  145. disconnected = []
  146. for connection in self.active_connections:
  147. conn_uid = getattr(connection.state, "bambuddy_principal_user_id", None)
  148. if conn_uid is None or conn_uid not in user_ids or not _may_receive(connection, printer_id):
  149. continue
  150. try:
  151. await connection.send_text(data)
  152. except Exception:
  153. disconnected.append(connection)
  154. for conn in disconnected:
  155. if conn in self.active_connections:
  156. self.active_connections.remove(conn)
  157. async def close_user(self, user_id: int):
  158. """Close a deleted user's sockets, which would otherwise keep receiving
  159. the messages of whoever is given their id next."""
  160. await self._close(lambda c: getattr(c.state, "bambuddy_principal_user_id", None) == user_id)
  161. async def close_signed_in(self):
  162. """Close every signed-in user's socket, after everyone is signed out (a
  163. backup restore). API-key and anonymous sockets stay: nothing ended
  164. theirs."""
  165. await self._close(lambda c: getattr(c.state, "bambuddy_principal_user_id", None) is not None)
  166. async def close_anonymous(self):
  167. """Close the sockets opened while authentication was off, which have
  168. every printer, once it is turned on."""
  169. await self._close(
  170. lambda c: getattr(c.state, "bambuddy_scope_principal", (None, None)) in ((None, None), ("", None))
  171. )
  172. async def close_api_keys(self, key_ids):
  173. """Close the sockets opened with deleted API keys. Their scope is looked
  174. up by key id on every refresh: SQLite gives the id to the next key."""
  175. key_ids = set(key_ids)
  176. await self._close(lambda c: getattr(c.state, "bambuddy_scope_principal", (None, None))[1] in key_ids)
  177. async def _close(self, matches):
  178. async with self._lock:
  179. theirs = [c for c in self.active_connections if matches(c)]
  180. for connection in theirs:
  181. self.active_connections.remove(connection)
  182. for connection in theirs:
  183. try:
  184. await connection.close(code=4401)
  185. except Exception: # noqa: BLE001 -- already gone
  186. pass
  187. async def refresh_printer_scopes(self):
  188. """Recompute every connection's printer scope (#1727).
  189. Called after an admin changes which printers a group may see, or who
  190. is in a group, so open dashboards stop (or start) receiving those
  191. printers' events without a reconnect.
  192. """
  193. from backend.app.core.auth import (
  194. is_auth_enabled,
  195. principal_may_connect,
  196. principal_printer_scope,
  197. usable_api_key_prefix,
  198. )
  199. from backend.app.core.database import async_session
  200. from backend.app.core.printer_scope import ALL_PRINTERS, PrinterScope
  201. async with self._lock:
  202. connections = list(self.active_connections)
  203. if not connections:
  204. return
  205. orphaned = []
  206. try:
  207. async with async_session() as db:
  208. auth_enabled = await is_auth_enabled(db)
  209. for connection in connections:
  210. if not auth_enabled:
  211. connection.state.bambuddy_printer_scope = ALL_PRINTERS
  212. continue
  213. username, api_key_id = getattr(connection.state, "bambuddy_scope_principal", (None, None))
  214. if api_key_id is not None:
  215. # The key is looked up by id, which SQLite gives to the
  216. # next key created: a socket whose key is gone, or whose
  217. # id another key now has, is closed, not re-scoped (a key
  218. # rotated from the command line, which can't close it).
  219. # Disabled or expired counts as gone: REST refuses it.
  220. prefix = await usable_api_key_prefix(db, api_key_id)
  221. if prefix is None or prefix != getattr(connection.state, "bambuddy_api_key_prefix", prefix):
  222. connection.state.bambuddy_printer_scope = PrinterScope(frozenset())
  223. orphaned.append(connection)
  224. continue
  225. if not await principal_may_connect(db, username, api_key_id):
  226. # websocket:connect was taken away (a group or key change)
  227. connection.state.bambuddy_printer_scope = PrinterScope(frozenset())
  228. orphaned.append(connection)
  229. continue
  230. connection.state.bambuddy_printer_scope = await principal_printer_scope(db, username, api_key_id)
  231. except Exception: # SEC-AUTH-EXC: refresh failed → fail closed (empty scope, then disconnect to re-auth)
  232. # The old scopes may be wider than what was just granted, so they
  233. # can't be kept. Drop every socket to no printers and close it with
  234. # the "unauthorised" code: the SPA mints a new token and reconnects,
  235. # and its scope is worked out afresh at connect.
  236. logger.warning("WebSocket printer scope refresh failed; disconnecting clients", exc_info=True)
  237. for connection in connections:
  238. connection.state.bambuddy_printer_scope = PrinterScope(frozenset())
  239. try:
  240. await connection.close(code=4401)
  241. except Exception: # noqa: BLE001 -- already gone; disconnect() cleans it up
  242. pass
  243. return
  244. if orphaned:
  245. await self._close(lambda c: c in orphaned)
  246. async def send_printer_status(self, printer_id: int, status: dict):
  247. """Send printer status update to all clients."""
  248. await self.broadcast(
  249. {
  250. "type": "printer_status",
  251. "printer_id": printer_id,
  252. "data": status,
  253. }
  254. )
  255. async def send_print_start(self, printer_id: int, data: dict):
  256. """Notify clients that a print has started."""
  257. await self.broadcast(
  258. {
  259. "type": "print_start",
  260. "printer_id": printer_id,
  261. "data": data,
  262. }
  263. )
  264. async def send_print_complete(self, printer_id: int, data: dict):
  265. """Notify clients that a print has completed."""
  266. await self.broadcast(
  267. {
  268. "type": "print_complete",
  269. "printer_id": printer_id,
  270. "data": data,
  271. }
  272. )
  273. async def send_print_confirm_request(self, printer_id: int, data: dict, user_ids: set[int] | None = None):
  274. """Ask connected clients for a post-print outcome verdict (#1898).
  275. ``user_ids`` are the users who may answer it; None asks everyone who
  276. can see the printer.
  277. """
  278. message = {"type": "print_confirm_request", "printer_id": printer_id, "data": data}
  279. if user_ids is None:
  280. await self.broadcast(message)
  281. else:
  282. await self.broadcast_to_users(user_ids, message)
  283. async def send_archive_created(self, archive: dict):
  284. """Notify clients that a new archive was created.
  285. Only that one was, on which printer: whose it is and what it holds stays
  286. with the archive routes, which every socket's client asks next anyway.
  287. """
  288. await self.broadcast(
  289. {
  290. "type": "archive_created",
  291. "data": {"printer_id": archive.get("printer_id")},
  292. }
  293. )
  294. async def send_archive_updated(self, archive: dict):
  295. """Notify clients that an archive was updated.
  296. Only sockets that may see the archive's printer receive it: callers send
  297. just the changed fields, so the printer is looked up here when missing.
  298. As for a new archive, the message says no more than that.
  299. """
  300. printer_id, printed_on = await _archive_printer_ids(archive.get("id"))
  301. if isinstance(archive.get("printer_id"), int):
  302. printer_id = archive["printer_id"]
  303. await self.broadcast(
  304. {"type": "archive_updated", "printer_id": printer_id, "data": {"printer_id": printer_id}},
  305. also_printers=printed_on,
  306. # Sockets that see one of its prints only: not the archive's printer.
  307. also_message={"type": "archive_updated", "data": {}},
  308. )
  309. async def send_queue_item_uploading(
  310. self,
  311. user_id: int | None,
  312. queue_item_id: int,
  313. printer_id: int,
  314. printer_name: str | None,
  315. file_name: str,
  316. total_bytes: int,
  317. ):
  318. """Toast trigger: scheduler picked the item up, FTP upload starts."""
  319. await self.broadcast_to_user(
  320. user_id,
  321. {
  322. "type": "queue_item_uploading",
  323. "queue_item_id": queue_item_id,
  324. "printer_id": printer_id,
  325. "printer_name": printer_name,
  326. "file_name": file_name,
  327. "total_bytes": total_bytes,
  328. },
  329. )
  330. async def send_queue_item_upload_progress(
  331. self,
  332. user_id: int | None,
  333. queue_item_id: int,
  334. bytes_transferred: int,
  335. total_bytes: int,
  336. ):
  337. """Toast update: throttled byte-level progress during the FTP upload."""
  338. pct = int(round(100 * bytes_transferred / total_bytes)) if total_bytes else 0
  339. await self.broadcast_to_user(
  340. user_id,
  341. {
  342. "type": "queue_item_upload_progress",
  343. "queue_item_id": queue_item_id,
  344. "bytes_transferred": bytes_transferred,
  345. "total_bytes": total_bytes,
  346. "pct": pct,
  347. },
  348. )
  349. async def send_queue_item_acked(
  350. self,
  351. user_id: int | None,
  352. queue_item_id: int,
  353. printer_id: int,
  354. ):
  355. """Toast trigger: watchdog confirmed the printer transitioned out of pre_state."""
  356. await self.broadcast_to_user(
  357. user_id,
  358. {
  359. "type": "queue_item_acked",
  360. "queue_item_id": queue_item_id,
  361. "printer_id": printer_id,
  362. },
  363. )
  364. async def send_queue_item_failed(
  365. self,
  366. user_id: int | None,
  367. queue_item_id: int,
  368. printer_id: int | None,
  369. reason: str,
  370. ):
  371. """Toast trigger: dispatch failed at any stage. Toast turns red, auto-dismisses."""
  372. await self.broadcast_to_user(
  373. user_id,
  374. {
  375. "type": "queue_item_failed",
  376. "queue_item_id": queue_item_id,
  377. "printer_id": printer_id,
  378. "reason": reason,
  379. },
  380. )
  381. async def send_missing_spool_assignment(
  382. self,
  383. printer_id: int,
  384. printer_name: str,
  385. missing_slots: list[dict[str, str]],
  386. ):
  387. """Notify clients that a print started with missing spool assignments."""
  388. await self.broadcast(
  389. {
  390. "type": "missing_spool_assignment",
  391. "printer_id": printer_id,
  392. "printer_name": printer_name,
  393. "missing_slots": missing_slots,
  394. }
  395. )
  396. # Global connection manager
  397. ws_manager = ConnectionManager()