diff --git a/package.json b/package.json index bc72fb60..4359dcaf 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,8 @@ "scripts": { "generate:assets": "node scripts/generate-asset-manifest.js", "llms:generate": "node scripts/generate-llms-files.mjs", + "map:generate": "node scripts/generate-world-map-dots.mjs", + "centroids:generate": "node scripts/generate-country-centroids.mjs", "release:manifest": "node scripts/generate-release-manifest.mjs", "release:manifest:keygen": "node scripts/generate-release-manifest-keypair.mjs", "sync:directories": "node scripts/sync-directories.js", diff --git a/scripts/generate-country-centroids.mjs b/scripts/generate-country-centroids.mjs new file mode 100644 index 00000000..3ec1f47c --- /dev/null +++ b/scripts/generate-country-centroids.mjs @@ -0,0 +1,74 @@ +// Generates src/data/country-centroids.ts: ISO 3166-1 alpha-2 -> approximate +// country centroid, used to place P2P peer markers at the center of the country +// already shown as the peer's flag. Run with `yarn centroids:generate`. +// +// Peer country itself is derived offline (see lib/peer-geo getApproximateCountryCode) +// so no peer IP is ever sent to a geolocation API; this only supplies the static +// "where is country X" centroids the markers snap to. + +import { writeFileSync } from 'node:fs'; +import { dirname, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(SCRIPT_DIR, '..'); +const OUTPUT_PATH = resolve(REPO_ROOT, 'src/data/country-centroids.ts'); + +// Public-domain ISO country centroids (country,latitude,longitude,name). +// Override with COUNTRY_CENTROIDS_CSV_URL. +const CSV_URL = + process.env.COUNTRY_CENTROIDS_CSV_URL ?? 'https://raw.githubusercontent.com/google/dspl/master/samples/google/canonical/countries.csv'; + +const log = (message) => process.stdout.write(`[generate-country-centroids] ${message}\n`); + +const fetchCsv = async (url) => { + log(`fetching ${url}`); + const response = await fetch(url); + if (!response.ok) throw new Error(`failed to fetch centroid CSV: ${response.status} ${response.statusText}`); + return response.text(); +}; + +const round = (value) => Math.round(value * 100) / 100; + +const parseCentroids = (csv) => { + const centroids = {}; + for (const line of csv.trim().split('\n').slice(1)) { + // Only the first three columns are needed; the trailing name may contain commas. + const [code, lat, lon] = line.split(','); + const iso = code?.trim().toLowerCase(); + const latNum = Number(lat); + const lonNum = Number(lon); + if (!/^[a-z]{2}$/.test(iso ?? '') || !Number.isFinite(latNum) || !Number.isFinite(lonNum)) continue; + centroids[iso] = { lat: round(latNum), lon: round(lonNum) }; + } + return centroids; +}; + +const renderModule = (centroids) => { + const entries = Object.keys(centroids) + .sort() + .map((iso) => ` ${iso}: { lat: ${centroids[iso].lat}, lon: ${centroids[iso].lon} },`) + .join('\n'); + return `// GENERATED FILE — do not edit by hand. +// Source: Google DSPL canonical countries.csv (public domain), via +// scripts/generate-country-centroids.mjs. Run \`yarn centroids:generate\` to refresh. +// +// ISO 3166-1 alpha-2 (lowercase) -> approximate country centroid, used to place +// P2P peer markers at the center of the country shown as the peer's flag. +export const COUNTRY_CENTROIDS: Record = { +${entries} +}; +`; +}; + +const main = async () => { + const csv = await fetchCsv(CSV_URL); + const centroids = parseCentroids(csv); + writeFileSync(OUTPUT_PATH, renderModule(centroids)); + log(`wrote ${relative(REPO_ROOT, OUTPUT_PATH)} (${Object.keys(centroids).length} countries)`); +}; + +main().catch((error) => { + process.stderr.write(`[generate-country-centroids] ${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; +}); diff --git a/scripts/generate-world-map-dots.mjs b/scripts/generate-world-map-dots.mjs new file mode 100644 index 00000000..5c150f13 --- /dev/null +++ b/scripts/generate-world-map-dots.mjs @@ -0,0 +1,144 @@ +// Generates src/data/world-map-dots.ts: a compact dotted-world-map backdrop for +// the P2P stats panel, rasterized from Natural Earth 1:110m land polygons +// (public domain). Run with `yarn map:generate`. +// +// The map is decorative ("approximate locations"), so we only need a recognizable +// land/sea mask. We rasterize the real land polygons onto a regular lon/lat grid +// and pack the result into a 1-bit-per-cell bitmap (base64). At runtime the panel +// expands set bits into square dots using the same equirectangular projection the +// peer markers use, so dots and markers stay aligned without shipping a geo lib. + +import { Buffer } from 'node:buffer'; +import { writeFileSync } from 'node:fs'; +import { dirname, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(SCRIPT_DIR, '..'); +const OUTPUT_PATH = resolve(REPO_ROOT, 'src/data/world-map-dots.ts'); + +// Natural Earth 1:110m land (public domain). Override with WORLD_MAP_GEOJSON_URL. +const GEOJSON_URL = + process.env.WORLD_MAP_GEOJSON_URL ?? + 'https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_110m_land.geojson'; + +// Equirectangular grid. STEP is the cell size in degrees; smaller = finer/more +// dots. The latitude band excludes Antarctica and trims the very top of the map. +const STEP = 2; +const LON_MIN = -180; +const LAT_MAX = 84; +const LAT_MIN = -56; +const COLS = Math.round((180 - LON_MIN) / STEP); +const ROWS = Math.round((LAT_MAX - LAT_MIN) / STEP); + +const log = (message) => process.stdout.write(`[generate-world-map-dots] ${message}\n`); + +const fetchGeoJson = async (url) => { + log(`fetching ${url}`); + const response = await fetch(url); + if (!response.ok) throw new Error(`failed to fetch land GeoJSON: ${response.status} ${response.statusText}`); + return response.json(); +}; + +// Flatten Polygon/MultiPolygon features into rings plus a bounding box for a fast +// reject before the per-edge ray cast. +const collectPolygons = (geojson) => { + const polygons = []; + for (const feature of geojson.features ?? []) { + const geometry = feature.geometry; + if (!geometry) continue; + const coordinateSets = + geometry.type === 'Polygon' ? [geometry.coordinates] : geometry.type === 'MultiPolygon' ? geometry.coordinates : []; + for (const rings of coordinateSets) { + let minLon = Infinity; + let minLat = Infinity; + let maxLon = -Infinity; + let maxLat = -Infinity; + for (const ring of rings) { + for (const [lon, lat] of ring) { + if (lon < minLon) minLon = lon; + if (lon > maxLon) maxLon = lon; + if (lat < minLat) minLat = lat; + if (lat > maxLat) maxLat = lat; + } + } + polygons.push({ bbox: [minLon, minLat, maxLon, maxLat], rings }); + } + } + return polygons; +}; + +// Even-odd ray casting across every ring of a polygon, so interior holes (lakes) +// correctly read as sea. Natural Earth cuts geometry at the antimeridian, so no +// polygon wraps past +/-180 and a plain lon-space ray cast is sound. +const isInsidePolygon = (lon, lat, rings) => { + let inside = false; + for (const ring of rings) { + for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) { + const xi = ring[i][0]; + const yi = ring[i][1]; + const xj = ring[j][0]; + const yj = ring[j][1]; + if (yi > lat !== yj > lat && lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi) inside = !inside; + } + } + return inside; +}; + +const isLand = (lon, lat, polygons) => { + for (const { bbox, rings } of polygons) { + if (lon < bbox[0] || lon > bbox[2] || lat < bbox[1] || lat > bbox[3]) continue; + if (isInsidePolygon(lon, lat, rings)) return true; + } + return false; +}; + +const rasterize = (polygons) => { + const bits = new Uint8Array(Math.ceil((COLS * ROWS) / 8)); + let landCount = 0; + for (let row = 0; row < ROWS; row++) { + const lat = LAT_MAX - (row + 0.5) * STEP; + for (let col = 0; col < COLS; col++) { + const lon = LON_MIN + (col + 0.5) * STEP; + if (isLand(lon, lat, polygons)) { + const index = row * COLS + col; + bits[index >> 3] |= 1 << (7 - (index & 7)); + landCount++; + } + } + } + return { bits, landCount }; +}; + +const renderModule = (base64) => + `// GENERATED FILE — do not edit by hand. +// Source: Natural Earth 1:110m land (public domain), rasterized by +// scripts/generate-world-map-dots.mjs. Run \`yarn map:generate\` to refresh. +// +// Row-major land/sea grid for the P2P stats world map. \`bitmap\` is a base64 +// 1-bit-per-cell mask (MSB first, 1 = land). Cell (col,row) centers on +// lon = lonMin + (col + 0.5) * step, lat = latMax - (row + 0.5) * step. +export const WORLD_MAP_DOTS = { + step: ${STEP}, + lonMin: ${LON_MIN}, + latMax: ${LAT_MAX}, + cols: ${COLS}, + rows: ${ROWS}, + bitmap: '${base64}', +} as const; +`; + +const main = async () => { + const geojson = await fetchGeoJson(GEOJSON_URL); + const polygons = collectPolygons(geojson); + log(`rasterizing ${polygons.length} land polygons onto a ${COLS}x${ROWS} grid (step ${STEP}°)`); + const { bits, landCount } = rasterize(polygons); + const base64 = Buffer.from(bits).toString('base64'); + writeFileSync(OUTPUT_PATH, renderModule(base64)); + log(`wrote ${relative(REPO_ROOT, OUTPUT_PATH)} (${landCount} land cells, ${base64.length} base64 chars)`); +}; + +main().catch((error) => { + process.stderr.write(`[generate-world-map-dots] ${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; +}); diff --git a/src/components/settings-modal/p2p-stats-settings/__tests__/p2p-stats-settings.test.tsx b/src/components/settings-modal/p2p-stats-settings/__tests__/p2p-stats-settings.test.tsx index e83f2f1a..de8ae783 100644 --- a/src/components/settings-modal/p2p-stats-settings/__tests__/p2p-stats-settings.test.tsx +++ b/src/components/settings-modal/p2p-stats-settings/__tests__/p2p-stats-settings.test.tsx @@ -60,6 +60,15 @@ describe('P2PStatsSettings', () => { }; testState.rpcSettings = { state: 'disconnected' }; testState.setAccountMock.mockReset().mockResolvedValue(undefined); + // Default: own-IP country lookups (api.country.is) resolve offline so browser + // stats tests never hit the network. Individual tests can override this stub. + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ country: 'US', ip: '147.75.84.175' }), + }), + ); container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); @@ -68,6 +77,7 @@ describe('P2PStatsSettings', () => { afterEach(() => { act(() => root.unmount()); container.remove(); + vi.unstubAllGlobals(); }); it('renders browser libp2p stats from the active PKC client', async () => { @@ -97,7 +107,7 @@ describe('P2PStatsSettings', () => { status: 'open', }, ], - getMultiaddrs: () => ['/ip4/127.0.0.1/tcp/4001'], + getMultiaddrs: () => ['/ip4/147.75.84.175/tcp/4001/ws'], getPeers: () => ['peer-1', 'peer-2'], metrics: { toJSON: () => ({ @@ -134,6 +144,15 @@ describe('P2PStatsSettings', () => { const rows = getStatRows(); const connectedPeers = container.querySelector('[data-testid="connected-peers"]'); expect(container.textContent).toContain('Leeching'); + expect(container.textContent).toContain('want to seed'); + const seederLink = container.querySelector('a[href="https://github.com/bitsocialnet/bitsocial-seeder"]'); + expect(seederLink).not.toBeNull(); + expect(seederLink?.textContent).toBe('want to seed?'); + expect(rows.get('Your IP')).toContain('147.75.84.175'); + // The own IP is geolocated accurately (per-IP lookup), not via the coarse peer guess. + expect(fetch).toHaveBeenCalledWith('https://api.country.is/147.75.84.175', expect.objectContaining({ signal: expect.any(AbortSignal) })); + const yourIpRow = Array.from(container.querySelectorAll('tr')).find((row) => row.textContent?.includes('Your IP')); + expect(yourIpRow?.querySelector('[role="img"]')).not.toBeNull(); expect(container.textContent).not.toContain('browser Helia'); expect(container.textContent).not.toContain('seed mode'); expect(container.textContent).not.toContain('status'); @@ -143,6 +162,14 @@ describe('P2PStatsSettings', () => { expect(rows.has('connections')).toBe(false); expect(rows.has('Listen addresses')).toBe(false); expect(rows.has('p2p_stats_updated')).toBe(true); + const tableRows = Array.from(container.querySelectorAll('tr')); + const rowTexts = tableRows.map((row) => row.textContent ?? ''); + const dataSentIndex = rowTexts.findIndex((text) => text.includes('Data sent')); + const updatedIndex = rowTexts.findIndex((text) => text.includes('p2p_stats_updated')); + const connectedPeersIndex = rowTexts.findIndex((text) => text.includes('Connected peers')); + expect(dataSentIndex).toBeGreaterThanOrEqual(0); + expect(updatedIndex).toBeGreaterThan(dataSentIndex); + expect(connectedPeersIndex).toBeGreaterThan(updatedIndex); expect(container.textContent).toContain('self-peer'); expect(container.textContent).toContain('Peer ID'); expect(container.textContent).toContain('Data received'); @@ -183,7 +210,7 @@ describe('P2PStatsSettings', () => { }, libp2p: { getConnections: () => [], - getMultiaddrs: () => [], + getMultiaddrs: () => ['/ip4/147.75.84.175/tcp/4001/ws'], getPeers: () => [], peerId: { toString: () => 'self-peer' }, }, @@ -217,7 +244,7 @@ describe('P2PStatsSettings', () => { _helia: { libp2p: { getConnections: () => [], - getMultiaddrs: () => [], + getMultiaddrs: () => ['/ip4/147.75.84.175/tcp/4001/ws'], getPeers: () => [], peerId: { toString: () => 'self-peer' }, }, @@ -236,6 +263,55 @@ describe('P2PStatsSettings', () => { expect(rows.get('Data sent')).toBe('0 B'); }); + it('falls back to the browser node public endpoint when Helia exposes no public address', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ country: 'US', ip: '2001:4860:4860::8888' }), + }), + ); + testState.account = { + ...testState.account, + pkcOptions: { + libp2pJsClientsOptions: [{ key: 'libp2pjs' }], + }, + pkc: { + clients: { + libp2pJsClients: { + libp2pjs: { + key: 'libp2pjs', + _helia: { + libp2p: { + getConnections: () => [ + { + localAddr: { toString: () => '/ip4/127.0.0.1/tcp/4001/ws' }, + remoteAddr: { toString: () => '/ip4/127.0.0.1/tcp/4001/ws/p2p/peer-1' }, + remotePeer: { toString: () => 'peer-1' }, + }, + ], + getMultiaddrs: () => [], + getPeers: () => [], + peerId: { toString: () => 'self-peer' }, + }, + }, + }, + }, + }, + }, + }; + + await renderSettings(false); + await act(async () => Promise.resolve()); + + const rows = getStatRows(); + expect(rows.get('Your IP')).toContain('2001:4860:4860::8888'); + expect(rows.get('Your IP')).not.toContain('unknown'); + const yourIpRow = Array.from(container.querySelectorAll('tr')).find((row) => row.textContent?.includes('Your IP')); + expect(yourIpRow?.querySelector('[role="img"]')).not.toBeNull(); + expect(fetch).toHaveBeenCalledWith('https://api.country.is', expect.objectContaining({ signal: expect.any(AbortSignal) })); + }); + it('reports seeding only when browser Helia can add and publish provider records', async () => { testState.account = { ...testState.account, @@ -253,7 +329,7 @@ describe('P2PStatsSettings', () => { _helia: { libp2p: { getConnections: () => [], - getMultiaddrs: () => [], + getMultiaddrs: () => ['/ip4/147.75.84.175/tcp/4001/ws'], getPeers: () => [], peerId: { toString: () => 'self-peer' }, }, @@ -275,6 +351,7 @@ describe('P2PStatsSettings', () => { await act(async () => Promise.resolve()); expect(container.textContent).toContain('Seeding'); + expect(container.querySelector('a[href="https://github.com/bitsocialnet/bitsocial-seeder"]')).toBeNull(); expect(container.textContent).not.toContain('seed mode'); }); }); diff --git a/src/components/settings-modal/p2p-stats-settings/__tests__/peer-world-map.test.tsx b/src/components/settings-modal/p2p-stats-settings/__tests__/peer-world-map.test.tsx new file mode 100644 index 00000000..17953cb1 --- /dev/null +++ b/src/components/settings-modal/p2p-stats-settings/__tests__/peer-world-map.test.tsx @@ -0,0 +1,41 @@ +import * as React from 'react'; +import { createElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import PeerWorldMap from '../peer-world-map'; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +const act = (React as { act?: (cb: () => void) => void }).act as (cb: () => void) => void; + +let container: HTMLDivElement; +let root: Root; + +const render = (element: React.ReactElement) => act(() => root.render(element)); + +describe('PeerWorldMap', () => { + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it('renders the rasterized land backdrop and a marker for a placeable peer', () => { + render(createElement(PeerWorldMap, { peers: [{ address: '/ip4/8.8.8.8/tcp/4001', id: 'c1', peerId: 'peer-1' }] })); + const landPath = container.querySelector('svg path'); + expect(landPath).not.toBeNull(); + // The land mask decodes into a large multi-square path, not a handful of points. + expect((landPath?.getAttribute('d') ?? '').length).toBeGreaterThan(1000); + expect(container.querySelectorAll('svg rect')).toHaveLength(1); + expect(container.querySelector('svg rect title')?.textContent).toBe('peer-1'); + }); + + it('renders nothing when no peer can be placed offline', () => { + render(createElement(PeerWorldMap, { peers: [{ address: '/ip4/10.0.0.1/tcp/4001', id: 'c1', peerId: 'peer-1' }] })); + expect(container.querySelector('svg')).toBeNull(); + }); +}); diff --git a/src/components/settings-modal/p2p-stats-settings/p2p-stats-settings.module.css b/src/components/settings-modal/p2p-stats-settings/p2p-stats-settings.module.css index 01fc8dca..cc4d2c9d 100644 --- a/src/components/settings-modal/p2p-stats-settings/p2p-stats-settings.module.css +++ b/src/components/settings-modal/p2p-stats-settings/p2p-stats-settings.module.css @@ -38,6 +38,17 @@ word-break: break-word; } +.statValue a { + color: var(--post-link-text-color); + text-decoration: var(--post-content-link-text-decoration); + text-transform: lowercase; +} + +.statValue a:hover { + color: var(--post-link-text-color-hover); + text-decoration: var(--post-content-link-text-decoration-hover); +} + /* Connected peers span the full panel width instead of the narrow value column. */ .stats td.connectedPeersCell { padding: 4px 4px 5px 0; @@ -120,11 +131,23 @@ min-width: 0; } +.nodeEndpoint { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 4px; + min-width: 0; +} + +.nodeIp { + overflow-wrap: anywhere; +} + .peerFlag { flex-shrink: 0; width: 16px; height: 11px; - background-image: url("/assets/icons/flags-1.png"); + background-image: url('/assets/icons/flags-1.png'); background-repeat: no-repeat; image-rendering: pixelated; } @@ -159,7 +182,7 @@ .landDot { fill: currentColor; - opacity: 0.18; + opacity: 0.28; } .peerMarker { diff --git a/src/components/settings-modal/p2p-stats-settings/p2p-stats-settings.tsx b/src/components/settings-modal/p2p-stats-settings/p2p-stats-settings.tsx index 96e558fe..b2276d94 100644 --- a/src/components/settings-modal/p2p-stats-settings/p2p-stats-settings.tsx +++ b/src/components/settings-modal/p2p-stats-settings/p2p-stats-settings.tsx @@ -1,8 +1,8 @@ -import { memo, useEffect, useReducer } from 'react'; +import { Fragment, memo, useEffect, useReducer } from 'react'; import { useAccount, usePkcRpcSettings } from '@bitsocial/bitsocial-react-hooks'; import { useTranslation } from 'react-i18next'; -import { getCountryFlagPosition, getCountryLabel } from '../../../lib/country-flags'; -import { getApproximateCountryCode } from '../../../lib/peer-geo'; +import { getCountryFlagPosition, getCountryLabel, normalizeCountryCode } from '../../../lib/country-flags'; +import { fetchOwnIpCountryCode, fetchOwnPublicEndpoint, getApproximateCountryCode, getFirstPublicIpFromAddresses, type PublicEndpoint } from '../../../lib/peer-geo'; import { getP2PRuntimeMode, type P2PRuntimeMode } from '../../../lib/p2p-runtime'; import PeerWorldMap from './peer-world-map'; import styles from './p2p-stats-settings.module.css'; @@ -32,7 +32,14 @@ type ConnectedPeersStatRow = { type: 'connectedPeers'; }; -type StatRow = ConnectedPeersStatRow | TextStatRow; +type NodeEndpointStatRow = { + countryCode?: string; + ip: string; + name: string; + type: 'nodeEndpoint'; +}; + +type StatRow = ConnectedPeersStatRow | NodeEndpointStatRow | TextStatRow; type StatsState = { error?: string; @@ -59,6 +66,9 @@ type StatsAction = type Libp2pClientShape = { _helia?: { libp2p?: { + components?: { + addressManager?: Libp2pAddressManagerShape; + }; getConnections?: () => unknown[] | Promise; getMultiaddrs?: () => unknown[] | Promise; getPeers?: () => unknown[] | Promise; @@ -81,6 +91,13 @@ type Libp2pClientShape = { key?: string; }; +type Libp2pAddressManagerShape = { + getAddressesWithMetadata?: () => unknown[] | Promise; + getObservedAddrs?: () => unknown[] | Promise; +}; + +type BrowserLibp2pShape = NonNullable['libp2p']>>; + type TransferStats = { downloadedBytes?: number; uploadedBytes?: number; @@ -94,6 +111,7 @@ type ObservedTransferStats = { }; const KUBO_API_URL = 'http://localhost:50019/api/v0'; +const SEEDER_REPO_URL = 'https://github.com/bitsocialnet/bitsocial-seeder'; const STATS_REFRESH_MS = 5000; const MAX_TRANSFER_COUNTER_DEPTH = 10; const MAX_TRANSFER_COUNTER_OBJECTS = 400; @@ -176,6 +194,21 @@ const getSafeArray = async (getValue?: () => unknown[] | Promise | un } }; +const getAddressManagerAddresses = async (libp2p?: BrowserLibp2pShape): Promise => { + const addressManager = isRecord(libp2p?.components) ? (libp2p.components.addressManager as Libp2pAddressManagerShape | undefined) : undefined; + const [observedAddrs, addressesWithMetadata] = await Promise.all([ + getSafeArray(() => addressManager?.getObservedAddrs?.()), + getSafeArray(() => addressManager?.getAddressesWithMetadata?.()), + ]); + return [ + ...observedAddrs, + ...addressesWithMetadata.flatMap((entry) => { + const address = isRecord(entry) ? (entry.multiaddr ?? entry.address) : entry; + return address ? [address] : []; + }), + ]; +}; + const getByteLength = (value: unknown): number | undefined => { if (value === null || value === undefined) return undefined; if (typeof value === 'string') return new TextEncoder().encode(value).byteLength; @@ -478,6 +511,16 @@ const getBrowserConnectedPeersRow = (peers: unknown[], connections: unknown[]): }; }; +// Resolves the "Your IP" row from the node's own observed addresses. The shown IP +// is geolocated accurately (it is the user's own address, never a peer's) so the +// flag matches it, instead of the coarse continent guess used for connected peers. +// Falls back to a public-endpoint lookup when libp2p only knows local/private addresses. +const resolveOwnEndpoint = async (addresses: unknown[], signal?: AbortSignal): Promise => { + const ip = getFirstPublicIpFromAddresses(addresses); + if (ip) return { countryCode: await fetchOwnIpCountryCode(ip, signal), ip }; + return fetchOwnPublicEndpoint(signal); +}; + const getElectronConnectedPeersRow = (peers: unknown): ConnectedPeersStatRow => { const peerEntries = isRecord(peers) && Array.isArray(peers.Peers) ? peers.Peers : []; const entries = peerEntries.map((peer) => { @@ -506,15 +549,26 @@ const getElectronConnectedPeersRow = (peers: unknown): ConnectedPeersStatRow => }; }; -const getBrowserLibp2pStats = async (account?: AccountShape): Promise => { +const getBrowserLibp2pStats = async (account?: AccountShape, signal?: AbortSignal): Promise => { const client = getFirstObjectValue(account?.pkc?.clients?.libp2pJsClients) as Libp2pClientShape | undefined; const libp2p = client?._helia?.libp2p; - const [peers, connections] = await Promise.all([getSafeArray(() => libp2p?.getPeers?.()), getSafeArray(() => libp2p?.getConnections?.())]); + const [peers, connections, multiaddrs, addressManagerAddresses] = await Promise.all([ + getSafeArray(() => libp2p?.getPeers?.()), + getSafeArray(() => libp2p?.getConnections?.()), + getSafeArray(() => libp2p?.getMultiaddrs?.()), + getAddressManagerAddresses(libp2p), + ]); const transferStats = await getBrowserTransferStats(client, connections); + const localAddresses = connections.flatMap((connection) => { + const localAddr = isRecord(connection) ? connection.localAddr : undefined; + return localAddr ? [localAddr] : []; + }); + const nodeEndpoint = await resolveOwnEndpoint([...multiaddrs, ...addressManagerAddresses, ...localAddresses], signal); return [ { name: 'Mode', value: getBrowserMode(client) }, { name: 'Peer ID', value: libp2p?.peerId?.toString() ?? 'unknown' }, + nodeEndpoint ? { countryCode: nodeEndpoint.countryCode, ip: nodeEndpoint.ip, name: 'Your IP', type: 'nodeEndpoint' } : { name: 'Your IP', value: 'unavailable' }, { name: 'Data received', value: transferStats.downloadedBytes === undefined ? 'unknown' : formatBytes(transferStats.downloadedBytes) }, { name: 'Data sent', value: transferStats.uploadedBytes === undefined ? 'unknown' : formatBytes(transferStats.uploadedBytes) }, getBrowserConnectedPeersRow(peers, connections), @@ -561,10 +615,46 @@ const getElectronKuboStats = async (rpcState?: string, signal?: AbortSignal): Pr }; const getP2PStats = async (mode: P2PRuntimeMode, account?: AccountShape, rpcState?: string, signal?: AbortSignal) => { - if (mode === 'browser-libp2p') return getBrowserLibp2pStats(account); + if (mode === 'browser-libp2p') return getBrowserLibp2pStats(account, signal); return getElectronKuboStats(rpcState, signal); }; +const NodeEndpointValue = ({ row }: { row: NodeEndpointStatRow }) => { + const countryCode = normalizeCountryCode(row.countryCode); + const flagPosition = getCountryFlagPosition(countryCode); + const countryLabel = getCountryLabel(row.countryCode); + + return ( + + {flagPosition && ( + + )} + {row.ip} + + ); +}; + +const StatValueCell = ({ row }: { row: TextStatRow }) => { + if (row.name === 'Mode' && row.value === 'Leeching') { + return ( + <> + Leeching ( + + want to seed? + + ) + + ); + } + return row.value; +}; + const ConnectedPeersValue = ({ row }: { row: ConnectedPeersStatRow }) => (
@@ -666,24 +756,35 @@ const P2PStatsSettings = () => { {statsState.rows.map((row) => row.type === 'connectedPeers' ? ( + + {updatedAtLabel && ( + + {t('p2p_stats_updated')} + {updatedAtLabel} + + )} + + + + + + + ) : row.type === 'nodeEndpoint' ? ( - - + {row.name} + + ) : ( {row.name} - {row.value} + + + ), )} - {updatedAtLabel && ( - - {t('p2p_stats_updated')} - {updatedAtLabel} - - )}
diff --git a/src/components/settings-modal/p2p-stats-settings/peer-world-map.tsx b/src/components/settings-modal/p2p-stats-settings/peer-world-map.tsx index 5c0769cc..c828a02f 100644 --- a/src/components/settings-modal/p2p-stats-settings/peer-world-map.tsx +++ b/src/components/settings-modal/p2p-stats-settings/peer-world-map.tsx @@ -1,3 +1,4 @@ +import { WORLD_MAP_DOTS } from '../../../data/world-map-dots'; import { getApproximateLatLon } from '../../../lib/peer-geo'; import styles from './p2p-stats-settings.module.css'; @@ -7,230 +8,29 @@ type MapPeer = { peerId: string; }; -// Rough continent outlines as [lon, lat] vertices. Deliberately coarse: they are -// only rasterized into a faint dotted backdrop, so approximate shapes are fine. -const CONTINENTS: [number, number][][] = [ - // North America - [ - [-168, 65], - [-156, 71], - [-128, 70], - [-110, 68], - [-95, 69], - [-81, 73], - [-78, 67], - [-64, 60], - [-56, 52], - [-66, 49], - [-67, 44], - [-70, 41], - [-75, 35], - [-81, 25], - [-90, 29], - [-97, 26], - [-97, 21], - [-105, 20], - [-112, 24], - [-117, 32], - [-122, 37], - [-125, 43], - [-130, 51], - [-141, 59], - [-155, 58], - ], - // Greenland - [ - [-45, 60], - [-30, 60], - [-18, 66], - [-20, 73], - [-25, 80], - [-40, 83], - [-58, 82], - [-55, 76], - [-50, 68], - ], - // South America - [ - [-81, 8], - [-72, 11], - [-62, 10], - [-50, 0], - [-35, -6], - [-39, -14], - [-48, -25], - [-58, -34], - [-65, -41], - [-69, -50], - [-74, -53], - [-72, -45], - [-73, -37], - [-71, -28], - [-71, -18], - [-78, -8], - [-81, -4], - [-80, 2], - ], - // Europe - [ - [-10, 36], - [-9, 44], - [-2, 43], - [-2, 49], - [-5, 54], - [-6, 58], - [5, 61], - [8, 58], - [12, 59], - [16, 66], - [24, 71], - [30, 70], - [40, 67], - [46, 60], - [42, 52], - [36, 46], - [28, 45], - [24, 40], - [18, 40], - [12, 38], - [2, 42], - [-4, 37], - ], - // Africa - [ - [-17, 15], - [-16, 21], - [-10, 27], - [-6, 32], - [1, 37], - [10, 37], - [11, 33], - [20, 32], - [25, 32], - [32, 31], - [35, 24], - [37, 18], - [43, 12], - [51, 12], - [44, 5], - [48, -3], - [40, -15], - [35, -21], - [31, -26], - [26, -34], - [20, -35], - [16, -29], - [12, -17], - [9, -1], - [8, 4], - [-4, 5], - [-9, 5], - [-13, 9], - ], - // Asia - [ - [42, 48], - [45, 55], - [55, 62], - [68, 68], - [80, 73], - [100, 77], - [115, 74], - [140, 73], - [160, 70], - [170, 66], - [178, 65], - [170, 60], - [158, 53], - [150, 46], - [143, 44], - [140, 50], - [135, 44], - [131, 43], - [127, 37], - [122, 40], - [120, 34], - [122, 30], - [115, 22], - [108, 21], - [106, 11], - [100, 14], - [98, 8], - [100, 2], - [95, 7], - [90, 22], - [88, 22], - [80, 13], - [77, 8], - [73, 17], - [68, 24], - [61, 25], - [57, 37], - [52, 41], - [47, 43], - ], - // Australia - [ - [113, -22], - [121, -19], - [129, -15], - [136, -12], - [142, -11], - [145, -15], - [147, -19], - [153, -25], - [153, -31], - [150, -37], - [143, -39], - [138, -35], - [131, -32], - [123, -34], - [115, -34], - [114, -29], - ], - // Indonesia - [ - [96, 5], - [120, 7], - [128, 8], - [131, 1], - [122, -4], - [114, -8], - [103, -8], - [98, -1], - ], - // Japan - [ - [130, 31], - [136, 35], - [141, 41], - [142, 45], - [140, 38], - [135, 34], - [131, 31], - ], -]; - -const pointInPolygon = (lon: number, lat: number, polygon: [number, number][]) => { - let inside = false; - for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { - const [xi, yi] = polygon[i]; - const [xj, yj] = polygon[j]; - if (yi > lat !== yj > lat && lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi) inside = !inside; - } - return inside; -}; - -// Equirectangular projection into the SVG viewBox below: x = lon + 180, y = 90 - lat. -const LAND_STEP = 3.5; -const LAND_DOTS: { x: number; y: number }[] = (() => { - const dots: { x: number; y: number }[] = []; - for (let lat = 80; lat >= -56; lat -= LAND_STEP) { - for (let lon = -178; lon <= 180; lon += LAND_STEP) { - if (CONTINENTS.some((continent) => pointInPolygon(lon, lat, continent))) dots.push({ x: lon + 180, y: 90 - lat }); +// Square side per land dot, sized to cover most of a grid cell so the rasterized +// Natural Earth land mask (src/data/world-map-dots.ts) reads as a halftone map. +const DOT_SIZE = WORLD_MAP_DOTS.step * 0.6; + +// Equirectangular projection shared with the peer markers below: x = lon + 180, +// y = 90 - lat. Expand the land bitmap into one of small squares so the +// backdrop is a single static node instead of thousands of elements. +const LAND_PATH = (() => { + const { step, lonMin, latMax, cols, rows, bitmap } = WORLD_MAP_DOTS; + const binary = atob(bitmap); + const square = `h${DOT_SIZE}v${DOT_SIZE}h${-DOT_SIZE}z`; + const fmt = (value: number) => +value.toFixed(2); + let path = ''; + for (let row = 0; row < rows; row++) { + for (let col = 0; col < cols; col++) { + const index = row * cols + col; + if (!((binary.charCodeAt(index >> 3) >> (7 - (index & 7))) & 1)) continue; + const lon = lonMin + (col + 0.5) * step; + const lat = latMax - (row + 0.5) * step; + path += `M${fmt(lon + 180 - DOT_SIZE / 2)} ${fmt(90 - lat - DOT_SIZE / 2)}${square}`; } } - return dots; + return path; })(); const PeerWorldMap = ({ peers }: { peers: MapPeer[] }) => { @@ -245,9 +45,7 @@ const PeerWorldMap = ({ peers }: { peers: MapPeer[] }) => { return (
- {LAND_DOTS.map((dot) => ( - - ))} + {plotted.map((peer) => ( {peer.peerId} diff --git a/src/data/country-centroids.ts b/src/data/country-centroids.ts new file mode 100644 index 00000000..3a9aec67 --- /dev/null +++ b/src/data/country-centroids.ts @@ -0,0 +1,253 @@ +// GENERATED FILE — do not edit by hand. +// Source: Google DSPL canonical countries.csv (public domain), via +// scripts/generate-country-centroids.mjs. Run `yarn centroids:generate` to refresh. +// +// ISO 3166-1 alpha-2 (lowercase) -> approximate country centroid, used to place +// P2P peer markers at the center of the country shown as the peer's flag. +export const COUNTRY_CENTROIDS: Record = { + ad: { lat: 42.55, lon: 1.6 }, + ae: { lat: 23.42, lon: 53.85 }, + af: { lat: 33.94, lon: 67.71 }, + ag: { lat: 17.06, lon: -61.8 }, + ai: { lat: 18.22, lon: -63.07 }, + al: { lat: 41.15, lon: 20.17 }, + am: { lat: 40.07, lon: 45.04 }, + an: { lat: 12.23, lon: -69.06 }, + ao: { lat: -11.2, lon: 17.87 }, + aq: { lat: -75.25, lon: -0.07 }, + ar: { lat: -38.42, lon: -63.62 }, + as: { lat: -14.27, lon: -170.13 }, + at: { lat: 47.52, lon: 14.55 }, + au: { lat: -25.27, lon: 133.78 }, + aw: { lat: 12.52, lon: -69.97 }, + az: { lat: 40.14, lon: 47.58 }, + ba: { lat: 43.92, lon: 17.68 }, + bb: { lat: 13.19, lon: -59.54 }, + bd: { lat: 23.68, lon: 90.36 }, + be: { lat: 50.5, lon: 4.47 }, + bf: { lat: 12.24, lon: -1.56 }, + bg: { lat: 42.73, lon: 25.49 }, + bh: { lat: 25.93, lon: 50.64 }, + bi: { lat: -3.37, lon: 29.92 }, + bj: { lat: 9.31, lon: 2.32 }, + bm: { lat: 32.32, lon: -64.76 }, + bn: { lat: 4.54, lon: 114.73 }, + bo: { lat: -16.29, lon: -63.59 }, + br: { lat: -14.24, lon: -51.93 }, + bs: { lat: 25.03, lon: -77.4 }, + bt: { lat: 27.51, lon: 90.43 }, + bv: { lat: -54.42, lon: 3.41 }, + bw: { lat: -22.33, lon: 24.68 }, + by: { lat: 53.71, lon: 27.95 }, + bz: { lat: 17.19, lon: -88.5 }, + ca: { lat: 56.13, lon: -106.35 }, + cc: { lat: -12.16, lon: 96.87 }, + cd: { lat: -4.04, lon: 21.76 }, + cf: { lat: 6.61, lon: 20.94 }, + cg: { lat: -0.23, lon: 15.83 }, + ch: { lat: 46.82, lon: 8.23 }, + ci: { lat: 7.54, lon: -5.55 }, + ck: { lat: -21.24, lon: -159.78 }, + cl: { lat: -35.68, lon: -71.54 }, + cm: { lat: 7.37, lon: 12.35 }, + cn: { lat: 35.86, lon: 104.2 }, + co: { lat: 4.57, lon: -74.3 }, + cr: { lat: 9.75, lon: -83.75 }, + cu: { lat: 21.52, lon: -77.78 }, + cv: { lat: 16, lon: -24.01 }, + cx: { lat: -10.45, lon: 105.69 }, + cy: { lat: 35.13, lon: 33.43 }, + cz: { lat: 49.82, lon: 15.47 }, + de: { lat: 51.17, lon: 10.45 }, + dj: { lat: 11.83, lon: 42.59 }, + dk: { lat: 56.26, lon: 9.5 }, + dm: { lat: 15.41, lon: -61.37 }, + do: { lat: 18.74, lon: -70.16 }, + dz: { lat: 28.03, lon: 1.66 }, + ec: { lat: -1.83, lon: -78.18 }, + ee: { lat: 58.6, lon: 25.01 }, + eg: { lat: 26.82, lon: 30.8 }, + eh: { lat: 24.22, lon: -12.89 }, + er: { lat: 15.18, lon: 39.78 }, + es: { lat: 40.46, lon: -3.75 }, + et: { lat: 9.15, lon: 40.49 }, + fi: { lat: 61.92, lon: 25.75 }, + fj: { lat: -16.58, lon: 179.41 }, + fk: { lat: -51.8, lon: -59.52 }, + fm: { lat: 7.43, lon: 150.55 }, + fo: { lat: 61.89, lon: -6.91 }, + fr: { lat: 46.23, lon: 2.21 }, + ga: { lat: -0.8, lon: 11.61 }, + gb: { lat: 55.38, lon: -3.44 }, + gd: { lat: 12.26, lon: -61.6 }, + ge: { lat: 42.32, lon: 43.36 }, + gf: { lat: 3.93, lon: -53.13 }, + gg: { lat: 49.47, lon: -2.59 }, + gh: { lat: 7.95, lon: -1.02 }, + gi: { lat: 36.14, lon: -5.35 }, + gl: { lat: 71.71, lon: -42.6 }, + gm: { lat: 13.44, lon: -15.31 }, + gn: { lat: 9.95, lon: -9.7 }, + gp: { lat: 17, lon: -62.07 }, + gq: { lat: 1.65, lon: 10.27 }, + gr: { lat: 39.07, lon: 21.82 }, + gs: { lat: -54.43, lon: -36.59 }, + gt: { lat: 15.78, lon: -90.23 }, + gu: { lat: 13.44, lon: 144.79 }, + gw: { lat: 11.8, lon: -15.18 }, + gy: { lat: 4.86, lon: -58.93 }, + gz: { lat: 31.35, lon: 34.31 }, + hk: { lat: 22.4, lon: 114.11 }, + hm: { lat: -53.08, lon: 73.5 }, + hn: { lat: 15.2, lon: -86.24 }, + hr: { lat: 45.1, lon: 15.2 }, + ht: { lat: 18.97, lon: -72.29 }, + hu: { lat: 47.16, lon: 19.5 }, + id: { lat: -0.79, lon: 113.92 }, + ie: { lat: 53.41, lon: -8.24 }, + il: { lat: 31.05, lon: 34.85 }, + im: { lat: 54.24, lon: -4.55 }, + in: { lat: 20.59, lon: 78.96 }, + io: { lat: -6.34, lon: 71.88 }, + iq: { lat: 33.22, lon: 43.68 }, + ir: { lat: 32.43, lon: 53.69 }, + is: { lat: 64.96, lon: -19.02 }, + it: { lat: 41.87, lon: 12.57 }, + je: { lat: 49.21, lon: -2.13 }, + jm: { lat: 18.11, lon: -77.3 }, + jo: { lat: 30.59, lon: 36.24 }, + jp: { lat: 36.2, lon: 138.25 }, + ke: { lat: -0.02, lon: 37.91 }, + kg: { lat: 41.2, lon: 74.77 }, + kh: { lat: 12.57, lon: 104.99 }, + ki: { lat: -3.37, lon: -168.73 }, + km: { lat: -11.88, lon: 43.87 }, + kn: { lat: 17.36, lon: -62.78 }, + kp: { lat: 40.34, lon: 127.51 }, + kr: { lat: 35.91, lon: 127.77 }, + kw: { lat: 29.31, lon: 47.48 }, + ky: { lat: 19.51, lon: -80.57 }, + kz: { lat: 48.02, lon: 66.92 }, + la: { lat: 19.86, lon: 102.5 }, + lb: { lat: 33.85, lon: 35.86 }, + lc: { lat: 13.91, lon: -60.98 }, + li: { lat: 47.17, lon: 9.56 }, + lk: { lat: 7.87, lon: 80.77 }, + lr: { lat: 6.43, lon: -9.43 }, + ls: { lat: -29.61, lon: 28.23 }, + lt: { lat: 55.17, lon: 23.88 }, + lu: { lat: 49.82, lon: 6.13 }, + lv: { lat: 56.88, lon: 24.6 }, + ly: { lat: 26.34, lon: 17.23 }, + ma: { lat: 31.79, lon: -7.09 }, + mc: { lat: 43.75, lon: 7.41 }, + md: { lat: 47.41, lon: 28.37 }, + me: { lat: 42.71, lon: 19.37 }, + mg: { lat: -18.77, lon: 46.87 }, + mh: { lat: 7.13, lon: 171.18 }, + mk: { lat: 41.61, lon: 21.75 }, + ml: { lat: 17.57, lon: -4 }, + mm: { lat: 21.91, lon: 95.96 }, + mn: { lat: 46.86, lon: 103.85 }, + mo: { lat: 22.2, lon: 113.54 }, + mp: { lat: 17.33, lon: 145.38 }, + mq: { lat: 14.64, lon: -61.02 }, + mr: { lat: 21.01, lon: -10.94 }, + ms: { lat: 16.74, lon: -62.19 }, + mt: { lat: 35.94, lon: 14.38 }, + mu: { lat: -20.35, lon: 57.55 }, + mv: { lat: 3.2, lon: 73.22 }, + mw: { lat: -13.25, lon: 34.3 }, + mx: { lat: 23.63, lon: -102.55 }, + my: { lat: 4.21, lon: 101.98 }, + mz: { lat: -18.67, lon: 35.53 }, + na: { lat: -22.96, lon: 18.49 }, + nc: { lat: -20.9, lon: 165.62 }, + ne: { lat: 17.61, lon: 8.08 }, + nf: { lat: -29.04, lon: 167.95 }, + ng: { lat: 9.08, lon: 8.68 }, + ni: { lat: 12.87, lon: -85.21 }, + nl: { lat: 52.13, lon: 5.29 }, + no: { lat: 60.47, lon: 8.47 }, + np: { lat: 28.39, lon: 84.12 }, + nr: { lat: -0.52, lon: 166.93 }, + nu: { lat: -19.05, lon: -169.87 }, + nz: { lat: -40.9, lon: 174.89 }, + om: { lat: 21.51, lon: 55.92 }, + pa: { lat: 8.54, lon: -80.78 }, + pe: { lat: -9.19, lon: -75.02 }, + pf: { lat: -17.68, lon: -149.41 }, + pg: { lat: -6.31, lon: 143.96 }, + ph: { lat: 12.88, lon: 121.77 }, + pk: { lat: 30.38, lon: 69.35 }, + pl: { lat: 51.92, lon: 19.15 }, + pm: { lat: 46.94, lon: -56.27 }, + pn: { lat: -24.7, lon: -127.44 }, + pr: { lat: 18.22, lon: -66.59 }, + ps: { lat: 31.95, lon: 35.23 }, + pt: { lat: 39.4, lon: -8.22 }, + pw: { lat: 7.51, lon: 134.58 }, + py: { lat: -23.44, lon: -58.44 }, + qa: { lat: 25.35, lon: 51.18 }, + re: { lat: -21.12, lon: 55.54 }, + ro: { lat: 45.94, lon: 24.97 }, + rs: { lat: 44.02, lon: 21.01 }, + ru: { lat: 61.52, lon: 105.32 }, + rw: { lat: -1.94, lon: 29.87 }, + sa: { lat: 23.89, lon: 45.08 }, + sb: { lat: -9.65, lon: 160.16 }, + sc: { lat: -4.68, lon: 55.49 }, + sd: { lat: 12.86, lon: 30.22 }, + se: { lat: 60.13, lon: 18.64 }, + sg: { lat: 1.35, lon: 103.82 }, + sh: { lat: -24.14, lon: -10.03 }, + si: { lat: 46.15, lon: 15 }, + sj: { lat: 77.55, lon: 23.67 }, + sk: { lat: 48.67, lon: 19.7 }, + sl: { lat: 8.46, lon: -11.78 }, + sm: { lat: 43.94, lon: 12.46 }, + sn: { lat: 14.5, lon: -14.45 }, + so: { lat: 5.15, lon: 46.2 }, + sr: { lat: 3.92, lon: -56.03 }, + st: { lat: 0.19, lon: 6.61 }, + sv: { lat: 13.79, lon: -88.9 }, + sy: { lat: 34.8, lon: 39 }, + sz: { lat: -26.52, lon: 31.47 }, + tc: { lat: 21.69, lon: -71.8 }, + td: { lat: 15.45, lon: 18.73 }, + tf: { lat: -49.28, lon: 69.35 }, + tg: { lat: 8.62, lon: 0.82 }, + th: { lat: 15.87, lon: 100.99 }, + tj: { lat: 38.86, lon: 71.28 }, + tk: { lat: -8.97, lon: -171.86 }, + tl: { lat: -8.87, lon: 125.73 }, + tm: { lat: 38.97, lon: 59.56 }, + tn: { lat: 33.89, lon: 9.54 }, + to: { lat: -21.18, lon: -175.2 }, + tr: { lat: 38.96, lon: 35.24 }, + tt: { lat: 10.69, lon: -61.22 }, + tv: { lat: -7.11, lon: 177.65 }, + tw: { lat: 23.7, lon: 120.96 }, + tz: { lat: -6.37, lon: 34.89 }, + ua: { lat: 48.38, lon: 31.17 }, + ug: { lat: 1.37, lon: 32.29 }, + um: { lat: 0, lon: 0 }, + us: { lat: 37.09, lon: -95.71 }, + uy: { lat: -32.52, lon: -55.77 }, + uz: { lat: 41.38, lon: 64.59 }, + va: { lat: 41.9, lon: 12.45 }, + vc: { lat: 12.98, lon: -61.29 }, + ve: { lat: 6.42, lon: -66.59 }, + vg: { lat: 18.42, lon: -64.64 }, + vi: { lat: 18.34, lon: -64.9 }, + vn: { lat: 14.06, lon: 108.28 }, + vu: { lat: -15.38, lon: 166.96 }, + wf: { lat: -13.77, lon: -177.16 }, + ws: { lat: -13.76, lon: -172.1 }, + xk: { lat: 42.6, lon: 20.9 }, + ye: { lat: 15.55, lon: 48.52 }, + yt: { lat: -12.83, lon: 45.17 }, + za: { lat: -30.56, lon: 22.94 }, + zm: { lat: -13.13, lon: 27.85 }, + zw: { lat: -19.02, lon: 29.15 }, +}; diff --git a/src/data/world-map-dots.ts b/src/data/world-map-dots.ts new file mode 100644 index 00000000..6badc378 --- /dev/null +++ b/src/data/world-map-dots.ts @@ -0,0 +1,16 @@ +// GENERATED FILE — do not edit by hand. +// Source: Natural Earth 1:110m land (public domain), rasterized by +// scripts/generate-world-map-dots.mjs. Run `yarn map:generate` to refresh. +// +// Row-major land/sea grid for the P2P stats world map. `bitmap` is a base64 +// 1-bit-per-cell mask (MSB first, 1 = land). Cell (col,row) centers on +// lon = lonMin + (col + 0.5) * step, lat = latMax - (row + 0.5) * step. +export const WORLD_MAP_DOTS = { + step: 2, + lonMin: -180, + latMax: 84, + cols: 180, + rows: 70, + bitmap: + 'AAAAAAAAf4AP/AAAAAAAAAAAAAAAAAAAAAAAAX/z///+AAAAAAAABAAAAAAAAAAAAAYd8P///wAA+AAAAAA8AAAAAAAAAAAwAnw////4AAIAAAAAAGAAAAAAAAAAADivwAf//wAAAAADAAf/wAHYAAAAAADoi3sAP//gAAAAAMAD///sAAAAgBgACfwz/AD/+gAAAwAEHf///+/8gBAP/7/nJdjwD/+AAAH/AA7f///////f4P//////h8H/gAAAf/6//f////////Mf/////9H4D8AeAA+ev///////////AP/////4A0B4AAAD5/////////////Af3////gHgA4AAAH5///////////LwAHgH///gHkAAAAAH4/////////+CIAABAB///4D+AAAAGCx/////////4A8AAIAAf///n/gAAAOCD/////////wA4AAAAAf///n/wAAAbP///////////AgAAAAAP/////wAAADf//////////9AAAAAAAF////0YAAAB///////////9AAAAAAAD////8EAAAB///////////5AAAAAAAD////2AAAAB/f5fP//////wAAAAAAAD////gAAAAfxnwPP//////jAAAAAAAD////AAAAAPCb3/n/////+CAAAAAAAD///8AAAAAfALf/n////+ECAAAAAAAB///8AAAAAGHQP/n/////mMAAAAAAAA///8AAAAAH+Ai///////E8AAAAAAAAf//wAAAAAP/AA///////BgAAAAAAAAP//gAAAAAf/73///////gAAAAAAAAAD/AQAAAAAf////f/////gAAAAAAAAAF+AQAAAAB///+/n/////AAAAAAAAAAC+AAAAAAB///+f0H////AAAAAAAAAAAeAwAAAAD////f/B///8gAAAAAAAAAAeGEAAAAH////v+B/z/AAAAAAAAAAAAPMAgAAAD////n+A/B+gAAAAAAAAAAAD8AAAAAD////n4AeB/AgAAAAAAAAAAAPAAAAAH////3gAcAfAgAAAAAAAAAAADAAAAAD////6AAcAfggAAAAAAAAAAABDwAAAD////8wAMATAIAAAAAAAAAAAAr/AAAB/////gAKASAAAAAAAAAAAAAAH/gAAA/////gACAAAIAAAAAAAAAAAAH/8AAAaH///AAAAsGAAAAAAAAAAAAAH/+AAAAB//+AAAAUOAAAAAAAAAAAAAP/+AAAAB//8AAAAYegAAAAAAAAAAAAP//gAAAD//4AAAAMeBgAAAAAAAAAAAP//8AAAB//wAAAAGdiuAAAAAAAAAAAf///AAAA//wAAAACAQHgAAAAAAAAAAP///gAAA//wAAAABwAHwgAAAAAAAAAH///AAAA//wAAAAACIDQIAAAAAAAAAH//+AAAAf/wAAAAAAAAAAAAAAAAAAAD//+AAAA//wgAAAAABxAAAAAAAAAAAD//+AAAA//wgAAAAAPxgBAAAAAAAAAA//8AAAA//jgAAAAAf5gAAAAAAAAAAAf/8AAAA//DgAAAAAf/gAAAAAAAAAAAf/8AAAAf/DAAAAAD//4CAAAAAAAAAAf/wAAAAf/DAAAAAH//4AAAAAAAAAAAf/AAAAAf+CAAAAAH//8AAAAAAAAAAAf/AAAAAP8AAAAAAH//+AAAAAAAAAAA/+AAAAAP8AAAAAAH//+AAAAAAAAAAA/+AAAAAH4AAAAAAD//+AAAAAAAAAAA/8AAAAAHwAAAAAADwf8AAAAAAAAAAA/gAAAAAAAAAAAAACAH4AIAAAAAAAAB/wAAAAAAAAAAAAAAAD4AEAAAAAAAAB+AAAAAAAAAAAAAAAAAAAGAAAAAAAAB6AAAAAAAAAAAAAAAAAwAMAAAAAAAAA8AAAAAAAAAAAAAAAAAQAYAAAAAAAAB4AAAAAAAAAAAAAAAAAAAwAAAAAAAAB4AAAAAAAAAAAAAAAAAAAAAAAAAAAADwAAAAAAAAAACAAAAAAAAAAAAAAAAADgAAAAAAAAAAAAAAAAAAAAAAAAAAAABwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwAAAAAAAAAAAAAAAAAAAA', +} as const; diff --git a/src/lib/__tests__/peer-geo.test.ts b/src/lib/__tests__/peer-geo.test.ts index 9fb53a06..0cf7a483 100644 --- a/src/lib/__tests__/peer-geo.test.ts +++ b/src/lib/__tests__/peer-geo.test.ts @@ -1,5 +1,16 @@ -import { describe, expect, it } from 'vitest'; -import { extractIpv4FromAddress, getApproximateCountryCode, getApproximateLatLon, isPrivateOrReservedIpv4 } from '../peer-geo'; +import { describe, expect, it, vi } from 'vitest'; +import { COUNTRY_CENTROIDS } from '../../data/country-centroids'; +import { + extractIpFromAddress, + extractIpv4FromAddress, + extractIpv6FromAddress, + fetchOwnIpCountryCode, + fetchOwnPublicEndpoint, + getApproximateCountryCode, + getApproximateLatLon, + getFirstPublicIpFromAddresses, + isPrivateOrReservedIpv4, +} from '../peer-geo'; const addr = (ip: string) => `/ip4/${ip}/tcp/4001/ws/p2p/12D3KooWExample`; @@ -19,6 +30,13 @@ describe('extractIpv4FromAddress', () => { }); }); +describe('extractIpv6FromAddress', () => { + it('extracts the IPv6 from a multiaddr', () => { + expect(extractIpv6FromAddress('/ip6/2001:4860:4860::8888/tcp/4001/ws')).toBe('2001:4860:4860::8888'); + expect(extractIpFromAddress('/ip6/2001:4860:4860::8888/tcp/4001/ws')).toBe('2001:4860:4860::8888'); + }); +}); + describe('isPrivateOrReservedIpv4', () => { it('flags private and reserved ranges', () => { for (const ip of ['10.0.0.1', '172.16.5.4', '192.168.1.10', '127.0.0.1', '169.254.1.1', '100.64.0.1', '0.0.0.0', '239.255.0.1']) { @@ -44,27 +62,17 @@ describe('getApproximateLatLon', () => { expect(getApproximateLatLon(addr('8.8.8.8'))).toEqual(getApproximateLatLon(addr('8.8.8.8'))); }); - it('places addresses in the expected continental region', () => { - const na = getApproximateLatLon(addr('8.8.8.8')); - expect(na?.lon).toBeLessThan(-80); // North America - expect(na?.lat).toBeGreaterThan(30); - - const eu = getApproximateLatLon(addr('80.80.80.80')); - expect(eu?.lon).toBeGreaterThan(0); - expect(eu?.lon).toBeLessThan(30); - expect(eu?.lat).toBeGreaterThan(40); - - const as = getApproximateLatLon(addr('1.1.1.1')); - expect(as?.lon).toBeGreaterThan(90); - - const af = getApproximateLatLon(addr('41.0.0.1')); - expect(af?.lon).toBeGreaterThan(8); - expect(af?.lon).toBeLessThan(34); - expect(af?.lat).toBeLessThan(12); - - const sa = getApproximateLatLon(addr('200.0.0.1')); - expect(sa?.lon).toBeLessThan(-45); - expect(sa?.lat).toBeLessThan(-5); + it('snaps a peer to the centroid of its flag country', () => { + for (const ip of ['8.8.8.8', '80.80.80.80', '1.1.1.1', '41.0.0.1', '200.0.0.1', '194.110.247.146', '91.234.199.189']) { + const country = getApproximateCountryCode(addr(ip)); + expect(country).toBeDefined(); + const centroid = COUNTRY_CENTROIDS[country!]; + expect(centroid).toBeDefined(); + const loc = getApproximateLatLon(addr(ip))!; + // Marker sits at the country centroid, within the small placement jitter. + expect(Math.abs(loc.lat - centroid.lat)).toBeLessThanOrEqual(1); + expect(Math.abs(loc.lon - centroid.lon)).toBeLessThanOrEqual(1.3); + } }); it('stays within valid coordinate bounds', () => { @@ -77,6 +85,68 @@ describe('getApproximateLatLon', () => { }); }); +describe('getFirstPublicIpFromAddresses', () => { + it('returns the first public IP from multiaddrs', () => { + expect(getFirstPublicIpFromAddresses(['/ip4/127.0.0.1/tcp/4001', '/ip4/147.75.84.175/tcp/4001/ws'])).toBe('147.75.84.175'); + expect(getFirstPublicIpFromAddresses(['/ip4/127.0.0.1/tcp/4001', '/ip6/2001:4860:4860::8888/tcp/443'])).toBe('2001:4860:4860::8888'); + }); + + it('returns undefined when only private addresses are present', () => { + expect(getFirstPublicIpFromAddresses(['/ip4/127.0.0.1/tcp/4001', '/ip4/10.0.0.5/tcp/4001', '/ip6/fd00::1/tcp/4001'])).toBeUndefined(); + }); +}); + +describe('fetchOwnPublicEndpoint', () => { + it('caches the fetched public endpoint', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ country: 'US', ip: '2001:4860:4860::8888' }), + }); + vi.stubGlobal('fetch', fetchMock); + + await expect(fetchOwnPublicEndpoint()).resolves.toEqual({ countryCode: 'us', ip: '2001:4860:4860::8888' }); + await expect(fetchOwnPublicEndpoint()).resolves.toEqual({ countryCode: 'us', ip: '2001:4860:4860::8888' }); + expect(fetchMock).toHaveBeenCalledTimes(1); + + vi.unstubAllGlobals(); + }); +}); + +describe('fetchOwnIpCountryCode', () => { + it("resolves and caches the accurate country for the node's own ip", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ country: 'VN', ip: '172.225.56.8' }), + }); + vi.stubGlobal('fetch', fetchMock); + + await expect(fetchOwnIpCountryCode('172.225.56.8')).resolves.toBe('vn'); + await expect(fetchOwnIpCountryCode('172.225.56.8')).resolves.toBe('vn'); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0][0]).toBe('https://api.country.is/172.225.56.8'); + + vi.unstubAllGlobals(); + }); + + it('does not cache a result from an aborted lookup', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ country: 'VN', ip: '203.0.113.50' }), + }); + vi.stubGlobal('fetch', fetchMock); + + const controller = new AbortController(); + controller.abort(); + // An aborted request is cancellation, not a real result, so it must not be cached. + await fetchOwnIpCountryCode('203.0.113.50', controller.signal); + // A later non-aborted call must perform a fresh lookup instead of a cached blank. + await expect(fetchOwnIpCountryCode('203.0.113.50')).resolves.toBe('vn'); + expect(fetchMock).toHaveBeenCalledTimes(2); + + vi.unstubAllGlobals(); + }); +}); + describe('getApproximateCountryCode', () => { it('returns undefined when the peer cannot be placed offline', () => { expect(getApproximateCountryCode('/ip4/10.0.0.1/tcp/4001')).toBeUndefined(); diff --git a/src/lib/peer-geo.ts b/src/lib/peer-geo.ts index 29a903eb..e9c698c9 100644 --- a/src/lib/peer-geo.ts +++ b/src/lib/peer-geo.ts @@ -1,11 +1,112 @@ // Offline, approximate peer geolocation for the P2P stats world map. // -// This intentionally avoids any external geolocation API: 5chan is serverless and -// privacy-focused, so we must not leak the set of peers a user is connected to. +// Connected peer geolocation intentionally avoids any external geolocation API: +// 5chan is serverless and privacy-focused, so we must not leak the set of peers +// a user is connected to. // Instead we map an IPv4 address to its Regional Internet Registry (RIR) region at // continent resolution, using the coarse IANA /8 allocation table below. Positions // are therefore approximate ("roughly which continent"), not precise coordinates. +import { COUNTRY_CENTROIDS } from '../data/country-centroids'; + +const formatAddressString = (address: unknown): string => { + if (typeof address === 'string') return address; + if (address && typeof address === 'object' && typeof (address as { toString?: unknown }).toString === 'function') { + try { + return String(address); + } catch { + return ''; + } + } + return ''; +}; + +export type PublicEndpoint = { + countryCode?: string; + ip: string; +}; + +const COUNTRY_LOOKUP_URL = 'https://api.country.is'; +const PUBLIC_IPV4_LOOKUP_URL = 'https://api64.ipify.org?format=json'; + +export const extractIpFromAddress = (address: string): string | null => extractIpv4FromAddress(address) ?? extractIpv6FromAddress(address); + +export const getFirstPublicIpFromAddresses = (addresses: unknown[]): string | undefined => { + for (const address of addresses) { + const ip = extractIpFromAddress(formatAddressString(address)); + if (ip && isPublicIpAddress(ip)) return ip; + } + return undefined; +}; + +let cachedOwnPublicEndpoint: { expiresAt: number; value?: PublicEndpoint } | undefined; + +const normalizeLookupCountryCode = (value: unknown) => { + if (typeof value !== 'string') return undefined; + const code = value.trim().toLowerCase(); + return /^[a-z]{2}$/.test(code) ? code : undefined; +}; + +const parsePublicEndpoint = (data: unknown): PublicEndpoint | undefined => { + if (!data || typeof data !== 'object') return undefined; + const ip = (data as { ip?: unknown }).ip; + if (typeof ip !== 'string' || !isPublicIpAddress(ip)) return undefined; + return { + countryCode: normalizeLookupCountryCode((data as { country?: unknown }).country), + ip, + }; +}; + +const fetchPublicEndpoint = async (url: string, signal?: AbortSignal): Promise => { + try { + const response = await fetch(url, { signal }); + if (!response.ok) return undefined; + return parsePublicEndpoint(await response.json()); + } catch { + return undefined; + } +}; + +// Fetches the browser node's own public endpoint for the P2P stats panel when +// libp2p only advertises local/private listen addresses (common in browser nodes +// and VPN setups). This only asks about the current browser's public endpoint; +// connected peer geolocation remains offline/approximate below. +export const fetchOwnPublicEndpoint = async (signal?: AbortSignal): Promise => { + if (cachedOwnPublicEndpoint && Date.now() < cachedOwnPublicEndpoint.expiresAt) return cachedOwnPublicEndpoint.value; + + const endpoint = await fetchPublicEndpoint(COUNTRY_LOOKUP_URL, signal); + if (endpoint) { + cachedOwnPublicEndpoint = { expiresAt: Date.now() + 60_000, value: endpoint }; + return endpoint; + } + + const ipv4Endpoint = await fetchPublicEndpoint(PUBLIC_IPV4_LOOKUP_URL, signal); + const fallback = ipv4Endpoint?.ip ? { countryCode: getApproximateCountryCode(`/ip4/${ipv4Endpoint.ip}/tcp/0`), ip: ipv4Endpoint.ip } : undefined; + // Don't cache an empty result produced by an aborted lookup (e.g. the panel was + // closed mid-request): that is cancellation, not a real failure, so a later + // reopen should retry instead of being served a cached blank for 30s. + if (!signal?.aborted) cachedOwnPublicEndpoint = { expiresAt: Date.now() + 30_000, value: fallback }; + return fallback; +}; + +const ownIpCountryCache = new Map(); + +// Accurate country code for the local node's OWN public IP, so the P2P stats +// "Your IP" flag matches the address shown instead of the coarse continent guess. +// Like fetchOwnPublicEndpoint, this only ever looks up the user's own node +// address; connected peer geolocation stays offline (getApproximateCountryCode) +// so the set of peers a user connects to is never sent to an external API. +export const fetchOwnIpCountryCode = async (ip: string, signal?: AbortSignal): Promise => { + const cached = ownIpCountryCache.get(ip); + if (cached && Date.now() < cached.expiresAt) return cached.value; + const endpoint = await fetchPublicEndpoint(`${COUNTRY_LOOKUP_URL}/${ip}`, signal); + const value = endpoint?.countryCode; + // See fetchOwnPublicEndpoint: skip caching when the lookup was aborted so a + // cancelled request does not blank the flag for 60s on the next open. + if (!signal?.aborted) ownIpCountryCache.set(ip, { expiresAt: Date.now() + 60_000, value }); + return value; +}; + export type LatLon = { lat: number; lon: number }; type Region = 'AF' | 'AS' | 'EU' | 'NA' | 'SA'; @@ -99,6 +200,11 @@ export const extractIpv4FromAddress = (address: string): string | null => { return null; }; +export const extractIpv6FromAddress = (address: string): string | null => { + const direct = /\/ip6\/([^/]+)/.exec(address); + return direct ? direct[1] : null; +}; + const parseOctets = (ip: string): number[] | null => { const parts = ip.split('.').map(Number); if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return null; @@ -118,6 +224,17 @@ export const isPrivateOrReservedIpv4 = (ip: string): boolean => { return false; }; +const isProbablyPublicIpv6 = (ip: string): boolean => { + const normalized = ip.trim().toLowerCase(); + if (!normalized.includes(':')) return false; + if (normalized === '::' || normalized === '::1') return false; + if (normalized.startsWith('fe80:') || normalized.startsWith('fc') || normalized.startsWith('fd') || normalized.startsWith('ff')) return false; + if (normalized.startsWith('2001:db8:')) return false; + return /^[0-9a-f:.]+$/.test(normalized); +}; + +const isPublicIpAddress = (ip: string): boolean => (ip.includes(':') ? isProbablyPublicIpv6(ip) : !isPrivateOrReservedIpv4(ip)); + const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); const hashOctets = (parts: number[]) => { @@ -134,12 +251,15 @@ export const getApproximateLatLon = (address: string): LatLon | null => { const parts = parseOctets(ip); if (!parts) return null; - const centroid = REGION_CENTROIDS[REGION_BY_OCTET[parts[0]]]; const hash = hashOctets(parts); - // Deterministic intra-region spread so peers don't stack on one point. The - // region is the meaningful signal; the offset is cosmetic. - const lonOffset = ((hash % 1000) / 1000 - 0.5) * 24; // +/- 12 deg - const latOffset = ((Math.floor(hash / 1000) % 1000) / 1000 - 0.5) * 14; // +/- 7 deg + // Snap the marker to the centroid of the same country shown as the peer's flag + // (getApproximateCountryCode), falling back to the continent centroid if that + // country has no known centroid. A small deterministic jitter keeps multiple + // peers in one country from stacking exactly while staying near its center. + const country = getApproximateCountryCode(address); + const centroid = (country && COUNTRY_CENTROIDS[country]) || REGION_CENTROIDS[REGION_BY_OCTET[parts[0]]]; + const lonOffset = ((hash % 1000) / 1000 - 0.5) * 2.4; // +/- 1.2 deg + const latOffset = ((Math.floor(hash / 1000) % 1000) / 1000 - 0.5) * 1.6; // +/- 0.8 deg return { lat: clamp(centroid.lat + latOffset, -85, 85), lon: clamp(centroid.lon + lonOffset, -180, 180) }; };