aboutsummaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-common/src/meshbay_common/adminop.py4
-rw-r--r--packages/meshbay-common/src/meshbay_common/protocol.py2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js77
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js27
-rw-r--r--packages/meshbay-hub/tests/test_upload_controls_hidden.py122
-rw-r--r--packages/meshbay-node/src/meshbay_node/daemon.py7
-rw-r--r--packages/meshbay-node/src/meshbay_node/roster.py57
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py88
-rw-r--r--packages/meshbay-node/tests/test_member_upload_policy.py176
19 files changed, 617 insertions, 3 deletions
diff --git a/packages/meshbay-common/src/meshbay_common/adminop.py b/packages/meshbay-common/src/meshbay_common/adminop.py
index 4e28f65..a793471 100644
--- a/packages/meshbay-common/src/meshbay_common/adminop.py
+++ b/packages/meshbay-common/src/meshbay_common/adminop.py
@@ -45,6 +45,10 @@ OP_MEMBER_REVOKE = "member_revoke"
OP_GEK_ROTATE = "gek_rotate"
# Forgetting a pinned identity, so someone can pair again after losing a device.
OP_MEMBER_UNPIN = "member_unpin"
+# Turning uploading by ordinary members on or off. Signed like the rest: the
+# setting decides who may write to the operator's disk, so a node that took it
+# from an unsigned message would let any member re-enable it for everyone.
+OP_MEMBER_UPLOAD = "member_upload"
# OP_GEK_BUNDLE_STORE is gone. Members no longer hand the node key material at
# all: the node holds the GEK and wraps it itself, for a key the recipient proved
# they hold (see `join.py` and docs/invite-pairing-v1.md). The operation existed
diff --git a/packages/meshbay-common/src/meshbay_common/protocol.py b/packages/meshbay-common/src/meshbay_common/protocol.py
index bbdff63..1c6dd3e 100644
--- a/packages/meshbay-common/src/meshbay_common/protocol.py
+++ b/packages/meshbay-common/src/meshbay_common/protocol.py
@@ -78,6 +78,8 @@ class MNP:
MEMBER_REVOKE_ACK = "member_revoke_ack"
MEMBER_UNPIN = "member_unpin" # operator → node: forget an identity
MEMBER_UNPIN_ACK = "member_unpin_ack"
+ MEMBER_UPLOAD = "member_upload" # operator → node: may members upload?
+ MEMBER_UPLOAD_ACK = "member_upload_ack"
# Device linking. A new device files a request bound to a code it displays;
# an already-pinned device of the same account approves it. Neither the hub
# nor the node can produce the countersignature.
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index 62bbfa5..0843d1d 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -1304,6 +1304,10 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
const [nodeRoots, setNodeRoots] = useState([]);
const [isNodeAdmin, setIsNodeAdmin] = useState(false);
+ // Whether ordinary members may upload here. The node decides and enforces it;
+ // this only says whether to offer the controls. Defaults to true so a node
+ // that predates the setting behaves as it always did.
+ const [memberUpload, setMemberUpload] = useState(true);
// Paired ≠ operator account. `is_node_admin` says the hub account owning this
// node is the one connecting; this says the node pinned *this browser's* key
// as an operator key. Only the second one lets you sign an invite, and only
@@ -1392,6 +1396,11 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
_pendingJoinCode = null;
if (cancelled) return;
setIsNodeAdmin(!!ack.is_node_admin);
+ setMemberUpload(ack.member_upload !== false);
+ // Changed while we are connected, by an operator who may be someone
+ // else entirely. Without this the button stays until a reconnection,
+ // and a button that is still there is a button people press.
+ transport.onUploadPolicy = (allowed) => setMemberUpload(allowed);
setOperatorPaired(transport.memberRole === 'operator');
// A first join to this node generated an identity for it; leave it with
@@ -1792,6 +1801,10 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
? selectedFiles[0] : null;
const deletableFiles = selectedFiles.filter(
e => isNodeAdmin || (userId && e.uploader_id === userId));
+
+ // Asked in two places — the Files toolbar and the chat composer — so it is
+ // answered once. The operator is never locked out of their own node.
+ const mayUpload = memberUpload || isNodeAdmin;
const run = (fn) => {
setSelecting(false);
setSelected(new Set());
@@ -1963,11 +1976,13 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
${tab === 'files' && status === 'connected' && html`
<div class="file-toolbar">
<div class="toolbar-group">
+ ${mayUpload && html`
<label class="tb-btn primary">
<${Icon} name="upload" /> ${t('group.upload')}
<input type="file" multiple style="display:none"
onChange=${uploadFile} />
</label>
+ `}
${canCreateDir && html`
<button class="tb-btn" onClick=${makeDirectory}>
<${Icon} name="folder-plus" /> ${t('group.mkdir')}
@@ -2085,6 +2100,7 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
${tab === 'chat' && status === 'connected' && html`
<${ChatPanel} transportRef=${transportRef} username=${username}
entries=${entries} gekRef=${gekRef} onRefreshIndex=${refreshIndex}
+ mayUpload=${mayUpload}
onPreview=${(entry) => {
if (entry.type === 'video') setVideoEntry(entry);
else setPreviewEntry(entry);
@@ -2099,6 +2115,8 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
transportRef=${transportRef} gekRef=${gekRef}
isNodeAdmin=${isNodeAdmin} userId=${userId}
operatorPaired=${operatorPaired} connected=${status === 'connected'}
+ memberUpload=${memberUpload}
+ onMemberUpload=${(allowed) => setMemberUpload(allowed)}
onLeft=${onLeft}
onPaired=${() => setOperatorPaired(true)} />
`}
@@ -2272,7 +2290,7 @@ function _b64ToU8(b64) {
*/
function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
isNodeAdmin, userId, operatorPaired, connected,
- onPaired, onLeft }) {
+ memberUpload, onMemberUpload, onPaired, onLeft }) {
const [members, setMembers] = useState([]);
const [adminId, setAdminId] = useState('');
const [loading, setLoading] = useState(true);
@@ -2350,6 +2368,37 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
}
}, [pairCode, transportRef, userId]);
+ const [uploadBusy, setUploadBusy] = useState(false);
+ const [uploadMsg, setUploadMsg] = useState('');
+
+ /**
+ * Close or open uploading for everyone who is not the operator.
+ *
+ * Signed, like removing a member: the node refuses an unsigned instruction,
+ * so this is a request to the node rather than a decision taken here. The
+ * button does not move until the node has said it did it.
+ */
+ const setUploads = useCallback(async (allowed) => {
+ const transport = transportRef && transportRef.current;
+ setUploadMsg('');
+ setUploadBusy(true);
+ try {
+ if (!transport || !transport.connected) {
+ throw new Error('Not connected to the node');
+ }
+ const sk = transport.sessionKeys && transport.sessionKeys.skEdB64;
+ const signFn = (sk && window.MeshBayKeys)
+ ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript)
+ : null;
+ await transport.setMemberUpload(allowed, signFn);
+ if (onMemberUpload) onMemberUpload(allowed);
+ } catch (err) {
+ setUploadMsg(err.message);
+ } finally {
+ setUploadBusy(false);
+ }
+ }, [transportRef, onMemberUpload]);
+
const [removing, setRemoving] = useState('');
/**
@@ -2504,6 +2553,27 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
</div>
`}
+ ${/* Operator only, and only with a live connection: the node is what
+ holds and enforces this, so there is nothing to show or change
+ without one. */ isNodeAdmin && connected && html`
+ <div class="settings-section">
+ <h3 class="settings-heading">${t('members.uploads_title')}</h3>
+ <div class="settings-row">
+ <span class="settings-label">
+ ${memberUpload ? t('members.uploads_on') : t('members.uploads_off')}
+ </span>
+ <button class="admin-btn" disabled=${uploadBusy}
+ onClick=${() => setUploads(!memberUpload)}>
+ ${uploadBusy ? '...'
+ : (memberUpload ? t('members.uploads_disable')
+ : t('members.uploads_enable'))}
+ </button>
+ </div>
+ <p class="settings-hint">${t('members.uploads_hint')}</p>
+ ${uploadMsg && html`<p class="error-msg">${uploadMsg}</p>`}
+ </div>
+ `}
+
${connected && html`
<div class="settings-section">
<h3 class="settings-heading">${t('device.mine_title')}</h3>
@@ -2744,7 +2814,8 @@ function ChatImage({ filename, entries, transportRef, gekRef }) {
return html`<img class="chat-att-thumb" src=${blobUrl} alt=${filename} />`;
}
-function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, onPreview }) {
+function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex,
+ onPreview, mayUpload = true }) {
const [messages, setMessages] = useState([]);
const [hasMore, setHasMore] = useState(false);
const [loadingOlder, setLoadingOlder] = useState(false);
@@ -3041,11 +3112,13 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, on
</button>
`}
<div class="chat-input-row">
+ ${mayUpload && 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>
+ `}
<textarea class="chat-input" rows="1"
placeholder="${t('chat.placeholder')}"
value=${input}
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 ddeaeeb..df72382 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -79,6 +79,12 @@ export default {
'group.tab_members': 'Mitglieder',
'group.tab_settings': "Einstellungen",
'members.danger_leave_hint': "Sie verlieren den Zugriff auf die Dateien und den Chat dieser Gruppe.",
+ 'members.uploads_title': "Uploads",
+ 'members.uploads_on': "Mitglieder können Dateien hinzufügen",
+ 'members.uploads_off': "Nur Sie können Dateien hinzufügen",
+ 'members.uploads_disable': "Ausschalten",
+ 'members.uploads_enable': "Einschalten",
+ 'members.uploads_hint': "Gilt für alle außer Ihnen. Der Knoten lehnt den Upload selbst ab — es geht nicht darum, eine Schaltfläche zu verbergen.",
'members.danger_delete_hint': "Die Gruppe verschwindet für alle Mitglieder. Das lässt sich nicht rückgängig machen.",
'group.filter': 'Dateien filtern …',
'group.col_name': 'Name',
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 9a0f754..ce14f0b 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -80,6 +80,12 @@ export default {
'group.tab_members': 'Members',
'group.tab_settings': "Settings",
'members.danger_leave_hint': "You will lose access to this group's files and chat.",
+ 'members.uploads_title': "Uploads",
+ 'members.uploads_on': "Members can add files",
+ 'members.uploads_off': "Only you can add files",
+ 'members.uploads_disable': "Turn off",
+ 'members.uploads_enable': "Turn on",
+ 'members.uploads_hint': "Applies to everyone but you. The node refuses the upload itself, so this is not a matter of hiding a button.",
'members.danger_delete_hint': "This removes the group for every member. It cannot be undone.",
'group.filter': 'Filter files...',
'group.col_name': 'Name',
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 0c72445..2d9084b 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -77,6 +77,12 @@ export default {
'group.tab_members': 'Miembros',
'group.tab_settings': "Ajustes",
'members.danger_leave_hint': "Perderá el acceso a los archivos y al chat de este grupo.",
+ 'members.uploads_title': "Subidas",
+ 'members.uploads_on': "Los miembros pueden añadir archivos",
+ 'members.uploads_off': "Solo usted puede añadir archivos",
+ 'members.uploads_disable': "Desactivar",
+ 'members.uploads_enable': "Activar",
+ 'members.uploads_hint': "Se aplica a todos menos a usted. El nodo rechaza la subida por sí mismo: no se trata de ocultar un botón.",
'members.danger_delete_hint': "El grupo desaparece para todos sus miembros. No se puede deshacer.",
'group.filter': 'Filtrar archivos...',
'group.col_name': 'Nombre',
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 0dec5cf..9aa2857 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -78,6 +78,12 @@ export default {
'group.tab_members': 'Membres',
'group.tab_settings': "Paramètres",
'members.danger_leave_hint': "Vous perdrez l’accès aux fichiers et à la discussion de ce groupe.",
+ 'members.uploads_title': "Envois de fichiers",
+ 'members.uploads_on': "Les membres peuvent ajouter des fichiers",
+ 'members.uploads_off': "Vous seul pouvez ajouter des fichiers",
+ 'members.uploads_disable': "Désactiver",
+ 'members.uploads_enable': "Activer",
+ 'members.uploads_hint': "S’applique à tout le monde sauf vous. C’est le nœud qui refuse l’envoi : il ne s’agit pas de masquer un bouton.",
'members.danger_delete_hint': "Le groupe disparaît pour tous ses membres. C’est irréversible.",
'group.filter': 'Filtrer les fichiers...',
'group.col_name': 'Nom',
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 81b6a3a..379e7cb 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -78,6 +78,12 @@ export default {
'group.tab_members': 'Membri',
'group.tab_settings': "Impostazioni",
'members.danger_leave_hint': "Perderai l’accesso ai file e alla chat di questo gruppo.",
+ 'members.uploads_title': "Caricamenti",
+ 'members.uploads_on': "I membri possono aggiungere file",
+ 'members.uploads_off': "Solo tu puoi aggiungere file",
+ 'members.uploads_disable': "Disattiva",
+ 'members.uploads_enable': "Attiva",
+ 'members.uploads_hint': "Vale per tutti tranne te. È il nodo a rifiutare il caricamento: non si tratta di nascondere un pulsante.",
'members.danger_delete_hint': "Il gruppo scompare per tutti i membri. Non è reversibile.",
'group.filter': 'Filtra i file...',
'group.col_name': 'Nome',
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 11c6e31..45e7aa5 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -76,6 +76,12 @@ export default {
'group.tab_members': 'メンバー',
'group.tab_settings': "設定",
'members.danger_leave_hint': "このグループのファイルとチャットにアクセスできなくなります。",
+ 'members.uploads_title': "アップロード",
+ 'members.uploads_on': "メンバーはファイルを追加できます",
+ 'members.uploads_off': "あなただけがファイルを追加できます",
+ 'members.uploads_disable': "無効にする",
+ 'members.uploads_enable': "有効にする",
+ 'members.uploads_hint': "あなた以外の全員に適用されます。ノード自身がアップロードを拒否するため、ボタンを隠すだけの話ではありません。",
'members.danger_delete_hint': "グループはすべてのメンバーから消えます。元に戻せません。",
'group.filter': 'ファイルを絞り込み…',
'group.col_name': '名前',
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 1af0eb1..37d3e24 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -79,6 +79,12 @@ export default {
'group.tab_members': 'Leden',
'group.tab_settings': "Instellingen",
'members.danger_leave_hint': "U verliest de toegang tot de bestanden en de chat van deze groep.",
+ 'members.uploads_title': "Uploads",
+ 'members.uploads_on': "Leden kunnen bestanden toevoegen",
+ 'members.uploads_off': "Alleen u kunt bestanden toevoegen",
+ 'members.uploads_disable': "Uitschakelen",
+ 'members.uploads_enable': "Inschakelen",
+ 'members.uploads_hint': "Geldt voor iedereen behalve u. De node weigert de upload zelf — het gaat niet om het verbergen van een knop.",
'members.danger_delete_hint': "De groep verdwijnt voor alle leden. Dit kan niet ongedaan worden gemaakt.",
'group.filter': 'Bestanden filteren...',
'group.col_name': 'Naam',
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 f57dc90..6766515 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -83,6 +83,12 @@ export default {
'group.tab_members': 'Członkowie',
'group.tab_settings': "Ustawienia",
'members.danger_leave_hint': "Utracisz dostęp do plików i czatu tej grupy.",
+ 'members.uploads_title': "Przesyłanie plików",
+ 'members.uploads_on': "Członkowie mogą dodawać pliki",
+ 'members.uploads_off': "Tylko Ty możesz dodawać pliki",
+ 'members.uploads_disable': "Wyłącz",
+ 'members.uploads_enable': "Włącz",
+ 'members.uploads_hint': "Dotyczy wszystkich poza Tobą. To węzeł odrzuca przesłanie — nie chodzi o ukrycie przycisku.",
'members.danger_delete_hint': "Grupa zniknie dla wszystkich członków. Tego nie można cofnąć.",
'group.filter': 'Filtruj pliki...',
'group.col_name': 'Nazwa',
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 0f1586f..fa46887 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
@@ -79,6 +79,12 @@ export default {
'group.tab_members': 'Membros',
'group.tab_settings': "Configurações",
'members.danger_leave_hint': "Você perderá o acesso aos arquivos e ao chat deste grupo.",
+ 'members.uploads_title': "Envios",
+ 'members.uploads_on': "Os membros podem adicionar arquivos",
+ 'members.uploads_off': "Somente você pode adicionar arquivos",
+ 'members.uploads_disable': "Desativar",
+ 'members.uploads_enable': "Ativar",
+ 'members.uploads_hint': "Vale para todos menos você. O nó recusa o envio por conta própria: não se trata de esconder um botão.",
'members.danger_delete_hint': "O grupo desaparece para todos os membros. Não há como desfazer.",
'group.filter': 'Filtrar arquivos...',
'group.col_name': 'Nome',
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 89927bb..3276db8 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
@@ -76,6 +76,12 @@ export default {
'group.tab_members': '成员',
'group.tab_settings': "设置",
'members.danger_leave_hint': "您将无法再访问该群组的文件和聊天。",
+ 'members.uploads_title': "上传",
+ 'members.uploads_on': "成员可以添加文件",
+ 'members.uploads_off': "只有您可以添加文件",
+ 'members.uploads_disable': "关闭",
+ 'members.uploads_enable': "开启",
+ 'members.uploads_hint': "适用于除您之外的所有人。节点自身会拒绝上传,并非只是隐藏按钮。",
'members.danger_delete_hint': "该群组将对所有成员消失,且无法恢复。",
'group.filter': '筛选文件…',
'group.col_name': '名称',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
index 853ae75..a0509a0 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -103,6 +103,7 @@ class MeshBayTransport {
set onStreamEnd(fn) { this._onStreamEnd = fn; }
set onStreamError(fn) { this._onStreamError = fn; }
set onIndexSync(fn) { this._onIndexSync = fn; }
+ set onUploadPolicy(fn) { this._onUploadPolicy = fn; }
get sessionKeys() { return this._sessionKeys; }
@@ -592,6 +593,24 @@ class MeshBayTransport {
* Only the node can do this: its roster decides who it serves. Removing them
* 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;
+ }
+
async revokeMember(userId, signFn) {
const msg = await this._sendAndWait({
type: 'member_revoke', v: '0.1', user_id: userId,
@@ -1079,6 +1098,14 @@ 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.
+ if (msg.type === 'member_upload_ack' && this._onUploadPolicy) {
+ this._onUploadPolicy(Boolean(msg.allowed));
+ }
+
if (msg.type === 'index_sync' && msg.entries) {
if (this._onIndexSync) this._onIndexSync(msg);
const oldest = this._pending.entries().next();
diff --git a/packages/meshbay-hub/tests/test_upload_controls_hidden.py b/packages/meshbay-hub/tests/test_upload_controls_hidden.py
new file mode 100644
index 0000000..d859125
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_upload_controls_hidden.py
@@ -0,0 +1,122 @@
+"""
+When the operator closes uploading, the controls go — both of them.
+
+There are two ways to put a file into a group and they are 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.
+
+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.
+"""
+
+import re
+from pathlib import Path
+
+import pytest
+
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+APP = STATIC / "app.js"
+TRANSPORT = STATIC / "transport.js"
+
+pytestmark = pytest.mark.skipif(not APP.exists(), reason="SPA sources unavailable")
+
+
+@pytest.fixture(scope="module")
+def app() -> str:
+ return APP.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)]
+
+
+# ── Both controls ───────────────────────────────────────────────────────────
+
+def test_the_files_toolbar_hides_its_upload_button(app):
+ page = _component(app, "GroupPage")
+ toolbar = page[page.index("file-toolbar"):]
+ toolbar = toolbar[:toolbar.index("group.mkdir")]
+ assert "mayUpload &&" in toolbar, "the Upload button is offered regardless"
+
+
+def test_the_chat_composer_hides_its_paperclip(app):
+ chat = _component(app, "ChatPanel")
+ composer = chat[chat.index("chat-input-row"):]
+ assert "mayUpload &&" 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."""
+ page = _component(app, "GroupPage")
+ assert re.search(r"const mayUpload = memberUpload \|\| isNodeAdmin;", page), (
+ "mayUpload is no longer derived in one place")
+ assert "mayUpload=${mayUpload}" in page, "the chat panel is told separately"
+
+
+def test_the_operator_keeps_their_own_controls(app):
+ page = _component(app, "GroupPage")
+ assert "memberUpload || isNodeAdmin" in page, (
+ "turning uploads off would hide the operator's own upload button")
+
+
+# ── Learning the answer ─────────────────────────────────────────────────────
+
+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."""
+ page = _component(app, "GroupPage")
+ assert "ack.member_upload !== false" in page, (
+ "the handshake ack is what carries this")
+ assert "hubFetch" not in page[page.index("ack.member_upload") - 400:
+ page.index("ack.member_upload")]
+
+
+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."""
+ page = _component(app, "GroupPage")
+ assert "!== false" in page[page.index("ack.member_upload"):
+ page.index("ack.member_upload") + 60]
+
+
+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."""
+ page = _component(app, "GroupPage")
+ assert "transport.onUploadPolicy" in page
+ transport = TRANSPORT.read_text(encoding="utf-8")
+ assert "member_upload_ack" in transport, "nothing routes the node's notice"
+
+
+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."""
+ transport = TRANSPORT.read_text(encoding="utf-8")
+ block = transport[transport.index("member_upload_ack"):]
+ block = block[:block.index("index_sync")]
+ assert "return" not in block
+
+
+# ── Changing it ─────────────────────────────────────────────────────────────
+
+def test_changing_it_is_signed(app):
+ 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")
+
+
+def test_only_the_operator_is_offered_the_setting(app):
+ panel = _component(app, "GroupSettingsPanel")
+ section = panel[panel.index("members.uploads_title") - 400:
+ panel.index("members.uploads_title")]
+ assert "isNodeAdmin && connected" in section
diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py
index f4dcca5..45aca7a 100644
--- a/packages/meshbay-node/src/meshbay_node/daemon.py
+++ b/packages/meshbay-node/src/meshbay_node/daemon.py
@@ -256,6 +256,13 @@ class NodeDaemon:
# 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,
}
if not groups_ctx:
diff --git a/packages/meshbay-node/src/meshbay_node/roster.py b/packages/meshbay-node/src/meshbay_node/roster.py
index 226b784..c811016 100644
--- a/packages/meshbay-node/src/meshbay_node/roster.py
+++ b/packages/meshbay-node/src/meshbay_node/roster.py
@@ -100,6 +100,26 @@ CREATE TABLE IF NOT EXISTS members (
PRIMARY KEY (group_id, user_id)
);
+-- Per-group settings the operator changes while the node runs.
+--
+-- Not node.toml: that file is hand-written, full of comments explaining
+-- decisions, and `ops.py` deliberately appends to it rather than round-tripping
+-- it through a TOML writer. A setting toggled from a panel has to take effect
+-- without an edit to the operator's file and without a restart, so it lives
+-- here, where the node already keeps what it decided rather than what it was
+-- configured with.
+--
+-- Absent means default. Nothing writes a row until someone changes something,
+-- so an existing node has the same behaviour it had before this table existed.
+CREATE TABLE IF NOT EXISTS group_settings (
+ group_id TEXT NOT NULL,
+ key TEXT NOT NULL,
+ value TEXT NOT NULL,
+ set_by TEXT NOT NULL DEFAULT '',
+ set_at TEXT NOT NULL DEFAULT '',
+ PRIMARY KEY (group_id, key)
+);
+
CREATE TABLE IF NOT EXISTS invites (
code_hash TEXT PRIMARY KEY,
group_id TEXT NOT NULL,
@@ -518,6 +538,43 @@ class Roster:
# ── Invites ──────────────────────────────────────────────────────────────
+ # ── 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"
+
+ async def get_setting(self, group_id: str, key: str,
+ default: str | None = None) -> str | None:
+ async with self._db.execute(
+ "SELECT value FROM group_settings WHERE group_id = ? AND key = ?",
+ (group_id, key)) as cur:
+ row = await cur.fetchone()
+ return row["value"] if row else default
+
+ async def set_setting(self, group_id: str, key: str, value: str,
+ set_by: str = "") -> None:
+ await self._db.execute(
+ "INSERT INTO group_settings (group_id, key, value, set_by, set_at) "
+ "VALUES (?, ?, ?, ?, ?) "
+ "ON CONFLICT(group_id, key) DO UPDATE SET "
+ "value = excluded.value, set_by = excluded.set_by, "
+ "set_at = excluded.set_at",
+ (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
+
async def create_invite(
self,
group_id: str,
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 9d16f82..22e5e15 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
@@ -62,6 +62,7 @@ from meshbay_common.adminop import (
OP_MEMBER_REVOKE,
OP_GEK_ROTATE,
OP_MEMBER_UNPIN,
+ OP_MEMBER_UPLOAD,
admin_transcript,
)
from meshbay_common.crypto import pk_to_b64, wrap_gek_aes
@@ -469,6 +470,8 @@ class WebRTCPeerSession:
self._spawn(self._do_device_list(msg))
elif mtype == MNP.DEVICE_REVOKE:
self._spawn(self._do_device_revoke(msg))
+ elif mtype == MNP.MEMBER_UPLOAD:
+ self._do_member_upload(msg)
elif mtype == MNP.MEMBER_UNPIN:
self._do_member_unpin(msg)
elif mtype == MNP.GEK_ROTATE:
@@ -687,7 +690,11 @@ class WebRTCPeerSession:
"proof": base64.b64encode(node_proof).decode(),
"sig": base64.b64encode(
self._ctx["sk_node"].sign(node_transcript)).decode(),
- "is_node_admin": bool(node_user_id and self._user_id == node_user_id),
+ "is_node_admin": self._is_node_admin(),
+ # So the interface knows whether to offer uploading at all. Not a
+ # permission — the node refuses regardless — but without it the
+ # only way to discover the answer is to try.
+ "member_upload": bool(self._group_ctx().get("member_upload", True)),
}
if node_user_id:
ack["node_user_id"] = node_user_id
@@ -1568,6 +1575,58 @@ class WebRTCPeerSession:
self._send({"type": MNP.MEMBER_UNPIN_ACK, "v": MNP_VERSION,
"user_id": user_id})
+ def _do_member_upload(self, msg: dict) -> None:
+ """
+ Turn uploading by ordinary members on or off, for this group.
+
+ Signed like every other operator action. The setting decides who may
+ write to the operator's disk, so a node that took it from an unsigned
+ message would let any member turn it back on for everyone — the control
+ would be a suggestion.
+ """
+ if "allowed" not in msg:
+ self._send({"type": "error", "detail": "Missing allowed"})
+ return
+ if not self._has_admin_authority():
+ self._send({"type": "error", "detail": "No authorized key for this"})
+ return
+ # The subject is what the operator is shown before signing, so it has to
+ # name the outcome rather than the operation.
+ self._issue_admin_challenge(
+ OP_MEMBER_UPLOAD, "on" if msg.get("allowed") else "off")
+
+ async def _admin_exec_member_upload(
+ self, pending: dict, transcript: bytes, sig: bytes,
+ ) -> None:
+ allowed = pending["subject"] == "on"
+ if not await self._verify_admin_sig(transcript, sig):
+ self._send({"type": "error", "detail": "Signature verification failed"})
+ self._audit("admin_auth_failed", f"member_upload:{pending['subject']}")
+ return
+ roster = self._ctx.get("roster")
+ if roster is None:
+ self._send({"type": "error", "detail": "No roster on this node"})
+ return
+ await roster.set_member_upload(self._group_id or "", allowed,
+ set_by=self._user_id)
+ # Stored *and* applied. The upload path is synchronous and reads this
+ # dict; leaving it to the next restart would make the panel say one
+ # thing while the node did another.
+ self._group_ctx()["member_upload"] = allowed
+ self._audit("member_upload", pending["subject"])
+
+ # Everyone already connected is told, rather than finding out by having
+ # an upload refused. Enforcement does not depend on this reaching them —
+ # it is the node that refuses — but a button that stays visible until
+ # the next reconnection is a button people press.
+ notice = {"type": MNP.MEMBER_UPLOAD_ACK, "v": MNP_VERSION,
+ "allowed": allowed}
+ for uid, session in list(self._peer_registry().items()):
+ try:
+ session._send(notice)
+ except Exception:
+ pass
+
async def _run_op(self, fn, *args, **kwargs):
"""
Call an operation from `meshbay_node.ops` with the daemon's own view.
@@ -1995,6 +2054,19 @@ class WebRTCPeerSession:
"filename": filename})
return
+ # The operator can close uploading to everyone but themselves. Enforced
+ # here rather than by hiding a button: the button is a courtesy to the
+ # people who are not trying, and this is the part that holds against
+ # someone who is. `is_node_admin` is computed from the identity this
+ # node pinned, never from a hub claim.
+ if not ctx.get("member_upload", True) and not self._is_node_admin():
+ self._send({"type": "error",
+ "detail": "Uploading is turned off for this group",
+ "code": "member_upload_off",
+ "filename": filename})
+ self._audit("upload_refused", filename[:64])
+ return
+
roots: RootSet | None = ctx.get("roots")
upload_root = roots.upload_root if roots else None
if upload_root is None:
@@ -2189,6 +2261,17 @@ class WebRTCPeerSession:
if ident:
self._pinned_pk = ident["pk_ed25519"]
+ def _is_node_admin(self) -> bool:
+ """
+ Whether the peer on this connection is the node's operator.
+
+ Was written out twice — once in the handshake ack and once at the gate
+ below it — which is how the two come to disagree. From the node's own
+ record of who it belongs to, never from a hub claim.
+ """
+ node_user_id = self._ctx.get("node_user_id")
+ return bool(node_user_id and self._user_id == node_user_id)
+
def _has_admin_authority(self) -> bool:
"""
Cheap synchronous pre-check: is there anyone who could authorize this?
@@ -2269,6 +2352,9 @@ class WebRTCPeerSession:
elif pending["op"] == OP_MEMBER_UNPIN:
self._spawn(
self._admin_exec_member_unpin(pending, transcript, sig_bytes))
+ elif pending["op"] == OP_MEMBER_UPLOAD:
+ self._spawn(
+ self._admin_exec_member_upload(pending, transcript, sig_bytes))
else:
self._send({"type": "error", "detail": "Unknown admin operation"})
diff --git a/packages/meshbay-node/tests/test_member_upload_policy.py b/packages/meshbay-node/tests/test_member_upload_policy.py
new file mode 100644
index 0000000..b1dc0cb
--- /dev/null
+++ b/packages/meshbay-node/tests/test_member_upload_policy.py
@@ -0,0 +1,176 @@
+"""
+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()