aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-node/src')
-rw-r--r--packages/meshbay-node/src/meshbay_node/daemon.py151
-rw-r--r--packages/meshbay-node/src/meshbay_node/ops.py201
-rw-r--r--packages/meshbay-node/src/meshbay_node/roster.py134
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py190
4 files changed, 528 insertions, 148 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py
index 4fc07ad..7637b41 100644
--- a/packages/meshbay-node/src/meshbay_node/daemon.py
+++ b/packages/meshbay-node/src/meshbay_node/daemon.py
@@ -70,26 +70,36 @@ if WEBRTC_AVAILABLE:
log = logging.getLogger(__name__)
-def _under_video_root(path: str, video_root: str) -> bool:
- """Mirrors video-app.js's underVideoRoot: same folder, or a descendant."""
- path = path or ""
- return path == video_root or path.startswith(video_root + "/")
-
+def _under_any_directory(path: str, directories: list[str]) -> bool:
+ """
+ Whether an entry's folder is one of an app's directories, or inside one.
-def _under_audio_root(path: str, audio_root: str) -> bool:
- """Mirrors music-app.js's underAudioRoot — same shape as _under_video_root."""
+ Mirrors `underAnyDirectory` in the SPA's app modules. One helper for every
+ app since they all take a list: Videos and Music used to take a single
+ folder and had a function each saying the same thing, which is how the two
+ came to differ in what they did with a trailing slash.
+ """
path = path or ""
- return path == audio_root or path.startswith(audio_root + "/")
+ return any(path == d or path.startswith(d + "/") for d in directories)
-def _under_any_photo_root(path: str, photo_roots: list[str]) -> bool:
+def _owning_directory(path: str, directories: list[str]) -> str | None:
"""
- Mirrors photos-app.js's underAnyPhotoRoot. Unlike video/audio's single
- root, photo_roots is a list (docs/photos.md §2.1) — a match against any
- one of them is enough.
+ Which of an app's directories an entry belongs to — the deepest match.
+
+ Deepest, because directories may nest: with both `Media` and
+ `Media/Albums` configured, a file under the second belongs to the second.
+ Taking the first match instead would measure it against a boundary one
+ level too shallow, which for Music is the difference between reading a
+ folder as an artist and reading it as a release.
"""
path = path or ""
- return any(path == r or path.startswith(r + "/") for r in photo_roots)
+ best: str | None = None
+ for d in directories:
+ if path == d or path.startswith(d + "/"):
+ if best is None or len(d) > len(best):
+ best = d
+ return best
# ── Argon2id calibration ──────────────────────────────────────────────────────
@@ -396,19 +406,15 @@ class NodeDaemon:
# which the RootSet above already carries.)
"enabled_apps": await self._roster.enabled_apps(
group_cfg.id) if self._roster else list(Roster.DEFAULT_APPS),
- # Which folder is the Videos app's entry point for this
- # group — "" means the whole group index.
- "video_root": await self._roster.video_root(
- group_cfg.id) if self._roster else "",
- # Same shape, Music app's own entry point.
- "audio_root": await self._roster.audio_root(
- group_cfg.id) if self._roster else "",
- # Photos app's entry points — a *list*, unlike video_root/
- # audio_root above (docs/photos.md §2.1: a photo library
- # is routinely scattered across several folders). Empty
- # list means nothing configured yet.
- "photo_roots": await self._roster.photo_roots(
- group_cfg.id) if self._roster else [],
+ # Which folder(s) inside the shared roots each app works
+ # over. One shape for every app (roster.py's
+ # app_directories) — an empty list means nothing has been
+ # chosen, which every app reads as "show nothing yet",
+ # never "the whole group index".
+ **(await self._app_directories_ctx(group_cfg.id)),
+ # Whether the node unfurls links members post here.
+ "chat_link_preview": await self._roster.chat_link_preview(
+ group_cfg.id) if self._roster else True,
# Whether TMDB lookups run for this group at all —
# per-group (2026-08-24, used to be node-wide), same
# "read once, kept current in place by the signed op"
@@ -631,9 +637,14 @@ class NodeDaemon:
self._state["quic_server"] = self._quic_server
self._state["hub"] = hub
self._state["reload_fn"] = self._reload_config
- self._state["enrich_video_root_fn"] = self._enrich_video_root_now
- self._state["enrich_audio_root_fn"] = self._enrich_audio_root_now
- self._state["enrich_photo_roots_fn"] = self._enrich_photo_roots_now
+ # Keyed by app, so `ops.set_app_directories` finds the right
+ # sweep without knowing which apps exist — an app with nothing to
+ # enrich simply has no entry.
+ self._state["enrich_app_dirs_fns"] = {
+ "video": self._enrich_video_root_now,
+ "music": self._enrich_audio_root_now,
+ "photo": self._enrich_photo_roots_now,
+ }
# Rotating a key has to reach every transport holding a copy of it,
# and clearing the denylist has to reach the one the handshake
# consults — so both are published rather than reachable only
@@ -854,15 +865,10 @@ class NodeDaemon:
"enabled_apps": (
await self._roster.enabled_apps(group_cfg.id)
if self._roster else list(Roster.DEFAULT_APPS)),
- "video_root": (
- await self._roster.video_root(group_cfg.id)
- if self._roster else ""),
- "audio_root": (
- await self._roster.audio_root(group_cfg.id)
- if self._roster else ""),
- "photo_roots": (
- await self._roster.photo_roots(group_cfg.id)
- if self._roster else []),
+ **(await self._app_directories_ctx(group_cfg.id)),
+ "chat_link_preview": (
+ await self._roster.chat_link_preview(group_cfg.id)
+ if self._roster else True),
"tmdb_enabled": (
await self._roster.tmdb_enabled(group_cfg.id)
if self._roster else True),
@@ -1076,6 +1082,36 @@ class NodeDaemon:
spec["ejected"] = True
return RootSet.build(specs)
+ # Every app that keeps directories. Not derived from `enabled_apps`: the
+ # context is read once at load and an app enabled later must not find its
+ # own setting missing. Adding an app adds a name here and nowhere else on
+ # this side.
+ APP_DIR_KEYS = ("video", "music", "photo", "chat")
+
+ async def _app_directories_ctx(self, group_id: str) -> dict:
+ """
+ Each app's configured directories, plus the legacy scalar names the
+ rest of the tree still reads.
+
+ The scalars are derived here rather than stored, so the two can never
+ disagree: `video_root` is the first of `video_directories` and exists
+ for MNP 1.0 clients and for the handful of call sites that predate the
+ list. A group with several video directories reports the first as its
+ `video_root` — which is what an old client can represent, and all it
+ could ever have shown.
+ """
+ dirs = {}
+ for app in self.APP_DIR_KEYS:
+ dirs[f"{app}_directories"] = (
+ await self._roster.app_directories(group_id, app)
+ if self._roster else [])
+ aliases = {}
+ for app in self.APP_DIR_KEYS:
+ alias = Roster.ctx_alias(app, dirs[f"{app}_directories"])
+ if alias:
+ aliases[alias[0]] = alias[1]
+ return {**dirs, **aliases}
+
def _eject_persister(self, group_id: str):
"""`on_root_ejected` bound to one group, for that group's indexer."""
async def persist(root_name: str, ejected: bool) -> None:
@@ -1249,13 +1285,13 @@ class NodeDaemon:
"""
if not self._enricher or not self._roster:
return
- video_root = await self._roster.video_root(indexer.group_id)
- if not video_root:
+ video_dirs = await self._roster.app_directories(indexer.group_id, "video")
+ if not video_dirs:
return
for entry in entries:
if entry.type != "video" or (indexer.group_id, entry.id) in self._enriched_attempted:
continue
- if not _under_video_root(entry.path, video_root):
+ if not _under_any_directory(entry.path, video_dirs):
continue
file_path = entry_abs_path(indexer.roots, entry)
if not file_path or not file_path.exists():
@@ -1330,14 +1366,18 @@ class NodeDaemon:
"""
if not self._audio_enricher or not self._roster:
return
- audio_root = await self._roster.audio_root(indexer.group_id)
- if not audio_root:
+ audio_dirs = await self._roster.app_directories(indexer.group_id, "music")
+ if not audio_dirs:
return
- root_boundary = indexer.roots.resolve(audio_root, require_available=False)
+ # Resolved once per directory, not once per file: a library is
+ # thousands of entries and this is a filesystem call each time.
+ boundaries = {d: indexer.roots.resolve(d, require_available=False)
+ for d in audio_dirs}
for entry in entries:
if entry.type != "audio" or (indexer.group_id, entry.id) in self._enriched_attempted:
continue
- if not _under_audio_root(entry.path, audio_root):
+ owner = _owning_directory(entry.path, audio_dirs)
+ if owner is None:
continue
file_path = entry_abs_path(indexer.roots, entry)
if not file_path or not file_path.exists():
@@ -1347,13 +1387,16 @@ class NodeDaemon:
async def on_done(file_id: str, fields: dict, _indexer=indexer) -> None:
await self._on_enriched(_indexer, file_id, fields)
- # `root_boundary` — audio_root itself, not the shared root it
- # lives under — so the ancestor walk
+ # The boundary is *the configured directory this file is under*,
+ # not the shared root it lives in — so the ancestor walk
# (enrich_audio._artist_album_from_ancestors) treats a flat
- # top-level folder right under the *configured* Music root as
- # ambiguous (artist-or-release, §2.1), not one level too shallow
- # if audio_root is itself a subfolder of a larger shared root.
- self._audio_enricher.spawn(entry, file_path, on_done, root_boundary)
+ # top-level folder right under the configured Music directory as
+ # ambiguous (artist-or-release, musicbay.md §2.1), rather than one
+ # level too shallow when that directory is itself a subfolder.
+ # With several configured, each file is measured against its own:
+ # a single shared boundary would be wrong for all but one of them.
+ self._audio_enricher.spawn(entry, file_path, on_done,
+ boundaries.get(owner))
async def _enrich_audio_root_now(self, group_id: str) -> None:
"""
@@ -1403,13 +1446,13 @@ class NodeDaemon:
"""
if not self._photo_enricher or not self._roster:
return
- photo_roots = await self._roster.photo_roots(indexer.group_id)
- if not photo_roots:
+ photo_dirs = await self._roster.app_directories(indexer.group_id, "photo")
+ if not photo_dirs:
return
for entry in entries:
if entry.type != "image" or (indexer.group_id, entry.id) in self._enriched_attempted:
continue
- if not _under_any_photo_root(entry.path, photo_roots):
+ if not _under_any_directory(entry.path, photo_dirs):
continue
file_path = entry_abs_path(indexer.roots, entry)
if not file_path or not file_path.exists():
diff --git a/packages/meshbay-node/src/meshbay_node/ops.py b/packages/meshbay-node/src/meshbay_node/ops.py
index 13a7250..f7f6190 100644
--- a/packages/meshbay-node/src/meshbay_node/ops.py
+++ b/packages/meshbay-node/src/meshbay_node/ops.py
@@ -1205,76 +1205,171 @@ async def set_musicbrainz_enabled(state: dict, group_id: str, enabled: bool) ->
return {"enabled": enabled, "group_id": group_id}
-async def set_video_root(state: dict, group_id: str, path: str) -> dict:
+# ── App directories ──────────────────────────────────────────────────────────
+
+def _validate_app_dirs(state: dict, group_id: str, paths: list[str], *,
+ require_writable: bool) -> list[str]:
"""
- Which folder (possibly a subfolder of a shared root) is the Videos app's
- entry point for this group. Same shape as set_enabled_apps: lives on the
- node (roster.db), takes effect without a restart, signed by the operator.
- `path=""` clears it — the Videos tab then asks for one to be chosen
- before anything (including TMDB enrichment, docs/mediacenter.md §5.2)
- runs, rather than defaulting to the whole shared index.
+ Every path an app is pointed at must live inside one of the group's roots.
+
+ The per-app setters this replaces validated nothing: a typo, or a path left
+ behind by a root that was removed, was stored and then quietly matched no
+ entry — an app showing an empty tab with no way to tell "misconfigured"
+ from "no files yet". Refusing at the point of setting is the only moment
+ the operator is present to be told.
- A non-empty path fires (never awaits) a sweep of whatever that folder
- already contains: the ordinary per-change enrichment path only ever
- looks at files new since the last broadcast, so anything already sitting
- in a folder before it became the video_root would otherwise never be
- picked up.
+ Not `RootSet.resolve()`, deliberately: that also refuses a directory whose
+ root is currently *unavailable*, and an operator must be able to configure
+ a library on a drive they have unplugged. What is checked here is the
+ shape — inside a named root, no traversal — which does not change with
+ what happens to be mounted.
"""
- roster = _roster(state)
- ctx = _group_ctx(state, group_id)
- await roster.set_video_root(group_id, path, set_by=state.get("node_user_id", ""))
- ctx["video_root"] = path
- log.info("Videos root for group %s: %r", group_id[:8], path)
- if path:
- enrich_fn = state.get("enrich_video_root_fn")
- if enrich_fn:
- asyncio.ensure_future(enrich_fn(group_id))
- return {"path": path, "group_id": group_id}
+ roots: RootSet | None = _group_ctx(state, group_id).get("roots")
+ if roots is None:
+ raise OpError("Group has no roots", status=503)
+
+ clean: list[str] = []
+ for raw in paths:
+ path = str(raw or "").strip().strip("/")
+ if not path:
+ continue
+ if ".." in path.split("/"):
+ raise OpError(f"{path!r} is not a directory inside this group",
+ status=400)
+ found = roots.split(path)
+ if found is None:
+ raise OpError(
+ f"{path!r} is not inside any of this group's shared "
+ f"directories", status=400,
+ extra={"available": roots.names})
+ root, _tail = found
+ if require_writable and not root.writable:
+ raise OpError(
+ f"{root.name!r} is read-only, and this setting needs a "
+ f"directory that accepts uploads", status=400)
+ clean.append(path)
+ return sorted(set(clean))
-async def set_audio_root(state: dict, group_id: str, path: str) -> dict:
+async def set_app_directories(state: dict, group_id: str, app_key: str,
+ paths: list[str], *,
+ require_writable: bool = False) -> dict:
"""
- Same shape as set_video_root above — the Music app's own entry point,
- added later (docs/musicbay.md's original "no root, works over the
- whole shared tree" simplification didn't hold up against a real messy
- library). `path=""` clears it — the Music tab then asks for one to be
- chosen before anything (including tag/cover enrichment) runs, rather
- than defaulting to the whole shared index.
+ Which folder(s) inside the group's shared roots an application works over.
+
+ One function for every app, keyed by the app's own name: adding an
+ application is a registry entry and a settings component, not another
+ near-identical op here. It replaces `set_video_root`, `set_audio_root` and
+ `set_photo_roots`, which differed only in the key they wrote and whether
+ they took a string or a list.
+
+ Empty means nothing configured, which every app reads as "show nothing
+ until an operator has chosen" — never "the whole group index". Pointing an
+ app at the whole library is a decision, not a default nobody made.
+
+ A change always fires (never awaits) a sweep of what the new directories
+ already contain: the ordinary per-change enrichment path only looks at
+ entries new since the last broadcast, so files already sitting in a folder
+ when it was chosen would otherwise never be picked up.
"""
roster = _roster(state)
ctx = _group_ctx(state, group_id)
- await roster.set_audio_root(group_id, path, set_by=state.get("node_user_id", ""))
- ctx["audio_root"] = path
- log.info("Music root for group %s: %r", group_id[:8], path)
- if path:
- enrich_fn = state.get("enrich_audio_root_fn")
- if enrich_fn:
- asyncio.ensure_future(enrich_fn(group_id))
- return {"path": path, "group_id": group_id}
+ clean = _validate_app_dirs(state, group_id, paths,
+ require_writable=require_writable)
+ await roster.set_app_directories(group_id, app_key, clean,
+ set_by=state.get("node_user_id", ""))
+ ctx[f"{app_key}_directories"] = clean
+ # The scalar the handshake ack still publishes for MNP 1.0 clients is
+ # derived, and has to be re-derived here: leaving it behind would make the
+ # ack disagree with the list within a single run, and only until a restart
+ # — the shape of bug that reads as "it works after a restart".
+ from meshbay_node.roster import Roster
+ alias = Roster.ctx_alias(app_key, clean)
+ if alias:
+ ctx[alias[0]] = alias[1]
+ log.info("%s directories for group %s: %s", app_key, group_id[:8],
+ ", ".join(clean) or "(none)")
+
+ enrich = (state.get("enrich_app_dirs_fns") or {}).get(app_key)
+ if enrich:
+ asyncio.ensure_future(enrich(group_id))
+ return {"app": app_key, "directories": clean, "group_id": group_id}
+
+
+async def set_app_directory(state: dict, group_id: str, app_key: str,
+ path: str, *,
+ require_writable: bool = False) -> dict:
+ """
+ The single-directory form, for an app that only ever wants one.
+
+ Stored as a one-element list like every other app, because two storage
+ shapes for one idea is what made `video_root` (scalar) and `photo_roots`
+ (list) need separate ops, separate MNP messages and separate widgets to
+ say the same thing. `path=""` clears it.
+ """
+ result = await set_app_directories(
+ state, group_id, app_key, [path] if path else [],
+ require_writable=require_writable)
+ dirs = result["directories"]
+ return {**result, "path": dirs[0] if dirs else ""}
+
+
+# The per-app wrappers MNP still names. They exist so an MNP 1.0 client's
+# `video_root` / `audio_root` / `photo_roots` messages keep working; nothing
+# new should be added here — a new app calls the generic pair above.
+
+async def set_video_root(state: dict, group_id: str, path: str) -> dict:
+ result = await set_app_directory(state, group_id, "video", path)
+ return {"path": result["path"], "group_id": group_id}
+
+
+async def set_audio_root(state: dict, group_id: str, path: str) -> dict:
+ # "music", not "audio": the app's registry key is what identifies it
+ # everywhere, and `audio_root` is only the name the setting used to have.
+ result = await set_app_directory(state, group_id, "music", path)
+ return {"path": result["path"], "group_id": group_id}
async def set_photo_roots(state: dict, group_id: str, roots: list[str]) -> dict:
+ result = await set_app_directories(state, group_id, "photo", roots)
+ return {"roots": result["directories"], "group_id": group_id}
+
+
+# ── Chat ─────────────────────────────────────────────────────────────────────
+
+async def set_chat_directory(state: dict, group_id: str, path: str) -> dict:
"""
- Which folder(s) are the Photos app's entry points for this group. Unlike
- `set_video_root`/`set_audio_root`, the whole *set* is replaced in one
- call (docs/photos.md §2.1) — signed once, same shape as
- `set_enabled_apps`, rather than one op per root added/removed.
+ Where chat attachments are written.
- Always fires a sweep, even to an empty list: a root just added needs its
- existing contents enriched (nothing else re-visits already-indexed
- entries), and a root just removed leaves its cache entries harmlessly
- unused rather than needing any cleanup — re-sweeping the new set costs
- nothing when it's empty.
+ `require_writable`, unlike every other app directory: this one is a
+ *destination*, not a view. Pointing it at a read-only root would produce an
+ attachment button that fails at the moment somebody uses it, which is the
+ failure mode the RO/RW model exists to move earlier.
+ """
+ return await set_app_directory(state, group_id, "chat", path,
+ require_writable=True)
+
+
+async def set_chat_link_preview(state: dict, group_id: str,
+ enabled: bool) -> dict:
+ """
+ Whether the node fetches a page's title and image when a member posts a
+ link.
+
+ Outbound third-party traffic on the operator's connection, caused by a
+ message they did not write and pointing at a URL they did not choose — so
+ it is theirs to switch off, on the same reasoning as the per-group TMDB
+ switch. Absent means on, because that is what the node did before this
+ existed.
"""
roster = _roster(state)
ctx = _group_ctx(state, group_id)
- await roster.set_photo_roots(group_id, roots, set_by=state.get("node_user_id", ""))
- ctx["photo_roots"] = roots
- log.info("Photo roots for group %s: %s", group_id[:8], ", ".join(sorted(roots)) or "(none)")
- enrich_fn = state.get("enrich_photo_roots_fn")
- if enrich_fn:
- asyncio.ensure_future(enrich_fn(group_id))
- return {"roots": roots, "group_id": group_id}
+ await roster.set_chat_link_preview(group_id, enabled,
+ set_by=state.get("node_user_id", ""))
+ ctx["chat_link_preview"] = enabled
+ log.info("Chat link previews for group %s: %s", group_id[:8],
+ "on" if enabled else "off")
+ return {"enabled": enabled, "group_id": group_id}
# ── Scan settings ────────────────────────────────────────────────────────────
diff --git a/packages/meshbay-node/src/meshbay_node/roster.py b/packages/meshbay-node/src/meshbay_node/roster.py
index 5f38acd..af87f92 100644
--- a/packages/meshbay-node/src/meshbay_node/roster.py
+++ b/packages/meshbay-node/src/meshbay_node/roster.py
@@ -661,58 +661,110 @@ class Roster:
await self.set_setting(self.NODE_WIDE_GROUP_ID, self.SETTING_TMDB_LANGUAGE,
language, set_by)
- # Which folder is the Videos app's entry point for this group — per-group
- # (unlike the token/language above), since different groups share
- # different trees. Empty/unset means the whole group index, exactly as
- # today.
- SETTING_VIDEO_ROOT = "video_root"
-
- async def video_root(self, group_id: str) -> str:
- return await self.get_setting(group_id, self.SETTING_VIDEO_ROOT, "") or ""
+ # ── App directories ─────────────────────────────────────────────────────
+ #
+ # Which folder(s) inside the group's shared roots each application uses as
+ # its entry point. One storage shape for every app, keyed by the app's own
+ # name, so adding an application needs no change here at all — that is the
+ # whole point of the plugin architecture (docs/refactor-groups.md §1.6).
+ #
+ # Always a JSON list, even for an app that only ever wants one directory.
+ # Two shapes for one idea is how `video_root` (scalar) and `photo_roots`
+ # (list) ended up needing separate ops, separate MNP messages and separate
+ # settings widgets to say the same thing.
+ #
+ # Empty/unset means nothing configured yet, and every app reads that as
+ # "show nothing until an operator has chosen" rather than "the whole group
+ # index" — the discipline video_root established, kept.
+ SETTING_APP_DIRS_SUFFIX = "_directories"
- async def set_video_root(self, group_id: str, path: str, set_by: str = "") -> str:
- await self.set_setting(group_id, self.SETTING_VIDEO_ROOT, path or "", set_by)
- return path or ""
+ # What each app's directories used to be stored under, before they were
+ # one shape. Read as a fallback so an existing node keeps working with no
+ # migration step: the legacy key is never written again, and the first
+ # save through the new path leaves it behind.
+ # Keyed by the *registry* name the app is known by everywhere else
+ # (apps.js, ALLOWED_APPS, enabled_apps) — which for Music is "music", while
+ # its old setting was called `audio_root`. One identifier per app, and the
+ # place the two names meet is this table and nowhere else.
+ LEGACY_DIR_KEYS = {
+ "video": ("video_root", "scalar"),
+ "music": ("audio_root", "scalar"),
+ "photo": ("photo_roots", "list"),
+ }
- # Same shape as SETTING_VIDEO_ROOT — the Music app's own entry point,
- # added later (docs/musicbay.md's original "no root, works over the
- # whole shared tree" simplification turned out not to hold up against a
- # real messy library: the operator asked for the same scoping Videos
- # already had). Empty/unset means Music shows nothing yet, exactly like
- # an unset video_root — see daemon.py's enrichment gate.
- SETTING_AUDIO_ROOT = "audio_root"
+ # The name each app's directories are *also* published under, for readers
+ # that predate the list — the handshake ack's `video_root`, and the group
+ # context the ack builds from. Derived from the list, never stored beside
+ # it, so the two cannot disagree; the shape says how to derive it.
+ CTX_ALIASES = {
+ "video": ("video_root", "scalar"),
+ "music": ("audio_root", "scalar"),
+ "photo": ("photo_roots", "list"),
+ "chat": ("chat_directory", "scalar"),
+ }
- async def audio_root(self, group_id: str) -> str:
- return await self.get_setting(group_id, self.SETTING_AUDIO_ROOT, "") or ""
+ @classmethod
+ def app_dirs_key(cls, app_key: str) -> str:
+ return f"{app_key}{cls.SETTING_APP_DIRS_SUFFIX}"
- async def set_audio_root(self, group_id: str, path: str, set_by: str = "") -> str:
- await self.set_setting(group_id, self.SETTING_AUDIO_ROOT, path or "", set_by)
- return path or ""
+ @classmethod
+ def ctx_alias(cls, app_key: str, directories: list[str]) -> tuple[str, object] | None:
+ """The (name, value) an app's directories are also published under."""
+ alias = cls.CTX_ALIASES.get(app_key)
+ if not alias:
+ return None
+ name, shape = alias
+ if shape == "list":
+ return name, list(directories)
+ return name, (directories[0] if directories else "")
- # Which folder(s) are the Photos app's entry points for this group —
- # a *set*, unlike video_root/audio_root above: a photo library is
- # routinely scattered across several unrelated folders (docs/photos.md
- # §2.1), so there is no single natural root to pick. Stored the same way
- # `enabled_apps` already is (json.dumps(sorted(...))). Empty/unset means
- # nothing configured yet — same "show nothing until an operator has
- # chosen" discipline video_root/audio_root already established, not
- # "the whole group index".
- SETTING_PHOTO_ROOTS = "photo_roots"
+ async def app_directories(self, group_id: str, app_key: str) -> list[str]:
+ value = await self.get_setting(group_id, self.app_dirs_key(app_key))
+ if value is not None:
+ try:
+ return [str(p) for p in json.loads(value)]
+ except (ValueError, TypeError):
+ return []
- async def photo_roots(self, group_id: str) -> list[str]:
- value = await self.get_setting(group_id, self.SETTING_PHOTO_ROOTS)
- if value is None:
+ legacy = self.LEGACY_DIR_KEYS.get(app_key)
+ if not legacy:
return []
+ key, shape = legacy
+ raw = await self.get_setting(group_id, key)
+ if raw is None:
+ return []
+ if shape == "scalar":
+ return [raw] if raw else []
try:
- return list(json.loads(value))
+ return [str(p) for p in json.loads(raw)]
except (ValueError, TypeError):
return []
- async def set_photo_roots(self, group_id: str, roots: list[str],
- set_by: str = "") -> list[str]:
- await self.set_setting(group_id, self.SETTING_PHOTO_ROOTS,
- json.dumps(sorted(roots)), set_by)
- return roots
+ async def set_app_directories(self, group_id: str, app_key: str,
+ paths: list[str], set_by: str = "") -> list[str]:
+ clean = sorted({str(p).strip("/") for p in paths if str(p).strip("/")})
+ await self.set_setting(group_id, self.app_dirs_key(app_key),
+ json.dumps(clean), set_by)
+ return clean
+
+ # ── Chat ────────────────────────────────────────────────────────────────
+
+ # Whether the node fetches a page's title/preview when a member posts a
+ # link. Outbound third-party traffic on the operator's connection, from a
+ # message they did not write, so it is theirs to switch off — the same
+ # reasoning as the per-group TMDB switch. Unset means on, because that is
+ # what the node did before this existed.
+ SETTING_CHAT_LINK_PREVIEW = "chat_link_preview"
+
+ async def chat_link_preview(self, group_id: str) -> bool:
+ value = await self.get_setting(group_id, self.SETTING_CHAT_LINK_PREVIEW, "1")
+ return value != "0"
+
+ async def set_chat_link_preview(self, group_id: str, enabled: bool,
+ set_by: str = "") -> bool:
+ await self.set_setting(group_id, self.SETTING_CHAT_LINK_PREVIEW,
+ "1" if enabled else "0", set_by)
+ return enabled
# Whether TMDB lookups run for this group at all — per-group, unlike the
# token/language above: one node process can share a real media library
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
index b99affc..c4d053e 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
@@ -76,6 +76,9 @@ from meshbay_common.adminop import (
OP_MUSICBRAINZ_ENABLED,
OP_AUDIO_ROOT,
OP_PHOTO_ROOTS,
+ OP_APP_DIRECTORIES,
+ OP_CHAT_DIRECTORY,
+ OP_CHAT_LINK_PREVIEW,
OP_ROOT_ADD,
OP_ROOT_REMOVE,
OP_ROOT_UPDATE,
@@ -484,6 +487,12 @@ class WebRTCPeerSession:
self._do_audio_root(msg)
elif mtype == MNP.PHOTO_ROOTS:
self._do_photo_roots(msg)
+ elif mtype == MNP.APP_DIRECTORIES:
+ self._do_app_directories(msg)
+ elif mtype == MNP.CHAT_DIRECTORY:
+ self._do_chat_directory(msg)
+ elif mtype == MNP.CHAT_LINK_PREVIEW:
+ self._do_chat_link_preview(msg)
elif mtype == MNP.MEDIA_META_REQ:
self._spawn(self._do_media_meta_request(msg))
elif mtype == MNP.SEASON_META_REQ:
@@ -802,6 +811,23 @@ class WebRTCPeerSession:
# (docs/photos.md §2.1). Empty means unset (the Photos tab shows
# nothing yet).
"photo_roots": list(self._group_ctx().get("photo_roots") or []),
+ # The same three answers in one shape, plus every other app's —
+ # `<app>_directories`, keyed by the app's registry name, always a
+ # list. The scalars above are derived from these (daemon.py's
+ # `_app_directories_ctx`) and kept for MNP 1.0 clients, which can
+ # represent one folder and never more. A client reading the plural
+ # form gets all of them.
+ **{f"{app}_directories":
+ list(self._group_ctx().get(f"{app}_directories") or [])
+ for app in ("video", "music", "photo", "chat")},
+ # Where chat attachments are written — the singular form, because
+ # Chat genuinely has one destination. "" means the operator has not
+ # chosen, and the paperclip says so.
+ "chat_directory": self._group_ctx().get("chat_directory") or "",
+ # Whether the node unfurls links members post here. Absent means
+ # on, which is what it did before this existed.
+ "chat_link_preview": bool(
+ self._group_ctx().get("chat_link_preview", True)),
# So a client that connects mid-scan shows the indexing state
# immediately, instead of waiting for the next periodic
# INDEX_PROGRESS push. Never a path or filename — see
@@ -2096,6 +2122,150 @@ class WebRTCPeerSession:
except Exception:
pass
+ # ── App directories (generic) ────────────────────────────────────────
+
+ def _do_app_directories(self, msg: dict) -> None:
+ """
+ Which folder(s) an application works over, for any application.
+
+ One handler where there were three near-identical ones (`video_root`,
+ `audio_root`, `photo_roots`) differing only in the key they wrote and
+ whether they carried a string or a list. Those three still exist for
+ clients that speak them; nothing new is added beside them.
+
+ `app` must be one this node knows (`ALLOWED_APPS`) — a client-supplied
+ key is otherwise a way to write arbitrary rows into `group_settings`.
+ The paths are checked by `ops._validate_app_dirs`, which runs after the
+ signature: this is a settings change, not a capability, so refusing
+ early here would be a courtesy rather than the control.
+ """
+ app = str(msg.get("app", "")).strip()
+ dirs = msg.get("directories")
+ if app not in self.ALLOWED_APPS:
+ self._send({"type": "error", "detail": f"Unknown app {app!r}"})
+ return
+ if not isinstance(dirs, list) or not all(isinstance(d, str) for d in dirs):
+ self._send({"type": "error",
+ "detail": "Missing or invalid 'directories'"})
+ return
+ clean = sorted({d.strip("/") for d in dirs if d.strip("/")})
+ if not self._has_admin_authority():
+ self._send({"type": "error", "detail": "No authorized key for this"})
+ return
+ # The app is in the subject, not only the paths: an operator shown
+ # "Media/Films" alone cannot tell which application is about to be
+ # pointed at it, and two apps' challenges would be indistinguishable.
+ self._issue_admin_challenge(
+ OP_APP_DIRECTORIES, f"{app}:{','.join(clean)}")
+
+ async def _admin_exec_app_directories(
+ self, pending: dict, transcript: bytes, sig: bytes,
+ ) -> None:
+ app, _, joined = pending["subject"].partition(":")
+ dirs = joined.split(",") if joined else []
+ if not await self._verify_admin_sig(transcript, sig):
+ self._send({"type": "error", "detail": "Signature verification failed"})
+ self._audit("admin_auth_failed", f"app_directories:{pending['subject']}")
+ return
+ try:
+ result = await self._run_op(
+ ops.set_app_directories, self._group_id or "", app, dirs)
+ except ops.OpError as e:
+ self._send({"type": "error", "detail": e.message})
+ return
+ self._audit("app_directories", pending["subject"])
+ self._broadcast_to_group({"type": MNP.APP_DIRECTORIES_ACK,
+ "v": MNP_VERSION, "app": app,
+ "directories": result["directories"]})
+
+ # ── Chat ─────────────────────────────────────────────────────────────
+
+ def _do_chat_directory(self, msg: dict) -> None:
+ """
+ Where chat attachments are written.
+
+ Unlike every other app directory this one is a destination, so it has
+ to be on a read-write root — checked by `ops.set_chat_directory` after
+ the signature, which is where the refusal actually lives.
+ """
+ path = msg.get("path")
+ if not isinstance(path, str):
+ self._send({"type": "error", "detail": "Missing or invalid 'path'"})
+ return
+ path = path.strip("/")
+ if not self._has_admin_authority():
+ self._send({"type": "error", "detail": "No authorized key for this"})
+ return
+ self._issue_admin_challenge(OP_CHAT_DIRECTORY, path)
+
+ async def _admin_exec_chat_directory(
+ self, pending: dict, transcript: bytes, sig: bytes,
+ ) -> None:
+ path = pending["subject"]
+ if not await self._verify_admin_sig(transcript, sig):
+ self._send({"type": "error", "detail": "Signature verification failed"})
+ self._audit("admin_auth_failed", f"chat_directory:{path}")
+ return
+ try:
+ await self._run_op(
+ ops.set_chat_directory, self._group_id or "", path)
+ except ops.OpError as e:
+ self._send({"type": "error", "detail": e.message})
+ return
+ self._audit("chat_directory", path)
+ self._broadcast_to_group(
+ {"type": MNP.CHAT_DIRECTORY_ACK, "v": MNP_VERSION, "path": path})
+
+ def _do_chat_link_preview(self, msg: dict) -> None:
+ """
+ Whether the node fetches a page's title and image when a member posts
+ a link — outbound traffic on the operator's connection, from a message
+ they did not write, so it is signed like everything else that decides
+ what leaves this machine.
+ """
+ enabled = msg.get("enabled")
+ if not isinstance(enabled, bool):
+ self._send({"type": "error", "detail": "Missing or invalid 'enabled'"})
+ return
+ if not self._has_admin_authority():
+ self._send({"type": "error", "detail": "No authorized key for this"})
+ return
+ self._issue_admin_challenge(
+ OP_CHAT_LINK_PREVIEW, "on" if enabled else "off")
+
+ async def _admin_exec_chat_link_preview(
+ self, pending: dict, transcript: bytes, sig: bytes,
+ ) -> None:
+ enabled = pending["subject"] == "on"
+ if not await self._verify_admin_sig(transcript, sig):
+ self._send({"type": "error", "detail": "Signature verification failed"})
+ self._audit("admin_auth_failed",
+ f"chat_link_preview:{pending['subject']}")
+ return
+ try:
+ await self._run_op(
+ ops.set_chat_link_preview, self._group_id or "", enabled)
+ except ops.OpError as e:
+ self._send({"type": "error", "detail": e.message})
+ return
+ self._audit("chat_link_preview", pending["subject"])
+ self._broadcast_to_group({"type": MNP.CHAT_LINK_PREVIEW_ACK,
+ "v": MNP_VERSION, "enabled": enabled})
+
+ def _broadcast_to_group(self, notice: dict) -> None:
+ """
+ Tell everyone connected to this group about a setting that changed.
+
+ Enforcement never depends on this reaching them — the node is what
+ refuses — but a control that stays on screen until the next
+ reconnection is a control people use.
+ """
+ for _uid, session in list(self._peer_registry().items()):
+ try:
+ session._send(notice)
+ except Exception:
+ pass
+
def _do_musicbrainz_enabled(self, msg: dict) -> None:
"""
Whether MusicBrainz lookups run for this group at all. Per-group
@@ -3734,6 +3904,17 @@ class WebRTCPeerSession:
"""
url = msg.get("url")
key = url if isinstance(url, str) else ""
+
+ # Checked before the cache, not after: the operator turning previews
+ # off has to stop serving the ones already fetched too, or the setting
+ # takes effect only for links nobody has posted yet. Refused as an
+ # ordinary miss — the client shows the bare link, which is exactly what
+ # "no preview" looks like for a page that has none.
+ if not self._group_ctx().get("chat_link_preview", True):
+ self._send({"type": MNP.LINK_PREVIEW_RESP, "v": MNP_VERSION,
+ "url": key, "ok": False})
+ return
+
cached = _link_preview_cache_get(key)
if cached is not None:
self._send({**cached, "type": MNP.LINK_PREVIEW_RESP, "v": MNP_VERSION})
@@ -4155,6 +4336,15 @@ class WebRTCPeerSession:
elif pending["op"] == OP_ROOT_REMOVE:
self._spawn(
self._admin_exec_root_remove(pending, transcript, sig_bytes))
+ elif pending["op"] == OP_APP_DIRECTORIES:
+ self._spawn(
+ self._admin_exec_app_directories(pending, transcript, sig_bytes))
+ elif pending["op"] == OP_CHAT_DIRECTORY:
+ self._spawn(
+ self._admin_exec_chat_directory(pending, transcript, sig_bytes))
+ elif pending["op"] == OP_CHAT_LINK_PREVIEW:
+ self._spawn(
+ self._admin_exec_chat_link_preview(pending, transcript, sig_bytes))
elif pending["op"] == OP_ROOT_UPDATE:
self._spawn(
self._admin_exec_root_update(pending, transcript, sig_bytes))