summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/cli/members.py
blob: ba680561d7f46e3b32bee3e7bfa9b6c209bd0e91 (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
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
"""Who may use the node: members and the operator's own pairing."""

import sys
from urllib.parse import quote

from meshbay_node.cli.api import _daemon_api, _resolve_group
from meshbay_node.config import DEFAULT_CONFIG_PATH, load_config


def member(args) -> None:
    cfg = load_config(args.config or DEFAULT_CONFIG_PATH)
    sub = args.subcommand or "list"

    if sub == "list":
        group = args.group or ""
        out = _daemon_api(
            cfg, f"/api/roster?group_id={group}" if group else "/api/roster")
        identities = {i["user_id"]: i for i in out.get("identities", [])}

        members = out.get("members", [])
        if not members:
            print("no members admitted yet")
            print("invite someone:  meshbay-node member invite <username>")
        for m in members:
            ident = identities.get(m["user_id"], {})
            scope = m["group_id"][:8] if m["group_id"] else "node-wide"
            print(f"{(ident.get('username') or m['user_id'])[:20]:20} "
                  f"{m['role']:9} {m['status']:8} {scope:10} "
                  f"pinned {ident.get('pinned_at', '?')} "
                  f"({ident.get('pinned_via', '?')})")

        invites = out.get("invites", [])
        if invites:
            print()
            for i in invites:
                # A link names nobody until it is used, so its handle is what
                # identifies it — and what `cancel` takes.
                who = (f"link {i['invite_id']}" if i.get("kind") == "link"
                       else f"user {i['user_id'][:12]}")
                print(f"pending invite  {who}  "
                      f"group {(i['group_id'] or 'node-wide')[:8]}  "
                      f"expires {i['expires_at']}")
        return

    # `member upload` is gone: whether uploads are accepted is `writable`
    # on the root they would land in, not a per-group switch. Named
    # explicitly rather than left to the usage line below, which offered a
    # username for a verb that no longer takes one — an operator following
    # it would have got "unknown subcommand" and no idea what replaced it.
    if sub == "upload":
        print("`member upload` is gone. Uploads are decided per directory "
              "now:")
        print()
        print("  meshbay-node root list                    "
              "# which are read-write")
        print("  meshbay-node root set <name> --writable   "
              "# accept uploads there")
        print("  meshbay-node root set <name> --no-writable  # stop them")
        print()
        print("A group whose directories are all read-only accepts no "
              "uploads at all,")
        print("which is what turning the old switch off meant.")
        sys.exit(1)

    if not args.target:
        print(f"usage: meshbay-node member {sub} <username>")
        sys.exit(1)

    if sub == "invite" and args.link:
        # A link for someone who may have no account yet, bound to their
        # address on the hub. The code and the ticket are both in it, so
        # it goes to them and to nobody else — the CLI mails nothing.
        group_id = _resolve_group(cfg, args.group)
        out = _daemon_api(
            cfg, f"/api/groups/{group_id}/invite-links?email={quote(args.target)}",
            method="POST")
        from meshbay_node.roster import write_code_file
        write_code_file(cfg.data_dir, out["link"], out.get("expires_at", ""),
                        name="invite-link")
        print(f"INVITATION LINK  {out['link']}")
        print(f"valid until      {out.get('expires_at', '?')}")
        print(f"cancel with      meshbay-node member cancel {out['invite_id']}")
        print()
        print(f"Send it to {args.target} yourself. It works once, and only for an")
        print("account registered with that address: they open it, create their")
        print("account or sign in, and land in the group without typing a code.")
        return

    if sub == "invite":
        group_id = _resolve_group(cfg, args.group)
        out = _daemon_api(
            cfg, f"/api/groups/{group_id}/invites?username={args.target}",
            method="POST")
        from meshbay_node.roster import write_code_file
        path = write_code_file(cfg.data_dir, out["code"],
                               out.get("expires_at", ""), name="invite-code")
        print(f"INVITATION CODE  {out['code']}")
        print(f"valid until      {out.get('expires_at', '?')}")
        print()
        print(f"Send it to {args.target} however you normally talk. It works")
        print("once, for that account only, and never passes through the hub.")
        print("They enter it the first time they open the group — you do not")
        print("need to be online then.")
        print()
        print(f"also written to {path}")
        return

    if sub == "cancel":
        # Takes back a link that has not been used, on the node and the hub.
        group_id = _resolve_group(cfg, args.group)
        out = _daemon_api(
            cfg, f"/api/groups/{group_id}/invite-links/{quote(args.target)}",
            method="DELETE")
        print(f"invitation link {args.target[:8]} cancelled")
        if not out.get("hub", True):
            print("The hub's half could not be reached; it expires on its own.")
        return

    # revoke and unpin both name a person; the daemon resolves the account.
    # It tries its own roster first and falls back to the hub, so a node that
    # pinned someone before invitations carried a name is still manageable.
    match = _daemon_api(cfg, f"/api/resolve?username={args.target}")

    if sub == "revoke":
        group_id = _resolve_group(cfg, args.group)
        out = _daemon_api(
            cfg, f"/api/members/{match['user_id']}/revoke?group_id={group_id}",
            method="POST")
        print(f"{args.target} revoked from {group_id[:8]}")
        if out.get("invites_dropped"):
            print("Their unredeemed invitation was cancelled.")
        # The node decides whether a rotation is warranted and says so in
        # the reminder — somebody who never redeemed a code never held the
        # key, and advising a rotation there is advice to ignore the next
        # time it is real. Deciding it again here is the second
        # implementation this file exists not to have.
        if out.get("reminder"):
            print("They stop receiving the group key on their next connection.")
            print("They still hold the current one — rotate it:")
            print(f"  meshbay-node gek-init --group {group_id}")
        return

    if sub == "unpin":
        _daemon_api(cfg, f"/api/members/{match['user_id']}/unpin", method="POST")
        print(f"{args.target} unpinned — they can pair again with a new key")
        print(f"issue a code:  meshbay-node member invite {args.target}")
        return

    print("usage: meshbay-node member list|invite|cancel|revoke|unpin")
    sys.exit(1)


def operator(args) -> None:
    if args.subcommand != "pair":
        print("usage: meshbay-node operator pair")
        sys.exit(1)
    if args.group:
        # Silently ignoring it invited the reading that a code belongs to a
        # group, and then that pairing had not worked because the group did
        # not change.
        print("operator pair takes no --group: pairing is node-wide.")
        print("One paired browser can invite to, and delete files in, every")
        print("group this node hosts.")
        sys.exit(1)

    cfg = load_config(args.config or DEFAULT_CONFIG_PATH)
    out = _daemon_api(cfg, "/api/operator/pair", method="POST")

    from meshbay_node.roster import write_code_file
    path = write_code_file(cfg.data_dir, out["code"], out.get("expires_at", ""))

    print(f"PAIRING CODE   {out['code']}")
    print(f"valid until    {out.get('expires_at', '?')}")
    print()
    print("Sign in to the web app as this node's operator, open one of your")
    print("groups, go to the Members tab and enter the code there.")
    print("It works once, for that account only, and authorizes invites and")
    print("file deletion from that browser.")
    print()
    print(f"also written to {path}")
    return