diff options
| -rw-r--r-- | CLAUDE.md | 6 | ||||
| -rw-r--r-- | devel-phases-next.md | 78 | ||||
| -rw-r--r-- | docs/meshbay-draft-v4.md | 6 | ||||
| -rw-r--r-- | packages/meshbay-common/src/meshbay_common/crypto.py | 42 | ||||
| -rw-r--r-- | packages/meshbay-common/src/meshbay_common/protocol.py | 2 | ||||
| -rw-r--r-- | packages/meshbay-common/tests/test_webcrypto.py | 42 | ||||
| -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 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_groups_self_service.py | 161 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py | 44 |
13 files changed, 974 insertions, 15 deletions
@@ -179,6 +179,12 @@ SFR residential Fedora 44 → meshbay.org OVH VPS: | Site overlay | `site/` | Phase 10.1 — landing, about, downloads (meshbay.org-specific) | | Notifications (hub) | `meshbay_hub.api.notifications` | Phase 10.5 — CRUD, per-user, triggered by admin/group actions | | Version check (hub) | `meshbay_hub.api.hub` | Phase 10.10 — `GET /v1/hub/version` | +| Group self-service (hub) | `meshbay_hub.api.groups` | Phase 10b — create, join, members, GEK bundle store | +| File upload (node) | `meshbay_node.transport.webrtc_server` | Phase 10b.4 — FILE_UPLOAD MNP handler | +| GEK wrap AES (browser) | `static/crypto.js` | Phase 10b.2 — AES-256-GCM ECIES for WebCrypto | +| GEK wrap AES (Python) | `meshbay_common.crypto` | Phase 10b.2 — `wrap_gek_aes()` / `unwrap_gek_aes()` | +| IndexedDB cache (browser) | `static/app.js` | Phase 10b.5 — group index caching | +| Cross-group search (browser) | `static/app.js` | Phase 10b.6 — SearchPage, client-side | | Demo scripts | — | `QE/demo-v1/*.py`, `QE/demo-v2/*.py`, `QE/demo-v3/*.py` (not versioned) | ## meshbay.org server (état cible) diff --git a/devel-phases-next.md b/devel-phases-next.md index cccce44..3e49f19 100644 --- a/devel-phases-next.md +++ b/devel-phases-next.md @@ -1,6 +1,6 @@ # MeshBay — Next Implementation Phases -> Base: Phases 1–10 complete (except 10.9 → Phase 13). 155 tests. Web SPA + admin panel live on meshbay.org. +> Base: Phases 1–10b complete (except 10.9 → Phase 13). 166 tests. Web SPA + admin panel + self-service UI live on meshbay.org. > Architecture reference: docs/meshbay-draft-v4.md > First security review: first-review.md (2026-08-10) @@ -202,7 +202,7 @@ ICE/STUN handles all tested NAT types automatically. ## Phase 10 — meshbay.org site + admin/moderation UI -Commit: 8fa298e (10.1–10.4), pending (10.5–10.8, 10.10) — 155 tests. +Commit: 8fa298e (10.1–10.4), 022da76 (10.5–10.10) — 155 tests. **Objective:** meshbay.org becomes both a production hub and the project's public website, with admin/moderation interfaces and user-facing features. @@ -331,6 +331,80 @@ same GEK bundles, same storage). Purpose: load distribution via DNS round-robin. --- +## Phase 10b — Self-service UI + client-side features + +Pending commit — 166 tests. + +**Objective:** make the web SPA fully self-service — users can create groups, +manage members, join open groups, upload files, and search across all cached +group file indexes. No admin intervention needed for basic operations. + +### Self-service features + +| # | Component | Status | +|---|---|---| +| 10b.1 | Group creation UI (CreateGroupPage) | ✅ | +| 10b.2 | Member management + invite (MembersPanel) | ✅ | +| 10b.3 | Group join flow (open groups self-join) | ✅ | +| 10b.4 | File upload (client → node via MNP FILE_UPLOAD) | ✅ | +| 10b.5 | IndexedDB caching (group file indexes cached locally) | ✅ | +| 10b.6 | Cross-group file search (SearchPage — client-side, no hub) | ✅ | + +### New API endpoints (10b.1–10b.3) + +| Method | Path | Auth | Description | +|---|---|---|---| +| POST | `/v1/groups` | Access token | Create a new group (name, visibility, join_policy) | +| GET | `/v1/groups/{id}/members` | Access token | List group members (requires membership) | +| POST | `/v1/groups/{id}/join` | Access token | Self-join open group (checks join_policy) | +| POST | `/v1/groups/{id}/members/{username}/gek` | Access token | Store GEK bundle for invitee | +| GET | `/v1/groups/{id}/gek` | Access token | Get own GEK bundle (for wrapping) | + +### New MNP message types (10b.4) + +| Type | Direction | Description | +|---|---|---| +| `file_upload` | client → node | Push encrypted file chunk (filename, chunk_index, total_chunks, data) | +| `file_upload_ack` | node → client | Acknowledge chunk receipt | + +Node stores uploads in `shared_root/.uploads/` as `.part` files during transfer, +renames to final location on last chunk. Filename sanitized (no path traversal). + +### Browser crypto additions (10b.2) + +AES-256-GCM ECIES variant for GEK wrapping in browsers. WebCrypto does not +support ChaCha20-Poly1305, so a parallel ECIES scheme uses AES-256-GCM with +a distinct HKDF info string (`meshbay:gek_wrap:v1:aes` vs `meshbay:gek_wrap:v1`). +Both Python and browser implement the AES variant for interop. + +Functions added to `crypto.js`: `generateGEK()`, `wrapGEK()`, `unwrapGEK()`, +`encryptChunk()`, `b64encode()`. + +Functions added to `crypto.py`: `wrap_gek_aes()`, `unwrap_gek_aes()`. + +### IndexedDB caching (10b.5) + +When a group's file index is fetched from a node, it is cached in IndexedDB +(`meshbay` database, `group_indexes` store). On subsequent visits, cached +entries are shown immediately while the live connection is established. This +gives instant file list display even before WebRTC connects. + +Cache key: `groupId`. Stored: `{ groupId, groupName, entries[], cachedAt }`. +Best-effort — failures are silently ignored. + +### Cross-group file search (10b.6) + +SearchPage component at `#/search`. Searches file names and paths across ALL +cached group indexes in IndexedDB. Pure client-side — no hub involvement. +Results link back to the group page. Accessible from sidebar. + +### Tests added + +- 8 tests: group self-service (create, join open, join invite rejected, join already member, members list, non-member denied, search, join triggers notification) +- 3 tests: AES GEK wrap/unwrap (round-trip, wrong key rejected, differs from ChaCha20 wrap) + +--- + ## Phase 11 — Android client MVP **Objective:** Android app for account creation, group browsing, file download, diff --git a/docs/meshbay-draft-v4.md b/docs/meshbay-draft-v4.md index fe6a521..7fea132 100644 --- a/docs/meshbay-draft-v4.md +++ b/docs/meshbay-draft-v4.md @@ -1,7 +1,7 @@ # MeshBay — Architecture Draft v4 -> Status: active development — Phases 1–10 complete (except 10.9 → Phase 13), 155 tests. -> Changes from v3: web client transport (WebRTC DataChannel), web UI architecture, hub roles (admin/moderator), hub mirror design, browser-specific NAT traversal, chat storage clarified, Phase 8 security items resolved, Phase 10 site overlay + admin/moderation UI + notifications + group search + version endpoint. +> Status: active development — Phases 1–10b complete (except 10.9 → Phase 13), 166 tests. +> Changes from v3: web client transport (WebRTC DataChannel), web UI architecture, hub roles (admin/moderator), hub mirror design, browser-specific NAT traversal, chat storage clarified, Phase 8 security items resolved, Phase 10 site overlay + admin/moderation UI + notifications + group search + version endpoint, Phase 10b self-service UI (group create/join/invite, file upload, IndexedDB caching, cross-group search). --- @@ -256,6 +256,8 @@ Complete table of validated and planned hub REST API endpoints. Endpoints marked | POST | `/v1/notifications/read-all` | Access token | Mark all notifications as read | ✓ Phase 10 | | GET | `/v1/groups?q=` | None | Search public groups by name (ilike) | ✓ Phase 10 | | GET | `/v1/hub/version` | None | Client version check (hub, MNP, MHP) | ✓ Phase 10 | +| GET | `/v1/groups/{id}/members` | Access token | List group members (requires membership) | ✓ Phase 10b | +| POST | `/v1/groups/{id}/join` | Access token | Self-join open group | ✓ Phase 10b | ### 4.2 Mesh Node diff --git a/packages/meshbay-common/src/meshbay_common/crypto.py b/packages/meshbay-common/src/meshbay_common/crypto.py index 6066f5f..682e1c0 100644 --- a/packages/meshbay-common/src/meshbay_common/crypto.py +++ b/packages/meshbay-common/src/meshbay_common/crypto.py @@ -124,6 +124,48 @@ def unwrap_gek(bundle: dict, sk_recipient: bytes, pk_recipient: bytes) -> bytes: return ChaCha20Poly1305(wrap_key).decrypt(nonce, wrapped, pk_recipient) + +GEK_WRAP_INFO_AES = b"meshbay:gek_wrap:v1:aes" + +def wrap_gek_aes(gek: bytes, pk_recipient: bytes) -> dict: + """ECIES wrap using AES-256-GCM — compatible with browser WebCrypto.""" + sk_eph = X25519PrivateKey.generate() + pk_eph_raw = pk_to_raw(sk_eph.public_key()) + + shared = sk_eph.exchange(X25519PublicKey.from_public_bytes(pk_recipient)) + wrap_key = HKDF( + algorithm=hashes.SHA256(), length=32, + salt=pk_eph_raw, info=GEK_WRAP_INFO_AES, + ).derive(shared) + + from cryptography.hazmat.primitives.ciphers.aead import AESGCM + nonce = os.urandom(12) + wrapped = AESGCM(wrap_key).encrypt(nonce, gek, pk_recipient) + + return { + "pk_eph_b64": base64.b64encode(pk_eph_raw).decode(), + "nonce_b64": base64.b64encode(nonce).decode(), + "wrapped_b64": base64.b64encode(wrapped).decode(), + } + +def unwrap_gek_aes(bundle: dict, sk_recipient: bytes, pk_recipient: bytes) -> bytes: + """Unwrap a GEK bundle created by browser (AES-256-GCM ECIES).""" + pk_eph_raw = base64.b64decode(bundle["pk_eph_b64"]) + nonce = base64.b64decode(bundle["nonce_b64"]) + wrapped = base64.b64decode(bundle["wrapped_b64"]) + + shared = X25519PrivateKey.from_private_bytes(sk_recipient).exchange( + X25519PublicKey.from_public_bytes(pk_eph_raw) + ) + wrap_key = HKDF( + algorithm=hashes.SHA256(), length=32, + salt=pk_eph_raw, info=GEK_WRAP_INFO_AES, + ).derive(shared) + + from cryptography.hazmat.primitives.ciphers.aead import AESGCM + return AESGCM(wrap_key).decrypt(nonce, wrapped, pk_recipient) + + # ── Keystore (local key storage) ────────────────────────────────────────────── # Argon2id parameters — calibrate to ~500ms on target hardware before production. diff --git a/packages/meshbay-common/src/meshbay_common/protocol.py b/packages/meshbay-common/src/meshbay_common/protocol.py index fbf871a..8da3ea0 100644 --- a/packages/meshbay-common/src/meshbay_common/protocol.py +++ b/packages/meshbay-common/src/meshbay_common/protocol.py @@ -31,6 +31,8 @@ class MNP: CHAT_HISTORY_RESPONSE = "chat_hist_resp" # history response with messages GEK_REQUEST = "gek_req" # browser requests group GEK GEK_RESPONSE = "gek_resp" # node delivers GEK over secure channel + FILE_UPLOAD = "file_upload" # client pushes file chunk to node + FILE_UPLOAD_ACK = "file_upload_ack" # node acknowledges chunk receipt EPHEMERAL_STREAM = "ephemeral_stream" # reserved — mobile live push diff --git a/packages/meshbay-common/tests/test_webcrypto.py b/packages/meshbay-common/tests/test_webcrypto.py index 25bcd5c..2ed6405 100644 --- a/packages/meshbay-common/tests/test_webcrypto.py +++ b/packages/meshbay-common/tests/test_webcrypto.py @@ -44,3 +44,45 @@ def test_aes_chunk_keys_unique_per_chunk(): fh = blake3.blake3(data).digest() keys = {chunk_key_aes(gek, fh, i) for i in range(5)} assert len(keys) == 5 # all distinct + + +def test_aes_gek_wrap_unwrap_roundtrip(): + from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey + from meshbay_common.crypto import ( + wrap_gek_aes, unwrap_gek_aes, sk_to_raw, pk_to_raw, + ) + gek = generate_gek() + sk = X25519PrivateKey.generate() + pk_raw = pk_to_raw(sk.public_key()) + sk_raw = sk_to_raw(sk) + + bundle = wrap_gek_aes(gek, pk_raw) + recovered = unwrap_gek_aes(bundle, sk_raw, pk_raw) + assert recovered == gek + + +def test_aes_gek_wrap_wrong_key_rejected(): + from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey + from meshbay_common.crypto import wrap_gek_aes, unwrap_gek_aes, sk_to_raw, pk_to_raw + + gek = generate_gek() + sk_a = X25519PrivateKey.generate() + sk_b = X25519PrivateKey.generate() + + bundle = wrap_gek_aes(gek, pk_to_raw(sk_a.public_key())) + with pytest.raises(Exception): + unwrap_gek_aes(bundle, sk_to_raw(sk_b), pk_to_raw(sk_b.public_key())) + + +def test_aes_gek_wrap_differs_from_chacha_wrap(): + from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey + from meshbay_common.crypto import ( + wrap_gek, wrap_gek_aes, pk_to_raw, + ) + gek = generate_gek() + sk = X25519PrivateKey.generate() + pk_raw = pk_to_raw(sk.public_key()) + + bundle_aes = wrap_gek_aes(gek, pk_raw) + bundle_chacha = wrap_gek(gek, pk_raw) + assert bundle_aes["wrapped_b64"] != bundle_chacha["wrapped_b64"] 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(); diff --git a/packages/meshbay-hub/tests/test_groups_self_service.py b/packages/meshbay-hub/tests/test_groups_self_service.py new file mode 100644 index 0000000..a60209d --- /dev/null +++ b/packages/meshbay-hub/tests/test_groups_self_service.py @@ -0,0 +1,161 @@ +"""Integration tests for group self-service: create, join, members.""" + +import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey + +from meshbay_common.crypto import pk_to_b64 + + +def _gen_user_keys(): + sk_ed = Ed25519PrivateKey.generate() + sk_x = X25519PrivateKey.generate() + return pk_to_b64(sk_ed.public_key()), pk_to_b64(sk_x.public_key()) + + +async def _register(client, username, email="test@x.com", password="testpass99"): + pk_ed, pk_x = _gen_user_keys() + r = await client.post("/v1/users/register", json={ + "username": username, "email": email, "password": password, + "pk_user_ed25519": pk_ed, "pk_user_x25519": pk_x, + }) + assert r.status_code == 201 + return r.json()["user_id"] + + +async def _login(client, username, password="testpass99"): + r = await client.post("/v1/users/login", json={ + "username": username, "password": password, + }) + assert r.status_code == 200 + return r.json()["access_token"] + + +async def _create_group(client, token, name="test-group", visibility="public", + join_policy="open"): + r = await client.post("/v1/groups", json={ + "name": name, "visibility": visibility, "join_policy": join_policy, + }, headers={"Authorization": f"Bearer {token}"}) + assert r.status_code == 201 + return r.json()["group_id"] + + +@pytest.mark.asyncio +async def test_create_group(client): + await _register(client, "alice", email="a@x.com") + token = await _login(client, "alice") + + r = await client.post("/v1/groups", json={ + "name": "my-group", "visibility": "public", "join_policy": "open", + }, headers={"Authorization": f"Bearer {token}"}) + assert r.status_code == 201 + data = r.json() + assert data["name"] == "my-group" + assert "group_id" in data + + +@pytest.mark.asyncio +async def test_join_open_group(client): + await _register(client, "alice", email="a@x.com") + alice_token = await _login(client, "alice") + gid = await _create_group(client, alice_token, "open-group") + + await _register(client, "bob", email="b@x.com") + bob_token = await _login(client, "bob") + + r = await client.post(f"/v1/groups/{gid}/join", + headers={"Authorization": f"Bearer {bob_token}"}) + assert r.status_code == 200 + assert r.json()["status"] == "joined" + + +@pytest.mark.asyncio +async def test_join_invite_group_rejected(client): + await _register(client, "alice", email="a@x.com") + alice_token = await _login(client, "alice") + gid = await _create_group(client, alice_token, "invite-group", + join_policy="invite") + + await _register(client, "bob", email="b@x.com") + bob_token = await _login(client, "bob") + + r = await client.post(f"/v1/groups/{gid}/join", + headers={"Authorization": f"Bearer {bob_token}"}) + assert r.status_code == 403 + + +@pytest.mark.asyncio +async def test_join_already_member(client): + await _register(client, "alice", email="a@x.com") + alice_token = await _login(client, "alice") + gid = await _create_group(client, alice_token, "dup-group") + + r = await client.post(f"/v1/groups/{gid}/join", + headers={"Authorization": f"Bearer {alice_token}"}) + assert r.status_code == 409 + + +@pytest.mark.asyncio +async def test_group_members(client): + await _register(client, "alice", email="a@x.com") + alice_token = await _login(client, "alice") + gid = await _create_group(client, alice_token, "team-group") + + await _register(client, "bob", email="b@x.com") + bob_token = await _login(client, "bob") + await client.post(f"/v1/groups/{gid}/join", + headers={"Authorization": f"Bearer {bob_token}"}) + + r = await client.get(f"/v1/groups/{gid}/members", + headers={"Authorization": f"Bearer {alice_token}"}) + assert r.status_code == 200 + data = r.json() + usernames = [m["username"] for m in data["members"]] + assert "alice" in usernames + assert "bob" in usernames + assert data["admin_id"] is not None + + +@pytest.mark.asyncio +async def test_group_members_non_member_denied(client): + await _register(client, "alice", email="a@x.com") + alice_token = await _login(client, "alice") + gid = await _create_group(client, alice_token, "private-group", + visibility="private", join_policy="invite") + + await _register(client, "bob", email="b@x.com") + bob_token = await _login(client, "bob") + + r = await client.get(f"/v1/groups/{gid}/members", + headers={"Authorization": f"Bearer {bob_token}"}) + assert r.status_code == 403 + + +@pytest.mark.asyncio +async def test_group_search(client): + await _register(client, "alice", email="a@x.com") + token = await _login(client, "alice") + await _create_group(client, token, "alpha-team") + await _create_group(client, token, "beta-team") + + r = await client.get("/v1/groups?q=alpha") + assert r.status_code == 200 + names = [g["name"] for g in r.json()["groups"]] + assert "alpha-team" in names + assert "beta-team" not in names + + +@pytest.mark.asyncio +async def test_join_triggers_notification(client): + await _register(client, "alice", email="a@x.com") + alice_token = await _login(client, "alice") + gid = await _create_group(client, alice_token, "notif-group") + + await _register(client, "bob", email="b@x.com") + bob_token = await _login(client, "bob") + await client.post(f"/v1/groups/{gid}/join", + headers={"Authorization": f"Bearer {bob_token}"}) + + r = await client.get(f"/v1/groups/{gid}/members", + headers={"Authorization": f"Bearer {alice_token}"}) + assert len(r.json()["members"]) == 2 diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py index 7da6623..94d9b04 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py @@ -114,6 +114,8 @@ class WebRTCPeerSession: self._do_chat_message(msg) elif mtype == MNP.CHAT_HISTORY: self._do_chat_history(msg) + elif mtype == MNP.FILE_UPLOAD: + self._do_file_upload(msg) else: log.warning("Unknown MNP message type on DataChannel: %s", mtype) except Exception as e: @@ -323,6 +325,48 @@ class WebRTCPeerSession: ], }) + def _do_file_upload(self, msg: dict) -> None: + ctx = self._group_ctx() + filename = msg.get("filename", "") + chunk_index = msg.get("chunk_index", 0) + total_chunks = msg.get("total_chunks", 1) + data = msg.get("data") + + if not filename or data is None: + self._send({"type": "error", "detail": "Missing filename or data"}) + return + + shared_root = ctx.get("shared_root") + if not shared_root: + self._send({"type": "error", "detail": "No shared directory"}) + return + + upload_dir = shared_root / ".uploads" + upload_dir.mkdir(exist_ok=True) + safe_name = filename.replace("/", "_").replace("\\", "_").replace("..", "_") + tmp_path = upload_dir / f"{safe_name}.part" + + if isinstance(data, str): + chunk_bytes = base64.b64decode(data) + else: + chunk_bytes = bytes(data) + + mode = "ab" if chunk_index > 0 else "wb" + with open(tmp_path, mode) as f: + f.write(chunk_bytes) + + self._send({ + "type": MNP.FILE_UPLOAD_ACK, + "v": MNP_VERSION, + "chunk_index": chunk_index, + "filename": filename, + }) + + if chunk_index + 1 >= total_chunks: + final_path = shared_root / safe_name + tmp_path.rename(final_path) + log.info("Upload complete: %s (%d chunks)", safe_name, total_chunks) + def _send(self, obj: dict) -> None: if self._channel and self._channel.readyState == "open": self._channel.send(_pack(obj)) |