aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/group-link.js
blob: 3c9063c9aa47a9da5c6cfb20215953a8bd17039c (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
/**
 * A group named in the address: `#/name@owner`, optionally followed by a path
 * inside it — `#/name@owner/root/folder/file.jpg`.
 *
 * The same handle `GroupName` shows under every group, so a link reads the way
 * the group is labelled. It is resolved against the signed-in account's own
 * group list and nothing else: no hub route answers "which group is this
 * name", so a name says nothing to someone who is not already a member, and
 * nobody can probe for one. The UUID stays the group's identity; a renamed
 * group breaks its name links, never its `#/group/<id>` ones.
 *
 * In the fragment, never the path: what follows `#` is not sent to the server,
 * so a group's name and a file's path appear in no hub or proxy log and in no
 * Referer.
 *
 * The owner is everything after the *last* `@`: a username cannot contain one
 * (users.py, RegisterRequest), a group name can. Each segment is
 * percent-decoded on its own, so a `/` inside a name or a file name travels as
 * `%2F` without splitting the path.
 *
 * No imports, so `test_group_link.py` can execute the module as it is.
 */

function _decode(segment) {
  try { return decodeURIComponent(segment); } catch { return null; }
}

// `@` is legal in a fragment and the owner is found by the last one, so the
// name's own need no escaping; everything else is escaped as a URI component.
function _encode(segment) {
  return encodeURIComponent(segment).replace(/%40/g, '@');
}

/**
 * `{ name, owner, path }` for a route (the hash without its `#`), or null when
 * the route does not name a group. `path` is '' for the group itself.
 */
function parseGroupLink(route) {
  if (!route || route[0] !== '/') return null;
  const parts = route.slice(1).split('/');
  const head = _decode(parts[0]);
  if (!head) return null;
  const at = head.lastIndexOf('@');
  if (at <= 0 || at === head.length - 1) return null;
  const rest = parts.slice(1).filter(Boolean).map(_decode);
  if (rest.some((s) => s === null || s === '.' || s === '..')) return null;
  return { name: head.slice(0, at), owner: head.slice(at + 1), path: rest.join('/') };
}

/** The route naming `group`, and `path` inside it when one is given. */
function groupLinkRoute(group, path = '') {
  const head = `/${_encode(group.name)}@${_encode(group.owner_username || '')}`;
  const tail = path ? '/' + path.split('/').filter(Boolean).map(_encode).join('/') : '';
  return head + tail;
}

/**
 * The group `link` names among `groups` (`/v1/groups/mine` rows), or null.
 *
 * Case-insensitively on the name, as the hub keeps it unique
 * (`uq_groups_owner_name` is on `lower(name)`); the owner as typed first, then
 * case-insensitively when that finds exactly one.
 */
function findLinkedGroup(groups, link) {
  if (!link) return null;
  const name = link.name.toLowerCase();
  const named = (groups || []).filter((g) => (g.name || '').toLowerCase() === name);
  const exact = named.find((g) => g.owner_username === link.owner);
  if (exact) return exact;
  const owner = link.owner.toLowerCase();
  const loose = named.filter((g) => (g.owner_username || '').toLowerCase() === owner);
  return loose.length === 1 ? loose[0] : null;
}

/**
 * What `path` names in a group's index: `{ kind: 'file', entry }`,
 * `{ kind: 'dir', dir }`, or null. An entry's `path` is its folder, root
 * first, and `name` its file name — so a file is matched on both together.
 * Folders come from the files under them and from the node's own listing,
 * which is the only place an empty one appears.
 */
function resolveLinkedPath(entries, nodeDirs, path) {
  if (!path) return null;
  const cut = path.lastIndexOf('/');
  const dir = cut < 0 ? '' : path.slice(0, cut);
  const name = cut < 0 ? path : path.slice(cut + 1);
  const entry = (entries || []).find((e) => (e.path || '') === dir && e.name === name);
  if (entry) return { kind: 'file', entry };
  const prefix = path + '/';
  const isDir = (nodeDirs || []).includes(path)
    || (entries || []).some((e) => (e.path || '') === path || (e.path || '').startsWith(prefix));
  return isDir ? { kind: 'dir', dir: path } : null;
}

export { parseGroupLink, groupLinkRoute, findLinkedGroup, resolveLinkedPath };