From c7bfce1268821dc6c3a5bd84396d7de792cd62bc Mon Sep 17 00:00:00 2001 From: Alex Vanderpot <553597+ajvpot@users.noreply.github.com> Date: Fri, 19 Jun 2026 00:06:07 -0400 Subject: [PATCH] =?UTF-8?q?Add=20node=20privacy:=20hide=20nodes=20whose=20?= =?UTF-8?q?name=20contains=20=E2=9B=94=EF=B8=8F=20=F0=9F=9B=91=20?= =?UTF-8?q?=F0=9F=9A=AB=20(#44)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operators can opt a node out of the public-facing surfaces by putting an opt-out emoji in the node name. Hidden nodes are removed from the map, neighbor edges/lists, search, and their own detail page (server-side ClickHouse filters), and chat messages from a hidden sender are dropped client-side after decryption. Matching keys on the base codepoint so the variation-selector form (⛔️) is caught too. Co-authored-by: Alex Vanderpot Co-authored-by: Claude Opus 4.8 (1M context) --- .../src/components/ChatMessageItem.tsx | 5 ++++ meshexplorer/src/lib/clickhouse/actions.ts | 24 ++++++++++++++--- meshexplorer/src/lib/node-privacy.ts | 27 +++++++++++++++++++ 3 files changed, 52 insertions(+), 4 deletions(-) create mode 100644 meshexplorer/src/lib/node-privacy.ts diff --git a/meshexplorer/src/components/ChatMessageItem.tsx b/meshexplorer/src/components/ChatMessageItem.tsx index 01ed42e..3dc039a 100644 --- a/meshexplorer/src/components/ChatMessageItem.tsx +++ b/meshexplorer/src/components/ChatMessageItem.tsx @@ -6,6 +6,7 @@ import PathVisualization from "./PathVisualization"; import { PathData } from "@/lib/pathUtils"; import NodeLinkWithHover from "./NodeLinkWithHover"; import { findNodeMentions } from "@/lib/node-utils"; +import { isHiddenNodeName } from "@/lib/node-privacy"; export interface ChatMessage { message_id: string; @@ -140,6 +141,10 @@ function ChatMessageItem({ msg, showErrorRow }: { msg: ChatMessage, showErrorRow if (parsed) { + // Node privacy: a sender that opted out via its name has its message hidden entirely. + if (isHiddenNodeName(parsed.sender)) { + return null; + } return (
diff --git a/meshexplorer/src/lib/clickhouse/actions.ts b/meshexplorer/src/lib/clickhouse/actions.ts index 4b77f99..2bdbb12 100644 --- a/meshexplorer/src/lib/clickhouse/actions.ts +++ b/meshexplorer/src/lib/clickhouse/actions.ts @@ -3,6 +3,7 @@ import { clickhouse } from "./clickhouse"; import { generateRegionWhereClauseFromArray, generateRegionWhereClause } from "@/lib/regionFilters"; import { regionFromTopic, normalizeRegion, groupCodeOf } from "@/lib/regions"; import { publicChannelMessagesSubquery } from "./chatMessages"; +import { isHiddenNodeName, visibleNodeSqlClause } from "@/lib/node-privacy"; export async function getNodePositions({ minLat, maxLat, minLng, maxLng, nodeTypes, lastSeen }: { minLat?: string | null, maxLat?: string | null, minLng?: string | null, maxLng?: string | null, nodeTypes?: string[], lastSeen?: string | null } = {}) { try { @@ -10,7 +11,9 @@ export async function getNodePositions({ minLat, maxLat, minLng, maxLng, nodeTyp "latitude IS NOT NULL", "longitude IS NOT NULL", // Exclude the (0,0) "null island" sentinel (location bit set but no real GPS fix) - "(abs(latitude) > 0.01 OR abs(longitude) > 0.01)" + "(abs(latitude) > 0.01 OR abs(longitude) > 0.01)", + // Node privacy: hide nodes whose name carries an opt-out emoji. + visibleNodeSqlClause("name") ]; const params: Record = {}; if (minLat !== null && minLat !== undefined && minLat !== "") { @@ -166,7 +169,12 @@ export async function getMeshcoreNodeInfo(publicKey: string, limit: number = 50) if (!nodeInfo || nodeInfo.length === 0) { return null; } - + + // Node privacy: a node that opted out via its name is treated as not found. + if (isHiddenNodeName(nodeInfo[0].node_name)) { + return null; + } + // Get recent adverts grouped by adv_timestamp with origin_path_pubkey tuples const advertsQuery = ` SELECT @@ -316,6 +324,9 @@ export async function getAllNodeNeighbors(lastSeen: string | null = null, minLat "source_longitude IS NOT NULL", "target_latitude IS NOT NULL", "target_longitude IS NOT NULL", + // Node privacy: drop the edge if either endpoint opted out via its name. + visibleNodeSqlClause("source_name"), + visibleNodeSqlClause("target_name"), ]; // Bounding box: both endpoints must be within view (matches the old visible_nodes behavior) @@ -391,7 +402,11 @@ export async function getMeshcoreNodeNeighbors(publicKey: string, lastSeen: stri // Reads the precomputed (hourly-refreshed) per-node direct adjacency from the // refreshable materialized view meshcore_node_direct_neighbors. const params: Record = { publicKey }; - const whereConditions = ["node_public_key = {publicKey:String}"]; + const whereConditions = [ + "node_public_key = {publicKey:String}", + // Node privacy: hide neighbors that opted out via their name. + visibleNodeSqlClause("neighbor_name"), + ]; if (lastSeen !== null) { whereConditions.push("neighbor_last_seen >= now() - INTERVAL {lastSeen:UInt32} SECOND"); params.lastSeen = Number(lastSeen); @@ -473,7 +488,8 @@ export async function searchMeshcoreNodes(searchParams: SearchQuery | SearchQuer is_repeater } = searchQuery; - const where: string[] = []; + // Node privacy: hidden nodes are unsearchable. + const where: string[] = [visibleNodeSqlClause("node_name")]; const queryParams: Record = {}; // Add search conditions diff --git a/meshexplorer/src/lib/node-privacy.ts b/meshexplorer/src/lib/node-privacy.ts new file mode 100644 index 0000000..f3774ec --- /dev/null +++ b/meshexplorer/src/lib/node-privacy.ts @@ -0,0 +1,27 @@ +/** + * Node privacy: operators can opt a node out of the public-facing surfaces of + * meshexplorer (map, neighbors, chat, search, detail page) by putting any of the + * "hidden" emoji in the node name. + * + * Match on the base codepoints below, ignoring the optional variation selector + * (⛔️ = ⛔ + U+FE0F): a substring / position() check on the base codepoint matches + * whether or not the VS-16 is present, so we never special-case it. + */ + +// Base codepoints only — the VS-16 (U+FE0F) variant of ⛔ is matched by substring. +export const HIDDEN_NODE_EMOJIS = ["⛔", "🛑", "🚫"] as const; + +/** JS check used client-side (chat) and server-side (node detail). */ +export function isHiddenNodeName(name?: string | null): boolean { + if (!name) return false; + return HIDDEN_NODE_EMOJIS.some((e) => name.includes(e)); +} + +/** + * SQL predicate that is TRUE for VISIBLE (non-hidden) nodes, for pushing into a + * WHERE list. `column` is a trusted column identifier (never user input); the + * emoji are our own constants, so inlining them as literals is safe. + */ +export function visibleNodeSqlClause(column: string): string { + return HIDDEN_NODE_EMOJIS.map((e) => `position(${column}, '${e}') = 0`).join(" AND "); +}