diff options
Diffstat (limited to 'packages/meshbay-hub/src')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/api/groups.py | 53 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/app.js | 423 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/crypto.js | 79 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/i18n.js | 41 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/transport.js | 12 |
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(); |