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
|
/**
* Invitation links: their one shape, and the invitation waiting in this tab.
*
* https://<hub>/#/invite?v=1&g=<group>&t=<ticket>&n=<node key>&c=<CODE>
*
* docs/MESHBAY_DESIGN.md §3.4. Two secrets: the ticket is for the hub, which
* grants membership to the one account whose address it was sent to; the code
* is for the node the link names, and is never sent to the hub from here.
*
* Everything is after `#`, so no part of the link reaches the hub in a request.
* It is read the moment this module loads — before the router, before any
* `await` — and the address is rewritten to `#/invite`, so the code does not sit
* in the address bar, in a bookmark or in a screenshot. What was read is kept in
* this tab's `sessionStorage`: it has to survive registration, a reload and a
* sign-in, and nothing else. Not `localStorage`, which every tab shares and
* which outlives the tab. Cleared on success, refusal, expiry and sign-out.
*
* `api/invite_links.py` `invite_url` writes the same shape when the hub mails
* a link; `test_invite_link_client.py` holds the two to each other.
*/
import * as platform from './platform.js';
const STORE_KEY = 'mb.pendingInvite';
// A client-side bound on how long an unused invitation is carried. The node
// and the hub keep their own, shorter by default; this only stops a tab left
// open for a month from offering a link that died long ago.
const CARRY_MS = 30 * 24 * 3600 * 1000;
const GROUP = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const TICKET = /^[A-Za-z0-9_-]{22}$/;
const NODE_PK = /^[A-Za-z0-9_-]{43}$/;
const CODE = /^[0-9A-Za-z]{4}-[0-9A-Za-z]{4}$/;
/**
* `{g, t, n, c}` out of a link, a `#/invite?…` fragment or a pasted piece of
* text containing either — or null. Every field is checked for its exact shape,
* so nothing read here is ever more than an id, a token, a key and a code.
*/
export function parseInvite(text) {
const s = String(text || '').trim();
const at = s.indexOf('/invite?');
if (at < 0) return null;
const q = new URLSearchParams(s.slice(at + '/invite?'.length));
const inv = { g: q.get('g') || '', t: q.get('t') || '', n: q.get('n') || '',
c: (q.get('c') || '').toUpperCase() };
if (q.get('v') !== '1' || !GROUP.test(inv.g) || !TICKET.test(inv.t)
|| !NODE_PK.test(inv.n) || !CODE.test(inv.c)) return null;
return inv;
}
export function buildInviteLink(origin, { g, t, n, c }) {
return `${origin}/#/invite?v=1&g=${g}&t=${t}&n=${n}&c=${c}`;
}
/** The origin a pasted link points at, or '' if it is not a URL at all. */
export function linkOrigin(text) {
try { return new URL(String(text).trim()).origin; } catch { return ''; }
}
/** A node key as the transport and the hub write it (standard base64). */
export function nodePkFromLink(n) {
const b64 = n.replace(/-/g, '+').replace(/_/g, '/');
return b64 + '='.repeat((4 - (b64.length % 4)) % 4);
}
/** The same key as a link carries it: URL-safe, unpadded. */
export function nodePkForLink(b64) {
return String(b64).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
export function savePending(inv) {
try {
sessionStorage.setItem(STORE_KEY, JSON.stringify({ ...inv, exp: Date.now() + CARRY_MS }));
} catch { /* storage refused: the link can be opened again */ }
}
export function loadPending() {
try {
const inv = JSON.parse(sessionStorage.getItem(STORE_KEY) || 'null');
if (!inv || !(inv.exp > Date.now()) || !parseInvite(buildInviteLink('', inv))) {
clearPending();
return null;
}
return inv;
} catch {
return null;
}
}
export function clearPending() {
try { sessionStorage.removeItem(STORE_KEY); } catch { /* nothing kept */ }
}
/** The pending invitation, if it is for this group. */
export function pendingFor(groupId) {
const inv = loadPending();
return inv && inv.g === groupId ? inv : null;
}
/**
* Take an invitation out of the address, keep it, and leave `#/invite` behind.
*
* Runs on load and on every `hashchange` — a link pasted into the address bar
* of a tab already open. `replaceState` fires no `hashchange`, so the router
* reads the cleaned address, never the one with the code in it. A malformed
* link is cleaned out of the address too; it is simply not kept.
*/
export function captureFromLocation() {
const hash = window.location.hash || '';
if (!hash.startsWith('#/invite?')) return null;
const inv = parseInvite(hash);
window.history.replaceState(
null, '', window.location.pathname + window.location.search + '#/invite');
if (inv) savePending(inv);
return inv;
}
/** This hub's link for an invitation, for the person who creates it. */
export function inviteLinkHere(inv) {
return buildInviteLink(platform.hubOrigin(), inv);
}
captureFromLocation();
window.addEventListener('hashchange', captureFromLocation);
|