diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-09 04:58:06 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-09 04:58:06 +0200 |
| commit | c9422eaf95090cde6411186c0d62b04286a8477c (patch) | |
| tree | 558378df2af6869a86215bcfe53e893b79c756bd /packages | |
| parent | 9b503785afe10849f40a26735aa08e0af24a6efc (diff) | |
| download | meshbay-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>
Diffstat (limited to 'packages')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/api/groups.py | 29 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/api/webapp.py | 74 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/app.py | 4 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/app.js | 198 |
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,'&').replace(/</g,'<').replace(/>/g,'>') + .replace(/"/g,'"').replace(/'/g,'''); +} + +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); |