aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
diff options
context:
space:
mode:
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.py190
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))