From 8f25294b0f6bc3f292442edd69a2e149f0717b52 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Mon, 5 Oct 2026 11:53:57 +0200 Subject: feat: open a group, a folder or a file from a #/name@owner link A group can now be reached by the handle shown under its name, and a path after it points inside the group: #/name@owner/root/dir/file downloads the file and opens Files on its folder; a folder opens Files there. The handle is resolved in the client against the account's own /v1/groups/mine, so no hub route answers for a name and nobody can probe for one. While a group is open the address shows the handle (replace, no history entry); a linked path is taken out of the address once acted on, so a reload does not download twice. Signing in no longer sends everyone home: the form stood in for the page the address named, and that is where a link opened signed out was going. group-link.js holds the parsing and lookups, executed whole by test_group_link.py; harness/group_link_probe.py drives the router in Chrome. Co-Authored-By: Claude Opus 5.5 --- .../src/meshbay_hub/static/group-link.js | 95 ++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 packages/meshbay-hub/src/meshbay_hub/static/group-link.js (limited to 'packages/meshbay-hub/src/meshbay_hub/static/group-link.js') 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/` 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 }; -- cgit v1.2.3