From e76e27868b30a2b00b1ba42dd8e7ee6071e0c0d7 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Sun, 6 Sep 2026 16:05:39 +0200 Subject: feat: groups refactor Phase 1 — root RO/RW model + shared directories UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the upload boolean with per-root writable/removable/ejected flags. Backend: new ops (update_root, eject_root, plug_root), MNP 1.1 protocol messages, live RootSet updates so API always reflects current state, CLI root subcommand (add/remove/set/list/eject/plug). Frontend: SharedDirectoriesTable with optimistic toggle switches, eject/plug in Files and Settings, upload gated on root.writable, ejected-root filtering in all media apps, updated Create Group wizard, 10-locale i18n. Co-Authored-By: Claude Opus 4.6 --- packages/meshbay-node/src/meshbay_node/config.py | 42 ++- packages/meshbay-node/src/meshbay_node/daemon.py | 185 ++++++++++++- .../src/meshbay_node/indexer/indexer.py | 38 +++ packages/meshbay-node/src/meshbay_node/ops.py | 293 +++++++++++++++++---- packages/meshbay-node/src/meshbay_node/roots.py | 62 ++--- packages/meshbay-node/src/meshbay_node/roster.py | 14 +- .../src/meshbay_node/transport/webrtc_server.py | 276 ++++++++++++++----- packages/meshbay-node/src/meshbay_node/ui/app.py | 32 ++- 8 files changed, 745 insertions(+), 197 deletions(-) (limited to 'packages/meshbay-node/src/meshbay_node') diff --git a/packages/meshbay-node/src/meshbay_node/config.py b/packages/meshbay-node/src/meshbay_node/config.py index 0e5a7de..4712312 100644 --- a/packages/meshbay-node/src/meshbay_node/config.py +++ b/packages/meshbay-node/src/meshbay_node/config.py @@ -89,25 +89,20 @@ transcode_incompatible_video = true # A root's name is the directory's basename, and it becomes the first segment of # every path members see: /home/user/Media appears to everyone as "Media/". # Two roots cannot share a name (compared without regard to case), and no root -# may sit inside another. Exactly one root receives uploads. +# may sit inside another. A writable root accepts uploads from group members. [[groups]] id = "" # set after joining name = "My Media" quic_port = 19010 [[groups.roots]] - path = "/home/user/Media" - upload = true + path = "/home/user/Media" + writable = true [[groups.roots]] - path = "/run/media/user/USB/Musique" # an external drive is fine: if it is - kind = "audio" # unplugged the root goes unavailable - # and its files stay in the index, - # rather than looking deleted - -# upload_dir: a separate directory for uploads. Files land directly in it, -# not in an "uploads" subdirectory. It appears as its own root in the index. -# upload_dir = "/home/user/Incoming" + path = "/run/media/user/USB/Musique" + kind = "audio" + removable = true # eject before unplugging # The single-directory form still works and means the same thing — one root, # named after the directory, receiving uploads. @@ -187,7 +182,8 @@ class RootSpec: path: str = "" name: str = "" # empty → the directory's basename, derived at load kind: str = "generic" # generic|video|audio|photo — a view hint, unused for now - upload: bool = False # exactly one root per group receives uploads + writable: bool = False # RW roots accept uploads from group members + removable: bool = False # operator can eject this root before unplugging the device direct: bool = False # uploads land at root path, not in a subdirectory @@ -201,7 +197,7 @@ class GroupConfig: # unprefixed shape. roots: list[RootSpec] = field(default_factory=list) shared_dir: str = "" # legacy single-root form, migrated at load - upload_dir: str = "" # separate filesystem path for uploads + upload_dir: str = "" # legacy — migrated to a writable root visibility: str = "private" # public|private — discoverability, not admission # Admission. "invite" (default) means a newcomer needs a one-time pairing code # before the node wraps the group key for them; "open" means the node pins @@ -224,12 +220,12 @@ class GroupConfig: which reads like configuration rather than a bug. """ if not self.roots and self.shared_dir.strip(): - self.roots = [RootSpec(path=self.shared_dir.strip(), upload=True)] + self.roots = [RootSpec(path=self.shared_dir.strip(), writable=True)] if self.upload_dir.strip(): for r in self.roots: - r.upload = False + r.writable = False self.roots.append(RootSpec( - path=self.upload_dir.strip(), upload=True, direct=True)) + path=self.upload_dir.strip(), writable=True, direct=True)) @dataclass @@ -285,15 +281,17 @@ def _read_roots(group: dict) -> list[RootSpec]: than merged: which one receives uploads would be a guess, and a wrong guess is discovered weeks later. """ - specs = [ - RootSpec( + specs = [] + for r in group.get("roots", []) or []: + # Backward compat: old configs have `upload = true` instead of `writable` + writable = bool(r.get("writable", r.get("upload", False))) + specs.append(RootSpec( path=str(r.get("path", "")), name=str(r.get("name", "")), kind=str(r.get("kind", "generic")), - upload=bool(r.get("upload", False)), - ) - for r in group.get("roots", []) or [] - ] + writable=writable, + removable=bool(r.get("removable", False)), + )) legacy = str(group.get("shared_dir", "") or "").strip() if specs and legacy: log.warning( diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py index ea13680..f45101f 100644 --- a/packages/meshbay-node/src/meshbay_node/daemon.py +++ b/packages/meshbay-node/src/meshbay_node/daemon.py @@ -1663,15 +1663,17 @@ def main() -> None: parser = argparse.ArgumentParser(description="MeshBay Node daemon") parser.add_argument("command", nargs="?", choices=["init", "reset", "status", "gek-init", - "gek", "operator", "member", "group", "file", - "video", "denylist", "stun", "reload", + "gek", "operator", "member", "group", "root", + "file", "video", "denylist", "stun", "reload", "restart-daemon", "autostart", "service", "calibrate-argon2"], help="init: provision config + keystore | reset: erase all " "node state | status: node state and keys " "| operator pair: pair a " "browser with this node | member list|invite|revoke|unpin " - "| group list|add|remove | gek init|rotate | file list|rm " + "| group list|add|remove " + "| root list|add|remove|set|eject|plug " + "| gek init|rotate | file list|rm " "| video rematch: re-resolve TMDB matches for a group's " "videos | denylist show|clear " "| stun list|add|remove|reset " @@ -1687,7 +1689,9 @@ def main() -> None: "| calibrate-argon2: benchmark") parser.add_argument("subcommand", nargs="?", help="'pair' for operator; list|invite|revoke|unpin for " - "member; list|add|remove for group; init|rotate for gek; " + "member; list|add|remove for group; " + "list|add|remove|set|eject|plug for root; " + "init|rotate for gek; " "list|rm for file; rematch for video; show|clear for " "denylist; list|add|remove|reset for stun; " "install|remove|start|stop|status for autostart and " @@ -1710,14 +1714,29 @@ def main() -> None: help="Config file path") parser.add_argument("--group", default=None, help="group id (optional if only one is configured)") + parser.add_argument("--writable", action="store_true", default=None, + dest="writable", + help="mark root as read-write (root set/add)") + parser.add_argument("--no-writable", action="store_false", + dest="writable", + help="mark root as read-only (root set)") + parser.add_argument("--removable", action="store_true", default=None, + dest="removable", + help="mark root as removable (root set/add)") + parser.add_argument("--no-removable", action="store_false", + dest="removable", + help="mark root as not removable (root set)") + parser.add_argument("--name", default=None, + help="root name (root add; defaults to directory basename)") parser.add_argument("--log-level", default="INFO", choices=["DEBUG", "INFO", "WARNING", "ERROR"]) args = parser.parse_args() # Query commands print a report; library logging would interleave with it. quiet = args.command in ("status", "gek-init", "gek", "operator", - "member", "group", "file", "video", "denylist", - "stun", "reload", "restart-daemon", "reset") + "member", "group", "root", "file", "video", + "denylist", "stun", "reload", "restart-daemon", + "reset") logging.basicConfig( level=logging.ERROR if quiet else getattr(logging, args.log_level), format="%(asctime)s %(levelname)-8s %(name)s: %(message)s", @@ -1961,9 +1980,16 @@ def main() -> None: print(" ") for r in g.roots: label = r.name or Path(r.path).name - flag = " (uploads)" if r.upload else "" + flags = [] + if getattr(r, 'writable', False) or getattr(r, 'upload', False): + flags.append("rw") + else: + flags.append("ro") + if getattr(r, 'removable', False): + flags.append("removable") + flag_str = f" ({', '.join(flags)})" if flags else "" live = "" if Path(r.path).expanduser().is_dir() else " [UNAVAILABLE]" - print(f" {label} → {r.path}{flag}{live}") + print(f" {label} → {r.path}{flag_str}{live}") # Node authority: the roster is the source of truth, node.toml the legacy # form. Read the DB directly so this reports correctly while the daemon is # stopped — the state an operator is most often in when checking. @@ -2408,11 +2434,18 @@ def main() -> None: f"{g.get('peers', 0)} peer(s)") print(f" {g['id']}") for r in g.get("roots", []): - flags = "" - if r.get("upload"): - flags = " (uploads, direct)" if r.get("direct") else " (uploads)" + flags = [] + if r.get("writable"): + flags.append("rw") + else: + flags.append("ro") + if r.get("removable"): + flags.append("removable") + if r.get("ejected"): + flags.append("ejected") + flag_str = f" ({', '.join(flags)})" if flags else "" live = "" if r.get("available", True) else " [UNAVAILABLE]" - print(f" root {r['name']}{flags}{live}") + print(f" root {r['name']}{flag_str}{live}") if not g.get("has_gek"): print(f" give it a key: meshbay-node gek init " f"--group {g['name']}") @@ -2450,10 +2483,18 @@ def main() -> None: cfg = load_config(args.config or DEFAULT_CONFIG_PATH) body = {"name": args.target, "shared_dir": args.dir} if args.upload_dir: + import warnings + warnings.warn( + "--upload-dir is deprecated; the main root is writable by " + "default. Use 'meshbay-node root add' for additional roots.", + DeprecationWarning, stacklevel=1) + print("WARNING: --upload-dir is deprecated. The shared directory is " + "writable by default. Use 'meshbay-node root add' for " + "additional roots.") body["upload_dir"] = args.upload_dir out = _daemon_api(cfg, "/api/groups/attach", method="POST", body=body) print(f"{out['name']} ({out['group_id'][:8]}) added to {out['config']}") - print(f" shared_dir {out['shared_dir']}") + print(f" shared_dir {out['shared_dir']} (writable)") if out.get("upload_dir"): print(f" upload_dir {out['upload_dir']}") print() @@ -2465,6 +2506,124 @@ def main() -> None: print("read it, and joining one says nothing about the other.") return + if args.command == "root": + cfg = load_config(args.config or DEFAULT_CONFIG_PATH) + sub = args.subcommand or "list" + group_id = _resolve_group(cfg, args.group) + + if sub == "list": + out = _daemon_api(cfg, f"/api/groups") + group = next((g for g in out.get("groups", []) + if g["id"] == group_id), None) + if not group: + print(f"group {group_id[:8]} not hosted on this node") + sys.exit(1) + roots = group.get("roots", []) + if not roots: + print("no roots configured") + print(f"add one: meshbay-node root add /path/to/dir --group {group_id}") + return + for r in roots: + flags = [] + if r.get("writable"): + flags.append("rw") + else: + flags.append("ro") + if r.get("removable"): + flags.append("removable") + if r.get("ejected"): + flags.append("EJECTED") + avail = "available" if r.get("available", True) else "UNAVAILABLE" + flags.append(avail) + print(f" {r['name']:<20} {', '.join(flags)}") + print(f" {r.get('path', '?')}") + return + + if sub == "add": + path = args.target + if not path: + print("usage: meshbay-node root add [--name NAME] " + "[--writable] [--removable] [--group NAME]") + sys.exit(1) + body = { + "path": path, + "name": args.name or Path(path).name, + "writable": args.writable if args.writable is not None else True, + "removable": bool(args.removable), + } + _daemon_api(cfg, f"/api/groups/{group_id}/roots", + method="POST", body=body) + w = "rw" if body["writable"] else "ro" + rm = ", removable" if body["removable"] else "" + print(f"added root {body['name']} → {path} ({w}{rm})") + print("reload the daemon to start indexing:") + print(" meshbay-node reload") + return + + if sub == "remove": + name = args.target + if not name: + print("usage: meshbay-node root remove [--group NAME]") + sys.exit(1) + if not args.yes: + print(f"Remove root '{name}' from group {group_id[:8]}?") + print("Files on disk are untouched; only the node config changes.") + if input("remove? [y/N] ").strip().lower() not in ("y", "yes"): + print("cancelled") + return + _daemon_api(cfg, f"/api/groups/{group_id}/roots/{name}", + method="DELETE") + print(f"removed root {name}") + print("reload the daemon to apply:") + print(" meshbay-node reload") + return + + if sub == "set": + name = args.target + if not name: + print("usage: meshbay-node root set " + "[--writable|--no-writable] " + "[--removable|--no-removable] [--group NAME]") + sys.exit(1) + body = {} + if args.writable is not None: + body["writable"] = args.writable + if args.removable is not None: + body["removable"] = args.removable + if not body: + print("nothing to change — pass --writable/--no-writable " + "or --removable/--no-removable") + sys.exit(1) + _daemon_api(cfg, f"/api/groups/{group_id}/roots/{name}", + method="PATCH", body=body) + changes = ", ".join(f"{k}={v}" for k, v in body.items()) + print(f"updated root {name}: {changes}") + return + + if sub == "eject": + name = args.target + if not name: + print("usage: meshbay-node root eject [--group NAME]") + sys.exit(1) + _daemon_api(cfg, f"/api/groups/{group_id}/roots/{name}/eject", + method="PUT") + print(f"ejected root {name} — files are hidden until plugged back") + return + + if sub == "plug": + name = args.target + if not name: + print("usage: meshbay-node root plug [--group NAME]") + sys.exit(1) + _daemon_api(cfg, f"/api/groups/{group_id}/roots/{name}/plug", + method="PUT") + print(f"plugged root {name} — files are visible again") + return + + print("usage: meshbay-node root list|add|remove|set|eject|plug [name] " + "[--group NAME]") + sys.exit(1) + if args.command == "operator": if args.subcommand != "pair": print("usage: meshbay-node operator pair") diff --git a/packages/meshbay-node/src/meshbay_node/indexer/indexer.py b/packages/meshbay-node/src/meshbay_node/indexer/indexer.py index b0a8e50..2911278 100644 --- a/packages/meshbay-node/src/meshbay_node/indexer/indexer.py +++ b/packages/meshbay-node/src/meshbay_node/indexer/indexer.py @@ -723,6 +723,44 @@ class DirectoryIndexer: self._observer = None self._start_observer() + def eject_root(self, root_name: str) -> None: + """Stop watching a root without touching its entries.""" + from meshbay_common.paths import fold + target = fold(root_name) + for root in self.roots: + if fold(root.name) == target: + root.ejected = True + root.available = False + frozen = len(self._entries_under(root)) + log.info("Root %r ejected — %d entries frozen", root.name, frozen) + break + self._restart_observer() + self._index.roots = self.roots.describe() + self._index.version = int(time.time()) + + async def plug_root(self, root_name: str) -> None: + """Restart watching a previously ejected root and reconcile.""" + from meshbay_common.paths import fold + target = fold(root_name) + root = None + for r in self.roots: + if fold(r.name) == target: + root = r + break + if root is None: + return + root.ejected = False + root.available = root.is_live() + if root.available: + log.info("Root %r plugged — rescanning", root.name) + self._drop_root_entries(root) + await self._scan_root(root) + self._restart_observer() + self._index.roots = self.roots.describe() + self._index.version = int(time.time()) + if self.on_change: + await self.on_change(self) + # ── Internal update ─────────────────────────────────────────────────────── def _schedule_update(self, file_path: Path, deleted: bool = False) -> None: diff --git a/packages/meshbay-node/src/meshbay_node/ops.py b/packages/meshbay-node/src/meshbay_node/ops.py index 4c20c2a..c3a3f9c 100644 --- a/packages/meshbay-node/src/meshbay_node/ops.py +++ b/packages/meshbay-node/src/meshbay_node/ops.py @@ -429,20 +429,16 @@ async def attach_group(state: dict, name: str, shared_dir: str, raise OpError(f"Cannot create {path}: {e}") from e conf_path = Path(state.get("config_path") or DEFAULT_CONFIG_PATH) - # 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'join_policy = "{join_policy}"\n') - separate_upload = False + # Legacy: upload_dir becomes a second writable root if upload_dir: 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: @@ -451,9 +447,8 @@ async def attach_group(state: dict, name: str, shared_dir: str, block += (f'\n [[groups.roots]]\n' # Forward slashes: a Windows path in a TOML basic string is a # parse error (`\U`, `\a`, ... are escapes). pathlib reads `/`. - f' path = "{path.as_posix()}"\n') - if not separate_upload: - block += f' upload = true\n' + f' path = "{path.as_posix()}"\n' + f' writable = true\n') try: with conf_path.open("a", encoding="utf-8", newline="\n") as f: f.write(block) @@ -463,8 +458,6 @@ 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 separate_upload: - result["upload_dir"] = str(upload_path) return result @@ -641,7 +634,8 @@ def _remove_roots_block(conf_path: Path, group_id: str, async def add_root(state: dict, group_id: str, path: str, *, name: str = "", kind: str = "generic", - upload: bool = False) -> dict: + writable: bool = False, + removable: bool = False) -> dict: """ Add a directory to a group, refusing anything ambiguous. @@ -655,7 +649,8 @@ async def add_root(state: dict, group_id: str, path: str, *, raise OpError("Group not configured on this node", status=404) specs = [asdict(r) for r in cfg.roots] - specs.append({"path": path, "name": name, "kind": kind, "upload": upload}) + specs.append({"path": path, "name": name, "kind": kind, + "writable": writable, "removable": removable}) try: built = RootSet.build(specs) except RootError as e: @@ -674,14 +669,17 @@ async def add_root(state: dict, group_id: str, path: str, *, root_block += f'\n name = "{added.name}"' if kind != "generic": root_block += f'\n kind = "{added.kind}"' - if upload: - root_block += f'\n upload = true' + if writable: + root_block += f'\n writable = true' + if removable: + root_block += f'\n removable = true' _insert_roots_block(conf_path, group_id, root_block) from meshbay_node.config import RootSpec cfg.roots.append(RootSpec( path=str(added.path), name=added.name, kind=added.kind, - upload=added.upload, direct=added.direct)) + writable=added.writable, removable=added.removable, + direct=added.direct)) log.info("Root added: %s → group %s", added.name, group_id[:8]) return {"status": "added", "name": added.name, "path": str(added.path), @@ -714,25 +712,242 @@ async def remove_root(state: dict, group_id: str, root_name: str) -> dict: raise OpError("Cannot remove the only root", status=400) removed = cfg.roots[match_idx] - if removed.upload: - raise OpError( - "Cannot remove the upload root — file uploads and chat " - "attachments are stored there", status=400) resolved = str(Path(removed.path).expanduser().resolve()) conf_path = Path(state.get("config_path") or DEFAULT_CONFIG_PATH) _remove_roots_block(conf_path, group_id, resolved) cfg.roots.pop(match_idx) - remaining = [asdict(r) for r in cfg.roots] - try: - built = RootSet.build(remaining) - except RootError: - built = None + + # Update the live RootSet so GET /api/groups returns correct data + # immediately, without waiting for the async reload. + live_roots = state.get("groups_ctx", {}).get( + group_id, {}).get("roots") + if live_roots: + live_roots.roots = [ + r for r in live_roots.roots if fold(r.name) != target] + + result_roots = live_roots.describe() if live_roots else [] 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 []} + "roots": result_roots} + + +async def update_root(state: dict, group_id: str, root_name: str, *, + writable: bool | None = None, + removable: bool | None = None) -> dict: + """Toggle writable/removable on an existing root without removing it.""" + config = _config(state) + cfg = next((g for g in config.groups if g.id == group_id), None) + if cfg is None: + raise OpError("Group not configured on this node", status=404) + + from meshbay_common.paths import fold + from meshbay_node.roots import RootSet + target = fold(root_name) + match = None + for r in cfg.roots: + rname = r.name or str(Path(r.path).name) + if fold(rname) == target: + match = r + break + if match is None: + raise OpError(f"No root named {root_name!r} in this group", status=404) + + changed = False + if writable is not None and match.writable != writable: + match.writable = writable + changed = True + if removable is not None and match.removable != removable: + match.removable = removable + changed = True + + if not changed: + specs = [asdict(r) for r in cfg.roots] + built = RootSet.build(specs) + return {"status": "unchanged", "name": root_name, "group_id": group_id, + "roots": built.describe()} + + conf_path = Path(state.get("config_path") or DEFAULT_CONFIG_PATH) + _update_root_field(conf_path, group_id, str(Path(match.path).expanduser().resolve()), + writable=match.writable, removable=match.removable) + + # Update the live RootSet so GET /api/groups returns correct data + # immediately, without waiting for the async reload to finish. + live_roots: RootSet | None = state.get("groups_ctx", {}).get( + group_id, {}).get("roots") + if live_roots: + for lr in live_roots.roots: + lr_name = lr.name or str(Path(lr.path).name) + if fold(lr_name) == target: + if writable is not None: + lr.writable = writable + if removable is not None: + lr.removable = removable + break + + result_roots = live_roots.describe() if live_roots else [] + + log.info("Root updated: %s (writable=%s, removable=%s) in group %s", + root_name, match.writable, match.removable, group_id[:8]) + return {"status": "updated", "name": root_name, "group_id": group_id, + "roots": result_roots} + + +async def eject_root(state: dict, group_id: str, root_name: str) -> dict: + """Mark a removable root as ejected so the operator can safely unplug.""" + config = _config(state) + cfg = next((g for g in config.groups if g.id == group_id), None) + if cfg is None: + raise OpError("Group not configured on this node", status=404) + + from meshbay_common.paths import fold + target = fold(root_name) + ctx = _group_ctx(state, group_id) + roots: RootSet | None = ctx.get("roots") + if not roots: + raise OpError("Group has no roots", status=503) + + root = None + for r in roots: + if fold(r.name) == target: + root = r + break + if root is None: + raise OpError(f"No root named {root_name!r} in this group", status=404) + if not root.removable: + raise OpError(f"Root {root_name!r} is not marked as removable", status=400) + if root.ejected: + return {"status": "already_ejected", "name": root_name, + "group_id": group_id, "roots": roots.describe()} + + root.ejected = True + root.available = False + + roster = _roster(state) + if roster: + await roster.set_setting( + group_id, f"root_ejected:{fold(root_name)}", "1", + set_by=state.get("node_user_id", "")) + + indexer = state.get("indexers", {}).get(group_id) + if indexer: + indexer.eject_root(root_name) + + log.info("Root ejected: %s from group %s", root_name, group_id[:8]) + return {"status": "ejected", "name": root_name, "group_id": group_id, + "roots": roots.describe()} + + +async def plug_root(state: dict, group_id: str, root_name: str) -> dict: + """Re-enable an ejected root after the device is plugged back in.""" + config = _config(state) + cfg = next((g for g in config.groups if g.id == group_id), None) + if cfg is None: + raise OpError("Group not configured on this node", status=404) + + from meshbay_common.paths import fold + target = fold(root_name) + ctx = _group_ctx(state, group_id) + roots: RootSet | None = ctx.get("roots") + if not roots: + raise OpError("Group has no roots", status=503) + + root = None + for r in roots: + if fold(r.name) == target: + root = r + break + if root is None: + raise OpError(f"No root named {root_name!r} in this group", status=404) + if not root.ejected: + return {"status": "already_plugged", "name": root_name, + "group_id": group_id, "roots": roots.describe()} + if not root.is_live(): + raise OpError( + f"Directory not found: {root.path}. Is the device connected?", + status=409) + + root.ejected = False + root.available = True + + roster = _roster(state) + if roster: + await roster.set_setting( + group_id, f"root_ejected:{fold(root_name)}", "0", + set_by=state.get("node_user_id", "")) + + indexer = state.get("indexers", {}).get(group_id) + if indexer: + await indexer.plug_root(root_name) + + log.info("Root plugged: %s in group %s", root_name, group_id[:8]) + return {"status": "plugged", "name": root_name, "group_id": group_id, + "roots": roots.describe()} + + +def _update_root_field(conf_path: Path, group_id: str, + resolved_path: str, *, + writable: bool, removable: bool) -> None: + """Update writable/removable fields on a root in node.toml.""" + text = conf_path.read_text(encoding="utf-8") + lines = text.split("\n") + + rng = _find_group_range(lines, group_id) + if rng is None: + raise OpError(f"Group {group_id[:8]} not found in {conf_path}") + + start, end = rng + path_re = re.compile(r'^\s*path\s*=\s*"([^"]*)"') + writable_re = re.compile(r'^\s*(writable|upload)\s*=') + removable_re = re.compile(r'^\s*removable\s*=') + roots_starts: list[int] = [] + for i in range(start + 1, end): + if lines[i].strip() == "[[groups.roots]]": + roots_starts.append(i) + + for j, rs in enumerate(roots_starts): + rs_end = roots_starts[j + 1] if j + 1 < len(roots_starts) else end + found_path = False + for k in range(rs, rs_end): + m = path_re.match(lines[k]) + if m: + try: + p = str(Path(m.group(1)).expanduser().resolve()) + except OSError: + continue + if p == resolved_path: + found_path = True + break + if not found_path: + continue + + writable_idx = None + removable_idx = None + for k in range(rs, rs_end): + if writable_re.match(lines[k]): + writable_idx = k + if removable_re.match(lines[k]): + removable_idx = k + + if writable_idx is not None: + lines[writable_idx] = f" writable = {'true' if writable else 'false'}" + else: + lines.insert(rs_end, f" writable = {'true' if writable else 'false'}") + if removable_idx is not None and removable_idx >= rs_end: + removable_idx += 1 + rs_end += 1 + + if removable_idx is not None: + lines[removable_idx] = f" removable = {'true' if removable else 'false'}" + else: + lines.insert(rs_end, f" removable = {'true' if removable else 'false'}") + + conf_path.write_text("\n".join(lines), encoding="utf-8", newline="\n") + return + + raise OpError(f"Root path not found in config", status=404) # ── Files ──────────────────────────────────────────────────────────────────── @@ -806,26 +1021,6 @@ async def clear_denylist(state: dict, *, subject: str = "") -> dict: 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} - - # ── Node settings ──────────────────────────────────────────────────────────── async def get_node_settings(state: dict) -> dict: @@ -924,12 +1119,14 @@ async def set_enabled_apps(state: dict, group_id: str, apps: list[str]) -> dict: """ Which group "applications" (Chat, Files, ...) are shown to members. - Same shape as `set_member_upload`: lives on the node (roster.db), takes + Same shape as other signed ops: lives on the node (roster.db), takes effect without a restart, and is signed by the operator (webrtc_server.py checks the caller's own admin-authority allow-list before this runs). """ roster = _roster(state) ctx = _group_ctx(state, group_id) + if "files" not in apps: + apps = ["files"] + list(apps) await roster.set_enabled_apps(group_id, apps, set_by=state.get("node_user_id", "")) ctx["enabled_apps"] = apps @@ -946,7 +1143,7 @@ async def set_tmdb_config(state: dict, token: str | None = None, and in what language it queries TMDB (docs/mediacenter.md §5.5). Node-wide (roster.py group_settings, group_id="") rather than per-group - like set_member_upload/set_enabled_apps: the token and the shared-cache + like set_enabled_apps: the token and the shared-cache language are one operator's budget and one credential, not a per-group or per-viewer concern. Whether TMDB is used *at all* is the per-group decision set_tmdb_enabled below makes instead. `token=""` explicitly @@ -1086,7 +1283,7 @@ async def set_scan_settings(state: dict, group_id: str, reconcile_interval_secs: """ How often the indexer's reconciliation backstop runs, and how long a changed file is left alone before being hashed (indexer.py - DirectoryIndexer). Persisted like set_member_upload/set_enabled_apps — + DirectoryIndexer). Persisted like set_enabled_apps — but there is also a *live* DirectoryIndexer object to update, since it reads these once at construction and runs its own background loop with them rather than consulting groups_ctx on every use. diff --git a/packages/meshbay-node/src/meshbay_node/roots.py b/packages/meshbay-node/src/meshbay_node/roots.py index 74ea2f6..a83b729 100644 --- a/packages/meshbay-node/src/meshbay_node/roots.py +++ b/packages/meshbay-node/src/meshbay_node/roots.py @@ -120,10 +120,10 @@ class Root: name: str path: Path kind: str = "generic" - upload: bool = False + writable: bool = False + removable: bool = False direct: bool = False - # Runtime, not configuration: set by the indexer when the directory can no - # longer be read, and cleared when it comes back. + ejected: bool = False available: bool = True @property @@ -172,8 +172,9 @@ class RootSet: """ Build from configuration, refusing anything ambiguous. - `specs` are dicts with `path`, and optionally `name`, `kind`, `upload`. - Raises RootError with a message meant for an operator reading a log. + `specs` are dicts with `path`, and optionally `name`, `kind`, `writable`, + `removable`. Raises RootError with a message meant for an operator reading + a log. """ roots: list[Root] = [] by_folded: dict[str, Root] = {} @@ -209,34 +210,18 @@ class RootSet: log.warning("root %r: unknown kind %r — using 'generic'", name, kind) kind = "generic" + # Backward compat: old configs use `upload` instead of `writable` + writable = bool(spec.get("writable", spec.get("upload", False))) root = Root(name=name, path=path, kind=kind, - upload=bool(spec.get("upload", False)), + writable=writable, + removable=bool(spec.get("removable", False)), direct=bool(spec.get("direct", False))) _refuse_nesting(root, roots) roots.append(root) by_folded[root.folded] = root - cls._settle_upload_root(roots) return cls(roots=roots) - @staticmethod - def _settle_upload_root(roots: list[Root]) -> None: - """ - Exactly one root receives uploads, and the operator picks it. - - Not guessed when several are marked, because "uploads went somewhere - else" is discovered weeks later. With none marked and a single root, the - answer is not ambiguous, so it is taken. - """ - marked = [r for r in roots if r.upload] - if len(marked) > 1: - names = ", ".join(r.name for r in marked) - raise RootError( - f"several roots are marked upload = true ({names}) — exactly one " - f"receives uploads") - if not marked and len(roots) == 1: - roots[0].upload = True - # ── Lookup ─────────────────────────────────────────────────────────────── def by_name(self, name: str) -> Root | None: @@ -247,11 +232,8 @@ class RootSet: return None @property - def upload_root(self) -> Root | None: - for root in self.roots: - if root.upload: - return root - return None + def writable_roots(self) -> list[Root]: + return [r for r in self.roots if r.writable] @property def names(self) -> list[str]: @@ -336,10 +318,23 @@ class RootSet: Called periodically and after a filesystem event that looks like a disappearance. A change here never edits the index: a root going away freezes its entries, and a root coming back triggers a rescan. + + An ejected root stays unavailable regardless of `is_live()` — the + operator must explicitly plug it back. A removable root whose path + disappears without an eject is auto-ejected as a safety net. """ changed: list[tuple[Root, bool]] = [] for root in self.roots: + if root.ejected: + if root.available: + root.available = False + changed.append((root, False)) + continue live = root.is_live() + if not live and root.removable and not root.ejected: + root.ejected = True + log.warning("Root %r auto-ejected (device disappeared): %s", + root.name, root.path) if live != root.available: root.available = live changed.append((root, live)) @@ -352,7 +347,12 @@ class RootSet: out = [] for r in self.roots: d: dict = {"name": r.name, "kind": r.kind, - "available": r.available, "upload": r.upload} + "available": r.available, + "writable": r.writable, + "removable": r.removable, + "ejected": r.ejected, + # Backward compat for MNP 1.0 clients + "upload": r.writable} if r.direct: d["direct"] = True out.append(d) diff --git a/packages/meshbay-node/src/meshbay_node/roster.py b/packages/meshbay-node/src/meshbay_node/roster.py index c78281b..e2f749f 100644 --- a/packages/meshbay-node/src/meshbay_node/roster.py +++ b/packages/meshbay-node/src/meshbay_node/roster.py @@ -587,11 +587,15 @@ class Roster: async def enabled_apps(self, group_id: str) -> list[str]: value = await self.get_setting(group_id, self.SETTING_ENABLED_APPS) if value is None: - return list(self.DEFAULT_APPS) - try: - return list(json.loads(value)) - except (ValueError, TypeError): - return list(self.DEFAULT_APPS) + apps = list(self.DEFAULT_APPS) + else: + try: + apps = list(json.loads(value)) + except (ValueError, TypeError): + apps = list(self.DEFAULT_APPS) + if "files" not in apps: + apps.insert(0, "files") + return apps async def set_enabled_apps(self, group_id: str, apps: list[str], set_by: str = "") -> list[str]: 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 94dfd8e..4d6dd34 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py @@ -78,6 +78,9 @@ from meshbay_common.adminop import ( OP_PHOTO_ROOTS, OP_ROOT_ADD, OP_ROOT_REMOVE, + OP_ROOT_UPDATE, + OP_ROOT_EJECT, + OP_ROOT_PLUG, OP_GROUP_ATTACH, OP_GROUP_DETACH, admin_transcript, @@ -507,6 +510,12 @@ class WebRTCPeerSession: self._do_root_add(msg) elif mtype == MNP.ROOT_REMOVE: self._do_root_remove(msg) + elif mtype == MNP.ROOT_UPDATE: + self._do_root_update(msg) + elif mtype == MNP.ROOT_EJECT: + self._do_root_eject(msg) + elif mtype == MNP.ROOT_PLUG: + self._do_root_plug(msg) elif mtype == MNP.ROSTER_READ: self._spawn(self._do_roster_read(msg)) elif mtype == MNP.DENYLIST_READ: @@ -754,10 +763,12 @@ class WebRTCPeerSession: # channel and nothing else. config = { "is_node_admin": self._is_node_admin(), - # So the interface knows whether to offer uploading at all. Not a - # permission — the node refuses regardless — but without it the - # only way to discover the answer is to try. - "member_upload": bool(self._group_ctx().get("member_upload", True)), + # Backward compat for MNP 1.0 clients: computed from writable roots. + # New clients read per-root writable from the index payload instead. + "member_upload": any( + r.get("writable") for r in + (self._group_ctx().get("roots").describe() + if self._group_ctx().get("roots") else [])), # Which group "applications" to show. Absent/empty falls back to # every registered one client-side, so a node that predates this # setting (or one whose context has not loaded it yet) hides @@ -1756,52 +1767,12 @@ class WebRTCPeerSession: "user_id": user_id}) def _do_member_upload(self, msg: dict) -> None: - """ - Turn uploading by ordinary members on or off, for this group. - - Signed like every other operator action. The setting decides who may - write to the operator's disk, so a node that took it from an unsigned - message would let any member turn it back on for everyone — the control - would be a suggestion. - """ - if "allowed" not in msg: - self._send({"type": "error", "detail": "Missing allowed"}) - return - if not self._has_admin_authority(): - self._send({"type": "error", "detail": "No authorized key for this"}) - return - # The subject is what the operator is shown before signing, so it has to - # name the outcome rather than the operation. - self._issue_admin_challenge( - OP_MEMBER_UPLOAD, "on" if msg.get("allowed") else "off") - - async def _admin_exec_member_upload( - self, pending: dict, transcript: bytes, sig: bytes, - ) -> None: - allowed = 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"member_upload:{pending['subject']}") - return - 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 - # an upload refused. Enforcement does not depend on this reaching them — - # it is the node that refuses — but a button that stays visible until - # the next reconnection is a button people press. - notice = {"type": MNP.MEMBER_UPLOAD_ACK, "v": MNP_VERSION, - "allowed": allowed} - for uid, session in list(self._peer_registry().items()): - try: - session._send(notice) - except Exception: - pass + # Deprecated: upload control is now per-root via writable flag. + # Old clients may still send this — acknowledge without acting. + log.warning("Deprecated member_upload message received — use root " + "writable/read-only instead") + self._send({"type": MNP.MEMBER_UPLOAD_ACK, "v": MNP_VERSION, + "allowed": True, "deprecated": True}) # Every "application" a group can show. Photos joins this set (and # apps.js's registry, client-side) when it lands; nothing else about @@ -1828,6 +1799,8 @@ class WebRTCPeerSession: self._send({"type": "error", "detail": f"Unknown app(s): {', '.join(sorted(unknown))}"}) return + if "files" not in apps: + apps.append("files") if not self._has_admin_authority(): self._send({"type": "error", "detail": "No authorized key for this"}) return @@ -2409,7 +2382,8 @@ class WebRTCPeerSession: "group_id": target_group, "path": path, "name": str(msg.get("name", ""))[:128], "kind": str(msg.get("kind", "generic"))[:16], - "upload": bool(msg.get("upload", False)), + "writable": bool(msg.get("writable", msg.get("upload", False))), + "removable": bool(msg.get("removable", False)), }, group_id=target_group) @@ -2425,7 +2399,8 @@ class WebRTCPeerSession: result = await self._run_op( ops.add_root, p["group_id"], p["path"], name=p.get("name", ""), kind=p.get("kind", "generic"), - upload=p.get("upload", False)) + writable=p.get("writable", False), + removable=p.get("removable", False)) except ops.OpError as e: self._send({"type": "error", "detail": e.message}) return @@ -2473,6 +2448,141 @@ class WebRTCPeerSession: await self._retarget_indexer(p["group_id"]) self._send({"type": MNP.ROOT_REMOVE_ACK, "v": MNP_VERSION, **result}) + def _do_root_update(self, msg: dict) -> None: + target_group = str(msg.get("group_id", self._group_id or "")).strip() + root_name = str(msg.get("root_name", "")).strip() + if not target_group or not root_name: + self._send({"type": "error", "detail": "Missing group_id or root_name"}) + return + if not self._has_admin_authority(): + self._send({"type": "error", "detail": "No authorized key for this"}) + return + updates = [] + if "writable" in msg: + updates.append(f"rw={'on' if msg['writable'] else 'off'}") + if "removable" in msg: + updates.append(f"rem={'on' if msg['removable'] else 'off'}") + subject = f"{root_name}:{','.join(updates)}" if updates else root_name + self._issue_admin_challenge( + OP_ROOT_UPDATE, subject, + payload={ + "group_id": target_group, "root_name": root_name, + "writable": msg.get("writable"), + "removable": msg.get("removable"), + }, + group_id=target_group) + + async def _admin_exec_root_update( + self, pending: dict, transcript: bytes, sig: bytes, + ) -> None: + if not await self._verify_admin_sig(transcript, sig): + self._send({"type": "error", "detail": "Signature verification failed"}) + self._audit("admin_auth_failed", + f"root_update:{pending['subject'][:24]}") + return + p = pending["payload"] + try: + result = await self._run_op( + ops.update_root, p["group_id"], p["root_name"], + writable=p.get("writable"), removable=p.get("removable")) + except ops.OpError as e: + self._send({"type": "error", "detail": e.message}) + return + except Exception as e: + log.error("root_update failed: %s", e, exc_info=True) + self._send({"type": "error", "detail": "Internal error"}) + return + self._audit("root_update", pending["subject"]) + await self._retarget_indexer(p["group_id"]) + notice = {"type": MNP.ROOT_UPDATE_ACK, "v": MNP_VERSION, **result} + for uid, session in list(self._peer_registry().items()): + try: + session._send(notice) + except Exception: + pass + + def _do_root_eject(self, msg: dict) -> None: + target_group = str(msg.get("group_id", self._group_id or "")).strip() + root_name = str(msg.get("root_name", "")).strip() + if not target_group or not root_name: + self._send({"type": "error", "detail": "Missing group_id or root_name"}) + return + if not self._has_admin_authority(): + self._send({"type": "error", "detail": "No authorized key for this"}) + return + self._issue_admin_challenge( + OP_ROOT_EJECT, root_name, + payload={"group_id": target_group, "root_name": root_name}, + group_id=target_group) + + async def _admin_exec_root_eject( + self, pending: dict, transcript: bytes, sig: bytes, + ) -> None: + if not await self._verify_admin_sig(transcript, sig): + self._send({"type": "error", "detail": "Signature verification failed"}) + self._audit("admin_auth_failed", + f"root_eject:{pending['subject'][:24]}") + return + p = pending["payload"] + try: + result = await self._run_op( + ops.eject_root, p["group_id"], p["root_name"]) + except ops.OpError as e: + self._send({"type": "error", "detail": e.message}) + return + except Exception as e: + log.error("root_eject failed: %s", e, exc_info=True) + self._send({"type": "error", "detail": "Internal error"}) + return + self._audit("root_eject", p["root_name"]) + notice = {"type": MNP.ROOT_EJECT_ACK, "v": MNP_VERSION, **result} + for uid, session in list(self._peer_registry().items()): + try: + session._send(notice) + except Exception: + pass + + def _do_root_plug(self, msg: dict) -> None: + target_group = str(msg.get("group_id", self._group_id or "")).strip() + root_name = str(msg.get("root_name", "")).strip() + if not target_group or not root_name: + self._send({"type": "error", "detail": "Missing group_id or root_name"}) + return + if not self._has_admin_authority(): + self._send({"type": "error", "detail": "No authorized key for this"}) + return + self._issue_admin_challenge( + OP_ROOT_PLUG, root_name, + payload={"group_id": target_group, "root_name": root_name}, + group_id=target_group) + + async def _admin_exec_root_plug( + self, pending: dict, transcript: bytes, sig: bytes, + ) -> None: + if not await self._verify_admin_sig(transcript, sig): + self._send({"type": "error", "detail": "Signature verification failed"}) + self._audit("admin_auth_failed", + f"root_plug:{pending['subject'][:24]}") + return + p = pending["payload"] + try: + result = await self._run_op( + ops.plug_root, p["group_id"], p["root_name"]) + except ops.OpError as e: + self._send({"type": "error", "detail": e.message}) + return + except Exception as e: + log.error("root_plug failed: %s", e, exc_info=True) + self._send({"type": "error", "detail": "Internal error"}) + return + self._audit("root_plug", p["root_name"]) + notice = {"type": MNP.ROOT_PLUG_ACK, "v": MNP_VERSION, **result} + for uid, session in list(self._peer_registry().items()): + try: + session._send(notice) + except Exception: + pass + async def _run_op(self, fn, *args, **kwargs): """ Call an operation from `meshbay_node.ops` with the daemon's own view. @@ -3678,35 +3788,44 @@ class WebRTCPeerSession: "filename": filename}) return - # The operator can close uploading to everyone but themselves. Enforced - # here rather than by hiding a button: the button is a courtesy to the - # people who are not trying, and this is the part that holds against - # someone who is. `is_node_admin` is computed from the identity this - # node pinned, never from a hub claim. - if not ctx.get("member_upload", True) and not self._is_node_admin(): + roots: RootSet | None = ctx.get("roots") + if not roots: self._send({"type": "error", - "detail": "Uploading is turned off for this group", - "code": "member_upload_off", + "detail": "No directories configured for this group", "filename": filename}) - self._audit("upload_refused", filename[:64]) return - roots: RootSet | None = ctx.get("roots") - upload_root = roots.upload_root if roots else None + # The client names the target root. If absent, pick the first writable + # one (backward compat with old clients that don't send it). + target_root_name = msg.get("root") + upload_root = None + if target_root_name: + from meshbay_common.paths import fold + target_folded = fold(target_root_name) + for r in roots: + if fold(r.name) == target_folded: + upload_root = r + break + else: + writable = roots.writable_roots + upload_root = writable[0] if writable else None + if upload_root is None: - # Refused, never guessed. With several roots, picking one would send - # a member's file to a disk the operator did not intend, and that is - # discovered weeks later. self._send({"type": "error", - "detail": "No upload folder is configured for this group", + "detail": "No writable directory found for uploads", + "code": "no_writable_root", "filename": filename}) return + if not upload_root.writable: + self._send({"type": "error", + "detail": f"Directory '{upload_root.name}' is read-only", + "code": "root_read_only", + "filename": filename}) + self._audit("upload_refused", filename[:64]) + return if not upload_root.available: - # The designated root's volume is absent. Falling back to another - # root would scatter uploads across disks depending on what happened - # to be plugged in. self._send({"type": "error", - "detail": f"The upload folder ({upload_root.name}) is " + "detail": f"Directory '{upload_root.name}' is " f"currently unavailable", "filename": filename}) return @@ -3981,8 +4100,10 @@ class WebRTCPeerSession: self._spawn( self._admin_exec_member_unpin(pending, transcript, sig_bytes)) elif pending["op"] == OP_MEMBER_UPLOAD: - self._spawn( - self._admin_exec_member_upload(pending, transcript, sig_bytes)) + log.warning("Deprecated OP_MEMBER_UPLOAD signed op — use root " + "writable/read-only instead") + self._send({"type": MNP.MEMBER_UPLOAD_ACK, "v": MNP_VERSION, + "allowed": True, "deprecated": True}) elif pending["op"] == OP_APPS_ENABLED: self._spawn( self._admin_exec_apps_enabled(pending, transcript, sig_bytes)) @@ -4019,6 +4140,15 @@ class WebRTCPeerSession: elif pending["op"] == OP_ROOT_REMOVE: self._spawn( self._admin_exec_root_remove(pending, transcript, sig_bytes)) + elif pending["op"] == OP_ROOT_UPDATE: + self._spawn( + self._admin_exec_root_update(pending, transcript, sig_bytes)) + elif pending["op"] == OP_ROOT_EJECT: + self._spawn( + self._admin_exec_root_eject(pending, transcript, sig_bytes)) + elif pending["op"] == OP_ROOT_PLUG: + self._spawn( + self._admin_exec_root_plug(pending, transcript, sig_bytes)) elif pending["op"] == OP_GROUP_ATTACH: self._spawn( self._admin_exec_group_attach(pending, transcript, sig_bytes)) diff --git a/packages/meshbay-node/src/meshbay_node/ui/app.py b/packages/meshbay-node/src/meshbay_node/ui/app.py index 6fdc78f..3d24000 100644 --- a/packages/meshbay-node/src/meshbay_node/ui/app.py +++ b/packages/meshbay-node/src/meshbay_node/ui/app.py @@ -349,13 +349,35 @@ def create_ui_app(state: dict) -> FastAPI: (payload.get("path") or "").strip(), name=(payload.get("name") or "").strip(), kind=(payload.get("kind") or "generic").strip(), - upload=bool(payload.get("upload", False)), + writable=bool(payload.get("writable", + payload.get("upload", False))), + removable=bool(payload.get("removable", False)), )) reload_fn = state.get("reload_fn") if reload_fn: asyncio.ensure_future(reload_fn()) return result + @app.patch("/api/groups/{group_id}/roots/{root_name}") + async def update_root(group_id: str, root_name: str, payload: dict): + result = await _op(lambda: ops.update_root( + state, group_id, root_name, + writable=payload.get("writable"), + removable=payload.get("removable"), + )) + reload_fn = state.get("reload_fn") + if reload_fn: + asyncio.ensure_future(reload_fn()) + return result + + @app.put("/api/groups/{group_id}/roots/{root_name}/eject") + async def eject_root(group_id: str, root_name: str): + return await _op(lambda: ops.eject_root(state, group_id, root_name)) + + @app.put("/api/groups/{group_id}/roots/{root_name}/plug") + async def plug_root(group_id: str, root_name: str): + return await _op(lambda: ops.plug_root(state, group_id, root_name)) + @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)) @@ -397,13 +419,13 @@ def create_ui_app(state: dict) -> FastAPI: "current_dir": progress.current_dir, } - # ── Upload toggle (operator only, localhost) ───────────────────────── + # ── Upload toggle (DEPRECATED — per-root writable replaces this) ──── @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)), - )) + log.warning("PUT member-upload is deprecated — use PATCH roots/{name} " + "with writable instead") + return {"deprecated": True, "message": "Use per-root writable flag"} # ── Enabled apps (operator only, localhost) ──────────────────────────── # -- cgit v1.2.3 From ea56b8c79538323875c00db2e7006b255f7cd494 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Sun, 6 Sep 2026 17:48:36 +0200 Subject: fix(groups): finish Phase 1 — MNP root management, upload targets, eject state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of the Phase 1 commit found the RO/RW model sound but three paths unfinished, each of which broke the flow the phase exists to deliver. Plus 29 test failures it introduced and no coverage for anything it added. Uploads went to the wrong directory. The node read a `root` field on file_upload that no client ever sent, so every upload landed in the first writable root while the Files toolbar offered its button based on the root being browsed — with two writable roots, uploading from one wrote into the other. Files now names the root it is showing; Chat names one chosen in the shell (an operator-configured directory arrives in Phase 2); the node refuses an unknown name rather than falling back, and refuses read-only and ejected roots by code. Shared directories were unreachable on the web. The table read its roots only from the loopback API, which resolves to "not available" in a browser, so the section rendered for nobody there — while the Uploads controls it replaced had worked — and the transport.updateRoot/ejectRoot/plugRoot methods beside it were dead. MNP is now the path, loopback the fallback for a local node with no live connection, and adding a root over MNP takes a typed path since no web page can browse a remote disk. Ejecting updated nobody's screen. transport.js resolves an admin ack against the pending request and returns, which is right for every op whose caller knows the value it chose; the root acks carry state only the node can compute, so the operator who clicked Eject was the one client that never saw it happen. And the ejected flag reached roster.db but was never read back, so a restart undid it and the next scan read an empty mount point as an erased library. Also: the member-upload endpoint answered 200 and did nothing (removed); the wizard ignored the first root's RW switch; reload compared roots on name and path, so editing writable in node.toml did nothing; the table had no path column, which is the only thing separating two libraries sharing a basename; apps_enabled normalisation differed between the two sides of a signed subject. Tests: eject/plug, per-root upload refusal and the node.toml rewrite had no coverage at all. test_member_upload_policy.py is replaced by test_root_writable_policy.py — it tested a removed feature — and every property worth keeping from it moved rather than being dropped. Docs: draft-v6 structural decision 9 is annotated as superseded (the operator can no longer have a directory only they may write to — a real capability removed, flagged rather than hidden), the man page documents the root verb and the RO/RW fields, and refactor-groups.md §7b records what the plan got wrong. Suite: 41 failures before, 13 after — all 13 pre-existing on main. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us --- docs/apps.md | 44 ++- docs/meshbay-draft-v6.md | 37 +- docs/refactor-groups.md | 139 ++++++-- man/meshbay-node.1 | 125 ++++++- .../meshbay-hub/src/meshbay_hub/static/chat-app.js | 17 +- .../src/meshbay_hub/static/create-group-page.js | 10 +- .../src/meshbay_hub/static/files-app.js | 11 +- .../src/meshbay_hub/static/group-page.js | 34 +- .../src/meshbay_hub/static/group-settings.js | 397 +++++++++++++-------- .../src/meshbay_hub/static/locales/de.js | 14 +- .../src/meshbay_hub/static/locales/en.js | 9 +- .../src/meshbay_hub/static/locales/es.js | 14 +- .../src/meshbay_hub/static/locales/fr.js | 9 +- .../src/meshbay_hub/static/locales/it.js | 14 +- .../src/meshbay_hub/static/locales/ja.js | 14 +- .../src/meshbay_hub/static/locales/nl.js | 14 +- .../src/meshbay_hub/static/locales/pl.js | 14 +- .../src/meshbay_hub/static/locales/pt-BR.js | 14 +- .../src/meshbay_hub/static/locales/zh-CN.js | 14 +- .../src/meshbay_hub/static/node-page.js | 16 +- .../src/meshbay_hub/static/search-page.js | 1 - .../meshbay-hub/src/meshbay_hub/static/style.css | 25 ++ .../src/meshbay_hub/static/transport.js | 88 +++-- .../tests/test_upload_controls_hidden.py | 224 ++++++++---- packages/meshbay-node/src/meshbay_node/daemon.py | 99 +++-- .../src/meshbay_node/indexer/indexer.py | 16 + packages/meshbay-node/src/meshbay_node/ops.py | 47 +-- packages/meshbay-node/src/meshbay_node/roots.py | 19 +- packages/meshbay-node/src/meshbay_node/roster.py | 53 ++- .../src/meshbay_node/transport/webrtc_server.py | 41 ++- packages/meshbay-node/src/meshbay_node/ui/app.py | 9 +- packages/meshbay-node/tests/conftest.py | 10 +- .../meshbay-node/tests/test_apps_enabled_policy.py | 4 +- packages/meshbay-node/tests/test_cli_dispatch.py | 9 + .../tests/test_member_upload_policy.py | 176 --------- packages/meshbay-node/tests/test_node_status.py | 117 +++++- packages/meshbay-node/tests/test_ops.py | 100 +++++- .../meshbay-node/tests/test_root_availability.py | 11 +- packages/meshbay-node/tests/test_root_eject.py | 268 ++++++++++++++ .../tests/test_root_writable_policy.py | 203 +++++++++++ packages/meshbay-node/tests/test_roots.py | 81 ++++- .../tests/test_scan_settings_policy.py | 2 +- .../tests/test_security_regressions.py | 145 +++++++- 43 files changed, 2063 insertions(+), 645 deletions(-) delete mode 100644 packages/meshbay-node/tests/test_member_upload_policy.py create mode 100644 packages/meshbay-node/tests/test_root_eject.py create mode 100644 packages/meshbay-node/tests/test_root_writable_policy.py (limited to 'packages/meshbay-node/src/meshbay_node') diff --git a/docs/apps.md b/docs/apps.md index ef8cc1a..7ce1e73 100644 --- a/docs/apps.md +++ b/docs/apps.md @@ -130,9 +130,10 @@ any app with similar per-group local state. ## 3. Enable/disable: the mechanism -Same shape as `member_upload` (`meshbay-draft-v6.md` §2.1b) — an +Same shape as a root's `writable` flag (`refactor-groups.md` §1.1) — an operator-signed setting, stored on the node, enforced by absence rather than -by the client's honesty. +by the client's honesty. It used to be described against `member_upload`, +which was the group-wide upload switch; that was removed in the same refactor. **Node side** (`meshbay_node/roster.py`): ```python @@ -148,8 +149,13 @@ from exactly one place: `webrtc_server.py`'s `_admin_exec_apps_enabled`, after `_do_apps_enabled` in `webrtc_server.py` validates before it ever issues a challenge: - `apps` non-empty — the operator can never lock a group down to nothing. -- every entry in `WebRTCPeerSession.ALLOWED_APPS` (`{"chat", "files"}` today) - — **this is the line a new app's node-side registration touches.** +- every entry in `WebRTCPeerSession.ALLOWED_APPS` + (`{"chat", "files", "video", "music", "photo"}` today) — **this is the line + a new app's node-side registration touches.** +- `files` is added to the list if it is absent, at both writers + (`_do_apps_enabled` and `ops.set_enabled_apps`, both at the front so the two + agree). It is not a toggle: MNP permits root exploration regardless of what + this list says, so hiding the tab only ever misled. The whole set is signed in one message (`apps_enabled`, `OP_APPS_ENABLED` in `meshbay_common.adminop`) rather than one op per app — ticking several boxes @@ -158,11 +164,11 @@ sorted, comma-joined app list (`"chat,files"`), built the same way on both sides so the operator's browser and the node arrive at identical bytes to sign/verify. -`enabled_apps` rides in `handshake_ack` and `node_status`, next to -`member_upload`. Changing it broadcasts `apps_enabled_ack` to everyone already -connected — `transport.js`'s `onAppsEnabled` — so a disabled tab disappears -without waiting for a reconnection, the same as `member_upload`'s live -broadcast. +`enabled_apps` rides in `handshake_ack` and `node_status`, next to the roots +table. Changing it broadcasts `apps_enabled_ack` to everyone already connected +— `transport.js`'s `onAppsEnabled` — so a disabled tab disappears without +waiting for a reconnection. The root ops (`root_update_ack`, `root_eject_ack`, +`root_plug_ack`) broadcast the same way, through `onRootsChanged`. **Client side:** `apps.js`'s `visibleApps(enabledKeys)` filters the registry; `group-page.js` calls it with `enabledApps` state (from the ack, `null` until @@ -234,9 +240,17 @@ the only node-side touches, and both are allow-lists, not new wire messages. machinery again; unlike Videos/Music it needs several root folders per group rather than one, has a single album-grid view with no third-party matching step, and reads EXIF locally on the node instead. -- **The offline/loopback settings path.** `member_upload` can be toggled two - ways: over a live MNP connection, or (Electron only) via the node's local - HTTP API when MNP isn't connected (`platform.node.call('PUT', .../member- - upload')`, `group-settings.js`). `apps_enabled` only has the MNP path today. - Adding the loopback twin is a `meshbay_node.ui` endpoint plus a - `group-settings.js` branch, mirroring the existing `member_upload` one. +- **The offline/loopback settings path.** A root's flags can be changed two + ways: over a live MNP connection (any browser, anywhere), or — Electron + only, and only when MNP is not connected — via the node's local HTTP API + (`platform.node.call('PATCH', '/api/groups//roots/')`, + `SharedDirectoriesTable` in `group-settings.js`). `apps_enabled` only has + the MNP path today. Adding the loopback twin is a `meshbay_node.ui` endpoint + plus a branch in the table's `run()` helper, mirroring the root ops. + + **MNP is the path that must exist, not the fallback.** The operator of a + node is not necessarily sitting at it. The first version of the shared + directories table read its roots exclusively from the loopback API, which + resolves to "not available" in a browser — so the whole section rendered for + nobody on the web, while the controls it replaced had worked there. Any + operator-facing setting added here needs the MNP route first. diff --git a/docs/meshbay-draft-v6.md b/docs/meshbay-draft-v6.md index 180050c..4439cd1 100644 --- a/docs/meshbay-draft-v6.md +++ b/docs/meshbay-draft-v6.md @@ -57,7 +57,7 @@ | 6 | Portability | exFAT/NTFS and Windows are the **common** case. Case folding and Unicode normalization become correctness requirements, not compatibility notes | E8 / decision 12 | | 7 | Accounts | Native registration is **hybrid**: passphrase-derived `auth_key` (the recovery path) plus a device Ed25519 key for day-to-day authentication | E3 / decision 4 | | 8 | Authorship | Chat senders are **cryptographically authenticated to each other**; an upload has a **provable owner** who may delete it, as the operator may. v5's node-asserted attribution is replaced | operator decision, §2.4b | -| 9 | Node authority | The operator may **close uploading to everyone but themselves**, per group. Signed MNP op, stored on the node, enforced by the node — the hidden button is a courtesy, the refusal is the control | §2.1b | +| 9 | Node authority | The operator decides **which directories accept uploads**, per root. Signed MNP op, stored on the node, enforced by the node — the hidden button is a courtesy, the refusal is the control. **Superseded 2026-09-06** by `docs/refactor-groups.md` §1.1: the group-wide `member_upload` switch this section described is replaced by RO/RW per root, and the "everyone but the operator" carve-out is gone | §2.1b | | 10 | Client | A group's UI is a **set of pluggable applications** (Chat, Files today), not one monolithic page. Which are shown is a per-group, operator-signed setting on the same pattern as change 9 | §2.7 | | 11 | Hub role | The hub gains a **runtime instance-policy store** (`hub_settings`). First policy: an admin switches **public groups off** hub-wide, enforced server-side on every hub-mediated path. `suspend` vs `revoke` on a group are now written down as the distinct things they are | §2.8 | | 12 | Group registry | A group name is **unique per owner account**, not globally; the group's identity is still its UUID. Listed everywhere as `name@owner` | §2.9 | @@ -74,10 +74,12 @@ v5 confines uploads to `shared_root/uploads/` with a filename allowlist, no overwrite, chunk ordering and a size cap. All four protections stand. Two amendments: -- There is no single `shared_root`. **The operator designates one root as the upload - destination**; the quarantine lives inside it. If that root is unavailable the upload - fails with a stated reason and never falls back to another; if none is designated, - uploads are refused rather than guessed. +- There is no single `shared_root`. **Each root is read-only or read-write**, and the + quarantine lives inside whichever writable root the upload is addressed to. If that + root is unavailable the upload fails with a stated reason and never falls back to + another; if the group has no writable root, uploads are refused rather than guessed. + (Amended 2026-09-06 — the original text designated *one* root as the upload + destination, and the client named none. See `docs/refactor-groups.md` §1.1.) - **The no-overwrite rule is unchanged and still holds on exFAT/NTFS.** An earlier draft claimed a string comparison let `README.TXT` land on `readme.txt` there. It does not: the check is `Path.exists()`, and `stat()` is itself case-insensitive on those @@ -91,6 +93,31 @@ device that asked, so the node keeps no thumbnail store. ### 2.1b §5.2 Uploads — the operator may close them +> **Superseded 2026-09-06.** `member_upload` is gone; the mechanism is `writable` on +> each root. What the three load-bearing properties below say is *unchanged* — read +> "the root's `writable` flag" for "`member_upload`" and every word of them still +> holds, which is why they are kept rather than deleted. What did change: +> +> - **It is per root, not per group.** A group can publish one library read-only and +> accept uploads into another, which the single switch could not express. +> - **There is no carve-out for the operator.** Read-only means read-only for +> everyone, because a published library that quietly accepts writes from whoever +> holds admin authority is not one. The paragraph below justifying the setting by +> "the only way to get a curated library was to designate no upload root at all, +> which refuses the operator too" is therefore the reasoning that was reversed: that +> *is* the model now, and refusing the operator is the point rather than the defect. +> - **The client names the destination root.** With several writable roots the node +> cannot choose without guessing, and a guess sends a member's file to a disk the +> operator did not intend. It names a root, never a path; everything below the root +> is still decided by the node. +> - The signed op is `OP_ROOT_UPDATE` (plus `OP_ROOT_EJECT` / `OP_ROOT_PLUG`) rather +> than `OP_MEMBER_UPLOAD`, and the flags live in `node.toml` — they are +> configuration — while the *ejected* runtime state lives in `roster.db`. +> `member_upload` survives on the handshake ack alone, computed as "any root is +> writable", for MNP 1.0 clients that read no other field. +> +> See `docs/refactor-groups.md` §1.1 and §1.5b. + New. A group where every member may add files is the default and stays the default; some groups want a library the operator curates, and until now the only way to get one was to designate no upload root at all, which refuses the operator too. diff --git a/docs/refactor-groups.md b/docs/refactor-groups.md index 11956ce..3d7412b 100644 --- a/docs/refactor-groups.md +++ b/docs/refactor-groups.md @@ -1,10 +1,14 @@ # Groups Refactor — Per-Root Permissions & App Plugin Architecture -> Status: **Phase 1 implemented.** Phase 2 and 3 not started. +> Status: **Phase 1 complete and reviewed** (2026-09-06). Phase 2 and 3 not started. > > This is the most significant refactoring of the project. It changes how roots > are permissioned, how group applications are configured, and how the Settings > and Create Group pages are structured. +> +> §7b records what the review of Phase 1 found and how the plan below was wrong +> where it was wrong. Read it before starting Phase 2 — two of its entries are +> rules the later phases have to follow, not one-off fixes. --- @@ -175,17 +179,31 @@ flip it back to available. refuses with "Directory not found. Is the device connected?" 4. On success: sets `ejected = false`, marks root `available = true`, restarts the watchdog observer -5. The indexer runs a **reconciliation** (not a full rescan) — compares frozen - entries against current filesystem state. New/changed/deleted files are - handled normally +5. The indexer **rescans the root** — its frozen entries are dropped and the + directory is read again. (The plan said "a reconciliation, not a full + rescan"; it is a rescan, deliberately. It is the same path a root coming + back from `refresh_availability` already took, and a device people carry + around can come back arbitrarily different — the hash cache means unchanged + files are not re-read, which is where the cost would have been.) 6. `index_sync` update propagates — entries reappear in all apps +**The flag is persisted, and restored at startup.** `ejected` lives in +`roster.db` (`root_ejected:`), not in `node.toml`: it is runtime +state, and an operator's hand-written config must not be rewritten because a USB +drive was unplugged. It has to survive a restart — a restart is exactly what an +operator does after noticing a drive fell off, and a flag that only lived in +memory would let the scan that follows read the empty mount point as an erased +library. `daemon._build_roots()` merges the two sources; it is the only place +that builds a `RootSet` for a group. + **Auto-detection safety net.** If a `removable` root's path suddenly disappears (operator unplugged without clicking eject): - `refresh_availability()` detects `is_live() = false` - Because `removable = true`, it sets `ejected = true` automatically (as if the - operator had clicked eject) + operator had clicked eject), and reports it through the indexer's + `on_root_ejected` callback so the daemon writes it to `roster.db` — an + auto-eject that only existed in memory would be undone by the next restart - Entries freeze, no deletions propagate - The root stays in "ejected" state until the operator explicitly plugs it back @@ -281,25 +299,27 @@ For backward compatibility with MNP 1.0 peers: ### 1.10 CLI changes +As built. The group is a `--group` option rather than a positional, matching +every other verb in this CLI, and the negative flags are spelled `--no-writable` +/ `--no-removable` rather than `--read-only`, so each pair reads as one setting. + ``` # Group creation (first root defaults to RW) -meshbay-node group add --dir # first root, RW -meshbay-node group add --dir --read-only # first root, RO - -# Root management -meshbay-node root add [--name ] [--writable] [--removable] -meshbay-node root remove -meshbay-node root set --writable # toggle to RW -meshbay-node root set --read-only # toggle to RO -meshbay-node root set --removable # mark as removable -meshbay-node root set --no-removable # unmark -meshbay-node root eject # safe eject -meshbay-node root plug # re-plug -meshbay-node root list - -# Deprecated (removed with warning) +meshbay-node group add --dir # first root, RW +meshbay-node group add --dir --no-writable # first root, RO + +# Root management (--group is optional with one group configured) +meshbay-node root list [--group ] +meshbay-node root add [--name ] [--writable] [--removable] +meshbay-node root remove [--yes] +meshbay-node root set --writable | --no-writable +meshbay-node root set --removable | --no-removable +meshbay-node root eject # safe eject +meshbay-node root plug # re-plug + +# Deprecated (accepted with a warning) --upload-dir → "use --writable on the target root instead" -member upload → "use 'root set --read-only' / 'root set --writable' instead" +member upload → removed; use 'root set --no-writable' / '--writable' ``` ### 1.11 HelloWorld proof-of-concept @@ -655,3 +675,80 @@ QE/migration/migrate_groups_v2.py (new, not | Phase 3 | HelloWorld + CLI polish + migration script | ~500 lines | Each phase is one focused Claude session. Test between phases. + +--- + +## 7b. Phase 1 review (2026-09-06) + +What the plan above got wrong, and what was actually built. The first three +entries are **rules for phases 2 and 3**, not one-off fixes: each describes a +shape the same code can take again. + +### The rules + +**An operator is not sitting at their node.** The shared directories table read +its roots exclusively from the loopback API (`platform.node.available`), which +resolves to "not available" in a browser. So the section rendered for nobody on +the web — while the Uploads controls it replaced *had* worked there — and the +`transport.updateRoot` / `ejectRoot` / `plugRoot` methods written next to it +were unreachable. §2.2 of the plan said "calls loopback API", and that was the +mistake: MNP is the path that must exist, and loopback is the fallback for a +local node with no live connection. Every operator-facing control phase 2 adds +(the folder tree, four app settings panes, the link-preview toggle) needs the +MNP route first. Pinned by `test_upload_controls_hidden.py`. + +**A reply that carries state nobody could have predicted has to be handed on.** +`transport.js` resolves an admin `*_ack` against the pending request and +returns, deliberately: every caller already updates local state from the value +it chose. The root acks are not like that — they carry the node's whole roots +table, including things only it knows (availability, the name it settled on, +the eject a failed plug left in place). Returning left the operator who clicked +Eject as the single client that never saw it happen, while every *other* peer +got the broadcast. Any phase-2 op returning computed state has the same shape. + +**A control that writes needs to name where.** The node was given a `root` +field on `file_upload` and no client ever sent it, so every upload went to +`writable_roots[0]` while the Files toolbar offered the button based on the root +being browsed. With two writable roots, uploading from one wrote into the other. +This is the failure `_settle_upload_root`'s deleted docstring existed to +prevent, reintroduced by removing it. Chat's attachments have the same problem +one level up and get an explicit `attachRoot` until §1.7 gives them a +configured directory. + +### The rest + +- **`ejected` was written to `roster.db` and never read back**, and the + auto-eject path did not persist at all. Both fixed; see §1.5b. +- **`PUT /api/groups/{gid}/member-upload` became a stub returning `200 + {"deprecated": true}`.** A route that answers OK and changes nothing is + indistinguishable from a working one to whoever calls it. Removed. +- **The wizard ignored the first root's RW switch** — `ops.attach_group` always + wrote `writable = true`. It takes the flag now. +- **`refresh_availability` was the only reader of a root's config.** The reload + path compared roots on `(name, path)`, so an operator editing `writable` in + `node.toml` and reloading saw nothing happen. The comparison includes the + flags. +- **The table had no Path column** (§1.5 asked for one). Two libraries whose + folders share a basename are indistinguishable without it, and the basename is + the identity — so it is the one thing that has to be visible. +- **Phase 1 shipped no tests.** 29 of the suite's failures were its own. The + gap that mattered was not the broken helpers but that eject, plug, per-root + upload refusal and the `node.toml` rewrite had no coverage at all: + `test_root_eject.py`, `test_root_writable_policy.py` and the new cases in + `test_ops.py` / `test_node_status.py` / `test_security_regressions.py` are + that. `test_member_upload_policy.py` is gone — it tested a removed feature. +- **`chat-app.js` was in §2.2's file list and was never touched.** +- **Eight of the ten locales were missing the new keys.** `test_locales.py` + holds them to `en.js`, so this was a failing test rather than a silent gap — + but it is worth noting that adding a key means adding it ten times. + +### Still open, deliberately + +- **The operator can no longer have a directory only they may write to.** RW is + open to every member; RO refuses everyone including the operator. This + reverses draft-v6's structural decision 9, which is annotated there. It is a + real capability removed, and if it turns out to be wanted the answer is a + third state on the root, not the old group-wide switch. +- `test_ops.py::test_a_backslash_path_written_into_node_toml_stays_parseable` + fails on any non-Windows machine and always has — it builds a + `PurePosixPath` from a Windows path. Unrelated to this refactor, left alone. diff --git a/man/meshbay-node.1 b/man/meshbay-node.1 index 1e1173b..92067a2 100644 --- a/man/meshbay-node.1 +++ b/man/meshbay-node.1 @@ -68,12 +68,18 @@ List all hosted groups with their roots, key status, file count, and connected peers. . .TP -\fBgroup add\fR \fIname\fR \fB\-\-dir\fR \fIpath\fR [\fB\-\-upload\-dir\fR \fIpath\fR] +\fBgroup add\fR \fIname\fR \fB\-\-dir\fR \fIpath\fR Attach a hub\-side group to this node by writing a .B [[groups]] entry to .IR node.toml . The group must already exist on the hub. +The directory becomes the group's first root, and is +.B read\-write +so that a new group can receive an upload without further configuration; +pass +.B \-\-no\-writable +for a group that only publishes. Run .B meshbay\-node reload afterwards, then @@ -88,6 +94,54 @@ Asks for confirmation unless .B \-\-yes is given. . +.SS Root management +A group has one or more named roots: directories on this machine that its +members see. Each is read\-only or read\-write, independently; a group whose +roots are all read\-only is valid and accepts no uploads at all. +. +.TP +.B root list +List this group's roots with their flags and current availability. +. +.TP +\fBroot add\fR \fIpath\fR [\fB\-\-name\fR \fIname\fR] [\fB\-\-writable\fR] [\fB\-\-removable\fR] +Add a directory to the group. The name defaults to the directory's +basename; two roots in a group cannot share a name, compared without +regard to case, and no root may sit inside another. +Run +.B meshbay\-node reload +afterwards to start indexing it. +. +.TP +\fBroot remove\fR \fIname\fR +Remove a root from the group. Files on disk are untouched; only +.I node.toml +changes. The last remaining root cannot be removed. +Asks for confirmation unless +.B \-\-yes +is given. +. +.TP +\fBroot set\fR \fIname\fR [\fB\-\-writable\fR|\fB\-\-no\-writable\fR] [\fB\-\-removable\fR|\fB\-\-no\-removable\fR] +Change a root's flags without removing it. Takes effect immediately; no +reload is needed. +. +.TP +\fBroot eject\fR \fIname\fR +Mark a removable root as ejected before physically disconnecting the +device. Its files are hidden from members and its index entries are +frozen \(em nothing is deleted \(em and the directory watcher stops, so +the unplug produces no deletions to propagate. The device can then be +removed safely. Refused on a root that is not marked +.BR removable . +. +.TP +\fBroot plug\fR \fIname\fR +Re\-enable an ejected root once the device is back. Refused if the +directory is not readable, since clearing the flag while the device is +still absent would hand the next scan an empty directory. The root is +rescanned, so anything that changed while it was away is picked up. +. .SS Member management .TP .B member list @@ -196,11 +250,33 @@ Shared directory, used with .BR "group add" . . .TP -\fB\-\-upload\-dir\fR \fIpath\fR -Separate upload directory, used with -.BR "group add" . -Files land directly in this directory (not in a subdirectory) and it -appears as its own root in the index. +.BR \-\-writable ", " \-\-no\-writable +Whether a root accepts uploads from group members, used with +.BR "root add" ", " "root set" " and " "group add" . +Uploads land in an +.I uploads +subdirectory of the root; existing files are never replaced. +A new root is read\-only unless +.B \-\-writable +is given; the directory passed to +.B "group add" +is the exception and is writable by default. +. +.TP +.BR \-\-removable ", " \-\-no\-removable +Whether a root lives on a device that gets disconnected, used with +.BR "root add" " and " "root set" . +Enables +.BR "root eject" " and " "root plug" , +and makes the node treat the directory suddenly disappearing as an +unannounced eject rather than as a deletion. +. +.TP +\fB\-\-name\fR \fIname\fR +Explicit name for a root, used with +.BR "root add" . +Default: the directory's basename. Required for a drive or filesystem +root, which has no basename to derive one from. . .TP .B \-\-yes @@ -343,15 +419,18 @@ Human\-readable group name. . .TP .B shared_dir -Single\-directory shorthand: equivalent to declaring one root named after -the directory's basename, which receives uploads. Cannot be combined with +Single\-directory shorthand: equivalent to declaring one read\-write root +named after the directory's basename. Cannot be combined with .BR [[groups.roots]] . . .TP .B upload_dir -A separate filesystem path for uploads. Files land directly in it (not in -a subdirectory) and it appears as its own root in the index. When set, -no other root receives uploads. +Deprecated. A separate filesystem path for uploads, from before roots +carried their own read\-write flag. A configuration still using it is +read as a second, writable root and every other root is forced +read\-only. Use +.B writable +on the intended root instead. . .TP .B visibility @@ -399,10 +478,30 @@ A view hint: one of Currently unused. . .TP -.B upload -Boolean. Exactly one root per group must receive uploads. Default: +.B writable +Boolean. Whether members may upload into this root. Uploads land in an +.I uploads +subdirectory; an existing file is never replaced. Any number of roots in +a group may be writable, including none. Default: +.BR false . +. +.TP +.B removable +Boolean. Whether this root lives on a device that gets disconnected. +Enables +.BR "meshbay\-node root eject" , +and makes the directory suddenly disappearing freeze the root rather +than look like a deletion of everything in it. Default: .BR false . . +.TP +.B upload +Deprecated spelling of +.BR writable , +read for configurations written before the two were separated. +.B writable +wins where both appear. +. .SS [keystore] .TP .B path diff --git a/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js b/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js index 3bc110f..4714674 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js @@ -198,8 +198,13 @@ function ChatImage({ filename, entries, transportRef, gekRef }) { return html`${filename}`; } +// `attachRoot` is the shared directory attachments are written to: the name of +// the first writable, available root, decided in group-page.js so Files and Chat +// read one answer. Empty means the group has no writable root right now — every +// root is read-only, or the one drive that was writable is unplugged — and the +// paperclip says so rather than producing a refusal from the node. function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, - onPreview, mayUpload = true, onActivity, status }) { + onPreview, attachRoot = '', onActivity, status }) { const [messages, setMessages] = useState([]); const [hasMore, setHasMore] = useState(false); const [loadingOlder, setLoadingOlder] = useState(false); @@ -479,7 +484,7 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, try { // Two people sending IMG_1234.jpg both succeed; the node picks a free name // and the message has to point at the one it chose. - const ack = await transport.uploadFile(file); + const ack = await transport.uploadFile(file, { root: attachRoot }); const storedAs = (ack && ack.stored_as) || file.name; await new Promise(r => setTimeout(r, 2500)); if (onRefreshIndex) await onRefreshIndex(); @@ -501,7 +506,7 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, } finally { setAttaching(false); } - }, [username, onRefreshIndex, jumpToBottom]); + }, [username, onRefreshIndex, jumpToBottom, attachRoot]); const onKeyDown = useCallback((e) => { if (e.key === 'Enter' && !e.shiftKey) { @@ -592,12 +597,16 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, `}
- ${mayUpload && html` + ${attachRoot ? html` + ` : html` + + <${Icon} name="clip" /> + `}