From cc90dc943fcd0e7bbf52674fb3f95ff097026f4a Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Tue, 11 Aug 2026 14:55:52 +0200 Subject: feat: Phase 10b — Self-service UI (group create/join, upload, IndexedDB, search) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 6 + devel-phases-next.md | 78 +++- docs/meshbay-draft-v4.md | 6 +- .../meshbay-common/src/meshbay_common/crypto.py | 42 ++ .../meshbay-common/src/meshbay_common/protocol.py | 2 + packages/meshbay-common/tests/test_webcrypto.py | 42 ++ packages/meshbay-hub/src/meshbay_hub/api/groups.py | 53 +++ packages/meshbay-hub/src/meshbay_hub/static/app.js | 423 ++++++++++++++++++++- .../meshbay-hub/src/meshbay_hub/static/crypto.js | 79 +++- .../meshbay-hub/src/meshbay_hub/static/i18n.js | 41 ++ .../src/meshbay_hub/static/transport.js | 12 + .../meshbay-hub/tests/test_groups_self_service.py | 161 ++++++++ .../src/meshbay_node/transport/webrtc_server.py | 44 +++ 13 files changed, 974 insertions(+), 15 deletions(-) create mode 100644 packages/meshbay-hub/tests/test_groups_self_service.py diff --git a/CLAUDE.md b/CLAUDE.md index 033da62..dd816ef 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 }) { ${t('sidebar.public_groups')} + ${t('sidebar.search')} + ${t('sidebar.create_group')} ${t('sidebar.settings')} ${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`
-

${t('explore.title')}

+
+

${t('explore.title')}

+ ${t('explore.create_group')} +
@@ -369,13 +444,26 @@ function ExplorePage({ token }) { : html`
${groups.map(g => html` - -

${g.name}

+
+ +

${g.name}

+
${g.join_policy} ${g.source && g.source !== 'local' && html` ${' '}${g.source} `} - + ${' '} + ${isMember(g.id) + ? html`${t('explore.member')}` + : g.join_policy === 'open' && html` + + ` + } +
`)}
` @@ -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` +
+

${t('create_group.title')}

+ ${error && html`

${error}

`} + +
+ `; +} + // ── 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')} +
${tab === 'files' && html`
+