aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-cli/src/meshbay_cli/commands/join.py
blob: 06494dd2fa623444c78d893e421b13fef43e04d3 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
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, terminal
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 terminal(sys.stdin):
                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