From 5653a43941de4d3d3f4bd2976fe1eadb670bd267 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Wed, 1 Apr 2026 15:57:22 -0700 Subject: [PATCH] Add new node ingest blocking --- app/migrations.py | 29 +++++++++ app/models.py | 7 +++ app/packet_processor.py | 14 +++++ app/repository/settings.py | 16 ++++- app/routers/settings.py | 13 ++++ frontend/src/components/Sidebar.tsx | 60 ++++++++----------- .../settings/SettingsDatabaseSection.tsx | 60 ++++++++++++++++++- frontend/src/test/settingsModal.test.tsx | 1 + frontend/src/types.ts | 2 + tests/test_migrations.py | 32 +++++----- tests/test_repository.py | 1 + 11 files changed, 181 insertions(+), 54 deletions(-) diff --git a/app/migrations.py b/app/migrations.py index f7048b9..f7e690c 100644 --- a/app/migrations.py +++ b/app/migrations.py @@ -367,6 +367,13 @@ async def run_migrations(conn: aiosqlite.Connection) -> int: await set_version(conn, 47) applied += 1 + # Migration 48: Add discovery_blocked_types column to app_settings + if version < 48: + logger.info("Applying migration 48: add discovery_blocked_types to app_settings") + await _migrate_048_discovery_blocked_types(conn) + await set_version(conn, 48) + applied += 1 + if applied > 0: logger.info( "Applied %d migration(s), schema now at version %d", applied, await get_version(conn) @@ -2909,3 +2916,25 @@ async def _migrate_047_add_statistics_indexes(conn: aiosqlite.Connection) -> Non """ ) await conn.commit() + + +async def _migrate_048_discovery_blocked_types(conn: aiosqlite.Connection) -> None: + """Add discovery_blocked_types column to app_settings. + + Stores a JSON array of integer contact type codes (1=Client, 2=Repeater, + 3=Room, 4=Sensor) whose advertisements should not create new contacts. + Empty list means all types are accepted. + """ + try: + await conn.execute( + "ALTER TABLE app_settings ADD COLUMN discovery_blocked_types TEXT DEFAULT '[]'" + ) + except Exception as e: + error_msg = str(e).lower() + if "duplicate column" in error_msg: + logger.debug("discovery_blocked_types column already exists, skipping") + elif "no such table" in error_msg: + logger.debug("app_settings table not ready, skipping discovery_blocked_types migration") + else: + raise + await conn.commit() diff --git a/app/models.py b/app/models.py index 8857c4d..e6833a2 100644 --- a/app/models.py +++ b/app/models.py @@ -840,6 +840,13 @@ class AppSettings(BaseModel): default_factory=list, description="Display names whose messages are hidden from the UI", ) + discovery_blocked_types: list[int] = Field( + default_factory=list, + description=( + "Contact type codes (1=Client, 2=Repeater, 3=Room, 4=Sensor) whose " + "advertisements should not create new contacts; existing contacts are still updated" + ), + ) class FanoutConfig(BaseModel): diff --git a/app/packet_processor.py b/app/packet_processor.py index 8a43618..c2ce89a 100644 --- a/app/packet_processor.py +++ b/app/packet_processor.py @@ -462,6 +462,20 @@ async def _process_advertisement( advert.device_role if advert.device_role > 0 else (existing.type if existing else 0) ) + # Check discovery_blocked_types: skip new contacts whose type is blocked. + # Existing contacts are always updated (location, name, last_seen, etc.). + if existing is None and contact_type > 0: + from app.repository import AppSettingsRepository + + settings = await AppSettingsRepository.get() + if contact_type in settings.discovery_blocked_types: + logger.debug( + "Skipping new contact %s: type %d is in discovery_blocked_types", + advert.public_key[:12], + contact_type, + ) + return + # Keep recent unique advert paths for all contacts. await ContactAdvertPathRepository.record_observation( public_key=advert.public_key.lower(), diff --git a/app/repository/settings.py b/app/repository/settings.py index 2428feb..6b91148 100644 --- a/app/repository/settings.py +++ b/app/repository/settings.py @@ -29,7 +29,7 @@ class AppSettingsRepository: SELECT max_radio_contacts, favorites, auto_decrypt_dm_on_advert, sidebar_sort_order, last_message_times, preferences_migrated, advert_interval, last_advert_time, flood_scope, - blocked_keys, blocked_names + blocked_keys, blocked_names, discovery_blocked_types FROM app_settings WHERE id = 1 """ ) @@ -81,6 +81,14 @@ class AppSettingsRepository: except (json.JSONDecodeError, TypeError): blocked_names = [] + # Parse discovery_blocked_types JSON + discovery_blocked_types: list[int] = [] + if row["discovery_blocked_types"]: + try: + discovery_blocked_types = json.loads(row["discovery_blocked_types"]) + except (json.JSONDecodeError, TypeError): + discovery_blocked_types = [] + # Validate sidebar_sort_order (fallback to "recent" if invalid) sort_order = row["sidebar_sort_order"] if sort_order not in ("recent", "alpha"): @@ -98,6 +106,7 @@ class AppSettingsRepository: flood_scope=row["flood_scope"] or "", blocked_keys=blocked_keys, blocked_names=blocked_names, + discovery_blocked_types=discovery_blocked_types, ) @staticmethod @@ -113,6 +122,7 @@ class AppSettingsRepository: flood_scope: str | None = None, blocked_keys: list[str] | None = None, blocked_names: list[str] | None = None, + discovery_blocked_types: list[int] | None = None, ) -> AppSettings: """Update app settings. Only provided fields are updated.""" updates = [] @@ -163,6 +173,10 @@ class AppSettingsRepository: updates.append("blocked_names = ?") params.append(json.dumps(blocked_names)) + if discovery_blocked_types is not None: + updates.append("discovery_blocked_types = ?") + params.append(json.dumps(discovery_blocked_types)) + if updates: query = f"UPDATE app_settings SET {', '.join(updates)} WHERE id = 1" await db.conn.execute(query, params) diff --git a/app/routers/settings.py b/app/routers/settings.py index 913a1a8..3625584 100644 --- a/app/routers/settings.py +++ b/app/routers/settings.py @@ -48,6 +48,13 @@ class AppSettingsUpdate(BaseModel): default=None, description="Display names whose messages are hidden from the UI", ) + discovery_blocked_types: list[int] | None = Field( + default=None, + description=( + "Contact type codes (1=Client, 2=Repeater, 3=Room, 4=Sensor) whose " + "advertisements should not create new contacts" + ), + ) class BlockKeyRequest(BaseModel): @@ -122,6 +129,12 @@ async def update_settings(update: AppSettingsUpdate) -> AppSettings: if update.blocked_names is not None: kwargs["blocked_names"] = update.blocked_names + # Discovery blocked types + if update.discovery_blocked_types is not None: + # Only allow valid contact type codes (1-4) + valid = [t for t in update.discovery_blocked_types if t in (1, 2, 3, 4)] + kwargs["discovery_blocked_types"] = sorted(set(valid)) + # Flood scope flood_scope_changed = False if update.flood_scope is not None: diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 52710ac..aa86ece 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -409,44 +409,32 @@ export function Sidebar({ [sortedChannels, query] ); - const filteredNonRepeaterContacts = useMemo( - () => { - const visible = sortedNonRepeaterContacts.filter((c) => !isContactBlocked(c)); - return query - ? visible.filter( - (c) => - c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query) - ) - : visible; - }, - [sortedNonRepeaterContacts, query, isContactBlocked] - ); + const filteredNonRepeaterContacts = useMemo(() => { + const visible = sortedNonRepeaterContacts.filter((c) => !isContactBlocked(c)); + return query + ? visible.filter( + (c) => c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query) + ) + : visible; + }, [sortedNonRepeaterContacts, query, isContactBlocked]); - const filteredRooms = useMemo( - () => { - const visible = sortedRooms.filter((c) => !isContactBlocked(c)); - return query - ? visible.filter( - (c) => - c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query) - ) - : visible; - }, - [sortedRooms, query, isContactBlocked] - ); + const filteredRooms = useMemo(() => { + const visible = sortedRooms.filter((c) => !isContactBlocked(c)); + return query + ? visible.filter( + (c) => c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query) + ) + : visible; + }, [sortedRooms, query, isContactBlocked]); - const filteredRepeaters = useMemo( - () => { - const visible = sortedRepeaters.filter((c) => !isContactBlocked(c)); - return query - ? visible.filter( - (c) => - c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query) - ) - : visible; - }, - [sortedRepeaters, query, isContactBlocked] - ); + const filteredRepeaters = useMemo(() => { + const visible = sortedRepeaters.filter((c) => !isContactBlocked(c)); + return query + ? visible.filter( + (c) => c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query) + ) + : visible; + }, [sortedRepeaters, query, isContactBlocked]); // Expand sections while searching; restore prior collapse state when search ends. useEffect(() => { diff --git a/frontend/src/components/settings/SettingsDatabaseSection.tsx b/frontend/src/components/settings/SettingsDatabaseSection.tsx index 622befe..56e7df0 100644 --- a/frontend/src/components/settings/SettingsDatabaseSection.tsx +++ b/frontend/src/components/settings/SettingsDatabaseSection.tsx @@ -33,12 +33,14 @@ export function SettingsDatabaseSection({ const [cleaning, setCleaning] = useState(false); const [purgingDecryptedRaw, setPurgingDecryptedRaw] = useState(false); const [autoDecryptOnAdvert, setAutoDecryptOnAdvert] = useState(false); + const [discoveryBlockedTypes, setDiscoveryBlockedTypes] = useState([]); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); useEffect(() => { setAutoDecryptOnAdvert(appSettings.auto_decrypt_dm_on_advert); + setDiscoveryBlockedTypes(appSettings.discovery_blocked_types ?? []); }, [appSettings]); const handleCleanup = async () => { @@ -92,7 +94,15 @@ export function SettingsDatabaseSection({ setError(null); try { - await onSaveAppSettings({ auto_decrypt_dm_on_advert: autoDecryptOnAdvert }); + const update: AppSettingsUpdate = { auto_decrypt_dm_on_advert: autoDecryptOnAdvert }; + const currentBlocked = appSettings.discovery_blocked_types ?? []; + if ( + discoveryBlockedTypes.length !== currentBlocked.length || + discoveryBlockedTypes.some((t) => !currentBlocked.includes(t)) + ) { + update.discovery_blocked_types = discoveryBlockedTypes; + } + await onSaveAppSettings(update); toast.success('Database settings saved'); } catch (err) { console.error('Failed to save database settings:', err); @@ -268,6 +278,54 @@ export function SettingsDatabaseSection({ )} + + +
+ +

