diff options
Diffstat (limited to 'packages/meshbay-cli/src/meshbay_cli/client/invite.py')
| -rw-r--r-- | packages/meshbay-cli/src/meshbay_cli/client/invite.py | 50 |
1 files changed, 50 insertions, 0 deletions
diff --git a/packages/meshbay-cli/src/meshbay_cli/client/invite.py b/packages/meshbay-cli/src/meshbay_cli/client/invite.py new file mode 100644 index 0000000..6b26c97 --- /dev/null +++ b/packages/meshbay-cli/src/meshbay_cli/client/invite.py @@ -0,0 +1,50 @@ +""" +Invitation links: `https://<hub>/#/invite?v=1&g=<group>&t=<ticket>&n=<node key>&c=<CODE>`. + +The shape `static/invite-link.js` reads and `api/invite_links.py` writes +(docs/MESHBAY_DESIGN.md §3.4). Two secrets: the ticket is for the hub, which +makes the first account to redeem it a member; the code is for the node the +link names, and goes to no other node and never to the hub. Every field is +checked for its exact shape, as the application checks it. +""" + +import re +from dataclasses import dataclass +from urllib.parse import parse_qs, urlsplit + +_GROUP = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$") +_TICKET = re.compile(r"^[A-Za-z0-9_-]{22}$") +_NODE_PK = re.compile(r"^[A-Za-z0-9_-]{43}$") +_CODE = re.compile(r"^[0-9A-Za-z]{4}-[0-9A-Za-z]{4}$") + + +@dataclass +class InviteLink: + origin: str # the hub the link was made on, '' when only the fragment was given + group_id: str + ticket: str + node_pk: str # standard base64, as the hub lists node keys + code: str + + +def node_pk_from_link(n: str) -> str: + """The link's URL-safe, unpadded key, as the hub and the transport write it.""" + b64 = n.replace("-", "+").replace("_", "/") + return b64 + "=" * (-len(b64) % 4) + + +def parse_link(text: str) -> InviteLink | None: + """The link in `text`, or None when it holds none (or a malformed one).""" + s = text.strip() + at = s.find("/invite?") + if at < 0: + return None + q = {k: v[0] for k, v in parse_qs(s[at + len("/invite?"):]).items()} + g, t, n = q.get("g", ""), q.get("t", ""), q.get("n", "") + c = q.get("c", "").upper() + if q.get("v") != "1" or not (_GROUP.match(g) and _TICKET.match(t) + and _NODE_PK.match(n) and _CODE.match(c)): + return None + parts = urlsplit(s) + origin = f"{parts.scheme}://{parts.netloc}" if parts.scheme and parts.netloc else "" + return InviteLink(origin, g, t, node_pk_from_link(n), c) |