websocket.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352
  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. class ConnectionManager:
  26. """Manages WebSocket connections and broadcasts."""
  27. def __init__(self):
  28. self.active_connections: list[WebSocket] = []
  29. self._lock = asyncio.Lock()
  30. async def connect(self, websocket: WebSocket):
  31. """Accept a new WebSocket connection."""
  32. await websocket.accept()
  33. async with self._lock:
  34. self.active_connections.append(websocket)
  35. async def disconnect(self, websocket: WebSocket):
  36. """Remove a WebSocket connection."""
  37. async with self._lock:
  38. if websocket in self.active_connections:
  39. self.active_connections.remove(websocket)
  40. async def broadcast(self, message: dict[str, Any]):
  41. """Broadcast a message to all connected clients."""
  42. if not self.active_connections:
  43. return
  44. data = json.dumps(message)
  45. printer_id = _message_printer_id(message)
  46. async with self._lock:
  47. disconnected = []
  48. for connection in self.active_connections:
  49. if not _may_receive(connection, printer_id):
  50. continue
  51. try:
  52. await connection.send_text(data)
  53. except Exception:
  54. disconnected.append(connection)
  55. # Clean up disconnected clients
  56. for conn in disconnected:
  57. if conn in self.active_connections:
  58. self.active_connections.remove(conn)
  59. async def broadcast_to_user(self, user_id: int | None, message: dict[str, Any]):
  60. """Send a message to every connection authenticated as the given user.
  61. When ``user_id`` is None the message fans out to all connections —
  62. this is the auth-disabled single-user path, where neither the queue
  63. item's ``created_by_id`` nor the WS principal is set, and the
  64. existing fan-out semantics are exactly what the user wants.
  65. Per-user routing reads ``websocket.state.bambuddy_principal_user_id``
  66. stamped at connect time (``routes/websocket.py``). Connections
  67. without a stamped id are skipped on the targeted path so an
  68. anonymous reader never receives another user's dispatch toast.
  69. """
  70. if user_id is None:
  71. await self.broadcast(message)
  72. return
  73. if not self.active_connections:
  74. return
  75. data = json.dumps(message)
  76. printer_id = _message_printer_id(message)
  77. async with self._lock:
  78. disconnected = []
  79. for connection in self.active_connections:
  80. conn_uid = getattr(connection.state, "bambuddy_principal_user_id", None)
  81. if conn_uid != user_id or not _may_receive(connection, printer_id):
  82. continue
  83. try:
  84. await connection.send_text(data)
  85. except Exception:
  86. disconnected.append(connection)
  87. for conn in disconnected:
  88. if conn in self.active_connections:
  89. self.active_connections.remove(conn)
  90. async def close_user(self, user_id: int):
  91. """Close a deleted user's sockets, which would otherwise keep receiving
  92. the messages of whoever is given their id next."""
  93. await self._close(lambda c: getattr(c.state, "bambuddy_principal_user_id", None) == user_id)
  94. async def close_signed_in(self):
  95. """Close every signed-in user's socket, after everyone is signed out (a
  96. backup restore). API-key and anonymous sockets stay: nothing ended
  97. theirs."""
  98. await self._close(lambda c: getattr(c.state, "bambuddy_principal_user_id", None) is not None)
  99. async def close_anonymous(self):
  100. """Close the sockets opened while authentication was off, which have
  101. every printer, once it is turned on."""
  102. await self._close(
  103. lambda c: getattr(c.state, "bambuddy_scope_principal", (None, None)) in ((None, None), ("", None))
  104. )
  105. async def close_api_keys(self, key_ids):
  106. """Close the sockets opened with deleted API keys. Their scope is looked
  107. up by key id on every refresh: SQLite gives the id to the next key."""
  108. key_ids = set(key_ids)
  109. await self._close(lambda c: getattr(c.state, "bambuddy_scope_principal", (None, None))[1] in key_ids)
  110. async def _close(self, matches):
  111. async with self._lock:
  112. theirs = [c for c in self.active_connections if matches(c)]
  113. for connection in theirs:
  114. self.active_connections.remove(connection)
  115. for connection in theirs:
  116. try:
  117. await connection.close(code=4401)
  118. except Exception: # noqa: BLE001 -- already gone
  119. pass
  120. async def refresh_printer_scopes(self):
  121. """Recompute every connection's printer scope (#1727).
  122. Called after an admin changes which printers a group may see, or who
  123. is in a group, so open dashboards stop (or start) receiving those
  124. printers' events without a reconnect.
  125. """
  126. from backend.app.core.auth import is_auth_enabled, principal_printer_scope, usable_api_key_prefix
  127. from backend.app.core.database import async_session
  128. from backend.app.core.printer_scope import ALL_PRINTERS, PrinterScope
  129. async with self._lock:
  130. connections = list(self.active_connections)
  131. if not connections:
  132. return
  133. orphaned = []
  134. try:
  135. async with async_session() as db:
  136. auth_enabled = await is_auth_enabled(db)
  137. for connection in connections:
  138. if not auth_enabled:
  139. connection.state.bambuddy_printer_scope = ALL_PRINTERS
  140. continue
  141. username, api_key_id = getattr(connection.state, "bambuddy_scope_principal", (None, None))
  142. if api_key_id is not None:
  143. # The key is looked up by id, which SQLite gives to the
  144. # next key created: a socket whose key is gone, or whose
  145. # id another key now has, is closed, not re-scoped (a key
  146. # rotated from the command line, which can't close it).
  147. # Disabled or expired counts as gone: REST refuses it.
  148. prefix = await usable_api_key_prefix(db, api_key_id)
  149. if prefix is None or prefix != getattr(connection.state, "bambuddy_api_key_prefix", prefix):
  150. connection.state.bambuddy_printer_scope = PrinterScope(frozenset())
  151. orphaned.append(connection)
  152. continue
  153. connection.state.bambuddy_printer_scope = await principal_printer_scope(db, username, api_key_id)
  154. except Exception: # SEC-AUTH-EXC: refresh failed → fail closed (empty scope, then disconnect to re-auth)
  155. # The old scopes may be wider than what was just granted, so they
  156. # can't be kept. Drop every socket to no printers and close it with
  157. # the "unauthorised" code: the SPA mints a new token and reconnects,
  158. # and its scope is worked out afresh at connect.
  159. logger.warning("WebSocket printer scope refresh failed; disconnecting clients", exc_info=True)
  160. for connection in connections:
  161. connection.state.bambuddy_printer_scope = PrinterScope(frozenset())
  162. try:
  163. await connection.close(code=4401)
  164. except Exception: # noqa: BLE001 -- already gone; disconnect() cleans it up
  165. pass
  166. return
  167. if orphaned:
  168. await self._close(lambda c: c in orphaned)
  169. async def send_printer_status(self, printer_id: int, status: dict):
  170. """Send printer status update to all clients."""
  171. await self.broadcast(
  172. {
  173. "type": "printer_status",
  174. "printer_id": printer_id,
  175. "data": status,
  176. }
  177. )
  178. async def send_print_start(self, printer_id: int, data: dict):
  179. """Notify clients that a print has started."""
  180. await self.broadcast(
  181. {
  182. "type": "print_start",
  183. "printer_id": printer_id,
  184. "data": data,
  185. }
  186. )
  187. async def send_print_complete(self, printer_id: int, data: dict):
  188. """Notify clients that a print has completed."""
  189. await self.broadcast(
  190. {
  191. "type": "print_complete",
  192. "printer_id": printer_id,
  193. "data": data,
  194. }
  195. )
  196. async def send_print_confirm_request(self, printer_id: int, data: dict):
  197. """Ask connected clients for a post-print outcome verdict (#1898)."""
  198. await self.broadcast(
  199. {
  200. "type": "print_confirm_request",
  201. "printer_id": printer_id,
  202. "data": data,
  203. }
  204. )
  205. async def send_archive_created(self, archive: dict):
  206. """Notify clients that a new archive was created."""
  207. await self.broadcast(
  208. {
  209. "type": "archive_created",
  210. "data": archive,
  211. }
  212. )
  213. async def send_archive_updated(self, archive: dict):
  214. """Notify clients that an archive was updated."""
  215. await self.broadcast(
  216. {
  217. "type": "archive_updated",
  218. "data": archive,
  219. }
  220. )
  221. async def send_queue_item_uploading(
  222. self,
  223. user_id: int | None,
  224. queue_item_id: int,
  225. printer_id: int,
  226. printer_name: str | None,
  227. file_name: str,
  228. total_bytes: int,
  229. ):
  230. """Toast trigger: scheduler picked the item up, FTP upload starts."""
  231. await self.broadcast_to_user(
  232. user_id,
  233. {
  234. "type": "queue_item_uploading",
  235. "queue_item_id": queue_item_id,
  236. "printer_id": printer_id,
  237. "printer_name": printer_name,
  238. "file_name": file_name,
  239. "total_bytes": total_bytes,
  240. },
  241. )
  242. async def send_queue_item_upload_progress(
  243. self,
  244. user_id: int | None,
  245. queue_item_id: int,
  246. bytes_transferred: int,
  247. total_bytes: int,
  248. ):
  249. """Toast update: throttled byte-level progress during the FTP upload."""
  250. pct = int(round(100 * bytes_transferred / total_bytes)) if total_bytes else 0
  251. await self.broadcast_to_user(
  252. user_id,
  253. {
  254. "type": "queue_item_upload_progress",
  255. "queue_item_id": queue_item_id,
  256. "bytes_transferred": bytes_transferred,
  257. "total_bytes": total_bytes,
  258. "pct": pct,
  259. },
  260. )
  261. async def send_queue_item_acked(
  262. self,
  263. user_id: int | None,
  264. queue_item_id: int,
  265. printer_id: int,
  266. ):
  267. """Toast trigger: watchdog confirmed the printer transitioned out of pre_state."""
  268. await self.broadcast_to_user(
  269. user_id,
  270. {
  271. "type": "queue_item_acked",
  272. "queue_item_id": queue_item_id,
  273. "printer_id": printer_id,
  274. },
  275. )
  276. async def send_queue_item_failed(
  277. self,
  278. user_id: int | None,
  279. queue_item_id: int,
  280. printer_id: int | None,
  281. reason: str,
  282. ):
  283. """Toast trigger: dispatch failed at any stage. Toast turns red, auto-dismisses."""
  284. await self.broadcast_to_user(
  285. user_id,
  286. {
  287. "type": "queue_item_failed",
  288. "queue_item_id": queue_item_id,
  289. "printer_id": printer_id,
  290. "reason": reason,
  291. },
  292. )
  293. async def send_missing_spool_assignment(
  294. self,
  295. printer_id: int,
  296. printer_name: str,
  297. missing_slots: list[dict[str, str]],
  298. ):
  299. """Notify clients that a print started with missing spool assignments."""
  300. await self.broadcast(
  301. {
  302. "type": "missing_spool_assignment",
  303. "printer_id": printer_id,
  304. "printer_name": printer_name,
  305. "missing_slots": missing_slots,
  306. }
  307. )
  308. # Global connection manager
  309. ws_manager = ConnectionManager()