aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/group-link.js
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/group-link.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-link.js95
1 files changed, 95 insertions, 0 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-link.js b/packages/meshbay-hub/src/meshbay_hub/static/group-link.js
new file mode 100644
index 0000000..3c9063c
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-link.js
@@ -0,0 +1,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 };