aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-cli/src/meshbay_cli/client/invite.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-cli/src/meshbay_cli/client/invite.py')
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/client/invite.py50
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)