diff options
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/cli/members.py')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/cli/members.py | 181 |
1 files changed, 181 insertions, 0 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/cli/members.py b/packages/meshbay-node/src/meshbay_node/cli/members.py new file mode 100644 index 0000000..ba68056 --- /dev/null +++ b/packages/meshbay-node/src/meshbay_node/cli/members.py @@ -0,0 +1,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 |