websocket.py 15 KB

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