diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-11 04:13:53 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-11 04:13:53 +0200 |
| commit | e23e33adeaf8ee7439187d4451c856b37816a51f (patch) | |
| tree | a41eef1fba34cdd642d25576395b4ad484a748ad | |
| parent | 60c4570e72e36c2a9720593c8baec74ee2ab52d6 (diff) | |
| download | meshbay-e23e33adeaf8ee7439187d4451c856b37816a51f.tar.gz | |
feat: Phase 9 — Web client SPA with WebRTC P2P transport
Complete browser-based client: Preact SPA with login, group file browser,
encrypted download, video playback, group chat, i18n, and dark/light theme.
Browser connects P2P to nodes behind residential NAT via WebRTC DataChannel
(aiortc). Hub handles signaling only — all data flows E2E.
Performance: pipelined downloads (8-chunk sliding window), binary msgpack
wire format (no base64), redundant I/O elimination. Large file downloads
stream to disk via File System Access API (showSaveFilePicker).
Validated on SFR + Orange residential NATs, Chrome + Firefox, IPv4/IPv6.
132 tests passing. Deployed to meshbay.org + Orange node.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
27 files changed, 3039 insertions, 316 deletions
@@ -132,7 +132,7 @@ SFR residential Fedora 44 → meshbay.org OVH VPS: ### WebRTC browser clients (Phase 9 spike, 2026-08-10) -Mobile 4G SFR → node behind SFR residential NAT (Port-Restricted Cone + CGNAT 4G): +**SFR residential NAT** — Mobile 4G SFR → node behind SFR residential (Port-Restricted Cone + CGNAT 4G): | Test | ICE path | Result | |---|---|---| @@ -140,6 +140,15 @@ Mobile 4G SFR → node behind SFR residential NAT (Port-Restricted Cone + CGNAT | 4G + IPv6 | IPv6 inter-network | OK, ~600ms | | 4G + IPv4 only (IPv6 disabled) | STUN hole-punch IPv4 | OK, ~650ms | +**Orange Livebox NAT** — Firefox/Chrome laptop (SFR) → node behind Orange residential NAT: + +| Test | ICE path | Result | +|---|---|---| +| Chrome laptop → Orange node | IPv6 inter-network | OK, ~7000ms | +| Firefox laptop → Orange node | IPv6 inter-network | OK, ~6700ms | +| Firefox laptop → Orange node (IPv6 disabled) | STUN hole-punch IPv4 | OK, ~6900ms | + +- **Two ISPs validated** — SFR + Orange residential NAT, both work without TURN - **No TURN relay needed** — ICE/STUN handles both NAT types automatically - **Hub role**: signaling only (SDP/ICE relay via WebSocket, <1 KB) - **Data path**: browser ↔ node P2P via WebRTC DataChannel @@ -162,7 +171,9 @@ Mobile 4G SFR → node behind SFR residential NAT (Port-Restricted Cone + CGNAT | WebRTC signaling (hub) | `meshbay_hub.api.signaling` | Phase 9.2 — SDP/ICE relay | | Browser transport client | `static/transport.js` | Phase 9.4 — WebRTC DataChannel | | Web SPA | `static/app.js` | Phase 9.6 — Preact + preact-router | -| Demo scripts | — | `QE/demo-v1/*.py`, `QE/demo-v2/*.py` (not versioned) | +| File download (large) | `static/app.js` | File System Access API (`showSaveFilePicker`) — stream to disk | +| i18n (browser) | `static/i18n.js` | `t()` lookup, ESM, localStorage lang selection | +| 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 09d07fa..b2806bd 100644 --- a/devel-phases-next.md +++ b/devel-phases-next.md @@ -1,6 +1,6 @@ # MeshBay — Next Implementation Phases -> Base: Phases 1–8 complete. 117 tests. demo-v3 validated against meshbay.org. +> Base: Phases 1–9 complete. 132 tests. Web SPA live on meshbay.org. > Architecture reference: docs/meshbay-draft-v4.md > First security review: first-review.md (2026-08-10) @@ -42,7 +42,10 @@ Deployed to meshbay.org. Existing emails encrypted. DB schema migrated. --- -## Phase 9 — Web client: WebRTC transport + core SPA +## Phase 9 — Web client: WebRTC transport + core SPA ✅ DONE + +Commit: TBD — 23 files, ~2000 lines, 132 tests. +Deployed to meshbay.org + Orange node. Tested browser → node P2P through two ISP NATs. **Objective:** a web browser can connect P2P to a node behind residential NAT, browse files, download, stream video, and chat — with zero data through the hub. @@ -134,17 +137,23 @@ Content is not indexed on the hub. Search works client-side: | 9.3 | WebRTC→MNP transport adapter on node | `node/transport/webrtc_server.py` + hub_client WebRTC handler | ✅ | | 9.4 | `transport.js` — browser WebRTC client | `static/transport.js` — connect, handshake, fetch, msgpack | ✅ | | 9.5 | **Spike: E2E browser→NAT→node file transfer** | Mobile 4G → SFR NAT → node, IPv4 STUN + IPv6 validated | ✅ | -| 9.6 | Preact SPA shell (login, routing, theme) | `static/app.js`, `static/css/style.css` | High | -| 9.7 | Group list + file explorer UI | `static/components/GroupList.js`, `FileExplorer.js` | High | -| 9.8 | File download via DataChannel | `static/components/Download.js` — chunk reassembly | High | -| 9.9 | Video streaming via DataChannel | HLS segments → MediaSource API | Medium | -| 9.10 | Chat/forum UI via DataChannel | `static/components/ChatView.js` — Sender Keys | Medium | -| 9.11 | i18n framework + English strings | `static/i18n/en.json` | Medium | -| 9.12 | Settings UI (profile, theme, notifications) | `static/components/Settings.js` | Medium | -| 9.13 | Tests: unit + integration | WebRTC transport, MNP over DataChannel | High | +| 9.6 | Preact SPA shell (login, routing, theme) | `static/app.js`, `static/style.css`, `static/vendor/htm-preact.js` | ✅ | +| 9.7 | Group list + file explorer UI | `app.js` GroupPage, `groups.py` nodes endpoint, `revocation.py` group tracking | ✅ | +| 9.8 | File download via DataChannel | AES-GCM chunks, GEK delivery, progress bar, browser download | ✅ | +| 9.9 | Video streaming via DataChannel | Chunk download → Blob URL, video overlay with native controls | ✅ | +| 9.10 | Chat/forum UI via DataChannel | ChatPanel component, chat history MNP, peer broadcast, tabs UI | ✅ | +| 9.11 | i18n framework + English strings | `static/i18n.js` — t() lookup, ESM, localStorage lang, all strings extracted | ✅ | +| 9.12 | Settings UI (profile, theme, language) | SettingsPage component, system theme support, sidebar link | ✅ | +| 9.13 | Tests: unit + integration | WebRTC transport, MNP over DataChannel | ✅ | +| 9.14 | Performance: pipelined download | sliding window (8 concurrent chunks) | ✅ | +| 9.15 | Performance: binary wire format | raw bytes via msgpack, no base64 (+33%) | ✅ | +| 9.16 | Performance: avoid redundant I/O | file_hash from index, not re-read per chunk | ✅ | +| 9.17 | Large file download to disk | File System Access API (`showSaveFilePicker`) | ✅ | **Critical path validated (2026-08-10):** 9.1 → 9.5 all pass. WebRTC DataChannel -works browser → node through SFR residential NAT, confirmed with three scenarios: +works browser → node through two different ISP residential NATs: + +**SFR residential NAT** (mobile 4G → node behind SFR Port-Restricted Cone + CGNAT): | Test | ICE path | Result | |---|---|---| @@ -152,8 +161,37 @@ works browser → node through SFR residential NAT, confirmed with three scenari | Mobile 4G SFR + IPv6 | IPv6 inter-network | OK, ~600ms | | Mobile 4G SFR + IPv4 only | STUN hole-punch IPv4 | OK, ~650ms | -Node behind SFR Port-Restricted Cone NAT + mobile behind SFR CGNAT 4G. -No TURN relay needed. ICE/STUN handles both NAT types automatically. +**Orange Livebox NAT** (laptop browser → node behind Orange residential NAT, cross-site): + +| Test | ICE path | Result | +|---|---|---| +| Chrome laptop → Orange node | IPv6 inter-network | OK, ~7000ms | +| Firefox laptop → Orange node | IPv6 inter-network | OK, ~6700ms | +| Firefox laptop → Orange node (IPv6 disabled) | STUN hole-punch IPv4 | OK, ~6900ms | + +Two ISPs validated, both Chrome and Firefox. No TURN relay needed. +ICE/STUN handles all tested NAT types automatically. + +**Performance optimizations (2026-08-11):** +- Initial transfer speed: ~2 MB/s (sequential, base64, redundant I/O) +- After file_hash fix (9.16): ~3 MB/s (eliminated 78 GB redundant reads on 279 MB file) +- After pipelining (9.14): ~5 MB/s (8-chunk sliding window, concurrent requests) +- After binary wire format (9.15): eliminated 33% base64 inflation + removed + redundant per-chunk fields (sig, hashes, pk_node) — AES-GCM tag already + authenticates ciphertext, DTLS authenticates transport +- Large file support (9.17): `showSaveFilePicker` (Chrome/Edge) streams decrypted + chunks directly to disk — flat ~8 MB RAM regardless of file size. Firefox/Safari + fall back to Blob-in-RAM approach. + +**Indexer debounce (2026-08-11):** +- File copy triggers multiple watchdog events at different file sizes → duplicate + index entries with different blake3 hashes. Fixed with 2-second debounce + + path-based dedup (remove old entry before adding new). + +**Known remaining items for future phases:** +- True video streaming (MSE or Service Worker) — currently downloads full file first +- Multiple shared directories per node (UI + config) +- Multi-node per user support **Dependencies added:** - `aiortc>=1.9` in `meshbay-node/pyproject.toml` ✅ diff --git a/devel-phases.md b/devel-phases.md index f1735e4..b5bef49 100644 --- a/devel-phases.md +++ b/devel-phases.md @@ -325,6 +325,76 @@ persistent MHP federation, content replication, and browser private group decryp --- +## Phase 7 — Node v2: production, streaming, chat ✅ DONE + +**Goal:** multi-group node, Sender Keys chat, QUIC 0-RTT, jti denylist push, HLS streaming. + +Commit: fc56585 — 26 files, +2155/−159 lines, 109 tests. + +See `devel-phases-next.md` for details. + +--- + +## Phase 8 — Hub v2: admin, federation, security ✅ DONE + +**Goal:** admin roles, email encryption, refresh token rotation, rate limiting, healthcheck. + +Commit: 46918ec — 20 files, +508/−90 lines, 117 tests. +Deployed to meshbay.org. All security review items S1/S2/S5 resolved. + +See `devel-phases-next.md` for details. + +--- + +## Phase 9 — Web client: WebRTC transport + core SPA ✅ DONE + +**Goal:** browser connects P2P to a node behind residential NAT via WebRTC DataChannel. +Full SPA: login, groups, file browser, download, video playback, chat, i18n, dark/light. + +### Milestones + +| # | Component | Status | +|---|---|---| +| 9.1–9.5 | WebRTC DataChannel spike + E2E NAT validation | ✅ | +| 9.6–9.12 | Preact SPA (login, groups, files, video, chat, i18n, settings) | ✅ | +| 9.13 | Tests: 132 passing | ✅ | +| 9.14–9.16 | Performance: pipelining, binary wire format, I/O reduction | ✅ | +| 9.17 | Large file download: File System Access API (stream to disk) | ✅ | + +**Total: 132/132 tests. Deployed to meshbay.org + Orange node (2026-08-11).** + +### Key technical decisions + +- **Transport:** WebRTC DataChannel (aiortc on node) — browsers can't use QUIC for NAT traversal +- **Wire format:** length-prefixed msgpack, binary chunk fields (no base64) +- **UI:** Preact + htm ESM (vendored, no build step, no CDN, no npm) +- **Crypto:** WebCrypto SubtleCrypto AES-256-GCM for E2E chunk decryption in browser +- **Large files:** File System Access API (`showSaveFilePicker`) — stream to disk, ~8 MB RAM +- **Indexer:** 2s debounce + path-based dedup for file copy events + +### NAT traversal validated + +Two ISPs (SFR + Orange residential NAT), Chrome + Firefox, IPv4 STUN + IPv6 direct. +No TURN relay needed. See `devel-phases-next.md` for detailed test matrix. + +### QE deployment state (2026-08-11) + +- **Hub (meshbay.org):** running as `meshbay-hub.service`, DB has 3 users + (admin, cbesson, grenet), 1 group (`d3bbd90b`), `admin_usernames = ["admin"]` +- **Node (Orange host via `ssh cbesson@localhost -p 2222`):** running as + `nohup .venv/bin/python3 QE/demo-v3/run_node_simple.py`, user grenet, + connected via WS to hub, WebRTC + QUIC dual transport +- **Credentials:** `QE/demo-v3/creds.json` (not versioned) +- **Shared dir on node:** `~/meshbay/QE/demo-v3/shared/` +- All 3 users password: see creds.json + +### Dependencies added + +- `aiortc>=1.9` in meshbay-node (WebRTC DataChannel) +- `preact` + `htm` vendored as `static/vendor/htm-preact.js` (ESM, ~3 KB gzipped) + +--- + ## Conventions - Commits: `feat(node):`, `fix(hub):`, `chore(common):`, `docs:`, `test(node):` diff --git a/packages/meshbay-common/src/meshbay_common/protocol.py b/packages/meshbay-common/src/meshbay_common/protocol.py index c3f2b1a..fbf871a 100644 --- a/packages/meshbay-common/src/meshbay_common/protocol.py +++ b/packages/meshbay-common/src/meshbay_common/protocol.py @@ -27,6 +27,10 @@ class MNP: STREAM_SEGMENT = "stream_seg" # HLS/DASH segment CHAT_MESSAGE = "chat_msg" # Double Ratchet message CHAT_ATTACHMENT = "chat_attach" # attachment metadata + CHAT_HISTORY = "chat_hist" # request message history + 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 EPHEMERAL_STREAM = "ephemeral_stream" # reserved — mobile live push diff --git a/packages/meshbay-hub/src/meshbay_hub/api/groups.py b/packages/meshbay-hub/src/meshbay_hub/api/groups.py index 5bccf71..6dd4275 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/groups.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/groups.py @@ -15,6 +15,57 @@ from meshbay_hub.db.models import ( router = APIRouter(prefix="/v1/groups", tags=["groups"]) +@router.get("/mine") +async def my_groups( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """List groups the current user belongs to.""" + result = await db.execute( + select(Group) + .join(GroupMember, Group.id == GroupMember.group_id) + .where(GroupMember.user_id == current_user.id, Group.status == "active") + .order_by(Group.name) + ) + groups = result.scalars().all() + return { + "groups": [ + { + "id": g.id, + "name": g.name, + "visibility": g.visibility, + "join_policy": g.join_policy, + "created_at": g.created_at.isoformat(), + "is_admin": g.admin_id == current_user.id, + } + for g in groups + ] + } + + +@router.get("/{group_id}/nodes") +async def group_online_nodes( + group_id: str, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Return online nodes that serve a group (for WebRTC connection).""" + from meshbay_hub.api.revocation import get_online_nodes_for_group + from meshbay_hub.db.models import Node + + group = await db.get(Group, group_id) + if not group: + raise HTTPException(status_code=404, detail="Group not found") + + node_ids = get_online_nodes_for_group(group_id) + nodes = [] + for nid in node_ids: + node = await db.get(Node, nid) + if node: + nodes.append({"node_id": nid, "pk_node": node.pk_node}) + return {"nodes": nodes} + + @router.get("") async def list_public_groups( db: AsyncSession = Depends(get_db), diff --git a/packages/meshbay-hub/src/meshbay_hub/api/revocation.py b/packages/meshbay-hub/src/meshbay_hub/api/revocation.py index 8f30745..8f65f89 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/revocation.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/revocation.py @@ -51,6 +51,7 @@ router = APIRouter(tags=["revocation"]) # ── Connected node registry ─────────────────────────────────────────────────── _connected_nodes: dict[str, WebSocket] = {} # node_id → websocket +_node_groups: dict[str, list[str]] = {} # node_id → [group_id, ...] _punch_events: dict[str, asyncio.Event] = {} # node_id → signaling event @@ -58,6 +59,10 @@ def get_connected_node_count() -> int: return len(_connected_nodes) +def get_online_nodes_for_group(group_id: str) -> list[str]: + return [nid for nid, gids in _node_groups.items() if group_id in gids] + + async def broadcast_revocation(token: str) -> int: """Push a signed revocation token to all connected nodes. Returns count sent.""" payload = json.dumps({"type": "revocation", "token": token}) @@ -121,7 +126,10 @@ async def node_websocket(ws: WebSocket): node_id = msg.get("node_id") or decoded.get("sub", "unknown") _connected_nodes[node_id] = ws - log.info("Node WS connected: %s", node_id[:8]) + group_ids = msg.get("group_ids", []) + if group_ids: + _node_groups[node_id] = group_ids + log.info("Node WS connected: %s (groups=%d)", node_id[:8], len(group_ids)) await ws.send_text(json.dumps({"type": "auth_ok", "node_id": node_id})) # Message loop — handle ping, punch_ready, etc. @@ -145,6 +153,7 @@ async def node_websocket(ws: WebSocket): finally: if node_id: _connected_nodes.pop(node_id, None) + _node_groups.pop(node_id, None) # ── Admin revocation endpoint ───────────────────────────────────────────────── diff --git a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py index 6005927..62917f4 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py @@ -1,47 +1,26 @@ """ -Hub web application — serves the MeshBay web client at /. +Hub web application — serves the MeshBay SPA and static assets. -The web client (HTML/JS) is a single-page app that: - - Logs in via the hub API - - Discovers public groups - - Connects to a node URL entered by the user - - Browses the node's file index - - Downloads or streams files via the node HTTP API +The SPA (Preact + htm) handles: + - Authentication (login, register, token refresh) + - Group discovery and browsing + - WebRTC connection to nodes for P2P file transfer + - Dark/light theme with system preference detection -Static files are served from meshbay_hub/static/. -API routes remain at /v1/*. +Static files are served from meshbay_hub/static/ via Starlette StaticFiles. +The root route (/) returns the SPA HTML shell. """ from pathlib import Path from fastapi import APIRouter -from fastapi.responses import FileResponse, HTMLResponse +from fastapi.responses import HTMLResponse STATIC_DIR = Path(__file__).parent.parent / "static" router = APIRouter(tags=["webapp"]) -@router.get("/app.js") -async def app_js(): - return FileResponse(STATIC_DIR / "app.js", media_type="application/javascript") - - -@router.get("/transport.js") -async def transport_js(): - return FileResponse(STATIC_DIR / "transport.js", media_type="application/javascript") - - -@router.get("/crypto.js") -async def crypto_js(): - return FileResponse(STATIC_DIR / "crypto.js", media_type="application/javascript") - - -@router.get("/webrtc-test.html") -async def webrtc_test(): - return FileResponse(STATIC_DIR / "webrtc-test.html", media_type="text/html") - - @router.get("/", response_class=HTMLResponse) async def index(): return HTMLResponse(_HTML) @@ -54,36 +33,14 @@ _HTML = """\ <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>MeshBay</title> - <style> - *, *::before, *::after { box-sizing: border-box; } - body { font-family: system-ui, sans-serif; margin: 0; background: #f8fafc; color: #1e293b; } - #nav { background: #0f172a; color: #e2e8f0; padding: 12px 24px; } - #nav b { color: #38bdf8; font-size: 1.2em; } - #main { max-width: 960px; margin: 32px auto; padding: 0 16px; } - h2 { color: #0f172a; margin-top: 1.5em; } - input { padding: 8px 12px; border: 1px solid #cbd5e1; border-radius: 6px; - font-size: 1em; margin: 4px; } - button { padding: 8px 16px; background: #0ea5e9; color: #fff; border: none; - border-radius: 6px; cursor: pointer; font-size: 0.9em; margin: 4px; } - button:hover { background: #0284c7; } - .card { background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; - padding: 16px; margin: 8px 0; cursor: pointer; } - .card:hover { border-color: #0ea5e9; } - .badge { background: #e0f2fe; color: #0284c7; padding: 2px 8px; - border-radius: 12px; font-size: 0.8em; margin-left: 8px; } - table { width: 100%; border-collapse: collapse; background: #fff; - border: 1px solid #e2e8f0; border-radius: 8px; overflow: hidden; } - th { background: #f1f5f9; padding: 10px; text-align: left; } - td { padding: 10px; border-top: 1px solid #f1f5f9; } - video { border-radius: 8px; box-shadow: 0 4px 16px rgba(0,0,0,.15); } - a { color: #0ea5e9; text-decoration: none; } - a:hover { text-decoration: underline; } - </style> + <link rel="stylesheet" href="/style.css"> </head> <body> - <div id="nav"></div> - <div id="main"><p>Loading…</p></div> - <script src="/app.js"></script> + <div id="app"></div> + <script src="/keyderive.js"></script> + <script src="/crypto.js"></script> + <script src="/transport.js"></script> + <script type="module" src="/app.js"></script> </body> </html> """ diff --git a/packages/meshbay-hub/src/meshbay_hub/app.py b/packages/meshbay-hub/src/meshbay_hub/app.py index 7bd5ee3..1b12a37 100644 --- a/packages/meshbay-hub/src/meshbay_hub/app.py +++ b/packages/meshbay-hub/src/meshbay_hub/app.py @@ -33,7 +33,7 @@ from meshbay_hub.csam import csam_router from meshbay_hub.api.health import router as health_router from meshbay_hub.api.relay import router as relay_router from meshbay_hub.api.signaling import router as signaling_router -from meshbay_hub.api.webapp import router as webapp_router +from meshbay_hub.api.webapp import router as webapp_router, STATIC_DIR from meshbay_hub.api.middleware import limiter @@ -97,4 +97,7 @@ def create_app(cfg: HubConfig | None = None) -> FastAPI: app.include_router(signaling_router) app.include_router(webapp_router) + from starlette.staticfiles import StaticFiles + app.mount("/", StaticFiles(directory=STATIC_DIR), name="static") + return app diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index 1b284bb..2179e99 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -1,198 +1,1108 @@ -/* MeshBay Web Client — Phase 4 - * Communicates with: Hub API (auth, groups) + Node HTTP API (files, streaming) - * Requires: hub at same origin, node at configured URL - */ +import { + html, render, useState, useEffect, useCallback, useRef, + createContext, useContext, +} from './vendor/htm-preact.js'; +import { t, getLocale, setLocale, LOCALES } from './i18n.js'; -const HUB = ''; // same origin — hub serves this file +// ── Constants ──────────────────────────────────────────────────────────────── -// ── State ───────────────────────────────────────────────────────────────────── +const HUB = ''; +const AUTH_KEY = 'mb_auth'; +const THEME_KEY = 'mb_theme'; -let state = { - token: localStorage.getItem('mb_token') || null, - refreshToken: localStorage.getItem('mb_rt') || null, - username: localStorage.getItem('mb_user') || null, - nodeUrl: localStorage.getItem('mb_node') || null, -}; +// ── Auth persistence ───────────────────────────────────────────────────────── -// ── Hub API helpers ─────────────────────────────────────────────────────────── +let _sessionKeys = null; -async function hubGet(path) { - const headers = state.token ? { Authorization: `Bearer ${state.token}` } : {}; - const r = await fetch(HUB + path, { headers }); - if (!r.ok) throw new Error(`${r.status} ${await r.text()}`); - return r.json(); +function loadAuth() { + try { + return JSON.parse(localStorage.getItem(AUTH_KEY)); + } catch { + return null; + } } -async function hubPost(path, body) { - const headers = { - 'Content-Type': 'application/json', - ...(state.token ? { Authorization: `Bearer ${state.token}` } : {}), - }; - const r = await fetch(HUB + path, { method: 'POST', headers, body: JSON.stringify(body) }); - if (!r.ok) throw new Error(`${r.status} ${await r.text()}`); +function saveAuth(auth) { + if (auth) { + localStorage.setItem(AUTH_KEY, JSON.stringify(auth)); + } else { + localStorage.removeItem(AUTH_KEY); + _sessionKeys = null; + } +} + +// ── Theme ──────────────────────────────────────────────────────────────────── + +function getInitialTheme() { + const stored = localStorage.getItem(THEME_KEY); + if (stored === 'dark' || stored === 'light' || stored === 'system') return stored; + return 'system'; +} + +function resolveTheme(pref) { + if (pref === 'system') { + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + } + return pref; +} + +// ── Hub API ────────────────────────────────────────────────────────────────── + +async function hubFetch(path, { method = 'GET', body, token } = {}) { + const headers = {}; + if (body) headers['Content-Type'] = 'application/json'; + if (token) headers['Authorization'] = `Bearer ${token}`; + const opts = { method, headers }; + if (body) opts.body = JSON.stringify(body); + const r = await fetch(HUB + path, opts); + if (!r.ok) { + const err = await r.json().catch(() => ({ detail: r.statusText })); + throw new Error(err.detail || r.statusText); + } return r.json(); } -// ── Auth ────────────────────────────────────────────────────────────────────── +// ── Router ─────────────────────────────────────────────────────────────────── -async function login(username, password) { - const data = await hubPost('/v1/users/login', { username, password }); - state.token = data.access_token; - state.refreshToken = data.refresh_token; - state.username = username; - localStorage.setItem('mb_token', state.token); - localStorage.setItem('mb_rt', state.refreshToken); - localStorage.setItem('mb_user', username); - return data; +function useRoute() { + const [hash, setHash] = useState(window.location.hash.slice(1) || '/'); + useEffect(() => { + const onHash = () => setHash(window.location.hash.slice(1) || '/'); + window.addEventListener('hashchange', onHash); + return () => window.removeEventListener('hashchange', onHash); + }, []); + return hash; } -async function register(username, email, password, pkEd, pkX) { - return hubPost('/v1/users/register', { - username, email, password, - pk_user_ed25519: pkEd, - pk_user_x25519: pkX, - }); +function navigate(path) { + window.location.hash = path; } -function logout() { - state = { token: null, refreshToken: null, username: null, nodeUrl: null }; - localStorage.clear(); - render(); +// ── Context ────────────────────────────────────────────────────────────────── + +const AuthContext = createContext(null); +function useAuth() { return useContext(AuthContext); } + +// ── Nav ────────────────────────────────────────────────────────────────────── + +function Nav({ user, theme, onThemeToggle, onLogout, onMenuToggle }) { + return html` + <nav class="nav"> + <div class="nav-left"> + ${user && html` + <button class="nav-hamburger" onClick=${onMenuToggle} + aria-label="${t('nav.toggle_menu')}">≡</button> + `} + <a class="nav-brand" href="#/">MeshBay</a> + </div> + <div class="nav-right"> + <button class="nav-theme" onClick=${onThemeToggle} + aria-label="${t('nav.toggle_menu')}" title=${theme === 'dark' ? t('nav.light_mode') : t('nav.dark_mode')}> + ${theme === 'dark' ? '☀' : '☾'} + </button> + ${user ? html` + <span class="nav-user">${user.username}</span> + <button class="nav-btn" onClick=${onLogout}>${t('nav.logout')}</button> + ` : html` + <a class="nav-btn" href="#/login">${t('nav.login')}</a> + `} + </div> + </nav> + `; } -// ── Node API helpers ────────────────────────────────────────────────────────── +// ── Sidebar ────────────────────────────────────────────────────────────────── -async function nodeGet(path) { - if (!state.nodeUrl) throw new Error('No node configured'); - const sep = path.includes('?') ? '&' : '?'; - const url = state.nodeUrl + path + (state.token ? `${sep}token=${state.token}` : ''); - const r = await fetch(url); - if (!r.ok) throw new Error(`Node ${r.status}`); - return r.json(); +function Sidebar({ groups, route, menuOpen }) { + return html` + <aside class="sidebar ${menuOpen ? 'open' : ''}"> + <div class="sidebar-section"> + <div class="sidebar-heading">${t('sidebar.my_groups')}</div> + ${groups.length === 0 + ? html`<div class="sidebar-empty">${t('sidebar.no_groups')}</div>` + : groups.map(g => html` + <a key=${g.id} + class="sidebar-item ${route === '/group/' + g.id ? 'active' : ''}" + href="#/group/${g.id}"> + ${g.name} + </a> + `) + } + </div> + <div class="sidebar-section"> + <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 === '/settings' ? 'active' : ''}" + href="#/settings">${t('sidebar.settings')}</a> + </div> + </aside> + `; } -// ── Pages ───────────────────────────────────────────────────────────────────── +// ── Login Page ─────────────────────────────────────────────────────────────── -async function pageHome() { - const groups = await hubGet('/v1/groups'); - const items = groups.groups.map(g => ` - <div class="card" onclick="pageGroup('${g.id}')"> - <b>${esc(g.name)}</b> - <span class="badge">${g.join_policy}</span> - </div>`).join(''); +function LoginPage() { + const auth = useAuth(); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + const onSubmit = async (e) => { + e.preventDefault(); + if (!username || !password) return; + setError(''); + setLoading(true); + try { + await auth.login(username, password); + navigate('/'); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + }; - return ` - <h2>Public Groups</h2> - ${items || '<p>No public groups yet.</p>'} - ${state.nodeUrl ? ` - <h2>My Node (<a href="${esc(state.nodeUrl)}" target="_blank">${esc(state.nodeUrl)}</a>)</h2> - <button onclick="pageNodeBrowser()">Browse My Node</button> - ` : ` - <h2>Connect to a Node</h2> - <input id="nodeUrl" placeholder="http://node-ip:19001" style="width:300px"> - <button onclick="connectNode()">Connect</button> - `}`; + return html` + <div class="page-center"> + <div class="card login-card"> + <h2>${t('login.title')}</h2> + <form onSubmit=${onSubmit}> + <input type="text" placeholder="${t('login.username')}" value=${username} + onInput=${e => setUsername(e.target.value)} + autocomplete="username" required /> + <input type="password" placeholder="${t('login.password')}" value=${password} + onInput=${e => setPassword(e.target.value)} + autocomplete="current-password" required /> + ${error && html`<div class="error-msg">${error}</div>`} + <button type="submit" disabled=${loading}> + ${loading ? t('login.loading') : t('login.submit')} + </button> + </form> + <div class="login-footer"> + ${t('login.no_account')} <a href="#/register">${t('login.register_link')}</a> + </div> + </div> + </div> + `; } -async function pageGroup(groupId) { - // TODO: fetch group info + node from hub - return `<p>Group ${groupId} — coming soon</p><button onclick="render()">Back</button>`; +// ── Register Page ──────────────────────────────────────────────────────────── + +function RegisterPage() { + const [username, setUsername] = useState(''); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [confirm, setConfirm] = useState(''); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(false); + const [loading, setLoading] = useState(false); + + const onSubmit = async (e) => { + e.preventDefault(); + if (password !== confirm) { setError(t('register.err_mismatch')); return; } + if (password.length < 8) { setError(t('register.err_min_len')); return; } + setError(''); + setLoading(true); + try { + if (window.MeshBayKeys) { + await window.MeshBayKeys.registerUser(username, email, password); + } else { + await hubFetch('/v1/users/register', { + method: 'POST', + body: { username, email, password, pk_user_ed25519: '', pk_user_x25519: '' }, + }); + } + setSuccess(true); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + }; + + if (success) { + return html` + <div class="page-center"> + <div class="card login-card"> + <h2>${t('register.success_title')}</h2> + <p style="text-align:center; margin-bottom:16px; color:var(--text-secondary)"> + ${t('register.success_msg')} + </p> + <a href="#/login" style="display:block; text-align:center">${t('register.go_login')}</a> + </div> + </div> + `; + } + + return html` + <div class="page-center"> + <div class="card login-card"> + <h2>${t('register.title')}</h2> + <form onSubmit=${onSubmit}> + <input type="text" placeholder="${t('register.username')}" value=${username} + onInput=${e => setUsername(e.target.value)} + autocomplete="username" required /> + <input type="email" placeholder="${t('register.email')}" value=${email} + onInput=${e => setEmail(e.target.value)} + autocomplete="email" required /> + <input type="password" placeholder="${t('register.password')}" value=${password} + onInput=${e => setPassword(e.target.value)} + autocomplete="new-password" required minlength="8" /> + <input type="password" placeholder="${t('register.confirm')}" value=${confirm} + onInput=${e => setConfirm(e.target.value)} + autocomplete="new-password" required /> + ${error && html`<div class="error-msg">${error}</div>`} + <button type="submit" disabled=${loading}> + ${loading ? t('register.loading') : t('register.submit')} + </button> + </form> + <div class="login-footer"> + ${t('register.has_account')} <a href="#/login">${t('register.login_link')}</a> + </div> + </div> + </div> + `; } -async function pageNodeBrowser() { - const data = await nodeGet('/index'); - const rows = data.entries.map(e => ` - <tr> - <td>${esc(e.name)}</td> - <td>${e.type}</td> - <td>${fmtSize(e.size)}</td> - <td> - ${e.type === 'video' ? `<button onclick="streamVideo('${e.id}','${esc(e.name)}')">▶ Play</button>` : ''} - <a href="${state.nodeUrl}/file/${e.id}" target="_blank">⬇ Download</a> - </td> - </tr>`).join(''); +// ── Home Page ──────────────────────────────────────────────────────────────── + +function HomePage({ groups }) { + if (groups.length === 0) { + return html` + <div> + <h2>${t('home.welcome')}</h2> + <p class="page-message"> + ${t('home.no_groups')} + ${' '}${t('home.browse_prefix')}<a href="#/explore">${t('home.browse_link')}</a>${t('home.browse_suffix')} + </p> + </div> + `; + } - return ` - <h2>📁 ${esc(data.group_name)}</h2> - <p>${data.entries.length} files — index v${data.version}</p> - <table> - <thead><tr><th>Name</th><th>Type</th><th>Size</th><th>Actions</th></tr></thead> - <tbody>${rows}</tbody> - </table> - <button onclick="render()">← Back</button>`; + return html` + <div> + <h2>${t('home.my_groups')}</h2> + <div class="group-grid"> + ${groups.map(g => html` + <a key=${g.id} class="group-card" href="#/group/${g.id}"> + <h3>${g.name}</h3> + <span class="badge">${g.visibility}</span> + ${' '} + <span class="badge">${g.join_policy}</span> + ${g.is_admin && html`${' '}<span class="badge">admin</span>`} + </a> + `)} + </div> + </div> + `; } -function pageStream(fileId, name) { - const src = `${state.nodeUrl}/hls/${fileId}/playlist.m3u8${state.token ? '?token=' + state.token : ''}`; - return ` - <h2>▶ ${esc(name)}</h2> - <video controls autoplay style="max-width:100%;width:800px"> - <source src="${esc(src)}" type="application/vnd.apple.mpegurl"> - Your browser does not support HLS. <a href="${state.nodeUrl}/file/${fileId}">Download instead</a>. - </video> - <br><button onclick="pageNodeBrowser().then(setMain)">← Back to files</button>`; +// ── Explore Page ───────────────────────────────────────────────────────────── + +function ExplorePage({ token }) { + const [groups, setGroups] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + hubFetch('/v1/groups', { token }) + .then(data => setGroups(data.groups || [])) + .catch(() => {}) + .finally(() => setLoading(false)); + }, [token]); + + return html` + <div> + <h2>${t('explore.title')}</h2> + ${loading + ? html`<p class="page-message">${t('explore.loading')}</p>` + : groups.length === 0 + ? html`<p class="page-message">${t('explore.empty')}</p>` + : html` + <div class="group-grid"> + ${groups.map(g => html` + <a key=${g.id} class="group-card" href="#/group/${g.id}"> + <h3>${g.name}</h3> + <span class="badge">${g.join_policy}</span> + ${g.source && g.source !== 'local' && html` + ${' '}<span class="badge">${g.source}</span> + `} + </a> + `)} + </div> + ` + } + </div> + `; } -// ── Actions ─────────────────────────────────────────────────────────────────── +// ── Helpers ────────────────────────────────────────────────────────────────── + +const FILE_ICONS = { + video: '\u{1F3AC}', audio: '\u{1F3B5}', image: '\u{1F5BC}', + document: '\u{1F4C4}', archive: '\u{1F4E6}', other: '\u{1F4CE}', +}; -async function connectNode() { - const url = document.getElementById('nodeUrl')?.value?.trim(); - if (!url) return; - state.nodeUrl = url; - localStorage.setItem('mb_node', url); - await pageNodeBrowser().then(setMain); +function formatSize(bytes) { + if (bytes < 1024) return bytes + ' B'; + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; + if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; + return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB'; } -async function streamVideo(fileId, name) { - setMain(pageStream(fileId, name)); +function formatDate(ts) { + return new Date(ts * 1000).toLocaleDateString(undefined, { + year: 'numeric', month: 'short', day: 'numeric', + }); } -async function doLogin() { - const u = document.getElementById('lu').value; - const p = document.getElementById('lp').value; - try { - await login(u, p); - render(); - } catch(e) { alert('Login failed: ' + e.message); } +// ── Group Page ────────────────────────────────────────────────────────────── + +const CHUNK_SIZE = 1024 * 1024; +const PIPELINE_WINDOW = 8; + +async function pipelinedDownload(transport, gekKey, fileId, totalChunks, onChunk, writable) { + const results = writable ? null : new Array(totalChunks); + let nextSend = 0, nextRecv = 0; + const inflight = new Array(totalChunks); + + const fire = () => { + while (nextSend < totalChunks && nextSend - nextRecv < PIPELINE_WINDOW) { + inflight[nextSend] = transport.fetchChunk(fileId, nextSend); + nextSend++; + } + }; + + fire(); + while (nextRecv < totalChunks) { + const chunkMsg = await inflight[nextRecv]; + let plaintext; + if (gekKey && chunkMsg.ct) { + plaintext = await window.MeshBayCrypto.decryptChunkBin( + gekKey, fileId, nextRecv, chunkMsg.nonce, chunkMsg.ct); + } else if (gekKey && chunkMsg.ct_b64) { + plaintext = await window.MeshBayCrypto.decryptChunk( + gekKey, fileId, nextRecv, chunkMsg.nonce_b64, chunkMsg.ct_b64); + } else { + plaintext = _b64ToU8(chunkMsg.ct_b64 || chunkMsg.data_b64); + } + if (writable) { + await writable.write(plaintext); + } else { + results[nextRecv] = plaintext; + } + nextRecv++; + fire(); + if (onChunk) onChunk(plaintext.byteLength, nextRecv, totalChunks); + } + return results; +} + +function GroupPage({ groupId, group, token, username }) { + const [status, setStatus] = useState('idle'); + const [entries, setEntries] = useState([]); + const [error, setError] = useState(''); + const [sortKey, setSortKey] = useState('name'); + const [sortAsc, setSortAsc] = useState(true); + const [filter, setFilter] = useState(''); + const [currentPath, setCurrentPath] = useState(''); + const [dlState, setDlState] = useState(null); + const [videoEntry, setVideoEntry] = useState(null); + const [tab, setTab] = useState('files'); + const transportRef = useRef(null); + const gekRef = useRef(null); + + useEffect(() => { + let cancelled = false; + const connect = async () => { + setStatus('discovering'); + setError(''); + setEntries([]); + gekRef.current = null; + try { + const nodesData = await hubFetch(`/v1/groups/${groupId}/nodes`, { token }); + if (cancelled) return; + if (!nodesData.nodes || nodesData.nodes.length === 0) { + setStatus('offline'); + return; + } + + setStatus('connecting'); + const nodeId = nodesData.nodes[0].node_id; + const transport = new window.MeshBayTransport('', token); + transportRef.current = transport; + + await transport.connect(nodeId, token, groupId); + if (cancelled) return; + setStatus('fetching'); + + const indexMsg = await transport.fetchIndex(); + if (cancelled) return; + setEntries(indexMsg.entries || []); + setStatus('connected'); + } catch (err) { + if (!cancelled) { + setError(err.message); + setStatus('error'); + } + } + }; + + if (token && window.MeshBayTransport) { + connect(); + } else if (!window.MeshBayTransport) { + setStatus('error'); + setError(t('group.err_transport')); + } + + return () => { + cancelled = true; + if (transportRef.current) { + transportRef.current.close(); + transportRef.current = null; + } + }; + }, [groupId, token]); + + const downloadFile = useCallback(async (entry) => { + const transport = transportRef.current; + if (!transport || !transport.connected) return; + + setDlState({ fileId: entry.id, name: entry.name, progress: 0, total: entry.size }); + + try { + if (!gekRef.current && window.MeshBayCrypto) { + const gekB64 = await transport.fetchGEK(); + gekRef.current = await window.MeshBayCrypto.importGEK(gekB64); + } + + const totalChunks = Math.ceil(entry.size / CHUNK_SIZE); + let downloaded = 0; + const onProgress = (bytes) => { + downloaded += bytes; + setDlState(prev => ({ ...prev, progress: downloaded })); + }; + + if (window.showSaveFilePicker) { + const handle = await window.showSaveFilePicker({ suggestedName: entry.name }); + const writable = await handle.createWritable(); + try { + await pipelinedDownload( + transport, gekRef.current, entry.id, totalChunks, onProgress, writable); + await writable.close(); + } catch (err) { + await writable.abort(); + throw err; + } + } else { + const chunks = await pipelinedDownload( + transport, gekRef.current, entry.id, totalChunks, onProgress); + const blob = new Blob(chunks); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = entry.name; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } + setDlState(null); + } catch (err) { + setDlState(null); + if (err.name === 'AbortError') return; + setError(t('group.dl_failed', { err: err.message })); + } + }, []); + + const toggleSort = useCallback((key) => { + setSortAsc(prev => sortKey === key ? !prev : true); + setSortKey(key); + }, [sortKey]); + + const dirs = new Set(); + const filteredEntries = entries.filter(e => { + const ePath = e.path || ''; + if (ePath === currentPath) { + return !filter || e.name.toLowerCase().includes(filter.toLowerCase()); + } + if (!currentPath && ePath) { + dirs.add(ePath.split('/')[0]); + } else if (currentPath && ePath.startsWith(currentPath + '/')) { + const rest = ePath.slice(currentPath.length + 1); + dirs.add(rest.split('/')[0]); + } + return false; + }); + + const sorted = [...filteredEntries].sort((a, b) => { + let cmp = 0; + if (sortKey === 'name') cmp = a.name.localeCompare(b.name); + else if (sortKey === 'size') cmp = a.size - b.size; + else if (sortKey === 'type') cmp = a.type.localeCompare(b.type); + else if (sortKey === 'date') cmp = a.added_at - b.added_at; + return sortAsc ? cmp : -cmp; + }); + + const subdirs = [...dirs].sort(); + + const statusLabel = { + idle: t('status.idle'), + discovering: t('status.discovering'), + connecting: t('status.connecting'), + fetching: t('status.fetching'), + connected: t('status.files', { n: entries.length }), + offline: t('status.offline'), + error: t('status.error'), + }[status] || status; + + const statusClass = status === 'connected' ? 'status-ok' + : status === 'error' || status === 'offline' ? 'status-err' : 'status-busy'; + + const breadcrumbs = currentPath ? currentPath.split('/') : []; + + return html` + <div> + <div class="group-header"> + <h2>${group ? group.name : t('group.default_name')}</h2> + <span class="status-badge ${statusClass}">${statusLabel}</span> + </div> + ${error && html`<div class="error-msg" style="margin-bottom:12px">${error}</div>`} + ${dlState && html` + <div class="dl-bar"> + <span class="dl-name">${dlState.name}</span> + <div class="dl-progress"> + <div class="dl-fill" style="width:${Math.round(dlState.progress / dlState.total * 100)}%"></div> + </div> + <span class="dl-pct">${formatSize(dlState.progress)} / ${formatSize(dlState.total)}</span> + </div> + `} + ${status === 'connected' && html` + <div class="group-tabs"> + <button class="group-tab ${tab === 'files' ? 'active' : ''}" + onClick=${() => setTab('files')}>${t('group.tab_files')}</button> + <button class="group-tab ${tab === 'chat' ? 'active' : ''}" + onClick=${() => setTab('chat')}>${t('group.tab_chat')}</button> + </div> + + ${tab === 'files' && html` + <div class="file-toolbar"> + <div class="breadcrumbs"> + <a class="crumb" onClick=${() => setCurrentPath('')}>/</a> + ${breadcrumbs.map((seg, i) => { + const path = breadcrumbs.slice(0, i + 1).join('/'); + return html` + <span class="crumb-sep">/</span> + <a class="crumb" onClick=${() => setCurrentPath(path)}>${seg}</a> + `; + })} + </div> + <input type="text" class="file-search" placeholder="${t('group.filter')}" + value=${filter} onInput=${e => setFilter(e.target.value)} /> + </div> + <table class="file-table"> + <thead> + <tr> + <th></th> + <th class="sortable" onClick=${() => toggleSort('name')}> + ${t('group.col_name')} ${sortKey === 'name' ? (sortAsc ? '▲' : '▼') : ''} + </th> + <th class="sortable" onClick=${() => toggleSort('size')}> + ${t('group.col_size')} ${sortKey === 'size' ? (sortAsc ? '▲' : '▼') : ''} + </th> + <th class="sortable th-type" onClick=${() => toggleSort('type')}> + ${t('group.col_type')} ${sortKey === 'type' ? (sortAsc ? '▲' : '▼') : ''} + </th> + <th class="sortable th-date" onClick=${() => toggleSort('date')}> + ${t('group.col_date')} ${sortKey === 'date' ? (sortAsc ? '▲' : '▼') : ''} + </th> + <th></th> + </tr> + </thead> + <tbody> + ${subdirs.map(d => html` + <tr class="file-row dir-row" onClick=${() => + setCurrentPath(currentPath ? currentPath + '/' + d : d)}> + <td>\u{1F4C1}</td> + <td>${d}/</td> + <td></td> + <td class="td-type"></td> + <td class="td-date"></td> + <td></td> + </tr> + `)} + ${sorted.map(e => html` + <tr class="file-row" key=${e.id}> + <td>${FILE_ICONS[e.type] || FILE_ICONS.other}</td> + <td class="file-name">${e.name}</td> + <td class="file-size">${formatSize(e.size)}</td> + <td class="file-type td-type">${e.type}</td> + <td class="file-date td-date">${formatDate(e.added_at)}</td> + <td> + ${e.type === 'video' && html` + <button class="play-btn" onClick=${() => setVideoEntry(e)} + disabled=${!!dlState || !!videoEntry} title="${t('group.play')}" + \u{25B6} + </button> + `} + <button class="dl-btn" onClick=${() => downloadFile(e)} + disabled=${!!dlState} title="${t('group.download')}" + \u{2B07} + </button> + </td> + </tr> + `)} + ${sorted.length === 0 && subdirs.length === 0 && html` + <tr><td colspan="6" class="file-empty"> + ${filter ? t('group.empty_filter') : t('group.empty_dir')} + </td></tr> + `} + </tbody> + </table> + `} + + ${tab === 'chat' && html` + <${ChatPanel} transportRef=${transportRef} username=${username} /> + `} + `} + ${status === 'offline' && html` + <p class="page-message"> + ${t('group.offline_title')} + ${' '}${t('group.offline_hint')} + </p> + `} + ${(status === 'discovering' || status === 'connecting' || status === 'fetching') && html` + <p class="page-message">${statusLabel}</p> + `} + ${videoEntry && html` + <${VideoPlayer} + entry=${videoEntry} + transportRef=${transportRef} + gekRef=${gekRef} + onClose=${() => setVideoEntry(null)} /> + `} + </div> + `; } -// ── Router / render ─────────────────────────────────────────────────────────── +function _b64ToU8(b64) { + const bin = atob(b64); + const arr = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i); + return arr; +} + +// ── Chat Panel ────────────────────────────────────────────────────────── -function setMain(html) { - document.getElementById('main').innerHTML = html; +function formatTime(ts) { + const d = new Date(ts * 1000); + const now = new Date(); + const time = d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); + if (d.toDateString() === now.toDateString()) return time; + return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + ' ' + time; } -async function render() { - const nav = document.getElementById('nav'); - if (state.username) { - nav.innerHTML = `<b>MeshBay</b> | Logged in as <b>${esc(state.username)}</b> - <button onclick="logout()" style="float:right">Logout</button>`; - setMain('<p>Loading…</p>'); - setMain(await pageHome()); - } else { - nav.innerHTML = '<b>MeshBay</b>'; - setMain(` - <h2>Login</h2> - <input id="lu" placeholder="Username" autocomplete="username"> - <input id="lp" type="password" placeholder="Password" autocomplete="current-password"> - <button onclick="doLogin()">Login</button> - <p><small>No account? Register via the API for now.</small></p>`); - } +function ChatPanel({ transportRef, username }) { + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(''); + const [sending, setSending] = useState(false); + const listRef = useRef(null); + const bottomRef = useRef(null); + const loadedRef = useRef(false); + + useEffect(() => { + const transport = transportRef.current; + if (!transport || !transport.connected) return; + + if (!loadedRef.current) { + loadedRef.current = true; + transport.fetchChatHistory(0, 200) + .then(msgs => setMessages(msgs)) + .catch(() => {}); + } + + transport.onChat = (msg) => { + setMessages(prev => [...prev, { + sender_id: msg.sender_id, + payload: msg.payload, + timestamp: msg.timestamp || Date.now() / 1000, + thread_id: msg.thread_id, + }]); + }; + + return () => { transport.onChat = null; }; + }, [transportRef.current?.connected]); + + useEffect(() => { + if (bottomRef.current) { + bottomRef.current.scrollIntoView({ behavior: 'smooth' }); + } + }, [messages.length]); + + const sendMessage = useCallback(async () => { + const text = input.trim(); + if (!text) return; + const transport = transportRef.current; + if (!transport || !transport.connected) return; + + setSending(true); + setInput(''); + try { + await transport.sendChat(text, 0, null); + setMessages(prev => [...prev, { + sender_id: username, + payload: text, + timestamp: Date.now() / 1000, + thread_id: null, + }]); + } catch { + setInput(text); + } finally { + setSending(false); + } + }, [input, username]); + + const onKeyDown = useCallback((e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + sendMessage(); + } + }, [sendMessage]); + + return html` + <div class="chat-panel"> + <div class="chat-messages" ref=${listRef}> + ${messages.length === 0 && html` + <div class="chat-empty">${t('chat.empty')}</div> + `} + ${messages.map((m, i) => { + const isOwn = m.sender_id === username; + const showSender = !isOwn && (i === 0 || messages[i - 1].sender_id !== m.sender_id); + return html` + <div key=${i} class="chat-msg ${isOwn ? 'chat-msg-own' : ''}"> + ${showSender && html` + <div class="chat-sender">${m.sender_id}</div> + `} + <div class="chat-bubble ${isOwn ? 'chat-bubble-own' : ''}"> + <span class="chat-text">${m.payload}</span> + <span class="chat-time">${formatTime(m.timestamp)}</span> + </div> + </div> + `; + })} + <div ref=${bottomRef} /> + </div> + <div class="chat-input-row"> + <textarea class="chat-input" rows="1" + placeholder="${t('chat.placeholder')}" + value=${input} + onInput=${e => setInput(e.target.value)} + onKeyDown=${onKeyDown} + disabled=${sending} /> + <button class="chat-send" onClick=${sendMessage} + disabled=${sending || !input.trim()}> + ${t('chat.send')} + </button> + </div> + </div> + `; } -// ── Utils ───────────────────────────────────────────────────────────────────── +// ── Video Player ──────────────────────────────────────────────────────── + +const VIDEO_MIMES = { + '.mp4': 'video/mp4', '.webm': 'video/webm', '.mkv': 'video/x-matroska', + '.avi': 'video/x-msvideo', '.mov': 'video/quicktime', '.m4v': 'video/mp4', + '.flv': 'video/x-flv', '.wmv': 'video/x-ms-wmv', +}; -function esc(s) { - return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>') - .replace(/"/g,'"').replace(/'/g,'''); +function videoMime(name) { + const dot = name.lastIndexOf('.'); + if (dot < 0) return 'video/mp4'; + return VIDEO_MIMES[name.slice(dot).toLowerCase()] || 'video/mp4'; } -function fmtSize(bytes) { - if (bytes < 1024) return bytes + ' B'; - if (bytes < 1024**2) return (bytes/1024).toFixed(1) + ' KB'; - if (bytes < 1024**3) return (bytes/1024**2).toFixed(1) + ' MB'; - return (bytes/1024**3).toFixed(2) + ' GB'; +function VideoPlayer({ entry, transportRef, gekRef, onClose }) { + const [phase, setPhase] = useState('loading'); + const [progress, setProgress] = useState(0); + const [error, setError] = useState(''); + const videoRef = useRef(null); + const blobUrlRef = useRef(null); + + useEffect(() => { + let cancelled = false; + + const load = async () => { + const transport = transportRef.current; + if (!transport || !transport.connected) { + setError(t('video.err_transport')); + setPhase('error'); + return; + } + + try { + if (!gekRef.current && window.MeshBayCrypto) { + const gekB64 = await transport.fetchGEK(); + gekRef.current = await window.MeshBayCrypto.importGEK(gekB64); + } + + const totalChunks = Math.ceil(entry.size / CHUNK_SIZE); + let downloaded = 0; + const chunks = await pipelinedDownload( + transport, gekRef.current, entry.id, totalChunks, + (bytes) => { downloaded += bytes; setProgress(downloaded / entry.size); }, + ); + + if (cancelled) return; + + const blob = new Blob(chunks, { type: videoMime(entry.name) }); + const url = URL.createObjectURL(blob); + blobUrlRef.current = url; + setPhase('ready'); + } catch (err) { + if (!cancelled) { + setError(err.message); + setPhase('error'); + } + } + }; + + load(); + return () => { cancelled = true; }; + }, [entry]); + + useEffect(() => { + if (phase === 'ready' && videoRef.current && blobUrlRef.current) { + videoRef.current.src = blobUrlRef.current; + videoRef.current.play().catch(() => {}); + } + }, [phase]); + + useEffect(() => { + return () => { + if (blobUrlRef.current) { + URL.revokeObjectURL(blobUrlRef.current); + blobUrlRef.current = null; + } + }; + }, []); + + useEffect(() => { + const onKey = (e) => { if (e.key === 'Escape') onClose(); }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [onClose]); + + return html` + <div class="video-overlay" onClick=${(e) => { + if (e.target.classList.contains('video-overlay')) onClose(); + }}> + <div class="video-top-bar"> + <span class="video-title">${entry.name}</span> + <button class="video-close" onClick=${onClose} title="${t('video.close')}">✕</button> + </div> + + ${phase === 'loading' && html` + <div class="video-loading"> + <div class="video-loading-label">${t('video.loading', { name: entry.name })}</div> + <div class="video-progress-bar"> + <div class="video-progress-fill" + style="width:${Math.round(progress * 100)}%"></div> + </div> + <div class="video-progress-text"> + ${formatSize(Math.round(progress * entry.size))} / ${formatSize(entry.size)} + </div> + </div> + `} + + ${phase === 'ready' && html` + <div class="video-container"> + <video ref=${videoRef} controls autoplay /> + </div> + `} + + ${phase === 'error' && html` + <div class="video-error">${error}</div> + `} + </div> + `; +} + +// ── Settings Page ─────────────────────────────────────────────────────────── + +const THEME_OPTIONS = ['light', 'dark', 'system']; + +function SettingsPage({ user, theme, onThemeChange }) { + const [locale, setLoc] = useState(getLocale); + + const onLocaleChange = useCallback((e) => { + const code = e.target.value; + setLocale(code); + setLoc(code); + window.location.reload(); + }, []); + + const onThemeSelect = useCallback((e) => { + onThemeChange(e.target.value); + }, [onThemeChange]); + + return html` + <div> + <h2>${t('settings.title')}</h2> + + <div class="settings-section"> + <h3 class="settings-heading">${t('settings.profile')}</h3> + <div class="settings-row"> + <span class="settings-label">${t('settings.username')}</span> + <span class="settings-value">${user.username}</span> + </div> + </div> + + <div class="settings-section"> + <h3 class="settings-heading">${t('settings.appearance')}</h3> + <div class="settings-row"> + <span class="settings-label">${t('settings.theme')}</span> + <select class="settings-select" value=${theme} onChange=${onThemeSelect}> + <option value="light">${t('settings.theme_light')}</option> + <option value="dark">${t('settings.theme_dark')}</option> + <option value="system">${t('settings.theme_system')}</option> + </select> + </div> + <div class="settings-row"> + <span class="settings-label">${t('settings.language')}</span> + <select class="settings-select" value=${locale} onChange=${onLocaleChange}> + ${LOCALES.map(l => html` + <option key=${l.code} value=${l.code}>${l.name}</option> + `)} + </select> + </div> + </div> + + <div class="settings-section"> + <h3 class="settings-heading">${t('settings.about')}</h3> + <div class="settings-row"> + <span class="settings-label">${t('settings.version')}</span> + <span class="settings-value">0.1.0</span> + </div> + <div class="settings-row"> + <span class="settings-label">${t('settings.protocol')}</span> + <span class="settings-value">MNP 0.1 / MHP 0.1</span> + </div> + </div> + </div> + `; } -// ── Boot ────────────────────────────────────────────────────────────────────── -document.addEventListener('DOMContentLoaded', render); +// ── App ────────────────────────────────────────────────────────────────────── + +function App() { + const route = useRoute(); + const [theme, setTheme] = useState(getInitialTheme); + const [user, setUser] = useState(loadAuth); + const [groups, setGroups] = useState([]); + const [menuOpen, setMenuOpen] = useState(false); + + const resolved = resolveTheme(theme); + + useEffect(() => { + document.documentElement.className = `theme-${resolved}`; + localStorage.setItem(THEME_KEY, theme); + }, [theme, resolved]); + + useEffect(() => { + if (!user) { setGroups([]); return; } + hubFetch('/v1/groups/mine', { token: user.token }) + .then(data => setGroups(data.groups || [])) + .catch(() => setGroups([])); + }, [user]); + + useEffect(() => { setMenuOpen(false); }, [route]); + + const toggleTheme = useCallback(() => { + setTheme(prev => resolveTheme(prev) === 'dark' ? 'light' : 'dark'); + }, []); + + const authCtx = { + user, + login: async (username, password) => { + if (window.MeshBayKeys) { + const data = await window.MeshBayKeys.loginAndRecover(username, password); + _sessionKeys = { skXB64: data.skXB64, skEdB64: data.skEdB64 }; + const u = { + username, + token: data.accessToken, + refreshToken: data.refreshToken, + }; + setUser(u); + saveAuth(u); + } else { + const data = await hubFetch('/v1/users/login', { + method: 'POST', + body: { username, password }, + }); + const u = { + username, + token: data.access_token, + refreshToken: data.refresh_token, + }; + setUser(u); + saveAuth(u); + } + }, + logout: () => { + setUser(null); + saveAuth(null); + setGroups([]); + navigate('/login'); + }, + }; + + let page; + if (route === '/login' || route === '/register') { + page = route === '/register' + ? html`<${RegisterPage} />` + : html`<${LoginPage} />`; + } else if (!user) { + page = html`<${LoginPage} />`; + } else if (route === '/explore') { + page = html`<${ExplorePage} token=${user.token} />`; + } else if (route.startsWith('/group/')) { + const groupId = route.slice(7); + const group = groups.find(g => g.id === groupId); + page = html`<${GroupPage} + groupId=${groupId} group=${group} token=${user.token} + username=${user.username} />`; + } else if (route === '/settings') { + page = html`<${SettingsPage} user=${user} theme=${theme} + onThemeChange=${setTheme} />`; + } else { + page = html`<${HomePage} groups=${groups} />`; + } + + return html` + <${AuthContext.Provider} value=${authCtx}> + <${Nav} + user=${user} + theme=${resolved} + onThemeToggle=${toggleTheme} + onLogout=${authCtx.logout} + onMenuToggle=${() => setMenuOpen(o => !o)} /> + <div class="layout"> + ${user && html`<${Sidebar} + groups=${groups} + route=${route} + menuOpen=${menuOpen} />`} + ${menuOpen && html`<div class="overlay visible" + onClick=${() => setMenuOpen(false)} />`} + <main class="main"> + ${page} + </main> + </div> + <//> + `; +} + +// ── Boot ───────────────────────────────────────────────────────────────────── + +render(html`<${App} />`, document.getElementById('app')); diff --git a/packages/meshbay-hub/src/meshbay_hub/static/crypto.js b/packages/meshbay-hub/src/meshbay_hub/static/crypto.js index 7862283..ee442fb 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/crypto.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/crypto.js @@ -136,7 +136,7 @@ function b64decode(b64) { function hexToBytes(hex) { const bytes = new Uint8Array(hex.length / 2); for (let i = 0; i < hex.length; i += 2) - bytes[i / 2] = parseInt(hex.substring(i, 2), 16); + bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16); return bytes; } @@ -151,5 +151,12 @@ function concatBuffers(arrays) { return result; } +async function decryptChunkBin(gek, fileHashHex, chunkIndex, nonce, ct) { + const chunkKey = await deriveChunkKey(gek, fileHashHex, chunkIndex); + const plaintext = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv: nonce }, chunkKey, ct); + return new Uint8Array(plaintext); +} + // Export for use in app.js -window.MeshBayCrypto = { importGEK, deriveChunkKey, decryptChunk, decryptFile }; +window.MeshBayCrypto = { importGEK, deriveChunkKey, decryptChunk, decryptChunkBin, decryptFile }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js new file mode 100644 index 0000000..e0242cf --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js @@ -0,0 +1,172 @@ +/** + * MeshBay i18n — lightweight string localization. + * + * Usage: + * import { t, setLocale, getLocale, LOCALES } from './i18n.js'; + * t('nav.logout') // "Logout" + * t('status.files', { n: 42 }) // "42 files" + */ + +const LANG_KEY = 'mb_lang'; + +// ── English strings ───────────────────────────────────────────────────────── + +const en = { + // Nav + 'nav.toggle_menu': 'Toggle menu', + 'nav.light_mode': 'Light mode', + 'nav.dark_mode': 'Dark mode', + 'nav.logout': 'Logout', + 'nav.login': 'Login', + + // Sidebar + 'sidebar.my_groups': 'My Groups', + 'sidebar.no_groups': 'No groups yet', + 'sidebar.discover': 'Discover', + 'sidebar.public_groups': 'Public groups', + + // Login + 'login.title': 'Login', + 'login.username': 'Username', + 'login.password': 'Password', + 'login.submit': 'Login', + 'login.loading': 'Logging in...', + 'login.no_account': 'No account?', + 'login.register_link': 'Register', + + // Register + 'register.title': 'Register', + 'register.username': 'Username', + 'register.email': 'Email', + 'register.password': 'Password (min 8 chars)', + 'register.confirm': 'Confirm password', + 'register.submit': 'Register', + 'register.loading': 'Creating account...', + 'register.has_account': 'Already have an account?', + 'register.login_link': 'Login', + 'register.success_title': 'Account created', + 'register.success_msg': 'You can now log in with your credentials.', + 'register.go_login': 'Go to login', + 'register.err_mismatch': 'Passwords do not match', + 'register.err_min_len': 'Password must be at least 8 characters', + + // Home + 'home.welcome': 'Welcome to MeshBay', + 'home.no_groups': 'You are not a member of any group yet.', + 'home.browse_prefix': 'Browse ', + 'home.browse_link': 'public groups', + 'home.browse_suffix': ' or ask a group admin to invite you.', + 'home.my_groups': 'My Groups', + + // Explore + 'explore.title': 'Public Groups', + 'explore.loading': 'Loading...', + 'explore.empty': 'No public groups available.', + + // Group page + 'group.default_name': 'Group', + 'group.tab_files': 'Files', + 'group.tab_chat': 'Chat', + 'group.filter': 'Filter files...', + 'group.col_name': 'Name', + 'group.col_size': 'Size', + 'group.col_type': 'Type', + 'group.col_date': 'Date', + 'group.empty_filter': 'No files match the filter', + 'group.empty_dir': 'This directory is empty', + 'group.download': 'Download', + 'group.play': 'Play', + '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.err_transport': 'Transport module not loaded', + + // Status + 'status.idle': 'Idle', + 'status.discovering': 'Finding nodes...', + 'status.connecting': 'Connecting via WebRTC...', + 'status.fetching': 'Fetching index...', + 'status.files': '{n} files', + 'status.offline': 'No nodes online', + 'status.error': 'Connection failed', + + // Chat + 'chat.empty': 'No messages yet. Start the conversation!', + 'chat.placeholder': 'Type a message...', + 'chat.send': 'Send', + + // Video player + 'video.loading': 'Loading {name}...', + 'video.close': 'Close (Esc)', + 'video.err_transport': 'Transport not connected', + + // Settings + 'settings.title': 'Settings', + 'settings.coming_soon': 'Coming soon.', + 'settings.profile': 'Profile', + 'settings.username': 'Username', + 'settings.appearance': 'Appearance', + 'settings.theme': 'Theme', + 'settings.theme_light': 'Light', + 'settings.theme_dark': 'Dark', + 'settings.theme_system': 'System', + 'settings.language': 'Language', + 'settings.about': 'About', + 'settings.version': 'Version', + 'settings.protocol': 'Protocol', + + // Sidebar + 'sidebar.settings': 'Settings', +}; + +// ── Locale registry ───────────────────────────────────────────────────────── + +const _strings = { en }; +let _locale = 'en'; + +export const LOCALES = [ + { code: 'en', name: 'English' }, +]; + +export function getLocale() { return _locale; } + +export function setLocale(code) { + if (_strings[code]) { + _locale = code; + localStorage.setItem(LANG_KEY, code); + return true; + } + return false; +} + +export function addLocale(code, strings) { + _strings[code] = strings; +} + +export function t(key, params) { + let s = (_strings[_locale] && _strings[_locale][key]) + || _strings.en[key] + || key; + if (params) { + for (const [k, v] of Object.entries(params)) { + s = s.replace(`{${k}}`, v); + } + } + return s; +} + +// ── Init ──────────────────────────────────────────────────────────────────── + +function _init() { + const stored = localStorage.getItem(LANG_KEY); + if (stored && _strings[stored]) { + _locale = stored; + return; + } + const browserLang = (navigator.language || '').split('-')[0]; + if (_strings[browserLang]) { + _locale = browserLang; + } +} + +_init(); diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css new file mode 100644 index 0000000..27e645e --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css @@ -0,0 +1,802 @@ +/* MeshBay Web Client — Phase 9.6 */ + +/* ── Theme variables ──────────────────────────────────────────────────────── */ + +:root, .theme-light { + --bg-base: #f8fafc; + --bg-surface: #ffffff; + --bg-raised: #f1f5f9; + --text: #0f172a; + --text-secondary: #475569; + --text-dim: #94a3b8; + --border: #e2e8f0; + --border-focus: #0ea5e9; + --accent: #0ea5e9; + --accent-hover: #0284c7; + --accent-text: #ffffff; + --error: #ef4444; + --error-bg: #fef2f2; + --success: #22c55e; + --nav-bg: #0f172a; + --nav-text: #e2e8f0; + --nav-brand: #38bdf8; + --sidebar-bg: #f1f5f9; + --sidebar-hover: #e2e8f0; + --shadow: 0 1px 3px rgba(0, 0, 0, 0.08); + --shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.1); +} + +.theme-dark { + --bg-base: #0f172a; + --bg-surface: #1e293b; + --bg-raised: #334155; + --text: #f1f5f9; + --text-secondary: #94a3b8; + --text-dim: #64748b; + --border: #334155; + --error-bg: #450a0a; + --nav-bg: #020617; + --sidebar-bg: #1e293b; + --sidebar-hover: #334155; + --shadow: 0 1px 3px rgba(0, 0, 0, 0.3); + --shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.3); +} + +/* ── Reset ────────────────────────────────────────────────────────────────── */ + +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +body { + font-family: system-ui, -apple-system, sans-serif; + background: var(--bg-base); + color: var(--text); + line-height: 1.6; + min-height: 100vh; +} + +a { color: var(--accent); text-decoration: none; } +a:hover { text-decoration: underline; } + +/* ── Navigation ───────────────────────────────────────────────────────────── */ + +.nav { + background: var(--nav-bg); + color: var(--nav-text); + padding: 0 16px; + height: 52px; + display: flex; + align-items: center; + justify-content: space-between; + position: sticky; + top: 0; + z-index: 100; +} + +.nav-left, .nav-right { display: flex; align-items: center; gap: 12px; } + +.nav-brand { + color: var(--nav-brand); + font-weight: 700; + font-size: 1.15em; + text-decoration: none; +} +.nav-brand:hover { text-decoration: none; } + +.nav-hamburger { + display: none; + background: none; + border: none; + color: var(--nav-text); + font-size: 1.4em; + cursor: pointer; + padding: 4px 8px; + line-height: 1; +} + +.nav-user { color: var(--text-dim); font-size: 0.9em; } + +.nav-theme { + background: none; + border: 1px solid rgba(255, 255, 255, 0.15); + color: var(--nav-text); + width: 32px; + height: 32px; + border-radius: 6px; + cursor: pointer; + font-size: 1.1em; + display: flex; + align-items: center; + justify-content: center; +} +.nav-theme:hover { border-color: rgba(255, 255, 255, 0.3); background: none; } + +.nav-btn { + background: rgba(255, 255, 255, 0.1); + color: var(--nav-text); + border: none; + padding: 6px 14px; + border-radius: 6px; + font-size: 0.85em; + cursor: pointer; + text-decoration: none; +} +.nav-btn:hover { background: rgba(255, 255, 255, 0.18); text-decoration: none; } + +/* ── Layout ───────────────────────────────────────────────────────────────── */ + +.layout { display: flex; min-height: calc(100vh - 52px); } + +/* ── Sidebar ──────────────────────────────────────────────────────────────── */ + +.sidebar { + width: 240px; + background: var(--sidebar-bg); + border-right: 1px solid var(--border); + padding: 16px 0; + flex-shrink: 0; + overflow-y: auto; +} + +.sidebar-section { padding: 0 12px; margin-bottom: 24px; } + +.sidebar-heading { + font-size: 0.7em; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-dim); + padding: 4px 8px; + margin-bottom: 4px; + font-weight: 600; +} + +.sidebar-item { + display: block; + padding: 7px 12px; + border-radius: 6px; + color: var(--text); + text-decoration: none; + font-size: 0.9em; + transition: background 0.12s; +} +.sidebar-item:hover { background: var(--sidebar-hover); text-decoration: none; } +.sidebar-item.active { background: var(--accent); color: var(--accent-text); } + +.sidebar-empty { + padding: 8px 12px; + color: var(--text-dim); + font-size: 0.85em; + font-style: italic; +} + +/* ── Main content ─────────────────────────────────────────────────────────── */ + +.main { + flex: 1; + padding: 24px 32px; + max-width: 960px; + min-width: 0; +} + +.main h2 { font-size: 1.25em; margin-bottom: 16px; } + +.page-message { + color: var(--text-secondary); + margin-bottom: 24px; +} + +/* ── Page center (login / register) ───────────────────────────────────────── */ + +.page-center { + display: flex; + align-items: center; + justify-content: center; + min-height: calc(100vh - 52px); + padding: 24px; +} + +/* ── Cards ────────────────────────────────────────────────────────────────── */ + +.card { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: 8px; + padding: 24px; + box-shadow: var(--shadow); +} + +.login-card { width: 100%; max-width: 380px; } +.login-card h2 { text-align: center; margin-bottom: 20px; } +.login-card form { display: flex; flex-direction: column; gap: 12px; } + +.login-footer { + text-align: center; + margin-top: 16px; + font-size: 0.85em; + color: var(--text-dim); +} + +/* ── Form elements ────────────────────────────────────────────────────────── */ + +input[type="text"], +input[type="password"], +input[type="email"] { + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg-base); + color: var(--text); + font-size: 0.95em; + width: 100%; + transition: border-color 0.15s; +} +input:focus { outline: none; border-color: var(--border-focus); } + +button { + padding: 10px 20px; + background: var(--accent); + color: var(--accent-text); + border: none; + border-radius: 6px; + font-size: 0.95em; + cursor: pointer; + transition: background 0.15s; +} +button:hover { background: var(--accent-hover); } +button:disabled { opacity: 0.5; cursor: not-allowed; } + +/* ── Alerts ───────────────────────────────────────────────────────────────── */ + +.error-msg { + background: var(--error-bg); + color: var(--error); + border: 1px solid var(--error); + border-radius: 6px; + padding: 8px 12px; + font-size: 0.85em; +} + +/* ── Group cards (9.7 prep) ───────────────────────────────────────────────── */ + +.group-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 16px; +} + +.group-card { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: 8px; + padding: 16px; + cursor: pointer; + transition: border-color 0.12s, box-shadow 0.12s; + text-decoration: none; + color: var(--text); + display: block; +} +.group-card:hover { + border-color: var(--accent); + box-shadow: var(--shadow-lg); + text-decoration: none; +} +.group-card h3 { font-size: 1em; margin-bottom: 4px; } + +.badge { + display: inline-block; + background: var(--bg-raised); + color: var(--text-secondary); + padding: 2px 8px; + border-radius: 12px; + font-size: 0.75em; +} + +/* ── Group page header ───────────────────────────────────────────────────── */ + +.group-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 16px; +} +.group-header h2 { margin-bottom: 0; } + +.status-badge { + display: inline-block; + padding: 3px 10px; + border-radius: 12px; + font-size: 0.75em; + font-weight: 500; +} +.status-ok { background: #16a34a20; color: var(--success); } +.status-err { background: var(--error-bg); color: var(--error); } +.status-busy { background: var(--bg-raised); color: var(--text-secondary); } + +/* ── Group tabs ──────────────────────────────────────────────────────────── */ + +.group-tabs { + display: flex; + gap: 0; + margin-bottom: 16px; + border-bottom: 2px solid var(--border); +} + +.group-tab { + background: none; + border: none; + border-bottom: 2px solid transparent; + margin-bottom: -2px; + padding: 8px 20px; + color: var(--text-secondary); + font-size: 0.9em; + font-weight: 500; + cursor: pointer; + border-radius: 0; + transition: color 0.12s, border-color 0.12s; +} +.group-tab:hover { color: var(--text); background: none; } +.group-tab.active { + color: var(--accent); + border-bottom-color: var(--accent); +} + +/* ── Chat panel ──────────────────────────────────────────────────────────── */ + +.chat-panel { + display: flex; + flex-direction: column; + height: calc(100vh - 220px); + min-height: 300px; + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; +} + +.chat-messages { + flex: 1; + overflow-y: auto; + padding: 16px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.chat-empty { + text-align: center; + color: var(--text-dim); + font-style: italic; + padding: 40px 16px; +} + +.chat-msg { + display: flex; + flex-direction: column; + align-items: flex-start; + max-width: 75%; +} + +.chat-msg-own { + align-self: flex-end; + align-items: flex-end; +} + +.chat-sender { + font-size: 0.72em; + color: var(--text-dim); + margin-bottom: 2px; + padding-left: 10px; + font-weight: 600; +} + +.chat-bubble { + background: var(--bg-raised); + padding: 8px 12px; + border-radius: 12px 12px 12px 4px; + font-size: 0.9em; + line-height: 1.45; + word-break: break-word; + display: inline-flex; + align-items: baseline; + gap: 8px; + flex-wrap: wrap; +} + +.chat-bubble-own { + background: var(--accent); + color: var(--accent-text); + border-radius: 12px 12px 4px 12px; +} + +.chat-text { white-space: pre-wrap; } + +.chat-time { + font-size: 0.65em; + color: var(--text-dim); + white-space: nowrap; + flex-shrink: 0; +} +.chat-bubble-own .chat-time { color: rgba(255, 255, 255, 0.6); } + +.chat-input-row { + display: flex; + gap: 8px; + padding: 12px; + border-top: 1px solid var(--border); + background: var(--bg-base); +} + +.chat-input { + flex: 1; + resize: none; + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg-surface); + color: var(--text); + font-size: 0.9em; + font-family: inherit; + line-height: 1.4; + min-height: 38px; + max-height: 100px; +} +.chat-input:focus { outline: none; border-color: var(--border-focus); } + +.chat-send { + align-self: flex-end; + padding: 8px 16px; + font-size: 0.85em; + border-radius: 8px; + white-space: nowrap; +} + +/* ── File toolbar ────────────────────────────────────────────────────────── */ + +.file-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; + flex-wrap: wrap; +} + +.breadcrumbs { + display: flex; + align-items: center; + gap: 2px; + font-size: 0.85em; + min-width: 0; + flex-wrap: wrap; +} +.crumb { + cursor: pointer; + color: var(--accent); + padding: 2px 4px; + border-radius: 4px; +} +.crumb:hover { background: var(--bg-raised); text-decoration: none; } +.crumb-sep { color: var(--text-dim); } + +.file-search { + max-width: 220px; + padding: 6px 10px !important; + font-size: 0.85em !important; +} + +/* ── File table ──────────────────────────────────────────────────────────── */ + +.file-table { + width: 100%; + border-collapse: collapse; + font-size: 0.9em; +} + +.file-table th { + text-align: left; + padding: 8px 12px; + border-bottom: 2px solid var(--border); + color: var(--text-secondary); + font-size: 0.8em; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + white-space: nowrap; + user-select: none; +} + +.file-table th.sortable { cursor: pointer; } +.file-table th.sortable:hover { color: var(--text); } + +.file-row td { + padding: 8px 12px; + border-bottom: 1px solid var(--border); + vertical-align: middle; +} + +.file-row:hover { background: var(--bg-raised); } +.dir-row { cursor: pointer; } +.dir-row td { font-weight: 500; } + +.file-name { + word-break: break-word; + min-width: 0; +} +.file-size { white-space: nowrap; color: var(--text-secondary); } +.file-type { color: var(--text-dim); } +.file-date { white-space: nowrap; color: var(--text-dim); } +.file-empty { + text-align: center; + padding: 24px 12px !important; + color: var(--text-dim); + font-style: italic; +} + +/* ── Download button + progress ──────────────────────────────────────────── */ + +.dl-btn { + background: none; + border: 1px solid var(--border); + color: var(--accent); + width: 30px; + height: 30px; + border-radius: 6px; + font-size: 0.9em; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; +} +.dl-btn:hover { background: var(--bg-raised); border-color: var(--accent); } +.dl-btn:disabled { opacity: 0.3; cursor: not-allowed; } + +.dl-bar { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 14px; + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: 8px; + margin-bottom: 12px; + font-size: 0.85em; +} + +.dl-name { + flex-shrink: 0; + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 500; +} + +.dl-progress { + flex: 1; + height: 6px; + background: var(--bg-raised); + border-radius: 3px; + overflow: hidden; +} + +.dl-fill { + height: 100%; + background: var(--accent); + border-radius: 3px; + transition: width 0.2s; +} + +.dl-pct { + flex-shrink: 0; + color: var(--text-secondary); + font-size: 0.85em; + white-space: nowrap; +} + +/* ── Settings page ───────────────────────────────────────────────────────── */ + +.settings-section { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: 8px; + padding: 16px 20px; + margin-bottom: 16px; +} + +.settings-heading { + font-size: 0.8em; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-dim); + font-weight: 600; + margin-bottom: 12px; +} + +.settings-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 0; +} +.settings-row + .settings-row { + border-top: 1px solid var(--border); +} + +.settings-label { + font-size: 0.9em; + color: var(--text); +} + +.settings-value { + font-size: 0.9em; + color: var(--text-secondary); +} + +.settings-select { + padding: 6px 10px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg-base); + color: var(--text); + font-size: 0.9em; + cursor: pointer; + min-width: 120px; +} +.settings-select:focus { outline: none; border-color: var(--border-focus); } + +/* ── Video player overlay ─────────────────────────────────────────────────── */ + +.video-overlay { + position: fixed; + inset: 0; + z-index: 200; + background: rgba(0, 0, 0, 0.92); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +.video-top-bar { + position: absolute; + top: 0; + left: 0; + right: 0; + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 20px; + z-index: 210; +} + +.video-title { + color: #e2e8f0; + font-size: 0.9em; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: calc(100% - 60px); +} + +.video-close { + background: rgba(255, 255, 255, 0.12); + border: none; + color: #e2e8f0; + width: 36px; + height: 36px; + border-radius: 50%; + font-size: 1.2em; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} +.video-close:hover { background: rgba(255, 255, 255, 0.25); } + +.video-container { + width: 100%; + max-width: min(90vw, 1280px); + max-height: calc(100vh - 120px); + display: flex; + align-items: center; + justify-content: center; +} + +.video-container video { + width: 100%; + max-height: calc(100vh - 120px); + border-radius: 4px; + outline: none; +} + +.video-loading { + text-align: center; + color: #94a3b8; +} + +.video-loading-label { + margin-bottom: 16px; + font-size: 0.95em; +} + +.video-progress-bar { + width: min(400px, 80vw); + height: 6px; + background: rgba(255, 255, 255, 0.1); + border-radius: 3px; + overflow: hidden; + margin-bottom: 8px; +} + +.video-progress-fill { + height: 100%; + background: var(--accent); + border-radius: 3px; + transition: width 0.15s; +} + +.video-progress-text { + font-size: 0.8em; + color: #64748b; +} + +.video-error { + color: var(--error); + font-size: 0.9em; + text-align: center; + max-width: 400px; +} + +.play-btn { + background: none; + border: 1px solid var(--border); + color: var(--success); + width: 30px; + height: 30px; + border-radius: 6px; + font-size: 0.85em; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + margin-right: 4px; +} +.play-btn:hover { background: var(--bg-raised); border-color: var(--success); } +.play-btn:disabled { opacity: 0.3; cursor: not-allowed; } + +/* ── Overlay (mobile sidebar backdrop) ────────────────────────────────────── */ + +.overlay { + display: none; + position: fixed; + inset: 0; + top: 52px; + background: rgba(0, 0, 0, 0.4); + z-index: 40; +} +.overlay.visible { display: block; } + +/* ── Responsive ───────────────────────────────────────────────────────────── */ + +@media (max-width: 768px) { + .nav-hamburger { display: flex; } + + .sidebar { + position: fixed; + left: -240px; + top: 52px; + height: calc(100vh - 52px); + z-index: 50; + transition: left 0.2s; + box-shadow: none; + } + .sidebar.open { left: 0; box-shadow: var(--shadow-lg); } + + .main { padding: 16px; } + .page-center { padding: 16px; } + .th-type, .td-type { display: none; } + .th-date, .td-date { display: none; } +} diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index 9112734..de93b2d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -41,21 +41,36 @@ class MeshBayTransport { this._channel = this._pc.createDataChannel('mnp', { ordered: true }); this._channel.binaryType = 'arraybuffer'; + let channelReject = null; const channelReady = new Promise((resolve, reject) => { + channelReject = reject; const timeout = setTimeout(() => reject(new Error('DataChannel open timeout')), 30000); this._channel.onopen = () => { clearTimeout(timeout); this._connected = true; resolve(); }; - this._channel.onerror = (e) => { - clearTimeout(timeout); - reject(new Error('DataChannel error: ' + e.message)); - }; }); this._channel.onmessage = (event) => this._onMessage(event.data); - this._channel.onclose = () => { this._connected = false; }; + this._channel.onclose = (ev) => { + console.warn('[MeshBay] DataChannel closed', this._channel?.readyState, ev); + this._connected = false; + if (channelReject) channelReject(new Error('DataChannel closed')); + for (const [, p] of this._pending) p.reject(new Error('DataChannel closed')); + this._pending.clear(); + }; + this._channel.onerror = (ev) => { + console.error('[MeshBay] DataChannel error', ev); + if (channelReject) channelReject(new Error('DataChannel error')); + }; + + this._pc.onconnectionstatechange = () => { + console.log('[MeshBay] PC state:', this._pc.connectionState); + }; + this._pc.oniceconnectionstatechange = () => { + console.log('[MeshBay] ICE state:', this._pc.iceConnectionState); + }; const offer = await this._pc.createOffer(); await this._pc.setLocalDescription(offer); @@ -106,7 +121,13 @@ class MeshBayTransport { async fetchIndex() { const msg = await this._sendAndWait({ type: 'index_sync', v: '0.1' }); if (msg.type === 'error') throw new Error(msg.detail); - return _b64decode(msg.index_b64); + return msg; + } + + async fetchGEK() { + const msg = await this._sendAndWait({ type: 'gek_req', v: '0.1' }); + if (msg.type === 'error') throw new Error(msg.detail); + return msg.gek_b64; } async fetchChunk(fileId, chunkIndex) { @@ -132,6 +153,17 @@ class MeshBayTransport { return _b64decode(msg.data_b64); } + async fetchChatHistory(since, limit) { + const msg = await this._sendAndWait({ + type: 'chat_hist', + v: '0.1', + since: since || 0, + limit: limit || 100, + }); + if (msg.type === 'error') throw new Error(msg.detail); + return msg.messages || []; + } + async sendChat(payload, iteration, threadId) { const msg = await this._sendAndWait({ type: 'chat_msg', @@ -169,6 +201,9 @@ class MeshBayTransport { } _send(obj) { + if (!this._channel || this._channel.readyState !== 'open') { + throw new Error(`DataChannel not open (state: ${this._channel?.readyState})`); + } const encoded = msgpack_encode(obj); const header = new Uint8Array(4); new DataView(header.buffer).setUint32(0, encoded.byteLength, false); diff --git a/packages/meshbay-hub/src/meshbay_hub/static/vendor/htm-preact.js b/packages/meshbay-hub/src/meshbay_hub/static/vendor/htm-preact.js new file mode 100644 index 0000000..e24f87b --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/vendor/htm-preact.js @@ -0,0 +1 @@ +var e,n,_,t,o,r,u,l={},i=[],c=/acit|ex(?:s|g|n|p|$)|rph|grid|ows|mnc|ntw|ine[ch]|zoo|^ord|itera/i;function s(e,n){for(var _ in n)e[_]=n[_];return e}function f(e){var n=e.parentNode;n&&n.removeChild(e)}function a(n,_,t){var o,r,u,l={};for(u in _)"key"==u?o=_[u]:"ref"==u?r=_[u]:l[u]=_[u];if(arguments.length>2&&(l.children=arguments.length>3?e.call(arguments,2):t),"function"==typeof n&&null!=n.defaultProps)for(u in n.defaultProps)void 0===l[u]&&(l[u]=n.defaultProps[u]);return p(n,l,o,r,null)}function p(e,t,o,r,u){var l={type:e,props:t,key:o,ref:r,__k:null,__:null,__b:0,__e:null,__d:void 0,__c:null,__h:null,constructor:void 0,__v:null==u?++_:u};return null!=n.vnode&&n.vnode(l),l}function h(e){return e.children}function d(e,n){this.props=e,this.context=n}function v(e,n){if(null==n)return e.__?v(e.__,e.__.__k.indexOf(e)+1):null;for(var _;n<e.__k.length;n++)if(null!=(_=e.__k[n])&&null!=_.__e)return _.__e;return"function"==typeof e.type?v(e):null}function y(e){var n,_;if(null!=(e=e.__)&&null!=e.__c){for(e.__e=e.__c.base=null,n=0;n<e.__k.length;n++)if(null!=(_=e.__k[n])&&null!=_.__e){e.__e=e.__c.base=_.__e;break}return y(e)}}function m(e){(!e.__d&&(e.__d=!0)&&t.push(e)&&!g.__r++||r!==n.debounceRendering)&&((r=n.debounceRendering)||o)(g)}function g(){for(var e;g.__r=t.length;)e=t.sort(function(e,n){return e.__v.__b-n.__v.__b}),t=[],e.some(function(e){var n,_,t,o,r,u;e.__d&&(r=(o=(n=e).__v).__e,(u=n.__P)&&(_=[],(t=s({},o)).__v=o.__v+1,P(u,o,t,n.__n,void 0!==u.ownerSVGElement,null!=o.__h?[r]:null,_,null==r?v(o):r,o.__h),D(_,o),o.__e!=r&&y(o)))})}function k(e,n,_,t,o,r,u,c,s,f){var a,d,y,m,g,k,x,H=t&&t.__k||i,E=H.length;for(_.__k=[],a=0;a<n.length;a++)if(null!=(m=_.__k[a]=null==(m=n[a])||"boolean"==typeof m?null:"string"==typeof m||"number"==typeof m||"bigint"==typeof m?p(null,m,null,null,m):Array.isArray(m)?p(h,{children:m},null,null,null):m.__b>0?p(m.type,m.props,m.key,null,m.__v):m)){if(m.__=_,m.__b=_.__b+1,null===(y=H[a])||y&&m.key==y.key&&m.type===y.type)H[a]=void 0;else for(d=0;d<E;d++){if((y=H[d])&&m.key==y.key&&m.type===y.type){H[d]=void 0;break}y=null}P(e,m,y=y||l,o,r,u,c,s,f),g=m.__e,(d=m.ref)&&y.ref!=d&&(x||(x=[]),y.ref&&x.push(y.ref,null,m),x.push(d,m.__c||g,m)),null!=g?(null==k&&(k=g),"function"==typeof m.type&&null!=m.__k&&m.__k===y.__k?m.__d=s=b(m,s,e):s=C(e,m,y,H,g,s),f||"option"!==_.type?"function"==typeof _.type&&(_.__d=s):e.value=""):s&&y.__e==s&&s.parentNode!=e&&(s=v(y))}for(_.__e=k,a=E;a--;)null!=H[a]&&("function"==typeof _.type&&null!=H[a].__e&&H[a].__e==_.__d&&(_.__d=v(t,a+1)),U(H[a],H[a]));if(x)for(a=0;a<x.length;a++)T(x[a],x[++a],x[++a])}function b(e,n,_){var t,o;for(t=0;t<e.__k.length;t++)(o=e.__k[t])&&(o.__=e,n="function"==typeof o.type?b(o,n,_):C(_,o,o,e.__k,o.__e,n));return n}function C(e,n,_,t,o,r){var u,l,i;if(void 0!==n.__d)u=n.__d,n.__d=void 0;else if(null==_||o!=r||null==o.parentNode)e:if(null==r||r.parentNode!==e)e.appendChild(o),u=null;else{for(l=r,i=0;(l=l.nextSibling)&&i<t.length;i+=2)if(l==o)break e;e.insertBefore(o,r),u=r}return void 0!==u?u:o.nextSibling}function x(e,n,_){"-"===n[0]?e.setProperty(n,_):e[n]=null==_?"":"number"!=typeof _||c.test(n)?_:_+"px"}function H(e,n,_,t,o){var r;e:if("style"===n)if("string"==typeof _)e.style.cssText=_;else{if("string"==typeof t&&(e.style.cssText=t=""),t)for(n in t)_&&n in _||x(e.style,n,"");if(_)for(n in _)t&&_[n]===t[n]||x(e.style,n,_[n])}else if("o"===n[0]&&"n"===n[1])r=n!==(n=n.replace(/Capture$/,"")),n=n.toLowerCase()in e?n.toLowerCase().slice(2):n.slice(2),e.l||(e.l={}),e.l[n+r]=_,_?t||e.addEventListener(n,r?S:E,r):e.removeEventListener(n,r?S:E,r);else if("dangerouslySetInnerHTML"!==n){if(o)n=n.replace(/xlink[H:h]/,"h").replace(/sName$/,"s");else if("href"!==n&&"list"!==n&&"form"!==n&&"tabIndex"!==n&&"download"!==n&&n in e)try{e[n]=null==_?"":_;break e}catch(e){}"function"==typeof _||(null!=_&&(!1!==_||"a"===n[0]&&"r"===n[1])?e.setAttribute(n,_):e.removeAttribute(n))}}function E(e){this.l[e.type+!1](n.event?n.event(e):e)}function S(e){this.l[e.type+!0](n.event?n.event(e):e)}function P(e,_,t,o,r,u,l,i,c){var f,a,p,v,y,m,g,b,C,x,H,E=_.type;if(void 0!==_.constructor)return null;null!=t.__h&&(c=t.__h,i=_.__e=t.__e,_.__h=null,u=[i]),(f=n.__b)&&f(_);try{e:if("function"==typeof E){if(b=_.props,C=(f=E.contextType)&&o[f.__c],x=f?C?C.props.value:f.__:o,t.__c?g=(a=_.__c=t.__c).__=a.__E:("prototype"in E&&E.prototype.render?_.__c=a=new E(b,x):(_.__c=a=new d(b,x),a.constructor=E,a.render=A),C&&C.sub(a),a.props=b,a.state||(a.state={}),a.context=x,a.__n=o,p=a.__d=!0,a.__h=[]),null==a.__s&&(a.__s=a.state),null!=E.getDerivedStateFromProps&&(a.__s==a.state&&(a.__s=s({},a.__s)),s(a.__s,E.getDerivedStateFromProps(b,a.__s))),v=a.props,y=a.state,p)null==E.getDerivedStateFromProps&&null!=a.componentWillMount&&a.componentWillMount(),null!=a.componentDidMount&&a.__h.push(a.componentDidMount);else{if(null==E.getDerivedStateFromProps&&b!==v&&null!=a.componentWillReceiveProps&&a.componentWillReceiveProps(b,x),!a.__e&&null!=a.shouldComponentUpdate&&!1===a.shouldComponentUpdate(b,a.__s,x)||_.__v===t.__v){a.props=b,a.state=a.__s,_.__v!==t.__v&&(a.__d=!1),a.__v=_,_.__e=t.__e,_.__k=t.__k,_.__k.forEach(function(e){e&&(e.__=_)}),a.__h.length&&l.push(a);break e}null!=a.componentWillUpdate&&a.componentWillUpdate(b,a.__s,x),null!=a.componentDidUpdate&&a.__h.push(function(){a.componentDidUpdate(v,y,m)})}a.context=x,a.props=b,a.state=a.__s,(f=n.__r)&&f(_),a.__d=!1,a.__v=_,a.__P=e,f=a.render(a.props,a.state,a.context),a.state=a.__s,null!=a.getChildContext&&(o=s(s({},o),a.getChildContext())),p||null==a.getSnapshotBeforeUpdate||(m=a.getSnapshotBeforeUpdate(v,y)),H=null!=f&&f.type===h&&null==f.key?f.props.children:f,k(e,Array.isArray(H)?H:[H],_,t,o,r,u,l,i,c),a.base=_.__e,_.__h=null,a.__h.length&&l.push(a),g&&(a.__E=a.__=null),a.__e=!1}else null==u&&_.__v===t.__v?(_.__k=t.__k,_.__e=t.__e):_.__e=w(t.__e,_,t,o,r,u,l,c);(f=n.diffed)&&f(_)}catch(e){_.__v=null,(c||null!=u)&&(_.__e=i,_.__h=!!c,u[u.indexOf(i)]=null),n.__e(e,_,t)}}function D(e,_){n.__c&&n.__c(_,e),e.some(function(_){try{e=_.__h,_.__h=[],e.some(function(e){e.call(_)})}catch(e){n.__e(e,_.__v)}})}function w(n,_,t,o,r,u,i,c){var s,a,p,h=t.props,d=_.props,y=_.type,m=0;if("svg"===y&&(r=!0),null!=u)for(;m<u.length;m++)if((s=u[m])&&(s===n||(y?s.localName==y:3==s.nodeType))){n=s,u[m]=null;break}if(null==n){if(null===y)return document.createTextNode(d);n=r?document.createElementNS("http://www.w3.org/2000/svg",y):document.createElement(y,d.is&&d),u=null,c=!1}if(null===y)h===d||c&&n.data===d||(n.data=d);else{if(u=u&&e.call(n.childNodes),a=(h=t.props||l).dangerouslySetInnerHTML,p=d.dangerouslySetInnerHTML,!c){if(null!=u)for(h={},m=0;m<n.attributes.length;m++)h[n.attributes[m].name]=n.attributes[m].value;(p||a)&&(p&&(a&&p.__html==a.__html||p.__html===n.innerHTML)||(n.innerHTML=p&&p.__html||""))}if(function(e,n,_,t,o){var r;for(r in _)"children"===r||"key"===r||r in n||H(e,r,null,_[r],t);for(r in n)o&&"function"!=typeof n[r]||"children"===r||"key"===r||"value"===r||"checked"===r||_[r]===n[r]||H(e,r,n[r],_[r],t)}(n,d,h,r,c),p)_.__k=[];else if(m=_.props.children,k(n,Array.isArray(m)?m:[m],_,t,o,r&&"foreignObject"!==y,u,i,u?u[0]:t.__k&&v(t,0),c),null!=u)for(m=u.length;m--;)null!=u[m]&&f(u[m]);c||("value"in d&&void 0!==(m=d.value)&&(m!==n.value||"progress"===y&&!m)&&H(n,"value",m,h.value,!1),"checked"in d&&void 0!==(m=d.checked)&&m!==n.checked&&H(n,"checked",m,h.checked,!1))}return n}function T(e,_,t){try{"function"==typeof e?e(_):e.current=_}catch(e){n.__e(e,t)}}function U(e,_,t){var o,r;if(n.unmount&&n.unmount(e),(o=e.ref)&&(o.current&&o.current!==e.__e||T(o,null,_)),null!=(o=e.__c)){if(o.componentWillUnmount)try{o.componentWillUnmount()}catch(e){n.__e(e,_)}o.base=o.__P=null}if(o=e.__k)for(r=0;r<o.length;r++)o[r]&&U(o[r],_,"function"!=typeof e.type);t||null==e.__e||f(e.__e),e.__e=e.__d=void 0}function A(e,n,_){return this.constructor(e,_)}function M(_,t,o){var r,u,i;n.__&&n.__(_,t),u=(r="function"==typeof o)?null:o&&o.__k||t.__k,i=[],P(t,_=(!r&&o||t).__k=a(h,null,[_]),u||l,l,void 0!==t.ownerSVGElement,!r&&o?[o]:u?null:t.firstChild?e.call(t.childNodes):null,i,!r&&o?o:u?u.__e:t.firstChild,r),D(i,_)}function F(e,n){var _={__c:n="__cC"+u++,__:e,Consumer:function(e,n){return e.children(n)},Provider:function(e){var _,t;return this.getChildContext||(_=[],(t={})[n]=this,this.getChildContext=function(){return t},this.shouldComponentUpdate=function(e){this.props.value!==e.value&&_.some(m)},this.sub=function(e){_.push(e);var n=e.componentWillUnmount;e.componentWillUnmount=function(){_.splice(_.indexOf(e),1),n&&n.call(e)}}),e.children}};return _.Provider.__=_.Consumer.contextType=_}e=i.slice,n={__e:function(e,n){for(var _,t,o;n=n.__;)if((_=n.__c)&&!_.__)try{if((t=_.constructor)&&null!=t.getDerivedStateFromError&&(_.setState(t.getDerivedStateFromError(e)),o=_.__d),null!=_.componentDidCatch&&(_.componentDidCatch(e),o=_.__d),o)return _.__E=_}catch(n){e=n}throw e}},_=0,d.prototype.setState=function(e,n){var _;_=null!=this.__s&&this.__s!==this.state?this.__s:this.__s=s({},this.state),"function"==typeof e&&(e=e(s({},_),this.props)),e&&s(_,e),null!=e&&this.__v&&(n&&this.__h.push(n),m(this))},d.prototype.forceUpdate=function(e){this.__v&&(this.__e=!0,e&&this.__h.push(e),m(this))},d.prototype.render=h,t=[],o="function"==typeof Promise?Promise.prototype.then.bind(Promise.resolve()):setTimeout,g.__r=0,u=0;var L,N,W,R=0,I=[],O=n.__b,V=n.__r,q=n.diffed,B=n.__c,$=n.unmount;function j(e,_){n.__h&&n.__h(N,e,R||_),R=0;var t=N.__H||(N.__H={__:[],__h:[]});return e>=t.__.length&&t.__.push({}),t.__[e]}function G(e){return R=1,z(ie,e)}function z(e,n,_){var t=j(L++,2);return t.t=e,t.__c||(t.__=[_?_(n):ie(void 0,n),function(e){var n=t.t(t.__[0],e);t.__[0]!==n&&(t.__=[n,t.__[1]],t.__c.setState({}))}],t.__c=N),t.__}function J(e,_){var t=j(L++,3);!n.__s&&le(t.__H,_)&&(t.__=e,t.__H=_,N.__H.__h.push(t))}function K(e,_){var t=j(L++,4);!n.__s&&le(t.__H,_)&&(t.__=e,t.__H=_,N.__h.push(t))}function Q(e){return R=5,Y(function(){return{current:e}},[])}function X(e,n,_){R=6,K(function(){"function"==typeof e?e(n()):e&&(e.current=n())},null==_?_:_.concat(e))}function Y(e,n){var _=j(L++,7);return le(_.__H,n)&&(_.__=e(),_.__H=n,_.__h=e),_.__}function Z(e,n){return R=8,Y(function(){return e},n)}function ee(e){var n=N.context[e.__c],_=j(L++,9);return _.c=e,n?(null==_.__&&(_.__=!0,n.sub(N)),n.props.value):e.__}function ne(e,_){n.useDebugValue&&n.useDebugValue(_?_(e):e)}function _e(e){var n=j(L++,10),_=G();return n.__=e,N.componentDidCatch||(N.componentDidCatch=function(e){n.__&&n.__(e),_[1](e)}),[_[0],function(){_[1](void 0)}]}function te(){I.forEach(function(e){if(e.__P)try{e.__H.__h.forEach(re),e.__H.__h.forEach(ue),e.__H.__h=[]}catch(_){e.__H.__h=[],n.__e(_,e.__v)}}),I=[]}n.__b=function(e){N=null,O&&O(e)},n.__r=function(e){V&&V(e),L=0;var n=(N=e.__c).__H;n&&(n.__h.forEach(re),n.__h.forEach(ue),n.__h=[])},n.diffed=function(e){q&&q(e);var _=e.__c;_&&_.__H&&_.__H.__h.length&&(1!==I.push(_)&&W===n.requestAnimationFrame||((W=n.requestAnimationFrame)||function(e){var n,_=function(){clearTimeout(t),oe&&cancelAnimationFrame(n),setTimeout(e)},t=setTimeout(_,100);oe&&(n=requestAnimationFrame(_))})(te)),N=void 0},n.__c=function(e,_){_.some(function(e){try{e.__h.forEach(re),e.__h=e.__h.filter(function(e){return!e.__||ue(e)})}catch(t){_.some(function(e){e.__h&&(e.__h=[])}),_=[],n.__e(t,e.__v)}}),B&&B(e,_)},n.unmount=function(e){$&&$(e);var _=e.__c;if(_&&_.__H)try{_.__H.__.forEach(re)}catch(e){n.__e(e,_.__v)}};var oe="function"==typeof requestAnimationFrame;function re(e){var n=N;"function"==typeof e.__c&&e.__c(),N=n}function ue(e){var n=N;e.__c=e.__(),N=n}function le(e,n){return!e||e.length!==n.length||n.some(function(n,_){return n!==e[_]})}function ie(e,n){return"function"==typeof n?n(e):n}var ce=function(e,n,_,t){var o;n[0]=0;for(var r=1;r<n.length;r++){var u=n[r++],l=n[r]?(n[0]|=u?1:2,_[n[r++]]):n[++r];3===u?t[0]=l:4===u?t[1]=Object.assign(t[1]||{},l):5===u?(t[1]=t[1]||{})[n[++r]]=l:6===u?t[1][n[++r]]+=l+"":u?(o=e.apply(l,ce(e,l,_,["",null])),t.push(o),l[0]?n[0]|=2:(n[r-2]=0,n[r]=o)):t.push(l)}return t},se=new Map,fe=function(e){var n=se.get(this);return n||(n=new Map,se.set(this,n)),(n=ce(this,n.get(e)||(n.set(e,n=function(e){for(var n,_,t=1,o="",r="",u=[0],l=function(e){1===t&&(e||(o=o.replace(/^\s*\n\s*|\s*\n\s*$/g,"")))?u.push(0,e,o):3===t&&(e||o)?(u.push(3,e,o),t=2):2===t&&"..."===o&&e?u.push(4,e,0):2===t&&o&&!e?u.push(5,0,!0,o):t>=5&&((o||!e&&5===t)&&(u.push(t,0,o,_),t=6),e&&(u.push(t,e,0,_),t=6)),o=""},i=0;i<e.length;i++){i&&(1===t&&l(),l(i));for(var c=0;c<e[i].length;c++)n=e[i][c],1===t?"<"===n?(l(),u=[u],t=3):o+=n:4===t?"--"===o&&">"===n?(t=1,o=""):o=n+o[0]:r?n===r?r="":o+=n:'"'===n||"'"===n?r=n:">"===n?(l(),t=1):t&&("="===n?(t=5,_=o,o=""):"/"===n&&(t<5||">"===e[i][c+1])?(l(),3===t&&(u=u[0]),t=u,(u=u[0]).push(2,0,t),t=0):" "===n||"\t"===n||"\n"===n||"\r"===n?(l(),t=2):o+=n),3===t&&"!--"===o&&(t=4,u=u[0])}return l(),u}(e)),n),arguments,[])).length>1?n:n[0]}.bind(a);export{a as h,fe as html,M as render,d as Component,F as createContext,G as useState,z as useReducer,J as useEffect,K as useLayoutEffect,Q as useRef,X as useImperativeHandle,Y as useMemo,Z as useCallback,ee as useContext,ne as useDebugValue,_e as useErrorBoundary}; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/webrtc-test.html b/packages/meshbay-hub/src/meshbay_hub/static/webrtc-test.html index 0d5750b..46003a7 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/webrtc-test.html +++ b/packages/meshbay-hub/src/meshbay_hub/static/webrtc-test.html @@ -68,7 +68,7 @@ <div id="log"></div> </div> -<script src="/transport.js"></script> +<script src="/transport.js?v=2"></script> <script> const HUB_URL = window.location.origin; const params = new URLSearchParams(window.location.search); @@ -76,13 +76,14 @@ let accessToken = null; let jwtToken = null; let transport = null; let fileIndex = null; +let connecting = false; // Pre-fill from URL params if (params.get('user')) document.getElementById('username').value = params.get('user'); if (params.get('pass')) document.getElementById('password').value = params.get('pass'); if (params.get('node')) document.getElementById('node-id').value = params.get('node'); if (params.get('group')) document.getElementById('group-id').value = params.get('group'); -if (params.get('file')) document.getElementById('file-id').value = params.get('file'); +if (params.get('file')) document.getElementById('file-id').value = params.get('file').replace(/\s+/g, ''); // Auto-run if all params provided if (params.get('auto')) { @@ -143,10 +144,14 @@ async function doLogin() { } async function doConnect() { + if (connecting) { logMsg('warn', 'Connect already in progress'); return; } const nodeId = document.getElementById('node-id').value; const groupId = document.getElementById('group-id').value; if (!nodeId) { logMsg('warn', 'Enter a node ID'); return; } + connecting = true; + if (transport) { transport.close(); transport = null; } + logMsg('info', `Connecting to node ${nodeId.substring(0, 8)}... via WebRTC`); setStep('step-connect', 'active'); @@ -164,7 +169,7 @@ async function doConnect() { logMsg('ok', `WebRTC connected in ${elapsed}ms`); logMsg('ok', ` MNP handshake_ack — node_pk: ${ack.node_pk?.substring(0, 16)}...`); - logMsg('ok', ` DataChannel: open, ordered, reliable`); + logMsg('ok', ` DataChannel state: ${transport._channel?.readyState}`); setStep('step-connect', 'done'); document.getElementById('connect-status').innerHTML = '<span class="badge">P2P OK</span>'; document.getElementById('btn-index').disabled = false; @@ -173,11 +178,13 @@ async function doConnect() { logMsg('err', `Connection FAILED: ${e.message}`); setStep('step-connect', 'fail'); document.getElementById('connect-status').innerHTML = '<span class="badge fail">FAIL</span>'; + } finally { + connecting = false; } } async function doFetchIndex() { - logMsg('info', 'Fetching Mesh Group Index via DataChannel...'); + logMsg('info', `Fetching Mesh Group Index... (channel: ${transport?._channel?.readyState})`); try { const t0 = performance.now(); const indexBytes = await transport.fetchIndex(); @@ -210,7 +217,7 @@ async function doFetchIndex() { } async function doFetchChunk() { - let fileId = document.getElementById('file-id').value.trim(); + let fileId = document.getElementById('file-id').value.replace(/\s+/g, ''); if (!fileId) { logMsg('warn', 'Enter a file_id (blake3 hex hash from node indexer log)'); @@ -219,7 +226,7 @@ async function doFetchChunk() { return; } - logMsg('info', `Fetching chunk 0 of file ${fileId.substring(0, 16)}... via DataChannel...`); + logMsg('info', `Fetching chunk 0 of ${fileId.substring(0, 16)}... (channel: ${transport?._channel?.readyState})`); try { const t0 = performance.now(); diff --git a/packages/meshbay-hub/tests/test_hub_api.py b/packages/meshbay-hub/tests/test_hub_api.py index a9c97a3..f7c499e 100644 --- a/packages/meshbay-hub/tests/test_hub_api.py +++ b/packages/meshbay-hub/tests/test_hub_api.py @@ -351,6 +351,131 @@ async def test_jwt_contains_groups_claim(client): assert group_id in decoded_alice["groups"] +# ── My groups (9.6) ───────────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_my_groups(client): + """GET /v1/groups/mine returns groups the user belongs to.""" + pk_ed_a, pk_x_a, _ = _gen_user_keys() + pk_ed_b, pk_x_b, _ = _gen_user_keys() + + await client.post("/v1/users/register", json={ + "username": "mg_alice", "email": "mga@x.com", "password": "alicepass99", + "pk_user_ed25519": pk_ed_a, "pk_user_x25519": pk_x_a}) + await client.post("/v1/users/register", json={ + "username": "mg_bob", "email": "mgb@x.com", "password": "bobpass99", + "pk_user_ed25519": pk_ed_b, "pk_user_x25519": pk_x_b}) + + alice_token = (await client.post("/v1/users/login", + json={"username": "mg_alice", "password": "alicepass99"})).json()["access_token"] + bob_token = (await client.post("/v1/users/login", + json={"username": "mg_bob", "password": "bobpass99"})).json()["access_token"] + + # Bob has no groups initially + r = await client.get("/v1/groups/mine", + headers={"Authorization": f"Bearer {bob_token}"}) + assert r.status_code == 200 + assert r.json()["groups"] == [] + + # Alice creates a group and adds Bob + r = await client.post("/v1/groups", json={"name": "mg-group"}, + headers={"Authorization": f"Bearer {alice_token}"}) + group_id = r.json()["group_id"] + gek = generate_gek() + bundle = wrap_gek(gek, base64.b64decode(pk_x_b)) + await client.post(f"/v1/groups/{group_id}/members/mg_bob/gek", + json=bundle, + headers={"Authorization": f"Bearer {alice_token}"}) + + # Re-login to get fresh token with group claims + bob_token = (await client.post("/v1/users/login", + json={"username": "mg_bob", "password": "bobpass99"})).json()["access_token"] + + # Now Bob should see the group + r = await client.get("/v1/groups/mine", + headers={"Authorization": f"Bearer {bob_token}"}) + assert r.status_code == 200 + groups = r.json()["groups"] + assert len(groups) == 1 + assert groups[0]["id"] == group_id + assert groups[0]["name"] == "mg-group" + assert groups[0]["is_admin"] is False + + # Alice should see it too, with is_admin=True + alice_token = (await client.post("/v1/users/login", + json={"username": "mg_alice", "password": "alicepass99"})).json()["access_token"] + r = await client.get("/v1/groups/mine", + headers={"Authorization": f"Bearer {alice_token}"}) + groups = r.json()["groups"] + assert any(g["id"] == group_id and g["is_admin"] for g in groups) + + # Unauthenticated → rejected + r = await client.get("/v1/groups/mine") + assert r.status_code >= 400 + + +@pytest.mark.asyncio +async def test_group_online_nodes(client): + """GET /v1/groups/{id}/nodes returns online nodes serving the group.""" + import json + from meshbay_hub.api.revocation import _connected_nodes, _node_groups + + pk_ed, pk_x, _ = _gen_user_keys() + await client.post("/v1/users/register", json={ + "username": "gn_user", "email": "gn@x.com", "password": "gnpass999", + "pk_user_ed25519": pk_ed, "pk_user_x25519": pk_x}) + r = await client.post("/v1/users/login", + json={"username": "gn_user", "password": "gnpass999"}) + token = r.json()["access_token"] + + r = await client.post("/v1/groups", json={"name": "gn-group"}, + headers={"Authorization": f"Bearer {token}"}) + group_id = r.json()["group_id"] + + # Re-login to get fresh token with group claims + token = (await client.post("/v1/users/login", + json={"username": "gn_user", "password": "gnpass999"})).json()["access_token"] + + # Announce a node + r = await client.post("/v1/nodes/announce", json={ + "pk_node": pk_ed, "endpoint_hint": "1.2.3.4:19000"}, + headers={"Authorization": f"Bearer {token}"}) + node_id = r.json()["node_id"] + + # No nodes online yet + r = await client.get(f"/v1/groups/{group_id}/nodes", + headers={"Authorization": f"Bearer {token}"}) + assert r.status_code == 200 + assert r.json()["nodes"] == [] + + # Simulate node connecting via WS with group_ids + class FakeWS: + async def send_text(self, text): pass + _connected_nodes[node_id] = FakeWS() + _node_groups[node_id] = [group_id] + + try: + r = await client.get(f"/v1/groups/{group_id}/nodes", + headers={"Authorization": f"Bearer {token}"}) + assert r.status_code == 200 + nodes = r.json()["nodes"] + assert len(nodes) == 1 + assert nodes[0]["node_id"] == node_id + assert nodes[0]["pk_node"] == pk_ed + finally: + _connected_nodes.pop(node_id, None) + _node_groups.pop(node_id, None) + + # 404 for nonexistent group + r = await client.get("/v1/groups/fake-id/nodes", + headers={"Authorization": f"Bearer {token}"}) + assert r.status_code == 404 + + # Unauthenticated → rejected + r = await client.get(f"/v1/groups/{group_id}/nodes") + assert r.status_code >= 400 + + # ── Admin authz (8.1) ─────────────────────────────────────────────────────── @pytest.mark.asyncio @@ -578,3 +703,22 @@ async def test_ip_log_cleanup(app): )).scalar_one() assert count == 1 break + + +# ── Webapp HTML shell (9.13) ───────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_webapp_html_includes_scripts(client): + """SPA HTML shell includes all required script tags.""" + r = await client.get("/") + assert r.status_code == 200 + html = r.text + assert "<!DOCTYPE html>" in html + assert '<div id="app">' in html + assert 'src="/keyderive.js"' in html + assert 'src="/crypto.js"' in html + assert 'src="/transport.js"' in html + assert 'src="/app.js"' in html + assert 'type="module"' in html + assert 'rel="stylesheet"' in html + assert 'href="/style.css"' in html diff --git a/packages/meshbay-node/src/meshbay_node/hub_client.py b/packages/meshbay-node/src/meshbay_node/hub_client.py index 3e77ed0..a9a1d6c 100644 --- a/packages/meshbay-node/src/meshbay_node/hub_client.py +++ b/packages/meshbay-node/src/meshbay_node/hub_client.py @@ -251,6 +251,7 @@ class HubClient: on_incoming: Any = None, on_revocation: Any = None, on_webrtc_offer: Any = None, + group_ids: list[str] | None = None, ) -> None: """ Maintain a persistent WebSocket connection to the hub. @@ -268,11 +269,14 @@ class HubClient: while True: try: async with websockets.connect(ws_url) as ws: - await ws.send(json.dumps({ + auth_msg = { "type": "auth", "token": self._session.access_token, "node_id": self._session.node_id, - })) + } + if group_ids: + auth_msg["group_ids"] = group_ids + await ws.send(json.dumps(auth_msg)) auth_resp = json.loads(await ws.recv()) if auth_resp.get("type") != "auth_ok": log.error("WS auth failed: %s", auth_resp) diff --git a/packages/meshbay-node/src/meshbay_node/indexer/group_index.py b/packages/meshbay-node/src/meshbay_node/indexer/group_index.py index 4edeeab..a69429c 100644 --- a/packages/meshbay-node/src/meshbay_node/indexer/group_index.py +++ b/packages/meshbay-node/src/meshbay_node/indexer/group_index.py @@ -25,14 +25,16 @@ import zstandard as zstd from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from meshbay_common.crypto import ( - chunk_key as derive_chunk_key, - encrypt_chunk, - decrypt_chunk, sign_chunk, verify_chunk_signature, pk_to_b64, generate_gek, ) +from meshbay_common.webcrypto import ( + chunk_key_aes as derive_chunk_key, + encrypt_chunk_aes as encrypt_chunk, + decrypt_chunk_aes as decrypt_chunk, +) from meshbay_common.protocol import IndexEntry, IndexDelta log = logging.getLogger(__name__) diff --git a/packages/meshbay-node/src/meshbay_node/indexer/indexer.py b/packages/meshbay-node/src/meshbay_node/indexer/indexer.py index 4bb1527..60dc04b 100644 --- a/packages/meshbay-node/src/meshbay_node/indexer/indexer.py +++ b/packages/meshbay-node/src/meshbay_node/indexer/indexer.py @@ -169,30 +169,49 @@ class DirectoryIndexer: # ── Internal update ─────────────────────────────────────────────────────── + _DEBOUNCE_SECS = 2.0 + def _schedule_update(self, file_path: Path, deleted: bool = False) -> None: - """Called from watchdog thread — schedule async update on the event loop.""" - if self._loop: - self._loop.call_soon_threadsafe( - lambda: asyncio.ensure_future( - self._update_entry(file_path, deleted))) + """Called from watchdog thread — schedule debounced async update.""" + if not self._loop: + return + key = str(file_path.resolve()) + self._loop.call_soon_threadsafe( + self._debounce, key, file_path, deleted) + + def _debounce(self, key: str, file_path: Path, deleted: bool) -> None: + if not hasattr(self, "_pending_timers"): + self._pending_timers: dict[str, asyncio.TimerHandle] = {} + old = self._pending_timers.pop(key, None) + if old: + old.cancel() + handle = self._loop.call_later( + self._DEBOUNCE_SECS, + lambda: asyncio.ensure_future(self._update_entry(file_path, deleted)), + ) + self._pending_timers[key] = handle + + def _remove_by_path(self, file_path: Path) -> None: + """Remove any existing entries that match this file's path + name.""" + resolved = file_path.resolve() + to_remove = [ + e.id for e in self._index.entries + if (self.root / e.path / e.name).resolve() == resolved + ] + for fid in to_remove: + self._index.remove_entry(fid) async def _update_entry(self, file_path: Path, deleted: bool) -> None: - if deleted: - # Remove by matching path (hash not available after deletion) - to_remove = [ - e.id for e in self._index.entries - if (self.root / e.path / e.name).resolve() == file_path.resolve() - ] - for fid in to_remove: - self._index.remove_entry(fid) - log.debug("Removed from index: %s", file_path.name) - else: + self._remove_by_path(file_path) + + if not deleted: loop = asyncio.get_event_loop() entry = await loop.run_in_executor( self._executor, _scan_file, self.root, file_path) if entry: self._index.add_entry(entry) - log.debug("Indexed: %s (%s)", file_path.name, entry.id[:8]) + log.debug("Indexed: %s (%s, %d bytes)", + file_path.name, entry.id[:8], entry.size) self._index.version = int(time.time()) if self.on_change: diff --git a/packages/meshbay-node/src/meshbay_node/transport/http_server.py b/packages/meshbay-node/src/meshbay_node/transport/http_server.py index 7554086..151c2e8 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/http_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/http_server.py @@ -37,7 +37,8 @@ from fastapi.responses import FileResponse, JSONResponse, StreamingResponse from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from meshbay_common import MNP_VERSION -from meshbay_common.crypto import chunk_key as derive_chunk_key, encrypt_chunk, sign_chunk, pk_to_b64 +from meshbay_common.crypto import sign_chunk, pk_to_b64 +from meshbay_common.webcrypto import chunk_key_aes as derive_chunk_key, encrypt_chunk_aes as encrypt_chunk from meshbay_node import __version__ from meshbay_node.indexer import GroupIndex from meshbay_node.indexer.group_index import GroupIndex @@ -189,7 +190,7 @@ def create_http_app( if not plaintext: raise HTTPException(status_code=416, detail="Chunk out of range") - file_hash = blake3.blake3(file_path.read_bytes()).digest() + file_hash = bytes.fromhex(entry.id) pt_hash = blake3.blake3(plaintext).digest() if gek: diff --git a/packages/meshbay-node/src/meshbay_node/transport/quic_client.py b/packages/meshbay-node/src/meshbay_node/transport/quic_client.py index 83b729e..288465f 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/quic_client.py +++ b/packages/meshbay-node/src/meshbay_node/transport/quic_client.py @@ -23,11 +23,8 @@ from aioquic.quic.events import QuicEvent, StreamDataReceived from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey from meshbay_common import MNP_VERSION -from meshbay_common.crypto import ( - chunk_key as derive_chunk_key, - decrypt_chunk, - verify_chunk_signature, -) +from meshbay_common.crypto import verify_chunk_signature +from meshbay_common.webcrypto import chunk_key_aes as derive_chunk_key, decrypt_chunk_aes as decrypt_chunk from meshbay_common.protocol import MNP log = logging.getLogger(__name__) diff --git a/packages/meshbay-node/src/meshbay_node/transport/quic_server.py b/packages/meshbay-node/src/meshbay_node/transport/quic_server.py index 43c1026..f439e62 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/quic_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/quic_server.py @@ -35,11 +35,10 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from meshbay_common import MNP_VERSION from meshbay_common.crypto import ( - chunk_key as derive_chunk_key, - encrypt_chunk, sign_chunk, pk_to_b64, ) +from meshbay_common.webcrypto import chunk_key_aes as derive_chunk_key, encrypt_chunk_aes as encrypt_chunk from meshbay_common.protocol import MNP from meshbay_node.indexer import GroupIndex from meshbay_node.transport.tls_cert import server_ssl_context @@ -217,11 +216,13 @@ class _MNPServerProtocol(QuicConnectionProtocol): self._send(stream_id, {"type": "error", "detail": "File not on disk"}) return + file_hash = bytes.fromhex(entry.id) chunk_data = _read_and_encrypt( self._ctx["sk_node"], ctx["gek"], file_path, chunk_index, + file_hash, ) self._send(stream_id, chunk_data) @@ -304,13 +305,13 @@ def _read_and_encrypt( gek: bytes, file_path: Path, chunk_index: int, + file_hash: bytes, ) -> dict: """Read and encrypt one chunk (blocking — runs in executor).""" with open(file_path, "rb") as f: f.seek(chunk_index * CHUNK_SIZE) plaintext = f.read(CHUNK_SIZE) - file_hash = blake3.blake3(file_path.read_bytes()).digest() pt_hash = blake3.blake3(plaintext).digest() ckey = derive_chunk_key(gek, file_hash, chunk_index) nonce, ct = encrypt_chunk(ckey, plaintext) diff --git a/packages/meshbay-node/src/meshbay_node/transport/server.py b/packages/meshbay-node/src/meshbay_node/transport/server.py index 76ac13a..b77f1f2 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/server.py @@ -197,7 +197,7 @@ class _ConnectionHandler: "type": "error", "detail": "File not on disk"}) continue - file_hash = blake3.blake3(file_path.read_bytes()).digest() + file_hash = bytes.fromhex(entry.id) chunk = await loop.run_in_executor( None, _serve_chunk, self._sk_node, self._gek, file_path, file_hash, chunk_index) 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 89391b9..7da6623 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py @@ -29,19 +29,14 @@ import struct from pathlib import Path from typing import Any -import blake3 import jwt import msgpack from aiortc import RTCPeerConnection, RTCSessionDescription, RTCDataChannel from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from meshbay_common import MNP_VERSION -from meshbay_common.crypto import ( - chunk_key as derive_chunk_key, - encrypt_chunk, - sign_chunk, - pk_to_b64, -) +from meshbay_common.crypto import pk_to_b64 +from meshbay_common.webcrypto import chunk_key_aes, encrypt_chunk_aes from meshbay_common.protocol import MNP from meshbay_node.indexer import GroupIndex @@ -101,6 +96,7 @@ class WebRTCPeerSession: def _handle_message(self, msg: dict) -> None: mtype = msg.get("type") + log.debug("WebRTC recv: %s", mtype) try: if mtype == MNP.HANDSHAKE: self._do_handshake(msg) @@ -112,8 +108,12 @@ class WebRTCPeerSession: self._do_file_request(msg) elif mtype == MNP.STREAM_SEGMENT: self._do_stream_segment(msg) + elif mtype == MNP.GEK_REQUEST: + self._do_gek_request() elif mtype == MNP.CHAT_MESSAGE: self._do_chat_message(msg) + elif mtype == MNP.CHAT_HISTORY: + self._do_chat_history(msg) else: log.warning("Unknown MNP message type on DataChannel: %s", mtype) except Exception as e: @@ -145,6 +145,10 @@ class WebRTCPeerSession: self._user_id = decoded["sub"] self._group_id = group_id + peers = self._ctx.get("_peers") + if peers is not None: + peers[self._user_id] = self + log.info("WebRTC handshake OK — user=%s group=%s", self._user_id[:8], group_id[:8] if group_id else "none") self._send({ @@ -160,11 +164,32 @@ class WebRTCPeerSession: def _do_index_sync(self) -> None: ctx = self._group_ctx() - wire = ctx["index"].serialize() + idx = ctx["index"] + entries = [ + { + "id": e.id, "name": e.name, "path": e.path, + "size": e.size, "type": e.type, "added_at": e.added_at, + } + for e in idx.entries + ] self._send({ "type": MNP.INDEX_SYNC, "v": MNP_VERSION, - "index_b64": base64.b64encode(wire).decode(), + "group_id": idx.group_id, + "version": idx.version, + "entries": entries, + }) + + def _do_gek_request(self) -> None: + ctx = self._group_ctx() + gek = ctx.get("gek") + if not gek: + self._send({"type": "error", "detail": "No GEK available"}) + return + self._send({ + "type": MNP.GEK_RESPONSE, + "v": MNP_VERSION, + "gek_b64": base64.b64encode(gek).decode(), }) def _do_file_request(self, msg: dict) -> None: @@ -173,6 +198,7 @@ class WebRTCPeerSession: chunk_index = msg["chunk_index"] entry = ctx["index"].get_entry(file_id) if not entry: + log.warning("File not found: %s", file_id[:16]) self._send({"type": "error", "detail": "File not found"}) return @@ -181,11 +207,13 @@ class WebRTCPeerSession: self._send({"type": "error", "detail": "File not on disk"}) return + file_hash = bytes.fromhex(entry.id) chunk_data = _read_and_encrypt( self._ctx["sk_node"], ctx["gek"], file_path, chunk_index, + file_hash, ) self._send(chunk_data) @@ -235,21 +263,77 @@ class WebRTCPeerSession: def _do_chat_message(self, msg: dict) -> None: chat_store = self._ctx.get("chat_store") + payload = msg.get("payload", "") if chat_store: + raw = payload.encode() if isinstance(payload, str) else payload asyncio.ensure_future(chat_store.save_message( sender_id=msg.get("sender_id", self._user_id), iteration=msg.get("iteration", 0), - payload=msg.get("payload", b"").encode() - if isinstance(msg.get("payload"), str) else msg.get("payload", b""), + payload=raw, thread_id=msg.get("thread_id"), )) + + peers = self._ctx.get("_peers", {}) + broadcast = { + "type": MNP.CHAT_MESSAGE, + "v": MNP_VERSION, + "sender_id": msg.get("sender_id", self._user_id), + "payload": payload, + "thread_id": msg.get("thread_id"), + "timestamp": __import__("time").time(), + } + for uid, session in peers.items(): + if uid != self._user_id and session is not self: + try: + session._send(broadcast) + except Exception: + pass + self._send({"type": "ack", "v": MNP_VERSION}) + def _do_chat_history(self, msg: dict) -> None: + chat_store = self._ctx.get("chat_store") + if not chat_store: + self._send({ + "type": MNP.CHAT_HISTORY_RESPONSE, + "v": MNP_VERSION, + "messages": [], + }) + return + + since = msg.get("since", 0) + limit = msg.get("limit", 100) + asyncio.ensure_future(self._send_chat_history(chat_store, since, limit)) + + async def _send_chat_history(self, chat_store, since: float, limit: int) -> None: + msgs = await chat_store.get_messages(since=since, limit=limit) + self._send({ + "type": MNP.CHAT_HISTORY_RESPONSE, + "v": MNP_VERSION, + "messages": [ + { + "id": m.id, + "sender_id": m.sender_id, + "payload": m.payload.decode("utf-8", errors="replace") + if isinstance(m.payload, bytes) else m.payload, + "timestamp": m.timestamp, + "thread_id": m.thread_id, + } + for m in msgs + ], + }) + def _send(self, obj: dict) -> None: if self._channel and self._channel.readyState == "open": self._channel.send(_pack(obj)) + else: + log.warning("WebRTC send skipped: channel=%s", + self._channel.readyState if self._channel else "none") async def close(self) -> None: + peers = self._ctx.get("_peers") + if peers and self._user_id: + peers.pop(self._user_id, None) await self._pc.close() @@ -258,30 +342,22 @@ def _read_and_encrypt( gek: bytes, file_path: Path, chunk_index: int, + file_hash: bytes, ) -> dict: with open(file_path, "rb") as f: f.seek(chunk_index * CHUNK_SIZE) plaintext = f.read(CHUNK_SIZE) - file_hash = blake3.blake3(file_path.read_bytes()).digest() - pt_hash = blake3.blake3(plaintext).digest() - ckey = derive_chunk_key(gek, file_hash, chunk_index) - nonce, ct = encrypt_chunk(ckey, plaintext) - ct_hash = blake3.blake3(ct).digest() - sig = sign_chunk(sk_node, chunk_index, nonce, ct_hash) + ckey = chunk_key_aes(gek, file_hash, chunk_index) + nonce, ct = encrypt_chunk_aes(ckey, plaintext) return { "type": MNP.FILE_CHUNK, "v": MNP_VERSION, "chunk_index": chunk_index, "plaintext_size": len(plaintext), - "nonce_b64": base64.b64encode(nonce).decode(), - "ct_b64": base64.b64encode(ct).decode(), - "ct_hash_b64": base64.b64encode(ct_hash).decode(), - "pt_hash_b64": base64.b64encode(pt_hash).decode(), - "sig_b64": base64.b64encode(sig).decode(), - "pk_node_b64": pk_to_b64(sk_node.public_key()), - "file_hash_b64": base64.b64encode(file_hash).decode(), + "nonce": nonce, + "ct": ct, } @@ -312,6 +388,7 @@ class WebRTCTransport: "gek": gek, "shared_root": shared_root, "index": index, + "_peers": {}, } if groups: self._ctx["groups"] = groups diff --git a/packages/meshbay-node/tests/test_http_server.py b/packages/meshbay-node/tests/test_http_server.py index 1483a1d..d4ccc32 100644 --- a/packages/meshbay-node/tests/test_http_server.py +++ b/packages/meshbay-node/tests/test_http_server.py @@ -156,7 +156,7 @@ async def test_chunk_public_group(sk_node, sk_hub, hub_pk_pem, shared_dir): @pytest.mark.asyncio async def test_chunk_private_group(sk_node, sk_hub, hub_pk_pem, gek, shared_dir): """Private group chunk: encrypted with GEK.""" - from meshbay_common.crypto import chunk_key as derive_chunk_key, decrypt_chunk + from meshbay_common.webcrypto import chunk_key_aes as derive_chunk_key, decrypt_chunk_aes as decrypt_chunk import blake3 indexer = DirectoryIndexer(root=shared_dir, group_id="g", sk_node=sk_node, gek=gek) diff --git a/packages/meshbay-node/tests/test_indexer.py b/packages/meshbay-node/tests/test_indexer.py index 60f9b01..380d752 100644 --- a/packages/meshbay-node/tests/test_indexer.py +++ b/packages/meshbay-node/tests/test_indexer.py @@ -175,7 +175,7 @@ async def test_on_change_callback(shared_dir, sk_node, gek): await asyncio.sleep(0.1) (shared_dir / "newfile.mp4").write_bytes(os.urandom(256)) - await asyncio.sleep(0.5) # let watchdog detect the change + await asyncio.sleep(3.0) # watchdog detect + 2s debounce await indexer.stop() assert len(changes) >= 1, "on_change should have been called" diff --git a/packages/meshbay-node/tests/test_webrtc_transport.py b/packages/meshbay-node/tests/test_webrtc_transport.py index 4c0fdbf..d3847ff 100644 --- a/packages/meshbay-node/tests/test_webrtc_transport.py +++ b/packages/meshbay-node/tests/test_webrtc_transport.py @@ -13,7 +13,6 @@ import os import struct import time -import blake3 import jwt import msgpack import pytest @@ -25,10 +24,8 @@ from meshbay_common import MNP_VERSION from meshbay_common.crypto import ( generate_gek, pk_to_b64, - chunk_key as derive_chunk_key, - decrypt_chunk, - verify_chunk_signature, ) +from meshbay_common.webcrypto import chunk_key_aes, decrypt_chunk_aes from meshbay_common.protocol import MNP from meshbay_node.indexer import DirectoryIndexer from meshbay_node.transport.webrtc_server import WebRTCTransport @@ -197,7 +194,8 @@ async def test_webrtc_datachannel_file_transfer(sk_node, sk_hub, gek, shared_dir channel.send(_pack({"type": MNP.INDEX_SYNC, "v": MNP_VERSION})) idx_msg = await asyncio.wait_for(received.get(), timeout=5.0) assert idx_msg["type"] == MNP.INDEX_SYNC - assert "index_b64" in idx_msg + assert "entries" in idx_msg + assert len(idx_msg["entries"]) > 0 # 3) Request file chunk entry = next(e for e in indexer.index.entries if e.name == "test.bin") @@ -211,25 +209,24 @@ async def test_webrtc_datachannel_file_transfer(sk_node, sk_hub, gek, shared_dir chunk_msg = await asyncio.wait_for(received.get(), timeout=5.0) assert chunk_msg["type"] == MNP.FILE_CHUNK - # 4) Verify and decrypt - ct = base64.b64decode(chunk_msg["ct_b64"]) - nonce = base64.b64decode(chunk_msg["nonce_b64"]) - ct_hash = base64.b64decode(chunk_msg["ct_hash_b64"]) - pt_hash = base64.b64decode(chunk_msg["pt_hash_b64"]) - sig = base64.b64decode(chunk_msg["sig_b64"]) - file_hash = base64.b64decode(chunk_msg["file_hash_b64"]) + # 4) Verify and decrypt (binary fields — no base64, minimal envelope) + ct = chunk_msg["ct"] + nonce = chunk_msg["nonce"] + file_hash = bytes.fromhex(entry.id) - pk_node = sk_node.public_key() - verify_chunk_signature(pk_node, 0, nonce, ct_hash, sig) - assert blake3.blake3(ct).digest() == ct_hash - - ckey = derive_chunk_key(gek, file_hash, 0) - plaintext = decrypt_chunk(ckey, nonce, ct) - assert blake3.blake3(plaintext).digest() == pt_hash + ckey = chunk_key_aes(gek, file_hash, 0) + plaintext = decrypt_chunk_aes(ckey, nonce, ct) original = (shared_dir / "test.bin").read_bytes() assert plaintext == original + # 5) Request GEK over DataChannel + channel.send(_pack({"type": MNP.GEK_REQUEST, "v": MNP_VERSION})) + gek_msg = await asyncio.wait_for(received.get(), timeout=5.0) + assert gek_msg["type"] == MNP.GEK_RESPONSE + received_gek = base64.b64decode(gek_msg["gek_b64"]) + assert received_gek == gek + await browser_pc.close() await transport.close_all() @@ -324,3 +321,407 @@ async def test_webrtc_request_before_handshake_rejected(sk_node, sk_hub, gek, sh await browser_pc.close() await transport.close_all() + + +@pytest.mark.asyncio +async def test_webrtc_chat_send_and_history(sk_node, sk_hub, gek, shared_dir, tmp_path): + """WebRTC DataChannel: send chat message, then retrieve history.""" + from meshbay_node.chat.store import ChatStore + + hub_pk_pem = _hub_pk_pem(sk_hub) + indexer = DirectoryIndexer(root=shared_dir, group_id="g", sk_node=sk_node, gek=gek) + await indexer.initial_scan() + + chat_store = ChatStore(db_path=tmp_path / "chat_test.db") + await chat_store.open() + + transport = WebRTCTransport( + sk_node=sk_node, hub_pk_pem=hub_pk_pem, gek=gek, + shared_root=shared_dir, index=indexer.index, + stun_servers=[], + ) + transport._ctx["chat_store"] = chat_store + + browser_pc = RTCPeerConnection() + received = asyncio.Queue() + buf = bytearray() + + channel = browser_pc.createDataChannel("mnp") + + @channel.on("open") + def on_open(): + token = _make_jwt(sk_hub) + channel.send(_pack({ + "type": MNP.HANDSHAKE, + "v": MNP_VERSION, + "token": token, + })) + + @channel.on("message") + def on_msg(message): + if isinstance(message, str): + message = message.encode() + buf.extend(message) + while len(buf) >= 4: + length = struct.unpack(">I", buf[:4])[0] + if len(buf) < 4 + length: + break + msg_bytes = bytes(buf[4:4 + length]) + del buf[:4 + length] + received.put_nowait(msgpack.unpackb(msg_bytes, raw=False)) + + offer = await browser_pc.createOffer() + await browser_pc.setLocalDescription(offer) + + answer_sdp, _ = await transport.handle_offer( + browser_pc.localDescription.sdp, "peer-chat") + await browser_pc.setRemoteDescription( + RTCSessionDescription(sdp=answer_sdp, type="answer")) + + ack = await asyncio.wait_for(received.get(), timeout=5.0) + assert ack["type"] == MNP.HANDSHAKE_ACK + + channel.send(_pack({ + "type": MNP.CHAT_MESSAGE, + "v": MNP_VERSION, + "payload": "hello from browser", + })) + chat_ack = await asyncio.wait_for(received.get(), timeout=5.0) + assert chat_ack["type"] == "ack" + + await asyncio.sleep(0.2) + + channel.send(_pack({ + "type": MNP.CHAT_HISTORY, + "v": MNP_VERSION, + "since": 0, + "limit": 50, + })) + hist = await asyncio.wait_for(received.get(), timeout=5.0) + assert hist["type"] == MNP.CHAT_HISTORY_RESPONSE + assert len(hist["messages"]) == 1 + assert hist["messages"][0]["payload"] == "hello from browser" + assert hist["messages"][0]["sender_id"] == "user-001" + + await chat_store.close() + await browser_pc.close() + await transport.close_all() + + +@pytest.mark.asyncio +async def test_webrtc_chat_history_no_store(sk_node, sk_hub, gek, shared_dir): + """WebRTC DataChannel: chat history without chat_store returns empty list.""" + hub_pk_pem = _hub_pk_pem(sk_hub) + indexer = DirectoryIndexer(root=shared_dir, group_id="g", sk_node=sk_node, gek=gek) + await indexer.initial_scan() + + transport = WebRTCTransport( + sk_node=sk_node, hub_pk_pem=hub_pk_pem, gek=gek, + shared_root=shared_dir, index=indexer.index, + stun_servers=[], + ) + + browser_pc = RTCPeerConnection() + received = asyncio.Queue() + buf = bytearray() + + channel = browser_pc.createDataChannel("mnp") + + @channel.on("open") + def on_open(): + channel.send(_pack({ + "type": MNP.HANDSHAKE, "v": MNP_VERSION, + "token": _make_jwt(sk_hub), + })) + + @channel.on("message") + def on_msg(message): + if isinstance(message, str): + message = message.encode() + buf.extend(message) + while len(buf) >= 4: + length = struct.unpack(">I", buf[:4])[0] + if len(buf) < 4 + length: + break + msg_bytes = bytes(buf[4:4 + length]) + del buf[:4 + length] + received.put_nowait(msgpack.unpackb(msg_bytes, raw=False)) + + offer = await browser_pc.createOffer() + await browser_pc.setLocalDescription(offer) + answer_sdp, _ = await transport.handle_offer( + browser_pc.localDescription.sdp, "peer-no-store") + await browser_pc.setRemoteDescription( + RTCSessionDescription(sdp=answer_sdp, type="answer")) + + ack = await asyncio.wait_for(received.get(), timeout=5.0) + assert ack["type"] == MNP.HANDSHAKE_ACK + + channel.send(_pack({ + "type": MNP.CHAT_HISTORY, "v": MNP_VERSION, "since": 0, "limit": 50, + })) + hist = await asyncio.wait_for(received.get(), timeout=5.0) + assert hist["type"] == MNP.CHAT_HISTORY_RESPONSE + assert hist["messages"] == [] + + await browser_pc.close() + await transport.close_all() + + +@pytest.mark.asyncio +async def test_webrtc_chat_broadcast(sk_node, sk_hub, gek, shared_dir, tmp_path): + """WebRTC DataChannel: chat message from peer A is broadcast to peer B.""" + from meshbay_node.chat.store import ChatStore + + hub_pk_pem = _hub_pk_pem(sk_hub) + indexer = DirectoryIndexer(root=shared_dir, group_id="g", sk_node=sk_node, gek=gek) + await indexer.initial_scan() + + chat_store = ChatStore(db_path=tmp_path / "chat_bc.db") + await chat_store.open() + + transport = WebRTCTransport( + sk_node=sk_node, hub_pk_pem=hub_pk_pem, gek=gek, + shared_root=shared_dir, index=indexer.index, + stun_servers=[], + ) + transport._ctx["chat_store"] = chat_store + + async def _connect_peer(peer_id, jwt_sub, groups=None): + pc = RTCPeerConnection() + q = asyncio.Queue() + b = bytearray() + ch = pc.createDataChannel("mnp") + + sk_h_pem = sk_hub.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ) + now = int(time.time()) + token = jwt.encode({ + "iss": "test-hub", "sub": jwt_sub, + "pk_user": "test", "hub_id": "test-hub", + "jti": f"jti-{peer_id}", "iat": now, "exp": now + 3600, + "groups": groups or [], + }, sk_h_pem, algorithm="EdDSA") + + @ch.on("open") + def on_open(): + ch.send(_pack({ + "type": MNP.HANDSHAKE, "v": MNP_VERSION, "token": token, + })) + + @ch.on("message") + def on_msg(message): + if isinstance(message, str): + message = message.encode() + b.extend(message) + while len(b) >= 4: + length = struct.unpack(">I", b[:4])[0] + if len(b) < 4 + length: + break + msg_bytes = bytes(b[4:4 + length]) + del b[:4 + length] + q.put_nowait(msgpack.unpackb(msg_bytes, raw=False)) + + offer = await pc.createOffer() + await pc.setLocalDescription(offer) + answer_sdp, _ = await transport.handle_offer( + pc.localDescription.sdp, peer_id) + await pc.setRemoteDescription( + RTCSessionDescription(sdp=answer_sdp, type="answer")) + + ack = await asyncio.wait_for(q.get(), timeout=5.0) + assert ack["type"] == MNP.HANDSHAKE_ACK + return pc, ch, q + + pc_a, ch_a, q_a = await _connect_peer("peer-A", "user-A") + pc_b, ch_b, q_b = await _connect_peer("peer-B", "user-B") + + ch_a.send(_pack({ + "type": MNP.CHAT_MESSAGE, "v": MNP_VERSION, "payload": "hi from A", + })) + + ack_a = await asyncio.wait_for(q_a.get(), timeout=5.0) + assert ack_a["type"] == "ack" + + broadcast = await asyncio.wait_for(q_b.get(), timeout=5.0) + assert broadcast["type"] == MNP.CHAT_MESSAGE + assert broadcast["sender_id"] == "user-A" + assert broadcast["payload"] == "hi from A" + + await chat_store.close() + await pc_a.close() + await pc_b.close() + await transport.close_all() + + +@pytest.mark.asyncio +async def test_webrtc_group_membership_enforced(sk_node, sk_hub, gek, shared_dir): + """WebRTC DataChannel: JWT without matching group claim is rejected.""" + hub_pk_pem = _hub_pk_pem(sk_hub) + indexer = DirectoryIndexer(root=shared_dir, group_id="g", sk_node=sk_node, gek=gek) + await indexer.initial_scan() + + transport = WebRTCTransport( + sk_node=sk_node, hub_pk_pem=hub_pk_pem, gek=gek, + shared_root=shared_dir, index=indexer.index, + stun_servers=[], + ) + + browser_pc = RTCPeerConnection() + received = asyncio.Queue() + + channel = browser_pc.createDataChannel("mnp") + + @channel.on("message") + def on_msg(message): + if isinstance(message, str): + message = message.encode() + received.put_nowait(_unpack(message)) + + offer = await browser_pc.createOffer() + await browser_pc.setLocalDescription(offer) + answer_sdp, _ = await transport.handle_offer( + browser_pc.localDescription.sdp, "peer-group-test") + await browser_pc.setRemoteDescription( + RTCSessionDescription(sdp=answer_sdp, type="answer")) + + await asyncio.sleep(0.5) + + token = _make_jwt(sk_hub, groups=["other-group"]) + channel.send(_pack({ + "type": MNP.HANDSHAKE, "v": MNP_VERSION, + "token": token, "group_id": "my-group", + })) + + msg = await asyncio.wait_for(received.get(), timeout=5.0) + assert msg["type"] == "error" + assert "Not a member" in msg["detail"] + + await browser_pc.close() + await transport.close_all() + + +@pytest.mark.asyncio +async def test_webrtc_peer_cleanup_on_close(sk_node, sk_hub, gek, shared_dir): + """WebRTC DataChannel: peer removed from _peers dict on session close.""" + hub_pk_pem = _hub_pk_pem(sk_hub) + indexer = DirectoryIndexer(root=shared_dir, group_id="g", sk_node=sk_node, gek=gek) + await indexer.initial_scan() + + transport = WebRTCTransport( + sk_node=sk_node, hub_pk_pem=hub_pk_pem, gek=gek, + shared_root=shared_dir, index=indexer.index, + stun_servers=[], + ) + + browser_pc = RTCPeerConnection() + received = asyncio.Queue() + buf = bytearray() + + channel = browser_pc.createDataChannel("mnp") + + @channel.on("open") + def on_open(): + channel.send(_pack({ + "type": MNP.HANDSHAKE, "v": MNP_VERSION, + "token": _make_jwt(sk_hub), + })) + + @channel.on("message") + def on_msg(message): + if isinstance(message, str): + message = message.encode() + buf.extend(message) + while len(buf) >= 4: + length = struct.unpack(">I", buf[:4])[0] + if len(buf) < 4 + length: + break + msg_bytes = bytes(buf[4:4 + length]) + del buf[:4 + length] + received.put_nowait(msgpack.unpackb(msg_bytes, raw=False)) + + offer = await browser_pc.createOffer() + await browser_pc.setLocalDescription(offer) + answer_sdp, _ = await transport.handle_offer( + browser_pc.localDescription.sdp, "peer-cleanup") + await browser_pc.setRemoteDescription( + RTCSessionDescription(sdp=answer_sdp, type="answer")) + + ack = await asyncio.wait_for(received.get(), timeout=5.0) + assert ack["type"] == MNP.HANDSHAKE_ACK + + assert "user-001" in transport._ctx["_peers"] + assert transport.active_peers == 1 + + await transport.close_peer("peer-cleanup") + + assert "user-001" not in transport._ctx["_peers"] + assert transport.active_peers == 0 + + await browser_pc.close() + + +@pytest.mark.asyncio +async def test_webrtc_stream_segment_missing_file(sk_node, sk_hub, gek, shared_dir): + """WebRTC DataChannel: stream_segment for non-existent file returns error.""" + hub_pk_pem = _hub_pk_pem(sk_hub) + indexer = DirectoryIndexer(root=shared_dir, group_id="g", sk_node=sk_node, gek=gek) + await indexer.initial_scan() + + transport = WebRTCTransport( + sk_node=sk_node, hub_pk_pem=hub_pk_pem, gek=gek, + shared_root=shared_dir, index=indexer.index, + stun_servers=[], + ) + + browser_pc = RTCPeerConnection() + received = asyncio.Queue() + buf = bytearray() + + channel = browser_pc.createDataChannel("mnp") + + @channel.on("open") + def on_open(): + channel.send(_pack({ + "type": MNP.HANDSHAKE, "v": MNP_VERSION, + "token": _make_jwt(sk_hub), + })) + + @channel.on("message") + def on_msg(message): + if isinstance(message, str): + message = message.encode() + buf.extend(message) + while len(buf) >= 4: + length = struct.unpack(">I", buf[:4])[0] + if len(buf) < 4 + length: + break + msg_bytes = bytes(buf[4:4 + length]) + del buf[:4 + length] + received.put_nowait(msgpack.unpackb(msg_bytes, raw=False)) + + offer = await browser_pc.createOffer() + await browser_pc.setLocalDescription(offer) + answer_sdp, _ = await transport.handle_offer( + browser_pc.localDescription.sdp, "peer-stream") + await browser_pc.setRemoteDescription( + RTCSessionDescription(sdp=answer_sdp, type="answer")) + + ack = await asyncio.wait_for(received.get(), timeout=5.0) + assert ack["type"] == MNP.HANDSHAKE_ACK + + channel.send(_pack({ + "type": MNP.STREAM_SEGMENT, "v": MNP_VERSION, + "file_id": "nonexistent-file-id", + "segment_index": 0, "segment_duration": 4, + })) + + msg = await asyncio.wait_for(received.get(), timeout=5.0) + assert msg["type"] == "error" + assert "not found" in msg["detail"].lower() + + await browser_pc.close() + await transport.close_all() |