"""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 ") 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 --writable " "# accept uploads there") print(" meshbay-node root set --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} ") 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