summaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/files-app.js19
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css4
-rw-r--r--packages/meshbay-hub/tests/test_upload_controls_hidden.py30
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py22
-rw-r--r--packages/meshbay-node/tests/test_root_writable_policy.py41
5 files changed, 115 insertions, 1 deletions
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 9ca3aae..fb57a0f 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/files-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/files-app.js
@@ -234,7 +234,13 @@ function FilesPanel({
// set of roots, which is the operator's configuration and not a directory on
// anyone's disk. The node refuses it, so offering it would only produce an
// error nobody can act on.
- const canCreateDir = Boolean(currentPath) && isNodeAdmin;
+ //
+ // Otherwise the rule is the same as the Upload button's, and for the same
+ // reason the node gives: "making a directory is not a privileged act — a
+ // member who can add a file can organise where it goes". It used to require
+ // `isNodeAdmin`, which contradicted the node and hid the control from
+ // everyone who could actually use it.
+ const canCreateDir = Boolean(currentPath) && currentRootWritable && !readOnly;
const breadcrumbs = currentPath ? currentPath.split('/') : [];
@@ -363,6 +369,17 @@ function FilesPanel({
onChange=${uploadFile} />
</label>
`}
+ ${/* Icon only: the toolbar already carries a labelled primary
+ action, and a second one beside it competes with it for the
+ width a breadcrumb trail needs. The name lives in the tooltip
+ and in aria-label, so it is not lost to anyone reading with
+ something other than their eyes. */''}
+ ${canCreateDir && html`
+ <button class="tb-btn tb-btn-icon" onClick=${makeDirectory}
+ title=${t('group.mkdir')} aria-label=${t('group.mkdir')}>
+ <${Icon} name="folder-plus" />
+ </button>
+ `}
</div>
<div class="breadcrumbs">
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css
index 0441e14..4647209 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/style.css
+++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css
@@ -833,6 +833,10 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
.tb-btn:disabled { opacity: 0.45; cursor: not-allowed; }
.tb-btn:disabled:hover { border-color: var(--border); color: var(--text); }
.tb-btn .icon { width: 15px; height: 15px; }
+/* Square, for a toolbar button whose name is in its tooltip rather than
+ beside it. The horizontal padding goes; the height does not, so it lines up
+ with the labelled buttons next to it. */
+.tb-btn-icon { padding: 0; width: 32px; justify-content: center; gap: 0; }
.tb-btn.primary {
background: var(--accent);
border-color: var(--accent);
diff --git a/packages/meshbay-hub/tests/test_upload_controls_hidden.py b/packages/meshbay-hub/tests/test_upload_controls_hidden.py
index 7b8b0e3..a61de1f 100644
--- a/packages/meshbay-hub/tests/test_upload_controls_hidden.py
+++ b/packages/meshbay-hub/tests/test_upload_controls_hidden.py
@@ -73,6 +73,36 @@ def test_the_files_upload_button_is_not_offered_at_the_top_of_a_group():
assert "currentPath &&" in toolbar
+def test_the_new_folder_button_follows_the_same_rule_as_upload():
+ """
+ Both write to the operator's disk, so both need a writable root — the node
+ refuses either otherwise. It used to require `isNodeAdmin`, which
+ contradicted the node ("a member who can add a file can organise where it
+ goes") and hid the control from everyone who could have used it.
+ """
+ page = _component(FILES_APP.read_text(encoding="utf-8"), "FilesPanel")
+ decl = page[page.index("const canCreateDir"):]
+ decl = decl[:decl.index(";") + 1]
+ assert "currentRootWritable" in decl
+ assert "currentPath" in decl, (
+ "the top of a group is the set of roots, not a directory to create in")
+ assert "isNodeAdmin" not in decl
+
+
+def test_an_icon_only_button_still_says_what_it_is():
+ """
+ The name moved into a tooltip to save toolbar width. A `title` is invisible
+ to a screen reader on a button with no text, so the label has to be there
+ as well — otherwise the control is simply unnamed for anyone not reading
+ with their eyes.
+ """
+ page = _component(FILES_APP.read_text(encoding="utf-8"), "FilesPanel")
+ block = page[page.index("canCreateDir && html`"):]
+ block = block[:block.index("</button>")]
+ assert "title=" in block and "aria-label=" in block
+ assert "group.mkdir" in block
+
+
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"):]
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 61458f2..0e2d418 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
@@ -1595,6 +1595,28 @@ class WebRTCPeerSession:
"detail": "Choose a folder to create this in"})
return
+ # Read-only means read-only, and creating a folder writes to the
+ # operator's disk. `_do_file_upload` gained this check with the RO/RW
+ # model and this one did not — so a member could not add a file to a
+ # published library but could still leave empty directories in it.
+ owner = roots.split(parent_rel)
+ if owner is None:
+ self._send({"type": "error", "detail": "Invalid directory"})
+ return
+ parent_root, _tail = owner
+ if not parent_root.writable:
+ self._send({"type": "error",
+ "detail": f"Directory '{parent_root.name}' is read-only",
+ "code": "root_read_only"})
+ self._audit("dir_create_refused", parent_rel[:64])
+ return
+ if not parent_root.available:
+ self._send({"type": "error",
+ "detail": f"Directory '{parent_root.name}' is "
+ f"currently unavailable",
+ "code": "root_unavailable"})
+ return
+
parent = safe_subdir(roots, parent_rel)
if parent is None or not parent.is_dir():
self._send({"type": "error", "detail": "Invalid directory"})
diff --git a/packages/meshbay-node/tests/test_root_writable_policy.py b/packages/meshbay-node/tests/test_root_writable_policy.py
index d7f2666..7eb75fd 100644
--- a/packages/meshbay-node/tests/test_root_writable_policy.py
+++ b/packages/meshbay-node/tests/test_root_writable_policy.py
@@ -108,6 +108,47 @@ async def test_read_only_binds_the_operator_too(tmp_path):
assert refusal and refusal[0].get("code") == "root_read_only"
+async def test_a_member_cannot_create_a_folder_in_a_read_only_root(tmp_path):
+ """
+ Read-only has to mean read-only for every way of writing, not just for
+ files. `_do_file_upload` gained this check with the RO/RW model and
+ `_do_dir_create` did not, so a member refused a file in a published library
+ could still leave empty directories all through it.
+
+ Creating a folder stays unprivileged — the node's own words: "a member who
+ can add a file can organise where it goes". What changed is that it now
+ requires the same root to be writable that adding the file would have.
+ """
+ session = _session(tmp_path, "member-1", writable=False)
+ session._do_dir_create({"dir": "shared", "name": "New folder"})
+
+ refusal = [m for m in session.sent if m.get("type") == "error"]
+ assert refusal and refusal[0].get("code") == "root_read_only"
+ assert not (tmp_path / "shared" / "New folder").exists()
+
+
+async def test_a_member_can_create_a_folder_in_a_writable_root(tmp_path):
+ """The counter-property: it must stay unprivileged where it is allowed."""
+ session = _session(tmp_path, "member-1", writable=True)
+ session._do_dir_create({"dir": "shared", "name": "New folder"})
+
+ assert not [m for m in session.sent if m.get("type") == "error"]
+ assert (tmp_path / "shared" / "New folder").is_dir()
+
+
+async def test_an_ejected_root_refuses_a_new_folder(tmp_path):
+ """Writing to a drive somebody has their hand on, one level up from a file."""
+ session = _session(tmp_path, "member-1", writable=True)
+ roots = session._ctx["roots"]
+ roots.roots[0].ejected = True
+ roots.roots[0].available = False
+
+ session._do_dir_create({"dir": "shared", "name": "New folder"})
+ refusal = [m for m in session.sent if m.get("type") == "error"]
+ assert refusal and refusal[0].get("code") == "root_unavailable"
+ assert not (tmp_path / "shared" / "New folder").exists()
+
+
# ── Signed, or it is a suggestion ────────────────────────────────────────────
def _capture_challenges(session) -> list[tuple[str, str]]: