aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-11 14:55:52 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-11 14:55:52 +0200
commitcc90dc943fcd0e7bbf52674fb3f95ff097026f4a (patch)
tree6315a5e966c027c50b92d59e60c4b506a418143e /packages/meshbay-hub/src/meshbay_hub
parentedde9e441fb6b84e9d56215d6e2a8d9338b8f962 (diff)
downloadmeshbay-cc90dc943fcd0e7bbf52674fb3f95ff097026f4a.tar.gz
feat: Phase 10b — Self-service UI (group create/join, upload, IndexedDB, search)
Six self-service features for the web SPA: - Group creation UI with GEK auto-generation (AES-256-GCM ECIES) - Member management + invite by username (GEK wrapping for invitee) - Open group self-join flow (POST /v1/groups/{id}/join) - File upload client→node (FILE_UPLOAD MNP type, .uploads/ staging) - IndexedDB caching of group file indexes (instant display on revisit) - Cross-group file search (SearchPage, pure client-side on cached indexes) 11 new tests (166 total): 8 group self-service + 3 AES GEK wrap/unwrap. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/groups.py53
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js423
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/crypto.js79
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/i18n.js41
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js12
5 files changed, 597 insertions, 11 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/groups.py b/packages/meshbay-hub/src/meshbay_hub/api/groups.py
index a8a45c9..e0ea016 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/groups.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/groups.py
@@ -162,6 +162,59 @@ async def swarm_sources(
}
+@router.get("/{group_id}/members")
+async def group_members(
+ group_id: str,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ group = await db.get(Group, group_id)
+ if not group:
+ raise HTTPException(status_code=404, detail="Group not found")
+
+ mem = await db.get(GroupMember, (group_id, current_user.id))
+ if not mem:
+ raise HTTPException(status_code=403, detail="Not a member")
+
+ result = await db.execute(
+ select(User.id, User.username)
+ .join(GroupMember, User.id == GroupMember.user_id)
+ .where(GroupMember.group_id == group_id)
+ )
+ members = [{"user_id": uid, "username": uname} for uid, uname in result.all()]
+ return {
+ "group_id": group_id,
+ "admin_id": group.admin_id,
+ "members": members,
+ }
+
+
+@router.post("/{group_id}/join")
+async def join_group(
+ group_id: str,
+ request: Request,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ group = await db.get(Group, group_id)
+ if not group:
+ raise HTTPException(status_code=404, detail="Group not found")
+ if group.status != "active":
+ raise HTTPException(status_code=403, detail="Group is not active")
+ if group.join_policy != "open":
+ raise HTTPException(status_code=403, detail="Group does not allow open joining")
+
+ existing = await db.get(GroupMember, (group_id, current_user.id))
+ if existing:
+ raise HTTPException(status_code=409, detail="Already a member")
+
+ db.add(GroupMember(group_id=group_id, user_id=current_user.id))
+ db.add(IPLog(user_id=current_user.id, event="group_join",
+ ip_address=_ip(request), detail=group.name))
+ await db.commit()
+ return {"status": "joined", "group_id": group_id, "name": group.name}
+
+
class GroupCreateRequest(BaseModel):
name: str
visibility: str = "private" # public|private
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index 4ef334b..dda4a09 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -9,6 +9,59 @@ import { t, getLocale, setLocale, LOCALES } from './i18n.js';
const HUB = '';
const AUTH_KEY = 'mb_auth';
const THEME_KEY = 'mb_theme';
+const IDB_NAME = 'meshbay';
+const IDB_VERSION = 1;
+const IDB_STORE = 'group_indexes';
+
+// ── IndexedDB cache ─────────────────────────────────────────────────────────
+
+function openDB() {
+ return new Promise((resolve, reject) => {
+ const req = indexedDB.open(IDB_NAME, IDB_VERSION);
+ req.onupgradeneeded = () => {
+ const db = req.result;
+ if (!db.objectStoreNames.contains(IDB_STORE)) {
+ db.createObjectStore(IDB_STORE, { keyPath: 'groupId' });
+ }
+ };
+ req.onsuccess = () => resolve(req.result);
+ req.onerror = () => reject(req.error);
+ });
+}
+
+async function cacheGroupIndex(groupId, groupName, entries) {
+ try {
+ const db = await openDB();
+ const tx = db.transaction(IDB_STORE, 'readwrite');
+ tx.objectStore(IDB_STORE).put({
+ groupId, groupName, entries, cachedAt: Date.now(),
+ });
+ await new Promise((r, rej) => { tx.oncomplete = r; tx.onerror = rej; });
+ db.close();
+ } catch { /* best-effort */ }
+}
+
+async function getCachedGroupIndex(groupId) {
+ try {
+ const db = await openDB();
+ const tx = db.transaction(IDB_STORE, 'readonly');
+ const req = tx.objectStore(IDB_STORE).get(groupId);
+ const result = await new Promise((r, rej) => { req.onsuccess = () => r(req.result); req.onerror = rej; });
+ db.close();
+ return result || null;
+ } catch { return null; }
+}
+
+async function getAllCachedIndexes() {
+ try {
+ const db = await openDB();
+ const tx = db.transaction(IDB_STORE, 'readonly');
+ const req = tx.objectStore(IDB_STORE).getAll();
+ const result = await new Promise((r, rej) => { req.onsuccess = () => r(req.result); req.onerror = rej; });
+ db.close();
+ return result || [];
+ } catch { return []; }
+}
// ── Auth persistence ─────────────────────────────────────────────────────────
@@ -139,6 +192,10 @@ function Sidebar({ groups, route, menuOpen, role }) {
<div class="sidebar-heading">${t('sidebar.discover')}</div>
<a class="sidebar-item ${route === '/explore' ? 'active' : ''}"
href="#/explore">${t('sidebar.public_groups')}</a>
+ <a class="sidebar-item ${route === '/search' ? 'active' : ''}"
+ href="#/search">${t('sidebar.search')}</a>
+ <a class="sidebar-item ${route === '/create-group' ? 'active' : ''}"
+ href="#/create-group">${t('sidebar.create_group')}</a>
<a class="sidebar-item ${route === '/settings' ? 'active' : ''}"
href="#/settings">${t('sidebar.settings')}</a>
${isStaff && html`
@@ -333,10 +390,11 @@ function HomePage({ groups, notifications, onMarkRead }) {
// ── Explore Page ─────────────────────────────────────────────────────────────
-function ExplorePage({ token }) {
+function ExplorePage({ token, myGroupIds }) {
const [groups, setGroups] = useState([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
+ const [joining, setJoining] = useState(null);
const doSearch = useCallback((q) => {
setLoading(true);
@@ -355,9 +413,26 @@ function ExplorePage({ token }) {
doSearch(q);
}, [doSearch]);
+ const joinGroup = useCallback(async (gid) => {
+ setJoining(gid);
+ try {
+ await hubFetch(`/v1/groups/${gid}/join`, { method: 'POST', token });
+ window.location.reload();
+ } catch (err) {
+ alert(err.message);
+ } finally {
+ setJoining(null);
+ }
+ }, [token]);
+
+ const isMember = (gid) => myGroupIds && myGroupIds.includes(gid);
+
return html`
<div>
- <h2>${t('explore.title')}</h2>
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px">
+ <h2 style="margin:0">${t('explore.title')}</h2>
+ <a class="admin-btn" href="#/create-group">${t('explore.create_group')}</a>
+ </div>
<div class="file-toolbar" style="margin-bottom:16px">
<input type="text" class="admin-search" placeholder="${t('explore.search')}"
value=${search} onInput=${onSearch} />
@@ -369,13 +444,26 @@ function ExplorePage({ token }) {
: html`
<div class="group-grid">
${groups.map(g => html`
- <a key=${g.id} class="group-card" href="#/group/${g.id}">
- <h3>${g.name}</h3>
+ <div key=${g.id} class="group-card">
+ <a href="#/group/${g.id}" style="text-decoration:none;color:inherit">
+ <h3>${g.name}</h3>
+ </a>
<span class="badge">${g.join_policy}</span>
${g.source && g.source !== 'local' && html`
${' '}<span class="badge">${g.source}</span>
`}
- </a>
+ ${' '}
+ ${isMember(g.id)
+ ? html`<span class="badge">${t('explore.member')}</span>`
+ : g.join_policy === 'open' && html`
+ <button class="admin-btn" style="margin-top:8px"
+ disabled=${joining === g.id}
+ onClick=${() => joinGroup(g.id)}>
+ ${joining === g.id ? '...' : t('explore.join')}
+ </button>
+ `
+ }
+ </div>
`)}
</div>
`
@@ -384,6 +472,82 @@ function ExplorePage({ token }) {
`;
}
+// ── Create Group Page ────────────────────────────────────────────────────────
+
+function CreateGroupPage({ token, onCreated }) {
+ const [name, setName] = useState('');
+ const [visibility, setVisibility] = useState('private');
+ const [joinPolicy, setJoinPolicy] = useState('invite');
+ const [error, setError] = useState('');
+ const [loading, setLoading] = useState(false);
+
+ const onSubmit = async (e) => {
+ e.preventDefault();
+ if (!name.trim()) return;
+ setLoading(true);
+ setError('');
+ try {
+ const data = await hubFetch('/v1/groups', {
+ method: 'POST', token,
+ body: { name: name.trim(), visibility, join_policy: joinPolicy },
+ });
+
+ if (window.MeshBayCrypto && window.MeshBayKeys && _sessionKeys) {
+ const gek = window.MeshBayCrypto.generateGEK();
+ const skXB64 = _sessionKeys.skXB64;
+ const skXRaw = Uint8Array.from(atob(skXB64), c => c.charCodeAt(0));
+ const skX = await crypto.subtle.importKey(
+ 'pkcs8', skXRaw, { name: 'X25519' }, true, ['deriveBits']);
+ const pkXRaw = new Uint8Array(
+ await crypto.subtle.exportKey('raw',
+ (await crypto.subtle.generateKey({ name: 'X25519' }, true, ['deriveBits'])).publicKey));
+
+ const meResp = await hubFetch('/v1/users/me', { token });
+ const pubkeys = await hubFetch(`/v1/users/${meResp.username}/pubkeys`);
+ const pkXBytes = Uint8Array.from(atob(pubkeys.pk_x25519), c => c.charCodeAt(0));
+
+ const bundle = await window.MeshBayCrypto.wrapGEK(gek, pkXBytes);
+ await hubFetch(`/v1/groups/${data.group_id}/members/${meResp.username}/gek`, {
+ method: 'POST', token, body: bundle,
+ });
+ }
+
+ if (onCreated) onCreated();
+ navigate('/');
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return html`
+ <div class="page-center">
+ <h2>${t('create_group.title')}</h2>
+ ${error && html`<p class="error-msg">${error}</p>`}
+ <form class="login-form" onSubmit=${onSubmit}>
+ <input type="text" placeholder="${t('create_group.name')}"
+ value=${name} onInput=${e => setName(e.target.value)} required />
+ <label class="settings-label">${t('create_group.visibility')}</label>
+ <select class="settings-select" value=${visibility}
+ onChange=${e => setVisibility(e.target.value)}>
+ <option value="private">${t('create_group.private')}</option>
+ <option value="public">${t('create_group.public')}</option>
+ </select>
+ <label class="settings-label">${t('create_group.join_policy')}</label>
+ <select class="settings-select" value=${joinPolicy}
+ onChange=${e => setJoinPolicy(e.target.value)}>
+ <option value="invite">${t('create_group.invite')}</option>
+ <option value="open">${t('create_group.open')}</option>
+ </select>
+ <button type="submit" disabled=${loading}>
+ ${loading ? t('create_group.creating') : t('create_group.submit')}
+ </button>
+ </form>
+ </div>
+ `;
+}
+
// ── Helpers ──────────────────────────────────────────────────────────────────
const FILE_ICONS = {
@@ -449,6 +613,7 @@ async function pipelinedDownload(transport, gekKey, fileId, totalChunks, onChunk
function GroupPage({ groupId, group, token, username }) {
const [status, setStatus] = useState('idle');
const [entries, setEntries] = useState([]);
+ const [cached, setCached] = useState(false);
const [error, setError] = useState('');
const [sortKey, setSortKey] = useState('name');
const [sortAsc, setSortAsc] = useState(true);
@@ -457,15 +622,23 @@ function GroupPage({ groupId, group, token, username }) {
const [dlState, setDlState] = useState(null);
const [videoEntry, setVideoEntry] = useState(null);
const [tab, setTab] = useState('files');
+ const [uploading, setUploading] = useState(false);
const transportRef = useRef(null);
const gekRef = useRef(null);
useEffect(() => {
let cancelled = false;
+
+ getCachedGroupIndex(groupId).then(hit => {
+ if (hit && !cancelled) {
+ setEntries(hit.entries || []);
+ setCached(true);
+ }
+ });
+
const connect = async () => {
setStatus('discovering');
setError('');
- setEntries([]);
gekRef.current = null;
try {
const nodesData = await hubFetch(`/v1/groups/${groupId}/nodes`, { token });
@@ -486,8 +659,12 @@ function GroupPage({ groupId, group, token, username }) {
const indexMsg = await transport.fetchIndex();
if (cancelled) return;
- setEntries(indexMsg.entries || []);
+ const freshEntries = indexMsg.entries || [];
+ setEntries(freshEntries);
+ setCached(false);
setStatus('connected');
+
+ cacheGroupIndex(groupId, group ? group.name : groupId, freshEntries);
} catch (err) {
if (!cancelled) {
setError(err.message);
@@ -563,6 +740,30 @@ function GroupPage({ groupId, group, token, username }) {
}
}, []);
+ const uploadFile = useCallback(async (e) => {
+ const file = e.target.files?.[0];
+ if (!file) return;
+ e.target.value = '';
+ const transport = transportRef.current;
+ if (!transport || !transport.connected) return;
+ setUploading(true);
+ setError('');
+ try {
+ const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
+ for (let i = 0; i < totalChunks; i++) {
+ const slice = file.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE);
+ const buf = new Uint8Array(await slice.arrayBuffer());
+ await transport.uploadChunk(file.name, i, totalChunks, buf);
+ }
+ const indexMsg = await transport.fetchIndex();
+ setEntries(indexMsg.entries || []);
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setUploading(false);
+ }
+ }, []);
+
const toggleSort = useCallback((key) => {
setSortAsc(prev => sortKey === key ? !prev : true);
setSortKey(key);
@@ -594,7 +795,7 @@ function GroupPage({ groupId, group, token, username }) {
const subdirs = [...dirs].sort();
- const statusLabel = {
+ const baseLabel = {
idle: t('status.idle'),
discovering: t('status.discovering'),
connecting: t('status.connecting'),
@@ -603,6 +804,9 @@ function GroupPage({ groupId, group, token, username }) {
offline: t('status.offline'),
error: t('status.error'),
}[status] || status;
+ const statusLabel = (cached && status !== 'connected')
+ ? `${baseLabel} (${t('status.cached', { n: entries.length })})`
+ : baseLabel;
const statusClass = status === 'connected' ? 'status-ok'
: status === 'error' || status === 'offline' ? 'status-err' : 'status-busy';
@@ -631,10 +835,17 @@ function GroupPage({ groupId, group, token, username }) {
onClick=${() => setTab('files')}>${t('group.tab_files')}</button>
<button class="group-tab ${tab === 'chat' ? 'active' : ''}"
onClick=${() => setTab('chat')}>${t('group.tab_chat')}</button>
+ <button class="group-tab ${tab === 'members' ? 'active' : ''}"
+ onClick=${() => setTab('members')}>${t('group.tab_members')}</button>
</div>
${tab === 'files' && html`
<div class="file-toolbar">
+ <label class="admin-btn upload-btn" style="cursor:pointer;margin-right:8px">
+ ${uploading ? t('group.uploading') : t('group.upload')}
+ <input type="file" style="display:none" onChange=${uploadFile}
+ disabled=${uploading} />
+ </label>
<div class="breadcrumbs">
<a class="crumb" onClick=${() => setCurrentPath('')}>/</a>
${breadcrumbs.map((seg, i) => {
@@ -712,6 +923,10 @@ function GroupPage({ groupId, group, token, username }) {
${tab === 'chat' && html`
<${ChatPanel} transportRef=${transportRef} username=${username} />
`}
+
+ ${tab === 'members' && html`
+ <${MembersPanel} groupId=${groupId} group=${group} token=${token} />
+ `}
`}
${status === 'offline' && html`
<p class="page-message">
@@ -740,6 +955,107 @@ function _b64ToU8(b64) {
return arr;
}
+// ── Members Panel ────────────────────────────────────────────────────────
+
+function MembersPanel({ groupId, group, token }) {
+ const [members, setMembers] = useState([]);
+ const [adminId, setAdminId] = useState('');
+ const [loading, setLoading] = useState(true);
+ const [inviteUser, setInviteUser] = useState('');
+ const [inviting, setInviting] = useState(false);
+ const [error, setError] = useState('');
+
+ const loadMembers = useCallback(() => {
+ setLoading(true);
+ hubFetch(`/v1/groups/${groupId}/members`, { token })
+ .then(data => {
+ setMembers(data.members || []);
+ setAdminId(data.admin_id || '');
+ })
+ .catch(() => {})
+ .finally(() => setLoading(false));
+ }, [groupId, token]);
+
+ useEffect(() => { loadMembers(); }, [loadMembers]);
+
+ const isAdmin = group && group.is_admin;
+
+ const doInvite = useCallback(async (e) => {
+ e.preventDefault();
+ if (!inviteUser.trim()) return;
+ setInviting(true);
+ setError('');
+ try {
+ const pubkeys = await hubFetch(`/v1/users/${inviteUser.trim()}/pubkeys`);
+ const pkXBytes = Uint8Array.from(atob(pubkeys.pk_x25519), c => c.charCodeAt(0));
+
+ const gekB64 = await (async () => {
+ const transport = window._activeTransport;
+ if (transport && transport.connected) {
+ return await transport.fetchGEK();
+ }
+ const bundleResp = await hubFetch(`/v1/groups/${groupId}/gek`, { token });
+ if (!_sessionKeys) throw new Error('No session keys — log in via browser registration');
+ const skXB64 = _sessionKeys.skXB64;
+ const skXRaw = Uint8Array.from(atob(skXB64), c => c.charCodeAt(0));
+ const meResp = await hubFetch('/v1/users/me', { token });
+ const myPubkeys = await hubFetch(`/v1/users/${meResp.username}/pubkeys`);
+ const myPkX = Uint8Array.from(atob(myPubkeys.pk_x25519), c => c.charCodeAt(0));
+ const rawGek = await window.MeshBayCrypto.unwrapGEK(bundleResp, skXRaw, myPkX);
+ return btoa(String.fromCharCode(...rawGek));
+ })();
+
+ const gekBytes = Uint8Array.from(atob(gekB64), c => c.charCodeAt(0));
+ const bundle = await window.MeshBayCrypto.wrapGEK(gekBytes, pkXBytes);
+ await hubFetch(`/v1/groups/${groupId}/members/${inviteUser.trim()}/gek`, {
+ method: 'POST', token, body: bundle,
+ });
+ setInviteUser('');
+ loadMembers();
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setInviting(false);
+ }
+ }, [groupId, token, inviteUser, loadMembers]);
+
+ if (loading) return html`<p class="page-message">${t('explore.loading')}</p>`;
+
+ return html`
+ <div class="members-panel">
+ <table class="admin-table">
+ <thead>
+ <tr>
+ <th>${t('admin.col_username')}</th>
+ <th>${t('members.col_role')}</th>
+ </tr>
+ </thead>
+ <tbody>
+ ${members.map(m => html`
+ <tr key=${m.user_id}>
+ <td>${m.username}</td>
+ <td>${m.user_id === adminId ? t('members.admin') : t('members.member')}</td>
+ </tr>
+ `)}
+ </tbody>
+ </table>
+ ${isAdmin && html`
+ <form class="invite-form" onSubmit=${doInvite}>
+ <h4>${t('members.invite_title')}</h4>
+ ${error && html`<p class="error-msg">${error}</p>`}
+ <div style="display:flex;gap:8px">
+ <input type="text" placeholder="${t('members.username_placeholder')}"
+ value=${inviteUser} onInput=${e => setInviteUser(e.target.value)} required />
+ <button class="admin-btn" type="submit" disabled=${inviting}>
+ ${inviting ? '...' : t('members.invite_btn')}
+ </button>
+ </div>
+ </form>
+ `}
+ </div>
+ `;
+}
+
// ── Chat Panel ──────────────────────────────────────────────────────────
function formatTime(ts) {
@@ -976,6 +1292,85 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose }) {
`;
}
+// ── Search Page (cross-group file search) ───────────────────────────────────
+
+function SearchPage() {
+ const [query, setQuery] = useState('');
+ const [results, setResults] = useState([]);
+ const [searched, setSearched] = useState(false);
+
+ const doSearch = useCallback(async (q) => {
+ const term = q.trim().toLowerCase();
+ if (!term) { setResults([]); setSearched(false); return; }
+ const indexes = await getAllCachedIndexes();
+ const hits = [];
+ for (const idx of indexes) {
+ for (const e of (idx.entries || [])) {
+ if (e.name.toLowerCase().includes(term) ||
+ (e.path && e.path.toLowerCase().includes(term))) {
+ hits.push({ ...e, groupId: idx.groupId, groupName: idx.groupName });
+ }
+ }
+ }
+ setResults(hits);
+ setSearched(true);
+ }, []);
+
+ const onInput = useCallback((e) => {
+ const q = e.target.value;
+ setQuery(q);
+ doSearch(q);
+ }, [doSearch]);
+
+ return html`
+ <div>
+ <h2>${t('search.title')}</h2>
+ <div class="file-toolbar" style="margin-bottom:16px">
+ <input type="text" class="admin-search" style="width:100%"
+ placeholder="${t('search.placeholder')}"
+ value=${query} onInput=${onInput} autofocus />
+ </div>
+ ${searched && results.length === 0 && html`
+ <p class="page-message">${t('search.no_results')}</p>
+ `}
+ ${results.length > 0 && html`
+ <table class="file-table">
+ <thead>
+ <tr>
+ <th></th>
+ <th>${t('group.col_name')}</th>
+ <th>${t('group.col_size')}</th>
+ <th>${t('search.col_group')}</th>
+ <th>${t('group.col_type')}</th>
+ </tr>
+ </thead>
+ <tbody>
+ ${results.map(r => html`
+ <tr class="file-row" key=${r.id + r.groupId}>
+ <td>${FILE_ICONS[r.type] || FILE_ICONS.other}</td>
+ <td class="file-name">
+ <a href="#/group/${r.groupId}" style="color:inherit">${r.name}</a>
+ </td>
+ <td class="file-size">${formatSize(r.size)}</td>
+ <td>
+ <a href="#/group/${r.groupId}" class="badge">${r.groupName || r.groupId.slice(0, 8)}</a>
+ </td>
+ <td class="file-type">${r.type}</td>
+ </tr>
+ `)}
+ </tbody>
+ </table>
+ <p class="settings-value" style="margin-top:8px">
+ ${t('search.result_count', { n: results.length })}
+ </p>
+ `}
+ ${!searched && html`
+ <p class="page-message">${t('search.hint')}</p>
+ `}
+ </div>
+ `;
+}
+
// ── Settings Page ───────────────────────────────────────────────────────────
const THEME_OPTIONS = ['light', 'dark', 'system'];
@@ -1491,8 +1886,18 @@ function App() {
: html`<${LoginPage} />`;
} else if (!user) {
page = html`<${LoginPage} />`;
+ } else if (route === '/search') {
+ page = html`<${SearchPage} />`;
} else if (route === '/explore') {
- page = html`<${ExplorePage} token=${user.token} />`;
+ page = html`<${ExplorePage} token=${user.token}
+ myGroupIds=${groups.map(g => g.id)} />`;
+ } else if (route === '/create-group') {
+ page = html`<${CreateGroupPage} token=${user.token}
+ onCreated=${() => {
+ hubFetch('/v1/groups/mine', { token: user.token })
+ .then(data => setGroups(data.groups || []))
+ .catch(() => {});
+ }} />`;
} else if (route.startsWith('/group/')) {
const groupId = route.slice(7);
const group = groups.find(g => g.id === groupId);
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/crypto.js b/packages/meshbay-hub/src/meshbay_hub/static/crypto.js
index ee442fb..eb96eef 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/crypto.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/crypto.js
@@ -56,7 +56,7 @@ async function deriveChunkKey(gek, fileHashHex, chunkIndex) {
gek,
{ name: 'AES-GCM', length: 256 },
false,
- ['decrypt'],
+ ['encrypt', 'decrypt'],
);
}
@@ -158,5 +158,80 @@ async function decryptChunkBin(gek, fileHashHex, chunkIndex, nonce, ct) {
return new Uint8Array(plaintext);
}
+// ── GEK generation + ECIES wrapping ──────────────────────────────────────────
+
+function generateGEK() {
+ return crypto.getRandomValues(new Uint8Array(32));
+}
+
+async function wrapGEK(gek, pkXRaw) {
+ const skEph = await crypto.subtle.generateKey({ name: 'X25519' }, true, ['deriveBits']);
+ const pkEphRaw = new Uint8Array(await crypto.subtle.exportKey('raw', skEph.publicKey));
+
+ const pkRecip = await crypto.subtle.importKey('raw', pkXRaw, { name: 'X25519' }, false, []);
+ const sharedBits = await crypto.subtle.deriveBits(
+ { name: 'X25519', public: pkRecip }, skEph.privateKey, 256);
+
+ const sharedKey = await crypto.subtle.importKey(
+ 'raw', sharedBits, 'HKDF', false, ['deriveKey']);
+ const wrapKey = await crypto.subtle.deriveKey(
+ { name: 'HKDF', hash: 'SHA-256', salt: pkEphRaw,
+ info: new TextEncoder().encode('meshbay:gek_wrap:v1:aes') },
+ sharedKey,
+ { name: 'AES-GCM', length: 256 }, false, ['encrypt']);
+
+ const nonce = crypto.getRandomValues(new Uint8Array(12));
+ const ct = await crypto.subtle.encrypt(
+ { name: 'AES-GCM', iv: nonce, additionalData: pkXRaw }, wrapKey, gek);
+
+ return {
+ pk_eph_b64: btoa(String.fromCharCode(...pkEphRaw)),
+ nonce_b64: btoa(String.fromCharCode(...nonce)),
+ wrapped_b64: btoa(String.fromCharCode(...new Uint8Array(ct))),
+ };
+}
+
+async function unwrapGEK(bundle, skXPkcs8, pkXRaw) {
+ const pkEphRaw = b64decode(bundle.pk_eph_b64);
+ const nonce = b64decode(bundle.nonce_b64);
+ const wrapped = b64decode(bundle.wrapped_b64);
+
+ const skX = await crypto.subtle.importKey(
+ 'pkcs8', skXPkcs8, { name: 'X25519' }, false, ['deriveBits']);
+ const pkEph = await crypto.subtle.importKey(
+ 'raw', pkEphRaw, { name: 'X25519' }, false, []);
+ const sharedBits = await crypto.subtle.deriveBits(
+ { name: 'X25519', public: pkEph }, skX, 256);
+
+ const sharedKey = await crypto.subtle.importKey(
+ 'raw', sharedBits, 'HKDF', false, ['deriveKey']);
+ const wrapKey = await crypto.subtle.deriveKey(
+ { name: 'HKDF', hash: 'SHA-256', salt: pkEphRaw,
+ info: new TextEncoder().encode('meshbay:gek_wrap:v1:aes') },
+ sharedKey,
+ { name: 'AES-GCM', length: 256 }, false, ['decrypt']);
+
+ const plain = await crypto.subtle.decrypt(
+ { name: 'AES-GCM', iv: nonce, additionalData: pkXRaw }, wrapKey, wrapped);
+ return new Uint8Array(plain);
+}
+
+// ── Chunk encryption (for upload) ────────────────────────────────────────────
+
+async function encryptChunk(gek, fileHashHex, chunkIndex, plaintext) {
+ const chunkKey = await deriveChunkKey(gek, fileHashHex, chunkIndex);
+ const nonce = crypto.getRandomValues(new Uint8Array(12));
+ const ct = await crypto.subtle.encrypt(
+ { name: 'AES-GCM', iv: nonce }, chunkKey, plaintext);
+ return { nonce, ct: new Uint8Array(ct) };
+}
+
+function b64encode(bytes) {
+ return btoa(String.fromCharCode(...bytes));
+}
+
// Export for use in app.js
-window.MeshBayCrypto = { importGEK, deriveChunkKey, decryptChunk, decryptChunkBin, decryptFile };
+window.MeshBayCrypto = {
+ importGEK, deriveChunkKey, decryptChunk, decryptChunkBin, decryptFile,
+ generateGEK, wrapGEK, unwrapGEK, encryptChunk, b64encode, b64decode,
+};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js
index 11543fd..0ec7d4a 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js
@@ -63,11 +63,15 @@ const en = {
'explore.search': 'Search groups...',
'explore.loading': 'Loading...',
'explore.empty': 'No public groups available.',
+ 'explore.create_group': 'Create group',
+ 'explore.join': 'Join',
+ 'explore.member': 'Joined',
// Group page
'group.default_name': 'Group',
'group.tab_files': 'Files',
'group.tab_chat': 'Chat',
+ 'group.tab_members': 'Members',
'group.filter': 'Filter files...',
'group.col_name': 'Name',
'group.col_size': 'Size',
@@ -80,6 +84,8 @@ const en = {
'group.dl_failed': 'Download failed: {err}',
'group.offline_title': 'No nodes are currently online for this group.',
'group.offline_hint': 'Files will appear when a node hosting this group connects.',
+ 'group.upload': 'Upload',
+ 'group.uploading': 'Uploading...',
'group.err_transport': 'Transport module not loaded',
// Status
@@ -120,6 +126,7 @@ const en = {
'settings.notifications': 'Notifications',
// Sidebar
+ 'sidebar.create_group': 'Create group',
'sidebar.settings': 'Settings',
'sidebar.admin': 'Admin',
@@ -183,9 +190,43 @@ const en = {
'admin.btn_unblock': 'Unblock',
// Notifications
+ // Create group
+ 'create_group.title': 'Create Group',
+ 'create_group.name': 'Group name',
+ 'create_group.visibility': 'Visibility',
+ 'create_group.private': 'Private',
+ 'create_group.public': 'Public',
+ 'create_group.join_policy': 'Join policy',
+ 'create_group.invite': 'Invite only',
+ 'create_group.open': 'Open (anyone can join)',
+ 'create_group.submit': 'Create',
+ 'create_group.creating': 'Creating...',
+
+ // Members
+ 'members.col_role': 'Role',
+ 'members.admin': 'Admin',
+ 'members.member': 'Member',
+ 'members.invite_title': 'Invite member',
+ 'members.username_placeholder': 'Username',
+ 'members.invite_btn': 'Invite',
+
'notif.title': 'Notifications',
'notif.empty': 'No notifications',
'notif.mark_all_read': 'Mark all read',
+
+ // Sidebar
+ 'sidebar.search': 'Search files',
+
+ // Status
+ 'status.cached': '{n} cached files',
+
+ // Search
+ 'search.title': 'Search Files',
+ 'search.placeholder': 'Search across all groups...',
+ 'search.no_results': 'No files match your search.',
+ 'search.col_group': 'Group',
+ 'search.result_count': '{n} files found',
+ 'search.hint': 'Search file names across all cached group indexes.',
};
// ── Locale registry ─────────────────────────────────────────────────────────
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
index de93b2d..2f6680e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -175,6 +175,18 @@ class MeshBayTransport {
return msg;
}
+ async uploadChunk(filename, chunkIndex, totalChunks, data) {
+ const msg = await this._sendAndWait({
+ type: 'file_upload',
+ v: '0.1',
+ filename,
+ chunk_index: chunkIndex,
+ total_chunks: totalChunks,
+ data: data,
+ });
+ return msg;
+ }
+
close() {
if (this._channel) this._channel.close();
if (this._pc) this._pc.close();