/** * 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 };