diff options
Diffstat (limited to 'packages')
25 files changed, 603 insertions, 48 deletions
diff --git a/packages/meshbay-common/src/meshbay_common/adminop.py b/packages/meshbay-common/src/meshbay_common/adminop.py index 8638e60..3e9fe11 100644 --- a/packages/meshbay-common/src/meshbay_common/adminop.py +++ b/packages/meshbay-common/src/meshbay_common/adminop.py @@ -99,6 +99,11 @@ OP_ROOT_REMOVE = "root_remove" OP_APP_DIRECTORIES = "app_directories" OP_CHAT_DIRECTORY = "chat_directory" OP_CHAT_LINK_PREVIEW = "chat_link_preview" +# Whether this group's files appear in its members' cross-group Search. A +# presentation choice and nothing more — every member still lists the group by +# opening it — but it changes what every member's Search shows, so it is the +# operator's, signed like the other per-group switches. +OP_SEARCH_LISTED = "search_listed" # Open a new chat epoch for a group, by hand. The removals that matter open one # by themselves (member revoke/unpin, device revoke, gek_rotate); this is the # operator saying "do it anyway", which is the same shape as `gek_rotate` and diff --git a/packages/meshbay-common/src/meshbay_common/protocol.py b/packages/meshbay-common/src/meshbay_common/protocol.py index 1dfa519..f4cd497 100644 --- a/packages/meshbay-common/src/meshbay_common/protocol.py +++ b/packages/meshbay-common/src/meshbay_common/protocol.py @@ -223,6 +223,9 @@ class MNP: CHAT_DIRECTORY_ACK = "chat_directory_ack" CHAT_LINK_PREVIEW = "chat_link_preview" CHAT_LINK_PREVIEW_ACK = "chat_link_preview_ack" + # Whether the group's files are listed in members' cross-group Search. + SEARCH_LISTED = "search_listed" + SEARCH_LISTED_ACK = "search_listed_ack" # The keys a group's chat archive is encrypted under, on their way to a # member. Sealed under a group-derived subkey, so the payload carries an # authentication tag from a key the hub does not hold — and a member who has 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 0f28adf..988bcb1 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js @@ -149,6 +149,9 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, // destination rather than a set of folders it reads. const [chatDirectory, setChatDirectory] = useState(''); const [chatLinkPreview, setChatLinkPreview] = useState(true); + // Whether members' cross-group Search lists this group. Not an app setting: + // it is about the group as a whole, and it hides nothing from this page. + const [searchListed, setSearchListed] = useState(true); // MusicBrainz on/off (per-group) — docs/musicbay.md §3.2. const [musicbrainzConfig, setMusicbrainzConfig] = useState(null); const onPlayQueue = useCallback((tracks, startIndex) => { @@ -401,6 +404,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, .map((k) => [k.slice(0, -'_directories'.length), ack[k] || []]))); setChatDirectory(ack.chat_directory || ''); setChatLinkPreview(ack.chat_link_preview !== false); + setSearchListed(ack.search_listed !== false); setMusicbrainzConfig({ enabled: ack.musicbrainz_enabled !== false, }); @@ -417,6 +421,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, setAppDirectories((prev) => ({ ...prev, [app]: dirs })); transport.onChatDirectory = (path) => setChatDirectory(path); transport.onChatLinkPreview = (on) => setChatLinkPreview(on); + transport.onSearchListed = (listed) => setSearchListed(listed); transport.onMusicbrainzEnabled = (enabled) => setMusicbrainzConfig((prev) => ({ ...(prev || {}), enabled })); transport.onRootsChanged = (msg) => { @@ -865,6 +870,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, onEnabledApps=${(keys) => setEnabledApps(keys)} scanSettings=${scanSettings} onScanSettings=${(s) => setScanSettings(s)} + searchListed=${searchListed} entries=${entries} nodeDirs=${nodeDirs} appSettings=${appSettings} ${/* The saving pane already knows what it asked for; this is so 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 0f8ccdd..4d67348 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js @@ -399,6 +399,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, mnpRoots, enabledApps, onEnabledApps, scanSettings, onScanSettings, + searchListed, entries, nodeDirs, appSettings, onAppDirectories, onRefreshIndex, @@ -652,6 +653,27 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, } }, [transportRef, onScanSettings, reconcileMinutes, debounceSeconds]); + // Whether members' cross-group Search lists this group. The switch follows + // the node's answer: the ack is broadcast and replayed to the requester + // (transport.js BROADCAST_ACK_TYPES), which is what moves `searchListed`. + const [listedBusy, setListedBusy] = useState(false); + const [listedMsg, setListedMsg] = useState(''); + const toggleSearchListed = useCallback(async (next) => { + const transport = transportRef && transportRef.current; + setListedMsg(''); + setListedBusy(true); + try { + if (!transport || !transport.connected) { + throw new Error('Not connected to the node'); + } + await transport.setSearchListed(next, adminSignFn); + } catch (err) { + setListedMsg(err.message); + } finally { + setListedBusy(false); + } + }, [transportRef, adminSignFn]); + // ── Per-app settings ──────────────────────────────────────────────── // // What every app's settings pane is given, and the one operation the page @@ -936,6 +958,23 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, ${appsMsg && html`<p class="error-msg">${appsMsg}</p>`} + ${/* Whether this group shows up in members' cross-group Search. A + listing preference, not a permission: the hint says so, because a + switch next to "members" and "devices" reads as access control. */ + isNodeAdmin && connected && html` + <${CollapsibleSection} titleKey="settings_node.search_listed_title" + defaultOpen=${false}> + <div class="settings-row"> + <${ToggleSwitch} checked=${searchListed !== false} + disabled=${listedBusy} + onChange=${toggleSearchListed} + label=${t('settings_node.search_listed_label')} /> + </div> + <p class="settings-hint">${t('settings_node.search_listed_hint')}</p> + ${listedMsg && html`<p class="error-msg">${listedMsg}</p>`} + </${CollapsibleSection}> + `} + ${/* How hard the node works watching its own disk — indexer.py DirectoryIndexer. A performance knob, not a permission: it changes nothing about who can see or do what. */ 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 8607011..4ad8b4b 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -710,6 +710,7 @@ export default { 'search.hint': 'Tippen Sie einen Begriff ein, um in allen Ihren Gruppen zu suchen.', 'search.indexing': 'Indexierung von {done} / {total} Gruppen …', 'search.unreachable': { one: '{n} Gruppe nicht erreichbar', other: '{n} Gruppen nicht erreichbar' }, + 'search.unlisted': { one: '{n} Gruppe wird in der Suche nicht angezeigt – öffnen Sie sie zum Durchsuchen', other: '{n} Gruppen werden in der Suche nicht angezeigt – öffnen Sie sie zum Durchsuchen' }, 'search.n_sources': { one: '{n} Quelle', other: '{n} Quellen' }, 'search.view_files': 'Dateien', 'search.view_videos': 'Videos', @@ -889,6 +890,9 @@ export default { 'settings_node.roots': 'Freigegebene Verzeichnisse', 'settings_node.scan_title': 'Scan', 'settings_node.scan_hint': 'Wie oft der Node seine Verzeichnisse erneut auf verpasste Änderungen prüft und wie lange er nach einer Änderung wartet, bevor eine Datei indiziert wird.', + 'settings_node.search_listed_title': 'Suche', + 'settings_node.search_listed_label': 'Diese Gruppe in der Suche anzeigen', + 'settings_node.search_listed_hint': 'Wenn deaktiviert, erscheinen die Dateien dieser Gruppe nicht in der gruppenübergreifenden Suche der Mitglieder. Mitglieder sehen weiterhin alles, wenn sie die Gruppe öffnen: Das nimmt sie nur aus der globalen Auflistung und verbirgt vor niemandem etwas.', 'settings_node.scan_reconcile_label': 'Prüfintervall (Minuten)', 'settings_node.scan_debounce_label': 'Wartezeit nach einer Änderung (Sekunden)', 'settings_node.scan_save': 'Speichern', 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 fee6cc3..43a0db0 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -630,6 +630,9 @@ export default { 'settings_node.detach_failed_continue': 'Could not detach the group from the node. Delete on hub anyway?', 'settings_node.scan_title': 'Scanning', 'settings_node.scan_hint': 'How often the node re-checks its directories for changes it may have missed, and how long it waits after a file changes before indexing it.', + 'settings_node.search_listed_title': 'Search', + 'settings_node.search_listed_label': 'List this group in Search', + 'settings_node.search_listed_hint': 'When off, this group\'s files do not appear in members\' cross-group Search. Members still see everything by opening the group: this keeps it out of the global listing and hides nothing from anyone.', 'settings_node.scan_reconcile_label': 'Re-check interval (minutes)', 'settings_node.scan_debounce_label': 'Wait after a change (seconds)', 'settings_node.scan_save': 'Save', @@ -819,6 +822,7 @@ export default { 'search.hint': 'Type to search file names across all your groups.', 'search.indexing': 'Indexing {done} / {total} groups...', 'search.unreachable': { one: '{n} group unreachable', other: '{n} groups unreachable' }, + 'search.unlisted': { one: '{n} group is not listed in Search — open it to browse', other: '{n} groups are not listed in Search — open them to browse' }, 'search.n_sources': { one: '{n} source', other: '{n} sources' }, 'search.view_files': 'Files', 'search.view_videos': 'Videos', 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 b958da3..0df5a38 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -705,6 +705,7 @@ export default { 'search.hint': 'Escriba un término para buscar en todos sus grupos.', 'search.indexing': 'Indexando {done} / {total} grupos…', 'search.unreachable': { one: '{n} grupo inalcanzable', other: '{n} grupos inalcanzables' }, + 'search.unlisted': { one: '{n} grupo no aparece en la búsqueda: ábrelo para explorarlo', other: '{n} grupos no aparecen en la búsqueda: ábrelos para explorarlos' }, 'search.n_sources': { one: '{n} fuente', other: '{n} fuentes' }, 'search.view_files': 'Archivos', 'search.view_videos': 'Vídeos', @@ -884,6 +885,9 @@ export default { 'settings_node.roots': 'Directorios compartidos', 'settings_node.scan_title': 'Escaneo', 'settings_node.scan_hint': 'Con qué frecuencia el node vuelve a comprobar sus directorios en busca de cambios que pudo haber pasado por alto, y cuánto tiempo espera tras un cambio antes de indexar un archivo.', + 'settings_node.search_listed_title': 'Búsqueda', + 'settings_node.search_listed_label': 'Mostrar este grupo en la búsqueda', + 'settings_node.search_listed_hint': 'Si está desactivado, los archivos de este grupo no aparecen en la búsqueda global de los miembros. Los miembros siguen viéndolo todo al abrir el grupo: solo lo saca del listado global y no oculta nada a nadie.', 'settings_node.scan_reconcile_label': 'Intervalo de comprobación (minutos)', 'settings_node.scan_debounce_label': 'Espera tras un cambio (segundos)', 'settings_node.scan_save': 'Guardar', 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 c993d9c..e7dcacd 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -708,6 +708,7 @@ export default { 'search.hint': 'Saisissez un terme pour rechercher dans tous vos groupes.', 'search.indexing': 'Indexation de {done} / {total} groupes…', 'search.unreachable': { one: '{n} groupe injoignable', other: '{n} groupes injoignables' }, + 'search.unlisted': { one: '{n} groupe n\'est pas affiché dans la recherche — ouvrez-le pour le parcourir', other: '{n} groupes ne sont pas affichés dans la recherche — ouvrez-les pour les parcourir' }, 'search.n_sources': { one: '{n} source', other: '{n} sources' }, 'search.view_files': 'Fichiers', 'search.view_videos': 'Vidéos', @@ -902,6 +903,9 @@ export default { 'settings_node.roots': 'Répertoires partagés', 'settings_node.scan_title': 'Analyse', 'settings_node.scan_hint': 'À quelle fréquence le node revérifie ses répertoires à la recherche de changements manqués, et combien de temps il attend après une modification avant d\'indexer un fichier.', + 'settings_node.search_listed_title': 'Recherche', + 'settings_node.search_listed_label': 'Afficher ce groupe dans la recherche', + 'settings_node.search_listed_hint': 'Désactivé, les fichiers de ce groupe n\'apparaissent pas dans la recherche globale des membres. Les membres voient toujours tout en ouvrant le groupe : cela le retire du listage global, sans rien cacher à personne.', 'settings_node.scan_reconcile_label': 'Intervalle de revérification (minutes)', 'settings_node.scan_debounce_label': 'Attente après un changement (secondes)', 'settings_node.scan_save': 'Enregistrer', 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 3d4b502..26de960 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -707,6 +707,7 @@ export default { 'search.hint': 'Digita un termine per cercare in tutti i tuoi gruppi.', 'search.indexing': 'Indicizzazione di {done} / {total} gruppi…', 'search.unreachable': { one: '{n} gruppo non raggiungibile', other: '{n} gruppi non raggiungibili' }, + 'search.unlisted': { one: '{n} gruppo non è mostrato nella ricerca: aprilo per sfogliarlo', other: '{n} gruppi non sono mostrati nella ricerca: aprili per sfogliarli' }, 'search.n_sources': { one: '{n} fonte', other: '{n} fonti' }, 'search.view_files': 'File', 'search.view_videos': 'Video', @@ -898,6 +899,9 @@ export default { 'settings_node.roots': 'Directory condivise', 'settings_node.scan_title': 'Scansione', 'settings_node.scan_hint': 'Con quale frequenza il node ricontrolla le sue directory per cambiamenti che potrebbe aver perso, e quanto tempo attende dopo una modifica prima di indicizzare un file.', + 'settings_node.search_listed_title': 'Ricerca', + 'settings_node.search_listed_label': 'Mostra questo gruppo nella ricerca', + 'settings_node.search_listed_hint': 'Se disattivato, i file di questo gruppo non compaiono nella ricerca globale dei membri. I membri vedono comunque tutto aprendo il gruppo: lo esclude solo dall\'elenco globale e non nasconde nulla a nessuno.', 'settings_node.scan_reconcile_label': 'Intervallo di ricontrollo (minuti)', 'settings_node.scan_debounce_label': 'Attesa dopo una modifica (secondi)', 'settings_node.scan_save': 'Salva', 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 3140527..4db42a1 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -695,6 +695,7 @@ export default { 'search.hint': '入力してすべてのグループを検索します。', 'search.indexing': '{done} / {total} グループをインデックス中…', 'search.unreachable': { other: '{n} グループに接続できません' }, + 'search.unlisted': { other: '{n} グループは検索に表示されません — 開いて閲覧してください' }, 'search.n_sources': { other: '{n} 個のソース' }, 'search.view_files': 'ファイル', 'search.view_videos': '動画', @@ -882,6 +883,9 @@ export default { 'settings_node.roots': '共有ディレクトリ', 'settings_node.scan_title': 'スキャン', 'settings_node.scan_hint': 'node が見逃した変更がないかディレクトリを再確認する頻度と、ファイルの変更後にインデックスするまで待つ時間。', + 'settings_node.search_listed_title': '検索', + 'settings_node.search_listed_label': 'このグループを検索に表示する', + 'settings_node.search_listed_hint': 'オフにすると、このグループのファイルはメンバーのグループ横断検索に表示されません。メンバーはグループを開けば引き続きすべて見られます。全体の一覧から外すだけで、誰に対しても何も隠しません。', 'settings_node.scan_reconcile_label': '再確認の間隔(分)', 'settings_node.scan_debounce_label': '変更後の待機時間(秒)', 'settings_node.scan_save': '保存', 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 1e55c8c..a345e94 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -709,6 +709,7 @@ export default { 'search.hint': 'Typ een zoekterm om in al uw groepen te zoeken.', 'search.indexing': '{done} / {total} groepen indexeren…', 'search.unreachable': { one: '{n} groep onbereikbaar', other: '{n} groepen onbereikbaar' }, + 'search.unlisted': { one: '{n} groep wordt niet getoond in Zoeken — open haar om te bladeren', other: '{n} groepen worden niet getoond in Zoeken — open ze om te bladeren' }, 'search.n_sources': { one: '{n} bron', other: '{n} bronnen' }, 'search.view_files': 'Bestanden', 'search.view_videos': 'Video\'s', @@ -900,6 +901,9 @@ export default { 'settings_node.roots': 'Gedeelde mappen', 'settings_node.scan_title': 'Scannen', 'settings_node.scan_hint': 'Hoe vaak de node zijn mappen opnieuw controleert op wijzigingen die zijn gemist, en hoe lang hij na een wijziging wacht voordat een bestand wordt geïndexeerd.', + 'settings_node.search_listed_title': 'Zoeken', + 'settings_node.search_listed_label': 'Deze groep tonen in Zoeken', + 'settings_node.search_listed_hint': 'Uitgeschakeld verschijnen de bestanden van deze groep niet in de groepsoverstijgende zoekfunctie van leden. Leden zien nog steeds alles door de groep te openen: dit haalt haar alleen uit de globale lijst en verbergt niets voor niemand.', 'settings_node.scan_reconcile_label': 'Controle-interval (minuten)', 'settings_node.scan_debounce_label': 'Wachttijd na een wijziging (seconden)', 'settings_node.scan_save': 'Opslaan', 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 8486718..665fd0b 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -727,6 +727,7 @@ export default { 'search.hint': 'Wpisz frazę, aby wyszukać we wszystkich grupach.', 'search.indexing': 'Indeksowanie {done} / {total} grup…', 'search.unreachable': { one: '{n} grupa nieosiągalna', few: '{n} grupy nieosiągalne', many: '{n} grup nieosiągalnych', other: '{n} grupy nieosiągalnej' }, + 'search.unlisted': { one: '{n} grupa nie jest pokazywana w wyszukiwaniu — otwórz ją, aby przeglądać', few: '{n} grupy nie są pokazywane w wyszukiwaniu — otwórz je, aby przeglądać', many: '{n} grup nie jest pokazywanych w wyszukiwaniu — otwórz je, aby przeglądać', other: '{n} grupy nie jest pokazywane w wyszukiwaniu — otwórz je, aby przeglądać' }, 'search.n_sources': { one: '{n} źródło', few: '{n} źródła', many: '{n} źródeł', other: '{n} źródła' }, 'search.view_files': 'Pliki', 'search.view_videos': 'Filmy', @@ -926,6 +927,9 @@ export default { 'settings_node.roots': 'Katalogi współdzielone', 'settings_node.scan_title': 'Skanowanie', 'settings_node.scan_hint': 'Jak często node ponownie sprawdza swoje katalogi w poszukiwaniu pominiętych zmian oraz jak długo czeka po zmianie pliku przed jego zindeksowaniem.', + 'settings_node.search_listed_title': 'Wyszukiwanie', + 'settings_node.search_listed_label': 'Pokazuj tę grupę w wyszukiwaniu', + 'settings_node.search_listed_hint': 'Po wyłączeniu pliki tej grupy nie pojawiają się w globalnym wyszukiwaniu członków. Członkowie nadal widzą wszystko po otwarciu grupy: to tylko usuwa ją z globalnej listy i niczego przed nikim nie ukrywa.', 'settings_node.scan_reconcile_label': 'Interwał sprawdzania (minuty)', 'settings_node.scan_debounce_label': 'Oczekiwanie po zmianie (sekundy)', 'settings_node.scan_save': 'Zapisz', 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 e51df81..a85d96e 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 @@ -706,6 +706,7 @@ export default { 'search.hint': 'Digite um termo para pesquisar em todos os seus grupos.', 'search.indexing': 'Indexando {done} / {total} grupos…', 'search.unreachable': { one: '{n} grupo inacessível', other: '{n} grupos inacessíveis' }, + 'search.unlisted': { one: '{n} grupo não aparece na busca — abra-o para navegar', other: '{n} grupos não aparecem na busca — abra-os para navegar' }, 'search.n_sources': { one: '{n} fonte', other: '{n} fontes' }, 'search.view_files': 'Arquivos', 'search.view_videos': 'Vídeos', @@ -885,6 +886,9 @@ export default { 'settings_node.roots': 'Diretórios compartilhados', 'settings_node.scan_title': 'Varredura', 'settings_node.scan_hint': 'Com que frequência o node reverifica seus diretórios em busca de mudanças que possa ter perdido, e quanto tempo espera após uma mudança antes de indexar um arquivo.', + 'settings_node.search_listed_title': 'Busca', + 'settings_node.search_listed_label': 'Mostrar este grupo na busca', + 'settings_node.search_listed_hint': 'Desativado, os arquivos deste grupo não aparecem na busca global dos membros. Os membros continuam vendo tudo ao abrir o grupo: isso só o retira da listagem global e não esconde nada de ninguém.', 'settings_node.scan_reconcile_label': 'Intervalo de reverificação (minutos)', 'settings_node.scan_debounce_label': 'Espera após uma mudança (segundos)', 'settings_node.scan_save': 'Salvar', 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 cc22a70..3e7c1c9 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 @@ -682,6 +682,7 @@ export default { 'search.hint': '输入关键词搜索所有群组。', 'search.indexing': '正在索引 {done} / {total} 个群组…', 'search.unreachable': { other: '{n} 个群组无法连接' }, + 'search.unlisted': { other: '{n} 个群组未在搜索中显示——打开群组即可浏览' }, 'search.n_sources': { other: '{n} 个来源' }, 'search.view_files': '文件', 'search.view_videos': '视频', @@ -869,6 +870,9 @@ export default { 'settings_node.roots': '共享目录', 'settings_node.scan_title': '扫描', 'settings_node.scan_hint': 'node 多久重新检查一次目录以发现可能错过的变化,以及文件变化后等待多久才建立索引。', + 'settings_node.search_listed_title': '搜索', + 'settings_node.search_listed_label': '在搜索中显示此群组', + 'settings_node.search_listed_hint': '关闭后,此群组的文件不会出现在成员的跨群组搜索中。成员打开群组后仍可看到全部内容:这只是将其移出全局列表,不对任何人隐藏任何内容。', 'settings_node.scan_reconcile_label': '重新检查间隔(分钟)', 'settings_node.scan_debounce_label': '变化后的等待时间(秒)', 'settings_node.scan_save': '保存', 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 ee99ccc..a77e8c3 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/search-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/search-page.js @@ -32,6 +32,51 @@ const SEARCH_VIDEO_ROOT = '__search__'; const SEARCH_AUDIO_ROOT = '__search__'; const SEARCH_PHOTO_ROOTS = ['__search_photos__']; +// -- Connecting to a group ---------------------------------------------------- + +/** + * A live connection to one of the nodes serving `groupId`, and its ack — or + * null when the hub lists no node at all. + * + * Every node the hub lists, in turn, exactly as group-page.js does since + * 2026-09-11: the list is in hub registration order, and its head is not + * necessarily a node that answers. Search took `nodes[0]` and stopped, so a + * group with a second, working node counted as unreachable here while it + * opened fine from the sidebar. A refusal naming a state of this browser (a + * code, a passphrase, a device) is the same from every node and stops the + * walk; `not_hosted`, a timeout or a failed connection moves on. + */ +async function connectToGroup(hubBase, groupId, token, bundleKey, username, userId) { + const nodesData = await hubFetch(`/v1/groups/${groupId}/nodes`, { token }); + if (!nodesData.nodes || !nodesData.nodes.length) return null; + + const live = (await ensureFreshToken()) || token; + let lastErr = null; + for (const n of nodesData.nodes) { + const transport = new window.MeshBayTransport(hubBase, live); + transport.onNeedToken = async () => (await ensureFreshToken()) || token; + let timer; + try { + const ack = await Promise.race([ + transport.connect( + n.node_id, live, groupId, null, null, bundleKey, + username, userId, null), + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error('Connection timeout')), SEARCH_TIMEOUT); + }), + ]); + clearTimeout(timer); + return { transport, ack }; + } catch (e) { + clearTimeout(timer); + lastErr = e; + try { transport.close(); } catch {} + if (e.reason && e.reason !== 'not_hosted') throw e; + } + } + throw (lastErr || new Error('no node served this group')); +} + // -- Connection pool ---------------------------------------------------------- class ConnectionPool { @@ -66,29 +111,10 @@ class ConnectionPool { } async _doConnect(groupId, token, bundleKey, username, userId) { - const nodesData = await hubFetch(`/v1/groups/${groupId}/nodes`, { token }); - if (!nodesData.nodes || !nodesData.nodes.length) throw new Error('offline'); - - const live = (await ensureFreshToken()) || token; - const transport = new window.MeshBayTransport(this._hubBase, live); - transport.onNeedToken = async () => (await ensureFreshToken()) || token; - - let timer; - try { - await Promise.race([ - transport.connect( - nodesData.nodes[0].node_id, live, groupId, null, null, bundleKey, - username, userId, null), - new Promise((_, reject) => { - timer = setTimeout(() => reject(new Error('Connection timeout')), SEARCH_TIMEOUT); - }), - ]); - clearTimeout(timer); - } catch (e) { - clearTimeout(timer); - try { transport.close(); } catch {} - throw e; - } + const found = await connectToGroup( + this._hubBase, groupId, token, bundleKey, username, userId); + if (!found) throw new Error('offline'); + const { transport } = found; let gek = null; if (transport.gekRaw && window.MeshBayCrypto) { @@ -128,24 +154,17 @@ class ConnectionPool { // -- Index fetching ----------------------------------------------------------- async function fetchGroupIndex(groupId, token, bundleKey, username, userId) { - const nodesData = await hubFetch(`/v1/groups/${groupId}/nodes`, { token }); - if (!nodesData.nodes || !nodesData.nodes.length) return null; - - const live = (await ensureFreshToken()) || token; - const transport = new window.MeshBayTransport(HUB, live); - transport.onNeedToken = async () => (await ensureFreshToken()) || token; + const found = await connectToGroup(HUB, groupId, token, bundleKey, username, userId); + if (!found) return null; + const { transport, ack } = found; try { - let timer; - const ack = await Promise.race([ - transport.connect( - nodesData.nodes[0].node_id, live, groupId, null, null, bundleKey, - username, userId, null), - new Promise((_, reject) => { - timer = setTimeout(() => reject(new Error('Connection timeout')), SEARCH_TIMEOUT); - }), - ]); - clearTimeout(timer); + // The operator asked for this group to stay out of the global listing. + // Decided here, before the index is asked for, so nothing of it is held, + // cached or merged by this page. A listing preference and not a boundary: + // the node cannot tell this request from the group page's, and opening the + // group lists everything. + if (ack.search_listed === false) return { unlisted: true }; const indexMsg = await transport.fetchIndex(); // Plural, with the old scalars as the fallback for a node still speaking @@ -174,6 +193,7 @@ async function fetchAllIndexes(groups, token, username, userId, onProgress, onBa const total = groups.length; let done = 0; const unreachable = []; + const unlisted = []; const results = new Map(); for (let i = 0; i < groups.length; i += BATCH_SIZE) { @@ -181,7 +201,9 @@ async function fetchAllIndexes(groups, token, username, userId, onProgress, onBa await Promise.all(batch.map(async (g) => { try { const result = await fetchGroupIndex(g.id, token, bundleKey, username, userId); - if (result) { + if (result && result.unlisted) { + unlisted.push(g.name || g.id); + } else if (result) { results.set(g.id, { ...result, groupName: g.name, @@ -195,11 +217,11 @@ async function fetchAllIndexes(groups, token, username, userId, onProgress, onBa unreachable.push(g.name || g.id); } done++; - onProgress({ done, total, unreachable: [...unreachable] }); + onProgress({ done, total, unreachable: [...unreachable], unlisted: [...unlisted] }); })); onBatch(new Map(results)); } - return { results, unreachable }; + return { results, unreachable, unlisted }; } // -- SearchPage --------------------------------------------------------------- @@ -281,7 +303,8 @@ function photoUnits(entries) { function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs }) { const [indexedGroups, setIndexedGroups] = useState(new Map()); - const [progress, setProgress] = useState({ done: 0, total: 0, unreachable: [] }); + const [progress, setProgress] = useState( + { done: 0, total: 0, unreachable: [], unlisted: [] }); const [fetching, setFetching] = useState(false); // Bumped by the Files breadcrumb refresh button — re-runs the all-groups // index fetch below, the only "cache" this page has. @@ -333,7 +356,7 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs }) (async () => { setFetching(true); - setProgress({ done: 0, total: groups.length, unreachable: [] }); + setProgress({ done: 0, total: groups.length, unreachable: [], unlisted: [] }); await fetchAllIndexes( groups, token, username, userId, @@ -718,6 +741,12 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs }) </p> `} + ${!fetching && progress.unlisted.length > 0 && html` + <p class="search-unreachable"> + ${t('search.unlisted', { n: progress.unlisted.length })} + </p> + `} + ${viewMode === 'files' && hasResults && html` <${FilesPanel} groupId="search" diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index 9de5407..1f05b8d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -96,7 +96,7 @@ const BROADCAST_ACK_TYPES = new Set([ 'root_update_ack', 'root_eject_ack', 'root_plug_ack', 'root_add_ack', 'root_remove_ack', 'app_directories_ack', 'chat_directory_ack', 'chat_link_preview_ack', - 'chat_epoch_ack', + 'chat_epoch_ack', 'search_listed_ack', ]); /** Hand a broadcast ack to the callback that would have had it from a peer. */ @@ -107,6 +107,8 @@ function _replayBroadcast(transport, msg) { transport._onChatDirectory(msg.path || ''); } else if (msg.type === 'chat_link_preview_ack' && transport._onChatLinkPreview) { transport._onChatLinkPreview(Boolean(msg.enabled)); + } else if (msg.type === 'search_listed_ack' && transport._onSearchListed) { + transport._onSearchListed(msg.listed !== false); } else if (msg.type === 'chat_epoch_ack') { transport._applyChatEpoch(msg); } else if (transport._onRootsChanged) { @@ -120,7 +122,7 @@ const ADMIN_OP_TYPES = new Set([ 'apps_enabled', 'set_scan_settings', 'member_revoke', 'root_add', 'root_remove', 'root_update', 'root_eject', 'root_plug', 'app_directories', 'chat_directory', 'chat_link_preview', 'chat_epoch', - 'member_unpin', 'gek_rotate', 'group_attach', + 'search_listed', 'member_unpin', 'gek_rotate', 'group_attach', 'group_detach', 'invite_create', ]); @@ -533,6 +535,7 @@ class MeshBayTransport { set onAppDirectories(fn) { this._onAppDirectories = fn; } set onChatDirectory(fn) { this._onChatDirectory = fn; } set onChatLinkPreview(fn) { this._onChatLinkPreview = fn; } + set onSearchListed(fn) { this._onSearchListed = fn; } set onChatEpoch(fn) { this._onChatEpoch = fn; } set onTmdbConfig(fn) { this._onTmdbConfig = fn; } set onTmdbEnabled(fn) { this._onTmdbEnabled = fn; } @@ -1526,6 +1529,22 @@ class MeshBayTransport { } /** + * Whether this group's files appear in members' cross-group Search. + * Presentation only — opening the group lists everything regardless. + */ + async setSearchListed(listed, signFn) { + const msg = await this._sendAndWait({ + type: 'search_listed', v: '3.0', listed: Boolean(listed), + }); + if (msg.type === 'error') throw new Error(msg.detail); + if (msg.type === 'admin_challenge') { + return this._authorizeAdminOp( + msg, 'search_listed', listed ? 'on' : 'off', signFn); + } + return msg; + } + + /** * Open a new chat epoch by hand. Operator only, and signed. * * Not a switch — there is nothing to turn on. The removals that matter open @@ -3097,6 +3116,9 @@ class MeshBayTransport { if (msg.type === 'chat_link_preview_ack' && this._onChatLinkPreview) { this._onChatLinkPreview(Boolean(msg.enabled)); } + if (msg.type === 'search_listed_ack' && this._onSearchListed) { + this._onSearchListed(msg.listed !== false); + } if (msg.type === 'chat_epoch_ack') { this._applyChatEpoch(msg); return; diff --git a/packages/meshbay-hub/tests/test_app_settings_plugin.py b/packages/meshbay-hub/tests/test_app_settings_plugin.py index 940d975..027fee6 100644 --- a/packages/meshbay-hub/tests/test_app_settings_plugin.py +++ b/packages/meshbay-hub/tests/test_app_settings_plugin.py @@ -224,8 +224,9 @@ def test_the_page_performs_exactly_one_app_specific_operation(): "GroupSettingsPanel") calls = set(re.findall(r"transport\.(set\w+)\(", panel)) # The page's own settings, which belong to no app: which apps are enabled - # at all, and how hard the node works watching its disk. - page_level = {"setAppsEnabled", "setScanSettings"} + # at all, how hard the node works watching its disk, and whether members' + # cross-group Search lists the group. + page_level = {"setAppsEnabled", "setScanSettings", "setSearchListed"} # One generic operation, keyed by the app's own name: adding an app adds # no message type and no call site here. generic = {"setAppDirectories"} diff --git a/packages/meshbay-hub/tests/test_search_unlisted.py b/packages/meshbay-hub/tests/test_search_unlisted.py new file mode 100644 index 0000000..4ffec32 --- /dev/null +++ b/packages/meshbay-hub/tests/test_search_unlisted.py @@ -0,0 +1,96 @@ +""" +A group the operator left out of Search is not listed there, in any view. + +`search_listed` is a presentation preference, not an access control (design +§9.11): the node serves the same index to Search and to the group page. What +this file holds is the client half — that Search stops at the handshake ack for +such a group, so nothing of its index is fetched, cached, merged or shown — and +that the switch in Settings reaches the page that renders it. + +Source-reading, like the other Search tests: weak evidence, and the only kind +available for the SPA. Each check is a one-line edit away from failing, which is +what a source-reading test catches well. +""" + +import re +from pathlib import Path + +import pytest + +STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" +SEARCH_PAGE = STATIC / "search-page.js" +TRANSPORT = STATIC / "transport.js" +GROUP_PAGE = STATIC / "group-page.js" +GROUP_SETTINGS = STATIC / "group-settings.js" + +pytestmark = pytest.mark.skipif( + not SEARCH_PAGE.exists(), reason="the SPA sources are not available") + + +def _function(src: str, name: str) -> str: + m = re.search(r"^async function " + re.escape(name) + r"\(.*?^\}", src, re.M | re.S) + assert m, f"{name} is no longer where this test reads it" + return m.group(0) + + +def test_search_stops_before_asking_for_the_index(): + body = _function(SEARCH_PAGE.read_text(encoding="utf-8"), "fetchGroupIndex") + stop = body.find("ack.search_listed === false") + fetch = body.find("transport.fetchIndex()") + assert stop != -1, "fetchGroupIndex no longer reads search_listed off the ack" + assert fetch != -1 + assert stop < fetch, ( + "the unlisted check comes after the index is fetched — the index of a " + "group the operator left out of Search is then held by the page") + + +def test_an_unlisted_group_is_neither_indexed_nor_cached_nor_unreachable(): + body = _function(SEARCH_PAGE.read_text(encoding="utf-8"), "fetchAllIndexes") + branch = body[body.index("result.unlisted"):] + branch = branch[:branch.index("} else if (result)")] + assert "unlisted.push" in branch + for forbidden in ("results.set", "cacheGroupIndex", "unreachable.push"): + assert forbidden not in branch, ( + f"an unlisted group reaches `{forbidden}` — it would be shown, " + "cached, or reported as down") + + +def test_every_search_view_is_built_from_the_indexed_groups_only(): + """ + The four views read `indexedGroups`, which only `fetchAllIndexes`'s results + fill. If a view ever read the group list directly, an unlisted group would + come back through it. + """ + src = SEARCH_PAGE.read_text(encoding="utf-8") + for memo in ("fileEntries", "videoEntries", "musicEntries", "photoEntries"): + m = re.search(r"^ const " + memo + r" = useMemo\(\(\) => \{.*?^ \}, \[", + src, re.M | re.S) + assert m, f"{memo} is no longer a useMemo" + assert "for (const [groupId, data] of indexedGroups)" in m.group(0) + assert "groups" not in m.group(0).replace("indexedGroups", ""), ( + f"{memo} reads the raw group list") + + +def test_the_operator_hears_back_from_their_own_change(): + """Same failure as Chat's directory: an admin ack swallowed by its request.""" + transport = TRANSPORT.read_text(encoding="utf-8") + block = transport[transport.index("const BROADCAST_ACK_TYPES"):] + assert "'search_listed_ack'" in block[:block.index("]);")] + replay = transport[transport.index("function _replayBroadcast"):] + replay = replay[:replay.index("\n}") + 2] + assert "_onSearchListed" in replay + + page = GROUP_PAGE.read_text(encoding="utf-8") + assert "transport.onSearchListed = " in page + assert "setSearchListed(ack.search_listed !== false)" in page, ( + "absent must read as listed, or every group on an older node vanishes " + "from Search") + + +def test_the_switch_is_offered_to_the_operator_only(): + settings = GROUP_SETTINGS.read_text(encoding="utf-8") + at = settings.index("settings_node.search_listed_title") + guard = settings.rfind("isNodeAdmin && connected", 0, at) + assert guard != -1 and at - guard < 600, ( + "the Search listing switch is rendered outside the operator's section") + assert "transport.setSearchListed(next, adminSignFn)" in settings diff --git a/packages/meshbay-hub/tests/test_transport_contracts.py b/packages/meshbay-hub/tests/test_transport_contracts.py index c506ff1..ab942e4 100644 --- a/packages/meshbay-hub/tests/test_transport_contracts.py +++ b/packages/meshbay-hub/tests/test_transport_contracts.py @@ -506,3 +506,31 @@ def test_the_refusal_the_loop_keys_on_has_a_message(transport): refusals = transport[transport.index("const HANDSHAKE_REFUSALS"):] refusals = refusals[:refusals.index("};")] assert "not_hosted:" in refusals + + +# Search had the same `nodes[0]` twice — once to read a group's index, once for +# the pooled connection its thumbnails and playback use — and was not part of +# the fix above, so a group with a second, working node counted as unreachable +# there while it opened fine from the sidebar. + +SEARCH_PAGE = STATIC / "search-page.js" + + +def test_search_tries_every_node_the_hub_offers(): + code = _code_only(SEARCH_PAGE.read_text(encoding="utf-8")) + assert "nodes[0]" not in code, "Search takes the head of the node list again" + walk = code[code.index("for (const n of nodesData.nodes)"):] + walk = walk[:walk.index("throw (lastErr")] + assert "not_hosted" in walk and "throw e" in walk, ( + "the walk must stop for a refusal about this browser and move on " + "for one about this node") + + +def test_search_connects_in_one_place(): + """Two call sites with their own connect is how one of them kept `nodes[0]`.""" + code = _code_only(SEARCH_PAGE.read_text(encoding="utf-8")) + assert code.count("transport.connect(") == 1 + pool = code[code.index("async _doConnect("):code.index("_evict() {")] + index = code[code.index("async function fetchGroupIndex("): + code.index("async function fetchAllIndexes(")] + assert "connectToGroup(" in pool and "connectToGroup(" in index diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py index a43e3a3..7646c5d 100644 --- a/packages/meshbay-node/src/meshbay_node/daemon.py +++ b/packages/meshbay-node/src/meshbay_node/daemon.py @@ -431,6 +431,9 @@ class NodeDaemon: # Whether the node unfurls links members post here. "chat_link_preview": await self._roster.chat_link_preview( group_cfg.id) if self._roster else True, + # Whether members' cross-group Search lists this group. + "search_listed": await self._roster.search_listed( + group_cfg.id) if self._roster else True, # Which chat epoch key is current. Opened here if the # group has none, because chat is always encrypted (MNP # 2.0) and a group with no epoch is a group nobody can @@ -925,6 +928,9 @@ class NodeDaemon: "chat_link_preview": ( await self._roster.chat_link_preview(group_cfg.id) if self._roster else True), + "search_listed": ( + await self._roster.search_listed(group_cfg.id) + if self._roster else True), "chat_epoch": await self._ensure_chat_epoch(group_cfg.id), "tmdb_enabled": ( await self._roster.tmdb_enabled(group_cfg.id) diff --git a/packages/meshbay-node/src/meshbay_node/ops.py b/packages/meshbay-node/src/meshbay_node/ops.py index 91a922a..8cd893c 100644 --- a/packages/meshbay-node/src/meshbay_node/ops.py +++ b/packages/meshbay-node/src/meshbay_node/ops.py @@ -1733,6 +1733,26 @@ async def set_chat_link_preview(state: dict, group_id: str, return {"enabled": enabled, "group_id": group_id} +async def set_search_listed(state: dict, group_id: str, listed: bool) -> dict: + """ + Whether this group's files appear in members' cross-group Search. + + A presentation choice, and it must never be described as more: a member + still lists the whole group by opening it, the node serves the index + exactly as before, and a client that ignores the flag lists the group in + Search too. What it buys is a family album not turning up in the middle of + a film library. Absent means listed. + """ + roster = _roster(state) + ctx = _group_ctx(state, group_id) + await roster.set_search_listed(group_id, listed, + set_by=state.get("node_user_id", "")) + ctx["search_listed"] = listed + log.info("Search listing for group %s: %s", group_id[:8], + "on" if listed else "off") + return {"listed": listed, "group_id": group_id} + + # ── Scan settings ──────────────────────────────────────────────────────────── async def set_scan_settings(state: dict, group_id: str, reconcile_interval_secs: float, diff --git a/packages/meshbay-node/src/meshbay_node/roster.py b/packages/meshbay-node/src/meshbay_node/roster.py index b7fbf8e..b0ca78b 100644 --- a/packages/meshbay-node/src/meshbay_node/roster.py +++ b/packages/meshbay-node/src/meshbay_node/roster.py @@ -889,6 +889,24 @@ class Roster: "1" if enabled else "0", set_by) return enabled + # ── Search ────────────────────────────────────────────────────────────── + + # Whether this group's files appear in members' cross-group Search. Not an + # access control: a member lists the group by opening it, and a client that + # ignores this lists it in Search too. Unset means listed, because that is + # what every group did before this existed. + SETTING_SEARCH_LISTED = "search_listed" + + async def search_listed(self, group_id: str) -> bool: + value = await self.get_setting(group_id, self.SETTING_SEARCH_LISTED, "1") + return value != "0" + + async def set_search_listed(self, group_id: str, listed: bool, + set_by: str = "") -> bool: + await self.set_setting(group_id, self.SETTING_SEARCH_LISTED, + "1" if listed else "0", set_by) + return listed + # Whether TMDB lookups run for this group at all — per-group, unlike the # token/language above: one node process can share a real media library # group and several test/demo groups, and outbound TMDB traffic (and API 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 79a97cc..af41061 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py @@ -79,6 +79,7 @@ from meshbay_common.adminop import ( OP_CHAT_DIRECTORY, OP_CHAT_EPOCH, OP_CHAT_LINK_PREVIEW, + OP_SEARCH_LISTED, OP_ROOT_ADD, OP_ROOT_REMOVE, OP_ROOT_UPDATE, @@ -623,6 +624,8 @@ class WebRTCPeerSession: self._do_chat_directory(msg) elif mtype == MNP.CHAT_LINK_PREVIEW: self._do_chat_link_preview(msg) + elif mtype == MNP.SEARCH_LISTED: + self._do_search_listed(msg) elif mtype == MNP.CHAT_EPOCH: self._do_chat_epoch(msg) elif mtype == MNP.CHAT_KEYS_REQ: @@ -949,6 +952,11 @@ class WebRTCPeerSession: # on, which is what it did before this existed. "chat_link_preview": bool( self._group_ctx().get("chat_link_preview", True)), + # Whether the reader's cross-group Search should list this group. + # Presentation only: the index below is served to Search and to the + # group page alike, and this cannot tell them apart. Sealed like the + # rest, so the hub cannot flip it. Absent means listed. + "search_listed": bool(self._group_ctx().get("search_listed", True)), # Which chat epoch key a client should be sealing under. Inside # the sealed part of the ack like every other configuration field, # so it carries an authentication tag from a key the hub does not @@ -2392,6 +2400,38 @@ class WebRTCPeerSession: self._broadcast_to_group({"type": MNP.CHAT_LINK_PREVIEW_ACK, "v": MNP_VERSION, "enabled": enabled}) + def _do_search_listed(self, msg: dict) -> None: + """ + Whether this group's files appear in members' cross-group Search. + Signed because it changes what every member's Search shows, not + because it protects anything — see ops.set_search_listed. + """ + listed = msg.get("listed") + if not isinstance(listed, bool): + self._send({"type": "error", "detail": "Missing or invalid 'listed'"}) + return + if not self._has_admin_authority(): + self._send({"type": "error", "detail": "No authorized key for this"}) + return + self._issue_admin_challenge(OP_SEARCH_LISTED, "on" if listed else "off") + + async def _admin_exec_search_listed( + self, pending: dict, transcript: bytes, sig: bytes, + ) -> None: + listed = 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"search_listed:{pending['subject']}") + return + try: + await self._run_op(ops.set_search_listed, self._group_id or "", listed) + except ops.OpError as e: + self._send({"type": "error", "detail": e.message}) + return + self._audit("search_listed", pending["subject"]) + self._broadcast_to_group({"type": MNP.SEARCH_LISTED_ACK, + "v": MNP_VERSION, "listed": listed}) + def _do_chat_epoch(self, msg: dict) -> None: """ Open a new chat epoch by hand. Operator only, and signed. @@ -5446,6 +5486,9 @@ class WebRTCPeerSession: elif pending["op"] == OP_CHAT_LINK_PREVIEW: self._spawn( self._admin_exec_chat_link_preview(pending, transcript, sig_bytes)) + elif pending["op"] == OP_SEARCH_LISTED: + self._spawn( + self._admin_exec_search_listed(pending, transcript, sig_bytes)) elif pending["op"] == OP_CHAT_EPOCH: self._spawn( self._admin_exec_chat_epoch(pending, transcript, sig_bytes)) diff --git a/packages/meshbay-node/src/meshbay_node/ui/app.py b/packages/meshbay-node/src/meshbay_node/ui/app.py index 22b5afb..96fa137 100644 --- a/packages/meshbay-node/src/meshbay_node/ui/app.py +++ b/packages/meshbay-node/src/meshbay_node/ui/app.py @@ -506,6 +506,11 @@ def create_ui_app(state: dict) -> FastAPI: return await _op(lambda: ops.set_chat_link_preview( state, group_id, bool(payload.get("enabled", True)))) + @app.put("/api/groups/{group_id}/search-listed") + async def set_search_listed(group_id: str, payload: dict): + return await _op(lambda: ops.set_search_listed( + state, group_id, bool(payload.get("listed", True)))) + # ── Scan settings (operator only, localhost) ────────────────────────── @app.put("/api/groups/{group_id}/scan-settings") diff --git a/packages/meshbay-node/tests/test_search_listed.py b/packages/meshbay-node/tests/test_search_listed.py new file mode 100644 index 0000000..412d743 --- /dev/null +++ b/packages/meshbay-node/tests/test_search_listed.py @@ -0,0 +1,194 @@ +""" +Whether a group's files are listed in members' cross-group Search. + +A presentation preference, stated as such everywhere (design §9.11): the node +serves the same index to Search and to the group page, and cannot tell them +apart. What is held here is the node half — stored on the node, absent means +listed, changed only by a signed operator instruction, kept in step with the +live context, and broadcast so every connected member's page moves with it. +""" + +import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from meshbay_common.adminop import OP_SEARCH_LISTED +from meshbay_common.protocol import MNP +from meshbay_node import ops +from meshbay_node.indexer.group_index import GroupIndex +from meshbay_node.roster import Roster +from meshbay_node.transport.webrtc_server import WebRTCPeerSession + +GROUP = "g" * 32 + + +def _session(user_id: str = "op") -> WebRTCPeerSession: + session = WebRTCPeerSession.__new__(WebRTCPeerSession) + session._ctx = {"node_user_id": "op"} + session._group_id = GROUP + session._user_id = user_id + session._pk_user = "" + session.sent = [] + session._send = session.sent.append + session.audited = [] + session._audit = lambda *a, **k: session.audited.append(a) + return session + + +def _challenges(session: WebRTCPeerSession) -> list: + issued = [] + session._issue_admin_challenge = lambda op, subject: issued.append((op, subject)) + return issued + + +# ── Where it is stored ────────────────────────────────────────────────────── + +async def test_absent_means_listed_and_the_setting_survives_a_restart(tmp_path): + roster = Roster(db_path=tmp_path / "roster.db") + await roster.open() + try: + assert await roster.search_listed(GROUP) is True, ( + "absent must mean listed — an upgrade must not empty anyone's Search") + await roster.set_search_listed(GROUP, False, set_by="op") + assert await roster.search_listed(GROUP) is False + finally: + await roster.close() + + reopened = Roster(db_path=tmp_path / "roster.db") + await reopened.open() + try: + assert await reopened.search_listed(GROUP) is False + assert await reopened.search_listed("other") is True, ( + "one group's setting must not answer for another") + finally: + await reopened.close() + + +async def test_the_op_updates_the_live_context(tmp_path): + """The handshake ack reads the context, so the op keeps the two in step.""" + roster = Roster(db_path=tmp_path / "roster.db") + await roster.open() + try: + index = GroupIndex(group_id=GROUP, sk_node=Ed25519PrivateKey.generate()) + state = {"roster": roster, "node_user_id": "op", + "groups_ctx": {GROUP: {"index": index}}} + out = await ops.set_search_listed(state, GROUP, False) + assert out == {"listed": False, "group_id": GROUP} + assert state["groups_ctx"][GROUP]["search_listed"] is False + assert await roster.search_listed(GROUP) is False + finally: + await roster.close() + + +async def test_the_op_refuses_a_group_this_node_does_not_host(tmp_path): + roster = Roster(db_path=tmp_path / "roster.db") + await roster.open() + try: + state = {"roster": roster, "groups_ctx": {}} + with pytest.raises(ops.OpError): + await ops.set_search_listed(state, GROUP, False) + finally: + await roster.close() + + +# ── Signed, and refused before a challenge otherwise ──────────────────────── + +@pytest.mark.parametrize("payload", [{}, {"listed": "no"}, {"listed": 0}]) +async def test_a_malformed_request_is_refused(payload): + session = _session() + session._has_admin_authority = lambda: True + issued = _challenges(session) + + session._do_search_listed(payload) + + assert not issued + assert [m for m in session.sent if m.get("type") == "error"] + + +async def test_a_member_without_authority_is_refused(): + session = _session("member-1") + session._has_admin_authority = lambda: False + issued = _challenges(session) + + session._do_search_listed({"listed": False}) + + assert not issued + assert [m for m in session.sent if m.get("type") == "error"] + + +@pytest.mark.parametrize("listed,subject", [(True, "on"), (False, "off")]) +async def test_the_subject_names_the_outcome(listed, subject): + session = _session() + session._has_admin_authority = lambda: True + issued = _challenges(session) + + session._do_search_listed({"listed": listed}) + + assert issued == [(OP_SEARCH_LISTED, subject)] + + +async def test_a_bad_signature_changes_nothing(): + session = _session() + ran = [] + + async def refuse(transcript, sig): + return False + + async def run_op(fn, *args): + ran.append(args) + + session._verify_admin_sig = refuse + session._run_op = run_op + session._broadcast_to_group = lambda notice: ran.append(notice) + + await session._admin_exec_search_listed( + {"subject": "off"}, b"transcript", b"sig") + + assert not ran + assert [m for m in session.sent if m.get("type") == "error"] + + +def test_the_handshake_ack_carries_it_sealed_and_absent_reads_as_listed(): + """ + Read off the real builder rather than a hand-made config: the ack's + configuration is the dict `_complete_handshake` seals, and a client that + connects after the operator changed the setting learns it from there. + """ + import ast + import inspect + import textwrap + + source = textwrap.dedent(inspect.getsource(WebRTCPeerSession._complete_handshake)) + tree = ast.parse(source) + config = next( + n.value for n in ast.walk(tree) + if isinstance(n, ast.Assign) + and any(isinstance(t, ast.Name) and t.id == "config" for t in n.targets)) + assert isinstance(config, ast.Dict) + values = {k.value: v for k, v in zip(config.keys, config.values) + if isinstance(k, ast.Constant)} + assert "search_listed" in values, "the sealed ack no longer carries search_listed" + expr = ast.unparse(values["search_listed"]) + assert "'search_listed', True" in expr, ( + f"search_listed is built as {expr} — absent must read as listed") + + +async def test_a_signed_change_is_applied_and_broadcast(): + session = _session() + applied, broadcast = [], [] + + async def accept(transcript, sig): + return True + + async def run_op(fn, *args): + applied.append((fn, args)) + + session._verify_admin_sig = accept + session._run_op = run_op + session._broadcast_to_group = broadcast.append + + await session._admin_exec_search_listed( + {"subject": "off"}, b"transcript", b"sig") + + assert applied == [(ops.set_search_listed, (GROUP, False))] + assert len(broadcast) == 1 + assert broadcast[0]["type"] == MNP.SEARCH_LISTED_ACK + assert broadcast[0]["listed"] is False |