aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/ops.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-06 16:05:39 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-06 16:05:39 +0200
commite76e27868b30a2b00b1ba42dd8e7ee6071e0c0d7 (patch)
tree3c23483207d77adf3b67f290b67a57ce185fb1a1 /packages/meshbay-node/src/meshbay_node/ops.py
parent0ed078c92cabab1dab0f70f321562032ea549ce6 (diff)
downloadmeshbay-e76e27868b30a2b00b1ba42dd8e7ee6071e0c0d7.tar.gz
feat: groups refactor Phase 1 — root RO/RW model + shared directories UI
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 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/ops.py')
-rw-r--r--packages/meshbay-node/src/meshbay_node/ops.py293
1 files changed, 245 insertions, 48 deletions
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.