Add new node ingest blocking

This commit is contained in:
Jack Kingsman
2026-04-01 15:57:22 -07:00
parent 7f07aedb8a
commit 5653a43941
11 changed files with 181 additions and 54 deletions

View File

@@ -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()

View File

@@ -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):

View File

@@ -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(),

View File

@@ -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)

View File

@@ -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:

View File

@@ -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(() => {

View File

@@ -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<number[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(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({
)}
</div>
<Separator />
<div className="space-y-3">
<Label>Block Discovery of New Node Types</Label>
<p className="text-xs text-muted-foreground">
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.
</p>
<div className="space-y-1.5">
{(
[
[1, 'Block clients'],
[2, 'Block repeaters'],
[3, 'Block room servers'],
[4, 'Block sensors'],
] as const
).map(([typeCode, label]) => {
const checked = discoveryBlockedTypes.includes(typeCode);
return (
<label key={typeCode} className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={checked}
onChange={() =>
setDiscoveryBlockedTypes((prev) =>
checked ? prev.filter((t) => t !== typeCode) : [...prev, typeCode]
)
}
className="rounded border-input"
/>
{label}
</label>
);
})}
</div>
{discoveryBlockedTypes.length > 0 && (
<p className="text-xs text-warning">
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.
</p>
)}
</div>
{error && (
<div className="text-sm text-destructive" role="alert">
{error}

View File

@@ -69,6 +69,7 @@ const baseSettings: AppSettings = {
flood_scope: '',
blocked_keys: [],
blocked_names: [],
discovery_blocked_types: [],
};
function renderModal(overrides?: {

View File

@@ -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 {

View File

@@ -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(
"""

View File

@@ -630,6 +630,7 @@ class TestAppSettingsRepository:
"flood_scope": "",
"blocked_keys": "[]",
"blocked_names": "[]",
"discovery_blocked_types": "[]",
}
)
mock_conn.execute = AsyncMock(return_value=mock_cursor)