diff options
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/transport')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py | 76 |
1 files changed, 75 insertions, 1 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 6d21175..b4db051 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py @@ -73,6 +73,7 @@ from meshbay_common.adminop import ( OP_MUSICBRAINZ_CONFIG, OP_MUSICBRAINZ_ENABLED, OP_AUDIO_ROOT, + OP_PHOTO_ROOTS, OP_ROOT_ADD, OP_ROOT_REMOVE, OP_GROUP_ATTACH, @@ -401,6 +402,8 @@ class WebRTCPeerSession: self._do_video_root(msg) elif mtype == MNP.AUDIO_ROOT: self._do_audio_root(msg) + elif mtype == MNP.PHOTO_ROOTS: + self._do_photo_roots(msg) elif mtype == MNP.MEDIA_META_REQ: self._spawn(self._do_media_meta_request(msg)) elif mtype == MNP.SEASON_META_REQ: @@ -692,6 +695,11 @@ class WebRTCPeerSession: # group — same shape as video_root above, "" means unset (the # Music tab shows nothing yet). "audio_root": self._group_ctx().get("audio_root") or "", + # Which folder(s) the Photos app treats as its entry points for + # this group — a *list*, unlike video_root/audio_root above + # (docs/photos.md §2.1). Empty means unset (the Photos tab shows + # nothing yet). + "photo_roots": list(self._group_ctx().get("photo_roots") or []), # 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 @@ -1655,7 +1663,7 @@ class WebRTCPeerSession: # network calls (TMDB, MusicBrainz) once enabled, so an operator opts a # group in explicitly rather than getting it for free # (docs/mediacenter.md §5.6, docs/musicbay.md §4.4). - ALLOWED_APPS = frozenset({"chat", "files", "video", "music"}) + ALLOWED_APPS = frozenset({"chat", "files", "video", "music", "photo"}) def _do_apps_enabled(self, msg: dict) -> None: """ @@ -1901,6 +1909,69 @@ class WebRTCPeerSession: except Exception: pass + def _do_photo_roots(self, msg: dict) -> None: + """ + Which folder(s) the Photos app treats as its entry points for this + group (docs/photos.md §2.1) — a *set*, replaced whole in one signed + op, same shape as apps_enabled rather than one op per root the way + video_root/audio_root are single values. + + An empty list is always accepted (nothing configured yet, today's + "Photos shows nothing" state). Every non-empty path must resolve to + a real, currently-readable directory, and no root may be nested + inside another in the same submitted set — both checked, and + refused, before a signature is ever asked for, same principle as + video_root's path check and apps_enabled's "empty set refused up + front". + """ + roots = msg.get("roots") + if not isinstance(roots, list) or not all(isinstance(r, str) for r in roots): + self._send({"type": "error", "detail": "Missing or invalid 'roots'"}) + return + roots = sorted({r.strip("/") for r in roots if r.strip("/")}) + ctx = self._group_ctx() + for path in roots: + resolved = ctx["roots"].resolve(path) if ctx.get("roots") else None + if not resolved or not resolved.is_dir(): + self._send({"type": "error", + "detail": f"Not a directory in this group: {path}"}) + return + # Case-insensitive nesting check (§6.8) — a root may not be a folder + # itself sitting inside another root in the same set. + folded = [r.casefold() for r in roots] + for i, a in enumerate(folded): + for j, b in enumerate(folded): + if i != j and (a == b or a.startswith(b + "/")): + self._send({"type": "error", + "detail": f"Root nested inside another: {roots[i]}"}) + return + if not self._has_admin_authority(): + self._send({"type": "error", "detail": "No authorized key for this"}) + return + self._issue_admin_challenge(OP_PHOTO_ROOTS, ",".join(roots)) + + async def _admin_exec_photo_roots( + self, pending: dict, transcript: bytes, sig: bytes, + ) -> None: + roots = pending["subject"].split(",") if pending["subject"] else [] + if not await self._verify_admin_sig(transcript, sig): + self._send({"type": "error", "detail": "Signature verification failed"}) + self._audit("admin_auth_failed", f"photo_roots:{pending['subject']}") + return + try: + await self._run_op(ops.set_photo_roots, self._group_id or "", roots) + except ops.OpError as e: + self._send({"type": "error", "detail": e.message}) + return + self._audit("photo_roots", pending["subject"]) + + notice = {"type": MNP.PHOTO_ROOTS_ACK, "v": MNP_VERSION, "roots": roots} + for uid, session in list(self._peer_registry().items()): + try: + session._send(notice) + except Exception: + pass + def _do_musicbrainz_config(self, msg: dict) -> None: """ Set (or clear) the node-wide MusicBrainz User-Agent contact string @@ -3603,6 +3674,9 @@ class WebRTCPeerSession: elif pending["op"] == OP_AUDIO_ROOT: self._spawn( self._admin_exec_audio_root(pending, transcript, sig_bytes)) + elif pending["op"] == OP_PHOTO_ROOTS: + self._spawn( + self._admin_exec_photo_roots(pending, transcript, sig_bytes)) elif pending["op"] == OP_ROOT_ADD: self._spawn( self._admin_exec_root_add(pending, transcript, sig_bytes)) |