aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-14 21:39:42 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-14 21:39:42 +0200
commitb3ef2aff738cc4efd974efab9315a6ce6c3de493 (patch)
tree0c4feaf50dd275d8fd2c482d62e7cf4124d5b853 /packages/meshbay-hub
parent54b535d7102e9a68d9b37fb215623fbd4faff95e (diff)
downloadmeshbay-b3ef2aff738cc4efd974efab9315a6ce6c3de493.tar.gz
feat(files): upload into the current directory, and create folders
The per-user quarantine is gone. `.uploads/{user_id}/` was the fix for C5a, and it worked, but it made the shared directory something nobody could organise: every file landed under a uuid nobody recognises. Files now go where the member is looking, most often the root. What the quarantine actually bought is kept, and is now what the tests assert rather than the location: - an existing file is never replaced. That was the real defect — overwriting a file also made the attacker its recorded uploader, and therefore able to delete it through the uploader path - the name allowlist is unchanged - the destination is confined under the shared root That last one is new surface: the directory arrives from the client. safe_subdir() is the single place that decides, with two independent guards — every segment against the name allowlist, and the resolved result under the root — because one of them will eventually be refactored by someone who does not know why it is there. Ten traversal cases are covered, and they fail if both guards go. Also adds `dir_create` (any member may organise a shared directory; audited like anything that writes to the operator's disk) and makes the node report its real directory list in index_sync — folders were inferred from file paths, so a new empty one, or one that had been emptied, simply did not exist as far as the UI was concerned. Two C5a tests changed their assertions deliberately, as C5b's did before: they encoded the quarantine path, which is the thing being removed. The property they existed for is asserted more directly than before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js124
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/i18n.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js15
3 files changed, 94 insertions, 47 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index cff001b..def6c5b 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -824,6 +824,9 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth }) {
const [tab, setTab] = useState('files');
const [uploading, setUploading] = useState(false);
const [ulState, setUlState] = useState(null);
+ // Directories are not index entries, so a new empty one needs a nudge
+ // to appear in the breadcrumb listing.
+ const [nodeDirs, setNodeDirs] = useState([]);
const [menuOpen, setMenuOpen] = useState(null);
const [isNodeAdmin, setIsNodeAdmin] = useState(false);
const [needsCode, setNeedsCode] = useState(false);
@@ -916,6 +919,7 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth }) {
if (cancelled) return;
const synced = msg.entries || [];
setEntries(synced);
+ if (msg.dirs) setNodeDirs(msg.dirs);
setCached(false);
cacheGroupIndex(groupId, group ? group.name : groupId, synced);
};
@@ -924,6 +928,7 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth }) {
if (cancelled) return;
const freshEntries = indexMsg.entries || [];
setEntries(freshEntries);
+ setNodeDirs(indexMsg.dirs || []);
setCached(false);
setStatus('connected');
@@ -1027,7 +1032,7 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth }) {
for (let i = 0; i < totalChunks; i++) {
const slice = file.slice(i * UPLOAD_CHUNK_SIZE, (i + 1) * UPLOAD_CHUNK_SIZE);
const buf = new Uint8Array(await slice.arrayBuffer());
- await transport.uploadChunk(file.name, i, totalChunks, buf);
+ await transport.uploadChunk(file.name, i, totalChunks, buf, currentPath);
// Bytes actually acknowledged by the node, not bytes read locally.
setUlState(prev => prev && { ...prev, sent: Math.min(file.size,
(i + 1) * UPLOAD_CHUNK_SIZE) });
@@ -1038,13 +1043,29 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth }) {
await new Promise(r => setTimeout(r, 2500));
const indexMsg = await transport.fetchIndex();
if (indexMsg.entries) setEntries(indexMsg.entries);
+ if (indexMsg.dirs) setNodeDirs(indexMsg.dirs);
} catch (err) {
setError(err.message);
} finally {
setUploading(false);
setUlState(null);
}
- }, []);
+ }, [currentPath]);
+
+ const makeDirectory = useCallback(async () => {
+ const transport = transportRef.current;
+ if (!transport || !transport.connected) return;
+ const name = prompt(t('group.mkdir_prompt'));
+ if (!name || !name.trim()) return;
+ try {
+ await transport.createDirectory(currentPath, name.trim());
+ const indexMsg = await transport.fetchIndex();
+ if (indexMsg.entries) setEntries(indexMsg.entries);
+ if (indexMsg.dirs) setNodeDirs(indexMsg.dirs);
+ } catch (err) {
+ setError(err.message);
+ }
+ }, [currentPath]);
const deleteFile = useCallback(async (entry) => {
const transport = transportRef.current;
@@ -1104,6 +1125,15 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth }) {
return sortAsc ? cmp : -cmp;
});
+ // The node's own listing, so an empty folder is visible, plus anything implied
+ // by a file path in case the two ever disagree.
+ for (const d of nodeDirs) {
+ if (!currentPath && !d.includes('/')) dirs.add(d);
+ else if (currentPath && d.startsWith(currentPath + '/')) {
+ const rest = d.slice(currentPath.length + 1);
+ if (!rest.includes('/')) dirs.add(rest);
+ }
+ }
const subdirs = [...dirs].sort();
const baseLabel = {
@@ -1205,6 +1235,8 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth }) {
<input type="file" style="display:none" onChange=${uploadFile}
disabled=${uploading} />
</label>
+ <button class="admin-btn" style="margin-right:8px" onClick=${makeDirectory}
+ disabled=${uploading}>${t('group.mkdir')}</button>
<div class="breadcrumbs">
<a class="crumb" onClick=${() => setCurrentPath('')}>/</a>
${breadcrumbs.map((seg, i) => {
@@ -1578,49 +1610,6 @@ function MembersPanel({ groupId, group, token, transportRef, gekRef,
return html`
<div class="members-panel">
- ${isAdmin && html`
- <form class="invite-form" onSubmit=${doInvite}>
- <h4>${t('members.invite_title')}</h4>
- ${error && html`<p class="error-msg">${error}</p>`}
- ${inviteCode && html`
- <div class="success-msg" style="margin-bottom:8px">
- <p>${t('members.invite_code_ready', { user: inviteCode.username })}</p>
- <p style="font-family:monospace;font-size:1.4em;letter-spacing:2px;margin:6px 0">
- ${inviteCode.code}
- </p>
- <p>${t('members.invite_code_hint')}</p>
- </div>
- `}
- <div style="display:flex;gap:8px">
- <input type="text" placeholder="${t('members.username_placeholder')}"
- value=${inviteUser} onInput=${e => setInviteUser(e.target.value)} required />
- <button class="admin-btn" type="submit" disabled=${inviting}>
- ${inviting ? '...' : t('members.invite_btn')}
- </button>
- </div>
- </form>
- `}
- <table class="admin-table">
- <thead>
- <tr>
- <th>${t('admin.col_username')}</th>
- <th>${t('members.group_role')}</th>
- </tr>
- </thead>
- <tbody>
- ${members.map(m => html`
- <tr key=${m.user_id}>
- <td>${m.username}</td>
- <td>
- ${m.user_id === adminId
- ? html`<span class="badge" style="background:var(--accent);color:var(--accent-text)">${t('members.owner')}</span>`
- : html`<span class="badge">${t('members.member')}</span>`
- }
- </td>
- </tr>
- `)}
- </tbody>
- </table>
${isNodeAdmin && html`
<form class="invite-form" onSubmit=${doPair}>
<h4>${t('members.pair_title')}</h4>
@@ -2443,7 +2432,50 @@ function AdminPage({ token }) {
value=${userSearch} onInput=${e => { setUserSearch(e.target.value); loadUsers(e.target.value); }} />
<span class="settings-value">${usersTotal} total</span>
</div>
- <table class="admin-table">
+ ${isAdmin && html`
+ <form class="invite-form" onSubmit=${doInvite}>
+ <h4>${t('members.invite_title')}</h4>
+ ${error && html`<p class="error-msg">${error}</p>`}
+ ${inviteCode && html`
+ <div class="success-msg" style="margin-bottom:8px">
+ <p>${t('members.invite_code_ready', { user: inviteCode.username })}</p>
+ <p style="font-family:monospace;font-size:1.4em;letter-spacing:2px;margin:6px 0">
+ ${inviteCode.code}
+ </p>
+ <p>${t('members.invite_code_hint')}</p>
+ </div>
+ `}
+ <div style="display:flex;gap:8px">
+ <input type="text" placeholder="${t('members.username_placeholder')}"
+ value=${inviteUser} onInput=${e => setInviteUser(e.target.value)} required />
+ <button class="admin-btn" type="submit" disabled=${inviting}>
+ ${inviting ? '...' : t('members.invite_btn')}
+ </button>
+ </div>
+ </form>
+ `}
+ <table class="admin-table">
+ <thead>
+ <tr>
+ <th>${t('admin.col_username')}</th>
+ <th>${t('members.group_role')}</th>
+ </tr>
+ </thead>
+ <tbody>
+ ${members.map(m => html`
+ <tr key=${m.user_id}>
+ <td>${m.username}</td>
+ <td>
+ ${m.user_id === adminId
+ ? html`<span class="badge" style="background:var(--accent);color:var(--accent-text)">${t('members.owner')}</span>`
+ : html`<span class="badge">${t('members.member')}</span>`
+ }
+ </td>
+ </tr>
+ `)}
+ </tbody>
+ </table>
+ <table class="admin-table">
<thead><tr>
<th>${t('admin.col_username')}</th>
<th>${t('admin.col_role')}</th>
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js
index e3e0def..c19411b 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js
@@ -89,6 +89,8 @@ const en = {
'group.offline_title': 'No nodes are currently online for this group.',
'group.offline_hint': 'Files will appear when a node hosting this group connects.',
'group.upload': 'Upload',
+ 'group.mkdir': 'New folder',
+ 'group.mkdir_prompt': 'Name of the new folder:',
'group.uploading': 'Uploading...',
'group.view': 'View',
'group.delete': 'Delete',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
index 0a8796e..d2b24a3 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -488,15 +488,28 @@ class MeshBayTransport {
this._send({ type: 'stream_req', v: '0.1', file_id: fileId });
}
- async uploadChunk(filename, chunkIndex, totalChunks, data) {
+ async uploadChunk(filename, chunkIndex, totalChunks, data, dir) {
const msg = await this._sendAndWait({
type: 'file_upload',
v: '0.1',
filename,
+ // Where the member is looking. The node confines it under the shared root
+ // and refuses to overwrite, so this is a destination, not a licence.
+ dir: dir || '',
chunk_index: chunkIndex,
total_chunks: totalChunks,
data: data,
});
+ if (msg.type === 'error') throw new Error(msg.detail);
+ return msg;
+ }
+
+ /** Create a directory under the current one. Any member may. */
+ async createDirectory(dir, name) {
+ const msg = await this._sendAndWait({
+ type: 'dir_create', v: '0.1', dir: dir || '', name,
+ });
+ if (msg.type === 'error') throw new Error(msg.detail);
return msg;
}