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 "); +}