Add node privacy: hide nodes whose name contains 🛑 🚫 (#44)

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 <alex@Alexs-MacBook-Pro-2.local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alex Vanderpot
2026-06-19 00:06:07 -04:00
committed by GitHub
parent 7cea182c6d
commit c7bfce1268
3 changed files with 52 additions and 4 deletions
@@ -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 (
<div className="border-b border-gray-200 dark:border-neutral-800 pb-2 mb-2">
<div className="text-xs text-gray-400 flex items-center gap-2">
+20 -4
View File
@@ -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<string, any> = {};
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<string, any> = { 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<string, any> = {};
// Add search conditions
+27
View File
@@ -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 ");
}