summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/invite-link.js
blob: f0b48369ea38193a2b98263eb4cd21baa3ed25b1 (plain) (blame)
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);