diff options
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/ops.py')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/ops.py | 201 |
1 files changed, 148 insertions, 53 deletions
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 ──────────────────────────────────────────────────────────── |