diff --git a/web/public/assets/js/app/__tests__/federation-instance-display.test.js b/web/public/assets/js/app/__tests__/federation-instance-display.test.js new file mode 100644 index 0000000..927e071 --- /dev/null +++ b/web/public/assets/js/app/__tests__/federation-instance-display.test.js @@ -0,0 +1,64 @@ +/* + * Copyright © 2025-26 l5yth & contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + filterDisplayableFederationInstances, + isSuppressedFederationSiteName, + resolveFederationInstanceLabel, + resolveFederationInstanceSortValue, + resolveFederationSiteNameForDisplay, + shouldDisplayFederationInstance, + truncateFederationSiteName +} from '../federation-instance-display.js'; + +test('isSuppressedFederationSiteName detects URL-like advertising names', () => { + assert.equal(isSuppressedFederationSiteName('http://spam.example offer'), true); + assert.equal(isSuppressedFederationSiteName('Visit www.spam.example today'), true); + assert.equal(isSuppressedFederationSiteName('Mesh Collective'), false); + assert.equal(isSuppressedFederationSiteName(''), false); + assert.equal(isSuppressedFederationSiteName(null), false); +}); + +test('truncateFederationSiteName shortens names longer than 32 characters', () => { + assert.equal(truncateFederationSiteName('Short Mesh'), 'Short Mesh'); + assert.equal( + truncateFederationSiteName('abcdefghijklmnopqrstuvwxyz1234567890'), + 'abcdefghijklmnopqrstuvwxyz123...' + ); + assert.equal(truncateFederationSiteName('abcdefghijklmnopqrstuvwxyz123456').length, 32); + assert.equal(truncateFederationSiteName(null), ''); +}); + +test('display helpers filter suppressed names and preserve original domains', () => { + const entries = [ + { name: 'Normal Mesh', domain: 'normal.mesh' }, + { name: 'https://spam.example promo', domain: 'spam.mesh' }, + { domain: 'unnamed.mesh' } + ]; + + assert.equal(shouldDisplayFederationInstance(entries[0]), true); + assert.equal(shouldDisplayFederationInstance(entries[1]), false); + assert.deepEqual(filterDisplayableFederationInstances(entries), [ + { name: 'Normal Mesh', domain: 'normal.mesh' }, + { domain: 'unnamed.mesh' } + ]); + assert.equal(resolveFederationSiteNameForDisplay(entries[0]), 'Normal Mesh'); + assert.equal(resolveFederationInstanceLabel(entries[2]), 'unnamed.mesh'); + assert.equal(resolveFederationInstanceSortValue(entries[0]), 'Normal Mesh'); +}); diff --git a/web/public/assets/js/app/__tests__/federation-page.test.js b/web/public/assets/js/app/__tests__/federation-page.test.js index 8210b2a..c9cebf1 100644 --- a/web/public/assets/js/app/__tests__/federation-page.test.js +++ b/web/public/assets/js/app/__tests__/federation-page.test.js @@ -21,6 +21,74 @@ import { createDomEnvironment } from './dom-environment.js'; import { initializeFederationPage } from '../federation-page.js'; import { roleColors } from '../role-helpers.js'; +function createBasicFederationPageHarness() { + const env = createDomEnvironment({ includeBody: true, bodyHasDarkClass: false }); + const { document, createElement, registerElement } = env; + + const mapEl = createElement('div', 'map'); + registerElement('map', mapEl); + const statusEl = createElement('div', 'status'); + registerElement('status', statusEl); + const tableEl = createElement('table', 'instances'); + const tbodyEl = createElement('tbody'); + registerElement('instances', tableEl); + tableEl.appendChild(tbodyEl); + const configEl = createElement('div'); + configEl.setAttribute('data-app-config', JSON.stringify({ mapCenter: { lat: 0, lon: 0 }, mapZoom: 3 })); + + document.querySelector = selector => { + if (selector === '[data-app-config]') return configEl; + if (selector === '#instances tbody') return tbodyEl; + return null; + }; + + return { ...env, statusEl, tbodyEl }; +} + +function createBasicLeafletStub(options = {}) { + const { markerPopups = null, fitBounds = false } = options; + + return { + map() { + return { + setView() {}, + on() {}, + fitBounds: fitBounds ? () => {} : undefined, + getPane() { + return null; + } + }; + }, + tileLayer() { + return { + addTo() { + return this; + }, + getContainer() { + return null; + }, + on() {} + }; + }, + layerGroup() { + return { + addLayer() {}, + addTo() { + return this; + } + }; + }, + circleMarker() { + return { + bindPopup(html) { + markerPopups?.push(html); + return this; + } + }; + } + }; +} + test('federation map centers on configured coordinates and follows theme filters', async () => { const env = createDomEnvironment({ includeBody: true, bodyHasDarkClass: true }); const { document, window, createElement, registerElement, cleanup } = env; @@ -603,57 +671,141 @@ test('federation legend toggle respects media query changes', async () => { }); test('federation page tolerates fetch failures', async () => { + const { cleanup } = createBasicFederationPageHarness(); + + const fetchImpl = async () => { + throw new Error('boom'); + }; + + const leafletStub = createBasicLeafletStub(); + await initializeFederationPage({ config: {}, fetchImpl, leaflet: leafletStub }); + cleanup(); +}); + +test('federation page suppresses spammy site names and truncates long names in visible UI', async () => { + const { cleanup, statusEl, tbodyEl } = createBasicFederationPageHarness(); + const markerPopups = []; + const leafletStub = createBasicLeafletStub({ markerPopups, fitBounds: true }); + + const fetchImpl = async () => ({ + ok: true, + json: async () => [ + { + domain: 'visible.mesh', + name: 'abcdefghijklmnopqrstuvwxyz1234567890', + latitude: 1, + longitude: 1, + lastUpdateTime: Math.floor(Date.now() / 1000) - 30 + }, + { + domain: 'spam.mesh', + name: 'www.spam.example buy now', + latitude: 2, + longitude: 2, + lastUpdateTime: Math.floor(Date.now() / 1000) - 60 + } + ] + }); + + try { + await initializeFederationPage({ config: {}, fetchImpl, leaflet: leafletStub }); + + assert.equal(statusEl.textContent, '1 instances'); + assert.equal(tbodyEl.childNodes.length, 1); + assert.match(tbodyEl.childNodes[0].innerHTML, /abcdefghijklmnopqrstuvwxyz123\.\.\./); + assert.doesNotMatch(tbodyEl.childNodes[0].innerHTML, /spam\.mesh/); + assert.equal(markerPopups.length, 1); + assert.match(markerPopups[0], /abcdefghijklmnopqrstuvwxyz123\.\.\./); + assert.doesNotMatch(markerPopups[0], /www\.spam\.example/); + } finally { + cleanup(); + } +}); + +test('federation page sorts by full site names before truncating visible labels', async () => { const env = createDomEnvironment({ includeBody: true, bodyHasDarkClass: false }); const { document, createElement, registerElement, cleanup } = env; + const sharedPrefix = 'abcdefghijklmnopqrstuvwxyz123'; const mapEl = createElement('div', 'map'); registerElement('map', mapEl); const statusEl = createElement('div', 'status'); registerElement('status', statusEl); + const tableEl = createElement('table', 'instances'); const tbodyEl = createElement('tbody'); registerElement('instances', tableEl); + tableEl.appendChild(tbodyEl); + + const headerNameTh = createElement('th'); + const headerName = createElement('span'); + headerName.classList.add('sort-header'); + headerName.dataset.sortKey = 'name'; + headerName.dataset.sortLabel = 'Name'; + headerNameTh.appendChild(headerName); + + const ths = [headerNameTh]; + const headers = [headerName]; + const headerHandlers = new Map(); + headers.forEach(header => { + header.addEventListener = (event, handler) => { + const existing = headerHandlers.get(header) || {}; + existing[event] = handler; + headerHandlers.set(header, existing); + }; + header.closest = () => ths.find(th => th.childNodes.includes(header)); + header.querySelector = () => null; + }); + + tableEl.querySelectorAll = selector => { + if (selector === 'thead .sort-header[data-sort-key]') return headers; + if (selector === 'thead th') return ths; + return []; + }; + const configEl = createElement('div'); - configEl.setAttribute('data-app-config', JSON.stringify({})); + configEl.setAttribute('data-app-config', JSON.stringify({ mapCenter: { lat: 0, lon: 0 }, mapZoom: 3 })); + document.querySelector = selector => { if (selector === '[data-app-config]') return configEl; if (selector === '#instances tbody') return tbodyEl; return null; }; - const leafletStub = { - map() { - return { - setView() {}, - on() {}, - getPane() { - return null; - } - }; - }, - tileLayer() { - return { - addTo() { - return this; - }, - getContainer() { - return null; - }, - on() {} - }; - }, - layerGroup() { - return { addLayer() {}, addTo() { return this; } }; - }, - circleMarker() { - return { bindPopup() { return this; } }; - } - }; + const fetchImpl = async () => ({ + ok: true, + json: async () => [ + { + domain: 'zeta.mesh', + name: `${sharedPrefix}zeta suffix`, + latitude: 1, + longitude: 1, + lastUpdateTime: Math.floor(Date.now() / 1000) - 30 + }, + { + domain: 'alpha.mesh', + name: `${sharedPrefix}alpha suffix`, + latitude: 2, + longitude: 2, + lastUpdateTime: Math.floor(Date.now() / 1000) - 60 + } + ] + }); - const fetchImpl = async () => { - throw new Error('boom'); - }; + try { + await initializeFederationPage({ + config: {}, + fetchImpl, + leaflet: createBasicLeafletStub({ fitBounds: true }) + }); - await initializeFederationPage({ config: {}, fetchImpl, leaflet: leafletStub }); - cleanup(); + const nameHandlers = headerHandlers.get(headerName); + nameHandlers.click(); + assert.match(tbodyEl.childNodes[0].innerHTML, /alpha\.mesh/); + assert.match(tbodyEl.childNodes[1].innerHTML, /zeta\.mesh/); + assert.match(tbodyEl.childNodes[0].innerHTML, /abcdefghijklmnopqrstuvwxyz123\.\.\./); + assert.match(tbodyEl.childNodes[1].innerHTML, /abcdefghijklmnopqrstuvwxyz123\.\.\./); + } finally { + cleanup(); + } }); diff --git a/web/public/assets/js/app/__tests__/instance-selector.test.js b/web/public/assets/js/app/__tests__/instance-selector.test.js index d0a8e90..4ba5e55 100644 --- a/web/public/assets/js/app/__tests__/instance-selector.test.js +++ b/web/public/assets/js/app/__tests__/instance-selector.test.js @@ -154,6 +154,75 @@ test('initializeInstanceSelector populates options alphabetically and selects th } }); +test('initializeInstanceSelector hides suppressed names and truncates long labels', async () => { + const env = createDomEnvironment(); + const select = setupSelectElement(env.document); + const navLink = env.document.createElement('a'); + navLink.classList.add('js-federation-nav'); + navLink.textContent = 'Federation'; + env.document.body.appendChild(navLink); + + const fetchImpl = async () => ({ + ok: true, + async json() { + return [ + { name: 'Visit https://spam.example now', domain: 'spam.mesh' }, + { name: 'abcdefghijklmnopqrstuvwxyz1234567890', domain: 'long.mesh' }, + { name: 'Alpha Mesh', domain: 'alpha.mesh' } + ]; + } + }); + + try { + await initializeInstanceSelector({ + selectElement: select, + fetchImpl, + windowObject: env.window, + documentObject: env.document + }); + + assert.equal(select.options.length, 3); + assert.equal(select.options[1].textContent, 'abcdefghijklmnopqrstuvwxyz123...'); + assert.equal(select.options[2].textContent, 'Alpha Mesh'); + assert.equal(navLink.textContent, 'Federation (2)'); + assert.equal(select.options.some(option => option.value === 'spam.mesh'), false); + } finally { + env.cleanup(); + } +}); + +test('initializeInstanceSelector sorts by full site names before truncating labels', async () => { + const env = createDomEnvironment(); + const select = setupSelectElement(env.document); + const sharedPrefix = 'abcdefghijklmnopqrstuvwxyz123'; + + const fetchImpl = async () => ({ + ok: true, + async json() { + return [ + { name: `${sharedPrefix}zeta suffix`, domain: 'zeta.mesh' }, + { name: `${sharedPrefix}alpha suffix`, domain: 'alpha.mesh' } + ]; + } + }); + + try { + await initializeInstanceSelector({ + selectElement: select, + fetchImpl, + windowObject: env.window, + documentObject: env.document + }); + + assert.equal(select.options[1].value, 'alpha.mesh'); + assert.equal(select.options[2].value, 'zeta.mesh'); + assert.equal(select.options[1].textContent, 'abcdefghijklmnopqrstuvwxyz123...'); + assert.equal(select.options[2].textContent, 'abcdefghijklmnopqrstuvwxyz123...'); + } finally { + env.cleanup(); + } +}); + test('initializeInstanceSelector navigates to the chosen instance domain', async () => { const env = createDomEnvironment(); const select = setupSelectElement(env.document); diff --git a/web/public/assets/js/app/federation-instance-display.js b/web/public/assets/js/app/federation-instance-display.js new file mode 100644 index 0000000..fac8f0f --- /dev/null +++ b/web/public/assets/js/app/federation-instance-display.js @@ -0,0 +1,172 @@ +/* + * Copyright © 2025-26 l5yth & contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const MAX_VISIBLE_SITE_NAME_LENGTH = 32; +const TRUNCATION_SUFFIX = '...'; +const TRUNCATED_SITE_NAME_LENGTH = MAX_VISIBLE_SITE_NAME_LENGTH - TRUNCATION_SUFFIX.length; +const SUPPRESSED_SITE_NAME_PATTERN = /(?:^|[^a-z0-9])(?:https?:\/\/|www\.)\S+/i; + +/** + * Read a federated instance site name as a trimmed string. + * + * @param {{ name?: string } | null | undefined} entry Federation instance payload entry. + * @returns {string} Trimmed site name or an empty string when absent. + */ +function readSiteName(entry) { + if (!entry || typeof entry !== 'object') { + return ''; + } + + return typeof entry.name === 'string' ? entry.name.trim() : ''; +} + +/** + * Read a federated instance domain as a trimmed string. + * + * @param {{ domain?: string } | null | undefined} entry Federation instance payload entry. + * @returns {string} Trimmed domain or an empty string when absent. + */ +function readDomain(entry) { + if (!entry || typeof entry !== 'object') { + return ''; + } + + return typeof entry.domain === 'string' ? entry.domain.trim() : ''; +} + +/** + * Determine whether a remote site name should be suppressed from frontend displays. + * + * @param {string} name Remote site name. + * @returns {boolean} true when the name contains a URL-like advertising token. + */ +export function isSuppressedFederationSiteName(name) { + if (typeof name !== 'string') { + return false; + } + + const trimmed = name.trim(); + if (!trimmed) { + return false; + } + + return SUPPRESSED_SITE_NAME_PATTERN.test(trimmed); +} + +/** + * Truncate an instance site name for frontend display without mutating source data. + * + * Names longer than 32 characters are shortened to stay within that 32-character + * budget including the trailing ellipsis. + * + * @param {string} name Remote site name. + * @returns {string} Display-ready site name. + */ +export function truncateFederationSiteName(name) { + if (typeof name !== 'string') { + return ''; + } + + const trimmed = name.trim(); + if (trimmed.length <= MAX_VISIBLE_SITE_NAME_LENGTH) { + return trimmed; + } + + return `${trimmed.slice(0, TRUNCATED_SITE_NAME_LENGTH)}${TRUNCATION_SUFFIX}`; +} + +/** + * Determine whether an instance should remain visible in frontend federation views. + * + * @param {{ name?: string } | null | undefined} entry Federation instance payload entry. + * @returns {boolean} true when the entry should be shown to users. + */ +export function shouldDisplayFederationInstance(entry) { + return !isSuppressedFederationSiteName(readSiteName(entry)); +} + +/** + * Resolve a frontend display name for a federation instance. + * + * @param {{ name?: string } | null | undefined} entry Federation instance payload entry. + * @returns {string} Display-ready site name or an empty string when absent. + */ +export function resolveFederationSiteNameForDisplay(entry) { + const siteName = readSiteName(entry); + return siteName ? truncateFederationSiteName(siteName) : ''; +} + +/** + * Resolve the original trimmed site name for a federation instance. + * + * @param {{ name?: string } | null | undefined} entry Federation instance payload entry. + * @returns {string} Full trimmed site name or an empty string when absent. + */ +export function resolveFederationSiteName(entry) { + return readSiteName(entry); +} + +/** + * Determine the full sort value for an instance selector entry. + * + * Sorting must use the original trimmed site name so truncation does not collapse + * multiple entries into the same comparison key. + * + * @param {{ name?: string, domain?: string } | null | undefined} entry Federation instance payload entry. + * @returns {string} Full trimmed site name falling back to the domain. + */ +export function resolveFederationInstanceSortValue(entry) { + const siteName = resolveFederationSiteName(entry); + return siteName || readDomain(entry); +} + +/** + * Determine the most suitable display label for an instance list entry. + * + * @param {{ name?: string, domain?: string } | null | undefined} entry Federation instance payload entry. + * @returns {string} Display label falling back to the domain. + */ +export function resolveFederationInstanceLabel(entry) { + const siteName = resolveFederationSiteNameForDisplay(entry); + if (siteName) { + return siteName; + } + + return readDomain(entry); +} + +/** + * Filter a federation payload down to the instances that should remain visible. + * + * @param {Array} entries Federation payload from the API. + * @returns {Array} Visible instances for frontend rendering. + */ +export function filterDisplayableFederationInstances(entries) { + if (!Array.isArray(entries)) { + return []; + } + + return entries.filter(shouldDisplayFederationInstance); +} + +export const __test__ = { + MAX_VISIBLE_SITE_NAME_LENGTH, + TRUNCATION_SUFFIX, + TRUNCATED_SITE_NAME_LENGTH, + readDomain, + readSiteName, + SUPPRESSED_SITE_NAME_PATTERN +}; diff --git a/web/public/assets/js/app/federation-page.js b/web/public/assets/js/app/federation-page.js index a3f2130..95925c8 100644 --- a/web/public/assets/js/app/federation-page.js +++ b/web/public/assets/js/app/federation-page.js @@ -15,6 +15,11 @@ */ import { readAppConfig } from './config.js'; +import { + filterDisplayableFederationInstances, + resolveFederationSiteName, + resolveFederationSiteNameForDisplay +} from './federation-instance-display.js'; import { resolveLegendVisibility } from './map-legend-visibility.js'; import { mergeConfig } from './settings.js'; import { roleColors } from './role-helpers.js'; @@ -274,7 +279,12 @@ export async function initializeFederationPage(options = {}) { ? true : legendCollapsedValue.trim() !== 'false'; const tableSorters = { - name: { getValue: inst => inst.name ?? '', compare: compareString, hasValue: hasStringValue, defaultDirection: 'asc' }, + name: { + getValue: inst => resolveFederationSiteName(inst), + compare: compareString, + hasValue: hasStringValue, + defaultDirection: 'asc' + }, domain: { getValue: inst => inst.domain ?? '', compare: compareString, hasValue: hasStringValue, defaultDirection: 'asc' }, contact: { getValue: inst => inst.contactLink ?? '', compare: compareString, hasValue: hasStringValue, defaultDirection: 'asc' }, version: { getValue: inst => inst.version ?? '', compare: compareString, hasValue: hasStringValue, defaultDirection: 'asc' }, @@ -363,7 +373,8 @@ export async function initializeFederationPage(options = {}) { for (const instance of sorted) { const tr = document.createElement('tr'); const url = buildInstanceUrl(instance.domain); - const nameHtml = instance.name ? escapeHtml(instance.name) : ''; + const displayName = resolveFederationSiteNameForDisplay(instance); + const nameHtml = displayName ? escapeHtml(displayName) : ''; const domainHtml = url ? `${escapeHtml(instance.domain || '')}` : escapeHtml(instance.domain || ''); @@ -529,7 +540,7 @@ export async function initializeFederationPage(options = {}) { credentials: 'omit' }); if (response.ok) { - instances = await response.json(); + instances = filterDisplayableFederationInstances(await response.json()); } } catch (err) { console.warn('Failed to fetch federation instances', err); @@ -636,7 +647,8 @@ export async function initializeFederationPage(options = {}) { bounds.push([lat, lon]); - const name = instance.name || instance.domain || 'Unknown'; + const displayName = resolveFederationSiteNameForDisplay(instance); + const name = displayName || instance.domain || 'Unknown'; const url = buildInstanceUrl(instance.domain); const nodeCountValue = toFiniteNumber(instance.nodesCount ?? instance.nodes_count); const popupLines = [ diff --git a/web/public/assets/js/app/instance-selector.js b/web/public/assets/js/app/instance-selector.js index 1d84800..6197827 100644 --- a/web/public/assets/js/app/instance-selector.js +++ b/web/public/assets/js/app/instance-selector.js @@ -14,6 +14,12 @@ * limitations under the License. */ +import { + filterDisplayableFederationInstances, + resolveFederationInstanceLabel, + resolveFederationInstanceSortValue +} from './federation-instance-display.js'; + /** * Determine the most suitable label for an instance list entry. * @@ -21,17 +27,7 @@ * @returns {string} Preferred display label falling back to the domain. */ function resolveInstanceLabel(entry) { - if (!entry || typeof entry !== 'object') { - return ''; - } - - const name = typeof entry.name === 'string' ? entry.name.trim() : ''; - if (name.length > 0) { - return name; - } - - const domain = typeof entry.domain === 'string' ? entry.domain.trim() : ''; - return domain; + return resolveFederationInstanceLabel(entry); } /** @@ -206,23 +202,21 @@ export async function initializeInstanceSelector(options) { return; } - if (!Array.isArray(payload)) { - return; - } - - updateFederationNavCount({ documentObject: doc, count: payload.length }); + const visibleEntries = filterDisplayableFederationInstances(payload); + updateFederationNavCount({ documentObject: doc, count: visibleEntries.length }); const sanitizedDomain = typeof instanceDomain === 'string' ? instanceDomain.trim().toLowerCase() : null; - const sortedEntries = payload + const sortedEntries = visibleEntries .filter(entry => entry && typeof entry.domain === 'string' && entry.domain.trim() !== '') .map(entry => ({ domain: entry.domain.trim(), label: resolveInstanceLabel(entry), + sortLabel: resolveFederationInstanceSortValue(entry), })) .sort((a, b) => { - const labelA = a.label || a.domain; - const labelB = b.label || b.domain; + const labelA = a.sortLabel || a.domain; + const labelB = b.sortLabel || b.domain; return labelA.localeCompare(labelB, undefined, { sensitivity: 'base' }); });