summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-09 04:58:06 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-09 04:58:06 +0200
commitc9422eaf95090cde6411186c0d62b04286a8477c (patch)
tree558378df2af6869a86215bcfe53e893b79c756bd
parent9b503785afe10849f40a26735aa08e0af24a6efc (diff)
downloadmeshbay-c9422eaf95090cde6411186c0d62b04286a8477c.tar.gz
feat(hub): add web app + public group listing endpoint
webapp.py: serves / (HTML SPA) and /app.js (JS client). app.js: login, hub API calls, node HTTP API integration, file browser, HLS video streaming via <video> tag. groups.py: GET /v1/groups — public group listing (no auth). Deployed on https://meshbay.org — 200 OK. 47/47 tests. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/groups.py29
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/webapp.py74
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/app.py4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js198
4 files changed, 304 insertions, 1 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/groups.py b/packages/meshbay-hub/src/meshbay_hub/api/groups.py
index 942a88e..0092fbd 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/groups.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/groups.py
@@ -12,6 +12,35 @@ from meshbay_hub.db.models import GEKBundle, Group, GroupMember, IPLog, User
router = APIRouter(prefix="/v1/groups", tags=["groups"])
+@router.get("")
+async def list_public_groups(
+ db: AsyncSession = Depends(get_db),
+ limit: int = 50,
+ offset: int = 0,
+):
+ """List public groups — browsable without authentication."""
+ result = await db.execute(
+ select(Group)
+ .where(Group.visibility == "public", Group.status == "active")
+ .order_by(Group.created_at.desc())
+ .limit(limit)
+ .offset(offset)
+ )
+ groups = result.scalars().all()
+ return {
+ "groups": [
+ {
+ "id": g.id,
+ "name": g.name,
+ "join_policy": g.join_policy,
+ "created_at": g.created_at.isoformat(),
+ }
+ for g in groups
+ ],
+ "total": len(groups),
+ }
+
+
class GroupCreateRequest(BaseModel):
name: str
visibility: str = "private" # public|private
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py
new file mode 100644
index 0000000..3d5e78b
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py
@@ -0,0 +1,74 @@
+"""
+Hub web application — serves the MeshBay web client at /.
+
+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
+
+Static files are served from meshbay_hub/static/.
+API routes remain at /v1/*.
+"""
+
+from pathlib import Path
+
+from fastapi import APIRouter
+from fastapi.responses import FileResponse, 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("/", response_class=HTMLResponse)
+async def index():
+ return HTMLResponse(_HTML)
+
+
+_HTML = """\
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <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>
+</head>
+<body>
+ <div id="nav"></div>
+ <div id="main"><p>Loading…</p></div>
+ <script 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 c469a87..5897ce0 100644
--- a/packages/meshbay-hub/src/meshbay_hub/app.py
+++ b/packages/meshbay-hub/src/meshbay_hub/app.py
@@ -24,6 +24,7 @@ from meshbay_hub.api.hub import router as hub_router
from meshbay_hub.api.users import router as users_router, set_config as users_set_config
from meshbay_hub.api.nodes import router as nodes_router
from meshbay_hub.api.groups import router as groups_router
+from meshbay_hub.api.webapp import router as webapp_router
from meshbay_hub.api.middleware import limiter
@@ -59,10 +60,11 @@ def create_app(cfg: HubConfig | None = None) -> FastAPI:
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
- # Routers
+ # Routers (webapp last — catches / before API routes)
app.include_router(hub_router)
app.include_router(users_router)
app.include_router(nodes_router)
app.include_router(groups_router)
+ app.include_router(webapp_router)
return app
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
new file mode 100644
index 0000000..1b284bb
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -0,0 +1,198 @@
+/* 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
+ */
+
+const HUB = ''; // same origin — hub serves this file
+
+// ── State ─────────────────────────────────────────────────────────────────────
+
+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,
+};
+
+// ── Hub API helpers ───────────────────────────────────────────────────────────
+
+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();
+}
+
+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()}`);
+ return r.json();
+}
+
+// ── Auth ──────────────────────────────────────────────────────────────────────
+
+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;
+}
+
+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 logout() {
+ state = { token: null, refreshToken: null, username: null, nodeUrl: null };
+ localStorage.clear();
+ render();
+}
+
+// ── Node API helpers ──────────────────────────────────────────────────────────
+
+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();
+}
+
+// ── Pages ─────────────────────────────────────────────────────────────────────
+
+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('');
+
+ 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>
+ `}`;
+}
+
+async function pageGroup(groupId) {
+ // TODO: fetch group info + node from hub
+ return `<p>Group ${groupId} — coming soon</p><button onclick="render()">Back</button>`;
+}
+
+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('');
+
+ 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>`;
+}
+
+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>`;
+}
+
+// ── Actions ───────────────────────────────────────────────────────────────────
+
+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);
+}
+
+async function streamVideo(fileId, name) {
+ setMain(pageStream(fileId, name));
+}
+
+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); }
+}
+
+// ── Router / render ───────────────────────────────────────────────────────────
+
+function setMain(html) {
+ document.getElementById('main').innerHTML = html;
+}
+
+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>`);
+ }
+}
+
+// ── Utils ─────────────────────────────────────────────────────────────────────
+
+function esc(s) {
+ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
+ .replace(/"/g,'&quot;').replace(/'/g,'&#39;');
+}
+
+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';
+}
+
+// ── Boot ──────────────────────────────────────────────────────────────────────
+document.addEventListener('DOMContentLoaded', render);