+ Checked types will be ignored when heard via advertisement. Existing contacts of these + types are still updated. This does not affect contacts added manually or via DM. +

+
+ {( + [ + [1, 'Block clients'], + [2, 'Block repeaters'], + [3, 'Block room servers'], + [4, 'Block sensors'], + ] as const + ).map(([typeCode, label]) => { + const checked = discoveryBlockedTypes.includes(typeCode); + return ( + + ); + })} +
+ {discoveryBlockedTypes.length > 0 && ( +

+ New{' '} + {discoveryBlockedTypes + .map((t) => + t === 1 ? 'clients' : t === 2 ? 'repeaters' : t === 3 ? 'room servers' : 'sensors' + ) + .join(', ')}{' '} + heard via advertisement will not be added to your contact list. +

+ )} +
+ {error && (
{error} diff --git a/frontend/src/test/settingsModal.test.tsx b/frontend/src/test/settingsModal.test.tsx index 95a69c2..305dafc 100644 --- a/frontend/src/test/settingsModal.test.tsx +++ b/frontend/src/test/settingsModal.test.tsx @@ -69,6 +69,7 @@ const baseSettings: AppSettings = { flood_scope: '', blocked_keys: [], blocked_names: [], + discovery_blocked_types: [], }; function renderModal(overrides?: { diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 441d8a8..a924abd 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -332,6 +332,7 @@ export interface AppSettings { flood_scope: string; blocked_keys: string[]; blocked_names: string[]; + discovery_blocked_types: number[]; } export interface AppSettingsUpdate { @@ -342,6 +343,7 @@ export interface AppSettingsUpdate { flood_scope?: string; blocked_keys?: string[]; blocked_names?: string[]; + discovery_blocked_types?: number[]; } export interface MigratePreferencesRequest { diff --git a/tests/test_migrations.py b/tests/test_migrations.py index 7608015..c12c652 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -1247,8 +1247,8 @@ class TestMigration039: applied = await run_migrations(conn) - assert applied == 9 - assert await get_version(conn) == 47 + assert applied == 10 + assert await get_version(conn) == 48 cursor = await conn.execute( """ @@ -1319,8 +1319,8 @@ class TestMigration039: applied = await run_migrations(conn) - assert applied == 9 - assert await get_version(conn) == 47 + assert applied == 10 + assert await get_version(conn) == 48 cursor = await conn.execute( """ @@ -1386,8 +1386,8 @@ class TestMigration039: applied = await run_migrations(conn) - assert applied == 3 - assert await get_version(conn) == 47 + assert applied == 4 + assert await get_version(conn) == 48 cursor = await conn.execute( """ @@ -1439,8 +1439,8 @@ class TestMigration040: applied = await run_migrations(conn) - assert applied == 8 - assert await get_version(conn) == 47 + assert applied == 9 + assert await get_version(conn) == 48 await conn.execute( """ @@ -1501,8 +1501,8 @@ class TestMigration041: applied = await run_migrations(conn) - assert applied == 7 - assert await get_version(conn) == 47 + assert applied == 8 + assert await get_version(conn) == 48 await conn.execute( """ @@ -1554,8 +1554,8 @@ class TestMigration042: applied = await run_migrations(conn) - assert applied == 6 - assert await get_version(conn) == 47 + assert applied == 7 + assert await get_version(conn) == 48 await conn.execute( """ @@ -1694,8 +1694,8 @@ class TestMigration046: applied = await run_migrations(conn) - assert applied == 2 - assert await get_version(conn) == 47 + assert applied == 3 + assert await get_version(conn) == 48 cursor = await conn.execute( """ @@ -1788,8 +1788,8 @@ class TestMigration047: applied = await run_migrations(conn) - assert applied == 1 - assert await get_version(conn) == 47 + assert applied == 2 + assert await get_version(conn) == 48 cursor = await conn.execute( """ diff --git a/tests/test_repository.py b/tests/test_repository.py index 05a656c..a213113 100644 --- a/tests/test_repository.py +++ b/tests/test_repository.py @@ -630,6 +630,7 @@ class TestAppSettingsRepository: "flood_scope": "", "blocked_keys": "[]", "blocked_names": "[]", + "discovery_blocked_types": "[]", } ) mock_conn.execute = AsyncMock(return_value=mock_cursor)