diff options
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py | 190 |
1 files changed, 190 insertions, 0 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py index b99affc..c4d053e 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py @@ -76,6 +76,9 @@ from meshbay_common.adminop import ( OP_MUSICBRAINZ_ENABLED, OP_AUDIO_ROOT, OP_PHOTO_ROOTS, + OP_APP_DIRECTORIES, + OP_CHAT_DIRECTORY, + OP_CHAT_LINK_PREVIEW, OP_ROOT_ADD, OP_ROOT_REMOVE, OP_ROOT_UPDATE, @@ -484,6 +487,12 @@ class WebRTCPeerSession: self._do_audio_root(msg) elif mtype == MNP.PHOTO_ROOTS: self._do_photo_roots(msg) + elif mtype == MNP.APP_DIRECTORIES: + self._do_app_directories(msg) + elif mtype == MNP.CHAT_DIRECTORY: + self._do_chat_directory(msg) + elif mtype == MNP.CHAT_LINK_PREVIEW: + self._do_chat_link_preview(msg) elif mtype == MNP.MEDIA_META_REQ: self._spawn(self._do_media_meta_request(msg)) elif mtype == MNP.SEASON_META_REQ: @@ -802,6 +811,23 @@ class WebRTCPeerSession: # (docs/photos.md §2.1). Empty means unset (the Photos tab shows # nothing yet). "photo_roots": list(self._group_ctx().get("photo_roots") or []), + # The same three answers in one shape, plus every other app's — + # `<app>_directories`, keyed by the app's registry name, always a + # list. The scalars above are derived from these (daemon.py's + # `_app_directories_ctx`) and kept for MNP 1.0 clients, which can + # represent one folder and never more. A client reading the plural + # form gets all of them. + **{f"{app}_directories": + list(self._group_ctx().get(f"{app}_directories") or []) + for app in ("video", "music", "photo", "chat")}, + # Where chat attachments are written — the singular form, because + # Chat genuinely has one destination. "" means the operator has not + # chosen, and the paperclip says so. + "chat_directory": self._group_ctx().get("chat_directory") or "", + # Whether the node unfurls links members post here. Absent means + # on, which is what it did before this existed. + "chat_link_preview": bool( + self._group_ctx().get("chat_link_preview", True)), # So a client that connects mid-scan shows the indexing state # immediately, instead of waiting for the next periodic # INDEX_PROGRESS push. Never a path or filename — see @@ -2096,6 +2122,150 @@ class WebRTCPeerSession: except Exception: pass + # ── App directories (generic) ──────────────────────────────────────── + + def _do_app_directories(self, msg: dict) -> None: + """ + Which folder(s) an application works over, for any application. + + One handler where there were three near-identical ones (`video_root`, + `audio_root`, `photo_roots`) differing only in the key they wrote and + whether they carried a string or a list. Those three still exist for + clients that speak them; nothing new is added beside them. + + `app` must be one this node knows (`ALLOWED_APPS`) — a client-supplied + key is otherwise a way to write arbitrary rows into `group_settings`. + The paths are checked by `ops._validate_app_dirs`, which runs after the + signature: this is a settings change, not a capability, so refusing + early here would be a courtesy rather than the control. + """ + app = str(msg.get("app", "")).strip() + dirs = msg.get("directories") + if app not in self.ALLOWED_APPS: + self._send({"type": "error", "detail": f"Unknown app {app!r}"}) + return + if not isinstance(dirs, list) or not all(isinstance(d, str) for d in dirs): + self._send({"type": "error", + "detail": "Missing or invalid 'directories'"}) + return + clean = sorted({d.strip("/") for d in dirs if d.strip("/")}) + if not self._has_admin_authority(): + self._send({"type": "error", "detail": "No authorized key for this"}) + return + # The app is in the subject, not only the paths: an operator shown + # "Media/Films" alone cannot tell which application is about to be + # pointed at it, and two apps' challenges would be indistinguishable. + self._issue_admin_challenge( + OP_APP_DIRECTORIES, f"{app}:{','.join(clean)}") + + async def _admin_exec_app_directories( + self, pending: dict, transcript: bytes, sig: bytes, + ) -> None: + app, _, joined = pending["subject"].partition(":") + dirs = joined.split(",") if joined else [] + if not await self._verify_admin_sig(transcript, sig): + self._send({"type": "error", "detail": "Signature verification failed"}) + self._audit("admin_auth_failed", f"app_directories:{pending['subject']}") + return + try: + result = await self._run_op( + ops.set_app_directories, self._group_id or "", app, dirs) + except ops.OpError as e: + self._send({"type": "error", "detail": e.message}) + return + self._audit("app_directories", pending["subject"]) + self._broadcast_to_group({"type": MNP.APP_DIRECTORIES_ACK, + "v": MNP_VERSION, "app": app, + "directories": result["directories"]}) + + # ── Chat ───────────────────────────────────────────────────────────── + + def _do_chat_directory(self, msg: dict) -> None: + """ + Where chat attachments are written. + + Unlike every other app directory this one is a destination, so it has + to be on a read-write root — checked by `ops.set_chat_directory` after + the signature, which is where the refusal actually lives. + """ + path = msg.get("path") + if not isinstance(path, str): + self._send({"type": "error", "detail": "Missing or invalid 'path'"}) + return + path = path.strip("/") + if not self._has_admin_authority(): + self._send({"type": "error", "detail": "No authorized key for this"}) + return + self._issue_admin_challenge(OP_CHAT_DIRECTORY, path) + + async def _admin_exec_chat_directory( + self, pending: dict, transcript: bytes, sig: bytes, + ) -> None: + path = pending["subject"] + if not await self._verify_admin_sig(transcript, sig): + self._send({"type": "error", "detail": "Signature verification failed"}) + self._audit("admin_auth_failed", f"chat_directory:{path}") + return + try: + await self._run_op( + ops.set_chat_directory, self._group_id or "", path) + except ops.OpError as e: + self._send({"type": "error", "detail": e.message}) + return + self._audit("chat_directory", path) + self._broadcast_to_group( + {"type": MNP.CHAT_DIRECTORY_ACK, "v": MNP_VERSION, "path": path}) + + def _do_chat_link_preview(self, msg: dict) -> None: + """ + Whether the node fetches a page's title and image when a member posts + a link — outbound traffic on the operator's connection, from a message + they did not write, so it is signed like everything else that decides + what leaves this machine. + """ + enabled = msg.get("enabled") + if not isinstance(enabled, bool): + self._send({"type": "error", "detail": "Missing or invalid 'enabled'"}) + return + if not self._has_admin_authority(): + self._send({"type": "error", "detail": "No authorized key for this"}) + return + self._issue_admin_challenge( + OP_CHAT_LINK_PREVIEW, "on" if enabled else "off") + + async def _admin_exec_chat_link_preview( + self, pending: dict, transcript: bytes, sig: bytes, + ) -> None: + enabled = pending["subject"] == "on" + if not await self._verify_admin_sig(transcript, sig): + self._send({"type": "error", "detail": "Signature verification failed"}) + self._audit("admin_auth_failed", + f"chat_link_preview:{pending['subject']}") + return + try: + await self._run_op( + ops.set_chat_link_preview, self._group_id or "", enabled) + except ops.OpError as e: + self._send({"type": "error", "detail": e.message}) + return + self._audit("chat_link_preview", pending["subject"]) + self._broadcast_to_group({"type": MNP.CHAT_LINK_PREVIEW_ACK, + "v": MNP_VERSION, "enabled": enabled}) + + def _broadcast_to_group(self, notice: dict) -> None: + """ + Tell everyone connected to this group about a setting that changed. + + Enforcement never depends on this reaching them — the node is what + refuses — but a control that stays on screen until the next + reconnection is a control people use. + """ + for _uid, session in list(self._peer_registry().items()): + try: + session._send(notice) + except Exception: + pass + def _do_musicbrainz_enabled(self, msg: dict) -> None: """ Whether MusicBrainz lookups run for this group at all. Per-group @@ -3734,6 +3904,17 @@ class WebRTCPeerSession: """ url = msg.get("url") key = url if isinstance(url, str) else "" + + # Checked before the cache, not after: the operator turning previews + # off has to stop serving the ones already fetched too, or the setting + # takes effect only for links nobody has posted yet. Refused as an + # ordinary miss — the client shows the bare link, which is exactly what + # "no preview" looks like for a page that has none. + if not self._group_ctx().get("chat_link_preview", True): + self._send({"type": MNP.LINK_PREVIEW_RESP, "v": MNP_VERSION, + "url": key, "ok": False}) + return + cached = _link_preview_cache_get(key) if cached is not None: self._send({**cached, "type": MNP.LINK_PREVIEW_RESP, "v": MNP_VERSION}) @@ -4155,6 +4336,15 @@ class WebRTCPeerSession: elif pending["op"] == OP_ROOT_REMOVE: self._spawn( self._admin_exec_root_remove(pending, transcript, sig_bytes)) + elif pending["op"] == OP_APP_DIRECTORIES: + self._spawn( + self._admin_exec_app_directories(pending, transcript, sig_bytes)) + elif pending["op"] == OP_CHAT_DIRECTORY: + self._spawn( + self._admin_exec_chat_directory(pending, transcript, sig_bytes)) + elif pending["op"] == OP_CHAT_LINK_PREVIEW: + self._spawn( + self._admin_exec_chat_link_preview(pending, transcript, sig_bytes)) elif pending["op"] == OP_ROOT_UPDATE: self._spawn( self._admin_exec_root_update(pending, transcript, sig_bytes)) |