diff options
Diffstat (limited to 'packages')
39 files changed, 1769 insertions, 588 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js b/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js index 3bc110f..4714674 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js @@ -198,8 +198,13 @@ function ChatImage({ filename, entries, transportRef, gekRef }) { return html`<img class="chat-att-thumb" src=${blobUrl} alt=${filename} />`; } +// `attachRoot` is the shared directory attachments are written to: the name of +// the first writable, available root, decided in group-page.js so Files and Chat +// read one answer. Empty means the group has no writable root right now — every +// root is read-only, or the one drive that was writable is unplugged — and the +// paperclip says so rather than producing a refusal from the node. function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, - onPreview, mayUpload = true, onActivity, status }) { + onPreview, attachRoot = '', onActivity, status }) { const [messages, setMessages] = useState([]); const [hasMore, setHasMore] = useState(false); const [loadingOlder, setLoadingOlder] = useState(false); @@ -479,7 +484,7 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, try { // Two people sending IMG_1234.jpg both succeed; the node picks a free name // and the message has to point at the one it chose. - const ack = await transport.uploadFile(file); + const ack = await transport.uploadFile(file, { root: attachRoot }); const storedAs = (ack && ack.stored_as) || file.name; await new Promise(r => setTimeout(r, 2500)); if (onRefreshIndex) await onRefreshIndex(); @@ -501,7 +506,7 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, } finally { setAttaching(false); } - }, [username, onRefreshIndex, jumpToBottom]); + }, [username, onRefreshIndex, jumpToBottom, attachRoot]); const onKeyDown = useCallback((e) => { if (e.key === 'Enter' && !e.shiftKey) { @@ -592,12 +597,16 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, </button> `} <div class="chat-input-row"> - ${mayUpload && html` + ${attachRoot ? html` <label class="chat-attach" title="${t('chat.attach')}"> ${attaching ? html`<span class="spinner"></span>` : html`<${Icon} name="clip" />`} <input type="file" style="display:none" onChange=${attachFile} disabled=${attaching} /> </label> + ` : html` + <span class="chat-attach chat-attach-off" title="${t('chat.attach_read_only')}"> + <${Icon} name="clip" /> + </span> `} <textarea class="chat-input" rows="1" ref=${inputRef} placeholder="${t('chat.placeholder')}" diff --git a/packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js index a5676ba..c3b542f 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js @@ -212,8 +212,16 @@ function CreateGroupWizard({ token, username, onCreated, allowPublicGroups = tru // 2. Attach to node with first root update('running'); + // The first root is attached with the group, so its RW switch has to + // travel with it — writing it and then correcting it afterwards would + // leave a window where a group the operator marked read-only accepts + // uploads. const mainRoot = roots[0]; - const attachBody = { name: name.trim(), shared_dir: mainRoot.path }; + const attachBody = { + name: name.trim(), + shared_dir: mainRoot.path, + writable: mainRoot.writable !== false, + }; await platform.node.call('POST', '/api/groups/attach', attachBody); await platform.node.call('POST', '/api/reload'); update('done'); diff --git a/packages/meshbay-hub/src/meshbay_hub/static/files-app.js b/packages/meshbay-hub/src/meshbay_hub/static/files-app.js index 56311ae..6825c8d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/files-app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/files-app.js @@ -26,7 +26,7 @@ import { function FilesPanel({ groupId, transportRef, gekRef, status, entries, nodeDirs, nodeRoots, setEntries, setNodeDirs, setNodeRoots, applyIndex, - isNodeAdmin, operatorPaired, mayUpload, userId, setError, onPreview, + isNodeAdmin, operatorPaired, userId, setError, onPreview, showGroup, readOnly, getTransport, onRefreshIndex, showRefresh, }) { const [selected, setSelected] = useState(() => new Set()); @@ -75,6 +75,12 @@ function FilesPanel({ e.target.value = ''; const transport = transportRef.current; if (!files.length || !transport || !transport.connected) return; + // The root being browsed is the destination. A group can have several + // writable roots, so leaving the node to pick one means a file uploaded + // from a folder the operator is looking at lands in a different one — + // which is only noticed much later, if at all. + const uploadRoot = currentPath ? currentPath.split('/')[0] : ''; + if (!uploadRoot) return; setError(''); for (const file of files) { @@ -85,6 +91,7 @@ function FilesPanel({ // Bytes the node acknowledged, not bytes read locally. onProgress: (sent) => onProgress(sent, file.size), signal, + root: uploadRoot, }); // The node re-indexes on a filesystem event, so there is nothing to // wait on but the clock. Refreshing here means the file appears in @@ -94,7 +101,7 @@ function FilesPanel({ }, }); } - }, [applyIndex]); + }, [applyIndex, currentPath]); const makeDirectory = useCallback(async () => { const transport = transportRef.current; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js index 1b2661c..dfde172 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js @@ -88,8 +88,10 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, const [nodeRoots, setNodeRoots] = useState([]); const [isNodeAdmin, setIsNodeAdmin] = useState(false); - // DEPRECATED: memberUpload is now derived from per-root writable flags. - // Kept as state only for backward compat with nodes that still send it. + // Legacy: the group-wide upload switch a node speaking MNP 1.0 sends on its + // handshake ack. Per-root `writable` replaced it, and this is read only when + // the roots carry no flags at all — see `attachRoot` below. Defaults to true + // so such a node behaves as it always did. const [memberUpload, setMemberUpload] = useState(true); // Which applications this group has enabled, from the node. Falls back to // every registered app when a node predates the setting (or hasn't answered @@ -539,11 +541,26 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, } }, [groupId, token, descDraft, onGroupUpdated]); - // Asked in two places — the Files toolbar and the chat composer — so it is - // answered once. Per-root writable flags replace the old binary toggle; - // falls back to the legacy memberUpload for old nodes. - const hasWritableRoot = nodeRoots.some((r) => r.writable); - const mayUpload = hasWritableRoot || memberUpload || isNodeAdmin; + // Where an attachment goes, answered once for the whole page. + // + // Files does not use this — it uploads into the root being browsed, which is + // the only unambiguous answer once a group can have several writable roots. + // Chat has no folder to browse, so it needs one picked for it, and this is + // the same rule the node applies when a client names no root at all. It + // becomes an operator-chosen directory in phase 2 (refactor-groups.md §1.7). + // + // `memberUpload` is the fallback for a node still speaking MNP 1.0, whose + // roots carry no `writable` at all: there, the single upload root is the one + // the node marked, and the ack's computed flag is all we get. + const writableRoots = useMemo( + () => nodeRoots.filter((r) => r.writable && r.available !== false), + [nodeRoots]); + const legacyNode = nodeRoots.length > 0 + && nodeRoots.every((r) => r.writable === undefined); + const attachRoot = writableRoots.length ? writableRoots[0].name + : (legacyNode && memberUpload + ? (nodeRoots.find((r) => r.upload) || nodeRoots[0]).name + : ''); // A single dispatcher so any app can open the right modal without owning // video/preview state itself — Files' table and Chat's attachments both @@ -587,7 +604,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, groupId, transportRef, gekRef, status, username, entries, availableEntries, nodeDirs, nodeRoots, setEntries, setNodeDirs, setNodeRoots, applyIndex, - isNodeAdmin, operatorPaired, mayUpload, userId, setError, onPreview, + isNodeAdmin, operatorPaired, attachRoot, userId, setError, onPreview, onRefreshIndex: refreshIndex, onActivity: touchActivity, videoRoot, onVideoRoot: (path) => setVideoRoot(path), audioRoot, onAudioRoot: (path) => setAudioRoot(path), @@ -717,6 +734,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, transportRef=${transportRef} gekRef=${gekRef} isNodeAdmin=${isNodeAdmin} userId=${userId} operatorPaired=${operatorPaired} connected=${status === 'connected'} + mnpRoots=${nodeRoots} enabledApps=${enabledApps} onEnabledApps=${(keys) => setEnabledApps(keys)} scanSettings=${scanSettings} diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js index de6f8c0..eeed786 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js @@ -177,42 +177,104 @@ function PhotoRootsRow({ folders, value, busy, msg, onSave }) { // ── Shared Directories Table ──────────────────────────────────────────── /** - * Reusable table of a group's root directories with per-root controls. + * A group's root directories, and the operator's controls over them. * - * Used in both the Settings page (with full edit controls) and the Create - * Group wizard (with add-only). Each root shows its name, a writable - * toggle, a removable badge, and eject/plug buttons for removable roots. + * One component, two modes, because the Create Group wizard and the Settings + * page were drifting apart while showing the same thing: + * + * mode="live" — a hosted group. Every change is a signed operator op sent + * over MNP, or the loopback API when the node is on this + * machine and there is no live connection. + * mode="local" — the wizard, before the group exists. Changes are held in + * an array the caller owns; nothing is persisted until the + * group is attached. + * + * **Both paths matter and neither is optional.** The operator of a node is not + * necessarily sitting at it: they may be signing in from any browser, and the + * only thing that reaches their node from there is MNP. An earlier version of + * this read its roots exclusively from the loopback API, which resolves to + * "not available" in a browser — so the section rendered for nobody on the + * web, while the controls it replaced had worked there. `mnpRoots` is the + * source whenever a connection exists; the loopback list is the fallback for + * a local node that is not currently connected (a group still scanning, say). * * Props: - * roots — array of { name, writable, removable, ejected, available, kind } - * groupId — the group id - * transport — MeshBayTransport instance (null when not connected) - * signFn — signing function for admin ops - * platform — platform bridge (for Electron root picker) - * nodeDetected — whether local node API is available - * readOnly — suppress edit controls (default false) - * onRootsChange — callback(roots) after a change - * onRefreshIndex — trigger a full index refresh after add/remove - */ -/** - * Two modes: - * mode="live" — connected to a node, persists changes via MNP/loopback API - * mode="local" — during group creation, manages a local array, reports changes - * via onLocalRootsChange(roots) + * roots — the node's current roots: { name, path, writable, + * removable, ejected, available, kind } + * groupId — the group id + * transport — MeshBayTransport instance, or null when not connected + * signFn — signing function for admin ops + * nodeDetected — whether the loopback node API answers + * readOnly — suppress every edit control + * onRootsChange — called after a change, to re-read the loopback list + * onRefreshIndex — full index refresh, needed after an add or a remove + * mode — "live" (default) or "local" + * localRoots / onLocalRootsChange — the array, in "local" mode */ function SharedDirectoriesTable({ roots, groupId, transport, signFn, - nodeDetected: nodeAvail, readOnly, - onRootsChange, onRefreshIndex, - mode = 'live', - localRoots, onLocalRootsChange }) { + nodeDetected: nodeAvail, readOnly, + onRootsChange, onRefreshIndex, + mode = 'live', + localRoots, onLocalRootsChange }) { const isLocal = mode === 'local'; - const [optimistic, setOptimistic] = useState({}); - const serverRoots = isLocal ? (localRoots || []) : roots; - const displayRoots = serverRoots.map(r => - optimistic[r.name] ? { ...r, ...optimistic[r.name] } : r); + const serverRoots = isLocal ? (localRoots || []) : (roots || []); const [busy, setBusy] = useState(false); const [msg, setMsg] = useState(''); const [indexProgress, setIndexProgress] = useState(null); + const [pathDraft, setPathDraft] = useState(''); + const [addingByPath, setAddingByPath] = useState(false); + + // A toggle has to move under the finger, and the answer only comes back + // when the node has signed, written node.toml and pushed the new table. + // The patch is therefore held until the incoming `roots` actually agrees + // with it — clearing it when the request resolves (which is what this did) + // drops it in the frame *before* the new table arrives, so the switch + // visibly snaps back and then forward again. + const [optimistic, setOptimistic] = useState({}); + useEffect(() => { + setOptimistic((prev) => { + const keys = Object.keys(prev); + if (!keys.length) return prev; + const next = {}; + let changed = false; + for (const name of keys) { + const server = serverRoots.find(r => r.name === name); + const patch = prev[name]; + // Gone from the table, or the server now says what we asked for: + // either way this patch has nothing left to hide. + const settled = !server + || Object.keys(patch).every(k => server[k] === patch[k]); + if (settled) changed = true; else next[name] = patch; + } + return changed ? next : prev; + }); + }, [serverRoots]); + + const displayRoots = serverRoots.map(r => + optimistic[r.name] ? { ...r, ...optimistic[r.name] } : r); + + // Which door a change goes through. MNP first: it is the only one that + // exists for an operator on the web, and it is signed, which the loopback + // API is not (it is authorized by being on localhost with the run token). + const overMnp = !isLocal && transport && transport.connected; + const overLoopback = !isLocal && !overMnp && nodeAvail; + const canEdit = !readOnly && (isLocal || overMnp || overLoopback); + + const rootUrl = (name, suffix = '') => + '/api/groups/' + groupId + '/roots/' + encodeURIComponent(name) + suffix; + + const run = useCallback(async (work, { refreshIndex = false } = {}) => { + setBusy(true); setMsg(''); + try { + await work(); + if (onRootsChange) await onRootsChange(); + if (refreshIndex && onRefreshIndex) await onRefreshIndex(); + return true; + } catch (err) { + setMsg(platform.bridgeMessage(err)); + return false; + } finally { setBusy(false); } + }, [onRootsChange, onRefreshIndex]); const doUpdateRoot = useCallback(async (rootName, updates) => { if (isLocal) { @@ -222,53 +284,35 @@ function SharedDirectoriesTable({ roots, groupId, transport, signFn, } return; } - setOptimistic(prev => ({ ...prev, [rootName]: { ...(prev[rootName] || {}), ...updates } })); - setBusy(true); setMsg(''); - try { - if (transport && transport.connected) { - await transport.updateRoot(groupId, rootName, updates, signFn); - } else if (nodeAvail) { - await platform.node.call('PATCH', - '/api/groups/' + groupId + '/roots/' + encodeURIComponent(rootName), - updates); - } - if (onRootsChange) await onRootsChange(); - } catch (err) { setMsg(err.message); } - finally { - setOptimistic(prev => { const next = { ...prev }; delete next[rootName]; return next; }); - setBusy(false); + setOptimistic(prev => ({ + ...prev, [rootName]: { ...(prev[rootName] || {}), ...updates }, + })); + const ok = await run(async () => { + if (overMnp) await transport.updateRoot(groupId, rootName, updates, signFn); + else if (overLoopback) await platform.node.call('PATCH', rootUrl(rootName), updates); + else throw new Error(t('node.root_no_route')); + }); + // Only a failure clears the patch here; a success waits for the node's + // own table, so the switch never travels backwards on its way forwards. + if (!ok) { + setOptimistic(prev => { + const next = { ...prev }; delete next[rootName]; return next; + }); } - }, [isLocal, localRoots, onLocalRootsChange, transport, groupId, signFn, nodeAvail, onRootsChange]); + }, [isLocal, localRoots, onLocalRootsChange, overMnp, overLoopback, + transport, groupId, signFn, run]); - const doEjectRoot = useCallback(async (rootName) => { - if (isLocal) return; - setBusy(true); setMsg(''); - try { - if (transport && transport.connected) { - await transport.ejectRoot(groupId, rootName, signFn); - } else if (nodeAvail) { - await platform.node.call('PUT', - '/api/groups/' + groupId + '/roots/' + encodeURIComponent(rootName) + '/eject'); - } - if (onRootsChange) onRootsChange(); - } catch (err) { setMsg(err.message); } - finally { setBusy(false); } - }, [isLocal, transport, groupId, signFn, nodeAvail, onRootsChange]); + const doEjectRoot = useCallback((rootName) => run(async () => { + if (overMnp) await transport.ejectRoot(groupId, rootName, signFn); + else if (overLoopback) await platform.node.call('PUT', rootUrl(rootName, '/eject')); + else throw new Error(t('node.root_no_route')); + }), [overMnp, overLoopback, transport, groupId, signFn, run]); - const doPlugRoot = useCallback(async (rootName) => { - if (isLocal) return; - setBusy(true); setMsg(''); - try { - if (transport && transport.connected) { - await transport.plugRoot(groupId, rootName, signFn); - } else if (nodeAvail) { - await platform.node.call('PUT', - '/api/groups/' + groupId + '/roots/' + encodeURIComponent(rootName) + '/plug'); - } - if (onRootsChange) onRootsChange(); - } catch (err) { setMsg(err.message); } - finally { setBusy(false); } - }, [isLocal, transport, groupId, signFn, nodeAvail, onRootsChange]); + const doPlugRoot = useCallback((rootName) => run(async () => { + if (overMnp) await transport.plugRoot(groupId, rootName, signFn); + else if (overLoopback) await platform.node.call('PUT', rootUrl(rootName, '/plug')); + else throw new Error(t('node.root_no_route')); + }), [overMnp, overLoopback, transport, groupId, signFn, run]); const doRemoveRoot = useCallback(async (rootName) => { if (isLocal) { @@ -278,59 +322,99 @@ function SharedDirectoriesTable({ roots, groupId, transport, signFn, return; } if (!confirm(t('node.root_remove_confirm', { name: rootName }))) return; - setBusy(true); setMsg(''); - try { - if (transport && transport.connected) { - await transport.removeRoot(groupId, rootName, signFn); - } else if (nodeAvail) { - await platform.node.call('DELETE', - '/api/groups/' + groupId + '/roots/' + encodeURIComponent(rootName)); + const ok = await run(async () => { + if (overMnp) await transport.removeRoot(groupId, rootName, signFn); + else if (overLoopback) { + await platform.node.call('DELETE', rootUrl(rootName)); await platform.node.call('POST', '/api/reload'); - } - setMsg(t('node.root_removed')); - if (onRootsChange) onRootsChange(); - if (onRefreshIndex) await onRefreshIndex(); - } catch (err) { setMsg(err.message); } - finally { setBusy(false); } - }, [isLocal, localRoots, onLocalRootsChange, transport, groupId, signFn, nodeAvail, onRootsChange, onRefreshIndex]); + } else throw new Error(t('node.root_no_route')); + }, { refreshIndex: true }); + if (ok) setMsg(t('node.root_removed')); + }, [isLocal, localRoots, onLocalRootsChange, overMnp, overLoopback, + transport, groupId, signFn, run]); - const doAddRoot = useCallback(async () => { - const chosen = await platform.rootPicker.choose(); - if (!chosen) return; + // Adding a root needs a directory that exists on the *node's* filesystem. + // With the node on this machine that is a native folder picker; from any + // other browser the operator has to type the path, because nothing in a web + // page can browse a remote disk. Both end at the same signed op. + const addRootAtPath = useCallback(async (path, name) => { if (isLocal) { - if ((localRoots || []).some(r => r.path === chosen.path)) return; + if ((localRoots || []).some(r => r.path === path)) return true; const isFirst = (localRoots || []).length === 0; - const newRoot = { - name: chosen.name, path: chosen.path, - writable: isFirst, removable: false, - }; - if (onLocalRootsChange) onLocalRootsChange([...(localRoots || []), newRoot]); - return; + // The first directory is writable so a new group can receive an upload + // without the operator having to find this switch first. Every later + // one is read-only until they say otherwise. + if (onLocalRootsChange) { + onLocalRootsChange([...(localRoots || []), + { name, path, writable: isFirst, removable: false }]); + } + return true; } - setBusy(true); setMsg(''); setIndexProgress(null); - try { - if (nodeAvail) { - await platform.node.call('POST', - '/api/groups/' + groupId + '/roots', - { path: chosen.path, name: chosen.name }); + setIndexProgress(null); + return run(async () => { + if (overMnp) { + await transport.addRoot(groupId, path, { name }, signFn); + } else if (overLoopback) { + await platform.node.call('POST', '/api/groups/' + groupId + '/roots', + { path, name }); await platform.node.call('POST', '/api/reload'); await platform.watchIndexProgress(groupId, setIndexProgress); - } - setMsg(t('node.root_added')); - if (onRootsChange) onRootsChange(); - if (onRefreshIndex) await onRefreshIndex(); - } catch (err) { setMsg(platform.bridgeMessage(err)); } - finally { setBusy(false); } - }, [isLocal, localRoots, onLocalRootsChange, groupId, nodeAvail, onRootsChange, onRefreshIndex]); + } else throw new Error(t('node.root_no_route')); + }, { refreshIndex: true }); + }, [isLocal, localRoots, onLocalRootsChange, overMnp, overLoopback, + transport, groupId, signFn, run]); + + const doPickRoot = useCallback(async () => { + const chosen = await platform.rootPicker.choose(); + if (!chosen) return; + const ok = await addRootAtPath(chosen.path, chosen.name); + if (ok && !isLocal) setMsg(t('node.root_added')); + }, [addRootAtPath, isLocal]); + + const doAddByPath = useCallback(async () => { + const path = pathDraft.trim(); + if (!path) return; + // The name is the node's business — it derives the basename and refuses a + // duplicate. Sending one guessed from a string typed here would be a + // second opinion about something already decided in one place. + const ok = await addRootAtPath(path, ''); + if (ok) { setPathDraft(''); setAddingByPath(false); if (!isLocal) setMsg(t('node.root_added')); } + }, [pathDraft, addRootAtPath, isLocal]); - if (!displayRoots || displayRoots.length === 0) { + const addControls = !canEdit ? '' : html` + ${platform.rootPicker.available ? html` + <button class="btn btn-small btn-secondary" style="margin-top:8px" + disabled=${busy} onClick=${doPickRoot}> + <${Icon} name="folder-plus" /> ${t('node.add_root')} + </button> + ` : addingByPath ? html` + <div class="sdt-add-row"> + <input class="sdt-add-input" type="text" value=${pathDraft} + placeholder=${t('node.root_path_placeholder')} + disabled=${busy} + onInput=${(e) => setPathDraft(e.target.value)} + onKeyDown=${(e) => { if (e.key === 'Enter') doAddByPath(); }} /> + <button class="btn btn-small btn-secondary" disabled=${busy || !pathDraft.trim()} + onClick=${doAddByPath}>${t('node.add_root')}</button> + <button class="btn btn-small" disabled=${busy} + onClick=${() => { setAddingByPath(false); setPathDraft(''); }}> + ${t('settings.cancel')}</button> + </div> + <p class="settings-hint">${t('node.root_path_hint')}</p> + ` : html` + <button class="btn btn-small btn-secondary" style="margin-top:8px" + disabled=${busy} onClick=${() => setAddingByPath(true)}> + <${Icon} name="folder-plus" /> ${t('node.add_root')} + </button> + `} + `; + + if (!displayRoots.length) { return html` <div class="shared-directories-table"> + ${msg && html`<p class="settings-hint">${msg}</p>`} <p class="settings-hint">${t('settings_node.shared_directories_hint')}</p> - <button class="btn btn-small btn-secondary" style="margin-top:8px" - onClick=${doAddRoot}> - <${Icon} name="folder-plus" /> ${t('node.add_root')} - </button> + ${addControls} </div> `; } @@ -342,15 +426,16 @@ function SharedDirectoriesTable({ roots, groupId, transport, signFn, <thead> <tr> <th class="sdt-col-dir">${t('node.directory')}</th> - ${!readOnly && html`<th class="sdt-col-toggle">${t('node.root_rw')}</th>`} - ${!readOnly && !isLocal && html`<th class="sdt-col-toggle">${t('node.removable')}</th>`} + <th class="sdt-col-path">${t('node.root_path')}</th> + ${canEdit && html`<th class="sdt-col-toggle">${t('node.root_rw')}</th>`} + ${canEdit && !isLocal && html`<th class="sdt-col-toggle">${t('node.removable')}</th>`} <th class="sdt-col-actions"></th> </tr> </thead> <tbody> ${displayRoots.map(r => { const rowClass = r.ejected ? 'sdt-row-ejected' - : (!isLocal && !r.available) ? 'sdt-row-unavail' : ''; + : (!isLocal && r.available === false) ? 'sdt-row-unavail' : ''; return html` <tr class=${rowClass} key=${r.name}> <td class="sdt-col-dir"> @@ -360,30 +445,38 @@ function SharedDirectoriesTable({ roots, groupId, transport, signFn, </span> ${r.ejected && html` <span class="node-root-badge node-root-badge-warn">${t('group.root_ejected')}</span>`} - ${!isLocal && !r.available && !r.ejected && html` + ${!isLocal && r.available === false && !r.ejected && html` <span class="node-root-badge node-root-badge-warn">${t('node.unavailable')}</span>`} </td> - ${!readOnly && html` + ${/* Two roots can never share a name, so the name is the identity — + but it is the *basename*, and two libraries under different + parents look identical without this. */''} + <td class="sdt-col-path" title=${r.path || ''}>${r.path || ''}</td> + ${canEdit && html` <td class="sdt-col-toggle"> <${ToggleSwitch} checked=${!!r.writable} disabled=${busy || !!r.ejected} onChange=${(v) => doUpdateRoot(r.name, { writable: v })} /> </td> `} - ${!readOnly && !isLocal && html` + ${canEdit && !isLocal && html` <td class="sdt-col-toggle"> <${ToggleSwitch} checked=${!!r.removable} disabled=${busy} onChange=${(v) => doUpdateRoot(r.name, { removable: v })} /> </td> `} <td class="sdt-col-actions"> - ${!readOnly && !isLocal && html` + ${canEdit && !isLocal && html` <button class="sdt-action-btn" disabled=${busy || !r.removable} title=${r.ejected ? t('group.root_plug') : t('group.root_eject')} onClick=${() => r.ejected ? doPlugRoot(r.name) : doEjectRoot(r.name)}> ${r.ejected ? '\u{1F50C}' : '\u{23CF}'} </button> - <button class="sdt-action-btn sdt-action-danger" disabled=${busy} - title=${t('node.remove_root')} + `} + ${canEdit && html` + <button class="sdt-action-btn sdt-action-danger" + disabled=${busy || displayRoots.length < 2} + title=${displayRoots.length < 2 + ? t('node.root_remove_last') : t('node.remove_root')} onClick=${() => doRemoveRoot(r.name)}> \u{2715} </button> @@ -393,13 +486,7 @@ function SharedDirectoriesTable({ roots, groupId, transport, signFn, `; })} </tbody> </table> - ${!readOnly && (isLocal || nodeAvail) && html` - <button class="btn btn-small btn-secondary" style="margin-top:8px" - disabled=${busy} - onClick=${doAddRoot}> - <${Icon} name="folder-plus" /> ${t('node.add_root')} - </button> - `} + ${addControls} ${indexProgress && indexProgress.scanning && html` <div class="index-progress" style="margin-top:8px"> <div class="index-progress-bar"> @@ -435,6 +522,7 @@ function SharedDirectoriesTable({ roots, groupId, transport, signFn, */ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, isNodeAdmin, userId, operatorPaired, connected, + mnpRoots, enabledApps, onEnabledApps, scanSettings, onScanSettings, tmdbConfig, onTmdbConfig, onTmdbEnabled, @@ -461,6 +549,29 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, // platform.watchIndexProgress. const [nodeIndexProgress, setNodeIndexProgress] = useState(null); + // The roots to show, from whichever source can actually answer. + // + // `mnpRoots` comes from the index payload the node pushes over the live + // connection, and is the only source an operator signing in from an + // ordinary browser has. `nodeRoots` comes from the loopback API and exists + // only on the machine running the node. Preferring MNP when connected also + // keeps this table on the same data Files and the apps read, so an eject + // shows in one place at the same instant it shows in the other. + const effectiveRoots = (connected && mnpRoots && mnpRoots.length) + ? mnpRoots : nodeRoots; + + // Declared here rather than inline at the call site: a function rebuilt on + // every render is a new prop identity every render, and the callbacks that + // close over it in the table below are memoised on it. + const adminSignFn = useCallback((transcript) => { + const sk = transportRef.current && transportRef.current.sessionKeys + && transportRef.current.sessionKeys.skEdB64; + if (!sk || !window.MeshBayKeys) { + throw new Error(t('node.root_no_signing_key')); + } + return window.MeshBayKeys.signBytes(sk, transcript); + }, [transportRef]); + const loadNodeInfo = useCallback(async () => { if (!platform.node.available) return; try { @@ -1102,24 +1213,22 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, </div> `} - ${/* Shared directories — the group's root folders. Shown to the - operator when the node is detected locally (Electron) or a live - MNP connection is available, so root properties can be toggled. - Appears early because it is the fundamental structural control. */ - isNodeAdmin && (connected || nodeDetected) && nodeRoots.length > 0 && html` + ${/* Shared directories — the group's root folders, and the structural + control everything else in this page sits on top of, so it comes + first. Rendered whenever the operator has a route to their node: + a live MNP connection (any browser, anywhere) or the loopback API + (the node on this machine). It used to require the second, which + meant it rendered for nobody on the web. */ + isNodeAdmin && (connected || nodeDetected) && html` <${CollapsibleSection} titleKey="settings_node.shared_directories_title"> <p class="settings-hint">${t('settings_node.shared_directories_hint')}</p> + ${!connected && nodeDetected && html` + <p class="settings-hint">${t('settings_node.roots_offline_hint')}</p>`} <${SharedDirectoriesTable} - roots=${nodeRoots} + roots=${effectiveRoots} groupId=${groupId} transport=${transportRef.current} - signFn=${(() => { - const sk = transportRef.current && transportRef.current.sessionKeys - && transportRef.current.sessionKeys.skEdB64; - return (sk && window.MeshBayKeys) - ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript) - : null; - })()} + signFn=${adminSignFn} nodeDetected=${nodeDetected} onRootsChange=${loadNodeInfo} onRefreshIndex=${onRefreshIndex} /> diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js index f929873..ea14814 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -169,6 +169,10 @@ export default { 'device.approve_btn': 'Approve', 'device.approved': 'Device linked.', 'group.root_unavailable': '(nicht verfügbar — Laufwerk getrennt)', + 'group.root_plug': 'Einstecken', + 'group.root_eject': 'Auswerfen', + 'group.root_writable': 'Lesen/Schreiben', + 'group.root_ejected': '(ausgeworfen)', 'group.view': 'Ansehen', 'group.delete': 'Löschen', 'group.delete_confirm': '{name} löschen?', @@ -195,6 +199,7 @@ export default { 'chat.placeholder': 'Nachricht schreiben …', 'chat.send': 'Senden', 'chat.attach': 'Datei anhängen', + 'chat.attach_read_only': 'Kein beschreibbares freigegebenes Verzeichnis — Anhänge sind aus', // Video player 'video.loading': '{name} wird geladen …', @@ -722,10 +727,16 @@ export default { 'node.roots': 'Verzeichnisse', 'node.add_root': 'Verzeichnis hinzufügen', 'node.remove_root': 'Entfernen', + 'node.root_no_signing_key': 'Kein Signaturschlüssel verfügbar — koppeln Sie dieses Gerät zuerst mit dem Node', + 'node.root_no_route': 'Keine Verbindung zum Node — verbinden Sie sich damit oder verwenden Sie die App auf dem Rechner, der ihn hostet', + 'node.root_remove_last': 'Eine Gruppe braucht mindestens ein Verzeichnis', + 'node.root_path_hint': 'Der Pfad, wie der Node ihn sieht, auf dem Rechner, der diese Gruppe hostet.', + 'node.root_path_placeholder': '/home/user/Media', + 'node.root_path': 'Pfad', + 'node.directory': 'Verzeichnis', 'node.root_added': 'Verzeichnis hinzugefügt.', 'node.root_remove_confirm': '„{name}" aus dieser Gruppe entfernen?', 'node.root_removed': 'Verzeichnis entfernt. Neustart empfohlen, um den Index zu aktualisieren.', - 'node.upload_root': 'Uploads', 'node.attach_group': 'Gruppe hinzufügen', 'node.attach_pick': 'Zu hostende Gruppe', 'node.attach_dir': 'Freigegebenes Verzeichnis', @@ -807,6 +818,7 @@ export default { 'settings_node.photo_roots_save': 'Speichern', 'settings_node.shared_directories_title': 'Freigegebene Verzeichnisse', 'settings_node.shared_directories_hint': 'Ordner, die mit dieser Gruppe geteilt werden. Lesen/Schreiben umschalten, um Uploads zu erlauben. Als wechselbar markieren für externe Laufwerke.', + 'settings_node.roots_offline_hint': 'Nicht mit dem Node verbunden — Änderungen laufen über den lokalen Node und greifen beim nächsten Neuladen.', 'settings_node.directories_title': 'App-Verzeichnisse', 'settings_node.directories_hint': 'Freigegebene Ordner und welchen davon die Videos-, Musik- und Fotos-Apps als eigene(n) Einstiegspunkt(e) nutzen.', 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 f6c47fe..cbb790f 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -198,6 +198,7 @@ export default { 'chat.placeholder': 'Type a message...', 'chat.send': 'Send', 'chat.attach': 'Attach file', + 'chat.attach_read_only': 'No writable shared directory — attachments are off', // Video player 'video.loading': 'Loading {name}...', @@ -605,6 +606,7 @@ export default { 'settings_node.photo_roots_save': 'Save', 'settings_node.shared_directories_title': 'Shared directories', 'settings_node.shared_directories_hint': 'Folders shared with this group. Toggle read-write to allow uploads, mark as removable for external drives.', + 'settings_node.roots_offline_hint': 'Not connected to the node — changes go through the local node instead, and take effect on its next reload.', 'settings_node.directories_title': 'App directories', 'settings_node.directories_hint': 'Which shared folders the Videos, Music and Photos apps use as their entry point(s).', @@ -759,7 +761,6 @@ export default { 'node.peers': { one: '1 peer', other: '{n} peers' }, 'node.no_gek': 'No group key', 'node.roots': 'Directories', - 'node.upload_root': 'uploads', 'node.directory': 'Directory', 'node.root_rw': 'Writable', 'node.root_ro': 'read-only', @@ -767,6 +768,12 @@ export default { 'node.unavailable': 'unavailable', 'node.add_root': 'Add directory', 'node.remove_root': 'Remove', + 'node.root_no_signing_key': 'No signing key available — pair this device with the node first', + 'node.root_no_route': 'No route to the node — connect to it, or use the app on the machine hosting it', + 'node.root_remove_last': 'A group needs at least one directory', + 'node.root_path_hint': 'The path as the node sees it, on the machine hosting this group.', + 'node.root_path_placeholder': '/home/user/Media', + 'node.root_path': 'Path', 'node.root_added': 'Directory added.', 'node.root_removed': 'Directory removed. Restart recommended to update the index.', 'node.root_remove_confirm': 'Remove "{name}" from this group?', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js index a8c1296..3921532 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -167,6 +167,10 @@ export default { 'device.approve_btn': 'Approve', 'device.approved': 'Device linked.', 'group.root_unavailable': '(no disponible — la unidad está desconectada)', + 'group.root_plug': 'Conectar', + 'group.root_eject': 'Expulsar', + 'group.root_writable': 'Lectura/Escritura', + 'group.root_ejected': '(expulsado)', 'group.view': 'Ver', 'group.delete': 'Eliminar', 'group.delete_confirm': '¿Eliminar {name}?', @@ -193,6 +197,7 @@ export default { 'chat.placeholder': 'Escriba un mensaje...', 'chat.send': 'Enviar', 'chat.attach': 'Adjuntar archivo', + 'chat.attach_read_only': 'Ningún directorio compartido con escritura: los adjuntos están desactivados', // Video player 'video.loading': 'Cargando {name}...', @@ -718,10 +723,16 @@ export default { 'node.roots': 'Directorios', 'node.add_root': 'Añadir directorio', 'node.remove_root': 'Eliminar', + 'node.root_no_signing_key': 'No hay clave de firma disponible: empareja primero este dispositivo con el nodo', + 'node.root_no_route': 'No hay ruta al nodo: conéctate a él o usa la aplicación en la máquina que lo aloja', + 'node.root_remove_last': 'Un grupo necesita al menos un directorio', + 'node.root_path_hint': 'La ruta tal como la ve el nodo, en la máquina que aloja este grupo.', + 'node.root_path_placeholder': '/home/user/Media', + 'node.root_path': 'Ruta', + 'node.directory': 'Directorio', 'node.root_added': 'Directorio añadido.', 'node.root_remove_confirm': '¿Eliminar «{name}» de este grupo?', 'node.root_removed': 'Directorio eliminado. Se recomienda reiniciar para actualizar el índice.', - 'node.upload_root': 'subidas', 'node.attach_group': 'Añadir grupo', 'node.attach_pick': 'Grupo a alojar', 'node.attach_dir': 'Directorio compartido', @@ -803,6 +814,7 @@ export default { 'settings_node.photo_roots_save': 'Guardar', 'settings_node.shared_directories_title': 'Directorios compartidos', 'settings_node.shared_directories_hint': 'Carpetas compartidas con este grupo. Active lectura-escritura para permitir subidas, marque como extraíble para unidades externas.', + 'settings_node.roots_offline_hint': 'Sin conexión con el nodo: los cambios pasan por el nodo local y se aplican en su próxima recarga.', 'settings_node.directories_title': 'Directorios de apps', 'settings_node.directories_hint': 'Carpetas compartidas, y cuál de ellas usan las apps de Vídeos, Música y Fotos como su(s) propio(s) punto(s) de entrada.', 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 6d36e41..4e60eb9 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -198,6 +198,7 @@ export default { 'chat.placeholder': 'Écrivez un message...', 'chat.send': 'Envoyer', 'chat.attach': 'Joindre un fichier', + 'chat.attach_read_only': 'Aucun répertoire partagé en écriture — pièces jointes désactivées', // Video player 'video.loading': 'Chargement de {name}...', @@ -731,10 +732,15 @@ export default { 'node.roots': 'Répertoires', 'node.add_root': 'Ajouter un répertoire', 'node.remove_root': 'Retirer', + 'node.root_no_signing_key': 'Aucune clé de signature disponible — appairez d\'abord cet appareil avec le nœud', + 'node.root_no_route': 'Aucune route vers le nœud — connectez-vous à lui, ou utilisez l\'application sur la machine qui l\'héberge', + 'node.root_remove_last': 'Un groupe a besoin d\'au moins un répertoire', + 'node.root_path_hint': 'Le chemin tel que le nœud le voit, sur la machine qui héberge ce groupe.', + 'node.root_path_placeholder': '/home/user/Media', + 'node.root_path': 'Chemin', 'node.root_added': 'Répertoire ajouté.', 'node.root_remove_confirm': 'Retirer « {name} » de ce groupe ?', 'node.root_removed': 'Répertoire retiré. Un redémarrage est recommandé pour mettre à jour l\'index.', - 'node.upload_root': 'uploads', 'node.directory': 'Répertoire', 'node.root_rw': 'Écriture', 'node.root_ro': 'lecture seule', @@ -826,6 +832,7 @@ export default { 'settings_node.photo_roots_save': 'Enregistrer', 'settings_node.shared_directories_title': 'Répertoires partagés', 'settings_node.shared_directories_hint': 'Dossiers partagés avec ce groupe. Activez lecture-écriture pour autoriser les envois, marquez comme amovible pour les disques externes.', + 'settings_node.roots_offline_hint': 'Non connecté au nœud — les changements passent par le nœud local et prennent effet à son prochain rechargement.', 'settings_node.directories_title': 'Répertoires des applications', 'settings_node.directories_hint': 'Quel(s) dossier(s) partagés les applications Vidéos, Musique et Photos utilisent comme leur(s) propre(s) point(s) d\'entrée.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js index 508d69c..38fc241 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -168,6 +168,10 @@ export default { 'device.approve_btn': 'Approve', 'device.approved': 'Device linked.', 'group.root_unavailable': '(non disponibile — l’unità è scollegata)', + 'group.root_plug': 'Ricollega', + 'group.root_eject': 'Espelli', + 'group.root_writable': 'Lettura/Scrittura', + 'group.root_ejected': '(espulso)', 'group.view': 'Visualizza', 'group.delete': 'Elimina', 'group.delete_confirm': 'Eliminare {name}?', @@ -194,6 +198,7 @@ export default { 'chat.placeholder': 'Scriva un messaggio...', 'chat.send': 'Invia', 'chat.attach': 'Allega un file', + 'chat.attach_read_only': 'Nessuna directory condivisa scrivibile: gli allegati sono disattivati', // Video player 'video.loading': 'Caricamento di {name}...', @@ -726,10 +731,16 @@ export default { 'node.roots': 'Directory', 'node.add_root': 'Aggiungi directory', 'node.remove_root': 'Rimuovi', + 'node.root_no_signing_key': 'Nessuna chiave di firma disponibile: associa prima questo dispositivo al nodo', + 'node.root_no_route': 'Nessuna via verso il nodo: connettiti a esso oppure usa l\'applicazione sulla macchina che lo ospita', + 'node.root_remove_last': 'Un gruppo ha bisogno di almeno una directory', + 'node.root_path_hint': 'Il percorso come lo vede il nodo, sulla macchina che ospita questo gruppo.', + 'node.root_path_placeholder': '/home/user/Media', + 'node.root_path': 'Percorso', + 'node.directory': 'Directory', 'node.root_added': 'Directory aggiunta.', 'node.root_remove_confirm': 'Rimuovere «{name}» da questo gruppo?', 'node.root_removed': "Directory rimossa. Si consiglia un riavvio per aggiornare l'indice.", - 'node.upload_root': 'caricamenti', 'node.attach_group': 'Aggiungi gruppo', 'node.attach_pick': 'Gruppo da ospitare', 'node.attach_dir': 'Directory condivisa', @@ -817,6 +828,7 @@ export default { 'settings_node.photo_roots_save': 'Salva', 'settings_node.shared_directories_title': 'Directory condivise', 'settings_node.shared_directories_hint': 'Cartelle condivise con questo gruppo. Attiva lettura-scrittura per consentire il caricamento, segna come rimovibile per unità esterne.', + 'settings_node.roots_offline_hint': 'Non connesso al nodo: le modifiche passano dal nodo locale e hanno effetto al successivo ricaricamento.', 'settings_node.directories_title': 'Directory delle app', 'settings_node.directories_hint': 'Cartelle condivise, e quale di esse le app Video, Musica e Foto usano come proprio/i punto/i di ingresso.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js index cfd65da..7890ab7 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -166,6 +166,10 @@ export default { 'device.approve_btn': 'Approve', 'device.approved': 'Device linked.', 'group.root_unavailable': '(利用不可 — ドライブが切断されています)', + 'group.root_plug': '接続する', + 'group.root_eject': '取り外す', + 'group.root_writable': '読み書き可', + 'group.root_ejected': '(取り外し済み)', 'group.view': '表示', 'group.delete': '削除', 'group.delete_confirm': '{name} を削除しますか?', @@ -191,6 +195,7 @@ export default { 'chat.placeholder': 'メッセージを入力…', 'chat.send': '送信', 'chat.attach': 'ファイルを添付', + 'chat.attach_read_only': '書き込み可能な共有ディレクトリがありません — 添付は無効です', // Video player 'video.loading': '{name} を読み込んでいます…', @@ -712,10 +717,16 @@ export default { 'node.roots': 'ディレクトリ', 'node.add_root': 'ディレクトリを追加', 'node.remove_root': '削除', + 'node.root_no_signing_key': '署名鍵がありません — 先にこの端末をノードとペアリングしてください', + 'node.root_no_route': 'ノードへの経路がありません — 接続するか、ノードを動かしているマシンでアプリを使ってください', + 'node.root_remove_last': 'グループには少なくとも 1 つのディレクトリが必要です', + 'node.root_path_hint': 'このグループをホストしているマシン上で、ノードから見たパスです。', + 'node.root_path_placeholder': '/home/user/Media', + 'node.root_path': 'パス', + 'node.directory': 'ディレクトリ', 'node.root_added': 'ディレクトリを追加しました。', 'node.root_remove_confirm': '「{name}」をこのグループから削除しますか?', 'node.root_removed': 'ディレクトリを削除しました。インデックスを更新するために再起動を推奨します。', - 'node.upload_root': 'アップロード', 'node.attach_group': 'グループを追加', 'node.attach_pick': 'ホストするグループ', 'node.attach_dir': '共有ディレクトリ', @@ -801,6 +812,7 @@ export default { 'settings_node.photo_roots_save': '保存', 'settings_node.shared_directories_title': '共有ディレクトリ', 'settings_node.shared_directories_hint': 'このグループと共有されているフォルダー。読み書きを切り替えてアップロードを許可し、外付けドライブにはリムーバブルを設定します。', + 'settings_node.roots_offline_hint': 'ノードに接続していません — 変更はローカルノード経由で行われ、次回の再読み込みで反映されます。', 'settings_node.directories_title': 'アプリのディレクトリ', 'settings_node.directories_hint': '共有フォルダと、動画・音楽・写真の各アプリがそれぞれの起点として使用するフォルダです。', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js index 0569efb..cdd4720 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -169,6 +169,10 @@ export default { 'device.approve_btn': 'Approve', 'device.approved': 'Device linked.', 'group.root_unavailable': '(niet beschikbaar — de schijf is losgekoppeld)', + 'group.root_plug': 'Aansluiten', + 'group.root_eject': 'Uitwerpen', + 'group.root_writable': 'Lezen/schrijven', + 'group.root_ejected': '(uitgeworpen)', 'group.view': 'Bekijken', 'group.delete': 'Verwijderen', 'group.delete_confirm': '{name} verwijderen?', @@ -195,6 +199,7 @@ export default { 'chat.placeholder': 'Typ een bericht...', 'chat.send': 'Versturen', 'chat.attach': 'Bestand bijvoegen', + 'chat.attach_read_only': 'Geen beschrijfbare gedeelde map — bijlagen staan uit', // Video player 'video.loading': '{name} wordt geladen...', @@ -728,10 +733,16 @@ export default { 'node.roots': 'Mappen', 'node.add_root': 'Map toevoegen', 'node.remove_root': 'Verwijderen', + 'node.root_no_signing_key': 'Geen ondertekeningssleutel beschikbaar — koppel dit apparaat eerst aan de node', + 'node.root_no_route': 'Geen route naar de node — maak verbinding, of gebruik de app op de machine die hem host', + 'node.root_remove_last': 'Een groep heeft minstens één map nodig', + 'node.root_path_hint': 'Het pad zoals de node het ziet, op de machine die deze groep host.', + 'node.root_path_placeholder': '/home/user/Media', + 'node.root_path': 'Pad', + 'node.directory': 'Map', 'node.root_added': 'Map toegevoegd.', 'node.root_remove_confirm': '„{name}" uit deze groep verwijderen?', 'node.root_removed': 'Map verwijderd. Herstart aanbevolen om de index bij te werken.', - 'node.upload_root': 'uploads', 'node.attach_group': 'Groep toevoegen', 'node.attach_pick': 'Groep om te hosten', 'node.attach_dir': 'Gedeelde map', @@ -819,6 +830,7 @@ export default { 'settings_node.photo_roots_save': 'Opslaan', 'settings_node.shared_directories_title': 'Gedeelde mappen', 'settings_node.shared_directories_hint': 'Mappen gedeeld met deze groep. Schakel lezen-schrijven in om uploads toe te staan, markeer als verwijderbaar voor externe schijven.', + 'settings_node.roots_offline_hint': 'Niet verbonden met de node — wijzigingen gaan via de lokale node en worden bij de volgende herlaadbeurt actief.', 'settings_node.directories_title': 'App-mappen', 'settings_node.directories_hint': 'Gedeelde mappen, en welke daarvan de Video\'s-, Muziek- en Foto\'s-apps als eigen startpunt(en) gebruiken.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js index 92c853b..d3826f5 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -172,6 +172,10 @@ export default { 'device.approve_btn': 'Approve', 'device.approved': 'Device linked.', 'group.root_unavailable': '(niedostępne — dysk jest odłączony)', + 'group.root_plug': 'Podłącz', + 'group.root_eject': 'Odłącz', + 'group.root_writable': 'Odczyt/zapis', + 'group.root_ejected': '(odłączony)', 'group.view': 'Podgląd', 'group.delete': 'Usuń', 'group.delete_confirm': 'Usunąć {name}?', @@ -200,6 +204,7 @@ export default { 'chat.placeholder': 'Napisz wiadomość...', 'chat.send': 'Wyślij', 'chat.attach': 'Załącz plik', + 'chat.attach_read_only': 'Brak zapisywalnego katalogu współdzielonego — załączniki wyłączone', // Video player 'video.loading': 'Wczytywanie {name}...', @@ -750,10 +755,16 @@ export default { 'node.roots': 'Katalogi', 'node.add_root': 'Dodaj katalog', 'node.remove_root': 'Usuń', + 'node.root_no_signing_key': 'Brak klucza podpisu — najpierw sparuj to urządzenie z węzłem', + 'node.root_no_route': 'Brak połączenia z węzłem — połącz się z nim albo użyj aplikacji na komputerze, który go hostuje', + 'node.root_remove_last': 'Grupa wymaga co najmniej jednego katalogu', + 'node.root_path_hint': 'Ścieżka widziana przez węzeł, na komputerze hostującym tę grupę.', + 'node.root_path_placeholder': '/home/user/Media', + 'node.root_path': 'Ścieżka', + 'node.directory': 'Katalog', 'node.root_added': 'Katalog dodany.', 'node.root_remove_confirm': 'Usunąć „{name}" z tej grupy?', 'node.root_removed': 'Katalog usunięty. Zalecany restart w celu odświeżenia indeksu.', - 'node.upload_root': 'przesyłanie', 'node.attach_group': 'Dodaj grupę', 'node.attach_pick': 'Grupa do hostowania', 'node.attach_dir': 'Katalog współdzielony', @@ -845,6 +856,7 @@ export default { 'settings_node.photo_roots_save': 'Zapisz', 'settings_node.shared_directories_title': 'Katalogi udostępnione', 'settings_node.shared_directories_hint': 'Foldery udostępnione tej grupie. Przełącz odczyt-zapis, aby zezwolić na przesyłanie, oznacz jako wymienny dla dysków zewnętrznych.', + 'settings_node.roots_offline_hint': 'Brak połączenia z węzłem — zmiany przechodzą przez węzeł lokalny i zaczną działać po jego następnym przeładowaniu.', 'settings_node.directories_title': 'Katalogi aplikacji', 'settings_node.directories_hint': 'Katalogi udostępnione oraz to, który z nich aplikacje Wideo, Muzyka i Zdjęcia traktują jako własny punkt (punkty) wejścia.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js index cd5f7e8..6706c13 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js @@ -169,6 +169,10 @@ export default { 'device.approve_btn': 'Approve', 'device.approved': 'Device linked.', 'group.root_unavailable': '(indisponível — a unidade está desconectada)', + 'group.root_plug': 'Conectar', + 'group.root_eject': 'Ejetar', + 'group.root_writable': 'Leitura/Escrita', + 'group.root_ejected': '(ejetado)', 'group.view': 'Visualizar', 'group.delete': 'Excluir', 'group.delete_confirm': 'Excluir {name}?', @@ -195,6 +199,7 @@ export default { 'chat.placeholder': 'Escreva uma mensagem...', 'chat.send': 'Enviar', 'chat.attach': 'Anexar arquivo', + 'chat.attach_read_only': 'Nenhum diretório compartilhado gravável — anexos desativados', // Video player 'video.loading': 'Carregando {name}...', @@ -719,10 +724,16 @@ export default { 'node.roots': 'Diretórios', 'node.add_root': 'Adicionar diretório', 'node.remove_root': 'Remover', + 'node.root_no_signing_key': 'Nenhuma chave de assinatura disponível — pareie este dispositivo com o nó primeiro', + 'node.root_no_route': 'Sem rota até o nó — conecte-se a ele ou use o aplicativo na máquina que o hospeda', + 'node.root_remove_last': 'Um grupo precisa de pelo menos um diretório', + 'node.root_path_hint': 'O caminho como o nó o vê, na máquina que hospeda este grupo.', + 'node.root_path_placeholder': '/home/user/Media', + 'node.root_path': 'Caminho', + 'node.directory': 'Diretório', 'node.root_added': 'Diretório adicionado.', 'node.root_remove_confirm': 'Remover "{name}" deste grupo?', 'node.root_removed': 'Diretório removido. Reinicialização recomendada para atualizar o índice.', - 'node.upload_root': 'uploads', 'node.attach_group': 'Adicionar grupo', 'node.attach_pick': 'Grupo a hospedar', 'node.attach_dir': 'Diretório compartilhado', @@ -804,6 +815,7 @@ export default { 'settings_node.photo_roots_save': 'Salvar', 'settings_node.shared_directories_title': 'Diretórios compartilhados', 'settings_node.shared_directories_hint': 'Pastas compartilhadas com este grupo. Alterne leitura-escrita para permitir uploads, marque como removível para unidades externas.', + 'settings_node.roots_offline_hint': 'Sem conexão com o nó — as alterações passam pelo nó local e entram em vigor no próximo recarregamento.', 'settings_node.directories_title': 'Diretórios de apps', 'settings_node.directories_hint': 'Pastas compartilhadas, e qual delas os apps Vídeos, Música e Fotos tratam como seu(s) próprio(s) ponto(s) de entrada.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js index b80ac08..f62d6fd 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js @@ -165,6 +165,10 @@ export default { 'device.approve_btn': 'Approve', 'device.approved': 'Device linked.', 'group.root_unavailable': '(不可用 — 驱动器已断开连接)', + 'group.root_plug': '重新接入', + 'group.root_eject': '弹出', + 'group.root_writable': '读写', + 'group.root_ejected': '(已弹出)', 'group.view': '查看', 'group.delete': '删除', 'group.delete_confirm': '删除 {name}?', @@ -189,6 +193,7 @@ export default { 'chat.placeholder': '输入消息…', 'chat.send': '发送', 'chat.attach': '添加附件', + 'chat.attach_read_only': '没有可写的共享目录 — 附件已停用', // Video player 'video.loading': '正在加载 {name}…', @@ -699,10 +704,16 @@ export default { 'node.roots': '目录', 'node.add_root': '添加目录', 'node.remove_root': '移除', + 'node.root_no_signing_key': '没有可用的签名密钥 — 请先将本设备与节点配对', + 'node.root_no_route': '无法连接到节点 — 请先连接,或在运行该节点的机器上使用应用', + 'node.root_remove_last': '每个群组至少需要一个目录', + 'node.root_path_hint': '托管该群组的机器上,节点所看到的路径。', + 'node.root_path_placeholder': '/home/user/Media', + 'node.root_path': '路径', + 'node.directory': '目录', 'node.root_added': '目录已添加。', 'node.root_remove_confirm': '从此群组中移除"{name}"?', 'node.root_removed': '目录已移除。建议重启以更新索引。', - 'node.upload_root': '上传目录', 'node.attach_group': '添加群组', 'node.attach_pick': '要托管的群组', 'node.attach_dir': '共享目录', @@ -788,6 +799,7 @@ export default { 'settings_node.photo_roots_save': '保存', 'settings_node.shared_directories_title': '共享目录', 'settings_node.shared_directories_hint': '与此群组共享的文件夹。切换读写以允许上传,标记为可移除用于外置驱动器。', + 'settings_node.roots_offline_hint': '未连接到节点 — 变更将通过本地节点进行,并在其下次重新加载时生效。', 'settings_node.directories_title': '应用目录', 'settings_node.directories_hint': '共享文件夹,以及“视频”“音乐”和“照片”应用各自使用哪个(些)作为入口。', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/node-page.js b/packages/meshbay-hub/src/meshbay_hub/static/node-page.js index ec73128..2e216a9 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/node-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/node-page.js @@ -750,13 +750,21 @@ export function NodePage({ groups }) { <${Icon} name="folder" /> ${r.name} </span> - ${r.upload && html` - <span class="node-root-badge">${t('node.upload_root')}</span>`} - ${!r.available && html` + ${r.writable && html` + <span class="node-root-badge">${t('node.root_rw')}</span>`} + ${r.removable && html` + <span class="node-root-badge">${t('node.removable')}</span>`} + ${r.ejected ? html` + <span class="node-root-badge node-root-badge-warn"> + ${t('group.root_ejected')}</span>` + : !r.available && html` <span class="node-root-badge node-root-badge-warn"> ${t('node.unavailable')}</span>`} </div> - ${(g.roots || []).length > 1 && !r.upload && html` + ${/* Removing a writable root is allowed now — several can be + writable, and a group with none is a valid read-only + group. The last root is still the one that cannot go. */''} + ${(g.roots || []).length > 1 && html` <button class="btn btn-small btn-danger" disabled=${busy} onClick=${() => removeRoot(g.id, r.name)}> ${t('node.remove_root')}</button>`} diff --git a/packages/meshbay-hub/src/meshbay_hub/static/search-page.js b/packages/meshbay-hub/src/meshbay_hub/static/search-page.js index 608bb81..6e4e5b9 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/search-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/search-page.js @@ -707,7 +707,6 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs }) applyIndex=${noop} isNodeAdmin=${false} operatorPaired=${false} - mayUpload=${false} userId=${userId} setError=${noop} onPreview=${onPreview} diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css index 51f9d70..eaf1298 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/style.css +++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css @@ -2554,6 +2554,21 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; } .shared-dirs-tbl td { padding: 7px 12px 7px 0; border: none; vertical-align: middle; } .shared-dirs-tbl tbody tr + tr td { border-top: 1px solid var(--border); } .sdt-col-dir { min-width: 140px; } +/* The path is the only thing separating two libraries whose folders happen to + share a basename, so it is shown — truncated, because it is usually long and + rarely the thing being read. */ +.sdt-col-path { + color: var(--text-dim); font-size: 0.85em; + max-width: 260px; overflow: hidden; text-overflow: ellipsis; + white-space: nowrap; +} +.sdt-add-row { display: flex; gap: 6px; align-items: center; margin-top: 8px; } +.sdt-add-input { + flex: 1 1 auto; min-width: 0; padding: 5px 8px; + border: 1px solid var(--border); border-radius: 4px; + background: var(--bg-input, transparent); color: var(--text); + font-family: inherit; font-size: 0.9em; +} .sdt-col-toggle { width: 90px; text-align: center; } .sdt-col-toggle th { text-align: center; } .sdt-col-toggle .toggle-switch { justify-content: center; } @@ -2577,6 +2592,16 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; } line-height: 1; } .root-eject-btn:hover { background: var(--bg-hover); } +.root-eject-btn { margin-left: 8px; } +/* An ejected root in the Files table: still listed, deliberately — its files + are frozen, not gone — but not somewhere you can walk into. */ +.file-row.root-ejected { opacity: 0.5; } +.file-row.root-ejected td:not(.sel-cell) { cursor: default; } + +/* The paperclip with no writable directory to write to. Shown rather than + hidden, so the reason is discoverable instead of the control just being + absent. */ +.chat-attach-off { opacity: 0.35; cursor: not-allowed; } .node-root { display: flex; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index 482574f..8df7700 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -72,11 +72,18 @@ function _aborted() { // change anything — `op` is already on every admin_challenge, and this // list is what lets a response two steps later be tied back to the right // one. +// The acks whose payload is state no caller could have predicted: they carry +// the node's whole roots table back. See the note where they are dispatched. +const ROOT_ACK_TYPES = new Set([ + 'root_update_ack', 'root_eject_ack', 'root_plug_ack', + 'root_add_ack', 'root_remove_ack', +]); + const ADMIN_OP_TYPES = new Set([ 'tmdb_override', 'tmdb_rematch', 'tmdb_config', 'tmdb_enabled', 'video_root', 'audio_root', 'photo_roots', 'musicbrainz_enabled', 'file_delete', 'dir_delete', - 'member_upload', 'apps_enabled', 'set_scan_settings', 'member_revoke', + 'apps_enabled', 'set_scan_settings', 'member_revoke', 'root_add', 'root_remove', 'root_update', 'root_eject', 'root_plug', 'member_unpin', 'gek_rotate', 'group_attach', 'group_detach', 'invite_create', @@ -1072,7 +1079,7 @@ class MeshBayTransport { * queried in (e.g. "fr-FR") — one for the whole node, since both are one * operator's shared credential/cache, not a per-group concern (see * setTmdbEnabled below for the per-group on/off switch). Signed like - * setAppsEnabled/setMemberUpload — an unsigned change would let any + * setAppsEnabled/updateRoot — an unsigned change would let any * member alter outbound third-party network traffic the operator never * agreed to (docs/mediacenter.md §5.5, §8). `token: ''` explicitly clears * a previously-set custom token; omit it (undefined/null), like @@ -1367,24 +1374,6 @@ class MeshBayTransport { * on the hub is the other half, and neither implies the other. */ /** - * Turn uploading by ordinary members on or off. - * - * Signed by the operator like any other privileged operation — the node - * refuses an unsigned one, which is what stops a member turning it back on. - */ - async setMemberUpload(allowed, signFn) { - const msg = await this._sendAndWait({ - type: 'member_upload', v: '0.1', allowed: Boolean(allowed), - }); - if (msg.type === 'error') throw new Error(msg.detail); - if (msg.type === 'admin_challenge') { - return this._authorizeAdminOp( - msg, 'member_upload', allowed ? 'on' : 'off', signFn); - } - return msg; - } - - /** * Turn a group "application" (Chat, Files, ...) on or off for everyone. * * Takes the whole set in one signed message rather than one op per app, so @@ -1393,13 +1382,25 @@ class MeshBayTransport { * `_authorizeAdminOp` below checks the two match. */ async setAppsEnabled(apps, signFn) { + // Files cannot be turned off — MNP permits root exploration regardless of + // this list, so hiding the tab only ever misled — and the node adds it if + // it is missing. That normalisation has to happen *here too*: the subject + // below is rebuilt from what this client sent, and compared byte for byte + // against what the node put in the challenge. A list arriving here without + // `files` would produce two different strings and `_authorizeAdminOp` + // would refuse to sign an op the operator did ask for. It is reachable + // only from a caller that builds the list from something other than the + // node's own answer, which is exactly the kind of caller a later phase + // adds. (`apps.js` marks it `alwaysEnabled`; this file is a classic + // script and cannot import it.) + const full = apps.includes('files') ? [...apps] : ['files', ...apps]; const msg = await this._sendAndWait({ - type: 'apps_enabled', v: '0.1', apps, + type: 'apps_enabled', v: '0.1', apps: full, }); if (msg.type === 'error') throw new Error(msg.detail); if (msg.type === 'admin_challenge') { return this._authorizeAdminOp( - msg, 'apps_enabled', [...apps].sort().join(','), signFn); + msg, 'apps_enabled', [...full].sort().join(','), signFn); } return msg; } @@ -1657,8 +1658,14 @@ class MeshBayTransport { * The node decides where this lands (uploads/) and under what name — it finds a * free one rather than replacing anything. The ack says which, and that is what * this returns. + * + * `root` names which shared directory to upload into — a name, never a path; + * the node picks the destination inside it. Since a group can have several + * writable roots, leaving it out is a guess, and the node's fallback ("the + * first writable one") exists only for MNP 1.0 clients, which had exactly one + * destination. Every caller here browses a root and knows which one it is. */ - async uploadFile(file, { chunkSize, onProgress, signal } = {}) { + async uploadFile(file, { chunkSize, onProgress, signal, root } = {}) { // The same file twice at once would confuse the node, which keys its own // upload state by name — and would race for the same destination. if (this._uploaders.has(file.name)) { @@ -1709,6 +1716,7 @@ class MeshBayTransport { chunk_index: i, total_chunks: total, data: buf, + ...(root ? { root } : {}), }); } while (acked < total) { @@ -2206,7 +2214,21 @@ class MeshBayTransport { } else if (typeof msg.type === 'string' && msg.type.endsWith('_ack')) { const key = `admin:${msg.type.slice(0, -4)}`; for (const [, handler] of this._pending) { - if (handler._key === key) { handler.resolve(msg); return; } + if (handler._key === key) { + handler.resolve(msg); + // The comment above ("its own caller already updates local state + // from what it sent") is true of every op whose caller passes the + // value it just chose to an onX(next). The root ops are not like + // that: what changes is the whole roots table, which only the node + // can compute — availability, the eject that the plug refused, the + // name it settled on. Returning here left the operator who clicked + // Eject as the one client that never saw it happen, while every + // other peer got the broadcast. So this one type is handed on. + if (ROOT_ACK_TYPES.has(msg.type) && this._onRootsChanged) { + this._onRootsChanged(msg); + } + return; + } } } @@ -2253,10 +2275,12 @@ class MeshBayTransport { return; } - // The operator changed who may upload. Unsolicited: it arrives at everyone - // connected, not only at whoever asked. It still has to reach a pending - // caller — the operator's own request resolves on this reply — so it falls - // through to the matching below rather than returning here. + // Legacy. An MNP 1.0 node still broadcasts this when its operator changes + // the group-wide upload switch, and its roots carry no `writable` for us + // to read instead — so this is the only answer available from such a node + // and it is still honoured. Nothing here *sends* the message any more: + // per-root RO/RW replaced it, and a current node answers it with a + // deprecation notice and no action. if (msg.type === 'member_upload_ack' && this._onUploadPolicy) { this._onUploadPolicy(Boolean(msg.allowed)); } @@ -2322,10 +2346,10 @@ class MeshBayTransport { this._onMusicbrainzEnabled(Boolean(msg.enabled)); } - // A root's writable/removable flags changed, or a root was ejected/plugged. - // Broadcast to all peers so everyone sees the change. - if ((msg.type === 'root_update_ack' || msg.type === 'root_eject_ack' - || msg.type === 'root_plug_ack') && this._onRootsChanged) { + // A root's flags changed, or one was ejected, plugged, added or removed. + // Broadcast by the node to every peer, so everyone's table updates without + // waiting for the next index_sync. + if (ROOT_ACK_TYPES.has(msg.type) && this._onRootsChanged) { this._onRootsChanged(msg); } diff --git a/packages/meshbay-hub/tests/test_upload_controls_hidden.py b/packages/meshbay-hub/tests/test_upload_controls_hidden.py index 94917d2..ae1f444 100644 --- a/packages/meshbay-hub/tests/test_upload_controls_hidden.py +++ b/packages/meshbay-hub/tests/test_upload_controls_hidden.py @@ -1,14 +1,22 @@ """ -When the operator closes uploading, the controls go — both of them. +When a directory is read-only, the controls that write to it go — both of them. -There are two ways to put a file into a group and they are in different +There are two ways to put a file into a group and they live in different components: the Upload button in the Files toolbar, and the paperclip in the chat composer. Hiding one and forgetting the other is the obvious mistake, and -the second one is the easier to forget because it does not look like an upload. +the paperclip is the easier to forget because it does not look like an upload. Nothing here is a security property. **The node refuses the upload** — that is -`test_member_upload_policy.py` in the node package. This is about not offering -somebody a button whose only outcome is an error message. +`test_root_writable_policy.py` and `test_security_regressions.py` in the node +package. This is about not offering somebody a button whose only outcome is an +error message. + +What the RO/RW refactor changed: there is no group-wide answer any more. Files +uploads into *the root being browsed*, so its button follows that root's +`writable`. Chat has no folder on screen, so the shell picks one for it. The +two therefore read different things on purpose, and the tests below pin that +each reads the right one — a stronger claim than the old "both read one +boolean", which is why that assertion is gone rather than adapted. """ import re @@ -17,10 +25,6 @@ from pathlib import Path import pytest STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" -# The group-page refactor split what used to be one app.js into one file per -# "application" plus the group shell. mayUpload itself is still derived once, -# in the shell (group-page.js) — Files and Chat each moved to their own file -# and receive it as a prop, the same shape ChatPanel already took. APP = STATIC / "app.js" GROUP_PAGE = STATIC / "group-page.js" FILES_APP = STATIC / "files-app.js" @@ -36,45 +40,93 @@ def app() -> str: return GROUP_PAGE.read_text(encoding="utf-8") -def _component(app: str, name: str) -> str: - start = app.index(f"\nfunction {name}(") - end = app.find("\nfunction ", start + 1) - return app[start:end if end != -1 else len(app)] +def _component(source: str, name: str) -> str: + start = source.index(f"\nfunction {name}(") + end = source.find("\nfunction ", start + 1) + return source[start:end if end != -1 else len(source)] # ── Both controls ─────────────────────────────────────────────────────────── def test_the_files_toolbar_hides_its_upload_button(): + """ + Gated on the root being browsed, not on a group-wide answer: with one + writable root and one read-only one, a single boolean would offer the + button in both and produce a refusal in one of them. + """ + page = _component(FILES_APP.read_text(encoding="utf-8"), "FilesPanel") + toolbar = page[page.index("file-toolbar"):] + toolbar = toolbar[:toolbar.index("breadcrumbs")] + assert "currentRootWritable" in toolbar, ( + "the Upload button is offered regardless of the directory's own flag") + + +def test_the_files_upload_button_is_not_offered_at_the_top_of_a_group(): + """ + The top level is the set of roots, which is the operator's configuration + and not a directory on anyone's disk. There is nothing to upload *into* + there, and no root name to give the node. + """ page = _component(FILES_APP.read_text(encoding="utf-8"), "FilesPanel") toolbar = page[page.index("file-toolbar"):] toolbar = toolbar[:toolbar.index("breadcrumbs")] - assert "mayUpload &&" in toolbar, "the Upload button is offered regardless" + assert "currentPath &&" in toolbar def test_the_chat_composer_hides_its_paperclip(): chat = _component(CHAT_APP.read_text(encoding="utf-8"), "ChatPanel") composer = chat[chat.index("chat-input-row"):] - assert "mayUpload &&" in composer, ( + assert "attachRoot ?" in composer, ( "the chat attachment is the second way in and is still offered") -def test_both_read_the_same_answer(app): - """Two derivations would eventually disagree, and the disagreement would - be one of them offering an upload the node refuses.""" - assert re.search(r"const mayUpload = memberUpload \|\| isNodeAdmin;", app), ( - "mayUpload is no longer derived in one place") - # Files and Chat both receive it from the same `commonProps` object the - # shell spreads into whichever app tab is active — one derivation feeding - # one object, rather than two hand-written prop attributes that could - # drift apart. +def test_the_paperclip_says_why_rather_than_vanishing(): + """ + A control that disappears leaves the reader no way to find out what would + bring it back. A group with no writable directory is a state an operator + can fix, so it is worth naming. + """ + chat = _component(CHAT_APP.read_text(encoding="utf-8"), "ChatPanel") + composer = chat[chat.index("chat-input-row"):] + assert "chat.attach_read_only" in composer + + +# ── One derivation, in the shell ──────────────────────────────────────────── + +def test_the_attachment_directory_is_decided_once(app): + """ + Two derivations would eventually disagree, and the disagreement would be + one of them offering an upload the node refuses. + """ + assert re.search(r"const attachRoot = ", app), ( + "attachRoot is no longer derived in one place") props = app[app.index("const commonProps = {"):app.index("return html`")] - assert "mayUpload," in props or "mayUpload:" in props, ( - "mayUpload is not in the shared props object every app receives") + assert "attachRoot," in props or "attachRoot:" in props, ( + "attachRoot is not in the shared props object every app receives") + +def test_an_unavailable_root_is_not_offered_as_a_destination(app): + """ + `writable` is configuration and stays true while a drive is unplugged or + ejected. Offering it anyway produces a refusal from the node with no + explanation on screen. + """ + block = app[app.index("const writableRoots"):] + block = block[:block.index("const attachRoot")] + assert "available" in block -def test_the_operator_keeps_their_own_controls(app): - assert "memberUpload || isNodeAdmin" in app, ( - "turning uploads off would hide the operator's own upload button") + +def test_files_uploads_into_the_root_it_is_showing(): + """ + The client has to name the destination now, because the node cannot choose + between several writable roots without guessing — and a guess here means a + file landing in a directory nobody was looking at. + """ + page = _component(FILES_APP.read_text(encoding="utf-8"), "FilesPanel") + upload = page[page.index("const uploadFile"):] + upload = upload[:upload.index("const makeDirectory")] + assert "root: uploadRoot" in upload, "the node is left to choose" + assert "currentPath.split('/')[0]" in upload # ── Learning the answer ───────────────────────────────────────────────────── @@ -82,54 +134,104 @@ def test_the_operator_keeps_their_own_controls(app): def test_the_answer_comes_from_the_node(app): """Not from the hub, which has no say in what may be written to someone else's disk, and no way to be believed about it.""" - assert "ack.member_upload !== false" in app, ( - "the handshake ack is what carries this") - assert "hubFetch" not in app[app.index("ack.member_upload") - 400: - app.index("ack.member_upload")] + assert "if (indexMsg.roots) setNodeRoots(indexMsg.roots)" in app, ( + "the roots table in the index payload is what carries this") + idx = app.index("setNodeRoots(indexMsg.roots)") + assert "hubFetch" not in app[idx - 400:idx] def test_an_older_node_is_treated_as_permissive(app): - """A node that predates the setting sends no such field. Reading a missing - field as "off" would close every group on the older half of the network.""" + """ + A node speaking MNP 1.0 sends roots with no `writable` at all, plus the old + group-wide flag. Reading a missing field as "read-only" would close every + group on the older half of the network. + """ + assert "ack.member_upload !== false" in app assert "!== false" in app[app.index("ack.member_upload"): app.index("ack.member_upload") + 60] + block = app[app.index("const legacyNode"):] + block = block[:block.index("const commonProps")] + assert "writable === undefined" in block, ( + "nothing distinguishes a 1.0 node from one with no writable roots") def test_a_change_reaches_people_already_connected(app): - """The operator may be someone else entirely, changing it while you have - the group open. A button that survives until the next reconnection is a - button somebody presses.""" - assert "transport.onUploadPolicy" in app + """ + The operator may be someone else entirely, ejecting a drive while you have + the group open. A file list that survives until the next reconnection is a + list somebody clicks. + """ + assert "transport.onRootsChanged" in app transport = TRANSPORT.read_text(encoding="utf-8") - assert "member_upload_ack" in transport, "nothing routes the node's notice" + assert "root_eject_ack" in transport, "nothing routes the node's notice" + +def test_the_notice_also_answers_the_operators_own_request(): + """ + The same message is both a broadcast and the reply to the request that + caused it. -def test_the_notice_still_answers_the_operators_own_request(app): - """The same message is both a broadcast and the reply to the request that - caused it — returning early on it would leave that request hanging until it - timed out.""" + Every other admin ack can be resolved and dropped, because its caller + already knows what it asked for and updates local state from that. The root + acks carry a whole table only the node can compute — availability, the name + it settled on, the eject a failed plug left in place — so resolving one + without handing it on left the operator who clicked Eject as the only + client that never saw it happen. + """ transport = TRANSPORT.read_text(encoding="utf-8") - # Scoped to member_upload_ack's own handler, not everything up to the next - # occurrence of "index_sync" — other handlers with their own, legitimate - # early `return` (index_progress, set_scan_settings_ack: neither is ever a - # reply anyone awaits) now sit between the two in the file. - block = transport[transport.index("member_upload_ack"):] - block = block[:block.index("apps_enabled_ack")] - assert "return" not in block + block = transport[transport.index("msg.type.endsWith('_ack')"):] + block = block[:block.index("_uploaders")] + assert "ROOT_ACK_TYPES" in block and "_onRootsChanged" in block, ( + "the initiating client resolves the ack and learns nothing from it") # ── Changing it ───────────────────────────────────────────────────────────── -def test_changing_it_is_signed(app): +def test_changing_a_root_is_signed(): transport = TRANSPORT.read_text(encoding="utf-8") - method = transport[transport.index("async setMemberUpload("):] - method = method[:method.index("\n async ", 1)] - assert "admin_challenge" in method and "_authorizeAdminOp" in method, ( - "an unsigned instruction would let any member turn uploads back on") + for method in ("updateRoot", "ejectRoot", "plugRoot"): + body = transport[transport.index(f"async {method}("):] + body = body[:body.index("\n async ", 1)] + assert "admin_challenge" in body and "_authorizeAdminOp" in body, ( + f"{method} is unsigned — any member could use it") def test_only_the_operator_is_offered_the_setting(): - panel = _component(GROUP_SETTINGS.read_text(encoding="utf-8"), "GroupSettingsPanel") - section = panel[panel.index("members.uploads_title") - 400: - panel.index("members.uploads_title")] - assert "isNodeAdmin && connected" in section + panel = _component(GROUP_SETTINGS.read_text(encoding="utf-8"), + "GroupSettingsPanel") + section = panel[panel.index("settings_node.shared_directories_title") - 600: + panel.index("settings_node.shared_directories_title")] + assert "isNodeAdmin &&" in section + + +def test_the_operator_is_offered_it_on_the_web_too(): + """ + An operator is not necessarily sitting at their node. The first version of + this section required the loopback API, which resolves to "not available" + in a browser — so it rendered for nobody on the web, while the upload + controls it replaced had worked there. + """ + source = GROUP_SETTINGS.read_text(encoding="utf-8") + panel = _component(source, "GroupSettingsPanel") + section = panel[panel.index("settings_node.shared_directories_title") - 600: + panel.index("settings_node.shared_directories_title")] + assert "connected ||" in section, ( + "the shared directories section still requires a local node") + + table = _component(source, "SharedDirectoriesTable") + for call in ("transport.updateRoot", "transport.ejectRoot", + "transport.plugRoot", "transport.removeRoot", + "transport.addRoot"): + assert call in table, f"{call} has no MNP route from the table" + + +def test_the_roots_shown_come_from_the_live_connection_when_there_is_one(): + """ + The loopback list is a second source, and the two drift: it is read once on + mount and after a change, while the MNP one is pushed. Preferring MNP also + keeps this table on the same data Files reads, so an eject shows in both at + the same instant. + """ + panel = _component(GROUP_SETTINGS.read_text(encoding="utf-8"), + "GroupSettingsPanel") + assert "const effectiveRoots = (connected && mnpRoots" in panel diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py index f45101f..4fc07ad 100644 --- a/packages/meshbay-node/src/meshbay_node/daemon.py +++ b/packages/meshbay-node/src/meshbay_node/daemon.py @@ -36,6 +36,7 @@ from pathlib import Path import uvicorn +from meshbay_common.paths import fold from meshbay_common import MNP_VERSION from meshbay_common.protocol import MNP from meshbay_node.audit import AuditStore @@ -128,6 +129,11 @@ class _WsSender: # ── Daemon ──────────────────────────────────────────────────────────────────── +def _root_shape(roots) -> set[tuple]: + """What has to match for a group's roots to count as unchanged on reload.""" + return {(r.name, str(r.path), r.writable, r.removable) for r in roots} + + class NodeDaemon: def __init__(self, config: Config, config_path: Path = DEFAULT_CONFIG_PATH): self._config = config @@ -303,7 +309,7 @@ class NodeDaemon: continue try: - roots = RootSet.build([asdict(r) for r in group_cfg.roots]) + roots = await self._build_roots(group_cfg) except RootError as e: # Configuration the operator has to fix; guessing would put # a member's file on the wrong disk or index one twice. @@ -330,7 +336,7 @@ class NodeDaemon: log.info("No GEK yet for group %s — will accept first setup", group_cfg.name) - # Read once at load, like member_upload/enabled_apps below — + # Read once at load, like enabled_apps below — # kept current in place afterwards by set_scan_settings # (ops.py), which updates both this indexer object directly # and roster.db, so a restart picks up the same values. @@ -347,6 +353,7 @@ class NodeDaemon: sk_node=keys.sk_ed25519, gek=gek, on_change=self._on_index_change, + on_root_ejected=self._eject_persister(group_cfg.id), cache=self._index_cache, reconcile_secs=scan_settings["reconcile_interval_secs"], debounce_secs=scan_settings["debounce_secs"], @@ -374,22 +381,19 @@ class NodeDaemon: "note_activity": indexer.note_activity, # Shown to the operator in Settings, and kept current in # place by set_scan_settings (ops.py) — same reasoning as - # member_upload below. + # enabled_apps below. "reconcile_interval_secs": scan_settings["reconcile_interval_secs"], "debounce_secs": scan_settings["debounce_secs"], "visibility": group_cfg.visibility, # Admission policy comes from node.toml, never from the hub: # a hub that could declare a group open would be handed its key. "join_policy": group_cfg.join_policy, - # Whether ordinary members may upload. Read once here, into - # the context, because the upload handler is synchronous and - # a database round trip per chunk would be absurd. The - # signed operation that changes it updates this dict in - # place, so the two never drift within a run. - "member_upload": await self._roster.member_upload_allowed( - group_cfg.id) if self._roster else True, - # Same reasoning: read once at load, kept current in place - # by the signed operation that changes it. + # Read once at load, kept current in place by the signed + # operation that changes it — the upload handler is + # synchronous and a database round trip per chunk would be + # absurd. (Whether a member may upload is not here any + # more: it is `writable` on the root being written to, + # which the RootSet above already carries.) "enabled_apps": await self._roster.enabled_apps( group_cfg.id) if self._roster else list(Roster.DEFAULT_APPS), # Which folder is the Videos app's entry point for this @@ -745,14 +749,16 @@ class NodeDaemon: if not ctx: continue try: - roots = RootSet.build([asdict(r) for r in group_cfg.roots]) + roots = await self._build_roots(group_cfg) except RootError as e: log.error("Group %r: %s — keeping the roots already loaded", group_cfg.name, e) continue - before = {(r.name, str(r.path)) for r in ctx["roots"]} - after = {(r.name, str(r.path)) for r in roots} - if before == after: + # `writable` and `removable` are in the comparison because an + # operator editing node.toml by hand and reloading is a supported + # way to change them, and a set compared on name and path alone + # reports "nothing changed" for exactly that edit. + if _root_shape(ctx["roots"]) == _root_shape(roots): continue roots.refresh_availability() indexer = next((i for i in self._indexers @@ -786,7 +792,7 @@ class NodeDaemon: continue try: - roots = RootSet.build([asdict(r) for r in group_cfg.roots]) + roots = await self._build_roots(group_cfg) except RootError as e: log.error("New group %r: %s — skipping", group_cfg.name, e) continue @@ -812,6 +818,7 @@ class NodeDaemon: sk_node=sk_ed, gek=gek, on_change=self._on_index_change, + on_root_ejected=self._eject_persister(group_cfg.id), cache=self._index_cache, reconcile_secs=scan_settings["reconcile_interval_secs"], debounce_secs=scan_settings["debounce_secs"], @@ -844,9 +851,6 @@ class NodeDaemon: "debounce_secs": scan_settings["debounce_secs"], "visibility": group_cfg.visibility, "join_policy": group_cfg.join_policy, - "member_upload": ( - await self._roster.member_upload_allowed(group_cfg.id) - if self._roster else True), "enabled_apps": ( await self._roster.enabled_apps(group_cfg.id) if self._roster else list(Roster.DEFAULT_APPS)), @@ -1052,6 +1056,35 @@ class NodeDaemon: log.debug("Index progress pushed to %d peer(s) for group %s", pushed, group_id[:8]) + async def _build_roots(self, group_cfg) -> RootSet: + """ + Build a group's RootSet from node.toml, with the ejected state restored. + + node.toml carries configuration (`writable`, `removable`); the roster + carries the runtime answer to "is this drive ejected right now". They + are merged here, in the one place every caller goes through, because a + root that quietly comes back available across a restart is exactly the + surprise unplug that eject exists to survive. + """ + specs = [asdict(r) for r in group_cfg.roots] + if self._roster: + ejected = await self._roster.ejected_roots(group_cfg.id) + if ejected: + for spec in specs: + name = spec.get("name") or Path(spec.get("path", "")).name + if fold(name) in ejected: + spec["ejected"] = True + return RootSet.build(specs) + + def _eject_persister(self, group_id: str): + """`on_root_ejected` bound to one group, for that group's indexer.""" + async def persist(root_name: str, ejected: bool) -> None: + if self._roster: + await self._roster.set_root_ejected( + group_id, root_name, ejected, + set_by=self._state.get("node_user_id", "")) + return persist + async def _on_index_change(self, indexer: DirectoryIndexer) -> None: """ Called when a DirectoryIndexer detects file changes — once per @@ -1716,10 +1749,10 @@ def main() -> None: help="group id (optional if only one is configured)") parser.add_argument("--writable", action="store_true", default=None, dest="writable", - help="mark root as read-write (root set/add)") + help="root accepts member uploads (root add/set)") parser.add_argument("--no-writable", action="store_false", dest="writable", - help="mark root as read-only (root set)") + help="root is read-only (root add/set, group add)") parser.add_argument("--removable", action="store_true", default=None, dest="removable", help="mark root as removable (root set/add)") @@ -2481,20 +2514,22 @@ def main() -> None: sys.exit(1) cfg = load_config(args.config or DEFAULT_CONFIG_PATH) - body = {"name": args.target, "shared_dir": args.dir} + # Writable unless the operator says otherwise: a brand-new group that + # cannot receive a single file until its owner finds a second command + # is not a working group. Every root added *later* is read-only by + # default, which is the opposite rule and the right one there. + writable = args.writable is not False + body = {"name": args.target, "shared_dir": args.dir, + "writable": writable} if args.upload_dir: - import warnings - warnings.warn( - "--upload-dir is deprecated; the main root is writable by " - "default. Use 'meshbay-node root add' for additional roots.", - DeprecationWarning, stacklevel=1) print("WARNING: --upload-dir is deprecated. The shared directory is " - "writable by default. Use 'meshbay-node root add' for " - "additional roots.") + "read-write by default; use 'meshbay-node root add " + "<path> --writable' for a second one.") body["upload_dir"] = args.upload_dir out = _daemon_api(cfg, "/api/groups/attach", method="POST", body=body) print(f"{out['name']} ({out['group_id'][:8]}) added to {out['config']}") - print(f" shared_dir {out['shared_dir']} (writable)") + print(f" shared_dir {out['shared_dir']}" + f" ({'read-write' if writable else 'read-only'})") if out.get("upload_dir"): print(f" upload_dir {out['upload_dir']}") print() @@ -2512,7 +2547,7 @@ def main() -> None: group_id = _resolve_group(cfg, args.group) if sub == "list": - out = _daemon_api(cfg, f"/api/groups") + out = _daemon_api(cfg, "/api/groups") group = next((g for g in out.get("groups", []) if g["id"] == group_id), None) if not group: diff --git a/packages/meshbay-node/src/meshbay_node/indexer/indexer.py b/packages/meshbay-node/src/meshbay_node/indexer/indexer.py index 2911278..f7ffdca 100644 --- a/packages/meshbay-node/src/meshbay_node/indexer/indexer.py +++ b/packages/meshbay-node/src/meshbay_node/indexer/indexer.py @@ -263,12 +263,17 @@ class DirectoryIndexer: cache: IndexCache | None = None, reconcile_secs: float = DEFAULT_RECONCILE_SECS, debounce_secs: float = DEFAULT_DEBOUNCE_SECS, + on_root_ejected: Callable[[str, bool], Awaitable[None]] | None = None, ): self.roots = roots self.group_id = group_id self.sk_node = sk_node self.gek = gek self.on_change = on_change + # Called with (root_name, ejected) whenever this indexer changes a + # root's ejected state by itself — the surprise-unplug safety net. + # The daemon writes it to the roster, so a restart does not undo it. + self.on_root_ejected = on_root_ejected self.reconcile_secs = reconcile_secs self.debounce_secs = debounce_secs # Current backoff delay — starts at reconcile_secs, doubles on every @@ -582,6 +587,17 @@ class DirectoryIndexer: changed = self.roots.refresh_availability() touched = False + # Drained before the loop below, because persisting the flag is what + # makes the safety net survive a restart — and a restart is exactly + # what an operator does after noticing a drive fell off. + while self.roots.auto_ejected: + name = self.roots.auto_ejected.pop(0) + if self.on_root_ejected: + try: + await self.on_root_ejected(name, True) + except Exception: + log.exception("Could not persist the auto-eject of root %r", name) + for root, available in changed: if available: log.info("Root %r is back — rescanning", root.name) diff --git a/packages/meshbay-node/src/meshbay_node/ops.py b/packages/meshbay-node/src/meshbay_node/ops.py index c3a3f9c..13a7250 100644 --- a/packages/meshbay-node/src/meshbay_node/ops.py +++ b/packages/meshbay-node/src/meshbay_node/ops.py @@ -390,7 +390,7 @@ async def list_groups(state: dict) -> dict: async def attach_group(state: dict, name: str, shared_dir: str, - upload_dir: str = "") -> dict: + upload_dir: str = "", writable: bool = True) -> dict: """ Write a new [[groups]] block into node.toml. @@ -448,7 +448,7 @@ async def attach_group(state: dict, name: str, shared_dir: str, # Forward slashes: a Windows path in a TOML basic string is a # parse error (`\U`, `\a`, ... are escapes). pathlib reads `/`. f' path = "{path.as_posix()}"\n' - f' writable = true\n') + f' writable = {"true" if writable else "false"}\n') try: with conf_path.open("a", encoding="utf-8", newline="\n") as f: f.write(block) @@ -457,6 +457,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), + "writable": writable, "note": "restart the node to pick it up"} return result @@ -629,7 +630,7 @@ def _remove_roots_block(conf_path: Path, group_id: str, conf_path.write_text("\n".join(new_lines), encoding="utf-8", newline="\n") return - raise OpError(f"Root path not found in config", status=404) + raise OpError("Root path not found in config", status=404) async def add_root(state: dict, group_id: str, path: str, *, @@ -670,9 +671,9 @@ async def add_root(state: dict, group_id: str, path: str, *, if kind != "generic": root_block += f'\n kind = "{added.kind}"' if writable: - root_block += f'\n writable = true' + root_block += '\n writable = true' if removable: - root_block += f'\n removable = true' + root_block += '\n removable = true' _insert_roots_block(conf_path, group_id, root_block) from meshbay_node.config import RootSpec @@ -822,18 +823,17 @@ async def eject_root(state: dict, group_id: str, root_name: str) -> dict: return {"status": "already_ejected", "name": root_name, "group_id": group_id, "roots": roots.describe()} - root.ejected = True - root.available = False - - roster = _roster(state) - if roster: - await roster.set_setting( - group_id, f"root_ejected:{fold(root_name)}", "1", - set_by=state.get("node_user_id", "")) - + # The indexer stops its watchdog and freezes the entries; it holds the same + # RootSet object, but the flags are set here too so a context whose indexer + # was replaced by a retarget cannot be left disagreeing with the roster. indexer = state.get("indexers", {}).get(group_id) if indexer: indexer.eject_root(root_name) + root.ejected = True + root.available = False + + await _roster(state).set_root_ejected( + group_id, root_name, True, set_by=state.get("node_user_id", "")) log.info("Root ejected: %s from group %s", root_name, group_id[:8]) return {"status": "ejected", "name": root_name, "group_id": group_id, @@ -869,18 +869,17 @@ async def plug_root(state: dict, group_id: str, root_name: str) -> dict: f"Directory not found: {root.path}. Is the device connected?", status=409) - root.ejected = False - root.available = True - - roster = _roster(state) - if roster: - await roster.set_setting( - group_id, f"root_ejected:{fold(root_name)}", "0", - set_by=state.get("node_user_id", "")) + # Persisted before the rescan, which can take minutes on a large library: + # a crash halfway through must leave the root plugged, not ejected with + # entries half rebuilt. + await _roster(state).set_root_ejected( + group_id, root_name, False, set_by=state.get("node_user_id", "")) indexer = state.get("indexers", {}).get(group_id) if indexer: await indexer.plug_root(root_name) + root.ejected = False + root.available = root.is_live() log.info("Root plugged: %s in group %s", root_name, group_id[:8]) return {"status": "plugged", "name": root_name, "group_id": group_id, @@ -947,7 +946,7 @@ def _update_root_field(conf_path: Path, group_id: str, conf_path.write_text("\n".join(lines), encoding="utf-8", newline="\n") return - raise OpError(f"Root path not found in config", status=404) + raise OpError("Root path not found in config", status=404) # ── Files ──────────────────────────────────────────────────────────────────── @@ -1125,6 +1124,8 @@ async def set_enabled_apps(state: dict, group_id: str, apps: list[str]) -> dict: """ roster = _roster(state) ctx = _group_ctx(state, group_id) + # See the same guard in webrtc_server._do_apps_enabled: Files cannot be + # turned off, and both writers put it at the front so the two agree. if "files" not in apps: apps = ["files"] + list(apps) await roster.set_enabled_apps(group_id, apps, diff --git a/packages/meshbay-node/src/meshbay_node/roots.py b/packages/meshbay-node/src/meshbay_node/roots.py index a83b729..9d3f7cb 100644 --- a/packages/meshbay-node/src/meshbay_node/roots.py +++ b/packages/meshbay-node/src/meshbay_node/roots.py @@ -165,6 +165,13 @@ class RootSet: roots: list[Root] = field(default_factory=list) + # Roots this set ejected by itself — a removable device that went away + # without the operator clicking Eject. Drained by the indexer, which is + # the only caller holding a roster to write the state to. Without that + # the flag is lost on the next restart, and the surprise unplug looks + # like a deletion all over again on the pass after it. + auto_ejected: list[str] = field(default_factory=list) + # ── Construction ───────────────────────────────────────────────────────── @classmethod @@ -212,9 +219,14 @@ class RootSet: # Backward compat: old configs use `upload` instead of `writable` writable = bool(spec.get("writable", spec.get("upload", False))) + # `ejected` is runtime state, not configuration — it reaches here + # only from the roster, restored at startup so a drive ejected + # before a restart does not come back on its own. root = Root(name=name, path=path, kind=kind, writable=writable, removable=bool(spec.get("removable", False)), + ejected=bool(spec.get("ejected", False)), + available=not bool(spec.get("ejected", False)), direct=bool(spec.get("direct", False))) _refuse_nesting(root, roots) roots.append(root) @@ -331,8 +343,13 @@ class RootSet: changed.append((root, False)) continue live = root.is_live() - if not live and root.removable and not root.ejected: + if not live and root.removable: root.ejected = True + # Recorded for the caller to persist. A flag that only lives + # in memory would be forgotten on the next restart, and the + # rescan that followed would read an empty mount point as an + # erased library — the exact outcome eject exists to prevent. + self.auto_ejected.append(root.name) log.warning("Root %r auto-ejected (device disappeared): %s", root.name, root.path) if live != root.available: diff --git a/packages/meshbay-node/src/meshbay_node/roster.py b/packages/meshbay-node/src/meshbay_node/roster.py index e2f749f..5f38acd 100644 --- a/packages/meshbay-node/src/meshbay_node/roster.py +++ b/packages/meshbay-node/src/meshbay_node/roster.py @@ -31,6 +31,8 @@ from pathlib import Path import aiosqlite +from meshbay_common.paths import fold + log = logging.getLogger(__name__) # Crockford base32 without I, L, O and U: no character pair a human can confuse @@ -543,10 +545,40 @@ class Roster: # ── Group settings ────────────────────────────────────────────────────── - # Whether members who are not the operator may upload. Default is yes: a - # group that nobody may add to is the unusual case, and an existing node - # must not change behaviour because a table was added under it. - SETTING_MEMBER_UPLOAD = "member_upload" + # Whether a root is ejected. Runtime state, one key per root, keyed by the + # *folded* name so it agrees with the case-insensitive comparison the rest + # of the root code makes. It lives here rather than in node.toml because it + # is not configuration — an operator's hand-written config file should not + # be rewritten because a USB drive was unplugged — and it has to survive a + # restart, or the rescan that follows reads an empty mount point as an + # erased library, which is the whole thing eject exists to prevent. + SETTING_ROOT_EJECTED_PREFIX = "root_ejected:" + + @classmethod + def root_ejected_key(cls, root_name: str) -> str: + return cls.SETTING_ROOT_EJECTED_PREFIX + fold(root_name) + + async def set_root_ejected(self, group_id: str, root_name: str, + ejected: bool, set_by: str = "") -> None: + await self.set_setting(group_id, self.root_ejected_key(root_name), + "1" if ejected else "0", set_by) + + async def ejected_roots(self, group_id: str) -> set[str]: + """ + The folded names of this group's ejected roots. + + Matched in Python rather than with `LIKE 'root_ejected:%'`: `_` is a + single-character wildcard there, so that pattern also matches keys this + does not own. A group has a handful of settings rows, so reading them + all costs nothing and the prefix test is then exact. + """ + prefix = self.SETTING_ROOT_EJECTED_PREFIX + async with self._db.execute( + "SELECT key, value FROM group_settings WHERE group_id = ?", + (group_id,)) as cur: + rows = await cur.fetchall() + return {r["key"][len(prefix):] for r in rows + if r["key"].startswith(prefix) and r["value"] == "1"} async def get_setting(self, group_id: str, key: str, default: str | None = None) -> str | None: @@ -567,17 +599,6 @@ class Roster: (group_id, key, value, set_by, _now())) await self._db.commit() - async def member_upload_allowed(self, group_id: str) -> bool: - """Whether an ordinary member may upload to this group.""" - value = await self.get_setting(group_id, self.SETTING_MEMBER_UPLOAD, "1") - return value != "0" - - async def set_member_upload(self, group_id: str, allowed: bool, - set_by: str = "") -> bool: - await self.set_setting(group_id, self.SETTING_MEMBER_UPLOAD, - "1" if allowed else "0", set_by) - return allowed - # Which group "applications" (Chat, Files, and whatever registers later in # apps.js) are shown to members. Unset means every app that exists — an # existing group's tabs must not disappear because a node was upgraded. @@ -609,7 +630,7 @@ class Roster: # user_id)` authorizing the operator node-wide (desktop-client-v1.md # §6.3). Unset means "the shipped default token, TMDB's own default # language" — the same "absent means the old behaviour" discipline - # member_upload/enabled_apps already follow. + # enabled_apps already follows. # # Whether TMDB is used *at all*, though, is per-group (moved off the # node-wide sentinel below, 2026-08-24): an operator running a real media 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 4d6dd34..b99affc 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py @@ -1787,7 +1787,7 @@ class WebRTCPeerSession: """ Turn a group "application" on or off for everyone, for this group. - Signed like `member_upload`: this decides what a member sees, and an + Signed like the root ops: this decides what a member sees, and an unsigned message would let any member turn a disabled one back on. """ apps = msg.get("apps") @@ -1799,8 +1799,12 @@ class WebRTCPeerSession: self._send({"type": "error", "detail": f"Unknown app(s): {', '.join(sorted(unknown))}"}) return + # Files is not a toggle: MNP permits root exploration regardless of + # what this list says, so hiding the tab only ever misled. Added at the + # front, the same order ops.set_enabled_apps writes, so the landing-tab + # preference sees one list and not two. if "files" not in apps: - apps.append("files") + apps.insert(0, "files") if not self._has_admin_authority(): self._send({"type": "error", "detail": "No authorized key for this"}) return @@ -1884,7 +1888,7 @@ class WebRTCPeerSession: self._audit("tmdb_config", pending["subject"]) # Node-wide setting: every connected peer in every group is told, not - # just this group's peers (unlike apps_enabled/member_upload/the + # just this group's peers (unlike apps_enabled/the root ops/the # per-group tmdb_enabled below). notice = { "type": MNP.TMDB_CONFIG_ACK, "v": MNP_VERSION, @@ -3795,24 +3799,34 @@ class WebRTCPeerSession: "filename": filename}) return - # The client names the target root. If absent, pick the first writable - # one (backward compat with old clients that don't send it). - target_root_name = msg.get("root") + # The client names the root it is uploading into — it is browsing one, + # and with several writable roots any other choice is a guess. It names + # a root, never a path: the destination inside it is decided below and + # is not negotiable, which is what keeps C5a closed. + # + # An unknown name is refused rather than falling back to a writable + # root, because "the file went somewhere else" is discovered weeks + # later — the same reason the old single upload root was never guessed. + # A client that names nothing is an MNP 1.0 one, and there was exactly + # one destination in its world: the first writable root. + target_root_name = str(msg.get("root") or "").strip() upload_root = None if target_root_name: - from meshbay_common.paths import fold - target_folded = fold(target_root_name) - for r in roots: - if fold(r.name) == target_folded: - upload_root = r - break + upload_root = roots.by_name(target_root_name) + if upload_root is None: + self._send({"type": "error", + "detail": f"No directory named " + f"{target_root_name!r} in this group", + "code": "no_such_root", + "filename": filename}) + return else: writable = roots.writable_roots upload_root = writable[0] if writable else None if upload_root is None: self._send({"type": "error", - "detail": "No writable directory found for uploads", + "detail": "No writable directory in this group", "code": "no_writable_root", "filename": filename}) return @@ -3827,6 +3841,7 @@ class WebRTCPeerSession: self._send({"type": "error", "detail": f"Directory '{upload_root.name}' is " f"currently unavailable", + "code": "root_unavailable", "filename": filename}) return diff --git a/packages/meshbay-node/src/meshbay_node/ui/app.py b/packages/meshbay-node/src/meshbay_node/ui/app.py index 3d24000..ef86ce3 100644 --- a/packages/meshbay-node/src/meshbay_node/ui/app.py +++ b/packages/meshbay-node/src/meshbay_node/ui/app.py @@ -176,6 +176,7 @@ def create_ui_app(state: dict) -> FastAPI: (payload.get("name") or "").strip(), (payload.get("shared_dir") or "").strip(), upload_dir=(payload.get("upload_dir") or "").strip(), + writable=bool(payload.get("writable", True)), )) reload_fn = state.get("reload_fn") if reload_fn: @@ -419,14 +420,6 @@ def create_ui_app(state: dict) -> FastAPI: "current_dir": progress.current_dir, } - # ── Upload toggle (DEPRECATED — per-root writable replaces this) ──── - - @app.put("/api/groups/{group_id}/member-upload") - async def set_member_upload(group_id: str, payload: dict): - log.warning("PUT member-upload is deprecated — use PATCH roots/{name} " - "with writable instead") - return {"deprecated": True, "message": "Use per-root writable flag"} - # ── Enabled apps (operator only, localhost) ──────────────────────────── # # Same loopback shape as member-upload: the Create Group wizard sets this diff --git a/packages/meshbay-node/tests/conftest.py b/packages/meshbay-node/tests/conftest.py index 20724aa..3dc9cd9 100644 --- a/packages/meshbay-node/tests/conftest.py +++ b/packages/meshbay-node/tests/conftest.py @@ -24,14 +24,18 @@ win32_todo = pytest.mark.skipif( ) -def one_root(path: Path, *, name: str = "", kind: str = "generic") -> RootSet: +def one_root(path: Path, *, name: str = "", kind: str = "generic", + writable: bool = True) -> RootSet: """ - A RootSet with a single root over `path`, receiving uploads. + A RootSet with a single writable root over `path`. The equivalent of the old `shared_dir`. Note what it implies for assertions: a file directly in `path` now has `entry.path == <basename of path>`, not `""` — every index path carries its root name, in a group with one root as much as in a group with five. + + Writable by default because most callers are testing something else and + want a root an upload can reach. `writable=False` is the read-only group. """ return RootSet.build([{"path": str(path), "name": name, "kind": kind, - "upload": True}]) + "writable": writable}]) diff --git a/packages/meshbay-node/tests/test_apps_enabled_policy.py b/packages/meshbay-node/tests/test_apps_enabled_policy.py index 671005a..ac44ab3 100644 --- a/packages/meshbay-node/tests/test_apps_enabled_policy.py +++ b/packages/meshbay-node/tests/test_apps_enabled_policy.py @@ -1,7 +1,7 @@ """ The operator decides which group "applications" (Chat, Files, ...) are shown. -Same shape as `test_member_upload_policy.py`, because it is the same kind of +Same shape as `test_root_writable_policy.py`, because it is the same kind of setting: changed by a signed operator instruction, stored on the node rather than the hub, and safe for an existing group to have never heard of. The two things specific to this one: the whole set is signed in one message rather @@ -88,7 +88,7 @@ async def test_a_request_with_nobody_to_authorize_it_is_refused(tmp_path): async def test_changing_it_needs_a_signature(tmp_path): """The request only ever produces a challenge. Nothing is applied until a - signature over the transcript verifies — the same path as member_upload.""" + signature over the transcript verifies — the same path as the root ops.""" session = _session(tmp_path, "op", operator="op") session._has_admin_authority = lambda: True issued = [] diff --git a/packages/meshbay-node/tests/test_cli_dispatch.py b/packages/meshbay-node/tests/test_cli_dispatch.py index f58c020..2ba251f 100644 --- a/packages/meshbay-node/tests/test_cli_dispatch.py +++ b/packages/meshbay-node/tests/test_cli_dispatch.py @@ -26,6 +26,15 @@ VERBS = [ ["status"], ["group", "list"], ["group", "add"], # missing --dir: usage, then exit + ["group", "add", "g", "--dir", "/tmp/media", "--no-writable"], + ["root", "list"], + ["root", "add"], # missing path: usage, then exit + ["root", "add", "/tmp/media", "--writable", "--removable"], + ["root", "remove", "media", "--yes"], + ["root", "set", "media", "--no-writable"], + ["root", "set", "media"], # nothing to change: usage, then exit + ["root", "eject", "media"], + ["root", "plug", "media"], ["gek", "init"], ["gek", "rotate", "--yes"], ["gek-init"], diff --git a/packages/meshbay-node/tests/test_member_upload_policy.py b/packages/meshbay-node/tests/test_member_upload_policy.py deleted file mode 100644 index b1dc0cb..0000000 --- a/packages/meshbay-node/tests/test_member_upload_policy.py +++ /dev/null @@ -1,176 +0,0 @@ -""" -The operator can close uploading to everyone but themselves. - -The point of these tests is the difference between a hidden button and a closed -door. The interface stops offering the control, which is a courtesy to the -people who are not trying; **the node refuses the upload**, which is the part -that holds against someone who is. A member who kept an old tab open, or who -speaks MNP directly, gets the same answer as everyone else. - -Two further things are worth holding: - -* the setting is changed by a **signed** operator instruction. A node that took - it from an unsigned message would let any member turn it back on, and the - control would be a suggestion; -* it is stored on the **node**, not the hub. A hub that could decide who may - write to the operator's disk is a hub with authority over the node, which is - the thing this whole design is arranged to avoid. -""" - -import base64 -from pathlib import Path - -import pytest -from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey - -from meshbay_common.adminop import OP_MEMBER_UPLOAD -from meshbay_node.indexer.group_index import GroupIndex -from meshbay_node.roster import Roster -from meshbay_node.transport.webrtc_server import WebRTCPeerSession - -from conftest import one_root - -pytestmark = pytest.mark.asyncio - - -def _session(tmp_path: Path, user_id: str, *, member_upload: bool, - operator: str | None = None) -> WebRTCPeerSession: - shared_root = tmp_path / "shared" - shared_root.mkdir(exist_ok=True) - index = GroupIndex(group_id="g" * 32, sk_node=Ed25519PrivateKey.generate()) - ctx = { - "roots": one_root(shared_root), - "index": index, - "sk_node": index.sk_node, - "member_upload": member_upload, - "node_user_id": operator, - } - session = WebRTCPeerSession.__new__(WebRTCPeerSession) - session._ctx = ctx - session._group_id = None - session._user_id = user_id - session._pk_user = "" - session._uploads = {} - session.sent = [] - session._send = session.sent.append - session._audit = lambda *a, **k: None - return session - - -def _upload(session, filename="clip.mp4", body=b"bytes"): - session._do_file_upload({ - "filename": filename, "chunk_index": 0, "total_chunks": 1, - "data": base64.b64encode(body).decode(), - }) - - -def _uploads_dir(session) -> Path: - return session._ctx["roots"].upload_root.path / "uploads" - - -# ── The door, not the button ──────────────────────────────────────────────── - -async def test_a_member_cannot_upload_when_it_is_turned_off(tmp_path): - session = _session(tmp_path, "member-1", member_upload=False, - operator="the-operator") - _upload(session) - - assert not (_uploads_dir(session) / "clip.mp4").exists(), ( - "the file was written even though uploading is off — the setting is " - "decorative and the hidden button was the whole control") - refusal = [m for m in session.sent if m.get("type") == "error"] - assert refusal and refusal[0].get("code") == "member_upload_off" - - -async def test_the_operator_can_still_upload(tmp_path): - """Otherwise turning it off locks the operator out of their own node, and - the only way back is a config file and a restart.""" - session = _session(tmp_path, "the-operator", member_upload=False, - operator="the-operator") - _upload(session) - - assert (_uploads_dir(session) / "clip.mp4").read_bytes() == b"bytes" - - -async def test_members_upload_normally_when_it_is_on(tmp_path): - session = _session(tmp_path, "member-1", member_upload=True, - operator="the-operator") - _upload(session) - - assert (_uploads_dir(session) / "clip.mp4").read_bytes() == b"bytes" - - -async def test_a_node_that_never_heard_of_the_setting_still_accepts_uploads(tmp_path): - """An existing node's context has no such key. The absence must read as - "allowed", or upgrading the node silently closes every group.""" - session = _session(tmp_path, "member-1", member_upload=True, - operator="the-operator") - del session._ctx["member_upload"] - _upload(session) - - assert (_uploads_dir(session) / "clip.mp4").read_bytes() == b"bytes" - - -# ── Who may change it ─────────────────────────────────────────────────────── - -async def test_changing_it_needs_a_signature(tmp_path): - """ - The request only ever produces a challenge. Nothing is applied until a - signature over the transcript verifies — the same path as removing a member. - """ - session = _session(tmp_path, "member-1", member_upload=True, - operator="the-operator") - session._has_admin_authority = lambda: True - issued = [] - session._issue_admin_challenge = lambda op, subject: issued.append((op, subject)) - - session._do_member_upload({"allowed": False}) - - assert issued == [(OP_MEMBER_UPLOAD, "off")] - assert session._ctx["member_upload"] is True, "applied before it was signed" - - -async def test_the_subject_names_the_outcome_not_the_operation(tmp_path): - """The operator is shown the subject before signing. "member_upload" tells - them nothing; "off" tells them what they are about to do.""" - session = _session(tmp_path, "op", member_upload=False, operator="op") - session._has_admin_authority = lambda: True - issued = [] - session._issue_admin_challenge = lambda op, subject: issued.append((op, subject)) - - session._do_member_upload({"allowed": True}) - - assert issued == [(OP_MEMBER_UPLOAD, "on")] - - -async def test_a_request_with_nobody_to_authorize_it_is_refused(tmp_path): - session = _session(tmp_path, "member-1", member_upload=True, - operator="the-operator") - session._has_admin_authority = lambda: False - - session._do_member_upload({"allowed": False}) - - assert [m for m in session.sent if m.get("type") == "error"] - - -# ── Where it is stored ────────────────────────────────────────────────────── - -async def test_the_setting_lives_on_the_node_and_survives_a_restart(tmp_path): - roster = Roster(db_path=tmp_path / "roster.db") - await roster.open() - try: - assert await roster.member_upload_allowed("g1") is True, ( - "absent must mean allowed, or an upgrade closes every group") - await roster.set_member_upload("g1", False, set_by="op") - assert await roster.member_upload_allowed("g1") is False - finally: - await roster.close() - - reopened = Roster(db_path=tmp_path / "roster.db") - await reopened.open() - try: - assert await reopened.member_upload_allowed("g1") is False - assert await reopened.member_upload_allowed("g2") is True, ( - "one group's setting must not answer for another") - finally: - await reopened.close() diff --git a/packages/meshbay-node/tests/test_node_status.py b/packages/meshbay-node/tests/test_node_status.py index b56eb6e..091b1db 100644 --- a/packages/meshbay-node/tests/test_node_status.py +++ b/packages/meshbay-node/tests/test_node_status.py @@ -255,7 +255,7 @@ async def test_add_root_creates_directory_and_returns_info(tmp_path): from meshbay_node.config import NodeConfig, GroupConfig, RootSpec cfg = GroupConfig(id=GROUP, name="test", roots=[ - RootSpec(path=str(shared), name="shared", kind="generic", upload=True), + RootSpec(path=str(shared), name="shared", kind="generic", writable=True), ]) conf = tmp_path / "node.toml" @@ -295,7 +295,7 @@ async def test_remove_root_requires_at_least_one_remaining(tmp_path): from meshbay_node.config import GroupConfig, RootSpec, NodeConfig cfg = GroupConfig(id=GROUP, name="test", roots=[ - RootSpec(path=str(shared), name="shared", kind="generic", upload=True), + RootSpec(path=str(shared), name="shared", kind="generic", writable=True), ]) node_cfg = NodeConfig.__new__(NodeConfig) node_cfg.groups = [cfg] @@ -314,16 +314,22 @@ async def test_remove_root_requires_at_least_one_remaining(tmp_path): await ops.remove_root(state, GROUP, "shared") -async def test_remove_root_refuses_upload_root(tmp_path): - d1 = tmp_path / "uploads" +async def test_removing_a_writable_root_is_allowed(tmp_path): + """ + It used to be refused: with one designated upload root, removing it left + the group with nowhere to put an upload and no way to say so. Several roots + can be writable now, and a group with none is a valid read-only group — so + the refusal would be protecting a state that is no longer special. + """ + d1 = tmp_path / "incoming" d2 = tmp_path / "shared" d1.mkdir() d2.mkdir() from meshbay_node.config import GroupConfig, RootSpec, NodeConfig cfg = GroupConfig(id=GROUP, name="test", roots=[ - RootSpec(path=str(d1), name="uploads", kind="generic", upload=True), - RootSpec(path=str(d2), name="shared", kind="generic", upload=False), + RootSpec(path=str(d1), name="incoming", kind="generic", writable=True), + RootSpec(path=str(d2), name="shared", kind="generic", writable=False), ]) node_cfg = NodeConfig.__new__(NodeConfig) node_cfg.groups = [cfg] @@ -331,7 +337,7 @@ async def test_remove_root_refuses_upload_root(tmp_path): conf = tmp_path / "node.toml" conf.write_text( f'[[groups]]\nid = "{GROUP}"\nname = "test"\n\n' - f' [[groups.roots]]\n path = "{d1}"\n name = "uploads"\n upload = true\n\n' + f' [[groups.roots]]\n path = "{d1}"\n name = "incoming"\n writable = true\n\n' f' [[groups.roots]]\n path = "{d2}"\n name = "shared"\n') roots = RootSet.build([asdict(r) for r in cfg.roots]) index = GroupIndex(group_id=GROUP, sk_node=Ed25519PrivateKey.generate()) @@ -340,8 +346,97 @@ async def test_remove_root_refuses_upload_root(tmp_path): "config_path": str(conf), "groups_ctx": {GROUP: {"index": index, "roots": roots, "gek": b"\x01" * 32}}, } - with pytest.raises(ops.OpError, match="upload root"): - await ops.remove_root(state, GROUP, "uploads") + result = await ops.remove_root(state, GROUP, "incoming") + assert result["status"] == "removed" + assert [r["name"] for r in result["roots"]] == ["shared"] + assert conf.read_text().count("[[groups.roots]]") == 1 + + +async def test_update_root_rewrites_the_flags_in_node_toml(tmp_path): + """ + The flags live in the operator's config file, so they survive a restart — + and the file is hand-written and full of comments, so the change is a line + edit rather than a round trip through a TOML writer that would discard + every one of them. + """ + d1 = tmp_path / "media" + d1.mkdir() + + from meshbay_node.config import GroupConfig, RootSpec, NodeConfig + cfg = GroupConfig(id=GROUP, name="test", roots=[ + RootSpec(path=str(d1), name="media", kind="generic", writable=False), + ]) + node_cfg = NodeConfig.__new__(NodeConfig) + node_cfg.groups = [cfg] + + conf = tmp_path / "node.toml" + conf.write_text( + f'[[groups]]\nid = "{GROUP}"\nname = "test"\n\n' + f' [[groups.roots]]\n' + f' # the operator explained this one to themselves\n' + f' path = "{d1}"\n name = "media"\n') + roots = RootSet.build([asdict(r) for r in cfg.roots]) + index = GroupIndex(group_id=GROUP, sk_node=Ed25519PrivateKey.generate()) + state = { + "config": node_cfg, + "config_path": str(conf), + "groups_ctx": {GROUP: {"index": index, "roots": roots, "gek": b"\x01" * 32}}, + } + + result = await ops.update_root(state, GROUP, "media", + writable=True, removable=True) + assert result["status"] == "updated" + text = conf.read_text() + assert "writable = true" in text + assert "removable = true" in text + assert "the operator explained this one to themselves" in text, ( + "the config file was rewritten instead of edited") + + # And the live root set agrees immediately, without waiting for a reload: + # the loopback API reads it, and an operator who toggles a switch and sees + # it snap back assumes the change did not take. + assert roots.roots[0].writable is True + assert roots.roots[0].removable is True + + # A second call that changes nothing must not append a duplicate line. + await ops.update_root(state, GROUP, "media", writable=True, removable=True) + assert conf.read_text().count("writable =") == 1 + + +async def test_update_root_replaces_a_legacy_upload_line(tmp_path): + """ + A config written before the refactor says `upload = true`. Leaving it in + place next to a new `writable` line would give the file two answers, and + `RootSet.build` prefers `writable` — so the stale one would sit there + contradicting the running node for as long as anyone read it. + """ + d1 = tmp_path / "media" + d1.mkdir() + + from meshbay_node.config import GroupConfig, RootSpec, NodeConfig + cfg = GroupConfig(id=GROUP, name="test", roots=[ + RootSpec(path=str(d1), name="media", kind="generic", writable=True), + ]) + node_cfg = NodeConfig.__new__(NodeConfig) + node_cfg.groups = [cfg] + + conf = tmp_path / "node.toml" + conf.write_text( + f'[[groups]]\nid = "{GROUP}"\nname = "test"\n\n' + f' [[groups.roots]]\n path = "{d1}"\n name = "media"\n' + f' upload = true\n') + roots = RootSet.build([asdict(r) for r in cfg.roots]) + index = GroupIndex(group_id=GROUP, sk_node=Ed25519PrivateKey.generate()) + state = { + "config": node_cfg, + "config_path": str(conf), + "groups_ctx": {GROUP: {"index": index, "roots": roots, "gek": b"\x01" * 32}}, + } + + await ops.update_root(state, GROUP, "media", writable=False) + text = conf.read_text() + assert "upload = true" not in text + assert "writable = false" in text async def test_remove_root_succeeds_with_two_roots(tmp_path): @@ -352,8 +447,8 @@ async def test_remove_root_succeeds_with_two_roots(tmp_path): from meshbay_node.config import GroupConfig, RootSpec, NodeConfig cfg = GroupConfig(id=GROUP, name="test", roots=[ - RootSpec(path=str(d1), name="dir1", kind="generic", upload=True), - RootSpec(path=str(d2), name="dir2", kind="generic", upload=False), + RootSpec(path=str(d1), name="dir1", kind="generic", writable=True), + RootSpec(path=str(d2), name="dir2", kind="generic", writable=False), ]) node_cfg = NodeConfig.__new__(NodeConfig) node_cfg.groups = [cfg] diff --git a/packages/meshbay-node/tests/test_ops.py b/packages/meshbay-node/tests/test_ops.py index 92e32bf..b3f0378 100644 --- a/packages/meshbay-node/tests/test_ops.py +++ b/packages/meshbay-node/tests/test_ops.py @@ -12,11 +12,13 @@ call them. import asyncio import inspect from pathlib import Path +from types import SimpleNamespace import pytest from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from meshbay_node import ops from meshbay_node.indexer.group_index import GroupIndex +from meshbay_node.roots import RootSet from meshbay_node.transport.quic_server import Denylist from conftest import one_root @@ -64,8 +66,8 @@ def test_the_http_adapter_adds_no_logic(): # 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", - "add_root", "remove_root", "set_member_upload", - "reload_config"): + "add_root", "remove_root", "update_root", + "eject_root", "plug_root", "reload_config"): start = source.index(f"async def {endpoint}(") body = source[start:start + 700] assert "_op(" in body.split("\n\n")[0] + body, ( @@ -181,25 +183,103 @@ async def test_an_unhosted_group_offers_what_it_does_host(tmp_path): assert exc.value.extra.get("available") -# ── Upload policy (set_member_upload) ─────────────────────────────────────── +# ── Upload policy (per-root writable) ─────────────────────────────────────── -async def test_set_member_upload_toggles_and_persists(tmp_path): +async def test_the_group_wide_upload_switch_is_gone(tmp_path): + """ + `set_member_upload` was the whole of the old policy, and it is deliberately + not here any more — RO/RW on the root replaced it. A wrapper kept "for + compatibility" would be a second way to decide who writes to the operator's + disk, and two answers to that question is how C1 and C6 both happened. + """ + assert not hasattr(ops, "set_member_upload") + from meshbay_node.roster import Roster + assert not hasattr(Roster, "set_member_upload") + assert not hasattr(Roster, "member_upload_allowed") + + +async def test_eject_and_plug_persist_through_the_roster(tmp_path): + """ + The state has to outlive the process: an operator ejects a drive, unplugs + it, and restarts the node — and the rescan that follows must not read the + empty mount point as an erased library. + """ from meshbay_node.roster import Roster state = _state(tmp_path) + usb = tmp_path / "USB" + usb.mkdir() + state["groups_ctx"]["g" * 32]["roots"] = RootSet.build( + [{"path": str(usb), "removable": True, "writable": True}]) + state["config"] = SimpleNamespace( + groups=[SimpleNamespace(id="g" * 32, roots=[])]) roster = Roster(db_path=tmp_path / "roster.db") await roster.open() state["roster"] = roster state["node_user_id"] = "operator" + try: + out = await ops.eject_root(state, "g" * 32, "USB") + assert out["status"] == "ejected" + assert await roster.ejected_roots("g" * 32) == {"usb"} + assert out["roots"][0]["ejected"] is True + assert out["roots"][0]["available"] is False + + out = await ops.plug_root(state, "g" * 32, "USB") + assert out["status"] == "plugged" + assert await roster.ejected_roots("g" * 32) == set() + finally: + await roster.close() - 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 +async def test_a_root_that_is_not_removable_cannot_be_ejected(tmp_path): + """ + Eject means "I am about to unplug this". On a directory that is not on a + removable device it would hide a library with no way for the safety net to + notice anything happened, and nothing to plug back in. + """ + from meshbay_node.roster import Roster + state = _state(tmp_path) + fixed = tmp_path / "Fixed" + fixed.mkdir() + state["groups_ctx"]["g" * 32]["roots"] = RootSet.build( + [{"path": str(fixed), "writable": True}]) + state["config"] = SimpleNamespace( + groups=[SimpleNamespace(id="g" * 32, roots=[])]) + roster = Roster(db_path=tmp_path / "roster.db") + await roster.open() + state["roster"] = roster + try: + with pytest.raises(ops.OpError, match="removable"): + await ops.eject_root(state, "g" * 32, "Fixed") + finally: + await roster.close() - 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 +async def test_plugging_a_drive_that_is_not_there_is_refused(tmp_path): + """ + Clearing the flag while the device is still absent would restart the + watchdog on a missing path and hand the next reconcile an empty directory — + the deletion storm the eject was there to prevent, produced by the recovery. + """ + from meshbay_node.roster import Roster + state = _state(tmp_path) + usb = tmp_path / "USB" + usb.mkdir() + roots = RootSet.build([{"path": str(usb), "removable": True}]) + roots.roots[0].ejected = True + roots.roots[0].available = False + state["groups_ctx"]["g" * 32]["roots"] = roots + state["config"] = SimpleNamespace( + groups=[SimpleNamespace(id="g" * 32, roots=[])]) + roster = Roster(db_path=tmp_path / "roster.db") + await roster.open() + state["roster"] = roster + usb.rmdir() + try: + with pytest.raises(ops.OpError, match="device connected"): + await ops.plug_root(state, "g" * 32, "USB") + assert roots.roots[0].ejected is True + finally: + await roster.close() # ── Reload ────────────────────────────────────────────────────────────────── diff --git a/packages/meshbay-node/tests/test_root_availability.py b/packages/meshbay-node/tests/test_root_availability.py index 0201c1f..d514dee 100644 --- a/packages/meshbay-node/tests/test_root_availability.py +++ b/packages/meshbay-node/tests/test_root_availability.py @@ -26,9 +26,12 @@ from meshbay_node.roots import RootSet pytestmark = pytest.mark.asyncio -def _roots(*paths: Path) -> RootSet: +def _roots(*paths: Path, removable: bool = False) -> RootSet: specs = [{"path": str(p)} for p in paths] - specs[0]["upload"] = True + specs[0]["writable"] = True + if removable: + for spec in specs: + spec["removable"] = True return RootSet.build(specs) @@ -117,7 +120,9 @@ async def test_members_are_told_which_roots_are_unavailable(tmp_path): idx = await _indexer(_roots(films)) assert idx.index.roots == [ - {"name": "Films", "kind": "generic", "available": True, "upload": True}] + {"name": "Films", "kind": "generic", "available": True, + "writable": True, "removable": False, "ejected": False, + "upload": True}] (films / "a.mkv").unlink() films.rmdir() diff --git a/packages/meshbay-node/tests/test_root_eject.py b/packages/meshbay-node/tests/test_root_eject.py new file mode 100644 index 0000000..0ec36a4 --- /dev/null +++ b/packages/meshbay-node/tests/test_root_eject.py @@ -0,0 +1,268 @@ +""" +Safe eject, and the surprise unplug it exists to survive. + +`test_root_availability.py` pins the freeze: a root that goes away keeps its +entries. This pins the half the operator drives — telling the node the drive is +about to leave, and telling it the drive is back. + +The distinction that makes any of this work is that `ejected` and `is_live()` +are separate answers. Between clicking Eject and physically unplugging, the +directory is still readable; a design that recomputed availability from the +filesystem alone would flip the root straight back to available and start +serving files from a disk somebody has their hand on. + +The other property here is that the flag is *persisted*. It reached the roster +in the first implementation and was never read back, so a restart — which is +exactly what an operator does after noticing a drive fell off — silently undid +the eject, and the next scan read an empty mount point as an erased library. +""" + +import asyncio +from pathlib import Path + +import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +from meshbay_node.indexer.indexer import DirectoryIndexer +from meshbay_node.roots import RootSet +from meshbay_node.roster import Roster + +pytestmark = pytest.mark.asyncio + + +def _roots(*paths: Path, removable: bool = True) -> RootSet: + return RootSet.build([ + {"path": str(p), "removable": removable} for p in paths]) + + +async def _indexer(roots: RootSet, **kw) -> DirectoryIndexer: + idx = DirectoryIndexer(roots=roots, group_id="g" * 32, + sk_node=Ed25519PrivateKey.generate(), gek=None, **kw) + await idx.initial_scan() + return idx + + +def _names(idx: DirectoryIndexer) -> set[str]: + return {e.name for e in idx.index.entries} + + +# ── The two states are not the same question ───────────────────────────────── + +async def test_ejecting_hides_a_root_that_is_still_readable(tmp_path): + """ + The whole point of an eject button: the operator says the drive is leaving + *before* it leaves. The directory is still there and still readable at this + moment, so anything deriving availability from the filesystem would refuse + to believe it. + """ + films = tmp_path / "Films" + films.mkdir() + (films / "a.mkv").write_bytes(b"a") + + roots = _roots(films) + idx = await _indexer(roots) + idx.eject_root("Films") + + assert films.is_dir(), "the drive has not been unplugged yet" + assert roots.roots[0].is_live() is True + assert roots.roots[0].available is False + assert idx.index.roots[0]["ejected"] is True + assert idx.index.roots[0]["available"] is False + + +async def test_an_eject_freezes_entries_rather_than_dropping_them(tmp_path): + films = tmp_path / "Films" + films.mkdir() + (films / "a.mkv").write_bytes(b"a") + (films / "b.mkv").write_bytes(b"b") + + idx = await _indexer(_roots(films)) + idx.eject_root("Films") + + assert _names(idx) == {"a.mkv", "b.mkv"}, "eject deleted entries" + + +async def test_reconciling_does_not_un_eject_a_root(tmp_path): + """ + The backstop runs every minute regardless. An ejected root whose directory + is still readable must stay ejected, or the operator's eject lasts until + the next tick. + """ + films = tmp_path / "Films" + films.mkdir() + (films / "a.mkv").write_bytes(b"a") + + roots = _roots(films) + idx = await _indexer(roots) + idx.eject_root("Films") + await idx.reconcile() + + assert roots.roots[0].ejected is True + assert roots.roots[0].available is False + + +async def test_plugging_back_relists_the_files(tmp_path): + films = tmp_path / "Films" + films.mkdir() + (films / "a.mkv").write_bytes(b"a") + + roots = _roots(films) + idx = await _indexer(roots) + idx.eject_root("Films") + await idx.plug_root("Films") + + assert roots.roots[0].ejected is False + assert roots.roots[0].available is True + assert _names(idx) == {"a.mkv"} + + +async def test_what_changed_while_unplugged_is_picked_up_on_plug(tmp_path): + """ + A drive people take away comes back different. The plug pass has to see + that, or the index describes a library that no longer exists on the disk + the node is about to serve from. + """ + films = tmp_path / "Films" + films.mkdir() + (films / "a.mkv").write_bytes(b"a") + + roots = _roots(films) + idx = await _indexer(roots) + idx.eject_root("Films") + + (films / "a.mkv").unlink() + (films / "c.mkv").write_bytes(b"c") + + await idx.plug_root("Films") + assert _names(idx) == {"c.mkv"} + + +# ── The surprise unplug ────────────────────────────────────────────────────── + +async def test_a_removable_root_that_vanishes_is_auto_ejected(tmp_path): + """ + Nobody clicks Eject when they are in a hurry. A removable root whose path + disappears is treated as ejected rather than merely unavailable, so it does + not silently come back the moment the same mount point is readable again — + which on a machine with automount is any other drive, or an empty stub. + """ + films = tmp_path / "Films" + films.mkdir() + (films / "a.mkv").write_bytes(b"a") + + roots = _roots(films) + idx = await _indexer(roots) + + (films / "a.mkv").unlink() + films.rmdir() + await idx.reconcile() + + assert roots.roots[0].ejected is True + assert _names(idx) == {"a.mkv"}, "the library was treated as erased" + + +async def test_a_non_removable_root_is_not_auto_ejected(tmp_path): + """ + The counter-property. Auto-eject requires the operator to have said the + device is removable; an ordinary directory that briefly fails to stat must + keep the old behaviour and come back on its own. + """ + films = tmp_path / "Films" + films.mkdir() + (films / "a.mkv").write_bytes(b"a") + + roots = _roots(films, removable=False) + idx = await _indexer(roots) + + (films / "a.mkv").unlink() + films.rmdir() + await idx.reconcile() + assert roots.roots[0].ejected is False + assert roots.roots[0].available is False + + films.mkdir() + (films / "a.mkv").write_bytes(b"a") + await idx.reconcile() + assert roots.roots[0].available is True + + +async def test_an_auto_eject_is_reported_so_it_can_be_persisted(tmp_path): + """ + The flag has to outlive the process. The first version of this set it in + memory only, so restarting the node — which is what an operator does after + noticing a drive fell off — cleared it, and the scan that followed read the + empty mount point as a deletion of the whole library. + """ + films = tmp_path / "Films" + films.mkdir() + (films / "a.mkv").write_bytes(b"a") + + seen: list[tuple[str, bool]] = [] + + async def record(name: str, ejected: bool) -> None: + seen.append((name, ejected)) + + roots = _roots(films) + idx = await _indexer(roots, on_root_ejected=record) + + (films / "a.mkv").unlink() + films.rmdir() + await idx.reconcile() + + assert seen == [("Films", True)] + + # And only once, however many times the backstop runs afterwards. + await idx.reconcile() + await idx.reconcile() + assert seen == [("Films", True)] + + +# ── Restoring the flag ─────────────────────────────────────────────────────── + +async def test_a_root_built_as_ejected_starts_unavailable(tmp_path): + """ + What the daemon does with what the roster remembers. `available` must not + be left at its default `True` here, or the group serves a drive that is not + there for as long as it takes the first reconcile to run. + """ + films = tmp_path / "Films" + films.mkdir() + roots = RootSet.build([{"path": str(films), "removable": True, + "ejected": True}]) + assert roots.roots[0].ejected is True + assert roots.roots[0].available is False + + +async def test_the_roster_round_trips_the_ejected_set(tmp_path): + roster = Roster(db_path=tmp_path / "roster.db") + await roster.open() + try: + assert await roster.ejected_roots("g1") == set() + + await roster.set_root_ejected("g1", "Films", True, set_by="op") + await roster.set_root_ejected("g1", "Music", False, set_by="op") + assert await roster.ejected_roots("g1") == {"films"} + + # Another group's drives are its own. + assert await roster.ejected_roots("g2") == set() + + await roster.set_root_ejected("g1", "Films", False, set_by="op") + assert await roster.ejected_roots("g1") == set() + finally: + await roster.close() + + +async def test_the_ejected_key_is_case_folded(tmp_path): + """ + Root names are compared without regard to case everywhere else, and a key + that did not fold would let `Films` and `films` disagree about the same + drive — on Windows and macOS, the same directory. + """ + roster = Roster(db_path=tmp_path / "roster.db") + await roster.open() + try: + await roster.set_root_ejected("g1", "FILMS", True, set_by="op") + assert await roster.ejected_roots("g1") == {"films"} + assert Roster.root_ejected_key("Films") == Roster.root_ejected_key("FILMS") + finally: + await roster.close() diff --git a/packages/meshbay-node/tests/test_root_writable_policy.py b/packages/meshbay-node/tests/test_root_writable_policy.py new file mode 100644 index 0000000..da95032 --- /dev/null +++ b/packages/meshbay-node/tests/test_root_writable_policy.py @@ -0,0 +1,203 @@ +""" +Who may write to the operator's disk, now that RO/RW on the root decides it. + +This replaces `test_member_upload_policy.py`. The old model had two orthogonal +controls — one root designated as the upload target, and a group-wide +`member_upload` switch — and collapsed into one property per root: `writable`. +The properties worth keeping from the old file survive the change unaltered: + +* the interface hiding a control is a courtesy to the people who are not + trying; **the node refusing is the part that holds** against someone who is. + A member with an old tab open, or one speaking MNP directly, gets the same + answer. That half is pinned in `test_security_regressions.py`, next to the + overwrite properties it belongs with; +* the setting is changed by a **signed** operator instruction, or it is a + suggestion any member can undo; +* it is stored on the **node**, never the hub. A hub that could decide who + writes to the operator's disk would have authority over the node. + +And one that is new: the *old* message must no longer be able to change +anything. A deprecated instruction that still works is not deprecated, and this +one would reopen uploads group-wide. +""" + +import base64 +from pathlib import Path + +import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +from meshbay_common.adminop import OP_ROOT_UPDATE, OP_ROOT_EJECT, OP_ROOT_PLUG +from meshbay_common.protocol import MNP +from meshbay_node.indexer.group_index import GroupIndex +from meshbay_node.roots import RootSet +from meshbay_node.transport.webrtc_server import WebRTCPeerSession + +pytestmark = pytest.mark.asyncio + + +def _session(tmp_path: Path, user_id: str, *, + writable: bool = True, + operator: str | None = None) -> WebRTCPeerSession: + shared_root = tmp_path / "shared" + shared_root.mkdir(exist_ok=True) + index = GroupIndex(group_id="g" * 32, sk_node=Ed25519PrivateKey.generate()) + ctx = { + "roots": RootSet.build([{"path": str(shared_root), "writable": writable}]), + "index": index, + "sk_node": index.sk_node, + "node_user_id": operator, + } + session = WebRTCPeerSession.__new__(WebRTCPeerSession) + session._ctx = ctx + session._group_id = "g" * 32 + session._user_id = user_id + session._pk_user = "" + session._uploads = {} + session.sent = [] + session._send = session.sent.append + session._audit = lambda *a, **k: None + return session + + +def _upload(session, filename="clip.mp4", body=b"bytes"): + session._do_file_upload({ + "filename": filename, "root": "shared", + "chunk_index": 0, "total_chunks": 1, + "data": base64.b64encode(body).decode(), + }) + + +def _uploads_dir(session) -> Path: + return session._ctx["roots"].roots[0].path / "uploads" + + +# ── The door, not the button ───────────────────────────────────────────────── + +async def test_a_member_cannot_upload_to_a_read_only_root(tmp_path): + session = _session(tmp_path, "member-1", writable=False) + _upload(session) + + refusal = [m for m in session.sent if m.get("type") == "error"] + assert refusal and refusal[0].get("code") == "root_read_only" + assert not _uploads_dir(session).exists() + + +async def test_members_upload_normally_to_a_writable_root(tmp_path): + session = _session(tmp_path, "member-1", writable=True) + _upload(session) + + assert not [m for m in session.sent if m.get("type") == "error"] + assert (_uploads_dir(session) / "clip.mp4").read_bytes() == b"bytes" + + +async def test_read_only_binds_the_operator_too(tmp_path): + """ + The old model exempted the operator, because the switch was about *members*. + RO is about the directory: a published library is read-only for everyone, and + an exception for admin authority is how a rule turns into a default. + """ + session = _session(tmp_path, "the-operator", writable=False, + operator="the-operator") + session._is_node_admin = lambda: True + _upload(session) + + refusal = [m for m in session.sent if m.get("type") == "error"] + assert refusal and refusal[0].get("code") == "root_read_only" + + +# ── Signed, or it is a suggestion ──────────────────────────────────────────── + +def _capture_challenges(session) -> list[tuple[str, str]]: + issued: list[tuple[str, str]] = [] + + def issue(op, subject, **kw): + issued.append((op, subject)) + + session._issue_admin_challenge = issue + session._has_admin_authority = lambda: True + return issued + + +async def test_changing_a_roots_flags_needs_a_signature(tmp_path): + """The flags are not applied by the request — only by the signed response.""" + session = _session(tmp_path, "the-operator", operator="the-operator") + issued = _capture_challenges(session) + + session._do_root_update({"group_id": "g" * 32, "root_name": "shared", + "writable": False}) + + assert [op for op, _ in issued] == [OP_ROOT_UPDATE] + assert session._ctx["roots"].roots[0].writable is True, ( + "applied before it was signed") + + +async def test_the_subject_names_the_outcome_not_the_operation(tmp_path): + """ + The operator is shown the subject before signing, so it has to say what will + be true afterwards. "shared" alone would have them authorize a change they + cannot see the direction of. + """ + session = _session(tmp_path, "op", operator="op") + issued = _capture_challenges(session) + + session._do_root_update({"group_id": "g" * 32, "root_name": "shared", + "writable": True, "removable": True}) + + assert issued == [(OP_ROOT_UPDATE, "shared:rw=on,rem=on")] + + +async def test_eject_and_plug_are_signed_too(tmp_path): + """ + Hiding a group's whole library from every member is not a lesser act than + changing a flag. An unsigned one would let any member black out a group. + """ + session = _session(tmp_path, "op", operator="op") + issued = _capture_challenges(session) + + session._do_root_eject({"group_id": "g" * 32, "root_name": "shared"}) + session._do_root_plug({"group_id": "g" * 32, "root_name": "shared"}) + + assert issued == [(OP_ROOT_EJECT, "shared"), (OP_ROOT_PLUG, "shared")] + + +async def test_a_request_with_nobody_to_authorize_it_is_refused(tmp_path): + """ + An unpaired node has no key to check a signature against, so the challenge + is never issued rather than issued and then unverifiable. + """ + session = _session(tmp_path, "member-1") + issued = _capture_challenges(session) + session._has_admin_authority = lambda: False + + session._do_root_update({"group_id": "g" * 32, "root_name": "shared", + "writable": True}) + + assert issued == [] + assert [m for m in session.sent if m.get("type") == "error"] + + +# ── The deprecated message must not still work ─────────────────────────────── + +async def test_the_old_member_upload_message_changes_nothing(tmp_path): + """ + MNP still parses `member_upload` so an old client gets an answer instead of + a dropped request. What it must not do is act: this instruction could + reopen uploads for a whole group, and a client old enough to send it is + exactly one that knows nothing about read-only roots. + """ + session = _session(tmp_path, "member-1", writable=False) + session._has_admin_authority = lambda: True + issued = _capture_challenges(session) + + session._do_member_upload({"allowed": True}) + + assert issued == [], "a deprecated instruction asked to be signed" + assert session._ctx["roots"].roots[0].writable is False + acks = [m for m in session.sent if m.get("type") == MNP.MEMBER_UPLOAD_ACK] + assert acks and acks[0].get("deprecated") is True + + # And the door is still shut. + _upload(session) + refusal = [m for m in session.sent if m.get("type") == "error"] + assert refusal and refusal[0].get("code") == "root_read_only" diff --git a/packages/meshbay-node/tests/test_roots.py b/packages/meshbay-node/tests/test_roots.py index 1beb220..505091b 100644 --- a/packages/meshbay-node/tests/test_roots.py +++ b/packages/meshbay-node/tests/test_roots.py @@ -108,31 +108,61 @@ def test_a_sibling_with_a_shared_prefix_is_fine(tmp_path): assert roots.names == ["Media", "Media2"] -# ── Uploads ────────────────────────────────────────────────────────────────── +# ── Writable roots ─────────────────────────────────────────────────────────── -def test_a_single_root_receives_uploads_without_being_asked(tmp_path): +def test_a_root_is_read_only_unless_it_says_otherwise(tmp_path): + """ + The default is the safe one. An operator who shares a directory has not + thereby agreed to let anyone write into it, and the version of this that + guessed — one root, so it must be the upload target — meant adding a + second directory silently changed what the first one was. + """ (tmp_path / "Media").mkdir() roots = RootSet.build([_spec(tmp_path / "Media")]) - assert roots.upload_root is roots.roots[0] + assert roots.roots[0].writable is False + assert roots.writable_roots == [] + + +def test_several_roots_can_be_writable_at_once(tmp_path): + (tmp_path / "A").mkdir() + (tmp_path / "B").mkdir() + (tmp_path / "C").mkdir() + roots = RootSet.build([_spec(tmp_path / "A", writable=True), + _spec(tmp_path / "B"), + _spec(tmp_path / "C", writable=True)]) + assert [r.name for r in roots.writable_roots] == ["A", "C"] -def test_several_roots_and_no_designation_means_no_uploads(tmp_path): +def test_a_fully_read_only_group_is_valid(tmp_path): """ - Refused, never guessed: picking one would send a member's file to a disk the - operator did not intend, and that is discovered weeks later. + A group that only publishes is the point of the read-only model, not a + misconfiguration — build must not refuse it, and nothing downstream may + promote a root to writable to have somewhere to put an upload. """ (tmp_path / "A").mkdir() (tmp_path / "B").mkdir() roots = RootSet.build([_spec(tmp_path / "A"), _spec(tmp_path / "B")]) - assert roots.upload_root is None + assert roots.writable_roots == [] + assert len(roots) == 2 -def test_two_upload_roots_are_refused(tmp_path): - (tmp_path / "A").mkdir() - (tmp_path / "B").mkdir() - with pytest.raises(RootError, match="exactly one"): - RootSet.build([_spec(tmp_path / "A", upload=True), - _spec(tmp_path / "B", upload=True)]) +def test_the_old_upload_flag_still_reads_as_writable(tmp_path): + """A node.toml written before this refactor must not change meaning.""" + (tmp_path / "Media").mkdir() + roots = RootSet.build([_spec(tmp_path / "Media", upload=True)]) + assert roots.roots[0].writable is True + assert roots.describe()[0]["writable"] is True + + +def test_writable_wins_over_a_leftover_upload_flag(tmp_path): + """ + A config carrying both is one a migration touched. `writable` is the field + the operator's tooling writes now, so it is the one that decides — reading + the legacy field there would undo the migration on the next load. + """ + (tmp_path / "Media").mkdir() + roots = RootSet.build([_spec(tmp_path / "Media", upload=True, writable=False)]) + assert roots.roots[0].writable is False # ── Resolution ─────────────────────────────────────────────────────────────── @@ -236,18 +266,35 @@ def test_availability_follows_the_directory(tmp_path): def test_describe_reports_what_a_member_needs(tmp_path): (tmp_path / "Media").mkdir() (tmp_path / "Music").mkdir() - roots = RootSet.build([_spec(tmp_path / "Media", upload=True), - _spec(tmp_path / "Music", kind="audio")]) + roots = RootSet.build([_spec(tmp_path / "Media", writable=True), + _spec(tmp_path / "Music", kind="audio", + removable=True)]) described = roots.describe() assert described == [ - {"name": "Media", "kind": "generic", "available": True, "upload": True}, - {"name": "Music", "kind": "audio", "available": True, "upload": False}, + {"name": "Media", "kind": "generic", "available": True, + "writable": True, "removable": False, "ejected": False, + "upload": True}, + {"name": "Music", "kind": "audio", "available": True, + "writable": False, "removable": True, "ejected": False, + "upload": False}, ] # 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) +def test_describe_still_carries_upload_for_mnp_1_0_clients(tmp_path): + """ + `upload` is `writable` under its old name, kept because an MNP 1.0 client + reads no other field and would otherwise decide the group takes no uploads + at all. It is derived, never stored — the two can never disagree. + """ + (tmp_path / "Media").mkdir() + roots = RootSet.build([_spec(tmp_path / "Media", writable=True)]) + described = roots.describe()[0] + assert described["upload"] == described["writable"] is True + + # ── SAFE_UPLOAD_NAME ──────────────────────────────────────────────────────── def test_safe_name_accepts_unicode_letters(): diff --git a/packages/meshbay-node/tests/test_scan_settings_policy.py b/packages/meshbay-node/tests/test_scan_settings_policy.py index 719b988..94f4421 100644 --- a/packages/meshbay-node/tests/test_scan_settings_policy.py +++ b/packages/meshbay-node/tests/test_scan_settings_policy.py @@ -2,7 +2,7 @@ The operator can tune how often the indexer's reconciliation backstop runs, and how long it waits after a file's last write before hashing it. -Same shape as test_apps_enabled_policy.py / test_member_upload_policy.py: +Same shape as test_apps_enabled_policy.py / test_root_writable_policy.py: changed by a signed operator instruction, stored on the node rather than the hub. Unlike those two, there is also a *live* DirectoryIndexer object to update — see test_set_scan_settings_updates_the_live_indexer below. diff --git a/packages/meshbay-node/tests/test_security_regressions.py b/packages/meshbay-node/tests/test_security_regressions.py index 7f71da5..9db8ac1 100644 --- a/packages/meshbay-node/tests/test_security_regressions.py +++ b/packages/meshbay-node/tests/test_security_regressions.py @@ -18,6 +18,7 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from meshbay_common.protocol import IndexEntry from meshbay_node.indexer.group_index import GroupIndex +from meshbay_node.roots import RootSet from conftest import one_root from meshbay_node.transport.webrtc_server import WebRTCPeerSession @@ -132,14 +133,14 @@ def test_the_node_never_generates_a_name_it_would_refuse(tmp_path): def _uploads_dir(session) -> Path: """ - Where this session's uploads land: uploads/ inside the group's upload root. + Where this session's uploads land: uploads/ inside its first writable root. Asked of the root set rather than assembled by hand, so a test cannot pass while agreeing with a wrong answer the code also produced. """ - root = session._ctx["roots"].upload_root - assert root is not None, "the fixture must designate an upload root" - return root.path / "uploads" + writable = session._ctx["roots"].writable_roots + assert writable, "the fixture must give the group a writable root" + return writable[0].path / "uploads" def _session(tmp_path: Path, user_id: str) -> WebRTCPeerSession: @@ -227,14 +228,17 @@ def test_dir_create_cannot_escape_the_shared_root(tmp_path, bad): def test_upload_ignores_any_directory_the_client_asks_for(tmp_path): """ - Uploads land in uploads/, chosen by the node. A client that names somewhere - else — or nowhere at all — changes nothing, so the traversal surface that a - client-chosen destination would open does not exist on this path. + The destination inside a root is the node's decision, and stays so. + + A client now names the *root* it is uploading into — it has to, once a group + can have several writable ones — but that is a name looked up in the root + table, never a path. Everything below the root is still chosen here, so the + traversal surface a client-chosen destination would open does not exist. """ session = _session(tmp_path, "user-1") session._do_file_upload({ - "filename": "note.txt", "dir": "../../etc", + "filename": "note.txt", "dir": "../../etc", "path": "/etc", "chunk_index": 0, "total_chunks": 1, "data": base64.b64encode(b"x").decode(), }) @@ -243,6 +247,131 @@ def test_upload_ignores_any_directory_the_client_asks_for(tmp_path): assert not (tmp_path / "etc").exists() +@pytest.mark.parametrize("named_root", [ + "../../etc", "/etc", "shared/../..", "Shared/uploads", "nope", +]) +def test_a_root_name_is_looked_up_never_joined(tmp_path, named_root): + """ + The name the client sends is matched against the group's root table and + refused when it matches nothing. A version that joined it to a path — or + that quietly fell back to the first writable root — would turn "which + directory" into either a traversal or a file on a disk the operator did + not intend, and the second is discovered weeks later. + """ + session = _session(tmp_path, "user-1") + before = set(tmp_path.rglob("*")) + + session._do_file_upload({ + "filename": "note.txt", "root": named_root, + "chunk_index": 0, "total_chunks": 1, + "data": base64.b64encode(b"x").decode(), + }) + + refusal = [m for m in session.sent if m.get("type") == "error"] + assert refusal and refusal[0].get("code") == "no_such_root", named_root + assert set(tmp_path.rglob("*")) == before, f"wrote something via {named_root!r}" + + +def test_an_upload_goes_to_the_root_it_names(tmp_path): + """ + With two writable roots there is no defensible default, and the client is + the only party that knows which directory the person is looking at. The + node picking one meant a file uploaded from a folder on screen landed in a + different one — the same "uploads went somewhere else" the single upload + root was never allowed to guess about. + """ + media = tmp_path / "Media" + incoming = tmp_path / "Incoming" + media.mkdir() + incoming.mkdir() + session = _session(tmp_path, "user-1") + session._ctx["roots"] = RootSet.build([ + {"path": str(media), "writable": True}, + {"path": str(incoming), "writable": True}, + ]) + + session._do_file_upload({ + "filename": "note.txt", "root": "Incoming", + "chunk_index": 0, "total_chunks": 1, + "data": base64.b64encode(b"x").decode(), + }) + + assert (incoming / "uploads" / "note.txt").read_bytes() == b"x" + assert not (media / "uploads").exists(), "it went to the first root instead" + + +def test_a_read_only_root_refuses_an_upload(tmp_path): + """ + RO is the mechanism now, not a hidden button. It binds the operator too: + "read-only for everyone" is what makes a published library one, and an + exception for whoever happens to hold admin authority is the sort of + carve-out that later reads as the rule. + """ + published = tmp_path / "Published" + published.mkdir() + session = _session(tmp_path, "user-1") + session._ctx["roots"] = RootSet.build([{"path": str(published)}]) + session._is_node_admin = lambda: True + + session._do_file_upload({ + "filename": "note.txt", "root": "Published", + "chunk_index": 0, "total_chunks": 1, + "data": base64.b64encode(b"x").decode(), + }) + + refusal = [m for m in session.sent if m.get("type") == "error"] + assert refusal and refusal[0].get("code") == "root_read_only" + assert not (published / "uploads").exists() + + +def test_a_fully_read_only_group_refuses_an_unaddressed_upload(tmp_path): + """ + An MNP 1.0 client names no root, so the node falls back to the first + writable one. There isn't one here, and the fallback must refuse rather + than write into whatever root happens to come first. + """ + published = tmp_path / "Published" + published.mkdir() + session = _session(tmp_path, "user-1") + session._ctx["roots"] = RootSet.build([{"path": str(published)}]) + + session._do_file_upload({ + "filename": "note.txt", + "chunk_index": 0, "total_chunks": 1, + "data": base64.b64encode(b"x").decode(), + }) + + refusal = [m for m in session.sent if m.get("type") == "error"] + assert refusal and refusal[0].get("code") == "no_writable_root" + assert not (published / "uploads").exists() + + +def test_an_ejected_root_refuses_an_upload(tmp_path): + """ + Writing to a drive somebody has their hand on is the thing eject exists to + stop. `writable` is still true — that is configuration — so availability + has to be checked separately, which is what an earlier version conflated. + """ + usb = tmp_path / "USB" + usb.mkdir() + session = _session(tmp_path, "user-1") + roots = RootSet.build([{"path": str(usb), "writable": True, + "removable": True}]) + roots.roots[0].ejected = True + roots.roots[0].available = False + session._ctx["roots"] = roots + + session._do_file_upload({ + "filename": "note.txt", "root": "USB", + "chunk_index": 0, "total_chunks": 1, + "data": base64.b64encode(b"x").decode(), + }) + + refusal = [m for m in session.sent if m.get("type") == "error"] + assert refusal and refusal[0].get("code") == "root_unavailable" + assert not (usb / "uploads").exists() + + def test_two_members_can_send_the_same_filename(tmp_path): """ One shared uploads/ means collisions are ordinary — every camera produces |