summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-cli/src/meshbay_cli/commands/join.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-cli/src/meshbay_cli/commands/join.py')
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/commands/join.py139
1 files changed, 139 insertions, 0 deletions
diff --git a/packages/meshbay-cli/src/meshbay_cli/commands/join.py b/packages/meshbay-cli/src/meshbay_cli/commands/join.py
new file mode 100644
index 0000000..ee75c96
--- /dev/null
+++ b/packages/meshbay-cli/src/meshbay_cli/commands/join.py
@@ -0,0 +1,139 @@
+"""
+`join`: become a member of a group, and be admitted by its node.
+
+Two halves, as in the application (docs/MESHBAY_DESIGN.md §3.4): the hub makes
+the account a member — an invitation accepted, or an invitation link redeemed —
+and the node admits it, wrapping the group key for a key pair made here and
+kept. The node is dialled at once, so the group can be used as soon as this
+returns. A link's code goes to the node the link names and to no other; a node
+that is away gets the code at the next connection, kept until then.
+"""
+
+import json
+import sys
+
+from ..client.groups import my_groups, open_group
+from ..client.hub import HubError
+from ..client.invite import parse_link
+from ..client.paths import group_arg, handle, resolve_group
+from ..client.session import DeviceLinkPending, NodeRefusal
+from ..errors import AUTH, NODE, NOT_FOUND, USAGE, CliError
+from ..output import err, safe, sentence
+from .context import Context
+from .invitations import find_invitation
+
+HELP = "join a group: accept an invitation, or use an invitation link"
+
+BROWSER_HINT = ("A browser will not open {group} with your passphrase: this machine "
+ "leaves no copy of your keys on its node. To leave one, as the "
+ "application does with Browser access on: meshbay-cli browser-access on")
+
+
+def add_parser(sub, parent) -> None:
+ p = sub.add_parser("join", parents=[parent], help=HELP, description=sentence(HELP)
+ + ". The group's node is reached at once and admits this "
+ "machine with its own keys.")
+ p.add_argument("target", metavar="INVITATION|LINK",
+ help="an invitation as `invitations` lists it, an invitation link as "
+ "it was sent, or a group of yours whose node asked for a code")
+ p.add_argument("--code", help="the invitation's code (XXXX-XXXX), when the node asks "
+ "for one")
+
+
+async def _membership(ctx: Context, account, target: str) -> tuple[str, str | None,
+ str | None]:
+ """The hub's half: the group's id, and the code and node a link carries."""
+ link = parse_link(target)
+ if link is not None:
+ if link.origin and link.origin.rstrip("/") != ctx.hub_url:
+ raise CliError(USAGE, f"This link was made on {link.origin}; run the command "
+ f"with --hub {link.origin}.")
+ try:
+ preview = await account.call(account.hub.preview_link, link.ticket)
+ if not preview.get("already_member"):
+ await account.call(account.hub.redeem_link, link.ticket)
+ except HubError as e:
+ if e.status in (403, 404):
+ raise CliError(NOT_FOUND, "This invitation link no longer works: used by "
+ "another account, cancelled, or expired. Ask "
+ "for a new one.") from e
+ raise
+ if not ctx.args.quiet and not ctx.args.json:
+ err(f"Joining {safe(preview.get('group_name') or '')}, invited by "
+ f"{safe(preview.get('inviter') or '?')}.")
+ return link.group_id, link.code, link.node_pk
+
+ invitations = await account.call(account.hub.invitations)
+ try:
+ group = find_invitation(invitations, target)
+ except CliError as e:
+ if e.code != NOT_FOUND:
+ raise
+ # Already a member: only the node's half is left to do.
+ group = resolve_group(await my_groups(account), group_arg(target))
+ return group["id"], None, None
+ try:
+ await account.call(account.hub.accept_invitation, group["id"])
+ except HubError as e:
+ if e.status == 404:
+ raise CliError(NOT_FOUND, "That invitation is no longer waiting.") from e
+ raise
+ return group["id"], None, None
+
+
+async def run(ctx: Context) -> int:
+ a = ctx.args
+ await ctx.hub.check_version()
+ account = ctx.account()
+ if not account.remembered:
+ raise CliError(AUTH, "Joining needs a saved session: the keys made for the group "
+ "are kept on this machine. Run `meshbay-cli login` first.")
+ group_id, link_code, node_pk = await _membership(ctx, account, a.target)
+ group = next((g for g in await my_groups(account) if g["id"] == group_id), None)
+ if group is None:
+ raise CliError(NOT_FOUND, "The hub did not make this account a member.")
+ name = handle(group)
+ code = (a.code or link_code or "").strip().upper() or None
+
+ while True:
+ try:
+ session = await open_group(account, group, join_code=code, node_pk=node_pk)
+ break
+ except NodeRefusal as e:
+ if e.refusal_code != "code_required" or code:
+ raise
+ if not sys.stdin.isatty():
+ raise CliError(NODE, f"You are a member of {safe(name)}; its node has not "
+ "admitted you yet and needs your invitation's code:\n"
+ f" meshbay-cli join {name} --code CODE") from e
+ sys.stderr.write(f"The node asks for your invitation's code to {name} "
+ "(XXXX-XXXX): ")
+ sys.stderr.flush()
+ code = sys.stdin.readline().strip().upper() or None
+ if not code:
+ raise CliError(USAGE, "No code given.") from e
+ except CliError as e:
+ if e.code != NODE or not code or isinstance(e, NodeRefusal | DeviceLinkPending):
+ raise
+ await account.keep_join_code(group_id, code, node_pk)
+ raise CliError(NODE, f"You are a member of {safe(name)}, but its node cannot "
+ f"be reached now ({e.message}). The code is kept and "
+ "sent at the next connection, such as:\n"
+ f" meshbay-cli ls {name}/") from e
+ await session.close()
+
+ if a.json:
+ print(json.dumps({"joined": True, "group": group["name"],
+ "owner": group.get("owner_username"), "id": group_id,
+ "path": name}))
+ else:
+ print(f"Joined {safe(name)}. Its files: meshbay-cli ls {safe(name)}/")
+ if account.browser_access is None and not a.quiet:
+ try:
+ prefs = await account.call(account.hub.preferences)
+ except (HubError, CliError):
+ prefs = {}
+ # The hub's word can only produce this line, never a copy of the keys.
+ if prefs.get("browser_access") != "off":
+ err(BROWSER_HINT.format(group=safe(name)))
+ return 0