diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-20 22:21:24 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-20 22:21:24 +0200 |
| commit | ae52b69b14a6997d01aad66f49fbea7b5ca2dfe6 (patch) | |
| tree | 26f850a88565846a139868a4b85c715734751a41 | |
| parent | c8af746c846b5dbc792f7e4f0d806647d513cc5c (diff) | |
| parent | 2e9490ca27047ae03e495d397abbe1aec1b2273a (diff) | |
| download | meshbay-ae52b69b14a6997d01aad66f49fbea7b5ca2dfe6.tar.gz | |
Merge feat/unified-group-management: wizard, public groups, activity sidebar
22 files changed, 1146 insertions, 215 deletions
diff --git a/packages/meshbay-client/src/main.js b/packages/meshbay-client/src/main.js index e7e7b15..f2ec645 100644 --- a/packages/meshbay-client/src/main.js +++ b/packages/meshbay-client/src/main.js @@ -643,6 +643,101 @@ function registerBridge() { try { fs.unlinkSync(sink.path); } catch { /* already gone */ } return true; }); + + // ── Node loopback bridge ───────────────────────────────────────────────── + // + // The renderer never sees the session token. It names an operation and this + // process executes it — the same pattern as hub:fetch. The token is read + // from the daemon's data directory, cached for the lifetime of this process, + // and never exposed through the preload. + + let _nodeToken = null; + let _nodePort = 18000; + let _nodePairingCode = null; + + function nodeConfigPath() { + return path.join(os.homedir(), '.config', 'meshbay', 'node.toml'); + } + + function readNodeConfig() { + try { + const text = fs.readFileSync(nodeConfigPath(), 'utf8'); + let dataDir = path.join(os.homedir(), '.local', 'share', 'meshbay'); + let uiPort = 18000; + const dataMatch = text.match(/^\s*data_dir\s*=\s*"([^"]+)"/m); + if (dataMatch) { + dataDir = dataMatch[1].replace(/^~/, os.homedir()); + } + const portMatch = text.match(/^\s*ui_port\s*=\s*(\d+)/m); + if (portMatch) uiPort = parseInt(portMatch[1], 10); + return { dataDir, uiPort }; + } catch { + return null; + } + } + + function readNodeToken(dataDir) { + try { + return fs.readFileSync(path.join(dataDir, 'ui-token'), 'utf8').trim(); + } catch { + return null; + } + } + + ipcMain.handle('node:detect', async () => { + const nc = readNodeConfig(); + if (!nc) return { detected: false }; + const token = readNodeToken(nc.dataDir); + if (!token) return { detected: false }; + _nodeToken = token; + _nodePort = nc.uiPort; + try { + const r = await fetch( + `http://127.0.0.1:${_nodePort}/api/status?t=${_nodeToken}`, + { signal: AbortSignal.timeout(3000) }); + if (!r.ok) return { detected: false }; + const status = await r.json(); + return { + detected: true, + status: status.status, + pk_node_ed25519: status.pk_node_ed25519 || '', + }; + } catch { + return { detected: false }; + } + }); + + ipcMain.handle('node:call', async (_e, method, apiPath, body) => { + if (!_nodeToken) throw new Error('Node not detected'); + const sep = apiPath.includes('?') ? '&' : '?'; + const url = `http://127.0.0.1:${_nodePort}${apiPath}${sep}t=${_nodeToken}`; + const init = { method: String(method).toUpperCase() }; + if (body !== undefined && body !== null) { + init.headers = { 'Content-Type': 'application/json' }; + init.body = JSON.stringify(body); + } + init.signal = AbortSignal.timeout(30000); + const r = await fetch(url, init); + const text = await r.text(); + let data; + try { data = JSON.parse(text); } catch { data = text; } + if (!r.ok) { + const msg = (data && data.error) || (data && data.detail) || text; + throw new Error(`Node ${r.status}: ${msg}`); + } + return data; + }); + + ipcMain.handle('node:pairing-code', async () => { + const code = _nodePairingCode; + _nodePairingCode = null; + return code; + }); + + ipcMain.handle('node:set-pairing-code', async (_e, code) => { + _nodePairingCode = code || null; + return true; + }); } /** diff --git a/packages/meshbay-client/src/preload.js b/packages/meshbay-client/src/preload.js index e3af68f..04dfe44 100644 --- a/packages/meshbay-client/src/preload.js +++ b/packages/meshbay-client/src/preload.js @@ -80,6 +80,16 @@ contextBridge.exposeInMainWorld('meshbay', { choose: () => ipcRenderer.invoke('root:choose'), }, + // The local node, if one is running. The renderer never sees the session + // token — it names an operation and the main process executes it, the same + // pattern as hub:fetch. + node: { + detect: () => ipcRenderer.invoke('node:detect'), + call: (method, path, body) => ipcRenderer.invoke('node:call', method, path, body), + pairingCode: () => ipcRenderer.invoke('node:pairing-code'), + setPairingCode: (code) => ipcRenderer.invoke('node:set-pairing-code', code), + }, + // A sink that writes to disk as chunks arrive, never a buffer handed over at // the end. `auto` uses the remembered folder without a dialog, which is what // "save automatically" means; without one, or when the person asked to be diff --git a/packages/meshbay-hub/src/meshbay_hub/api/groups.py b/packages/meshbay-hub/src/meshbay_hub/api/groups.py index 6819ff6..65307f1 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/groups.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/groups.py @@ -2,7 +2,8 @@ from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel -from sqlalchemy import func, or_, select +from datetime import datetime, timezone +from sqlalchemy import func, or_, select, update from sqlalchemy.ext.asyncio import AsyncSession from meshbay_hub.api.deps import get_current_user, require_user_scope @@ -36,7 +37,7 @@ async def my_groups( # added to it before a node exists would see a name they cannot # open and cannot be told why. or_(Group.hosted_at.is_not(None), Group.admin_id == current_user.id)) - .order_by(Group.name) + .order_by(Group.last_activity_at.desc()) ) groups = result.scalars().all() muted_rows = await db.execute( @@ -63,12 +64,35 @@ async def my_groups( # which is the evidence that actually concerns the user. "node_online": bool(get_online_nodes_for_group(g.id)), "hosted": g.hosted_at is not None, + "last_activity_at": g.last_activity_at.isoformat(), } for g in groups ] } +@router.post("/{group_id}/activity") +async def touch_group_activity( + group_id: str, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Bump a group's last_activity_at. Called by the client on chat/file events.""" + result = await db.execute( + select(GroupMember.group_id).where( + GroupMember.group_id == group_id, + GroupMember.user_id == current_user.id, + )) + if not result.first(): + raise HTTPException(status_code=403, detail="Not a member") + await db.execute( + update(Group) + .where(Group.id == group_id) + .values(last_activity_at=datetime.now(timezone.utc))) + await db.commit() + return {"ok": True} + + @router.get("/{group_id}/nodes") async def group_online_nodes( group_id: str, diff --git a/packages/meshbay-hub/src/meshbay_hub/api/revocation.py b/packages/meshbay-hub/src/meshbay_hub/api/revocation.py index d555f9f..003e396 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/revocation.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/revocation.py @@ -285,6 +285,11 @@ async def node_websocket(ws: WebSocket): elif msg.get("type") == "webrtc_answer": from meshbay_hub.api.signaling import handle_webrtc_answer handle_webrtc_answer(msg) + elif msg.get("type") == "update_groups": + new_gids = msg.get("group_ids", []) + _node_groups[node_id] = new_gids + await _mark_hosted(new_gids) + log.info("Node %s updated groups: %d", node_id[:8], len(new_gids)) elif msg.get("type") == "chat_notify": asyncio.ensure_future(_handle_chat_notify( msg.get("group_id", ""), diff --git a/packages/meshbay-hub/src/meshbay_hub/api/signaling.py b/packages/meshbay-hub/src/meshbay_hub/api/signaling.py index cb00a67..84c1167 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/signaling.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/signaling.py @@ -90,7 +90,9 @@ async def webrtc_offer( if not ws: raise HTTPException(status_code=404, detail="Node not connected") - # The caller must share at least one active group with the target node. + # The caller must share at least one active group with the target node, + # OR the node must host at least one open-join group (public groups admit + # anyone — the node's MNP handshake handles authorization). node_group_ids = set(_node_groups.get(node_id, [])) if node_group_ids: result = await db.execute( @@ -100,12 +102,19 @@ async def webrtc_offer( )) shared = [gid for (gid,) in result.all()] if not shared: - raise HTTPException(status_code=403, detail="Not a member of any group on this node") - - active = await db.execute( - select(Group.id).where(Group.id.in_(shared), Group.status == "active")) - if not active.first(): - raise HTTPException(status_code=403, detail="Group is not active") + has_open = await db.execute( + select(Group.id).where( + Group.id.in_(node_group_ids), + Group.join_policy == "open", + Group.status == "active", + )) + if not has_open.first(): + raise HTTPException(status_code=403, detail="Not a member of any group on this node") + else: + active = await db.execute( + select(Group.id).where(Group.id.in_(shared), Group.status == "active")) + if not active.first(): + raise HTTPException(status_code=403, detail="Group is not active") if _pending_per_user.get(current_user.id, 0) >= MAX_PENDING_PER_USER: raise HTTPException(status_code=429, detail="Too many pending connections") diff --git a/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/a7b8c9d0e1f2_add_group_last_activity.py b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/a7b8c9d0e1f2_add_group_last_activity.py new file mode 100644 index 0000000..8a4e2ee --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/a7b8c9d0e1f2_add_group_last_activity.py @@ -0,0 +1,28 @@ +"""add_group_last_activity_at + +Revision ID: a7b8c9d0e1f2 +Revises: f1a2b3c4d5e6 +Create Date: 2026-08-20 20:50:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = 'a7b8c9d0e1f2' +down_revision: Union[str, Sequence[str], None] = 'f1a2b3c4d5e6' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column('groups', + sa.Column('last_activity_at', sa.DateTime(timezone=True), + server_default=sa.func.now(), nullable=False)) + op.execute("UPDATE groups SET last_activity_at = created_at") + + +def downgrade() -> None: + op.drop_column('groups', 'last_activity_at') diff --git a/packages/meshbay-hub/src/meshbay_hub/db/models.py b/packages/meshbay-hub/src/meshbay_hub/db/models.py index f1f11e2..3966bdc 100644 --- a/packages/meshbay-hub/src/meshbay_hub/db/models.py +++ b/packages/meshbay-hub/src/meshbay_hub/db/models.py @@ -103,7 +103,9 @@ class Group(Base): # group. Until then the group has no files, no key and nobody to serve it, so # it is shown to its owner only and is what `prune-groups` collects. Set once # and never cleared: a node going offline does not un-host a group. - hosted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + hosted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + last_activity_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=_now, nullable=False) members: Mapped[list["GroupMember"]] = relationship(back_populates="group") diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index 3fdba07..50ee9f6 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -638,21 +638,23 @@ function Sidebar({ groups, presence, route, menuOpen, role, hasNodeKey }) { href="#/explore"><${Icon} name="globe" /> ${t('sidebar.public_groups')}</a> <a class="sidebar-item ${route === '/search' ? 'active' : ''}" href="#/search"><${Icon} name="search" /> ${t('sidebar.search')}</a> - <a class="sidebar-item ${route === '/create-group' ? 'active' : ''}" - href="#/create-group"><${Icon} name="plus" /> ${t('sidebar.create_group')}</a> </div> ${platform.capabilities.nodeAdmin && hasNodeKey && html` <div class="sidebar-section"> <div class="sidebar-heading">${t('sidebar.node')}</div> <a class="sidebar-item ${route === '/node' ? 'active' : ''}" href="#/node"><${Icon} name="server" /> ${t('node.title')}</a> + <a class="sidebar-item ${route === '/create-group' ? 'active' : ''}" + href="#/create-group"><${Icon} name="plus" /> ${t('sidebar.create_group')}</a> </div> `} <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 => { + : [...groups].sort((a, b) => + (b.last_activity_at || b.created_at || '').localeCompare( + a.last_activity_at || a.created_at || '')).map(g => { // Three states, each backed by something. `node_online` comes from // the hub's signaling registry and rides on the group list itself, // so there is no poll and no timer; a connection this browser tried @@ -984,7 +986,9 @@ function ExplorePage({ token, myGroupIds }) { <div> <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px"> <h2 style="margin:0">${t('explore.title')}</h2> - <a class="admin-btn" href="#/create-group">${t('explore.create_group')}</a> + ${platform.node.available && html` + <a class="admin-btn" href="#/create-group">${t('explore.create_group')}</a> + `} </div> <div class="file-toolbar" style="margin-bottom:16px"> <input type="text" class="admin-search" placeholder="${t('explore.search')}" @@ -1028,7 +1032,12 @@ function ExplorePage({ token, myGroupIds }) { // ── Create Group Page ──────────────────────────────────────────────────────── -function CreateGroupPage({ token, onCreated }) { +function CreateGroupPage(props) { + if (platform.node.available) return html`<${CreateGroupWizard} ...${props} />`; + return html`<${CreateGroupFormSimple} ...${props} />`; +} + +function CreateGroupFormSimple({ token, onCreated }) { const [name, setName] = useState(''); const [description, setDescription] = useState(''); const [joinPolicy, setJoinPolicy] = useState('invite'); @@ -1041,8 +1050,6 @@ function CreateGroupPage({ token, onCreated }) { setLoading(true); setError(''); try { - // Derived, not asked: "open" is what makes a group listed, and there is - // no third combination the server would accept. const body = { name: name.trim(), join_policy: joinPolicy, visibility: joinPolicy === 'open' ? 'public' : 'private' }; if (description.trim()) body.description = description.trim().slice(0, 512); @@ -1083,15 +1090,6 @@ function CreateGroupPage({ token, onCreated }) { </div> </div> - ${/* One question, not two. Visibility and admission were separate - selectors that could only ever be set together: a public group - admits everyone by definition, and a private one that anyone may - join is a directory listing nobody can find. The server already - refused public+invite with a 422 — the form could build a request - that could not succeed. Now the answer to "who can join" settles - both, and the descriptions say what each one means for who can - *find* the group, which is the part the visibility box was there - to state and no longer needs to. */ html` <div class="settings-section"> <h3 class="settings-heading">${t('create_group.join_policy')}</h3> <div class="choice-list"> @@ -1115,7 +1113,6 @@ function CreateGroupPage({ token, onCreated }) { </label> </div> </div> - `} <button class="btn-primary" type="submit" disabled=${loading}> ${loading ? t('create_group.creating') : t('create_group.submit')} @@ -1125,6 +1122,300 @@ function CreateGroupPage({ token, onCreated }) { `; } +// ── Create Group Wizard (Electron-only) ───────────────────────────────────── + +function CreateGroupWizard({ token, onCreated }) { + const [step, setStep] = useState(0); // 0=node check, 1=details, 2=setup, 3=done + const [nodeStatus, setNodeStatus] = useState(null); // null=loading, object=result + const [error, setError] = useState(''); + + // Step 1 fields + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [joinPolicy, setJoinPolicy] = useState('invite'); + const [roots, setRoots] = useState([]); + const [uploadIdx, setUploadIdx] = useState(0); + + // Step 2 progress + const [setupSteps, setSetupSteps] = useState([]); + const [setupError, setSetupError] = useState(''); + const [groupId, setGroupId] = useState(''); + + // Step 0: detect node + const detectNode = useCallback(async () => { + setNodeStatus(null); + setError(''); + try { + const result = await platform.node.detect(); + setNodeStatus(result); + if (result.detected) { + // Auto-link node key to hub if not already done + if (result.pk_node_ed25519) { + try { + await hubFetch('/v1/users/me/node_key', { + method: 'PUT', token, + body: { pk_node_ed25519: result.pk_node_ed25519 }, + }); + } catch { /* already linked or same key */ } + } + setStep(1); + } + } catch (err) { + setError(err.message); + setNodeStatus({ detected: false }); + } + }, [token]); + + useEffect(() => { detectNode(); }, [detectNode]); + + const addRoot = useCallback(async () => { + const chosen = await platform.rootPicker.choose(); + if (!chosen) return; + if (roots.some(r => r.path === chosen.path)) return; + setRoots(prev => [...prev, chosen]); + }, [roots]); + + const removeRoot = useCallback((idx) => { + setRoots(prev => { + const next = prev.filter((_, i) => i !== idx); + if (uploadIdx >= next.length && next.length > 0) setUploadIdx(0); + return next; + }); + }, [uploadIdx]); + + const runSetup = useCallback(async () => { + setStep(2); + setSetupError(''); + const steps = [ + { label: t('wizard.step_create_hub'), status: 'pending' }, + { label: t('wizard.step_attach'), status: 'pending' }, + ]; + if (roots.length > 1) + steps.push({ label: t('wizard.step_add_roots'), status: 'pending' }); + steps.push({ label: t('wizard.step_gek'), status: 'pending' }); + steps.push({ label: t('wizard.step_pair'), status: 'pending' }); + setSetupSteps([...steps]); + + let si = 0; + const update = (status) => { + steps[si].status = status; + setSetupSteps([...steps]); + }; + const advance = () => { si++; }; + + try { + // 1. Create group on hub + update('running'); + const body = { name: name.trim(), join_policy: joinPolicy, + visibility: joinPolicy === 'open' ? 'public' : 'private' }; + if (description.trim()) body.description = description.trim().slice(0, 512); + const data = await hubFetch('/v1/groups', { method: 'POST', token, body }); + const gid = data.group_id; + setGroupId(gid); + update('done'); + advance(); + + // 2. Attach to node with first root + update('running'); + const mainRoot = roots[uploadIdx] || roots[0]; + const attachBody = { name: name.trim(), shared_dir: mainRoot.path }; + if (roots.length === 1 || uploadIdx === 0) { + attachBody.upload_dir = mainRoot.path; + } + await platform.node.call('POST', '/api/groups/attach', attachBody); + await platform.node.call('POST', '/api/reload'); + update('done'); + advance(); + + // 3. Add extra roots (if >1) + if (roots.length > 1) { + update('running'); + for (let i = 0; i < roots.length; i++) { + if (i === (uploadIdx < roots.length ? uploadIdx : 0)) continue; + const r = roots[i]; + await platform.node.call('POST', `/api/groups/${gid}/roots`, { + path: r.path, name: r.name, + upload: i === uploadIdx, + }); + } + update('done'); + advance(); + } + + // 4. GEK init + update('running'); + await platform.node.call('POST', `/api/groups/${gid}/gek`); + update('done'); + advance(); + + // 5. Generate pairing code + update('running'); + const pairResult = await platform.node.call('POST', '/api/operator/pair'); + if (pairResult && pairResult.code) { + await platform.node.setPairingCode(pairResult.code); + } + update('done'); + + // Reload node config so it picks up the new group + try { await platform.node.call('POST', '/api/reload'); } catch { /* best effort */ } + + setStep(3); + if (onCreated) onCreated(); + } catch (err) { + update('error'); + setSetupError(platform.bridgeMessage(err)); + } + }, [name, description, joinPolicy, roots, uploadIdx, token, onCreated]); + + // Step 0: Node detection + if (step === 0) { + if (nodeStatus === null) { + return html`<div class="page-content"> + <h2>${t('wizard.title')}</h2> + <p class="page-message">${t('wizard.detecting')}</p> + </div>`; + } + if (!nodeStatus.detected) { + return html`<div class="page-content"> + <h2>${t('wizard.title')}</h2> + <p class="page-message">${t('wizard.node_not_found')}</p> + ${error && html`<div class="error-msg">${error}</div>`} + <div style="display:flex;gap:8px;margin-top:16px"> + <button class="btn btn-primary" onClick=${detectNode}> + ${t('wizard.retry')}</button> + </div> + </div>`; + } + } + + // Step 1: Group details + directories + if (step === 1) { + const canProceed = name.trim() && roots.length > 0; + return html`<div class="page-content"> + <h2>${t('wizard.title')}</h2> + ${error && html`<div class="error-msg" style="margin-bottom:16px">${error}</div>`} + + <div class="settings-section"> + <div class="form-field"> + <label class="form-label">${t('create_group.name')}</label> + <input type="text" placeholder="${t('create_group.name_placeholder')}" + value=${name} onInput=${e => setName(e.target.value)} required autofocus /> + </div> + + <div class="form-field"> + <label class="form-label">${t('create_group.description')}</label> + <textarea class="form-textarea" rows="3" maxlength="512" + placeholder="${t('create_group.description_hint')}" + value=${description} + onInput=${e => setDescription(e.target.value)} /> + <div class="form-char-count">${description.length}/512</div> + </div> + </div> + + <div class="settings-section"> + <h3 class="settings-heading">${t('create_group.join_policy')}</h3> + <div class="choice-list"> + <label class="choice ${joinPolicy === 'invite' ? 'selected' : ''}"> + <input type="radio" name="join_policy" checked=${joinPolicy === 'invite'} + onChange=${() => setJoinPolicy('invite')} /> + <${Icon} name="lock" cls="choice-icon" /> + <span class="choice-text"> + <span class="choice-title">${t('create_group.invite')}</span> + <span class="choice-desc">${t('create_group.invite_desc')}</span> + </span> + </label> + <label class="choice ${joinPolicy === 'open' ? 'selected' : ''}"> + <input type="radio" name="join_policy" checked=${joinPolicy === 'open'} + onChange=${() => setJoinPolicy('open')} /> + <${Icon} name="globe" cls="choice-icon" /> + <span class="choice-text"> + <span class="choice-title">${t('create_group.open')}</span> + <span class="choice-desc">${t('create_group.open_desc')}</span> + </span> + </label> + </div> + </div> + + <div class="settings-section"> + <h3 class="settings-heading">${t('wizard.directories')}</h3> + <p style="font-size:0.85em;color:var(--text-dim);margin-bottom:8px"> + ${t('wizard.directories_hint')}</p> + ${roots.map((r, i) => html` + <div class="wizard-root" key=${r.path}> + <div class="wizard-root-info"> + <${Icon} name="folder" /> + <span class="wizard-root-name">${r.name}</span> + <span class="wizard-root-path">${r.path}</span> + ${i === uploadIdx && html` + <span class="node-root-badge">${t('wizard.upload_target')}</span>`} + </div> + <div class="wizard-root-actions"> + ${roots.length > 1 && i !== uploadIdx && html` + <button class="btn btn-small btn-secondary" + onClick=${() => setUploadIdx(i)}> + ${t('wizard.set_upload')}</button>`} + <button class="btn btn-small btn-danger" + onClick=${() => removeRoot(i)}> + ${t('wizard.remove')}</button> + </div> + </div> + `)} + <button class="btn btn-secondary" style="margin-top:8px" + onClick=${addRoot}> + <${Icon} name="folder-plus" /> ${t('wizard.add_directory')} + </button> + </div> + + <div style="display:flex;gap:8px;margin-top:16px"> + <button class="btn btn-primary" disabled=${!canProceed} + onClick=${runSetup}> + ${t('wizard.create_and_setup')}</button> + </div> + </div>`; + } + + // Step 2: Automatic setup progress + if (step === 2) { + return html`<div class="page-content"> + <h2>${t('wizard.title')}</h2> + <p class="page-message">${t('wizard.setting_up')}</p> + <div class="wizard-progress"> + ${setupSteps.map((s, i) => html` + <div class="wizard-step wizard-step-${s.status}" key=${i}> + <span class="wizard-step-icon"> + ${s.status === 'done' ? '✓' : + s.status === 'running' ? '●' : + s.status === 'error' ? '✗' : '○'} + </span> + <span>${s.label}</span> + </div> + `)} + </div> + ${setupError && html` + <div class="error-msg" style="margin-top:16px">${setupError}</div> + <div style="display:flex;gap:8px;margin-top:8px"> + <button class="btn btn-primary" onClick=${runSetup}> + ${t('wizard.retry')}</button> + <button class="btn btn-secondary" onClick=${() => { + if (onCreated) onCreated(); + navigate('/'); + }}> + ${t('wizard.finish_later')}</button> + </div> + `} + </div>`; + } + + // Step 3: Done + return html`<div class="page-content"> + <h2>${t('wizard.done_title')}</h2> + <p class="page-message">${t('wizard.done_message')}</p> + <button class="btn btn-primary" style="margin-top:16px" + onClick=${() => navigate(`/groups/${groupId}`)}> + ${t('wizard.go_to_group')}</button> + </div>`; +} + // ── Helpers ────────────────────────────────────────────────────────────────── const FILE_ICONS = { @@ -1322,8 +1613,19 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, const [previewEntry, setPreviewEntry] = useState(null); const defaultTab = (userPrefs && (userPrefs[`default_tab:${groupId}`] || userPrefs['default_tab'])) || 'chat'; const [tab, setTab] = useState(defaultTab); + useEffect(() => { setTab(defaultTab); }, [groupId]); const [groupMuted, setGroupMuted] = useState(() => !!(group && group.muted)); + const _lastTouch = useRef(0); + const touchActivity = useCallback(() => { + const now = Date.now(); + if (now - _lastTouch.current < 60_000) return; + _lastTouch.current = now; + const ts = new Date().toISOString(); + if (onGroupUpdated) onGroupUpdated(groupId, { last_activity_at: ts }); + hubFetch(`/v1/groups/${groupId}/activity`, { method: 'POST', token }).catch(() => {}); + }, [groupId, token, onGroupUpdated]); + const toggleGroupMute = useCallback(async () => { const next = !groupMuted; setGroupMuted(next); @@ -1477,6 +1779,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, if (cancelled) return; applyIndex(indexMsg); setStatus('connected'); + touchActivity(); // First-hand evidence, and the strongest available: this browser spoke // to the node. It outranks whatever the hub said in the group list. if (onPresence) onPresence(groupId, 'online'); @@ -2170,7 +2473,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, ${tab === 'chat' && status === 'connected' && html` <${ChatPanel} transportRef=${transportRef} username=${username} entries=${entries} gekRef=${gekRef} onRefreshIndex=${refreshIndex} - mayUpload=${mayUpload} + mayUpload=${mayUpload} onActivity=${touchActivity} onPreview=${(entry) => { if (entry.type === 'video') setVideoEntry(entry); else setPreviewEntry(entry); @@ -2194,7 +2497,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, onPaired=${() => setOperatorPaired(true)} /> `} `} - ${status === 'offline' && html` + ${status === 'offline' && !group && html` <p class="page-message"> ${t('group.offline_title')} ${' '}${t('group.offline_hint')} @@ -2381,6 +2684,31 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, const [inviteUser, setInviteUser] = useState(''); const [inviting, setInviting] = useState(false); const [error, setError] = useState(''); + + // Node loopback state (Electron-only) + const [nodeDetected, setNodeDetected] = useState(false); + const [nodeRoots, setNodeRoots] = useState([]); + const [nodeGroupName, setNodeGroupName] = useState(''); + const [nodeBusy, setNodeBusy] = useState(false); + const [nodeMsg, setNodeMsg] = useState(''); + + const loadNodeInfo = useCallback(async () => { + if (!platform.node.available) return; + try { + const detect = await platform.node.detect(); + if (!detect.detected) { setNodeDetected(false); return; } + setNodeDetected(true); + const data = await platform.node.call('GET', '/api/groups'); + const groups = data.groups || []; + const ng = groups.find(g => g.id === groupId); + if (ng) { + setNodeRoots(ng.roots || []); + setNodeGroupName(ng.name || ''); + } + } catch { setNodeDetected(false); } + }, [groupId]); + + useEffect(() => { loadNodeInfo(); }, [loadNodeInfo]); const [inviteCode, setInviteCode] = useState(null); const [pairCode, setPairCode] = useState(''); const [pairStatus, setPairStatus] = useState(''); @@ -2498,7 +2826,16 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, setError(''); setRemoving(member.user_id); try { - if (transport && transport.connected && operatorPaired) { + if (platform.node.available) { + try { + await platform.node.call('POST', + `/api/members/${member.user_id}/revoke?group_id=${groupId}`); + } catch { /* best effort — node may not host this group */ } + try { + await platform.node.call('POST', + `/api/members/${member.user_id}/unpin`); + } catch { /* best effort */ } + } else if (transport && transport.connected && operatorPaired) { const sk = transport.sessionKeys && transport.sessionKeys.skEdB64; const signFn = (sk && window.MeshBayKeys) ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript) @@ -2585,7 +2922,8 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, ${error && html`<div class="error-msg" style="margin-bottom:12px">${error}</div>`} ${/* Inviting needs the node: it is the node that wraps the group key and - issues the code, not the hub. */ isAdmin && html` + issues the code, not the hub. Public groups admit anyone — no invite. */ + isAdmin && group?.join_policy !== 'open' && html` <div class="settings-section"> <h3 class="settings-heading">${t('members.invite_title')}</h3> ${!connected && html` @@ -2696,9 +3034,96 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, </div> `} - ${/* Before the roster, not after it: this is what someone came here to - do, and a list of two hundred names is a long way to scroll for - it. */ html` + ${/* Roots management (Electron-only, when node is local) */ + nodeDetected && nodeRoots.length > 0 && html` + <div class="settings-section"> + <h3 class="settings-heading">${t('settings_node.roots')}</h3> + ${nodeMsg && html`<p class="settings-hint">${nodeMsg}</p>`} + <div class="node-roots"> + ${nodeRoots.map(r => html` + <div class="node-root ${!r.available ? 'node-root-unavailable' : ''}" + key=${r.name}> + <div class="node-root-info"> + <span class="node-root-name"> + <${Icon} name="folder" /> + ${r.name} + </span> + ${r.upload && html` + <span class="node-root-badge">${t('node.upload_root')}</span>`} + ${!r.available && html` + <span class="node-root-badge node-root-badge-warn"> + ${t('node.unavailable')}</span>`} + </div> + ${nodeRoots.length > 1 && !r.upload && html` + <button class="btn btn-small btn-danger" + disabled=${nodeBusy} + onClick=${async () => { + if (!confirm(t('node.root_remove_confirm', { name: r.name }))) return; + setNodeBusy(true); setNodeMsg(''); + try { + await platform.node.call('DELETE', + '/api/groups/' + groupId + '/roots/' + encodeURIComponent(r.name)); + await platform.node.call('POST', '/api/reload'); + setNodeMsg(t('node.root_removed')); + await loadNodeInfo(); + } catch (err) { setNodeMsg(platform.bridgeMessage(err)); } + finally { setNodeBusy(false); } + }}> + ${t('node.remove_root')}</button>`} + </div> + `)} + <button class="btn btn-small btn-secondary" style="margin-top:8px" + disabled=${nodeBusy} + onClick=${async () => { + const chosen = await platform.rootPicker.choose(); + if (!chosen) return; + setNodeBusy(true); setNodeMsg(''); + try { + await platform.node.call('POST', + '/api/groups/' + groupId + '/roots', + { path: chosen.path, name: chosen.name }); + await platform.node.call('POST', '/api/reload'); + setNodeMsg(t('node.root_added')); + await loadNodeInfo(); + } catch (err) { setNodeMsg(platform.bridgeMessage(err)); } + finally { setNodeBusy(false); } + }}> + <${Icon} name="folder-plus" /> ${t('node.add_root')} + </button> + </div> + </div> + `} + + ${/* Upload toggle via loopback when MNP not connected */ + nodeDetected && !connected && html` + <div class="settings-section"> + <h3 class="settings-heading">${t('members.uploads_title')}</h3> + <div class="settings-row"> + <span class="settings-label"> + ${memberUpload ? t('members.uploads_on') : t('members.uploads_off')} + </span> + <button class="admin-btn" disabled=${nodeBusy} + onClick=${async () => { + setNodeBusy(true); setNodeMsg(''); + try { + const newVal = !memberUpload; + await platform.node.call('PUT', + '/api/groups/' + groupId + '/member-upload', + { allowed: newVal }); + if (onMemberUpload) onMemberUpload(newVal); + } catch (err) { setNodeMsg(platform.bridgeMessage(err)); } + finally { setNodeBusy(false); } + }}> + ${memberUpload ? t('members.uploads_disable') + : t('members.uploads_enable')} + </button> + </div> + <p class="settings-hint">${t('members.uploads_hint')}</p> + </div> + `} + + ${/* Delete/leave — node detach first (reversible), then hub delete + (irreversible). */ html` <div class="settings-section"> <h3 class="settings-heading"> ${isOwner ? t('group.delete_group') : t('group.leave')} @@ -2713,6 +3138,15 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, <button class="admin-btn danger" onClick=${async () => { if (!confirm(t('group.delete_group_confirm', { name: group.name }))) return; try { + // Node detach first (reversible), then hub delete (irreversible) + if (nodeDetected && nodeGroupName) { + try { + await platform.node.call('POST', '/api/groups/detach', + { name: nodeGroupName }); + } catch (detachErr) { + if (!confirm(t('settings_node.detach_failed_continue'))) return; + } + } await hubFetch('/v1/groups/' + groupId, { method: 'DELETE', token }); navigate('/'); window.location.reload(); @@ -2725,9 +3159,6 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, try { await hubFetch('/v1/groups/' + groupId + '/leave', { method: 'POST', token }); - // Dropped from the list here rather than reloading: a - // reload would tear down the WebRTC connections other - // groups hold. if (onLeft) onLeft(groupId); } catch (err) { setError(err.message); } }}>${t('group.leave')}</button> @@ -2899,7 +3330,7 @@ function ChatImage({ filename, entries, transportRef, gekRef }) { } function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, - onPreview, mayUpload = true }) { + onPreview, mayUpload = true, onActivity }) { const [messages, setMessages] = useState([]); const [hasMore, setHasMore] = useState(false); const [loadingOlder, setLoadingOlder] = useState(false); @@ -3072,6 +3503,7 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, thread_id: null, }]); jumpToBottom(); + if (onActivity) onActivity(); } catch { setInput(text); } finally { @@ -5248,7 +5680,7 @@ function NodePage({ token, username, userId, groups }) { if (status === 'idle' || status === 'connecting') { return html`<div class="page-content"> - <p class="page-message">${t('status.connecting')}</p> + <p class="page-message"><span class="spinner"></span>${' '}${t('status.connecting')}</p> </div>`; } if (status === 'no_groups') { @@ -5306,9 +5738,8 @@ function NodePage({ token, username, userId, groups }) { ${!r.available && html` <span class="node-root-badge node-root-badge-warn"> ${t('node.unavailable')}</span>`} - <span class="node-root-kind">${r.kind}</span> </div> - ${(g.roots || []).length > 1 && html` + ${(g.roots || []).length > 1 && !r.upload && html` <button class="btn btn-small btn-danger" disabled=${busy} onClick=${() => removeRoot(g.id, r.name)}> ${t('node.remove_root')}</button>`} @@ -5322,11 +5753,11 @@ function NodePage({ token, username, userId, groups }) { <div class="node-section"> <span class="settings-heading">${t('node.gek')}</span> - ${g.has_gek ? html` + ${g.has_gek ? (g.visibility !== 'public' ? html` <button class="btn btn-small btn-secondary" disabled=${busy} onClick=${() => rotateGek(g.id)}> ${t('node.gek_rotate')}</button> - ` : html` + ` : html`<p class="node-hint">${t('node.gek_public_hint')}</p>`) : html` <p class="node-hint">${t('node.gek_init_hint')}</p> `} </div> @@ -5433,7 +5864,9 @@ function NodePage({ token, username, userId, groups }) { `} </div> - ${(() => { + ${/* In Electron, the Create Group wizard handles attaching. Keep for + browser users who manage nodes via MNP. */ + !platform.node.available && (() => { const hostedIds = new Set(nodeGroups.map(g => g.id)); const unhosted = (groups || []).filter(g => !hostedIds.has(g.id)); if (!unhosted.length) return null; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js index 4193f00..a8392c7 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -125,7 +125,7 @@ export default { // Status 'status.idle': 'Idle', 'status.discovering': 'Finding nodes...', - 'status.connecting': 'Connecting via WebRTC...', + 'status.connecting': 'Connecting...', 'status.connecting_short': 'Connecting...', 'status.fetching': 'Fetching index...', 'status.files': { @@ -332,6 +332,36 @@ export default { 'create_group.hint': 'A group needs a node to host files. You can create the group now and connect a node later.', 'create_group.creating': 'Creating...', + // Create Group Wizard (Electron-only) + 'wizard.title': 'Create Group', + 'wizard.detecting': 'Detecting local node...', + 'wizard.node_not_found': 'No local node detected. Make sure meshbay-node is running.', + 'wizard.retry': 'Retry', + 'wizard.skip_node': 'Continue without node', + 'wizard.node_offline_warning': 'Node is not running. The group will be created on the hub only. You can connect the node later from the Node page.', + 'wizard.directories': 'Shared directories', + 'wizard.directories_hint': 'Choose the directories this group will share. At least one is required.', + 'wizard.add_directory': 'Add directory', + 'wizard.upload_target': 'upload target', + 'wizard.set_upload': 'Set as upload target', + 'wizard.remove': 'Remove', + 'wizard.create_and_setup': 'Create and set up', + 'wizard.create_hub_only': 'Create on hub only', + 'wizard.setting_up': 'Setting up your group...', + 'wizard.step_create_hub': 'Creating group on hub', + 'wizard.step_attach': 'Attaching to node', + 'wizard.step_add_roots': 'Adding directories', + 'wizard.step_gek': 'Initializing encryption key', + 'wizard.step_pair': 'Setting up pairing', + 'wizard.finish_later': 'Finish setup later', + 'wizard.done_title': 'Group created', + 'wizard.done_message': 'Your group is ready. Your node is hosting it and encryption is set up.', + 'wizard.go_to_group': 'Go to group', + + // Unified Group Settings (node sections) + 'settings_node.roots': 'Shared directories', + 'settings_node.detach_failed_continue': 'Could not detach the group from the node. Delete on hub anyway?', + // Members 'members.col_role': 'Role', 'members.group_role': 'Group role', @@ -469,6 +499,7 @@ export default { 'node.gek_rotate_confirm': 'Rotate the group key? Connected members will receive the new key automatically. Content already downloaded is unaffected.', 'node.gek_rotated': 'Group key rotated.', 'node.gek_init_hint': 'No group key yet. Run GEK init from the CLI to set one up.', + 'node.gek_public_hint': 'Key rotation is not available for public groups.', 'node.roster': 'Roster', 'node.roster_load': 'Load roster', 'node.roster_empty': 'No members pinned yet.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js index fe6c666..9eb2f30 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -125,7 +125,7 @@ export default { // Status 'status.idle': 'Inactif', 'status.discovering': 'Recherche de nodes...', - 'status.connecting': 'Connexion via WebRTC...', + 'status.connecting': 'Connexion…', 'status.connecting_short': 'Connexion…', 'status.fetching': "Récupération de l'index...", 'status.files': { diff --git a/packages/meshbay-hub/src/meshbay_hub/static/platform.js b/packages/meshbay-hub/src/meshbay_hub/static/platform.js index d379e9d..02107f5 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/platform.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/platform.js @@ -207,8 +207,35 @@ export const rootPicker = { async choose() { return bridge && bridge.rootPicker ? bridge.rootPicker.choose() : null; }, }; +/** + * The local node, if one is running on this machine. + * + * Detection probes `127.0.0.1:{ui_port}` with the session token read from the + * daemon's data directory. The renderer never sees the token — it names an + * operation and the main process executes it, the same trust model as hub:fetch. + * + * In a browser all calls resolve to a "not available" result, so the interface + * can gate features on `node.available` without a build flag. + */ +export const node = { + available: Boolean(bridge && bridge.node), + async detect() { + return bridge && bridge.node ? bridge.node.detect() : { detected: false }; + }, + async call(method, path, body) { + if (!bridge || !bridge.node) throw new Error('Node bridge not available'); + return bridge.node.call(method, path, body); + }, + async pairingCode() { + return bridge && bridge.node ? bridge.node.pairingCode() : null; + }, + async setPairingCode(code) { + return bridge && bridge.node ? bridge.node.setPairingCode(code) : false; + }, +}; + export default { isNative, hubBase, capabilities, secrets, nativeSave, - apiFetch, device, bridgeMessage, folder, rootPicker }; + apiFetch, device, bridgeMessage, folder, rootPicker, node }; // Also a global, because `transport.js` is loaded as a classic script — it // predates the module graph and exposes `MeshBayTransport` the same way. The @@ -217,5 +244,5 @@ export default { isNative, hubBase, capabilities, secrets, nativeSave, if (typeof window !== 'undefined') { window.MeshBayPlatform = { isNative, hubBase, capabilities, secrets, nativeSave, apiFetch, device, - bridgeMessage, folder, rootPicker }; + bridgeMessage, folder, rootPicker, node }; } diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css index ac3d08f..7ce073a 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/style.css +++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css @@ -2222,3 +2222,71 @@ a.transfer-name { align-items: center; } .node-attach-dir-row input { flex: 1; } + +/* ── Create Group Wizard ──────────────────────────────────────────────────── */ + +.wizard-root { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + gap: 8px; +} +.wizard-root + .wizard-root { margin-top: 6px; } +.wizard-root-info { + display: flex; + align-items: center; + gap: 8px; + flex: 1; + min-width: 0; +} +.wizard-root-name { font-weight: 500; } +.wizard-root-path { + font-size: 0.82em; + color: var(--text-dim); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.wizard-root-actions { + display: flex; + gap: 4px; + flex-shrink: 0; +} + +.wizard-progress { + display: flex; + flex-direction: column; + gap: 10px; + margin-top: 16px; +} +.wizard-step { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + font-size: 0.9em; +} +.wizard-step-icon { + width: 20px; + text-align: center; + font-weight: 600; +} +.wizard-step-done .wizard-step-icon { color: var(--success); } +.wizard-step-running .wizard-step-icon { color: var(--accent); } +.wizard-step-error .wizard-step-icon { color: var(--error); } +.wizard-step-pending { opacity: 0.5; } + +.warning-msg { + padding: 10px 14px; + background: color-mix(in srgb, #f59e0b 15%, transparent); + border: 1px solid #f59e0b; + border-radius: 8px; + font-size: 0.85em; +} diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py index 7e3ebc1..385a1da 100644 --- a/packages/meshbay-node/src/meshbay_node/daemon.py +++ b/packages/meshbay-node/src/meshbay_node/daemon.py @@ -125,6 +125,7 @@ class NodeDaemon: self._indexers: list[DirectoryIndexer] = [] self._tasks: list[asyncio.Task] = [] self._hub: HubClient | None = None + self._reload_lock = asyncio.Lock() async def run(self) -> None: log.info("MeshBay Node starting up") @@ -225,14 +226,13 @@ class NodeDaemon: ", ".join(str(r.path) for r in roots)) gek = None - if group_cfg.visibility == "private": - gek = await self._load_gek( - group_cfg.id, session.user_id, sk_x_raw, pk_x_raw) - if gek: - log.info("GEK loaded for group %s", group_cfg.id[:8]) - else: - log.info("No GEK yet for group %s — will accept first setup", - group_cfg.name) + gek = await self._load_gek( + group_cfg.id, session.user_id, sk_x_raw, pk_x_raw) + if gek: + log.info("GEK loaded for group %s", group_cfg.id[:8]) + else: + log.info("No GEK yet for group %s — will accept first setup", + group_cfg.name) indexer = DirectoryIndexer( roots=roots, @@ -399,7 +399,7 @@ class NodeDaemon: on_incoming=on_incoming, on_revocation=on_revocation, on_webrtc_offer=on_webrtc_offer, - group_ids=list(groups_ctx.keys()), + group_ids=lambda: list((self._state.get("groups_ctx") or {}).keys()), )) self._tasks.append(ws_task) log.info("Hub WS task started") @@ -470,7 +470,15 @@ class NodeDaemon: Handles root changes on existing groups, hot-loads new groups, and tears down removed groups. Existing connections are untouched: a member watching a film keeps watching it. + + Serialised by _reload_lock: fire-and-forget reloads from config-mutating + endpoints can overlap with the wizard's explicit /api/reload call, + and two concurrent hot-loads of the same group corrupt the runtime state. """ + async with self._reload_lock: + await self._reload_config_inner() + + async def _reload_config_inner(self) -> None: log.info("Reloading config from %s", self._config_path) try: fresh = load_config(self._config_path) @@ -537,7 +545,7 @@ class NodeDaemon: roots.refresh_availability() gek = None - if group_cfg.visibility == "private" and sk_x_raw and pk_x_raw: + if sk_x_raw and pk_x_raw: gek = await self._load_gek( group_cfg.id, node_user_id, sk_x_raw, pk_x_raw) if gek: @@ -616,6 +624,10 @@ class NodeDaemon: log.info("Reload complete — %d re-rooted, %d added, %d removed", changed, len(added_names), len(removed_names)) + if (added_names or removed_names) and self._hub: + gids = list((self._state.get("groups_ctx") or {}).keys()) + await self._hub.update_ws_groups(gids) + async def _login_with_retry(self, hub: HubClient): """Login to hub, retrying if the node key hasn't been linked yet.""" import httpx as _httpx diff --git a/packages/meshbay-node/src/meshbay_node/hub_client.py b/packages/meshbay-node/src/meshbay_node/hub_client.py index b345187..92c39db 100644 --- a/packages/meshbay-node/src/meshbay_node/hub_client.py +++ b/packages/meshbay-node/src/meshbay_node/hub_client.py @@ -229,12 +229,24 @@ class HubClient: except Exception: pass + async def update_ws_groups(self, group_ids: list[str]) -> None: + """Tell the hub about changed group list without dropping the connection.""" + ws = self._ws + if ws: + try: + await ws.send(json.dumps({ + "type": "update_groups", + "group_ids": group_ids, + })) + except Exception: + pass + async def maintain_ws( self, on_incoming: Any = None, on_revocation: Any = None, on_webrtc_offer: Any = None, - group_ids: list[str] | None = None, + group_ids: list[str] | None = None, # static list or callable returning one ) -> None: """ Maintain a persistent WebSocket connection to the hub. @@ -267,8 +279,9 @@ class HubClient: "token": self._session.access_token, "node_id": self._session.node_id, } - if group_ids: - auth_msg["group_ids"] = group_ids + gids = group_ids() if callable(group_ids) else group_ids + if gids: + auth_msg["group_ids"] = gids await ws.send(json.dumps(auth_msg)) # Bounded: a hub that accepts the socket and then says nothing # — which is what it does for a few seconds while restarting — diff --git a/packages/meshbay-node/src/meshbay_node/ops.py b/packages/meshbay-node/src/meshbay_node/ops.py index 2a76290..daddf17 100644 --- a/packages/meshbay-node/src/meshbay_node/ops.py +++ b/packages/meshbay-node/src/meshbay_node/ops.py @@ -157,38 +157,44 @@ async def pair_operator(state: dict) -> dict: return {"code": code, "expires_at": expires, "user_id": user_id} -async def create_invite(state: dict, group_id: str, username: str) -> dict: +async def create_invite(state: dict, group_id: str, username: str, *, + user_id: str = "", + created_by: str = "local-cli") -> dict: """ Issue an invitation code. The hub is asked for the account id and nothing else — never for a key. A hub that answered with the wrong account would produce an invite whose code it never learns, since the code goes to a human out of band. + + When ``user_id`` is supplied directly (MNP path), the hub lookup is skipped. """ roster = _roster(state) _group_ctx(state, group_id) - hub = _hub(state) - try: - account = await hub.get_user_pubkeys(username) - except Exception as e: - raise OpError(f"Unknown user {username!r}: {e}", status=404) from e + if not user_id: + hub = _hub(state) + try: + account = await hub.get_user_pubkeys(username) + except Exception as e: + raise OpError(f"Unknown user {username!r}: {e}", status=404) from e + user_id = account["user_id"] config = state.get("config") ttl = (config.node.invite_ttl_hours if config else 168) * 3600 code = await roster.create_invite( group_id=group_id, - user_id=account["user_id"], + user_id=user_id, role=ROLE_MEMBER, - created_by="local-cli", + created_by=created_by, ttl=ttl, username=username, ) invites = await roster.list_invites() expires = next((i["expires_at"] for i in invites - if i["user_id"] == account["user_id"] + if i["user_id"] == user_id and i["group_id"] == group_id), "") return {"code": code, "expires_at": expires, - "username": username, "user_id": account["user_id"]} + "username": username, "user_id": user_id} async def revoke_member(state: dict, user_id: str, group_id: str) -> dict: @@ -236,6 +242,10 @@ async def set_gek(state: dict, group_id: str, *, rotate: bool = False) -> dict: ctx = _group_ctx(state, group_id) hub = _hub(state) + if rotate and ctx.get("visibility") == "public": + raise OpError( + "Key rotation is not available for public groups", status=400) + bundle_store = state.get("bundle_store") if not bundle_store: raise OpError("Bundle store not available", status=503) @@ -361,20 +371,25 @@ async def attach_group(state: dict, name: str, shared_dir: str, # Appended as text rather than re-serialised: node.toml is hand-written and # full of comments explaining decisions, and a round trip through a TOML # writer would throw all of that away. + join_policy = group.get("join_policy", "invite") block = (f'\n[[groups]]\n' f'id = "{group["id"]}"\n' f'name = "{group["name"]}"\n' - f'visibility = "{group.get("visibility", "private")}"\n') + f'visibility = "{group.get("visibility", "private")}"\n' + f'join_policy = "{join_policy}"\n') + separate_upload = False if upload_dir: - upload_path = Path(upload_dir).expanduser() - try: - upload_path.mkdir(parents=True, exist_ok=True) - except OSError as e: - raise OpError(f"Cannot create {upload_path}: {e}") from e - block += f'upload_dir = "{upload_path}"\n' + upload_path = Path(upload_dir).expanduser().resolve() + if upload_path != path.resolve(): + separate_upload = True + try: + upload_path.mkdir(parents=True, exist_ok=True) + except OSError as e: + raise OpError(f"Cannot create {upload_path}: {e}") from e + block += f'upload_dir = "{upload_path}"\n' block += (f'\n [[groups.roots]]\n' f' path = "{path}"\n') - if not upload_dir: + if not separate_upload: block += f' upload = true\n' try: with conf_path.open("a") as f: @@ -385,7 +400,7 @@ async def attach_group(state: dict, name: str, shared_dir: str, result = {"group_id": group["id"], "name": group["name"], "shared_dir": str(path), "config": str(conf_path), "note": "restart the node to pick it up"} - if upload_dir: + if separate_upload: result["upload_dir"] = str(upload_path) return result @@ -552,9 +567,6 @@ async def add_root(state: dict, group_id: str, path: str, *, cfg.roots.append(RootSpec( path=str(added.path), name=added.name, kind=added.kind, upload=added.upload, direct=added.direct)) - groups_ctx = state.get("groups_ctx", {}) - if group_id in groups_ctx: - groups_ctx[group_id]["roots"] = built log.info("Root added: %s → group %s", added.name, group_id[:8]) return {"status": "added", "name": added.name, "path": str(added.path), @@ -602,15 +614,10 @@ async def remove_root(state: dict, group_id: str, root_name: str) -> dict: built = RootSet.build(remaining) except RootError: built = None - if built is not None: - groups_ctx = state.get("groups_ctx", {}) - if group_id in groups_ctx: - groups_ctx[group_id]["roots"] = built log.info("Root removed: %s from group %s", root_name, group_id[:8]) return {"status": "removed", "name": root_name, "group_id": group_id, - "roots": built.describe() if built else [], - "note": "restart recommended to update the file index"} + "roots": built.describe() if built else []} # ── Files ──────────────────────────────────────────────────────────────────── @@ -682,3 +689,34 @@ async def clear_denylist(state: dict, *, subject: str = "") -> dict: log.warning("Denylist cleared (%s): %d entr(y/ies) removed", subject or "all", removed) return {"status": "cleared", "removed": removed, "subject": subject or "all"} + + +# ── Upload policy ─────────────────────────────────────────────────────────── + +async def set_member_upload(state: dict, group_id: str, allowed: bool) -> dict: + """ + Turn uploading by ordinary members on or off. + + The setting lives on the node (roster.db), not on the hub and not in + node.toml — changing it must not rewrite the operator's config file, + and must not need a restart. + """ + roster = _roster(state) + ctx = _group_ctx(state, group_id) + await roster.set_member_upload(group_id, allowed, + set_by=state.get("node_user_id", "")) + ctx["member_upload"] = allowed + log.info("Upload policy: %s for group %s", "on" if allowed else "off", + group_id[:8]) + return {"allowed": allowed, "group_id": group_id} + + +# ── Reload ────────────────────────────────────────────────────────────────── + +async def reload_config(state: dict) -> dict: + """Hot-reload node.toml without dropping connections.""" + reload_fn = state.get("reload_fn") + if not reload_fn: + raise OpError("Reload not available", status=503) + await reload_fn() + return {"status": "reloaded"} diff --git a/packages/meshbay-node/src/meshbay_node/roots.py b/packages/meshbay-node/src/meshbay_node/roots.py index bc27bf7..74ea2f6 100644 --- a/packages/meshbay-node/src/meshbay_node/roots.py +++ b/packages/meshbay-node/src/meshbay_node/roots.py @@ -29,6 +29,7 @@ are one directory. from __future__ import annotations import logging +import re from dataclasses import dataclass, field from pathlib import Path @@ -38,6 +39,75 @@ log = logging.getLogger(__name__) VALID_KINDS = ("generic", "video", "audio", "photo") +# Filenames and subdirectory names sent by clients. A leading dot is a hidden +# file on every platform, a leading hyphen confuses CLI tools, a leading space +# cannot start one, and a trailing space or dot is refused because it makes two +# different files look identical in a list. +SAFE_UPLOAD_NAME = re.compile( + r"^[^\W_]" # letter or digit — never ‘.’, ‘-’ or space + r"[\w .\-()\[\]'\u2019,&+#@]{0,127}" # body: word chars plus mild punctuation + r"(?<![ .])$", # and never ending on a space or a dot + re.UNICODE) + + +def _free_name(directory: Path, filename: str) -> str: + """ + `filename`, or the first "name (n).ext" that is not taken. + + Never returns the name of a file that exists, so an upload cannot replace + one — the property the per-user quarantine used to provide (C5a). + """ + if not (directory / filename).exists(): + return filename + stem, dot, ext = filename.rpartition(".") + if not dot: + stem, ext = filename, "" + for n in range(2, 1000): + candidate = f"{stem} ({n}){dot}{ext}" + if not (directory / candidate).exists(): + return candidate + raise FileExistsError(filename) + + +def safe_subdir(roots: "RootSet", rel: str) -> Path | None: + """ + Resolve a client-supplied directory inside one of the group's roots, or refuse. + + The path arrives from the wire, so every part is checked: the first segment + must name a root that is readable right now, each later segment against the + same allowlist as filenames, and the resolved result against that root's + directory. `..`, absolute paths, symlinks pointing out, and anything with a + separator in a segment are all refused here rather than in the caller, so + there is one place to get it right. + + The virtual root itself — `""` — is deliberately **not** resolvable. It is + not a directory on anyone's disk: a file cannot be written there and a + directory cannot be created there, because it belongs to no volume. Callers + that used to receive the shared root for an empty path now receive None, + which is the honest answer. + + The quarantine was the fix for C5a; what actually mattered in it — no + overwrite, a name allowlist, and confinement — is kept by this plus the + caller's existing checks. + """ + found = roots.split(rel or "") + if found is None: + return None + root, tail = found + if not root.available: + return None + parts = [seg for seg in tail.split("/") if seg not in ("", ".")] + if any(seg == ".." or not SAFE_UPLOAD_NAME.match(seg) for seg in parts): + return None + try: + target = (root.path / Path(*parts)).resolve() if parts else root.path.resolve() + base = root.path.resolve() + except OSError: + return None + if target != base and base not in target.parents: + return None + return target + class RootError(ValueError): """A root set that cannot be built. The message is shown to the operator.""" 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 1f1f2d2..947d1f9 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py @@ -28,7 +28,6 @@ import hashlib import hmac import logging import os -import re import struct import time from pathlib import Path @@ -86,8 +85,9 @@ from meshbay_common.webcrypto import chunk_key_aes, encrypt_chunk_aes from meshbay_common.protocol import MNP from meshbay_node.indexer import GroupIndex from meshbay_node import ops -from meshbay_node.roots import RootSet, entry_abs_path -from meshbay_node.roster import DEFAULT_INVITE_TTL +from meshbay_node.roots import ( + RootSet, entry_abs_path, SAFE_UPLOAD_NAME, safe_subdir, _free_name, +) log = logging.getLogger(__name__) @@ -135,81 +135,6 @@ JOIN_FAILURE_WINDOW = 600 # seconds # into, back up or empty — rather than a hidden tree of per-user uuids that # nobody could read, or files scattered wherever someone happened to be looking. UPLOAD_DIR_NAME = "uploads" -# Conservative allowlist: also what keeps markup out of filenames, which the node admin -# UI used to render unescaped (finding H2). -# An allowlist, still — C5a and H2 depend on it — but one that does not assume -# the world writes in ASCII. `été.txt` and `rapport (1).pdf` were refused, and -# the second of those is a name _free_name generates itself, so the node was -# rejecting files it had named. `\w` is Unicode here, which admits letters and -# digits of any script while `<`, `>`, `"`, `;`, `/`, `\` and control characters -# stay out. The first character must be a letter or digit, so ".." and dotfiles -# cannot start one, and a trailing space or dot is refused because it makes two -# different files look identical in a list. -SAFE_UPLOAD_NAME = re.compile( - r"^[^\W_]" # letter or digit — never '.', '-' or space - r"[\w .\-()\[\]'\u2019,&+#@]{0,127}" # body: word chars plus mild punctuation - r"(?<![ .])$", # and never ending on a space or a dot - re.UNICODE) - - -def _free_name(directory: Path, filename: str) -> str: - """ - `filename`, or the first "name (n).ext" that is not taken. - - Never returns the name of a file that exists, so an upload cannot replace - one — the property the per-user quarantine used to provide (C5a). - """ - if not (directory / filename).exists(): - return filename - stem, dot, ext = filename.rpartition(".") - if not dot: - stem, ext = filename, "" - for n in range(2, 1000): - candidate = f"{stem} ({n}){dot}{ext}" - if not (directory / candidate).exists(): - return candidate - raise FileExistsError(filename) - - -def safe_subdir(roots: RootSet, rel: str) -> Path | None: - """ - Resolve a client-supplied directory inside one of the group's roots, or refuse. - - The path arrives from the wire, so every part is checked: the first segment - must name a root that is readable right now, each later segment against the - same allowlist as filenames, and the resolved result against that root's - directory. `..`, absolute paths, symlinks pointing out, and anything with a - separator in a segment are all refused here rather than in the caller, so - there is one place to get it right. - - The virtual root itself — `""` — is deliberately **not** resolvable. It is - not a directory on anyone's disk: a file cannot be written there and a - directory cannot be created there, because it belongs to no volume. Callers - that used to receive the shared root for an empty path now receive None, - which is the honest answer. - - The quarantine was the fix for C5a; what actually mattered in it — no - overwrite, a name allowlist, and confinement — is kept by this plus the - caller's existing checks. - """ - found = roots.split(rel or "") - if found is None: - return None - root, tail = found - if not root.available: - return None - parts = [seg for seg in tail.split("/") if seg not in ("", ".")] - if any(seg == ".." or not SAFE_UPLOAD_NAME.match(seg) for seg in parts): - return None - try: - target = (root.path / Path(*parts)).resolve() if parts else root.path.resolve() - base = root.path.resolve() - except OSError: - return None - if target != base and base not in target.parents: - return None - return target - def _extract_dtls_fingerprint(sdp: str) -> bytes: @@ -985,6 +910,12 @@ class WebRTCPeerSession: # the client is told it has no role on a node it administers. member = (await roster.get_member(group_id, user_id) or await roster.get_member("", user_id)) + if not member and self._group_join_policy(session_group) == "open": + await roster.set_member( + group_id=session_group, user_id=user_id, role=ROLE_MEMBER, + status="active", approved_by="open-join", + ) + member = await roster.get_member(session_group, user_id) await self._join_ok( user_id, pk_x_raw, session_group, role=member["role"] if member else "", @@ -1635,16 +1566,12 @@ class WebRTCPeerSession: self._send({"type": "error", "detail": "Signature verification failed"}) self._audit("admin_auth_failed", f"member_upload:{pending['subject']}") return - roster = self._ctx.get("roster") - if roster is None: - self._send({"type": "error", "detail": "No roster on this node"}) + try: + await self._run_op( + ops.set_member_upload, self._group_id or "", allowed) + except ops.OpError as e: + self._send({"type": "error", "detail": e.message}) return - await roster.set_member_upload(self._group_id or "", allowed, - set_by=self._user_id) - # Stored *and* applied. The upload path is synchronous and reads this - # dict; leaving it to the next restart would make the panel say one - # thing while the node did another. - self._group_ctx()["member_upload"] = allowed self._audit("member_upload", pending["subject"]) # Everyone already connected is told, rather than finding out by having @@ -1928,14 +1855,11 @@ class WebRTCPeerSession: self._audit("admin_auth_failed", f"member_revoke:{user_id[:8]}") return - roster = self._ctx.get("roster") - if roster is None: - self._send({"type": "error", "detail": "Roster not available"}) - return - - group_id = self._group_id or "" - if not await roster.set_status(group_id, user_id, "revoked"): - self._send({"type": "error", "detail": "Not a member of this group"}) + try: + result = await self._run_op( + ops.revoke_member, user_id, self._group_id or "") + except ops.OpError as e: + self._send({"type": "error", "detail": e.message}) return # Anyone connected right now keeps the key they already unwrapped; what @@ -1948,14 +1872,11 @@ class WebRTCPeerSession: except Exception: pass - log.info("Member revoked by %s: user=%s group=%s", - self._user_id[:8], user_id[:8], group_id[:8] or "-") self._audit("member_revoke", user_id) self._send({ "type": MNP.MEMBER_REVOKE_ACK, "v": MNP_VERSION, "user_id": user_id, - "reminder": "they still hold the current group key — rotate it with " - "meshbay-node gek-init", + "reminder": result.get("reminder", ""), }) async def _do_keypair_bundle_delete(self) -> None: @@ -2694,37 +2615,27 @@ class WebRTCPeerSession: self._audit("admin_auth_failed", f"invite_create:{pending['subject'][:16]}") return - roster = self._ctx.get("roster") - if roster is None: - self._send({"type": "error", "detail": "Roster not available"}) - return - payload = pending["payload"] - code = await roster.create_invite( - group_id=payload["group_id"], - user_id=payload["user_id"], - role=ROLE_MEMBER, - created_by=self._user_id or "", - ttl=self._ctx.get("invite_ttl", DEFAULT_INVITE_TTL), - username=payload.get("username", ""), - ) - invites = await roster.list_invites() - expires = next( - (i["expires_at"] for i in invites - if i["user_id"] == payload["user_id"] - and i["group_id"] == payload["group_id"]), "") + try: + result = await self._run_op( + ops.create_invite, + payload["group_id"], + payload.get("username", ""), + user_id=payload["user_id"], + created_by=self._user_id or "", + ) + except ops.OpError as e: + self._send({"type": "error", "detail": e.message}) + return - log.info("Invite created: group=%s user=%s", - payload["group_id"][:8], payload["user_id"][:8]) self._audit("invite_create", f"target={payload['user_id'][:8]}") - # The code exists in the clear exactly here and in the operator's hands. self._send({ "type": MNP.INVITE_RESULT, "v": MNP_VERSION, - "code": code, - "expires_at": expires, - "user_id": payload["user_id"], - "username": payload.get("username", ""), + "code": result["code"], + "expires_at": result["expires_at"], + "user_id": result["user_id"], + "username": result.get("username", ""), }) def _exec_file_delete(self, ctx: dict, file_id: str, entry) -> None: diff --git a/packages/meshbay-node/src/meshbay_node/ui/app.py b/packages/meshbay-node/src/meshbay_node/ui/app.py index a068a8d..b505f25 100644 --- a/packages/meshbay-node/src/meshbay_node/ui/app.py +++ b/packages/meshbay-node/src/meshbay_node/ui/app.py @@ -295,6 +295,44 @@ def create_ui_app(state: dict) -> FastAPI: async def init_gek(group_id: str, rotate: bool = False): return await _op(lambda: ops.set_gek(state, group_id, rotate=rotate)) + # ── Roots management (operator only, localhost) ──────────────────────── + + @app.post("/api/groups/{group_id}/roots") + async def add_root(group_id: str, payload: dict): + result = await _op(lambda: ops.add_root( + state, group_id, + (payload.get("path") or "").strip(), + name=(payload.get("name") or "").strip(), + kind=(payload.get("kind") or "generic").strip(), + upload=bool(payload.get("upload", False)), + )) + reload_fn = state.get("reload_fn") + if reload_fn: + asyncio.ensure_future(reload_fn()) + return result + + @app.delete("/api/groups/{group_id}/roots/{root_name}") + async def remove_root(group_id: str, root_name: str): + result = await _op(lambda: ops.remove_root(state, group_id, root_name)) + reload_fn = state.get("reload_fn") + if reload_fn: + asyncio.ensure_future(reload_fn()) + return result + + # ── Upload toggle (operator only, localhost) ───────────────────────── + + @app.put("/api/groups/{group_id}/member-upload") + async def set_member_upload(group_id: str, payload: dict): + return await _op(lambda: ops.set_member_upload( + state, group_id, bool(payload.get("allowed", False)), + )) + + # ── Reload config ──────────────────────────────────────────────────── + + @app.post("/api/reload") + async def reload_config(): + return await _op(lambda: ops.reload_config(state)) + # ── Chat endpoints ─────────────────────────────────────────────────────── _chat_subscribers: list[WebSocket] = [] diff --git a/packages/meshbay-node/tests/test_ops.py b/packages/meshbay-node/tests/test_ops.py index f7fd259..d2ccc0d 100644 --- a/packages/meshbay-node/tests/test_ops.py +++ b/packages/meshbay-node/tests/test_ops.py @@ -63,7 +63,9 @@ def test_the_http_adapter_adds_no_logic(): source = inspect.getsource(ui) # Every endpoint that performs an operation routes through _op(...). for endpoint in ("operator_pair", "create_invite", "revoke_member", - "unpin_member", "init_gek", "attach_group", "delete_file"): + "unpin_member", "init_gek", "attach_group", "delete_file", + "add_root", "remove_root", "set_member_upload", + "reload_config"): start = source.index(f"async def {endpoint}(") body = source[start:start + 700] assert "_op(" in body.split("\n\n")[0] + body, ( @@ -177,3 +179,45 @@ async def test_an_unhosted_group_offers_what_it_does_host(tmp_path): await ops.delete_file(state, "z" * 32, "a" * 64) assert exc.value.status == 404 assert exc.value.extra.get("available") + + +# ── Upload policy (set_member_upload) ─────────────────────────────────────── + +async def test_set_member_upload_toggles_and_persists(tmp_path): + from meshbay_node.roster import Roster + state = _state(tmp_path) + roster = Roster(db_path=tmp_path / "roster.db") + await roster.open() + state["roster"] = roster + state["node_user_id"] = "operator" + + out = await ops.set_member_upload(state, "g" * 32, True) + + assert out["allowed"] is True + assert state["groups_ctx"]["g" * 32]["member_upload"] is True + + out2 = await ops.set_member_upload(state, "g" * 32, False) + + assert out2["allowed"] is False + assert state["groups_ctx"]["g" * 32]["member_upload"] is False + + +# ── Reload ────────────────────────────────────────────────────────────────── + +async def test_reload_config_calls_reload_fn(tmp_path): + state = _state(tmp_path) + called = [] + async def fake_reload(): + called.append(True) + state["reload_fn"] = fake_reload + + out = await ops.reload_config(state) + + assert out["status"] == "reloaded" + assert called + + +async def test_reload_config_without_fn_is_refused(tmp_path): + state = _state(tmp_path) + with pytest.raises(ops.OpError, match="Reload not available"): + await ops.reload_config(state) diff --git a/packages/meshbay-node/tests/test_roots.py b/packages/meshbay-node/tests/test_roots.py index ea4ba6a..fc5bd64 100644 --- a/packages/meshbay-node/tests/test_roots.py +++ b/packages/meshbay-node/tests/test_roots.py @@ -11,7 +11,10 @@ from pathlib import Path import pytest -from meshbay_node.roots import Root, RootError, RootSet, entry_abs_path +from meshbay_node.roots import ( + Root, RootError, RootSet, entry_abs_path, + SAFE_UPLOAD_NAME, safe_subdir, _free_name, +) from meshbay_common.protocol import IndexEntry @@ -240,3 +243,65 @@ def test_describe_reports_what_a_member_needs(tmp_path): # Deliberately no paths: a member is told what exists and whether it is # readable, not where on the operator's disk it lives. assert not any("path" in d for d in described) + + +# ── SAFE_UPLOAD_NAME ──────────────────────────────────────────────────────── + +def test_safe_name_accepts_unicode_letters(): + assert SAFE_UPLOAD_NAME.match("rapport (1).pdf") + assert SAFE_UPLOAD_NAME.match("hello.txt") + + +def test_safe_name_rejects_dotfiles(): + assert not SAFE_UPLOAD_NAME.match(".hidden") + assert not SAFE_UPLOAD_NAME.match("..secret") + + +def test_safe_name_rejects_trailing_dot_or_space(): + assert not SAFE_UPLOAD_NAME.match("file.") + assert not SAFE_UPLOAD_NAME.match("file ") + + +# ── _free_name ────────────────────────────────────────────────────────────── + +def test_free_name_returns_original_when_not_taken(tmp_path): + assert _free_name(tmp_path, "photo.jpg") == "photo.jpg" + + +def test_free_name_appends_counter_on_collision(tmp_path): + (tmp_path / "photo.jpg").write_text("x") + assert _free_name(tmp_path, "photo.jpg") == "photo (2).jpg" + + +def test_free_name_increments_past_multiple_collisions(tmp_path): + (tmp_path / "photo.jpg").write_text("x") + (tmp_path / "photo (2).jpg").write_text("x") + assert _free_name(tmp_path, "photo.jpg") == "photo (3).jpg" + + +# ── safe_subdir ───────────────────────────────────────────────────────────── + +def test_safe_subdir_resolves_valid_path(tmp_path): + (tmp_path / "Films").mkdir() + (tmp_path / "Films" / "2024").mkdir() + roots = RootSet.build([_spec(tmp_path / "Films")]) + assert safe_subdir(roots, "Films/2024") == (tmp_path / "Films" / "2024").resolve() + + +def test_safe_subdir_refuses_traversal(tmp_path): + (tmp_path / "Films").mkdir() + roots = RootSet.build([_spec(tmp_path / "Films")]) + assert safe_subdir(roots, "Films/../../etc") is None + + +def test_safe_subdir_refuses_empty_virtual_root(tmp_path): + (tmp_path / "Films").mkdir() + roots = RootSet.build([_spec(tmp_path / "Films")]) + assert safe_subdir(roots, "") is None + + +def test_safe_subdir_refuses_unavailable_root(tmp_path): + (tmp_path / "Films").mkdir() + roots = RootSet.build([_spec(tmp_path / "Films")]) + roots.roots[0].available = False + assert safe_subdir(roots, "Films/2024") is None diff --git a/packages/meshbay-node/tests/test_roster_pairing.py b/packages/meshbay-node/tests/test_roster_pairing.py index 61d53ac..7eb436c 100644 --- a/packages/meshbay-node/tests/test_roster_pairing.py +++ b/packages/meshbay-node/tests/test_roster_pairing.py @@ -91,6 +91,10 @@ def _session(tmp_path: Path, roster, user_id: str = "grenet", session.sent = [] session._send = session.sent.append session._audit = lambda *a, **k: None + session._ctx["daemon_state"] = { + "roster": roster, + "groups_ctx": session._ctx.get("groups", {}), + } return session diff --git a/packages/meshbay-node/tests/test_webrtc_transport.py b/packages/meshbay-node/tests/test_webrtc_transport.py index 5727ef9..ec6e987 100644 --- a/packages/meshbay-node/tests/test_webrtc_transport.py +++ b/packages/meshbay-node/tests/test_webrtc_transport.py @@ -1134,6 +1134,10 @@ async def test_invite_then_join_delivers_the_gek(sk_node, sk_hub, gek, shared_di transport._ctx["groups"] = { TEST_GROUP: {"gek": gek, "roots": shared_dir, "index": indexer.index}, } + transport._ctx["daemon_state"] = { + "roster": roster, + "groups_ctx": transport._ctx["groups"], + } # A paired operator, as `meshbay-node operator pair` would have left it. sk_admin = Ed25519PrivateKey.generate() |