From 2e9490ca27047ae03e495d397abbe1aec1b2273a Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Thu, 20 Aug 2026 22:21:19 +0200 Subject: feat: unified group management, public groups, and activity-based sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Create Group wizard (Electron-only) consolidates 6 steps across 4 interfaces into a single multi-step page: group creation on hub, node attachment, root selection via folder picker, GEK initialization, and auto-pairing — all in one flow. Browser SPA keeps its current behavior unchanged. Public group support (Option A — GEK for all groups): - All groups have GEK regardless of visibility; open-join groups auto-admit via TOFU when join_policy is "open" - Key rotation blocked for public groups (API guard + UI hidden) - Hub signaling allows WebRTC offers for nodes hosting open-join groups even when the caller isn't a member yet - attach_group writes join_policy to node.toml - Daemon loads GEK for all groups, not just private ones - Known-device path in join_request now auto-admits to open-join groups Node loopback API bridge (Electron IPC): - node:detect, node:call, node:pairing-code IPC handlers in main process - Renderer never sees tokens, paths, or keys (session token = physical access) - platform.js node namespace for UI consumption - Loopback endpoints: roots CRUD, member-upload toggle, reload Bug fixes: - Root change detection: removed premature ctx["roots"] updates from add_root and remove_root that prevented indexer retarget on reload - Duplicate offline message: global fallback now gated on !group - Signaling membership check: fallback to open-join groups for non-members Sidebar groups sorted by last_activity_at (most recent first): - New Group.last_activity_at column with Alembic migration - POST /v1/groups/{id}/activity endpoint, called on connect and chat send - Client-side sort + throttled hub updates (1/min) Co-Authored-By: Claude Opus 4.6 --- packages/meshbay-node/src/meshbay_node/daemon.py | 32 ++-- .../meshbay-node/src/meshbay_node/hub_client.py | 19 ++- packages/meshbay-node/src/meshbay_node/ops.py | 94 ++++++++---- packages/meshbay-node/src/meshbay_node/roots.py | 70 +++++++++ .../src/meshbay_node/transport/webrtc_server.py | 161 +++++---------------- packages/meshbay-node/src/meshbay_node/ui/app.py | 38 +++++ packages/meshbay-node/tests/test_ops.py | 46 +++++- packages/meshbay-node/tests/test_roots.py | 67 ++++++++- packages/meshbay-node/tests/test_roster_pairing.py | 4 + .../meshbay-node/tests/test_webrtc_transport.py | 4 + 10 files changed, 367 insertions(+), 168 deletions(-) (limited to 'packages/meshbay-node') diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py index 7e3ebc1..385a1da 100644 --- a/packages/meshbay-node/src/meshbay_node/daemon.py +++ b/packages/meshbay-node/src/meshbay_node/daemon.py @@ -125,6 +125,7 @@ class NodeDaemon: self._indexers: list[DirectoryIndexer] = [] self._tasks: list[asyncio.Task] = [] self._hub: HubClient | None = None + self._reload_lock = asyncio.Lock() async def run(self) -> None: log.info("MeshBay Node starting up") @@ -225,14 +226,13 @@ class NodeDaemon: ", ".join(str(r.path) for r in roots)) gek = None - if group_cfg.visibility == "private": - gek = await self._load_gek( - group_cfg.id, session.user_id, sk_x_raw, pk_x_raw) - if gek: - log.info("GEK loaded for group %s", group_cfg.id[:8]) - else: - log.info("No GEK yet for group %s — will accept first setup", - group_cfg.name) + gek = await self._load_gek( + group_cfg.id, session.user_id, sk_x_raw, pk_x_raw) + if gek: + log.info("GEK loaded for group %s", group_cfg.id[:8]) + else: + log.info("No GEK yet for group %s — will accept first setup", + group_cfg.name) indexer = DirectoryIndexer( roots=roots, @@ -399,7 +399,7 @@ class NodeDaemon: on_incoming=on_incoming, on_revocation=on_revocation, on_webrtc_offer=on_webrtc_offer, - group_ids=list(groups_ctx.keys()), + group_ids=lambda: list((self._state.get("groups_ctx") or {}).keys()), )) self._tasks.append(ws_task) log.info("Hub WS task started") @@ -470,7 +470,15 @@ class NodeDaemon: Handles root changes on existing groups, hot-loads new groups, and tears down removed groups. Existing connections are untouched: a member watching a film keeps watching it. + + Serialised by _reload_lock: fire-and-forget reloads from config-mutating + endpoints can overlap with the wizard's explicit /api/reload call, + and two concurrent hot-loads of the same group corrupt the runtime state. """ + async with self._reload_lock: + await self._reload_config_inner() + + async def _reload_config_inner(self) -> None: log.info("Reloading config from %s", self._config_path) try: fresh = load_config(self._config_path) @@ -537,7 +545,7 @@ class NodeDaemon: roots.refresh_availability() gek = None - if group_cfg.visibility == "private" and sk_x_raw and pk_x_raw: + if sk_x_raw and pk_x_raw: gek = await self._load_gek( group_cfg.id, node_user_id, sk_x_raw, pk_x_raw) if gek: @@ -616,6 +624,10 @@ class NodeDaemon: log.info("Reload complete — %d re-rooted, %d added, %d removed", changed, len(added_names), len(removed_names)) + if (added_names or removed_names) and self._hub: + gids = list((self._state.get("groups_ctx") or {}).keys()) + await self._hub.update_ws_groups(gids) + async def _login_with_retry(self, hub: HubClient): """Login to hub, retrying if the node key hasn't been linked yet.""" import httpx as _httpx diff --git a/packages/meshbay-node/src/meshbay_node/hub_client.py b/packages/meshbay-node/src/meshbay_node/hub_client.py index b345187..92c39db 100644 --- a/packages/meshbay-node/src/meshbay_node/hub_client.py +++ b/packages/meshbay-node/src/meshbay_node/hub_client.py @@ -229,12 +229,24 @@ class HubClient: except Exception: pass + async def update_ws_groups(self, group_ids: list[str]) -> None: + """Tell the hub about changed group list without dropping the connection.""" + ws = self._ws + if ws: + try: + await ws.send(json.dumps({ + "type": "update_groups", + "group_ids": group_ids, + })) + except Exception: + pass + async def maintain_ws( self, on_incoming: Any = None, on_revocation: Any = None, on_webrtc_offer: Any = None, - group_ids: list[str] | None = None, + group_ids: list[str] | None = None, # static list or callable returning one ) -> None: """ Maintain a persistent WebSocket connection to the hub. @@ -267,8 +279,9 @@ class HubClient: "token": self._session.access_token, "node_id": self._session.node_id, } - if group_ids: - auth_msg["group_ids"] = group_ids + gids = group_ids() if callable(group_ids) else group_ids + if gids: + auth_msg["group_ids"] = gids await ws.send(json.dumps(auth_msg)) # Bounded: a hub that accepts the socket and then says nothing # — which is what it does for a few seconds while restarting — diff --git a/packages/meshbay-node/src/meshbay_node/ops.py b/packages/meshbay-node/src/meshbay_node/ops.py index 2a76290..daddf17 100644 --- a/packages/meshbay-node/src/meshbay_node/ops.py +++ b/packages/meshbay-node/src/meshbay_node/ops.py @@ -157,38 +157,44 @@ async def pair_operator(state: dict) -> dict: return {"code": code, "expires_at": expires, "user_id": user_id} -async def create_invite(state: dict, group_id: str, username: str) -> dict: +async def create_invite(state: dict, group_id: str, username: str, *, + user_id: str = "", + created_by: str = "local-cli") -> dict: """ Issue an invitation code. The hub is asked for the account id and nothing else — never for a key. A hub that answered with the wrong account would produce an invite whose code it never learns, since the code goes to a human out of band. + + When ``user_id`` is supplied directly (MNP path), the hub lookup is skipped. """ roster = _roster(state) _group_ctx(state, group_id) - hub = _hub(state) - try: - account = await hub.get_user_pubkeys(username) - except Exception as e: - raise OpError(f"Unknown user {username!r}: {e}", status=404) from e + if not user_id: + hub = _hub(state) + try: + account = await hub.get_user_pubkeys(username) + except Exception as e: + raise OpError(f"Unknown user {username!r}: {e}", status=404) from e + user_id = account["user_id"] config = state.get("config") ttl = (config.node.invite_ttl_hours if config else 168) * 3600 code = await roster.create_invite( group_id=group_id, - user_id=account["user_id"], + user_id=user_id, role=ROLE_MEMBER, - created_by="local-cli", + created_by=created_by, ttl=ttl, username=username, ) invites = await roster.list_invites() expires = next((i["expires_at"] for i in invites - if i["user_id"] == account["user_id"] + if i["user_id"] == user_id and i["group_id"] == group_id), "") return {"code": code, "expires_at": expires, - "username": username, "user_id": account["user_id"]} + "username": username, "user_id": user_id} async def revoke_member(state: dict, user_id: str, group_id: str) -> dict: @@ -236,6 +242,10 @@ async def set_gek(state: dict, group_id: str, *, rotate: bool = False) -> dict: ctx = _group_ctx(state, group_id) hub = _hub(state) + if rotate and ctx.get("visibility") == "public": + raise OpError( + "Key rotation is not available for public groups", status=400) + bundle_store = state.get("bundle_store") if not bundle_store: raise OpError("Bundle store not available", status=503) @@ -361,20 +371,25 @@ async def attach_group(state: dict, name: str, shared_dir: str, # Appended as text rather than re-serialised: node.toml is hand-written and # full of comments explaining decisions, and a round trip through a TOML # writer would throw all of that away. + join_policy = group.get("join_policy", "invite") block = (f'\n[[groups]]\n' f'id = "{group["id"]}"\n' f'name = "{group["name"]}"\n' - f'visibility = "{group.get("visibility", "private")}"\n') + f'visibility = "{group.get("visibility", "private")}"\n' + f'join_policy = "{join_policy}"\n') + separate_upload = False if upload_dir: - upload_path = Path(upload_dir).expanduser() - try: - upload_path.mkdir(parents=True, exist_ok=True) - except OSError as e: - raise OpError(f"Cannot create {upload_path}: {e}") from e - block += f'upload_dir = "{upload_path}"\n' + upload_path = Path(upload_dir).expanduser().resolve() + if upload_path != path.resolve(): + separate_upload = True + try: + upload_path.mkdir(parents=True, exist_ok=True) + except OSError as e: + raise OpError(f"Cannot create {upload_path}: {e}") from e + block += f'upload_dir = "{upload_path}"\n' block += (f'\n [[groups.roots]]\n' f' path = "{path}"\n') - if not upload_dir: + if not separate_upload: block += f' upload = true\n' try: with conf_path.open("a") as f: @@ -385,7 +400,7 @@ async def attach_group(state: dict, name: str, shared_dir: str, result = {"group_id": group["id"], "name": group["name"], "shared_dir": str(path), "config": str(conf_path), "note": "restart the node to pick it up"} - if upload_dir: + if separate_upload: result["upload_dir"] = str(upload_path) return result @@ -552,9 +567,6 @@ async def add_root(state: dict, group_id: str, path: str, *, cfg.roots.append(RootSpec( path=str(added.path), name=added.name, kind=added.kind, upload=added.upload, direct=added.direct)) - groups_ctx = state.get("groups_ctx", {}) - if group_id in groups_ctx: - groups_ctx[group_id]["roots"] = built log.info("Root added: %s → group %s", added.name, group_id[:8]) return {"status": "added", "name": added.name, "path": str(added.path), @@ -602,15 +614,10 @@ async def remove_root(state: dict, group_id: str, root_name: str) -> dict: built = RootSet.build(remaining) except RootError: built = None - if built is not None: - groups_ctx = state.get("groups_ctx", {}) - if group_id in groups_ctx: - groups_ctx[group_id]["roots"] = built log.info("Root removed: %s from group %s", root_name, group_id[:8]) return {"status": "removed", "name": root_name, "group_id": group_id, - "roots": built.describe() if built else [], - "note": "restart recommended to update the file index"} + "roots": built.describe() if built else []} # ── Files ──────────────────────────────────────────────────────────────────── @@ -682,3 +689,34 @@ async def clear_denylist(state: dict, *, subject: str = "") -> dict: log.warning("Denylist cleared (%s): %d entr(y/ies) removed", subject or "all", removed) return {"status": "cleared", "removed": removed, "subject": subject or "all"} + + +# ── Upload policy ─────────────────────────────────────────────────────────── + +async def set_member_upload(state: dict, group_id: str, allowed: bool) -> dict: + """ + Turn uploading by ordinary members on or off. + + The setting lives on the node (roster.db), not on the hub and not in + node.toml — changing it must not rewrite the operator's config file, + and must not need a restart. + """ + roster = _roster(state) + ctx = _group_ctx(state, group_id) + await roster.set_member_upload(group_id, allowed, + set_by=state.get("node_user_id", "")) + ctx["member_upload"] = allowed + log.info("Upload policy: %s for group %s", "on" if allowed else "off", + group_id[:8]) + return {"allowed": allowed, "group_id": group_id} + + +# ── Reload ────────────────────────────────────────────────────────────────── + +async def reload_config(state: dict) -> dict: + """Hot-reload node.toml without dropping connections.""" + reload_fn = state.get("reload_fn") + if not reload_fn: + raise OpError("Reload not available", status=503) + await reload_fn() + return {"status": "reloaded"} diff --git a/packages/meshbay-node/src/meshbay_node/roots.py b/packages/meshbay-node/src/meshbay_node/roots.py index bc27bf7..74ea2f6 100644 --- a/packages/meshbay-node/src/meshbay_node/roots.py +++ b/packages/meshbay-node/src/meshbay_node/roots.py @@ -29,6 +29,7 @@ are one directory. from __future__ import annotations import logging +import re from dataclasses import dataclass, field from pathlib import Path @@ -38,6 +39,75 @@ log = logging.getLogger(__name__) VALID_KINDS = ("generic", "video", "audio", "photo") +# Filenames and subdirectory names sent by clients. A leading dot is a hidden +# file on every platform, a leading hyphen confuses CLI tools, a leading space +# cannot start one, and a trailing space or dot is refused because it makes two +# different files look identical in a list. +SAFE_UPLOAD_NAME = re.compile( + r"^[^\W_]" # letter or digit — never ‘.’, ‘-’ or space + r"[\w .\-()\[\]'\u2019,&+#@]{0,127}" # body: word chars plus mild punctuation + r"(? str: + """ + `filename`, or the first "name (n).ext" that is not taken. + + Never returns the name of a file that exists, so an upload cannot replace + one — the property the per-user quarantine used to provide (C5a). + """ + if not (directory / filename).exists(): + return filename + stem, dot, ext = filename.rpartition(".") + if not dot: + stem, ext = filename, "" + for n in range(2, 1000): + candidate = f"{stem} ({n}){dot}{ext}" + if not (directory / candidate).exists(): + return candidate + raise FileExistsError(filename) + + +def safe_subdir(roots: "RootSet", rel: str) -> Path | None: + """ + Resolve a client-supplied directory inside one of the group's roots, or refuse. + + The path arrives from the wire, so every part is checked: the first segment + must name a root that is readable right now, each later segment against the + same allowlist as filenames, and the resolved result against that root's + directory. `..`, absolute paths, symlinks pointing out, and anything with a + separator in a segment are all refused here rather than in the caller, so + there is one place to get it right. + + The virtual root itself — `""` — is deliberately **not** resolvable. It is + not a directory on anyone's disk: a file cannot be written there and a + directory cannot be created there, because it belongs to no volume. Callers + that used to receive the shared root for an empty path now receive None, + which is the honest answer. + + The quarantine was the fix for C5a; what actually mattered in it — no + overwrite, a name allowlist, and confinement — is kept by this plus the + caller's existing checks. + """ + found = roots.split(rel or "") + if found is None: + return None + root, tail = found + if not root.available: + return None + parts = [seg for seg in tail.split("/") if seg not in ("", ".")] + if any(seg == ".." or not SAFE_UPLOAD_NAME.match(seg) for seg in parts): + return None + try: + target = (root.path / Path(*parts)).resolve() if parts else root.path.resolve() + base = root.path.resolve() + except OSError: + return None + if target != base and base not in target.parents: + return None + return target + class RootError(ValueError): """A root set that cannot be built. The message is shown to the operator.""" 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 1f1f2d2..947d1f9 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py @@ -28,7 +28,6 @@ import hashlib import hmac import logging import os -import re import struct import time from pathlib import Path @@ -86,8 +85,9 @@ from meshbay_common.webcrypto import chunk_key_aes, encrypt_chunk_aes from meshbay_common.protocol import MNP from meshbay_node.indexer import GroupIndex from meshbay_node import ops -from meshbay_node.roots import RootSet, entry_abs_path -from meshbay_node.roster import DEFAULT_INVITE_TTL +from meshbay_node.roots import ( + RootSet, entry_abs_path, SAFE_UPLOAD_NAME, safe_subdir, _free_name, +) log = logging.getLogger(__name__) @@ -135,81 +135,6 @@ JOIN_FAILURE_WINDOW = 600 # seconds # into, back up or empty — rather than a hidden tree of per-user uuids that # nobody could read, or files scattered wherever someone happened to be looking. UPLOAD_DIR_NAME = "uploads" -# Conservative allowlist: also what keeps markup out of filenames, which the node admin -# UI used to render unescaped (finding H2). -# An allowlist, still — C5a and H2 depend on it — but one that does not assume -# the world writes in ASCII. `été.txt` and `rapport (1).pdf` were refused, and -# the second of those is a name _free_name generates itself, so the node was -# rejecting files it had named. `\w` is Unicode here, which admits letters and -# digits of any script while `<`, `>`, `"`, `;`, `/`, `\` and control characters -# stay out. The first character must be a letter or digit, so ".." and dotfiles -# cannot start one, and a trailing space or dot is refused because it makes two -# different files look identical in a list. -SAFE_UPLOAD_NAME = re.compile( - r"^[^\W_]" # letter or digit — never '.', '-' or space - r"[\w .\-()\[\]'\u2019,&+#@]{0,127}" # body: word chars plus mild punctuation - r"(? str: - """ - `filename`, or the first "name (n).ext" that is not taken. - - Never returns the name of a file that exists, so an upload cannot replace - one — the property the per-user quarantine used to provide (C5a). - """ - if not (directory / filename).exists(): - return filename - stem, dot, ext = filename.rpartition(".") - if not dot: - stem, ext = filename, "" - for n in range(2, 1000): - candidate = f"{stem} ({n}){dot}{ext}" - if not (directory / candidate).exists(): - return candidate - raise FileExistsError(filename) - - -def safe_subdir(roots: RootSet, rel: str) -> Path | None: - """ - Resolve a client-supplied directory inside one of the group's roots, or refuse. - - The path arrives from the wire, so every part is checked: the first segment - must name a root that is readable right now, each later segment against the - same allowlist as filenames, and the resolved result against that root's - directory. `..`, absolute paths, symlinks pointing out, and anything with a - separator in a segment are all refused here rather than in the caller, so - there is one place to get it right. - - The virtual root itself — `""` — is deliberately **not** resolvable. It is - not a directory on anyone's disk: a file cannot be written there and a - directory cannot be created there, because it belongs to no volume. Callers - that used to receive the shared root for an empty path now receive None, - which is the honest answer. - - The quarantine was the fix for C5a; what actually mattered in it — no - overwrite, a name allowlist, and confinement — is kept by this plus the - caller's existing checks. - """ - found = roots.split(rel or "") - if found is None: - return None - root, tail = found - if not root.available: - return None - parts = [seg for seg in tail.split("/") if seg not in ("", ".")] - if any(seg == ".." or not SAFE_UPLOAD_NAME.match(seg) for seg in parts): - return None - try: - target = (root.path / Path(*parts)).resolve() if parts else root.path.resolve() - base = root.path.resolve() - except OSError: - return None - if target != base and base not in target.parents: - return None - return target - def _extract_dtls_fingerprint(sdp: str) -> bytes: @@ -985,6 +910,12 @@ class WebRTCPeerSession: # the client is told it has no role on a node it administers. member = (await roster.get_member(group_id, user_id) or await roster.get_member("", user_id)) + if not member and self._group_join_policy(session_group) == "open": + await roster.set_member( + group_id=session_group, user_id=user_id, role=ROLE_MEMBER, + status="active", approved_by="open-join", + ) + member = await roster.get_member(session_group, user_id) await self._join_ok( user_id, pk_x_raw, session_group, role=member["role"] if member else "", @@ -1635,16 +1566,12 @@ class WebRTCPeerSession: self._send({"type": "error", "detail": "Signature verification failed"}) self._audit("admin_auth_failed", f"member_upload:{pending['subject']}") return - roster = self._ctx.get("roster") - if roster is None: - self._send({"type": "error", "detail": "No roster on this node"}) - return - await roster.set_member_upload(self._group_id or "", allowed, - set_by=self._user_id) - # Stored *and* applied. The upload path is synchronous and reads this - # dict; leaving it to the next restart would make the panel say one - # thing while the node did another. - self._group_ctx()["member_upload"] = allowed + try: + await self._run_op( + ops.set_member_upload, self._group_id or "", allowed) + except ops.OpError as e: + self._send({"type": "error", "detail": e.message}) + return self._audit("member_upload", pending["subject"]) # Everyone already connected is told, rather than finding out by having @@ -1928,14 +1855,11 @@ class WebRTCPeerSession: self._audit("admin_auth_failed", f"member_revoke:{user_id[:8]}") return - roster = self._ctx.get("roster") - if roster is None: - self._send({"type": "error", "detail": "Roster not available"}) - return - - group_id = self._group_id or "" - if not await roster.set_status(group_id, user_id, "revoked"): - self._send({"type": "error", "detail": "Not a member of this group"}) + try: + result = await self._run_op( + ops.revoke_member, user_id, self._group_id or "") + except ops.OpError as e: + self._send({"type": "error", "detail": e.message}) return # Anyone connected right now keeps the key they already unwrapped; what @@ -1948,14 +1872,11 @@ class WebRTCPeerSession: except Exception: pass - log.info("Member revoked by %s: user=%s group=%s", - self._user_id[:8], user_id[:8], group_id[:8] or "-") self._audit("member_revoke", user_id) self._send({ "type": MNP.MEMBER_REVOKE_ACK, "v": MNP_VERSION, "user_id": user_id, - "reminder": "they still hold the current group key — rotate it with " - "meshbay-node gek-init", + "reminder": result.get("reminder", ""), }) async def _do_keypair_bundle_delete(self) -> None: @@ -2694,37 +2615,27 @@ class WebRTCPeerSession: self._audit("admin_auth_failed", f"invite_create:{pending['subject'][:16]}") return - roster = self._ctx.get("roster") - if roster is None: - self._send({"type": "error", "detail": "Roster not available"}) + payload = pending["payload"] + try: + result = await self._run_op( + ops.create_invite, + payload["group_id"], + payload.get("username", ""), + user_id=payload["user_id"], + created_by=self._user_id or "", + ) + except ops.OpError as e: + self._send({"type": "error", "detail": e.message}) return - payload = pending["payload"] - code = await roster.create_invite( - group_id=payload["group_id"], - user_id=payload["user_id"], - role=ROLE_MEMBER, - created_by=self._user_id or "", - ttl=self._ctx.get("invite_ttl", DEFAULT_INVITE_TTL), - username=payload.get("username", ""), - ) - invites = await roster.list_invites() - expires = next( - (i["expires_at"] for i in invites - if i["user_id"] == payload["user_id"] - and i["group_id"] == payload["group_id"]), "") - - log.info("Invite created: group=%s user=%s", - payload["group_id"][:8], payload["user_id"][:8]) self._audit("invite_create", f"target={payload['user_id'][:8]}") - # The code exists in the clear exactly here and in the operator's hands. self._send({ "type": MNP.INVITE_RESULT, "v": MNP_VERSION, - "code": code, - "expires_at": expires, - "user_id": payload["user_id"], - "username": payload.get("username", ""), + "code": result["code"], + "expires_at": result["expires_at"], + "user_id": result["user_id"], + "username": result.get("username", ""), }) def _exec_file_delete(self, ctx: dict, file_id: str, entry) -> None: diff --git a/packages/meshbay-node/src/meshbay_node/ui/app.py b/packages/meshbay-node/src/meshbay_node/ui/app.py index a068a8d..b505f25 100644 --- a/packages/meshbay-node/src/meshbay_node/ui/app.py +++ b/packages/meshbay-node/src/meshbay_node/ui/app.py @@ -295,6 +295,44 @@ def create_ui_app(state: dict) -> FastAPI: async def init_gek(group_id: str, rotate: bool = False): return await _op(lambda: ops.set_gek(state, group_id, rotate=rotate)) + # ── Roots management (operator only, localhost) ──────────────────────── + + @app.post("/api/groups/{group_id}/roots") + async def add_root(group_id: str, payload: dict): + result = await _op(lambda: ops.add_root( + state, group_id, + (payload.get("path") or "").strip(), + name=(payload.get("name") or "").strip(), + kind=(payload.get("kind") or "generic").strip(), + upload=bool(payload.get("upload", False)), + )) + reload_fn = state.get("reload_fn") + if reload_fn: + asyncio.ensure_future(reload_fn()) + return result + + @app.delete("/api/groups/{group_id}/roots/{root_name}") + async def remove_root(group_id: str, root_name: str): + result = await _op(lambda: ops.remove_root(state, group_id, root_name)) + reload_fn = state.get("reload_fn") + if reload_fn: + asyncio.ensure_future(reload_fn()) + return result + + # ── Upload toggle (operator only, localhost) ───────────────────────── + + @app.put("/api/groups/{group_id}/member-upload") + async def set_member_upload(group_id: str, payload: dict): + return await _op(lambda: ops.set_member_upload( + state, group_id, bool(payload.get("allowed", False)), + )) + + # ── Reload config ──────────────────────────────────────────────────── + + @app.post("/api/reload") + async def reload_config(): + return await _op(lambda: ops.reload_config(state)) + # ── Chat endpoints ─────────────────────────────────────────────────────── _chat_subscribers: list[WebSocket] = [] diff --git a/packages/meshbay-node/tests/test_ops.py b/packages/meshbay-node/tests/test_ops.py index f7fd259..d2ccc0d 100644 --- a/packages/meshbay-node/tests/test_ops.py +++ b/packages/meshbay-node/tests/test_ops.py @@ -63,7 +63,9 @@ def test_the_http_adapter_adds_no_logic(): source = inspect.getsource(ui) # Every endpoint that performs an operation routes through _op(...). for endpoint in ("operator_pair", "create_invite", "revoke_member", - "unpin_member", "init_gek", "attach_group", "delete_file"): + "unpin_member", "init_gek", "attach_group", "delete_file", + "add_root", "remove_root", "set_member_upload", + "reload_config"): start = source.index(f"async def {endpoint}(") body = source[start:start + 700] assert "_op(" in body.split("\n\n")[0] + body, ( @@ -177,3 +179,45 @@ async def test_an_unhosted_group_offers_what_it_does_host(tmp_path): await ops.delete_file(state, "z" * 32, "a" * 64) assert exc.value.status == 404 assert exc.value.extra.get("available") + + +# ── Upload policy (set_member_upload) ─────────────────────────────────────── + +async def test_set_member_upload_toggles_and_persists(tmp_path): + from meshbay_node.roster import Roster + state = _state(tmp_path) + roster = Roster(db_path=tmp_path / "roster.db") + await roster.open() + state["roster"] = roster + state["node_user_id"] = "operator" + + out = await ops.set_member_upload(state, "g" * 32, True) + + assert out["allowed"] is True + assert state["groups_ctx"]["g" * 32]["member_upload"] is True + + out2 = await ops.set_member_upload(state, "g" * 32, False) + + assert out2["allowed"] is False + assert state["groups_ctx"]["g" * 32]["member_upload"] is False + + +# ── Reload ────────────────────────────────────────────────────────────────── + +async def test_reload_config_calls_reload_fn(tmp_path): + state = _state(tmp_path) + called = [] + async def fake_reload(): + called.append(True) + state["reload_fn"] = fake_reload + + out = await ops.reload_config(state) + + assert out["status"] == "reloaded" + assert called + + +async def test_reload_config_without_fn_is_refused(tmp_path): + state = _state(tmp_path) + with pytest.raises(ops.OpError, match="Reload not available"): + await ops.reload_config(state) diff --git a/packages/meshbay-node/tests/test_roots.py b/packages/meshbay-node/tests/test_roots.py index ea4ba6a..fc5bd64 100644 --- a/packages/meshbay-node/tests/test_roots.py +++ b/packages/meshbay-node/tests/test_roots.py @@ -11,7 +11,10 @@ from pathlib import Path import pytest -from meshbay_node.roots import Root, RootError, RootSet, entry_abs_path +from meshbay_node.roots import ( + Root, RootError, RootSet, entry_abs_path, + SAFE_UPLOAD_NAME, safe_subdir, _free_name, +) from meshbay_common.protocol import IndexEntry @@ -240,3 +243,65 @@ def test_describe_reports_what_a_member_needs(tmp_path): # Deliberately no paths: a member is told what exists and whether it is # readable, not where on the operator's disk it lives. assert not any("path" in d for d in described) + + +# ── SAFE_UPLOAD_NAME ──────────────────────────────────────────────────────── + +def test_safe_name_accepts_unicode_letters(): + assert SAFE_UPLOAD_NAME.match("rapport (1).pdf") + assert SAFE_UPLOAD_NAME.match("hello.txt") + + +def test_safe_name_rejects_dotfiles(): + assert not SAFE_UPLOAD_NAME.match(".hidden") + assert not SAFE_UPLOAD_NAME.match("..secret") + + +def test_safe_name_rejects_trailing_dot_or_space(): + assert not SAFE_UPLOAD_NAME.match("file.") + assert not SAFE_UPLOAD_NAME.match("file ") + + +# ── _free_name ────────────────────────────────────────────────────────────── + +def test_free_name_returns_original_when_not_taken(tmp_path): + assert _free_name(tmp_path, "photo.jpg") == "photo.jpg" + + +def test_free_name_appends_counter_on_collision(tmp_path): + (tmp_path / "photo.jpg").write_text("x") + assert _free_name(tmp_path, "photo.jpg") == "photo (2).jpg" + + +def test_free_name_increments_past_multiple_collisions(tmp_path): + (tmp_path / "photo.jpg").write_text("x") + (tmp_path / "photo (2).jpg").write_text("x") + assert _free_name(tmp_path, "photo.jpg") == "photo (3).jpg" + + +# ── safe_subdir ───────────────────────────────────────────────────────────── + +def test_safe_subdir_resolves_valid_path(tmp_path): + (tmp_path / "Films").mkdir() + (tmp_path / "Films" / "2024").mkdir() + roots = RootSet.build([_spec(tmp_path / "Films")]) + assert safe_subdir(roots, "Films/2024") == (tmp_path / "Films" / "2024").resolve() + + +def test_safe_subdir_refuses_traversal(tmp_path): + (tmp_path / "Films").mkdir() + roots = RootSet.build([_spec(tmp_path / "Films")]) + assert safe_subdir(roots, "Films/../../etc") is None + + +def test_safe_subdir_refuses_empty_virtual_root(tmp_path): + (tmp_path / "Films").mkdir() + roots = RootSet.build([_spec(tmp_path / "Films")]) + assert safe_subdir(roots, "") is None + + +def test_safe_subdir_refuses_unavailable_root(tmp_path): + (tmp_path / "Films").mkdir() + roots = RootSet.build([_spec(tmp_path / "Films")]) + roots.roots[0].available = False + assert safe_subdir(roots, "Films/2024") is None diff --git a/packages/meshbay-node/tests/test_roster_pairing.py b/packages/meshbay-node/tests/test_roster_pairing.py index 61d53ac..7eb436c 100644 --- a/packages/meshbay-node/tests/test_roster_pairing.py +++ b/packages/meshbay-node/tests/test_roster_pairing.py @@ -91,6 +91,10 @@ def _session(tmp_path: Path, roster, user_id: str = "grenet", session.sent = [] session._send = session.sent.append session._audit = lambda *a, **k: None + session._ctx["daemon_state"] = { + "roster": roster, + "groups_ctx": session._ctx.get("groups", {}), + } return session diff --git a/packages/meshbay-node/tests/test_webrtc_transport.py b/packages/meshbay-node/tests/test_webrtc_transport.py index 5727ef9..ec6e987 100644 --- a/packages/meshbay-node/tests/test_webrtc_transport.py +++ b/packages/meshbay-node/tests/test_webrtc_transport.py @@ -1134,6 +1134,10 @@ async def test_invite_then_join_delivers_the_gek(sk_node, sk_hub, gek, shared_di transport._ctx["groups"] = { TEST_GROUP: {"gek": gek, "roots": shared_dir, "index": indexer.index}, } + transport._ctx["daemon_state"] = { + "roster": roster, + "groups_ctx": transport._ctx["groups"], + } # A paired operator, as `meshbay-node operator pair` would have left it. sk_admin = Ed25519PrivateKey.generate() -- cgit v1.2.3